feat: plan 18

This commit is contained in:
thanhnv
2026-07-10 11:28:14 +09:00
parent fafccc47ad
commit d882a9dc23
15 changed files with 934 additions and 25 deletions
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAgent, ChatAnswer, SettingsActor } from '../lib/api';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
@@ -10,6 +10,23 @@ function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
return `${res.mode} / ${res.risk}`;
}
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
return {
...(p as Partial<ChatAnswer>),
success: p.decision === 'ANSWERED',
mode: p.mode,
risk: (p.risk as string) ?? 'low',
decision: p.decision,
answer: p.answer,
sources: p.sources ?? [],
certified: p.certified,
audit: p.audit ?? {},
audit_verify: p.audit_verify ?? { ok: true, output: '' },
router: p.router ?? {},
actor,
} as ChatAnswer;
}
function auditHash(res: ChatAnswer) {
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
}
@@ -32,6 +49,9 @@ export function Chat() {
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
const [last, setLast] = useState<ChatAnswer | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [draftText, setDraftText] = useState<string | null>(null);
const [streamBusy, setStreamBusy] = useState(false);
const auditQuery = useQuery({
queryKey: ['chat-audit'],
@@ -72,6 +92,37 @@ export function Chat() {
},
});
const runStream = async () => {
setStreamBusy(true);
setError(null);
setDraftText(null);
try {
await api.askChatStream(
actor,
{ message, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel },
(phase: ChatStreamPhase) => {
if (phase.phase === 'draft') {
setDraftText(phase.answer);
} else {
setDraftText(null);
setLast(finalToAnswer(phase, actor));
}
},
);
void auditQuery.refetch();
} catch (err: any) {
setError(err?.message || 'Stream failed');
} finally {
setStreamBusy(false);
}
};
const onAsk = () => {
if (streaming) void runStream();
else ask.mutate({});
};
const busy = ask.isPending || streamBusy;
return (
<>
<Card
@@ -92,13 +143,26 @@ export function Chat() {
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:bg-gray-300"
disabled={!message.trim() || ask.isPending}
onClick={() => ask.mutate({})}
disabled={!message.trim() || busy}
onClick={onAsk}
>
{ask.isPending ? 'Asking...' : 'Ask'}
{busy ? 'Asking...' : 'Ask'}
</button>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input type="checkbox" checked={streaming} onChange={(e) => setStreaming(e.target.checked)} />
Stream
</label>
<StatusBadge value="read-only" />
</div>
{draftText && (
<div className="rounded border border-orange-200 bg-orange-50 p-3 text-sm text-gray-700">
<div className="mb-1 flex items-center gap-2">
<StatusBadge value="draft" />
<span className="text-xs text-orange-700">UNCERTIFIED — awaiting H4/certify</span>
</div>
<div className="whitespace-pre-wrap leading-6">{draftText}</div>
</div>
)}
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
</div>
@@ -171,6 +235,11 @@ export function Chat() {
<StatusBadge value={last.mode} />
<StatusBadge value={last.risk} />
<StatusBadge value={last.decision} />
{last.synthesis && (
<StatusBadge value={last.synthesis.mode === 'model'
? `model: ${last.synthesis.provider ?? 'provider'}`
: 'deterministic'} />
)}
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
{last.loop_run && <StatusBadge value={last.loop_run.draft_certified ? 'loop certified' : 'loop held'} />}