Files
memind_adm/src/admin/pages/ProvidersPage.tsx
T
john 6bd0faac40 refactor(admin): unify model center and Memory V2 as asset plugin cards
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>
2026-07-12 12:17:47 +08:00

921 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}