diff --git a/README.md b/README.md index 4415671..6c0d198 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,6 @@ docs 架构与工作流设计 - 提供结构化 Style DNA 面板。 - 提供可验证的工作流状态机和命令接口。 - 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。 -- 提供分类设置中心、连接测试、必填检查和工作流就绪门禁。 +- 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。 详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 514fb1b..655fdb8 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; } diff --git a/apps/web/components/settings-center.tsx b/apps/web/components/settings-center.tsx index ce69596..a338958 100644 --- a/apps/web/components/settings-center.tsx +++ b/apps/web/components/settings-center.tsx @@ -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 = { infrastructure: "测试数据库与队列", storage: "测试 MinIO", baidu_ocr: "测试百度 OCR", - ai_models: "验证 API 与模型", gpu: "测试 GPU Worker", langfuse: "测试 Langfuse", sentry: "测试 Sentry", }; +const capabilityLabels: Record = { + orchestration: "总调度", + spatial_understanding: "空间理解", + image_generation: "图像生成", + image_editing: "局部编辑", +}; + +const providerLabels: Record = { + 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): 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(null); const [draft, setDraft] = useState>({}); @@ -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; }) .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

首次使用向导

系统设置

-

只填写你真正使用的服务;密钥保存后不会再次显示。

+

按服务分类配置并逐项测试。所有 API Key 都由后端加密保存。

@@ -249,7 +308,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen

{activeCategory.label}

{activeCategory.description}

- {activeCategory.fields.some((field) => field.advanced) ? ( + {activeCategory.id !== "models" && activeCategory.fields.some((field) => field.advanced) ? (