refactor(canvas): 优化模型选择器和定时同步配置

- 将模型选择器替换为统一的 shadcn 风格下拉组件
- 移除原生数字输入框的步进箭头样式
- 更新默认定时同步配置为每5分钟执行
- 修复画布助手和节点配置中的模型选择逻辑
- 统一各页面中的模型选择器样式和交互行为
This commit is contained in:
HouYunFei
2026-05-23 15:56:17 +08:00
parent c9682481d7
commit 593feddc3f
13 changed files with 303 additions and 213 deletions
+52 -33
View File
@@ -1,9 +1,10 @@
"use client";
import { Select } from "antd";
import { useMemo, useState } from "react";
import { Cpu } from "lucide-react";
import styles from "./model-picker.module.css";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { AiConfig } from "@/stores/use-config-store";
type ModelPickerProps = {
@@ -17,50 +18,68 @@ type ModelPickerProps = {
};
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: <ModelLabel model={model} /> }));
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
const [open, setOpen] = useState(false);
const options = useMemo(() => Array.from(new Set([value, ...config.models].filter(Boolean))), [config.models, value]);
const current = value || "";
return (
<Select
showSearch
className={`canvas-control-select ${className || ""}`}
classNames={{ popup: { root: styles.dropdown } }}
popupMatchSelectWidth
popupRender={(menu) => (
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{menu}
</div>
)}
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
value={value || undefined}
placeholder={placeholder}
options={options}
notFoundContent="请先到配置里拉取模型列表"
onChange={onChange}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
if (!options.length) onMissingConfig?.();
open={open}
value={current}
onOpenChange={(nextOpen) => {
if (nextOpen && !options.length) {
onMissingConfig?.();
return;
}
setOpen(nextOpen);
}}
filterOption={(input, option) =>
String(option?.value || "")
.toLowerCase()
.includes(input.toLowerCase())
}
/>
onValueChange={onChange}
>
<SelectTrigger
className={cn(
"canvas-composer-model-picker h-8 w-fit max-w-full gap-2 rounded-full border border-input bg-transparent px-3 text-sm font-normal shadow-sm transition-colors",
fullWidth ? "w-full min-w-0 justify-start" : "min-w-[9rem] justify-start",
"data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/20",
className,
)}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title={current || placeholder}
>
<ModelIcon model={current} />
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
</SelectTrigger>
<SelectContent className="z-50 w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl" position="popper" align="start">
{options.length ? (
options.map((model) => (
<SelectItem key={model} value={model} textValue={model}>
<ModelLabel model={model} />
</SelectItem>
))
) : (
<SelectItem value="__empty__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
);
}
function ModelLabel({ model }: { model: string }) {
const icon = resolveModelIcon(model);
return (
<span className="model-picker-label flex min-w-0 items-center gap-2">
{icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />}
<span className="model-picker-label-text truncate">{model}</span>
<span className="flex min-w-0 items-center gap-2">
<ModelIcon model={model} />
<span className="truncate">{model}</span>
</span>
);
}
function ModelIcon({ model }: { model: string }) {
const icon = resolveModelIcon(model);
return icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
}
function resolveModelIcon(model: string) {
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";