First cut
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user