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

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

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "bun run build:bridge && tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
@@ -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 || "";
@@ -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>
);
}
@@ -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,
@@ -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 });
}
@@ -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(" · ");
+7 -13
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 }) {
@@ -0,0 +1,78 @@
import { canonicalize } from "json-canonicalize";
import type { NodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { generationPromptFingerprint } from "@/lib/generation-error";
type GenerationReference = NodeGenerationContext["referenceImages"][number] | NodeGenerationContext["referenceVideos"][number] | NodeGenerationContext["referenceAudios"][number];
export type CanvasGenerationRequestFingerprintInput = {
nodeId: string;
mode: CanvasNodeGenerationMode;
prompt: string;
model: string;
options: Record<string, unknown>;
workflow?: {
provider?: string;
workflowId?: string;
workflowKind?: string;
parameters?: Record<string, unknown>;
};
operation?: string;
audioInstructions?: string;
promptTemplateOperation?: string;
promptTemplateVariables?: Record<string, string>;
context: Pick<NodeGenerationContext, "referenceImages" | "referenceVideos" | "referenceAudios" | "characterReferences" | "resolvedCharacterVersions" | "resolvedCharacterVoices">;
};
export function canvasGenerationRequestFingerprint(input: CanvasGenerationRequestFingerprintInput) {
const serialized = canonicalize({
version: 1,
nodeId: input.nodeId,
mode: input.mode,
prompt: input.prompt.trim(),
model: input.model,
options: input.options,
workflow: input.workflow,
operation: input.operation,
audioInstructions: input.audioInstructions,
promptTemplateOperation: input.promptTemplateOperation,
promptTemplateVariables: input.promptTemplateVariables,
references: {
images: input.context.referenceImages.map(referenceIdentity),
videos: input.context.referenceVideos.map(referenceIdentity),
audios: input.context.referenceAudios.map(referenceIdentity),
characters: input.context.characterReferences,
resolvedCharacterVersions: input.context.resolvedCharacterVersions,
resolvedCharacterVoices: input.context.resolvedCharacterVoices,
},
});
return `canvas-generation:v1:${generationPromptFingerprint(serialized)}`;
}
function referenceIdentity(reference: GenerationReference) {
const source = "storageKey" in reference && reference.storageKey ? reference.storageKey : "url" in reference && reference.url ? reference.url : "dataUrl" in reference ? reference.dataUrl : "";
return {
id: reference.id,
name: reference.name,
type: reference.type,
source: source.startsWith("data:") ? `data:${generationPromptFingerprint(source)}` : source,
derivedSource: "source" in reference ? reference.source : undefined,
durationMs: "durationMs" in reference ? reference.durationMs : undefined,
};
}
export function runCanvasGenerationSubmissionOnce<T>(locks: Map<string, Promise<unknown>>, nodeId: string, operation: () => Promise<T>, onDuplicate?: () => void): Promise<T> {
const existing = locks.get(nodeId) as Promise<T> | undefined;
if (existing) {
onDuplicate?.();
return existing;
}
const running = Promise.resolve()
.then(operation)
.finally(() => {
if (locks.get(nodeId) === running) locks.delete(nodeId);
});
locks.set(nodeId, running);
return running;
}
@@ -0,0 +1,88 @@
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
const FILE_NAME_PART_LIMIT = 80;
const MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"image/svg+xml": "svg",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/ogg": "ogv",
"video/mpeg": "mpeg",
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/aac": "aac",
"audio/flac": "flac",
"audio/ogg": "ogg",
"audio/opus": "opus",
};
const CONTENT_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "mp4", "webm", "mov", "ogv", "ogg", "mpeg", "mpg", "mp3", "m4a", "wav", "aac", "flac", "opus"]);
export function buildCanvasMediaDownloadFileName(canvasTitle: string, node: CanvasNodeData, now = new Date()) {
const canvasName = safeFileNamePart(canvasTitle, "未命名画布");
const nodeName = safeFileNamePart(node.title, defaultNodeName(node.type));
return `${canvasName}_${nodeName}_${formatDownloadDate(now)}.${canvasMediaFileExtension(node)}`;
}
export function canvasMediaFileExtension(node: CanvasNodeData) {
return extensionFromMimeType(node.metadata?.mimeType)
|| extensionFromContent(node.metadata?.content)
|| defaultExtension(node.type);
}
function extensionFromMimeType(mimeType?: string) {
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
return normalized ? MIME_EXTENSIONS[normalized] : undefined;
}
function extensionFromContent(content?: string) {
if (!content) return undefined;
const dataMimeType = content.match(/^data:([^;,]+)/i)?.[1];
const dataExtension = extensionFromMimeType(dataMimeType);
if (dataExtension) return dataExtension;
const path = content.split(/[?#]/, 1)[0];
const extension = path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
if (!extension || !CONTENT_EXTENSIONS.has(extension)) return undefined;
if (extension === "jpeg") return "jpg";
if (extension === "mpg") return "mpeg";
return extension;
}
function safeFileNamePart(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_")
.replace(/_+/g, "_")
.replace(/\s+/g, " ")
.replace(/^_+|_+$/g, "")
.slice(0, FILE_NAME_PART_LIMIT)
.replace(/[. ]+$/g, "");
if (!normalized) return fallback;
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(normalized) ? `_${normalized}` : normalized;
}
function formatDownloadDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
function defaultNodeName(type: CanvasNodeTypeId) {
if (type === CanvasNodeType.Video) return "视频节点";
if (type === CanvasNodeType.Audio) return "音频节点";
return "图片节点";
}
function defaultExtension(type: CanvasNodeTypeId) {
if (type === CanvasNodeType.Video) return "mp4";
if (type === CanvasNodeType.Audio) return "mp3";
return "png";
}
+15
View File
@@ -1,6 +1,21 @@
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import type { CanvasNodeData, CanvasNodeMetadata, StoryboardRow } from "@/types/canvas";
const COPY_TITLE_SUFFIX = /^(.*)_copy(\d+)$/i;
export function nextCopiedNodeTitle(sourceTitle: string, existingTitles: Iterable<string>) {
const sourceMatch = sourceTitle.match(COPY_TITLE_SUFFIX);
const baseTitle = (sourceMatch?.[1] || sourceTitle.replace(/ Copy$/i, "")).trim() || sourceTitle.trim() || "未命名节点";
let maxCopyIndex = 0;
for (const title of existingTitles) {
const match = title.match(COPY_TITLE_SUFFIX);
if (!match || match[1] !== baseTitle) continue;
const copyIndex = Number(match[2]);
if (Number.isSafeInteger(copyIndex)) maxCopyIndex = Math.max(maxCopyIndex, copyIndex);
}
return `${baseTitle}_copy${maxCopyIndex + 1}`;
}
function remapReferenceId(nodeId: string | undefined, idMap: ReadonlyMap<string, string>) {
return nodeId ? idMap.get(nodeId) || nodeId : undefined;
}
+82
View File
@@ -0,0 +1,82 @@
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { canvasNodeCreatedAt, canvasNodeUpdatedAt } from "@/lib/canvas/canvas-node-timestamps";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
const searchTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80) {
const keyword = query.trim().toLocaleLowerCase();
return nodes
.filter((node) => !keyword || canvasNodeSearchTerms(node).some((value) => value.toLocaleLowerCase().includes(keyword)))
.toSorted((left, right) => timestampValue(canvasNodeUpdatedAt(right)) - timestampValue(canvasNodeUpdatedAt(left)))
.slice(0, keyword ? limit : Math.min(limit, 40));
}
export function canvasNodeSearchContext(node: CanvasNodeData) {
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
const textContent = node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Skill
? node.metadata?.content
: undefined;
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type);
}
export function canvasNodeMaterialSummary(node: CanvasNodeData) {
const details = [getNodeListLabel(node.type)];
const width = node.metadata?.naturalWidth;
const height = node.metadata?.naturalHeight;
if (width && height) details.push(`${width}×${height}`);
if (node.metadata?.durationMs) details.push(formatDuration(node.metadata.durationMs));
if (node.metadata?.bytes) details.push(formatBytes(node.metadata.bytes));
if (node.metadata?.model) details.push(node.metadata.model);
return details.slice(0, 3).join(" · ");
}
export function canvasNodeSearchTimes(node: CanvasNodeData) {
const createdAt = canvasNodeCreatedAt(node);
const updatedAt = canvasNodeUpdatedAt(node);
return {
createdAt,
updatedAt,
createdLabel: createdAt ? searchTimeFormatter.format(new Date(createdAt)) : "未记录",
updatedLabel: updatedAt ? searchTimeFormatter.format(new Date(updatedAt)) : "未记录",
};
}
function canvasNodeSearchTerms(node: CanvasNodeData) {
return [
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 || []),
].filter((value): value is string => typeof value === "string" && Boolean(value));
}
function timestampValue(value?: string) {
const timestamp = value ? Date.parse(value) : 0;
return Number.isFinite(timestamp) ? timestamp : 0;
}
function formatDuration(durationMs: number) {
const seconds = Math.max(0, Math.round(durationMs / 1000));
if (seconds < 60) return `${seconds}秒`;
return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
}
@@ -0,0 +1,103 @@
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
type CanvasNodeTimestampFallback = {
createdAt?: string;
updatedAt?: string;
};
const HYDRATED_MEDIA_KEYS = new Set<keyof CanvasNodeMetadata>([
"content",
"storageKey",
"naturalWidth",
"naturalHeight",
"bytes",
"mimeType",
"durationMs",
"drawingPreviewUrl",
]);
const MEDIA_NODE_TYPES = new Set<string>([CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Drawing]);
export function canvasNodeCreatedAt(node: CanvasNodeData, fallback?: string) {
return firstValidDate(node.createdAt, node.metadata?.taskCreatedAt, node.metadata?.folder?.createdAt, fallback);
}
export function canvasNodeUpdatedAt(node: CanvasNodeData, fallback?: string) {
return firstValidDate(
node.updatedAt,
node.metadata?.taskUpdatedAt,
node.metadata?.drawingUpdatedAt,
node.metadata?.subtitleUpdatedAt,
node.metadata?.taskCompletedAt,
canvasNodeCreatedAt(node),
fallback,
);
}
export function normalizeCanvasNodeTimestamps(nodes: CanvasNodeData[], fallback: CanvasNodeTimestampFallback = {}) {
let changed = false;
const normalized = nodes.map((node) => {
const createdAt = canvasNodeCreatedAt(node, fallback.createdAt || fallback.updatedAt);
const updatedAt = canvasNodeUpdatedAt(node, fallback.updatedAt || createdAt);
if (createdAt === node.createdAt && updatedAt === node.updatedAt) return node;
changed = true;
return { ...node, ...(createdAt ? { createdAt } : {}), ...(updatedAt ? { updatedAt } : {}) };
});
return changed ? normalized : nodes;
}
export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNodes: CanvasNodeData[], now = new Date().toISOString()) {
if (previousNodes === nextNodes) return previousNodes;
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
let changed = false;
const stamped = nextNodes.map((node) => {
const previous = previousById.get(node.id);
const createdAt = canvasNodeCreatedAt(node, canvasNodeCreatedAt(previous || node, now)) || now;
const meaningfulChange = previous ? canvasNodeMeaningfullyChanged(previous, node) : false;
const updatedAt = previous
? meaningfulChange
? now
: canvasNodeUpdatedAt(node, canvasNodeUpdatedAt(previous, createdAt)) || createdAt
: canvasNodeUpdatedAt(node, createdAt) || createdAt;
if (node.createdAt === createdAt && node.updatedAt === updatedAt) return node;
changed = true;
return { ...node, createdAt, updatedAt };
});
return changed ? stamped : nextNodes;
}
function canvasNodeMeaningfullyChanged(previous: CanvasNodeData, next: CanvasNodeData) {
if (
previous.type !== next.type
|| previous.title !== next.title
|| previous.parentId !== next.parentId
|| previous.width !== next.width
|| previous.height !== next.height
|| previous.position.x !== next.position.x
|| previous.position.y !== next.position.y
) return true;
if (previous.metadata === next.metadata) return false;
const previousMetadata = previous.metadata || {};
const nextMetadata = next.metadata || {};
const keys = new Set([...Object.keys(previousMetadata), ...Object.keys(nextMetadata)] as Array<keyof CanvasNodeMetadata>);
for (const key of keys) {
if (MEDIA_NODE_TYPES.has(next.type) && HYDRATED_MEDIA_KEYS.has(key)) continue;
if (!serializableValuesEqual(previousMetadata[key], nextMetadata[key])) return true;
}
return false;
}
function serializableValuesEqual(left: unknown, right: unknown) {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}
function firstValidDate(...values: Array<string | undefined>) {
return values.find((value) => value && Number.isFinite(Date.parse(value)));
}
@@ -39,11 +39,14 @@ export function createCanvasNode(type: CanvasNodeTypeId, position: Position, met
const spec = builtinSpec || (pluginDefinition ? { width: pluginDefinition.defaultSize.width, height: pluginDefinition.defaultSize.height, title: pluginDefinition.defaultTitle, metadata: pluginDefinition.defaultMetadata } : undefined);
if (!spec) throw new Error(`未注册的画布节点类型:${type}`);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const now = new Date().toISOString();
return {
id,
type,
title: spec.title,
createdAt: now,
updatedAt: now,
position: {
x: position.x - spec.width / 2,
y: position.y - spec.height / 2,
+220
View File
@@ -0,0 +1,220 @@
export type CanvasShortcutCategoryId = "common" | "navigation" | "selection" | "editing";
export type CanvasShortcutCategory = {
id: CanvasShortcutCategoryId;
label: string;
description: string;
};
export type CanvasShortcutItem = {
id: string;
category: CanvasShortcutCategoryId;
title: string;
description: string;
keys: string[][];
keywords?: string[];
};
export const CANVAS_MODIFIER_KEY = "Ctrl / Cmd";
export const CANVAS_SHORTCUT_CATEGORIES: CanvasShortcutCategory[] = [
{ id: "common", label: "常用", description: "搜索、保存与界面" },
{ id: "navigation", label: "视图与导航", description: "平移、缩放与定位" },
{ id: "selection", label: "选择与连接", description: "批量选择和节点连接" },
{ id: "editing", label: "编辑与文件", description: "编辑、撤销与导入" },
];
export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
{
id: "search",
category: "common",
title: "搜索画布节点",
description: "按名称或内容搜索并定位节点",
keys: [[CANVAS_MODIFIER_KEY, "F"]],
keywords: ["查找", "定位", "find", "search"],
},
{
id: "shortcuts",
category: "common",
title: "打开快捷键中心",
description: "查看画布中的键盘和鼠标操作",
keys: [["?"]],
keywords: ["帮助", "说明", "help"],
},
{
id: "save",
category: "common",
title: "保存画布",
description: "保存当前画布布局和节点位置",
keys: [[CANVAS_MODIFIER_KEY, "S"]],
keywords: ["存储", "save"],
},
{
id: "focus",
category: "common",
title: "进入或退出专注模式",
description: "隐藏界面干扰,聚焦当前画布",
keys: [["Shift", CANVAS_MODIFIER_KEY, "F"]],
keywords: ["沉浸", "全屏", "focus"],
},
{
id: "pan",
category: "navigation",
title: "平移视图",
description: "在画布空白处拖动,或使用空格键与中键拖动",
keys: [["空白处拖动"], ["Space", "拖动"], ["中键拖动"]],
keywords: ["移动", "画布", "pan"],
},
{
id: "zoom-wheel",
category: "navigation",
title: "缩放画布",
description: "以鼠标所在位置为中心缩放",
keys: [["滚轮"]],
keywords: ["放大", "缩小", "zoom"],
},
{
id: "zoom-controls",
category: "navigation",
title: "精确调整缩放",
description: "使用画布缩放滑杆调整比例",
keys: [["缩放滑杆"]],
keywords: ["比例", "zoom"],
},
{
id: "zoom-steps",
category: "navigation",
title: "步进缩放画布",
description: "按固定步长放大或缩小画布",
keys: [[CANVAS_MODIFIER_KEY, "+"], [CANVAS_MODIFIER_KEY, "-"]],
keywords: ["放大", "缩小", "zoom"],
},
{
id: "zoom-presets",
category: "navigation",
title: "快速调整视图",
description: "0/1 恢复 100%,2 适应画布,3 适应选择",
keys: [[CANVAS_MODIFIER_KEY, "0"], [CANVAS_MODIFIER_KEY, "1"], [CANVAS_MODIFIER_KEY, "2"], [CANVAS_MODIFIER_KEY, "3"]],
keywords: ["100%", "适应", "居中", "缩放", "fit"],
},
{
id: "box-select",
category: "selection",
title: "框选多个节点",
description: "按住修饰键后拖动选区",
keys: [["Shift", "拖动"], [CANVAS_MODIFIER_KEY, "拖动"]],
keywords: ["多选", "范围", "selection"],
},
{
id: "box-select-tool",
category: "selection",
title: "使用框选工具",
description: "完成框选后自动回到移动与选择工具",
keys: [["框选工具", "拖动"]],
keywords: ["工具栏", "多选", "selection"],
},
{
id: "add-selection",
category: "selection",
title: "追加选择节点",
description: "保留已有选择并加入更多节点",
keys: [["Shift", "点击"], [CANVAS_MODIFIER_KEY, "点击"]],
keywords: ["多选", "添加", "selection"],
},
{
id: "remove-selection",
category: "selection",
title: "移除选择节点",
description: "从当前选择中移除点击或框选的节点",
keys: [["Alt", "点击 / 框选"]],
keywords: ["取消", "排除", "selection"],
},
{
id: "select-all",
category: "selection",
title: "全选节点",
description: "选择画布中的全部节点",
keys: [[CANVAS_MODIFIER_KEY, "A"]],
keywords: ["全部", "select all"],
},
{
id: "batch-connect",
category: "selection",
title: "批量连接节点",
description: "为两个或更多已选节点进入批量连接模式",
keys: [["Alt", "L"]],
keywords: ["连线", "连接", "link"],
},
{
id: "copy",
category: "editing",
title: "复制节点",
description: "复制当前选中的节点",
keys: [[CANVAS_MODIFIER_KEY, "C"]],
keywords: ["copy"],
},
{
id: "paste",
category: "editing",
title: "粘贴节点或剪贴板内容",
description: "粘贴已复制节点、文本或图片",
keys: [[CANVAS_MODIFIER_KEY, "V"]],
keywords: ["剪贴板", "paste"],
},
{
id: "delete",
category: "editing",
title: "删除选中内容",
description: "删除选中的节点或连线",
keys: [["Delete"], ["Backspace"]],
keywords: ["移除", "delete"],
},
{
id: "undo",
category: "editing",
title: "撤销",
description: "撤销上一步画布编辑",
keys: [[CANVAS_MODIFIER_KEY, "Z"]],
keywords: ["undo"],
},
{
id: "redo",
category: "editing",
title: "重做",
description: "恢复刚刚撤销的画布编辑",
keys: [[CANVAS_MODIFIER_KEY, "Shift", "Z"], [CANVAS_MODIFIER_KEY, "Y"]],
keywords: ["恢复", "redo"],
},
{
id: "escape",
category: "editing",
title: "取消当前操作",
description: "取消选择、关闭浮层或退出专注模式",
keys: [["Esc"]],
keywords: ["关闭", "退出", "cancel"],
},
{
id: "import-media",
category: "editing",
title: "导入媒体",
description: "将图片、视频或音频文件拖入画布",
keys: [["拖入媒体"]],
keywords: ["上传", "文件", "图片", "视频", "音频", "upload"],
},
];
export function filterCanvasShortcuts(query: string, category?: CanvasShortcutCategoryId | "all") {
const normalizedQuery = query.trim().toLocaleLowerCase();
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return CANVAS_SHORTCUTS.filter((shortcut) => {
if (category && category !== "all" && shortcut.category !== category) return false;
if (!terms.length) return true;
const categoryLabel = CANVAS_SHORTCUT_CATEGORIES.find((entry) => entry.id === shortcut.category)?.label || "";
const searchableText = [shortcut.title, shortcut.description, categoryLabel, shortcut.keys.flat().join(" "), ...(shortcut.keywords || [])]
.join(" ")
.toLocaleLowerCase();
return terms.every((term) => searchableText.includes(term));
});
}
+42
View File
@@ -0,0 +1,42 @@
export type LocalAgentSetupPlatform = "unix" | "windows";
const CANVAS_REPOSITORY_URL = "https://github.com/ddcat-ai/open-ai-canvas.git";
export function detectLocalAgentSetupPlatform(userAgent = defaultUserAgent()): LocalAgentSetupPlatform {
return /windows/i.test(userAgent) ? "windows" : "unix";
}
export function buildLocalAgentSetupCommands(origin: string, platform: LocalAgentSetupPlatform) {
const trustedOrigin = exactWebOrigin(origin);
if (platform === "windows") {
return {
install: `git clone ${CANVAS_REPOSITORY_URL}\nSet-Location .\\open-ai-canvas\\canvas-agent\nnpm install\nnpm run build\nSet-Location ..\\..`,
start: `Set-Location .\\open-ai-canvas\\canvas-agent\n$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS=${powerShellArgument(trustedOrigin)}\nnode .\\dist\\index.js`,
};
}
return {
install: `git clone ${CANVAS_REPOSITORY_URL}\ncd open-ai-canvas/canvas-agent\nnpm install\nnpm run build\ncd ../..`,
start: `cd open-ai-canvas/canvas-agent\nFRAMEFIELD_TRUSTED_WEB_ORIGINS=${shellArgument(trustedOrigin)} node dist/index.js`,
};
}
function exactWebOrigin(value: string) {
const url = new URL(value);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash || url.origin !== value) {
throw new Error("Web origin is invalid");
}
return url.origin;
}
function defaultUserAgent() {
if (typeof navigator === "undefined") return "";
return `${navigator.platform} ${navigator.userAgent}`;
}
function powerShellArgument(value: string) {
return `'${value.replace(/'/g, "''")}'`;
}
function shellArgument(value: string) {
return `'${value.replace(/'/g, "'\\''")}'`;
}
+5 -1
View File
@@ -902,10 +902,14 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
const resolution = videoResolutionRequest(profile, value.resolution) || profile.defaultResolution || profile.resolutions[0] || "";
const resolution = resolveVideoResolutionValue(profile, value.resolution);
return { seconds: String(duration), ratio, resolution };
}
export function resolveVideoResolutionValue(profile: VideoCapabilityConfig, value: string | undefined) {
return videoResolutionRequest(profile, value) || profile.defaultResolution || profile.resolutions[0] || "";
}
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
const requested = String(value || "")
.trim()
+2 -2
View File
@@ -1,5 +1,5 @@
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { normalizeVideoResolution } from "@/lib/video-generation-options";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import type { ModelRequestIntent } from "@/services/api/logical-models";
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
@@ -115,5 +115,5 @@ function priceSelectorForTier(tier: ModelPriceTier) {
export function normalizeTierResolution(value: string) {
const raw = String(value || "").trim();
if (!raw || raw === "*") return "*";
return `${normalizeVideoResolution(raw)}p`;
return videoResolutionComparisonKey(raw);
}
+2 -6
View File
@@ -1,4 +1,5 @@
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
export type ModelInputSummary = {
@@ -160,12 +161,7 @@ function logicalOptionMatches(name: string, constraint: { values?: unknown[]; mi
function normalizeLogicalOptionValue(name: string, value: unknown) {
const normalized = String(value).trim().toLowerCase();
if (name !== "vquality" && name !== "resolution") return normalized;
if (normalized === "low") return "480p";
if (["auto", "medium", "high"].includes(normalized)) return "720p";
if (normalized === "2k") return "1440p";
if (normalized === "4k") return "2160p";
const resolution = normalized.replace(/p$/i, "");
return resolution ? `${resolution}p` : "";
return videoResolutionComparisonKey(normalized);
}
function logicalOptionError(name: string) {
+23 -2
View File
@@ -9,11 +9,32 @@ export function normalizeVideoDuration(value: string | number | undefined) {
}
export function normalizeVideoResolution(value: string | number | undefined) {
const token = String(value || "").trim().toLowerCase();
const raw = String(value || "").trim();
const token = raw.toLowerCase();
if (token === "low") return "480";
if (token === "auto" || token === "medium" || token === "high") return "720";
if (token === "2k") return "1440";
if (token === "4k") return "2160";
const resolution = Number(token.replace(/p$/i, ""));
return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720";
if (Number.isFinite(resolution)) return resolution > 0 ? String(Math.floor(resolution)) : "720";
// Channel-declared values are opaque enums, not necessarily numeric tiers.
// Preserve them verbatim so values such as `768p竖` remain selectable and
// can be sent back to the provider without being collapsed to 720p.
return raw || "720";
}
export function videoResolutionComparisonKey(value: string | number | undefined) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = normalizeVideoResolution(raw);
return /^\d+$/.test(normalized) ? `${normalized}p` : normalized.toLowerCase();
}
export function formatVideoResolutionLabel(value: string | number | undefined) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = normalizeVideoResolution(raw);
if (normalized === "2160") return "4K";
if (/^\d+$/.test(normalized)) return `${normalized}P`;
return normalized.replace(/^(\d+)p/i, "$1P");
}
@@ -2,7 +2,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal } from "antd";
import { Button, Dropdown } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -13,6 +13,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/canvas";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
type CanvasTopBarProps = {
title: string;
@@ -114,7 +115,7 @@ export function CanvasTopBar({
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ K" />, onClick: onOpenSearch },
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ F" />, onClick: onOpenSearch },
{
key: "performance",
icon: <Gauge className="size-4" />,
@@ -233,7 +234,7 @@ export function CanvasTopBar({
</Link>
</CanvasTopBarTooltip>
) : null}
<CanvasTopBarTooltip label="进入专注模式(⇧⌘F)">
<CanvasTopBarTooltip label="进入专注模式(Shift + Ctrl/Cmd + F)">
<Button
type="text"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
@@ -274,30 +275,7 @@ export function CanvasTopBar({
</Button>
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["空白处左键拖动", "空格 + 左键 / 中键"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Shift / Ctrl / Cmd + 左键拖动"]} value="框选多个节点" />
<Shortcut keys={["工具栏「框选」", "左键拖动"]} value="框选多个节点,完成后自动回到「移动与选择」" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Alt", "点击 / 框选"]} value="移除选择节点" />
<Shortcut keys={["Ctrl / Cmd", "1 / 2 / 3"]} value="100% / 适应画布 / 适应选择" />
<Shortcut keys={["?"]} value="打开快捷键" />
<Shortcut keys={["Shift / Ctrl / Cmd", "F"]} value="进入 / 退出专注模式" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "K"]} value="搜索并定位节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "S"]} value="保存画布布局和位置" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
</div>
</Modal>
<CanvasShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
</>
);
}
@@ -428,21 +406,3 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return <CanvasTopBarTooltip label="打开本地 Codex 面板"><button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} aria-label="打开本地 Codex 面板"><span className="size-2 rounded-full" style={{ background: dotColor }} /><span className="max-w-[180px] truncate">{label}</span></button></CanvasTopBarTooltip>;
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{keys.map((key, index) => (
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
{index ? <span className="text-xs opacity-35">+</span> : null}
<kbd className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]" style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}>
{key}
</kbd>
</span>
))}
</span>
<span className="text-right text-sm opacity-55">{value}</span>
</div>
);
}
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Command, Search } from "lucide-react";
import { Modal } from "antd";
import {
CANVAS_SHORTCUT_CATEGORIES,
CANVAS_SHORTCUTS,
filterCanvasShortcuts,
type CanvasShortcutCategoryId,
type CanvasShortcutItem,
} from "@/lib/canvas/canvas-shortcuts";
type ShortcutCategoryFilter = CanvasShortcutCategoryId | "all";
export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<ShortcutCategoryFilter>("all");
useEffect(() => {
if (!open) return;
setQuery("");
setCategory("all");
}, [open]);
const results = useMemo(() => filterCanvasShortcuts(query, category), [category, query]);
const categoryCounts = useMemo(
() => new Map(CANVAS_SHORTCUT_CATEGORIES.map((entry) => [entry.id, CANVAS_SHORTCUTS.filter((shortcut) => shortcut.category === entry.id).length])),
[],
);
return (
<Modal
className="workspace-modal workspace-modal-wide canvas-shortcuts-modal"
open={open}
onCancel={onClose}
footer={null}
title={null}
centered
keyboard
width="min(860px, calc(100vw - 24px))"
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
afterOpenChange={(visible) => {
if (visible) window.requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<div className="canvas-shortcuts-shell">
<header className="canvas-shortcuts-header">
<div className="canvas-shortcuts-heading">
<span className="canvas-shortcuts-heading-icon" aria-hidden>
<Command />
</span>
<span>
<strong>画布快捷键</strong>
<small>快速找到键盘、鼠标和视图操作</small>
</span>
</div>
<label className="canvas-shortcuts-search">
<Search aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索按键或操作…"
aria-label="搜索画布快捷键"
/>
{query ? (
<button type="button" onClick={() => setQuery("")} aria-label="清空搜索">
清除
</button>
) : null}
</label>
</header>
<div className="canvas-shortcuts-body">
<nav className="canvas-shortcuts-categories" aria-label="快捷键分类">
<CategoryButton label="全部" count={CANVAS_SHORTCUTS.length} active={category === "all"} onClick={() => setCategory("all")} />
{CANVAS_SHORTCUT_CATEGORIES.map((entry) => (
<CategoryButton
key={entry.id}
label={entry.label}
count={categoryCounts.get(entry.id) || 0}
active={category === entry.id}
onClick={() => setCategory(entry.id)}
/>
))}
</nav>
<main className="canvas-shortcuts-results" aria-live="polite">
{results.length ? (
<div className="canvas-shortcuts-list">
{results.map((shortcut) => (
<ShortcutRow key={shortcut.id} shortcut={shortcut} showCategory={category === "all" || Boolean(query)} />
))}
</div>
) : (
<div className="canvas-shortcuts-empty">
<Search aria-hidden />
<strong>没有找到相关操作</strong>
<span>试试搜索“粘贴”“缩放”或按键名称</span>
</div>
)}
</main>
</div>
<footer className="canvas-shortcuts-footer">
<span>共 {results.length} 个快捷键</span>
<span className="canvas-shortcuts-close-hint"><kbd>Esc</kbd> 关闭</span>
</footer>
</div>
</Modal>
);
}
function CategoryButton({ label, count, active, onClick }: { label: string; count: number; active: boolean; onClick: () => void }) {
return (
<button type="button" className={active ? "is-active" : undefined} aria-pressed={active} onClick={onClick}>
<span>{label}</span>
<small>{count}</small>
</button>
);
}
function ShortcutRow({ shortcut, showCategory }: { shortcut: CanvasShortcutItem; showCategory: boolean }) {
const categoryLabel = CANVAS_SHORTCUT_CATEGORIES.find((entry) => entry.id === shortcut.category)?.label;
return (
<article className="canvas-shortcuts-row">
<div className="canvas-shortcuts-row-copy">
<span className="canvas-shortcuts-row-title">
<strong>{shortcut.title}</strong>
{showCategory ? <small>{categoryLabel}</small> : null}
</span>
<p>{shortcut.description}</p>
</div>
<div className="canvas-shortcuts-keys" aria-label={shortcut.keys.map((combination) => combination.join(" 加 ")).join(" 或 ")}>
{shortcut.keys.map((combination, combinationIndex) => (
<span key={`${shortcut.id}-${combination.join("-")}`} className="canvas-shortcuts-combination">
{combinationIndex ? <em>或</em> : null}
{combination.map((key, keyIndex) => (
<span key={`${key}-${keyIndex}`} className="canvas-shortcuts-key-part">
{keyIndex ? <i>+</i> : null}
<kbd>{key}</kbd>
</span>
))}
</span>
))}
</div>
</article>
);
}
+29 -29
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -72,6 +72,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
@@ -217,7 +218,21 @@ function InfiniteCanvasPage() {
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const nodesRef = useRef<CanvasNodeData[]>([]);
const [nodes, setNodesState] = useState<CanvasNodeData[]>([]);
const setNodes = useCallback<Dispatch<SetStateAction<CanvasNodeData[]>>>((value) => {
if (typeof value === "function") {
setNodesState((current) => {
const next = stampCanvasNodeChanges(current, value(current));
nodesRef.current = next;
return next;
});
return;
}
const next = stampCanvasNodeChanges(nodesRef.current, value);
nodesRef.current = next;
setNodesState(next);
}, []);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
@@ -303,19 +318,6 @@ function InfiniteCanvasPage() {
didInitialCenterRef.current = false;
}, [projectId]);
useEffect(() => {
const openSearch = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLocaleLowerCase() !== "k") return;
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) return;
event.preventDefault();
setNodeSearchOpen(true);
};
window.addEventListener("keydown", openSearch);
return () => window.removeEventListener("keydown", openSearch);
}, []);
const nodesRef = useRef(nodes);
const connectionsRef = useRef(connections);
const chatSessionsRef = useRef(chatSessions);
const activeChatIdRef = useRef(activeChatId);
@@ -625,7 +627,7 @@ function InfiniteCanvasPage() {
createVideoNodeFromBlob,
createImageAssetNode,
fileDropActive,
handleAssetInsert,
handleAssetsInsert,
handleDrop,
handleFileDragEnter,
handleFileDragLeave,
@@ -725,22 +727,18 @@ function InfiniteCanvasPage() {
return null;
};
const handleTimelineAssetInsert = useCallback(
async (payload: InsertAssetPayload) => {
const handleLibraryAssetsInsert = useCallback(
async (payloads: InsertAssetPayload[]) => {
if (assetInsertScope === "timeline") {
const media = payloadToTimelineMedia(payload);
if (media) {
timelineMediaAddRef.current?.(media);
closeAssetPicker();
} else {
message.info("图片/文本素材暂不支持直接入轨,请先在画布中添加节点");
}
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
media.forEach((item) => timelineMediaAddRef.current?.(item));
return;
}
const node = await handleAssetInsert(payload, { openDialog: false });
if (node) timelineAddNodeRef.current?.(node);
const created = await handleAssetsInsert(payloads);
created.forEach((node) => timelineAddNodeRef.current?.(node));
},
[assetInsertScope, closeAssetPicker, handleAssetInsert],
[assetInsertScope, handleAssetsInsert],
);
// 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。
@@ -1063,6 +1061,7 @@ function InfiniteCanvasPage() {
toggleNodeFreeResize,
} = useCanvasNodeEditor({
canvasId: projectId,
canvasTitle: currentProject?.title || "未命名画布",
domainProjectId: linkedProjectId,
nodesRef,
setNodes,
@@ -1450,6 +1449,7 @@ function InfiniteCanvasPage() {
focusMode,
exitFocusMode,
toggleFocusMode,
onOpenSearch: () => setNodeSearchOpen(true),
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
});
@@ -2697,7 +2697,7 @@ function InfiniteCanvasPage() {
onConfirmClear={clearCanvas}
/>
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
<AssetPickerModal open={assetPickerOpen} multiple={assetInsertScope === "canvas"} onInsert={handleLibraryAssetsInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
{codexCompactAgent && !assistantMounted ? (
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
@@ -2,20 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { createGenerationRetryContext, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { createGenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasAgentRetryContext = GenerationRetryContext;
type CanvasAgentGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasAgentGenerationOptions = {
context?: CanvasAgentGenerationContext;
retryContext?: CanvasAgentRetryContext;
onTaskUpdate?: (task: GenerationTask) => void;
};
type CanvasAgentGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasAgentGenerationContinuationDependencies = {
consumeAgent?: typeof consumeGenerationTaskAgent;
persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect;
@@ -149,6 +146,7 @@ export async function runCanvasAgentGenerationOps({
let continuationTaskId = "";
const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, {
context: context ? { conversationId: context.conversationId, messageId: context.messageId } : undefined,
skipDuplicateConfirmation: true,
...(retryContext ? { retryContext } : {}),
onTaskUpdate: (task) => {
if (continuationTaskId) return;
@@ -213,6 +213,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
void handleGenerateNode(node.id, generationMode, prompt, {
controller,
waitForTaskCapacity: true,
skipDuplicateConfirmation: true,
retryContext:
node.metadata?.retryOf && node.metadata.attemptGroupId && node.metadata.taskClientOperationId
? { retryOf: node.metadata.retryOf, attemptGroupId: node.metadata.attemptGroupId, clientOperationId: node.metadata.taskClientOperationId }
@@ -1,9 +1,10 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -50,6 +51,7 @@ export type CanvasNodeGenerationOptions = {
context?: { conversationId?: string; messageId?: string };
retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string };
onTaskUpdate?: (task: GenerationTask) => void;
skipDuplicateConfirmation?: boolean;
};
export function useCanvasGenerationExecutor({
@@ -70,251 +72,280 @@ export function useCanvasGenerationExecutor({
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationExecutorOptions) {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const submissionLocksRef = useRef(new Map<string, Promise<unknown>>());
const confirmDuplicateSubmission = useCallback(
() =>
new Promise<boolean>((resolve) => {
modal.confirm({
title: "再次生成相同内容?",
content: "当前节点已使用相同提示词、模型、参数和参考素材提交过任务。再次生成会新建任务,并可能再次消耗积分。",
okText: "仍然生成",
cancelText: "取消",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
}),
[modal],
);
return useCallback(
async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
}
let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id));
const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren;
if (hasStaleImageBatchState) {
setNodes((current) =>
current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}),
);
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
}
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
try {
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR,
taskStage: undefined,
taskProgress: undefined,
taskCreatedAt: undefined,
taskStartedAt: undefined,
taskCompletedAt: undefined,
taskDurationMs: undefined,
errorDetails: controller.signal.aborted ? undefined : errorDetails,
},
}
: node,
),
);
}
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
if (!controller.signal.aborted) message.error(errorDetails);
return;
}
const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills);
let effectivePrompt = expandedPrompt.trim();
let styleMetadata = {};
if (mode === "image") {
try {
const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode);
if (styleRuntime) {
effectivePrompt = styleRuntime.prompt;
styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan };
}
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage)
setNodes((current) =>
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)),
);
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error(errorDetails);
return;
}
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (controller.signal.aborted) {
if (isPreparingEmptyImage)
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node)));
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node,
),
);
let pendingNodeIds: string[] = [];
const execution = {
projectId,
(nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) =>
runCanvasGenerationSubmissionOnce(
submissionLocksRef.current,
nodeId,
sourceNode,
canvasNodes: nodesRef.current,
canvasConnections: connectionsRef.current,
prompt,
effectivePrompt,
generationConfig,
generationContext,
controller,
editingTextNode,
styleMetadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
options?.onTaskUpdate?.(task);
},
applyGenerationTaskResult,
showError: (content: string) => message.error(content),
registerPendingNodeIds: (nodeIds: string[]) => {
pendingNodeIds = nodeIds;
},
};
async () => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
}
let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
const hasLiveBatchChildren =
sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id));
const hasStaleImageBatchState =
mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren;
if (hasStaleImageBatchState) {
setNodes((current) =>
current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}),
);
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
try {
if (mode === "image") await executeImageGeneration(execution);
else if (mode === "video") await executeVideoGeneration(execution);
else if (mode === "audio") await executeAudioGeneration(execution);
else await executeTextGeneration(execution);
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, prompt);
if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) {
setNodes((current) =>
current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
delete metadata.taskStartedAt;
delete metadata.taskCompletedAt;
delete metadata.taskDurationMs;
return { ...node, metadata };
}),
);
return;
}
message.error(failure.errorDetails);
setNodes((current) =>
current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)),
);
} finally {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
}
},
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
try {
const baseContext = buildNodeGenerationContext(
nodeId,
nodesRef.current,
connectionsRef.current,
editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt,
assets,
promptOnly,
);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
return;
}
const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills);
let effectivePrompt = expandedPrompt.trim();
let styleMetadata = {};
if (mode === "image") {
try {
const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode);
if (styleRuntime) {
effectivePrompt = styleRuntime.prompt;
styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan };
}
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
return;
}
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
return;
}
const requestFingerprint = canvasGenerationRequestFingerprint({
nodeId,
mode,
prompt: effectivePrompt,
model: generationConfig.model,
options: modelRequestOptions(generationConfig, mode),
workflow:
generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"
? {
provider: generationConfig.taskWorkflowProvider,
workflowId: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.workflowId : generationConfig.comfyBridge.workflowId,
workflowKind: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.selectedKind : undefined,
parameters: sourceNode?.metadata?.workflowParameters,
}
: undefined,
operation: sourceNode?.metadata?.videoEditOperation,
audioInstructions: generationConfig.audioInstructions,
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
context: generationContext,
});
const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint;
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return;
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
if (controller.signal.aborted) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
}
: node,
),
);
let pendingNodeIds: string[] = [];
const execution = {
projectId,
nodeId,
sourceNode,
canvasNodes: nodesRef.current,
canvasConnections: connectionsRef.current,
prompt,
effectivePrompt,
generationConfig,
generationContext,
controller,
editingTextNode,
styleMetadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
setNodes((current) => {
const source = current.find((node) => node.id === nodeId);
if (!source || source.metadata?.lastGenerationRequestFingerprint === requestFingerprint) return current;
return current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, lastGenerationRequestFingerprint: requestFingerprint } } : node));
});
options?.onTaskUpdate?.(task);
},
applyGenerationTaskResult,
showError: (content: string) => message.error(content),
registerPendingNodeIds: (nodeIds: string[]) => {
pendingNodeIds = nodeIds;
},
};
try {
if (mode === "image") await executeImageGeneration(execution);
else if (mode === "video") await executeVideoGeneration(execution);
else if (mode === "audio") await executeAudioGeneration(execution);
else await executeTextGeneration(execution);
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, prompt);
if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) {
setNodes((current) =>
current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
delete metadata.taskStartedAt;
delete metadata.taskCompletedAt;
delete metadata.taskDurationMs;
return { ...node, metadata };
}),
);
return;
}
message.error(failure.errorDetails);
setNodes((current) =>
current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)),
);
} finally {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
}
},
() => message.info({ key: `canvas-generation-submission-${nodeId}`, content: "该节点已有生成请求正在提交或执行,请勿重复点击" }),
),
[
addedSkills,
applyGenerationTaskResult,
bindGenerationTask,
confirmDuplicateSubmission,
domainProjectId,
effectiveConfig,
finishGenerationRequest,
+20 -2
View File
@@ -34,9 +34,20 @@ type UseCanvasKeyboardOptions = {
focusMode: boolean;
exitFocusMode: () => void;
toggleFocusMode: () => void;
onOpenSearch: () => void;
beginBatchConnection: () => void;
};
type TextSelectionLike = {
isCollapsed: boolean;
rangeCount: number;
toString(): string;
};
export function hasCanvasTextSelection(selection: TextSelectionLike | null | undefined) {
return Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.toString());
}
export function useCanvasKeyboard({
nodesRef,
selectedNodeIdsRef,
@@ -69,6 +80,7 @@ export function useCanvasKeyboard({
focusMode,
exitFocusMode,
toggleFocusMode,
onOpenSearch,
beginBatchConnection,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
@@ -101,8 +113,13 @@ export function useCanvasKeyboard({
return;
}
if (isModifierShortcut && !event.altKey && key === "f") {
if (target?.closest(".ant-modal-wrap, .ant-dropdown, .ant-popover")) return;
event.preventDefault();
if (!event.repeat) toggleFocusMode();
event.stopPropagation();
if (!event.repeat) {
if (event.shiftKey) toggleFocusMode();
else onOpenSearch();
}
return;
}
if (isTextEditingTarget) return;
@@ -145,6 +162,7 @@ export function useCanvasKeyboard({
return;
}
if (isModifierShortcut && !event.altKey && key === "c") {
if (hasCanvasTextSelection(window.getSelection())) return;
event.preventDefault();
copySelectedNodes();
return;
@@ -200,5 +218,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}
@@ -5,14 +5,16 @@ import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame";
import { buildCanvasMediaDownloadFileName } from "@/lib/canvas/canvas-media-download";
import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
canvasId: string;
canvasTitle: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -25,6 +27,7 @@ type UseCanvasNodeEditorOptions = {
export function useCanvasNodeEditor({
canvasId,
canvasTitle,
domainProjectId,
nodesRef,
setNodes,
@@ -180,8 +183,8 @@ export function useCanvasNodeEditor({
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
}, []);
saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node));
}, [canvasTitle]);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");
@@ -7,7 +7,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
@@ -461,8 +461,11 @@ export function useCanvasNodeOperations({
const dy = center.y - (bounds.top + bounds.bottom) / 2;
const idMap = new Map(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const copiedSourceIds = new Set(clipboard.nodes.map((node) => node.id));
const reservedTitles = new Set(nodesRef.current.map((node) => node.title));
const nextNodes = clipboard.nodes.map((node) => {
const metadata = isolateCopiedNodeMetadata(node, idMap);
const title = nextCopiedNodeTitle(node.title, reservedTitles);
reservedTitles.add(title);
if (node.type === CanvasNodeType.Drawing && metadata) {
metadata.drawingId = `${idMap.get(node.id)}-document`;
metadata.drawingRevision = 0;
@@ -473,7 +476,7 @@ export function useCanvasNodeOperations({
return {
...node,
id: idMap.get(node.id)!,
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
title,
position: { x: node.position.x + dx, y: node.position.y + dy },
parentId: node.parentId ? idMap.get(node.parentId) : undefined,
metadata,
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
@@ -109,7 +110,10 @@ export function useCanvasProjectLifecycle({
};
const restore = async () => {
const initialNodes = resetInterruptedGeneration(project.nodes);
const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), {
createdAt: project.createdAt,
updatedAt: project.updatedAt,
});
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
+18 -23
View File
@@ -714,7 +714,7 @@ export function useCanvasUpload({
const storedImage = payload.url
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: payload.storageKey
? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" }
? { url: payload.dataUrl, storageKey: payload.storageKey, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: await uploadImage(payload.dataUrl);
const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
@@ -725,38 +725,33 @@ export function useCanvasUpload({
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
}, []);
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload, options: { openDialog?: boolean } = {}): Promise<CanvasNodeData | null> => {
const center = assetInsertPositionRef.current || getCanvasCenter();
try {
const node = await createAssetPayloadNode(payload, center);
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, options.openDialog === false ? "preserve" : payload.kind === "image" || payload.kind === "video" ? "open" : "preserve");
closeAssetPicker();
return node;
} catch (error) {
message.error(error instanceof Error ? error.message : "素材插入失败");
return null;
}
}, [closeAssetPicker, createAssetPayloadNode, getCanvasCenter, message, selectInsertedNode, setNodes]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise<CanvasNodeData[]> => {
const origin = position || getCanvasCenter();
const insertAssetPayloads = useCallback(async (payloads: InsertAssetPayload[], origin: Position, successMessage: string, failureMessage: string): Promise<CanvasNodeData[]> => {
try {
const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, {
x: origin.x + (index % 3) * 380,
y: origin.y + Math.floor(index / 3) * 300,
x: origin.x + (index % BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_COLUMN_GAP,
y: origin.y + Math.floor(index / BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_ROW_GAP,
})));
setNodes((current) => [...current, ...created]);
setSelectedNodeIds(new Set(created.map((node) => node.id)));
setSelectedConnectionId(null);
setDialogNodeId(null);
message.success(`已引入 ${created.length} 项项目资产`);
message.success(successMessage);
return created;
} catch (error) {
message.error(error instanceof Error ? error.message : "项目资产引入失败");
message.error(error instanceof Error ? error.message : failureMessage);
throw error;
}
}, [createAssetPayloadNode, getCanvasCenter, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
}, [createAssetPayloadNode, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const handleAssetsInsert = useCallback(async (payloads: InsertAssetPayload[]): Promise<CanvasNodeData[]> => {
const origin = assetInsertPositionRef.current || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已插入 ${payloads.length} 项素材`, "素材插入失败");
}, [getCanvasCenter, insertAssetPayloads]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise<CanvasNodeData[]> => {
const origin = position || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已引入 ${payloads.length} 项项目资产`, "项目资产引入失败");
}, [getCanvasCenter, insertAssetPayloads]);
return {
assetPickerOpen,
@@ -765,7 +760,7 @@ export function useCanvasUpload({
createAssetPayloadNode,
createImageAssetNode,
fileDropActive,
handleAssetInsert,
handleAssetsInsert,
handleDrop,
handleFileDragEnter,
handleFileDragLeave,
+1 -5
View File
@@ -20,7 +20,7 @@ import { generationErrorCode, generationErrorMessage } from "@/lib/generation-er
import { useCopyText } from "@/hooks/use-copy-text";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { backendModelRuntimeRequired, isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
@@ -1761,10 +1761,6 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasP
</>;
}
function videoResolutionLabel(value: string | number) {
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
}
function formatMessageTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
@@ -65,7 +65,7 @@ my-plugin.yingce-plugin
## 4. Provider 与工作流
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和字符串变换(`|trim`、`|lower`、`|upper`、`|video-resolution`),不执行插件代码。`video-resolution` 会 trim 并转为小写,仅在输入是纯数字时补上 `p`,因此同时兼容历史状态中的 `768`、标准值 `768p` 和工作流值 `768p竖`。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和通用字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。渠道声明的枚举值由画布完整保留,插件可使用通用大小写变换适配上游,不应要求宿主增加渠道专用归一化。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存影策资源引用。
+383 -9
View File
@@ -7016,7 +7016,6 @@
}
.canvas-tool-button,
.canvas-menu-item,
.canvas-node-tool-button {
transform: translate3d(0, 0, 0) scale(1);
transition:
@@ -7027,6 +7026,15 @@
transform 160ms var(--motion-ease-out);
}
.canvas-menu-item {
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
opacity 140ms ease,
box-shadow 140ms ease;
}
.canvas-connection-handle {
touch-action: none;
}
@@ -7044,25 +7052,22 @@
}
.canvas-tool-button:not(:disabled):active,
.canvas-node-tool-button:not(:disabled):active,
.canvas-menu-item:not(:disabled):active {
.canvas-node-tool-button:not(:disabled):active {
transform: translate3d(0, 0, 0) scale(0.94);
}
.canvas-menu-item:not(:disabled):hover {
transform: translate3d(4px, 0, 0);
}
.canvas-menu-item:not(:disabled):hover .canvas-menu-item-icon,
.canvas-node-tool-button:not(:disabled):hover .canvas-node-tool-icon {
transform: scale(1.08);
}
.canvas-menu-item-icon,
.canvas-node-tool-icon {
transition: transform 160ms var(--motion-ease-out);
}
.canvas-menu-item-icon {
transition: opacity 140ms ease;
}
.canvas-node-shell,
.canvas-frame-shell,
.canvas-folder-shell {
@@ -12838,6 +12843,375 @@ html:not(.dark) .creation-history-drawer-root {
background: var(--popover) !important;
box-shadow: var(--elevation-overlay) !important;
}
.canvas-shortcuts-modal .ant-modal-close {
top: 16px;
right: 16px;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
}
.canvas-shortcuts-modal .ant-modal-close:hover {
background: var(--surface-hover);
color: var(--foreground);
}
.canvas-shortcuts-shell {
display: flex;
height: min(680px, 78dvh);
min-height: 500px;
flex-direction: column;
color: var(--foreground);
overscroll-behavior: contain;
}
.canvas-shortcuts-header {
display: grid;
flex-shrink: 0;
grid-template-columns: minmax(220px, 1fr) minmax(260px, 360px);
align-items: center;
gap: var(--space-6);
border-bottom: 1px solid var(--border);
padding: 18px 56px 18px 20px;
}
.canvas-shortcuts-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.canvas-shortcuts-heading-icon {
display: grid;
width: 38px;
height: 38px;
flex-shrink: 0;
place-items: center;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 76%, transparent);
}
.canvas-shortcuts-heading-icon svg {
width: 18px;
height: 18px;
stroke-width: 1.7;
}
.canvas-shortcuts-heading > span:last-child {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.canvas-shortcuts-heading strong {
font-size: var(--fs-body);
font-weight: 680;
line-height: 1.2;
}
.canvas-shortcuts-heading small {
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: var(--fs-micro);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-shortcuts-search {
display: flex;
height: 38px;
min-width: 0;
align-items: center;
gap: 9px;
border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 0 10px;
transition: border-color var(--motion-state) ease, background-color var(--motion-state) ease, box-shadow var(--motion-state) ease;
}
.canvas-shortcuts-search:focus-within {
border-color: color-mix(in srgb, var(--ring) 48%, transparent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 18%, transparent);
}
.canvas-shortcuts-search > svg {
width: 15px;
height: 15px;
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
stroke-width: 1.8;
}
.canvas-shortcuts-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--foreground);
font-size: var(--fs-label);
}
.canvas-shortcuts-search input::placeholder {
color: color-mix(in srgb, var(--foreground) 36%, transparent);
}
.canvas-shortcuts-search button {
flex-shrink: 0;
border: 0;
border-radius: var(--r-xs);
background: transparent;
padding: 3px 5px;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
font-size: var(--fs-micro);
cursor: pointer;
}
.canvas-shortcuts-search button:hover {
background: var(--surface-hover);
color: var(--foreground);
}
.canvas-shortcuts-search button:focus-visible,
.canvas-shortcuts-categories button:focus-visible {
outline: 2px solid var(--control-focus-ring);
outline-offset: -2px;
}
.canvas-shortcuts-close-hint kbd {
display: inline-flex;
min-width: 28px;
height: 22px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: var(--r-xs);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 0 6px;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--fs-tiny);
font-weight: 600;
}
.canvas-shortcuts-body {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: 174px minmax(0, 1fr);
}
.canvas-shortcuts-categories {
display: flex;
min-height: 0;
flex-direction: column;
gap: 3px;
border-right: 1px solid var(--border);
padding: 14px 10px;
}
.canvas-shortcuts-categories button {
display: flex;
width: 100%;
min-height: 36px;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
padding: 0 10px;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: var(--fs-label);
text-align: left;
cursor: pointer;
transition: background-color var(--motion-state) ease, color var(--motion-state) ease;
}
.canvas-shortcuts-categories button:hover {
background: var(--surface-hover);
color: var(--foreground);
}
.canvas-shortcuts-categories button.is-active {
background: var(--control-selected-bg);
color: var(--control-selected-fg);
font-weight: 620;
}
.canvas-shortcuts-categories button small {
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: var(--fs-tiny);
font-variant-numeric: tabular-nums;
}
.canvas-shortcuts-results {
min-width: 0;
overflow-y: auto;
padding: 8px 18px 18px;
scrollbar-gutter: stable;
}
.canvas-shortcuts-list {
display: flex;
flex-direction: column;
}
.canvas-shortcuts-row {
display: grid;
min-height: 66px;
grid-template-columns: minmax(180px, 1fr) minmax(220px, auto);
align-items: center;
gap: var(--space-5);
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
padding: 11px 8px;
}
.canvas-shortcuts-row:last-child {
border-bottom: 0;
}
.canvas-shortcuts-row:hover {
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--foreground) 3.5%, transparent);
}
.canvas-shortcuts-row-copy {
min-width: 0;
}
.canvas-shortcuts-row-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.canvas-shortcuts-row-title strong {
overflow: hidden;
font-size: var(--fs-label);
font-weight: 640;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-shortcuts-row-title small {
flex-shrink: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 2px 6px;
color: color-mix(in srgb, var(--foreground) 44%, transparent);
font-size: var(--fs-tiny);
line-height: 1.3;
}
.canvas-shortcuts-row-copy p {
margin: 4px 0 0;
overflow: hidden;
color: color-mix(in srgb, var(--foreground) 43%, transparent);
font-size: var(--fs-micro);
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-shortcuts-keys {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.canvas-shortcuts-combination,
.canvas-shortcuts-key-part {
display: inline-flex;
align-items: center;
gap: 4px;
}
.canvas-shortcuts-combination em,
.canvas-shortcuts-key-part i {
color: color-mix(in srgb, var(--foreground) 28%, transparent);
font-size: var(--fs-tiny);
font-style: normal;
}
.canvas-shortcuts-combination em {
margin: 0 2px;
}
.canvas-shortcuts-key-part kbd {
display: inline-flex;
min-width: 28px;
min-height: 26px;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 4px 7px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--fs-tiny);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.canvas-shortcuts-empty {
display: grid;
min-height: 300px;
place-items: center;
align-content: center;
gap: 8px;
text-align: center;
}
.canvas-shortcuts-empty svg {
width: 26px;
height: 26px;
margin-bottom: 3px;
color: color-mix(in srgb, var(--foreground) 28%, transparent);
stroke-width: 1.5;
}
.canvas-shortcuts-empty strong {
font-size: var(--fs-label);
font-weight: 620;
}
.canvas-shortcuts-empty span {
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: var(--fs-micro);
}
.canvas-shortcuts-footer {
display: flex;
min-height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-top: 1px solid var(--border);
padding: 8px 18px;
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: var(--fs-micro);
}
.canvas-shortcuts-close-hint {
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (max-width: 680px) {
.canvas-shortcuts-shell {
height: min(720px, 88dvh);
min-height: 440px;
}
.canvas-shortcuts-header {
grid-template-columns: 1fr;
gap: 12px;
padding: 16px 48px 14px 14px;
}
.canvas-shortcuts-body {
display: flex;
flex-direction: column;
}
.canvas-shortcuts-categories {
flex-shrink: 0;
flex-direction: row;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
padding: 8px 10px;
}
.canvas-shortcuts-categories button {
width: auto;
min-width: max-content;
gap: 7px;
padding: 0 10px;
}
.canvas-shortcuts-results {
padding: 6px 10px 14px;
}
.canvas-shortcuts-row {
min-height: 0;
grid-template-columns: 1fr;
gap: 9px;
padding: 12px 6px;
}
.canvas-shortcuts-row-copy p {
white-space: normal;
}
.canvas-shortcuts-keys {
justify-content: flex-start;
}
}
.asset-library-picker-modal { margin: 0 auto !important; }
.asset-library-picker-modal .ant-modal-container { padding: 0 !important; }
.asset-library-picker-modal .ant-modal-body { padding: 0 !important; }
+3
View File
@@ -205,6 +205,7 @@ export type CanvasNodeMetadata = {
generationErrorCode?: string;
resourceReloadAvailable?: boolean;
failedPromptFingerprint?: string;
lastGenerationRequestFingerprint?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
@@ -407,6 +408,8 @@ export type CanvasNodeData = {
id: string;
type: CanvasNodeTypeId;
title: string;
createdAt?: string;
updatedAt?: string;
position: Position;
width: number;
height: number;
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { ImageAsset, TextAsset } from "@/stores/use-asset-store";
const timestamp = "2026-08-28T00:00:00.000Z";
describe("canvas asset picker", () => {
test("maps multiple local and external selections in the requested order", () => {
const image: ImageAsset = {
id: "image-1",
kind: "image",
title: "场景图",
coverUrl: "",
tags: [],
createdAt: timestamp,
updatedAt: timestamp,
data: { dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", width: 1280, height: 720, bytes: 1024, mimeType: "image/png" },
};
const text: TextAsset = {
id: "text-1",
kind: "text",
title: "旁白",
coverUrl: "",
tags: [],
createdAt: timestamp,
updatedAt: timestamp,
data: { content: "夜色渐深。" },
};
const items: AssetLibraryPickerItem[] = [
{ id: image.id, title: image.title, category: "environment", kindLabel: "图片", asset: image },
{ id: text.id, title: text.title, category: "other", kindLabel: "文本", asset: text },
{
id: "external-video",
title: "外部视频",
category: "external:eagle",
kindLabel: "视频",
external: {
sourceId: "eagle",
sourceName: "Eagle",
item: { id: "video-1", title: "外部视频", kind: "video", fileUrl: "http://127.0.0.1/video.mp4", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4" },
},
},
];
expect(assetPickerItemsToInsertPayloads(["external-video", image.id, text.id], items)).toEqual([
{ kind: "video", url: "http://127.0.0.1/video.mp4", title: "外部视频", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4", assetId: "external:eagle:video-1" },
{ kind: "image", dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", title: "场景图", width: 1280, height: 720, bytes: 1024, mimeType: "image/png", assetId: "image-1" },
{ kind: "text", content: "夜色渐深。", title: "旁白", assetId: "text-1" },
]);
});
test("rejects stale selections instead of silently dropping them", () => {
expect(() => assetPickerItemsToInsertPayloads(["missing"], [])).toThrow("所选素材已不存在");
});
});
describe("canvas context menu motion", () => {
test("keeps hover feedback stationary while retaining spotlight surfaces", () => {
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
const menu = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-context-menu.tsx"), "utf8");
const menuRule = styles.match(/\.canvas-menu-item\s*\{([\s\S]*?)\}/)?.[1] || "";
expect(menuRule).not.toContain("transform");
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):hover {");
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):active");
expect(menu).not.toContain("group-hover:translate-x");
expect(menu).not.toContain("scale: 0.97");
expect(menu).toContain("<SpotlightSurface");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test";
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
function fingerprint(overrides: Partial<Parameters<typeof canvasGenerationRequestFingerprint>[0]> = {}) {
return canvasGenerationRequestFingerprint({
nodeId: "node-1",
mode: "video",
prompt: "夜晚的城市",
model: "video-model",
options: { size: "16:9", videoSeconds: 10, vquality: "768p" },
context: {
referenceImages: [{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", storageKey: "resource:image-1" }],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
},
...overrides,
});
}
describe("canvas generation submission", () => {
test("同一节点的并发提交只执行一次", async () => {
const locks = new Map<string, Promise<unknown>>();
let executions = 0;
let duplicates = 0;
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const run = () =>
runCanvasGenerationSubmissionOnce(
locks,
"node-1",
async () => {
executions += 1;
await pending;
return "done";
},
() => {
duplicates += 1;
},
);
const submissions = [run(), run(), run()];
await Promise.resolve();
expect(executions).toBe(1);
expect(duplicates).toBe(2);
release();
expect(await Promise.all(submissions)).toEqual(["done", "done", "done"]);
await run();
expect(executions).toBe(2);
});
test("相同输入生成稳定指纹,关键内容变化会改变指纹", () => {
expect(fingerprint({ options: { vquality: "768p", videoSeconds: 10, size: "16:9" } })).toBe(fingerprint());
expect(fingerprint({ prompt: "白天的城市" })).not.toBe(fingerprint());
expect(fingerprint({ model: "another-model" })).not.toBe(fingerprint());
expect(fingerprint({ options: { size: "9:16", videoSeconds: 10, vquality: "768p" } })).not.toBe(fingerprint());
expect(
fingerprint({
context: {
referenceImages: [{ id: "image-2", name: "other.png", type: "image/png", dataUrl: "", storageKey: "resource:image-2" }],
referenceVideos: [],
referenceAudios: [],
characterReferences: [],
resolvedCharacterVersions: [],
resolvedCharacterVoices: [],
},
}),
).not.toBe(fingerprint());
});
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { hasCanvasTextSelection } from "../src/pages/canvas/use-canvas-keyboard";
test("Canvas copy shortcut yields to a real browser text selection", () => {
expect(hasCanvasTextSelection(null)).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: true, rangeCount: 1, toString: () => "Agent 文本" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 0, toString: () => "Agent 文本" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "" })).toBe(false);
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "Agent 文本" })).toBe(true);
});
test("Canvas keyboard keeps node copy as the fallback when no text is selected", async () => {
const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-keyboard.ts", import.meta.url)).text();
expect(source).toContain("if (hasCanvasTextSelection(window.getSelection())) return;");
expect(source).toContain("event.preventDefault();\n copySelectedNodes();");
});
+21
View File
@@ -230,6 +230,27 @@ test("Canvas launch mode auto-connects after rejected legacy deep-link secrets a
expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false);
});
test("Canvas local Agent setup generates exact-origin commands for Unix and Windows", async () => {
const module = await import("../src/lib/canvas/local-agent-setup");
const unix = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "unix");
const windows = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "windows");
expect(unix.install).toContain("git clone https://github.com/ddcat-ai/open-ai-canvas.git");
expect(unix.start).toContain("FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(unix.start).toContain("node dist/index.js");
expect(windows.start).toContain("$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(windows.start).toContain("node .\\dist\\index.js");
expect(module.detectLocalAgentSetupPlatform("Windows NT 10.0")).toBe("windows");
expect(module.detectLocalAgentSetupPlatform("Macintosh Intel Mac OS X")).toBe("unix");
});
test("Canvas local Agent tutorial replaces the conversation only while disconnected", async () => {
const source = await Bun.file(new URL("../src/components/canvas/canvas-local-agent-panel.tsx", import.meta.url)).text();
expect(source).toContain('!connected ? (\n <AgentConnectView');
expect(source).toContain("连接成功后,这里会自动恢复原对话");
expect(source).toContain("当前支持范围");
});
test("Canvas reconnect wait resolves quietly when component cleanup aborts", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const waitForReconnect = (
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { buildCanvasMediaDownloadFileName, canvasMediaFileExtension } from "@/lib/canvas/canvas-media-download";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function mediaNode(overrides: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id: "image-1",
type: CanvasNodeType.Image,
title: "女明星角色三视图",
position: { x: 0, y: 0 },
width: 1024,
height: 1024,
metadata: { content: "data:image/png;base64,aW1hZ2U=", mimeType: "image/png", status: "success" },
...overrides,
};
}
describe("canvas media download", () => {
test("按画布名、节点名和本地日期生成文件名", () => {
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书", mediaNode(), new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_女明星角色三视图_20260828.png");
});
test("清理跨平台非法字符且保留中文可读名称", () => {
const node = mediaNode({ title: "女明星/角色:三视图?" });
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书|终稿", node, new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_终稿_女明星_角色_三视图_20260828.png");
});
test("截断长名称后不会留下 Windows 不接受的结尾句点", () => {
const node = mediaNode({ title: `${"a".repeat(79)}.extra` });
expect(buildCanvasMediaDownloadFileName("画布", node, new Date(2026, 7, 28, 12))).toBe(`画布_${"a".repeat(79)}_20260828.png`);
});
test("优先按媒体 MIME 类型确定视频扩展名", () => {
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/webm" } }))).toBe("webm");
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/quicktime" } }))).toBe("mov");
});
test("MIME 类型不明确时从远程资源 URL 识别扩展名", () => {
expect(canvasMediaFileExtension(mediaNode({ metadata: { content: "https://example.com/result.jpeg?token=hidden", mimeType: "image/*" } }))).toBe("jpg");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
describe("canvas node copy title", () => {
test("首次复制使用 copy1,后续按已有最大序号递增", () => {
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图"])).toBe("女明星角色三视图_copy1");
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图_copy1", "女明星角色三视图_copy3"])).toBe("女明星角色三视图_copy4");
});
test("复制已有副本时仍使用原始名称计算下一序号", () => {
expect(nextCopiedNodeTitle("女明星角色三视图_copy1", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
});
test("兼容旧版空格 Copy 后缀", () => {
expect(nextCopiedNodeTitle("女明星角色三视图 Copy", ["女明星角色三视图", "女明星角色三视图 Copy"])).toBe("女明星角色三视图_copy1");
});
test("同一批粘贴可以通过预留标题连续分配序号", () => {
const titles = new Set(["节点", "节点_copy1"]);
const first = nextCopiedNodeTitle("节点", titles);
titles.add(first);
const second = nextCopiedNodeTitle("节点", titles);
expect([first, second]).toEqual(["节点_copy2", "节点_copy3"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function node(id: string, patch: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id,
type: CanvasNodeType.Image,
title: id,
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: {},
...patch,
};
}
describe("canvas node timestamps", () => {
test("legacy nodes receive the project timestamp baseline", () => {
const normalized = normalizeCanvasNodeTimestamps([node("legacy")], {
createdAt: "2026-08-01T01:00:00.000Z",
updatedAt: "2026-08-20T02:00:00.000Z",
});
expect(normalized[0]?.createdAt).toBe("2026-08-01T01:00:00.000Z");
expect(normalized[0]?.updatedAt).toBe("2026-08-20T02:00:00.000Z");
});
test("new nodes and meaningful edits receive timestamps without touching unchanged nodes", () => {
const created = stampCanvasNodeChanges([], [node("new")], "2026-08-28T01:00:00.000Z");
expect(created[0]).toMatchObject({ createdAt: "2026-08-28T01:00:00.000Z", updatedAt: "2026-08-28T01:00:00.000Z" });
const unchanged = created[0]!;
const edited = { ...unchanged, title: "新标题" };
const updated = stampCanvasNodeChanges(created, [edited], "2026-08-28T02:00:00.000Z");
expect(updated[0]?.createdAt).toBe("2026-08-28T01:00:00.000Z");
expect(updated[0]?.updatedAt).toBe("2026-08-28T02:00:00.000Z");
});
test("media hydration does not pretend to be a user edit", () => {
const previous = node("image", {
createdAt: "2026-08-28T01:00:00.000Z",
updatedAt: "2026-08-28T01:00:00.000Z",
metadata: { storageKey: "resource:1", content: "resource:1" },
});
const hydrated = { ...previous, metadata: { ...previous.metadata, content: "blob:preview", naturalWidth: 1920, naturalHeight: 1080 } };
const updated = stampCanvasNodeChanges([previous], [hydrated], "2026-08-28T02:00:00.000Z");
expect(updated[0]?.updatedAt).toBe("2026-08-28T01:00:00.000Z");
});
});
describe("canvas node search", () => {
test("defaults to recently edited order and searches model or tags", () => {
const older = node("older", { updatedAt: "2026-08-20T01:00:00.000Z", metadata: { model: "wan-video", assetTags: ["夜景"] } });
const newer = node("newer", { updatedAt: "2026-08-28T01:00:00.000Z", metadata: { model: "seedance" } });
expect(searchCanvasNodes([older, newer], "").map((item) => item.id)).toEqual(["newer", "older"]);
expect(searchCanvasNodes([older, newer], "夜景").map((item) => item.id)).toEqual(["older"]);
expect(searchCanvasNodes([older, newer], "seedance").map((item) => item.id)).toEqual(["newer"]);
});
test("summaries expose useful media metadata without leaking data URLs into context", () => {
const image = node("image", {
createdAt: "2026-08-28T01:00:00.000Z",
updatedAt: "2026-08-28T02:00:00.000Z",
metadata: { content: "data:image/png;base64,AAAA", naturalWidth: 1920, naturalHeight: 1080, bytes: 2 * 1024 * 1024, model: "image-model" },
});
expect(canvasNodeMaterialSummary(image)).toContain("1920×1080");
expect(canvasNodeSearchContext(image)).toBe("图片节点");
expect(canvasNodeSearchTimes(image)).toMatchObject({ createdAt: image.createdAt, updatedAt: image.updatedAt });
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { CANVAS_SHORTCUTS, filterCanvasShortcuts } from "../src/lib/canvas/canvas-shortcuts";
describe("canvas shortcuts", () => {
test("uses Ctrl/Cmd + F for search and Shift + Ctrl/Cmd + F for focus mode", () => {
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "search")?.keys).toEqual([["Ctrl / Cmd", "F"]]);
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "focus")?.keys).toEqual([["Shift", "Ctrl / Cmd", "F"]]);
});
test("searches titles, descriptions, keys and keywords", () => {
expect(filterCanvasShortcuts("粘贴").map((shortcut) => shortcut.id)).toContain("paste");
expect(filterCanvasShortcuts("缩放").map((shortcut) => shortcut.id)).toEqual(expect.arrayContaining(["zoom-wheel", "zoom-controls", "zoom-presets"]));
expect(filterCanvasShortcuts("Alt L").map((shortcut) => shortcut.id)).toContain("batch-connect");
});
test("filters by category without losing the full catalog", () => {
expect(filterCanvasShortcuts("", "common").every((shortcut) => shortcut.category === "common")).toBe(true);
expect(filterCanvasShortcuts("", "all")).toHaveLength(CANVAS_SHORTCUTS.length);
});
});
+30
View File
@@ -5,6 +5,8 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
import { VideoSettingsPanel } from "../src/components/video-settings-panel";
import { canvasThemes } from "../src/lib/canvas-theme";
import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
@@ -328,4 +330,32 @@ describe("public channel model catalog", () => {
expect(body.seconds).toBe("8");
expect(body.size).toBe("1280x720");
});
test("preserves provider resolution enums with direction suffixes across Canvas display and requests", async () => {
let body: Record<string, string> = {};
axios.post = (async (_url: string, requestBody: unknown) => {
body = formEntries(requestBody);
return { data: { id: "synthetic-directional" } };
}) as typeof axios.post;
const catalog: ChannelModelCatalogItem = {
...omniCatalog,
id: "directional-video",
displayName: "Directional Video",
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "768p竖" },
options: {
...omniCatalog.options,
durationSeconds: [{ value: "8" }],
resolution: [{ value: "480p竖" }, { value: "768p竖" }, { value: "480p横" }, { value: "768p横" }],
},
};
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "768P竖" });
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
const panelHtml = renderToStaticMarkup(React.createElement(VideoSettingsPanel, { config, onConfigChange: () => undefined, theme: canvasThemes.dark }));
await createVideoGenerationTask(config, "synthetic prompt");
expect(html).toContain("768P竖 · 16:9 · 8s");
expect(panelHtml).toMatch(/aria-pressed="true"[^>]*>768P竖<\/button>/);
expect(body.resolution_name).toBe("768p竖");
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { modelQuoteRequest, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
import type { ModelRequirements } from "../src/lib/model-selection";
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
@@ -58,6 +58,11 @@ const textVideoRequirements: ModelRequirements = {
};
describe("model request pricing", () => {
test("preserves provider-specific resolution enums when matching price tiers", () => {
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
});
test("matches the current Agnes resolution and duration tier and totals per-second credits", () => {
const config = systemConfig({
tiers: [
+5 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { normalizeVideoResolution, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
import { formatVideoResolutionLabel, normalizeVideoResolution, videoResolutionComparisonKey, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
describe("video generation resolution options", () => {
test("统一档位包含 1440P 与 4K,并识别常见别名", () => {
@@ -14,6 +14,10 @@ describe("video generation resolution options", () => {
test("保留模型声明的非标准档位而不是静默降级", () => {
expect(normalizeVideoResolution("768p")).toBe("768");
expect(normalizeVideoResolution("768p竖")).toBe("768p竖");
expect(normalizeVideoResolution("HD_Portrait")).toBe("HD_Portrait");
expect(videoResolutionComparisonKey("768P竖")).toBe("768p竖");
expect(formatVideoResolutionLabel("768p竖")).toBe("768P竖");
});
test("按协议限制实际可选档位", () => {