feat: orchestrate goals with local and cloud models
This commit is contained in:
@@ -11,6 +11,7 @@ import { FinOps } from './pages/FinOps';
|
||||
import { Approvals } from './pages/Approvals';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Goals } from './pages/Goals';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/command" element={<CommandCenter />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/goals" element={<Goals />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
|
||||
interface NavItem { to: string; label: string; icon: IconName; }
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
|
||||
{ to: '/', label: 'Overview', icon: 'grid' },
|
||||
{ to: '/command', label: 'Command center', icon: 'command' },
|
||||
{ to: '/chat', label: 'Ask CASAN', icon: 'chat' },
|
||||
{ to: '/goals', label: 'Goal orchestrator', icon: 'goal' },
|
||||
{ to: '/runs', label: 'Run observability', icon: 'runs' },
|
||||
] },
|
||||
{ label: 'Assure', items: [
|
||||
@@ -30,6 +31,7 @@ function Icon({ name }: { name: IconName }) {
|
||||
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
command: <><path d="M5 12h14M12 5l7 7-7 7" /><path d="M5 5v14" /></>,
|
||||
chat: <><path d="M20 11.5a7.5 7.5 0 0 1-8 7.5 8.4 8.4 0 0 1-3.7-.9L4 19l1.2-3.5A7.5 7.5 0 1 1 20 11.5Z" /><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" /></>,
|
||||
goal: <><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="4" /><path d="m12 12 7-7M16 5h3v3" /></>,
|
||||
runs: <><path d="M4 19V9M10 19V5M16 19v-7M22 19H2" /><path d="M3 9h2M9 5h2M15 12h2" /></>,
|
||||
shield: <path d="M12 3 20 6v5c0 5.2-3.4 8.9-8 10-4.6-1.1-8-4.8-8-10V6l8-3Z" />,
|
||||
governance: <><path d="M4 20h16M6 17V9M10 17V5M14 17V9M18 17V5" /><path d="M3 5h18l-9-3-9 3Z" /></>,
|
||||
|
||||
@@ -260,6 +260,38 @@ export interface HarnessTraceGraph {
|
||||
events: HarnessTraceEvent[];
|
||||
}
|
||||
|
||||
export interface GoalStage {
|
||||
id: 'local-worker' | 'cloud-reviewer' | string;
|
||||
status: string;
|
||||
detail: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GoalJob {
|
||||
id: string;
|
||||
trace_id: string;
|
||||
goal: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
|
||||
actor: string;
|
||||
tenant: string;
|
||||
project: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
local_provider: string;
|
||||
local_model: string;
|
||||
cloud_provider: string;
|
||||
cloud_model: string;
|
||||
stages: GoalStage[];
|
||||
local_draft?: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
audit_hash?: string;
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
ok: boolean;
|
||||
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
|
||||
@@ -418,6 +450,12 @@ export const api = {
|
||||
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
|
||||
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
startGoal: (actor: SettingsActor, goal: string) =>
|
||||
post<GoalJob>('goals', { goal }, actorHeaders(actor)),
|
||||
goal: (actor: SettingsActor, id: string) =>
|
||||
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)),
|
||||
goals: (actor: SettingsActor, limit = 20) =>
|
||||
getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -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