First cut
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# ── Provider API keys ───────────────────────────────────────────────────────
|
||||||
|
# Only keys for models you intend to use are required.
|
||||||
|
|
||||||
|
# Anthropic — for claude-sonnet-4-6-20250929
|
||||||
|
# Get key: https://console.anthropic.com/settings/keys
|
||||||
|
ANTHROPIC_API_KEY=your-anthropic-key
|
||||||
|
|
||||||
|
# OpenAI — for gpt-5.2
|
||||||
|
# Get key: https://platform.openai.com/api-keys
|
||||||
|
OPENAI_API_KEY=your-openai-key
|
||||||
|
|
||||||
|
# Google AI Studio — for gemini-3-flash-preview
|
||||||
|
# Get key: https://aistudio.google.com/app/apikey
|
||||||
|
GOOGLE_API_KEY=your-google-ai-studio-key
|
||||||
|
|
||||||
|
# Ollama Cloud — for qwen3.5:397b-cloud and llama4:maverick-cloud
|
||||||
|
# Get key: https://ollama.com → account → API keys
|
||||||
|
OLLAMA_API_KEY=your-ollama-api-key
|
||||||
|
|
||||||
|
# ── Server ──────────────────────────────────────────────────────────────────
|
||||||
|
PORT=3000
|
||||||
|
JOB_CONCURRENCY=3
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Vision Jobs
|
||||||
|
|
||||||
|
A multimodal visual analysis queue — submit an image + prompt, get a response from a cloud vision model. Switch between **Ollama Cloud** and **OpenRouter** by changing two environment variables, no code changes needed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Fastify server (port 3000)
|
||||||
|
│ openai npm package (OpenAI-compatible client)
|
||||||
|
▼
|
||||||
|
LLM_BASE_URL (configured in .env)
|
||||||
|
├─ https://ollama.com/v1 → Ollama Cloud (qwen3.5:397b-cloud, etc.)
|
||||||
|
└─ https://openrouter.ai/api/v1 → OpenRouter (300+ providers)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both Ollama Cloud and OpenRouter expose an OpenAI-compatible `/v1/chat/completions` endpoint, so the same `openai` npm package talks to both. No proxy or sidecar required.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Node.js · Fastify 5 · `@fastify/websocket` · `@fastify/multipart` |
|
||||||
|
| LLM client | `openai` npm package (pointed at Ollama Cloud or OpenRouter) |
|
||||||
|
| Queue | `p-queue` (in-process, no external server) |
|
||||||
|
| Database | SQLite via Sequelize ORM |
|
||||||
|
| Frontend | React 18 · Vite · plain CSS |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
**1. Install dependencies**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure your provider**
|
||||||
|
```bash
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
# Edit server/.env — choose Ollama Cloud or OpenRouter (see comments inside)
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run in development**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frontend → http://localhost:5173
|
||||||
|
- Backend → http://localhost:3000
|
||||||
|
|
||||||
|
**4. Production build**
|
||||||
|
```bash
|
||||||
|
npm run build # builds React into client/dist
|
||||||
|
npm start # serves everything from Fastify on port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider configuration
|
||||||
|
|
||||||
|
Edit `server/.env` and uncomment the block for the provider you want:
|
||||||
|
|
||||||
|
### Ollama Cloud
|
||||||
|
Get an API key at https://ollama.com → account → API keys.
|
||||||
|
Model IDs listed at https://ollama.com/search?c=cloud
|
||||||
|
```bash
|
||||||
|
LLM_BASE_URL=https://ollama.com/v1
|
||||||
|
LLM_API_KEY=your-ollama-api-key
|
||||||
|
LLM_MODEL=qwen3.5:397b-cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenRouter
|
||||||
|
Get an API key at https://openrouter.ai/keys.
|
||||||
|
Model IDs listed at https://openrouter.ai/models (format: `provider/model`)
|
||||||
|
```bash
|
||||||
|
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
LLM_API_KEY=sk-or-v1-...
|
||||||
|
LLM_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
vision-jobs/
|
||||||
|
├── server/
|
||||||
|
│ ├── index.js # Fastify entry point
|
||||||
|
│ ├── routes/jobs.js # REST + WebSocket routes
|
||||||
|
│ ├── jobs/queue.js # p-queue → openai client → provider
|
||||||
|
│ ├── db/models.js # Sequelize Job model (SQLite)
|
||||||
|
│ ├── ws/broadcast.js # WebSocket fan-out
|
||||||
|
│ └── .env.example
|
||||||
|
└── client/
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.js
|
||||||
|
└── src/
|
||||||
|
├── App.jsx
|
||||||
|
├── styles.css
|
||||||
|
├── components/
|
||||||
|
│ ├── ImageDrop.jsx # Drag-drop / file picker / camera
|
||||||
|
│ └── JobCard.jsx # Live status + result display
|
||||||
|
├── hooks/
|
||||||
|
│ └── useJobSocket.js
|
||||||
|
└── lib/
|
||||||
|
└── api.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. User drops an image + types a prompt → clicks **Analyze**.
|
||||||
|
2. `POST /api/jobs` receives the multipart upload, base64-encodes the image, saves a `queued` job to SQLite, and enqueues it via `p-queue`.
|
||||||
|
3. The queue runner calls the OpenAI-compatible `/v1/chat/completions` endpoint with the image embedded as a `data:` URI in an `image_url` content block.
|
||||||
|
4. As status changes (`queued → running → done/error`), the server broadcasts `job_update` WebSocket messages to every connected client.
|
||||||
|
5. React merges updates into the live job list — no polling.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `LLM_BASE_URL` | `https://ollama.com/v1` | Provider endpoint. |
|
||||||
|
| `LLM_API_KEY` | — | **Required.** API key for your chosen provider. |
|
||||||
|
| `LLM_MODEL` | `qwen3.5:397b-cloud` | Model identifier (format varies by provider). |
|
||||||
|
| `PORT` | `3000` | Server HTTP port. |
|
||||||
|
| `JOB_CONCURRENCY` | `3` | Max simultaneous LLM requests. |
|
||||||
63
db/models.js
Normal file
63
db/models.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Sequelize, DataTypes } from 'sequelize';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: join(__dirname, '../data/jobs.sqlite'),
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Job = sequelize.define('Job', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
// Base64 data URL stored for replay; in production use object storage
|
||||||
|
imageDataUrl: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
imageMimeType: {
|
||||||
|
type: DataTypes.STRING(64),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'image/jpeg',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
// queued | running | done | error
|
||||||
|
type: DataTypes.STRING(16),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'queued',
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: DataTypes.STRING(128),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'anthropic/claude-sonnet-4.6',
|
||||||
|
},
|
||||||
|
inputTokens: { type: DataTypes.INTEGER, allowNull: true },
|
||||||
|
outputTokens: { type: DataTypes.INTEGER, allowNull: true },
|
||||||
|
}, {
|
||||||
|
tableName: 'jobs',
|
||||||
|
timestamps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
const { mkdirSync } = await import('fs');
|
||||||
|
mkdirSync(join(__dirname, '../data'), { recursive: true });
|
||||||
|
await sequelize.sync();
|
||||||
|
}
|
||||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vision Jobs</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
index.js
Normal file
52
index.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import staticFiles from '@fastify/static';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
|
import { initDb } from './db/models.js';
|
||||||
|
import { jobRoutes } from './routes/jobs.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = parseInt(process.env.PORT ?? '3000', 10);
|
||||||
|
|
||||||
|
const fastify = Fastify({ logger: { level: 'info' } });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
await fastify.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: 20 * 1024 * 1024, // 20 MB max image
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(websocket);
|
||||||
|
|
||||||
|
// Serve the built React app — dist/ is built by `npm run dev` (watch)
|
||||||
|
// or `npm run build` before starting in production.
|
||||||
|
await fastify.register(staticFiles, {
|
||||||
|
root: join(__dirname, 'dist'),
|
||||||
|
prefix: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
await fastify.register(jobRoutes);
|
||||||
|
|
||||||
|
fastify.get('/api/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
// SPA fallback — let React Router handle any path Fastify doesn't recognise
|
||||||
|
fastify.setNotFoundHandler((_req, reply) => {
|
||||||
|
reply.sendFile('index.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
await initDb();
|
||||||
|
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
|
console.log(`✅ Server listening on http://localhost:${PORT}`);
|
||||||
115
jobs/queue.js
Normal file
115
jobs/queue.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import PQueue from 'p-queue';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
|
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||||
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
|
import { Job } from '../db/models.js';
|
||||||
|
import { broadcast } from '../ws/broadcast.js';
|
||||||
|
import { findModel, DEFAULT_MODEL_ID } from '../models.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider instances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const anthropic = createAnthropic({
|
||||||
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const google = createGoogleGenerativeAI({
|
||||||
|
apiKey: process.env.GOOGLE_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ollama Cloud exposes an OpenAI-compatible /v1 endpoint.
|
||||||
|
// Using @ai-sdk/openai-compatible avoids the local-Ollama schema validation
|
||||||
|
// in ollama-ai-provider which requires fields (eval_duration etc.) that
|
||||||
|
// Ollama Cloud doesn't return.
|
||||||
|
const ollamaCloud = createOpenAICompatible({
|
||||||
|
name: 'ollama-cloud',
|
||||||
|
baseURL: 'https://ollama.com/v1',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OLLAMA_API_KEY ?? ''}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROVIDERS = {
|
||||||
|
anthropic: (id) => anthropic(id),
|
||||||
|
openai: (id) => openai(id),
|
||||||
|
google: (id) => google(id),
|
||||||
|
ollama: (id) => ollamaCloud(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveModel(modelId) {
|
||||||
|
const meta = findModel(modelId) ?? findModel(DEFAULT_MODEL_ID);
|
||||||
|
return PROVIDERS[meta.provider](meta.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// p-queue: in-process queue, no external server needed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const queue = new PQueue({
|
||||||
|
concurrency: parseInt(process.env.JOB_CONCURRENCY ?? '3', 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.on('add', () => broadcastQueueStats());
|
||||||
|
queue.on('next', () => broadcastQueueStats());
|
||||||
|
queue.on('idle', () => broadcastQueueStats());
|
||||||
|
|
||||||
|
function broadcastQueueStats() {
|
||||||
|
broadcast({ type: 'queue_stats', pending: queue.size, running: queue.pending });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function setStatus(job, status, extra = {}) {
|
||||||
|
await job.update({ status, ...extra });
|
||||||
|
broadcast({ type: 'job_update', job: job.toJSON() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main runner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function runJob(jobId) {
|
||||||
|
const job = await Job.findByPk(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
await setStatus(job, 'running');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text, usage } = await generateText({
|
||||||
|
model: resolveModel(job.model),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: job.prompt },
|
||||||
|
{ type: 'image', image: job.imageDataUrl },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxTokens: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setStatus(job, 'done', {
|
||||||
|
result: text,
|
||||||
|
inputTokens: usage?.promptTokens ?? null,
|
||||||
|
outputTokens: usage?.completionTokens ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err?.message
|
||||||
|
? `${err.name ?? 'Error'}: ${err.message}${err.cause ? `\nCause: ${JSON.stringify(err.cause, null, 2)}` : ''}`
|
||||||
|
: String(err);
|
||||||
|
await setStatus(job, 'error', { errorMessage: detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function enqueueJob(jobId) {
|
||||||
|
queue.add(() => runJob(jobId));
|
||||||
|
}
|
||||||
4
models.js
Normal file
4
models.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Server-side re-export of the shared model config.
|
||||||
|
// Keeps the single source of truth in src/models.js (where Vite can reach it)
|
||||||
|
// while giving Node a root-level import path.
|
||||||
|
export { MODELS, DEFAULT_MODEL_ID, findModel } from './src/models.js';
|
||||||
5169
package-lock.json
generated
Normal file
5169
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "vision-jobs",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite build --watch & node --watch index.js",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^1.0.0",
|
||||||
|
"@ai-sdk/google": "^1.0.0",
|
||||||
|
"@ai-sdk/openai": "^1.0.0",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
"@fastify/static": "^8.0.3",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
|
"ai": "^4.3.16",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"@ai-sdk/openai-compatible": "^0.2.0",
|
||||||
|
"p-queue": "^8.0.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"sequelize": "^6.37.3",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
routes/jobs.js
Normal file
78
routes/jobs.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Job } from '../db/models.js';
|
||||||
|
import { enqueueJob } from '../jobs/queue.js';
|
||||||
|
import { registerClient } from '../ws/broadcast.js';
|
||||||
|
import { findModel, DEFAULT_MODEL_ID } from '../models.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export async function jobRoutes(fastify) {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// WebSocket — all clients connect here to receive live job updates
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
fastify.get('/ws', { websocket: true }, (socket) => {
|
||||||
|
registerClient(socket);
|
||||||
|
|
||||||
|
Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 })
|
||||||
|
.then((jobs) => {
|
||||||
|
if (socket.readyState === 1) {
|
||||||
|
socket.send(JSON.stringify({ type: 'init', jobs: jobs.map((j) => j.toJSON()) }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// POST /api/jobs
|
||||||
|
// Multipart form fields: prompt (text), modelId (text), image (file)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
fastify.post('/api/jobs', async (req, reply) => {
|
||||||
|
const data = await req.file();
|
||||||
|
if (!data) return reply.status(400).send({ error: 'No file uploaded' });
|
||||||
|
|
||||||
|
const fields = data.fields;
|
||||||
|
const prompt = (fields.prompt?.value ?? '').trim();
|
||||||
|
const modelId = (fields.modelId?.value ?? DEFAULT_MODEL_ID).trim();
|
||||||
|
|
||||||
|
if (!prompt) return reply.status(400).send({ error: 'prompt is required' });
|
||||||
|
|
||||||
|
// Validate that the submitted model ID is in our allowed list
|
||||||
|
const modelMeta = findModel(modelId);
|
||||||
|
if (!modelMeta) {
|
||||||
|
return reply.status(400).send({ error: `Unknown model: ${modelId}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of data.file) chunks.push(chunk);
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const mimeType = data.mimetype || 'image/jpeg';
|
||||||
|
const imageDataUrl = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||||
|
|
||||||
|
const job = await Job.create({
|
||||||
|
prompt,
|
||||||
|
imageDataUrl,
|
||||||
|
imageMimeType: mimeType,
|
||||||
|
model: modelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
enqueueJob(job.id);
|
||||||
|
return reply.status(201).send(job.toJSON());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GET /api/jobs
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
fastify.get('/api/jobs', async (_req, reply) => {
|
||||||
|
const jobs = await Job.findAll({ order: [['createdAt', 'DESC']], limit: 100 });
|
||||||
|
return reply.send(jobs.map((j) => j.toJSON()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GET /api/jobs/:id
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
fastify.get('/api/jobs/:id', async (req, reply) => {
|
||||||
|
const job = await Job.findByPk(req.params.id);
|
||||||
|
if (!job) return reply.status(404).send({ error: 'Not found' });
|
||||||
|
return reply.send(job.toJSON());
|
||||||
|
});
|
||||||
|
}
|
||||||
154
src/App.jsx
Normal file
154
src/App.jsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useCallback, useReducer } from 'react';
|
||||||
|
import { ImageDrop } from './components/ImageDrop.jsx';
|
||||||
|
import { JobCard } from './components/JobCard.jsx';
|
||||||
|
import { useJobSocket } from './hooks/useJobSocket.js';
|
||||||
|
import { submitJob } from './lib/api.js';
|
||||||
|
import { MODELS, DEFAULT_MODEL_ID } from './models.js';
|
||||||
|
|
||||||
|
// Group models for the dropdown optgroup
|
||||||
|
const GATEWAY_MODELS = MODELS.filter(m => m.provider !== 'ollama');
|
||||||
|
const OLLAMA_MODELS = MODELS.filter(m => m.provider === 'ollama');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Jobs state reducer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function jobsReducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'init': return action.jobs;
|
||||||
|
case 'upsert': {
|
||||||
|
const idx = state.findIndex(j => j.id === action.job.id);
|
||||||
|
if (idx === -1) return [action.job, ...state];
|
||||||
|
const next = [...state];
|
||||||
|
next[idx] = action.job;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
default: return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function App() {
|
||||||
|
const [jobs, dispatch] = useReducer(jobsReducer, []);
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [imageFile, setImageFile] = useState(null);
|
||||||
|
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [pending, setPending] = useState(0);
|
||||||
|
|
||||||
|
useJobSocket(useCallback(msg => {
|
||||||
|
if (msg.type === 'init') dispatch({ type: 'init', jobs: msg.jobs });
|
||||||
|
if (msg.type === 'job_update') dispatch({ type: 'upsert', job: msg.job });
|
||||||
|
if (msg.type === 'queue_stats') setPending(msg.pending);
|
||||||
|
}, []));
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!prompt.trim()) return setError('Please enter a prompt.');
|
||||||
|
if (!imageFile) return setError('Please select or drop an image.');
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitJob(prompt, imageFile, modelId);
|
||||||
|
setPrompt('');
|
||||||
|
setImageFile(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<h1 className="app-title">
|
||||||
|
<span className="title-eye">◉</span> Vision Jobs
|
||||||
|
</h1>
|
||||||
|
<p className="app-subtitle">Powered by Vercel AI Gateway · Ollama Cloud</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<section className="submit-section">
|
||||||
|
<form className="submit-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<ImageDrop file={imageFile} onFile={setImageFile} />
|
||||||
|
|
||||||
|
{/* Model selector */}
|
||||||
|
<div className="model-row">
|
||||||
|
<label className="model-label" htmlFor="model-select">Model</label>
|
||||||
|
<select
|
||||||
|
id="model-select"
|
||||||
|
className="model-select"
|
||||||
|
value={modelId}
|
||||||
|
onChange={e => setModelId(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<optgroup label="Cloud Providers">
|
||||||
|
{GATEWAY_MODELS.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Ollama Cloud">
|
||||||
|
{OLLAMA_MODELS.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prompt-row">
|
||||||
|
<textarea
|
||||||
|
className="prompt-input"
|
||||||
|
placeholder="Describe what you want to know about this image…"
|
||||||
|
value={prompt}
|
||||||
|
onChange={e => setPrompt(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="submit-btn"
|
||||||
|
disabled={submitting || !prompt.trim() || !imageFile}
|
||||||
|
>
|
||||||
|
{submitting ? <span className="spinner" /> : (
|
||||||
|
<>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Analyze
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="form-error" role="alert">{error}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="jobs-section">
|
||||||
|
<div className="jobs-header">
|
||||||
|
<h2 className="jobs-title">Jobs</h2>
|
||||||
|
{pending > 0 && (
|
||||||
|
<span className="queue-badge">{pending} queued</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<div className="jobs-empty">
|
||||||
|
<span>No jobs yet — submit an image above.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="jobs-list">
|
||||||
|
{jobs.map(job => <JobCard key={job.id} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/ImageDrop.jsx
Normal file
57
src/components/ImageDrop.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function ImageDrop({ file, onFile }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const dropped = e.dataTransfer.files[0];
|
||||||
|
if (dropped?.type.startsWith('image/')) onFile(dropped);
|
||||||
|
}, [onFile]);
|
||||||
|
|
||||||
|
const preview = file ? URL.createObjectURL(file) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`drop-zone ${dragging ? 'drag-over' : ''} ${file ? 'has-file' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
|
||||||
|
aria-label="Drop image or click to select"
|
||||||
|
>
|
||||||
|
{/* Hidden file input — `capture` triggers camera on mobile */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) onFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<img src={preview} alt="Preview" className="drop-preview" />
|
||||||
|
) : (
|
||||||
|
<div className="drop-placeholder">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<span>Drop image / tap to capture</span>
|
||||||
|
<small>JPEG, PNG, WEBP, GIF — up to 20 MB</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/JobCard.jsx
Normal file
79
src/components/JobCard.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { findModel } from '../models.js';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
queued: 'Queued',
|
||||||
|
running: 'Running',
|
||||||
|
done: 'Done',
|
||||||
|
error: 'Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ErrorDetail({ message }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const firstNewline = message.indexOf('\n');
|
||||||
|
const firstChunk = firstNewline !== -1 ? message.slice(0, firstNewline) : message;
|
||||||
|
const summary = firstChunk.length > 80 ? firstChunk.slice(0, 80) + '…' : firstChunk;
|
||||||
|
const hasMore = message.length > summary.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-error">
|
||||||
|
<div className="job-error-summary">
|
||||||
|
<span>{summary}</span>
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
className="job-error-toggle"
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? '▲ less' : '▼ more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && <pre className="job-error-detail">{message}</pre>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobCard({ job }) {
|
||||||
|
const ts = new Date(job.createdAt).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve a human-readable model label; fall back to the raw ID
|
||||||
|
const modelMeta = findModel(job.model);
|
||||||
|
const modelLabel = modelMeta ? modelMeta.label : (job.model ?? '—');
|
||||||
|
const modelBadge = modelMeta?.provider === 'ollama' ? 'Ollama Cloud' : 'AI Gateway';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`job-card status-${job.status}`}>
|
||||||
|
<header className="job-header">
|
||||||
|
<span className={`status-badge status-${job.status}`}>
|
||||||
|
{job.status === 'running' && <span className="spinner" aria-hidden="true" />}
|
||||||
|
{STATUS_LABEL[job.status]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Model pill */}
|
||||||
|
<span className="model-pill" title={`${modelBadge} · ${job.model}`}>
|
||||||
|
{modelLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<time className="job-time">{ts}</time>
|
||||||
|
{job.inputTokens != null && (
|
||||||
|
<span className="job-tokens">
|
||||||
|
{job.inputTokens}↑ {job.outputTokens}↓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="job-prompt">{job.prompt}</p>
|
||||||
|
|
||||||
|
{job.status === 'done' && job.result && (
|
||||||
|
<p className="job-result">{job.result}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === 'error' && job.errorMessage && (
|
||||||
|
<ErrorDetail message={job.errorMessage} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/hooks/useJobSocket.js
Normal file
35
src/hooks/useJobSocket.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the server WebSocket.
|
||||||
|
* @param {(msg: object) => void} onMessage Called with parsed JSON messages.
|
||||||
|
*/
|
||||||
|
export function useJobSocket(onMessage) {
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
onMessageRef.current(JSON.parse(e.data));
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Auto-reconnect after 2 s
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = connect();
|
||||||
|
return () => ws.close();
|
||||||
|
}, [connect]);
|
||||||
|
}
|
||||||
25
src/lib/api.js
Normal file
25
src/lib/api.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Submit a new vision job.
|
||||||
|
* @param {string} prompt
|
||||||
|
* @param {File} imageFile
|
||||||
|
* @param {string} modelId — must match an id in src/models.js
|
||||||
|
*/
|
||||||
|
export async function submitJob(prompt, imageFile, modelId) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('prompt', prompt);
|
||||||
|
form.append('modelId', modelId);
|
||||||
|
form.append('image', imageFile);
|
||||||
|
|
||||||
|
const res = await fetch('/api/jobs', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJobs() {
|
||||||
|
const res = await fetch('/api/jobs');
|
||||||
|
if (!res.ok) throw new Error('Failed to load jobs');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './styles.css';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
52
src/models.js
Normal file
52
src/models.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// models.js — shared model configuration
|
||||||
|
// Used by the frontend dropdown AND the backend router.
|
||||||
|
//
|
||||||
|
// provider values map to individual AI SDK packages:
|
||||||
|
// "anthropic" → @ai-sdk/anthropic (ANTHROPIC_API_KEY)
|
||||||
|
// "openai" → @ai-sdk/openai (OPENAI_API_KEY)
|
||||||
|
// "google" → @ai-sdk/google (GOOGLE_API_KEY)
|
||||||
|
// "ollama" → ollama-ai-provider-v2 (OLLAMA_API_KEY)
|
||||||
|
|
||||||
|
export const MODELS = [
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-6-20250929',
|
||||||
|
label: 'Claude Sonnet 4.6',
|
||||||
|
provider: 'anthropic',
|
||||||
|
creator: 'Anthropic',
|
||||||
|
vision: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-5.2',
|
||||||
|
label: 'GPT-5.2',
|
||||||
|
provider: 'openai',
|
||||||
|
creator: 'OpenAI',
|
||||||
|
vision: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-3-flash-preview',
|
||||||
|
label: 'Gemini 3 Flash',
|
||||||
|
provider: 'google',
|
||||||
|
creator: 'Google',
|
||||||
|
vision: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qwen3.5:397b-cloud',
|
||||||
|
label: 'Qwen 3.5 397B',
|
||||||
|
provider: 'ollama',
|
||||||
|
creator: 'Qwen / Ollama Cloud',
|
||||||
|
vision: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llama4:maverick-cloud',
|
||||||
|
label: 'Llama 4 Maverick',
|
||||||
|
provider: 'ollama',
|
||||||
|
creator: 'Meta / Ollama Cloud',
|
||||||
|
vision: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_MODEL_ID = 'claude-sonnet-4-6-20250929';
|
||||||
|
|
||||||
|
export function findModel(id) {
|
||||||
|
return MODELS.find(m => m.id === id);
|
||||||
|
}
|
||||||
538
src/styles.css
Normal file
538
src/styles.css
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Vision Jobs — Industrial dark theme
|
||||||
|
Fonts: Syne (display) + JetBrains Mono (data/code)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d0d0d;
|
||||||
|
--surface: #141414;
|
||||||
|
--surface2: #1c1c1c;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--border2: #383838;
|
||||||
|
--accent: #e8a020;
|
||||||
|
--accent-dim:#7a5510;
|
||||||
|
--text: #e8e0d0;
|
||||||
|
--text-muted:#7a7060;
|
||||||
|
--text-dim: #4a4438;
|
||||||
|
--green: #4caf6a;
|
||||||
|
--red: #d94f4f;
|
||||||
|
--blue: #4a8fd4;
|
||||||
|
|
||||||
|
--radius: 4px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--font-ui: 'Syne', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Mono', monospace;
|
||||||
|
|
||||||
|
--transition: 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { font-size: 15px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100dvh;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ─────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────── */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────── */
|
||||||
|
.app-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-eye {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: inline-block;
|
||||||
|
animation: pulse-eye 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-eye {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.6; transform: scale(0.88); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ───────────────────────────────────────────────────── */
|
||||||
|
.app-main {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit section ─────────────────────────────────────────── */
|
||||||
|
.submit-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop zone ──────────────────────────────────────────────── */
|
||||||
|
.drop-zone {
|
||||||
|
border: 1.5px dashed var(--border2);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition), background var(--transition);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone:focus-visible {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(232, 160, 32, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--border2);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-placeholder svg { color: var(--text-dim); }
|
||||||
|
.drop-placeholder span { font-size: 0.9rem; font-weight: 600; }
|
||||||
|
.drop-placeholder small { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.drop-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prompt row ─────────────────────────────────────────────── */
|
||||||
|
.prompt-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input::placeholder { color: var(--text-dim); }
|
||||||
|
.prompt-input:focus { border-color: var(--accent-dim); }
|
||||||
|
.prompt-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Submit button ──────────────────────────────────────────── */
|
||||||
|
.submit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0d0d0d;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity var(--transition), transform var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover:not(:disabled) { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
.submit-btn:active:not(:disabled) { transform: translateY(0); }
|
||||||
|
.submit-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Form error ─────────────────────────────────────────────── */
|
||||||
|
.form-error {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--red);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(217, 79, 79, 0.08);
|
||||||
|
border: 1px solid rgba(217, 79, 79, 0.2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spinner ────────────────────────────────────────────────── */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.65s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Jobs section ───────────────────────────────────────────── */
|
||||||
|
.jobs-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: rgba(232, 160, 32, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Job card ───────────────────────────────────────────────── */
|
||||||
|
.job-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
animation: card-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card.status-running { border-color: var(--accent-dim); }
|
||||||
|
.job-card.status-done { border-color: var(--border); }
|
||||||
|
.job-card.status-error { border-color: rgba(217, 79, 79, 0.35); }
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status badge ───────────────────────────────────────────── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 99px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-queued {
|
||||||
|
background: rgba(122, 112, 96, 0.12);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border2);
|
||||||
|
}
|
||||||
|
.status-badge.status-running {
|
||||||
|
background: rgba(232, 160, 32, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
.status-badge.status-done {
|
||||||
|
background: rgba(76, 175, 106, 0.1);
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(76, 175, 106, 0.3);
|
||||||
|
}
|
||||||
|
.status-badge.status-error {
|
||||||
|
background: rgba(217, 79, 79, 0.1);
|
||||||
|
color: var(--red);
|
||||||
|
border-color: rgba(217, 79, 79, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-tokens {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-prompt {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-result {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.65;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-error {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--red);
|
||||||
|
background: rgba(217, 79, 79, 0.06);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-main { padding: 20px 16px 48px; }
|
||||||
|
.prompt-row { flex-direction: column; }
|
||||||
|
.submit-btn { width: 100%; justify-content: center; }
|
||||||
|
.header-inner { padding: 12px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Expandable error detail ────────────────────────────────── */
|
||||||
|
.job-error {
|
||||||
|
background: rgba(217, 79, 79, 0.06);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-error-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-error-toggle {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(217, 79, 79, 0.4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--red);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 1px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-error-toggle:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.job-error-detail {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(217, 79, 79, 0.85);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.55;
|
||||||
|
border-top: 1px solid rgba(217, 79, 79, 0.2);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model selector row ─────────────────────────────────────── */
|
||||||
|
.model-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237a7060' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select:focus { border-color: var(--accent-dim); }
|
||||||
|
.model-select:hover { border-color: var(--border2); }
|
||||||
|
.model-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.model-select optgroup {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select option {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model pill on job cards ────────────────────────────────── */
|
||||||
|
.model-pill {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 160px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
10
vite.config.js
Normal file
10
vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
24
ws/broadcast.js
Normal file
24
ws/broadcast.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Central registry of all active WebSocket connections.
|
||||||
|
// Fastify-websocket exposes individual socket objects; we collect them here
|
||||||
|
// so any part of the server can broadcast without importing fastify itself.
|
||||||
|
|
||||||
|
/** @type {Set<import('ws').WebSocket>} */
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
export function registerClient(socket) {
|
||||||
|
clients.add(socket);
|
||||||
|
socket.on('close', () => clients.delete(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a job update to every connected client.
|
||||||
|
* @param {object} payload Plain object — will be JSON-serialised.
|
||||||
|
*/
|
||||||
|
export function broadcast(payload) {
|
||||||
|
const msg = JSON.stringify(payload);
|
||||||
|
for (const ws of clients) {
|
||||||
|
if (ws.readyState === 1 /* OPEN */) {
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user