feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容

This commit is contained in:
ddCat
2026-08-28 16:24:58 +08:00
parent 913cf4b304
commit ab89c05362
52 changed files with 2344 additions and 500 deletions

View File

@@ -16,13 +16,14 @@ export type InsertAssetPayload =
type Props = {
open: boolean;
onInsert: (payload: InsertAssetPayload) => void;
multiple?: boolean;
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
onClose: () => void;
};
const categoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
const assets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
@@ -38,22 +39,6 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
...externalAssetSources.items,
], [externalAssetSources.items, insertableAssets]);
const insert = (id: string) => {
const pickerItem = items.find((item) => item.id === id);
if (pickerItem?.external) {
onInsert(externalAssetToInsertPayload(pickerItem.external));
onClose();
return;
}
const asset = insertableAssets.find((item) => item.id === id);
if (!asset) throw new Error("所选素材已不存在,请重新选择");
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
else if (asset.kind === "audio") onInsert({ kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
else if (asset.kind === "video") onInsert({ kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
else onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id });
onClose();
};
return (
<AssetLibraryPickerModal
open={open}
@@ -61,15 +46,39 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
folders={externalAssetSources.folders}
footerNote={externalAssetSources.error || undefined}
multiple={false}
confirmLabel={() => "插入所选素材"}
multiple={multiple}
confirmLabel={(count) => `插入已选素材${count ? `(${count})` : ""}`}
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
onClose={onClose}
onConfirm={(ids) => insert(ids[0])}
onConfirm={async (ids) => {
await onInsert(assetPickerItemsToInsertPayloads(ids, items));
onClose();
}}
/>
);
}
export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibraryPickerItem[]): InsertAssetPayload[] {
const itemsById = new Map(items.map((item) => [item.id, item]));
return ids.map((id) => {
const pickerItem = itemsById.get(id);
if (!pickerItem) throw new Error("所选素材已不存在,请重新选择");
if (pickerItem.external) return externalAssetToInsertPayload(pickerItem.external);
const asset = pickerItem.asset;
if (!asset || (asset.kind !== "text" && asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio")) {
throw new Error(`“${pickerItem.title}”不是可插入画布的素材`);
}
return localAssetToInsertPayload(asset);
});
}
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
}
export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload {
const item = reference.item;
const url = item.fileUrl || "";

View File

@@ -1,4 +1,4 @@
import { AnimatePresence, useReducedMotion } from "motion/react";
import { AnimatePresence } from "motion/react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
@@ -93,7 +93,6 @@ export function CanvasNodeContextMenu({
onToggleFrame,
}: CanvasNodeContextMenuProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const [addOpen, setAddOpen] = useState(false);
const [categoryOpen, setCategoryOpen] = useState(false);
@@ -148,8 +147,8 @@ export function CanvasNodeContextMenu({
<SpotlightSurface
spotlightColor={theme.toolbar.itemHover}
data-canvas-context-menu
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, x: -3, y: -3 }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel fixed z-[var(--z-popover)] flex w-[224px] max-h-[calc(100vh-56px)] origin-top-left flex-col overflow-hidden rounded-xl border p-1.5 backdrop-blur-2xl"
style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
@@ -245,7 +244,6 @@ export function CanvasNodeContextMenu({
parentPosition={position}
workspaceMode={workspaceMode}
isProjectLinked={isProjectLinked}
reducedMotion={Boolean(reducedMotion)}
onAddNode={(type) => runAction(() => onAddNode(type))}
onAddFolder={() => runAction(onAddFolder)}
onChooseStyle={() => runAction(onChooseStyle)}
@@ -260,7 +258,7 @@ export function CanvasNodeContextMenu({
);
}
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const installations = usePluginStore((state) => state.installations);
const pluginStates = usePluginStore((state) => state.pluginStates);
@@ -299,9 +297,9 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
return (
<SpotlightSurface
spotlightColor={theme.toolbar.itemHover}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -5 : 5, scale: 0.97 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
@@ -342,7 +340,7 @@ function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, act
<span className="canvas-menu-item-icon grid size-7 shrink-0 place-items-center rounded-md border opacity-75 group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: danger ? `${theme.accent.danger}12` : theme.spatial.surface, borderColor: danger ? `${theme.accent.danger}33` : theme.toolbar.border, color: danger ? theme.accent.danger : theme.node.text }}>{icon}</span>
<span className="min-w-0 flex-1"><span className="flex items-center gap-1 text-xs font-medium"><span className="truncate">{label}</span>{badge ? <span className="rounded-full border px-1 py-0.5 text-[var(--fs-nano)] font-bold" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{badge}</span> : null}</span>{detail ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{detail}</span> : null}</span>
{shortcut ? <span className="shrink-0 text-[var(--fs-micro)] opacity-38">{shortcut}</span> : null}
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45 transition-transform group-hover:translate-x-0.5" /> : null}
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45" /> : null}
</button>
);
}

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Button, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { CheckCircle2, Copy, ExternalLink, FolderOpen, History, LoaderCircle, PlugZap, Plus, RefreshCw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -25,6 +25,7 @@ import {
import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
@@ -35,12 +36,6 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const AGENT_CONNECT_STEPS = [
{ title: "从仓库安装插件", text: "插件暂未上架公共目录,请按项目 README 添加仓库 marketplace;安装后新建 Codex 对话。" },
{ title: "自动连接本机 Runtime", text: "首次使用时,已信任的 Framefield 网页会自动建立当前浏览器的本机安全会话。" },
{ title: "打开画布连接", text: "回到这里点击连接,画布会复用同一个本机 Runtime 安全会话。" },
];
type AgentEventPayload = {
agent?: string;
type?: string;
@@ -755,7 +750,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
</Tooltip>
</div>
{activeTab === "history" ? (
{!connected ? (
<AgentConnectView
theme={theme}
enabled={enabled}
activity={activity}
connectError={connectError}
onToggleEnabled={toggleAgentConnection}
/>
) : activeTab === "history" ? (
<AgentHistoryView
theme={theme}
threads={threads}
@@ -925,55 +928,54 @@ function AgentLogView({
function AgentConnectView({
theme,
enabled,
connected,
activity,
connectError,
onToggleEnabled,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
enabled: boolean;
connected: boolean;
activity: string;
connectError: string;
onToggleEnabled: () => void;
}) {
const statusText = canvasAgentConnectionStatusText({ enabled, connected, activity, connectError });
const statusColor = statusText === "连接失败" ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
const { message } = App.useApp();
const [platform, setPlatform] = useState<LocalAgentSetupPlatform>(() => detectLocalAgentSetupPlatform());
const origin = typeof window === "undefined" ? "http://localhost:3000" : window.location.origin;
const commands = buildLocalAgentSetupCommands(origin, platform);
const statusText = canvasAgentConnectionStatusText({ enabled, connected: false, activity, connectError });
const statusColor = connectError ? "#dc2626" : enabled ? "#d97706" : theme.node.muted;
const copyCommand = async (value: string, label: string) => {
if (await copyToClipboard(value)) {
message.success(`${label}已复制`);
return;
}
message.warning("复制失败,请手动复制");
};
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="mx-auto w-full max-w-[620px] space-y-5 pb-4">
<div>
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
安装仓库自带的 Codex 插件后,画布会优先自动连接本机 Agent。
在这台电脑启动 Canvas Agent 后,网页会通过 127.0.0.1 建立本机安全连接。连接成功后,这里会自动恢复原对话。
</div>
</div>
<div className="space-y-2">
{AGENT_CONNECT_STEPS.map((step) => (
<div key={step.title} className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
{step.text}
</div>
</div>
))}
</div>
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
{enabled ? <LoaderCircle className="size-3 shrink-0 animate-spin" /> : <span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />}
<span className="truncate">{statusText}</span>
</span>
</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
自动复用“设置 → 本机工具”建立的短期安全会话。
{enabled ? "正在检测本机 127.0.0.1:17371;服务启动后会自动连接。" : "先完成下面两步,再点击连接。"}
</div>
</div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
{enabled ? "断开" : "连接"}
{enabled ? "停止重试" : connectError ? "重新连接" : "开始连接"}
</Button>
</div>
<div className="mt-3 grid gap-2.5">
@@ -984,11 +986,88 @@ function AgentConnectView({
) : null}
</div>
</div>
<div>
<section className="border-b pb-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>1</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium leading-5">安装本机 Runtime</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>需要 Git、Node.js 18+。当前版本从 GitHub 源码安装。</div>
</div>
<Segmented
size="small"
value={platform}
options={[
{ label: "macOS / Linux", value: "unix" },
{ label: "Windows", value: "windows" },
]}
onChange={(value) => setPlatform(value as LocalAgentSetupPlatform)}
/>
</div>
<CommandBlock value={commands.install} theme={theme} onCopy={() => copyCommand(commands.install, "安装命令")} />
</div>
</div>
</section>
<section className="border-b py-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>2</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5">启动并授权当前站点</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
命令已绑定 <span className="font-mono">{origin}</span>。保持终端运行,再回到上方开始连接。
</div>
<CommandBlock value={commands.start} theme={theme} onCopy={() => copyCommand(commands.start, "启动命令")} />
</div>
</div>
</section>
<section className="pt-4">
<div className="flex items-start gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.node.fill, color: theme.node.text }}><CheckCircle2 className="size-3.5" /></span>
<div className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
<div className="text-sm font-medium" style={{ color: theme.node.text }}>当前支持范围</div>
<div className="mt-1">网页侧边栏使用 Codex;外部 MCP 已提供 Codex 与 Claude Code 接入。Hermes、WorkBuddy 尚未验证。Codex 需要已登录或配置可用凭据。</div>
<Button type="link" size="small" className="mt-1 !h-7 !px-0" href="https://github.com/ddcat-ai/open-ai-canvas/tree/main/canvas-agent" target="_blank" rel="noreferrer" icon={<ExternalLink className="size-3.5" />}>
查看完整说明
</Button>
</div>
</div>
</section>
</div>
</div>
</div>
);
}
function CommandBlock({
value,
theme,
onCopy,
}: {
value: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onCopy: () => void | Promise<void>;
}) {
return (
<div className="mt-3 overflow-hidden rounded-md" style={{ background: "rgba(0,0,0,.22)" }}>
<div className="flex items-center justify-between gap-2 border-b px-3 py-1.5" style={{ borderColor: theme.node.stroke }}>
<span className="inline-flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<Terminal className="size-3.5" />
终端命令
</span>
<Button type="text" size="small" className="!h-6 !px-1.5" icon={<Copy className="size-3.5" />} onClick={() => void onCopy()} aria-label="复制终端命令">
复制
</Button>
</div>
<pre className="thin-scrollbar overflow-x-auto whitespace-pre p-3 font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{value}</pre>
</div>
);
}
function AgentHistoryView({
theme,
threads,

View File

@@ -1,49 +1,160 @@
import { useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { Input, Modal } from "antd";
import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react";
import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Video } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
const RESULT_LIST_ID = "canvas-node-search-results";
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
const [query, setQuery] = useState("");
const results = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase();
if (!keyword) return nodes.slice(0, 40);
return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
.some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80);
}, [nodes, query]);
const [activeIndex, setActiveIndex] = useState(0);
const results = useMemo(() => searchCanvasNodes(nodes, query), [nodes, query]);
useEffect(() => setActiveIndex(0), [query, open]);
useEffect(() => setActiveIndex((current) => Math.min(current, Math.max(0, results.length - 1))), [results.length]);
const focusNode = (node: CanvasNodeData | undefined) => {
if (!node) return;
onFocus(node.id);
onClose();
};
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) => Math.min(results.length - 1, current + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => Math.max(0, current - 1));
} else if (event.key === "Enter") {
event.preventDefault();
focusNode(results[activeIndex]);
}
};
return (
<Modal title="搜索画布节点" open={open} footer={null} width="min(680px, 90vw)" onCancel={onClose} afterClose={() => setQuery("")} centered>
<Input autoFocus allowClear value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-4 opacity-50" />} placeholder="搜索节点、章节、镜头、模型或标签" />
<div className="thin-scrollbar mt-3 max-h-[50vh] overflow-y-auto border-t pt-2">
{results.length ? results.map((node) => (
<button
<Modal
title="搜索画布节点"
open={open}
footer={null}
width="min(760px, calc(100vw - 32px))"
onCancel={onClose}
afterClose={() => { setQuery(""); setActiveIndex(0); }}
styles={{ body: { paddingTop: 8 } }}
centered
>
<Input
autoFocus
allowClear
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleInputKeyDown}
prefix={<Search className="size-4 opacity-50" />}
placeholder="搜索节点、章节、镜头、模型或标签…"
aria-label="搜索画布节点"
aria-controls={RESULT_LIST_ID}
aria-activedescendant={results[activeIndex] ? `canvas-node-search-result-${results[activeIndex].id}` : undefined}
/>
<div className="mt-2 flex min-h-7 items-center justify-between gap-3 border-b pb-2 text-[11px] tracking-[0.015em] text-foreground/45">
<span className="tabular-nums">{query.trim() ? `找到 ${results.length} 个节点` : `最近编辑 · ${results.length} 个节点`}</span>
<span className="hidden sm:inline">↑↓ 选择 · Enter 定位 · Esc 关闭</span>
</div>
<div id={RESULT_LIST_ID} role="listbox" aria-label="画布节点搜索结果" className="thin-scrollbar max-h-[54vh] overflow-y-auto overscroll-contain py-1.5">
{results.length ? results.map((node, index) => (
<CanvasNodeSearchResult
key={node.id}
type="button"
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => { onFocus(node.id); onClose(); }}
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-black/5 dark:bg-white/10">
{node.type === CanvasNodeType.Image ? <Image className="size-4" /> : node.type === CanvasNodeType.Video ? <Video className="size-4" /> : node.type === CanvasNodeType.Drawing ? <Pencil className="size-4" /> : <FileText className="size-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{node.title}</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs opacity-55">
{node.metadata?.chapterTitle ? <BookOpenText className="size-3 shrink-0" /> : null}
<span className="truncate">{nodeContextLabel(node)}</span>
</span>
</span>
</button>
node={node}
active={index === activeIndex}
onActivate={() => setActiveIndex(index)}
onSelect={() => focusNode(node)}
/>
)) : <WorkspaceState icon="canvas" compact title="没有匹配节点" description="换一个节点、章节、镜头、模型或标签继续搜索。" />}
</div>
</Modal>
);
}
function nodeContextLabel(node: CanvasNodeData) {
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type);
const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, active, onActivate, onSelect }: { node: CanvasNodeData; active: boolean; onActivate: () => void; onSelect: () => void }) {
const times = canvasNodeSearchTimes(node);
const materialSummary = canvasNodeMaterialSummary(node);
const context = canvasNodeSearchContext(node);
return (
<button
id={`canvas-node-search-result-${node.id}`}
type="button"
role="option"
aria-selected={active}
className="grid min-h-14 w-full grid-cols-[64px_minmax(0,1fr)_auto] items-center gap-2.5 rounded-[var(--r-md)] px-2 py-1.5 text-left transition-[background-color,box-shadow] hover:bg-[var(--surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 max-sm:grid-cols-[56px_minmax(0,1fr)]"
style={{
background: active ? "var(--surface-active)" : undefined,
boxShadow: active ? "inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)" : undefined,
contentVisibility: "auto",
containIntrinsicSize: "56px",
}}
onMouseEnter={onActivate}
onFocus={onActivate}
onClick={onSelect}
>
<CanvasNodeSearchThumbnail node={node} />
<span className="min-w-0 self-center">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-foreground" title={node.title}>{node.title || getNodeListLabel(node.type)}</span>
<span className="max-w-[210px] shrink truncate rounded-full bg-foreground/[0.055] px-2 py-0.5 text-[10px] font-medium tracking-[0.02em] text-foreground/50" title={materialSummary}>{materialSummary}</span>
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-foreground/45">
{node.metadata?.chapterTitle ? <BookOpenText className="size-3 shrink-0" /> : null}
<span className="truncate" title={context}>{context}</span>
</span>
<span className="mt-1 hidden items-center gap-3 text-[10px] tabular-nums tracking-[0.015em] text-foreground/40 max-sm:flex">
<time dateTime={times.createdAt} title={fullTime(times.createdAt)}>创建 {times.createdLabel}</time>
<time dateTime={times.updatedAt} title={fullTime(times.updatedAt)}>编辑 {times.updatedLabel}</time>
</span>
</span>
<span className="grid min-w-[112px] gap-0.5 border-l pl-3 text-[10px] tabular-nums tracking-[0.015em] text-foreground/40 max-sm:hidden" style={{ borderColor: "color-mix(in srgb, var(--foreground) 8%, transparent)" }}>
<span className="flex items-center gap-1.5"><Clock3 className="size-3" /><span>创建</span><time className="ml-auto" dateTime={times.createdAt} title={fullTime(times.createdAt)}>{times.createdLabel}</time></span>
<span className="flex items-center gap-1.5"><Pencil className="size-3" /><span>编辑</span><time className="ml-auto" dateTime={times.updatedAt} title={fullTime(times.updatedAt)}>{times.updatedLabel}</time></span>
</span>
</button>
);
});
function CanvasNodeSearchThumbnail({ node }: { node: CanvasNodeData }) {
const [failed, setFailed] = useState(false);
const mediaSource = node.metadata?.drawingPreviewUrl
|| node.metadata?.characterCoverUrl
|| node.metadata?.folder?.themeCover
|| ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined);
const commonClass = "h-11 w-16 rounded-[var(--r-sm)] border object-cover";
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
if (mediaSource && !failed && node.type === CanvasNodeType.Video) {
return <video src={mediaSource} muted playsInline preload="metadata" aria-label={`${node.title} 视频缩略图`} className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}
if (mediaSource && !failed) {
return <img src={mediaSource} alt="" width={64} height={44} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}
const textPreview = node.metadata?.previewContent || node.metadata?.composerContent || node.metadata?.prompt || node.metadata?.content;
if (textPreview && (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script)) {
return <span aria-hidden="true" className="line-clamp-3 h-11 w-16 overflow-hidden rounded-[var(--r-sm)] border px-1.5 py-1 text-[8px] leading-[11px] text-foreground/55" style={commonStyle}>{textPreview}</span>;
}
return (
<span aria-hidden="true" className="grid h-11 w-16 place-items-center rounded-[var(--r-sm)] border text-foreground/48" style={commonStyle}>
{node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama ? <Image className="size-4" />
: node.type === CanvasNodeType.Video ? <Video className="size-4" />
: node.type === CanvasNodeType.Audio ? <AudioLines className="size-4" />
: node.type === CanvasNodeType.Drawing ? <Pencil className="size-4" />
: <FileText className="size-4" />}
</span>
);
}
function fullTime(value?: string) {
if (!value) return "时间未记录";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}

View File

@@ -5,7 +5,7 @@ import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, resolveVideoResolutionValue } from "@/lib/model-capabilities";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -22,9 +22,11 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
const videoProfile = modelCapabilityConfigFor(config, config.model).video;
const resolutionSupported = Boolean(videoProfile?.resolutions.length);
const resolution = videoProfile ? resolveVideoResolutionValue(videoProfile, config.vquality) : "";
const summary = [
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
...(resolutionSupported ? [videoResolutionLabel(resolution)] : []),
videoSizeLabel(config.size),
videoSecondsLabel(config.videoSeconds),
].join(" · ");

View File

@@ -4,8 +4,8 @@ import { Switch } from "antd";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { formatVideoResolutionLabel, normalizeVideoDuration, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
const sizeOptions = [
@@ -38,8 +38,8 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
const seconds = normalizeVideoDuration(config.videoSeconds);
const size = normalizeVideoSizeValue(config.size);
const dimensions = readSizeDimensions(size);
const resolution = normalizeVideoResolutionValue(config.vquality);
const configuredResolutions = profile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value.toUpperCase() }));
const resolution = resolveVideoResolutionValue(profile, config.vquality);
const configuredResolutions = profile.resolutions.map((value) => ({ value, label: formatVideoResolutionLabel(value) }));
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
const updateDimension = (key: "width" | "height", value: number | null) => {
@@ -182,7 +182,7 @@ function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChang
}
export function videoResolutionLabel(value: string) {
return `${normalizeVideoResolutionValue(value)}P`;
return formatVideoResolutionLabel(value);
}
export function videoSizeLabel(value: string) {
@@ -205,13 +205,9 @@ export function normalizeVideoSizeValue(value: string) {
return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720";
}
export function normalizeVideoResolutionValue(value: string) {
return normalizeVideoResolution(value);
}
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button type="button" disabled={disabled} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[var(--fs-label)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
<button type="button" disabled={disabled} aria-pressed={selected} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[var(--fs-label)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
{children}
</button>
);
@@ -301,9 +297,7 @@ function hasPriceTierForVideoSelection(tiers: ReturnType<typeof modelPriceTiers>
}
function normalizeTierResolution(value: string) {
const normalized = normalizeVideoResolution(value);
if (/^\d+$/.test(normalized)) return `${normalized}p`;
return normalized.toLowerCase();
return videoResolutionComparisonKey(value);
}
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {