feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容
This commit is contained in:
1 parent
913cf4b304
commit
ab89c05362
52 files changed
+2344
-500
No files matched your search
+1
-1
@@ -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(" · ");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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, "'\\''")}'`;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();");
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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竖");
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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("按协议限制实际可选档位", () => {
|
||||
|
||||
Reference in new issue
Block a user