optz: Goal orchestrator

This commit is contained in:
thanhnv
2026-07-11 12:18:59 +09:00
parent b56f7d357e
commit 4fc72332f5
34 changed files with 850 additions and 452 deletions
@@ -1,4 +1,4 @@
import { type KeyboardEvent, useMemo, useState } from 'react';
import { type KeyboardEvent, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
@@ -8,6 +8,17 @@ import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
type MessageKind = 'user' | 'assistant' | 'draft';
type HarnessRunStatus = 'idle' | 'running' | 'completed' | 'failed';
const HARNESS_STAGES = [
{ id: 'H1', label: 'Context' },
{ id: 'H2', label: 'Tool' },
{ id: 'H3', label: 'Evaluation' },
{ id: 'H4', label: 'Security' },
{ id: 'H5', label: 'Governance' },
{ id: 'H6', label: 'AgentOps' },
{ id: 'H7', label: 'Orchestration' },
] as const;
interface WorkspaceMessage {
id: string;
@@ -95,6 +106,8 @@ export function Chat() {
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(true);
const [streamBusy, setStreamBusy] = useState(false);
const [harnessStatus, setHarnessStatus] = useState<HarnessRunStatus>('idle');
const [activeHarness, setActiveHarness] = useState(0);
const auditQuery = useQuery({ queryKey: ['chat-audit'], queryFn: api.verifyChatAudit, retry: false });
const conversationsQuery = useQuery({ queryKey: ['chat-history', actor], queryFn: () => api.chatHistory(actor), retry: false });
@@ -142,16 +155,27 @@ export function Chat() {
setPendingMessage(null);
setError(null);
setMessage('');
setActiveHarness(HARNESS_STAGES.length - 1);
setHarnessStatus('completed');
refreshChat();
},
onError: (reason: unknown) => {
setPendingMessage(null);
setError(errorMessage(reason));
setHarnessStatus('failed');
},
});
const busy = ask.isPending || streamBusy;
useEffect(() => {
if (!busy || harnessStatus !== 'running') return undefined;
const timer = window.setInterval(() => {
setActiveHarness((current) => Math.min(current + 1, HARNESS_STAGES.length - 1));
}, 1100);
return () => window.clearInterval(timer);
}, [busy, harnessStatus]);
const runStream = async (text: string) => {
setStreamBusy(true);
setError(null);
@@ -165,6 +189,8 @@ export function Chat() {
setLast(finalToAnswer(phase, actor));
setPendingMessage(null);
setMessage('');
setActiveHarness(HARNESS_STAGES.length - 1);
setHarnessStatus('completed');
}
});
refreshChat();
@@ -172,6 +198,7 @@ export function Chat() {
setPendingMessage(null);
setDraftText(null);
setError(errorMessage(reason));
setHarnessStatus('failed');
} finally {
setStreamBusy(false);
}
@@ -180,6 +207,8 @@ export function Chat() {
const submit = (override?: { message?: string; agentId?: string; skillId?: string }) => {
const text = (override?.message ?? message).trim();
if (!text || busy) return;
setActiveHarness(0);
setHarnessStatus('running');
if (streaming && !override) void runStream(text);
else {
setPendingMessage(text);
@@ -202,6 +231,8 @@ export function Chat() {
setPendingMessage(null);
setDraftText(null);
setError(null);
setHarnessStatus('idle');
setActiveHarness(0);
};
return (
@@ -263,6 +294,34 @@ export function Chat() {
</article>
</div>
))}
{harnessStatus !== 'idle' && (
<div aria-live="polite" aria-label="H1 to H7 response progress" className={`rounded-2xl border px-4 py-3 shadow-sm ${harnessStatus === 'failed' ? 'border-rose-200 bg-rose-50/80' : harnessStatus === 'completed' ? 'border-emerald-200 bg-emerald-50/70' : 'border-indigo-200 bg-indigo-50/70'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-800">
<span className="relative flex h-2.5 w-2.5 items-center justify-center">
{harnessStatus === 'running' && <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-indigo-400 opacity-60 motion-reduce:animate-none" />}
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${harnessStatus === 'failed' ? 'bg-rose-500' : harnessStatus === 'completed' ? 'bg-emerald-500' : 'bg-indigo-600'}`} />
</span>
{harnessStatus === 'running' ? `CASAN đang phản hồi · ${HARNESS_STAGES[activeHarness].id} ${HARNESS_STAGES[activeHarness].label}` : harnessStatus === 'completed' ? 'Phản hồi đã hoàn tất qua H1–H7' : `Phản hồi dừng tại ${HARNESS_STAGES[activeHarness].id}`}
</div>
<span className="text-[10px] font-bold uppercase tracking-[0.12em] text-slate-500">{harnessStatus === 'running' ? 'Processing' : harnessStatus}</span>
</div>
<div className="mt-3 grid grid-cols-7 gap-1.5">
{HARNESS_STAGES.map((stage, index) => {
const done = harnessStatus === 'completed' || index < activeHarness;
const active = harnessStatus === 'running' && index === activeHarness;
const failed = harnessStatus === 'failed' && index === activeHarness;
return (
<div key={stage.id} className="min-w-0 text-center">
<div className={`h-1.5 rounded-full transition-colors duration-500 ${failed ? 'bg-rose-500' : done ? 'bg-emerald-500' : active ? 'animate-pulse bg-indigo-600 motion-reduce:animate-none' : 'bg-slate-200'}`} />
<div className={`mt-1.5 text-[10px] font-bold ${failed ? 'text-rose-700' : done ? 'text-emerald-700' : active ? 'text-indigo-700' : 'text-slate-400'}`}>{stage.id}</div>
<div className="hidden truncate text-[9px] text-slate-500 sm:block">{stage.label}</div>
</div>
);
})}
</div>
</div>
)}
{!historyQuery.isLoading && messages.length === 0 && <div className="mx-auto flex max-w-md flex-col items-center py-20 text-center"><div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600"><Glyph name="spark" /></div><h3 className="mt-4 font-semibold text-slate-800">A governed empty state</h3><p className="mt-2 text-sm leading-6 text-slate-500">Ask for a plan, a security posture, or an evidence-backed comparison. CASAN will cite what it knows and decline what it cannot govern.</p></div>}
</div>
<div className="border-t border-slate-200 bg-white p-4">
@@ -300,7 +359,7 @@ export function Chat() {
</div>
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); setActiveHarness(0); setHarnessStatus('running'); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
</aside>
</div>
<ModelConnectionPanel actor={actor} open={connectionsOpen} onClose={() => setConnectionsOpen(false)} onSelect={(provider, model) => { setModelProvider(provider); setModelId(model); void connectionsQuery.refetch(); }} />
@@ -4,6 +4,8 @@ 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';
import { MarkdownText } from '../components/ui/MarkdownText';
import { ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram';
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' };
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed']);
@@ -111,19 +113,21 @@ export function Goals() {
<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>
<ModelInteractionDiagram goal={selected} localStage={localStage} cloudStage={cloudStage} />
<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 className="mt-2"><MarkdownText text={selected.goal} /></div>
</div>
{selected.result ? <div className="mt-5 whitespace-pre-wrap text-sm leading-7 text-slate-800">{selected.result}</div> : (
{selected.result ? <div className="mt-5"><MarkdownText text={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>}
{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"><MarkdownText text={selected.local_draft} /></div></details>}
</Card>
<TraceExplorer traceId={selected.trace_id} />
@@ -134,7 +138,7 @@ export function Goals() {
<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>
<div className="min-w-0 flex-1"><MarkdownText text={item.goal} compact /><div className="mt-1 text-xs text-slate-400">{item.created_at.replace('T', ' ').replace('Z', '')}</div></div>
<StatusBadge value={item.status} />
</button>
))}