Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/core/threads/types.ts
#	frontend-web/src/pages/ChatPage.tsx
This commit is contained in:
吉鑫升 committed 2026-08-12 15:55:51 +08:00
commit c4fd9dd7da
233 files changed
+12524 -1163

No files matched your search

+81 -10
View File
@@ -45,6 +45,10 @@ import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
import { useNotification } from "@/core/notification/hooks";
import {
getLastAgentId,
@@ -54,6 +58,7 @@ import {
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -304,6 +309,20 @@ export default function ChatPage() {
}
},
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const displayThread = useMemo(() => {
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
return thread;
}
const messages = [...thread.messages, ...singleRoundRag.messages];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading: thread.isLoading || singleRoundRag.isLoading,
} as typeof thread;
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -318,6 +337,46 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (singleRoundRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
}
})();
return;
}
// If the user picked an agent before sending the opening turn, hand off
// to AgentChatPage so the conversation runs against that agent. We stash
// the pending text in sessionStorage because the next/navigation shim
@@ -420,7 +479,13 @@ export default function ChatPage() {
isMock,
router,
settings.context.writing_mode,
settings.context.model_name,
writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
routeBase,
setIsNewThread,
setThreadId,
],
);
@@ -511,8 +576,12 @@ export default function ChatPage() {
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
}
await thread.stop();
}, [thread]);
}, [singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -558,7 +627,7 @@ export default function ChatPage() {
const compactActionsHidden = isHideChromeActive();
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
<ChatBox threadId={threadId}>
<div
className={cn(
@@ -576,7 +645,7 @@ export default function ChatPage() {
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
<ThreadTitle threadId={threadId} thread={displayThread} />
</div>
<div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -595,7 +664,7 @@ export default function ChatPage() {
)}
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
messages={displayThread.messages}
/>
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
@@ -633,7 +702,7 @@ export default function ChatPage() {
<MessageList
className={cn("size-full", "pt-10")}
threadId={threadId}
thread={thread}
thread={displayThread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
@@ -675,9 +744,9 @@ export default function ChatPage() {
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
todos={displayThread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
!displayThread.values.todos || displayThread.values.todos.length === 0
}
/>
</div>
@@ -702,16 +771,18 @@ export default function ChatPage() {
status={
thread.error
? "error"
: thread.isLoading
: displayThread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading
knowledgeSelectionLoading ||
singleRoundRag.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
@@ -771,7 +842,7 @@ export default function ChatPage() {
*/}
{!isLandingState && (
<QuestionNavigator
messages={thread.messages}
messages={displayThread.messages}
className="top-16 right-0 bottom-48 left-0"
/>
)}
@@ -0,0 +1,491 @@
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Settings2Icon,
SparklesIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import {
Button as TButton,
Dialog,
Input,
InputNumber,
MessagePlugin,
Select,
Switch,
Tag,
Textarea,
} from "tdesign-react";
import {
type ManagedModel,
type ModelSavePayload,
useDeleteManagedModel,
useManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
type ModelDraft = ModelSavePayload & {
api_key: string;
keep_existing_key: boolean;
extra_settings_text: string;
};
const PROVIDER_OPTIONS = [
{ label: "OpenAI 兼容接口", value: "langchain_openai:ChatOpenAI" },
{ label: "Anthropic", value: "langchain_anthropic:ChatAnthropic" },
{ label: "Google Gemini", value: "langchain_google_genai:ChatGoogleGenerativeAI" },
{ label: "Groq", value: "langchain_groq:ChatGroq" },
{ label: "Ollama", value: "langchain_ollama:ChatOllama" },
];
const emptyDraft = (): ModelDraft => ({
name: "",
display_name: "",
description: "",
use: "langchain_openai:ChatOpenAI",
model: "",
api_key: "",
keep_existing_key: false,
clear_api_key: false,
base_url: "",
request_timeout: 30,
max_retries: 2,
max_tokens: undefined,
temperature: undefined,
stream_usage: true,
supports_thinking: false,
supports_reasoning_effort: false,
supports_vision: false,
when_thinking_enabled: null,
when_thinking_disabled: null,
thinking: null,
use_responses_api: null,
output_version: null,
extra_settings: {},
extra_settings_text: "{}",
});
function draftFromModel(model: ManagedModel): ModelDraft {
return {
...model,
api_key: "",
keep_existing_key: model.api_key_configured,
clear_api_key: false,
extra_settings_text: JSON.stringify(model.extra_settings ?? {}, null, 2),
};
}
function optionalString(value: string | null | undefined): string | null {
const normalized = (value ?? "").trim();
return normalized || null;
}
function toNumber(value: number | string | null | undefined): number | undefined {
if (value === "" || value === null || value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function buildPayload(draft: ModelDraft): ModelSavePayload {
let extraSettings: Record<string, unknown> = {};
try {
const parsed = JSON.parse(draft.extra_settings_text || "{}") as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("额外参数必须是 JSON 对象");
}
extraSettings = parsed as Record<string, unknown>;
} catch (error) {
throw error instanceof Error ? error : new Error("额外参数不是有效 JSON");
}
const payload: ModelSavePayload = {
name: draft.name.trim(),
display_name: optionalString(draft.display_name),
description: optionalString(draft.description),
use: draft.use.trim(),
model: draft.model.trim(),
clear_api_key: Boolean(draft.clear_api_key),
base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries),
max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage,
supports_thinking: draft.supports_thinking,
supports_reasoning_effort: draft.supports_reasoning_effort,
supports_vision: draft.supports_vision,
when_thinking_enabled: draft.when_thinking_enabled,
when_thinking_disabled: draft.when_thinking_disabled,
thinking: draft.thinking,
use_responses_api: draft.use_responses_api,
output_version: optionalString(draft.output_version),
extra_settings: extraSettings,
};
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
return payload;
}
function StatCard({ icon: Icon, label, value }: { icon: typeof CpuIcon; label: string; value: number }) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><Icon className="size-5" /></span>
<div><div className="text-xl font-semibold tabular-nums">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div>
</div>
);
}
function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) {
return <label className="space-y-1.5 text-sm font-medium">{label}{required && <span className="ml-1 text-destructive">*</span>}{children}</label>;
}
function ModelCapabilityBadge({
icon: Icon,
label,
className,
}: {
icon?: typeof KeyRoundIcon;
label: string;
className: string;
}) {
return (
<span className={`inline-flex h-7 items-center gap-1 whitespace-nowrap rounded px-2 text-xs font-medium leading-none ${className}`}>
{Icon && <Icon aria-hidden className="block size-3.5 shrink-0" />}
<span className="block leading-none">{label}</span>
</span>
);
}
function showGlobalMessage(
theme: "success" | "error" | "warning",
content: string,
) {
// Schedule after the modal state update so the message is rendered above
// the page, rather than inside or behind a closing dialog.
window.setTimeout(() => {
MessagePlugin.closeAll();
void MessagePlugin[theme]({
content,
duration: 5_000,
placement: "top",
zIndex: 10_000,
attach: () => document.body,
});
}, 0);
}
function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
const isNew = model === null;
const [draft, setDraft] = useState<ModelDraft>(() => model ? draftFromModel(model) : emptyDraft());
const [advanced, setAdvanced] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; latency: number; message: string; preview?: string | null } | null>(null);
const save = useSaveManagedModel();
const test = useTestManagedModel();
const patch = (next: Partial<ModelDraft>) => setDraft((current) => ({ ...current, ...next }));
const handleTest = async () => {
try {
const payload = buildPayload(draft);
const result = await test.mutateAsync(payload);
setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
if (!result.success) return void showGlobalMessage("error", `连接失败:${result.message}`);
showGlobalMessage("success", `连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "连接测试失败");
}
};
const handleSave = async () => {
try {
const payload = buildPayload(draft);
if (!payload.name || !payload.use || !payload.model) {
return void showGlobalMessage("warning", "请填写模型标识、驱动与实际模型名");
}
await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
onClose();
showGlobalMessage(
"success",
isNew
? `模型“${payload.name}”已添加,所有页面的模型选择器已刷新。`
: `模型“${payload.name}”的配置已保存并立即生效。`,
);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "保存模型配置失败");
}
};
return (
<Dialog
visible
header={isNew ? "添加模型" : `编辑模型:${model?.display_name || model?.name}`}
placement="center"
width={860}
zIndex={3200}
confirmBtn={{ content: isNew ? "添加并启用" : "保存配置", theme: "primary" }}
cancelBtn="取消"
confirmLoading={save.isPending}
onClose={onClose}
onConfirm={() => void handleSave()}
onCancel={onClose}
>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-1 py-1 pr-3">
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
保存会直接更新当前部署的 <code className="rounded bg-background px-1 py-0.5">config.yaml</code> 模型段,自动备份并热刷新;不会中断已开始的会话。
</div>
<section className="grid gap-4 md:grid-cols-2">
<Field label="稳定标识" required><Input value={draft.name} disabled={!isNew} placeholder="例如 deepseek-chat" tips={isNew ? "保存后不可修改,用于智能体和运行配置引用。" : "稳定标识不可修改,避免已有配置失效。"} onChange={(value) => patch({ name: String(value) })} /></Field>
<Field label="显示名称"><Input value={draft.display_name ?? ""} placeholder="例如 DeepSeek Chat" onChange={(value) => patch({ display_name: String(value) })} /></Field>
<Field label="模型驱动" required><Select value={draft.use} options={PROVIDER_OPTIONS} filterable creatable inputProps={{ placeholder: "选择或输入 use 类路径" }} onChange={(value) => patch({ use: String(value) })} /></Field>
<Field label="实际模型名" required><Input value={draft.model} placeholder="例如 deepseek-chat" onChange={(value) => patch({ model: String(value) })} /></Field>
<Field label="Base URL"><Input value={draft.base_url ?? ""} type="url" placeholder="https://api.example.com/v1" onChange={(value) => patch({ base_url: String(value) })} /></Field>
<Field label="API Key"><Input value={draft.api_key} type="password" placeholder={draft.keep_existing_key && !draft.clear_api_key ? "已配置,留空则保持不变" : "可填写密钥或 $ENVIRONMENT_VARIABLE"} onChange={(value) => patch({ api_key: String(value), clear_api_key: false })} /></Field>
{draft.keep_existing_key && !draft.api_key && <div className="md:col-span-2 flex items-center gap-2 text-xs text-muted-foreground"><KeyRoundIcon className="size-3.5" />已配置密钥{model?.api_key_source ? `(环境变量 ${model.api_key_source})` : ""},不会回显。<TButton theme="default" variant="text" size="small" onClick={() => patch({ clear_api_key: !draft.clear_api_key })}>{draft.clear_api_key ? "撤销清除" : "清除密钥"}</TButton></div>}
<div className="md:col-span-2"><Field label="描述"><Textarea value={draft.description ?? ""} autosize={{ minRows: 2, maxRows: 4 }} placeholder="说明模型适用的任务与限制" onChange={(value) => patch({ description: String(value) })} /></Field></div>
</section>
<section className="rounded-lg border border-border p-4">
<div className="mb-2"><h3 className="font-medium">能力声明</h3><p className="mt-1 text-xs text-muted-foreground">这些标识决定哪些页面会向模型提供思考、视觉或工具调用入口。</p></div>
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm"><span className="flex items-center gap-2"><Switch value={draft.supports_thinking} onChange={(value) => patch({ supports_thinking: Boolean(value) })} />支持思考</span><span className="flex items-center gap-2"><Switch value={draft.supports_vision} onChange={(value) => patch({ supports_vision: Boolean(value) })} />支持视觉</span><span className="flex items-center gap-2"><Switch value={draft.supports_reasoning_effort} onChange={(value) => patch({ supports_reasoning_effort: Boolean(value) })} />推理档位</span></div>
</section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
</div>
</Dialog>
);
}
export default function ModelManagementPage() {
const { data, isLoading, error, refetch, isFetching } = useManagedModels();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const remove = useDeleteManagedModel();
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => {
if (!deleteTarget)
return;
try {
await remove.mutateAsync(deleteTarget.name);
const deletedName = deleteTarget.name;
setDeleteTarget(null);
showGlobalMessage("success", `模型“${deletedName}”已删除,配置已立即生效。`);
}
catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CpuIcon className="size-5" />
</span>
<h1 className="text-2xl font-semibold tracking-tight">模型配置</h1>
</div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}>
<RefreshCwIcon className="mr-1.5 size-4" />刷新
</TButton>
<TButton theme="primary" onClick={() => setEditor("new")}>
<PlusIcon className="mr-1.5 size-4" />添加模型
</TButton>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<StatCard icon={BotIcon} label="已配置模型" value={stats.total} />
<StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} />
<StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} />
</div>
<section className="rounded-xl border border-border bg-card shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{([
["all", "全部"],
["thinking", "支持思考"],
["vision", "支持视觉"],
] as const).map(([value, label]) => (
<TButton
key={value}
theme={filter === value ? "primary" : "default"}
variant={filter === value ? "base" : "text"}
size="small"
onClick={() => setFilter(value)}
>
{label}
</TButton>
))}
</div>
<Input
value={search}
placeholder="搜索名称、模型或驱动"
clearable
className="w-full sm:w-72"
onChange={(value) => setSearch(String(value))}
/>
</div>
{isLoading && (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…
</div>
)}
{error && (
<div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive">
<CircleAlertIcon className="mt-0.5 size-4" />{error.message}
</div>
)}
{!isLoading && !error && models.length === 0 && (
<div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<CpuIcon className="size-7" />没有匹配的模型配置
</div>
)}
{!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={"删除模型 " + (model.display_name || model.name)}
onClick={() => setDeleteTarget(model)}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
)}
</section>
{data?.config_path && (
<p className="text-xs text-muted-foreground">
当前配置文件:<code>{data.config_path}</code>
{data.updated_at ? " · 最近更新 " + new Date(data.updated_at).toLocaleString() : ""}
</p>
)}
</div>
</main>
</WorkspaceBody>
{editor !== null && (
<ModelEditor
key={editor === "new" ? "new" : editor.name}
model={editor === "new" ? null : editor}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<Dialog
visible
header="删除模型"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "删除", theme: "danger" }}
cancelBtn="取消"
confirmLoading={remove.isPending}
onClose={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onCancel={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onConfirm={() => void handleDelete()}
>
<p className="text-sm leading-6 text-muted-foreground">
确定要删除模型 <code className="rounded bg-muted px-1 py-0.5">{deleteTarget.name}</code> 吗?删除后会立即更新运行中的模型配置,依赖它的智能体或任务可能无法继续使用。
</p>
</Dialog>
)}
</WorkspaceContainer>
);
}
@@ -42,6 +42,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
@@ -785,6 +786,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/model-concurrency"
element={