github-actions[bot] commited on
Commit
6cdce85
·
0 Parent(s):

Deploy demo from GitHub Actions - 2025-12-24 02:23:20

Browse files
Files changed (47) hide show
  1. .gitattributes +7 -0
  2. Dockerfile +55 -0
  3. README.md +44 -0
  4. next-env.d.ts +5 -0
  5. next.config.js +31 -0
  6. package-lock.json +0 -0
  7. package.json +39 -0
  8. postcss.config.js +7 -0
  9. public/logo.png +3 -0
  10. pyproject.toml +43 -0
  11. src/app/api/chat/route.ts +252 -0
  12. src/app/api/examples/route.ts +91 -0
  13. src/app/api/execute/route.ts +568 -0
  14. src/app/api/status/route.ts +145 -0
  15. src/app/api/test/route.ts +610 -0
  16. src/app/api/warmup/route.ts +192 -0
  17. src/app/globals.css +390 -0
  18. src/app/layout.tsx +34 -0
  19. src/app/page.tsx +206 -0
  20. src/components/Chat/ChatInterface.tsx +305 -0
  21. src/components/Chat/ExecutionResult.tsx +265 -0
  22. src/components/Chat/LoadingStatus.tsx +231 -0
  23. src/components/Chat/Message.tsx +563 -0
  24. src/components/Chat/MessageInput.tsx +198 -0
  25. src/components/Chat/QubitIcon.tsx +87 -0
  26. src/components/Chat/WarmupIndicator.tsx +136 -0
  27. src/components/Examples/ExampleCard.tsx +94 -0
  28. src/components/Examples/ExamplesPanel.tsx +420 -0
  29. src/components/Header/Header.tsx +129 -0
  30. src/components/Practice/AIHelper.tsx +692 -0
  31. src/components/Practice/CodeEditor.tsx +121 -0
  32. src/components/Practice/PracticeInterface.tsx +460 -0
  33. src/components/Practice/ProblemList.tsx +489 -0
  34. src/components/Practice/TestRunner.tsx +284 -0
  35. src/components/Practice/index.ts +6 -0
  36. src/components/ResizablePanel/ResizablePanel.tsx +165 -0
  37. src/components/index.ts +14 -0
  38. src/config/constants.ts +172 -0
  39. src/lib/api/vlm-client.ts +212 -0
  40. src/lib/dataset/DatasetProvider.tsx +133 -0
  41. src/lib/dataset/loader.ts +252 -0
  42. src/lib/hooks/useWarmup.ts +174 -0
  43. src/lib/utils/image.ts +77 -0
  44. src/lib/utils/response.ts +267 -0
  45. src/types/index.ts +94 -0
  46. tailwind.config.js +66 -0
  47. tsconfig.json +25 -0
.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ *.jpg filter=lfs diff=lfs merge=lfs -text
2
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
3
+ *.gif filter=lfs diff=lfs merge=lfs -text
4
+ *.webp filter=lfs diff=lfs merge=lfs -text
5
+ *.ico filter=lfs diff=lfs merge=lfs -text
6
+ *.svg filter=lfs diff=lfs merge=lfs -text
7
+ *.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package*.json ./
6
+
7
+ RUN npm ci --legacy-peer-deps
8
+
9
+ COPY . .
10
+
11
+ ENV NEXT_TELEMETRY_DISABLED=1
12
+ RUN npm run build
13
+
14
+ FROM python:3.11-slim AS runner
15
+
16
+ RUN apt-get update && apt-get install -y \
17
+ curl \
18
+ gnupg \
19
+ git \
20
+ git-lfs \
21
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
22
+ && apt-get install -y nodejs \
23
+ && apt-get clean \
24
+ && rm -rf /var/lib/apt/lists/*
25
+
26
+ RUN useradd -m -u 1000 app
27
+ WORKDIR /app
28
+
29
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
30
+
31
+ COPY pyproject.toml ./
32
+
33
+ RUN uv venv .venv && \
34
+ . .venv/bin/activate && \
35
+ uv pip install -e . && \
36
+ rm -rf /root/.cache/uv
37
+
38
+ COPY --from=builder --chown=app:app /app/.next/standalone ./
39
+ COPY --from=builder --chown=app:app /app/.next/static ./.next/static
40
+ COPY --from=builder --chown=app:app /app/public ./public
41
+
42
+ RUN chown -R app:app /app
43
+
44
+ USER app
45
+
46
+ ENV NODE_ENV=production
47
+ ENV NEXT_TELEMETRY_DISABLED=1
48
+ ENV PORT=7860
49
+ ENV HOSTNAME=0.0.0.0
50
+ ENV PYTHON_PATH=/app/.venv/bin/python
51
+
52
+ EXPOSE 7860
53
+
54
+ CMD ["node", "server.js"]
55
+
README.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quantum Assistant
3
+ emoji: ♾️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: true
8
+ app_port: 7860
9
+ license: apache-2.0
10
+ short_description: Multimodal VLM for Quantum Computing with Qiskit
11
+ datasets:
12
+ - samuellimabraz/quantum-assistant
13
+ models:
14
+ - samuellimabraz/Qwen3-VL-8B-rslora-r32-2
15
+ ---
16
+
17
+ Interactive demo for **Quantum Assistant** - A Multimodal Vision Language Model specialized for Quantum Computing with Qiskit.
18
+
19
+ ## Features
20
+
21
+ - 💬 **Chat Interface**: Interact with the quantum-specialized VLM
22
+ - 📊 **Dataset Explorer**: Browse examples from the quantum-assistant dataset
23
+ - ⚡ **Code Execution**: Run Qiskit code directly in the browser
24
+ - 🖼️ **Image Understanding**: Analyze quantum circuit diagrams, Bloch spheres, and histograms
25
+
26
+ ## Resources
27
+
28
+ - 📦 [Dataset](https://huggingface.co/datasets/samuellimabraz/quantum-assistant)
29
+ - 🤖 [Models](https://huggingface.co/collections/samuellimabraz/quantum-assistant)
30
+ - 💻 [GitHub](https://github.com/samuellimabraz/quantum-assistant)
31
+
32
+ ## Configuration
33
+
34
+ Configure the model endpoint via Space secrets:
35
+
36
+ - `DEMO_MODEL_URL`: VLM API endpoint (OpenAI-compatible)
37
+ - `DEMO_MODEL_NAME`: Model identifier
38
+ - `DEMO_API_KEY`: API authentication key
39
+
40
+ ## Author
41
+
42
+ **Samuel Lima Braz** - UNIFEI (Universidade Federal de Itajubá)
43
+
44
+ Final Graduation Project - 2025
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
next.config.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+
5
+ output: 'standalone',
6
+
7
+ images: {
8
+ remotePatterns: [
9
+ {
10
+ protocol: 'https',
11
+ hostname: 'huggingface.co',
12
+ },
13
+ {
14
+ protocol: 'https',
15
+ hostname: '*.hf.space',
16
+ },
17
+ {
18
+ protocol: 'https',
19
+ hostname: 'datasets-server.huggingface.co',
20
+ },
21
+ ],
22
+ unoptimized: process.env.NODE_ENV === 'production',
23
+ },
24
+
25
+ env: {
26
+ NEXT_PUBLIC_HF_SPACE: process.env.NEXT_PUBLIC_HF_SPACE || '',
27
+ },
28
+ };
29
+
30
+ module.exports = nextConfig;
31
+
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quantum-assistant-demo",
3
+ "version": "1.0.0",
4
+ "description": "Interactive demo for Quantum Assistant - Multimodal VLM for Quantum Computing",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "next dev --port 3000",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "next lint"
11
+ },
12
+ "dependencies": {
13
+ "@monaco-editor/react": "^4.7.0",
14
+ "@types/react-katex": "^3.0.4",
15
+ "clsx": "^2.1.0",
16
+ "katex": "^0.16.27",
17
+ "lucide-react": "^0.400.0",
18
+ "monaco-editor": "^0.55.1",
19
+ "next": "^14.2.0",
20
+ "prop-types": "^15.8.1",
21
+ "react": "^18.3.0",
22
+ "react-dom": "^18.3.0",
23
+ "react-katex": "^3.1.0",
24
+ "react-markdown": "^9.0.0",
25
+ "react-syntax-highlighter": "^15.5.0",
26
+ "rehype-katex": "^7.0.1",
27
+ "remark-math": "^6.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.0.0",
31
+ "@types/react": "^18.3.0",
32
+ "@types/react-dom": "^18.3.0",
33
+ "@types/react-syntax-highlighter": "^15.5.0",
34
+ "autoprefixer": "^10.4.0",
35
+ "postcss": "^8.4.0",
36
+ "tailwindcss": "^3.4.0",
37
+ "typescript": "^5.4.0"
38
+ }
39
+ }
postcss.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
7
+
public/logo.png ADDED

Git LFS Details

  • SHA256: 46ac3ecb9c7a619da0507f110d5d357d771de829039a8790a526c8653c26260a
  • Pointer size: 131 Bytes
  • Size of remote file: 253 kB
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "quantum-assistant-demo"
3
+ version = "0.1.0"
4
+ description = "Python runtime for Quantum Assistant demo"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "qiskit==2.2.3",
8
+ "qiskit-aer>=0.17.2",
9
+ "qiskit-ibm-runtime>=0.43.1",
10
+ "qiskit-machine-learning>=0.8.2",
11
+ "qiskit-addon-sqd>=0.12.0",
12
+ "qiskit-addon-pna>=0.2.0",
13
+ "qiskit-addon-slc>=0.1.0",
14
+ "qiskit-addon-utils>=0.3.0",
15
+ "qiskit-addon-obp>=0.3.0",
16
+ "qiskit-addon-mpf>=0.3.0",
17
+ "qiskit-addon-aqc-tensor[aer,quimb-jax]>=0.2.0",
18
+ "qiskit-addon-opt-mapper>=0.1.0",
19
+ "qiskit-addon-cutting>=0.10.0",
20
+ "qiskit-ibm-transpiler>=0.13.1",
21
+ "qiskit-ibm-catalog>=0.12.0",
22
+ "qiskit-quimb>=0.0.9",
23
+ "scipy>=1.15.3",
24
+ "torch>=2.8.0,<2.9.0",
25
+ "numpy>=1.24.0",
26
+ "matplotlib>=3.7.0",
27
+ "pillow>=10.0",
28
+ "samplomatic>=0.13.0",
29
+ "mthree>=3.0.0",
30
+ "rustworkx>=0.17.1",
31
+ "imbalanced-learn>=0.14.0",
32
+ "gem-suite>=0.1.6",
33
+ "quimb>=1.11.2",
34
+ "numba>=0.57.0",
35
+ "yfinance>=0.2.66",
36
+ "plotly>=6.5.0",
37
+ "kaleido>=1.2.0",
38
+ "pylatexenc>=2.10",
39
+ "seaborn>=0.13.2",
40
+ "ffsim>=0.0.63",
41
+ "pytest>=9.0.0",
42
+ ]
43
+
src/app/api/chat/route.ts ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { createVLMClient } from '@/lib/api/vlm-client';
3
+ import { ALLOWED_TOPICS, BLOCKED_INPUT_PATTERNS } from '@/config/constants';
4
+
5
+ export const maxDuration = 120;
6
+
7
+ interface MessageContent {
8
+ type: 'text' | 'image_url';
9
+ text?: string;
10
+ image_url?: { url: string };
11
+ }
12
+
13
+ interface ChatMessage {
14
+ role: 'system' | 'user' | 'assistant';
15
+ content: string | MessageContent[];
16
+ }
17
+
18
+ interface ChatRequestBody {
19
+ messages: ChatMessage[];
20
+ stream?: boolean;
21
+ }
22
+
23
+ interface ContentValidation {
24
+ valid: boolean;
25
+ reason?: string;
26
+ isOffTopic?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Extract text content from a message for validation
31
+ */
32
+ function extractTextContent(content: string | MessageContent[]): string {
33
+ if (typeof content === 'string') {
34
+ return content;
35
+ }
36
+ return content
37
+ .filter((c): c is MessageContent & { type: 'text'; text: string } => c.type === 'text' && !!c.text)
38
+ .map(c => c.text)
39
+ .join(' ');
40
+ }
41
+
42
+ /**
43
+ * Validate user input for malicious patterns and topic relevance
44
+ */
45
+ function validateUserInput(text: string): ContentValidation {
46
+ const lowerText = text.toLowerCase();
47
+
48
+ // Check for blocked patterns (prompt injection, harmful content, etc.)
49
+ for (const pattern of BLOCKED_INPUT_PATTERNS) {
50
+ if (pattern.test(text)) {
51
+ return {
52
+ valid: false,
53
+ reason: "I can't process this request. Please ask a question related to quantum computing, Qiskit, physics, or mathematics.",
54
+ };
55
+ }
56
+ }
57
+
58
+ // Check message length (prevent abuse)
59
+ if (text.length > 10000) {
60
+ return {
61
+ valid: false,
62
+ reason: 'Message too long. Please keep your question under 10,000 characters.',
63
+ };
64
+ }
65
+
66
+ // Check if the message contains any relevant topic keywords
67
+ // Images are always allowed (circuit diagrams, Bloch spheres, etc.)
68
+ const hasImage = text.includes('[IMAGE]') || text.length < 20; // Short messages might be follow-ups
69
+
70
+ if (!hasImage) {
71
+ const words = lowerText.split(/\s+/);
72
+ const hasRelevantTopic = ALLOWED_TOPICS.some(topic => {
73
+ // Check for whole word or part of compound word
74
+ return words.some(word =>
75
+ word.includes(topic.toLowerCase()) ||
76
+ topic.toLowerCase().includes(word)
77
+ );
78
+ });
79
+
80
+ // Also check for common question patterns
81
+ const isQuestion = /^(what|how|why|when|where|can|could|would|should|is|are|do|does|explain|describe|help|show|create|implement|write|generate|build|make)/i.test(lowerText.trim());
82
+ const hasCodeContext = /```|def\s|import\s|class\s|function|circuit/i.test(text);
83
+
84
+ // Be permissive: if it's a question or has code context, allow it
85
+ // The model will redirect off-topic questions anyway
86
+ if (!hasRelevantTopic && !isQuestion && !hasCodeContext && text.length > 50) {
87
+ return {
88
+ valid: true, // Still valid, but flag as potentially off-topic
89
+ isOffTopic: true,
90
+ };
91
+ }
92
+ }
93
+
94
+ return { valid: true };
95
+ }
96
+
97
+ /**
98
+ * Create off-topic response message
99
+ */
100
+ function createOffTopicResponse(): string {
101
+ return `I'm **Quantum Assistant**, specialized in quantum computing, Qiskit, physics, and related mathematics.
102
+
103
+ I can help you with:
104
+ - 🔬 **Quantum Computing**: Circuits, gates, algorithms, error correction
105
+ - 💻 **Qiskit**: Code generation, debugging, best practices
106
+ - 📐 **Physics & Math**: Quantum mechanics, linear algebra, probability
107
+ - 🤖 **Quantum ML**: Variational algorithms, optimization, hybrid systems
108
+
109
+ **Please ask a question related to these topics!**
110
+
111
+ For example:
112
+ - "How do I create a Bell state in Qiskit?"
113
+ - "Explain the Grover's algorithm"
114
+ - "What is quantum entanglement?"`;
115
+ }
116
+
117
+ function isConnectionError(error: unknown): boolean {
118
+ if (error instanceof Error) {
119
+ const message = error.message.toLowerCase();
120
+ const cause = (error as Error & { cause?: Error })?.cause;
121
+
122
+ if (message.includes('fetch failed') || message.includes('econnrefused')) {
123
+ return true;
124
+ }
125
+
126
+ if (cause && 'code' in cause && cause.code === 'ECONNREFUSED') {
127
+ return true;
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
133
+ function createErrorMessage(isConnection: boolean): string {
134
+ if (isConnection) {
135
+ const modelUrl = process.env.DEMO_MODEL_URL || 'http://localhost:8000/v1';
136
+ return `**Model Server Not Available**\n\nCould not connect to the model at:\n\`${modelUrl}\`\n\n**To use the chat feature:**\n1. Start a VLM server (vLLM, Ollama, etc.)\n2. Configure \`.env.local\` with your endpoint:\n\`\`\`\nDEMO_MODEL_URL=http://your-server:port/v1\nDEMO_MODEL_NAME=your-model-name\nDEMO_API_KEY=your-api-key\n\`\`\`\n3. Restart the demo server\n\n*Examples panel still works - try selecting a test sample!*`;
137
+ }
138
+ return 'An error occurred while processing your request.';
139
+ }
140
+
141
+ export async function POST(request: NextRequest) {
142
+ try {
143
+ const body: ChatRequestBody = await request.json();
144
+ const { messages, stream = true } = body;
145
+
146
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
147
+ return new Response(
148
+ JSON.stringify({ error: 'Invalid request: messages array required' }),
149
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
150
+ );
151
+ }
152
+
153
+ // Find the last user message for validation
154
+ const userMessages = messages.filter(m => m.role === 'user');
155
+ const lastUserMessage = userMessages[userMessages.length - 1];
156
+
157
+ if (lastUserMessage) {
158
+ const userText = extractTextContent(lastUserMessage.content);
159
+ const validation = validateUserInput(userText);
160
+
161
+ // If input is invalid (malicious/harmful), return error
162
+ if (!validation.valid && validation.reason) {
163
+ const encoder = new TextEncoder();
164
+
165
+ if (stream) {
166
+ const errorStream = new ReadableStream({
167
+ start(controller) {
168
+ const data = JSON.stringify({ content: validation.reason, done: false });
169
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
170
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
171
+ controller.close();
172
+ },
173
+ });
174
+
175
+ return new Response(errorStream, {
176
+ headers: {
177
+ 'Content-Type': 'text/event-stream',
178
+ 'Cache-Control': 'no-cache',
179
+ 'Connection': 'keep-alive',
180
+ },
181
+ });
182
+ } else {
183
+ return new Response(
184
+ JSON.stringify({ content: validation.reason }),
185
+ { headers: { 'Content-Type': 'application/json' } }
186
+ );
187
+ }
188
+ }
189
+ }
190
+
191
+ const client = createVLMClient();
192
+
193
+ if (stream) {
194
+ const encoder = new TextEncoder();
195
+
196
+ const readableStream = new ReadableStream({
197
+ async start(controller) {
198
+ try {
199
+ for await (const chunk of client.chatStream(messages)) {
200
+ const data = JSON.stringify({ content: chunk, done: false });
201
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
202
+ }
203
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`));
204
+ controller.close();
205
+ } catch (error) {
206
+ console.error('Stream error:', error);
207
+ const isConnection = isConnectionError(error);
208
+ const errorMessage = isConnection
209
+ ? createErrorMessage(true)
210
+ : (error instanceof Error ? error.message : 'Stream error occurred');
211
+
212
+ controller.enqueue(
213
+ encoder.encode(`data: ${JSON.stringify({ error: errorMessage, done: true })}\n\n`)
214
+ );
215
+ controller.close();
216
+ }
217
+ },
218
+ });
219
+
220
+ return new Response(readableStream, {
221
+ headers: {
222
+ 'Content-Type': 'text/event-stream',
223
+ 'Cache-Control': 'no-cache',
224
+ 'Connection': 'keep-alive',
225
+ },
226
+ });
227
+ } else {
228
+ const response = await client.chat(messages);
229
+ return new Response(
230
+ JSON.stringify({ content: response }),
231
+ { headers: { 'Content-Type': 'application/json' } }
232
+ );
233
+ }
234
+ } catch (error) {
235
+ console.error('Chat API error:', error);
236
+
237
+ if (isConnectionError(error)) {
238
+ return new Response(
239
+ JSON.stringify({ error: createErrorMessage(true) }),
240
+ { status: 503, headers: { 'Content-Type': 'application/json' } }
241
+ );
242
+ }
243
+
244
+ const errorMessage =
245
+ error instanceof Error ? error.message : 'Internal server error';
246
+
247
+ return new Response(
248
+ JSON.stringify({ error: errorMessage }),
249
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
250
+ );
251
+ }
252
+ }
src/app/api/examples/route.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { datasetLoader } from '@/lib/dataset/loader';
3
+ import type { TaskType, Category } from '@/types';
4
+
5
+ // Server-side loader for API route (used for split info and fallback)
6
+ export async function GET(request: NextRequest) {
7
+ try {
8
+ const { searchParams } = new URL(request.url);
9
+
10
+ const split = (searchParams.get('split') as 'train' | 'validation' | 'test') || 'test';
11
+ const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 100);
12
+ const offset = parseInt(searchParams.get('offset') || '0', 10);
13
+ const type = searchParams.get('type') as TaskType | null;
14
+ const category = searchParams.get('category') as Category | null;
15
+ const hasImage = searchParams.get('hasImage');
16
+ const search = searchParams.get('search') || undefined;
17
+ const codingOnly = searchParams.get('codingOnly') === 'true';
18
+
19
+ // Ensure the split is loaded
20
+ if (!datasetLoader.isLoaded(split)) {
21
+ await datasetLoader.preloadSplit(split);
22
+ }
23
+
24
+ // Build filters
25
+ const filters: {
26
+ type?: TaskType;
27
+ category?: Category;
28
+ hasImage?: boolean;
29
+ search?: string;
30
+ codingOnly?: boolean;
31
+ } = { codingOnly };
32
+
33
+ if (type) filters.type = type;
34
+ if (category) filters.category = category;
35
+ if (hasImage !== null) filters.hasImage = hasImage === 'true';
36
+ if (search) filters.search = search;
37
+
38
+ const result = datasetLoader.filterExamples(split, filters, limit, offset);
39
+
40
+ return NextResponse.json({
41
+ examples: result.examples,
42
+ total: result.total,
43
+ split,
44
+ offset,
45
+ limit,
46
+ hasMore: offset + result.examples.length < result.total,
47
+ });
48
+ } catch (error) {
49
+ console.error('Examples API error:', error);
50
+
51
+ const errorMessage =
52
+ error instanceof Error ? error.message : 'Failed to load examples';
53
+
54
+ return NextResponse.json(
55
+ { error: errorMessage, examples: [], total: 0 },
56
+ { status: 500 }
57
+ );
58
+ }
59
+ }
60
+
61
+ // Endpoint to get split info
62
+ export async function POST(request: NextRequest) {
63
+ try {
64
+ const body = await request.json();
65
+
66
+ if (body.action === 'getSplitInfo') {
67
+ const splitInfo = await datasetLoader.getSplitInfo();
68
+ return NextResponse.json({ splitInfo });
69
+ }
70
+
71
+ if (body.action === 'getCodingCount') {
72
+ const split = (body.split || 'test') as 'train' | 'validation' | 'test';
73
+
74
+ // Ensure the split is loaded
75
+ if (!datasetLoader.isLoaded(split)) {
76
+ await datasetLoader.preloadSplit(split);
77
+ }
78
+
79
+ const codingProblems = datasetLoader.getCodingProblems(split);
80
+ return NextResponse.json({ count: codingProblems.length, split });
81
+ }
82
+
83
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
84
+ } catch (error) {
85
+ console.error('Examples API POST error:', error);
86
+ return NextResponse.json(
87
+ { error: 'Failed to process request' },
88
+ { status: 500 }
89
+ );
90
+ }
91
+ }
src/app/api/execute/route.ts ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { spawn } from 'child_process';
3
+ import { writeFile, unlink, mkdir, readFile, readdir } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import { randomUUID } from 'crypto';
7
+
8
+ export const maxDuration = 60;
9
+
10
+ interface ExecuteRequestBody {
11
+ code: string;
12
+ timeout?: number;
13
+ }
14
+
15
+ interface ExecutionResult {
16
+ success: boolean;
17
+ output: string;
18
+ error: string;
19
+ executionTime: number;
20
+ hasCircuitOutput: boolean;
21
+ images?: string[]; // Base64 encoded images
22
+ }
23
+
24
+ const DANGEROUS_PATTERNS = [
25
+ /os\.environ/,
26
+ /environ\[/,
27
+ /getenv\s*\(/,
28
+ // Dangerous modules
29
+ /\bctypes\b/,
30
+ /\bpickle\b/,
31
+ /\bmarshal\b/,
32
+ /\bshelve\b/,
33
+ /\bcommands\b/,
34
+ /\bpty\b/,
35
+ /\bpexpect\b/,
36
+ // System/shell access
37
+ /\bos\.system\b/,
38
+ /\bos\.popen\b/,
39
+ /\bos\.spawn/,
40
+ /\bos\.exec/,
41
+ /\bos\.fork\b/,
42
+ /\bsubprocess\b/,
43
+ /\bcommands\b/,
44
+ // File system attacks outside sandbox
45
+ /open\s*\(\s*['"]\s*\/etc/,
46
+ /open\s*\(\s*['"]\s*\/proc/,
47
+ /open\s*\(\s*['"]\s*\/sys/,
48
+ /open\s*\(\s*['"]\s*\/dev/,
49
+ /open\s*\(\s*['"]\s*\/var/,
50
+ /open\s*\(\s*['"]\s*\/root/,
51
+ /open\s*\(\s*['"]\s*\/home/,
52
+ /open\s*\(\s*['"]\s*\/tmp/,
53
+ /open\s*\(\s*['"]\s*\.env/,
54
+ /open\s*\(\s*['"]\s*\.\.\//, // Path traversal
55
+ /open\s*\(\s*f?['"]\s*\{/, // f-string with path
56
+ // Network access
57
+ /\bsocket\b/,
58
+ /\burllib\b/,
59
+ /\brequests\b/,
60
+ /\bhttpx\b/,
61
+ /\baiohttp\b/,
62
+ /\bhttp\.client\b/,
63
+ /\bftplib\b/,
64
+ /\bsmtplib\b/,
65
+ /\btelnetlib\b/,
66
+ /\bparamiko\b/,
67
+ // Code execution
68
+ /\beval\s*\(/,
69
+ /\bexec\s*\(/,
70
+ /\bcompile\s*\(/,
71
+ /\b__import__\b/,
72
+ /\bimportlib\b/,
73
+ /\bbuiltins\b/,
74
+ /\bglobals\s*\(\s*\)/,
75
+ /\blocals\s*\(\s*\)/,
76
+ /\bgetattr\s*\([^,]+,\s*['"]/, // getattr with string
77
+ /\bsetattr\s*\(/,
78
+ /\bdelattr\s*\(/,
79
+ // Class/object manipulation for sandbox escape
80
+ /\b__class__\b/,
81
+ /\b__bases__\b/,
82
+ /\b__subclasses__\b/,
83
+ /\b__mro__\b/,
84
+ /\b__globals__\b/,
85
+ /\b__code__\b/,
86
+ /\b__reduce__\b/,
87
+ /\b__getstate__\b/,
88
+ /\b__setstate__\b/,
89
+ // Multiprocessing (can be used to bypass restrictions)
90
+ /\bmultiprocessing\b/,
91
+ /\bthreading\b/,
92
+ /\bconcurrent\b/,
93
+ /\basyncio\.subprocess/,
94
+ ];
95
+
96
+ const ALLOWED_PATTERNS = [
97
+ /from qiskit/,
98
+ /import qiskit/,
99
+ /from numpy/,
100
+ /import numpy/,
101
+ /from scipy/,
102
+ /import scipy/,
103
+ /from matplotlib/,
104
+ /import matplotlib/,
105
+ ];
106
+
107
+ function validateCode(code: string): { valid: boolean; error?: string } {
108
+ const codeWithoutComments = code
109
+ .replace(/#.*$/gm, '') // Remove single-line comments
110
+ .replace(/'''[\s\S]*?'''/g, '') // Remove triple-single-quote strings
111
+ .replace(/"""[\s\S]*?"""/g, ''); // Remove triple-double-quote strings
112
+
113
+ for (const pattern of DANGEROUS_PATTERNS) {
114
+ if (pattern.test(codeWithoutComments)) {
115
+ return {
116
+ valid: false,
117
+ error: `Security error: Potentially dangerous code pattern detected. For security reasons, certain operations are not allowed in the sandbox.`
118
+ };
119
+ }
120
+ }
121
+
122
+ return { valid: true };
123
+ }
124
+
125
+ const createSafetyWrapper = (figureDir: string) => `
126
+ import sys
127
+ import io
128
+ import os
129
+ import warnings
130
+ import builtins
131
+ from contextlib import redirect_stdout, redirect_stderr
132
+
133
+ # Suppress warnings for cleaner output
134
+ warnings.filterwarnings('ignore')
135
+
136
+ # Setup figure capture directory
137
+ _FIGURE_DIR = "${figureDir.replace(/\\/g, '\\\\')}"
138
+ _figure_counter = [0]
139
+
140
+ # ============================================
141
+ # SECURITY SANDBOX SETUP (Second Line of Defense)
142
+ # Primary security is pattern detection + clean environment
143
+ # ============================================
144
+
145
+ # Block dangerous system operations
146
+ os.system = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.system not allowed in sandbox"))
147
+ os.popen = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.popen not allowed in sandbox"))
148
+ if hasattr(os, 'spawn'):
149
+ os.spawn = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawn not allowed"))
150
+ if hasattr(os, 'spawnl'):
151
+ os.spawnl = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnl not allowed"))
152
+ if hasattr(os, 'spawnle'):
153
+ os.spawnle = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnle not allowed"))
154
+ if hasattr(os, 'spawnlp'):
155
+ os.spawnlp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnlp not allowed"))
156
+ if hasattr(os, 'spawnlpe'):
157
+ os.spawnlpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnlpe not allowed"))
158
+ if hasattr(os, 'spawnv'):
159
+ os.spawnv = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnv not allowed"))
160
+ if hasattr(os, 'spawnve'):
161
+ os.spawnve = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnve not allowed"))
162
+ if hasattr(os, 'spawnvp'):
163
+ os.spawnvp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnvp not allowed"))
164
+ if hasattr(os, 'spawnvpe'):
165
+ os.spawnvpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnvpe not allowed"))
166
+ if hasattr(os, 'execl'):
167
+ os.execl = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execl not allowed"))
168
+ if hasattr(os, 'execle'):
169
+ os.execle = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execle not allowed"))
170
+ if hasattr(os, 'execlp'):
171
+ os.execlp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execlp not allowed"))
172
+ if hasattr(os, 'execlpe'):
173
+ os.execlpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execlpe not allowed"))
174
+ if hasattr(os, 'execv'):
175
+ os.execv = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execv not allowed"))
176
+ if hasattr(os, 'execve'):
177
+ os.execve = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execve not allowed"))
178
+ if hasattr(os, 'execvp'):
179
+ os.execvp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execvp not allowed"))
180
+ if hasattr(os, 'execvpe'):
181
+ os.execvpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execvpe not allowed"))
182
+ if hasattr(os, 'fork'):
183
+ os.fork = lambda: (_ for _ in ()).throw(PermissionError("os.fork not allowed"))
184
+ if hasattr(os, 'forkpty'):
185
+ os.forkpty = lambda: (_ for _ in ()).throw(PermissionError("os.forkpty not allowed"))
186
+ if hasattr(os, 'killpg'):
187
+ os.killpg = lambda *args: (_ for _ in ()).throw(PermissionError("os.killpg not allowed"))
188
+ if hasattr(os, 'kill'):
189
+ os.kill = lambda *args: (_ for _ in ()).throw(PermissionError("os.kill not allowed"))
190
+
191
+ # Block subprocess module
192
+ try:
193
+ import subprocess
194
+ subprocess.run = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
195
+ subprocess.call = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
196
+ subprocess.check_call = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
197
+ subprocess.check_output = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
198
+ subprocess.Popen = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
199
+ subprocess.getoutput = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
200
+ subprocess.getstatusoutput = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
201
+ except ImportError:
202
+ pass
203
+
204
+ # Create restricted open function to block access to sensitive files
205
+ _original_open = builtins.open
206
+ _ALLOWED_PATHS = [_FIGURE_DIR, '/tmp/quantum-sandbox']
207
+
208
+ def _restricted_open(file, mode='r', *args, **kwargs):
209
+ """Restricted open that blocks access to sensitive files"""
210
+ if isinstance(file, (str, bytes)):
211
+ file_str = file if isinstance(file, str) else file.decode()
212
+ # Don't block relative paths that are needed for library operation
213
+ if file_str.startswith('/'):
214
+ file_str_lower = file_str.lower()
215
+
216
+ # Block reading system sensitive paths
217
+ blocked_prefixes = ['/etc/passwd', '/etc/shadow', '/proc/self', '/proc/1']
218
+ for prefix in blocked_prefixes:
219
+ if file_str_lower.startswith(prefix):
220
+ raise PermissionError(f"Access to {prefix} is not allowed in sandbox")
221
+
222
+ # Block reading obvious secrets
223
+ blocked_patterns = ['.env.local', '.env.', 'secrets', 'credentials', 'private_key']
224
+ for pattern in blocked_patterns:
225
+ if pattern in file_str_lower:
226
+ raise PermissionError(f"Access to files matching '{pattern}' is not allowed in sandbox")
227
+
228
+ return _original_open(file, mode, *args, **kwargs)
229
+
230
+ builtins.open = _restricted_open
231
+
232
+ # ============================================
233
+ # END SECURITY SANDBOX SETUP
234
+ # ============================================
235
+
236
+ # Capture all output
237
+ _stdout_capture = io.StringIO()
238
+ _stderr_capture = io.StringIO()
239
+
240
+ # Setup matplotlib figure capture
241
+ try:
242
+ import matplotlib
243
+ matplotlib.use('Agg')
244
+ import matplotlib.pyplot as plt
245
+
246
+ _original_show = plt.show
247
+ _original_savefig = plt.savefig
248
+
249
+ def _capture_show(*args, **kwargs):
250
+ """Capture plt.show() calls and save figures"""
251
+ figs = [plt.figure(i) for i in plt.get_fignums()]
252
+ for fig in figs:
253
+ _figure_counter[0] += 1
254
+ filepath = os.path.join(_FIGURE_DIR, f"figure_{_figure_counter[0]}.png")
255
+ fig.savefig(filepath, format='png', dpi=150, bbox_inches='tight',
256
+ facecolor='#18181b', edgecolor='none', transparent=False)
257
+ plt.close('all')
258
+
259
+ def _capture_savefig(fname, *args, **kwargs):
260
+ """Capture savefig calls"""
261
+ _figure_counter[0] += 1
262
+ filepath = os.path.join(_FIGURE_DIR, f"figure_{_figure_counter[0]}.png")
263
+ kwargs_copy = dict(kwargs)
264
+ kwargs_copy['format'] = 'png'
265
+ kwargs_copy['dpi'] = kwargs_copy.get('dpi', 150)
266
+ kwargs_copy['bbox_inches'] = kwargs_copy.get('bbox_inches', 'tight')
267
+ kwargs_copy['facecolor'] = kwargs_copy.get('facecolor', '#18181b')
268
+ _original_savefig(filepath, **kwargs_copy)
269
+
270
+ plt.show = _capture_show
271
+ plt.savefig = _capture_savefig
272
+
273
+ # Also capture Qiskit circuit.draw() with mpl output
274
+ try:
275
+ from qiskit import QuantumCircuit
276
+ _original_draw = QuantumCircuit.draw
277
+
278
+ def _capture_draw(self, output=None, **kwargs):
279
+ result = _original_draw(self, output=output, **kwargs)
280
+ if output == 'mpl' and result is not None:
281
+ _figure_counter[0] += 1
282
+ filepath = os.path.join(_FIGURE_DIR, f"figure_{_figure_counter[0]}.png")
283
+ result.savefig(filepath, format='png', dpi=150, bbox_inches='tight',
284
+ facecolor='#18181b', edgecolor='none')
285
+ plt.close(result)
286
+ return result
287
+
288
+ QuantumCircuit.draw = _capture_draw
289
+ except ImportError:
290
+ pass
291
+
292
+ except ImportError:
293
+ pass
294
+
295
+ # Now execute the user code with output capture
296
+ with redirect_stdout(_stdout_capture), redirect_stderr(_stderr_capture):
297
+ try:
298
+ exec(compile('''
299
+ __USER_CODE__
300
+ ''', '<user_code>', 'exec'), {'__builtins__': builtins, '__name__': '__main__'})
301
+ except Exception as e:
302
+ print(f"{type(e).__name__}: {e}", file=sys.stderr)
303
+
304
+ # Final figure capture - save any remaining open figures
305
+ try:
306
+ import matplotlib.pyplot as plt
307
+ figs = [plt.figure(i) for i in plt.get_fignums()]
308
+ for fig in figs:
309
+ _figure_counter[0] += 1
310
+ filepath = os.path.join(_FIGURE_DIR, f"figure_{_figure_counter[0]}.png")
311
+ fig.savefig(filepath, format='png', dpi=150, bbox_inches='tight',
312
+ facecolor='#18181b', edgecolor='none', transparent=False)
313
+ plt.close('all')
314
+ except:
315
+ pass
316
+
317
+ # Print captured output
318
+ _stdout_result = _stdout_capture.getvalue()
319
+ _stderr_result = _stderr_capture.getvalue()
320
+
321
+ if _stdout_result:
322
+ print(_stdout_result, end='')
323
+ if _stderr_result:
324
+ print(_stderr_result, end='', file=sys.stderr)
325
+ `;
326
+
327
+ function createSafeCode(userCode: string, figureDir: string): string {
328
+ const escapedCode = userCode
329
+ .replace(/\\/g, '\\\\')
330
+ .replace(/'''/g, "\\'\\'\\'");
331
+
332
+ return createSafetyWrapper(figureDir).replace('__USER_CODE__', escapedCode);
333
+ }
334
+
335
+ function getSafeEnv(): Record<string, string> {
336
+ const env: Record<string, string> = {
337
+ PATH: '/usr/bin:/bin:/usr/local/bin',
338
+ HOME: '/tmp',
339
+ PYTHONUNBUFFERED: '1',
340
+ MPLBACKEND: 'Agg',
341
+ MallocStackLogging: '0',
342
+ MallocNanoZone: '0',
343
+ LANG: 'en_US.UTF-8',
344
+ LC_ALL: 'en_US.UTF-8',
345
+ };
346
+ if (process.env.PYTHON_PATH) {
347
+ env.PYTHON_PATH = process.env.PYTHON_PATH;
348
+ }
349
+ return env;
350
+ }
351
+
352
+ function detectCircuitOutput(code: string, output: string): boolean {
353
+ // Check if the code likely produces circuit visualization
354
+ const circuitPatterns = [
355
+ /\.draw\(/,
356
+ /circuit_drawer/,
357
+ /plot_histogram/,
358
+ /plot_bloch/,
359
+ /\.decompose\(\)/,
360
+ /print.*circuit/i,
361
+ ];
362
+
363
+ const outputPatterns = [
364
+ /[┌─┬┐│├┼┤└┴┘═║╔╗╚╝]/, // ASCII circuit characters
365
+ /q\d*.*[─┤├]/, // Qubit lines
366
+ /[HXYZTSRx].*├/, // Gate symbols
367
+ ];
368
+
369
+ const hasCircuitCode = circuitPatterns.some(p => p.test(code));
370
+ const hasCircuitOutput = outputPatterns.some(p => p.test(output));
371
+
372
+ return hasCircuitCode || hasCircuitOutput;
373
+ }
374
+
375
+ async function collectFigures(figureDir: string): Promise<string[]> {
376
+ const images: string[] = [];
377
+ try {
378
+ const files = await readdir(figureDir);
379
+ const pngFiles = files.filter(f => f.endsWith('.png')).sort();
380
+
381
+ for (const file of pngFiles) {
382
+ const filepath = join(figureDir, file);
383
+ const data = await readFile(filepath);
384
+ images.push(data.toString('base64'));
385
+ // Clean up the file
386
+ await unlink(filepath).catch(() => { });
387
+ }
388
+ } catch {
389
+ // Directory might not exist or be empty
390
+ }
391
+ return images;
392
+ }
393
+
394
+ async function executeCode(code: string, timeout: number): Promise<ExecutionResult> {
395
+ const startTime = Date.now();
396
+ const execId = randomUUID();
397
+ const tempDir = join(tmpdir(), 'quantum-sandbox');
398
+ const figureDir = join(tempDir, `figures_${execId}`);
399
+ const tempFile = join(tempDir, `exec_${execId}.py`);
400
+
401
+ try {
402
+ // Ensure temp directories exist
403
+ await mkdir(tempDir, { recursive: true });
404
+ await mkdir(figureDir, { recursive: true });
405
+
406
+ // Create safe wrapped code
407
+ const safeCode = createSafeCode(code, figureDir);
408
+ await writeFile(tempFile, safeCode, 'utf-8');
409
+
410
+ return await new Promise<ExecutionResult>(async (resolve) => {
411
+ let stdout = '';
412
+ let stderr = '';
413
+ let killed = false;
414
+
415
+ // Use the PYTHON_PATH environment variable if set, otherwise default to python3
416
+ const pythonPath = process.env.PYTHON_PATH || 'python3';
417
+
418
+ const pythonProcess = spawn(pythonPath, [tempFile], {
419
+ timeout: timeout * 1000,
420
+ env: getSafeEnv() as NodeJS.ProcessEnv, // Use minimal safe environment, no secrets
421
+ cwd: tempDir, // Run in isolated temp directory
422
+ });
423
+
424
+ pythonProcess.stdout.on('data', (data: Buffer) => {
425
+ stdout += data.toString();
426
+ });
427
+
428
+ pythonProcess.stderr.on('data', (data: Buffer) => {
429
+ stderr += data.toString();
430
+ });
431
+
432
+ const timeoutId = setTimeout(() => {
433
+ killed = true;
434
+ pythonProcess.kill('SIGKILL');
435
+ }, timeout * 1000);
436
+
437
+ pythonProcess.on('close', async (exitCode: number | null) => {
438
+ clearTimeout(timeoutId);
439
+ const executionTime = Date.now() - startTime;
440
+
441
+ // Collect any generated figures
442
+ const images = await collectFigures(figureDir);
443
+
444
+ if (killed) {
445
+ resolve({
446
+ success: false,
447
+ output: stdout,
448
+ error: `Execution timeout (>${timeout}s). The code took too long to execute.`,
449
+ executionTime,
450
+ hasCircuitOutput: false,
451
+ images,
452
+ });
453
+ return;
454
+ }
455
+
456
+ // Clean up stderr from common warnings
457
+ const cleanStderr = stderr
458
+ .split('\n')
459
+ .filter(line => !line.includes('UserWarning') &&
460
+ !line.includes('DeprecationWarning') &&
461
+ !line.includes('FutureWarning'))
462
+ .join('\n')
463
+ .trim();
464
+
465
+ const success = exitCode === 0 && !cleanStderr;
466
+
467
+ resolve({
468
+ success,
469
+ output: stdout.trim(),
470
+ error: cleanStderr,
471
+ executionTime,
472
+ hasCircuitOutput: detectCircuitOutput(code, stdout),
473
+ images,
474
+ });
475
+ });
476
+
477
+ pythonProcess.on('error', (err: Error) => {
478
+ clearTimeout(timeoutId);
479
+ resolve({
480
+ success: false,
481
+ output: '',
482
+ error: `Failed to start Python: ${err.message}`,
483
+ executionTime: Date.now() - startTime,
484
+ hasCircuitOutput: false,
485
+ images: [],
486
+ });
487
+ });
488
+ });
489
+ } finally {
490
+ // Clean up temp file and figure directory
491
+ try {
492
+ await unlink(tempFile);
493
+ } catch {
494
+ // Ignore cleanup errors
495
+ }
496
+ try {
497
+ // Clean up figure directory
498
+ const files = await readdir(figureDir).catch(() => []);
499
+ for (const file of files) {
500
+ await unlink(join(figureDir, file)).catch(() => { });
501
+ }
502
+ await unlink(figureDir).catch(() => { });
503
+ } catch {
504
+ // Ignore cleanup errors
505
+ }
506
+ }
507
+ }
508
+
509
+ export async function POST(request: NextRequest) {
510
+ try {
511
+ const body: ExecuteRequestBody = await request.json();
512
+ const { code, timeout = 30 } = body;
513
+
514
+ if (!code || typeof code !== 'string') {
515
+ return new Response(
516
+ JSON.stringify({ error: 'Invalid request: code string required' }),
517
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
518
+ );
519
+ }
520
+
521
+ // Limit code length
522
+ if (code.length > 50000) {
523
+ return new Response(
524
+ JSON.stringify({ error: 'Code too long (max 50KB)' }),
525
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
526
+ );
527
+ }
528
+
529
+ // Validate code for dangerous patterns (first line of defense)
530
+ const validation = validateCode(code);
531
+ if (!validation.valid) {
532
+ return new Response(
533
+ JSON.stringify({
534
+ success: false,
535
+ output: '',
536
+ error: validation.error,
537
+ executionTime: 0,
538
+ hasCircuitOutput: false,
539
+ images: [],
540
+ }),
541
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
542
+ );
543
+ }
544
+
545
+ // Limit timeout
546
+ const safeTimeout = Math.min(Math.max(timeout, 5), 60);
547
+
548
+ const result = await executeCode(code, safeTimeout);
549
+
550
+ return new Response(JSON.stringify(result), {
551
+ headers: { 'Content-Type': 'application/json' },
552
+ });
553
+ } catch (error) {
554
+ console.error('Execute API error:', error);
555
+
556
+ return new Response(
557
+ JSON.stringify({
558
+ success: false,
559
+ output: '',
560
+ error: error instanceof Error ? error.message : 'Execution failed',
561
+ executionTime: 0,
562
+ hasCircuitOutput: false,
563
+ images: [],
564
+ }),
565
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
566
+ );
567
+ }
568
+ }
src/app/api/status/route.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export interface RunPodHealth {
4
+ jobs: {
5
+ completed: number;
6
+ failed: number;
7
+ inProgress: number;
8
+ inQueue: number;
9
+ retried: number;
10
+ };
11
+ workers: {
12
+ idle: number;
13
+ initializing: number;
14
+ running: number;
15
+ throttled: number;
16
+ };
17
+ }
18
+
19
+ export interface StatusResponse {
20
+ status: 'ready' | 'cold_start' | 'initializing' | 'processing' | 'unavailable';
21
+ message: string;
22
+ workers: {
23
+ idle: number;
24
+ running: number;
25
+ initializing: number;
26
+ };
27
+ queue: {
28
+ inProgress: number;
29
+ inQueue: number;
30
+ };
31
+ estimatedWait?: number; // seconds
32
+ }
33
+
34
+ /**
35
+ * Check RunPod endpoint health to provide user feedback during cold starts
36
+ */
37
+ export async function GET(): Promise<NextResponse<StatusResponse>> {
38
+ const baseUrl = process.env.DEMO_MODEL_URL || 'http://localhost:8000/v1';
39
+ const apiKey = process.env.DEMO_API_KEY || '';
40
+
41
+ // Extract RunPod endpoint URL from the vLLM base URL
42
+ // vLLM URL format: https://api.runpod.ai/v2/{endpoint_id}/openai/v1
43
+ // Health URL format: https://api.runpod.ai/v2/{endpoint_id}/health
44
+ const runpodMatch = baseUrl.match(/https:\/\/api\.runpod\.ai\/v2\/([^/]+)/);
45
+
46
+ if (!runpodMatch) {
47
+ // Not a RunPod endpoint, assume it's always ready (local/other provider)
48
+ return NextResponse.json({
49
+ status: 'ready',
50
+ message: 'Model server ready',
51
+ workers: { idle: 1, running: 0, initializing: 0 },
52
+ queue: { inProgress: 0, inQueue: 0 },
53
+ });
54
+ }
55
+
56
+ const endpointId = runpodMatch[1];
57
+ const healthUrl = `https://api.runpod.ai/v2/${endpointId}/health`;
58
+
59
+ try {
60
+ const response = await fetch(healthUrl, {
61
+ method: 'GET',
62
+ headers: {
63
+ 'Authorization': `Bearer ${apiKey}`,
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ // Short timeout for health check
67
+ signal: AbortSignal.timeout(5000),
68
+ });
69
+
70
+ if (!response.ok) {
71
+ return NextResponse.json({
72
+ status: 'unavailable',
73
+ message: 'Unable to check model status',
74
+ workers: { idle: 0, running: 0, initializing: 0 },
75
+ queue: { inProgress: 0, inQueue: 0 },
76
+ });
77
+ }
78
+
79
+ const health: RunPodHealth = await response.json();
80
+
81
+ const totalWorkers = health.workers.idle + health.workers.running + (health.workers.initializing || 0);
82
+ const hasActiveWorkers = totalWorkers > 0;
83
+ const hasIdleWorkers = health.workers.idle > 0;
84
+ const isInitializing = (health.workers.initializing || 0) > 0;
85
+ const hasQueuedJobs = health.jobs.inQueue > 0;
86
+ const hasRunningJobs = health.jobs.inProgress > 0;
87
+
88
+ let status: StatusResponse['status'];
89
+ let message: string;
90
+ let estimatedWait: number | undefined;
91
+
92
+ if (hasIdleWorkers) {
93
+ status = 'ready';
94
+ message = 'Model ready';
95
+ } else if (isInitializing) {
96
+ status = 'initializing';
97
+ message = 'Model loading...';
98
+ estimatedWait = 30; // Typical vLLM model load time
99
+ } else if (health.workers.running > 0) {
100
+ status = 'processing';
101
+ message = hasQueuedJobs
102
+ ? `Processing (${health.jobs.inQueue} in queue)`
103
+ : 'Processing request...';
104
+ estimatedWait = hasQueuedJobs ? health.jobs.inQueue * 15 : undefined;
105
+ } else if (!hasActiveWorkers && (hasQueuedJobs || hasRunningJobs)) {
106
+ status = 'cold_start';
107
+ message = 'Starting worker...';
108
+ estimatedWait = 45; // Cold start + model load
109
+ } else if (!hasActiveWorkers) {
110
+ status = 'cold_start';
111
+ message = 'Workers scaled to zero, will start on request';
112
+ estimatedWait = 45;
113
+ } else {
114
+ status = 'ready';
115
+ message = 'Model ready';
116
+ }
117
+
118
+ return NextResponse.json({
119
+ status,
120
+ message,
121
+ workers: {
122
+ idle: health.workers.idle,
123
+ running: health.workers.running,
124
+ initializing: health.workers.initializing || 0,
125
+ },
126
+ queue: {
127
+ inProgress: health.jobs.inProgress,
128
+ inQueue: health.jobs.inQueue,
129
+ },
130
+ estimatedWait,
131
+ });
132
+ } catch (error) {
133
+ console.error('Health check error:', error);
134
+
135
+ // Network error might indicate cold start
136
+ return NextResponse.json({
137
+ status: 'cold_start',
138
+ message: 'Connecting to model server...',
139
+ workers: { idle: 0, running: 0, initializing: 0 },
140
+ queue: { inProgress: 0, inQueue: 0 },
141
+ estimatedWait: 45,
142
+ });
143
+ }
144
+ }
145
+
src/app/api/test/route.ts ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { spawn } from 'child_process';
3
+ import { writeFile, unlink, mkdir } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import { randomUUID } from 'crypto';
7
+ import type { TestResult } from '@/types';
8
+
9
+ export const maxDuration = 60;
10
+
11
+ interface TestRequestBody {
12
+ userCode: string;
13
+ testCode: string;
14
+ entryPoint: string;
15
+ timeout?: number;
16
+ }
17
+
18
+ // List of dangerous patterns that should be blocked (same as execute route)
19
+ const DANGEROUS_PATTERNS = [
20
+ // Environment variable access
21
+ /os\.environ/,
22
+ /environ\[/,
23
+ /getenv\s*\(/,
24
+ // Dangerous modules
25
+ /\bctypes\b/,
26
+ /\bpickle\b/,
27
+ /\bmarshal\b/,
28
+ /\bshelve\b/,
29
+ /\bcommands\b/,
30
+ /\bpty\b/,
31
+ /\bpexpect\b/,
32
+ // System/shell access
33
+ /\bos\.system\b/,
34
+ /\bos\.popen\b/,
35
+ /\bos\.spawn/,
36
+ /\bos\.exec/,
37
+ /\bos\.fork\b/,
38
+ /\bsubprocess\b/,
39
+ /\bcommands\b/,
40
+ // File system attacks outside sandbox
41
+ /open\s*\(\s*['"]\s*\/etc/,
42
+ /open\s*\(\s*['"]\s*\/proc/,
43
+ /open\s*\(\s*['"]\s*\/sys/,
44
+ /open\s*\(\s*['"]\s*\/dev/,
45
+ /open\s*\(\s*['"]\s*\/var/,
46
+ /open\s*\(\s*['"]\s*\/root/,
47
+ /open\s*\(\s*['"]\s*\/home/,
48
+ /open\s*\(\s*['"]\s*\/tmp/,
49
+ /open\s*\(\s*['"]\s*\.env/,
50
+ /open\s*\(\s*['"]\s*\.\.\//, // Path traversal
51
+ /open\s*\(\s*f?['"]\s*\{/, // f-string with path
52
+ // Network access
53
+ /\bsocket\b/,
54
+ /\burllib\b/,
55
+ /\brequests\b/,
56
+ /\bhttpx\b/,
57
+ /\baiohttp\b/,
58
+ /\bhttp\.client\b/,
59
+ /\bftplib\b/,
60
+ /\bsmtplib\b/,
61
+ /\btelnetlib\b/,
62
+ /\bparamiko\b/,
63
+ // Code execution
64
+ /\beval\s*\(/,
65
+ /\bexec\s*\(/,
66
+ /\bcompile\s*\(/,
67
+ /\b__import__\b/,
68
+ /\bimportlib\b/,
69
+ /\bbuiltins\b/,
70
+ /\bglobals\s*\(\s*\)/,
71
+ /\blocals\s*\(\s*\)/,
72
+ /\bgetattr\s*\([^,]+,\s*['"]/, // getattr with string
73
+ /\bsetattr\s*\(/,
74
+ /\bdelattr\s*\(/,
75
+ // Class/object manipulation for sandbox escape
76
+ /\b__class__\b/,
77
+ /\b__bases__\b/,
78
+ /\b__subclasses__\b/,
79
+ /\b__mro__\b/,
80
+ /\b__globals__\b/,
81
+ /\b__code__\b/,
82
+ /\b__reduce__\b/,
83
+ /\b__getstate__\b/,
84
+ /\b__setstate__\b/,
85
+ // Multiprocessing (can be used to bypass restrictions)
86
+ /\bmultiprocessing\b/,
87
+ /\bthreading\b/,
88
+ /\bconcurrent\b/,
89
+ /\basyncio\.subprocess/,
90
+ ];
91
+
92
+ function validateCode(code: string): { valid: boolean; error?: string } {
93
+ const codeWithoutComments = code
94
+ .replace(/#.*$/gm, '') // Remove single-line comments
95
+ .replace(/'''[\s\S]*?'''/g, '') // Remove triple-single-quote strings
96
+ .replace(/"""[\s\S]*?"""/g, ''); // Remove triple-double-quote strings
97
+
98
+ for (const pattern of DANGEROUS_PATTERNS) {
99
+ if (pattern.test(codeWithoutComments)) {
100
+ return {
101
+ valid: false,
102
+ error: `Security error: Potentially dangerous code pattern detected. For security reasons, certain operations are not allowed in the sandbox.`
103
+ };
104
+ }
105
+ }
106
+
107
+ return { valid: true };
108
+ }
109
+
110
+ // Minimal safe environment variables for Python execution
111
+ function getSafeEnv(): Record<string, string> {
112
+ const env: Record<string, string> = {
113
+ PATH: '/usr/bin:/bin:/usr/local/bin',
114
+ HOME: '/tmp',
115
+ PYTHONUNBUFFERED: '1',
116
+ MPLBACKEND: 'Agg',
117
+ MallocStackLogging: '0',
118
+ MallocNanoZone: '0',
119
+ LANG: 'en_US.UTF-8',
120
+ LC_ALL: 'en_US.UTF-8',
121
+ };
122
+ // Only pass PYTHON_PATH if needed, but not other secrets
123
+ if (process.env.PYTHON_PATH) {
124
+ env.PYTHON_PATH = process.env.PYTHON_PATH;
125
+ }
126
+ return env;
127
+ }
128
+
129
+ // Security wrapper for test execution
130
+ // Primary security is pattern detection + clean environment
131
+ const SECURITY_WRAPPER = `
132
+ import sys
133
+ import io
134
+ import os
135
+ import builtins
136
+ import warnings
137
+ from contextlib import redirect_stdout, redirect_stderr
138
+
139
+ # Suppress warnings for cleaner output
140
+ warnings.filterwarnings('ignore')
141
+
142
+ # ============================================
143
+ # SECURITY SANDBOX SETUP (Second Line of Defense)
144
+ # Primary security is pattern detection + clean environment
145
+ # ============================================
146
+
147
+ # Block dangerous system operations
148
+ os.system = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.system not allowed in sandbox"))
149
+ os.popen = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.popen not allowed in sandbox"))
150
+ if hasattr(os, 'spawn'):
151
+ os.spawn = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawn not allowed"))
152
+ if hasattr(os, 'spawnl'):
153
+ os.spawnl = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnl not allowed"))
154
+ if hasattr(os, 'spawnle'):
155
+ os.spawnle = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnle not allowed"))
156
+ if hasattr(os, 'spawnlp'):
157
+ os.spawnlp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnlp not allowed"))
158
+ if hasattr(os, 'spawnlpe'):
159
+ os.spawnlpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnlpe not allowed"))
160
+ if hasattr(os, 'spawnv'):
161
+ os.spawnv = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnv not allowed"))
162
+ if hasattr(os, 'spawnve'):
163
+ os.spawnve = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnve not allowed"))
164
+ if hasattr(os, 'spawnvp'):
165
+ os.spawnvp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnvp not allowed"))
166
+ if hasattr(os, 'spawnvpe'):
167
+ os.spawnvpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.spawnvpe not allowed"))
168
+ if hasattr(os, 'execl'):
169
+ os.execl = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execl not allowed"))
170
+ if hasattr(os, 'execle'):
171
+ os.execle = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execle not allowed"))
172
+ if hasattr(os, 'execlp'):
173
+ os.execlp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execlp not allowed"))
174
+ if hasattr(os, 'execlpe'):
175
+ os.execlpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execlpe not allowed"))
176
+ if hasattr(os, 'execv'):
177
+ os.execv = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execv not allowed"))
178
+ if hasattr(os, 'execve'):
179
+ os.execve = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execve not allowed"))
180
+ if hasattr(os, 'execvp'):
181
+ os.execvp = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execvp not allowed"))
182
+ if hasattr(os, 'execvpe'):
183
+ os.execvpe = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("os.execvpe not allowed"))
184
+ if hasattr(os, 'fork'):
185
+ os.fork = lambda: (_ for _ in ()).throw(PermissionError("os.fork not allowed"))
186
+ if hasattr(os, 'forkpty'):
187
+ os.forkpty = lambda: (_ for _ in ()).throw(PermissionError("os.forkpty not allowed"))
188
+ if hasattr(os, 'killpg'):
189
+ os.killpg = lambda *args: (_ for _ in ()).throw(PermissionError("os.killpg not allowed"))
190
+ if hasattr(os, 'kill'):
191
+ os.kill = lambda *args: (_ for _ in ()).throw(PermissionError("os.kill not allowed"))
192
+
193
+ # Block subprocess module
194
+ try:
195
+ import subprocess
196
+ subprocess.run = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
197
+ subprocess.call = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
198
+ subprocess.check_call = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
199
+ subprocess.check_output = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
200
+ subprocess.Popen = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
201
+ subprocess.getoutput = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
202
+ subprocess.getstatusoutput = lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("subprocess not allowed"))
203
+ except ImportError:
204
+ pass
205
+
206
+ # Create restricted open function to block access to sensitive files
207
+ _original_open = builtins.open
208
+ _ALLOWED_PATHS = ['/tmp/quantum-sandbox']
209
+
210
+ def _restricted_open(file, mode='r', *args, **kwargs):
211
+ """Restricted open that blocks access to sensitive files"""
212
+ if isinstance(file, (str, bytes)):
213
+ file_str = file if isinstance(file, str) else file.decode()
214
+ if file_str.startswith('/'):
215
+ file_str_lower = file_str.lower()
216
+
217
+ # Block reading system sensitive paths
218
+ blocked_prefixes = ['/etc/passwd', '/etc/shadow', '/proc/self', '/proc/1']
219
+ for prefix in blocked_prefixes:
220
+ if file_str_lower.startswith(prefix):
221
+ raise PermissionError(f"Access to {prefix} is not allowed in sandbox")
222
+
223
+ # Block reading obvious secrets
224
+ blocked_patterns = ['.env.local', '.env.', 'secrets', 'credentials', 'private_key']
225
+ for pattern in blocked_patterns:
226
+ if pattern in file_str_lower:
227
+ raise PermissionError(f"Access to files matching '{pattern}' is not allowed in sandbox")
228
+
229
+ return _original_open(file, mode, *args, **kwargs)
230
+
231
+ builtins.open = _restricted_open
232
+
233
+ # ============================================
234
+ # END SECURITY SANDBOX SETUP
235
+ # ============================================
236
+
237
+ # Setup matplotlib non-interactive backend
238
+ try:
239
+ import matplotlib
240
+ matplotlib.use('Agg')
241
+ except ImportError:
242
+ pass
243
+
244
+ # Now execute the user code
245
+ `;
246
+
247
+ /**
248
+ * Build executable code combining solution and test with security wrapper.
249
+ */
250
+ function buildSecureExecutableCode(
251
+ userCode: string,
252
+ testCode: string,
253
+ entryPoint: string
254
+ ): string {
255
+ // Deduplicate imports between code and test
256
+ const codeImports = new Set(
257
+ userCode.match(/^(?:from|import)\s+.+$/gm) || []
258
+ );
259
+
260
+ const testLines: string[] = [];
261
+ for (const line of testCode.split('\n')) {
262
+ const trimmed = line.trim();
263
+ if (trimmed.startsWith('from ') || trimmed.startsWith('import ')) {
264
+ if (!codeImports.has(trimmed)) {
265
+ testLines.push(line);
266
+ }
267
+ } else {
268
+ testLines.push(line);
269
+ }
270
+ }
271
+
272
+ const cleanedTest = testLines.join('\n');
273
+ const executionTrigger = getTestExecutionTrigger(testCode, entryPoint);
274
+
275
+ // Escape user code for embedding
276
+ const escapedUserCode = userCode
277
+ .replace(/\\/g, '\\\\')
278
+ .replace(/'''/g, "\\'\\'\\'");
279
+
280
+ const escapedTestCode = cleanedTest
281
+ .replace(/\\/g, '\\\\')
282
+ .replace(/'''/g, "\\'\\'\\'");
283
+
284
+ return `${SECURITY_WRAPPER}
285
+ try:
286
+ exec(compile('''
287
+ ${escapedUserCode}
288
+
289
+ ${escapedTestCode}${executionTrigger}
290
+
291
+ print("TEST_PASSED")
292
+ ''', '<user_code>', 'exec'), {'__builtins__': builtins, '__name__': '__main__'})
293
+ except Exception as e:
294
+ import traceback
295
+ traceback.print_exc()
296
+ `;
297
+ }
298
+
299
+ /**
300
+ * Determine the test execution trigger.
301
+ */
302
+ function getTestExecutionTrigger(testCode: string, entryPoint: string): string {
303
+ const hasCheck = /def\s+check\s*\(/.test(testCode);
304
+ const testFuncMatch = testCode.match(/def\s+(test_\w+)\s*\(/);
305
+
306
+ if (hasCheck && entryPoint) {
307
+ const checkCallPattern = new RegExp(
308
+ `check\\s*\\(\\s*${entryPoint.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\)`
309
+ );
310
+ if (checkCallPattern.test(testCode)) {
311
+ return '';
312
+ }
313
+ return `\ncheck(${entryPoint})`;
314
+ } else if (testFuncMatch) {
315
+ const testName = testFuncMatch[1];
316
+ return `\n${testName}()`;
317
+ }
318
+
319
+ return '';
320
+ }
321
+
322
+ /**
323
+ * Extract meaningful error message from stderr.
324
+ */
325
+ function extractErrorMessage(stderr: string): string {
326
+ if (!stderr) return 'Unknown error';
327
+
328
+ const lines = stderr.split('\n');
329
+
330
+ const errorTypes = [
331
+ 'AssertionError',
332
+ 'TypeError',
333
+ 'ValueError',
334
+ 'AttributeError',
335
+ 'ImportError',
336
+ 'ModuleNotFoundError',
337
+ 'NameError',
338
+ 'KeyError',
339
+ 'IndexError',
340
+ 'RuntimeError',
341
+ 'SyntaxError',
342
+ 'IndentationError',
343
+ 'PermissionError',
344
+ ];
345
+
346
+ let errorLineIdx = -1;
347
+ for (let i = lines.length - 1; i >= 0; i--) {
348
+ const line = lines[i].trim();
349
+ if (
350
+ line &&
351
+ (errorTypes.some((et) => line.startsWith(et)) || line.includes('Error:'))
352
+ ) {
353
+ errorLineIdx = i;
354
+ break;
355
+ }
356
+ }
357
+
358
+ if (errorLineIdx === -1) {
359
+ return stderr.slice(-500).trim();
360
+ }
361
+
362
+ const errorLine = lines[errorLineIdx].trim();
363
+
364
+ if (errorLine.startsWith('AssertionError')) {
365
+ for (let i = errorLineIdx - 1; i >= Math.max(0, errorLineIdx - 10); i--) {
366
+ const line = lines[i].trim();
367
+ if (line.startsWith('assert ')) {
368
+ if (errorLine === 'AssertionError') {
369
+ return `AssertionError at: ${line}`;
370
+ }
371
+ return `${errorLine} at: ${line}`;
372
+ }
373
+ }
374
+
375
+ for (let i = errorLineIdx - 1; i >= Math.max(0, errorLineIdx - 5); i--) {
376
+ if (lines[i].includes('File ') && lines[i].includes(', line ')) {
377
+ if (i + 1 < errorLineIdx) {
378
+ const codeLine = lines[i + 1].trim();
379
+ if (errorLine === 'AssertionError') {
380
+ return `AssertionError at: ${codeLine}`;
381
+ }
382
+ return `${errorLine} at: ${codeLine}`;
383
+ }
384
+ break;
385
+ }
386
+ }
387
+ }
388
+
389
+ if (
390
+ errorLine.startsWith('AttributeError') ||
391
+ errorLine.startsWith('ImportError') ||
392
+ errorLine.startsWith('ModuleNotFoundError') ||
393
+ errorLine.startsWith('PermissionError')
394
+ ) {
395
+ return errorLine;
396
+ }
397
+
398
+ return errorLine || stderr.slice(-500).trim();
399
+ }
400
+
401
+ /**
402
+ * Run tests with security wrapper.
403
+ */
404
+ async function runTests(
405
+ userCode: string,
406
+ testCode: string,
407
+ entryPoint: string,
408
+ timeout: number
409
+ ): Promise<TestResult> {
410
+ const startTime = Date.now();
411
+ const tempDir = join(tmpdir(), 'quantum-sandbox');
412
+ const tempFile = join(tempDir, `test_${randomUUID()}.py`);
413
+
414
+ try {
415
+ await mkdir(tempDir, { recursive: true });
416
+
417
+ // Build secure executable code with security wrapper
418
+ const fullCode = buildSecureExecutableCode(userCode, testCode, entryPoint);
419
+ await writeFile(tempFile, fullCode, 'utf-8');
420
+
421
+ return await new Promise<TestResult>((resolve) => {
422
+ let stdout = '';
423
+ let stderr = '';
424
+ let killed = false;
425
+
426
+ const pythonPath = process.env.PYTHON_PATH || 'python3';
427
+
428
+ const pythonProcess = spawn(pythonPath, [tempFile], {
429
+ timeout: timeout * 1000,
430
+ env: getSafeEnv() as NodeJS.ProcessEnv,
431
+ cwd: tempDir,
432
+ });
433
+
434
+ pythonProcess.stdout.on('data', (data: Buffer) => {
435
+ stdout += data.toString();
436
+ });
437
+
438
+ pythonProcess.stderr.on('data', (data: Buffer) => {
439
+ stderr += data.toString();
440
+ });
441
+
442
+ const timeoutId = setTimeout(() => {
443
+ killed = true;
444
+ pythonProcess.kill('SIGKILL');
445
+ }, timeout * 1000);
446
+
447
+ pythonProcess.on('close', (code) => {
448
+ clearTimeout(timeoutId);
449
+ const executionTime = Date.now() - startTime;
450
+
451
+ if (killed) {
452
+ resolve({
453
+ passed: false,
454
+ total: 1,
455
+ failed: 1,
456
+ details: [
457
+ {
458
+ name: 'Execution',
459
+ passed: false,
460
+ error: `Execution timeout (>${timeout}s). Your code took too long to execute.`,
461
+ },
462
+ ],
463
+ executionTime,
464
+ error: `Execution timeout (>${timeout}s)`,
465
+ });
466
+ return;
467
+ }
468
+
469
+ const stdoutClean = stdout.trim();
470
+ const testPassed = code === 0 && stdoutClean.includes('TEST_PASSED');
471
+
472
+ if (testPassed) {
473
+ const outputBeforePass = stdoutClean
474
+ .replace('TEST_PASSED', '')
475
+ .trim();
476
+
477
+ resolve({
478
+ passed: true,
479
+ total: 1,
480
+ failed: 0,
481
+ details: [
482
+ {
483
+ name: 'All tests',
484
+ passed: true,
485
+ },
486
+ ],
487
+ executionTime,
488
+ output: outputBeforePass || undefined,
489
+ });
490
+ } else {
491
+ const cleanStderr = stderr
492
+ .split('\n')
493
+ .filter(
494
+ (line) =>
495
+ !line.includes('UserWarning') &&
496
+ !line.includes('DeprecationWarning') &&
497
+ !line.includes('FutureWarning') &&
498
+ !line.includes('from cryptography')
499
+ )
500
+ .join('\n')
501
+ .trim();
502
+
503
+ const errorMessage = extractErrorMessage(cleanStderr);
504
+ const fullTraceback = cleanStderr || stderr.trim();
505
+
506
+ resolve({
507
+ passed: false,
508
+ total: 1,
509
+ failed: 1,
510
+ details: [
511
+ {
512
+ name: 'Test execution',
513
+ passed: false,
514
+ error: errorMessage,
515
+ },
516
+ ],
517
+ executionTime,
518
+ error: errorMessage,
519
+ traceback: fullTraceback !== errorMessage ? fullTraceback : undefined,
520
+ output: stdoutClean || undefined,
521
+ });
522
+ }
523
+ });
524
+
525
+ pythonProcess.on('error', (err) => {
526
+ clearTimeout(timeoutId);
527
+ resolve({
528
+ passed: false,
529
+ total: 0,
530
+ failed: 0,
531
+ details: [],
532
+ executionTime: Date.now() - startTime,
533
+ error: `Failed to start Python: ${err.message}`,
534
+ });
535
+ });
536
+ });
537
+ } finally {
538
+ try {
539
+ await unlink(tempFile);
540
+ } catch {
541
+ // Ignore cleanup errors
542
+ }
543
+ }
544
+ }
545
+
546
+ export async function POST(request: NextRequest) {
547
+ try {
548
+ const body: TestRequestBody = await request.json();
549
+ const { userCode, testCode, entryPoint, timeout = 30 } = body;
550
+
551
+ if (!userCode || typeof userCode !== 'string') {
552
+ return new Response(
553
+ JSON.stringify({ error: 'Invalid request: userCode string required' }),
554
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
555
+ );
556
+ }
557
+
558
+ if (!testCode || typeof testCode !== 'string') {
559
+ return new Response(
560
+ JSON.stringify({ error: 'Invalid request: testCode string required' }),
561
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
562
+ );
563
+ }
564
+
565
+ // Limit code length
566
+ if (userCode.length > 50000 || testCode.length > 50000) {
567
+ return new Response(
568
+ JSON.stringify({ error: 'Code too long (max 50KB each)' }),
569
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
570
+ );
571
+ }
572
+
573
+ // Validate user code for dangerous patterns (first line of defense)
574
+ const userValidation = validateCode(userCode);
575
+ if (!userValidation.valid) {
576
+ return new Response(
577
+ JSON.stringify({
578
+ passed: false,
579
+ total: 0,
580
+ failed: 0,
581
+ details: [],
582
+ executionTime: 0,
583
+ error: userValidation.error,
584
+ } as TestResult),
585
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
586
+ );
587
+ }
588
+
589
+ const safeTimeout = Math.min(Math.max(timeout, 5), 60);
590
+ const result = await runTests(userCode, testCode, entryPoint, safeTimeout);
591
+
592
+ return new Response(JSON.stringify(result), {
593
+ headers: { 'Content-Type': 'application/json' },
594
+ });
595
+ } catch (error) {
596
+ console.error('Test API error:', error);
597
+
598
+ return new Response(
599
+ JSON.stringify({
600
+ passed: false,
601
+ total: 0,
602
+ failed: 0,
603
+ details: [],
604
+ executionTime: 0,
605
+ error: error instanceof Error ? error.message : 'Test execution failed',
606
+ } as TestResult),
607
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
608
+ );
609
+ }
610
+ }
src/app/api/warmup/route.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function POST(): Promise<NextResponse> {
4
+ const baseUrl = process.env.DEMO_MODEL_URL || 'http://localhost:8000/v1';
5
+ const apiKey = process.env.DEMO_API_KEY || '';
6
+ const modelName = process.env.DEMO_MODEL_NAME || 'default';
7
+
8
+ console.log('[Warmup] Starting warmup...');
9
+ console.log('[Warmup] Base URL:', baseUrl);
10
+
11
+ const runpodMatch = baseUrl.match(/https:\/\/api\.runpod\.ai\/v2\/([^/]+)/);
12
+
13
+ if (!runpodMatch) {
14
+ console.log('[Warmup] Not a RunPod endpoint, skipping');
15
+ return NextResponse.json({
16
+ status: 'skipped',
17
+ message: 'Not a RunPod endpoint',
18
+ });
19
+ }
20
+
21
+ const endpointId = runpodMatch[1];
22
+ console.log('[Warmup] Endpoint ID:', endpointId);
23
+
24
+ try {
25
+ const healthUrl = `https://api.runpod.ai/v2/${endpointId}/health`;
26
+ let healthData = null;
27
+
28
+ try {
29
+ const healthResponse = await fetch(healthUrl, {
30
+ method: 'GET',
31
+ headers: {
32
+ 'Authorization': `Bearer ${apiKey}`,
33
+ },
34
+ signal: AbortSignal.timeout(5000),
35
+ });
36
+
37
+ if (healthResponse.ok) {
38
+ healthData = await healthResponse.json();
39
+ console.log('[Warmup] Health:', JSON.stringify(healthData));
40
+
41
+ if (healthData.workers?.idle > 0) {
42
+ console.log('[Warmup] Idle workers available');
43
+ return NextResponse.json({
44
+ status: 'ready',
45
+ message: 'Workers already available',
46
+ workers: healthData.workers,
47
+ });
48
+ }
49
+
50
+ if (healthData.workers?.initializing > 0) {
51
+ console.log('[Warmup] Workers already initializing');
52
+ return NextResponse.json({
53
+ status: 'warming',
54
+ message: 'Workers already starting',
55
+ workers: healthData.workers,
56
+ });
57
+ }
58
+ }
59
+ } catch (e) {
60
+ console.log('[Warmup] Health check error:', e);
61
+ }
62
+
63
+ const openaiUrl = `${baseUrl}/chat/completions`;
64
+ console.log('[Warmup] Sending to OpenAI endpoint:', openaiUrl);
65
+
66
+ const abortController = new AbortController();
67
+ const timeoutId = setTimeout(() => abortController.abort(), 5000);
68
+
69
+ try {
70
+ const warmupResponse = await fetch(openaiUrl, {
71
+ method: 'POST',
72
+ headers: {
73
+ 'Authorization': `Bearer ${apiKey}`,
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify({
77
+ model: modelName,
78
+ messages: [{ role: 'user', content: 'hi' }],
79
+ max_tokens: 1,
80
+ stream: false,
81
+ }),
82
+ signal: abortController.signal,
83
+ });
84
+
85
+ clearTimeout(timeoutId);
86
+
87
+ console.log('[Warmup] Response status:', warmupResponse.status);
88
+
89
+ return NextResponse.json({
90
+ status: warmupResponse.status === 200 ? 'ready' : 'warming',
91
+ message: warmupResponse.status === 200
92
+ ? 'Model responded (was ready)'
93
+ : 'Request queued, worker starting',
94
+ httpStatus: warmupResponse.status,
95
+ workers: healthData?.workers,
96
+ });
97
+
98
+ } catch (fetchError) {
99
+ clearTimeout(timeoutId);
100
+
101
+ if ((fetchError as Error).name === 'AbortError') {
102
+ console.log('[Warmup] Request sent (aborted wait - worker starting)');
103
+ return NextResponse.json({
104
+ status: 'warming',
105
+ message: 'Request sent, worker starting',
106
+ workers: healthData?.workers,
107
+ });
108
+ }
109
+
110
+ throw fetchError;
111
+ }
112
+
113
+ } catch (error) {
114
+ console.error('[Warmup] Error:', error);
115
+ return NextResponse.json({
116
+ status: 'error',
117
+ message: error instanceof Error ? error.message : 'Warmup failed',
118
+ }, { status: 500 });
119
+ }
120
+ }
121
+
122
+ export async function GET(): Promise<NextResponse> {
123
+ const baseUrl = process.env.DEMO_MODEL_URL || 'http://localhost:8000/v1';
124
+ const apiKey = process.env.DEMO_API_KEY || '';
125
+
126
+ const runpodMatch = baseUrl.match(/https:\/\/api\.runpod\.ai\/v2\/([^/]+)/);
127
+
128
+ if (!runpodMatch) {
129
+ return NextResponse.json({
130
+ ready: true,
131
+ message: 'Not a RunPod endpoint'
132
+ });
133
+ }
134
+
135
+ const endpointId = runpodMatch[1];
136
+ const healthUrl = `https://api.runpod.ai/v2/${endpointId}/health`;
137
+
138
+ try {
139
+ const response = await fetch(healthUrl, {
140
+ method: 'GET',
141
+ headers: {
142
+ 'Authorization': `Bearer ${apiKey}`,
143
+ },
144
+ signal: AbortSignal.timeout(10000),
145
+ });
146
+
147
+ if (!response.ok) {
148
+ console.log('[Warmup GET] Health check failed:', response.status);
149
+ return NextResponse.json({ ready: false, message: 'Health check failed' });
150
+ }
151
+
152
+ const health = await response.json();
153
+ console.log('[Warmup GET] Health:', JSON.stringify(health));
154
+
155
+ const idleWorkers = health.workers?.idle || 0;
156
+ const readyWorkers = health.workers?.ready || 0;
157
+ const runningWorkers = health.workers?.running || 0;
158
+ const initializingWorkers = health.workers?.initializing || 0;
159
+ const throttledWorkers = health.workers?.throttled || 0;
160
+
161
+ const isReady = idleWorkers > 0 || readyWorkers > 0;
162
+ const isWarming = initializingWorkers > 0;
163
+ const isBusy = runningWorkers > 0 && !isReady;
164
+ const jobsInQueue = health.jobs?.inQueue || 0;
165
+ const jobsInProgress = health.jobs?.inProgress || 0;
166
+
167
+ return NextResponse.json({
168
+ ready: isReady,
169
+ warming: isWarming,
170
+ busy: isBusy,
171
+ jobsInQueue,
172
+ jobsInProgress,
173
+ workers: {
174
+ idle: idleWorkers,
175
+ ready: readyWorkers,
176
+ running: runningWorkers,
177
+ initializing: initializingWorkers,
178
+ throttled: throttledWorkers,
179
+ },
180
+ });
181
+ } catch (error) {
182
+ const isTimeout = error instanceof Error && error.name === 'TimeoutError';
183
+ if (!isTimeout) {
184
+ console.error('[Warmup GET] Error:', error);
185
+ }
186
+ return NextResponse.json({
187
+ ready: false,
188
+ warming: true,
189
+ message: isTimeout ? 'Health check timed out' : 'Check failed'
190
+ });
191
+ }
192
+ }
src/app/globals.css ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');
6
+
7
+ :root {
8
+ --font-sans: 'Inter', system-ui, sans-serif;
9
+ --font-mono: 'JetBrains Mono', monospace;
10
+ }
11
+
12
+ @layer base {
13
+ * {
14
+ @apply border-zinc-800;
15
+ }
16
+
17
+ body {
18
+ @apply bg-zinc-950 text-zinc-100 antialiased;
19
+ font-family: var(--font-sans);
20
+ }
21
+
22
+ ::selection {
23
+ @apply bg-teal-600/40 text-white;
24
+ }
25
+
26
+ ::-webkit-scrollbar {
27
+ @apply w-2 h-2;
28
+ }
29
+
30
+ ::-webkit-scrollbar-track {
31
+ @apply bg-transparent;
32
+ }
33
+
34
+ ::-webkit-scrollbar-thumb {
35
+ @apply bg-zinc-700 rounded-full;
36
+ }
37
+
38
+ ::-webkit-scrollbar-thumb:hover {
39
+ @apply bg-zinc-600;
40
+ }
41
+ }
42
+
43
+ @layer utilities {
44
+ .animate-in {
45
+ animation: animateIn 0.3s ease-out forwards;
46
+ }
47
+
48
+ @keyframes animateIn {
49
+ from {
50
+ opacity: 0;
51
+ transform: translateY(10px);
52
+ }
53
+
54
+ to {
55
+ opacity: 1;
56
+ transform: translateY(0);
57
+ }
58
+ }
59
+
60
+ .typing-indicator span {
61
+ @apply inline-block w-2 h-2 bg-teal-500 rounded-full mx-0.5;
62
+ animation: typing 1.4s infinite ease-in-out both;
63
+ }
64
+
65
+ .typing-indicator span:nth-child(1) {
66
+ animation-delay: -0.32s;
67
+ }
68
+
69
+ .typing-indicator span:nth-child(2) {
70
+ animation-delay: -0.16s;
71
+ }
72
+
73
+ .typing-indicator span:nth-child(3) {
74
+ animation-delay: 0s;
75
+ }
76
+
77
+ @keyframes typing {
78
+
79
+ 0%,
80
+ 80%,
81
+ 100% {
82
+ transform: scale(0.8);
83
+ opacity: 0.5;
84
+ }
85
+
86
+ 40% {
87
+ transform: scale(1);
88
+ opacity: 1;
89
+ }
90
+ }
91
+ }
92
+
93
+ /* Code block wrapper - clean backgrounds */
94
+ .code-block-wrapper pre,
95
+ .code-block-wrapper code,
96
+ .code-block-wrapper span {
97
+ background: transparent !important;
98
+ background-color: transparent !important;
99
+ box-shadow: none !important;
100
+ text-shadow: none !important;
101
+ }
102
+
103
+ .code-block-wrapper pre>div {
104
+ background: #18181b !important;
105
+ }
106
+
107
+ .code-block-wrapper .token {
108
+ background: transparent !important;
109
+ }
110
+
111
+ .code-block-wrapper pre code span {
112
+ display: inline;
113
+ padding: 0;
114
+ margin: 0;
115
+ border: none;
116
+ outline: none;
117
+ }
118
+
119
+ /* Markdown styling */
120
+ .markdown-content {
121
+ @apply leading-relaxed;
122
+ }
123
+
124
+ .markdown-content h1,
125
+ .markdown-content h2,
126
+ .markdown-content h3 {
127
+ @apply font-semibold text-zinc-100 mt-6 mb-3;
128
+ }
129
+
130
+ .markdown-content h1 {
131
+ @apply text-2xl;
132
+ }
133
+
134
+ .markdown-content h2 {
135
+ @apply text-xl;
136
+ }
137
+
138
+ .markdown-content h3 {
139
+ @apply text-lg;
140
+ }
141
+
142
+ .markdown-content p {
143
+ @apply mb-4 text-zinc-300;
144
+ }
145
+
146
+ .markdown-content ul,
147
+ .markdown-content ol {
148
+ @apply mb-4 pl-6 text-zinc-300;
149
+ }
150
+
151
+ .markdown-content ul {
152
+ @apply list-disc;
153
+ }
154
+
155
+ .markdown-content ol {
156
+ @apply list-decimal;
157
+ }
158
+
159
+ .markdown-content li {
160
+ @apply mb-1;
161
+ }
162
+
163
+ .markdown-content code:not(pre code) {
164
+ @apply bg-zinc-800 px-1.5 py-0.5 rounded text-teal-300 text-sm font-mono;
165
+ }
166
+
167
+ .markdown-content pre {
168
+ @apply mb-4 !bg-zinc-900 rounded-lg overflow-x-auto;
169
+ }
170
+
171
+ .markdown-content a {
172
+ @apply text-teal-400 hover:text-teal-300 underline underline-offset-2;
173
+ }
174
+
175
+ .markdown-content blockquote {
176
+ @apply border-l-4 border-teal-600/50 pl-4 italic text-zinc-400 my-4;
177
+ }
178
+
179
+ .markdown-content hr {
180
+ @apply border-zinc-800 my-6;
181
+ }
182
+
183
+ .markdown-content table {
184
+ @apply w-full border-collapse mb-4;
185
+ }
186
+
187
+ .markdown-content th,
188
+ .markdown-content td {
189
+ @apply border border-zinc-700 px-3 py-2 text-left;
190
+ }
191
+
192
+ .markdown-content th {
193
+ @apply bg-zinc-800 font-medium;
194
+ }
195
+
196
+ /* KaTeX math styling for dark theme */
197
+ .markdown-content .katex {
198
+ @apply text-zinc-100;
199
+ font-size: 1.1em;
200
+ }
201
+
202
+ .markdown-content .katex-display {
203
+ @apply my-4 overflow-x-auto overflow-y-hidden py-2;
204
+ }
205
+
206
+ .markdown-content .katex-display>.katex {
207
+ @apply text-zinc-100;
208
+ }
209
+
210
+ /* Inline math styling */
211
+ .markdown-content .math-inline {
212
+ @apply text-zinc-100;
213
+ }
214
+
215
+ /* Display/block math styling */
216
+ .markdown-content .math-display {
217
+ @apply bg-zinc-800/50 rounded-lg px-4 py-3 my-4;
218
+ }
219
+
220
+ /* KaTeX color overrides for dark theme */
221
+ .katex .mord,
222
+ .katex .mbin,
223
+ .katex .mrel,
224
+ .katex .mopen,
225
+ .katex .mclose,
226
+ .katex .mpunct,
227
+ .katex .minner {
228
+ color: inherit;
229
+ }
230
+
231
+ .katex .mathnormal {
232
+ color: #93c5fd;
233
+ /* blue-300 for variables */
234
+ }
235
+
236
+ .katex .text {
237
+ color: #d4d4d8;
238
+ /* zinc-300 */
239
+ }
240
+
241
+ .katex .mop {
242
+ color: #c4b5fd;
243
+ /* violet-300 for operators like sum, integral */
244
+ }
245
+
246
+ .katex .sqrt>.root {
247
+ color: #86efac;
248
+ /* emerald-300 */
249
+ }
250
+
251
+ .katex-html {
252
+ color: #e4e4e7;
253
+ /* zinc-200 */
254
+ }
255
+
256
+ /* Execution result styling */
257
+ .execution-output {
258
+ @apply font-mono text-sm;
259
+ }
260
+
261
+ .execution-output pre {
262
+ @apply whitespace-pre-wrap break-words;
263
+ }
264
+
265
+ /* Circuit ASCII art preservation */
266
+ .circuit-output {
267
+ @apply font-mono text-xs leading-tight tracking-tight;
268
+ font-feature-settings: "liga" 0;
269
+ }
270
+
271
+ /* Pulse animation for loading states */
272
+ @keyframes executePulse {
273
+
274
+ 0%,
275
+ 100% {
276
+ opacity: 1;
277
+ }
278
+
279
+ 50% {
280
+ opacity: 0.5;
281
+ }
282
+ }
283
+
284
+ .execution-loading {
285
+ animation: executePulse 1.5s ease-in-out infinite;
286
+ }
287
+
288
+ /* Run button glow effect */
289
+ .run-button-glow {
290
+ box-shadow: 0 0 12px rgba(20, 184, 166, 0.3);
291
+ }
292
+
293
+ .run-button-glow:hover {
294
+ box-shadow: 0 0 20px rgba(20, 184, 166, 0.5);
295
+ }
296
+
297
+ /* Practice mode styles */
298
+ .practice-layout {
299
+ @apply flex h-full;
300
+ }
301
+
302
+ /* Monaco editor container */
303
+ .monaco-container {
304
+ @apply h-full w-full;
305
+ }
306
+
307
+ .monaco-container .monaco-editor {
308
+ @apply rounded-none;
309
+ }
310
+
311
+ /* Problem panel transition */
312
+ .problem-panel-transition {
313
+ transition: width 200ms ease-out;
314
+ }
315
+
316
+ /* Drag handle indicator */
317
+ .drag-handle-active {
318
+ @apply bg-teal-500/70;
319
+ }
320
+
321
+ /* Test result animations */
322
+ @keyframes slideIn {
323
+ from {
324
+ opacity: 0;
325
+ transform: translateY(-8px);
326
+ }
327
+ to {
328
+ opacity: 1;
329
+ transform: translateY(0);
330
+ }
331
+ }
332
+
333
+ .test-result-animate {
334
+ animation: slideIn 0.2s ease-out forwards;
335
+ }
336
+
337
+ /* AI Helper chat bubble */
338
+ .ai-bubble {
339
+ @apply relative;
340
+ }
341
+
342
+ .ai-bubble::before {
343
+ content: '';
344
+ @apply absolute w-2 h-2 bg-zinc-800/80 rotate-45;
345
+ left: -4px;
346
+ top: 12px;
347
+ }
348
+
349
+ /* Code completion success glow */
350
+ .success-glow {
351
+ box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
352
+ }
353
+
354
+ /* Scrollbar customization for panels */
355
+ .panel-scrollbar::-webkit-scrollbar {
356
+ @apply w-1.5;
357
+ }
358
+
359
+ .panel-scrollbar::-webkit-scrollbar-track {
360
+ @apply bg-transparent;
361
+ }
362
+
363
+ .panel-scrollbar::-webkit-scrollbar-thumb {
364
+ @apply bg-zinc-700/50 rounded-full;
365
+ }
366
+
367
+ .panel-scrollbar::-webkit-scrollbar-thumb:hover {
368
+ @apply bg-zinc-600/50;
369
+ }
370
+
371
+ /* Mode toggle active state */
372
+ .mode-toggle-active {
373
+ @apply bg-teal-600 text-white shadow-sm;
374
+ }
375
+
376
+ /* Problem card hover effect */
377
+ .problem-card-hover {
378
+ @apply hover:border-teal-700/40 hover:bg-zinc-800/80;
379
+ }
380
+
381
+ /* Solved problem indicator */
382
+ .solved-indicator {
383
+ @apply text-emerald-500;
384
+ }
385
+
386
+ /* Resize cursor override */
387
+ .resizing-active * {
388
+ cursor: col-resize !important;
389
+ user-select: none !important;
390
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import { PROJECT_CONFIG } from '@/config/constants';
3
+ import { DatasetProvider } from '@/lib/dataset/DatasetProvider';
4
+ import 'katex/dist/katex.min.css';
5
+ import './globals.css';
6
+
7
+ export const metadata: Metadata = {
8
+ title: `${PROJECT_CONFIG.name} | Demo`,
9
+ description: PROJECT_CONFIG.description,
10
+ authors: [{ name: PROJECT_CONFIG.author }],
11
+ keywords: [
12
+ 'quantum computing',
13
+ 'qiskit',
14
+ 'vision language model',
15
+ 'code generation',
16
+ 'multimodal AI',
17
+ ],
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: {
23
+ children: React.ReactNode;
24
+ }) {
25
+ return (
26
+ <html lang="en" className="dark">
27
+ <body className="min-h-screen bg-zinc-950">
28
+ <DatasetProvider initialSplits={['test', 'validation']}>
29
+ <div className="relative z-10">{children}</div>
30
+ </DatasetProvider>
31
+ </body>
32
+ </html>
33
+ );
34
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
+ import { PanelRightOpen, PanelRightClose, ChevronRight, ChevronLeft, MessageSquare, Code } from 'lucide-react';
5
+ import { clsx } from 'clsx';
6
+ import { Header, ChatInterface, ExamplesPanel, PracticeInterface } from '@/components';
7
+ import { PROJECT_CONFIG } from '@/config/constants';
8
+ import type { DatasetExample, AppMode } from '@/types';
9
+
10
+ export default function HomePage() {
11
+ const [selectedExample, setSelectedExample] = useState<DatasetExample | null>(null);
12
+ const [isPanelOpen, setIsPanelOpen] = useState(true);
13
+ const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
14
+ const [panelWidth, setPanelWidth] = useState(320);
15
+ const [mode, setMode] = useState<AppMode>('chat');
16
+ const [isDragging, setIsDragging] = useState(false);
17
+ const startXRef = useRef(0);
18
+ const startWidthRef = useRef(0);
19
+
20
+ const handleSelectExample = useCallback((example: DatasetExample) => {
21
+ setSelectedExample(example);
22
+ }, []);
23
+
24
+ const handleExampleUsed = useCallback(() => {
25
+ setSelectedExample(null);
26
+ }, []);
27
+
28
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
29
+ e.preventDefault();
30
+ setIsDragging(true);
31
+ startXRef.current = e.clientX;
32
+ startWidthRef.current = panelWidth;
33
+ }, [panelWidth]);
34
+
35
+ useEffect(() => {
36
+ const handleMouseMove = (e: MouseEvent) => {
37
+ if (!isDragging) return;
38
+ const diff = startXRef.current - e.clientX;
39
+ const newWidth = Math.min(500, Math.max(240, startWidthRef.current + diff));
40
+ setPanelWidth(newWidth);
41
+ };
42
+
43
+ const handleMouseUp = () => {
44
+ if (isDragging) {
45
+ setIsDragging(false);
46
+ localStorage.setItem('examplesPanelWidth', panelWidth.toString());
47
+ }
48
+ };
49
+
50
+ if (isDragging) {
51
+ document.addEventListener('mousemove', handleMouseMove);
52
+ document.addEventListener('mouseup', handleMouseUp);
53
+ document.body.style.cursor = 'col-resize';
54
+ document.body.style.userSelect = 'none';
55
+ }
56
+
57
+ return () => {
58
+ document.removeEventListener('mousemove', handleMouseMove);
59
+ document.removeEventListener('mouseup', handleMouseUp);
60
+ document.body.style.cursor = '';
61
+ document.body.style.userSelect = '';
62
+ };
63
+ }, [isDragging, panelWidth]);
64
+
65
+ useEffect(() => {
66
+ if (typeof window !== 'undefined') {
67
+ const stored = localStorage.getItem('examplesPanelWidth');
68
+ if (stored) {
69
+ const parsed = parseInt(stored, 10);
70
+ if (!isNaN(parsed) && parsed >= 240 && parsed <= 500) {
71
+ setPanelWidth(parsed);
72
+ }
73
+ }
74
+ }
75
+ }, []);
76
+
77
+ const currentWidth = isPanelCollapsed ? 48 : panelWidth;
78
+
79
+ return (
80
+ <div className="min-h-screen flex flex-col bg-zinc-950">
81
+ <Header mode={mode} onModeChange={setMode} />
82
+
83
+ <main className="flex-1 flex overflow-hidden">
84
+ {mode === 'chat' ? (
85
+ <>
86
+ <div
87
+ className={clsx(
88
+ 'flex-1 flex flex-col',
89
+ 'transition-[margin]',
90
+ isDragging ? 'duration-0' : 'duration-300',
91
+ isPanelOpen && !isPanelCollapsed ? '' : '',
92
+ isPanelOpen ? '' : ''
93
+ )}
94
+ style={{
95
+ marginRight: isPanelOpen ? currentWidth : 0,
96
+ }}
97
+ >
98
+ <div className="flex items-center justify-end px-4 py-2 border-b border-zinc-800/80 lg:hidden">
99
+ <button
100
+ onClick={() => setIsPanelOpen(!isPanelOpen)}
101
+ className="p-2 rounded-md hover:bg-zinc-800/50 transition-colors"
102
+ >
103
+ {isPanelOpen ? (
104
+ <PanelRightClose className="w-5 h-5 text-zinc-500" />
105
+ ) : (
106
+ <PanelRightOpen className="w-5 h-5 text-zinc-500" />
107
+ )}
108
+ </button>
109
+ </div>
110
+
111
+ <div className="flex-1 overflow-hidden">
112
+ <ChatInterface
113
+ selectedExample={selectedExample}
114
+ onExampleUsed={handleExampleUsed}
115
+ />
116
+ </div>
117
+ </div>
118
+
119
+ <aside
120
+ className={clsx(
121
+ 'fixed right-0 top-[57px] bottom-0 bg-zinc-900/95 backdrop-blur-sm border-l border-zinc-800/80',
122
+ 'transform z-40',
123
+ 'transition-[transform,width]',
124
+ isDragging ? 'duration-0' : 'duration-300',
125
+ 'lg:translate-x-0',
126
+ isPanelOpen ? 'translate-x-0' : 'translate-x-full'
127
+ )}
128
+ style={{ width: currentWidth }}
129
+ >
130
+ {/* Resize handle */}
131
+ {!isPanelCollapsed && (
132
+ <div
133
+ onMouseDown={handleMouseDown}
134
+ className={clsx(
135
+ 'absolute top-0 bottom-0 -left-0.5 w-1 cursor-col-resize z-50',
136
+ 'hover:bg-teal-500/50 transition-colors',
137
+ isDragging && 'bg-teal-500/70'
138
+ )}
139
+ >
140
+ <div
141
+ className={clsx(
142
+ 'absolute top-1/2 -translate-y-1/2 w-4 h-16 -left-1.5',
143
+ 'flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity',
144
+ isDragging && 'opacity-100'
145
+ )}
146
+ >
147
+ <div className="w-1 h-8 rounded-full bg-zinc-600 flex flex-col items-center justify-center gap-1">
148
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
149
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
150
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
151
+ </div>
152
+ </div>
153
+ </div>
154
+ )}
155
+
156
+ <button
157
+ onClick={() => setIsPanelCollapsed(!isPanelCollapsed)}
158
+ className="hidden lg:flex absolute -left-3 top-4 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700/50 items-center justify-center hover:bg-zinc-700 transition-colors z-50"
159
+ title={isPanelCollapsed ? 'Expand panel' : 'Collapse panel'}
160
+ >
161
+ {isPanelCollapsed ? (
162
+ <ChevronLeft className="w-4 h-4 text-zinc-400" />
163
+ ) : (
164
+ <ChevronRight className="w-4 h-4 text-zinc-400" />
165
+ )}
166
+ </button>
167
+
168
+ {isPanelCollapsed ? (
169
+ <div className="h-full flex flex-col items-center pt-8">
170
+ <button
171
+ onClick={() => setIsPanelCollapsed(false)}
172
+ className="p-2 rounded-md hover:bg-zinc-800/50 transition-colors"
173
+ title="Expand examples"
174
+ >
175
+ <span className="text-xs text-zinc-500 [writing-mode:vertical-lr] rotate-180 font-medium">
176
+ Test Examples
177
+ </span>
178
+ </button>
179
+ </div>
180
+ ) : (
181
+ <ExamplesPanel onSelectExample={handleSelectExample} />
182
+ )}
183
+ </aside>
184
+
185
+ {isPanelOpen && (
186
+ <div
187
+ className="fixed inset-0 bg-black/50 z-30 lg:hidden"
188
+ onClick={() => setIsPanelOpen(false)}
189
+ />
190
+ )}
191
+ </>
192
+ ) : (
193
+ <PracticeInterface className="flex-1" />
194
+ )}
195
+ </main>
196
+
197
+ <footer className="bg-zinc-900/95 border-t border-zinc-800/80 py-3 px-4 text-center text-xs text-zinc-500">
198
+ <p>
199
+ {PROJECT_CONFIG.name} - {PROJECT_CONFIG.year} |{' '}
200
+ <span>{PROJECT_CONFIG.institution}</span> |{' '}
201
+ Apache 2.0 License
202
+ </p>
203
+ </footer>
204
+ </div>
205
+ );
206
+ }
src/components/Chat/ChatInterface.tsx ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react';
4
+ import { Trash2 } from 'lucide-react';
5
+ import { Message } from './Message';
6
+ import { MessageInput, MessageInputRef } from './MessageInput';
7
+ import { QubitIcon } from './QubitIcon';
8
+ import { LoadingStatus } from './LoadingStatus';
9
+ import { SYSTEM_PROMPT } from '@/config/constants';
10
+ import { resizeImageForInference, fetchAndResizeImage } from '@/lib/utils/image';
11
+ import { postProcessResponse } from '@/lib/utils/response';
12
+ import type { Message as MessageType, DatasetExample } from '@/types';
13
+
14
+ interface ChatInterfaceProps {
15
+ selectedExample?: DatasetExample | null;
16
+ onExampleUsed?: () => void;
17
+ }
18
+
19
+ export function ChatInterface({ selectedExample, onExampleUsed }: ChatInterfaceProps) {
20
+ const [messages, setMessages] = useState<MessageType[]>([]);
21
+ const [isLoading, setIsLoading] = useState(false);
22
+ const [hasStartedStreaming, setHasStartedStreaming] = useState(false);
23
+ const messagesEndRef = useRef<HTMLDivElement>(null);
24
+ const inputRef = useRef<MessageInputRef>(null);
25
+ const processedExampleRef = useRef<string | null>(null);
26
+ const abortControllerRef = useRef<AbortController | null>(null);
27
+
28
+ const scrollToBottom = useCallback(() => {
29
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ scrollToBottom();
34
+ }, [messages, scrollToBottom]);
35
+
36
+ useEffect(() => {
37
+ if (
38
+ selectedExample &&
39
+ selectedExample.id !== processedExampleRef.current
40
+ ) {
41
+ processedExampleRef.current = selectedExample.id;
42
+ inputRef.current?.setContent(
43
+ selectedExample.question,
44
+ selectedExample.imageUrl
45
+ );
46
+ onExampleUsed?.();
47
+ }
48
+ }, [selectedExample, onExampleUsed]);
49
+
50
+ const handleSendMessage = async (
51
+ content: string,
52
+ imageUrl?: string,
53
+ imageBase64?: string
54
+ ) => {
55
+ if (!content.trim() && !imageUrl && !imageBase64) return;
56
+
57
+ if (abortControllerRef.current) {
58
+ abortControllerRef.current.abort();
59
+ }
60
+ abortControllerRef.current = new AbortController();
61
+
62
+ const userMessage: MessageType = {
63
+ id: crypto.randomUUID(),
64
+ role: 'user',
65
+ content,
66
+ imageUrl,
67
+ imageBase64,
68
+ timestamp: new Date(),
69
+ };
70
+
71
+ const assistantMessageId = crypto.randomUUID();
72
+ const loadingMessage: MessageType = {
73
+ id: assistantMessageId,
74
+ role: 'assistant',
75
+ content: '',
76
+ timestamp: new Date(),
77
+ isLoading: true,
78
+ };
79
+
80
+ setMessages((prev) => [...prev, userMessage, loadingMessage]);
81
+ setIsLoading(true);
82
+ setHasStartedStreaming(false);
83
+
84
+ try {
85
+ let imageData: string | undefined;
86
+
87
+ if (imageBase64) {
88
+ try {
89
+ imageData = await resizeImageForInference(`data:image/jpeg;base64,${imageBase64}`);
90
+ } catch (e) {
91
+ console.error('Failed to resize image:', e);
92
+ imageData = imageBase64;
93
+ }
94
+ } else if (imageUrl) {
95
+ try {
96
+ imageData = await fetchAndResizeImage(imageUrl);
97
+ } catch (e) {
98
+ console.error('Failed to fetch and resize image:', e);
99
+ }
100
+ }
101
+
102
+ const userContent = imageData
103
+ ? [
104
+ { type: 'text', text: content },
105
+ { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageData}` } },
106
+ ]
107
+ : content;
108
+
109
+ const response = await fetch('/api/chat', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({
113
+ messages: [
114
+ { role: 'system', content: SYSTEM_PROMPT },
115
+ ...messages.map((m) => ({
116
+ role: m.role,
117
+ content: m.content,
118
+ })),
119
+ { role: 'user', content: userContent },
120
+ ],
121
+ stream: true,
122
+ }),
123
+ signal: abortControllerRef.current.signal,
124
+ });
125
+
126
+ if (!response.ok) {
127
+ const data = await response.json();
128
+ throw new Error(data.error || 'Request failed');
129
+ }
130
+
131
+ const reader = response.body?.getReader();
132
+ if (!reader) {
133
+ throw new Error('No response body');
134
+ }
135
+
136
+ const decoder = new TextDecoder();
137
+ let buffer = '';
138
+ let fullContent = '';
139
+
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+
144
+ buffer += decoder.decode(value, { stream: true });
145
+ const lines = buffer.split('\n');
146
+ buffer = lines.pop() || '';
147
+
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
151
+
152
+ const jsonStr = trimmed.slice(6);
153
+ try {
154
+ const data = JSON.parse(jsonStr);
155
+
156
+ if (data.error) {
157
+ throw new Error(data.error);
158
+ }
159
+
160
+ if (data.content) {
161
+ // First content received - streaming has started
162
+ if (fullContent === '') {
163
+ setHasStartedStreaming(true);
164
+ setMessages((prev) =>
165
+ prev.map((m) =>
166
+ m.id === assistantMessageId ? { ...m, isLoading: false } : m
167
+ )
168
+ );
169
+ }
170
+
171
+ fullContent += data.content;
172
+ const processedContent = postProcessResponse(fullContent);
173
+ setMessages((prev) =>
174
+ prev.map((m) =>
175
+ m.id === assistantMessageId
176
+ ? { ...m, content: processedContent }
177
+ : m
178
+ )
179
+ );
180
+ }
181
+ } catch (e) {
182
+ if (e instanceof SyntaxError) continue;
183
+ throw e;
184
+ }
185
+ }
186
+ }
187
+
188
+ const finalContent = postProcessResponse(fullContent);
189
+ setMessages((prev) =>
190
+ prev.map((m) =>
191
+ m.id === assistantMessageId
192
+ ? { ...m, content: finalContent }
193
+ : m
194
+ )
195
+ );
196
+ } catch (error) {
197
+ if ((error as Error).name === 'AbortError') {
198
+ return;
199
+ }
200
+
201
+ setMessages((prev) =>
202
+ prev.map((m) =>
203
+ m.id === assistantMessageId
204
+ ? {
205
+ ...m,
206
+ content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`,
207
+ isLoading: false,
208
+ }
209
+ : m
210
+ )
211
+ );
212
+ } finally {
213
+ setIsLoading(false);
214
+ setHasStartedStreaming(false);
215
+ abortControllerRef.current = null;
216
+ }
217
+ };
218
+
219
+ const handleClearChat = () => {
220
+ if (abortControllerRef.current) {
221
+ abortControllerRef.current.abort();
222
+ }
223
+ setMessages([]);
224
+ inputRef.current?.clear();
225
+ processedExampleRef.current = null;
226
+ };
227
+
228
+ const handleCopyCode = (code: string) => {
229
+ console.log('Code copied:', code.substring(0, 50) + '...');
230
+ };
231
+
232
+ return (
233
+ <div className="flex flex-col h-full">
234
+ {messages.length > 0 && (
235
+ <div className="flex justify-end px-4 pt-2">
236
+ <button
237
+ onClick={handleClearChat}
238
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500
239
+ hover:text-zinc-300 hover:bg-zinc-800/50 rounded-md transition-colors"
240
+ >
241
+ <Trash2 className="w-3.5 h-3.5" />
242
+ Clear chat
243
+ </button>
244
+ </div>
245
+ )}
246
+
247
+ <div className="flex-1 overflow-y-auto p-4 space-y-6">
248
+ {messages.length === 0 ? (
249
+ <div className="flex flex-col items-center justify-center h-full text-center px-4">
250
+ <div className="w-16 h-16 mb-5 rounded-xl bg-zinc-800/80 border border-teal-700/30 flex items-center justify-center">
251
+ <QubitIcon size={32} className="text-teal-400" />
252
+ </div>
253
+ <h2 className="text-xl font-semibold text-zinc-200 mb-2">
254
+ Quantum Assistant
255
+ </h2>
256
+ <p className="text-zinc-500 max-w-md mb-8 text-sm leading-relaxed">
257
+ Ask questions about quantum computing, generate Qiskit code, or upload circuit diagrams for analysis.
258
+ </p>
259
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-xl">
260
+ {[
261
+ { label: 'Circuits', text: 'Create a Bell state circuit' },
262
+ { label: 'Concepts', text: 'Explain Bloch sphere representation' },
263
+ { label: 'Algorithms', text: 'Implement VQE algorithm' },
264
+ ].map((suggestion, i) => (
265
+ <button
266
+ key={i}
267
+ onClick={() => inputRef.current?.setContent(suggestion.text)}
268
+ className="bg-zinc-800/60 hover:bg-zinc-800 border border-zinc-700/50 hover:border-zinc-600/50 rounded-lg p-4 text-left group transition-all"
269
+ >
270
+ <span className="text-[10px] font-mono text-teal-500/80 mb-2 block uppercase tracking-wider">
271
+ {suggestion.label}
272
+ </span>
273
+ <span className="text-sm text-zinc-400 group-hover:text-zinc-200 transition-colors">
274
+ {suggestion.text}
275
+ </span>
276
+ </button>
277
+ ))}
278
+ </div>
279
+ </div>
280
+ ) : (
281
+ messages.map((message) => (
282
+ <Message
283
+ key={message.id}
284
+ message={message}
285
+ onCopyCode={handleCopyCode}
286
+ loadingStatus={
287
+ message.isLoading ? (
288
+ <LoadingStatus
289
+ isLoading={isLoading}
290
+ hasStartedStreaming={hasStartedStreaming}
291
+ />
292
+ ) : undefined
293
+ }
294
+ />
295
+ ))
296
+ )}
297
+ <div ref={messagesEndRef} />
298
+ </div>
299
+
300
+ <div className="p-4 border-t border-zinc-800/80">
301
+ <MessageInput ref={inputRef} onSend={handleSendMessage} isLoading={isLoading} />
302
+ </div>
303
+ </div>
304
+ );
305
+ }
src/components/Chat/ExecutionResult.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import {
5
+ CheckCircle2,
6
+ XCircle,
7
+ Clock,
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Terminal,
11
+ AlertTriangle,
12
+ Copy,
13
+ Check,
14
+ Image as ImageIcon,
15
+ Download,
16
+ ZoomIn,
17
+ } from 'lucide-react';
18
+ import { clsx } from 'clsx';
19
+
20
+ export interface ExecutionResultData {
21
+ success: boolean;
22
+ output: string;
23
+ error: string;
24
+ executionTime: number;
25
+ hasCircuitOutput?: boolean;
26
+ images?: string[]; // Base64 encoded images
27
+ }
28
+
29
+ interface ExecutionResultProps {
30
+ result: ExecutionResultData;
31
+ isLoading?: boolean;
32
+ }
33
+
34
+ function ImageViewer({ images }: { images: string[] }) {
35
+ const [selectedImage, setSelectedImage] = useState<number | null>(null);
36
+
37
+ const handleDownload = (base64: string, index: number) => {
38
+ const link = document.createElement('a');
39
+ link.href = `data:image/png;base64,${base64}`;
40
+ link.download = `quantum_output_${index + 1}.png`;
41
+ link.click();
42
+ };
43
+
44
+ return (
45
+ <div className="space-y-3">
46
+ <div className="flex items-center gap-2 text-xs text-zinc-500 mb-2">
47
+ <ImageIcon className="w-3.5 h-3.5" />
48
+ <span>Generated Figures ({images.length})</span>
49
+ </div>
50
+
51
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
52
+ {images.map((base64, idx) => (
53
+ <div
54
+ key={idx}
55
+ className="relative group rounded-lg overflow-hidden border border-zinc-700/50 bg-zinc-900"
56
+ >
57
+ <img
58
+ src={`data:image/png;base64,${base64}`}
59
+ alt={`Output figure ${idx + 1}`}
60
+ className="w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
61
+ onClick={() => setSelectedImage(idx)}
62
+ />
63
+ <div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
64
+ <button
65
+ onClick={() => setSelectedImage(idx)}
66
+ className="p-1.5 rounded bg-zinc-800/90 hover:bg-zinc-700 transition-colors"
67
+ title="View full size"
68
+ >
69
+ <ZoomIn className="w-3.5 h-3.5 text-zinc-300" />
70
+ </button>
71
+ <button
72
+ onClick={() => handleDownload(base64, idx)}
73
+ className="p-1.5 rounded bg-zinc-800/90 hover:bg-zinc-700 transition-colors"
74
+ title="Download image"
75
+ >
76
+ <Download className="w-3.5 h-3.5 text-zinc-300" />
77
+ </button>
78
+ </div>
79
+ </div>
80
+ ))}
81
+ </div>
82
+
83
+ {/* Full-size image modal */}
84
+ {selectedImage !== null && (
85
+ <div
86
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
87
+ onClick={() => setSelectedImage(null)}
88
+ >
89
+ <div className="relative max-w-4xl max-h-[90vh] overflow-auto">
90
+ <img
91
+ src={`data:image/png;base64,${images[selectedImage]}`}
92
+ alt={`Output figure ${selectedImage + 1}`}
93
+ className="max-w-full h-auto rounded-lg"
94
+ onClick={(e) => e.stopPropagation()}
95
+ />
96
+ <div className="absolute top-2 right-2 flex gap-2">
97
+ <button
98
+ onClick={(e) => {
99
+ e.stopPropagation();
100
+ handleDownload(images[selectedImage], selectedImage);
101
+ }}
102
+ className="p-2 rounded-lg bg-zinc-800/90 hover:bg-zinc-700 transition-colors"
103
+ title="Download image"
104
+ >
105
+ <Download className="w-4 h-4 text-zinc-300" />
106
+ </button>
107
+ <button
108
+ onClick={() => setSelectedImage(null)}
109
+ className="p-2 rounded-lg bg-zinc-800/90 hover:bg-zinc-700 transition-colors"
110
+ title="Close"
111
+ >
112
+ <XCircle className="w-4 h-4 text-zinc-300" />
113
+ </button>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export function ExecutionResult({ result, isLoading }: ExecutionResultProps) {
123
+ const [isExpanded, setIsExpanded] = useState(true);
124
+ const [copied, setCopied] = useState(false);
125
+
126
+ const hasOutput = result.output.trim().length > 0;
127
+ const hasError = result.error.trim().length > 0;
128
+ const hasImages = result.images && result.images.length > 0;
129
+ const outputToShow = hasError ? result.error : result.output;
130
+
131
+ const handleCopy = async () => {
132
+ await navigator.clipboard.writeText(outputToShow);
133
+ setCopied(true);
134
+ setTimeout(() => setCopied(false), 2000);
135
+ };
136
+
137
+ if (isLoading) {
138
+ return (
139
+ <div className="mt-3 rounded-lg border border-zinc-700/50 bg-zinc-900/50 overflow-hidden">
140
+ <div className="flex items-center gap-2 px-3 py-2 bg-zinc-800/50 border-b border-zinc-700/50">
141
+ <div className="w-4 h-4 border-2 border-teal-500/30 border-t-teal-500 rounded-full animate-spin" />
142
+ <span className="text-xs font-medium text-zinc-400">Executing code...</span>
143
+ </div>
144
+ <div className="p-3">
145
+ <div className="flex items-center gap-2 text-zinc-500">
146
+ <Terminal className="w-4 h-4" />
147
+ <span className="text-sm">Running Python with Qiskit...</span>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <div className={clsx(
156
+ 'mt-3 rounded-lg border overflow-hidden transition-all duration-200',
157
+ result.success
158
+ ? 'border-emerald-600/30 bg-emerald-950/20'
159
+ : 'border-red-600/30 bg-red-950/20'
160
+ )}>
161
+ {/* Header */}
162
+ <button
163
+ onClick={() => setIsExpanded(!isExpanded)}
164
+ className={clsx(
165
+ 'w-full flex items-center justify-between px-3 py-2 transition-colors',
166
+ result.success
167
+ ? 'bg-emerald-900/30 hover:bg-emerald-900/40'
168
+ : 'bg-red-900/30 hover:bg-red-900/40'
169
+ )}
170
+ >
171
+ <div className="flex items-center gap-2">
172
+ {result.success ? (
173
+ <CheckCircle2 className="w-4 h-4 text-emerald-400" />
174
+ ) : (
175
+ <XCircle className="w-4 h-4 text-red-400" />
176
+ )}
177
+ <span className={clsx(
178
+ 'text-xs font-medium',
179
+ result.success ? 'text-emerald-300' : 'text-red-300'
180
+ )}>
181
+ {result.success ? 'Execution Successful' : 'Execution Failed'}
182
+ </span>
183
+
184
+ <span className="flex items-center gap-1 text-xs text-zinc-500 ml-2">
185
+ <Clock className="w-3 h-3" />
186
+ {result.executionTime}ms
187
+ </span>
188
+
189
+ {hasImages && (
190
+ <span className="flex items-center gap-1 text-xs text-teal-400 ml-2">
191
+ <ImageIcon className="w-3 h-3" />
192
+ {result.images?.length} figure{result.images?.length !== 1 ? 's' : ''}
193
+ </span>
194
+ )}
195
+ </div>
196
+
197
+ <div className="flex items-center gap-2">
198
+ {(hasOutput || hasError || hasImages) && (
199
+ <span className="text-xs text-zinc-500">
200
+ {isExpanded ? 'Hide' : 'Show'} output
201
+ </span>
202
+ )}
203
+ {isExpanded ? (
204
+ <ChevronUp className="w-4 h-4 text-zinc-500" />
205
+ ) : (
206
+ <ChevronDown className="w-4 h-4 text-zinc-500" />
207
+ )}
208
+ </div>
209
+ </button>
210
+
211
+ {/* Output */}
212
+ {isExpanded && (hasOutput || hasError || hasImages) && (
213
+ <div className="relative">
214
+ {(hasOutput || hasError) && (
215
+ <div className="absolute right-2 top-2 z-10">
216
+ <button
217
+ onClick={handleCopy}
218
+ className="p-1.5 rounded bg-zinc-800/80 hover:bg-zinc-700 transition-colors"
219
+ title="Copy output"
220
+ >
221
+ {copied ? (
222
+ <Check className="w-3.5 h-3.5 text-emerald-400" />
223
+ ) : (
224
+ <Copy className="w-3.5 h-3.5 text-zinc-400" />
225
+ )}
226
+ </button>
227
+ </div>
228
+ )}
229
+
230
+ <div className={clsx(
231
+ 'p-3 font-mono text-sm',
232
+ result.success ? 'bg-zinc-900/50' : 'bg-zinc-900/50'
233
+ )}>
234
+ {hasError && (
235
+ <div className="flex items-start gap-2 mb-3 text-red-400">
236
+ <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
237
+ <pre className="whitespace-pre-wrap break-words text-red-300">{result.error}</pre>
238
+ </div>
239
+ )}
240
+
241
+ {hasOutput && (
242
+ <pre className={clsx(
243
+ 'whitespace-pre-wrap break-words mb-3',
244
+ result.hasCircuitOutput ? 'text-teal-300' : 'text-zinc-300'
245
+ )}>
246
+ {result.output}
247
+ </pre>
248
+ )}
249
+
250
+ {!hasOutput && !hasError && !hasImages && result.success && (
251
+ <span className="text-zinc-500 italic">
252
+ Code executed successfully with no output
253
+ </span>
254
+ )}
255
+
256
+ {/* Display generated images */}
257
+ {hasImages && (
258
+ <ImageViewer images={result.images!} />
259
+ )}
260
+ </div>
261
+ </div>
262
+ )}
263
+ </div>
264
+ );
265
+ }
src/components/Chat/LoadingStatus.tsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import { Loader2, Server, Cpu, Zap, Clock, AlertCircle } from 'lucide-react';
5
+ import { clsx } from 'clsx';
6
+ import type { StatusResponse } from '@/app/api/status/route';
7
+
8
+ type LoadingPhase =
9
+ | 'sending' // Initial request sent
10
+ | 'cold_start' // Workers starting up
11
+ | 'initializing' // Model loading
12
+ | 'processing' // Actively generating
13
+ | 'streaming'; // Receiving response
14
+
15
+ interface LoadingStatusProps {
16
+ isLoading: boolean;
17
+ hasStartedStreaming: boolean;
18
+ onStatusChange?: (status: StatusResponse | null) => void;
19
+ }
20
+
21
+ const PHASE_CONFIG: Record<LoadingPhase, {
22
+ icon: React.ElementType;
23
+ label: string;
24
+ color: string;
25
+ pulseColor: string;
26
+ }> = {
27
+ sending: {
28
+ icon: Zap,
29
+ label: 'Sending request...',
30
+ color: 'text-blue-400',
31
+ pulseColor: 'bg-blue-400',
32
+ },
33
+ cold_start: {
34
+ icon: Server,
35
+ label: 'Starting worker...',
36
+ color: 'text-amber-400',
37
+ pulseColor: 'bg-amber-400',
38
+ },
39
+ initializing: {
40
+ icon: Cpu,
41
+ label: 'Loading model...',
42
+ color: 'text-purple-400',
43
+ pulseColor: 'bg-purple-400',
44
+ },
45
+ processing: {
46
+ icon: Loader2,
47
+ label: 'Generating response...',
48
+ color: 'text-teal-400',
49
+ pulseColor: 'bg-teal-400',
50
+ },
51
+ streaming: {
52
+ icon: Zap,
53
+ label: 'Receiving...',
54
+ color: 'text-emerald-400',
55
+ pulseColor: 'bg-emerald-400',
56
+ },
57
+ };
58
+
59
+ export function LoadingStatus({ isLoading, hasStartedStreaming, onStatusChange }: LoadingStatusProps) {
60
+ const [phase, setPhase] = useState<LoadingPhase>('sending');
61
+ const [elapsedTime, setElapsedTime] = useState(0);
62
+ const [estimatedWait, setEstimatedWait] = useState<number | undefined>();
63
+ const [statusMessage, setStatusMessage] = useState<string>('');
64
+
65
+ // Poll for status while loading
66
+ const checkStatus = useCallback(async () => {
67
+ try {
68
+ const response = await fetch('/api/status');
69
+ if (response.ok) {
70
+ const status: StatusResponse = await response.json();
71
+ onStatusChange?.(status);
72
+
73
+ // Map status to phase
74
+ if (status.status === 'cold_start') {
75
+ setPhase('cold_start');
76
+ setStatusMessage(status.message);
77
+ } else if (status.status === 'initializing') {
78
+ setPhase('initializing');
79
+ setStatusMessage(status.message);
80
+ } else if (status.status === 'processing') {
81
+ setPhase('processing');
82
+ setStatusMessage(status.message);
83
+ }
84
+
85
+ if (status.estimatedWait) {
86
+ setEstimatedWait(status.estimatedWait);
87
+ }
88
+ }
89
+ } catch {
90
+ // Silently fail, keep current phase
91
+ }
92
+ }, [onStatusChange]);
93
+
94
+ // Start polling when loading starts
95
+ useEffect(() => {
96
+ if (!isLoading) {
97
+ setPhase('sending');
98
+ setElapsedTime(0);
99
+ setEstimatedWait(undefined);
100
+ setStatusMessage('');
101
+ return;
102
+ }
103
+
104
+ // Initial status check
105
+ checkStatus();
106
+
107
+ // Poll every 2 seconds while loading and not streaming
108
+ const statusInterval = setInterval(() => {
109
+ if (!hasStartedStreaming) {
110
+ checkStatus();
111
+ }
112
+ }, 2000);
113
+
114
+ // Track elapsed time
115
+ const timeInterval = setInterval(() => {
116
+ setElapsedTime((prev) => prev + 1);
117
+ }, 1000);
118
+
119
+ return () => {
120
+ clearInterval(statusInterval);
121
+ clearInterval(timeInterval);
122
+ };
123
+ }, [isLoading, hasStartedStreaming, checkStatus]);
124
+
125
+ // Update phase based on streaming state
126
+ useEffect(() => {
127
+ if (hasStartedStreaming) {
128
+ setPhase('streaming');
129
+ }
130
+ }, [hasStartedStreaming]);
131
+
132
+ // After 3 seconds without response, likely a cold start
133
+ useEffect(() => {
134
+ if (isLoading && !hasStartedStreaming && elapsedTime >= 3 && phase === 'sending') {
135
+ setPhase('cold_start');
136
+ }
137
+ }, [isLoading, hasStartedStreaming, elapsedTime, phase]);
138
+
139
+ if (!isLoading) return null;
140
+
141
+ const config = PHASE_CONFIG[phase];
142
+ const Icon = config.icon;
143
+ const showEstimate = estimatedWait && phase !== 'streaming';
144
+
145
+ // Calculate progress percentage
146
+ const progress = estimatedWait ? Math.min((elapsedTime / estimatedWait) * 100, 95) : undefined;
147
+
148
+ return (
149
+ <div className="flex flex-col gap-2">
150
+ {/* Main status indicator */}
151
+ <div className="flex items-center gap-3">
152
+ {/* Animated icon */}
153
+ <div className="relative">
154
+ <div className={clsx(
155
+ 'w-8 h-8 rounded-lg flex items-center justify-center',
156
+ 'bg-zinc-800/80 border border-zinc-700/50'
157
+ )}>
158
+ <Icon className={clsx(
159
+ 'w-4 h-4',
160
+ config.color,
161
+ phase !== 'streaming' && 'animate-pulse'
162
+ )} />
163
+ </div>
164
+ {/* Pulse effect for cold start */}
165
+ {(phase === 'cold_start' || phase === 'initializing') && (
166
+ <span className={clsx(
167
+ 'absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full',
168
+ config.pulseColor,
169
+ 'animate-ping opacity-75'
170
+ )} />
171
+ )}
172
+ </div>
173
+
174
+ {/* Status text */}
175
+ <div className="flex-1">
176
+ <div className={clsx('text-sm font-medium', config.color)}>
177
+ {statusMessage || config.label}
178
+ </div>
179
+
180
+ {/* Time indicator */}
181
+ <div className="flex items-center gap-2 text-xs text-zinc-500">
182
+ <Clock className="w-3 h-3" />
183
+ <span>{formatTime(elapsedTime)}</span>
184
+ {showEstimate && (
185
+ <>
186
+ <span className="text-zinc-600">•</span>
187
+ <span>~{formatTime(Math.max(0, estimatedWait - elapsedTime))} remaining</span>
188
+ </>
189
+ )}
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ {/* Progress bar for cold start */}
195
+ {showEstimate && progress !== undefined && (
196
+ <div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
197
+ <div
198
+ className={clsx(
199
+ 'h-full rounded-full transition-all duration-1000',
200
+ phase === 'cold_start' && 'bg-amber-500/50',
201
+ phase === 'initializing' && 'bg-purple-500/50',
202
+ phase === 'processing' && 'bg-teal-500/50'
203
+ )}
204
+ style={{ width: `${progress}%` }}
205
+ />
206
+ </div>
207
+ )}
208
+
209
+ {/* Cold start explanation */}
210
+ {phase === 'cold_start' && elapsedTime >= 5 && (
211
+ <div className="flex items-start gap-2 p-2 bg-amber-500/5 border border-amber-500/20 rounded-lg text-xs">
212
+ <AlertCircle className="w-3.5 h-3.5 text-amber-400 mt-0.5 flex-shrink-0" />
213
+ <p className="text-zinc-400">
214
+ <span className="text-amber-400 font-medium">Cold start detected.</span>{' '}
215
+ The model is scaling up from zero. This typically takes 30-60 seconds on first request.
216
+ </p>
217
+ </div>
218
+ )}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ function formatTime(seconds: number): string {
224
+ if (seconds < 60) {
225
+ return `${seconds}s`;
226
+ }
227
+ const mins = Math.floor(seconds / 60);
228
+ const secs = seconds % 60;
229
+ return `${mins}m ${secs}s`;
230
+ }
231
+
src/components/Chat/Message.tsx ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useMemo, useState, useCallback } from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6
+ import remarkMath from 'remark-math';
7
+ import rehypeKatex from 'rehype-katex';
8
+ import { InlineMath, BlockMath } from 'react-katex';
9
+ import { Copy, Check, Play, Square, Edit2, X } from 'lucide-react';
10
+ import Editor from '@monaco-editor/react';
11
+ import { clsx } from 'clsx';
12
+ import { QubitIcon } from './QubitIcon';
13
+ import { ExecutionResult, ExecutionResultData } from './ExecutionResult';
14
+ import type { Message as MessageType } from '@/types';
15
+
16
+ interface MessageProps {
17
+ message: MessageType;
18
+ onCopyCode?: (code: string) => void;
19
+ loadingStatus?: React.ReactNode;
20
+ }
21
+
22
+ // Custom matte dark theme - muted, professional colors
23
+ const customTheme: { [key: string]: React.CSSProperties } = {
24
+ 'code[class*="language-"]': {
25
+ color: '#d4d4d8',
26
+ background: 'none',
27
+ fontFamily: "'JetBrains Mono', Consolas, Monaco, 'Andale Mono', monospace",
28
+ fontSize: '0.875rem',
29
+ textAlign: 'left',
30
+ whiteSpace: 'pre',
31
+ wordSpacing: 'normal',
32
+ wordBreak: 'normal',
33
+ wordWrap: 'normal',
34
+ lineHeight: '1.6',
35
+ tabSize: 4,
36
+ hyphens: 'none',
37
+ },
38
+ 'pre[class*="language-"]': {
39
+ color: '#d4d4d8',
40
+ background: '#18181b',
41
+ fontFamily: "'JetBrains Mono', Consolas, Monaco, 'Andale Mono', monospace",
42
+ fontSize: '0.875rem',
43
+ textAlign: 'left',
44
+ whiteSpace: 'pre',
45
+ wordSpacing: 'normal',
46
+ wordBreak: 'normal',
47
+ wordWrap: 'normal',
48
+ lineHeight: '1.6',
49
+ tabSize: 4,
50
+ hyphens: 'none',
51
+ padding: '1rem',
52
+ margin: '0',
53
+ overflow: 'auto',
54
+ borderRadius: '0.5rem',
55
+ },
56
+ comment: { color: '#71717a' },
57
+ prolog: { color: '#71717a' },
58
+ doctype: { color: '#71717a' },
59
+ cdata: { color: '#71717a' },
60
+ punctuation: { color: '#a1a1aa' },
61
+ namespace: { opacity: 0.7 },
62
+ property: { color: '#f0abfc' },
63
+ tag: { color: '#f0abfc' },
64
+ boolean: { color: '#c4b5fd' },
65
+ number: { color: '#c4b5fd' },
66
+ constant: { color: '#c4b5fd' },
67
+ symbol: { color: '#c4b5fd' },
68
+ deleted: { color: '#fca5a5' },
69
+ selector: { color: '#86efac' },
70
+ 'attr-name': { color: '#fcd34d' },
71
+ string: { color: '#86efac' },
72
+ char: { color: '#86efac' },
73
+ builtin: { color: '#86efac' },
74
+ inserted: { color: '#86efac' },
75
+ operator: { color: '#f0abfc' },
76
+ entity: { color: '#fcd34d', cursor: 'help' },
77
+ url: { color: '#67e8f9' },
78
+ '.language-css .token.string': { color: '#67e8f9' },
79
+ '.style .token.string': { color: '#67e8f9' },
80
+ variable: { color: '#d4d4d8' },
81
+ atrule: { color: '#93c5fd' },
82
+ 'attr-value': { color: '#86efac' },
83
+ function: { color: '#93c5fd' },
84
+ 'class-name': { color: '#93c5fd' },
85
+ keyword: { color: '#c4b5fd' },
86
+ regex: { color: '#fcd34d' },
87
+ important: { color: '#fcd34d', fontWeight: 'bold' },
88
+ bold: { fontWeight: 'bold' },
89
+ italic: { fontStyle: 'italic' },
90
+ };
91
+
92
+ function isPythonCode(language: string, code: string): boolean {
93
+ if (language === 'python') return true;
94
+
95
+ const pythonPatterns = [
96
+ /^from\s+\w+\s+import/m,
97
+ /^import\s+\w+/m,
98
+ /^def\s+\w+\s*\(/m,
99
+ /^class\s+\w+/m,
100
+ /QuantumCircuit/,
101
+ /qiskit/i,
102
+ ];
103
+
104
+ return pythonPatterns.some(p => p.test(code));
105
+ }
106
+
107
+ function CodeBlock({
108
+ language,
109
+ code: initialCode,
110
+ onCopy,
111
+ }: {
112
+ language: string;
113
+ code: string;
114
+ onCopy?: (code: string) => void;
115
+ }) {
116
+ const [copied, setCopied] = useState(false);
117
+ const [isExecuting, setIsExecuting] = useState(false);
118
+ const [isEditing, setIsEditing] = useState(false);
119
+ const [editedCode, setEditedCode] = useState(initialCode);
120
+ const [executionResult, setExecutionResult] = useState<ExecutionResultData | null>(null);
121
+
122
+ // The code to use (edited or original)
123
+ const code = isEditing ? editedCode : initialCode;
124
+
125
+ const handleCopy = async () => {
126
+ await navigator.clipboard.writeText(code);
127
+ setCopied(true);
128
+ onCopy?.(code);
129
+ setTimeout(() => setCopied(false), 2000);
130
+ };
131
+
132
+ const handleExecute = useCallback(async () => {
133
+ if (isExecuting) return;
134
+
135
+ setIsExecuting(true);
136
+ setExecutionResult(null);
137
+
138
+ try {
139
+ const response = await fetch('/api/execute', {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({ code, timeout: 30 }),
143
+ });
144
+
145
+ const result = await response.json();
146
+ setExecutionResult(result);
147
+ } catch (error) {
148
+ setExecutionResult({
149
+ success: false,
150
+ output: '',
151
+ error: error instanceof Error ? error.message : 'Execution failed',
152
+ executionTime: 0,
153
+ hasCircuitOutput: false,
154
+ });
155
+ } finally {
156
+ setIsExecuting(false);
157
+ }
158
+ }, [code, isExecuting]);
159
+
160
+ const handleStopExecution = () => {
161
+ setIsExecuting(false);
162
+ };
163
+
164
+ const handleToggleEdit = () => {
165
+ if (isEditing) {
166
+ // Exiting edit mode - keep the edited code
167
+ setIsEditing(false);
168
+ } else {
169
+ // Entering edit mode
170
+ setEditedCode(initialCode);
171
+ setIsEditing(true);
172
+ }
173
+ };
174
+
175
+ const handleCancelEdit = () => {
176
+ setEditedCode(initialCode);
177
+ setIsEditing(false);
178
+ };
179
+
180
+ const detectedLanguage = language || detectLanguage(code);
181
+ const canExecute = isPythonCode(detectedLanguage, code);
182
+
183
+ // Calculate editor height based on line count
184
+ const lineCount = code.split('\n').length;
185
+ const editorHeight = Math.min(Math.max(lineCount * 20 + 32, 100), 400);
186
+
187
+ return (
188
+ <div className="relative group my-3 code-block-wrapper">
189
+ {/* Action buttons */}
190
+ <div className="absolute right-2 top-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity z-10">
191
+ <span className="text-xs text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">
192
+ {detectedLanguage || 'code'}
193
+ </span>
194
+
195
+ {/* Edit toggle */}
196
+ <button
197
+ onClick={isEditing ? handleCancelEdit : handleToggleEdit}
198
+ className={clsx(
199
+ 'flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all',
200
+ isEditing
201
+ ? 'bg-amber-600/20 text-amber-400 hover:bg-amber-600/30'
202
+ : 'bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700'
203
+ )}
204
+ title={isEditing ? 'Cancel editing' : 'Edit code'}
205
+ >
206
+ {isEditing ? (
207
+ <>
208
+ <X className="w-3 h-3" />
209
+ <span>Cancel</span>
210
+ </>
211
+ ) : (
212
+ <>
213
+ <Edit2 className="w-3 h-3" />
214
+ <span>Edit</span>
215
+ </>
216
+ )}
217
+ </button>
218
+
219
+ {canExecute && (
220
+ <button
221
+ onClick={isExecuting ? handleStopExecution : handleExecute}
222
+ disabled={isExecuting}
223
+ className={clsx(
224
+ 'flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all',
225
+ isExecuting
226
+ ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
227
+ : 'bg-teal-600/20 text-teal-400 hover:bg-teal-600/30'
228
+ )}
229
+ title={isExecuting ? 'Stop execution' : 'Run code'}
230
+ >
231
+ {isExecuting ? (
232
+ <>
233
+ <Square className="w-3 h-3" />
234
+ <span>Stop</span>
235
+ </>
236
+ ) : (
237
+ <>
238
+ <Play className="w-3 h-3" />
239
+ <span>Run</span>
240
+ </>
241
+ )}
242
+ </button>
243
+ )}
244
+
245
+ <button
246
+ onClick={handleCopy}
247
+ className="p-1.5 rounded bg-zinc-800 hover:bg-zinc-700 transition-colors"
248
+ title="Copy code"
249
+ >
250
+ {copied ? (
251
+ <Check className="w-3.5 h-3.5 text-emerald-400" />
252
+ ) : (
253
+ <Copy className="w-3.5 h-3.5 text-zinc-400" />
254
+ )}
255
+ </button>
256
+ </div>
257
+
258
+ {isEditing ? (
259
+ // Monaco Editor for editing
260
+ <div
261
+ className="rounded-lg overflow-hidden border border-amber-600/30"
262
+ style={{ height: editorHeight }}
263
+ >
264
+ <Editor
265
+ height="100%"
266
+ language={detectedLanguage || 'python'}
267
+ value={editedCode}
268
+ onChange={(value) => setEditedCode(value || '')}
269
+ theme="vs-dark"
270
+ options={{
271
+ fontSize: 14,
272
+ fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace",
273
+ minimap: { enabled: false },
274
+ scrollBeyondLastLine: false,
275
+ lineNumbers: 'on',
276
+ glyphMargin: false,
277
+ folding: false,
278
+ lineDecorationsWidth: 8,
279
+ lineNumbersMinChars: 3,
280
+ padding: { top: 12, bottom: 12 },
281
+ renderLineHighlight: 'line',
282
+ tabSize: 4,
283
+ insertSpaces: true,
284
+ wordWrap: 'on',
285
+ automaticLayout: true,
286
+ }}
287
+ />
288
+ </div>
289
+ ) : (
290
+ // Static code display
291
+ <SyntaxHighlighter
292
+ style={customTheme}
293
+ language={detectedLanguage || 'python'}
294
+ PreTag="div"
295
+ customStyle={{
296
+ margin: 0,
297
+ borderRadius: '0.5rem',
298
+ background: '#18181b',
299
+ padding: '1rem',
300
+ fontSize: '0.875rem',
301
+ border: '1px solid #27272a',
302
+ lineHeight: '1.6',
303
+ }}
304
+ codeTagProps={{
305
+ style: {
306
+ background: 'none',
307
+ padding: 0,
308
+ },
309
+ }}
310
+ wrapLongLines={false}
311
+ >
312
+ {code}
313
+ </SyntaxHighlighter>
314
+ )}
315
+
316
+ {/* Execution result */}
317
+ {(isExecuting || executionResult) && (
318
+ <ExecutionResult
319
+ result={executionResult || { success: false, output: '', error: '', executionTime: 0 }}
320
+ isLoading={isExecuting}
321
+ />
322
+ )}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ function detectLanguage(code: string): string {
328
+ const pythonPatterns = [
329
+ /^from\s+\w+\s+import/m,
330
+ /^import\s+\w+/m,
331
+ /^def\s+\w+\s*\(/m,
332
+ /^class\s+\w+/m,
333
+ /QuantumCircuit/,
334
+ /qiskit/i,
335
+ /\.measure/,
336
+ /numpy|np\./,
337
+ /print\s*\(/,
338
+ ];
339
+
340
+ if (pythonPatterns.some((p) => p.test(code))) {
341
+ return 'python';
342
+ }
343
+
344
+ const jsPatterns = [
345
+ /^const\s+\w+\s*=/m,
346
+ /^let\s+\w+\s*=/m,
347
+ /^function\s+\w+/m,
348
+ /=>\s*{/,
349
+ /console\.log/,
350
+ ];
351
+
352
+ if (jsPatterns.some((p) => p.test(code))) {
353
+ return 'javascript';
354
+ }
355
+
356
+ const bashPatterns = [/^\$\s+/m, /^#!\/bin\/(ba)?sh/m, /\|\s*grep/, /apt-get|pip\s+install/];
357
+
358
+ if (bashPatterns.some((p) => p.test(code))) {
359
+ return 'bash';
360
+ }
361
+
362
+ return 'python';
363
+ }
364
+
365
+ function looksLikeCode(text: string): boolean {
366
+ // Multi-line code indicators
367
+ if (text.includes('\n')) {
368
+ const codeIndicators = [
369
+ /^from\s+/m,
370
+ /^import\s+/m,
371
+ /^def\s+/m,
372
+ /^class\s+/m,
373
+ /^\s*return\s+/m,
374
+ /QuantumCircuit/,
375
+ /Parameter\(/,
376
+ /\.\w+\([^)]*\)/m, // Method calls like qc.h(), qc.cx()
377
+ ];
378
+ return codeIndicators.some((p) => p.test(text));
379
+ }
380
+
381
+ // Single-line code indicators for function completion responses
382
+ const singleLinePatterns = [
383
+ /^return\s+\w+/, // return circuit.control(...)
384
+ /^\w+\s*=\s*\w+\([^)]*\)/, // theta = Parameter("theta")
385
+ /^\w+\.\w+\([^)]*\)$/, // circuit.control(num_ctrl_qubits)
386
+ /\w+\s*=\s*\w+\([^)]*\)(?:\s+\w+\.|\s+\w+\s*=)/, // Multiple statements
387
+ /QuantumCircuit\(/,
388
+ /Parameter\(/,
389
+ /\.control\(/,
390
+ /\.measure\(/,
391
+ ];
392
+ return singleLinePatterns.some((p) => p.test(text.trim()));
393
+ }
394
+
395
+ export function Message({ message, onCopyCode, loadingStatus }: MessageProps) {
396
+ const isUser = message.role === 'user';
397
+ const isLoading = message.isLoading;
398
+
399
+ const avatar = useMemo(() => {
400
+ if (isUser) {
401
+ return (
402
+ <div className="w-8 h-8 rounded-md bg-zinc-800 flex items-center justify-center border border-zinc-700/50">
403
+ <span className="text-[10px] font-bold text-zinc-400 font-mono">YOU</span>
404
+ </div>
405
+ );
406
+ }
407
+ return (
408
+ <div className="w-8 h-8 rounded-md bg-zinc-800 flex items-center justify-center border border-teal-700/40">
409
+ <QubitIcon size={18} className="text-teal-400" />
410
+ </div>
411
+ );
412
+ }, [isUser]);
413
+
414
+ const imageSource =
415
+ message.imageUrl || (message.imageBase64 ? `data:image/jpeg;base64,${message.imageBase64}` : null);
416
+
417
+ const processedContent = useMemo(() => {
418
+ let content = message.content;
419
+
420
+ // Convert non-standard math delimiters to standard LaTeX format
421
+ // Display math: [ ... ] containing LaTeX → $$ ... $$
422
+ content = content.replace(
423
+ /\[\s*(\\[a-zA-Z][^\]]*)\s*\]/g,
424
+ (match, inner) => `\n$$\n${inner.trim()}\n$$\n`
425
+ );
426
+
427
+ // Inline math with \(...\) → $...$
428
+ content = content.replace(
429
+ /\\\(([^)]+)\\\)/g,
430
+ (match, inner) => `$${inner}$`
431
+ );
432
+
433
+ // Inline math: (expression) containing LaTeX → $...$
434
+ // Match parentheses containing backslash commands but not nested parens
435
+ content = content.replace(
436
+ /\(([^()]*(?:\\[a-zA-Z{}^_]|\\frac|\\sqrt|\\sum|\\exp|\\left|\\right|\\bigl|\\bigr|\\Bigl|\\Bigr|\|[01]\\rangle)[^()]*)\)/g,
437
+ (match, inner) => {
438
+ // Only convert if it really looks like math
439
+ if (/\\[a-zA-Z]/.test(inner) || /\|[01n]\\rangle/.test(inner)) {
440
+ return `$${inner}$`;
441
+ }
442
+ return match;
443
+ }
444
+ );
445
+
446
+ // Code detection for non-markdown responses
447
+ if (!content.includes('```') && !content.includes('$$') && !content.includes('$') && looksLikeCode(content)) {
448
+ content = content
449
+ .replace(/(\w+\s*=\s*\w+\([^)]*\))\s+(\w+\.)/g, '$1\n$2')
450
+ .replace(/(\w+\.[a-z_]+\([^)]*\))\s+(\w+\.)/g, '$1\n$2');
451
+
452
+ content = '```python\n' + content + '\n```';
453
+ }
454
+
455
+ return content;
456
+ }, [message.content]);
457
+
458
+ return (
459
+ <div className={clsx('flex gap-3 animate-in', isUser ? 'flex-row-reverse' : 'flex-row')}>
460
+ <div className="flex-shrink-0">{avatar}</div>
461
+
462
+ <div className={clsx('flex-1 max-w-[85%]', isUser ? 'flex flex-col items-end' : '')}>
463
+ {imageSource && (
464
+ <div className="mb-2 max-w-xs">
465
+ <img
466
+ src={imageSource}
467
+ alt="Attached image"
468
+ className="rounded-lg border border-zinc-700/50 max-h-64 object-contain bg-zinc-900"
469
+ />
470
+ </div>
471
+ )}
472
+
473
+ <div
474
+ className={clsx(
475
+ 'rounded-xl px-4 py-3',
476
+ isUser
477
+ ? 'bg-teal-700/80 text-white rounded-tr-sm'
478
+ : 'bg-zinc-800/90 border border-zinc-700/50 rounded-tl-sm'
479
+ )}
480
+ >
481
+ {isLoading ? (
482
+ loadingStatus || (
483
+ <div className="typing-indicator py-1">
484
+ <span />
485
+ <span />
486
+ <span />
487
+ </div>
488
+ )
489
+ ) : (
490
+ <div className={clsx('markdown-content', isUser && 'text-white/90')}>
491
+ <ReactMarkdown
492
+ remarkPlugins={[remarkMath]}
493
+ rehypePlugins={[rehypeKatex]}
494
+ components={{
495
+ code({ className, children, ...props }) {
496
+ const match = /language-(\w+)/.exec(className || '');
497
+ const code = String(children).replace(/\n$/, '');
498
+
499
+ // Check if this is a math block (from remark-math)
500
+ if (className === 'language-math' || className === 'math-inline') {
501
+ try {
502
+ return <InlineMath math={code} />;
503
+ } catch {
504
+ return <code className="text-red-400">{code}</code>;
505
+ }
506
+ }
507
+
508
+ const isBlock = match || code.includes('\n') || looksLikeCode(code);
509
+
510
+ if (isBlock) {
511
+ return <CodeBlock language={match?.[1] || ''} code={code} onCopy={onCopyCode} />;
512
+ }
513
+
514
+ return (
515
+ <code className={clsx('bg-zinc-700/50 px-1.5 py-0.5 rounded text-sm', className)} {...props}>
516
+ {children}
517
+ </code>
518
+ );
519
+ },
520
+ pre({ children }) {
521
+ return <>{children}</>;
522
+ },
523
+ // Handle math blocks from remark-math
524
+ span({ className, children, ...props }) {
525
+ if (className === 'math math-inline') {
526
+ try {
527
+ const math = String(children);
528
+ return <InlineMath math={math} />;
529
+ } catch {
530
+ return <span className="text-red-400">{children}</span>;
531
+ }
532
+ }
533
+ return <span className={className} {...props}>{children}</span>;
534
+ },
535
+ div({ className, children, ...props }) {
536
+ if (className === 'math math-display') {
537
+ try {
538
+ const math = String(children);
539
+ return <BlockMath math={math} />;
540
+ } catch {
541
+ return <div className="text-red-400">{children}</div>;
542
+ }
543
+ }
544
+ return <div className={className} {...props}>{children}</div>;
545
+ },
546
+ }}
547
+ >
548
+ {processedContent}
549
+ </ReactMarkdown>
550
+ </div>
551
+ )}
552
+ </div>
553
+
554
+ <span className="text-xs text-zinc-500 mt-1 px-2">
555
+ {message.timestamp.toLocaleTimeString([], {
556
+ hour: '2-digit',
557
+ minute: '2-digit',
558
+ })}
559
+ </span>
560
+ </div>
561
+ </div>
562
+ );
563
+ }
src/components/Chat/MessageInput.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useImperativeHandle, forwardRef } from 'react';
4
+ import { Send, Image as ImageIcon, X, Loader2 } from 'lucide-react';
5
+ import { clsx } from 'clsx';
6
+
7
+ interface MessageInputProps {
8
+ onSend: (message: string, imageUrl?: string, imageBase64?: string) => void;
9
+ isLoading: boolean;
10
+ placeholder?: string;
11
+ }
12
+
13
+ export interface MessageInputRef {
14
+ setContent: (text: string, imageUrl?: string) => void;
15
+ clear: () => void;
16
+ }
17
+
18
+ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
19
+ function MessageInput(
20
+ {
21
+ onSend,
22
+ isLoading,
23
+ placeholder = 'Ask about quantum computing, Qiskit, or upload a circuit diagram...',
24
+ },
25
+ ref
26
+ ) {
27
+ const [message, setMessage] = useState('');
28
+ const [imageBase64, setImageBase64] = useState<string | null>(null);
29
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
30
+ const [imagePreview, setImagePreview] = useState<string | null>(null);
31
+ const fileInputRef = useRef<HTMLInputElement>(null);
32
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
33
+
34
+ useImperativeHandle(ref, () => ({
35
+ setContent: (text: string, url?: string) => {
36
+ setMessage(text);
37
+ if (url) {
38
+ setImageUrl(url);
39
+ setImagePreview(url);
40
+ setImageBase64(null);
41
+ }
42
+ if (textareaRef.current) {
43
+ textareaRef.current.style.height = 'auto';
44
+ setTimeout(() => {
45
+ if (textareaRef.current) {
46
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
47
+ }
48
+ }, 0);
49
+ }
50
+ },
51
+ clear: () => {
52
+ setMessage('');
53
+ setImageBase64(null);
54
+ setImageUrl(null);
55
+ setImagePreview(null);
56
+ if (textareaRef.current) {
57
+ textareaRef.current.style.height = 'auto';
58
+ }
59
+ },
60
+ }));
61
+
62
+ const handleSubmit = useCallback(() => {
63
+ if ((!message.trim() && !imageBase64 && !imageUrl) || isLoading) return;
64
+
65
+ onSend(message.trim(), imageUrl || undefined, imageBase64 || undefined);
66
+ setMessage('');
67
+ setImageBase64(null);
68
+ setImageUrl(null);
69
+ setImagePreview(null);
70
+
71
+ if (textareaRef.current) {
72
+ textareaRef.current.style.height = 'auto';
73
+ }
74
+ }, [message, imageBase64, imageUrl, isLoading, onSend]);
75
+
76
+ const handleKeyDown = (e: React.KeyboardEvent) => {
77
+ if (e.key === 'Enter' && !e.shiftKey) {
78
+ e.preventDefault();
79
+ handleSubmit();
80
+ }
81
+ };
82
+
83
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
84
+ const file = e.target.files?.[0];
85
+ if (!file) return;
86
+
87
+ if (!file.type.startsWith('image/')) {
88
+ alert('Please upload an image file');
89
+ return;
90
+ }
91
+
92
+ const reader = new FileReader();
93
+ reader.onload = (event) => {
94
+ const result = event.target?.result as string;
95
+ const base64 = result.split(',')[1];
96
+ setImageBase64(base64);
97
+ setImageUrl(null);
98
+ setImagePreview(result);
99
+ };
100
+ reader.readAsDataURL(file);
101
+ };
102
+
103
+ const removeImage = () => {
104
+ setImageBase64(null);
105
+ setImageUrl(null);
106
+ setImagePreview(null);
107
+ if (fileInputRef.current) {
108
+ fileInputRef.current.value = '';
109
+ }
110
+ };
111
+
112
+ const adjustTextareaHeight = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
113
+ const textarea = e.target;
114
+ textarea.style.height = 'auto';
115
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
116
+ setMessage(textarea.value);
117
+ };
118
+
119
+ const hasContent = message.trim() || imageBase64 || imageUrl;
120
+
121
+ return (
122
+ <div className="bg-zinc-800/60 border border-zinc-700/50 rounded-xl p-3">
123
+ {imagePreview && (
124
+ <div className="mb-3 relative inline-block">
125
+ <img
126
+ src={imagePreview}
127
+ alt="Upload preview"
128
+ className="h-24 rounded-lg border border-zinc-700/50 object-contain bg-zinc-900"
129
+ />
130
+ <button
131
+ onClick={removeImage}
132
+ className="absolute -top-2 -right-2 p-1 bg-red-600/80 rounded-full hover:bg-red-600 transition-colors"
133
+ >
134
+ <X className="w-3 h-3 text-white" />
135
+ </button>
136
+ </div>
137
+ )}
138
+
139
+ <div className="flex items-end gap-2">
140
+ <input
141
+ ref={fileInputRef}
142
+ type="file"
143
+ accept="image/*"
144
+ onChange={handleImageUpload}
145
+ className="hidden"
146
+ />
147
+
148
+ <button
149
+ onClick={() => fileInputRef.current?.click()}
150
+ disabled={isLoading}
151
+ className={clsx(
152
+ 'p-3 rounded-lg transition-all duration-200',
153
+ 'hover:bg-zinc-700/50 text-zinc-500 hover:text-zinc-300',
154
+ isLoading && 'opacity-50 cursor-not-allowed'
155
+ )}
156
+ title="Upload image"
157
+ >
158
+ <ImageIcon className="w-5 h-5" />
159
+ </button>
160
+
161
+ <textarea
162
+ ref={textareaRef}
163
+ value={message}
164
+ onChange={adjustTextareaHeight}
165
+ onKeyDown={handleKeyDown}
166
+ placeholder={placeholder}
167
+ disabled={isLoading}
168
+ rows={1}
169
+ className={clsx(
170
+ 'flex-1 bg-transparent border-none outline-none resize-none',
171
+ 'text-zinc-200 placeholder:text-zinc-500',
172
+ 'min-h-[44px] max-h-[200px] py-3',
173
+ isLoading && 'opacity-50'
174
+ )}
175
+ />
176
+
177
+ <button
178
+ onClick={handleSubmit}
179
+ disabled={!hasContent || isLoading}
180
+ className={clsx(
181
+ 'p-3 rounded-lg transition-all duration-200',
182
+ hasContent
183
+ ? 'bg-teal-700/80 hover:bg-teal-600/80 text-white'
184
+ : 'bg-zinc-700/50 text-zinc-500',
185
+ isLoading && 'opacity-50 cursor-not-allowed'
186
+ )}
187
+ >
188
+ {isLoading ? (
189
+ <Loader2 className="w-5 h-5 animate-spin" />
190
+ ) : (
191
+ <Send className="w-5 h-5" />
192
+ )}
193
+ </button>
194
+ </div>
195
+ </div>
196
+ );
197
+ }
198
+ );
src/components/Chat/QubitIcon.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ interface QubitIconProps {
4
+ size?: number;
5
+ className?: string;
6
+ }
7
+
8
+ /**
9
+ * Minimalist qubit icon representing a Bloch sphere.
10
+ * Simple, clean design suitable for a professional interface.
11
+ */
12
+ export function QubitIcon({ size = 24, className = '' }: QubitIconProps) {
13
+ return (
14
+ <svg
15
+ width={size}
16
+ height={size}
17
+ viewBox="0 0 24 24"
18
+ fill="none"
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ className={className}
21
+ >
22
+ {/* Outer circle - Bloch sphere */}
23
+ <circle
24
+ cx="12"
25
+ cy="12"
26
+ r="9"
27
+ stroke="currentColor"
28
+ strokeWidth="1.5"
29
+ fill="none"
30
+ opacity="0.6"
31
+ />
32
+
33
+ {/* Equator ellipse */}
34
+ <ellipse
35
+ cx="12"
36
+ cy="12"
37
+ rx="9"
38
+ ry="3"
39
+ stroke="currentColor"
40
+ strokeWidth="1"
41
+ fill="none"
42
+ opacity="0.4"
43
+ />
44
+
45
+ {/* Vertical meridian */}
46
+ <ellipse
47
+ cx="12"
48
+ cy="12"
49
+ rx="3"
50
+ ry="9"
51
+ stroke="currentColor"
52
+ strokeWidth="1"
53
+ fill="none"
54
+ opacity="0.4"
55
+ />
56
+
57
+ {/* State vector arrow */}
58
+ <line
59
+ x1="12"
60
+ y1="12"
61
+ x2="16"
62
+ y2="6"
63
+ stroke="currentColor"
64
+ strokeWidth="1.5"
65
+ strokeLinecap="round"
66
+ />
67
+
68
+ {/* State point */}
69
+ <circle
70
+ cx="16"
71
+ cy="6"
72
+ r="2"
73
+ fill="currentColor"
74
+ />
75
+
76
+ {/* Center point */}
77
+ <circle
78
+ cx="12"
79
+ cy="12"
80
+ r="1.5"
81
+ fill="currentColor"
82
+ opacity="0.5"
83
+ />
84
+ </svg>
85
+ );
86
+ }
87
+
src/components/Chat/WarmupIndicator.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useWarmup, WarmupStatus } from '@/lib/hooks/useWarmup';
4
+ import { Cpu, Check, Loader2, AlertCircle } from 'lucide-react';
5
+ import { clsx } from 'clsx';
6
+
7
+ interface WarmupIndicatorProps {
8
+ className?: string;
9
+ showWhenReady?: boolean;
10
+ }
11
+
12
+ const STATUS_CONFIG: Record<WarmupStatus, {
13
+ icon: React.ElementType;
14
+ label: string;
15
+ color: string;
16
+ bgColor: string;
17
+ animate?: boolean;
18
+ }> = {
19
+ idle: {
20
+ icon: Cpu,
21
+ label: 'Checking model...',
22
+ color: 'text-zinc-400',
23
+ bgColor: 'bg-zinc-800/50',
24
+ },
25
+ checking: {
26
+ icon: Loader2,
27
+ label: 'Checking model...',
28
+ color: 'text-blue-400',
29
+ bgColor: 'bg-blue-500/10',
30
+ animate: true,
31
+ },
32
+ warming: {
33
+ icon: Cpu,
34
+ label: 'Pre-warming model...',
35
+ color: 'text-amber-400',
36
+ bgColor: 'bg-amber-500/10',
37
+ animate: true,
38
+ },
39
+ ready: {
40
+ icon: Check,
41
+ label: 'Model ready',
42
+ color: 'text-emerald-400',
43
+ bgColor: 'bg-emerald-500/10',
44
+ },
45
+ error: {
46
+ icon: AlertCircle,
47
+ label: 'Warmup failed',
48
+ color: 'text-red-400',
49
+ bgColor: 'bg-red-500/10',
50
+ },
51
+ };
52
+
53
+ /**
54
+ * Small indicator showing the pre-warming status of the model.
55
+ * Appears in the header or corner of the chat interface.
56
+ */
57
+ export function WarmupIndicator({ className, showWhenReady = false }: WarmupIndicatorProps) {
58
+ const { status, workers } = useWarmup(true);
59
+
60
+ if (status === 'ready' && !showWhenReady) {
61
+ return null;
62
+ }
63
+
64
+ if (status === 'idle') {
65
+ return null;
66
+ }
67
+
68
+ const config = STATUS_CONFIG[status];
69
+ const Icon = config.icon;
70
+
71
+ return (
72
+ <div
73
+ className={clsx(
74
+ 'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium transition-all duration-300',
75
+ config.bgColor,
76
+ config.color,
77
+ className
78
+ )}
79
+ >
80
+ <Icon
81
+ className={clsx(
82
+ 'w-3 h-3',
83
+ config.animate && 'animate-spin'
84
+ )}
85
+ />
86
+ <span>{config.label}</span>
87
+ {workers && status === 'warming' && workers.initializing > 0 && (
88
+ <span className="text-zinc-500">
89
+ ({workers.initializing} starting)
90
+ </span>
91
+ )}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ export function WarmupIndicatorCompact({ className }: { className?: string }) {
97
+ const { status, message } = useWarmup(true);
98
+
99
+ if (status === 'ready' || status === 'idle') {
100
+ return null;
101
+ }
102
+
103
+ const config = STATUS_CONFIG[status];
104
+ const Icon = config.icon;
105
+
106
+ return (
107
+ <div
108
+ className={clsx(
109
+ 'relative group',
110
+ className
111
+ )}
112
+ title={message || config.label}
113
+ >
114
+ <div
115
+ className={clsx(
116
+ 'p-1.5 rounded-full',
117
+ config.bgColor
118
+ )}
119
+ >
120
+ <Icon
121
+ className={clsx(
122
+ 'w-3.5 h-3.5',
123
+ config.color,
124
+ config.animate && 'animate-spin'
125
+ )}
126
+ />
127
+ </div>
128
+
129
+ {/* Pulse effect for warming */}
130
+ {status === 'warming' && (
131
+ <span className="absolute inset-0 rounded-full bg-amber-400/30 animate-ping" />
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+
src/components/Examples/ExampleCard.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Image as ImageIcon, Code, FileQuestion, ChevronRight } from 'lucide-react';
4
+ import { clsx } from 'clsx';
5
+ import { TASK_LABELS, CATEGORY_LABELS } from '@/config/constants';
6
+ import type { DatasetExample } from '@/types';
7
+
8
+ interface ExampleCardProps {
9
+ example: DatasetExample;
10
+ onSelect: (example: DatasetExample) => void;
11
+ isSelected?: boolean;
12
+ }
13
+
14
+ export function ExampleCard({ example, onSelect, isSelected }: ExampleCardProps) {
15
+ const taskConfig = TASK_LABELS[example.type];
16
+ const categoryConfig = CATEGORY_LABELS[example.category];
17
+
18
+ const getTaskIcon = () => {
19
+ switch (example.type) {
20
+ case 'function_completion':
21
+ return <Code className="w-3.5 h-3.5" />;
22
+ case 'code_generation':
23
+ return <Code className="w-3.5 h-3.5" />;
24
+ case 'qa':
25
+ return <FileQuestion className="w-3.5 h-3.5" />;
26
+ }
27
+ };
28
+
29
+ const truncateText = (text: string, maxLength: number) => {
30
+ if (text.length <= maxLength) return text;
31
+ return text.substring(0, maxLength).trim() + '...';
32
+ };
33
+
34
+ // Muted badge colors
35
+ const badgeColors: Record<string, string> = {
36
+ function_completion: 'bg-emerald-900/30 text-emerald-400 border-emerald-700/30',
37
+ code_generation: 'bg-blue-900/30 text-blue-400 border-blue-700/30',
38
+ qa: 'bg-amber-900/30 text-amber-400 border-amber-700/30',
39
+ };
40
+
41
+ return (
42
+ <button
43
+ onClick={() => onSelect(example)}
44
+ className={clsx(
45
+ 'w-full text-left p-3 rounded-lg transition-all duration-200 group',
46
+ 'border hover:border-teal-700/40',
47
+ isSelected
48
+ ? 'bg-teal-900/20 border-teal-700/40'
49
+ : 'bg-zinc-800/50 border-zinc-700/30 hover:bg-zinc-800/80'
50
+ )}
51
+ >
52
+ <div className="flex items-start gap-3">
53
+ {example.hasImage && (
54
+ <div className="flex-shrink-0 w-14 h-14 rounded-md bg-zinc-800 border border-zinc-700/50 flex items-center justify-center overflow-hidden">
55
+ {example.imageUrl ? (
56
+ <img
57
+ src={example.imageUrl}
58
+ alt=""
59
+ className="w-full h-full object-cover"
60
+ loading="lazy"
61
+ />
62
+ ) : (
63
+ <ImageIcon className="w-5 h-5 text-zinc-500" />
64
+ )}
65
+ </div>
66
+ )}
67
+
68
+ <div className="flex-1 min-w-0">
69
+ <div className="flex items-center gap-2 mb-1.5 flex-wrap">
70
+ <span className={clsx('inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium border', badgeColors[example.type])}>
71
+ {getTaskIcon()}
72
+ <span className="ml-1">{taskConfig.label}</span>
73
+ </span>
74
+ <span className="text-[10px] text-zinc-500">
75
+ {categoryConfig}
76
+ </span>
77
+ </div>
78
+
79
+ <p className="text-sm text-zinc-300 leading-snug font-mono">
80
+ {truncateText(example.question, 120)}
81
+ </p>
82
+ </div>
83
+
84
+ <ChevronRight
85
+ className={clsx(
86
+ 'w-4 h-4 flex-shrink-0 transition-transform duration-200',
87
+ 'text-zinc-600 group-hover:text-teal-500',
88
+ 'group-hover:translate-x-1'
89
+ )}
90
+ />
91
+ </div>
92
+ </button>
93
+ );
94
+ }
src/components/Examples/ExamplesPanel.tsx ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef } from 'react';
4
+ import {
5
+ Database,
6
+ Filter,
7
+ Image as ImageIcon,
8
+ FileText,
9
+ Loader2,
10
+ ChevronDown,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ Search,
14
+ X,
15
+ } from 'lucide-react';
16
+ import { clsx } from 'clsx';
17
+ import { ExampleCard } from './ExampleCard';
18
+ import { useDataset } from '@/lib/dataset/DatasetProvider';
19
+ import { TASK_LABELS, CATEGORY_LABELS } from '@/config/constants';
20
+ import type { DatasetExample, TaskType, Category } from '@/types';
21
+
22
+ interface ExamplesPanelProps {
23
+ onSelectExample: (example: DatasetExample) => void;
24
+ }
25
+
26
+ type ModalityFilter = 'all' | 'multimodal' | 'text-only';
27
+ type Split = 'train' | 'validation' | 'test';
28
+
29
+ const ITEMS_PER_PAGE = 25;
30
+
31
+ export function ExamplesPanel({ onSelectExample }: ExamplesPanelProps) {
32
+ const { isLoading: isDatasetLoading, loadedSplits, splitCounts, filterExamples, loadSplit } = useDataset();
33
+
34
+ const [typeFilter, setTypeFilter] = useState<TaskType | 'all'>('all');
35
+ const [categoryFilter, setCategoryFilter] = useState<Category | 'all'>('all');
36
+ const [modalityFilter, setModalityFilter] = useState<ModalityFilter>('all');
37
+ const [showFilters, setShowFilters] = useState(false);
38
+ const [searchQuery, setSearchQuery] = useState('');
39
+ const [debouncedSearch, setDebouncedSearch] = useState('');
40
+ const [split, setSplit] = useState<Split>('test');
41
+ const [currentPage, setCurrentPage] = useState(0);
42
+
43
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
44
+ const searchInputRef = useRef<HTMLInputElement>(null);
45
+
46
+ // Load train split if selected (not loaded by default)
47
+ useEffect(() => {
48
+ if (split === 'train' && !loadedSplits.has('train')) {
49
+ loadSplit('train');
50
+ }
51
+ }, [split, loadedSplits, loadSplit]);
52
+
53
+ // Debounce search
54
+ useEffect(() => {
55
+ const timer = setTimeout(() => {
56
+ setDebouncedSearch(searchQuery);
57
+ setCurrentPage(0);
58
+ }, 300);
59
+ return () => clearTimeout(timer);
60
+ }, [searchQuery]);
61
+
62
+ // Reset page when filters change
63
+ useEffect(() => {
64
+ setCurrentPage(0);
65
+ }, [split, typeFilter, categoryFilter, modalityFilter, debouncedSearch]);
66
+
67
+ // Filter locally loaded data
68
+ const { examples, totalExamples } = useMemo(() => {
69
+ if (!loadedSplits.has(split)) {
70
+ return { examples: [], totalExamples: 0 };
71
+ }
72
+
73
+ const filters: {
74
+ type?: TaskType;
75
+ category?: Category;
76
+ hasImage?: boolean;
77
+ search?: string;
78
+ } = {};
79
+
80
+ if (typeFilter !== 'all') filters.type = typeFilter;
81
+ if (categoryFilter !== 'all') filters.category = categoryFilter;
82
+ if (modalityFilter === 'multimodal') filters.hasImage = true;
83
+ else if (modalityFilter === 'text-only') filters.hasImage = false;
84
+ if (debouncedSearch) filters.search = debouncedSearch;
85
+
86
+ const result = filterExamples(split, filters, ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
87
+
88
+ return { examples: result.examples, totalExamples: result.total };
89
+ }, [loadedSplits, split, filterExamples, typeFilter, categoryFilter, modalityFilter, debouncedSearch, currentPage]);
90
+
91
+ // Scroll to top on page change
92
+ useEffect(() => {
93
+ if (scrollContainerRef.current) {
94
+ scrollContainerRef.current.scrollTop = 0;
95
+ }
96
+ }, [currentPage]);
97
+
98
+ const totalPages = Math.ceil(totalExamples / ITEMS_PER_PAGE);
99
+
100
+ const stats = useMemo(() => ({
101
+ total: totalExamples,
102
+ displayed: examples.length,
103
+ }), [examples, totalExamples]);
104
+
105
+ const clearSearch = () => {
106
+ setSearchQuery('');
107
+ searchInputRef.current?.focus();
108
+ };
109
+
110
+ const handleKeyDown = (e: React.KeyboardEvent) => {
111
+ if (e.key === 'Escape') {
112
+ clearSearch();
113
+ }
114
+ };
115
+
116
+ const isLoading = isDatasetLoading || !loadedSplits.has(split);
117
+
118
+ return (
119
+ <div className="h-full flex flex-col bg-zinc-900/95 overflow-hidden">
120
+ {/* Header */}
121
+ <div className="p-4 border-b border-zinc-800/80 flex-shrink-0">
122
+ <div className="flex items-center justify-between mb-3">
123
+ <div className="flex items-center gap-2">
124
+ <Database className="w-4 h-4 text-teal-500" />
125
+ <h2 className="font-semibold text-zinc-200">Dataset Examples</h2>
126
+ </div>
127
+ {isLoading && (
128
+ <Loader2 className="w-4 h-4 animate-spin text-zinc-500" />
129
+ )}
130
+ </div>
131
+
132
+ {/* Split Selector */}
133
+ <div className="flex gap-1 mb-3 bg-zinc-800/50 p-1 rounded-lg">
134
+ {(['train', 'validation', 'test'] as Split[]).map((s) => (
135
+ <button
136
+ key={s}
137
+ onClick={() => setSplit(s)}
138
+ className={clsx(
139
+ 'flex-1 px-2 py-1.5 text-xs font-medium rounded-md transition-all',
140
+ split === s
141
+ ? 'bg-teal-600/80 text-white shadow-sm'
142
+ : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50'
143
+ )}
144
+ >
145
+ <div className="flex items-center justify-center gap-1">
146
+ <span className="capitalize">{s}</span>
147
+ {splitCounts[s] && (
148
+ <span className="text-[10px] opacity-70">({splitCounts[s]})</span>
149
+ )}
150
+ </div>
151
+ </button>
152
+ ))}
153
+ </div>
154
+
155
+ {/* Search */}
156
+ <div className="relative mb-3">
157
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
158
+ <input
159
+ ref={searchInputRef}
160
+ type="text"
161
+ value={searchQuery}
162
+ onChange={(e) => setSearchQuery(e.target.value)}
163
+ onKeyDown={handleKeyDown}
164
+ placeholder="Search examples..."
165
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-lg pl-9 pr-8 py-2 text-sm text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:ring-1 focus:ring-teal-600/50 focus:border-teal-700/50"
166
+ />
167
+ {searchQuery && (
168
+ <button
169
+ onClick={clearSearch}
170
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
171
+ >
172
+ <X className="w-4 h-4" />
173
+ </button>
174
+ )}
175
+ </div>
176
+
177
+ {/* Stats */}
178
+ <div className="flex items-center gap-2 text-xs text-zinc-500 mb-3">
179
+ <span className="flex items-center gap-1">
180
+ <FileText className="w-3.5 h-3.5" />
181
+ {stats.total} examples
182
+ </span>
183
+ {debouncedSearch && (
184
+ <>
185
+ <span className="text-zinc-600">|</span>
186
+ <span className="text-teal-400 truncate max-w-[100px]">
187
+ &quot;{debouncedSearch}&quot;
188
+ </span>
189
+ </>
190
+ )}
191
+ </div>
192
+
193
+ {/* Filters Toggle */}
194
+ <button
195
+ onClick={() => setShowFilters(!showFilters)}
196
+ className="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
197
+ >
198
+ <Filter className="w-4 h-4" />
199
+ <span>Filters</span>
200
+ {(typeFilter !== 'all' || categoryFilter !== 'all' || modalityFilter !== 'all') && (
201
+ <span className="px-1.5 py-0.5 text-[10px] bg-teal-600/30 text-teal-400 rounded">
202
+ Active
203
+ </span>
204
+ )}
205
+ <ChevronDown
206
+ className={clsx(
207
+ 'w-4 h-4 transition-transform',
208
+ showFilters && 'rotate-180'
209
+ )}
210
+ />
211
+ </button>
212
+
213
+ {/* Filter Options */}
214
+ {showFilters && (
215
+ <div className="mt-3 space-y-3 animate-in slide-in-from-top-2 duration-200">
216
+ <div>
217
+ <label className="text-xs text-zinc-500 mb-1 block">
218
+ Task Type
219
+ </label>
220
+ <select
221
+ value={typeFilter}
222
+ onChange={(e) => setTypeFilter(e.target.value as TaskType | 'all')}
223
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-md px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:ring-1 focus:ring-teal-600/50"
224
+ >
225
+ <option value="all">All Types</option>
226
+ {Object.entries(TASK_LABELS).map(([key, config]) => (
227
+ <option key={key} value={key}>
228
+ {config.label}
229
+ </option>
230
+ ))}
231
+ </select>
232
+ </div>
233
+
234
+ <div>
235
+ <label className="text-xs text-zinc-500 mb-1 block">
236
+ Category
237
+ </label>
238
+ <select
239
+ value={categoryFilter}
240
+ onChange={(e) => setCategoryFilter(e.target.value as Category | 'all')}
241
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-md px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:ring-1 focus:ring-teal-600/50"
242
+ >
243
+ <option value="all">All Categories</option>
244
+ {Object.entries(CATEGORY_LABELS).map(([key, label]) => (
245
+ <option key={key} value={key}>
246
+ {label}
247
+ </option>
248
+ ))}
249
+ </select>
250
+ </div>
251
+
252
+ <div>
253
+ <label className="text-xs text-zinc-500 mb-1 block">
254
+ Modality
255
+ </label>
256
+ <div className="flex gap-2">
257
+ {(['all', 'multimodal', 'text-only'] as ModalityFilter[]).map((mode) => (
258
+ <button
259
+ key={mode}
260
+ onClick={() => setModalityFilter(mode)}
261
+ className={clsx(
262
+ 'flex-1 py-1.5 px-2 rounded-md text-xs font-medium transition-all',
263
+ modalityFilter === mode
264
+ ? 'bg-teal-700/80 text-white'
265
+ : 'bg-zinc-800/80 text-zinc-400 hover:text-zinc-200'
266
+ )}
267
+ >
268
+ {mode === 'all' ? 'All' : mode === 'multimodal' ? 'Multimodal' : 'Text'}
269
+ </button>
270
+ ))}
271
+ </div>
272
+ </div>
273
+
274
+ {/* Clear Filters */}
275
+ {(typeFilter !== 'all' || categoryFilter !== 'all' || modalityFilter !== 'all') && (
276
+ <button
277
+ onClick={() => {
278
+ setTypeFilter('all');
279
+ setCategoryFilter('all');
280
+ setModalityFilter('all');
281
+ }}
282
+ className="w-full py-2 text-xs text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50 rounded-md transition-colors"
283
+ >
284
+ Clear all filters
285
+ </button>
286
+ )}
287
+ </div>
288
+ )}
289
+ </div>
290
+
291
+ {/* Examples List */}
292
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 scroll-smooth min-h-0">
293
+ {isLoading ? (
294
+ <div className="flex flex-col items-center justify-center h-40 text-zinc-500">
295
+ <Loader2 className="w-6 h-6 animate-spin mb-2" />
296
+ <span className="text-sm">Loading examples...</span>
297
+ </div>
298
+ ) : examples.length === 0 ? (
299
+ <div className="flex flex-col items-center justify-center h-40 text-zinc-500 text-center">
300
+ <Filter className="w-6 h-6 mb-2 opacity-50" />
301
+ <p className="text-sm">No examples match your filters</p>
302
+ {debouncedSearch && (
303
+ <button
304
+ onClick={clearSearch}
305
+ className="mt-2 text-teal-400 hover:text-teal-300 text-sm"
306
+ >
307
+ Clear search
308
+ </button>
309
+ )}
310
+ </div>
311
+ ) : (
312
+ <div className="space-y-2">
313
+ <p className="text-xs text-zinc-500 px-1 mb-2">
314
+ Showing {currentPage * ITEMS_PER_PAGE + 1}–{Math.min((currentPage + 1) * ITEMS_PER_PAGE, totalExamples)} of {totalExamples}
315
+ </p>
316
+ {examples.map((example) => (
317
+ <ExampleCard
318
+ key={example.id}
319
+ example={example}
320
+ onSelect={onSelectExample}
321
+ />
322
+ ))}
323
+ </div>
324
+ )}
325
+ </div>
326
+
327
+ {/* Pagination */}
328
+ {totalPages > 1 && !isLoading && (
329
+ <div className="p-2 border-t border-zinc-800/80 flex-shrink-0">
330
+ <div className="flex items-center justify-between gap-1">
331
+ <button
332
+ onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
333
+ disabled={currentPage === 0}
334
+ className={clsx(
335
+ 'flex items-center gap-0.5 px-2 py-1 text-xs font-medium rounded-md transition-colors flex-shrink-0',
336
+ currentPage === 0
337
+ ? 'text-zinc-600 cursor-not-allowed'
338
+ : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
339
+ )}
340
+ >
341
+ <ChevronLeft className="w-3.5 h-3.5" />
342
+ <span className="hidden sm:inline">Prev</span>
343
+ </button>
344
+
345
+ <div className="flex items-center gap-0.5 overflow-hidden flex-1 justify-center min-w-0">
346
+ {(() => {
347
+ const maxVisible = 3;
348
+ const pages: (number | 'ellipsis')[] = [];
349
+
350
+ if (totalPages <= maxVisible + 2) {
351
+ for (let i = 0; i < totalPages; i++) pages.push(i);
352
+ } else {
353
+ pages.push(0);
354
+
355
+ if (currentPage > 2) {
356
+ pages.push('ellipsis');
357
+ }
358
+
359
+ const start = Math.max(1, currentPage - 1);
360
+ const end = Math.min(totalPages - 2, currentPage + 1);
361
+
362
+ for (let i = start; i <= end; i++) {
363
+ if (!pages.includes(i)) pages.push(i);
364
+ }
365
+
366
+ if (currentPage < totalPages - 3) {
367
+ pages.push('ellipsis');
368
+ }
369
+
370
+ if (!pages.includes(totalPages - 1)) {
371
+ pages.push(totalPages - 1);
372
+ }
373
+ }
374
+
375
+ return pages.map((page, idx) => {
376
+ if (page === 'ellipsis') {
377
+ return (
378
+ <span key={`ellipsis-${idx}`} className="text-zinc-600 px-0.5 text-xs">
379
+
380
+ </span>
381
+ );
382
+ }
383
+
384
+ return (
385
+ <button
386
+ key={page}
387
+ onClick={() => setCurrentPage(page)}
388
+ className={clsx(
389
+ 'w-6 h-6 text-[11px] font-medium rounded transition-colors flex-shrink-0',
390
+ currentPage === page
391
+ ? 'bg-teal-600/80 text-white'
392
+ : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
393
+ )}
394
+ >
395
+ {page + 1}
396
+ </button>
397
+ );
398
+ });
399
+ })()}
400
+ </div>
401
+
402
+ <button
403
+ onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
404
+ disabled={currentPage >= totalPages - 1}
405
+ className={clsx(
406
+ 'flex items-center gap-0.5 px-2 py-1 text-xs font-medium rounded-md transition-colors flex-shrink-0',
407
+ currentPage >= totalPages - 1
408
+ ? 'text-zinc-600 cursor-not-allowed'
409
+ : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
410
+ )}
411
+ >
412
+ <span className="hidden sm:inline">Next</span>
413
+ <ChevronRight className="w-3.5 h-3.5" />
414
+ </button>
415
+ </div>
416
+ </div>
417
+ )}
418
+ </div>
419
+ );
420
+ }
src/components/Header/Header.tsx ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { Github, Database, Boxes, ExternalLink, MessageSquare, Code } from 'lucide-react';
4
+ import { clsx } from 'clsx';
5
+ import { PROJECT_CONFIG, LINKS } from '@/config/constants';
6
+ import { WarmupIndicator } from '@/components/Chat/WarmupIndicator';
7
+ import type { AppMode } from '@/types';
8
+
9
+ interface HeaderProps {
10
+ mode?: AppMode;
11
+ onModeChange?: (mode: AppMode) => void;
12
+ }
13
+
14
+ interface BadgeProps {
15
+ href: string;
16
+ icon: React.ReactNode;
17
+ label: string;
18
+ variant: 'default' | 'accent' | 'highlight';
19
+ }
20
+
21
+ function Badge({ href, icon, label, variant }: BadgeProps) {
22
+ const variantStyles = {
23
+ default: 'bg-zinc-800/80 hover:bg-zinc-700/80 text-zinc-300 border-zinc-700/50',
24
+ accent: 'bg-teal-900/30 hover:bg-teal-800/40 text-teal-400 border-teal-700/40',
25
+ highlight: 'bg-amber-900/30 hover:bg-amber-800/40 text-amber-400 border-amber-700/40',
26
+ };
27
+
28
+ return (
29
+ <a
30
+ href={href}
31
+ target="_blank"
32
+ rel="noopener noreferrer"
33
+ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium
34
+ transition-all duration-200 hover:scale-[1.02] border ${variantStyles[variant]}`}
35
+ >
36
+ {icon}
37
+ <span>{label}</span>
38
+ <ExternalLink className="w-3 h-3 opacity-50" />
39
+ </a>
40
+ );
41
+ }
42
+
43
+ interface ModeToggleProps {
44
+ mode: AppMode;
45
+ onModeChange: (mode: AppMode) => void;
46
+ }
47
+
48
+ function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
49
+ return (
50
+ <div className="flex items-center bg-zinc-800/60 rounded-lg p-1 border border-zinc-700/50">
51
+ <button
52
+ onClick={() => onModeChange('chat')}
53
+ className={clsx(
54
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all',
55
+ mode === 'chat'
56
+ ? 'bg-teal-600 text-white shadow-sm'
57
+ : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50'
58
+ )}
59
+ >
60
+ <MessageSquare className="w-3.5 h-3.5" />
61
+ <span>Chat</span>
62
+ </button>
63
+ <button
64
+ onClick={() => onModeChange('practice')}
65
+ className={clsx(
66
+ 'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all',
67
+ mode === 'practice'
68
+ ? 'bg-teal-600 text-white shadow-sm'
69
+ : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50'
70
+ )}
71
+ >
72
+ <Code className="w-3.5 h-3.5" />
73
+ <span>Practice</span>
74
+ </button>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export function Header({ mode = 'chat', onModeChange }: HeaderProps) {
80
+ return (
81
+ <header className="bg-zinc-900/95 backdrop-blur-sm border-b border-zinc-800/80 sticky top-0 z-50">
82
+ <div className="max-w-7xl mx-auto px-4 py-3">
83
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3">
84
+ {/* Title - far left */}
85
+ <div className="flex-shrink-0">
86
+ <div className="flex items-center gap-2">
87
+ <h1 className="text-lg font-semibold text-zinc-100 tracking-tight">
88
+ {PROJECT_CONFIG.name}
89
+ </h1>
90
+ <WarmupIndicator />
91
+ </div>
92
+ <p className="text-xs text-zinc-500">
93
+ {PROJECT_CONFIG.description}
94
+ </p>
95
+ </div>
96
+
97
+ {/* Mode Toggle - center */}
98
+ {onModeChange && (
99
+ <div className="flex-1 flex justify-center">
100
+ <ModeToggle mode={mode} onModeChange={onModeChange} />
101
+ </div>
102
+ )}
103
+
104
+ {/* Badges - far right */}
105
+ <div className="flex flex-wrap items-center gap-2 flex-shrink-0 sm:ml-auto">
106
+ <Badge
107
+ href={LINKS.github}
108
+ icon={<Github className="w-3.5 h-3.5" />}
109
+ label="GitHub"
110
+ variant="default"
111
+ />
112
+ <Badge
113
+ href={LINKS.dataset}
114
+ icon={<Database className="w-3.5 h-3.5" />}
115
+ label="Dataset"
116
+ variant="highlight"
117
+ />
118
+ <Badge
119
+ href={LINKS.models}
120
+ icon={<Boxes className="w-3.5 h-3.5" />}
121
+ label="Models"
122
+ variant="accent"
123
+ />
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </header>
128
+ );
129
+ }
src/components/Practice/AIHelper.tsx ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react';
4
+ import { Sparkles, Send, Loader2, Trash2, ChevronLeft, Copy, Check, Play } from 'lucide-react';
5
+ import ReactMarkdown from 'react-markdown';
6
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
7
+ import { clsx } from 'clsx';
8
+ import type { CodingProblem } from '@/types';
9
+ import { postProcessResponse } from '@/lib/utils/response';
10
+ import { LoadingStatus } from '../Chat/LoadingStatus';
11
+
12
+ interface AIHelperProps {
13
+ problem: CodingProblem | null;
14
+ userCode: string;
15
+ isCollapsed: boolean;
16
+ onToggleCollapse: () => void;
17
+ onApplyCode?: (code: string) => void;
18
+ }
19
+
20
+ interface HelperMessage {
21
+ id: string;
22
+ role: 'user' | 'assistant';
23
+ content: string;
24
+ timestamp: Date;
25
+ }
26
+
27
+ // Custom matte dark theme - matching Chat component
28
+ const customTheme: { [key: string]: React.CSSProperties } = {
29
+ 'code[class*="language-"]': {
30
+ color: '#d4d4d8',
31
+ background: 'none',
32
+ fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace",
33
+ fontSize: '0.8rem',
34
+ textAlign: 'left',
35
+ whiteSpace: 'pre',
36
+ wordSpacing: 'normal',
37
+ wordBreak: 'normal',
38
+ wordWrap: 'normal',
39
+ lineHeight: '1.5',
40
+ tabSize: 4,
41
+ },
42
+ 'pre[class*="language-"]': {
43
+ color: '#d4d4d8',
44
+ background: '#18181b',
45
+ fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace",
46
+ fontSize: '0.8rem',
47
+ textAlign: 'left',
48
+ whiteSpace: 'pre',
49
+ wordSpacing: 'normal',
50
+ wordBreak: 'normal',
51
+ wordWrap: 'normal',
52
+ lineHeight: '1.5',
53
+ tabSize: 4,
54
+ padding: '0.75rem',
55
+ margin: '0',
56
+ overflow: 'auto',
57
+ borderRadius: '0.375rem',
58
+ },
59
+ comment: { color: '#71717a' },
60
+ prolog: { color: '#71717a' },
61
+ doctype: { color: '#71717a' },
62
+ punctuation: { color: '#a1a1aa' },
63
+ property: { color: '#f0abfc' },
64
+ tag: { color: '#f0abfc' },
65
+ boolean: { color: '#c4b5fd' },
66
+ number: { color: '#c4b5fd' },
67
+ constant: { color: '#c4b5fd' },
68
+ symbol: { color: '#c4b5fd' },
69
+ selector: { color: '#86efac' },
70
+ string: { color: '#86efac' },
71
+ char: { color: '#86efac' },
72
+ builtin: { color: '#86efac' },
73
+ operator: { color: '#f0abfc' },
74
+ variable: { color: '#d4d4d8' },
75
+ function: { color: '#93c5fd' },
76
+ 'class-name': { color: '#93c5fd' },
77
+ keyword: { color: '#c4b5fd' },
78
+ regex: { color: '#fcd34d' },
79
+ important: { color: '#fcd34d', fontWeight: 'bold' },
80
+ };
81
+
82
+ const HELPER_PROMPT = `You are a helpful coding assistant for quantum computing practice problems using Qiskit.
83
+
84
+ Your role is to:
85
+ 1. Provide hints and guidance without giving away the complete solution
86
+ 2. Explain quantum computing concepts when asked
87
+ 3. Help debug code issues
88
+ 4. Suggest improvements to the user's approach
89
+
90
+ Guidelines:
91
+ - Be encouraging and educational
92
+ - Give progressively more detailed hints if the user is stuck
93
+ - Focus on teaching, not just solving
94
+ - Reference Qiskit 2.0 best practices
95
+ - Keep responses concise and focused
96
+
97
+ Current problem context will be provided. Help the user learn while they solve the problem themselves.`;
98
+
99
+ function getSolvePrompt(problemType: 'function_completion' | 'code_generation') {
100
+ if (problemType === 'function_completion') {
101
+ return `You are a quantum computing expert using Qiskit.
102
+
103
+ Your task is to provide ONLY the code lines that complete the function body. Do NOT include the function signature/definition - just the implementation lines that go inside the function.
104
+
105
+ Guidelines:
106
+ - Provide ONLY the implementation code (the lines after the function definition)
107
+ - Do NOT repeat the function signature like "def function_name(...):"
108
+ - Include proper indentation for the function body
109
+ - Use Qiskit 2.0 best practices
110
+ - Add brief comments for complex steps
111
+
112
+ Example: If the function is:
113
+ \`\`\`python
114
+ def create_bell_state():
115
+ """Create a Bell state circuit."""
116
+ pass
117
+ \`\`\`
118
+
119
+ You should respond with ONLY:
120
+ \`\`\`python
121
+ qc = QuantumCircuit(2)
122
+ qc.h(0)
123
+ qc.cx(0, 1)
124
+ return qc
125
+ \`\`\`
126
+
127
+ Format your response with ONLY the implementation code in a Python code block.`;
128
+ }
129
+
130
+ return `You are a quantum computing expert using Qiskit.
131
+
132
+ Your task is to provide a complete, working solution for the given problem.
133
+
134
+ Guidelines:
135
+ - Provide a complete, executable Python solution
136
+ - Include all necessary imports
137
+ - Use Qiskit 2.0 best practices
138
+ - Include brief comments explaining key steps
139
+ - Make sure the solution passes the provided tests
140
+
141
+ Format your response with the complete code in a Python code block.`;
142
+ }
143
+
144
+ function looksLikeCode(text: string): boolean {
145
+ const codeIndicators = [
146
+ /^from\s+/m,
147
+ /^import\s+/m,
148
+ /^def\s+/m,
149
+ /^class\s+/m,
150
+ /^\s*return\s+/m,
151
+ /QuantumCircuit/,
152
+ /Parameter\(/,
153
+ /\.\w+\([^)]*\)/m,
154
+ /^\s{4}/m, // Indented code
155
+ /qc\.\w+/,
156
+ /circuit\.\w+/,
157
+ ];
158
+ return codeIndicators.some((p) => p.test(text));
159
+ }
160
+
161
+ interface CodeBlockProps {
162
+ code: string;
163
+ language: string;
164
+ onCopy: () => void;
165
+ onApply?: () => void;
166
+ copied: boolean;
167
+ }
168
+
169
+ function CodeBlock({ code, language, onCopy, onApply, copied }: CodeBlockProps) {
170
+ return (
171
+ <div className="relative group my-2">
172
+ {/* Action buttons - always visible for better discoverability */}
173
+ <div className="absolute right-2 top-2 flex items-center gap-1.5 z-10">
174
+ <span className="text-[10px] text-zinc-500 bg-zinc-900/80 px-1.5 py-0.5 rounded">
175
+ {language || 'python'}
176
+ </span>
177
+
178
+ <button
179
+ onClick={onCopy}
180
+ className="p-1 rounded bg-zinc-800/90 hover:bg-zinc-700 transition-colors"
181
+ title="Copy code"
182
+ >
183
+ {copied ? (
184
+ <Check className="w-3 h-3 text-emerald-400" />
185
+ ) : (
186
+ <Copy className="w-3 h-3 text-zinc-400" />
187
+ )}
188
+ </button>
189
+
190
+ {onApply && (
191
+ <button
192
+ onClick={onApply}
193
+ className="flex items-center gap-1 px-1.5 py-1 rounded bg-teal-700/80 hover:bg-teal-600 text-teal-100 transition-colors text-[10px] font-medium"
194
+ title="Apply code to editor"
195
+ >
196
+ <Play className="w-3 h-3" />
197
+ Apply
198
+ </button>
199
+ )}
200
+ </div>
201
+
202
+ <SyntaxHighlighter
203
+ style={customTheme}
204
+ language={language || 'python'}
205
+ PreTag="div"
206
+ customStyle={{
207
+ margin: 0,
208
+ borderRadius: '0.375rem',
209
+ background: '#18181b',
210
+ padding: '0.75rem',
211
+ paddingTop: '2rem', // Space for buttons
212
+ fontSize: '0.8rem',
213
+ border: '1px solid #27272a',
214
+ lineHeight: '1.5',
215
+ }}
216
+ codeTagProps={{
217
+ style: {
218
+ background: 'none',
219
+ padding: 0,
220
+ },
221
+ }}
222
+ wrapLongLines={false}
223
+ >
224
+ {code}
225
+ </SyntaxHighlighter>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ export function AIHelper({
231
+ problem,
232
+ userCode,
233
+ isCollapsed,
234
+ onToggleCollapse,
235
+ onApplyCode,
236
+ }: AIHelperProps) {
237
+ const [messages, setMessages] = useState<HelperMessage[]>([]);
238
+ const [input, setInput] = useState('');
239
+ const [isLoading, setIsLoading] = useState(false);
240
+ const [hasStartedStreaming, setHasStartedStreaming] = useState(false);
241
+ const [copiedId, setCopiedId] = useState<string | null>(null);
242
+ const abortControllerRef = useRef<AbortController | null>(null);
243
+
244
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
245
+
246
+ const scrollToBottom = useCallback(() => {
247
+ // Scroll only within the messages container, not the whole page
248
+ if (messagesContainerRef.current) {
249
+ messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
250
+ }
251
+ }, []);
252
+
253
+ useEffect(() => {
254
+ // Only scroll within the AI Helper panel, not the whole page
255
+ requestAnimationFrame(() => {
256
+ scrollToBottom();
257
+ });
258
+ }, [messages, scrollToBottom]);
259
+
260
+ useEffect(() => {
261
+ setMessages([]);
262
+ }, [problem?.id]);
263
+
264
+ // Fetch image as base64 for multimodal problems
265
+ const fetchImageBase64 = async (imageUrl: string): Promise<string | null> => {
266
+ try {
267
+ const response = await fetch(imageUrl);
268
+ const blob = await response.blob();
269
+ return new Promise((resolve) => {
270
+ const reader = new FileReader();
271
+ reader.onloadend = () => {
272
+ const base64 = reader.result as string;
273
+ const base64Data = base64.split(',')[1] || base64;
274
+ resolve(base64Data);
275
+ };
276
+ reader.onerror = () => resolve(null);
277
+ reader.readAsDataURL(blob);
278
+ });
279
+ } catch {
280
+ return null;
281
+ }
282
+ };
283
+
284
+ const handleSendMessage = async (customMessage?: string, isSolveRequest = false) => {
285
+ const messageText = customMessage || input.trim();
286
+ if (!messageText || isLoading || !problem) return;
287
+
288
+ if (abortControllerRef.current) {
289
+ abortControllerRef.current.abort();
290
+ }
291
+ abortControllerRef.current = new AbortController();
292
+
293
+ const userMessage: HelperMessage = {
294
+ id: crypto.randomUUID(),
295
+ role: 'user',
296
+ content: messageText,
297
+ timestamp: new Date(),
298
+ };
299
+
300
+ const assistantId = crypto.randomUUID();
301
+ const loadingMessage: HelperMessage = {
302
+ id: assistantId,
303
+ role: 'assistant',
304
+ content: '',
305
+ timestamp: new Date(),
306
+ };
307
+
308
+ setMessages((prev) => [...prev, userMessage, loadingMessage]);
309
+ setInput('');
310
+ setIsLoading(true);
311
+ setHasStartedStreaming(false);
312
+
313
+ try {
314
+ // Build context message with problem info
315
+ let contextMessage = `Problem: ${problem.question}`;
316
+
317
+ if (!isSolveRequest && userCode) {
318
+ contextMessage += `\n\nUser's current code:\n\`\`\`python\n${userCode || '# No code written yet'}\n\`\`\``;
319
+ }
320
+
321
+ contextMessage += `\n\nUser's request: ${messageText}`;
322
+
323
+ // Select appropriate system prompt
324
+ const systemPrompt = isSolveRequest
325
+ ? getSolvePrompt(problem.type as 'function_completion' | 'code_generation')
326
+ : HELPER_PROMPT;
327
+
328
+ // Build messages array
329
+ const apiMessages: Array<{
330
+ role: 'system' | 'user' | 'assistant';
331
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
332
+ }> = [
333
+ { role: 'system', content: systemPrompt },
334
+ ...messages.map((m) => ({
335
+ role: m.role as 'user' | 'assistant',
336
+ content: m.content,
337
+ })),
338
+ ];
339
+
340
+ // Handle multimodal problems - include image if available
341
+ if (problem.imageUrl && problem.hasImage) {
342
+ const imageBase64 = await fetchImageBase64(problem.imageUrl);
343
+ if (imageBase64) {
344
+ apiMessages.push({
345
+ role: 'user',
346
+ content: [
347
+ { type: 'text', text: contextMessage },
348
+ {
349
+ type: 'image_url',
350
+ image_url: { url: `data:image/jpeg;base64,${imageBase64}` },
351
+ },
352
+ ],
353
+ });
354
+ } else {
355
+ apiMessages.push({ role: 'user', content: contextMessage });
356
+ }
357
+ } else {
358
+ apiMessages.push({ role: 'user', content: contextMessage });
359
+ }
360
+
361
+ const response = await fetch('/api/chat', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({
365
+ messages: apiMessages,
366
+ stream: true,
367
+ }),
368
+ signal: abortControllerRef.current.signal,
369
+ });
370
+
371
+ if (!response.ok) {
372
+ const data = await response.json();
373
+ throw new Error(data.error || 'Request failed');
374
+ }
375
+
376
+ const reader = response.body?.getReader();
377
+ if (!reader) throw new Error('No response body');
378
+
379
+ const decoder = new TextDecoder();
380
+ let buffer = '';
381
+ let fullContent = '';
382
+
383
+ while (true) {
384
+ const { done, value } = await reader.read();
385
+ if (done) break;
386
+
387
+ buffer += decoder.decode(value, { stream: true });
388
+ const lines = buffer.split('\n');
389
+ buffer = lines.pop() || '';
390
+
391
+ for (const line of lines) {
392
+ const trimmed = line.trim();
393
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
394
+
395
+ const jsonStr = trimmed.slice(6);
396
+ try {
397
+ const data = JSON.parse(jsonStr);
398
+ if (data.content) {
399
+ // First content received - streaming has started
400
+ if (fullContent === '') {
401
+ setHasStartedStreaming(true);
402
+ }
403
+ fullContent += data.content;
404
+ // Use postProcessResponse like ChatInterface does for proper formatting
405
+ const processedContent = postProcessResponse(fullContent);
406
+ setMessages((prev) =>
407
+ prev.map((m) =>
408
+ m.id === assistantId ? { ...m, content: processedContent } : m
409
+ )
410
+ );
411
+ }
412
+ } catch {
413
+ continue;
414
+ }
415
+ }
416
+ }
417
+
418
+ // Apply final post-processing
419
+ const finalContent = postProcessResponse(fullContent);
420
+ setMessages((prev) =>
421
+ prev.map((m) =>
422
+ m.id === assistantId ? { ...m, content: finalContent } : m
423
+ )
424
+ );
425
+ } catch (error) {
426
+ if ((error as Error).name === 'AbortError') return;
427
+
428
+ setMessages((prev) =>
429
+ prev.map((m) =>
430
+ m.id === assistantId
431
+ ? { ...m, content: `Error: ${error instanceof Error ? error.message : 'Failed'}` }
432
+ : m
433
+ )
434
+ );
435
+ } finally {
436
+ setIsLoading(false);
437
+ setHasStartedStreaming(false);
438
+ abortControllerRef.current = null;
439
+ }
440
+ };
441
+
442
+ const handleKeyDown = (e: React.KeyboardEvent) => {
443
+ if (e.key === 'Enter' && !e.shiftKey) {
444
+ e.preventDefault();
445
+ handleSendMessage();
446
+ }
447
+ };
448
+
449
+ const clearMessages = () => {
450
+ if (abortControllerRef.current) {
451
+ abortControllerRef.current.abort();
452
+ }
453
+ setMessages([]);
454
+ };
455
+
456
+ // Extract code blocks from message content, preserving indentation
457
+ const extractCodeBlocks = (content: string): string[] => {
458
+ const codeBlockRegex = /```(?:python)?\n?([\s\S]*?)```/g;
459
+ const blocks: string[] = [];
460
+ let match;
461
+ while ((match = codeBlockRegex.exec(content)) !== null) {
462
+ // Preserve indentation - only trim trailing newlines, not leading whitespace
463
+ const code = match[1].replace(/\n+$/, '');
464
+ blocks.push(code);
465
+ }
466
+ return blocks;
467
+ };
468
+
469
+ const handleCopyCode = (code: string, messageId: string) => {
470
+ navigator.clipboard.writeText(code);
471
+ setCopiedId(messageId);
472
+ setTimeout(() => setCopiedId(null), 2000);
473
+ };
474
+
475
+ const handleApplyCode = (code: string) => {
476
+ if (onApplyCode) {
477
+ onApplyCode(code);
478
+ }
479
+ };
480
+
481
+ // Process content to add code blocks where needed
482
+ const processContent = useCallback((content: string): string => {
483
+ // If already has code blocks, return as-is
484
+ if (content.includes('```')) {
485
+ return content;
486
+ }
487
+
488
+ // If it looks like code, wrap it
489
+ if (looksLikeCode(content)) {
490
+ return '```python\n' + content + '\n```';
491
+ }
492
+
493
+ return content;
494
+ }, []);
495
+
496
+ // Collapsed view
497
+ if (isCollapsed) {
498
+ return (
499
+ <button
500
+ onClick={onToggleCollapse}
501
+ className="h-full w-full flex flex-col items-center justify-center gap-2 bg-zinc-900/95 border-l border-zinc-800/80 hover:bg-zinc-800/50 transition-colors cursor-pointer"
502
+ title="Expand AI Helper"
503
+ >
504
+ <Sparkles className="w-5 h-5 text-teal-500" />
505
+ <span className="text-xs text-zinc-500 [writing-mode:vertical-lr] rotate-180 font-medium">
506
+ AI Helper
507
+ </span>
508
+ </button>
509
+ );
510
+ }
511
+
512
+ // Expanded view
513
+ return (
514
+ <div className="h-full flex flex-col bg-zinc-900/95 border-l border-zinc-800/80">
515
+ <div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800/80 flex-shrink-0">
516
+ <div className="flex items-center gap-2">
517
+ <Sparkles className="w-4 h-4 text-teal-500" />
518
+ <h3 className="font-semibold text-zinc-200 text-sm">AI Helper</h3>
519
+ </div>
520
+ <div className="flex items-center gap-1">
521
+ {messages.length > 0 && (
522
+ <button
523
+ onClick={clearMessages}
524
+ className="p-1.5 rounded-md hover:bg-zinc-800/50 transition-colors"
525
+ title="Clear chat"
526
+ >
527
+ <Trash2 className="w-3.5 h-3.5 text-zinc-500" />
528
+ </button>
529
+ )}
530
+ <button
531
+ onClick={onToggleCollapse}
532
+ className="p-1.5 rounded-md hover:bg-zinc-800/50 transition-colors"
533
+ title="Collapse"
534
+ >
535
+ <ChevronLeft className="w-4 h-4 text-zinc-500 rotate-180" />
536
+ </button>
537
+ </div>
538
+ </div>
539
+
540
+ <div ref={messagesContainerRef} className="flex-1 overflow-y-auto p-3 space-y-3 min-h-0">
541
+ {messages.length === 0 ? (
542
+ <div className="flex flex-col items-center justify-center h-full text-center px-4">
543
+ <Sparkles className="w-8 h-8 text-teal-500/50 mb-3" />
544
+ <p className="text-sm text-zinc-500 mb-4">
545
+ Need help? Ask for hints or get the solution.
546
+ </p>
547
+ {problem && (
548
+ <div className="space-y-2 w-full">
549
+ {[
550
+ { label: 'Give me a hint', isSolve: false },
551
+ { label: 'Explain the concept', isSolve: false },
552
+ { label: 'Solve it', isSolve: true },
553
+ ].map(({ label, isSolve }) => (
554
+ <button
555
+ key={label}
556
+ onClick={() => handleSendMessage(label, isSolve)}
557
+ className={clsx(
558
+ 'w-full text-left px-3 py-2 rounded-md text-xs transition-colors',
559
+ isSolve
560
+ ? 'bg-teal-900/40 hover:bg-teal-800/50 text-teal-300 hover:text-teal-200 border border-teal-700/30'
561
+ : 'bg-zinc-800/60 hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200'
562
+ )}
563
+ >
564
+ {label}
565
+ </button>
566
+ ))}
567
+ </div>
568
+ )}
569
+ </div>
570
+ ) : (
571
+ messages.map((message) => {
572
+ const processedContent = processContent(message.content);
573
+ const codeBlocks = extractCodeBlocks(processedContent);
574
+ const hasCode = codeBlocks.length > 0;
575
+
576
+ return (
577
+ <div
578
+ key={message.id}
579
+ className={clsx(
580
+ 'flex flex-col',
581
+ message.role === 'user' ? 'items-end' : 'items-start'
582
+ )}
583
+ >
584
+ <div
585
+ className={clsx(
586
+ 'max-w-[95%] rounded-lg px-3 py-2 text-sm',
587
+ message.role === 'user'
588
+ ? 'bg-teal-700/60 text-white'
589
+ : 'bg-zinc-800/80 text-zinc-300'
590
+ )}
591
+ >
592
+ {message.role === 'assistant' && !message.content ? (
593
+ <LoadingStatus
594
+ isLoading={isLoading}
595
+ hasStartedStreaming={hasStartedStreaming}
596
+ />
597
+ ) : (
598
+ <ReactMarkdown
599
+ components={{
600
+ code({ className, children, ...props }) {
601
+ const match = /language-(\w+)/.exec(className || '');
602
+ const code = String(children).replace(/\n$/, '');
603
+ const isBlock = match || code.includes('\n') || looksLikeCode(code);
604
+
605
+ if (isBlock) {
606
+ return (
607
+ <CodeBlock
608
+ code={code}
609
+ language={match?.[1] || 'python'}
610
+ onCopy={() => handleCopyCode(code, message.id)}
611
+ onApply={onApplyCode ? () => handleApplyCode(code) : undefined}
612
+ copied={copiedId === message.id}
613
+ />
614
+ );
615
+ }
616
+
617
+ return (
618
+ <code className="bg-zinc-700/50 px-1 py-0.5 rounded text-xs" {...props}>
619
+ {children}
620
+ </code>
621
+ );
622
+ },
623
+ pre({ children }) {
624
+ return <>{children}</>;
625
+ },
626
+ p({ children }) {
627
+ return <p className="mb-2 last:mb-0">{children}</p>;
628
+ },
629
+ ul({ children }) {
630
+ return <ul className="list-disc ml-4 mb-2 space-y-1">{children}</ul>;
631
+ },
632
+ ol({ children }) {
633
+ return <ol className="list-decimal ml-4 mb-2 space-y-1">{children}</ol>;
634
+ },
635
+ li({ children }) {
636
+ return <li className="text-zinc-300">{children}</li>;
637
+ },
638
+ strong({ children }) {
639
+ return <strong className="font-semibold text-zinc-200">{children}</strong>;
640
+ },
641
+ }}
642
+ >
643
+ {processedContent}
644
+ </ReactMarkdown>
645
+ )}
646
+ </div>
647
+ </div>
648
+ );
649
+ })
650
+ )}
651
+ </div>
652
+
653
+ {problem && (
654
+ <div className="p-3 border-t border-zinc-800/80 flex-shrink-0">
655
+ <div className="flex items-end gap-2">
656
+ <textarea
657
+ value={input}
658
+ onChange={(e) => setInput(e.target.value)}
659
+ onKeyDown={handleKeyDown}
660
+ placeholder="Ask for help..."
661
+ disabled={isLoading}
662
+ rows={1}
663
+ className="flex-1 bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-teal-600/50 min-h-[40px] max-h-[100px]"
664
+ style={{ height: 'auto' }}
665
+ onInput={(e) => {
666
+ const target = e.target as HTMLTextAreaElement;
667
+ target.style.height = 'auto';
668
+ target.style.height = `${Math.min(target.scrollHeight, 100)}px`;
669
+ }}
670
+ />
671
+ <button
672
+ onClick={() => handleSendMessage()}
673
+ disabled={!input.trim() || isLoading}
674
+ className={clsx(
675
+ 'p-2 rounded-lg transition-all',
676
+ input.trim() && !isLoading
677
+ ? 'bg-teal-600 hover:bg-teal-500 text-white'
678
+ : 'bg-zinc-800 text-zinc-500'
679
+ )}
680
+ >
681
+ {isLoading ? (
682
+ <Loader2 className="w-4 h-4 animate-spin" />
683
+ ) : (
684
+ <Send className="w-4 h-4" />
685
+ )}
686
+ </button>
687
+ </div>
688
+ </div>
689
+ )}
690
+ </div>
691
+ );
692
+ }
src/components/Practice/CodeEditor.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useRef, useCallback } from 'react';
4
+ import Editor, { OnMount, OnChange } from '@monaco-editor/react';
5
+ import type * as Monaco from 'monaco-editor';
6
+ import { Loader2 } from 'lucide-react';
7
+
8
+ interface CodeEditorProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ language?: string;
12
+ readOnly?: boolean;
13
+ height?: string;
14
+ className?: string;
15
+ }
16
+
17
+ export function CodeEditor({
18
+ value,
19
+ onChange,
20
+ language = 'python',
21
+ readOnly = false,
22
+ height = '100%',
23
+ className,
24
+ }: CodeEditorProps) {
25
+ const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
26
+
27
+ const handleEditorMount: OnMount = useCallback((editor) => {
28
+ editorRef.current = editor;
29
+ // Don't auto-focus to prevent unwanted page scroll when selecting problems
30
+ }, []);
31
+
32
+ const handleChange: OnChange = useCallback((val) => {
33
+ onChange(val || '');
34
+ }, [onChange]);
35
+
36
+ return (
37
+ <div className={className} style={{ height, minHeight: '300px' }}>
38
+ <Editor
39
+ height="100%"
40
+ width="100%"
41
+ language={language}
42
+ value={value}
43
+ onChange={handleChange}
44
+ onMount={handleEditorMount}
45
+ theme="quantum-dark"
46
+ loading={
47
+ <div className="flex items-center justify-center h-full bg-zinc-900">
48
+ <Loader2 className="w-6 h-6 animate-spin text-teal-500" />
49
+ </div>
50
+ }
51
+ beforeMount={(monaco) => {
52
+ monaco.editor.defineTheme('quantum-dark', {
53
+ base: 'vs-dark',
54
+ inherit: true,
55
+ rules: [
56
+ { token: 'comment', foreground: '71717a', fontStyle: 'italic' },
57
+ { token: 'keyword', foreground: 'c4b5fd' },
58
+ { token: 'string', foreground: '86efac' },
59
+ { token: 'number', foreground: 'c4b5fd' },
60
+ { token: 'type', foreground: '93c5fd' },
61
+ { token: 'function', foreground: '93c5fd' },
62
+ { token: 'variable', foreground: 'd4d4d8' },
63
+ { token: 'operator', foreground: 'f0abfc' },
64
+ { token: 'delimiter', foreground: 'a1a1aa' },
65
+ ],
66
+ colors: {
67
+ 'editor.background': '#18181b',
68
+ 'editor.foreground': '#d4d4d8',
69
+ 'editor.lineHighlightBackground': '#27272a',
70
+ 'editor.selectionBackground': '#0d9488aa',
71
+ 'editor.inactiveSelectionBackground': '#27272a',
72
+ 'editorCursor.foreground': '#14b8a6',
73
+ 'editorLineNumber.foreground': '#52525b',
74
+ 'editorLineNumber.activeForeground': '#a1a1aa',
75
+ 'editorIndentGuide.background': '#27272a',
76
+ 'editorIndentGuide.activeBackground': '#3f3f46',
77
+ 'editor.selectionHighlightBackground': '#0d94882a',
78
+ 'editorBracketMatch.background': '#0d94884a',
79
+ 'editorBracketMatch.border': '#14b8a6',
80
+ 'scrollbar.shadow': '#00000000',
81
+ 'scrollbarSlider.background': '#3f3f4680',
82
+ 'scrollbarSlider.hoverBackground': '#52525b80',
83
+ 'scrollbarSlider.activeBackground': '#71717a80',
84
+ },
85
+ });
86
+ }}
87
+ options={{
88
+ readOnly,
89
+ fontSize: 14,
90
+ fontFamily: "'JetBrains Mono', Consolas, 'Courier New', monospace",
91
+ fontLigatures: true,
92
+ lineHeight: 1.6,
93
+ padding: { top: 16, bottom: 16 },
94
+ minimap: { enabled: false },
95
+ scrollBeyondLastLine: false,
96
+ automaticLayout: true,
97
+ tabSize: 4,
98
+ insertSpaces: true,
99
+ wordWrap: 'on',
100
+ lineNumbers: 'on',
101
+ glyphMargin: false,
102
+ folding: true,
103
+ lineDecorationsWidth: 8,
104
+ lineNumbersMinChars: 4,
105
+ renderLineHighlight: 'line',
106
+ cursorBlinking: 'smooth',
107
+ cursorSmoothCaretAnimation: 'on',
108
+ smoothScrolling: true,
109
+ contextmenu: true,
110
+ quickSuggestions: true,
111
+ suggestOnTriggerCharacters: true,
112
+ acceptSuggestionOnEnter: 'on',
113
+ formatOnPaste: true,
114
+ formatOnType: true,
115
+ bracketPairColorization: { enabled: true },
116
+ }}
117
+ />
118
+ </div>
119
+ );
120
+ }
121
+
src/components/Practice/PracticeInterface.tsx ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useEffect, useMemo } from 'react';
4
+ import { ChevronLeft, ChevronRight, FileText, Lightbulb, Image as ImageIcon } from 'lucide-react';
5
+ import { clsx } from 'clsx';
6
+ import { CodeEditor } from './CodeEditor';
7
+ import { ProblemList } from './ProblemList';
8
+ import { TestRunner } from './TestRunner';
9
+ import { AIHelper } from './AIHelper';
10
+ import { TASK_LABELS, CATEGORY_LABELS } from '@/config/constants';
11
+ import { extractCodeFromResponse, normalizeIndentation } from '@/lib/utils/response';
12
+ import type { CodingProblem, TestResult } from '@/types';
13
+
14
+ interface PracticeInterfaceProps {
15
+ className?: string;
16
+ }
17
+
18
+ // State saved per problem
19
+ interface ProblemState {
20
+ code: string;
21
+ testResult: TestResult | null;
22
+ }
23
+
24
+ export function PracticeInterface({ className }: PracticeInterfaceProps) {
25
+ const [selectedProblem, setSelectedProblem] = useState<CodingProblem | null>(null);
26
+ const [userCode, setUserCode] = useState('');
27
+ const [currentTestResult, setCurrentTestResult] = useState<TestResult | null>(null);
28
+
29
+ // Store state per problem (code and test results)
30
+ const [problemStates, setProblemStates] = useState<Map<string, ProblemState>>(new Map());
31
+
32
+ const [solvedProblems, setSolvedProblems] = useState<Set<string>>(() => {
33
+ if (typeof window !== 'undefined') {
34
+ const stored = localStorage.getItem('solvedProblems');
35
+ if (stored) {
36
+ try {
37
+ return new Set(JSON.parse(stored));
38
+ } catch {
39
+ return new Set();
40
+ }
41
+ }
42
+ }
43
+ return new Set();
44
+ });
45
+
46
+ const [isProblemListCollapsed, setIsProblemListCollapsed] = useState(false);
47
+ const [isAIHelperCollapsed, setIsAIHelperCollapsed] = useState(true);
48
+ const [problemListWidth, setProblemListWidth] = useState(320);
49
+ const [aiHelperWidth, setAIHelperWidth] = useState(320);
50
+
51
+ useEffect(() => {
52
+ if (typeof window !== 'undefined') {
53
+ localStorage.setItem('solvedProblems', JSON.stringify([...solvedProblems]));
54
+ }
55
+ }, [solvedProblems]);
56
+
57
+ // Save current problem state when code changes
58
+ useEffect(() => {
59
+ if (selectedProblem && userCode) {
60
+ setProblemStates(prev => {
61
+ const newStates = new Map(prev);
62
+ const existing = newStates.get(selectedProblem.id);
63
+ newStates.set(selectedProblem.id, {
64
+ code: userCode,
65
+ testResult: existing?.testResult ?? currentTestResult,
66
+ });
67
+ return newStates;
68
+ });
69
+ }
70
+ }, [selectedProblem, userCode]);
71
+
72
+ // Extract code from question for function_completion problems
73
+ const extractCodeFromQuestion = useCallback((question: string): { description: string; code: string | null } => {
74
+ const codeBlockMatch = question.match(/```python\n([\s\S]*?)```/);
75
+ if (codeBlockMatch) {
76
+ // Remove the code block from the description
77
+ const description = question.replace(/```python\n[\s\S]*?```/, '').trim();
78
+ return { description, code: codeBlockMatch[1].trim() };
79
+ }
80
+ return { description: question, code: null };
81
+ }, []);
82
+
83
+ // Get display description (without code block for function_completion)
84
+ const displayDescription = useMemo(() => {
85
+ if (!selectedProblem) return '';
86
+ if (selectedProblem.type === 'function_completion') {
87
+ const { description } = extractCodeFromQuestion(selectedProblem.question);
88
+ return description || 'Complete the function below:';
89
+ }
90
+ return selectedProblem.question;
91
+ }, [selectedProblem, extractCodeFromQuestion]);
92
+
93
+ // Get the function signature for function_completion problems
94
+ // Returns imports + def line + docstring (everything before 'pass')
95
+ const getFunctionSignature = useCallback((question: string): string | null => {
96
+ const { code } = extractCodeFromQuestion(question);
97
+ if (!code) return null;
98
+
99
+ const lines = code.split('\n');
100
+ const signatureLines: string[] = [];
101
+ let foundDef = false;
102
+ let inDocstring = false;
103
+ let docstringChar = '';
104
+ let docstringComplete = false;
105
+
106
+ for (const line of lines) {
107
+ const trimmed = line.trim();
108
+
109
+ // Check if this is the def line
110
+ if (!foundDef && trimmed.startsWith('def ')) {
111
+ foundDef = true;
112
+ signatureLines.push(line);
113
+ continue;
114
+ }
115
+
116
+ // If we haven't found def yet, this is an import or other preamble - include it
117
+ if (!foundDef) {
118
+ signatureLines.push(line);
119
+ continue;
120
+ }
121
+
122
+ // After def line, check for docstring
123
+ if (!inDocstring && !docstringComplete && (line.includes('"""') || line.includes("'''"))) {
124
+ signatureLines.push(line);
125
+ docstringChar = line.includes('"""') ? '"""' : "'''";
126
+ // Check if docstring starts and ends on same line
127
+ const count = (line.match(new RegExp(docstringChar.replace(/"/g, '\\"'), 'g')) || []).length;
128
+ if (count >= 2) {
129
+ // Docstring complete on one line
130
+ docstringComplete = true;
131
+ continue;
132
+ }
133
+ inDocstring = true;
134
+ continue;
135
+ }
136
+
137
+ // Check for docstring end (multi-line docstring)
138
+ if (inDocstring && line.includes(docstringChar)) {
139
+ signatureLines.push(line);
140
+ inDocstring = false;
141
+ docstringComplete = true;
142
+ continue;
143
+ }
144
+
145
+ // Still inside multi-line docstring
146
+ if (inDocstring) {
147
+ signatureLines.push(line);
148
+ continue;
149
+ }
150
+
151
+ // After docstring is complete, stop at 'pass' or any actual code
152
+ if (docstringComplete || foundDef) {
153
+ if (trimmed === 'pass' || trimmed === '' || trimmed.startsWith('#')) {
154
+ // Skip 'pass', empty lines, and comments after docstring
155
+ continue;
156
+ }
157
+ // Found actual implementation code - stop here
158
+ break;
159
+ }
160
+ }
161
+
162
+ return signatureLines.join('\n');
163
+ }, [extractCodeFromQuestion]);
164
+
165
+ const handleSelectProblem = useCallback((problem: CodingProblem) => {
166
+ // Check if we have saved state for this problem
167
+ const savedState = problemStates.get(problem.id);
168
+
169
+ if (savedState) {
170
+ // Restore saved code and test result
171
+ setUserCode(savedState.code);
172
+ setCurrentTestResult(savedState.testResult);
173
+ } else {
174
+ // Set initial code template based on problem type
175
+ if (problem.type === 'function_completion') {
176
+ const { code } = extractCodeFromQuestion(problem.question);
177
+ if (code) {
178
+ setUserCode(code + '\n # Your code here\n pass');
179
+ } else {
180
+ setUserCode('# Write your solution here\n');
181
+ }
182
+ } else {
183
+ setUserCode('# Write your solution here\n');
184
+ }
185
+ // Reset test result for new problem
186
+ setCurrentTestResult(null);
187
+ }
188
+
189
+ setSelectedProblem(problem);
190
+ }, [extractCodeFromQuestion, problemStates]);
191
+
192
+ const handleTestComplete = useCallback((result: TestResult) => {
193
+ setCurrentTestResult(result);
194
+
195
+ if (selectedProblem) {
196
+ // Save test result to problem state
197
+ setProblemStates(prev => {
198
+ const newStates = new Map(prev);
199
+ newStates.set(selectedProblem.id, {
200
+ code: userCode,
201
+ testResult: result,
202
+ });
203
+ return newStates;
204
+ });
205
+
206
+ if (result.passed) {
207
+ setSolvedProblems((prev) => new Set([...prev, selectedProblem.id]));
208
+ }
209
+ }
210
+ }, [selectedProblem, userCode]);
211
+
212
+ const toggleAIHelper = useCallback(() => {
213
+ setIsAIHelperCollapsed(prev => !prev);
214
+ }, []);
215
+
216
+ // Handler to apply code from AI Helper to the editor
217
+ const handleApplyCode = useCallback((code: string) => {
218
+ if (!selectedProblem) {
219
+ setUserCode(code);
220
+ return;
221
+ }
222
+
223
+ // Extract actual code from markdown code blocks if present
224
+ const extractedCode = extractCodeFromResponse(code, selectedProblem.entryPoint);
225
+
226
+ if (selectedProblem.type === 'function_completion') {
227
+ // For function completion, combine the function signature with the generated body
228
+ const signature = getFunctionSignature(selectedProblem.question);
229
+ if (signature) {
230
+ // Check if the AI response already includes the full function definition
231
+ const hasFullFunction = extractedCode.match(/^\s*def\s+\w+\s*\(/m);
232
+
233
+ if (hasFullFunction) {
234
+ // AI returned full function - use it directly
235
+ const normalized = normalizeIndentation(extractedCode, 0);
236
+ setUserCode(normalized);
237
+ } else {
238
+ // AI returned only the body - combine with signature
239
+ // Normalize the body code to have consistent 4-space indentation for function body
240
+ const normalizedBody = normalizeIndentation(extractedCode, 4);
241
+ setUserCode(signature + '\n' + normalizedBody);
242
+ }
243
+ } else {
244
+ setUserCode(extractedCode);
245
+ }
246
+ } else {
247
+ // For code generation, replace the entire code
248
+ const normalized = normalizeIndentation(extractedCode, 0);
249
+ setUserCode(normalized);
250
+ }
251
+ }, [selectedProblem, getFunctionSignature]);
252
+
253
+ return (
254
+ <div className={clsx('h-full flex overflow-hidden', className)}>
255
+ {/* Problem List Sidebar */}
256
+ <div
257
+ className={clsx(
258
+ 'flex-shrink-0 transition-all duration-200 relative h-full',
259
+ isProblemListCollapsed ? 'w-12' : ''
260
+ )}
261
+ style={{ width: isProblemListCollapsed ? 48 : problemListWidth }}
262
+ >
263
+ {isProblemListCollapsed ? (
264
+ <div className="h-full flex flex-col items-center pt-4 bg-zinc-900/95 border-r border-zinc-800/80">
265
+ <button
266
+ onClick={() => setIsProblemListCollapsed(false)}
267
+ className="p-2 rounded-md hover:bg-zinc-800/50 transition-colors"
268
+ title="Expand problems"
269
+ >
270
+ <span className="text-xs text-zinc-500 [writing-mode:vertical-lr] font-medium">
271
+ Problems
272
+ </span>
273
+ </button>
274
+ </div>
275
+ ) : (
276
+ <>
277
+ <ProblemList
278
+ onSelectProblem={handleSelectProblem}
279
+ selectedProblemId={selectedProblem?.id}
280
+ solvedProblems={solvedProblems}
281
+ />
282
+ <button
283
+ onClick={() => setIsProblemListCollapsed(true)}
284
+ className="absolute -right-3 top-4 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700/50 flex items-center justify-center hover:bg-zinc-700 transition-colors z-50"
285
+ title="Collapse problems"
286
+ >
287
+ <ChevronLeft className="w-4 h-4 text-zinc-400" />
288
+ </button>
289
+ <div
290
+ className="absolute top-0 bottom-0 -right-0.5 w-1 cursor-col-resize hover:bg-teal-500/50 transition-colors z-40"
291
+ onMouseDown={(e) => {
292
+ e.preventDefault();
293
+ const startX = e.clientX;
294
+ const startWidth = problemListWidth;
295
+
296
+ const handleMouseMove = (moveEvent: MouseEvent) => {
297
+ const newWidth = Math.min(500, Math.max(240, startWidth + moveEvent.clientX - startX));
298
+ setProblemListWidth(newWidth);
299
+ };
300
+
301
+ const handleMouseUp = () => {
302
+ document.removeEventListener('mousemove', handleMouseMove);
303
+ document.removeEventListener('mouseup', handleMouseUp);
304
+ document.body.style.cursor = '';
305
+ document.body.style.userSelect = '';
306
+ };
307
+
308
+ document.addEventListener('mousemove', handleMouseMove);
309
+ document.addEventListener('mouseup', handleMouseUp);
310
+ document.body.style.cursor = 'col-resize';
311
+ document.body.style.userSelect = 'none';
312
+ }}
313
+ />
314
+ </>
315
+ )}
316
+ </div>
317
+
318
+ {/* Main Content */}
319
+ <div className="flex-1 flex flex-col min-w-0 bg-zinc-950 h-full overflow-hidden">
320
+ {selectedProblem ? (
321
+ <>
322
+ {/* Problem Description - compact header */}
323
+ <div className="flex-shrink-0 border-b border-zinc-800/80 bg-zinc-900/50">
324
+ <div className="px-4 py-3">
325
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
326
+ <span
327
+ className={clsx(
328
+ 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
329
+ selectedProblem.type === 'function_completion'
330
+ ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/30'
331
+ : 'bg-blue-900/30 text-blue-400 border-blue-700/30'
332
+ )}
333
+ >
334
+ {TASK_LABELS[selectedProblem.type].label}
335
+ </span>
336
+ <span className="text-xs text-zinc-500">
337
+ {CATEGORY_LABELS[selectedProblem.category]}
338
+ </span>
339
+ {selectedProblem.hasImage && (
340
+ <span className="flex items-center gap-1 text-xs text-zinc-500">
341
+ <ImageIcon className="w-3.5 h-3.5" />
342
+ Has image
343
+ </span>
344
+ )}
345
+ </div>
346
+ <div className="flex items-start gap-4">
347
+ <div className="flex-1 min-w-0">
348
+ <p className="text-sm text-zinc-300 leading-relaxed">
349
+ {displayDescription}
350
+ </p>
351
+ </div>
352
+ {selectedProblem.imageUrl && (
353
+ <div className="flex-shrink-0">
354
+ <img
355
+ src={selectedProblem.imageUrl}
356
+ alt="Problem illustration"
357
+ className="max-w-[160px] max-h-24 rounded-lg border border-zinc-700/50 bg-zinc-900 object-contain"
358
+ />
359
+ </div>
360
+ )}
361
+ </div>
362
+ </div>
363
+ </div>
364
+
365
+ {/* Test Runner - at top, compact */}
366
+ <div className="flex-shrink-0 border-b border-zinc-800/80">
367
+ <TestRunner
368
+ key={selectedProblem.id}
369
+ userCode={userCode}
370
+ testCode={selectedProblem.testCode}
371
+ entryPoint={selectedProblem.entryPoint}
372
+ onTestComplete={handleTestComplete}
373
+ initialResult={currentTestResult}
374
+ />
375
+ </div>
376
+
377
+ {/* Code Editor - takes remaining space */}
378
+ <div className="flex-1 min-h-0">
379
+ <CodeEditor
380
+ value={userCode}
381
+ onChange={setUserCode}
382
+ language="python"
383
+ height="calc(100vh - 220px)"
384
+ />
385
+ </div>
386
+ </>
387
+ ) : (
388
+ <div className="flex-1 flex flex-col items-center justify-center text-center px-4">
389
+ <div className="w-16 h-16 mb-5 rounded-xl bg-zinc-800/80 border border-teal-700/30 flex items-center justify-center">
390
+ <FileText className="w-8 h-8 text-teal-400" />
391
+ </div>
392
+ <h2 className="text-xl font-semibold text-zinc-200 mb-2">
393
+ Practice Mode
394
+ </h2>
395
+ <p className="text-zinc-500 max-w-md mb-6 text-sm leading-relaxed">
396
+ Select a coding problem from the sidebar to start practicing.
397
+ Solve problems and run unit tests to verify your solutions.
398
+ </p>
399
+ <div className="flex items-center gap-2 text-xs text-zinc-600">
400
+ <Lightbulb className="w-4 h-4" />
401
+ <span>Use the AI Helper for hints and guidance</span>
402
+ </div>
403
+ </div>
404
+ )}
405
+ </div>
406
+
407
+ {/* AI Helper Sidebar */}
408
+ <div
409
+ className={clsx(
410
+ 'flex-shrink-0 transition-all duration-200 relative h-full'
411
+ )}
412
+ style={{ width: isAIHelperCollapsed ? 48 : aiHelperWidth }}
413
+ >
414
+ {!isAIHelperCollapsed && (
415
+ <>
416
+ <button
417
+ onClick={toggleAIHelper}
418
+ className="absolute -left-3 top-4 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700/50 flex items-center justify-center hover:bg-zinc-700 transition-colors z-50"
419
+ title="Collapse AI Helper"
420
+ >
421
+ <ChevronRight className="w-4 h-4 text-zinc-400" />
422
+ </button>
423
+ <div
424
+ className="absolute top-0 bottom-0 -left-0.5 w-1 cursor-col-resize hover:bg-teal-500/50 transition-colors z-40"
425
+ onMouseDown={(e) => {
426
+ e.preventDefault();
427
+ const startX = e.clientX;
428
+ const startWidth = aiHelperWidth;
429
+
430
+ const handleMouseMove = (moveEvent: MouseEvent) => {
431
+ const newWidth = Math.min(500, Math.max(240, startWidth - (moveEvent.clientX - startX)));
432
+ setAIHelperWidth(newWidth);
433
+ };
434
+
435
+ const handleMouseUp = () => {
436
+ document.removeEventListener('mousemove', handleMouseMove);
437
+ document.removeEventListener('mouseup', handleMouseUp);
438
+ document.body.style.cursor = '';
439
+ document.body.style.userSelect = '';
440
+ };
441
+
442
+ document.addEventListener('mousemove', handleMouseMove);
443
+ document.addEventListener('mouseup', handleMouseUp);
444
+ document.body.style.cursor = 'col-resize';
445
+ document.body.style.userSelect = 'none';
446
+ }}
447
+ />
448
+ </>
449
+ )}
450
+ <AIHelper
451
+ problem={selectedProblem}
452
+ userCode={userCode}
453
+ isCollapsed={isAIHelperCollapsed}
454
+ onToggleCollapse={toggleAIHelper}
455
+ onApplyCode={handleApplyCode}
456
+ />
457
+ </div>
458
+ </div>
459
+ );
460
+ }
src/components/Practice/ProblemList.tsx ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
4
+ import {
5
+ Code,
6
+ Loader2,
7
+ RefreshCw,
8
+ Filter,
9
+ ChevronDown,
10
+ ChevronLeft,
11
+ ChevronRight,
12
+ CheckCircle2,
13
+ Circle,
14
+ Search,
15
+ X,
16
+ Image as ImageIcon,
17
+ Database,
18
+ } from 'lucide-react';
19
+ import { clsx } from 'clsx';
20
+ import { useDataset } from '@/lib/dataset/DatasetProvider';
21
+ import { TASK_LABELS, CATEGORY_LABELS } from '@/config/constants';
22
+ import type { CodingProblem, TaskType, Category } from '@/types';
23
+
24
+ interface ProblemListProps {
25
+ onSelectProblem: (problem: CodingProblem) => void;
26
+ selectedProblemId?: string;
27
+ solvedProblems: Set<string>;
28
+ }
29
+
30
+ const ITEMS_PER_PAGE = 25;
31
+
32
+ type Split = 'validation' | 'test';
33
+
34
+ export function ProblemList({
35
+ onSelectProblem,
36
+ selectedProblemId,
37
+ solvedProblems,
38
+ }: ProblemListProps) {
39
+ const { isLoading: isDatasetLoading, loadedSplits, splitCounts, filterExamples } = useDataset();
40
+
41
+ const [typeFilter, setTypeFilter] = useState<TaskType | 'all'>('all');
42
+ const [categoryFilter, setCategoryFilter] = useState<Category | 'all'>('all');
43
+ const [multimodalFilter, setMultimodalFilter] = useState<'all' | 'with' | 'without'>('all');
44
+ const [showFilters, setShowFilters] = useState(false);
45
+ const [searchQuery, setSearchQuery] = useState('');
46
+ const [debouncedSearch, setDebouncedSearch] = useState('');
47
+ const [split, setSplit] = useState<Split>('test');
48
+ const [currentPage, setCurrentPage] = useState(0);
49
+
50
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
51
+ const searchInputRef = useRef<HTMLInputElement>(null);
52
+
53
+ // Debounce search
54
+ useEffect(() => {
55
+ const timer = setTimeout(() => {
56
+ setDebouncedSearch(searchQuery);
57
+ setCurrentPage(0);
58
+ }, 300);
59
+ return () => clearTimeout(timer);
60
+ }, [searchQuery]);
61
+
62
+ // Reset page when filters change
63
+ useEffect(() => {
64
+ setCurrentPage(0);
65
+ }, [split, typeFilter, categoryFilter, multimodalFilter, debouncedSearch]);
66
+
67
+ // Filter locally loaded data
68
+ const { problems, totalProblems } = useMemo(() => {
69
+ if (!loadedSplits.has(split)) {
70
+ return { problems: [], totalProblems: 0 };
71
+ }
72
+
73
+ const filters: {
74
+ type?: TaskType;
75
+ category?: Category;
76
+ hasImage?: boolean;
77
+ search?: string;
78
+ codingOnly: boolean;
79
+ } = { codingOnly: true };
80
+
81
+ if (typeFilter !== 'all') filters.type = typeFilter;
82
+ if (categoryFilter !== 'all') filters.category = categoryFilter;
83
+ if (multimodalFilter === 'with') filters.hasImage = true;
84
+ else if (multimodalFilter === 'without') filters.hasImage = false;
85
+ if (debouncedSearch) filters.search = debouncedSearch;
86
+
87
+ const result = filterExamples(split, filters, ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
88
+
89
+ const codingProblems = result.examples.filter(
90
+ (e): e is CodingProblem =>
91
+ e.testCode !== undefined &&
92
+ e.entryPoint !== undefined &&
93
+ (e.type === 'function_completion' || e.type === 'code_generation')
94
+ );
95
+
96
+ return { problems: codingProblems, totalProblems: result.total };
97
+ }, [loadedSplits, split, filterExamples, typeFilter, categoryFilter, multimodalFilter, debouncedSearch, currentPage]);
98
+
99
+ // Scroll to top on page change
100
+ useEffect(() => {
101
+ if (scrollContainerRef.current) {
102
+ scrollContainerRef.current.scrollTop = 0;
103
+ }
104
+ }, [currentPage]);
105
+
106
+ const totalPages = Math.ceil(totalProblems / ITEMS_PER_PAGE);
107
+
108
+ const stats = useMemo(() => ({
109
+ total: totalProblems,
110
+ solved: problems.filter((p) => solvedProblems.has(p.id)).length,
111
+ currentPageSolved: problems.filter((p) => solvedProblems.has(p.id)).length,
112
+ displayed: problems.length,
113
+ }), [problems, solvedProblems, totalProblems]);
114
+
115
+ const truncateText = (text: string, maxLength: number) => {
116
+ if (text.length <= maxLength) return text;
117
+ return text.substring(0, maxLength).trim() + '...';
118
+ };
119
+
120
+ const clearSearch = () => {
121
+ setSearchQuery('');
122
+ searchInputRef.current?.focus();
123
+ };
124
+
125
+ const badgeColors: Record<string, string> = {
126
+ function_completion: 'bg-emerald-900/30 text-emerald-400 border-emerald-700/30',
127
+ code_generation: 'bg-blue-900/30 text-blue-400 border-blue-700/30',
128
+ };
129
+
130
+ const handleKeyDown = (e: React.KeyboardEvent) => {
131
+ if (e.key === 'Escape') {
132
+ clearSearch();
133
+ }
134
+ };
135
+
136
+ const isLoading = isDatasetLoading || !loadedSplits.has(split);
137
+
138
+ return (
139
+ <div className="h-full flex flex-col bg-zinc-900/95 overflow-hidden">
140
+ {/* Header */}
141
+ <div className="p-4 border-b border-zinc-800/80 flex-shrink-0">
142
+ <div className="flex items-center justify-between mb-3">
143
+ <div className="flex items-center gap-2">
144
+ <Code className="w-4 h-4 text-teal-500" />
145
+ <h2 className="font-semibold text-zinc-200">Practice Problems</h2>
146
+ </div>
147
+ {isLoading && (
148
+ <Loader2 className="w-4 h-4 animate-spin text-zinc-500" />
149
+ )}
150
+ </div>
151
+
152
+ {/* Split Selector */}
153
+ <div className="flex gap-1 mb-3 bg-zinc-800/50 p-1 rounded-lg">
154
+ {(['test', 'validation'] as Split[]).map((s) => (
155
+ <button
156
+ key={s}
157
+ onClick={() => setSplit(s)}
158
+ className={clsx(
159
+ 'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all',
160
+ split === s
161
+ ? 'bg-teal-600/80 text-white shadow-sm'
162
+ : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50'
163
+ )}
164
+ >
165
+ <div className="flex items-center justify-center gap-1.5">
166
+ <Database className="w-3 h-3" />
167
+ <span className="capitalize">{s}</span>
168
+ {splitCounts[s] && (
169
+ <span className="text-[10px] opacity-70">({splitCounts[s]})</span>
170
+ )}
171
+ </div>
172
+ </button>
173
+ ))}
174
+ </div>
175
+
176
+ {/* Search */}
177
+ <div className="relative mb-3">
178
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
179
+ <input
180
+ ref={searchInputRef}
181
+ type="text"
182
+ value={searchQuery}
183
+ onChange={(e) => setSearchQuery(e.target.value)}
184
+ onKeyDown={handleKeyDown}
185
+ placeholder="Search problems..."
186
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-lg pl-9 pr-8 py-2 text-sm text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:ring-1 focus:ring-teal-600/50 focus:border-teal-700/50"
187
+ />
188
+ {searchQuery && (
189
+ <button
190
+ onClick={clearSearch}
191
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
192
+ >
193
+ <X className="w-4 h-4" />
194
+ </button>
195
+ )}
196
+ </div>
197
+
198
+ {/* Stats */}
199
+ <div className="flex items-center gap-2 text-xs text-zinc-500 mb-3">
200
+ <span className="flex items-center gap-1">
201
+ <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
202
+ {stats.currentPageSolved} solved
203
+ </span>
204
+ <span className="text-zinc-600">|</span>
205
+ <span>{stats.total} problems</span>
206
+ {debouncedSearch && (
207
+ <>
208
+ <span className="text-zinc-600">|</span>
209
+ <span className="text-teal-400 truncate max-w-[100px]">
210
+ &quot;{debouncedSearch}&quot;
211
+ </span>
212
+ </>
213
+ )}
214
+ </div>
215
+
216
+ {/* Filters Toggle */}
217
+ <button
218
+ onClick={() => setShowFilters(!showFilters)}
219
+ className="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
220
+ >
221
+ <Filter className="w-4 h-4" />
222
+ <span>Filters</span>
223
+ {(typeFilter !== 'all' || categoryFilter !== 'all' || multimodalFilter !== 'all') && (
224
+ <span className="px-1.5 py-0.5 text-[10px] bg-teal-600/30 text-teal-400 rounded">
225
+ Active
226
+ </span>
227
+ )}
228
+ <ChevronDown
229
+ className={clsx(
230
+ 'w-4 h-4 transition-transform',
231
+ showFilters && 'rotate-180'
232
+ )}
233
+ />
234
+ </button>
235
+
236
+ {/* Filter Options */}
237
+ {showFilters && (
238
+ <div className="mt-3 space-y-3 animate-in slide-in-from-top-2 duration-200">
239
+ <div>
240
+ <label className="text-xs text-zinc-500 mb-1 block">
241
+ Task Type
242
+ </label>
243
+ <select
244
+ value={typeFilter}
245
+ onChange={(e) => setTypeFilter(e.target.value as TaskType | 'all')}
246
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-md px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:ring-1 focus:ring-teal-600/50"
247
+ >
248
+ <option value="all">All Types</option>
249
+ <option value="function_completion">Function Completion</option>
250
+ <option value="code_generation">Code Generation</option>
251
+ </select>
252
+ </div>
253
+
254
+ <div>
255
+ <label className="text-xs text-zinc-500 mb-1 block">
256
+ Category
257
+ </label>
258
+ <select
259
+ value={categoryFilter}
260
+ onChange={(e) => setCategoryFilter(e.target.value as Category | 'all')}
261
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-md px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:ring-1 focus:ring-teal-600/50"
262
+ >
263
+ <option value="all">All Categories</option>
264
+ {Object.entries(CATEGORY_LABELS).map(([key, label]) => (
265
+ <option key={key} value={key}>
266
+ {label}
267
+ </option>
268
+ ))}
269
+ </select>
270
+ </div>
271
+
272
+ <div>
273
+ <label className="text-xs text-zinc-500 mb-1 block">
274
+ Multimodal
275
+ </label>
276
+ <select
277
+ value={multimodalFilter}
278
+ onChange={(e) => setMultimodalFilter(e.target.value as 'all' | 'with' | 'without')}
279
+ className="w-full bg-zinc-800/80 border border-zinc-700/50 rounded-md px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:ring-1 focus:ring-teal-600/50"
280
+ >
281
+ <option value="all">All Problems</option>
282
+ <option value="with">With Images</option>
283
+ <option value="without">Text Only</option>
284
+ </select>
285
+ </div>
286
+
287
+ {/* Clear Filters */}
288
+ {(typeFilter !== 'all' || categoryFilter !== 'all' || multimodalFilter !== 'all') && (
289
+ <button
290
+ onClick={() => {
291
+ setTypeFilter('all');
292
+ setCategoryFilter('all');
293
+ setMultimodalFilter('all');
294
+ }}
295
+ className="w-full py-2 text-xs text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50 rounded-md transition-colors"
296
+ >
297
+ Clear all filters
298
+ </button>
299
+ )}
300
+ </div>
301
+ )}
302
+ </div>
303
+
304
+ {/* Problem List */}
305
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 scroll-smooth min-h-0">
306
+ {isLoading ? (
307
+ <div className="flex flex-col items-center justify-center h-40 text-zinc-500">
308
+ <Loader2 className="w-6 h-6 animate-spin mb-2" />
309
+ <span className="text-sm">Loading problems...</span>
310
+ </div>
311
+ ) : problems.length === 0 ? (
312
+ <div className="flex flex-col items-center justify-center h-40 text-zinc-500 text-center">
313
+ <Filter className="w-6 h-6 mb-2 opacity-50" />
314
+ <p className="text-sm">No problems match your filters</p>
315
+ {debouncedSearch && (
316
+ <button
317
+ onClick={clearSearch}
318
+ className="mt-2 text-teal-400 hover:text-teal-300 text-sm"
319
+ >
320
+ Clear search
321
+ </button>
322
+ )}
323
+ </div>
324
+ ) : (
325
+ <div className="space-y-2">
326
+ <p className="text-xs text-zinc-500 px-1 mb-2">
327
+ Showing {currentPage * ITEMS_PER_PAGE + 1}–{Math.min((currentPage + 1) * ITEMS_PER_PAGE, totalProblems)} of {totalProblems}
328
+ </p>
329
+ {problems.map((problem, idx) => {
330
+ const isSolved = solvedProblems.has(problem.id);
331
+ const isSelected = problem.id === selectedProblemId;
332
+ const taskConfig = TASK_LABELS[problem.type];
333
+ const globalIndex = currentPage * ITEMS_PER_PAGE + idx + 1;
334
+
335
+ return (
336
+ <button
337
+ key={problem.id}
338
+ onClick={() => onSelectProblem(problem)}
339
+ className={clsx(
340
+ 'w-full text-left p-3 rounded-lg transition-all duration-200 group',
341
+ 'border',
342
+ isSelected
343
+ ? 'bg-teal-900/20 border-teal-700/40 ring-1 ring-teal-600/30'
344
+ : 'bg-zinc-800/50 border-zinc-700/30 hover:bg-zinc-800/80 hover:border-zinc-600/40'
345
+ )}
346
+ >
347
+ <div className="flex items-start gap-3">
348
+ <div className="flex-shrink-0 mt-0.5">
349
+ {isSolved ? (
350
+ <CheckCircle2 className="w-4 h-4 text-emerald-500" />
351
+ ) : (
352
+ <Circle className="w-4 h-4 text-zinc-600" />
353
+ )}
354
+ </div>
355
+
356
+ <div className="flex-1 min-w-0">
357
+ <div className="flex items-center gap-2 mb-1.5 flex-wrap">
358
+ <span className="text-[10px] text-zinc-600 font-mono">
359
+ #{globalIndex}
360
+ </span>
361
+ <span
362
+ className={clsx(
363
+ 'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium border',
364
+ badgeColors[problem.type]
365
+ )}
366
+ >
367
+ <Code className="w-3 h-3 mr-1" />
368
+ {taskConfig.label}
369
+ </span>
370
+ <span className="text-[10px] text-zinc-500 truncate">
371
+ {CATEGORY_LABELS[problem.category]}
372
+ </span>
373
+ {problem.hasImage && (
374
+ <span className="inline-flex items-center gap-0.5 text-[10px] text-amber-500/80">
375
+ <ImageIcon className="w-3 h-3" />
376
+ </span>
377
+ )}
378
+ </div>
379
+
380
+ <p className="text-sm text-zinc-300 leading-snug">
381
+ {truncateText(problem.question, 120)}
382
+ </p>
383
+ </div>
384
+ </div>
385
+ </button>
386
+ );
387
+ })}
388
+ </div>
389
+ )}
390
+ </div>
391
+
392
+ {/* Pagination - Fixed overflow */}
393
+ {totalPages > 1 && !isLoading && (
394
+ <div className="p-2 border-t border-zinc-800/80 flex-shrink-0">
395
+ <div className="flex items-center justify-between gap-1">
396
+ <button
397
+ onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
398
+ disabled={currentPage === 0}
399
+ className={clsx(
400
+ 'flex items-center gap-0.5 px-2 py-1 text-xs font-medium rounded-md transition-colors flex-shrink-0',
401
+ currentPage === 0
402
+ ? 'text-zinc-600 cursor-not-allowed'
403
+ : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
404
+ )}
405
+ >
406
+ <ChevronLeft className="w-3.5 h-3.5" />
407
+ <span className="hidden sm:inline">Prev</span>
408
+ </button>
409
+
410
+ <div className="flex items-center gap-0.5 overflow-hidden flex-1 justify-center min-w-0">
411
+ {(() => {
412
+ const maxVisible = 3;
413
+ const pages: (number | 'ellipsis')[] = [];
414
+
415
+ if (totalPages <= maxVisible + 2) {
416
+ // Show all pages
417
+ for (let i = 0; i < totalPages; i++) pages.push(i);
418
+ } else {
419
+ // Always show first page
420
+ pages.push(0);
421
+
422
+ if (currentPage > 2) {
423
+ pages.push('ellipsis');
424
+ }
425
+
426
+ // Show pages around current
427
+ const start = Math.max(1, currentPage - 1);
428
+ const end = Math.min(totalPages - 2, currentPage + 1);
429
+
430
+ for (let i = start; i <= end; i++) {
431
+ if (!pages.includes(i)) pages.push(i);
432
+ }
433
+
434
+ if (currentPage < totalPages - 3) {
435
+ pages.push('ellipsis');
436
+ }
437
+
438
+ // Always show last page
439
+ if (!pages.includes(totalPages - 1)) {
440
+ pages.push(totalPages - 1);
441
+ }
442
+ }
443
+
444
+ return pages.map((page, idx) => {
445
+ if (page === 'ellipsis') {
446
+ return (
447
+ <span key={`ellipsis-${idx}`} className="text-zinc-600 px-0.5 text-xs">
448
+
449
+ </span>
450
+ );
451
+ }
452
+
453
+ return (
454
+ <button
455
+ key={page}
456
+ onClick={() => setCurrentPage(page)}
457
+ className={clsx(
458
+ 'w-6 h-6 text-[11px] font-medium rounded transition-colors flex-shrink-0',
459
+ currentPage === page
460
+ ? 'bg-teal-600/80 text-white'
461
+ : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
462
+ )}
463
+ >
464
+ {page + 1}
465
+ </button>
466
+ );
467
+ });
468
+ })()}
469
+ </div>
470
+
471
+ <button
472
+ onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
473
+ disabled={currentPage >= totalPages - 1}
474
+ className={clsx(
475
+ 'flex items-center gap-0.5 px-2 py-1 text-xs font-medium rounded-md transition-colors flex-shrink-0',
476
+ currentPage >= totalPages - 1
477
+ ? 'text-zinc-600 cursor-not-allowed'
478
+ : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
479
+ )}
480
+ >
481
+ <span className="hidden sm:inline">Next</span>
482
+ <ChevronRight className="w-3.5 h-3.5" />
483
+ </button>
484
+ </div>
485
+ </div>
486
+ )}
487
+ </div>
488
+ );
489
+ }
src/components/Practice/TestRunner.tsx ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import {
5
+ Play,
6
+ CheckCircle2,
7
+ XCircle,
8
+ Clock,
9
+ ChevronDown,
10
+ ChevronUp,
11
+ AlertTriangle,
12
+ Loader2,
13
+ Terminal,
14
+ FileCode,
15
+ } from 'lucide-react';
16
+ import { clsx } from 'clsx';
17
+ import type { TestResult } from '@/types';
18
+
19
+ interface TestRunnerProps {
20
+ userCode: string;
21
+ testCode: string;
22
+ entryPoint: string;
23
+ onTestComplete: (result: TestResult) => void;
24
+ initialResult?: TestResult | null;
25
+ }
26
+
27
+ export function TestRunner({
28
+ userCode,
29
+ testCode,
30
+ entryPoint,
31
+ onTestComplete,
32
+ initialResult = null,
33
+ }: TestRunnerProps) {
34
+ const [isRunning, setIsRunning] = useState(false);
35
+ const [result, setResult] = useState<TestResult | null>(initialResult);
36
+ const [isExpanded, setIsExpanded] = useState(false);
37
+ const [showTraceback, setShowTraceback] = useState(false);
38
+
39
+ const runTests = async () => {
40
+ if (isRunning) return;
41
+
42
+ setIsRunning(true);
43
+ setResult(null);
44
+ setShowTraceback(false);
45
+
46
+ try {
47
+ const response = await fetch('/api/test', {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({
51
+ userCode,
52
+ testCode,
53
+ entryPoint,
54
+ timeout: 30,
55
+ }),
56
+ });
57
+
58
+ const data = await response.json();
59
+ setResult(data);
60
+ onTestComplete(data);
61
+ // Auto-expand on failure
62
+ if (!data.passed) {
63
+ setIsExpanded(true);
64
+ }
65
+ } catch (error) {
66
+ const errorResult: TestResult = {
67
+ passed: false,
68
+ total: 0,
69
+ failed: 0,
70
+ details: [],
71
+ executionTime: 0,
72
+ error: error instanceof Error ? error.message : 'Failed to run tests',
73
+ };
74
+ setResult(errorResult);
75
+ onTestComplete(errorResult);
76
+ setIsExpanded(true);
77
+ } finally {
78
+ setIsRunning(false);
79
+ }
80
+ };
81
+
82
+ const hasDetails = result && (result.error || result.details.length > 0 || result.traceback || result.output);
83
+ const passedCount = result?.details?.filter(t => t.passed).length ?? 0;
84
+ const totalCount = result?.total ?? result?.details?.length ?? 0;
85
+
86
+ return (
87
+ <div className="flex flex-col">
88
+ {/* Compact header with Run button and status */}
89
+ <div className="flex items-center justify-between px-4 py-2 bg-zinc-900/50">
90
+ <div className="flex items-center gap-3">
91
+ <button
92
+ onClick={runTests}
93
+ disabled={isRunning || !userCode.trim()}
94
+ className={clsx(
95
+ 'flex items-center gap-2 px-3 py-1.5 rounded-md font-medium text-sm transition-all',
96
+ isRunning || !userCode.trim()
97
+ ? 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
98
+ : 'bg-teal-600 hover:bg-teal-500 text-white'
99
+ )}
100
+ >
101
+ {isRunning ? (
102
+ <>
103
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
104
+ Running...
105
+ </>
106
+ ) : (
107
+ <>
108
+ <Play className="w-3.5 h-3.5" />
109
+ Run Tests
110
+ </>
111
+ )}
112
+ </button>
113
+
114
+ {result && (
115
+ <div className="flex items-center gap-2 text-sm">
116
+ {result.passed ? (
117
+ <span className="flex items-center gap-1.5 text-emerald-400">
118
+ <CheckCircle2 className="w-4 h-4" />
119
+ <span>Passed</span>
120
+ {totalCount > 0 && (
121
+ <span className="text-emerald-400/70 text-xs font-normal">
122
+ ({passedCount}/{totalCount} tests)
123
+ </span>
124
+ )}
125
+ </span>
126
+ ) : (
127
+ <span className="flex items-center gap-1.5 text-red-400">
128
+ <XCircle className="w-4 h-4" />
129
+ <span>Failed</span>
130
+ {totalCount > 0 && (
131
+ <span className="text-red-400/70 text-xs font-normal">
132
+ ({passedCount}/{totalCount} tests)
133
+ </span>
134
+ )}
135
+ </span>
136
+ )}
137
+ <span className="flex items-center gap-1 text-zinc-500 text-xs">
138
+ <Clock className="w-3 h-3" />
139
+ {result.executionTime}ms
140
+ </span>
141
+ </div>
142
+ )}
143
+ </div>
144
+
145
+ {result && (
146
+ <button
147
+ onClick={() => setIsExpanded(!isExpanded)}
148
+ className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
149
+ >
150
+ {isExpanded ? 'Hide' : 'Show'} details
151
+ {isExpanded ? (
152
+ <ChevronUp className="w-3.5 h-3.5" />
153
+ ) : (
154
+ <ChevronDown className="w-3.5 h-3.5" />
155
+ )}
156
+ </button>
157
+ )}
158
+ </div>
159
+
160
+ {/* Expandable details section */}
161
+ {isExpanded && result && (
162
+ <div className="px-4 py-3 bg-zinc-900/30 border-t border-zinc-800/50 max-h-64 overflow-y-auto">
163
+ {/* Summary for passed tests */}
164
+ {result.passed && !result.error && (
165
+ <div className="p-3 rounded-lg bg-emerald-950/20 border border-emerald-800/30 mb-3">
166
+ <div className="flex items-center gap-2">
167
+ <CheckCircle2 className="w-4 h-4 text-emerald-400" />
168
+ <span className="text-sm text-emerald-300 font-medium">
169
+ All {totalCount} test{totalCount !== 1 ? 's' : ''} passed successfully!
170
+ </span>
171
+ </div>
172
+ </div>
173
+ )}
174
+
175
+ {/* Main error message */}
176
+ {result.error && (
177
+ <div className="p-3 rounded-lg bg-red-950/30 border border-red-800/40 mb-3">
178
+ <div className="flex items-start gap-2">
179
+ <AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
180
+ <pre className="text-xs text-red-200/80 whitespace-pre-wrap font-mono flex-1 break-all">
181
+ {result.error}
182
+ </pre>
183
+ </div>
184
+ </div>
185
+ )}
186
+
187
+ {/* Traceback toggle and display */}
188
+ {result.traceback && result.traceback !== result.error && (
189
+ <div className="mb-3">
190
+ <button
191
+ onClick={() => setShowTraceback(!showTraceback)}
192
+ className="flex items-center gap-2 text-xs text-zinc-400 hover:text-zinc-200 transition-colors mb-2"
193
+ >
194
+ <FileCode className="w-3.5 h-3.5" />
195
+ {showTraceback ? 'Hide' : 'Show'} full traceback
196
+ {showTraceback ? (
197
+ <ChevronUp className="w-3 h-3" />
198
+ ) : (
199
+ <ChevronDown className="w-3 h-3" />
200
+ )}
201
+ </button>
202
+ {showTraceback && (
203
+ <div className="p-3 rounded-lg bg-zinc-900/80 border border-zinc-700/50 overflow-x-auto">
204
+ <pre className="text-[11px] text-zinc-300 whitespace-pre font-mono">
205
+ {result.traceback}
206
+ </pre>
207
+ </div>
208
+ )}
209
+ </div>
210
+ )}
211
+
212
+ {/* Output display */}
213
+ {result.output && (
214
+ <div className="mb-3">
215
+ <div className="flex items-center gap-2 text-xs text-zinc-500 mb-1.5">
216
+ <Terminal className="w-3.5 h-3.5" />
217
+ <span>Output</span>
218
+ </div>
219
+ <div className="p-2 rounded-lg bg-zinc-900/60 border border-zinc-800/50">
220
+ <pre className="text-[11px] text-zinc-400 whitespace-pre-wrap font-mono">
221
+ {result.output}
222
+ </pre>
223
+ </div>
224
+ </div>
225
+ )}
226
+
227
+ {/* Test details - always show */}
228
+ {result.details && result.details.length > 0 && (
229
+ <div className="space-y-2">
230
+ <div className="text-xs text-zinc-500 mb-2">Test Results:</div>
231
+ {result.details.map((test, idx) => (
232
+ <div
233
+ key={idx}
234
+ className={clsx(
235
+ 'p-2 rounded-md border text-xs',
236
+ test.passed
237
+ ? 'bg-emerald-950/20 border-emerald-800/30'
238
+ : 'bg-red-950/20 border-red-800/30'
239
+ )}
240
+ >
241
+ <div className="flex items-center gap-2">
242
+ {test.passed ? (
243
+ <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
244
+ ) : (
245
+ <XCircle className="w-3.5 h-3.5 text-red-400" />
246
+ )}
247
+ <span
248
+ className={clsx(
249
+ 'font-medium',
250
+ test.passed ? 'text-emerald-300' : 'text-red-300'
251
+ )}
252
+ >
253
+ {test.name}
254
+ </span>
255
+ </div>
256
+
257
+ {!test.passed && (test.expected || test.actual || test.error) && (
258
+ <div className="ml-5 mt-1 space-y-0.5 font-mono text-[11px]">
259
+ {test.expected && (
260
+ <p className="text-zinc-400">
261
+ <span className="text-zinc-500">Expected: </span>
262
+ <span className="text-emerald-300">{test.expected}</span>
263
+ </p>
264
+ )}
265
+ {test.actual && (
266
+ <p className="text-zinc-400">
267
+ <span className="text-zinc-500">Actual: </span>
268
+ <span className="text-red-300">{test.actual}</span>
269
+ </p>
270
+ )}
271
+ {test.error && !result.error && (
272
+ <p className="text-red-300/80">{test.error}</p>
273
+ )}
274
+ </div>
275
+ )}
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+ </div>
281
+ )}
282
+ </div>
283
+ );
284
+ }
src/components/Practice/index.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export { CodeEditor } from './CodeEditor';
2
+ export { ProblemList } from './ProblemList';
3
+ export { TestRunner } from './TestRunner';
4
+ export { AIHelper } from './AIHelper';
5
+ export { PracticeInterface } from './PracticeInterface';
6
+
src/components/ResizablePanel/ResizablePanel.tsx ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useEffect, ReactNode } from 'react';
4
+ import { clsx } from 'clsx';
5
+
6
+ interface ResizablePanelProps {
7
+ children: ReactNode;
8
+ defaultWidth: number;
9
+ minWidth: number;
10
+ maxWidth: number;
11
+ side: 'left' | 'right';
12
+ isOpen: boolean;
13
+ isCollapsed: boolean;
14
+ collapsedWidth: number;
15
+ storageKey?: string;
16
+ className?: string;
17
+ }
18
+
19
+ export function ResizablePanel({
20
+ children,
21
+ defaultWidth,
22
+ minWidth,
23
+ maxWidth,
24
+ side,
25
+ isOpen,
26
+ isCollapsed,
27
+ collapsedWidth,
28
+ storageKey,
29
+ className,
30
+ }: ResizablePanelProps) {
31
+ const [width, setWidth] = useState(() => {
32
+ if (typeof window !== 'undefined' && storageKey) {
33
+ const stored = localStorage.getItem(storageKey);
34
+ if (stored) {
35
+ const parsed = parseInt(stored, 10);
36
+ if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
37
+ return parsed;
38
+ }
39
+ }
40
+ }
41
+ return defaultWidth;
42
+ });
43
+
44
+ const [isDragging, setIsDragging] = useState(false);
45
+ const panelRef = useRef<HTMLDivElement>(null);
46
+ const startXRef = useRef(0);
47
+ const startWidthRef = useRef(0);
48
+
49
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
50
+ e.preventDefault();
51
+ setIsDragging(true);
52
+ startXRef.current = e.clientX;
53
+ startWidthRef.current = width;
54
+ }, [width]);
55
+
56
+ const handleMouseMove = useCallback((e: MouseEvent) => {
57
+ if (!isDragging) return;
58
+
59
+ const diff = side === 'right'
60
+ ? startXRef.current - e.clientX
61
+ : e.clientX - startXRef.current;
62
+
63
+ const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + diff));
64
+ setWidth(newWidth);
65
+ }, [isDragging, minWidth, maxWidth, side]);
66
+
67
+ const handleMouseUp = useCallback(() => {
68
+ if (isDragging) {
69
+ setIsDragging(false);
70
+ if (storageKey) {
71
+ localStorage.setItem(storageKey, width.toString());
72
+ }
73
+ }
74
+ }, [isDragging, storageKey, width]);
75
+
76
+ useEffect(() => {
77
+ if (isDragging) {
78
+ document.addEventListener('mousemove', handleMouseMove);
79
+ document.addEventListener('mouseup', handleMouseUp);
80
+ document.body.style.cursor = 'col-resize';
81
+ document.body.style.userSelect = 'none';
82
+ }
83
+
84
+ return () => {
85
+ document.removeEventListener('mousemove', handleMouseMove);
86
+ document.removeEventListener('mouseup', handleMouseUp);
87
+ document.body.style.cursor = '';
88
+ document.body.style.userSelect = '';
89
+ };
90
+ }, [isDragging, handleMouseMove, handleMouseUp]);
91
+
92
+ const currentWidth = isCollapsed ? collapsedWidth : width;
93
+
94
+ return (
95
+ <div
96
+ ref={panelRef}
97
+ style={{ width: currentWidth }}
98
+ className={clsx(
99
+ 'relative flex-shrink-0 transition-[width]',
100
+ isDragging ? 'duration-0' : 'duration-200',
101
+ !isOpen && 'hidden lg:flex',
102
+ className
103
+ )}
104
+ >
105
+ {children}
106
+
107
+ {!isCollapsed && isOpen && (
108
+ <div
109
+ onMouseDown={handleMouseDown}
110
+ className={clsx(
111
+ 'absolute top-0 bottom-0 w-1 cursor-col-resize z-50',
112
+ 'hover:bg-teal-500/50 transition-colors',
113
+ isDragging && 'bg-teal-500/70',
114
+ side === 'right' ? '-left-0.5' : '-right-0.5'
115
+ )}
116
+ >
117
+ <div
118
+ className={clsx(
119
+ 'absolute top-1/2 -translate-y-1/2 w-4 h-16 -left-1.5',
120
+ 'flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity',
121
+ isDragging && 'opacity-100'
122
+ )}
123
+ >
124
+ <div className="w-1 h-8 rounded-full bg-zinc-600 flex flex-col items-center justify-center gap-1">
125
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
126
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
127
+ <div className="w-0.5 h-1 rounded-full bg-zinc-400" />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ export function useResizableWidth(
137
+ defaultWidth: number,
138
+ minWidth: number,
139
+ maxWidth: number,
140
+ storageKey?: string
141
+ ) {
142
+ const [width, setWidth] = useState(() => {
143
+ if (typeof window !== 'undefined' && storageKey) {
144
+ const stored = localStorage.getItem(storageKey);
145
+ if (stored) {
146
+ const parsed = parseInt(stored, 10);
147
+ if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
148
+ return parsed;
149
+ }
150
+ }
151
+ }
152
+ return defaultWidth;
153
+ });
154
+
155
+ const updateWidth = useCallback((newWidth: number) => {
156
+ const clamped = Math.min(maxWidth, Math.max(minWidth, newWidth));
157
+ setWidth(clamped);
158
+ if (storageKey) {
159
+ localStorage.setItem(storageKey, clamped.toString());
160
+ }
161
+ }, [minWidth, maxWidth, storageKey]);
162
+
163
+ return [width, updateWidth] as const;
164
+ }
165
+
src/components/index.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export { Header } from './Header/Header';
2
+ export { ChatInterface } from './Chat/ChatInterface';
3
+ export { Message } from './Chat/Message';
4
+ export { MessageInput } from './Chat/MessageInput';
5
+ export type { MessageInputRef } from './Chat/MessageInput';
6
+ export { QubitIcon } from './Chat/QubitIcon';
7
+ export { ExecutionResult } from './Chat/ExecutionResult';
8
+ export type { ExecutionResultData } from './Chat/ExecutionResult';
9
+ export { LoadingStatus } from './Chat/LoadingStatus';
10
+ export { WarmupIndicator, WarmupIndicatorCompact } from './Chat/WarmupIndicator';
11
+ export { ExamplesPanel } from './Examples/ExamplesPanel';
12
+ export { ExampleCard } from './Examples/ExampleCard';
13
+ export { ResizablePanel, useResizableWidth } from './ResizablePanel/ResizablePanel';
14
+ export { PracticeInterface, CodeEditor, ProblemList, TestRunner, AIHelper } from './Practice';
src/config/constants.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Category, TaskType } from '@/types';
2
+
3
+ export const PROJECT_CONFIG = {
4
+ name: 'Quantum Assistant',
5
+ description: 'Multimodal VLM specialized for Quantum Computing with Qiskit',
6
+ version: '1.0.0',
7
+ author: 'Samuel Lima Braz',
8
+ advisor: 'João Paulo Reus Rodrigues Leite',
9
+ institution: 'UNIFEI - Universidade Federal de Itajubá',
10
+ year: 2025,
11
+ } as const;
12
+
13
+ export const LINKS = {
14
+ github: 'https://github.com/samuellimabraz/quantum-assistant',
15
+ dataset: 'https://huggingface.co/datasets/samuellimabraz/quantum-assistant',
16
+ models: 'https://huggingface.co/collections/samuellimabraz/quantum-assistant',
17
+ qiskit: 'https://qiskit.org/',
18
+ } as const;
19
+
20
+ export const TASK_LABELS: Record<TaskType, { label: string; description: string; color: string }> = {
21
+ function_completion: {
22
+ label: 'Function Completion',
23
+ description: 'Complete function body from signature + docstring',
24
+ color: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
25
+ },
26
+ code_generation: {
27
+ label: 'Code Generation',
28
+ description: 'Generate complete code from natural language',
29
+ color: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
30
+ },
31
+ qa: {
32
+ label: 'Question Answering',
33
+ description: 'Conceptual explanations and theory',
34
+ color: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
35
+ },
36
+ };
37
+
38
+ export const CATEGORY_LABELS: Record<Category, string> = {
39
+ circuits_and_gates: 'Circuits & Gates',
40
+ quantum_info_and_operators: 'Quantum Info',
41
+ algorithms_and_applications: 'Algorithms',
42
+ hardware_and_providers: 'Hardware',
43
+ transpilation_and_compilation: 'Transpilation',
44
+ primitives_and_execution: 'Primitives',
45
+ noise_and_error_mitigation: 'Error Mitigation',
46
+ };
47
+
48
+ export const SYSTEM_PROMPT = `You are Quantum Assistant, an expert AI specialized in quantum computing, physics, mathematics, and the Qiskit framework.
49
+
50
+ ## SCOPE RESTRICTIONS (STRICTLY ENFORCED)
51
+ You ONLY answer questions related to:
52
+ - Quantum computing (circuits, gates, algorithms, hardware, error correction)
53
+ - Qiskit framework and IBM Quantum services
54
+ - Physics (quantum mechanics, quantum information theory)
55
+ - Mathematics (linear algebra, probability, complex numbers as they relate to quantum)
56
+ - Machine learning for quantum (QML, variational algorithms, optimization)
57
+ - Scientific computing related to quantum (NumPy, SciPy for quantum applications)
58
+
59
+ ## FORBIDDEN TOPICS - DO NOT ANSWER
60
+ If asked about any of the following, politely decline and redirect to quantum computing:
61
+ - General programming unrelated to quantum computing
62
+ - Personal advice, relationships, lifestyle
63
+ - Politics, news, current events, opinions
64
+ - Harmful, illegal, or unethical content
65
+ - Medical, legal, or financial advice
66
+ - Creative writing, stories, poetry (unless quantum-themed educational)
67
+ - Other AI systems, jailbreaking, prompt injection
68
+ - Anything that could be used maliciously
69
+
70
+ ## RESPONSE GUIDELINES
71
+ 1. Generate precise, well-documented Qiskit code following Qiskit 2.0 best practices
72
+ 2. Explain quantum computing concepts clearly with mathematical rigor when appropriate
73
+ 3. Interpret quantum circuit diagrams, Bloch spheres, and measurement histograms
74
+ 4. Help with function completion, code generation, and conceptual questions
75
+ 5. Use Qiskit 2.0 APIs exclusively
76
+ 6. Prefer primitives (SamplerV2, EstimatorV2) over legacy execute()
77
+ 7. Use assign_parameters() instead of deprecated bind_parameters()
78
+ 8. Use generate_preset_pass_manager() for circuit optimization
79
+ 9. Include all necessary imports in code solutions
80
+ 10. Provide clear, educational explanations
81
+
82
+ ## CODE SAFETY
83
+ - Never generate code that accesses environment variables, files outside the sandbox, or network resources
84
+ - Never generate code using dangerous modules (ctypes, pickle, subprocess, etc.)
85
+ - Keep code focused on quantum computing tasks
86
+
87
+ ## OFF-TOPIC RESPONSE
88
+ If a question is outside your scope, respond with:
89
+ "I'm Quantum Assistant, specialized in quantum computing, Qiskit, physics, and related mathematics. I can't help with [topic], but I'd be happy to assist with quantum computing questions! For example, I can help you understand quantum gates, create quantum circuits, or explain quantum algorithms."
90
+
91
+ Respond accurately and helpfully to quantum computing questions while maintaining strict topic boundaries.`;
92
+
93
+ // List of allowed topic keywords for input validation
94
+ export const ALLOWED_TOPICS = [
95
+ // Quantum Computing
96
+ 'quantum', 'qubit', 'qubits', 'superposition', 'entanglement', 'measurement',
97
+ 'circuit', 'gate', 'hadamard', 'cnot', 'pauli', 'rotation', 'phase',
98
+ 'bloch', 'sphere', 'state', 'vector', 'amplitude', 'probability',
99
+ 'algorithm', 'grover', 'shor', 'vqe', 'qaoa', 'qft', 'fourier',
100
+ 'error', 'correction', 'noise', 'decoherence', 'fidelity', 'mitigation',
101
+ 'transpiler', 'transpile', 'optimization', 'compilation',
102
+
103
+ // Qiskit
104
+ 'qiskit', 'ibm', 'aer', 'simulator', 'backend', 'provider', 'runtime',
105
+ 'sampler', 'estimator', 'primitive', 'job', 'result', 'counts',
106
+ 'quantumcircuit', 'classicalregister', 'quantumregister',
107
+ 'pass', 'manager', 'layout', 'routing', 'scheduling',
108
+
109
+ // Physics & Math
110
+ 'physics', 'mechanics', 'hamiltonian', 'unitary', 'hermitian', 'operator',
111
+ 'eigenvalue', 'eigenvector', 'matrix', 'tensor', 'linear', 'algebra',
112
+ 'hilbert', 'space', 'basis', 'orthogonal', 'projection',
113
+ 'complex', 'number', 'exponential', 'trigonometric',
114
+ 'probability', 'distribution', 'expectation', 'variance',
115
+ 'wave', 'function', 'schrodinger', 'dirac', 'bra', 'ket', 'notation',
116
+
117
+ // QML & Optimization
118
+ 'machine', 'learning', 'variational', 'ansatz', 'parameter', 'parametrized',
119
+ 'optimization', 'gradient', 'descent', 'cost', 'function', 'loss',
120
+ 'training', 'classical', 'hybrid', 'neural', 'kernel',
121
+
122
+ // Scientific Computing
123
+ 'numpy', 'scipy', 'matplotlib', 'plot', 'histogram', 'visualization',
124
+ 'array', 'matrix', 'calculation', 'computation', 'simulation',
125
+
126
+ // General programming (quantum-related)
127
+ 'python', 'code', 'function', 'class', 'import', 'library',
128
+ 'example', 'tutorial', 'explain', 'how', 'what', 'why', 'help',
129
+ 'implement', 'create', 'build', 'make', 'generate', 'write',
130
+ ];
131
+
132
+ // Blocked patterns that should never be allowed
133
+ export const BLOCKED_INPUT_PATTERNS = [
134
+ // Prompt injection attempts
135
+ /ignore\s+(previous|all|above|prior)\s+(instructions?|prompts?|rules?)/i,
136
+ /disregard\s+(previous|all|above|prior)/i,
137
+ /forget\s+(everything|all|your)\s+(instructions?|rules?|training)/i,
138
+ /new\s+instructions?:/i,
139
+ /system\s*prompt/i,
140
+ /jailbreak/i,
141
+ /dan\s*mode/i,
142
+ /pretend\s+(you\s+are|to\s+be)/i,
143
+ /act\s+as\s+(if|a)/i,
144
+ /roleplay\s+as/i,
145
+ /you\s+are\s+now/i,
146
+
147
+ // Harmful content requests
148
+ /how\s+to\s+(hack|attack|exploit|break\s+into)/i,
149
+ /malware|virus|trojan|ransomware/i,
150
+ /steal\s+(data|information|credentials|password)/i,
151
+ /bypass\s+(security|authentication|firewall)/i,
152
+ /injection\s+attack/i,
153
+ /sql\s+injection/i,
154
+ /xss|cross.?site/i,
155
+
156
+ // Explicit requests to bypass restrictions
157
+ /bypass\s+(filter|restriction|limitation|safety)/i,
158
+ /disable\s+(safety|filter|moderation)/i,
159
+ /unlock\s+(hidden|secret|restricted)/i,
160
+ /override\s+(rules?|restrictions?)/i,
161
+
162
+ // Off-topic explicit requests
163
+ /write\s+(me\s+)?(a\s+)?(story|poem|essay|article|blog)/i,
164
+ /tell\s+me\s+(a\s+)?joke/i,
165
+ /relationship\s+advice/i,
166
+ /dating\s+advice/i,
167
+ /political\s+opinion/i,
168
+ /investment\s+advice/i,
169
+ /medical\s+(advice|diagnosis)/i,
170
+ /legal\s+advice/i,
171
+ ];
172
+
src/lib/api/vlm-client.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ModelConfig } from '@/types';
2
+
3
+ interface MessageContent {
4
+ type: 'text' | 'image_url';
5
+ text?: string;
6
+ image_url?: { url: string };
7
+ }
8
+
9
+ interface ChatMessage {
10
+ role: 'system' | 'user' | 'assistant';
11
+ content: string | MessageContent[];
12
+ }
13
+
14
+ interface VLMResponse {
15
+ choices: Array<{
16
+ message: {
17
+ content: string;
18
+ };
19
+ }>;
20
+ }
21
+
22
+ interface StreamChoice {
23
+ delta: {
24
+ content?: string;
25
+ };
26
+ }
27
+
28
+ interface StreamChunk {
29
+ choices: StreamChoice[];
30
+ }
31
+
32
+ export class VLMClient {
33
+ private config: ModelConfig;
34
+
35
+ constructor(config: ModelConfig) {
36
+ this.config = config;
37
+ }
38
+
39
+ private buildPayload(messages: ChatMessage[], stream = false): Record<string, unknown> {
40
+ return {
41
+ model: this.config.modelName,
42
+ messages,
43
+ max_tokens: this.config.maxTokens,
44
+ temperature: this.config.temperature,
45
+ stream,
46
+ };
47
+ }
48
+
49
+ async generate(
50
+ prompt: string,
51
+ systemPrompt?: string,
52
+ imageBase64?: string
53
+ ): Promise<string> {
54
+ const messages: ChatMessage[] = [];
55
+
56
+ if (systemPrompt) {
57
+ messages.push({ role: 'system', content: systemPrompt });
58
+ }
59
+
60
+ if (imageBase64) {
61
+ messages.push({
62
+ role: 'user',
63
+ content: [
64
+ { type: 'text', text: prompt },
65
+ {
66
+ type: 'image_url',
67
+ image_url: { url: `data:image/jpeg;base64,${imageBase64}` },
68
+ },
69
+ ],
70
+ });
71
+ } else {
72
+ messages.push({ role: 'user', content: prompt });
73
+ }
74
+
75
+ return this.chat(messages);
76
+ }
77
+
78
+ async chat(messages: ChatMessage[]): Promise<string> {
79
+ const url = `${this.config.baseUrl}/chat/completions`;
80
+ const headers: HeadersInit = { 'Content-Type': 'application/json' };
81
+
82
+ if (this.config.apiKey) {
83
+ headers['Authorization'] = `Bearer ${this.config.apiKey}`;
84
+ }
85
+
86
+ const payload = this.buildPayload(messages, false);
87
+
88
+ const controller = new AbortController();
89
+ const timeoutId = setTimeout(
90
+ () => controller.abort(),
91
+ this.config.timeout * 1000
92
+ );
93
+
94
+ try {
95
+ const response = await fetch(url, {
96
+ method: 'POST',
97
+ headers,
98
+ body: JSON.stringify(payload),
99
+ signal: controller.signal,
100
+ });
101
+
102
+ clearTimeout(timeoutId);
103
+
104
+ if (!response.ok) {
105
+ const errorText = await response.text();
106
+ throw new Error(`API error: ${response.status} - ${errorText}`);
107
+ }
108
+
109
+ const data: VLMResponse = await response.json();
110
+ const content = data.choices[0]?.message?.content;
111
+
112
+ if (!content) {
113
+ throw new Error('Empty response from model');
114
+ }
115
+
116
+ return content;
117
+ } catch (error) {
118
+ clearTimeout(timeoutId);
119
+ if (error instanceof Error && error.name === 'AbortError') {
120
+ throw new Error(`Request timeout after ${this.config.timeout}s`);
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async *chatStream(messages: ChatMessage[]): AsyncGenerator<string, void, unknown> {
127
+ const url = `${this.config.baseUrl}/chat/completions`;
128
+ const headers: HeadersInit = { 'Content-Type': 'application/json' };
129
+
130
+ if (this.config.apiKey) {
131
+ headers['Authorization'] = `Bearer ${this.config.apiKey}`;
132
+ }
133
+
134
+ const payload = this.buildPayload(messages, true);
135
+
136
+ const controller = new AbortController();
137
+ const timeoutId = setTimeout(
138
+ () => controller.abort(),
139
+ this.config.timeout * 1000
140
+ );
141
+
142
+ try {
143
+ const response = await fetch(url, {
144
+ method: 'POST',
145
+ headers,
146
+ body: JSON.stringify(payload),
147
+ signal: controller.signal,
148
+ });
149
+
150
+ clearTimeout(timeoutId);
151
+
152
+ if (!response.ok) {
153
+ const errorText = await response.text();
154
+ throw new Error(`API error: ${response.status} - ${errorText}`);
155
+ }
156
+
157
+ if (!response.body) {
158
+ throw new Error('No response body for streaming');
159
+ }
160
+
161
+ const reader = response.body.getReader();
162
+ const decoder = new TextDecoder();
163
+ let buffer = '';
164
+
165
+ while (true) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+
169
+ buffer += decoder.decode(value, { stream: true });
170
+ const lines = buffer.split('\n');
171
+ buffer = lines.pop() || '';
172
+
173
+ for (const line of lines) {
174
+ const trimmed = line.trim();
175
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
176
+
177
+ const data = trimmed.slice(6);
178
+ if (data === '[DONE]') return;
179
+
180
+ try {
181
+ const chunk: StreamChunk = JSON.parse(data);
182
+ const content = chunk.choices[0]?.delta?.content;
183
+ if (content) {
184
+ yield content;
185
+ }
186
+ } catch {
187
+ // Skip malformed chunks
188
+ }
189
+ }
190
+ }
191
+ } catch (error) {
192
+ clearTimeout(timeoutId);
193
+ if (error instanceof Error && error.name === 'AbortError') {
194
+ throw new Error(`Request timeout after ${this.config.timeout}s`);
195
+ }
196
+ throw error;
197
+ }
198
+ }
199
+ }
200
+
201
+ export function createVLMClient(): VLMClient {
202
+ const config: ModelConfig = {
203
+ baseUrl: process.env.DEMO_MODEL_URL || 'http://localhost:8000/v1',
204
+ modelName: process.env.DEMO_MODEL_NAME || 'Qwen/Qwen3-VL-8B-Instruct',
205
+ apiKey: process.env.DEMO_API_KEY || '',
206
+ maxTokens: parseInt(process.env.DEMO_MAX_TOKENS || '4096', 10),
207
+ temperature: parseFloat(process.env.DEMO_TEMPERATURE || '0.1'),
208
+ timeout: parseInt(process.env.DEMO_TIMEOUT || '120', 10),
209
+ };
210
+
211
+ return new VLMClient(config);
212
+ }
src/lib/dataset/DatasetProvider.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useEffect,
8
+ useCallback,
9
+ ReactNode,
10
+ } from 'react';
11
+ import { datasetLoader, FilterOptions, LoadExamplesResult } from './loader';
12
+ import type { DatasetExample, CodingProblem } from '@/types';
13
+
14
+ type Split = 'train' | 'validation' | 'test';
15
+
16
+ interface DatasetContextValue {
17
+ isLoading: boolean;
18
+ loadedSplits: Set<Split>;
19
+ splitCounts: Record<string, number>;
20
+ loadSplit: (split: Split) => Promise<void>;
21
+ filterExamples: (
22
+ split: Split,
23
+ filters: FilterOptions,
24
+ limit?: number,
25
+ offset?: number
26
+ ) => LoadExamplesResult;
27
+ getCodingProblems: (split: Split) => CodingProblem[];
28
+ getAllExamples: (split: Split) => DatasetExample[];
29
+ }
30
+
31
+ const DatasetContext = createContext<DatasetContextValue | null>(null);
32
+
33
+ interface DatasetProviderProps {
34
+ children: ReactNode;
35
+ initialSplits?: Split[];
36
+ }
37
+
38
+ export function DatasetProvider({
39
+ children,
40
+ initialSplits = ['train', 'test', 'validation'],
41
+ }: DatasetProviderProps) {
42
+ const [isLoading, setIsLoading] = useState(true);
43
+ const [loadedSplits, setLoadedSplits] = useState<Set<Split>>(new Set());
44
+ const [splitCounts, setSplitCounts] = useState<Record<string, number>>({});
45
+
46
+ // Load initial splits on mount
47
+ useEffect(() => {
48
+ const loadInitialData = async () => {
49
+ setIsLoading(true);
50
+ try {
51
+ // Load split info first
52
+ const info = await datasetLoader.getSplitInfo();
53
+ setSplitCounts(info);
54
+
55
+ // Load initial splits in parallel
56
+ await Promise.all(
57
+ initialSplits.map(async (split) => {
58
+ await datasetLoader.preloadSplit(split);
59
+ setLoadedSplits((prev) => new Set([...prev, split]));
60
+ })
61
+ );
62
+ } catch (error) {
63
+ console.error('Failed to load dataset:', error);
64
+ } finally {
65
+ setIsLoading(false);
66
+ }
67
+ };
68
+
69
+ loadInitialData();
70
+ }, []);
71
+
72
+ const loadSplit = useCallback(async (split: Split) => {
73
+ if (datasetLoader.isLoaded(split)) {
74
+ setLoadedSplits((prev) => new Set([...prev, split]));
75
+ return;
76
+ }
77
+
78
+ await datasetLoader.preloadSplit(split);
79
+ setLoadedSplits((prev) => new Set([...prev, split]));
80
+
81
+ // Update counts after loading
82
+ const examples = datasetLoader.getAllExamples(split);
83
+ setSplitCounts((prev) => ({ ...prev, [split]: examples.length }));
84
+ }, []);
85
+
86
+ const filterExamples = useCallback(
87
+ (
88
+ split: Split,
89
+ filters: FilterOptions,
90
+ limit: number = 50,
91
+ offset: number = 0
92
+ ): LoadExamplesResult => {
93
+ if (!datasetLoader.isLoaded(split)) {
94
+ return { examples: [], total: 0 };
95
+ }
96
+ return datasetLoader.filterExamples(split, filters, limit, offset);
97
+ },
98
+ []
99
+ );
100
+
101
+ const getCodingProblems = useCallback((split: Split): CodingProblem[] => {
102
+ return datasetLoader.getCodingProblems(split);
103
+ }, []);
104
+
105
+ const getAllExamples = useCallback((split: Split): DatasetExample[] => {
106
+ return datasetLoader.getAllExamples(split);
107
+ }, []);
108
+
109
+ return (
110
+ <DatasetContext.Provider
111
+ value={{
112
+ isLoading,
113
+ loadedSplits,
114
+ splitCounts,
115
+ loadSplit,
116
+ filterExamples,
117
+ getCodingProblems,
118
+ getAllExamples,
119
+ }}
120
+ >
121
+ {children}
122
+ </DatasetContext.Provider>
123
+ );
124
+ }
125
+
126
+ export function useDataset() {
127
+ const context = useContext(DatasetContext);
128
+ if (!context) {
129
+ throw new Error('useDataset must be used within a DatasetProvider');
130
+ }
131
+ return context;
132
+ }
133
+
src/lib/dataset/loader.ts ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetExample, TaskType, Category, CodingProblem } from '@/types';
2
+
3
+ interface HFImage {
4
+ src: string;
5
+ height: number;
6
+ width: number;
7
+ }
8
+
9
+ interface HFDatasetRow {
10
+ question: string;
11
+ answer: string;
12
+ type: string;
13
+ category: string;
14
+ image: HFImage | null;
15
+ test_code: string | null;
16
+ entry_point: string | null;
17
+ source: string;
18
+ }
19
+
20
+ interface HFDatasetResponse {
21
+ rows: Array<{ row: HFDatasetRow; row_idx: number }>;
22
+ num_rows_total: number;
23
+ }
24
+
25
+ interface HFSplitInfo {
26
+ num_examples: number;
27
+ }
28
+
29
+ interface HFDatasetInfo {
30
+ dataset_info?: {
31
+ default?: {
32
+ splits?: Record<string, HFSplitInfo>;
33
+ };
34
+ };
35
+ }
36
+
37
+ export interface LoadExamplesResult {
38
+ examples: DatasetExample[];
39
+ total: number;
40
+ }
41
+
42
+ export interface FilterOptions {
43
+ type?: TaskType;
44
+ category?: Category;
45
+ hasImage?: boolean;
46
+ search?: string;
47
+ codingOnly?: boolean;
48
+ }
49
+
50
+ const HF_DATASET_API = 'https://datasets-server.huggingface.co';
51
+ const DATASET_ID = 'samuellimabraz/quantum-assistant';
52
+ const MAX_FETCH_LIMIT = 100;
53
+
54
+ export class DatasetLoader {
55
+ private splitData: Map<string, DatasetExample[]> = new Map();
56
+ private splitInfo: Record<string, number> = {};
57
+ private isLoading: Map<string, Promise<void>> = new Map();
58
+
59
+ /**
60
+ * Preload all examples for a split (fetches all data at once)
61
+ */
62
+ async preloadSplit(split: 'train' | 'validation' | 'test'): Promise<void> {
63
+ if (this.splitData.has(split)) {
64
+ return;
65
+ }
66
+
67
+ // Prevent duplicate loading
68
+ if (this.isLoading.has(split)) {
69
+ return this.isLoading.get(split);
70
+ }
71
+
72
+ const loadPromise = this.fetchAllExamples(split);
73
+ this.isLoading.set(split, loadPromise);
74
+
75
+ try {
76
+ await loadPromise;
77
+ } finally {
78
+ this.isLoading.delete(split);
79
+ }
80
+ }
81
+
82
+ private async fetchAllExamples(split: 'train' | 'validation' | 'test'): Promise<void> {
83
+ const allExamples: DatasetExample[] = [];
84
+ let offset = 0;
85
+ let total = 0;
86
+
87
+ // First request to get total count
88
+ const firstBatch = await this.fetchBatch(split, 0, MAX_FETCH_LIMIT);
89
+ allExamples.push(...firstBatch.examples);
90
+ total = firstBatch.total;
91
+ offset = firstBatch.examples.length;
92
+
93
+ // Fetch remaining batches
94
+ while (offset < total) {
95
+ const batch = await this.fetchBatch(split, offset, MAX_FETCH_LIMIT);
96
+ allExamples.push(...batch.examples);
97
+ offset += batch.examples.length;
98
+
99
+ if (batch.examples.length < MAX_FETCH_LIMIT) break;
100
+ }
101
+
102
+ this.splitData.set(split, allExamples);
103
+ this.splitInfo[split] = allExamples.length;
104
+ }
105
+
106
+ private async fetchBatch(
107
+ split: string,
108
+ offset: number,
109
+ limit: number
110
+ ): Promise<{ examples: DatasetExample[]; total: number }> {
111
+ const url = `${HF_DATASET_API}/rows?dataset=${encodeURIComponent(DATASET_ID)}&config=default&split=${split}&offset=${offset}&length=${limit}`;
112
+
113
+ const response = await fetch(url);
114
+ if (!response.ok) {
115
+ throw new Error(`Failed to load dataset: ${response.status}`);
116
+ }
117
+
118
+ const data: HFDatasetResponse = await response.json();
119
+
120
+ const examples: DatasetExample[] = data.rows.map((item) => {
121
+ const row = item.row;
122
+ return {
123
+ id: `${split}-${item.row_idx}`,
124
+ question: row.question,
125
+ answer: row.answer,
126
+ type: row.type as TaskType,
127
+ category: row.category as Category,
128
+ imageUrl: row.image?.src || undefined,
129
+ hasImage: row.image !== null,
130
+ testCode: row.test_code || undefined,
131
+ entryPoint: row.entry_point || undefined,
132
+ source: row.source,
133
+ };
134
+ });
135
+
136
+ return { examples, total: data.num_rows_total };
137
+ }
138
+
139
+ /**
140
+ * Check if a split is loaded
141
+ */
142
+ isLoaded(split: 'train' | 'validation' | 'test'): boolean {
143
+ return this.splitData.has(split);
144
+ }
145
+
146
+ /**
147
+ * Get loading progress (for UI feedback)
148
+ */
149
+ isCurrentlyLoading(split: 'train' | 'validation' | 'test'): boolean {
150
+ return this.isLoading.has(split);
151
+ }
152
+
153
+ /**
154
+ * Get all examples for a split (must be preloaded first)
155
+ */
156
+ getAllExamples(split: 'train' | 'validation' | 'test'): DatasetExample[] {
157
+ return this.splitData.get(split) || [];
158
+ }
159
+
160
+ /**
161
+ * Get coding problems from loaded data
162
+ */
163
+ getCodingProblems(split: 'train' | 'validation' | 'test'): CodingProblem[] {
164
+ const examples = this.splitData.get(split) || [];
165
+ return examples.filter(
166
+ (e): e is CodingProblem =>
167
+ e.testCode !== undefined &&
168
+ e.entryPoint !== undefined &&
169
+ (e.type === 'function_completion' || e.type === 'code_generation')
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Filter and paginate locally loaded data
175
+ */
176
+ filterExamples(
177
+ split: 'train' | 'validation' | 'test',
178
+ filters: FilterOptions,
179
+ limit: number = 50,
180
+ offset: number = 0
181
+ ): LoadExamplesResult {
182
+ let examples = filters.codingOnly
183
+ ? this.getCodingProblems(split)
184
+ : this.getAllExamples(split);
185
+
186
+ // Apply filters
187
+ if (filters.type) {
188
+ examples = examples.filter((e) => e.type === filters.type);
189
+ }
190
+ if (filters.category) {
191
+ examples = examples.filter((e) => e.category === filters.category);
192
+ }
193
+ if (filters.hasImage !== undefined) {
194
+ examples = examples.filter((e) => e.hasImage === filters.hasImage);
195
+ }
196
+ if (filters.search) {
197
+ const searchLower = filters.search.toLowerCase();
198
+ examples = examples.filter(
199
+ (e) =>
200
+ e.question.toLowerCase().includes(searchLower) ||
201
+ e.answer.toLowerCase().includes(searchLower)
202
+ );
203
+ }
204
+
205
+ const total = examples.length;
206
+ const paginated = examples.slice(offset, offset + limit);
207
+
208
+ return { examples: paginated, total };
209
+ }
210
+
211
+ /**
212
+ * Get split information
213
+ */
214
+ async getSplitInfo(): Promise<Record<string, number>> {
215
+ // Return cached if available
216
+ if (Object.keys(this.splitInfo).length > 0) {
217
+ return this.splitInfo;
218
+ }
219
+
220
+ const url = `${HF_DATASET_API}/info?dataset=${encodeURIComponent(DATASET_ID)}`;
221
+
222
+ try {
223
+ const response = await fetch(url);
224
+ if (!response.ok) {
225
+ return { train: 8366, validation: 1247, test: 1291 };
226
+ }
227
+
228
+ const data: HFDatasetInfo = await response.json();
229
+ const splits = data.dataset_info?.default?.splits || {};
230
+
231
+ const result: Record<string, number> = {};
232
+ for (const [name, info] of Object.entries(splits)) {
233
+ result[name] = info.num_examples || 0;
234
+ }
235
+
236
+ this.splitInfo = result;
237
+ return result;
238
+ } catch {
239
+ return { train: 8366, validation: 1247, test: 1291 };
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Clear cache
245
+ */
246
+ clearCache(): void {
247
+ this.splitData.clear();
248
+ this.splitInfo = {};
249
+ }
250
+ }
251
+
252
+ export const datasetLoader = new DatasetLoader();
src/lib/hooks/useWarmup.ts ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+
5
+ export type WarmupStatus = 'idle' | 'checking' | 'warming' | 'ready' | 'error';
6
+
7
+ interface WarmupState {
8
+ status: WarmupStatus;
9
+ message: string;
10
+ workers: {
11
+ idle: number;
12
+ ready?: number;
13
+ running: number;
14
+ initializing: number;
15
+ } | null;
16
+ }
17
+
18
+ interface UseWarmupReturn extends WarmupState {
19
+ triggerWarmup: () => Promise<void>;
20
+ checkStatus: () => Promise<void>;
21
+ }
22
+
23
+ let warmupInProgress = false;
24
+ let lastWarmupTime = 0;
25
+ const WARMUP_COOLDOWN = 5000;
26
+
27
+ /**
28
+ * Hook to pre-warm RunPod workers when user lands on the page.
29
+ * Automatically triggers warmup on mount and polls for readiness.
30
+ */
31
+ export function useWarmup(autoWarmup = true): UseWarmupReturn {
32
+ const [state, setState] = useState<WarmupState>({
33
+ status: 'idle',
34
+ message: '',
35
+ workers: null,
36
+ });
37
+ const mountedRef = useRef(true);
38
+
39
+ const checkStatus = useCallback(async () => {
40
+ try {
41
+ const response = await fetch('/api/warmup', { method: 'GET' });
42
+ if (response.ok && mountedRef.current) {
43
+ const data = await response.json();
44
+
45
+ if (data.ready) {
46
+ setState({
47
+ status: 'ready',
48
+ message: 'Model ready',
49
+ workers: data.workers || null,
50
+ });
51
+ return;
52
+ }
53
+
54
+ if (data.warming) {
55
+ setState({
56
+ status: 'warming',
57
+ message: 'Model starting...',
58
+ workers: data.workers || null,
59
+ });
60
+ return;
61
+ }
62
+
63
+ if (data.busy) {
64
+ setState({
65
+ status: 'warming',
66
+ message: 'Model busy...',
67
+ workers: data.workers || null,
68
+ });
69
+ return;
70
+ }
71
+
72
+ setState((prev) => ({
73
+ ...prev,
74
+ workers: data.workers || null,
75
+ }));
76
+ }
77
+ } catch {
78
+ // Silently fail status checks
79
+ }
80
+ }, []);
81
+
82
+ const triggerWarmup = useCallback(async () => {
83
+ // Prevent duplicate warmups
84
+ const now = Date.now();
85
+ if (warmupInProgress || (now - lastWarmupTime) < WARMUP_COOLDOWN) {
86
+ await checkStatus();
87
+ return;
88
+ }
89
+
90
+ warmupInProgress = true;
91
+ lastWarmupTime = now;
92
+
93
+ setState((prev) => ({ ...prev, status: 'checking', message: 'Checking model status...' }));
94
+
95
+ try {
96
+ const response = await fetch('/api/warmup', { method: 'POST' });
97
+ const data = await response.json();
98
+
99
+ if (!mountedRef.current) return;
100
+
101
+ if (data.status === 'ready') {
102
+ setState({
103
+ status: 'ready',
104
+ message: 'Model ready',
105
+ workers: data.workers || null,
106
+ });
107
+ } else if (data.status === 'warming') {
108
+ setState({
109
+ status: 'warming',
110
+ message: data.message || 'Starting model...',
111
+ workers: data.workers || null,
112
+ });
113
+ } else if (data.status === 'skipped') {
114
+ setState({
115
+ status: 'ready',
116
+ message: '',
117
+ workers: null,
118
+ });
119
+ } else if (data.status === 'error') {
120
+ setState({
121
+ status: 'error',
122
+ message: data.message || 'Warmup failed',
123
+ workers: null,
124
+ });
125
+ }
126
+ } catch (error) {
127
+ if (mountedRef.current) {
128
+ setState({
129
+ status: 'error',
130
+ message: error instanceof Error ? error.message : 'Warmup request failed',
131
+ workers: null,
132
+ });
133
+ }
134
+ } finally {
135
+ warmupInProgress = false;
136
+ }
137
+ }, [checkStatus]);
138
+
139
+ useEffect(() => {
140
+ mountedRef.current = true;
141
+
142
+ if (autoWarmup) {
143
+ triggerWarmup();
144
+ }
145
+
146
+ return () => {
147
+ mountedRef.current = false;
148
+ };
149
+ }, [autoWarmup, triggerWarmup]);
150
+
151
+ useEffect(() => {
152
+ if (state.status !== 'warming') return;
153
+
154
+ const pollInterval = setInterval(() => {
155
+ checkStatus();
156
+ }, 5000);
157
+
158
+ const timeout = setTimeout(() => {
159
+ clearInterval(pollInterval);
160
+ }, 180000);
161
+
162
+ return () => {
163
+ clearInterval(pollInterval);
164
+ clearTimeout(timeout);
165
+ };
166
+ }, [state.status, checkStatus]);
167
+
168
+ return {
169
+ ...state,
170
+ triggerWarmup,
171
+ checkStatus,
172
+ };
173
+ }
174
+
src/lib/utils/image.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MAX_DIMENSION = 640;
2
+
3
+ export async function resizeImageForInference(
4
+ imageSource: string
5
+ ): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ const img = new Image();
8
+ img.crossOrigin = 'anonymous';
9
+
10
+ img.onload = () => {
11
+ let { width, height } = img;
12
+
13
+ if (width <= MAX_DIMENSION && height <= MAX_DIMENSION) {
14
+ if (imageSource.startsWith('data:')) {
15
+ resolve(imageSource.split(',')[1]);
16
+ } else {
17
+ const canvas = document.createElement('canvas');
18
+ canvas.width = width;
19
+ canvas.height = height;
20
+ const ctx = canvas.getContext('2d');
21
+ if (!ctx) {
22
+ reject(new Error('Failed to get canvas context'));
23
+ return;
24
+ }
25
+ ctx.drawImage(img, 0, 0);
26
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.95);
27
+ resolve(dataUrl.split(',')[1]);
28
+ }
29
+ return;
30
+ }
31
+
32
+ const scale = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);
33
+ const newWidth = Math.round(width * scale);
34
+ const newHeight = Math.round(height * scale);
35
+
36
+ const canvas = document.createElement('canvas');
37
+ canvas.width = newWidth;
38
+ canvas.height = newHeight;
39
+
40
+ const ctx = canvas.getContext('2d');
41
+ if (!ctx) {
42
+ reject(new Error('Failed to get canvas context'));
43
+ return;
44
+ }
45
+
46
+ ctx.imageSmoothingEnabled = true;
47
+ ctx.imageSmoothingQuality = 'high';
48
+ ctx.drawImage(img, 0, 0, newWidth, newHeight);
49
+
50
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.95);
51
+ resolve(dataUrl.split(',')[1]);
52
+ };
53
+
54
+ img.onerror = () => {
55
+ reject(new Error('Failed to load image'));
56
+ };
57
+
58
+ img.src = imageSource;
59
+ });
60
+ }
61
+
62
+ export async function fetchAndResizeImage(url: string): Promise<string> {
63
+ try {
64
+ const response = await fetch(url);
65
+ const blob = await response.blob();
66
+ const dataUrl = await new Promise<string>((resolve) => {
67
+ const reader = new FileReader();
68
+ reader.onloadend = () => resolve(reader.result as string);
69
+ reader.readAsDataURL(blob);
70
+ });
71
+ return resizeImageForInference(dataUrl);
72
+ } catch (error) {
73
+ console.error('Failed to fetch and resize image:', error);
74
+ throw error;
75
+ }
76
+ }
77
+
src/lib/utils/response.ts ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Response processing utilities for formatting model output.
3
+ * Handles code extraction, markdown formatting, and indentation normalization.
4
+ */
5
+
6
+ /**
7
+ * Extract code blocks from model response.
8
+ * Handles markdown code blocks and detects code patterns.
9
+ */
10
+ export function extractCodeFromResponse(response: string, entryPoint?: string): string {
11
+ // Find all markdown code blocks
12
+ const codeBlockRegex = /```(?:python)?\s*\n([\s\S]*?)```/g;
13
+ const matches: string[] = [];
14
+ let match;
15
+
16
+ while ((match = codeBlockRegex.exec(response)) !== null) {
17
+ // Preserve indentation - only trim trailing whitespace, not leading
18
+ matches.push(match[1].replace(/\s+$/, ''));
19
+ }
20
+
21
+ if (matches.length === 0) {
22
+ // No code blocks found - the response itself might be code
23
+ // Preserve indentation by only trimming trailing whitespace
24
+ return response.replace(/\s+$/, '');
25
+ }
26
+
27
+ if (matches.length === 1) {
28
+ return matches[0];
29
+ }
30
+
31
+ // If multiple blocks, prefer one with entry point
32
+ if (entryPoint) {
33
+ const entryPointRegex = new RegExp(`def\\s+${escapeRegex(entryPoint)}\\s*\\(`);
34
+ for (const block of matches) {
35
+ if (entryPointRegex.test(block)) {
36
+ return block;
37
+ }
38
+ }
39
+ }
40
+
41
+ // Return longest block
42
+ return matches.reduce((a, b) => (a.length > b.length ? a : b));
43
+ }
44
+
45
+ /**
46
+ * Detect if text contains Python code patterns.
47
+ */
48
+ export function detectsPythonCode(text: string): boolean {
49
+ const pythonPatterns = [
50
+ /^from\s+\w+\s+import/m,
51
+ /^import\s+\w+/m,
52
+ /^def\s+\w+\s*\(/m,
53
+ /^class\s+\w+/m,
54
+ /^\s*@\w+/m, // decorators
55
+ /QuantumCircuit\s*\(/,
56
+ /\.h\s*\(/,
57
+ /\.cx\s*\(/,
58
+ /\.measure/,
59
+ /qc\s*=\s*QuantumCircuit/,
60
+ ];
61
+
62
+ return pythonPatterns.some((pattern) => pattern.test(text));
63
+ }
64
+
65
+ /**
66
+ * Format response with proper markdown code blocks.
67
+ * Ensures code is properly fenced for rendering.
68
+ */
69
+ export function formatResponseWithCodeBlocks(response: string): string {
70
+ // If response already has code blocks, return as-is
71
+ if (/```[\s\S]*```/.test(response)) {
72
+ return response;
73
+ }
74
+
75
+ // Check if the entire response looks like code
76
+ const lines = response.split('\n');
77
+ const codeLines = lines.filter((line) => {
78
+ const trimmed = line.trim();
79
+ return (
80
+ trimmed.startsWith('from ') ||
81
+ trimmed.startsWith('import ') ||
82
+ trimmed.startsWith('def ') ||
83
+ trimmed.startsWith('class ') ||
84
+ trimmed.startsWith('@') ||
85
+ trimmed.startsWith('#') ||
86
+ /^\s*\w+\s*=/.test(trimmed) ||
87
+ /^\s*\w+\.\w+\(/.test(trimmed) ||
88
+ /^\s*return\s/.test(trimmed) ||
89
+ /^\s*if\s/.test(trimmed) ||
90
+ /^\s*for\s/.test(trimmed) ||
91
+ /^\s*while\s/.test(trimmed) ||
92
+ /^\s*try:/.test(trimmed) ||
93
+ /^\s*except/.test(trimmed) ||
94
+ trimmed === '' ||
95
+ trimmed === 'pass'
96
+ );
97
+ });
98
+
99
+ // If most lines look like code, wrap entire response
100
+ if (codeLines.length > lines.length * 0.7 && detectsPythonCode(response)) {
101
+ return '```python\n' + response.trim() + '\n```';
102
+ }
103
+
104
+ // Try to detect inline code that should be blocks
105
+ // Pattern: text followed by code on same line or multiple statements
106
+ const inlineCodePattern =
107
+ /(from\s+\w+\s+import\s+[\w,\s]+)\s+([\w]+\s*=\s*\w+\([^)]*\)(?:\s+[\w.]+\([^)]*\))*)/g;
108
+
109
+ if (inlineCodePattern.test(response)) {
110
+ // Split inline code into proper lines
111
+ const formatted = response
112
+ .replace(
113
+ /(from\s+\w+\s+import\s+[\w,\s]+)/g,
114
+ '\n```python\n$1'
115
+ )
116
+ .replace(
117
+ /\s+([\w]+\s*=\s*\w+\([^)]*\))/g,
118
+ '\n$1'
119
+ )
120
+ .replace(
121
+ /(\s+[\w.]+\([^)]*\))(?=\s+[\w.]+\()/g,
122
+ '$1\n'
123
+ );
124
+
125
+ // Clean up and close code block
126
+ const lines = formatted.split('\n');
127
+ let inCodeBlock = false;
128
+ const result: string[] = [];
129
+
130
+ for (const line of lines) {
131
+ if (line.includes('```python')) {
132
+ inCodeBlock = true;
133
+ }
134
+ result.push(line);
135
+ }
136
+
137
+ if (inCodeBlock) {
138
+ result.push('```');
139
+ }
140
+
141
+ return result.join('\n');
142
+ }
143
+
144
+ return response;
145
+ }
146
+
147
+ /**
148
+ * Process streaming chunk to maintain markdown structure.
149
+ * Handles partial code blocks during streaming.
150
+ */
151
+ export function processStreamingContent(
152
+ fullContent: string,
153
+ previousContent: string
154
+ ): { content: string; isInCodeBlock: boolean } {
155
+ // Count code block markers
156
+ const openMarkers = (fullContent.match(/```/g) || []).length;
157
+ const isInCodeBlock = openMarkers % 2 === 1;
158
+
159
+ return {
160
+ content: fullContent,
161
+ isInCodeBlock,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Normalize code indentation.
167
+ * Similar to _normalize_body_indentation in synthetic.py
168
+ *
169
+ * Handles the common pattern where model outputs function completion code with:
170
+ * - First line at 0 indentation
171
+ * - Subsequent lines with extra indentation (e.g., 4 spaces)
172
+ */
173
+ export function normalizeIndentation(code: string, targetIndent: number = 0): string {
174
+ const lines = code.split('\n');
175
+ const nonEmptyLines = lines
176
+ .map((line, idx) => ({ line, idx }))
177
+ .filter(({ line }) => line.trim().length > 0);
178
+
179
+ if (nonEmptyLines.length === 0) {
180
+ return code;
181
+ }
182
+
183
+ // Get first non-empty line's indentation
184
+ const firstNonEmpty = nonEmptyLines[0];
185
+ const firstIndent = getIndent(firstNonEmpty.line);
186
+
187
+ // Check for the common pattern: first line at 0, rest at 4+
188
+ if (firstIndent === 0 && nonEmptyLines.length > 1) {
189
+ const subsequentIndents = nonEmptyLines.slice(1).map(({ line }) => getIndent(line));
190
+ const minSubsequent = Math.min(...subsequentIndents);
191
+
192
+ // If subsequent lines have extra indentation, they should align with first line
193
+ if (minSubsequent > 0) {
194
+ const result: string[] = [];
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i];
197
+ if (!line.trim()) {
198
+ result.push('');
199
+ } else if (i === firstNonEmpty.idx) {
200
+ // First line gets target indent
201
+ result.push(' '.repeat(targetIndent) + line.trim());
202
+ } else {
203
+ // Subsequent lines: remove extra base indent, add target
204
+ const currentIndent = getIndent(line);
205
+ const relative = currentIndent - minSubsequent;
206
+ const newIndent = ' '.repeat(targetIndent + Math.max(0, relative));
207
+ result.push(newIndent + line.trim());
208
+ }
209
+ }
210
+ return result.join('\n');
211
+ }
212
+ }
213
+
214
+ // Standard case: subtract min indent and add target
215
+ const minIndent = Math.min(
216
+ ...nonEmptyLines.map(({ line }) => getIndent(line))
217
+ );
218
+
219
+ return lines
220
+ .map((line) => {
221
+ if (line.trim().length === 0) {
222
+ return '';
223
+ }
224
+ const currentIndent = getIndent(line);
225
+ const relativeIndent = currentIndent - minIndent;
226
+ const newIndent = ' '.repeat(targetIndent + relativeIndent);
227
+ return newIndent + line.trim();
228
+ })
229
+ .join('\n');
230
+ }
231
+
232
+ /**
233
+ * Get the indentation level of a line.
234
+ */
235
+ function getIndent(line: string): number {
236
+ const match = line.match(/^(\s*)/);
237
+ return match ? match[1].length : 0;
238
+ }
239
+
240
+ /**
241
+ * Post-process complete response for display.
242
+ * Applies formatting, code detection, and normalization.
243
+ */
244
+ export function postProcessResponse(response: string): string {
245
+ if (!response || response.trim().length === 0) {
246
+ return response;
247
+ }
248
+
249
+ // First, try to format with proper code blocks
250
+ let processed = formatResponseWithCodeBlocks(response);
251
+
252
+ // Normalize indentation within code blocks
253
+ processed = processed.replace(
254
+ /```python\n([\s\S]*?)```/g,
255
+ (match, code) => {
256
+ const normalized = normalizeIndentation(code.trim());
257
+ return '```python\n' + normalized + '\n```';
258
+ }
259
+ );
260
+
261
+ return processed;
262
+ }
263
+
264
+ function escapeRegex(string: string): string {
265
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
266
+ }
267
+
src/types/index.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type TaskType = 'function_completion' | 'code_generation' | 'qa';
2
+
3
+ export type Category =
4
+ | 'circuits_and_gates'
5
+ | 'quantum_info_and_operators'
6
+ | 'algorithms_and_applications'
7
+ | 'hardware_and_providers'
8
+ | 'transpilation_and_compilation'
9
+ | 'primitives_and_execution'
10
+ | 'noise_and_error_mitigation';
11
+
12
+ export interface DatasetExample {
13
+ id: string;
14
+ question: string;
15
+ answer: string;
16
+ type: TaskType;
17
+ category: Category;
18
+ imageUrl?: string;
19
+ hasImage: boolean;
20
+ testCode?: string;
21
+ entryPoint?: string;
22
+ source: string;
23
+ }
24
+
25
+ export interface CodingProblem extends DatasetExample {
26
+ testCode: string;
27
+ entryPoint: string;
28
+ }
29
+
30
+ export interface Message {
31
+ id: string;
32
+ role: 'user' | 'assistant' | 'system';
33
+ content: string;
34
+ imageUrl?: string;
35
+ imageBase64?: string;
36
+ timestamp: Date;
37
+ isLoading?: boolean;
38
+ }
39
+
40
+ export interface ChatRequest {
41
+ messages: Array<{
42
+ role: string;
43
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
44
+ }>;
45
+ image?: string;
46
+ }
47
+
48
+ export interface ChatResponse {
49
+ content: string;
50
+ error?: string;
51
+ }
52
+
53
+ export interface ModelConfig {
54
+ baseUrl: string;
55
+ modelName: string;
56
+ apiKey: string;
57
+ maxTokens: number;
58
+ temperature: number;
59
+ timeout: number;
60
+ }
61
+
62
+ export interface ExecuteRequest {
63
+ code: string;
64
+ timeout?: number;
65
+ }
66
+
67
+ export interface ExecuteResponse {
68
+ success: boolean;
69
+ output: string;
70
+ error: string;
71
+ executionTime: number;
72
+ hasCircuitOutput?: boolean;
73
+ }
74
+
75
+ export interface TestResult {
76
+ passed: boolean;
77
+ total: number;
78
+ failed: number;
79
+ details: TestCaseResult[];
80
+ executionTime: number;
81
+ error?: string;
82
+ traceback?: string;
83
+ output?: string;
84
+ }
85
+
86
+ export interface TestCaseResult {
87
+ name: string;
88
+ passed: boolean;
89
+ expected?: string;
90
+ actual?: string;
91
+ error?: string;
92
+ }
93
+
94
+ export type AppMode = 'chat' | 'practice';
tailwind.config.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: [
4
+ './src/**/*.{js,ts,jsx,tsx,mdx}',
5
+ ],
6
+ theme: {
7
+ extend: {
8
+ colors: {
9
+ quantum: {
10
+ 50: '#f0f4ff',
11
+ 100: '#e0e7ff',
12
+ 200: '#c7d2fe',
13
+ 300: '#a5b4fc',
14
+ 400: '#818cf8',
15
+ 500: '#6366f1',
16
+ 600: '#4f46e5',
17
+ 700: '#4338ca',
18
+ 800: '#3730a3',
19
+ 900: '#312e81',
20
+ 950: '#1e1b4b',
21
+ },
22
+ surface: {
23
+ 50: '#f8fafc',
24
+ 100: '#f1f5f9',
25
+ 200: '#e2e8f0',
26
+ 300: '#cbd5e1',
27
+ 400: '#94a3b8',
28
+ 500: '#64748b',
29
+ 600: '#475569',
30
+ 700: '#334155',
31
+ 800: '#1e293b',
32
+ 900: '#0f172a',
33
+ 950: '#020617',
34
+ },
35
+ },
36
+ fontFamily: {
37
+ sans: ['var(--font-geist-sans)', 'system-ui', 'sans-serif'],
38
+ mono: ['var(--font-geist-mono)', 'JetBrains Mono', 'monospace'],
39
+ display: ['var(--font-display)', 'system-ui', 'sans-serif'],
40
+ },
41
+ animation: {
42
+ 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
43
+ 'gradient': 'gradient 8s ease infinite',
44
+ 'float': 'float 6s ease-in-out infinite',
45
+ },
46
+ keyframes: {
47
+ gradient: {
48
+ '0%, 100%': { backgroundPosition: '0% 50%' },
49
+ '50%': { backgroundPosition: '100% 50%' },
50
+ },
51
+ float: {
52
+ '0%, 100%': { transform: 'translateY(0px)' },
53
+ '50%': { transform: 'translateY(-10px)' },
54
+ },
55
+ },
56
+ backgroundImage: {
57
+ 'grid-pattern': 'linear-gradient(to right, rgba(99, 102, 241, 0.03) 1px, transparent 1px), linear-gradient(to bottom, rgba(99, 102, 241, 0.03) 1px, transparent 1px)',
58
+ 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
59
+ },
60
+ backgroundSize: {
61
+ 'grid': '24px 24px',
62
+ },
63
+ },
64
+ },
65
+ plugins: [],
66
+ };
tsconfig.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "baseUrl": ".",
18
+ "paths": {
19
+ "@/*": ["./src/*"]
20
+ }
21
+ },
22
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
23
+ "exclude": ["node_modules"]
24
+ }
25
+