6bd0faac40
Align Providers and Memory V2 pages with asset-gateway card layout; fix Memory V2 root layout and balanced grid on Asset Gateway. Co-authored-by: Cursor <cursoragent@cursor.com>
921 lines
32 KiB
TypeScript
921 lines
32 KiB
TypeScript
import type React from 'react';
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
clearLlmVisionKey,
|
||
createLlmProviderKey,
|
||
deleteLlmProviderKey,
|
||
getLlmGlobalSettings,
|
||
getLlmVisionSettings,
|
||
launchLlmExecutor,
|
||
listLlmExecutorBindings,
|
||
listLlmExecutorLaunchPlans,
|
||
listLlmExecutorLaunchStatus,
|
||
listLlmExecutorRuntime,
|
||
listLlmProviderCatalog,
|
||
listLlmProviderKeys,
|
||
restartLlmExecutor,
|
||
selectLlmProviderKey,
|
||
setLlmExecutorBinding,
|
||
setLlmVisionKey,
|
||
stopLlmExecutor,
|
||
syncLlmProviderToGoosed,
|
||
testLlmProviderKey,
|
||
updateLlmProviderKey,
|
||
} from '../../api/client';
|
||
import type {
|
||
LlmExecutorBinding,
|
||
LlmExecutorLaunchPlan,
|
||
LlmExecutorLaunchState,
|
||
LlmExecutorRuntime,
|
||
LlmGlobalSettings,
|
||
LlmProviderDefinition,
|
||
LlmProviderKeyRow,
|
||
LlmVisionSettings,
|
||
} from '../../types';
|
||
|
||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||
|
||
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
|
||
goose: { icon: 'G', accent: 'blue' },
|
||
aider: { icon: 'A', accent: 'amber' },
|
||
openhands: { icon: 'O', accent: 'violet' },
|
||
};
|
||
|
||
const PROVIDER_ACCENTS = ['blue', 'violet', 'green', 'amber'] as const;
|
||
|
||
const defaultProviderForm = {
|
||
providerId: CUSTOM_PROVIDER_ID,
|
||
name: '',
|
||
apiKey: '',
|
||
apiUrl: '',
|
||
modelsText: '',
|
||
defaultModel: '',
|
||
relayProvider: '',
|
||
};
|
||
|
||
type ProviderFormState = typeof defaultProviderForm;
|
||
type ProviderDialogState =
|
||
| { mode: 'create'; form: ProviderFormState }
|
||
| { mode: 'edit'; row: LlmProviderKeyRow; form: ProviderFormState };
|
||
|
||
function parseModelsText(text: string) {
|
||
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function statusText(status?: LlmExecutorLaunchState | null) {
|
||
if (!status) return '未启动';
|
||
if (status.running) return `运行中${status.pid ? ` · pid ${status.pid}` : ''}`;
|
||
return status.stoppedAt ? '已停止' : '未运行';
|
||
}
|
||
|
||
function ProviderSelect({
|
||
keys,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
keys: LlmProviderKeyRow[];
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||
<option value="">选择 Provider</option>
|
||
{keys.map((key) => (
|
||
<option key={key.id} value={key.id}>
|
||
{key.name} · {key.providerLabel}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
function providerFormFromRow(row: LlmProviderKeyRow): ProviderFormState {
|
||
return {
|
||
providerId: row.providerId,
|
||
name: row.name,
|
||
apiKey: '',
|
||
apiUrl: row.apiUrl ?? '',
|
||
modelsText: row.models.join('\n'),
|
||
defaultModel: row.defaultModel,
|
||
relayProvider: row.relayProvider ?? '',
|
||
};
|
||
}
|
||
|
||
function ProviderDialog({
|
||
state,
|
||
catalog,
|
||
busy,
|
||
onChange,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
state: ProviderDialogState;
|
||
catalog: LlmProviderDefinition[];
|
||
busy: boolean;
|
||
onChange: (form: ProviderFormState) => void;
|
||
onClose: () => void;
|
||
onSubmit: (event: React.FormEvent) => void;
|
||
}) {
|
||
const form = state.form;
|
||
const isCreate = state.mode === 'create';
|
||
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
|
||
const customModels = parseModelsText(form.modelsText);
|
||
const selectedCatalog = catalog.find((item) => item.id === form.providerId) ?? null;
|
||
const modelOptions = isCustom ? customModels : selectedCatalog?.models ?? [];
|
||
|
||
return (
|
||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||
<div className="modal-panel provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3 id="provider-dialog-title">{isCreate ? '添加 Provider' : '编辑 Provider'}</h3>
|
||
<p className="muted">{isCreate ? '新增一组可用于执行器绑定的 LLM 配置' : '调整名称、模型列表或连接参数'}</p>
|
||
</div>
|
||
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
|
||
<form className="admin-form provider-dialog-form" onSubmit={onSubmit}>
|
||
<label>
|
||
<span>Provider 类型</span>
|
||
<select
|
||
value={form.providerId}
|
||
disabled={!isCreate || busy}
|
||
onChange={(event) => {
|
||
const nextProvider = event.target.value;
|
||
const nextCatalog = catalog.find((item) => item.id === nextProvider);
|
||
onChange({
|
||
...form,
|
||
providerId: nextProvider,
|
||
defaultModel:
|
||
nextProvider === CUSTOM_PROVIDER_ID
|
||
? parseModelsText(form.modelsText)[0] ?? ''
|
||
: nextCatalog?.defaultModel ?? '',
|
||
});
|
||
}}
|
||
>
|
||
{catalog.map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{item.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<label>
|
||
<span>配置名称</span>
|
||
<input placeholder="例如:OpenRouter 主账号" value={form.name} onChange={(event) => onChange({ ...form, name: event.target.value })} />
|
||
</label>
|
||
|
||
{isCustom ? (
|
||
<>
|
||
<label>
|
||
<span>API 地址</span>
|
||
<input placeholder="https://api.example.com/v1/chat/completions" value={form.apiUrl} onChange={(event) => onChange({ ...form, apiUrl: event.target.value })} />
|
||
</label>
|
||
<label>
|
||
<span>Relay Provider</span>
|
||
<input placeholder="可选,例如 ollama" value={form.relayProvider} onChange={(event) => onChange({ ...form, relayProvider: event.target.value })} />
|
||
</label>
|
||
<label className="form-span-all">
|
||
<span>模型列表</span>
|
||
<textarea
|
||
placeholder="每行一个模型,例如 qwen2.5:3b"
|
||
rows={5}
|
||
value={form.modelsText}
|
||
onChange={(event) => {
|
||
const models = parseModelsText(event.target.value);
|
||
onChange({
|
||
...form,
|
||
modelsText: event.target.value,
|
||
defaultModel: models.includes(form.defaultModel) ? form.defaultModel : models[0] ?? '',
|
||
});
|
||
}}
|
||
/>
|
||
</label>
|
||
</>
|
||
) : null}
|
||
|
||
<label>
|
||
<span>{isCreate ? 'API Key / Bearer Token' : '替换 API Key'}</span>
|
||
<input
|
||
placeholder={isCreate ? '必填' : '留空则保持原 Key'}
|
||
type="password"
|
||
value={form.apiKey}
|
||
onChange={(event) => onChange({ ...form, apiKey: event.target.value })}
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
<span>默认模型</span>
|
||
<select value={form.defaultModel} onChange={(event) => onChange({ ...form, defaultModel: event.target.value })}>
|
||
<option value="">选择模型</option>
|
||
{modelOptions.map((model) => (
|
||
<option key={model} value={model}>
|
||
{model}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<div className="modal-actions">
|
||
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
|
||
取消
|
||
</button>
|
||
<button type="submit" className="send-btn" disabled={busy}>
|
||
{isCreate ? '添加' : '保存'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExecutorCard({
|
||
binding,
|
||
keys,
|
||
runtime,
|
||
plan,
|
||
launchStatus,
|
||
busy,
|
||
onSave,
|
||
onLaunch,
|
||
onStop,
|
||
onRestart,
|
||
}: {
|
||
binding: LlmExecutorBinding;
|
||
keys: LlmProviderKeyRow[];
|
||
runtime?: LlmExecutorRuntime;
|
||
plan?: LlmExecutorLaunchPlan;
|
||
launchStatus?: LlmExecutorLaunchState | null;
|
||
busy: boolean;
|
||
onSave: (binding: LlmExecutorBinding, keyId: string | null, model: string, enabled: boolean) => Promise<void>;
|
||
onLaunch: (binding: LlmExecutorBinding, instruction: string) => Promise<void>;
|
||
onStop: (binding: LlmExecutorBinding) => Promise<void>;
|
||
onRestart: (binding: LlmExecutorBinding) => Promise<void>;
|
||
}) {
|
||
const [enabled, setEnabled] = useState(binding.enabled);
|
||
const [keyId, setKeyId] = useState(binding.providerKeyId ?? '');
|
||
const [model, setModel] = useState(binding.model);
|
||
const [instruction, setInstruction] = useState(binding.executor === 'aider' ? '请根据当前仓库继续执行常规代码维护任务。' : '');
|
||
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
|
||
const modelOptions = selectedKey?.models?.length ? selectedKey.models : binding.availableModels;
|
||
const effectiveInstruction =
|
||
instruction.trim() || (binding.executor === 'aider' ? '请根据当前仓库继续执行常规代码维护任务。' : '');
|
||
|
||
useEffect(() => {
|
||
setEnabled(binding.enabled);
|
||
setKeyId(binding.providerKeyId ?? '');
|
||
setModel(binding.model);
|
||
}, [binding]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedKey) return;
|
||
if (!model || !selectedKey.models.includes(model)) {
|
||
setModel(selectedKey.defaultModel || selectedKey.models[0] || '');
|
||
}
|
||
}, [model, selectedKey]);
|
||
|
||
const visual = EXECUTOR_VISUALS[binding.executor] ?? { icon: '✦', accent: 'blue' };
|
||
|
||
return (
|
||
<section className={`asset-plugin-card asset-plugin-card--${visual.accent} model-center-card`}>
|
||
<div className="asset-plugin-card-head">
|
||
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
|
||
<div>
|
||
<h3>{binding.executorLabel}</h3>
|
||
<p>{binding.executorDescription}</p>
|
||
</div>
|
||
<label className="asset-mini-toggle" aria-label={`${binding.executorLabel} 启用状态`}>
|
||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||
<span>{enabled ? '启用' : '关闭'}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="asset-plugin-fields">
|
||
<label>
|
||
Provider
|
||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||
</label>
|
||
<label>
|
||
模型
|
||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||
<option value="">选择模型</option>
|
||
{modelOptions.map((item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="model-center-card-body">
|
||
<div className="executor-meta">
|
||
<span>{statusText(launchStatus)}</span>
|
||
{runtime?.ok ? <span>{runtime.providerName} · {runtime.model}</span> : <span>{runtime?.message ?? '运行配置未就绪'}</span>}
|
||
{plan?.ok ? <span className="mono">{plan.command} {plan.args?.join(' ')}</span> : <span>{plan?.message}</span>}
|
||
</div>
|
||
|
||
{binding.executor === 'goose' ? (
|
||
<p className="executor-note">Goose 由现有服务托管,只管理绑定与同步。</p>
|
||
) : (
|
||
<>
|
||
<input
|
||
className="executor-instruction"
|
||
value={instruction}
|
||
onChange={(event) => setInstruction(event.target.value)}
|
||
placeholder={binding.executor === 'openhands' ? 'OpenHands serve 模式可留空' : 'Aider 启动任务指令'}
|
||
/>
|
||
<div className="admin-actions executor-actions">
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={busy || !plan?.ok || !enabled}
|
||
onClick={() => void onLaunch(binding, effectiveInstruction)}
|
||
>
|
||
启动
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={busy || !launchStatus?.running}
|
||
onClick={() => void onStop(binding)}
|
||
>
|
||
停止
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={busy || !plan?.ok}
|
||
onClick={() => void onRestart(binding)}
|
||
>
|
||
重启
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
|
||
</div>
|
||
|
||
<div className="asset-plugin-footer">
|
||
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
|
||
{enabled ? '绑定启用中' : '绑定已关闭'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="send-btn"
|
||
disabled={busy || (enabled && (!keyId || !model))}
|
||
onClick={() => void onSave(binding, keyId || null, model, enabled)}
|
||
>
|
||
保存绑定
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function VisionSection({
|
||
keys,
|
||
settings,
|
||
busy,
|
||
onSave,
|
||
onClear,
|
||
}: {
|
||
keys: LlmProviderKeyRow[];
|
||
settings: LlmVisionSettings | null;
|
||
busy: boolean;
|
||
onSave: (keyId: string, model: string) => Promise<void>;
|
||
onClear: () => Promise<void>;
|
||
}) {
|
||
const [keyId, setKeyId] = useState(settings?.keyId ?? '');
|
||
const [model, setModel] = useState(settings?.visionModel ?? '');
|
||
|
||
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
|
||
const modelOptions = selectedKey?.models?.length ? selectedKey.models : [];
|
||
|
||
useEffect(() => {
|
||
setKeyId(settings?.keyId ?? '');
|
||
setModel(settings?.visionModel ?? '');
|
||
}, [settings]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedKey) return;
|
||
if (!model || !selectedKey.models.includes(model)) {
|
||
setModel(selectedKey.defaultModel || selectedKey.models[0] || '');
|
||
}
|
||
}, [model, selectedKey]);
|
||
|
||
return (
|
||
<div className="asset-plugin-grid model-center-vision-grid">
|
||
<section className="asset-plugin-card asset-plugin-card--green model-center-card">
|
||
<div className="asset-plugin-card-head">
|
||
<div className="asset-plugin-icon" aria-hidden="true">◉</div>
|
||
<div>
|
||
<h3>图片任务模型</h3>
|
||
<p>发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
|
||
</div>
|
||
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
|
||
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="asset-plugin-fields">
|
||
<label>
|
||
Provider
|
||
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
|
||
</label>
|
||
<label>
|
||
视觉模型
|
||
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
|
||
<option value="">选择视觉模型</option>
|
||
{modelOptions.map((item) => (
|
||
<option key={item} value={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{!settings?.keyId ? (
|
||
<p className="model-center-card-note">未配置时,图片消息将由默认模型处理。</p>
|
||
) : null}
|
||
|
||
<div className="asset-plugin-footer">
|
||
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
|
||
{settings?.keyId ? '已绑定视觉模型' : '等待配置'}
|
||
</span>
|
||
<div className="model-center-card-actions">
|
||
{settings?.keyId ? (
|
||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
|
||
清除
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="send-btn"
|
||
disabled={busy || !keyId || !model}
|
||
onClick={() => void onSave(keyId, model)}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProvidersPage() {
|
||
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
|
||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
|
||
const [visionSettings, setVisionSettings] = useState<LlmVisionSettings | null>(null);
|
||
const [bindings, setBindings] = useState<LlmExecutorBinding[]>([]);
|
||
const [runtimes, setRuntimes] = useState<LlmExecutorRuntime[]>([]);
|
||
const [plans, setPlans] = useState<LlmExecutorLaunchPlan[]>([]);
|
||
const [launchStatuses, setLaunchStatuses] = useState<Record<string, LlmExecutorLaunchState>>({});
|
||
const [providerDialog, setProviderDialog] = useState<ProviderDialogState | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const activeKeys = useMemo(() => keys.filter((item) => item.status === 'active'), [keys]);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [catalogRows, keyRows, global, vision, bindingRows, runtimeRows, planRows, statusRows] = await Promise.all([
|
||
listLlmProviderCatalog(),
|
||
listLlmProviderKeys(),
|
||
getLlmGlobalSettings(),
|
||
getLlmVisionSettings(),
|
||
listLlmExecutorBindings(),
|
||
listLlmExecutorRuntime(),
|
||
listLlmExecutorLaunchPlans({ mode: 'serve' }),
|
||
listLlmExecutorLaunchStatus(),
|
||
]);
|
||
setCatalog(catalogRows);
|
||
setKeys(keyRows);
|
||
setGlobalSettings(global);
|
||
setVisionSettings(vision);
|
||
setBindings(bindingRows);
|
||
setRuntimes(runtimeRows);
|
||
setPlans(planRows);
|
||
setLaunchStatuses(
|
||
Object.fromEntries(statusRows.filter(Boolean).map((item) => [item!.executor, item!])) as Record<string, LlmExecutorLaunchState>,
|
||
);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载统一模型中心失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const openCreateProvider = () => {
|
||
const customProvider = catalog.find((item) => item.id === CUSTOM_PROVIDER_ID);
|
||
const firstProvider = customProvider ?? catalog[0];
|
||
setProviderDialog({
|
||
mode: 'create',
|
||
form: {
|
||
...defaultProviderForm,
|
||
providerId: firstProvider?.id ?? CUSTOM_PROVIDER_ID,
|
||
defaultModel: firstProvider?.defaultModel ?? '',
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleSaveProvider = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
if (!providerDialog) return;
|
||
const form = providerDialog.form;
|
||
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
|
||
const customModels = parseModelsText(form.modelsText);
|
||
const selectedCatalog = catalog.find((item) => item.id === form.providerId) ?? null;
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
if (providerDialog.mode === 'create') {
|
||
await createLlmProviderKey(
|
||
isCustom
|
||
? {
|
||
providerId: CUSTOM_PROVIDER_ID,
|
||
name: form.name,
|
||
apiKey: form.apiKey,
|
||
apiUrl: form.apiUrl,
|
||
models: customModels,
|
||
defaultModel: form.defaultModel || customModels[0],
|
||
relayProvider: form.relayProvider || undefined,
|
||
}
|
||
: {
|
||
providerId: form.providerId,
|
||
name: form.name,
|
||
apiKey: form.apiKey,
|
||
defaultModel: form.defaultModel || selectedCatalog?.defaultModel,
|
||
},
|
||
);
|
||
} else {
|
||
await updateLlmProviderKey(
|
||
providerDialog.row.id,
|
||
isCustom
|
||
? {
|
||
name: form.name,
|
||
...(form.apiKey ? { apiKey: form.apiKey } : {}),
|
||
apiUrl: form.apiUrl,
|
||
models: customModels,
|
||
defaultModel: form.defaultModel || customModels[0],
|
||
relayProvider: form.relayProvider || undefined,
|
||
}
|
||
: {
|
||
name: form.name,
|
||
...(form.apiKey ? { apiKey: form.apiKey } : {}),
|
||
defaultModel: form.defaultModel || selectedCatalog?.defaultModel,
|
||
},
|
||
);
|
||
}
|
||
setMessage(providerDialog.mode === 'create' ? 'Provider 已添加' : 'Provider 已保存');
|
||
setProviderDialog(null);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存 Provider 失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleProviderStatus = async (row: LlmProviderKeyRow) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await updateLlmProviderKey(row.id, { status: row.status === 'active' ? 'disabled' : 'active' });
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '状态更新失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleSelectProvider = async (keyId: string) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await selectLlmProviderKey(keyId);
|
||
setMessage('默认 Provider 已切换');
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '切换失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteProvider = async (row: LlmProviderKeyRow) => {
|
||
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
|
||
setBusy(true);
|
||
try {
|
||
await deleteLlmProviderKey(row.id);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '删除失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleTestProvider = async (row: LlmProviderKeyRow) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const result = await testLlmProviderKey(row.id, row.defaultModel);
|
||
if (result.ok) setMessage(`「${row.name}」联通成功`);
|
||
else setError(`「${row.name}」联通失败:${result.message ?? '未知错误'}`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '联通测试失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveBinding = async (
|
||
binding: LlmExecutorBinding,
|
||
keyId: string | null,
|
||
model: string,
|
||
enabled: boolean,
|
||
) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await setLlmExecutorBinding(binding.executor, { keyId, model, enabled, purpose: binding.purpose });
|
||
setMessage(`${binding.executorLabel} 绑定已保存`);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存执行器绑定失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleLaunch = async (binding: LlmExecutorBinding, instruction: string) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const launch = await launchLlmExecutor(binding.executor, {
|
||
mode: binding.executor === 'openhands' ? 'serve' : 'headless',
|
||
instruction,
|
||
purpose: binding.purpose,
|
||
});
|
||
setMessage(`${binding.executorLabel} 已启动${launch.pid ? ` · pid ${launch.pid}` : ''}`);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '启动失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleStop = async (binding: LlmExecutorBinding) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await stopLlmExecutor(binding.executor, { purpose: binding.purpose });
|
||
setMessage(`${binding.executorLabel} 已停止`);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '停止失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleRestart = async (binding: LlmExecutorBinding) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const launch = await restartLlmExecutor(binding.executor, { purpose: binding.purpose });
|
||
setMessage(`${binding.executorLabel} 已重启${launch.pid ? ` · pid ${launch.pid}` : ''}`);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '重启失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleSync = async () => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const result = await syncLlmProviderToGoosed();
|
||
setMessage(result.synced ? '已同步 Goose 绑定' : '未同步:请检查 Goose 执行器绑定');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '同步失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleSetVisionKey = async (keyId: string, model: string) => {
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
const vision = await setLlmVisionKey(keyId, model);
|
||
setVisionSettings(vision);
|
||
setMessage('图片任务模型已保存');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '保存失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleClearVisionKey = async () => {
|
||
if (!window.confirm('确定清除图片任务模型?清除后所有图片消息将使用默认模型处理。')) return;
|
||
setBusy(true);
|
||
setMessage(null);
|
||
setError(null);
|
||
try {
|
||
await clearLlmVisionKey();
|
||
setVisionSettings((prev) => prev ? { ...prev, keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] } : prev);
|
||
setMessage('图片任务模型已清除');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '清除失败');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const activeProviderCount = keys.filter((item) => item.status === 'active').length;
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
<div className="admin-page-head">
|
||
<h2>统一模型中心</h2>
|
||
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
|
||
</div>
|
||
|
||
{message && <p className="banner banner-info">{message}</p>}
|
||
{error && <p className="banner banner-error">{error}</p>}
|
||
|
||
<section className="asset-gateway-hero">
|
||
<div>
|
||
<div className="asset-kicker">MODEL CENTER</div>
|
||
<h3>默认模型与 Provider 资源池</h3>
|
||
<p>
|
||
当前默认模型 <strong className="mono">{globalSettings?.globalModel ?? '未设置'}</strong>
|
||
{' · '}
|
||
Provider 资源池 {activeProviderCount}/{keys.length} 可用
|
||
</p>
|
||
</div>
|
||
<div className="model-center-hero-actions">
|
||
<button type="button" className="ghost-btn" disabled={busy || loading} onClick={() => void load()}>
|
||
刷新
|
||
</button>
|
||
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
|
||
添加 Provider
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="model-center-section-head">
|
||
<h3 className="model-center-section-title">执行器绑定</h3>
|
||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
|
||
同步 Goose 绑定
|
||
</button>
|
||
</div>
|
||
{loading ? (
|
||
<p className="muted">加载中...</p>
|
||
) : (
|
||
<div className="asset-plugin-grid">
|
||
{bindings.map((binding) => (
|
||
<ExecutorCard
|
||
key={binding.executor}
|
||
binding={binding}
|
||
keys={activeKeys}
|
||
runtime={runtimes.find((item) => item.executor === binding.executor)}
|
||
plan={plans.find((item) => item.executor === binding.executor)}
|
||
launchStatus={launchStatuses[binding.executor]}
|
||
busy={busy}
|
||
onSave={handleSaveBinding}
|
||
onLaunch={handleLaunch}
|
||
onStop={handleStop}
|
||
onRestart={handleRestart}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<h3 className="model-center-section-title">Provider 资源池</h3>
|
||
<div className="asset-plugin-grid">
|
||
{keys.map((row, index) => {
|
||
const accent = PROVIDER_ACCENTS[index % PROVIDER_ACCENTS.length];
|
||
return (
|
||
<section key={row.id} className={`asset-plugin-card asset-plugin-card--${accent} model-center-card model-center-provider-card`}>
|
||
<div className="asset-plugin-card-head">
|
||
<div className="asset-plugin-icon" aria-hidden="true">✦</div>
|
||
<div>
|
||
<h3>{row.name}</h3>
|
||
<p>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</p>
|
||
</div>
|
||
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
|
||
{row.status === 'active' ? '可用' : '禁用'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="model-center-provider-badges">
|
||
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
|
||
{row.isVisionSelected ? <span className="risk-pill risk-medium">图片任务</span> : null}
|
||
</div>
|
||
|
||
<div className="asset-plugin-fields model-center-provider-meta">
|
||
<div className="model-center-meta-row">
|
||
<span>默认模型</span>
|
||
<span className="mono">{row.defaultModel}</span>
|
||
</div>
|
||
<div className="model-center-meta-row">
|
||
<span>API Key</span>
|
||
<span className="mono">{row.apiKeyMasked}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="asset-plugin-footer">
|
||
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
|
||
{row.isSelected ? '当前默认 Provider' : '备用配置'}
|
||
</span>
|
||
<div className="model-center-card-actions">
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={busy}
|
||
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
|
||
>
|
||
编辑
|
||
</button>
|
||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
|
||
测试
|
||
</button>
|
||
{!row.isSelected && row.status === 'active' ? (
|
||
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
|
||
设为默认
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
disabled={busy || row.isSelected}
|
||
onClick={() => void handleToggleProviderStatus(row)}
|
||
>
|
||
{row.status === 'active' ? '禁用' : '恢复'}
|
||
</button>
|
||
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<h3 className="model-center-section-title">图片任务模型</h3>
|
||
<VisionSection
|
||
keys={activeKeys}
|
||
settings={visionSettings}
|
||
busy={busy}
|
||
onSave={handleSetVisionKey}
|
||
onClear={handleClearVisionKey}
|
||
/>
|
||
|
||
{providerDialog ? (
|
||
<ProviderDialog
|
||
state={providerDialog}
|
||
catalog={catalog}
|
||
busy={busy}
|
||
onChange={(form) => setProviderDialog((current) => (current ? { ...current, form } : current))}
|
||
onClose={() => {
|
||
if (!busy) setProviderDialog(null);
|
||
}}
|
||
onSubmit={handleSaveProvider}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|