feat: connect chat to managed model providers
This commit is contained in:
@@ -2,6 +2,7 @@ import { type KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel';
|
||||
|
||||
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
|
||||
|
||||
@@ -78,11 +79,13 @@ function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' |
|
||||
}
|
||||
|
||||
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: 'project-admin', project: 'default', tenant: 'default' });
|
||||
const [chatId, setChatId] = useState('chat-default');
|
||||
const [agentId, setAgentId] = useState('evidence-reader');
|
||||
const [skillId, setSkillId] = useState('evidence-summary');
|
||||
const [modelProvider, setModelProvider] = useState('local');
|
||||
const [modelProvider, setModelProvider] = useState('ollama');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [connectionsOpen, setConnectionsOpen] = useState(false);
|
||||
const [delegationLevel, setDelegationLevel] = useState(0);
|
||||
const [message, setMessage] = useState('');
|
||||
const [last, setLast] = useState<ChatAnswer | null>(null);
|
||||
@@ -102,8 +105,13 @@ export function Chat() {
|
||||
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 modelsQuery = useQuery({ queryKey: ['chat-models', actor, selectedAgent?.model_role], queryFn: () => api.chatModels(actor, selectedAgent?.model_role ?? 'read_only'), retry: false });
|
||||
const connectionsQuery = useQuery({ queryKey: ['chat-connections', actor], queryFn: () => api.chatConnections(actor), retry: false });
|
||||
const models = modelsQuery.data?.providers ?? [];
|
||||
const selectedModel = models.find((provider) => provider.id === modelProvider) ?? models.find((provider) => provider.id === modelsQuery.data?.default) ?? models[0];
|
||||
const connections = connectionsQuery.data?.connections ?? [];
|
||||
const connectedModels = connections.filter((connection) => connection.connected).flatMap((connection) => connection.models.map((model) => ({ provider: connection.id, providerLabel: connection.label, model })));
|
||||
const selectedConnection = connections.find((connection) => connection.id === modelProvider && connection.connected);
|
||||
const selectedRuntimeModel = selectedConnection?.models.includes(modelId) ? modelId : (selectedConnection?.defaultModel || selectedConnection?.models[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[] = [];
|
||||
@@ -125,6 +133,7 @@ export function Chat() {
|
||||
agentId: override?.agentId ?? selectedAgent?.id ?? agentId,
|
||||
skillId: override?.skillId ?? selectedSkill,
|
||||
modelProvider: selectedModel?.id,
|
||||
...(selectedConnection ? { modelProvider: selectedConnection.id, modelId: selectedRuntimeModel } : {}),
|
||||
delegationLevel,
|
||||
}),
|
||||
onSuccess: (answer) => {
|
||||
@@ -148,7 +157,7 @@ export function Chat() {
|
||||
setDraftText(null);
|
||||
setPendingMessage(text);
|
||||
try {
|
||||
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, modelProvider: selectedModel?.id, delegationLevel }, (phase) => {
|
||||
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, modelProvider: selectedConnection?.id ?? selectedModel?.id, modelId: selectedConnection ? selectedRuntimeModel : undefined, delegationLevel }, (phase) => {
|
||||
if (phase.phase === 'draft') setDraftText(phase.answer);
|
||||
if (phase.phase === 'final') {
|
||||
setDraftText(null);
|
||||
@@ -272,12 +281,13 @@ export function Chat() {
|
||||
<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'} · {selectedModel?.model ?? 'model unavailable'}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'} · {selectedRuntimeModel || selectedModel?.model || 'model unavailable'}</div>
|
||||
<div className="mt-3 grid gap-2 border-t border-slate-100 pt-3 text-xs text-slate-600">
|
||||
<label>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} disabled={!agent.allowed_for_role}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label>
|
||||
<label>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>
|
||||
<label>Model provider<select value={selectedModel?.id ?? ''} onChange={(event) => setModelProvider(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{models.map((provider: ChatModelProvider) => <option key={provider.id} value={provider.id} disabled={!provider.allowed || !provider.configured}>{provider.id} · {provider.model}{provider.allowed && provider.configured ? '' : ` (${provider.reason || 'not allowed'})`}</option>)}</select></label>
|
||||
<p className="text-[11px] leading-4 text-slate-500">Provider is policy-bound to your role and agent; cloud/gateway routes enforce preflight.</p>
|
||||
<label>Model<select value={selectedConnection ? `${selectedConnection.id}::${selectedRuntimeModel}` : `policy::${selectedModel?.id ?? ''}`} onChange={(event) => { const [provider, model] = event.target.value.split('::'); if (provider === 'policy') { setModelProvider(model); setModelId(''); } else { setModelProvider(provider); setModelId(model); } }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{connectedModels.map((item) => <option key={`${item.provider}-${item.model}`} value={`${item.provider}::${item.model}`}>{item.providerLabel} · {item.model}</option>)}{models.map((provider: ChatModelProvider) => <option key={`policy-${provider.id}`} value={`policy::${provider.id}`} disabled={!provider.allowed || !provider.configured}>{provider.id} · {provider.model}{provider.allowed && provider.configured ? '' : ` (${provider.reason || 'not allowed'})`}</option>)}</select></label>
|
||||
<button type="button" onClick={() => setConnectionsOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-xs font-semibold text-indigo-700 transition hover:bg-indigo-100"><Glyph name="bolt" />Manage model connections</button>
|
||||
<p className="text-[11px] leading-4 text-slate-500">Connected models are loaded live; every cloud/gateway request still passes policy and preflight.</p>
|
||||
</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>
|
||||
@@ -292,6 +302,7 @@ export function Chat() {
|
||||
<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>
|
||||
<ModelConnectionPanel actor={actor} open={connectionsOpen} onClose={() => setConnectionsOpen(false)} onSelect={(provider, model) => { setModelProvider(provider); setModelId(model); void connectionsQuery.refetch(); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user