* feat(canvas): 设计token系统 - 建立三层架构骨架 #94 #96 建立 Primitive→Semantic→Component 三层设计 token 体系,覆盖 9 个维度: 色彩/间距/字号/圆角/阴影/Elevation/动效/描边/不透明度。 - Primitive 层:纯值无语义,含 neutral 色板+节点类型色板+状态色、 4px 栅格间距、10档字号、10档圆角、阴影+glow、12档 z-index、 动效(duration+easing+delay+motion-scale)、描边、不透明度 - Semantic 层:引用 Primitive,随主题切换,含通用语义色、 Canvas 语义(--cn-*)、Aceternity spatial 语义、节点类型语义、状态语义 - Component 层:引用 Semantic,含 Dock/Node/Modal/Panel/Prompt面板/ 进度条/缩放控制专属 token - @theme inline 映射:节点类型色、状态色、Canvas 语义色、字号映射到 Tailwind utility - prefers-reduced-motion 三级降级(L0正常/L1简化/L2禁用) - AGENTS.md 新增 Design Token 使用规范,禁止新增裸 px 值 - 附带修复 canvas-drawing-excalidraw-editor.tsx TS7006 类型注解 - 附带修复 canvas-drawing-editor-modal.tsx TS2339 engine 字段缺失 Wayfinder 决策来源:#95 #96 #97 #98 #99 * fix(canvas): 设计token系统 - 修复CSS变量循环引用和命名兼容性 #94 #96 修复两个问题: 1. CSS变量循环引用:Primitive层 --color-node-*/--color-status-* 与 @theme inline 中的 --color-node-*/--color-status-* 同名,经 Semantic 层中转后形成循环(A→B→A)。改用 --palette- 前缀隔离 Primitive 色板, 打断循环链路。 2. 变量名小数点兼容性:--space-0.5/1.5/2.5/3.5 中的小数点被部分 CSS linter 误判为类选择器。改为 --space-half/-1-half/-2-half/-3-half, 保持语义清晰的同时提升工具链兼容性。 * refactor(canvas): 画布核心组件 - 迁移硬编码值到设计token #94 批次1:5个画布核心组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token,统一视觉语言: - canvas-node.tsx:text-[10/11/9/17px]→fs-tiny/label/micro/heading-lg, rounded-[18px]→node-radius - canvas-node-hover-toolbar.tsx:text-[11/17/10/9px]→fs-*, rounded-[14/9px]→dock-radius/dock-item-radius,z-[70]→z-panel-floating - canvas-toolbar.tsx:text-[9/10/11px]→fs-*, rounded-[17/16/11/10px]→panel-radius/dock-item-radius-*, z-[30/40]→z-toolbar/z-toolbar-floating - canvas-assistant-panel.tsx:text-[8/9/10/11px]→fs-* - canvas-script-node.tsx:text-[9/10/11px]→fs-* 视觉零回归(token值与原硬编码值一致),代码可维护性提升。 * refactor(canvas): 画布辅助组件 - 迁移硬编码值到设计token #94 批次2:6个画布辅助组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token: - canvas-style-picker-modal.tsx:text-[10/11px]→fs-tiny/label - canvas-file-drop-overlay.tsx:z-[110]→z-modal-overlay, rounded-[28/20px]→r-5xl/r-3xl - canvas-character-reference-node.tsx:text-[10px]→fs-tiny - canvas-drawing-editor-modal.tsx:text-[11px]→fs-label - canvas-video-prompt-tools.tsx:z-[1500]→z-toast, rounded-[12/10px]→r-lg/dock-item-radius - asset-picker-modal.tsx:text-[10px]→fs-tiny 视觉零回归(token值与原硬编码值一致)。 * refactor(*): 全项目硬编码值 - 批量迁移到设计token #94 批次3:全项目 .tsx 文件中 372 处裸 text-[Npx]/rounded-[Npx] 批量 替换为对应设计token,剩余 25 处无直接 token 对应的低频值保留。 替换覆盖(按频率): - text-[10px]→fs-tiny (132处) - text-[11px]→fs-label (100处) - text-[9px]→fs-micro (45处) - text-[12px]→fs-caption, text-[13/14px]→fs-body, text-[15px]→fs-body-lg - text-[18px]→fs-heading-lg, text-[22px]→fs-title, text-[8px]→fs-micro - rounded-[9/10px]→dock-item-radius, rounded-[12px]→r-lg - rounded-[14px]→dock-radius, rounded-[16px]→r-2xl - rounded-[17px]→panel-radius, rounded-[8px]→r-md 涉及 components/ 和 pages/ 下所有 .tsx 文件。 视觉零回归(token值与原硬编码值一致)。 * feat(canvas): 节点视觉层次规范 - 色带+状态徽章+四态递进 #97 #94 基于 Wayfinder 地图 #97 决议执行节点视觉重设计: 1. 节点类型色带(决策1) - 新增 nodeTypeColorVar() 函数,10种节点类型对应色带色 - 节点左侧3px色条,引用 --cn-type-* token - 远距离最强信号,用户扫视画布即可识别节点类型 2. 节点状态徽章(决策2) - 新增 NodeStatusBadge 组件,loading/success/error 三态 - 左上角圆角徽章,backdrop-blur 玻璃质感 - success 态 2s 后自动淡出(canvas-status-success-fade 动画) 3. 四态递进视觉权重(决策3) - idle:无额外阴影 - hover:轻微抬升阴影(16px 48px) - selected:2px 边框环(accent.primary 40% 透明度) - selected-primary:4px 软色环 + resize handle - active:6px 外发光环 + inner glow(最强强调) - 120-150ms ease-out 过渡 4. 阴影迁移 - boxShadow 从硬编码改为分层语义化表达 - 边框色 hover 态使用 color-mix 动态加深 Closes #97 --------- Co-authored-by: sikongyue <3155157671@qq.com>
274 lines
16 KiB
TypeScript
274 lines
16 KiB
TypeScript
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||
import { Check, ChevronDown, Coins, Cpu } from "lucide-react";
|
||
import { Popover } from "antd";
|
||
|
||
import { canvasThemes } from "@/lib/canvas-theme";
|
||
import { cn } from "@/lib/utils";
|
||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||
import { useThemeStore } from "@/stores/use-theme-store";
|
||
|
||
type ModelPickerProps = {
|
||
config: AiConfig;
|
||
value?: string;
|
||
onChange: (model: string) => void;
|
||
capability?: ModelCapability;
|
||
className?: string;
|
||
fullWidth?: boolean;
|
||
placeholder?: string;
|
||
onMissingConfig?: () => void;
|
||
showSelectedPrice?: boolean;
|
||
variant?: "default" | "creation";
|
||
};
|
||
|
||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) {
|
||
const pickerId = useId();
|
||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||
const [open, setOpen] = useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const options = useMemo(
|
||
() => {
|
||
const filtered = selectableModelsByCapability(config, capability);
|
||
const current = value?.trim();
|
||
const currentIncluded = current ? filtered.includes(current) : true;
|
||
return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model))));
|
||
},
|
||
[capability, config, value],
|
||
);
|
||
const optionGroups = useMemo(() => {
|
||
const channelGroups = config.channels
|
||
.map((channel) => ({
|
||
key: channel.id,
|
||
label: channel.name || "未命名渠道",
|
||
scope: channel.scope === "system" ? "系统渠道" : "自定义渠道",
|
||
models: options.filter((model) => resolveModelChannel(config, model).id === channel.id),
|
||
}))
|
||
.filter((group) => group.models.length);
|
||
const groupedModels = new Set(channelGroups.flatMap((group) => group.models));
|
||
const ungroupedModels = options.filter((model) => !groupedModels.has(model));
|
||
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: ungroupedModels }] : channelGroups;
|
||
}, [config, options]);
|
||
const current = value || "";
|
||
const currentPrice = modelMenuPrice(config, current);
|
||
const creationVariant = variant === "creation";
|
||
|
||
useEffect(() => {
|
||
const closeOtherPicker = (event: Event) => {
|
||
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
|
||
};
|
||
window.addEventListener("model-picker-open", closeOtherPicker);
|
||
return () => window.removeEventListener("model-picker-open", closeOtherPicker);
|
||
}, [pickerId]);
|
||
|
||
const setPickerOpen = (nextOpen: boolean) => {
|
||
if (nextOpen && !options.length && config.channelMode === "local") onMissingConfig?.();
|
||
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
|
||
setOpen(nextOpen);
|
||
};
|
||
const focusMenuOption = (last = false) => {
|
||
window.requestAnimationFrame(() => {
|
||
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
|
||
target?.focus();
|
||
});
|
||
};
|
||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||
event.preventDefault();
|
||
setPickerOpen(true);
|
||
focusMenuOption(event.key === "ArrowUp");
|
||
};
|
||
const handleMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
return;
|
||
}
|
||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
|
||
if (!buttons.length) return;
|
||
event.preventDefault();
|
||
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowUp" ? Math.max(0, activeIndex - 1) : Math.min(buttons.length - 1, activeIndex + 1);
|
||
buttons[nextIndex]?.focus();
|
||
};
|
||
const content = (
|
||
<div
|
||
ref={menuRef}
|
||
data-canvas-no-zoom
|
||
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[320px]")}
|
||
style={{ background: theme.node.panel, color: theme.node.text }}
|
||
role="listbox"
|
||
aria-label={placeholder}
|
||
onKeyDown={handleMenuKeyDown}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{creationVariant ? <div className="creation-model-picker-heading"><span>选择模型</span>{current ? <strong>{modelDisplayName(config, current)}</strong> : null}</div> : null}
|
||
{optionGroups.length ? optionGroups.map((group) => (
|
||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||
<span className="truncate">{group.label}</span>
|
||
<span className="shrink-0" style={{ color: theme.node.muted }}>{group.scope}</span>
|
||
</div>
|
||
<div className="grid min-w-0 gap-1">
|
||
{group.models.map((model) => {
|
||
const selected = model === current;
|
||
return (
|
||
<button
|
||
key={model}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={selected}
|
||
className="canvas-model-picker-option"
|
||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||
onClick={() => {
|
||
onChange(model);
|
||
setOpen(false);
|
||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||
}}
|
||
>
|
||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} />
|
||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)) : <div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>{emptyModelLabel(config, capability)}</div>}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className={cn(fullWidth ? "w-full min-w-0" : "w-fit max-w-full")}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Popover
|
||
open={open}
|
||
onOpenChange={setPickerOpen}
|
||
trigger="click"
|
||
placement="bottomLeft"
|
||
arrow={false}
|
||
content={content}
|
||
classNames={{
|
||
root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover"),
|
||
container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"),
|
||
content: "canvas-composer-popover-content",
|
||
}}
|
||
>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
className={cn("canvas-composer-model-picker", fullWidth ? "w-full" : "min-w-36 max-w-full", className)}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-label={placeholder}
|
||
title={current ? modelOptionLabel(config, current) : placeholder}
|
||
onKeyDown={handleTriggerKeyDown}
|
||
>
|
||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}><ModelIcon model={current} /></span>
|
||
<span className="min-w-0 flex-1 truncate">{current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder}</span>
|
||
{showSelectedPrice ? <ModelPrice price={currentPrice} compact /> : null}
|
||
</span>
|
||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||
</button>
|
||
</Popover>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||
if (capability && config.models.length) return `当前渠道没有匹配的${label}模型`;
|
||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||
}
|
||
|
||
function ModelLabel({ config, model, capability, theme, creationVariant }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean }) {
|
||
const meta = modelMenuMeta(model, capability);
|
||
return (
|
||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
|
||
<ModelIcon model={model} />
|
||
</span>
|
||
<span className="min-w-0 flex-1 overflow-hidden">
|
||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||
</span>
|
||
<ModelPrice price={modelMenuPrice(config, model)} />
|
||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function modelMenuPrice(config: AiConfig, model: string): { value: number; unit: "次" | "秒" } | null | undefined {
|
||
if (!model) return undefined;
|
||
const channel = resolveModelChannel(config, model);
|
||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||
if (!cost) return channel.scope === "system" ? null : undefined;
|
||
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||
}
|
||
|
||
function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) {
|
||
if (price === undefined) return null;
|
||
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
|
||
return (
|
||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
|
||
<Coins className="size-3" />
|
||
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
|
||
const name = modelOptionName(model).toLowerCase();
|
||
if (capability === "image") {
|
||
if (name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen")) return { description: "Gemini 高质量图片生成,适合角色和商业成片" };
|
||
if (name.includes("nano") || name.includes("pro")) return { description: "高质量图片生成,适合角色和商业成片" };
|
||
if (name.includes("seedream")) return { description: "快速出图,适合批量探索风格" };
|
||
if (name.includes("gpt") || name.includes("image")) return { description: "通用图片模型,提示词理解稳定" };
|
||
return { description: "图片生成模型" };
|
||
}
|
||
if (capability === "video") {
|
||
if (name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) return { description: "Gemini 镜头生成与图生视频,适合成片流程", time: "3m" };
|
||
if (name.includes("seedance") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" };
|
||
return { description: "视频生成模型", time: "3m" };
|
||
}
|
||
if (capability === "audio") return { description: "语音、音效或音乐生成", time: "20s" };
|
||
if (name.includes("claude")) return { description: "长文本、推理与创意写作", time: "10s" };
|
||
if (name.includes("gemini")) return { description: "多模态理解与快速文本生成", time: "10s" };
|
||
if (name.includes("deepseek")) return { description: "推理、代码和结构化文本", time: "10s" };
|
||
return { description: capability === "text" ? "文本生成模型" : "当前渠道模型", time: "10s" };
|
||
}
|
||
|
||
export function ModelIcon({ model }: { model: string }) {
|
||
const icon = resolveModelIcon(modelOptionName(model));
|
||
return icon ? <img src={icon} alt="" className="size-3.5 shrink-0 dark:invert" /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
|
||
}
|
||
|
||
export function resolveModelIcon(model: string) {
|
||
const name = model.toLowerCase();
|
||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||
// Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。
|
||
if (
|
||
name.includes("gemini") ||
|
||
name.includes("google") ||
|
||
name.includes("nano banana") ||
|
||
name.includes("nanobanana") ||
|
||
name.includes("imagen") ||
|
||
name.includes("veo") ||
|
||
name.includes("omni flash") ||
|
||
name.includes("omni-flash")
|
||
) {
|
||
return "/icons/gemini.svg";
|
||
}
|
||
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
|
||
if (name.includes("grok")) return "/icons/grok.svg";
|
||
if (name.includes("deepseek")) return "/icons/deepseek.svg";
|
||
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
|
||
return "";
|
||
}
|