feat: add governed chat agent selection

This commit is contained in:
thanhnv
2026-07-08 22:19:51 +09:00
parent 06d8d31b16
commit 004afa73c9
18 changed files with 677 additions and 39 deletions
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAnswer, SettingsActor } from '../lib/api';
import { api, ChatAction, ChatAgent, ChatAnswer, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
@@ -26,6 +26,9 @@ export function Chat() {
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 [last, setLast] = useState<ChatAnswer | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -40,9 +43,25 @@ export function Chat() {
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 ask = useMutation({
mutationFn: (override?: { message?: string }) => api.askChat(actor, { message: override?.message ?? message, chatId }),
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
message: override?.message ?? message,
chatId,
agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
skillId: override?.skillId ?? selectedSkill,
delegationLevel,
}),
onSuccess: (res) => {
setLast(res);
setError(null);
@@ -111,6 +130,33 @@ export function Chat() {
<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>
</div>
</Card>
@@ -125,6 +171,8 @@ export function Chat() {
<StatusBadge value={last.mode} />
<StatusBadge value={last.risk} />
<StatusBadge value={last.decision} />
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
<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>
@@ -174,6 +222,18 @@ export function Chat() {
</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>
)}
<div>
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
<div className="mt-1 flex flex-wrap gap-2">
@@ -202,7 +262,7 @@ export function Chat() {
disabled={ask.isPending}
onClick={() => {
setMessage(trigger);
ask.mutate({ message: 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"
>