Files
deerflow-code/frontend-web/src/pages/ModelManagementPage.tsx
T
董宏凯andCursor a5a9c952e8 feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写
新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 21:46:04 +08:00

607 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
GripVerticalIcon,
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,
useReorderManagedModels,
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 SortableModelCard({
model,
isDefault,
dragEnabled,
onConfigure,
onDelete,
}: {
model: ManagedModel;
isDefault: boolean;
dragEnabled: boolean;
onConfigure: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: model.name,
disabled: !dragEnabled,
});
return (
<article
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
}}
className={`rounded-lg border border-border bg-background p-4 transition-[border-color,box-shadow] ${
dragEnabled ? "hover:border-primary/35 hover:shadow-sm" : ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<button
type="button"
className={`mt-0.5 shrink-0 rounded p-1 text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragEnabled
? "cursor-grab touch-none hover:bg-muted hover:text-foreground active:cursor-grabbing"
: "cursor-not-allowed opacity-40"
}`}
title={dragEnabled ? "拖动调整顺序" : "请清空搜索和筛选后再调整顺序"}
aria-label={`${model.display_name || model.name}:${dragEnabled ? "拖动调整顺序" : "当前不可调整顺序"}`}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
<GripVerticalIcon className="size-4" aria-hidden />
</button>
<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>
{isDefault && <Tag theme="primary" variant="light" size="small">默认模型</Tag>}
<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>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={onConfigure}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={`删除模型 ${model.display_name || model.name}`}
onClick={onDelete}
>
<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>
);
}
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 [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const remove = useDeleteManagedModel();
const reorder = useReorderManagedModels();
const sourceModels = data?.models ?? [];
const orderedModels = useMemo(() => {
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
const byName = new Map(sourceModels.map((model) => [model.name, model]));
const reordered = pendingOrder.map((name) => byName.get(name)).filter((model): model is ManagedModel => Boolean(model));
return reordered.length === sourceModels.length ? reordered : sourceModels;
}, [pendingOrder, sourceModels]);
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return orderedModels.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;
});
}, [filter, orderedModels, 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 : "删除模型失败,请稍后重试。");
}
};
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
setActiveDragId(null);
if (!dragEnabled || !over || active.id === over.id) return;
const oldIndex = orderedModels.findIndex((model) => model.name === active.id);
const newIndex = orderedModels.findIndex((model) => model.name === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextOrder = arrayMove(orderedModels.map((model) => model.name), oldIndex, newIndex);
setPendingOrder(nextOrder);
try {
await reorder.mutateAsync(nextOrder);
showGlobalMessage("success", `模型顺序已更新,“${nextOrder[0]}”现在是默认模型。`);
} catch (error) {
setPendingOrder(null);
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 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setActiveDragId(String(active.id))}
onDragCancel={() => setActiveDragId(null)}
onDragEnd={(event) => void handleDragEnd(event)}
>
<div className="border-b bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
拖动卡片左侧手柄即可调整顺序;排在第一位的模型会作为默认模型。
{!dragEnabled && " 请清空搜索和筛选条件后再调整顺序。"}
</div>
<SortableContext items={models.map((model) => model.name)} strategy={rectSortingStrategy}>
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<SortableModelCard
key={model.name}
model={model}
isDefault={model.name === orderedModels[0]?.name}
dragEnabled={dragEnabled}
onConfigure={() => setEditor(model)}
onDelete={() => setDeleteTarget(model)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId ? (
<div className="rounded-lg border border-primary/30 bg-background px-4 py-3 text-sm font-medium shadow-lg">
{orderedModels.find((model) => model.name === activeDragId)?.display_name || activeDragId}
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</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>
);
}