feat: orchestrate goals with local and cloud models

This commit is contained in:
thanhnv
2026-07-10 23:35:30 +09:00
parent 1345d4c930
commit 48b1186439
11 changed files with 920 additions and 2 deletions
@@ -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>
);
}