feat: add configurable AI model pool

This commit is contained in:
Codex
2026-08-01 23:11:44 +08:00
parent 020b9596dc
commit 4430149ee4
13 changed files with 1660 additions and 273 deletions
+475
View File
@@ -1263,6 +1263,435 @@ textarea:focus-visible,
color: var(--accent-strong);
}
.model-pool {
display: grid;
gap: 18px;
padding-top: 18px;
}
.model-pool h3,
.model-pool p {
margin: 0;
}
.model-pool-intro {
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-left: 3px solid var(--accent);
background: var(--accent-soft);
}
.model-pool-intro strong,
.model-group-heading h3,
.model-editor-heading h3,
.model-routing-heading h3 {
font-size: 11px;
font-weight: 680;
}
.model-pool-intro p,
.model-group-heading p,
.model-editor-heading p,
.model-routing-heading p,
.routing-row p {
margin-top: 4px;
color: var(--text-soft);
font-size: 9px;
line-height: 1.5;
}
.model-pool-summary {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.model-pool-summary span,
.model-capability-list span {
padding: 4px 7px;
border-radius: 7px;
color: var(--accent-strong);
background: color-mix(in srgb, var(--surface-raised) 74%, transparent);
font-size: 8px;
font-weight: 650;
white-space: nowrap;
}
.model-editor {
padding: 15px;
border: 1px solid var(--accent);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: 0 12px 32px color-mix(in srgb, var(--accent) 12%, transparent);
}
.model-editor-heading,
.model-group-heading,
.model-routing-heading {
display: flex;
align-items: flex-start;
gap: 10px;
}
.model-editor-heading {
justify-content: space-between;
margin-bottom: 14px;
}
.model-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px 14px;
}
.model-input {
min-width: 0;
display: grid;
gap: 6px;
}
.model-input-wide {
grid-column: 1 / -1;
}
.model-input > span,
.model-capabilities legend {
color: var(--text-soft);
font-size: 9px;
font-weight: 650;
}
.model-input input,
.model-input select,
.routing-row select {
width: 100%;
min-width: 0;
height: 38px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--text);
background: var(--surface);
font-size: 10px;
}
.model-input input::placeholder {
color: var(--text-faint);
opacity: 1;
}
.model-input input:focus-visible,
.model-input select:focus-visible,
.routing-row select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.model-capabilities {
margin: 13px 0 0;
padding: 10px 0 0;
display: flex;
flex-wrap: wrap;
gap: 9px 16px;
border: 0;
border-top: 1px solid var(--line);
}
.model-capabilities legend {
padding-right: 10px;
}
.model-capabilities label {
display: flex;
align-items: center;
gap: 5px;
color: var(--text-soft);
font-size: 9px;
}
.model-capabilities input,
.model-enabled-row input {
accent-color: var(--accent);
}
.model-enabled-row {
margin-top: 12px;
padding: 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.model-enabled-row span {
display: grid;
gap: 3px;
}
.model-enabled-row strong {
font-size: 10px;
}
.model-enabled-row small {
color: var(--text-faint);
font-size: 8px;
}
.model-enabled-row input {
width: 35px;
height: 19px;
}
.model-advanced-trigger {
margin-top: 10px;
padding: 0;
border: 0;
color: var(--accent-strong);
background: transparent;
font-size: 9px;
font-weight: 650;
}
.model-path-grid {
margin-top: 11px;
}
.model-editor-error {
margin-top: 11px !important;
display: flex;
align-items: center;
gap: 5px;
color: var(--warning);
font-size: 9px;
}
.model-editor-actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.model-group {
border-top: 1px solid var(--line);
}
.model-group-heading {
min-height: 64px;
align-items: center;
}
.model-group-heading > div:nth-child(2) {
flex: 1;
}
.model-group-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--accent-strong);
background: var(--accent-soft);
}
.model-empty {
width: 100%;
min-height: 74px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-control);
color: var(--text-soft);
background: var(--surface-muted);
text-align: left;
}
.model-empty:hover {
color: var(--text);
border-color: var(--accent);
}
.model-empty span {
display: grid;
gap: 3px;
}
.model-empty strong {
font-size: 10px;
}
.model-empty small {
color: var(--text-faint);
font-size: 8px;
}
.model-list {
display: grid;
gap: 7px;
}
.model-row {
padding: 10px 11px;
display: grid;
grid-template-columns: 30px minmax(170px, 1fr) minmax(120px, auto) auto;
align-items: center;
gap: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.model-row.disabled {
opacity: 0.66;
}
.model-state {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 50%;
}
.model-state.ok {
color: var(--accent-strong);
background: var(--accent-soft);
}
.model-state.failed {
color: var(--warning);
background: var(--warning-soft);
}
.model-state.untested {
color: var(--text-faint);
background: var(--surface-muted);
}
.model-identity {
min-width: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 7px;
align-items: center;
}
.model-identity > div {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.model-identity strong {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-identity small {
color: var(--warning);
font-size: 8px;
}
.model-identity code {
overflow: hidden;
grid-column: 1 / -1;
color: var(--text-soft);
font-family: "Cascadia Code", monospace;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-identity > span {
grid-column: 1 / -1;
color: var(--text-faint);
font-size: 8px;
}
.model-capability-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
}
.model-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.icon-button.danger {
color: var(--warning);
}
.model-test-message {
grid-column: 2 / -1;
color: var(--text-faint) !important;
font-size: 8px !important;
line-height: 1.45 !important;
}
.model-test-message.ok {
color: var(--accent-strong) !important;
}
.model-test-message.failed {
color: var(--warning) !important;
}
.model-routing {
border-top: 1px solid var(--line);
}
.model-routing-heading {
padding: 15px 0 12px;
}
.model-routing-heading > svg {
flex: 0 0 auto;
color: var(--accent-strong);
}
.routing-row {
min-height: 66px;
padding: 11px 0;
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(210px, 0.82fr);
align-items: center;
gap: 10px 18px;
border-top: 1px solid var(--line);
}
.routing-row strong {
font-size: 10px;
}
.routing-row-adaptive {
grid-template-columns: minmax(180px, 1fr) minmax(165px, 0.54fr) minmax(210px, 0.72fr);
}
.routing-auto-note {
margin: 0 !important;
padding: 9px 10px;
border-radius: 8px;
background: var(--surface-muted);
}
.spin {
animation: settings-spin 900ms linear infinite;
}
@@ -1449,6 +1878,52 @@ textarea:focus-visible,
margin-top: 6px;
}
.model-pool-intro {
align-items: flex-start;
flex-direction: column;
}
.model-pool-summary {
justify-content: flex-start;
}
.model-editor-grid,
.routing-row,
.routing-row-adaptive {
grid-template-columns: 1fr;
}
.model-input-wide {
grid-column: auto;
}
.model-group-heading {
align-items: flex-start;
padding: 12px 0;
}
.model-group-heading .button {
margin-left: auto;
}
.model-row {
grid-template-columns: 30px minmax(0, 1fr) auto;
}
.model-capability-list {
grid-column: 2 / -1;
justify-content: flex-start;
}
.model-row-actions {
grid-column: 3;
grid-row: 1;
}
.model-test-message {
grid-column: 2 / -1;
}
.settings-footer {
padding: 10px 12px;
}
+498 -60
View File
@@ -2,17 +2,26 @@
import {
ArrowClockwiseIcon,
BrainIcon,
CheckCircleIcon,
DatabaseIcon,
FloppyDiskIcon,
ImageIcon,
KeyIcon,
PencilSimpleIcon,
PlugIcon,
PlusIcon,
RobotIcon,
TrashIcon,
WarningCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";
import type {
ModelCapability,
ModelCategory,
ModelPoolItem,
SettingField,
SettingsReadiness,
SettingsResponse,
@@ -25,12 +34,24 @@ const testLabels: Record<string, string> = {
infrastructure: "测试数据库与队列",
storage: "测试 MinIO",
baidu_ocr: "测试百度 OCR",
ai_models: "验证 API 与模型",
gpu: "测试 GPU Worker",
langfuse: "测试 Langfuse",
sentry: "测试 Sentry",
};
const capabilityLabels: Record<ModelCapability, string> = {
orchestration: "总调度",
spatial_understanding: "空间理解",
image_generation: "图像生成",
image_editing: "局部编辑",
};
const providerLabels: Record<string, string> = {
lingke: "聚合引擎 AIGC",
openai: "OpenAI 官方",
custom: "其他兼容接口",
};
type SettingsCenterProps = {
open: boolean;
onClose: () => void;
@@ -49,6 +70,26 @@ function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "请求失败,请检查 API 服务是否启动。";
}
async function responseError(response: Response, fallback: string) {
try {
const payload = await response.json() as { detail?: string | { message?: string } };
if (typeof payload.detail === "string") return payload.detail;
if (payload.detail?.message) return payload.detail.message;
} catch {
// Use the readable fallback below when an upstream proxy returns non-JSON content.
}
return fallback;
}
function modelPoolFrom(draft: Record<string, unknown>): ModelPoolItem[] {
return Array.isArray(draft.model_pool) ? draft.model_pool as ModelPoolItem[] : [];
}
function modelCategoryReady(data: SettingsResponse) {
return ![...data.readiness.missing, ...data.readiness.untested]
.some((message) => message.startsWith("AI 模型:"));
}
export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCenterProps) {
const [data, setData] = useState<SettingsResponse | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>({});
@@ -71,13 +112,10 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
async function loadSettings() {
setBusy("load");
setNotice(null);
try {
const response = await fetch(`${API_BASE}/v1/settings`, { cache: "no-store" });
if (!response.ok) throw new Error("系统设置读取失败请确认后端 API 已启动。");
if (!response.ok) throw new Error("系统设置读取失败请确认后端 API 已启动。");
applyResponse(await response.json() as SettingsResponse);
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
} finally {
setBusy(null);
}
@@ -88,7 +126,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
let cancelled = false;
fetch(`${API_BASE}/v1/settings`, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error("系统设置读取失败请确认后端 API 已启动。");
if (!response.ok) throw new Error("系统设置读取失败请确认后端 API 已启动。");
return response.json() as Promise<SettingsResponse>;
})
.then((response) => {
@@ -117,7 +155,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: draft }),
});
if (!response.ok) throw new Error("保存失败,请检查填写内容。");
if (!response.ok) throw new Error(await responseError(response, "保存失败,请检查填写内容。"));
const payload = await response.json() as SettingsResponse;
applyResponse(payload);
if (showSuccess) setNotice({ tone: "success", text: "设置已加密保存。" });
@@ -152,6 +190,27 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
}
}
async function testModel(modelId: string) {
const target = `model:${modelId}`;
setBusy(target);
setNotice(null);
try {
await saveSettings(false);
setBusy(target);
const response = await fetch(`${API_BASE}/v1/settings/models/${encodeURIComponent(modelId)}/test`, {
method: "POST",
});
const result = await response.json() as TestResult;
setTestResults((current) => ({ ...current, [target]: result }));
await loadSettings();
setNotice({ tone: result.ok ? "success" : "error", text: result.message });
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
} finally {
setBusy(null);
}
}
async function generateFor(field: SettingField) {
if (!field.generator) return;
setBusy(field.key);
@@ -193,7 +252,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
<div>
<p className="settings-kicker">使</p>
<h1></h1>
<p>使</p>
<p> API Key </p>
</div>
<button className="icon-button" type="button" onClick={closeSafely} aria-label="关闭系统设置">
<XIcon size={18} />
@@ -218,9 +277,9 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
<div className="settings-layout">
<nav className="settings-nav" aria-label="设置分类">
{data?.categories.map((category) => {
const categoryTested = category.test_targets.length === 0 || category.test_targets.every(
(target) => data.tests[target]?.ok,
);
const categoryTested = category.id === "models"
? modelCategoryReady(data)
: category.test_targets.length === 0 || category.test_targets.every((target) => data.tests[target]?.ok);
return (
<button
key={category.id}
@@ -231,7 +290,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
<span>{category.label}</span>
{category.required_for_workflow ? (
<small className={categoryTested ? "done" : "required"}>
{categoryTested ? "已测试" : "必备"}
{categoryTested ? "已就绪" : "必备"}
</small>
) : <small></small>}
</button>
@@ -249,7 +308,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
<h2>{activeCategory.label}</h2>
<p>{activeCategory.description}</p>
</div>
{activeCategory.fields.some((field) => field.advanced) ? (
{activeCategory.id !== "models" && activeCategory.fields.some((field) => field.advanced) ? (
<label className="advanced-toggle">
<input
type="checkbox"
@@ -261,48 +320,60 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
) : null}
</div>
<div className="settings-fields">
{activeCategory.fields.map((field) => {
if (!isVisible(field, draft) || (field.advanced && !showAdvanced)) return null;
return (
<SettingControl
key={field.key}
field={field}
value={draft[field.key]}
configured={Boolean(data.configured[field.key])}
onChange={(value) => updateField(field.key, value)}
onGenerate={() => void generateFor(field)}
busy={busy === field.key}
/>
);
})}
</div>
{activeCategory.id === "models" ? (
<ModelPoolPanel
draft={draft}
tests={{ ...data.tests, ...testResults }}
busy={busy}
onChange={updateField}
onTest={(modelId) => void testModel(modelId)}
/>
) : (
<div className="settings-fields">
{activeCategory.fields.map((field) => {
if (!isVisible(field, draft) || (field.advanced && !showAdvanced)) return null;
return (
<SettingControl
key={field.key}
field={field}
value={draft[field.key]}
configured={Boolean(data.configured[field.key])}
onChange={(value) => updateField(field.key, value)}
onGenerate={() => void generateFor(field)}
busy={busy === field.key}
/>
);
})}
</div>
)}
{activeCategory.id === "deployment" ? (
<div className="deployment-note">
<KeyIcon size={18} />
<p> Redis <code>scripts/bootstrap.ps1</code> OpenSSL</p>
<p> Redis <code>scripts/bootstrap.ps1</code> </p>
</div>
) : null}
<div className="settings-test-actions">
{activeCategory.test_targets.map((target) => {
const recorded = testResults[target] ?? (data.tests[target] as TestResult | undefined);
return (
<button
className="button button-secondary"
type="button"
key={target}
onClick={() => void testTarget(target)}
disabled={Boolean(busy)}
>
{busy === target ? <ArrowClockwiseIcon className="spin" size={16} /> : <PlugIcon size={16} />}
{testLabels[target] ?? "测试连接"}
{recorded?.ok ? <CheckCircleIcon size={15} weight="fill" /> : null}
</button>
);
})}
</div>
{activeCategory.id !== "models" ? (
<div className="settings-test-actions">
{activeCategory.test_targets.map((target) => {
const recorded = testResults[target] ?? (data.tests[target] as TestResult | undefined);
return (
<button
className="button button-secondary"
type="button"
key={target}
onClick={() => void testTarget(target)}
disabled={Boolean(busy)}
>
{busy === target ? <ArrowClockwiseIcon className="spin" size={16} /> : <PlugIcon size={16} />}
{testLabels[target] ?? "测试连接"}
{recorded?.ok ? <CheckCircleIcon size={15} weight="fill" /> : null}
</button>
);
})}
</div>
) : null}
</>
) : null}
</div>
@@ -331,6 +402,382 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
);
}
type ModelPoolPanelProps = {
draft: Record<string, unknown>;
tests: Record<string, { ok: boolean; message: string }>;
busy: string | null;
onChange: (key: string, value: unknown) => void;
onTest: (modelId: string) => void;
};
function newModel(category: ModelCategory): ModelPoolItem {
return {
id: crypto.randomUUID(),
name: "",
model_id: "",
category,
provider: "lingke",
base_url: "",
api_key: "",
capabilities: category === "language" ? ["orchestration"] : ["spatial_understanding"],
models_path: "/models",
chat_path: "/chat/completions",
image_generation_path: "/images/generations",
image_edit_path: "/images/edits",
enabled: true,
api_key_configured: false,
};
}
function ModelPoolPanel({ draft, tests, busy, onChange, onTest }: ModelPoolPanelProps) {
const pool = modelPoolFrom(draft);
const [editor, setEditor] = useState<ModelPoolItem | null>(null);
const [editorError, setEditorError] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const languageModels = pool.filter((item) => item.category === "language");
const multimodalModels = pool.filter((item) => item.category === "multimodal");
const spatialModels = multimodalModels.filter((item) => item.capabilities.includes("spatial_understanding"));
const imageModels = multimodalModels.filter((item) => item.capabilities.includes("image_generation"));
function replacePool(nextPool: ModelPoolItem[]) {
onChange("model_pool", nextPool);
}
function saveEditor() {
if (!editor) return;
if (!editor.name.trim() || !editor.model_id.trim() || !editor.base_url.trim()) {
setEditorError("请填写显示名称、模型 ID 和 API Base URL。");
return;
}
if (!editor.api_key?.trim() && !editor.api_key_configured) {
setEditorError("请填写 API Key。");
return;
}
if (editor.category === "multimodal" && editor.capabilities.length === 0) {
setEditorError("请至少选择一种多模态能力。");
return;
}
const normalized = {
...editor,
name: editor.name.trim(),
model_id: editor.model_id.trim(),
base_url: editor.base_url.trim(),
capabilities: editor.category === "language" ? ["orchestration" as const] : editor.capabilities,
};
const exists = pool.some((item) => item.id === editor.id);
replacePool(exists
? pool.map((item) => item.id === editor.id ? normalized : item)
: [...pool, normalized]);
setEditor(null);
setEditorError("");
}
function removeModel(model: ModelPoolItem) {
if (!window.confirm(`确定从模型池删除“${model.name}”吗?保存前仍可刷新页面撤销。`)) return;
replacePool(pool.filter((item) => item.id !== model.id));
if (draft.orchestrator_model_id === model.id) onChange("orchestrator_model_id", "");
if (draft.spatial_model_id === model.id) onChange("spatial_model_id", "");
if (draft.image_model_id === model.id) onChange("image_model_id", "");
}
function updateCapability(capability: ModelCapability, checked: boolean) {
if (!editor) return;
const next = checked
? [...new Set([...editor.capabilities, capability])]
: editor.capabilities.filter((item) => item !== capability);
setEditor({ ...editor, capabilities: next });
}
return (
<div className="model-pool">
<div className="model-pool-intro">
<div>
<strong></strong>
<p></p>
</div>
<div className="model-pool-summary">
<span>{languageModels.length} </span>
<span>{multimodalModels.length} </span>
</div>
</div>
{editor ? (
<section className="model-editor" aria-label="模型配置编辑器">
<div className="model-editor-heading">
<div>
<h3>{pool.some((item) => item.id === editor.id) ? "编辑模型" : "添加模型"}</h3>
<p> ID </p>
</div>
<button className="icon-button small" type="button" onClick={() => setEditor(null)} aria-label="关闭模型编辑器">
<XIcon size={16} />
</button>
</div>
<div className="model-editor-grid">
<label className="model-input">
<span></span>
<select
value={editor.category}
onChange={(event) => {
const category = event.target.value as ModelCategory;
setEditor({
...editor,
category,
capabilities: category === "language" ? ["orchestration"] : ["spatial_understanding"],
});
}}
>
<option value="language"></option>
<option value="multimodal"></option>
</select>
</label>
<label className="model-input">
<span></span>
<input value={editor.name} placeholder="例如:主调度 GPT" onChange={(event) => setEditor({ ...editor, name: event.target.value })} />
</label>
<label className="model-input">
<span> ID</span>
<input value={editor.model_id} placeholder="例如:gpt-image-2" onChange={(event) => setEditor({ ...editor, model_id: event.target.value })} />
</label>
<label className="model-input">
<span>API </span>
<select value={editor.provider} onChange={(event) => setEditor({ ...editor, provider: event.target.value })}>
<option value="lingke"> AIGC</option>
<option value="openai">OpenAI </option>
<option value="custom"></option>
</select>
</label>
<label className="model-input model-input-wide">
<span>API Base URL</span>
<input value={editor.base_url} placeholder="https://example.com/v1" onChange={(event) => setEditor({ ...editor, base_url: event.target.value })} />
</label>
<label className="model-input model-input-wide">
<span>API Key</span>
<input
type="password"
value={editor.api_key ?? ""}
placeholder={editor.api_key_configured ? "已安全保存,留空不修改" : "请输入该模型使用的 API Key"}
autoComplete="off"
onChange={(event) => setEditor({ ...editor, api_key: event.target.value })}
/>
</label>
</div>
{editor.category === "multimodal" ? (
<fieldset className="model-capabilities">
<legend></legend>
{(["spatial_understanding", "image_generation", "image_editing"] as ModelCapability[]).map((capability) => (
<label key={capability}>
<input
type="checkbox"
checked={editor.capabilities.includes(capability)}
onChange={(event) => updateCapability(capability, event.target.checked)}
/>
{capabilityLabels[capability]}
</label>
))}
</fieldset>
) : null}
<label className="model-enabled-row">
<span><strong></strong><small>使</small></span>
<input type="checkbox" checked={editor.enabled} onChange={(event) => setEditor({ ...editor, enabled: event.target.checked })} />
</label>
<button className="model-advanced-trigger" type="button" onClick={() => setShowAdvanced((current) => !current)}>
{showAdvanced ? "收起接口路径" : "高级:接口路径"}
</button>
{showAdvanced ? (
<div className="model-editor-grid model-path-grid">
{([
["models_path", "模型列表路径"],
["chat_path", "对话路径"],
["image_generation_path", "生图路径"],
["image_edit_path", "图片编辑路径"],
] as const).map(([key, label]) => (
<label className="model-input" key={key}>
<span>{label}</span>
<input value={editor[key]} onChange={(event) => setEditor({ ...editor, [key]: event.target.value })} />
</label>
))}
</div>
) : null}
{editorError ? <p className="model-editor-error"><WarningCircleIcon size={15} /> {editorError}</p> : null}
<div className="model-editor-actions">
<button className="button button-secondary" type="button" onClick={() => setEditor(null)}></button>
<button className="button button-primary" type="button" onClick={saveEditor}></button>
</div>
</section>
) : null}
<ModelGroup
title="大语言模型"
description="负责多轮追问、任务拆解、状态维护和模型调度。"
category="language"
models={languageModels}
tests={tests}
busy={busy}
onAdd={() => { setEditor(newModel("language")); setShowAdvanced(false); }}
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
onRemove={removeModel}
onTest={onTest}
/>
<ModelGroup
title="多模态模型"
description="承担空间理解、风格评审、图像生成或局部编辑,可以同时具备多种能力。"
category="multimodal"
models={multimodalModels}
tests={tests}
busy={busy}
onAdd={() => { setEditor(newModel("multimodal")); setShowAdvanced(false); }}
onEdit={(model) => { setEditor({ ...model, api_key: "" }); setShowAdvanced(false); }}
onRemove={removeModel}
onTest={onTest}
/>
<section className="model-routing">
<div className="model-routing-heading">
<RobotIcon size={19} />
<div>
<h3></h3>
<p></p>
</div>
</div>
<div className="routing-row">
<div><strong></strong><p></p></div>
<select value={String(draft.orchestrator_model_id ?? "")} onChange={(event) => onChange("orchestrator_model_id", event.target.value)}>
<option value=""></option>
{languageModels.filter((item) => item.enabled).map((model) => (
<option key={model.id} value={model.id}>{model.name} · {model.model_id}</option>
))}
</select>
</div>
<RouteControl
label="空间理解模型"
description="分析户型、参考图、空间关系和审美一致性。"
mode={String(draft.spatial_routing_mode ?? "auto")}
selectedId={String(draft.spatial_model_id ?? "")}
models={spatialModels}
onModeChange={(value) => onChange("spatial_routing_mode", value)}
onModelChange={(value) => onChange("spatial_model_id", value)}
/>
<RouteControl
label="生图模型"
description="生成风格方向图、完整效果图和后续局部修改。"
mode={String(draft.image_routing_mode ?? "auto")}
selectedId={String(draft.image_model_id ?? "")}
models={imageModels}
onModeChange={(value) => onChange("image_routing_mode", value)}
onModelChange={(value) => onChange("image_model_id", value)}
/>
</section>
</div>
);
}
type ModelGroupProps = {
title: string;
description: string;
category: ModelCategory;
models: ModelPoolItem[];
tests: Record<string, { ok: boolean; message: string }>;
busy: string | null;
onAdd: () => void;
onEdit: (model: ModelPoolItem) => void;
onRemove: (model: ModelPoolItem) => void;
onTest: (modelId: string) => void;
};
function ModelGroup({ title, description, category, models, tests, busy, onAdd, onEdit, onRemove, onTest }: ModelGroupProps) {
return (
<section className="model-group">
<div className="model-group-heading">
<div className="model-group-icon">{category === "language" ? <BrainIcon size={18} /> : <ImageIcon size={18} />}</div>
<div><h3>{title}</h3><p>{description}</p></div>
<button className="button button-secondary compact" type="button" onClick={onAdd} disabled={Boolean(busy)}>
<PlusIcon size={15} />
</button>
</div>
{models.length === 0 ? (
<button className="model-empty" type="button" onClick={onAdd}>
<PlusIcon size={19} />
<span><strong>{title}</strong><small></small></span>
</button>
) : (
<div className="model-list">
{models.map((model) => {
const target = `model:${model.id}`;
const result = tests[target];
return (
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
{result?.ok ? <CheckCircleIcon size={18} weight="fill" /> : result ? <WarningCircleIcon size={18} /> : <PlugIcon size={17} />}
</div>
<div className="model-identity">
<div><strong>{model.name}</strong>{!model.enabled ? <small></small> : null}</div>
<code>{model.model_id}</code>
<span>{providerLabels[model.provider] ?? model.provider}</span>
</div>
<div className="model-capability-list">
{model.capabilities.map((capability) => <span key={capability}>{capabilityLabels[capability]}</span>)}
</div>
<div className="model-row-actions">
<button className="button button-secondary compact" type="button" onClick={() => onTest(model.id)} disabled={Boolean(busy) || !model.enabled}>
{busy === target ? <ArrowClockwiseIcon className="spin" size={14} /> : <PlugIcon size={14} />}
</button>
<button className="icon-button small" type="button" onClick={() => onEdit(model)} disabled={Boolean(busy)} aria-label={`编辑${model.name}`}>
<PencilSimpleIcon size={15} />
</button>
<button className="icon-button small danger" type="button" onClick={() => onRemove(model)} disabled={Boolean(busy)} aria-label={`删除${model.name}`}>
<TrashIcon size={15} />
</button>
</div>
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message"></p>}
</article>
);
})}
</div>
)}
</section>
);
}
type RouteControlProps = {
label: string;
description: string;
mode: string;
selectedId: string;
models: ModelPoolItem[];
onModeChange: (value: string) => void;
onModelChange: (value: string) => void;
};
function RouteControl({ label, description, mode, selectedId, models, onModeChange, onModelChange }: RouteControlProps) {
return (
<div className="routing-row routing-row-adaptive">
<div><strong>{label}</strong><p>{description}</p></div>
<select value={mode} onChange={(event) => onModeChange(event.target.value)} aria-label={`${label}路由方式`}>
<option value="auto"></option>
<option value="manual"></option>
</select>
{mode === "manual" ? (
<select value={selectedId} onChange={(event) => onModelChange(event.target.value)} aria-label={`选择${label}`}>
<option value=""></option>
{models.filter((item) => item.enabled).map((model) => (
<option key={model.id} value={model.id}>{model.name} · {model.model_id}</option>
))}
</select>
) : <p className="routing-auto-note"></p>}
</div>
);
}
type SettingControlProps = {
field: SettingField;
value: unknown;
@@ -349,10 +796,7 @@ function SettingControl({ field, value, configured, onChange, onGenerate, busy }
<span className={configured ? "configured" : "missing"}>
{configured ? <CheckCircleIcon size={18} weight="fill" /> : <WarningCircleIcon size={18} />}
</span>
<div>
<strong>{field.label}</strong>
<p>{field.description}</p>
</div>
<div><strong>{field.label}</strong><p>{field.description}</p></div>
<small>{configured ? "已配置" : "缺失"}</small>
</div>
);
@@ -361,10 +805,7 @@ function SettingControl({ field, value, configured, onChange, onGenerate, busy }
if (field.kind === "toggle") {
return (
<label className="setting-toggle-row">
<div>
<strong>{field.label}</strong>
<p>{field.description}</p>
</div>
<div><strong>{field.label}</strong><p>{field.description}</p></div>
<input type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
</label>
);
@@ -372,10 +813,7 @@ function SettingControl({ field, value, configured, onChange, onGenerate, busy }
return (
<label className="setting-field">
<span className="setting-field-label">
<strong>{field.label}</strong>
{field.required ? <small></small> : null}
</span>
<span className="setting-field-label"><strong>{field.label}</strong>{field.required ? <small></small> : null}</span>
<span className="setting-description">{field.description}</span>
<span className="setting-control-wrap">
{field.kind === "select" ? (
+20
View File
@@ -50,3 +50,23 @@ export type TestResult = {
message: string;
details: Record<string, unknown>;
};
export type ModelCategory = "language" | "multimodal";
export type ModelCapability = "orchestration" | "spatial_understanding" | "image_generation" | "image_editing";
export type ModelPoolItem = {
id: string;
name: string;
model_id: string;
category: ModelCategory;
provider: string;
base_url: string;
capabilities: ModelCapability[];
models_path: string;
chat_path: string;
image_generation_path: string;
image_edit_path: string;
enabled: boolean;
api_key?: string;
api_key_configured?: boolean;
};