feat: add configurable AI model pool
This commit is contained in:
@@ -40,6 +40,6 @@ docs 架构与工作流设计
|
||||
- 提供结构化 Style DNA 面板。
|
||||
- 提供可验证的工作流状态机和命令接口。
|
||||
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
||||
- 提供分类设置中心、连接测试、必填检查和工作流就绪门禁。
|
||||
- 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。
|
||||
|
||||
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+17
-10
@@ -10,7 +10,7 @@
|
||||
|
||||
- NAS MinIO 与三个 bucket。
|
||||
- 百度 OCR。
|
||||
- 总调度、空间理解和生图模型。
|
||||
- 可扩展模型池,以及总调度、空间理解和生图路由。
|
||||
- 本地或局域网 GPU Worker。
|
||||
- Langfuse 与 Sentry。
|
||||
|
||||
@@ -20,24 +20,31 @@
|
||||
|
||||
1. 基础运行:PostgreSQL、Redis、配置加密。
|
||||
2. 文件存储:MinIO 地址、服务账号和三个 bucket。
|
||||
3. AI 模型:统一 API、总调度模型、空间理解模型和生图模型。
|
||||
3. AI 模型:至少一个大语言模型、空间理解候选和生图候选,并完成路由选择。
|
||||
|
||||
必备分类既要填写完整,也要通过对应连接测试。未完成时,上传和工作流命令接口返回 `SETTINGS_INCOMPLETE`,前端“开始设计”按钮保持锁定。
|
||||
|
||||
百度 OCR、GPU、Langfuse 和 Sentry 是条件必填:关闭时不影响工作流;开启后应填写完整并测试。
|
||||
|
||||
## 聚合引擎 AIGC
|
||||
## 模型池与路由
|
||||
|
||||
聚合引擎按 OpenAI 兼容网关接入,设置项包括:
|
||||
模型池分为大语言模型和多模态模型。每个模型条目独立保存以下内容:
|
||||
|
||||
- API Base URL。
|
||||
- API Key。
|
||||
- 总调度模型 ID。
|
||||
- 空间理解模型 ID。
|
||||
- 默认生图模型 ID,预设为 `gpt-image-2`。
|
||||
- 显示名称与平台模型 ID。
|
||||
- API 来源、Base URL 和 API Key。
|
||||
- 空间理解、图像生成、局部编辑等能力标签。
|
||||
- 高级路径:`/models`、`/chat/completions`、`/images/generations`、`/images/edits`。
|
||||
|
||||
平台的公开文档入口在未登录状态下会跳转首页,因此 Base URL 不在代码中猜测或写死。用户从登录后的开发者文档复制一次即可,随后“验证 API 与模型”会调用模型列表接口,检查 Key 和三个模型名。
|
||||
同一个聚合账号可以添加多个模型,也可以把不同平台的模型放进同一个池。每个模型都有单独的测试按钮。测试会访问该条目的模型列表接口,并明确显示是哪个模型 ID、凭证或接口路径出错,不再返回“部分模型不可用”这类无法定位的信息。
|
||||
|
||||
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
||||
|
||||
- 总调度自动选择:只在已启用且测试通过的能力候选中路由。
|
||||
- 用户手动指定:固定使用选中的多模态模型。
|
||||
|
||||
旧版统一 API 配置在第一次读取时会自动迁移成三个模型池条目。出于安全考虑,迁移后的模型必须逐个重新测试。
|
||||
|
||||
聚合平台的 Base URL 不在代码中猜测或写死,仍需从登录后的开发者文档复制。
|
||||
|
||||
## 为什么本地 GPU 仍然需要 Worker
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ def health(
|
||||
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||
) -> dict:
|
||||
values = runtime_store.merged_values()
|
||||
router_state = ModelRouter(values)
|
||||
runtime_response = runtime_store.public_response()
|
||||
router_state = ModelRouter(values, runtime_response.tests)
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.runtime_settings import (
|
||||
SettingsReadiness,
|
||||
generate_secret,
|
||||
get_runtime_store,
|
||||
test_model_pool_item,
|
||||
test_runtime_settings,
|
||||
)
|
||||
|
||||
@@ -24,7 +25,7 @@ def read_settings(
|
||||
) -> RuntimeSettingsResponse:
|
||||
try:
|
||||
return store.public_response()
|
||||
except RuntimeError as exc:
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -52,7 +53,7 @@ async def test_settings(
|
||||
if not request.values:
|
||||
store.record_test(result, values)
|
||||
return result
|
||||
except (KeyError, RuntimeError) as exc:
|
||||
except (KeyError, RuntimeError, ValueError) as exc:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=request.target,
|
||||
ok=False,
|
||||
@@ -60,6 +61,24 @@ async def test_settings(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings/models/{model_id}/test", response_model=RuntimeSettingsTestResult)
|
||||
async def test_model(
|
||||
model_id: str,
|
||||
store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||
) -> RuntimeSettingsTestResult:
|
||||
try:
|
||||
values = store.merged_values()
|
||||
result = await test_model_pool_item(values, model_id)
|
||||
store.record_test(result, values)
|
||||
return result
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=f"model:{model_id}",
|
||||
ok=False,
|
||||
message=f"模型测试失败:{exc}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings/generate-secret", response_model=SecretGenerateResponse)
|
||||
def create_secret(request: SecretGenerateRequest) -> SecretGenerateResponse:
|
||||
return SecretGenerateResponse(value=generate_secret(request.kind))
|
||||
|
||||
@@ -1,43 +1,122 @@
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _configured(value: str) -> bool:
|
||||
return bool(value) and not value.startswith("replace_with_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderCapability:
|
||||
provider: str
|
||||
configured: bool
|
||||
strengths: tuple[str, ...]
|
||||
model_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | Mapping[str, Any],
|
||||
tests: Mapping[str, Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.tests = tests
|
||||
|
||||
def _value(self, key: str, default: str = "") -> str:
|
||||
def _value(self, key: str, default: Any = "") -> Any:
|
||||
if isinstance(self.settings, Mapping):
|
||||
return str(self.settings.get(key, default) or "")
|
||||
return str(getattr(self.settings, key, default) or "")
|
||||
return self.settings.get(key, default)
|
||||
return getattr(self.settings, key, default)
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
@property
|
||||
def pool(self) -> list[dict[str, Any]]:
|
||||
raw_pool = self._value("model_pool", [])
|
||||
return [item for item in raw_pool if isinstance(item, dict) and item.get("enabled", True)]
|
||||
|
||||
def _tested(self, item: Mapping[str, Any]) -> bool:
|
||||
if self.tests is None:
|
||||
return True
|
||||
return bool(self.tests.get(f"model:{item.get('id')}", {}).get("ok"))
|
||||
|
||||
def candidates(self, capability: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
ProviderCapability(
|
||||
provider=self._value("ai_provider", "custom"),
|
||||
configured=_configured(self._value("ai_api_key"))
|
||||
and bool(self._value("ai_base_url"))
|
||||
and bool(self._value("image_model")),
|
||||
strengths=("统一模型网关", "空间理解", "图像生成与编辑"),
|
||||
),
|
||||
item
|
||||
for item in self.pool
|
||||
if capability in item.get("capabilities", []) and self._tested(item)
|
||||
]
|
||||
|
||||
def orchestrator(self) -> dict[str, Any]:
|
||||
selected_id = str(self._value("orchestrator_model_id", ""))
|
||||
selected = next(
|
||||
(
|
||||
item
|
||||
for item in self.pool
|
||||
if item.get("id") == selected_id
|
||||
and item.get("category") == "language"
|
||||
and self._tested(item)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError("总调度模型未配置或尚未通过测试。")
|
||||
|
||||
def choose(self, route: str, orchestrator_choice_id: str | None = None) -> dict[str, Any]:
|
||||
if route == "spatial":
|
||||
mode_key, selected_key, capability = (
|
||||
"spatial_routing_mode",
|
||||
"spatial_model_id",
|
||||
"spatial_understanding",
|
||||
)
|
||||
elif route == "image":
|
||||
mode_key, selected_key, capability = (
|
||||
"image_routing_mode",
|
||||
"image_model_id",
|
||||
"image_generation",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"未知模型路由:{route}")
|
||||
|
||||
candidates = self.candidates(capability)
|
||||
if self._value(mode_key, "auto") == "manual":
|
||||
selected_id = str(self._value(selected_key, ""))
|
||||
selected = next((item for item in candidates if item.get("id") == selected_id), None)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError(f"手动选择的{route}模型不可用或尚未通过测试。")
|
||||
if orchestrator_choice_id:
|
||||
selected = next((item for item in candidates if item.get("id") == orchestrator_choice_id), None)
|
||||
if selected:
|
||||
return selected
|
||||
raise RuntimeError("总调度返回的模型不在已测试候选列表中。")
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
raise RuntimeError("自动路由存在多个候选,需要总调度返回模型实例 ID。")
|
||||
raise RuntimeError(f"没有通过测试且支持{capability}的模型。")
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
result: list[ProviderCapability] = []
|
||||
for item in self.pool:
|
||||
strengths: list[str] = []
|
||||
if "orchestration" in item.get("capabilities", []):
|
||||
strengths.append("总调度")
|
||||
if "spatial_understanding" in item.get("capabilities", []):
|
||||
strengths.append("空间理解")
|
||||
if "image_generation" in item.get("capabilities", []):
|
||||
strengths.append("图像生成")
|
||||
if "image_editing" in item.get("capabilities", []):
|
||||
strengths.append("图像编辑")
|
||||
result.append(
|
||||
ProviderCapability(
|
||||
provider=str(item.get("provider", "custom")),
|
||||
configured=self._tested(item),
|
||||
strengths=tuple(strengths),
|
||||
model_id=str(item.get("model_id", "")),
|
||||
name=str(item.get("name", "")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def choose_image_provider(self, task: str) -> str:
|
||||
configured = [item.provider for item in self.capabilities() if item.configured]
|
||||
if configured:
|
||||
return configured[0]
|
||||
raise RuntimeError("No image provider is configured.")
|
||||
return str(self.choose("image").get("provider", "custom"))
|
||||
|
||||
@@ -4,48 +4,70 @@ from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from app.integrations.model_router import ModelRouter
|
||||
|
||||
|
||||
class OpenAICompatibleGateway:
|
||||
"""One adapter for OpenAI, lk666.ai and other OpenAI-compatible gateways."""
|
||||
"""Adapter for independently configured OpenAI-compatible model-pool items."""
|
||||
|
||||
def __init__(self, values: Mapping[str, Any]) -> None:
|
||||
self.values = values
|
||||
self.router = ModelRouter(values)
|
||||
|
||||
def _url(self, path_key: str, fallback: str) -> str:
|
||||
base_url = str(self.values.get("ai_base_url", "")).rstrip("/") + "/"
|
||||
path = str(self.values.get(path_key, fallback)).lstrip("/")
|
||||
@staticmethod
|
||||
def _url(item: Mapping[str, Any], path_key: str, fallback: str) -> str:
|
||||
base_url = str(item.get("base_url", "")).rstrip("/") + "/"
|
||||
path = str(item.get(path_key, fallback)).lstrip("/")
|
||||
return urljoin(base_url, path)
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.values.get('ai_api_key', '')}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
@staticmethod
|
||||
def _headers(item: Mapping[str, Any], *, json_content: bool = True) -> dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {item.get('api_key', '')}"}
|
||||
if json_content:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
async def list_models(self, model: Mapping[str, Any] | None = None) -> list[str]:
|
||||
item = model or self.router.orchestrator()
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get(self._url("ai_models_path", "/models"), headers=self.headers)
|
||||
response = await client.get(
|
||||
self._url(item, "models_path", "/models"),
|
||||
headers=self._headers(item),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return [item["id"] for item in payload.get("data", []) if isinstance(item, dict) and item.get("id")]
|
||||
return [candidate["id"] for candidate in payload.get("data", []) if isinstance(candidate, dict) and candidate.get("id")]
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], *, vision: bool = False) -> dict[str, Any]:
|
||||
model_key = "vision_model" if vision else "orchestrator_model"
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
vision: bool = False,
|
||||
model_instance_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item = self.router.choose("spatial", model_instance_id) if vision else self.router.orchestrator()
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_chat_path", "/chat/completions"),
|
||||
headers=self.headers,
|
||||
json={"model": self.values[model_key], "messages": messages},
|
||||
self._url(item, "chat_path", "/chat/completions"),
|
||||
headers=self._headers(item),
|
||||
json={"model": item["model_id"], "messages": messages},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
async def generate_image(self, prompt: str, **options: Any) -> dict[str, Any]:
|
||||
payload = {"model": self.values["image_model"], "prompt": prompt, **options}
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model_instance_id: str | None = None,
|
||||
**options: Any,
|
||||
) -> dict[str, Any]:
|
||||
item = self.router.choose("image", model_instance_id)
|
||||
payload = {"model": item["model_id"], "prompt": prompt, **options}
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_image_generation_path", "/images/generations"),
|
||||
headers=self.headers,
|
||||
self._url(item, "image_generation_path", "/images/generations"),
|
||||
headers=self._headers(item),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -58,19 +80,18 @@ class OpenAICompatibleGateway:
|
||||
*,
|
||||
filename: str = "image.png",
|
||||
mask: bytes | None = None,
|
||||
model_instance_id: str | None = None,
|
||||
**options: Any,
|
||||
) -> dict[str, Any]:
|
||||
files: dict[str, tuple[str, bytes, str]] = {
|
||||
"image": (filename, image, "image/png"),
|
||||
}
|
||||
item = self.router.choose("image", model_instance_id)
|
||||
files: dict[str, tuple[str, bytes, str]] = {"image": (filename, image, "image/png")}
|
||||
if mask is not None:
|
||||
files["mask"] = ("mask.png", mask, "image/png")
|
||||
data = {"model": self.values["image_model"], "prompt": prompt, **options}
|
||||
headers = {"Authorization": self.headers["Authorization"]}
|
||||
data = {"model": item["model_id"], "prompt": prompt, **options}
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
response = await client.post(
|
||||
self._url("ai_image_edit_path", "/images/edits"),
|
||||
headers=headers,
|
||||
self._url(item, "image_edit_path", "/images/edits"),
|
||||
headers=self._headers(item, json_content=False),
|
||||
data=data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@@ -45,7 +45,6 @@ class RuntimeSettingsTestRequest(BaseModel):
|
||||
"infrastructure",
|
||||
"storage",
|
||||
"baidu_ocr",
|
||||
"ai_models",
|
||||
"gpu",
|
||||
"langfuse",
|
||||
"sentry",
|
||||
@@ -60,6 +59,26 @@ class RuntimeSettingsTestResult(BaseModel):
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
ModelCategory = Literal["language", "multimodal"]
|
||||
ModelCapability = Literal["orchestration", "spatial_understanding", "image_generation", "image_editing"]
|
||||
|
||||
|
||||
class ModelPoolItem(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
model_id: str = Field(min_length=1, max_length=200)
|
||||
category: ModelCategory
|
||||
provider: str = Field(default="custom", min_length=1, max_length=80)
|
||||
base_url: str = Field(min_length=1, max_length=500)
|
||||
api_key: str = ""
|
||||
capabilities: list[ModelCapability] = Field(default_factory=list)
|
||||
models_path: str = "/models"
|
||||
chat_path: str = "/chat/completions"
|
||||
image_generation_path: str = "/images/generations"
|
||||
image_edit_path: str = "/images/edits"
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class SecretGenerateRequest(BaseModel):
|
||||
kind: Literal["hex24", "hex32", "base64_32"]
|
||||
|
||||
@@ -79,31 +98,28 @@ REQUIRED_GROUPS: dict[str, list[str]] = {
|
||||
"s3_bucket_derived",
|
||||
"s3_bucket_renders",
|
||||
],
|
||||
"AI 模型": [
|
||||
"ai_provider",
|
||||
"ai_base_url",
|
||||
"ai_api_key",
|
||||
"orchestrator_model",
|
||||
"vision_model",
|
||||
"image_model",
|
||||
],
|
||||
}
|
||||
|
||||
REQUIRED_TESTS = {
|
||||
"基础运行": "infrastructure",
|
||||
"文件存储": "storage",
|
||||
"AI 模型": "ai_models",
|
||||
}
|
||||
|
||||
MODEL_SETTINGS_DEFAULTS: dict[str, Any] = {
|
||||
"model_pool": [],
|
||||
"orchestrator_model_id": "",
|
||||
"spatial_routing_mode": "auto",
|
||||
"spatial_model_id": "",
|
||||
"image_routing_mode": "auto",
|
||||
"image_model_id": "",
|
||||
}
|
||||
MODEL_SETTINGS_KEYS = set(MODEL_SETTINGS_DEFAULTS)
|
||||
LEGACY_SECRET_FIELDS = {"ai_api_key"}
|
||||
|
||||
TEST_FIELDS: dict[str, set[str]] = {
|
||||
"infrastructure": set(),
|
||||
"storage": {key for key in FIELDS if key.startswith("s3_")},
|
||||
"baidu_ocr": {key for key in FIELDS if key.startswith("baidu_ocr_")},
|
||||
"ai_models": {key for key in FIELDS if key.startswith("ai_")} | {
|
||||
"orchestrator_model",
|
||||
"vision_model",
|
||||
"image_model",
|
||||
},
|
||||
"gpu": {key for key in FIELDS if key.startswith("gpu_")},
|
||||
"langfuse": {key for key in FIELDS if key.startswith("langfuse_")},
|
||||
"sentry": {key for key in FIELDS if key.startswith("sentry_")},
|
||||
@@ -154,11 +170,68 @@ class EncryptedSettingsStore:
|
||||
return Fernet(key)
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {
|
||||
field_defaults = {
|
||||
key: field.default
|
||||
for key, field in FIELDS.items()
|
||||
if key in EDITABLE_FIELDS and field.default is not None
|
||||
}
|
||||
return {**field_defaults, **MODEL_SETTINGS_DEFAULTS}
|
||||
|
||||
def _migrate_legacy_models(self, document: dict[str, Any]) -> bool:
|
||||
values = document["values"]
|
||||
if values.get("model_pool") or not values.get("ai_base_url"):
|
||||
return False
|
||||
common = {
|
||||
"provider": values.get("ai_provider", "custom"),
|
||||
"base_url": values.get("ai_base_url", ""),
|
||||
"api_key": values.get("ai_api_key", ""),
|
||||
"models_path": values.get("ai_models_path", "/models"),
|
||||
"chat_path": values.get("ai_chat_path", "/chat/completions"),
|
||||
"image_generation_path": values.get("ai_image_generation_path", "/images/generations"),
|
||||
"image_edit_path": values.get("ai_image_edit_path", "/images/edits"),
|
||||
"enabled": True,
|
||||
}
|
||||
pool: list[dict[str, Any]] = []
|
||||
legacy_roles = [
|
||||
("legacy-orchestrator", "总调度模型", values.get("orchestrator_model"), "language", ["orchestration"]),
|
||||
("legacy-spatial", "空间理解模型", values.get("vision_model"), "multimodal", ["spatial_understanding"]),
|
||||
("legacy-image", "生图模型", values.get("image_model"), "multimodal", ["image_generation", "image_editing"]),
|
||||
]
|
||||
for item_id, name, model_id, category, capabilities in legacy_roles:
|
||||
if not model_id:
|
||||
continue
|
||||
pool.append({
|
||||
"id": item_id,
|
||||
"name": name,
|
||||
"model_id": model_id,
|
||||
"category": category,
|
||||
"capabilities": capabilities,
|
||||
**common,
|
||||
})
|
||||
if not pool:
|
||||
return False
|
||||
values.update(MODEL_SETTINGS_DEFAULTS)
|
||||
values["model_pool"] = pool
|
||||
values["orchestrator_model_id"] = "legacy-orchestrator" if values.get("orchestrator_model") else ""
|
||||
values["spatial_routing_mode"] = "manual"
|
||||
values["spatial_model_id"] = "legacy-spatial" if values.get("vision_model") else ""
|
||||
values["image_routing_mode"] = "manual"
|
||||
values["image_model_id"] = "legacy-image" if values.get("image_model") else ""
|
||||
for legacy_key in (
|
||||
"ai_provider",
|
||||
"ai_base_url",
|
||||
"ai_api_key",
|
||||
"orchestrator_model",
|
||||
"vision_model",
|
||||
"image_model",
|
||||
"ai_models_path",
|
||||
"ai_chat_path",
|
||||
"ai_image_generation_path",
|
||||
"ai_image_edit_path",
|
||||
):
|
||||
values.pop(legacy_key, None)
|
||||
document["tests"].pop("ai_models", None)
|
||||
return True
|
||||
|
||||
def load_document(self) -> dict[str, Any]:
|
||||
if not self.data_path.exists():
|
||||
@@ -170,6 +243,8 @@ class EncryptedSettingsStore:
|
||||
raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc
|
||||
document.setdefault("values", {})
|
||||
document.setdefault("tests", {})
|
||||
if self._migrate_legacy_models(document):
|
||||
self.save_document(document)
|
||||
return document
|
||||
|
||||
def save_document(self, document: dict[str, Any]) -> None:
|
||||
@@ -184,6 +259,12 @@ class EncryptedSettingsStore:
|
||||
document = self.load_document()
|
||||
values = {**self.defaults(), **document["values"]}
|
||||
for key, value in (pending or {}).items():
|
||||
if key == "model_pool":
|
||||
values[key] = self._coerce_model_pool(value, values.get("model_pool", []))
|
||||
continue
|
||||
if key in MODEL_SETTINGS_KEYS:
|
||||
values[key] = value.strip() if isinstance(value, str) else value
|
||||
continue
|
||||
if key not in EDITABLE_FIELDS:
|
||||
continue
|
||||
if key in SECRET_FIELDS and not _is_configured(value):
|
||||
@@ -195,6 +276,18 @@ class EncryptedSettingsStore:
|
||||
document = self.load_document()
|
||||
changed: set[str] = set()
|
||||
for key, raw_value in patch.items():
|
||||
if key == "model_pool":
|
||||
value = self._coerce_model_pool(raw_value, document["values"].get("model_pool", []))
|
||||
if document["values"].get(key) != value:
|
||||
document["values"][key] = value
|
||||
changed.add(key)
|
||||
continue
|
||||
if key in MODEL_SETTINGS_KEYS:
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||||
if document["values"].get(key) != value:
|
||||
document["values"][key] = value
|
||||
changed.add(key)
|
||||
continue
|
||||
if key not in EDITABLE_FIELDS:
|
||||
continue
|
||||
if key in SECRET_FIELDS and not _is_configured(raw_value):
|
||||
@@ -206,6 +299,13 @@ class EncryptedSettingsStore:
|
||||
for target, fields in TEST_FIELDS.items():
|
||||
if changed & fields:
|
||||
document["tests"].pop(target, None)
|
||||
if "model_pool" in changed:
|
||||
current_ids = {item["id"] for item in document["values"].get("model_pool", [])}
|
||||
document["tests"] = {
|
||||
target: result
|
||||
for target, result in document["tests"].items()
|
||||
if not target.startswith("model:") or target.removeprefix("model:") in current_ids
|
||||
}
|
||||
self.save_document(document)
|
||||
|
||||
def record_test(self, result: RuntimeSettingsTestResult, values: dict[str, Any] | None = None) -> None:
|
||||
@@ -220,7 +320,18 @@ class EncryptedSettingsStore:
|
||||
def public_response(self) -> RuntimeSettingsResponse:
|
||||
document = self.load_document()
|
||||
values = {**self.defaults(), **document["values"]}
|
||||
public_values = {key: value for key, value in values.items() if key not in SECRET_FIELDS}
|
||||
public_values = {
|
||||
key: value
|
||||
for key, value in values.items()
|
||||
if key not in SECRET_FIELDS and key not in LEGACY_SECRET_FIELDS
|
||||
}
|
||||
public_values["model_pool"] = [
|
||||
{
|
||||
**{key: value for key, value in item.items() if key != "api_key"},
|
||||
"api_key_configured": _is_configured(item.get("api_key")),
|
||||
}
|
||||
for item in values.get("model_pool", [])
|
||||
]
|
||||
configured = {key: _is_configured(values.get(key)) for key in FIELDS}
|
||||
configured.update(self.bootstrap_statuses())
|
||||
current_tests = self.current_tests(values, document["tests"])
|
||||
@@ -232,6 +343,38 @@ class EncryptedSettingsStore:
|
||||
readiness=self.readiness(values, current_tests),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_model_pool(raw_pool: Any, existing_pool: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw_pool, list):
|
||||
raise ValueError("模型池必须是列表。")
|
||||
if len(raw_pool) > 50:
|
||||
raise ValueError("模型池最多保存 50 个模型。")
|
||||
existing_by_id = {
|
||||
str(item.get("id")): item
|
||||
for item in existing_pool
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
}
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for raw_item in raw_pool:
|
||||
if not isinstance(raw_item, dict):
|
||||
raise ValueError("模型配置格式不正确。")
|
||||
item_data = dict(raw_item)
|
||||
item_id = str(item_data.get("id", "")).strip()
|
||||
if item_id in seen:
|
||||
raise ValueError(f"模型 ID 重复:{item_id}")
|
||||
if not _is_configured(item_data.get("api_key")):
|
||||
item_data["api_key"] = existing_by_id.get(item_id, {}).get("api_key", "")
|
||||
item_data.pop("api_key_configured", None)
|
||||
item = ModelPoolItem.model_validate(item_data)
|
||||
if item.category == "language":
|
||||
item.capabilities = ["orchestration"]
|
||||
elif not item.capabilities:
|
||||
raise ValueError(f"多模态模型“{item.name}”至少需要选择一种能力。")
|
||||
normalized.append(item.model_dump())
|
||||
seen.add(item.id)
|
||||
return normalized
|
||||
|
||||
def bootstrap_statuses(self) -> dict[str, bool]:
|
||||
return {
|
||||
"database_status": _is_configured(self.bootstrap.database_url),
|
||||
@@ -272,10 +415,67 @@ class EncryptedSettingsStore:
|
||||
untested.append(f"{group}:尚未通过连接测试")
|
||||
continue
|
||||
completed += 1
|
||||
|
||||
pool = [item for item in values.get("model_pool", []) if isinstance(item, dict) and item.get("enabled", True)]
|
||||
models = {str(item.get("id")): item for item in pool if item.get("id")}
|
||||
model_missing: list[str] = []
|
||||
model_untested: list[str] = []
|
||||
|
||||
language_models = [item for item in pool if item.get("category") == "language"]
|
||||
if not language_models:
|
||||
model_missing.append("至少添加一个已启用的大语言模型")
|
||||
orchestrator_id = str(values.get("orchestrator_model_id", ""))
|
||||
orchestrator = models.get(orchestrator_id)
|
||||
if not orchestrator_id:
|
||||
model_missing.append("选择总调度模型")
|
||||
elif not orchestrator or orchestrator.get("category") != "language":
|
||||
model_missing.append("总调度模型已删除、已停用或类型不正确")
|
||||
elif not tests.get(f"model:{orchestrator_id}", {}).get("ok"):
|
||||
model_untested.append(f"总调度模型“{orchestrator.get('name')}”尚未通过测试")
|
||||
|
||||
route_specs = [
|
||||
("空间理解", "spatial_routing_mode", "spatial_model_id", "spatial_understanding"),
|
||||
("生图", "image_routing_mode", "image_model_id", "image_generation"),
|
||||
]
|
||||
for label, mode_key, selected_key, capability in route_specs:
|
||||
eligible = [item for item in pool if capability in item.get("capabilities", [])]
|
||||
mode = values.get(mode_key, "auto")
|
||||
if not eligible:
|
||||
model_missing.append(f"添加支持{label}的多模态模型")
|
||||
continue
|
||||
if mode == "manual":
|
||||
selected_id = str(values.get(selected_key, ""))
|
||||
selected = models.get(selected_id)
|
||||
if not selected_id:
|
||||
model_missing.append(f"手动选择{label}模型")
|
||||
elif not selected or capability not in selected.get("capabilities", []):
|
||||
model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
|
||||
elif not tests.get(f"model:{selected_id}", {}).get("ok"):
|
||||
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过测试")
|
||||
elif mode == "auto":
|
||||
if not any(tests.get(f"model:{item['id']}", {}).get("ok") for item in eligible):
|
||||
model_untested.append(f"{label}自动路由没有已测试的候选模型")
|
||||
else:
|
||||
model_missing.append(f"{label}路由方式无效")
|
||||
|
||||
incomplete_models = [
|
||||
item.get("name", item.get("model_id", "未命名模型"))
|
||||
for item in pool
|
||||
if not all(_is_configured(item.get(key)) for key in ("name", "model_id", "base_url", "api_key"))
|
||||
]
|
||||
if incomplete_models:
|
||||
model_missing.append(f"补全模型配置:{'、'.join(incomplete_models)}")
|
||||
|
||||
if model_missing:
|
||||
missing.append(f"AI 模型:{';'.join(dict.fromkeys(model_missing))}")
|
||||
elif model_untested:
|
||||
untested.append(f"AI 模型:{';'.join(dict.fromkeys(model_untested))}")
|
||||
else:
|
||||
completed += 1
|
||||
return SettingsReadiness(
|
||||
ready=not missing and not untested,
|
||||
completed_required=completed,
|
||||
total_required=len(REQUIRED_GROUPS),
|
||||
total_required=len(REQUIRED_GROUPS) + 1,
|
||||
missing=missing,
|
||||
untested=untested,
|
||||
)
|
||||
@@ -287,6 +487,10 @@ class EncryptedSettingsStore:
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
current: dict[str, dict[str, Any]] = {}
|
||||
for target, result in tests.items():
|
||||
if target.startswith("model:"):
|
||||
model_id = target.removeprefix("model:")
|
||||
if not any(item.get("id") == model_id for item in values.get("model_pool", [])):
|
||||
continue
|
||||
item = dict(result)
|
||||
if item.get("fingerprint") != self.test_fingerprint(target, values):
|
||||
item["ok"] = False
|
||||
@@ -300,6 +504,12 @@ class EncryptedSettingsStore:
|
||||
"database_url": self.bootstrap.database_url,
|
||||
"redis_url": self.bootstrap.redis_url,
|
||||
}
|
||||
elif target.startswith("model:"):
|
||||
model_id = target.removeprefix("model:")
|
||||
relevant = next(
|
||||
(item for item in values.get("model_pool", []) if item.get("id") == model_id),
|
||||
{"id": model_id, "missing": True},
|
||||
)
|
||||
else:
|
||||
relevant = {key: values.get(key) for key in sorted(TEST_FIELDS.get(target, set()))}
|
||||
payload = json.dumps(relevant, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
||||
@@ -343,8 +553,6 @@ async def test_runtime_settings(
|
||||
return await _test_storage(values)
|
||||
if target == "baidu_ocr":
|
||||
return await _test_baidu_ocr(values)
|
||||
if target == "ai_models":
|
||||
return await _test_ai_models(values)
|
||||
if target == "gpu":
|
||||
return await _test_gpu(values)
|
||||
if target == "langfuse":
|
||||
@@ -433,24 +641,88 @@ async def _test_baidu_ocr(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||||
)
|
||||
|
||||
|
||||
async def _test_ai_models(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||||
url = _join_api_url(values["ai_base_url"], values.get("ai_models_path", "/models"))
|
||||
async with httpx.AsyncClient(timeout=12) as client:
|
||||
response = await client.get(url, headers={"Authorization": f"Bearer {values['ai_api_key']}"})
|
||||
response.raise_for_status()
|
||||
async def test_model_pool_item(values: dict[str, Any], model_id: str) -> RuntimeSettingsTestResult:
|
||||
target = f"model:{model_id}"
|
||||
item = next(
|
||||
(candidate for candidate in values.get("model_pool", []) if candidate.get("id") == model_id),
|
||||
None,
|
||||
)
|
||||
if item is None:
|
||||
return RuntimeSettingsTestResult(target=target, ok=False, message="模型不存在或已被删除。")
|
||||
name = item.get("name") or item.get("model_id") or model_id
|
||||
missing_fields = [
|
||||
label
|
||||
for key, label in (("model_id", "模型 ID"), ("base_url", "Base URL"), ("api_key", "API Key"))
|
||||
if not _is_configured(item.get(key))
|
||||
]
|
||||
if missing_fields:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”配置不完整:缺少{'、'.join(missing_fields)}。",
|
||||
)
|
||||
|
||||
url = _join_api_url(str(item["base_url"]), str(item.get("models_path", "/models")))
|
||||
headers = {"Authorization": f"Bearer {item['api_key']}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=12) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
model_ids = {
|
||||
item.get("id")
|
||||
for item in payload.get("data", [])
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status_code = exc.response.status_code
|
||||
if status_code in {401, 403}:
|
||||
reason = "API Key 无效或账号无权访问模型列表"
|
||||
elif status_code == 404:
|
||||
reason = f"模型列表地址不存在,请检查路径 {item.get('models_path', '/models')}"
|
||||
else:
|
||||
reason = f"模型列表接口返回 HTTP {status_code}"
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”测试失败:{reason}。",
|
||||
details={"model_id": item["model_id"], "status_code": status_code},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”无法连接 API:{exc}。",
|
||||
details={"model_id": item["model_id"]},
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”的模型列表接口没有返回有效 JSON。",
|
||||
details={"model_id": item["model_id"]},
|
||||
)
|
||||
|
||||
rows = payload.get("data", []) if isinstance(payload, dict) else []
|
||||
listed_ids = {
|
||||
candidate.get("id")
|
||||
for candidate in rows
|
||||
if isinstance(candidate, dict) and candidate.get("id")
|
||||
}
|
||||
selected = [values.get("orchestrator_model"), values.get("vision_model"), values.get("image_model")]
|
||||
missing = [model for model in selected if model and model_ids and model not in model_ids]
|
||||
if not listed_ids:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”的 API 可以连接,但模型列表为空,暂时无法确认模型 ID。",
|
||||
details={"model_id": item["model_id"], "model_count": 0},
|
||||
)
|
||||
if item["model_id"] not in listed_ids:
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message=f"模型“{name}”测试失败:账号模型列表中未找到 ID“{item['model_id']}”。",
|
||||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||||
)
|
||||
return RuntimeSettingsTestResult(
|
||||
target="ai_models",
|
||||
ok=not missing,
|
||||
message="API Key 有效,三个模型均可用。" if not missing else "API 可以连接,但部分模型名不在账号模型列表中。",
|
||||
details={"missing_models": missing, "model_count": len(model_ids)},
|
||||
target=target,
|
||||
ok=True,
|
||||
message=f"模型“{name}”连接正常,模型 ID“{item['model_id']}”可用。",
|
||||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -179,112 +179,10 @@ CATEGORIES = [
|
||||
),
|
||||
SettingCategory(
|
||||
id="models",
|
||||
label="AI 模型",
|
||||
description="统一配置总调度、空间理解和生图模型;同一个聚合 API 可以同时承担三类能力。",
|
||||
test_targets=["ai_models"],
|
||||
label="模型池",
|
||||
description="分别添加大语言模型与多模态模型。每个模型独立配置、独立测试,再用于总调度、空间理解或生图路由。",
|
||||
required_for_workflow=True,
|
||||
fields=[
|
||||
SettingField(
|
||||
key="ai_provider",
|
||||
label="API 来源",
|
||||
description="选择自然语意预设;自建或其他聚合服务选择兼容接口。",
|
||||
kind="select",
|
||||
required=True,
|
||||
default="lingke",
|
||||
options=[
|
||||
option("lingke", "聚合引擎 AIGC", "你提供的 lk666.ai 聚合服务,模型名以其控制台为准。"),
|
||||
option("openai", "OpenAI 官方", "直接使用 OpenAI 官方 API。"),
|
||||
option("custom", "其他兼容接口", "支持 OpenAI 请求格式的代理、自建或聚合服务。"),
|
||||
],
|
||||
),
|
||||
SettingField(
|
||||
key="ai_base_url",
|
||||
label="API Base URL",
|
||||
description="填写到 /v1 层级;聚合引擎的准确地址请从登录后的开发者文档复制。",
|
||||
kind="url",
|
||||
required=True,
|
||||
placeholder="https://example.com/v1",
|
||||
),
|
||||
SettingField(
|
||||
key="ai_api_key",
|
||||
label="API Key",
|
||||
description="同一聚合账号可供总调度、多模态和生图使用。",
|
||||
kind="password",
|
||||
required=True,
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
key="orchestrator_model",
|
||||
label="总调度模型",
|
||||
description="负责追问、拆解任务、维护 Plan / Scene / Style 状态。",
|
||||
kind="combobox",
|
||||
required=True,
|
||||
placeholder="选择预设或输入平台模型 ID",
|
||||
options=[
|
||||
option("gpt-5.6-terra", "GPT-5.6 Terra", "速度与规划能力均衡,适合日常工作流。"),
|
||||
option("gpt-5.6-sol", "GPT-5.6 Sol", "更强推理,适合复杂户型和高价值方案。"),
|
||||
option("gpt-5", "GPT-5", "通用调度预设,具体可用性取决于账号。"),
|
||||
],
|
||||
),
|
||||
SettingField(
|
||||
key="vision_model",
|
||||
label="空间理解模型",
|
||||
description="读取户型、参考图和渲染结果,判断空间关系与审美一致性。",
|
||||
kind="combobox",
|
||||
required=True,
|
||||
placeholder="选择预设或输入多模态模型 ID",
|
||||
options=[
|
||||
option("gpt-5.6-sol", "GPT-5.6 Sol", "优先空间推理和复杂视觉评审。"),
|
||||
option("gemini-3-pro", "Gemini 3 Pro", "长上下文与多模态理解预设。"),
|
||||
option("gpt-5", "GPT-5", "通用多模态预设。"),
|
||||
],
|
||||
),
|
||||
SettingField(
|
||||
key="image_model",
|
||||
label="默认生图模型",
|
||||
description="先使用你指定的 gpt-image-2;后续可以增加按任务自动路由。",
|
||||
kind="combobox",
|
||||
required=True,
|
||||
default="gpt-image-2",
|
||||
options=[
|
||||
option("gpt-image-2", "GPT Image 2", "默认室内方向图与局部编辑模型。"),
|
||||
option("seedream-5.0", "Seedream 5.0", "适合高质量中文场景生成,模型 ID 以平台为准。"),
|
||||
option("nano-banana-pro", "Nano Banana Pro", "适合参考图编辑,模型 ID 以平台为准。"),
|
||||
],
|
||||
),
|
||||
SettingField(
|
||||
key="ai_models_path",
|
||||
label="模型列表路径",
|
||||
description="测试按钮使用。OpenAI 兼容接口通常为 /models。",
|
||||
kind="text",
|
||||
default="/models",
|
||||
advanced=True,
|
||||
),
|
||||
SettingField(
|
||||
key="ai_chat_path",
|
||||
label="对话路径",
|
||||
description="OpenAI 兼容接口通常为 /chat/completions。",
|
||||
kind="text",
|
||||
default="/chat/completions",
|
||||
advanced=True,
|
||||
),
|
||||
SettingField(
|
||||
key="ai_image_generation_path",
|
||||
label="生图路径",
|
||||
description="OpenAI 兼容接口通常为 /images/generations。",
|
||||
kind="text",
|
||||
default="/images/generations",
|
||||
advanced=True,
|
||||
),
|
||||
SettingField(
|
||||
key="ai_image_edit_path",
|
||||
label="图片编辑路径",
|
||||
description="OpenAI 兼容接口通常为 /images/edits。",
|
||||
kind="text",
|
||||
default="/images/edits",
|
||||
advanced=True,
|
||||
),
|
||||
],
|
||||
fields=[],
|
||||
),
|
||||
SettingCategory(
|
||||
id="gpu",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from app.integrations.model_router import ModelRouter
|
||||
|
||||
|
||||
def test_auto_route_requires_orchestrator_choice_with_multiple_candidates() -> None:
|
||||
pool = [
|
||||
{
|
||||
"id": "image-a",
|
||||
"name": "Image A",
|
||||
"model_id": "image-a-model",
|
||||
"category": "multimodal",
|
||||
"provider": "provider-a",
|
||||
"enabled": True,
|
||||
"capabilities": ["image_generation"],
|
||||
},
|
||||
{
|
||||
"id": "image-b",
|
||||
"name": "Image B",
|
||||
"model_id": "image-b-model",
|
||||
"category": "multimodal",
|
||||
"provider": "provider-b",
|
||||
"enabled": True,
|
||||
"capabilities": ["image_generation"],
|
||||
},
|
||||
]
|
||||
tests = {
|
||||
"model:image-a": {"ok": True},
|
||||
"model:image-b": {"ok": True},
|
||||
}
|
||||
router = ModelRouter({"model_pool": pool, "image_routing_mode": "auto"}, tests)
|
||||
|
||||
with pytest.raises(RuntimeError, match="总调度返回模型实例 ID"):
|
||||
router.choose("image")
|
||||
assert router.choose("image", "image-b")["model_id"] == "image-b-model"
|
||||
|
||||
|
||||
def test_manual_route_uses_the_user_selected_model() -> None:
|
||||
pool = [
|
||||
{
|
||||
"id": "spatial",
|
||||
"name": "Spatial",
|
||||
"model_id": "spatial-model",
|
||||
"category": "multimodal",
|
||||
"provider": "custom",
|
||||
"enabled": True,
|
||||
"capabilities": ["spatial_understanding"],
|
||||
}
|
||||
]
|
||||
router = ModelRouter(
|
||||
{
|
||||
"model_pool": pool,
|
||||
"spatial_routing_mode": "manual",
|
||||
"spatial_model_id": "spatial",
|
||||
},
|
||||
{"model:spatial": {"ok": True}},
|
||||
)
|
||||
|
||||
assert router.choose("spatial")["id"] == "spatial"
|
||||
@@ -1,10 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.runtime_settings import (
|
||||
EncryptedSettingsStore,
|
||||
RuntimeSettingsTestResult,
|
||||
generate_secret,
|
||||
test_model_pool_item as run_model_pool_test,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,27 +46,122 @@ def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -
|
||||
"s3_public_endpoint": "http://localhost:9000",
|
||||
"s3_access_key_id": "access",
|
||||
"s3_secret_access_key": "secret",
|
||||
"ai_provider": "lingke",
|
||||
"ai_base_url": "https://example.com/v1",
|
||||
"ai_api_key": "api-secret",
|
||||
"orchestrator_model": "planner",
|
||||
"vision_model": "vision",
|
||||
"image_model": "gpt-image-2",
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "planner",
|
||||
"name": "总调度",
|
||||
"model_id": "planner-model",
|
||||
"category": "language",
|
||||
"provider": "lingke",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "planner-secret",
|
||||
"capabilities": ["orchestration"],
|
||||
},
|
||||
{
|
||||
"id": "designer",
|
||||
"name": "空间与生图",
|
||||
"model_id": "designer-model",
|
||||
"category": "multimodal",
|
||||
"provider": "lingke",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "designer-secret",
|
||||
"capabilities": ["spatial_understanding", "image_generation"],
|
||||
},
|
||||
],
|
||||
"orchestrator_model_id": "planner",
|
||||
"spatial_routing_mode": "auto",
|
||||
"image_routing_mode": "manual",
|
||||
"image_model_id": "designer",
|
||||
}
|
||||
)
|
||||
assert store.public_response().readiness.ready is False
|
||||
|
||||
for target in ("infrastructure", "storage", "ai_models"):
|
||||
for target in ("infrastructure", "storage", "model:planner", "model:designer"):
|
||||
store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
|
||||
|
||||
assert store.public_response().readiness.ready is True
|
||||
|
||||
store.update({"image_model": "another-image-model"})
|
||||
pool = store.merged_values()["model_pool"]
|
||||
pool[1]["model_id"] = "another-image-model"
|
||||
store.update({"model_pool": pool})
|
||||
assert store.public_response().readiness.ready is False
|
||||
assert store.public_response().tests.get("ai_models") is None
|
||||
assert store.public_response().tests["model:designer"]["ok"] is False
|
||||
assert "重新测试" in store.public_response().tests["model:designer"]["message"]
|
||||
|
||||
|
||||
def test_model_pool_keys_are_encrypted_and_redacted(tmp_path: Path) -> None:
|
||||
store = create_store(tmp_path)
|
||||
store.update(
|
||||
{
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "planner",
|
||||
"name": "总调度",
|
||||
"model_id": "planner-model",
|
||||
"category": "language",
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "nested-model-secret",
|
||||
"capabilities": ["orchestration"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
public_model = store.public_response().values["model_pool"][0]
|
||||
assert "api_key" not in public_model
|
||||
assert public_model["api_key_configured"] is True
|
||||
assert b"nested-model-secret" not in store.data_path.read_bytes()
|
||||
|
||||
|
||||
def test_secret_generators_use_expected_lengths() -> None:
|
||||
assert len(generate_secret("hex24")) == 48
|
||||
assert len(generate_secret("hex32")) == 64
|
||||
assert len(generate_secret("base64_32")) == 44
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_individual_model_error_names_the_failing_model(tmp_path: Path, monkeypatch) -> None:
|
||||
store = create_store(tmp_path)
|
||||
store.update(
|
||||
{
|
||||
"model_pool": [
|
||||
{
|
||||
"id": "image-model",
|
||||
"name": "客厅效果图模型",
|
||||
"model_id": "wrong-image-id",
|
||||
"category": "multimodal",
|
||||
"provider": "custom",
|
||||
"base_url": "https://example.com/v1",
|
||||
"api_key": "secret",
|
||||
"capabilities": ["image_generation"],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"data": [{"id": "available-image-id"}]}
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args, **kwargs) -> FakeResponse:
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||
result = await run_model_pool_test(store.merged_values(), "image-model")
|
||||
|
||||
assert result.ok is False
|
||||
assert "客厅效果图模型" in result.message
|
||||
assert "wrong-image-id" in result.message
|
||||
|
||||
Reference in New Issue
Block a user