feat: add governed chat console
This commit is contained in:
@@ -10,6 +10,7 @@ import { Settings } from './pages/Settings';
|
||||
import { FinOps } from './pages/FinOps';
|
||||
import { Approvals } from './pages/Approvals';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
import { Chat } from './pages/Chat';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -25,6 +26,7 @@ export default function App() {
|
||||
<Route path="/approvals" element={<Approvals />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/command" element={<CommandCenter />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom';
|
||||
const NAV = [
|
||||
['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'],
|
||||
['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'],
|
||||
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'],
|
||||
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'], ['/chat', 'Chat'],
|
||||
];
|
||||
export function Sidebar() {
|
||||
return (
|
||||
|
||||
@@ -78,6 +78,46 @@ export interface CommandCenterState extends Freshness {
|
||||
ticker: Array<{ at: string | null; kind: string; text: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface ChatSource {
|
||||
path: string;
|
||||
title?: string;
|
||||
line?: number;
|
||||
excerpt?: string;
|
||||
score: number;
|
||||
hash?: string;
|
||||
preview?: string;
|
||||
envelope?: CommandEnvelope;
|
||||
}
|
||||
|
||||
export interface ChatAnswer {
|
||||
success: boolean;
|
||||
mode: 'READ_ONLY' | 'OPERATOR' | 'BLOCK' | 'NOT_SUPPORTED' | string;
|
||||
risk: string;
|
||||
decision: 'ANSWERED' | 'ACTION_COMPLETED' | 'ACTION_FAILED' | 'REQUIRES_APPROVAL' | 'DENIED' | 'NOT_SUPPORTED' | string;
|
||||
answer: string;
|
||||
sources: ChatSource[];
|
||||
certified: boolean;
|
||||
audit: { hash?: string; record_hash?: string; head?: string; seq?: number; path?: string };
|
||||
audit_verify: { ok: boolean; output: string };
|
||||
router: {
|
||||
reason?: string;
|
||||
matched_rules?: string[];
|
||||
gates?: string[];
|
||||
side_effect_allowed?: boolean;
|
||||
needs_approval?: boolean;
|
||||
};
|
||||
action?: { id: string; label: string; description: string } | null;
|
||||
action_gate?: { outcome?: string; reason?: string; exit_code?: number };
|
||||
actor: SettingsActor;
|
||||
}
|
||||
|
||||
export interface ChatAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
@@ -124,6 +164,10 @@ export const api = {
|
||||
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
|
||||
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
|
||||
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
|
||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string }) =>
|
||||
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAnswer, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
|
||||
function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
|
||||
if (!res) return fallback;
|
||||
return `${res.mode} / ${res.risk}`;
|
||||
}
|
||||
|
||||
function auditHash(res: ChatAnswer) {
|
||||
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
|
||||
}
|
||||
|
||||
function sourceExcerpt(source: { preview?: string; excerpt?: string }) {
|
||||
return source.preview || source.excerpt || '';
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const [actor, setActor] = useState<SettingsActor>({
|
||||
actor: 'local-operator',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
});
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
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 auditQuery = useQuery({
|
||||
queryKey: ['chat-audit'],
|
||||
queryFn: api.verifyChatAudit,
|
||||
retry: false,
|
||||
});
|
||||
const actionsQuery = useQuery({
|
||||
queryKey: ['chat-actions', actor],
|
||||
queryFn: () => api.chatActions(actor),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const ask = useMutation({
|
||||
mutationFn: (override?: { message?: string }) => api.askChat(actor, { message: override?.message ?? message, chatId }),
|
||||
onSuccess: (res) => {
|
||||
setLast(res);
|
||||
setError(null);
|
||||
void auditQuery.refetch();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.response?.data?.message || err.message || 'Ask CASAN failed');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="Governed Chat"
|
||||
right={<StatusBadge value={last ? badgeValue(last) : (auditQuery.data?.ok ? 'audit ok' : 'ready')} />}
|
||||
>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
|
||||
<div className="xl:col-span-3 space-y-3">
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-gray-500">Ask CASAN</span>
|
||||
<textarea
|
||||
className="min-h-[132px] w-full rounded border border-gray-300 px-3 py-2 text-gray-800 focus:border-blue-400 focus:outline-none"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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({})}
|
||||
>
|
||||
{ask.isPending ? 'Asking...' : 'Ask'}
|
||||
</button>
|
||||
<StatusBadge value="read-only" />
|
||||
</div>
|
||||
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Project</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
||||
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tenant</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
||||
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Chat ID</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{last && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<Card
|
||||
title="Answer"
|
||||
right={<StatusBadge value={last.certified ? 'certified' : 'uncertified'} />}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatusBadge value={last.mode} />
|
||||
<StatusBadge value={last.risk} />
|
||||
<StatusBadge value={last.decision} />
|
||||
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-gray-400">audit hash</div>
|
||||
<div className="font-medium text-gray-700 break-all">{auditHash(last)}</div>
|
||||
</div>
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-gray-400">router</div>
|
||||
<div className="font-medium text-gray-700">{last.router?.reason ?? 'n/a'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Evidence">
|
||||
<div className="space-y-3">
|
||||
{last.sources.map((s) => (
|
||||
<div key={`${s.path}-${s.line ?? s.hash ?? s.score}`} className="rounded border border-gray-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-800 truncate">{s.title || s.path}</div>
|
||||
<div className="text-xs text-gray-400 break-all">{s.path}{s.line ? `:${s.line}` : ''}</div>
|
||||
</div>
|
||||
<StatusBadge value={`score ${s.score}`} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-600">{sourceExcerpt(s)}</div>
|
||||
<div className="mt-2 text-xs text-gray-400 break-all">
|
||||
{s.hash ? `hash ${s.hash}` : s.envelope?.verified ? 'verified source' : 'source'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{last.sources.length === 0 && <div className="text-sm text-gray-500">No evidence source returned.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Router">
|
||||
<div className="space-y-3 text-sm">
|
||||
{last.action && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Operator action</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.action.label}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.action.description}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.action.id} />
|
||||
<StatusBadge value={last.action_gate?.outcome ?? 'gate'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(last.router?.matched_rules ?? []).map((r) => <StatusBadge key={r} value={r} />)}
|
||||
{(last.router?.matched_rules ?? []).length === 0 && <span className="text-gray-500">none</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Gates</div>
|
||||
<div className="mt-1 text-gray-700">{(last.router?.gates ?? []).join(', ') || 'n/a'}</div>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">{JSON.stringify(last.router, null, 2)}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Registered operator actions">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{(actionsQuery.data?.actions ?? []).map((action: ChatAction) => {
|
||||
const trigger = action.triggers[0] || action.id;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
disabled={ask.isPending}
|
||||
onClick={() => {
|
||||
setMessage(trigger);
|
||||
ask.mutate({ message: trigger });
|
||||
}}
|
||||
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
<div className="font-medium text-gray-800">{action.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-gray-500">{action.description}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{action.triggers.slice(0, 2).map((t) => <StatusBadge key={t} value={t} />)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{actionsQuery.isError && <div className="text-sm text-red-600">Cannot load registered operator actions.</div>}
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && (actionsQuery.data?.actions ?? []).length === 0 && (
|
||||
<div className="text-sm text-gray-500">No operator actions registered.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user