feat: connect chat to managed model providers
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user