github-actions[bot] commited on
Commit ·
6cdce85
0
Parent(s):
Deploy demo from GitHub Actions - 2025-12-24 02:23:20
Browse files- .gitattributes +7 -0
- Dockerfile +55 -0
- README.md +44 -0
- next-env.d.ts +5 -0
- next.config.js +31 -0
- package-lock.json +0 -0
- package.json +39 -0
- postcss.config.js +7 -0
- public/logo.png +3 -0
- pyproject.toml +43 -0
- src/app/api/chat/route.ts +252 -0
- src/app/api/examples/route.ts +91 -0
- src/app/api/execute/route.ts +568 -0
- src/app/api/status/route.ts +145 -0
- src/app/api/test/route.ts +610 -0
- src/app/api/warmup/route.ts +192 -0
- src/app/globals.css +390 -0
- src/app/layout.tsx +34 -0
- src/app/page.tsx +206 -0
- src/components/Chat/ChatInterface.tsx +305 -0
- src/components/Chat/ExecutionResult.tsx +265 -0
- src/components/Chat/LoadingStatus.tsx +231 -0
- src/components/Chat/Message.tsx +563 -0
- src/components/Chat/MessageInput.tsx +198 -0
- src/components/Chat/QubitIcon.tsx +87 -0
- src/components/Chat/WarmupIndicator.tsx +136 -0
- src/components/Examples/ExampleCard.tsx +94 -0
- src/components/Examples/ExamplesPanel.tsx +420 -0
- src/components/Header/Header.tsx +129 -0
- src/components/Practice/AIHelper.tsx +692 -0
- src/components/Practice/CodeEditor.tsx +121 -0
- src/components/Practice/PracticeInterface.tsx +460 -0
- src/components/Practice/ProblemList.tsx +489 -0
- src/components/Practice/TestRunner.tsx +284 -0
- src/components/Practice/index.ts +6 -0
- src/components/ResizablePanel/ResizablePanel.tsx +165 -0
- src/components/index.ts +14 -0
- src/config/constants.ts +172 -0
- src/lib/api/vlm-client.ts +212 -0
- src/lib/dataset/DatasetProvider.tsx +133 -0
- src/lib/dataset/loader.ts +252 -0
- src/lib/hooks/useWarmup.ts +174 -0
- src/lib/utils/image.ts +77 -0
- src/lib/utils/response.ts +267 -0
- src/types/index.ts +94 -0
- tailwind.config.js +66 -0
- 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
|
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 |
+
"{debouncedSearch}"
|
| 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 |
+
"{debouncedSearch}"
|
| 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 |
+
|