feat: orchestrate goals with local and cloud models
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api, type GoalJob, type SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { TraceExplorer } from '../components/trace/TraceExplorer';
|
||||
|
||||
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' };
|
||||
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed']);
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
|
||||
return candidate.response?.data?.message || candidate.message || 'Goal orchestration could not start.';
|
||||
}
|
||||
return 'Goal orchestration could not start.';
|
||||
}
|
||||
|
||||
function WorkerCard({ title, subtitle, status, detail, provider, model }: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}) {
|
||||
const busy = status === 'running';
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{busy && <div className="absolute inset-x-0 top-0 h-0.5 overflow-hidden bg-blue-100"><div className="h-full w-1/3 animate-pulse rounded-full bg-blue-500" /></div>}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 className="font-semibold text-slate-900">{title}</h3><p className="mt-1 text-xs text-slate-500">{subtitle}</p></div>
|
||||
<StatusBadge value={status} />
|
||||
</div>
|
||||
<p className="mt-4 min-h-10 text-sm leading-5 text-slate-600">{detail}</p>
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 text-[11px] text-slate-400">
|
||||
<div className="font-medium text-slate-500">{provider || 'not selected'}</div>
|
||||
<div className="mt-1 break-all font-mono">{model || 'model unavailable'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Goals() {
|
||||
const [goal, setGoal] = useState('');
|
||||
const [actor] = useState(DEFAULT_ACTOR);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('id') ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const listQuery = useQuery({ queryKey: ['goals', actor], queryFn: () => api.goals(actor, 20) });
|
||||
const selectedQuery = useQuery({
|
||||
queryKey: ['goal', actor, selectedId],
|
||||
queryFn: () => api.goal(actor, selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
refetchInterval: (query) => {
|
||||
const current = query.state.data as GoalJob | undefined;
|
||||
return current && TERMINAL.has(current.status) ? false : 1500;
|
||||
},
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startGoal(actor, goal.trim()),
|
||||
onSuccess: (job) => {
|
||||
setSearchParams({ id: job.id });
|
||||
setGoal('');
|
||||
queryClient.setQueryData(['goal', actor, job.id], job);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
const selected = selectedQuery.data;
|
||||
const localStage = selected?.stages.find((stage) => stage.id === 'local-worker');
|
||||
const cloudStage = selected?.stages.find((stage) => stage.id === 'cloud-reviewer');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-slate-950 via-indigo-950 to-indigo-800 p-6 text-white shadow-[0_20px_45px_rgba(30,41,89,0.2)]">
|
||||
<div className="max-w-3xl">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200">Goal orchestrator</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">One objective. Two models. One governed outcome.</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-indigo-100/80">A local worker develops the primary solution. A cloud reviewer challenges it, closes gaps, and returns the final answer through the same H1–H7 controls.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card title="Give CASAN an objective">
|
||||
<textarea
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
placeholder="Ví dụ: Thiết kế kế hoạch đưa ứng dụng OKR hiện tại lên production, có rollback và tiêu chí nghiệm thu rõ ràng."
|
||||
className="min-h-32 w-full resize-y rounded-xl border border-slate-300 px-4 py-3 text-sm leading-6 text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100"
|
||||
maxLength={8000}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">CASAN selects the connected local and cloud models automatically. No IDE or CLI is required.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={goal.trim().length < 10 || start.isPending}
|
||||
onClick={() => start.mutate()}
|
||||
className="rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{start.isPending ? 'Starting…' : 'Solve objective'}
|
||||
</button>
|
||||
</div>
|
||||
{start.isError && <div role="alert" className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{errorMessage(start.error)}</div>}
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<WorkerCard title="Local worker" subtitle="Private first-pass solution" status={localStage?.status ?? 'queued'} detail={localStage?.detail ?? 'Waiting'} provider={localStage?.provider ?? selected.local_provider} model={localStage?.model ?? selected.local_model} />
|
||||
<WorkerCard title="Cloud reviewer" subtitle="Independent critique and refinement" status={cloudStage?.status ?? 'queued'} detail={cloudStage?.detail ?? 'Waiting'} provider={cloudStage?.provider ?? selected.cloud_provider} model={cloudStage?.model ?? selected.cloud_model} />
|
||||
</div>
|
||||
|
||||
<Card title="Governed outcome" right={<StatusBadge value={selected.status} />}>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Objective</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">{selected.goal}</p>
|
||||
</div>
|
||||
{selected.result ? <div className="mt-5 whitespace-pre-wrap text-sm leading-7 text-slate-800">{selected.result}</div> : (
|
||||
<div className="mt-5 flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-500" />
|
||||
Models are working. This page refreshes automatically.
|
||||
</div>
|
||||
)}
|
||||
{selected.error && <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{selected.error}</div>}
|
||||
{selected.local_draft && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Inspect local worker draft</summary><div className="mt-4 whitespace-pre-wrap text-sm leading-6 text-slate-600">{selected.local_draft}</div></details>}
|
||||
</Card>
|
||||
|
||||
<TraceExplorer traceId={selected.trace_id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card title={`Recent objectives (${listQuery.data?.count ?? 0})`}>
|
||||
<div className="space-y-2">
|
||||
{(listQuery.data?.goals ?? []).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => setSearchParams({ id: item.id })} className={`flex w-full items-center justify-between gap-4 rounded-xl border p-3 text-left transition hover:bg-slate-50 ${selectedId === item.id ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200'}`}>
|
||||
<div className="min-w-0"><div className="truncate text-sm font-medium text-slate-800">{item.goal}</div><div className="mt-1 text-xs text-slate-400">{item.created_at.replace('T', ' ').replace('Z', '')}</div></div>
|
||||
<StatusBadge value={item.status} />
|
||||
</button>
|
||||
))}
|
||||
{!listQuery.isLoading && (listQuery.data?.count ?? 0) === 0 && <p className="py-6 text-center text-sm text-slate-500">No objective has been orchestrated yet.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user