feat: connect chat to managed model providers

This commit is contained in:
thanhnv
2026-07-10 17:40:44 +09:00
parent ef51c90d91
commit adce7717f7
9 changed files with 708 additions and 21 deletions
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Headers, Inject, Post, Query, Res } from '@nestj
import type { Response } from 'express';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { ChatAskInput, ChatService } from './chat.service.js';
import { ChatAskInput, ChatConnectionInput, ChatDefaultModelInput, ChatService } from './chat.service.js';
@Controller('api/v1/chat')
export class ChatController {
@@ -58,4 +58,41 @@ export class ChatController {
) {
return ok(this.svc.listModels(actorFromHeaders(headers), modelRole || 'read_only'));
}
@Get('connections')
connections(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.connections(actorFromHeaders(headers)));
}
@Post('connections/connect')
connect(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: ChatConnectionInput,
) {
return ok(this.svc.connectProvider(body, actorFromHeaders(headers)));
}
@Post('connections/refresh')
refreshConnection(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: { provider: string },
) {
return ok(this.svc.refreshProvider(body.provider, actorFromHeaders(headers)));
}
@Post('connections/default')
defaultModel(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: ChatDefaultModelInput,
) {
return ok(this.svc.setDefaultModel(body, actorFromHeaders(headers)));
}
@Post('connections/disconnect')
disconnect(
@Headers() headers: Record<string, string | string[] | undefined>,
@Body() body: { provider: string },
) {
return ok(this.svc.disconnectProvider(body.provider, actorFromHeaders(headers)));
}
}
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync, spawn } from 'node:child_process';
import { join } from 'node:path';
import type { Response } from 'express';
@@ -11,9 +11,22 @@ export interface ChatAskInput {
agentId?: string;
skillId?: string;
modelProvider?: string;
modelId?: string;
delegationLevel?: number;
}
export interface ChatConnectionInput {
provider: string;
endpoint?: string;
apiKey?: string;
defaultModel?: string;
}
export interface ChatDefaultModelInput {
provider: string;
model: string;
}
interface CommandResult {
status: number;
stdout: string;
@@ -26,14 +39,23 @@ const OPERATOR_CLI = join(HARNESS_BIN, 'chat-operator.py');
const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): CommandResult {
const CONNECTION_POLICY_PROVIDER: Record<string, string> = {
openai: 'cloud-openai',
anthropic: 'cloud-anthropic',
omniroute: 'omniroute',
ollama: 'local',
};
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, input?: string): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...extraEnv },
input,
});
return { status: 0, stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
@@ -79,9 +101,10 @@ export class ChatService {
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider);
const runtime = this.modelRuntime(input, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
const res = runPython(CHAT_CLI, args);
const res = runPython(CHAT_CLI, args, runtime.env);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) {
return { ...parsed, actor, audit_verify: this.verifyAudit() };
@@ -138,12 +161,13 @@ export class ChatService {
];
if (input.agentId) args.push('--agent', input.agentId);
if (input.skillId) args.push('--skill', input.skillId);
if (input.modelProvider) args.push('--model-provider', input.modelProvider);
const runtime = this.modelRuntime(input, actor);
if (runtime.policyProvider) args.push('--model-provider', runtime.policyProvider);
if (input.delegationLevel !== undefined) args.push('--delegation-level', String(input.delegationLevel));
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env } });
const child = spawn('python3', args, { cwd: APP_ROOT, env: { ...process.env, ...runtime.env } });
child.stdout.on('data', (chunk) => res.write(chunk));
child.on('error', () => {
if (!res.headersSent) res.status(500);
@@ -189,6 +213,71 @@ export class ChatService {
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_MODELS_FAILED');
}
connections(actor: SettingsActor) {
this.requireRead(actor);
return this.connectionCommand(['list'], actor);
}
connectProvider(input: ChatConnectionInput, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!input.provider) throw new BadRequestException('provider required');
return this.connectionCommand(
['connect', '--provider', input.provider],
actor,
JSON.stringify({ endpoint: input.endpoint ?? '', apiKey: input.apiKey ?? '', defaultModel: input.defaultModel ?? '' }),
);
}
refreshProvider(provider: string, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!provider) throw new BadRequestException('provider required');
return this.connectionCommand(['refresh', '--provider', provider], actor);
}
setDefaultModel(input: ChatDefaultModelInput, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!input.provider || !input.model) throw new BadRequestException('provider and model required');
return this.connectionCommand(['set-default', '--provider', input.provider, '--model', input.model], actor);
}
disconnectProvider(provider: string, actor: SettingsActor) {
this.requireConnectionAdmin(actor);
if (!provider) throw new BadRequestException('provider required');
return this.connectionCommand(['disconnect', '--provider', provider], actor);
}
private connectionCommand(args: string[], actor: SettingsActor, stdin?: string) {
const res = runPython(CONNECTIONS_CLI, args, { CASAN_TENANT_ID: actor.tenant || 'default' }, stdin);
const parsed = parseJson<Record<string, unknown>>(res.stdout);
if (res.status === 0 && parsed) return parsed;
const errorPayload = parseJson<{ reason?: string }>(res.stderr);
throw new BadRequestException(errorPayload?.reason || res.stderr || res.stdout || 'MODEL_CONNECTION_FAILED');
}
private modelRuntime(input: ChatAskInput, actor: SettingsActor): { policyProvider: string; env: NodeJS.ProcessEnv } {
const provider = input.modelProvider || '';
const policyProvider = CONNECTION_POLICY_PROVIDER[provider] || provider;
if (!input.modelId) return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default' } };
if (!CONNECTION_POLICY_PROVIDER[provider]) throw new BadRequestException('connected provider required for model selection');
const res = runPython(
CONNECTIONS_CLI,
['runtime-env', '--provider', provider, '--model', input.modelId],
{ CASAN_TENANT_ID: actor.tenant || 'default' },
);
const parsed = parseJson<{ success?: boolean; env?: Record<string, string> }>(res.stdout);
if (res.status !== 0 || !parsed?.success || !parsed.env) {
const errorPayload = parseJson<{ reason?: string }>(res.stderr);
throw new BadRequestException(errorPayload?.reason || 'MODEL_RUNTIME_UNAVAILABLE');
}
return { policyProvider, env: { CASAN_TENANT_ID: actor.tenant || 'default', ...parsed.env } };
}
private requireConnectionAdmin(actor: SettingsActor) {
if (!['project-admin', 'org-admin'].includes(actor.role)) {
throw new ForbiddenException('MODEL_CONNECTION_ADMIN_REQUIRED');
}
}
private requireRead(actor: SettingsActor) {
const res = runPython(RBAC_CLI, [
'check',
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, ChatModelConnection, SettingsActor } from '../../lib/api';
interface ModelConnectionPanelProps {
actor: SettingsActor;
open: boolean;
onClose: () => void;
onSelect: (provider: string, model: string) => void;
}
interface ConnectionForm {
endpoint: string;
apiKey: string;
}
const DEFAULT_ENDPOINTS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com/v1',
omniroute: 'http://host.docker.internal:20128/v1',
ollama: 'http://host.docker.internal:11434',
};
const PROVIDER_HELP: Record<string, string> = {
openai: 'Dùng OpenAI API key để tải các model API mà tài khoản được phép sử dụng.',
anthropic: 'Dùng Anthropic API key để tải model Claude. Không nhập mật khẩu claude.ai.',
omniroute: 'Gateway OpenAI-compatible đang chạy trên máy host, mặc định cổng 20128; local setup không bắt buộc key.',
ollama: 'Model chạy hoàn toàn local qua Ollama, không cần API key.',
};
function messageOf(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const value = error as { message?: string; response?: { data?: { message?: string } } };
return value.response?.data?.message || value.message || 'Không thể cập nhật kết nối model.';
}
return 'Không thể cập nhật kết nối model.';
}
export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelConnectionPanelProps) {
const queryClient = useQueryClient();
const [forms, setForms] = useState<Record<string, ConnectionForm>>({});
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const query = useQuery({
queryKey: ['chat-connections', actor],
queryFn: () => api.chatConnections(actor),
enabled: open,
retry: false,
});
useEffect(() => {
if (!query.data?.connections) return;
setForms((current) => Object.fromEntries(query.data.connections.map((connection) => [connection.id, {
endpoint: current[connection.id]?.endpoint || connection.endpoint || DEFAULT_ENDPOINTS[connection.id],
apiKey: '',
}])));
}, [query.data]);
const finish = async (text: string) => {
setError(null);
setNotice(text);
await queryClient.invalidateQueries({ queryKey: ['chat-connections'] });
await query.refetch();
};
const connect = useMutation({
mutationFn: ({ connection, form }: { connection: ChatModelConnection; form: ConnectionForm }) => api.connectChatProvider(actor, {
provider: connection.id,
endpoint: form.endpoint,
apiKey: form.apiKey,
}),
onSuccess: async (result) => {
setForms((current) => ({ ...current, [result.connection.id]: { ...current[result.connection.id], apiKey: '' } }));
onSelect(result.connection.id, result.connection.defaultModel);
await finish(`${result.connection.label} đã kết nối và tải ${result.connection.models.length} model.`);
},
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const refresh = useMutation({
mutationFn: (connection: ChatModelConnection) => api.refreshChatProvider(actor, connection.id),
onSuccess: async (result) => finish(`Đã làm mới ${result.connection.models.length} model từ ${result.connection.label}.`),
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const setDefault = useMutation({
mutationFn: ({ connection, model }: { connection: ChatModelConnection; model: string }) => api.setChatDefaultModel(actor, connection.id, model),
onSuccess: async (result) => {
onSelect(result.connection.id, result.connection.defaultModel);
await finish(`Model mặc định đã đổi thành ${result.connection.defaultModel}.`);
},
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const disconnect = useMutation({
mutationFn: (connection: ChatModelConnection) => api.disconnectChatProvider(actor, connection.id),
onSuccess: async (result) => finish(`${result.connection.label} đã được ngắt kết nối; secret đã bị xóa.`),
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
});
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending;
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="model-connections-title">
<div className="max-h-[94vh] w-full max-w-5xl overflow-y-auto rounded-t-3xl border border-slate-200 bg-[#f8fafc] shadow-2xl sm:rounded-3xl">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 backdrop-blur sm:px-7">
<div>
<div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-600">Provider control deck</div>
<h2 id="model-connections-title" className="mt-1 text-xl font-semibold text-slate-900">Kết nối và chọn model cho CASAN Chat</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-500">API key được mã hóa theo tenant và không bao giờ được hiển thị lại. Đăng nhập tài khoản Codex/Claude trên CLI là luồng riêng; CASAN Chat chỉ dùng API key hoặc gateway của bạn.</p>
</div>
<button type="button" onClick={onClose} className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100">Đóng</button>
</header>
<div className="p-5 sm:p-7">
{actor.role !== 'project-admin' && actor.role !== 'org-admin' && <div role="alert" className="mb-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Chuyển Session scope sang <strong>project-admin</strong> hoặc <strong>org-admin</strong> để quản lý kết nối. Các role khác vẫn có thể xem trạng thái.</div>}
{notice && <div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{notice}</div>}
{error && <div role="alert" className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>}
{query.isLoading && <div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">Đang đọc kho kết nối đã mã hóa…</div>}
<div className="grid gap-4 md:grid-cols-2">
{(query.data?.connections ?? []).map((connection) => {
const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' };
const canManage = actor.role === 'project-admin' || actor.role === 'org-admin';
return (
<section key={connection.id} className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:-translate-y-px hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div><h3 className="font-semibold text-slate-900">{connection.label}</h3><p className="mt-1 text-xs leading-5 text-slate-500">{PROVIDER_HELP[connection.id]}</p></div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${connection.connected ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>{connection.connected ? 'Connected' : 'Offline'}</span>
</div>
<div className="mt-4 space-y-3">
<label className="block text-xs font-medium text-slate-600">API base URL<input value={form.endpoint} onChange={(event) => setForms((current) => ({ ...current, [connection.id]: { ...form, endpoint: event.target.value } }))} className="mt-1.5 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={connection.id === 'openai' || connection.id === 'anthropic' || busy} /></label>
{connection.requiresKey && <label className="block text-xs font-medium text-slate-600">API key {connection.connected && <span className="font-normal text-slate-400">· để trống không có nghĩa là key cũ được đọc lại</span>}<input type="password" autoComplete="new-password" value={form.apiKey} onChange={(event) => setForms((current) => ({ ...current, [connection.id]: { ...form, apiKey: event.target.value } }))} placeholder={connection.connected ? 'Nhập key mới nếu muốn reconnect' : 'Nhập API key'} className="mt-1.5 w-full rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={busy} /></label>}
{connection.connected && connection.models.length > 0 && <label className="block text-xs font-medium text-slate-600">Model mặc định<select value={connection.defaultModel} onChange={(event) => setDefault.mutate({ connection, model: event.target.value })} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" disabled={busy || !canManage}>{connection.models.map((model) => <option key={model} value={model}>{model}</option>)}</select></label>}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
<button type="button" onClick={() => connect.mutate({ connection, form })} disabled={busy || !canManage || (connection.requiresKey && !form.apiKey)} className="rounded-xl bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300">{connection.connected ? 'Reconnect' : 'Kết nối & tải model'}</button>
{connection.connected && <><button type="button" onClick={() => refresh.mutate(connection)} disabled={busy || !canManage} className="rounded-xl border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-50">Refresh</button><button type="button" onClick={() => disconnect.mutate(connection)} disabled={busy || !canManage} className="rounded-xl px-3 py-2 text-xs font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50">Disconnect</button></>}
{connection.lastCheckedAt && <span className="ml-auto text-[10px] text-slate-400">Checked {new Date(connection.lastCheckedAt).toLocaleString()}</span>}
</div>
</section>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -195,6 +195,19 @@ export interface ChatModelProvider {
reason: string;
}
export interface ChatModelConnection {
id: 'openai' | 'anthropic' | 'omniroute' | 'ollama';
label: string;
kind: 'cloud' | 'gateway' | 'local';
connected: boolean;
endpoint: string;
models: string[];
defaultModel: string;
lastCheckedAt: string | null;
status: string;
requiresKey: boolean;
}
export interface ChatReplay {
ok: boolean;
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
@@ -278,11 +291,11 @@ 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; approvalJwt?: 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; agentId?: string; skillId?: string; modelProvider?: string; delegationLevel?: number }) =>
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; modelId?: string; delegationLevel?: number }) =>
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
askChatStream: async (
actor: SettingsActor,
body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; delegationLevel?: number },
body: { message: string; chatId?: string; agentId?: string; skillId?: string; modelProvider?: string; modelId?: string; delegationLevel?: number },
onPhase: (phase: ChatStreamPhase) => void,
): Promise<void> => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
@@ -325,6 +338,15 @@ export const api = {
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
chatModels: (actor: SettingsActor, modelRole: string) => getWithHeaders<{ success: boolean; default: string; providers: ChatModelProvider[] }>(`chat/models?modelRole=${encodeURIComponent(modelRole)}`, actorHeaders(actor)),
chatConnections: (actor: SettingsActor) => getWithHeaders<{ success: boolean; connections: ChatModelConnection[] }>('chat/connections', actorHeaders(actor)),
connectChatProvider: (actor: SettingsActor, body: { provider: string; endpoint: string; apiKey: string; defaultModel?: string }) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/connect', body, actorHeaders(actor)),
refreshChatProvider: (actor: SettingsActor, provider: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/refresh', { provider }, actorHeaders(actor)),
setChatDefaultModel: (actor: SettingsActor, provider: string, model: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/default', { provider, model }, actorHeaders(actor)),
disconnectChatProvider: (actor: SettingsActor, provider: string) =>
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/disconnect', { provider }, actorHeaders(actor)),
};
// Health is raw (not enveloped) + carries HTTP status.
@@ -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>
);
}