feat: connect chat to managed model providers
This commit is contained in:
@@ -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