964 lines
40 KiB
TypeScript
964 lines
40 KiB
TypeScript
"use client";
|
|
|
|
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 {
|
|
ImageParameterProfile,
|
|
ModelCapability,
|
|
ModelCategory,
|
|
ModelPoolItem,
|
|
SettingField,
|
|
SettingsReadiness,
|
|
SettingsResponse,
|
|
TestResult,
|
|
} from "@/types/settings";
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
|
|
|
const testLabels: Record<string, string> = {
|
|
infrastructure: "测试数据库与队列",
|
|
storage: "测试 MinIO",
|
|
baidu_ocr: "测试百度 OCR",
|
|
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: "其他兼容接口",
|
|
};
|
|
|
|
const imagePresets: Record<Exclude<ImageParameterProfile, "generic">, Partial<ModelPoolItem>> = {
|
|
gpt_image_2: {
|
|
name: "GPT Image 2", model_id: "gpt-image-2", category: "multimodal", provider: "lingke",
|
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
|
image_protocol: "aigc_media", image_parameter_profile: "gpt_image_2",
|
|
},
|
|
nano_banana_pro: {
|
|
name: "Nano Banana Pro", model_id: "gemini-3-pro-image-preview", category: "multimodal", provider: "lingke",
|
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
|
image_protocol: "aigc_media", image_parameter_profile: "nano_banana_pro",
|
|
},
|
|
seedream_5_pro: {
|
|
name: "即梦 5.0 Pro", model_id: "doubao-seedream-5-0-pro-260628", category: "multimodal", provider: "lingke",
|
|
base_url: "https://api.lk888.ai", capabilities: ["image_generation", "image_editing"],
|
|
models_path: "/v1/models", image_generation_path: "/v1/media/generate",
|
|
image_edit_path: "/v1/media/generate", image_status_path: "/v1/media/status",
|
|
image_protocol: "aigc_media", image_parameter_profile: "seedream_5_pro",
|
|
},
|
|
};
|
|
|
|
type SettingsCenterProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onReadinessChange: (readiness: SettingsReadiness) => void;
|
|
};
|
|
|
|
function isVisible(field: SettingField, draft: Record<string, unknown>) {
|
|
if (!field.visible_when) return true;
|
|
return Object.entries(field.visible_when).every(([key, expected]) => {
|
|
if (key.endsWith("__not")) return draft[key.slice(0, -5)] !== expected;
|
|
return draft[key] === expected;
|
|
});
|
|
}
|
|
|
|
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>>({});
|
|
const [activeCategoryId, setActiveCategoryId] = useState("deployment");
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
|
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
|
|
|
const activeCategory = useMemo(
|
|
() => data?.categories.find((category) => category.id === activeCategoryId) ?? data?.categories[0],
|
|
[activeCategoryId, data],
|
|
);
|
|
|
|
function applyResponse(response: SettingsResponse) {
|
|
setData(response);
|
|
setDraft(response.values);
|
|
onReadinessChange(response.readiness);
|
|
}
|
|
|
|
async function loadSettings() {
|
|
setBusy("load");
|
|
try {
|
|
const response = await fetch(`${API_BASE}/v1/settings`, { cache: "no-store" });
|
|
if (!response.ok) throw new Error("系统设置读取失败,请确认后端 API 已启动。");
|
|
applyResponse(await response.json() as SettingsResponse);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let cancelled = false;
|
|
fetch(`${API_BASE}/v1/settings`, { cache: "no-store" })
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error("系统设置读取失败,请确认后端 API 已启动。");
|
|
return response.json() as Promise<SettingsResponse>;
|
|
})
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
setData(response);
|
|
setDraft(response.values);
|
|
onReadinessChange(response.readiness);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!cancelled) setNotice({ tone: "error", text: getErrorMessage(error) });
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [open, onReadinessChange]);
|
|
|
|
function updateField(key: string, value: unknown) {
|
|
setDraft((current) => ({ ...current, [key]: value }));
|
|
setNotice(null);
|
|
}
|
|
|
|
async function saveSettings(showSuccess = true) {
|
|
setBusy("save");
|
|
setNotice(null);
|
|
try {
|
|
const response = await fetch(`${API_BASE}/v1/settings`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ values: draft }),
|
|
});
|
|
if (!response.ok) throw new Error(await responseError(response, "保存失败,请检查填写内容。"));
|
|
const payload = await response.json() as SettingsResponse;
|
|
applyResponse(payload);
|
|
if (showSuccess) setNotice({ tone: "success", text: "设置已加密保存。" });
|
|
return payload;
|
|
} catch (error) {
|
|
setNotice({ tone: "error", text: getErrorMessage(error) });
|
|
throw error;
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function testTarget(target: string) {
|
|
setBusy(target);
|
|
setNotice(null);
|
|
try {
|
|
await saveSettings(false);
|
|
setBusy(target);
|
|
const response = await fetch(`${API_BASE}/v1/settings/test`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ target, values: {} }),
|
|
});
|
|
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 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 testModelGeneration(modelId: string) {
|
|
if (!window.confirm("这会真实生成一张低成本测试图,并消耗该模型的一次调用额度。是否继续?")) return;
|
|
const target = `generation:${modelId}`;
|
|
setBusy(target);
|
|
setNotice(null);
|
|
try {
|
|
await saveSettings(false);
|
|
setBusy(target);
|
|
const response = await fetch(`${API_BASE}/v1/settings/models/${encodeURIComponent(modelId)}/test-generation`, {
|
|
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);
|
|
try {
|
|
const response = await fetch(`${API_BASE}/v1/settings/generate-secret`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ kind: field.generator }),
|
|
});
|
|
if (!response.ok) throw new Error("密钥生成失败。");
|
|
const payload = await response.json() as { value: string };
|
|
updateField(field.key, payload.value);
|
|
setNotice({ tone: "success", text: `${field.label}已生成,点击保存后会加密存储。` });
|
|
} catch (error) {
|
|
setNotice({ tone: "error", text: getErrorMessage(error) });
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
function closeSafely() {
|
|
if (data) {
|
|
const secretKeys = new Set(data.categories.flatMap((category) => category.fields)
|
|
.filter((field) => field.secret)
|
|
.map((field) => field.key));
|
|
setDraft((current) => Object.fromEntries(
|
|
Object.entries(current).filter(([key]) => !secretKeys.has(key)),
|
|
));
|
|
}
|
|
onClose();
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="settings-backdrop" role="presentation">
|
|
<section className="settings-dialog" role="dialog" aria-modal="true" aria-label="系统设置">
|
|
<header className="settings-header">
|
|
<div>
|
|
<p className="settings-kicker">首次使用向导</p>
|
|
<h1>系统设置</h1>
|
|
<p>按服务分类配置并逐项测试。所有 API Key 都由后端加密保存。</p>
|
|
</div>
|
|
<button className="icon-button" type="button" onClick={closeSafely} aria-label="关闭系统设置">
|
|
<XIcon size={18} />
|
|
</button>
|
|
</header>
|
|
|
|
{data ? (
|
|
<div className="settings-readiness">
|
|
<div className={`readiness-orb ${data.readiness.ready ? "ready" : ""}`}>
|
|
{data.readiness.ready ? <CheckCircleIcon size={22} weight="fill" /> : <DatabaseIcon size={22} />}
|
|
</div>
|
|
<div>
|
|
<strong>{data.readiness.ready ? "工作流已就绪" : "完成必备配置后才能开始设计"}</strong>
|
|
<p>{data.readiness.completed_required} / {data.readiness.total_required} 个必备分类已配置并测试</p>
|
|
</div>
|
|
<div className="readiness-meter" aria-label="配置完成度">
|
|
<span style={{ width: `${(data.readiness.completed_required / data.readiness.total_required) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="settings-layout">
|
|
<nav className="settings-nav" aria-label="设置分类">
|
|
{data?.categories.map((category) => {
|
|
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}
|
|
type="button"
|
|
className={category.id === activeCategory?.id ? "active" : ""}
|
|
onClick={() => setActiveCategoryId(category.id)}
|
|
>
|
|
<span>{category.label}</span>
|
|
{category.required_for_workflow ? (
|
|
<small className={categoryTested ? "done" : "required"}>
|
|
{categoryTested ? "已就绪" : "必备"}
|
|
</small>
|
|
) : <small>可选</small>}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="settings-content">
|
|
{busy === "load" && !data ? (
|
|
<div className="settings-loading"><ArrowClockwiseIcon size={24} /> 正在读取设置</div>
|
|
) : activeCategory && data ? (
|
|
<>
|
|
<div className="settings-section-heading">
|
|
<div>
|
|
<h2>{activeCategory.label}</h2>
|
|
<p>{activeCategory.description}</p>
|
|
</div>
|
|
{activeCategory.id !== "models" && activeCategory.fields.some((field) => field.advanced) ? (
|
|
<label className="advanced-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={showAdvanced}
|
|
onChange={(event) => setShowAdvanced(event.target.checked)}
|
|
/>
|
|
高级设置
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
|
|
{activeCategory.id === "models" ? (
|
|
<ModelPoolPanel
|
|
draft={draft}
|
|
tests={{ ...data.tests, ...testResults }}
|
|
busy={busy}
|
|
onChange={updateField}
|
|
onTest={(modelId) => void testModel(modelId)}
|
|
onTrial={(modelId) => void testModelGeneration(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> 时会自动安全生成。</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{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>
|
|
</div>
|
|
|
|
<footer className="settings-footer">
|
|
<div className={`settings-notice ${notice?.tone ?? ""}`}>
|
|
{notice?.tone === "error" ? <WarningCircleIcon size={17} /> : null}
|
|
{notice?.tone === "success" ? <CheckCircleIcon size={17} weight="fill" /> : null}
|
|
<span>{notice?.text ?? "必备项完成并测试通过后,设计工作流会自动解锁。"}</span>
|
|
</div>
|
|
<div className="settings-footer-actions">
|
|
<button className="button button-secondary" type="button" onClick={closeSafely}>稍后设置</button>
|
|
<button
|
|
className="button button-primary"
|
|
type="button"
|
|
onClick={() => void saveSettings()}
|
|
disabled={Boolean(busy) || !data}
|
|
>
|
|
<FloppyDiskIcon size={16} /> 保存设置
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ModelPoolPanelProps = {
|
|
draft: Record<string, unknown>;
|
|
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
|
busy: string | null;
|
|
onChange: (key: string, value: unknown) => void;
|
|
onTest: (modelId: string) => void;
|
|
onTrial: (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",
|
|
image_status_path: "/v1/media/status",
|
|
image_protocol: "openai_images",
|
|
image_parameter_profile: "generic",
|
|
enabled: true,
|
|
api_key_configured: false,
|
|
};
|
|
}
|
|
|
|
function ModelPoolPanel({ draft, tests, busy, onChange, onTest, onTrial }: 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 applyPreset(profile: ImageParameterProfile) {
|
|
if (!editor) return;
|
|
if (profile === "generic") {
|
|
setEditor({ ...editor, image_parameter_profile: "generic" });
|
|
return;
|
|
}
|
|
setEditor({ ...editor, ...imagePresets[profile] });
|
|
setShowAdvanced(false);
|
|
}
|
|
|
|
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>“验证连接”检查地址、密钥和模型列表;部分媒体模型不会出现在列表中,最终以“试生成”的端到端结果为准。</p>
|
|
</div>
|
|
<button className="icon-button small" type="button" onClick={() => setEditor(null)} aria-label="关闭模型编辑器">
|
|
<XIcon size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="model-editor-grid">
|
|
{editor.category === "multimodal" ? (
|
|
<label className="model-input model-input-wide">
|
|
<span>接入预设</span>
|
|
<select value={editor.image_parameter_profile} onChange={(event) => applyPreset(event.target.value as ImageParameterProfile)}>
|
|
<option value="generic">自定义模型 / 其他兼容接口</option>
|
|
<option value="gpt_image_2">GPT Image 2 · 聚合引擎 AIGC</option>
|
|
<option value="nano_banana_pro">Nano Banana Pro · 聚合引擎 AIGC</option>
|
|
<option value="seedream_5_pro">即梦 5.0 Pro · 聚合引擎 AIGC</option>
|
|
</select>
|
|
<small>选择预设会自动填写准确的模型 ID、协议、路径与参数格式。</small>
|
|
</label>
|
|
) : null}
|
|
<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">
|
|
<label className="model-input model-input-wide">
|
|
<span>图像调用协议</span>
|
|
<select value={editor.image_protocol} onChange={(event) => setEditor({ ...editor, image_protocol: event.target.value as ModelPoolItem["image_protocol"] })}>
|
|
<option value="openai_images">OpenAI Images 同步协议</option>
|
|
<option value="aigc_media">AIGC 媒体任务协议(创建后轮询)</option>
|
|
</select>
|
|
</label>
|
|
{([
|
|
["models_path", "模型列表路径"],
|
|
["chat_path", "对话路径"],
|
|
["image_generation_path", "生图路径"],
|
|
["image_edit_path", "图片编辑路径"],
|
|
["image_status_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}
|
|
onTrial={onTrial}
|
|
/>
|
|
<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}
|
|
onTrial={onTrial}
|
|
/>
|
|
|
|
<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; details?: Record<string, unknown> }>;
|
|
busy: string | null;
|
|
onAdd: () => void;
|
|
onEdit: (model: ModelPoolItem) => void;
|
|
onRemove: (model: ModelPoolItem) => void;
|
|
onTest: (modelId: string) => void;
|
|
onTrial: (modelId: string) => void;
|
|
};
|
|
|
|
function ModelGroup({ title, description, category, models, tests, busy, onAdd, onEdit, onRemove, onTest, onTrial }: 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];
|
|
const generationTarget = `generation:${model.id}`;
|
|
const generationResult = tests[generationTarget];
|
|
const generationImageUrl = typeof generationResult?.details?.result_url === "string"
|
|
? generationResult.details.result_url
|
|
: null;
|
|
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>
|
|
{model.capabilities.includes("image_generation") ? (
|
|
<button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度">
|
|
{busy === generationTarget
|
|
? <ArrowClockwiseIcon className="spin" size={14} />
|
|
: generationResult?.ok
|
|
? <CheckCircleIcon size={14} weight="fill" />
|
|
: <ImageIcon size={14} />}
|
|
试生成
|
|
</button>
|
|
) : null}
|
|
<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">尚未验证模型 ID。</p>}
|
|
{generationResult ? (
|
|
<div className="model-generation-result">
|
|
<p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p>
|
|
{generationResult.ok && generationImageUrl ? (
|
|
<a href={generationImageUrl} target="_blank" rel="noreferrer" title="打开原始测试图片">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- runtime model URLs are not known to Next Image. */}
|
|
<img src={generationImageUrl} alt={`${model.name} 真实试生成结果`} />
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
) : model.capabilities.includes("image_generation") ? (
|
|
<p className="model-test-message">尚未完成真实生图验证;连接成功不代表已经获得有效图片。</p>
|
|
) : null}
|
|
</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;
|
|
configured: boolean;
|
|
onChange: (value: unknown) => void;
|
|
onGenerate: () => void;
|
|
busy: boolean;
|
|
};
|
|
|
|
function SettingControl({ field, value, configured, onChange, onGenerate, busy }: SettingControlProps) {
|
|
const selectedOption = field.options.find((option) => option.value === String(value ?? ""));
|
|
|
|
if (field.kind === "status") {
|
|
return (
|
|
<div className="setting-status-row">
|
|
<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>
|
|
<small>{configured ? "已配置" : "缺失"}</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.kind === "toggle") {
|
|
return (
|
|
<label className="setting-toggle-row">
|
|
<div><strong>{field.label}</strong><p>{field.description}</p></div>
|
|
<input type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<label className="setting-field">
|
|
<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" ? (
|
|
<select value={String(value ?? "")} onChange={(event) => onChange(event.target.value)}>
|
|
<option value="" disabled>请选择</option>
|
|
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
) : (
|
|
<>
|
|
<input
|
|
type={field.kind === "password" ? "password" : field.kind === "number" ? "number" : "text"}
|
|
list={field.kind === "combobox" ? `options-${field.key}` : undefined}
|
|
value={String(value ?? "")}
|
|
placeholder={field.secret && configured ? "已安全保存,留空不修改" : field.placeholder}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
{field.kind === "combobox" ? (
|
|
<datalist id={`options-${field.key}`}>
|
|
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</datalist>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{field.generator ? (
|
|
<button className="generate-button" type="button" onClick={onGenerate} disabled={busy}>
|
|
<KeyIcon size={14} /> 一键生成
|
|
</button>
|
|
) : null}
|
|
</span>
|
|
{selectedOption ? <span className="option-help">{selectedOption.help}</span> : null}
|
|
</label>
|
|
);
|
|
}
|