First cut

This commit is contained in:
khalid@traclabs.com
2026-04-18 15:22:12 -05:00
commit 270e088c15
21 changed files with 6663 additions and 0 deletions

154
src/App.jsx Normal file
View 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>
);
}