feat: chat + optmz control panel
This commit is contained in:
@@ -27,6 +27,15 @@ export class ChatController {
|
||||
return ok(this.svc.verifyAudit());
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
history(
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
@Query('chatId') chatId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return ok(this.svc.history(actorFromHeaders(headers), chatId || '', Number(limit ?? 50)));
|
||||
}
|
||||
|
||||
@Get('replay')
|
||||
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
|
||||
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
|
||||
|
||||
@@ -95,6 +95,17 @@ export class ChatService {
|
||||
return { ok: res.status === 0, output: res.stdout || res.stderr };
|
||||
}
|
||||
|
||||
history(actor: SettingsActor, chatId = '', limit = 50) {
|
||||
this.requireRead(actor);
|
||||
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
|
||||
const args = ['history', '--actor', actor.actor, '--tenant', actor.tenant, '--limit', String(safeLimit)];
|
||||
if (chatId) args.push('--chat-id', chatId);
|
||||
const res = runPython(CHAT_CLI, args);
|
||||
const parsed = parseJson<Record<string, unknown>>(res.stdout);
|
||||
if (res.status === 0 && parsed?.ok === true) return parsed;
|
||||
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_HISTORY_FAILED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 3: streaming read-only/analysis turns. The harness emits two NDJSON
|
||||
* phases — an UNCERTIFIED deterministic draft, then the certified final. We
|
||||
|
||||
@@ -47,6 +47,23 @@ test('chat ask returns certified read-only answer with evidence sources', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('chat history returns only integrity-checked previews for the requesting actor', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'history-chat' }, viewer);
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-2 status', chatId: 'history-chat' }, viewer);
|
||||
svc.ask({ message: 'Summarize Plan 18 MVP-0 status', chatId: 'other-actor' }, { ...viewer, actor: 'someone-else' });
|
||||
|
||||
const history = svc.history(viewer, 'history-chat') as any;
|
||||
assert.equal(history.ok, true);
|
||||
assert.equal(history.turns.length, 2);
|
||||
assert.equal(history.turns[0].chat_id, 'history-chat');
|
||||
assert.ok(history.turns.every((turn: any) => turn.prompt_preview && turn.audit_hash));
|
||||
assert.equal(history.conversations.length, 1);
|
||||
assert.equal(history.conversations[0].chat_id, 'history-chat');
|
||||
});
|
||||
});
|
||||
|
||||
test('chat ask denies prompt injection and returns governed block response', () => {
|
||||
withTempChatState(() => {
|
||||
const svc = new ChatService();
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<div className="flex min-h-screen bg-slate-100">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-6 space-y-6">{children}</main>
|
||||
<main className="flex-1 overflow-auto px-4 py-5 pb-24 sm:px-6 lg:px-8 lg:py-7">
|
||||
<div className="mx-auto max-w-[1600px] space-y-5">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { health } from '../../lib/api';
|
||||
|
||||
const PAGE_COPY: Record<string, { title: string; eyebrow: string }> = {
|
||||
'/': { title: 'Operations overview', eyebrow: 'System posture' },
|
||||
'/command': { title: 'Command center', eyebrow: 'Executive view' },
|
||||
'/chat': { title: 'Governed workspace', eyebrow: 'Ask CASAN' },
|
||||
'/runs': { title: 'Run observability', eyebrow: 'Execution ledger' },
|
||||
'/governance': { title: 'Governance ledger', eyebrow: 'Policy decisions' },
|
||||
'/security': { title: 'Security signals', eyebrow: 'H4 protection' },
|
||||
'/incidents': { title: 'Incident response', eyebrow: 'Containment' },
|
||||
'/traceability': { title: 'Traceability', eyebrow: 'Evidence graph' },
|
||||
'/finops': { title: 'FinOps & SLO', eyebrow: 'Model economics' },
|
||||
'/approvals': { title: 'Approval inbox', eyebrow: 'Human-in-the-loop' },
|
||||
'/settings': { title: 'Governed settings', eyebrow: 'Control plane' },
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const { data } = useQuery({ queryKey: ['health'], queryFn: health });
|
||||
const { pathname } = useLocation();
|
||||
const stale = data ? !data.ok : true;
|
||||
const page = PAGE_COPY[pathname] ?? PAGE_COPY['/'];
|
||||
return (
|
||||
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-base font-semibold text-gray-800">CASAN Ops Console <span className="text-gray-400 font-normal">· read-only</span></h1>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">runs: {data?.runs ?? '—'}</span>
|
||||
<span className={`px-2 py-1 rounded-full font-medium ${stale ? 'bg-orange-100 text-orange-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{stale ? `STALE${data?.metrics_age_s != null ? ` (${data.metrics_age_s}s)` : ''}` : 'LIVE'}
|
||||
</span>
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/85 px-4 py-3 backdrop-blur-xl sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-600">{page.eyebrow}</div>
|
||||
<h1 className="mt-0.5 text-lg font-semibold tracking-tight text-slate-900">{page.title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-xs">
|
||||
<div className="hidden rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-slate-500 sm:block">
|
||||
<span className="font-medium text-slate-700">{data?.runs ?? '—'}</span> governed runs
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 rounded-xl border px-3 py-2 font-semibold ${stale ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${stale ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||
{stale ? `STALE${data?.metrics_age_s != null ? ` · ${data.metrics_age_s}s` : ''}` : 'LIVE TRUST'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
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'], ['/chat', 'Chat'],
|
||||
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
|
||||
interface NavItem { to: string; label: string; icon: IconName; }
|
||||
|
||||
const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
|
||||
{ label: 'Observe', items: [
|
||||
{ to: '/', label: 'Overview', icon: 'grid' },
|
||||
{ to: '/command', label: 'Command center', icon: 'command' },
|
||||
{ to: '/chat', label: 'Ask CASAN', icon: 'chat' },
|
||||
{ to: '/runs', label: 'Run observability', icon: 'runs' },
|
||||
] },
|
||||
{ label: 'Assure', items: [
|
||||
{ to: '/governance', label: 'Governance', icon: 'governance' },
|
||||
{ to: '/security', label: 'Security', icon: 'shield' },
|
||||
{ to: '/incidents', label: 'Incidents', icon: 'incident' },
|
||||
{ to: '/traceability', label: 'Traceability', icon: 'trace' },
|
||||
] },
|
||||
{ label: 'Control', items: [
|
||||
{ to: '/finops', label: 'FinOps & SLO', icon: 'coins' },
|
||||
{ to: '/approvals', label: 'Approvals', icon: 'approval' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'settings' },
|
||||
] },
|
||||
];
|
||||
|
||||
function Icon({ name }: { name: IconName }) {
|
||||
const paths: Record<IconName, ReactNode> = {
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
incident: <><path d="M10.3 3.3 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 3.3a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" /></>,
|
||||
trace: <><circle cx="6" cy="6" r="3" /><circle cx="18" cy="18" r="3" /><circle cx="18" cy="6" r="3" /><path d="m8.6 7.5 6.8 3M9 6h6" /></>,
|
||||
coins: <><ellipse cx="12" cy="5" rx="7" ry="3" /><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" /></>,
|
||||
approval: <><path d="M9 11 11 13l4-4" /><path d="M12 22c5-2.1 8-5.3 8-10V5l-8-3-8 3v7c0 4.7 3 7.9 8 10Z" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2 2-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5v.2h-2.8v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1-2-2 .1-.1A1.7 1.7 0 0 0 7.4 15a1.7 1.7 0 0 0-1.5-1H5.7v-2.8h.2a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9L7 8.2l2-2 .1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.5v-.2h2.8v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1 2 2-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.5 1h.2V14h-.2a1.7 1.7 0 0 0-1.5 1Z" /></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="h-[18px] w-[18px]">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex-shrink-0">
|
||||
<div className="h-14 flex items-center px-6 font-bold text-blue-600 border-b border-gray-200">CASAN</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{NAV.map(([to, label]) => (
|
||||
<NavLink key={to} to={to} end={to === '/'}
|
||||
className={({ isActive }) => `block px-3 py-2 rounded-lg text-sm ${isActive ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'}`}>
|
||||
{label}
|
||||
</NavLink>
|
||||
<>
|
||||
<aside className="sticky top-0 hidden h-screen w-[252px] shrink-0 flex-col border-r border-slate-800 bg-[#111827] text-slate-300 lg:flex">
|
||||
<div className="flex h-[76px] items-center border-b border-slate-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-400 to-indigo-600 font-bold text-white shadow-lg shadow-indigo-950/40">C</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-semibold tracking-[0.18em] text-white">CASAN</div>
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.13em] text-slate-500">Control plane</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-5">
|
||||
{NAVIGATION.map((group) => (
|
||||
<div key={group.label} className="mb-6 last:mb-0">
|
||||
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">{group.label}</div>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition ${isActive ? 'bg-indigo-500/15 text-indigo-200 shadow-[inset_0_0_0_1px_rgba(129,140,248,0.16)]' : 'text-slate-400 hover:bg-slate-800/80 hover:text-slate-100'}`}
|
||||
>
|
||||
<span className="text-slate-500 transition group-hover:text-slate-300"><Icon name={item.icon} /></span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="m-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-300"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />Governed runtime</div>
|
||||
<p className="mt-1.5 text-[11px] leading-4 text-slate-500">Every action is policy checked and audit anchored.</p>
|
||||
</div>
|
||||
</aside>
|
||||
<nav className="fixed inset-x-3 bottom-3 z-30 flex items-center justify-around rounded-2xl border border-slate-200 bg-white/95 p-2 shadow-xl shadow-slate-900/10 backdrop-blur lg:hidden">
|
||||
{NAVIGATION[0].items.slice(0, 3).map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === '/'} className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
|
||||
<Icon name={item.icon} />{item.label.split(' ')[0]}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/settings" className={({ isActive }) => `flex min-w-16 flex-col items-center gap-1 rounded-xl px-2 py-1.5 text-[10px] font-semibold ${isActive ? 'bg-indigo-50 text-indigo-700' : 'text-slate-500'}`}>
|
||||
<Icon name="settings" />More
|
||||
</NavLink>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
export function Card({ title, children, right }: { title?: string; children: ReactNode; right?: ReactNode }) {
|
||||
export function Card({ title, children, right, className = '' }: { title?: string; children: ReactNode; right?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<section className={`rounded-2xl border border-slate-200/80 bg-white/95 p-5 shadow-[0_12px_30px_rgba(15,23,42,0.045)] backdrop-blur-sm sm:p-6 ${className}`}>
|
||||
{title && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h2>
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.13em] text-slate-500">{title}</h2>
|
||||
{right}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="text-2xl font-semibold text-gray-800 mt-1">{value}</div>
|
||||
{sub && <div className="text-xs text-gray-400 mt-1">{sub}</div>}
|
||||
<div className="group rounded-2xl border border-slate-200/80 bg-white/90 p-4 shadow-[0_10px_24px_rgba(15,23,42,0.035)] transition duration-200 hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(15,23,42,0.07)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">{value}</div>
|
||||
{sub && <div className="mt-1.5 text-xs leading-5 text-slate-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TONE: Record<string, string> = {
|
||||
ok: 'bg-green-100 text-green-700', pass: 'bg-green-100 text-green-700', success: 'bg-green-100 text-green-700', allow: 'bg-green-100 text-green-700',
|
||||
warn: 'bg-orange-100 text-orange-700', stale: 'bg-orange-100 text-orange-700',
|
||||
fail: 'bg-red-100 text-red-700', failed: 'bg-red-100 text-red-700', denied: 'bg-red-100 text-red-700', blocked: 'bg-red-100 text-red-700', deny: 'bg-red-100 text-red-700', block: 'bg-red-100 text-red-700', crit: 'bg-red-100 text-red-700',
|
||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', pass: 'border-emerald-200 bg-emerald-50 text-emerald-700', success: 'border-emerald-200 bg-emerald-50 text-emerald-700', allow: 'border-emerald-200 bg-emerald-50 text-emerald-700', approved: 'border-emerald-200 bg-emerald-50 text-emerald-700', answered: 'border-emerald-200 bg-emerald-50 text-emerald-700', certified: 'border-emerald-200 bg-emerald-50 text-emerald-700', verified: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warn: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
};
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
const tone = TONE[String(value).toLowerCase()] ?? 'bg-gray-100 text-gray-600';
|
||||
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${tone}`}>{value}</span>;
|
||||
const tone = TONE[String(value).toLowerCase()] ?? 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none ${tone}`}>{value}</span>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #162033;
|
||||
background: #edf1f6;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
html, body, #root { min-height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 72% -10%, rgba(90, 130, 255, 0.13), transparent 30rem),
|
||||
#edf1f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
::selection { background: #c8d7ff; color: #14234b; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #c8d0dd; border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -91,6 +91,9 @@ export interface ChatSource {
|
||||
|
||||
export interface ChatAnswer {
|
||||
success: boolean;
|
||||
chat_id?: string;
|
||||
turn_id?: string;
|
||||
trace_id?: string;
|
||||
mode: 'READ_ONLY' | 'OPERATOR' | 'BLOCK' | 'NOT_SUPPORTED' | string;
|
||||
risk: string;
|
||||
decision: 'ANSWERED' | 'ACTION_COMPLETED' | 'ACTION_FAILED' | 'REQUIRES_APPROVAL' | 'DENIED' | 'NOT_SUPPORTED' | string;
|
||||
@@ -148,6 +151,7 @@ export interface ChatAnswer {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cost_source?: string;
|
||||
fallback_from?: string | null;
|
||||
};
|
||||
actor: SettingsActor;
|
||||
}
|
||||
@@ -190,6 +194,34 @@ export interface ChatReplay {
|
||||
audit_path?: string;
|
||||
}
|
||||
|
||||
export interface ChatConversation {
|
||||
chat_id: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
turns: number;
|
||||
last_decision: string;
|
||||
last_mode: string;
|
||||
}
|
||||
|
||||
export interface ChatHistoryTurn {
|
||||
chat_id: string;
|
||||
turn_id: string;
|
||||
timestamp: string;
|
||||
mode: string;
|
||||
risk: string;
|
||||
decision: string;
|
||||
prompt_preview: string;
|
||||
answer_preview: string;
|
||||
certified: boolean;
|
||||
audit_hash: string;
|
||||
}
|
||||
|
||||
export interface ChatHistory {
|
||||
ok: boolean;
|
||||
conversations: ChatConversation[];
|
||||
turns: ChatHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
actor: SettingsActor;
|
||||
capabilities: {
|
||||
@@ -277,6 +309,8 @@ export const api = {
|
||||
flush(buf);
|
||||
},
|
||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||
chatHistory: (actor: SettingsActor, chatId = '', limit = 50) =>
|
||||
getWithHeaders<ChatHistory>(`chat/history?chatId=${encodeURIComponent(chatId)}&limit=${limit}`, actorHeaders(actor)),
|
||||
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
|
||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
||||
|
||||
@@ -1,78 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { type KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatStreamPhase, 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}`;
|
||||
type MessageKind = 'user' | 'assistant' | 'draft';
|
||||
|
||||
interface WorkspaceMessage {
|
||||
id: string;
|
||||
kind: MessageKind;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
mode?: string;
|
||||
decision?: string;
|
||||
certified?: boolean;
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
|
||||
function finalToAnswer(phase: 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 ?? {},
|
||||
...(phase as Partial<ChatAnswer>),
|
||||
success: phase.decision === 'ANSWERED',
|
||||
mode: phase.mode,
|
||||
risk: phase.risk ?? 'low',
|
||||
decision: phase.decision,
|
||||
answer: phase.answer,
|
||||
sources: phase.sources ?? [],
|
||||
certified: phase.certified,
|
||||
audit: phase.audit ?? {},
|
||||
audit_verify: phase.audit_verify ?? { ok: true, output: '' },
|
||||
router: phase.router ?? {},
|
||||
actor,
|
||||
} as ChatAnswer;
|
||||
}
|
||||
|
||||
function auditHash(res: ChatAnswer) {
|
||||
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
|
||||
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 || 'The governed chat request could not be completed.';
|
||||
}
|
||||
return 'The governed chat request could not be completed.';
|
||||
}
|
||||
|
||||
function sourceExcerpt(source: { preview?: string; excerpt?: string }) {
|
||||
return source.preview || source.excerpt || '';
|
||||
function formatTime(value: string) {
|
||||
if (!value) return 'now';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat('en', { hour: '2-digit', minute: '2-digit' }).format(parsed);
|
||||
}
|
||||
|
||||
function auditHash(answer: ChatAnswer | null) {
|
||||
return answer?.audit?.hash || answer?.audit?.record_hash || answer?.audit?.head || 'n/a';
|
||||
}
|
||||
|
||||
function turnMessages(turn: ChatHistoryTurn): WorkspaceMessage[] {
|
||||
const messages: WorkspaceMessage[] = [];
|
||||
if (turn.prompt_preview) {
|
||||
messages.push({ id: `${turn.turn_id}-user`, kind: 'user', body: turn.prompt_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, preview: true });
|
||||
}
|
||||
if (turn.answer_preview) {
|
||||
messages.push({ id: `${turn.turn_id}-assistant`, kind: 'assistant', body: turn.answer_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, certified: turn.certified, preview: true });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' | 'bolt' | 'history' }) {
|
||||
const paths = {
|
||||
add: <><path d="M12 5v14M5 12h14" /></>,
|
||||
send: <><path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Z" /><path d="m9.7 13.3 4.6-4.6" /></>,
|
||||
spark: <><path d="m12 3 1.7 5.3L19 10l-5.3 1.7L12 17l-1.7-5.3L5 10l5.3-1.7L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
|
||||
lock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>,
|
||||
chevron: <path d="m9 18 6-6-6-6" />,
|
||||
bolt: <path d="m13 2-9 12h7l-1 8 10-13h-7l0-7Z" />,
|
||||
history: <><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5M12 7v5l3 2" /></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const [actor, setActor] = useState<SettingsActor>({
|
||||
actor: 'local-operator',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
});
|
||||
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' });
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
const [agentId, setAgentId] = useState('evidence-reader');
|
||||
const [skillId, setSkillId] = useState('evidence-summary');
|
||||
const [delegationLevel, setDelegationLevel] = useState(0);
|
||||
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
|
||||
const [message, setMessage] = useState('');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const [draftText, setDraftText] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(true);
|
||||
const [streamBusy, setStreamBusy] = useState(false);
|
||||
|
||||
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 agentsQuery = useQuery({
|
||||
queryKey: ['chat-agents', actor],
|
||||
queryFn: () => api.chatAgents(actor),
|
||||
retry: false,
|
||||
});
|
||||
const auditQuery = useQuery({ queryKey: ['chat-audit'], queryFn: api.verifyChatAudit, retry: false });
|
||||
const conversationsQuery = useQuery({ queryKey: ['chat-history', actor], queryFn: () => api.chatHistory(actor), retry: false });
|
||||
const historyQuery = useQuery({ queryKey: ['chat-history', actor, chatId], queryFn: () => api.chatHistory(actor, chatId, 100), retry: false });
|
||||
const actionsQuery = useQuery({ queryKey: ['chat-actions', actor], queryFn: () => api.chatActions(actor), retry: false });
|
||||
const agentsQuery = useQuery({ queryKey: ['chat-agents', actor], queryFn: () => api.chatAgents(actor), retry: false });
|
||||
|
||||
const agents = agentsQuery.data?.agents ?? [];
|
||||
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
|
||||
? skillId
|
||||
: (selectedAgent?.skills_allowed[0] ?? '');
|
||||
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0];
|
||||
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId) ? skillId : (selectedAgent?.skills_allowed[0] ?? '');
|
||||
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]);
|
||||
const liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
|
||||
const liveMessages: WorkspaceMessage[] = [];
|
||||
if (pendingMessage) liveMessages.push({ id: 'pending-user', kind: 'user', body: pendingMessage, timestamp: '', mode: 'READ_ONLY' });
|
||||
if (draftText) liveMessages.push({ id: 'draft', kind: 'draft', body: draftText, timestamp: '', mode: 'DRAFTING', certified: false });
|
||||
if (last && !liveAlreadyStored && !pendingMessage) liveMessages.push({ id: last.turn_id ?? 'latest-answer', kind: 'assistant', body: last.answer, timestamp: '', mode: last.mode, decision: last.decision, certified: last.certified });
|
||||
const messages = [...persistedMessages, ...liveMessages];
|
||||
|
||||
const refreshChat = () => {
|
||||
void auditQuery.refetch();
|
||||
void conversationsQuery.refetch();
|
||||
void historyQuery.refetch();
|
||||
};
|
||||
|
||||
const ask = useMutation({
|
||||
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
|
||||
@@ -82,296 +122,165 @@ export function Chat() {
|
||||
skillId: override?.skillId ?? selectedSkill,
|
||||
delegationLevel,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLast(res);
|
||||
onSuccess: (answer) => {
|
||||
setLast(answer);
|
||||
setPendingMessage(null);
|
||||
setError(null);
|
||||
void auditQuery.refetch();
|
||||
setMessage('');
|
||||
refreshChat();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.response?.data?.message || err.message || 'Ask CASAN failed');
|
||||
onError: (reason: unknown) => {
|
||||
setPendingMessage(null);
|
||||
setError(errorMessage(reason));
|
||||
},
|
||||
});
|
||||
|
||||
const runStream = async () => {
|
||||
const busy = ask.isPending || streamBusy;
|
||||
|
||||
const runStream = async (text: string) => {
|
||||
setStreamBusy(true);
|
||||
setError(null);
|
||||
setDraftText(null);
|
||||
setPendingMessage(text);
|
||||
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');
|
||||
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel }, (phase) => {
|
||||
if (phase.phase === 'draft') setDraftText(phase.answer);
|
||||
if (phase.phase === 'final') {
|
||||
setDraftText(null);
|
||||
setLast(finalToAnswer(phase, actor));
|
||||
setPendingMessage(null);
|
||||
setMessage('');
|
||||
}
|
||||
});
|
||||
refreshChat();
|
||||
} catch (reason: unknown) {
|
||||
setPendingMessage(null);
|
||||
setDraftText(null);
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setStreamBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAsk = () => {
|
||||
if (streaming) void runStream();
|
||||
else ask.mutate({});
|
||||
const submit = (override?: { message?: string; agentId?: string; skillId?: string }) => {
|
||||
const text = (override?.message ?? message).trim();
|
||||
if (!text || busy) return;
|
||||
if (streaming && !override) void runStream(text);
|
||||
else {
|
||||
setPendingMessage(text);
|
||||
ask.mutate(override ?? {});
|
||||
}
|
||||
};
|
||||
|
||||
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const newConversation = () => {
|
||||
const id = `chat-${Date.now().toString(36)}`;
|
||||
setChatId(id);
|
||||
setLast(null);
|
||||
setMessage('');
|
||||
setPendingMessage(null);
|
||||
setDraftText(null);
|
||||
setError(null);
|
||||
};
|
||||
const busy = ask.isPending || streamBusy;
|
||||
|
||||
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() || busy}
|
||||
onClick={onAsk}
|
||||
>
|
||||
{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 className="space-y-5">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-[#172554] via-[#1e2f68] to-[#334aa0] px-5 py-5 text-white shadow-[0_20px_40px_rgba(30,41,89,0.22)] sm:px-6">
|
||||
<div className="absolute -right-12 -top-16 h-52 w-52 rounded-full bg-indigo-300/20 blur-3xl" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200"><Glyph name="spark" />Evidence-first assistant</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Ask with context. Act only with proof.</h2>
|
||||
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-indigo-100/80">Every response is routed, checked and anchored to an auditable evidence trail before it reaches this workspace.</p>
|
||||
</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>
|
||||
<label className="space-y-1 md:col-span-2 xl:col-span-1">
|
||||
<span className="text-gray-500">Agent</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
|
||||
onChange={(e) => {
|
||||
const next = agents.find((a) => a.id === e.target.value);
|
||||
setAgentId(e.target.value);
|
||||
setSkillId(next?.skills_allowed[0] ?? '');
|
||||
}}>
|
||||
{agents.map((a: ChatAgent) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Skill</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
|
||||
onChange={(e) => setSkillId(e.target.value)}>
|
||||
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Delegation</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
|
||||
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-xs font-semibold text-indigo-50 backdrop-blur">
|
||||
<span className={`h-2 w-2 rounded-full ${auditQuery.data?.ok ? 'bg-emerald-300' : 'bg-amber-300'}`} />
|
||||
{auditQuery.data?.ok ? 'Audit chain verified' : 'Checking audit chain'}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{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} />
|
||||
{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'} />}
|
||||
<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>
|
||||
)}
|
||||
{last.agent_binding && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
|
||||
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.agent_binding.decision} />
|
||||
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
|
||||
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.loop_run && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Loop run</div>
|
||||
<div className="mt-1 font-medium text-gray-800 break-all">{last.loop_run.run_id}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.loop_run.decision} />
|
||||
<StatusBadge value={last.loop_run.draft_certified ? 'draft certified' : 'draft held'} />
|
||||
<StatusBadge value={last.loop_run.side_effect_released ? 'released' : 'held'} />
|
||||
<StatusBadge value={last.loop_run.replay?.ok ? 'replay ok' : 'replay pending'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{last.codegen && (
|
||||
<div className="rounded border border-gray-200 p-3">
|
||||
<div className="text-xs font-semibold uppercase text-gray-400">Codegen draft</div>
|
||||
<div className="mt-1 font-medium text-gray-800 break-all">{last.codegen.artifact ?? 'n/a'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusBadge value={last.codegen.artifact_scan?.ok ? 'artifact scan ok' : 'artifact scan held'} />
|
||||
<StatusBadge value={last.codegen.tool_output_scan?.ok ? 'output scan ok' : 'output scan held'} />
|
||||
</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, agentId: 'ops-operator', skillId: 'registered-actions' });
|
||||
}}
|
||||
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>
|
||||
<div className="grid min-h-[680px] grid-cols-1 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_18px_45px_rgba(15,23,42,0.07)] xl:grid-cols-[260px_minmax(0,1fr)_300px]">
|
||||
<aside className="border-b border-slate-200 bg-slate-50/80 p-4 xl:border-b-0 xl:border-r">
|
||||
<button type="button" onClick={newConversation} className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-3 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 disabled:bg-slate-400" disabled={busy}>
|
||||
<Glyph name="add" />New conversation
|
||||
</button>
|
||||
<div className="mt-5 flex items-center justify-between text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400"><span>Recent threads</span><span>{conversationsQuery.data?.conversations.length ?? 0}</span></div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{(conversationsQuery.data?.conversations ?? []).map((conversation) => (
|
||||
<button key={conversation.chat_id} type="button" onClick={() => { setChatId(conversation.chat_id); setLast(null); setError(null); }} className={`w-full rounded-xl p-3 text-left transition ${chatId === conversation.chat_id ? 'bg-white shadow-sm ring-1 ring-indigo-200' : 'hover:bg-white/70'}`}>
|
||||
<div className="flex items-start justify-between gap-2"><div className="line-clamp-2 text-sm font-medium leading-5 text-slate-800">{conversation.title}</div><StatusBadge value={conversation.last_decision} /></div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-400"><span>{conversation.turns} turn{conversation.turns === 1 ? '' : 's'}</span><span>{formatTime(conversation.updated_at)}</span></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>
|
||||
</>
|
||||
))}
|
||||
{!conversationsQuery.isLoading && (conversationsQuery.data?.conversations.length ?? 0) === 0 && <div className="rounded-xl border border-dashed border-slate-200 p-4 text-xs leading-5 text-slate-500">Start a conversation to create an immutable, audit-safe thread.</div>}
|
||||
</div>
|
||||
<div className="mt-6 rounded-xl border border-indigo-100 bg-indigo-50/70 p-3 text-xs leading-5 text-indigo-800"><div className="flex items-center gap-1.5 font-semibold"><Glyph name="history" />Memory boundary</div><p className="mt-1 text-indigo-700/80">Only H4-scanned previews are restored. Raw prompts remain outside the UI history.</p></div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-[620px] min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3.5">
|
||||
<div><div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Active thread</div><div className="mt-0.5 font-mono text-xs text-slate-700">{chatId}</div></div>
|
||||
<div className="flex items-center gap-2"><StatusBadge value={streaming ? 'streaming' : 'certified only'} /><StatusBadge value={selectedAgent?.mode ?? 'READ_ONLY'} /></div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto bg-[linear-gradient(180deg,#fff_0%,#fafcff_100%)] px-5 py-6">
|
||||
{historyQuery.isLoading && <div className="text-sm text-slate-400">Loading audit-safe conversation history…</div>}
|
||||
{messages.map((item) => (
|
||||
<div key={item.id} className={`flex ${item.kind === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<article className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[78%] ${item.kind === 'user' ? 'rounded-br-md bg-slate-900 text-white shadow-md shadow-slate-900/10' : item.kind === 'draft' ? 'rounded-bl-md border border-amber-200 bg-amber-50 text-slate-700' : 'rounded-bl-md border border-slate-200 bg-white text-slate-800 shadow-sm'}`}>
|
||||
<div className={`mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em] ${item.kind === 'user' ? 'text-slate-300' : item.kind === 'draft' ? 'text-amber-700' : 'text-slate-400'}`}>
|
||||
{item.kind === 'user' ? 'You' : item.kind === 'draft' ? 'Uncertified draft' : 'CASAN'}
|
||||
{item.mode && <span className="font-medium normal-case tracking-normal">· {item.mode}</span>}
|
||||
{item.preview && <span className="font-medium normal-case tracking-normal">· audit preview</span>}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6">{item.body}</div>
|
||||
<div className={`mt-3 flex items-center gap-2 text-[10px] ${item.kind === 'user' ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||
<span>{formatTime(item.timestamp)}</span>
|
||||
{item.decision && <span>· {item.decision}</span>}
|
||||
{item.certified !== undefined && <span>· {item.certified ? 'certified' : 'held'}</span>}
|
||||
</div>
|
||||
</article>
|
||||
</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">
|
||||
{error && <div role="alert" className="mb-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>}
|
||||
<div className="rounded-2xl border border-slate-300 bg-white p-2 shadow-[0_8px_20px_rgba(15,23,42,0.05)] transition focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-100">
|
||||
<textarea aria-label="Ask CASAN" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={onComposerKeyDown} placeholder="Ask CASAN about your evidence, plans or governed actions…" className="min-h-[82px] w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-6 text-slate-800 outline-none placeholder:text-slate-400" disabled={busy} />
|
||||
<div className="flex items-center justify-between gap-3 px-1 pt-1">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500"><input type="checkbox" checked={streaming} onChange={(event) => setStreaming(event.target.checked)} className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />Show safe draft first</label>
|
||||
<button type="button" onClick={() => submit()} disabled={!message.trim() || busy} className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"><Glyph name="send" />{busy ? 'Working…' : 'Ask CASAN'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-400"><Glyph name="lock" />Enter sends · Shift + Enter adds a line · outputs pass governance before certification.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="border-t border-slate-200 bg-slate-50/70 p-4 xl:border-l xl:border-t-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
|
||||
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
|
||||
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
|
||||
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'}</div>
|
||||
<details className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-600"><summary className="cursor-pointer font-medium text-slate-700">Session scope</summary><div className="mt-3 grid grid-cols-2 gap-2"><label className="col-span-2">Actor<input value={actor.actor} onChange={(event) => setActor({ ...actor, actor: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Role<select value={actor.role} onChange={(event) => setActor({ ...actor, role: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{ROLES.map((role) => <option key={role}>{role}</option>)}</select></label><label>Delegate<input type="number" min={0} max={5} value={delegationLevel} onChange={(event) => setDelegationLevel(Number(event.target.value || 0))} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Project<input value={actor.project} onChange={(event) => setActor({ ...actor, project: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Tenant<input value={actor.tenant} onChange={(event) => setActor({ ...actor, tenant: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label className="col-span-2">Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label><label className="col-span-2">Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label></div></details>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
|
||||
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
|
||||
</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>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,36 +2,71 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
function SignalMark({ tone }: { tone: 'indigo' | 'emerald' | 'amber' }) {
|
||||
const color = { indigo: 'bg-indigo-500', emerald: 'bg-emerald-500', amber: 'bg-amber-500' }[tone];
|
||||
return <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${color}`} />;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['overview'], queryFn: api.overview });
|
||||
if (isLoading) return <div className="text-gray-500">Loading…</div>;
|
||||
if (isError || !data) return <div className="text-red-600">Cannot reach Ops Console API.</div>;
|
||||
const t = data.totals;
|
||||
if (isLoading) return <div className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-500">Loading operational signals…</div>;
|
||||
if (isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700">Cannot reach the Ops Console API.</div>;
|
||||
const totals = data.totals;
|
||||
const signalEntries = Object.entries(data.harness_signals);
|
||||
const posture = totals.failures > 0 || totals.action_blocks > 0 ? 'attention' : 'verified';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatTile label="Runs" value={t.runs} />
|
||||
<StatTile label="Failures" value={t.failures} />
|
||||
<StatTile label="Total cost (est)" value={`$${t.total_cost.toFixed(4)}`} sub={`${t.provider_tokens} provider tokens`} />
|
||||
<StatTile label="Avg latency" value={`${t.avg_latency_ms} ms`} />
|
||||
<StatTile label="Fallback routes" value={t.fallback_routes} />
|
||||
<StatTile label="Tool denies" value={t.tool_denies} />
|
||||
<StatTile label="Action blocks" value={t.action_blocks} />
|
||||
<StatTile label="Hallucination signals" value={t.hallucination_signals} />
|
||||
</div>
|
||||
<Card title="Harness signals (real counts)">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(data.harness_signals).map(([h, sig]) => (
|
||||
<div key={h} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="font-medium text-gray-700">{h}</div>
|
||||
<div className="text-gray-500 mt-1">{Object.entries(sig).map(([k, v]) => `${k}: ${v}`).join(' · ')}</div>
|
||||
<div className="space-y-5">
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_15px_35px_rgba(15,23,42,0.055)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div className="relative overflow-hidden px-5 py-6 sm:px-7">
|
||||
<div className="absolute right-0 top-0 h-40 w-40 translate-x-1/3 -translate-y-1/3 rounded-full bg-indigo-100 blur-2xl" />
|
||||
<div className="relative">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-600">CASAN system posture</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">Governance is visible. Evidence is actionable.</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">Monitor the health of every controlled run, policy decision and model interaction from one evidence-backed control plane.</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2"><StatusBadge value={posture} /><span className="text-xs text-slate-500">{data.audit_chain.records} audit records · head anchored {data.audit_chain.head ? 'now' : 'pending'}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 bg-slate-50/80 p-5 lg:border-l lg:border-t-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Trust ribbon</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex gap-3"><SignalMark tone="emerald" /><div><div className="text-sm font-semibold text-slate-800">Audit chain</div><div className="text-xs text-slate-500">{data.audit_chain.records ? 'Verified telemetry present' : 'Awaiting first record'}</div></div></div>
|
||||
<div className="flex gap-3"><SignalMark tone={totals.provider_tokens > 0 ? 'indigo' : 'amber'} /><div><div className="text-sm font-semibold text-slate-800">Model telemetry</div><div className="text-xs text-slate-500">{totals.provider_tokens.toLocaleString()} provider tokens observed</div></div></div>
|
||||
<div className="flex gap-3"><SignalMark tone={totals.failures > 0 ? 'amber' : 'emerald'} /><div><div className="text-sm font-semibold text-slate-800">Execution gate</div><div className="text-xs text-slate-500">{totals.failures ? `${totals.failures} run(s) need review` : 'No failed runs reported'}</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Audit chain" right={<StatusBadge value={data.audit_chain.last_decision ?? 'n/a'} />}>
|
||||
<div className="text-sm text-gray-600">records: {data.audit_chain.records} · head: <code className="text-xs">{data.audit_chain.head?.slice(0, 16) ?? '—'}…</code></div>
|
||||
</Card>
|
||||
</>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
|
||||
<StatTile label="Runs" value={totals.runs} />
|
||||
<StatTile label="Failures" value={totals.failures} />
|
||||
<StatTile label="Model cost" value={`$${totals.total_cost.toFixed(4)}`} sub={`${totals.provider_tokens.toLocaleString()} tokens`} />
|
||||
<StatTile label="Latency" value={`${totals.avg_latency_ms}ms`} sub="average" />
|
||||
<StatTile label="Fallbacks" value={totals.fallback_routes} />
|
||||
<StatTile label="Tool denies" value={totals.tool_denies} />
|
||||
<StatTile label="Action blocks" value={totals.action_blocks} />
|
||||
<StatTile label="H-signal flags" value={totals.hallucination_signals} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_330px]">
|
||||
<Card title="Harness signal map" right={<span className="text-xs text-slate-400">Live aggregate</span>}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{signalEntries.map(([harness, signal], index) => (
|
||||
<div key={harness} className="group rounded-xl border border-slate-200 bg-slate-50/70 p-4 transition hover:border-indigo-200 hover:bg-white hover:shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3"><span className="font-mono text-xs font-semibold text-indigo-600">{harness}</span><span className="text-[11px] text-slate-400">0{index + 1}</span></div>
|
||||
<div className="mt-3 text-sm font-medium leading-6 text-slate-700">{Object.entries(signal).map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`).join(' · ')}</div>
|
||||
</div>
|
||||
))}
|
||||
{signalEntries.length === 0 && <div className="text-sm text-slate-500">No harness signal aggregate has been recorded yet.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Audit anchor" right={<StatusBadge value={data.audit_chain.last_decision ?? 'pending'} />}>
|
||||
<div className="rounded-xl bg-slate-950 p-4 text-slate-100"><div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Current ledger head</div><div className="mt-2 break-all font-mono text-xs leading-5">{data.audit_chain.head ?? 'No audit head yet'}</div></div>
|
||||
<p className="mt-4 text-sm leading-6 text-slate-500">This value changes only when the governed ledger accepts a new event. Use the Governance view to inspect the decision path.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user