From 049f0c9b048716086d2aaad0343036959fb72abe Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sat, 18 Jul 2026 10:19:26 +0700 Subject: [PATCH] feat: visualize governed goal progress live --- .../goals/ModelInteractionDiagram.tsx | 127 ++++++++++++++---- .../frontend/src/index.css | 20 +++ .../frontend/src/pages/Goals.tsx | 11 +- 3 files changed, 127 insertions(+), 31 deletions(-) diff --git a/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx b/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx index 46fb73a..d470380 100644 --- a/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx +++ b/packages/casan-control-panel/frontend/src/components/goals/ModelInteractionDiagram.tsx @@ -1,54 +1,123 @@ -import type { GoalJob, GoalStage } from '../../lib/api'; +import { useEffect, useMemo, useState } from 'react'; +import type { GoalJob, GoalStage, HarnessGateNode, HarnessTraceGraph } from '../../lib/api'; import { StatusBadge } from '../ui/Card'; interface ModelInteractionDiagramProps { goal: GoalJob; localStage?: GoalStage; cloudStage?: GoalStage; + trace?: HarnessTraceGraph; } -const tone = (status: string) => status === 'pass' || status === 'completed' ? 'bg-emerald-500' : status === 'error' || status === 'failed' ? 'bg-rose-500' : status === 'warning' || status === 'degraded' ? 'bg-amber-500' : status === 'running' ? 'animate-pulse bg-indigo-500 motion-reduce:animate-none' : 'bg-slate-300'; +const GATE_ORDER = ['H1-context', 'H2-tool', 'H3-eval', 'H4-security', 'H5-governance', 'H6-agentops', 'H7-orchestration']; -function Exchange({ from, to, label, status, reverse = false }: { from: string; to: string; label: string; status: string; reverse?: boolean }) { +function statusTone(status: string): { dot: string; surface: string; border: string; text: string } { + if (status === 'pass' || status === 'completed') return { dot: 'bg-emerald-400', surface: 'bg-emerald-400/10', border: 'border-emerald-300/60', text: 'text-emerald-100' }; + if (status === 'error' || status === 'failed' || status === 'blocked') return { dot: 'bg-rose-400', surface: 'bg-rose-400/10', border: 'border-rose-300/60', text: 'text-rose-100' }; + if (status === 'warning' || status === 'degraded' || status === 'requires_approval') return { dot: 'bg-amber-300', surface: 'bg-amber-300/10', border: 'border-amber-200/60', text: 'text-amber-100' }; + if (status === 'running') return { dot: 'bg-cyan-300', surface: 'bg-cyan-300/10', border: 'border-cyan-200/60', text: 'text-cyan-50' }; + return { dot: 'bg-slate-500', surface: 'bg-slate-700/40', border: 'border-slate-600', text: 'text-slate-300' }; +} + +function formatReason(reason: string): string { + return reason.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function AgentAvatar({ label, role, model, status, selected, onClick }: { + label: string; + role: string; + model: string; + status: string; + selected: boolean; + onClick: () => void; +}) { + const tone = statusTone(status); + const initials = label.split(' ').map((word) => word[0]).join('').slice(0, 2); return ( -
-
{reverse ? to : from}
-
-
- - - + + ); +} + +function ControlGate({ node, selected, onClick }: { node: HarnessGateNode; selected: boolean; onClick: () => void }) { + const tone = statusTone(node.status); + const gateNumber = node.id.slice(0, 2); + return ( + + ); +} + +function GateDetail({ node }: { node: HarnessGateNode }) { + const tone = statusTone(node.status); + const evidence = Object.entries(node.evidence).filter(([, value]) => value !== null && value !== undefined).slice(0, 3); + return ( +
+
Focused control
{node.title}
+

{node.description}

+
Observed impact
{formatReason(node.reason)}
+ {evidence.length > 0 &&
{evidence.map(([key, value]) => {key}: {typeof value === 'string' ? value : JSON.stringify(value)})}
}
); } -export function ModelInteractionDiagram({ goal, localStage, cloudStage }: ModelInteractionDiagramProps) { +export function GovernedOutcomePulse({ goal, trace }: { goal: GoalJob; trace?: HarnessTraceGraph }) { + const nodes = trace?.nodes ?? []; + const progressed = trace?.progress ?? nodes.filter((node) => node.status !== 'queued').length; + const current = [...nodes].reverse().find((node) => node.status !== 'queued') ?? null; + const outcome = nodes.find((node) => node.id === 'H7-orchestration'); + const isFailure = goal.status === 'failed' || outcome?.status === 'blocked' || outcome?.status === 'error'; + const label = isFailure ? 'Stopped with evidence retained' : goal.status === 'requires_approval' ? 'Awaiting human approval' : goal.status === 'completed' ? 'Certified governed outcome' : 'Governed outcome in progress'; + const percentage = Math.min(100, Math.round((progressed / GATE_ORDER.length) * 100)); + return ( +
+
Governed outcome · live
{label}
+
+
{progressed}/7 controls have emitted evidence{percentage}%
+
Now at
{current?.title ?? 'Waiting for H1'}
Decision
{outcome?.status === 'pass' ? 'Certified' : isFailure ? 'Safely stopped' : 'Not final yet'}
Latest evidence
{current ? formatReason(current.reason) : 'Trace stream connecting'}
+
+ ); +} + +export function ModelInteractionDiagram({ goal, localStage, cloudStage, trace }: ModelInteractionDiagramProps) { + const nodes = useMemo(() => trace?.nodes ?? GATE_ORDER.map((id) => ({ id, title: id.replace('-', ' · '), description: 'Waiting for trace evidence', status: 'queued' as const, reason: 'Waiting for evidence', updated_at: null, evidence: {}, events: [] })), [trace]); + const preferred = [...nodes].reverse().find((node) => node.status === 'running' || node.status === 'error' || node.status === 'blocked' || node.status === 'warning')?.id ?? [...nodes].reverse().find((node) => node.status !== 'queued')?.id ?? 'H1-context'; + const [focusedGate, setFocusedGate] = useState(preferred); + const [focusedAgent, setFocusedAgent] = useState<'local' | 'cloud'>('local'); + useEffect(() => setFocusedGate(preferred), [preferred, trace?.updated_at]); + const focusedNode = nodes.find((node) => node.id === focusedGate) ?? nodes[0]; const localStatus = localStage?.status ?? 'queued'; const cloudStatus = cloudStage?.status ?? 'queued'; + const direction = focusedAgent === 'local' ? 'Local worker → security and review controls' : 'Cloud reviewer → governance and final certification'; + return ( -
-
-
Live model exchange

Models trao đổi và kiểm chứng như thế nào

- +
+
+
Interactive model exchange

Ai đang tác động vào outcome — và control nào đang quyết định

Chọn avatar hoặc từng H-gate để khám phá tác động và evidence thực tế.

-
-
-
Local worker
{localStage?.model || goal.local_model}
-
Cloud reviewer
{cloudStage?.model || goal.cloud_model}
-
-
- - - - - +
+
setFocusedAgent('local')} /> setFocusedAgent('cloud')} />
Selected influence: {direction}
+
+
Control spine · H1 → H7
+
{nodes.map((node) => setFocusedGate(node.id)} />)}
+ {focusedNode &&
}
-
Mỗi mũi tên thể hiện một boundary có kiểm soát; màu sắc lấy từ trạng thái thực tế của goal và từng worker.
+
Màu, chuyển động và tiến độ lấy từ trace H1–H7. Nếu một gate dừng, governed outcome dừng đúng tại evidence đó — không diễn giải nó là lỗi mới ở các gate sau.
); } diff --git a/packages/casan-control-panel/frontend/src/index.css b/packages/casan-control-panel/frontend/src/index.css index 10c9115..0fb16a6 100644 --- a/packages/casan-control-panel/frontend/src/index.css +++ b/packages/casan-control-panel/frontend/src/index.css @@ -58,6 +58,26 @@ button, input, select, textarea { font: inherit; } animation: trace-flow 1.2s linear infinite; } +@keyframes goal-orb-breathe { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 rgba(103, 232, 249, 0); } + 50% { transform: scale(1.055); box-shadow: 0 0 28px rgba(103, 232, 249, 0.28); } +} + +@keyframes goal-core-pulse { + 0%, 100% { opacity: 0.45; transform: scale(0.7); } + 50% { opacity: 0.1; transform: scale(2.2); } +} + +@keyframes goal-signal-ping { + 0% { box-shadow: 0 0 0 0 rgba(103, 232, 249, 0.68); } + 75%, 100% { box-shadow: 0 0 0 9px rgba(103, 232, 249, 0); } +} + +.goal-agent-running { animation: goal-orb-breathe 1.8s ease-in-out infinite; } +.goal-agent-running .goal-agent-core { position: absolute; animation: goal-core-pulse 1.8s ease-out infinite; } +.goal-control-signal { animation: goal-signal-ping 1.4s ease-out infinite; } + @media (prefers-reduced-motion: reduce) { .trace-edge-active::after { animation: none; } + .goal-agent-running, .goal-agent-running .goal-agent-core, .goal-control-signal { animation: none; } } diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx index a4f9646..5ff04cb 100644 --- a/packages/casan-control-panel/frontend/src/pages/Goals.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx @@ -5,7 +5,7 @@ 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'; +import { GovernedOutcomePulse, ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram'; const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' }; const TERMINAL = new Set(['completed', 'degraded', 'failed', 'requires_approval']); @@ -69,6 +69,12 @@ export function Goals() { return current && TERMINAL.has(current.status) ? false : 1500; }, }); + const traceQuery = useQuery({ + queryKey: ['goal-trace', selectedId], + queryFn: () => api.traceGraph(selectedQuery.data!.trace_id), + enabled: Boolean(selectedQuery.data?.trace_id), + refetchInterval: (query) => query.state.data?.terminal ? false : 1200, + }); const start = useMutation({ mutationFn: () => api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject), onSuccess: (job) => { @@ -178,9 +184,10 @@ export function Goals() {
- + }> + {selected.workspace &&
{selected.workspace.domain}·{selected.workspace.project_id}{selected.context_manifest && <>·{selected.context_manifest.files} files / {selected.context_manifest.characters} chars{selected.context_manifest.truncated && }}
}
Objective