新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
607 lines
29 KiB
TypeScript
607 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|