@@ -539,10 +538,10 @@ export function AgentChatComposer({
setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20)));
};
- // 在输入值末尾检测「/关键词」打开技能候选;选中后写入稳定 token,编辑器再把它渲染为技能 chip。
+ // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。
const handlePromptChange = (value: string) => {
onPromptChange(value);
- const match = /(^|\s)\/([^\s/]*)$/.exec(value);
+ const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value);
if (match && availableSlashSkills.length) {
const next = { start: match.index + match[1].length, query: match[2] };
setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next));
@@ -554,6 +553,7 @@ export function AgentChatComposer({
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skillId}] `;
+ // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
setSlash(null);
setSlashIndex(0);
@@ -674,7 +674,7 @@ export function AgentChatComposer({
value={prompt}
references={composerReferences}
includeAssetLibrary={includeAssetLibrary}
- sendOnEnter={false}
+ sendOnEnter={canSubmit ? "both" : false}
disabled={disabled}
onChange={handlePromptChange}
onSubmit={() => { if (canSubmit) onSubmit(); }}
@@ -741,7 +741,12 @@ export function AgentChatComposer({
{left}
-
{canStop ? "运行中:发送即插话,下一步生效" : "Enter 换行 · ⌘/Ctrl+Enter 发送"}
+ {disabled ? null : (
+
+ {canStop ? "运行中:发送即插话,下一步生效" : "Enter 发送 · Shift+Enter 换行"}
+ {canStop ? "运行中可插话" : "Enter 发送"}
+
+ )}
{canStop ?
onSubmit()}
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
@@ -835,7 +840,6 @@ function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasTheme
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
-
{icon || }
diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx
index 8962e1ba1..af8d86bcf 100644
--- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx
+++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx
@@ -733,7 +733,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
sending={busy}
running={running}
- placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 引用 Skills"}
+ placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills"}
theme={theme}
onPromptChange={setPrompt}
onSubmit={() => void submit()}
diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css
index 38bbe76c8..c861dc950 100644
--- a/web/src/components/canvas/canvas-cloud-agent.css
+++ b/web/src/components/canvas/canvas-cloud-agent.css
@@ -96,19 +96,14 @@
}
.agent-tool-row {
- /* 行为用图标和文案区分,颜色只留给真实的成功/失败状态。 */
+ /* 日志是事件流,不是连续卡片;只用间距和状态图标建立层次。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
- min-height: 42px;
- border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent);
- border-radius: 14px;
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
- padding: 9px 10px;
- transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
-}
-
-.agent-tool-row:hover {
- border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
- background: color-mix(in srgb, var(--foreground) 4%, var(--background));
+ min-height: 36px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ padding: 5px 2px;
+ transition: transform 160ms ease;
}
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
@@ -120,11 +115,6 @@
padding: 3px 2px;
}
-.agent-tool-row--plain:hover {
- border-color: transparent;
- background: transparent;
-}
-
.agent-tool-row--read {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
@@ -151,11 +141,11 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
- border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- padding: 2px 7px 2px 6px;
- color: color-mix(in srgb, var(--foreground) 76%, transparent);
+ border: 0;
+ border-radius: 5px;
+ background: color-mix(in srgb, var(--agent-tool-accent) 9%, transparent);
+ padding: 2px 5px;
+ color: color-mix(in srgb, var(--foreground) 68%, transparent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.01em;
@@ -235,10 +225,18 @@
.agent-composer-prompt-scroll {
min-height: 60px;
max-height: min(240px, 34vh);
+ min-width: 0;
overflow-y: auto;
+ overflow-x: hidden;
scrollbar-gutter: stable;
}
+.agent-composer-prompt-scroll > [data-canvas-no-zoom] {
+ min-height: 0;
+ height: 100%;
+ max-height: 100%;
+}
+
.agent-composer-resize-handle {
display: grid;
width: 100%;
@@ -366,11 +364,19 @@
white-space: nowrap;
}
-/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */
+/* 面板宽度包含内边距;默认 448px 面板放不下完整快捷键说明,窄态降级为短文案而不是隐藏提示本身。 */
+.agent-composer-send-hint .agent-composer-send-hint-compact {
+ display: none;
+}
+
@container (max-width: 520px) {
- .agent-composer-send-hint {
+ .agent-composer-send-hint .agent-composer-send-hint-full {
display: none;
}
+
+ .agent-composer-send-hint .agent-composer-send-hint-compact {
+ display: inline;
+ }
}
.agent-tool-status {
@@ -398,17 +404,15 @@
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: 14px;
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
- box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 3%, transparent);
- transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ box-shadow: none;
+ transition: background-color 160ms ease;
}
.agent-reasoning-card[open] {
- border-color: color-mix(in srgb, var(--agent-reasoning-accent) 28%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
- box-shadow: 0 8px 24px color-mix(in srgb, var(--agent-reasoning-accent) 7%, transparent);
}
.agent-reasoning-summary {
@@ -418,7 +422,7 @@
gap: 9px;
cursor: pointer;
list-style: none;
- padding: 9px 10px;
+ padding: 6px 4px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
user-select: none;
}
@@ -437,9 +441,9 @@
height: 25px;
flex: 0 0 25px;
place-items: center;
- border: 1px solid color-mix(in srgb, var(--agent-reasoning-accent) 24%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--agent-reasoning-accent) 11%, transparent);
+ border: 0;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--agent-reasoning-accent) 10%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -477,10 +481,10 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
- border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- padding: 2px 7px;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ padding: 2px 4px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 10px;
font-weight: 600;
@@ -488,7 +492,6 @@
}
.agent-reasoning-status.is-live {
- border-color: color-mix(in srgb, var(--agent-reasoning-accent) 22%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 9%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -524,21 +527,13 @@
position: relative;
display: flex;
max-height: min(232px, 30vh);
- gap: 10px;
+ gap: 0;
overflow: auto;
- border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- padding: 10px 12px 12px 14px;
+ border-top: 0;
+ padding: 4px 10px 10px 38px;
scrollbar-gutter: stable;
}
-.agent-reasoning-rail {
- width: 2px;
- flex: 0 0 2px;
- align-self: stretch;
- border-radius: 999px;
- background: linear-gradient(180deg, color-mix(in srgb, var(--agent-reasoning-accent) 72%, transparent), color-mix(in srgb, var(--agent-reasoning-accent) 12%, transparent));
-}
-
.agent-reasoning-text {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
@@ -873,170 +868,194 @@
gap: 8px;
}
+.canvas-agent-skill-library-modal .ant-modal {
+ padding-bottom: 0;
+ margin-top: var(--space-10);
+}
+
.canvas-agent-skill-library-modal .ant-modal-container {
- overflow: hidden;
- border: 1px solid var(--border);
- border-radius: var(--r-2xl);
- padding: 0;
- background: transparent;
- box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important;
+ border: 0;
+ border-radius: var(--r-5xl);
+ background: var(--background);
+ box-shadow: var(--elevation-overlay);
}
-.canvas-agent-skill-library-modal .ant-modal-body {
- padding: 0;
+.canvas-agent-skill-library-frame {
+ position: relative;
}
-.canvas-agent-skill-library-modal .ant-modal-close {
- top: 16px;
- right: 16px;
- width: 34px;
- height: 34px;
+.canvas-agent-skill-library-close {
+ position: absolute;
+ top: calc(-1 * var(--space-10));
+ inset-inline-start: var(--space-2);
+ inset-inline-end: auto;
+ width: auto;
+ height: var(--space-8);
border-radius: var(--r-md);
+ padding: 0 var(--space-2);
color: var(--foreground);
- background: color-mix(in srgb, var(--foreground) 7%, transparent);
+ background: transparent;
+ overflow: visible;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--fs-body);
}
-.canvas-agent-skill-library-modal .ant-modal-close:hover {
- background: color-mix(in srgb, var(--foreground) 12%, transparent);
+.canvas-agent-skill-library-close kbd {
+ border-radius: var(--r-xs);
+ background: var(--primary);
+ padding: var(--space-half) var(--space-2);
+ color: var(--primary-foreground);
+ font-size: var(--fs-caption);
}
.canvas-agent-skill-library-shell {
- position: relative;
display: grid;
- height: min(780px, calc(100dvh - 36px));
- grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ height: min(700px, 66dvh);
+ max-height: calc(100dvh - 88px);
+ grid-template-columns: 176px minmax(0, 1fr);
overflow: hidden;
border-radius: inherit;
- background:
- radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%),
- var(--cn-spatial-elevated, var(--background));
+}
+
+.canvas-agent-skill-library-sidebar {
+ display: flex;
+ min-height: 0;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--space-5);
+ margin: var(--space-6) 0;
+ border-right: 1px solid var(--border);
+ padding: 0 var(--space-3);
+}
+
+.canvas-agent-skill-library-search.ant-input-affix-wrapper {
+ flex: 0 0 auto;
+ height: var(--space-12);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--library-surface);
+ padding-inline: var(--space-4);
+ font-size: var(--fs-body);
+}
+
+.canvas-agent-skill-library-search .ant-input-prefix {
+ margin-inline-end: var(--space-2);
+}
+
+.canvas-agent-skill-library-categories {
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+ gap: var(--space-1);
+ overflow-y: auto;
+ padding: var(--space-1);
+ overscroll-behavior: contain;
+}
+
+.canvas-agent-skill-library-tab {
+ display: flex;
+ min-height: var(--space-12);
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--space-3);
+ border: 0;
+ border-radius: var(--r-lg);
+ padding: var(--space-3);
+ color: var(--muted-foreground);
+ font-size: var(--fs-body-lg);
+ text-align: left;
+ overflow-wrap: anywhere;
+ transition: background-color var(--motion-dur-fast) ease, color var(--motion-dur-fast) ease;
+}
+
+.canvas-agent-skill-library-tab::before {
+ width: var(--space-1);
+ height: var(--space-1);
+ flex: 0 0 auto;
+ border-radius: var(--r-full);
+ background: currentColor;
+ content: "";
+ opacity: 0.4;
+}
+
+.canvas-agent-skill-library-tab span {
+ margin-left: auto;
+ color: var(--muted-foreground);
+ font-size: var(--fs-caption);
+ font-variant-numeric: tabular-nums;
+}
+
+.canvas-agent-skill-library-tab:hover {
+ background: var(--library-surface-hover);
+ color: var(--foreground);
+}
+
+.canvas-agent-skill-library-tab.is-active {
+ color: var(--foreground);
+ font-weight: 650;
+}
+
+.canvas-agent-skill-library-tab.is-active::before {
+ opacity: 1;
+}
+
+.canvas-agent-skill-library-content {
+ container: agent-skill-content / inline-size;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
}
.canvas-agent-skill-library-header {
display: flex;
min-width: 0;
+ flex: 0 0 auto;
align-items: center;
justify-content: space-between;
- gap: 20px;
- padding: 18px 64px 14px 20px;
+ gap: var(--space-4);
+ padding: var(--space-5) var(--space-6);
}
-.canvas-agent-skill-library-header p {
- margin-bottom: 0;
+.canvas-agent-skill-library-header h2 {
+ margin: 0;
+ font-size: var(--fs-title);
+ font-weight: 650;
}
.canvas-agent-skill-library-count {
flex: 0 0 auto;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: var(--r-lg);
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- padding: 7px 10px;
- font-size: var(--fs-label);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--library-surface);
+ padding: var(--space-2-half) var(--space-4);
+ color: var(--foreground);
+ font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
}
-.canvas-agent-skill-library-toolbar {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
- align-items: center;
- gap: 14px;
- padding: 0 20px 12px;
+.canvas-agent-skill-library-count:hover {
+ background: var(--library-surface-hover);
}
-.canvas-agent-skill-library-tabs {
- display: flex;
- min-width: 0;
- align-items: center;
- gap: 4px;
-}
-
-.canvas-agent-skill-library-tab {
- display: inline-flex;
- min-height: 38px;
- flex: 0 0 auto;
- align-items: center;
- gap: 7px;
- border-radius: var(--r-md);
- padding: 0 12px;
- color: color-mix(in srgb, var(--foreground) 56%, transparent);
- font-size: var(--fs-caption);
- font-weight: 560;
- transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-library-tab span {
- min-width: 20px;
- border-radius: 999px;
- background: color-mix(in srgb, currentColor 9%, transparent);
- padding: 1px 6px;
- font-size: var(--fs-tiny);
- line-height: 18px;
- text-align: center;
-}
-
-.canvas-agent-skill-library-tab:hover {
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- color: color-mix(in srgb, var(--foreground) 76%, transparent);
-}
-
-.canvas-agent-skill-library-tab.is-active {
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- color: color-mix(in srgb, var(--foreground) 78%, transparent);
-}
-
-.canvas-agent-skill-library-search {
- height: 40px;
- border-radius: var(--r-lg);
-}
-
-.canvas-agent-skill-library-categories {
- display: flex;
- min-width: 0;
- gap: 8px;
- overflow-x: auto;
- border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- padding: 10px 20px;
- overscroll-behavior-x: contain;
- scrollbar-width: none;
-}
-
-.canvas-agent-skill-library-categories::-webkit-scrollbar {
- display: none;
-}
-
-.canvas-agent-skill-category {
- min-height: 34px;
- flex: 0 0 auto;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: var(--r-md);
- padding: 0 13px;
- color: color-mix(in srgb, var(--foreground) 58%, transparent);
- font-size: var(--fs-caption);
- transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-category:hover {
- border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
- color: var(--foreground);
-}
-
-.canvas-agent-skill-category.is-active {
- border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border));
- background: color-mix(in srgb, var(--workspace-accent) 11%, transparent);
- color: var(--workspace-accent);
+.canvas-agent-skill-library-count span {
+ color: var(--muted-foreground);
}
.canvas-agent-skill-library-list {
display: grid;
min-height: 0;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ flex: 1;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: max-content;
align-content: start;
- gap: 12px;
+ gap: var(--space-6) var(--space-4);
overflow-y: auto;
- padding: 16px 20px;
+ padding: var(--space-1) var(--space-6) var(--space-6);
overscroll-behavior: contain;
+ scrollbar-gutter: stable;
}
.canvas-agent-skill-library-state {
@@ -1045,54 +1064,52 @@
grid-column: 1 / -1;
place-items: center;
align-content: center;
- gap: 10px;
- padding: 28px;
- font-size: var(--fs-caption);
+ gap: var(--space-3);
+ padding: var(--space-6);
+ font-size: var(--fs-body);
text-align: center;
}
.canvas-agent-skill-card {
- display: grid;
min-width: 0;
- min-height: 136px;
- grid-template-columns: 164px minmax(0, 1fr) auto;
- gap: 14px;
- overflow: hidden;
- border: 1px solid var(--border);
- border-radius: var(--r-xl);
- background: color-mix(in srgb, var(--foreground) 3%, transparent);
- padding: 11px;
- transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
+ border: 0;
+ border-radius: var(--r-2xl);
+ transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease;
}
-.canvas-agent-skill-card:hover {
- border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important;
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- transform: translateY(-1px);
+.canvas-agent-skill-card:hover,
+.canvas-agent-skill-card:focus-within {
+ transform: translateY(var(--library-hover-offset));
+ background: var(--library-surface-hover);
}
-.canvas-agent-skill-card.is-selected {
- background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent);
+.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
+.canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
+ transform: scale(1.025);
}
.canvas-agent-skill-card-cover {
position: relative;
min-width: 0;
overflow: hidden;
- border-radius: var(--r-lg);
- aspect-ratio: 16 / 10;
+ border-radius: var(--r-2xl);
+ aspect-ratio: 16 / 9;
+ background: var(--library-cover-surface);
+ transition: outline-color var(--motion-dur-fast) ease;
}
-.canvas-agent-skill-card-cover img {
+.canvas-agent-skill-card.is-selected .canvas-agent-skill-card-cover {
+ outline: 1px solid var(--library-selected-stroke);
+ outline-offset: 2px;
+}
+
+.canvas-agent-skill-card-cover img,
+.canvas-agent-skill-card-cover video {
+ display: block;
width: 100%;
height: 100%;
object-fit: cover;
- transition: transform var(--motion-dur-base) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
- transform: scale(1.025);
+ transition: transform var(--motion-state) var(--motion-ease-out);
}
.canvas-agent-skill-card-placeholder {
@@ -1100,258 +1117,242 @@
width: 100%;
height: 100%;
place-items: center;
- color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground));
- background:
- radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%),
- linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent));
+ color: var(--muted-foreground);
+ background: var(--library-cover-surface);
}
.canvas-agent-skill-card-category {
position: absolute;
- top: 7px;
- right: 7px;
- max-width: calc(100% - 14px);
+ bottom: var(--space-3);
+ left: var(--space-3);
+ max-width: calc(100% - var(--space-6));
overflow: hidden;
- border: 1px solid color-mix(in srgb, white 18%, transparent);
- border-radius: var(--r-md);
- background: color-mix(in srgb, black 62%, transparent);
- padding: 2px 7px;
- color: white;
+ border-radius: var(--r-sm);
+ background: color-mix(in srgb, var(--color-neutral-950) 75%, transparent);
+ padding: var(--space-1) var(--space-2);
+ color: var(--color-neutral-0);
font-size: var(--fs-tiny);
- line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(8px);
}
+.canvas-agent-skill-card-selected {
+ position: absolute;
+ top: var(--space-3);
+ right: var(--space-3);
+ display: grid;
+ width: var(--space-8);
+ height: var(--space-8);
+ place-items: center;
+ border-radius: var(--r-full);
+ color: var(--control-selected-fg);
+ background: var(--control-selected-bg);
+}
+
.canvas-agent-skill-card-body {
display: flex;
min-width: 0;
flex-direction: column;
- padding: 4px 0 2px;
+ gap: var(--space-2);
+ padding-top: var(--space-3);
}
.canvas-agent-skill-card-title-row {
display: flex;
min-width: 0;
- align-items: baseline;
- gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
}
.canvas-agent-skill-card-title-row h3 {
min-width: 0;
overflow: hidden;
margin: 0;
- color: var(--foreground);
- font-size: 15px;
+ color: inherit;
+ font-size: var(--fs-heading);
font-weight: 650;
- line-height: 22px;
+ line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
-.canvas-agent-skill-card-version {
- flex: 0 0 auto;
- color: color-mix(in srgb, var(--foreground) 42%, transparent);
- font-size: var(--fs-tiny);
-}
-
.canvas-agent-skill-card-body > p {
display: -webkit-box;
min-width: 0;
overflow: hidden;
- margin: 7px 0 10px;
- color: color-mix(in srgb, var(--foreground) 54%, transparent);
- font-size: var(--fs-label);
- line-height: 19px;
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: var(--fs-body);
+ line-height: 1.65;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
+ overflow-wrap: anywhere;
}
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
align-items: center;
- gap: 6px;
- margin-top: auto;
- font-size: var(--fs-label);
+ gap: var(--space-2);
+ font-size: var(--fs-caption);
+}
+
+.canvas-agent-skill-card-version {
+ flex: 0 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.canvas-agent-skill-card-action {
- display: flex;
- align-items: flex-start;
- padding-top: 3px;
+ flex: 0 0 auto;
}
.canvas-agent-skill-card-action .ant-btn {
- min-width: 76px;
- border-radius: var(--r-md);
+ min-width: var(--space-16);
+ height: var(--space-8);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--primary);
+ padding-inline: var(--space-3);
+ color: var(--primary-foreground);
+ box-shadow: none;
+ font-size: var(--fs-body);
}
-.canvas-agent-skill-card-action .ant-btn.is-selected {
- border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border));
- background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
- color: var(--workspace-accent);
- box-shadow: none;
+.canvas-agent-skill-card-action .ant-btn:not(:disabled):hover {
+ background: color-mix(in srgb, var(--primary) 85%, var(--background));
+ color: var(--primary-foreground);
+}
+
+.canvas-agent-skill-card-action .ant-btn.is-selected,
+.canvas-agent-skill-card-action .ant-btn.is-selected:not(:disabled):hover {
+ background: var(--control-selected-bg);
+ color: var(--control-selected-fg);
+}
+
+.canvas-agent-skill-card-action .ant-btn:disabled {
+ background: var(--library-surface);
+ color: var(--muted-foreground);
+ opacity: 0.6;
}
.canvas-agent-skill-library-footer {
display: flex;
- min-height: 48px;
+ min-height: var(--space-12);
+ flex: 0 0 auto;
align-items: center;
justify-content: space-between;
- gap: 16px;
- border-top: 1px solid;
- padding: 8px 20px;
- font-size: var(--fs-label);
+ gap: var(--space-4);
+ padding: var(--space-2) var(--space-6);
+ font-size: var(--fs-caption);
}
.canvas-agent-skill-library-tab:focus-visible,
-.canvas-agent-skill-category:focus-visible {
+.canvas-agent-skill-library-close:focus-visible,
+.canvas-agent-skill-library-count:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
-@media (max-width: 920px) {
+@container agent-skill-content (max-width: 660px) {
.canvas-agent-skill-library-list {
- grid-template-columns: minmax(0, 1fr);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
-@media (max-width: 680px) {
+@media (max-width: 900px) {
+ .canvas-agent-skill-library-modal .ant-modal {
+ width: calc(100vw - 32px) !important;
+ }
+
.canvas-agent-skill-library-shell {
- height: calc(100dvh - 24px);
+ height: min(700px, calc(100dvh - 88px));
}
- .canvas-agent-skill-library-header {
- align-items: flex-start;
- gap: 10px;
- padding: 16px 56px 12px 14px;
+ .canvas-agent-skill-library-header,
+ .canvas-agent-skill-library-list,
+ .canvas-agent-skill-library-footer {
+ padding-inline: var(--space-5);
}
- .canvas-agent-skill-library-header p {
- display: none;
+ .canvas-agent-skill-library-sidebar {
+ padding-inline: var(--space-3);
+ }
+}
+
+@media (max-width: 639px) {
+ .canvas-agent-skill-library-modal .ant-modal-container {
+ border-radius: var(--r-4xl);
}
- .canvas-agent-skill-library-count {
- padding: 5px 8px;
- }
-
- .canvas-agent-skill-library-toolbar {
+ .canvas-agent-skill-library-shell {
grid-template-columns: minmax(0, 1fr);
- gap: 8px;
- padding: 0 14px 10px;
+ grid-template-rows: auto minmax(0, 1fr);
}
- .canvas-agent-skill-library-tabs {
- overflow-x: auto;
- scrollbar-width: none;
+ .canvas-agent-skill-library-sidebar {
+ gap: var(--space-2);
+ margin: 0;
+ border-right: 0;
+ padding: var(--space-4) var(--space-4) 0;
}
- .canvas-agent-skill-library-tabs::-webkit-scrollbar {
- display: none;
+ .canvas-agent-skill-library-search.ant-input-affix-wrapper {
+ height: var(--space-10);
}
.canvas-agent-skill-library-categories {
- padding: 8px 14px;
- }
-
- .canvas-agent-skill-library-list {
- gap: 10px;
- padding: 12px 14px;
- }
-
- .canvas-agent-skill-library-footer {
- min-height: 44px;
- padding: 6px 14px;
- }
-}
-
-@media (max-width: 560px) {
- .canvas-agent-skill-library-header {
- flex-wrap: wrap;
- }
-
- .canvas-agent-skill-library-count {
- position: absolute;
- top: 17px;
- right: 56px;
+ flex-direction: row;
+ gap: var(--space-1);
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
}
.canvas-agent-skill-library-tab {
- min-height: 34px;
- padding: 0 9px;
- font-size: var(--fs-label);
- }
-
- .canvas-agent-skill-card {
- min-height: 122px;
- grid-template-columns: 104px minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr) auto;
- gap: 8px 10px;
- padding: 9px;
- }
-
- .canvas-agent-skill-card-cover {
- grid-row: 1 / 3;
- align-self: stretch;
- aspect-ratio: auto;
- }
-
- .canvas-agent-skill-card-body {
- padding: 1px 0 0;
- }
-
- .canvas-agent-skill-card-body > p {
- margin: 4px 0 6px;
- font-size: var(--fs-tiny);
- line-height: 17px;
- }
-
- .canvas-agent-skill-card-meta {
- font-size: var(--fs-tiny);
- }
-
- .canvas-agent-skill-card-action {
- justify-content: flex-end;
- padding: 0;
- }
-
- .canvas-agent-skill-card-action .ant-btn {
- min-width: 72px;
- }
-
- .canvas-agent-skill-library-footer > span:last-child {
- display: none;
- }
-}
-
-@media (max-height: 560px) {
- .canvas-agent-skill-library-shell {
- height: calc(100dvh - 16px);
+ min-height: var(--space-10);
+ gap: var(--space-2);
+ padding: var(--space-2);
+ font-size: var(--fs-body);
+ white-space: nowrap;
}
.canvas-agent-skill-library-header {
- padding-top: 12px;
- padding-bottom: 8px;
+ padding: var(--space-4);
}
- .canvas-agent-skill-library-categories {
- padding-top: 7px;
- padding-bottom: 7px;
+ .canvas-agent-skill-library-header h2 {
+ font-size: var(--fs-heading-lg);
+ }
+
+ .canvas-agent-skill-library-list {
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-6);
+ padding-inline: var(--space-4);
+ }
+
+ .canvas-agent-skill-library-footer {
+ gap: var(--space-2);
+ padding-inline: var(--space-4);
+ font-size: var(--fs-label);
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-agent-skill-card,
+ .canvas-agent-skill-card-cover,
.canvas-agent-skill-card-cover img,
- .canvas-agent-skill-category,
+ .canvas-agent-skill-card-cover video,
.canvas-agent-skill-library-tab {
transition: none;
}
.canvas-agent-skill-card:hover,
- .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
+ .canvas-agent-skill-card:focus-within,
+ .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
+ .canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
transform: none;
}
}
diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx
index 4fafaaf84..475a9d664 100644
--- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx
+++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx
@@ -4,7 +4,7 @@ import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Layers3, Lock, LockOpen
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
-type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "imageEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
+type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
@@ -13,7 +13,6 @@ type ImageToolHandlers = {
onAnnotationEdit: (node: CanvasNodeData) => void;
onTextEdit: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
- onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
@@ -117,15 +116,6 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 15,
run: (node, handlers) => handlers.onTextEdit(node),
},
- {
- id: "imageEdit",
- label: "图片编辑",
- description: "用文字描述要修改的内容,生成新图片",
- icon: () => ,
- group: "primary",
- order: 20,
- run: (node, handlers) => handlers.onImageEdit(node),
- },
{
id: "removeBackground",
label: "去除背景",
diff --git a/web/src/components/canvas/canvas-node-image-edit-dialog.tsx b/web/src/components/canvas/canvas-node-image-edit-dialog.tsx
index 2c0e95bd4..12717765c 100644
--- a/web/src/components/canvas/canvas-node-image-edit-dialog.tsx
+++ b/web/src/components/canvas/canvas-node-image-edit-dialog.tsx
@@ -31,7 +31,7 @@ export function CanvasNodeImageEditDialog({
}, [config, open, dataUrl]);
return (
-
+

diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx
index 285e8c9e6..60ac2acee 100644
--- a/web/src/components/canvas/canvas-node-prompt-panel.tsx
+++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx
@@ -1,1141 +1,1109 @@
-import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
-import { Tooltip } from "@/components/ui/base/tooltip";
-import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
-import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, LayoutList, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
-
-import { ModelPicker } from "@/components/model-picker";
-import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
-import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
-import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
-import { CreditSymbol, requestCreditCost } from "@/constant/credits";
-import { canvasThemes } from "@/lib/canvas-theme";
-import { modelQuoteRequest } from "@/lib/model-pricing";
-import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
-import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
-import { navigateToSettings } from "@/lib/settings-navigation";
-import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
-import { useUserStore } from "@/stores/use-user-store";
-import { CanvasCameraControlPopover } from "./canvas-camera-control-popover";
-import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
-import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
-import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
-import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
-import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
-import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
-import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
-import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
-import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
-import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
-import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
-import { createPluginHostContext } from "@/services/plugin-host";
-import { usePluginStore } from "@/stores/use-plugin-store";
-import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
-import { quoteLogicalModel } from "@/services/api/logical-models";
-
-export type CanvasNodeGenerationMode = CanvasGenerationMode;
-
-type CanvasNodePromptPanelProps = {
- projectId: string;
- node: CanvasNodeData;
- isRunning: boolean;
- onPromptChange: (nodeId: string, prompt: string) => void;
- onConfigChange: (nodeId: string, patch: Partial
) => void;
- onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
- mentionReferences?: CanvasResourceReference[];
- onAddReference?: (nodeId: string, reference: CanvasResourceReference) => CanvasResourceReference | undefined;
- onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void;
- onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void;
- onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void;
- onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void;
- onClose?: () => void;
- onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
- onImageSettingsOpenChange?: (open: boolean) => void;
- workspaceMode?: CanvasWorkspaceMode;
- onListGenerate?: (nodeId: string, prompt: string) => void;
-};
-
-type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
-
-const PROMPT_REFERENCE_SHELF_HEIGHT = 58;
-// Keep the compact editor readable at rest: three 20px lines plus 12px vertical padding.
-const PROMPT_EDITOR_MIN_HEIGHT = 72;
-const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 200;
-const PROMPT_EDITOR_LINE_HEIGHT = 20;
-const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24;
-const PROMPT_EDITOR_VERTICAL_PADDING = 12;
-const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
-const PROMPT_EDITOR_MAX_LINES = 8;
-const PROMPT_EDITOR_EXPANDED_MAX_LINES = 14;
-const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)";
-const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200;
-const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420;
-
-export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional", onListGenerate }: CanvasNodePromptPanelProps) {
- const globalConfig = useEffectiveConfig();
- const themeName = useActiveTheme();
- const theme = canvasThemes[themeName];
- const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
- const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
- const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
- const simpleMode = workspaceMode === "simple";
- const mode = defaultMode(node.type);
- const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
- const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
- const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
- const [prompt, setPrompt] = useState(savedPrompt);
- const [presetOpen, setPresetOpen] = useState(false);
- const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
- const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
- const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null);
- const expandedModalRef = useRef(null);
- const [promptContentHeight, setPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, false));
- const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true));
- const [manualPromptHeight, setManualPromptHeight] = useState(null);
- const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
- const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
- const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
- const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId });
- const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]);
- const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill");
- const requirements: ModelRequirements = {
- capability: mode,
- input: {
- textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length,
- imageCount: activeReferences.filter((item) => item.kind === "image").length,
- videoCount: activeReferences.filter((item) => item.kind === "video").length,
- audioCount: activeReferences.filter((item) => item.kind === "audio").length,
- characterCount: activeReferences.filter((item) => item.kind === "character").length,
- },
- videoOperation: node.metadata?.videoEditOperation,
- videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
- options: modelRequestOptions({
- ...globalConfig,
- size: node.metadata?.size || globalConfig.size,
- quality: node.metadata?.quality || globalConfig.quality,
- count: String(node.metadata?.count || globalConfig.count),
- transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground,
- videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
- vquality: node.metadata?.vquality || globalConfig.vquality,
- videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
- videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
- audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
- audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
- audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
- }, mode),
- };
- const config = buildNodeConfig(globalConfig, node, mode, requirements);
- const resolvedRequirements: ModelRequirements = {
- ...requirements,
- options: modelRequestOptions(config, mode),
- videoSeconds: mode === "video" ? config.videoSeconds : undefined,
- };
- const promptOptimizerProvider = useMemo(() => {
- if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
- return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
- }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]);
- const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
- const priceChannel = resolveModelChannel(config, config.model);
- const configuredCredits = requestCreditCost({
- channelMode: priceChannel.scope === "system" ? "remote" : "local",
- modelCosts: priceChannel.modelCosts,
- model: modelOptionName(config.model),
- count: mode === "image" ? generationCount : 1,
- seconds: mode === "video" ? config.videoSeconds : 1,
- capability: mode,
- config,
- requirements: resolvedRequirements,
- });
- const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements);
- const quoteRequestKey = JSON.stringify(quoteRequest || null);
- const [quotedCredits, setQuotedCredits] = useState(null);
- const credits = quotedCredits ?? configuredCredits;
- const activeReferenceCount = activeReferences.length;
- const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
- const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0;
- const monochromeAccent = theme.node.activeStroke;
- const composerTokens = {
- "--canvas-composer-surface": theme.node.panel,
- "--canvas-composer-control-surface": theme.toolbar.itemHover,
- "--canvas-composer-control-hover": theme.toolbar.activeBg,
- "--canvas-composer-shadow": theme.node.shadow,
- "--cn-text": theme.node.text,
- } as CSSProperties;
- const composerSurfaceStyle = {
- ...composerTokens,
- background: theme.node.panel,
- color: theme.node.text,
- boxShadow: theme.node.shadow,
- } as CSSProperties;
- const controlSurface = "var(--canvas-composer-control-surface)";
- const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
- const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
- const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds);
- const expandedComposerHeight = clampPromptHeight(expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
- const measureExpandedModalSize = () => {
- const rect = expandedModalRef.current?.getBoundingClientRect();
- if (!rect?.width || !rect.height) return { width: PROMPT_EDITOR_MODAL_DEFAULT_WIDTH, height: PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT };
- return { width: Math.round(rect.width), height: Math.round(rect.height) };
- };
- const isSubmitDisabled = !isRunning && !prompt.trim();
- const canExpandPrompt = mode === "image" || mode === "video";
- const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt;
- const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
- const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]);
- const canAutoMention = autoMentionedPrompt !== prompt;
-
- useEffect(() => {
- setPrompt(normalizedSavedPrompt);
- if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt);
- }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]);
-
- useEffect(() => {
- setExpandedPromptOpen(false);
- setExpandedPresetOpen(false);
- setExpandedModalSize(null);
- setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false));
- setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true));
- setManualPromptHeight(null);
- }, [node.id]);
-
- useEffect(() => {
- if (!expandedPromptOpen) return;
- const constrainSize = () => setExpandedModalSize((size) => {
- if (!size) return size;
- const next = clampExpandedModalSize(size);
- return next.width === size.width && next.height === size.height ? size : next;
- });
- constrainSize();
- window.addEventListener("resize", constrainSize);
- return () => window.removeEventListener("resize", constrainSize);
- }, [expandedPromptOpen]);
-
- useEffect(() => {
- if (!creditsEnabled || !quoteRequest) {
- setQuotedCredits(null);
- return;
- }
- const controller = new AbortController();
- setQuotedCredits(null);
- quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
- .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000))
- .catch(() => {
- if (!controller.signal.aborted) setQuotedCredits(null);
- });
- return () => controller.abort();
- // quoteRequestKey captures the full normalized request without retriggering on object identity.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [creditsEnabled, quoteRequestKey]);
-
- const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
-
- const updatePrompt = (value: string) => {
- setPrompt(value);
- onPromptChange(node.id, value);
- if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
- if (expandedPromptOpen) setExpandedPresetOpen(true);
- else setPresetOpen(true);
- }
- };
-
- const applyPreset = (preset: CanvasPromptPreset) => {
- const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd();
- updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt);
- };
-
- const insertPromptReference = (reference: CanvasResourceReference) => {
- const insertText = `${canvasResourceMentionToken(reference)} `;
- const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt);
- if (pendingMentionMatch) {
- const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, "");
- updatePrompt(prefix ? `${prefix} ${insertText}` : insertText);
- return;
- }
- const basePrompt = prompt.replace(/\s*$/, "");
- updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText);
- };
-
- const submit = () => {
- const text = prompt.trim();
- if (!text || isRunning) return false;
- if (mode === "text" && node.metadata?.listMode && onListGenerate) {
- onListGenerate(node.id, text);
- } else {
- onGenerate(node.id, mode, text);
- }
- return true;
- };
-
- const submitExpandedPrompt = () => {
- if (submit()) {
- setExpandedPresetOpen(false);
- setExpandedPromptOpen(false);
- }
- };
-
- const renderComposerHeader = (expanded: boolean) => (
- {
- const target = event.target instanceof Element ? event.target : null;
- if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id);
- }}
- >
- {isPortraitTexture ? (
- <>
-
onConfigChange(node.id, { portraitTexture })} />
- {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
- >
- ) : (
-
-
-
-
- {modeDisplayName(mode)}生成
- {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
-
- )}
-
- {!simpleMode ? : null}
- {canOptimizePrompt ? (
-
-
-
- ) : null}
- {!expanded && canExpandPrompt ? (
-
-
-
- ) : null}
- {!expanded && onClose ? (
-
-
-
- ) : null}
-
-
- );
-
- const renderSubmitButton = (expanded: boolean) => {
- const showCost = creditsEnabled && credits !== null;
- const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
- const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
- return (
-
- );
- };
-
- const renderComposerControls = (expanded: boolean) =>
- simpleMode ? (
-
-
- {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
-
- updatePrompt(autoMentionedPrompt)}
- onAutoLinkEnabledChange={setAutoLinkEnabled}
- accent={monochromeAccent}
- compact
- />
- {renderSubmitButton(expanded)}
-
- ) : (
-
-
- onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })}
- capability={mode}
- requirements={resolvedRequirements}
- onMissingConfig={() => navigateToSettings({ continueCreation: true })}
- showSelectedPrice={false}
- showOptionPrices={creditsEnabled}
- variant="creation"
- showConfiguredModelName
- />
-
-
-
updatePrompt(autoMentionedPrompt)}
- onAutoLinkEnabledChange={setAutoLinkEnabled}
- accent={monochromeAccent}
- compact={!expanded}
- />
- {mode === "text" ? (
- <>
-
-
-
-
- {!node.metadata?.listMode ? (
-
- onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
- aria-label="文本生成份数"
- className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
- />
-
- ) : null}
- {node.metadata?.listMode ? (
- 行数和列结构由模型判断
- ) : null}
- >
- ) : mode === "image" ? (
- // 图片模式下,显示相机配置与镜头配置
- <>
- onConfigChange(node.id, { cameraControl: options })}
- theme={theme}
- compact={!expanded}
- />
- onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
- onMissingConfig={() => navigateToSettings({ continueCreation: true })}
- onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
- />
- >
- ) : mode === "video" ? (
- onConfigChange(node.id, videoConfigPatch(key, value))}
- />
- ) : mode === "audio" ? (
- onConfigChange(node.id, audioConfigPatch(key, value))}
- />
- ) : null}
- {renderSubmitButton(expanded)}
-
-
- );
-
- const renderPromptEditor = (expanded: boolean, fill = false) => {
- const bounds = expanded ? expandedPromptBounds : promptBounds;
- const height = expanded ? expandedComposerHeight : composerHeight;
- return (
- <>
-
- onRemoveReference?.(node.id, reference)}
- onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined}
- onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined}
- onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined}
- />
- onAddReference(node.id, reference) : undefined}
- includeAssetLibrary
- onChange={updatePrompt}
- autoLinkEnabled={autoLinkEnabled}
- onReferenceFilesDrop={onReplaceReferenceFiles ? (reference, files) => onReplaceReferenceFiles(node.id, reference, files) : undefined}
- onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
- containerClassName="min-h-0 flex-1"
- className={expanded
- ? "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
- : "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-1.5 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"}
- style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
- placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
- aria-label={`${modeDisplayName(mode)}提示词`}
- />
-
- {!expanded && }
- >
- );
- };
-
- return (
- item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType}
- config={globalConfig}
- optimizerModel={globalConfig.textModel}
- references={activeReferences}
- provider={promptOptimizerProvider}
- onClose={() => setPromptOptimizerOpen(false)}
- onApply={(nextPrompt) => updatePrompt(nextPrompt)}
- >
- event.stopPropagation()}
- onPointerDown={(event) => event.stopPropagation()}
- onWheel={(event) => event.stopPropagation()}
- >
- {renderComposerHeader(false)}
-
- {renderPromptEditor(false)}
-
- {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/}
- {hasVideoPromptTools ? (
-
-
- {paramsExpanded ? (
-
- onConfigChange(node.id, patch)} />
-
- ) : null}
-
- ) : null}
-
- {renderComposerControls(false)}
-
-
{
- setExpandedPresetOpen(false);
- setExpandedPromptOpen(false);
- }}
- styles={{
- container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow },
- body: { minHeight: 0, padding: 0 },
- }}
- >
-
-
-
{renderComposerHeader(true)}
- {renderPromptEditor(true, Boolean(expandedModalSize))}
- {hasVideoPromptTools ? (
-
- onConfigChange(node.id, patch)} />
-
- ) : null}
-
{renderComposerControls(true)}
-
-
-
-
-
-
-
- );
-}
-
-function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) {
- return (
-
-
-
智能引用
-
输入素材序号或名称后按 Tab,可快速引用
-
-
-
AutoLink
-
-
-
-
- }
- >
-
-
- );
-}
-
-function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) {
- if (mode === "image") return
;
- if (mode === "video") return
;
- if (mode === "audio") return
;
- return
;
-}
-
-function modeDisplayName(mode: CanvasNodeGenerationMode) {
- if (mode === "image") return "图片";
- if (mode === "video") return "视频";
- if (mode === "audio") return "音频";
- return "文本";
-}
-
-function referenceShelfHeading(references: CanvasResourceReference[]) {
- const label = references.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材";
- return `${label} · ${references.length}`;
-}
-
-function ConnectedReferenceShelf({
- targetNodeId,
- references,
- theme,
- onInsert,
- onRemove,
- onReorder,
- onReplaceReference,
- onReplaceReferenceFiles,
-}: {
- targetNodeId?: string;
- references: CanvasResourceReference[];
- theme: CanvasTheme;
- onInsert: (reference: CanvasResourceReference) => void;
- onRemove?: (reference: CanvasResourceReference) => void;
- onReorder?: (orderedNodeIds: string[]) => void;
- onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void;
- onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void;
-}) {
- const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
- const [imagePreview, setImagePreview] = useState
(null);
- const [draggedReferenceId, setDraggedReferenceId] = useState(null);
- const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null);
- if (!activeReferences.length) return null;
-
- const moveReference = (sourceId: string, targetId: string) => {
- if (!onReorder || sourceId === targetId) return;
- const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId);
- const targetIndex = activeReferences.findIndex((reference) => reference.nodeId === targetId);
- if (sourceIndex < 0 || targetIndex < 0) return;
- const ordered = [...activeReferences];
- const [moved] = ordered.splice(sourceIndex, 1);
- ordered.splice(targetIndex, 0, moved);
- onReorder(ordered.map((reference) => reference.nodeId));
- };
-
- const moveReferenceByOffset = (sourceId: string, offset: -1 | 1) => {
- const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId);
- const target = activeReferences[sourceIndex + offset];
- if (!target) return;
- moveReference(sourceId, target.nodeId);
- };
-
- return (
- <>
-
-
- {activeReferences.map((reference, index) => {
- const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video");
- const isDropTarget = dropTargetReferenceId === reference.id;
- return (
- {
- if (draggedReferenceId) {
- if (!onReorder) return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "move";
- return;
- }
- const hasImageNode = event.dataTransfer.types.includes("application/x-canvas-image-node-id");
- const hasFiles = event.dataTransfer.types.includes("Files");
- if (hasImageNode || hasFiles) {
- event.preventDefault();
- event.stopPropagation();
- event.dataTransfer.dropEffect = "copy";
- if (dropTargetReferenceId !== reference.id) {
- setDropTargetReferenceId(reference.id);
- }
- }
- }}
- onDragLeave={(event) => {
- if (!event.currentTarget.contains(event.relatedTarget as Node)) {
- if (dropTargetReferenceId === reference.id) {
- setDropTargetReferenceId(null);
- }
- }
- }}
- onDrop={(event) => {
- if (dropTargetReferenceId === reference.id) {
- setDropTargetReferenceId(null);
- }
- if (draggedReferenceId) {
- event.preventDefault();
- const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain");
- setDraggedReferenceId(null);
- moveReference(sourceId, reference.nodeId);
- return;
- }
- const sourceNodeId = event.dataTransfer.getData("application/x-canvas-image-node-id");
- if (sourceNodeId && sourceNodeId !== reference.nodeId && onReplaceReference) {
- event.preventDefault();
- event.stopPropagation();
- onReplaceReference(reference, sourceNodeId);
- return;
- }
- const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
- if (files.length && onReplaceReferenceFiles) {
- event.preventDefault();
- event.stopPropagation();
- onReplaceReferenceFiles(reference, files);
- return;
- }
- }}
- >
- {onReorder ? (
-
- ) : null}
- {index + 1}
-
-
- {onRemove ? (
-
- ) : null}
-
- );
- })}
-
-
- {imagePreview?.previewUrl ? (
- !open && setImagePreview(null),
- }}
- />
- ) : null}
- >
- );
-}
-
-function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference }) {
- if (reference.kind === "image" && reference.previewUrl) return
;
- if (reference.kind === "video" && reference.previewUrl) return
;
- if (reference.kind === "character" && reference.previewUrl) return
;
-
- const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
- return (
-
-
-
- );
-}
-
-function PromptResizeHandle({ height, min, max, onResize }: { height: number; min: number; max: number; onResize: (height: number) => void }) {
- const dragRef = useRef<{ pointerId: number; startY: number; startHeight: number } | null>(null);
-
- const finishResize = (event: ReactPointerEvent) => {
- if (dragRef.current?.pointerId !== event.pointerId) return;
- if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
- dragRef.current = null;
- };
-
- const handleKeyDown = (event: ReactKeyboardEvent) => {
- if (event.key === "ArrowUp") {
- event.preventDefault();
- onResize(Math.max(min, height - 8));
- } else if (event.key === "ArrowDown") {
- event.preventDefault();
- onResize(Math.min(max, height + 8));
- } else if (event.key === "Home") {
- event.preventDefault();
- onResize(min);
- } else if (event.key === "End") {
- event.preventDefault();
- onResize(max);
- }
- };
-
- return (
-
- );
-}
-
-function clampExpandedModalSize(size: { width: number; height: number }) {
- return clampPromptEditorModalSize(size, { width: window.innerWidth, height: window.innerHeight });
-}
-
-function PromptModalResizeHandle({ size, measure, onResize, accent }: { size: { width: number; height: number } | null; measure: () => { width: number; height: number }; onResize: (size: { width: number; height: number }) => void; accent: string }) {
- const dragRef = useRef<{ pointerId: number; startX: number; startY: number; width: number; height: number } | null>(null);
-
- const finishResize = (event: ReactPointerEvent) => {
- if (dragRef.current?.pointerId !== event.pointerId) return;
- if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
- dragRef.current = null;
- };
-
- const handleKeyDown = (event: ReactKeyboardEvent) => {
- const step = event.shiftKey ? 40 : 12;
- const widthDelta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
- const heightDelta = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
- if (!widthDelta && !heightDelta) return;
- event.preventDefault();
- event.stopPropagation();
- const base = size ?? measure();
- onResize(clampExpandedModalSize({ width: base.width + widthDelta, height: base.height + heightDelta }));
- };
-
- return (
-
- );
-}
-
-function promptEditorBounds(expanded: boolean, hasReferences: boolean) {
- const shelfHeight = hasReferences ? PROMPT_REFERENCE_SHELF_HEIGHT : 0;
- const min = (expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT) + shelfHeight;
- const max = (expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT * PROMPT_EDITOR_EXPANDED_MAX_LINES + PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_VERTICAL_PADDING) + shelfHeight;
- return { min, max };
-}
-
-function estimatePromptContentHeight(value: string, expanded: boolean) {
- if (!value.trim()) return expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT;
- const charsPerLine = expanded ? 34 : 38;
- const lineCount = value.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(Array.from(line).length / charsPerLine)), 0);
- const lineHeight = expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT : PROMPT_EDITOR_LINE_HEIGHT;
- const verticalPadding = expanded ? PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_VERTICAL_PADDING;
- return Math.max(expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT, lineCount * lineHeight + verticalPadding);
-}
-
-function clampPromptHeight(height: number, bounds: { min: number; max: number }) {
- return Math.min(bounds.max, Math.max(bounds.min, height));
-}
-
-function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
- return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
-}
-
-export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
- const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
- const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
- const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
- const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel;
- const defaults = resolveModelGenerationDefaults(
- globalConfig,
- model,
- mode === "image" ? "image" : mode === "video" ? "video" : undefined,
- mode === "image"
- ? {
- size: node.metadata?.size,
- quality: node.metadata?.quality,
- transparentBackground: node.metadata?.transparentBackground,
- videoWatermark: node.metadata?.watermark,
- count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
- }
- : {
- size: node.metadata?.size,
- videoSeconds: node.metadata?.seconds,
- vquality: node.metadata?.vquality,
- videoGenerateAudio: node.metadata?.generateAudio,
- videoWatermark: node.metadata?.watermark,
- },
- {
- size: globalConfig.size || defaultConfig.size,
- quality: globalConfig.quality || defaultConfig.quality,
- transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground,
- count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
- videoSeconds: globalConfig.videoSeconds || defaultConfig.videoSeconds,
- vquality: globalConfig.vquality || defaultConfig.vquality,
- videoGenerateAudio: globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
- videoWatermark: globalConfig.videoWatermark || defaultConfig.videoWatermark,
- },
- );
- return {
- ...globalConfig,
- model,
- quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
- size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
- transparentBackground: defaults.transparentBackground ?? "false",
- videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
- vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
- videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
- videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark,
- audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
- audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
- audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
- audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
- count: defaults.count ?? String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
- };
-}
-
-function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
- if (mode === "video") return "描述要生成的视频内容";
- if (mode === "audio") return "描述要生成的音频内容";
- if (mode === "image") return hasImageContent ? "输入新提示词,重新生成当前图片" : "描述要生成的图片内容";
- return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
-}
-
-function videoConfigPatch(key: keyof AiConfig, value: string) {
- if (key === "videoSeconds") return { seconds: value };
- if (key === "videoGenerateAudio") return { generateAudio: value };
- if (key === "videoWatermark") return { watermark: value };
- if (key === "videoArkPrivateAssetUpload") return { arkPrivateAssetUpload: value };
- return { [key]: value };
-}
-
-function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
- if (key === "audioVoice") return { audioVoice: value };
- if (key === "audioFormat") return { audioFormat: value };
- if (key === "audioSpeed") return { audioSpeed: value };
- return { audioInstructions: value };
-}
+import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
+import { Tooltip } from "@/components/ui/base/tooltip";
+import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
+import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
+
+import { ModelPicker } from "@/components/model-picker";
+import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
+import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
+import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
+import { CreditSymbol, requestCreditCost } from "@/constant/credits";
+import { canvasThemes } from "@/lib/canvas-theme";
+import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
+import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
+import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
+import { navigateToSettings } from "@/lib/settings-navigation";
+import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
+import { useUserStore } from "@/stores/use-user-store";
+import { CanvasCameraControlPopover } from "./canvas-camera-control-popover";
+import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
+import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
+import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
+import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
+import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
+import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
+import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover";
+import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer";
+import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas";
+import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
+import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
+import { createPluginHostContext } from "@/services/plugin-host";
+import { usePluginStore } from "@/stores/use-plugin-store";
+import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
+import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
+
+export type CanvasNodeGenerationMode = CanvasGenerationMode;
+
+type CanvasNodePromptPanelProps = {
+ projectId: string;
+ node: CanvasNodeData;
+ isRunning: boolean;
+ onPromptChange: (nodeId: string, prompt: string) => void;
+ onConfigChange: (nodeId: string, patch: Partial) => void;
+ onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
+ mentionReferences?: CanvasResourceReference[];
+ onAddReference?: (nodeId: string, reference: CanvasResourceReference) => CanvasResourceReference | undefined;
+ onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void;
+ onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void;
+ onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void;
+ onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void;
+ onClose?: () => void;
+ onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void;
+ onImageSettingsOpenChange?: (open: boolean) => void;
+ workspaceMode?: CanvasWorkspaceMode;
+};
+
+type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
+
+const PROMPT_REFERENCE_SHELF_HEIGHT = 58;
+// Keep the compact editor readable at rest: three 20px lines plus 12px vertical padding.
+const PROMPT_EDITOR_MIN_HEIGHT = 72;
+const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 200;
+const PROMPT_EDITOR_LINE_HEIGHT = 20;
+const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24;
+const PROMPT_EDITOR_VERTICAL_PADDING = 12;
+const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20;
+const PROMPT_EDITOR_MAX_LINES = 8;
+const PROMPT_EDITOR_EXPANDED_MAX_LINES = 14;
+const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)";
+const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200;
+const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420;
+
+export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
+ const globalConfig = useEffectiveConfig();
+ const themeName = useActiveTheme();
+ const theme = canvasThemes[themeName];
+ const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
+ const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
+ const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
+ const simpleMode = workspaceMode === "simple";
+ const mode = defaultMode(node.type);
+ const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
+ const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
+ const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
+ const [prompt, setPrompt] = useState(savedPrompt);
+ const [presetOpen, setPresetOpen] = useState(false);
+ const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
+ const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
+ const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null);
+ const expandedModalRef = useRef(null);
+ const [promptContentHeight, setPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, false));
+ const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true));
+ const [manualPromptHeight, setManualPromptHeight] = useState(null);
+ const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
+ const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
+ const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
+ const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId });
+ const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]);
+ const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill");
+ const requirements: ModelRequirements = {
+ capability: mode,
+ input: {
+ textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length,
+ imageCount: activeReferences.filter((item) => item.kind === "image").length,
+ videoCount: activeReferences.filter((item) => item.kind === "video").length,
+ audioCount: activeReferences.filter((item) => item.kind === "audio").length,
+ characterCount: activeReferences.filter((item) => item.kind === "character").length,
+ },
+ videoOperation: node.metadata?.videoEditOperation,
+ videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
+ options: modelRequestOptions({
+ ...globalConfig,
+ size: node.metadata?.size || globalConfig.size,
+ quality: node.metadata?.quality || globalConfig.quality,
+ count: String(node.metadata?.count || globalConfig.count),
+ transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground,
+ videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
+ vquality: node.metadata?.vquality || globalConfig.vquality,
+ videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
+ videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
+ audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
+ audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
+ audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
+ }, mode),
+ };
+ const config = buildNodeConfig(globalConfig, node, mode, requirements);
+ const resolvedRequirements: ModelRequirements = {
+ ...requirements,
+ options: modelRequestOptions(config, mode),
+ videoSeconds: mode === "video" ? config.videoSeconds : undefined,
+ };
+ const promptOptimizerProvider = useMemo(() => {
+ if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
+ return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
+ }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]);
+ const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
+ const priceChannel = resolveModelChannel(config, config.model);
+ const configuredCredits = requestCreditCost({
+ channelMode: priceChannel.scope === "system" ? "remote" : "local",
+ modelCosts: priceChannel.modelCosts,
+ model: modelOptionName(config.model),
+ count: mode === "image" ? generationCount : 1,
+ seconds: mode === "video" ? config.videoSeconds : 1,
+ capability: mode,
+ config,
+ requirements: resolvedRequirements,
+ });
+ const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements);
+ const quoteRequestKey = JSON.stringify(quoteRequest || null);
+ const [routeQuote, setRouteQuote] = useState(null);
+ const credits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : configuredCredits;
+ const activeReferenceCount = activeReferences.length;
+ const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
+ const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0;
+ const monochromeAccent = theme.node.activeStroke;
+ const composerTokens = {
+ "--canvas-composer-surface": theme.node.panel,
+ "--canvas-composer-control-surface": theme.toolbar.itemHover,
+ "--canvas-composer-control-hover": theme.toolbar.activeBg,
+ "--canvas-composer-shadow": theme.node.shadow,
+ "--cn-text": theme.node.text,
+ } as CSSProperties;
+ const composerSurfaceStyle = {
+ ...composerTokens,
+ background: theme.node.panel,
+ color: theme.node.text,
+ boxShadow: theme.node.shadow,
+ } as CSSProperties;
+ const controlSurface = "var(--canvas-composer-control-surface)";
+ const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
+ const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
+ const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds);
+ const expandedComposerHeight = clampPromptHeight(expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds);
+ const measureExpandedModalSize = () => {
+ const rect = expandedModalRef.current?.getBoundingClientRect();
+ if (!rect?.width || !rect.height) return { width: PROMPT_EDITOR_MODAL_DEFAULT_WIDTH, height: PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT };
+ return { width: Math.round(rect.width), height: Math.round(rect.height) };
+ };
+ const isSubmitDisabled = !isRunning && !prompt.trim();
+ const canExpandPrompt = mode === "image" || mode === "video";
+ const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt;
+ const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
+ const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]);
+ const canAutoMention = autoMentionedPrompt !== prompt;
+
+ useEffect(() => {
+ setPrompt(normalizedSavedPrompt);
+ if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt);
+ }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]);
+
+ useEffect(() => {
+ setExpandedPromptOpen(false);
+ setExpandedPresetOpen(false);
+ setExpandedModalSize(null);
+ setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false));
+ setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true));
+ setManualPromptHeight(null);
+ }, [node.id]);
+
+ useEffect(() => {
+ if (!expandedPromptOpen) return;
+ const constrainSize = () => setExpandedModalSize((size) => {
+ if (!size) return size;
+ const next = clampExpandedModalSize(size);
+ return next.width === size.width && next.height === size.height ? size : next;
+ });
+ constrainSize();
+ window.addEventListener("resize", constrainSize);
+ return () => window.removeEventListener("resize", constrainSize);
+ }, [expandedPromptOpen]);
+
+ useEffect(() => {
+ if (!creditsEnabled || !quoteRequest) {
+ setRouteQuote(null);
+ return;
+ }
+ const controller = new AbortController();
+ setRouteQuote(null);
+ quoteModel(quoteRequest, controller.signal)
+ .then(({ quote }) => setRouteQuote(quote))
+ .catch(() => {
+ if (!controller.signal.aborted) setRouteQuote(null);
+ });
+ return () => controller.abort();
+ // quoteRequestKey captures the full normalized request without retriggering on object identity.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [creditsEnabled, quoteRequestKey]);
+
+ const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]);
+
+ const updatePrompt = (value: string) => {
+ setPrompt(value);
+ onPromptChange(node.id, value);
+ if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
+ if (expandedPromptOpen) setExpandedPresetOpen(true);
+ else setPresetOpen(true);
+ }
+ };
+
+ const applyPreset = (preset: CanvasPromptPreset) => {
+ const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd();
+ updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt);
+ };
+
+ const insertPromptReference = (reference: CanvasResourceReference) => {
+ const insertText = `${canvasResourceMentionToken(reference)} `;
+ const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt);
+ if (pendingMentionMatch) {
+ const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, "");
+ updatePrompt(prefix ? `${prefix} ${insertText}` : insertText);
+ return;
+ }
+ const basePrompt = prompt.replace(/\s*$/, "");
+ updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText);
+ };
+
+ const submit = () => {
+ const text = prompt.trim();
+ if (!text || isRunning) return false;
+ onGenerate(node.id, mode, text);
+ return true;
+ };
+
+ const submitExpandedPrompt = () => {
+ if (submit()) {
+ setExpandedPresetOpen(false);
+ setExpandedPromptOpen(false);
+ }
+ };
+
+ const renderComposerHeader = (expanded: boolean) => (
+ {
+ const target = event.target instanceof Element ? event.target : null;
+ if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id);
+ }}
+ >
+ {isPortraitTexture ? (
+ <>
+
onConfigChange(node.id, { portraitTexture })} />
+ {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
+ >
+ ) : (
+
+
+
+
+ {modeDisplayName(mode)}生成
+ {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
+
+ )}
+
+ {!simpleMode ? : null}
+ {canOptimizePrompt ? (
+
+
+
+ ) : null}
+ {!expanded && canExpandPrompt ? (
+
+
+
+ ) : null}
+ {!expanded && onClose ? (
+
+
+
+ ) : null}
+
+
+ );
+
+ const renderSubmitButton = (expanded: boolean) => {
+ const showCost = creditsEnabled && credits !== null;
+ const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
+ const actionLabel = isRunning ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,生成` : "生成";
+ return (
+
+ );
+ };
+
+ const renderComposerControls = (expanded: boolean) =>
+ simpleMode ? (
+
+
+ {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
+
+ updatePrompt(autoMentionedPrompt)}
+ onAutoLinkEnabledChange={setAutoLinkEnabled}
+ accent={monochromeAccent}
+ compact
+ />
+ {renderSubmitButton(expanded)}
+
+ ) : (
+
+
+ onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })}
+ capability={mode}
+ requirements={resolvedRequirements}
+ onMissingConfig={() => navigateToSettings({ continueCreation: true })}
+ showSelectedPrice={false}
+ showOptionPrices={creditsEnabled}
+ variant="creation"
+ showConfiguredModelName
+ />
+
+
+ updatePrompt(autoMentionedPrompt)}
+ onAutoLinkEnabledChange={setAutoLinkEnabled}
+ accent={monochromeAccent}
+ compact={!expanded}
+ />
+ {mode === "text" ? (
+
+ onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
+ aria-label="文本生成份数"
+ className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
+ />
+
+ ) : mode === "image" ? (
+ // 图片模式下,显示相机配置与镜头配置
+ <>
+ onConfigChange(node.id, { cameraControl: options })}
+ theme={theme}
+ compact={!expanded}
+ />
+ onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
+ onMissingConfig={() => navigateToSettings({ continueCreation: true })}
+ onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
+ />
+ >
+ ) : mode === "video" ? (
+ onConfigChange(node.id, videoConfigPatch(key, value))}
+ />
+ ) : mode === "audio" ? (
+ onConfigChange(node.id, audioConfigPatch(key, value))}
+ />
+ ) : null}
+ {renderSubmitButton(expanded)}
+
+
+ );
+
+ const renderPromptEditor = (expanded: boolean, fill = false) => {
+ const bounds = expanded ? expandedPromptBounds : promptBounds;
+ const height = expanded ? expandedComposerHeight : composerHeight;
+ return (
+ <>
+
+ onRemoveReference?.(node.id, reference)}
+ onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined}
+ onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined}
+ onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined}
+ />
+ onAddReference(node.id, reference) : undefined}
+ includeAssetLibrary
+ onChange={updatePrompt}
+ autoLinkEnabled={autoLinkEnabled}
+ onReferenceFilesDrop={onReplaceReferenceFiles ? (reference, files) => onReplaceReferenceFiles(node.id, reference, files) : undefined}
+ onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
+ containerClassName="min-h-0 flex-1"
+ className={expanded
+ ? "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"
+ : "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-1.5 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"}
+ style={{ color: theme.node.text, outline: "none", boxShadow: "none" }}
+ placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
+ aria-label={`${modeDisplayName(mode)}提示词`}
+ />
+
+ {!expanded && }
+ >
+ );
+ };
+
+ return (
+ item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType}
+ config={globalConfig}
+ optimizerModel={globalConfig.textModel}
+ references={activeReferences}
+ provider={promptOptimizerProvider}
+ onClose={() => setPromptOptimizerOpen(false)}
+ onApply={(nextPrompt) => updatePrompt(nextPrompt)}
+ >
+ event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ onWheel={(event) => event.stopPropagation()}
+ >
+ {renderComposerHeader(false)}
+
+ {renderPromptEditor(false)}
+
+ {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/}
+ {hasVideoPromptTools ? (
+
+
+ {paramsExpanded ? (
+
+ onConfigChange(node.id, patch)} />
+
+ ) : null}
+
+ ) : null}
+
+ {renderComposerControls(false)}
+
+
{
+ setExpandedPresetOpen(false);
+ setExpandedPromptOpen(false);
+ }}
+ styles={{
+ container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow },
+ body: { minHeight: 0, padding: 0 },
+ }}
+ >
+
+
+
{renderComposerHeader(true)}
+ {renderPromptEditor(true, Boolean(expandedModalSize))}
+ {hasVideoPromptTools ? (
+
+ onConfigChange(node.id, patch)} />
+
+ ) : null}
+
{renderComposerControls(true)}
+
+
+
+
+
+
+
+ );
+}
+
+function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) {
+ return (
+
+
+
智能引用
+
输入素材序号或名称后按 Tab,可快速引用
+
+
+
AutoLink
+
+
+
+
+ }
+ >
+
+
+ );
+}
+
+function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) {
+ if (mode === "image") return ;
+ if (mode === "video") return ;
+ if (mode === "audio") return ;
+ return ;
+}
+
+function modeDisplayName(mode: CanvasNodeGenerationMode) {
+ if (mode === "image") return "图片";
+ if (mode === "video") return "视频";
+ if (mode === "audio") return "音频";
+ return "文本";
+}
+
+function referenceShelfHeading(references: CanvasResourceReference[]) {
+ const label = references.every((reference) => reference.kind === "image" || reference.kind === "character") ? "参考图" : "参考素材";
+ return `${label} · ${references.length}`;
+}
+
+function ConnectedReferenceShelf({
+ targetNodeId,
+ references,
+ theme,
+ onInsert,
+ onRemove,
+ onReorder,
+ onReplaceReference,
+ onReplaceReferenceFiles,
+}: {
+ targetNodeId?: string;
+ references: CanvasResourceReference[];
+ theme: CanvasTheme;
+ onInsert: (reference: CanvasResourceReference) => void;
+ onRemove?: (reference: CanvasResourceReference) => void;
+ onReorder?: (orderedNodeIds: string[]) => void;
+ onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void;
+ onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void;
+}) {
+ const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
+ const [imagePreview, setImagePreview] = useState(null);
+ const [draggedReferenceId, setDraggedReferenceId] = useState(null);
+ const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null);
+ if (!activeReferences.length) return null;
+
+ const moveReference = (sourceId: string, targetId: string) => {
+ if (!onReorder || sourceId === targetId) return;
+ const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId);
+ const targetIndex = activeReferences.findIndex((reference) => reference.nodeId === targetId);
+ if (sourceIndex < 0 || targetIndex < 0) return;
+ const ordered = [...activeReferences];
+ const [moved] = ordered.splice(sourceIndex, 1);
+ ordered.splice(targetIndex, 0, moved);
+ onReorder(ordered.map((reference) => reference.nodeId));
+ };
+
+ const moveReferenceByOffset = (sourceId: string, offset: -1 | 1) => {
+ const sourceIndex = activeReferences.findIndex((reference) => reference.nodeId === sourceId);
+ const target = activeReferences[sourceIndex + offset];
+ if (!target) return;
+ moveReference(sourceId, target.nodeId);
+ };
+
+ return (
+ <>
+
+
+ {activeReferences.map((reference, index) => {
+ const canPreview = Boolean(reference.previewUrl) && (reference.kind === "image" || reference.kind === "character" || reference.kind === "video");
+ const isDropTarget = dropTargetReferenceId === reference.id;
+ return (
+ {
+ if (draggedReferenceId) {
+ if (!onReorder) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ return;
+ }
+ const hasImageNode = event.dataTransfer.types.includes("application/x-canvas-image-node-id");
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ if (hasImageNode || hasFiles) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "copy";
+ if (dropTargetReferenceId !== reference.id) {
+ setDropTargetReferenceId(reference.id);
+ }
+ }
+ }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) {
+ if (dropTargetReferenceId === reference.id) {
+ setDropTargetReferenceId(null);
+ }
+ }
+ }}
+ onDrop={(event) => {
+ if (dropTargetReferenceId === reference.id) {
+ setDropTargetReferenceId(null);
+ }
+ if (draggedReferenceId) {
+ event.preventDefault();
+ const sourceId = draggedReferenceId || event.dataTransfer.getData("text/plain");
+ setDraggedReferenceId(null);
+ moveReference(sourceId, reference.nodeId);
+ return;
+ }
+ const sourceNodeId = event.dataTransfer.getData("application/x-canvas-image-node-id");
+ if (sourceNodeId && sourceNodeId !== reference.nodeId && onReplaceReference) {
+ event.preventDefault();
+ event.stopPropagation();
+ onReplaceReference(reference, sourceNodeId);
+ return;
+ }
+ const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
+ if (files.length && onReplaceReferenceFiles) {
+ event.preventDefault();
+ event.stopPropagation();
+ onReplaceReferenceFiles(reference, files);
+ return;
+ }
+ }}
+ >
+ {onReorder ? (
+
+ ) : null}
+ {index + 1}
+
+
+ {onRemove ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ {imagePreview?.previewUrl ? (
+ !open && setImagePreview(null),
+ }}
+ />
+ ) : null}
+ >
+ );
+}
+
+function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference }) {
+ if (reference.kind === "image" && reference.previewUrl) return
;
+ if (reference.kind === "video" && reference.previewUrl) return
;
+ if (reference.kind === "character" && reference.previewUrl) return
;
+
+ const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
+ return (
+
+
+
+ );
+}
+
+function PromptResizeHandle({ height, min, max, onResize }: { height: number; min: number; max: number; onResize: (height: number) => void }) {
+ const dragRef = useRef<{ pointerId: number; startY: number; startHeight: number } | null>(null);
+
+ const finishResize = (event: ReactPointerEvent) => {
+ if (dragRef.current?.pointerId !== event.pointerId) return;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
+ dragRef.current = null;
+ };
+
+ const handleKeyDown = (event: ReactKeyboardEvent) => {
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ onResize(Math.max(min, height - 8));
+ } else if (event.key === "ArrowDown") {
+ event.preventDefault();
+ onResize(Math.min(max, height + 8));
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ onResize(min);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ onResize(max);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function clampExpandedModalSize(size: { width: number; height: number }) {
+ return clampPromptEditorModalSize(size, { width: window.innerWidth, height: window.innerHeight });
+}
+
+function PromptModalResizeHandle({ size, measure, onResize, accent }: { size: { width: number; height: number } | null; measure: () => { width: number; height: number }; onResize: (size: { width: number; height: number }) => void; accent: string }) {
+ const dragRef = useRef<{ pointerId: number; startX: number; startY: number; width: number; height: number } | null>(null);
+
+ const finishResize = (event: ReactPointerEvent) => {
+ if (dragRef.current?.pointerId !== event.pointerId) return;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
+ dragRef.current = null;
+ };
+
+ const handleKeyDown = (event: ReactKeyboardEvent) => {
+ const step = event.shiftKey ? 40 : 12;
+ const widthDelta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
+ const heightDelta = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0;
+ if (!widthDelta && !heightDelta) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const base = size ?? measure();
+ onResize(clampExpandedModalSize({ width: base.width + widthDelta, height: base.height + heightDelta }));
+ };
+
+ return (
+
+ );
+}
+
+function promptEditorBounds(expanded: boolean, hasReferences: boolean) {
+ const shelfHeight = hasReferences ? PROMPT_REFERENCE_SHELF_HEIGHT : 0;
+ const min = (expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT) + shelfHeight;
+ const max = (expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT * PROMPT_EDITOR_EXPANDED_MAX_LINES + PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_VERTICAL_PADDING) + shelfHeight;
+ return { min, max };
+}
+
+function estimatePromptContentHeight(value: string, expanded: boolean) {
+ if (!value.trim()) return expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT;
+ const charsPerLine = expanded ? 34 : 38;
+ const lineCount = value.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(Array.from(line).length / charsPerLine)), 0);
+ const lineHeight = expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT : PROMPT_EDITOR_LINE_HEIGHT;
+ const verticalPadding = expanded ? PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_VERTICAL_PADDING;
+ return Math.max(expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT, lineCount * lineHeight + verticalPadding);
+}
+
+function clampPromptHeight(height: number, bounds: { min: number; max: number }) {
+ return Math.min(bounds.max, Math.max(bounds.min, height));
+}
+
+function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
+ return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
+}
+
+export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
+ const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
+ const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
+ const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
+ const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel;
+ const defaults = resolveModelGenerationDefaults(
+ globalConfig,
+ model,
+ mode === "image" ? "image" : mode === "video" ? "video" : undefined,
+ mode === "image"
+ ? {
+ size: node.metadata?.size,
+ quality: node.metadata?.quality,
+ transparentBackground: node.metadata?.transparentBackground,
+ videoWatermark: node.metadata?.watermark,
+ count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
+ }
+ : {
+ size: node.metadata?.size,
+ videoSeconds: node.metadata?.seconds,
+ vquality: node.metadata?.vquality,
+ videoGenerateAudio: node.metadata?.generateAudio,
+ videoWatermark: node.metadata?.watermark,
+ },
+ {
+ size: globalConfig.size || defaultConfig.size,
+ quality: globalConfig.quality || defaultConfig.quality,
+ transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground,
+ count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
+ videoSeconds: globalConfig.videoSeconds || defaultConfig.videoSeconds,
+ vquality: globalConfig.vquality || defaultConfig.vquality,
+ videoGenerateAudio: globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
+ videoWatermark: globalConfig.videoWatermark || defaultConfig.videoWatermark,
+ },
+ );
+ return {
+ ...globalConfig,
+ model,
+ quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
+ size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
+ transparentBackground: defaults.transparentBackground ?? "false",
+ videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
+ vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
+ videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
+ videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark,
+ audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
+ audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
+ audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
+ audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
+ count: defaults.count ?? String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
+ };
+}
+
+function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
+ if (mode === "video") return "描述要生成的视频内容";
+ if (mode === "audio") return "描述要生成的音频内容";
+ if (mode === "image") return hasImageContent ? "输入新提示词,重新生成当前图片" : "描述要生成的图片内容";
+ return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
+}
+
+function videoConfigPatch(key: keyof AiConfig, value: string) {
+ if (key === "videoSeconds") return { seconds: value };
+ if (key === "videoGenerateAudio") return { generateAudio: value };
+ if (key === "videoWatermark") return { watermark: value };
+ if (key === "videoArkPrivateAssetUpload") return { arkPrivateAssetUpload: value };
+ return { [key]: value };
+}
+
+function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
+ if (key === "audioVoice") return { audioVoice: value };
+ if (key === "audioFormat") return { audioFormat: value };
+ if (key === "audioSpeed") return { audioSpeed: value };
+ return { audioInstructions: value };
+}
diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx
index f629492ca..5dee9017b 100644
--- a/web/src/components/canvas/canvas-node-toolbar.tsx
+++ b/web/src/components/canvas/canvas-node-toolbar.tsx
@@ -37,7 +37,6 @@ type CanvasNodeToolbarProps = {
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
- onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
@@ -103,7 +102,6 @@ export function CanvasNodeToolbar({
onDownload,
onSaveAsset,
onMaskEdit,
- onImageEdit,
onRemoveBackground,
onLayerDecomposition,
onEmotion,
@@ -231,13 +229,13 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
- const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onImageEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
+ const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
- onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeImageEdit: onImageEdit, onNodeRemoveBackground: onRemoveBackground, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
+ onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeRemoveBackground: onRemoveBackground, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: (target) => onSplit(target, { rows: 2, columns: 2 }), onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
onNodeExtractVideoFrames: onExtractVideoFrames, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoSegments: onTrimVideoSegments, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx
index 77551138e..c168a7caf 100644
--- a/web/src/components/canvas/canvas-project-asset-modal.tsx
+++ b/web/src/components/canvas/canvas-project-asset-modal.tsx
@@ -66,7 +66,7 @@ export function CanvasProjectAssetModal({
mediaKind,
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
- imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
+ imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : remoteResourceId ? `resource:${remoteResourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx
index 7ac0188a7..fb9c6e0bb 100644
--- a/web/src/components/canvas/canvas-project-card.tsx
+++ b/web/src/components/canvas/canvas-project-card.tsx
@@ -1,7 +1,7 @@
import { Check, Clapperboard, CloudUpload, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
-import { Dropdown, Input } from "antd";
+import { App, Dropdown, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
@@ -13,6 +13,7 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
import { cn } from "@/lib/utils";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
+import { hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
const projectPreviewMediaCache = new WeakMap();
@@ -30,6 +31,7 @@ export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; on
}
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
+ const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
@@ -44,9 +46,19 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => navigate(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`);
- const saveTitle = () => {
- renameProject(project.id, editingTitle);
+ const saveTitle = async () => {
stopEditing();
+ if (!hasRemoteUserDataSyncSession()) {
+ renameProject(project.id, editingTitle);
+ return;
+ }
+ try {
+ await loadCanvasProjectForEditing(project.id);
+ renameProject(project.id, editingTitle);
+ await saveRemoteUserDataNow(project.id);
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "重命名失败");
+ }
};
const compact = variant === "recent";
diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
index 735bb3597..fdb4ef269 100644
--- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx
+++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
@@ -42,7 +42,7 @@ type Props = Omit, "onChange" | "val
containerClassName?: string;
highlightLabels?: boolean;
mentionMenuWidth?: number;
- sendOnEnter?: boolean;
+ sendOnEnter?: boolean | "both";
onContentSizeChange?: (height: number) => void;
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
@@ -50,6 +50,15 @@ type Props = Omit, "onChange" | "val
autoLinkEnabled?: boolean;
};
+// 回车提交语义由调用方决定:false 只在 ⌘/Ctrl+Enter 提交,"both" 两种都提交;Shift+Enter 始终换行。
+function shouldSubmitOnEnter(event: { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, sendOnEnter: boolean | "both") {
+ if (event.key !== "Enter" || event.shiftKey) return false;
+ const modifier = event.ctrlKey || event.metaKey;
+ if (sendOnEnter === false) return modifier;
+ if (sendOnEnter === "both") return true;
+ return !modifier;
+}
+
export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea(
{ value, references, onSelectReference, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, autoLinkEnabled = false, ...props },
forwardedRef,
@@ -350,7 +359,15 @@ export const CanvasResourceMentionTextarea = forwardRef {
composingRef.current = true;
@@ -425,7 +442,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
}
if (event.key === "Enter") {
event.preventDefault();
- const shouldSubmit = sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey;
+ const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (onSubmit && shouldSubmit) {
onSubmit();
return;
@@ -489,7 +506,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
}}
value={value}
className={`${className || ""} relative z-10`}
- style={mergedStyle}
+ style={{ ...mergedStyle, height: "100%", minHeight: 0, maxHeight: "100%", overflowY: "auto", overflowX: "hidden" }}
onChange={(event) => {
const next = event.target.value;
onChange(next);
@@ -539,7 +556,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
return;
}
}
- const shouldSubmit = event.key === "Enter" && (sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey);
+ const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (shouldSubmit && onSubmit) {
event.preventDefault();
onSubmit();
diff --git a/web/src/components/layout/app-providers.tsx b/web/src/components/layout/app-providers.tsx
index 5a1977ef4..827a0c551 100644
--- a/web/src/components/layout/app-providers.tsx
+++ b/web/src/components/layout/app-providers.tsx
@@ -39,7 +39,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
const isolateDevRepro = import.meta.env.DEV && typeof window !== "undefined" && window.location.pathname === "/dev/director-repro";
return (
-
+
{isolateDevRepro ? (
diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx
index 0531c3084..d078c5821 100644
--- a/web/src/components/model-logo.tsx
+++ b/web/src/components/model-logo.tsx
@@ -8,12 +8,13 @@ import { cn } from "@/lib/utils";
type LobeIconComponent = ComponentType & { size?: number | string }>;
-// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
+// 优先使用品牌原色,缺少彩色版本时使用 Mono。不能使用 eager glob:目录有数百个模块,
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
const iconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
+const colorIconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Color.js", { import: "default" });
const iconLoaders = Object.fromEntries(
- Object.entries(iconModules)
- .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
+ [...Object.entries(iconModules), ...Object.entries(colorIconModules)]
+ .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/(?:Mono|Color)\.js$/)?.[1], loader])
.filter((entry): entry is [string, () => Promise] => Boolean(entry[0] && entry[1])),
) as Record Promise>;
const iconRegistry = new Map();
diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx
index b2a450278..8a4b1b9c2 100644
--- a/web/src/components/model-picker.tsx
+++ b/web/src/components/model-picker.tsx
@@ -4,14 +4,15 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
-import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
-import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
+import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
+import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
+import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups";
import { cn } from "@/lib/utils";
-import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
+import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
-import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
+import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
type ModelPickerProps = {
config: AiConfig;
@@ -58,26 +59,11 @@ export function ModelPicker({
const menuRef = useRef(null);
const triggerRef = useRef(null);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
- const optionGroups = useMemo(() => {
- const channelGroups = config.channels
- .map((channel) => ({
- key: channel.id,
- label: channel.name || "未命名渠道",
- scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : channel.scope === "system" ? "平台服务" : "我的模型",
- models: groupModelsByDisplayName(
- config,
- options.filter((model) => resolveModelChannel(config, model).id === channel.id),
- ),
- }))
- .filter((group) => group.models.length);
- // options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
- // 不再显示“其他模型 / 未指定渠道”这种不可用入口。
- return channelGroups;
- }, [config, options]);
+ const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]);
const storedCurrent = value?.trim() || "";
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
- const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
+ const resolvedCurrent = isDirectSystemModel(config, storedCurrent) ? storedCurrent : resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
@@ -102,7 +88,7 @@ export function ModelPicker({
}
const controller = new AbortController();
setRouteQuote(undefined);
- quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
+ quoteModel(quoteRequest, controller.signal)
.then((payload) => setRouteQuote(payload.quote))
.catch(() => {
if (!controller.signal.aborted) setRouteQuote(undefined);
@@ -142,7 +128,7 @@ export function ModelPicker({
};
const focusMenuOption = (last = false) => {
window.requestAnimationFrame(() => {
- const buttons = menuRef.current?.querySelectorAll('[role="option"]');
+ const buttons = menuRef.current?.querySelectorAll('[data-model-picker-item]:not(:disabled)');
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
target?.focus();
});
@@ -160,8 +146,14 @@ export function ModelPicker({
triggerRef.current?.focus();
return;
}
+ if (event.key === "ArrowLeft" && activeGroupKey !== null) {
+ event.preventDefault();
+ setActiveGroupKey(null);
+ focusMenuOption();
+ return;
+ }
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
- const buttons = Array.from(event.currentTarget.querySelectorAll('[role="option"]'));
+ const buttons = Array.from(event.currentTarget.querySelectorAll('[data-model-picker-item]:not(:disabled)'));
if (!buttons.length) return;
event.preventDefault();
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
@@ -191,32 +183,32 @@ export function ModelPicker({
>
{optionGroups.length ? (
activeGroupKey === null ? (
-
+
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
- return
) :
-
+
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
-
- {group.label}{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}
+
+ {group.label}{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}
;
})}
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) =>
- setActiveGroupKey(null)} aria-label="返回品牌列表">
+ { setActiveGroupKey(null); focusMenuOption(); }} aria-label="返回模型列表">
{group.label}{group.scope ? {group.scope} : null}
@@ -229,6 +221,7 @@ export function ModelPicker({
item.toUpperCase()).join("/")}` : meta.description);
+ (isDirectSystemModel(config, model) ? "" : logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
return (
- {pickerModelDisplayName(config, model, showConfiguredModelName)}
+ {label || pickerModelDisplayName(config, model, showConfiguredModelName)}
{capabilitySummary}
{showPrice ? (
-
+
) : null}
{!creationVariant && meta.time ? (
@@ -431,7 +430,7 @@ function formatDurationSummary(profile: NonNullable= 0
+ ? { kind: "estimate", label: formatPriceRange([rate / 1_000_000], "积分/百万视频 Token"), title: "按视频 Token 单价预估,优先按有效上游用量结算;未返回用量时按视频公式结算" }
+ : { kind: "estimate" };
}
- if (cost.billingMode === "token") return { kind: "estimate" };
return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
}
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
- return showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
+ const name = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
+ return isDirectSystemModel(config, model) ? `${name} · ${modelChannelLabel(config, model)}` : name;
}
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
const displayName = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
const channel = resolveModelChannel(config, model);
- return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
+ return channel.scope === "system" ? pickerModelDisplayName(config, model, showConfiguredModelName) : `${displayName}(${channel.name})`;
}
function channelTierPriceSummary(
visibleTiers: NonNullable[number]["logicalPriceTiers"]>,
allTiers: NonNullable[number]["logicalPriceTiers"]>,
+ capability?: ModelCapability,
): Extract {
- const label = priceTierSummaryLabel(visibleTiers);
+ const label = priceTierSummaryLabel(visibleTiers, capability);
return {
kind: "tiers",
label,
compactLabel: label,
- title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
+ title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${priceTierSummaryLabel([tier], capability)}`).join(";")}${allTiers.some((tier) => tier.billingMode === "token") ? (capability === "video" ? ";优先按有效上游用量结算,未返回用量时按视频公式结算" : ";Token 费用为预估,最终按成功任务的实际用量结算") : ""}`,
};
}
@@ -491,21 +498,17 @@ function tierSpecificationLabel(tier: NonNullable[number]["logicalPriceTiers"]>[number]) {
- if (tier.billingMode === "token") return "按量预估";
- return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
-}
-
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
if (quote) {
- const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
- const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
+ const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
+ const label = quote.estimated ? `预估:${amount}` : `${amount}`;
return (
-
+
{compact ? label : `${label} 积分`}
@@ -522,7 +525,7 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
);
}
if (price.kind === "estimate") {
- return 按量预估;
+ return {price.label || "按量预估"};
}
return (
@@ -554,5 +557,5 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
}
export function ModelIcon({ config, model, icon }: { config?: AiConfig; model?: string; icon?: string }) {
- return ;
+ return ;
}
diff --git a/web/src/components/payment-brand-icons.tsx b/web/src/components/payment-brand-icons.tsx
new file mode 100644
index 000000000..f9920be4f
--- /dev/null
+++ b/web/src/components/payment-brand-icons.tsx
@@ -0,0 +1,30 @@
+import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
+
+export const ZHIFUFM_LOGO_SRC =
+ "data:image/png;base64,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";
+
+export function PaymentBrandIcon({ providerId, compact = false }: { providerId: string; compact?: boolean }) {
+ const size = compact ? "size-6" : "size-10";
+ if (providerId === "wechat-native" || providerId === "official-payment-wechat-native") {
+ return (
+
+
+
+ );
+ }
+ if (providerId === "alipay-page-pay" || providerId === "official-payment-alipay-page") {
+ return (
+
+
+
+ );
+ }
+ if (providerId === "zhifufm-pay" || providerId === "official-payment-zhifufm") {
+ return (
+
+
+
+ );
+ }
+ return PAY;
+}
diff --git a/web/src/constant/canvas.ts b/web/src/constant/canvas.ts
index b9c7d721b..e940927cf 100644
--- a/web/src/constant/canvas.ts
+++ b/web/src/constant/canvas.ts
@@ -27,7 +27,7 @@ export const NODE_DEFAULT_SIZE = {
[CanvasNodeType.Chart]: { width: 480, height: 320, title: "图表" },
[CanvasNodeType.ColorGrade]: { width: 420, height: 360, title: "调色" },
[CanvasNodeType.MediaConversion]: { width: 480, height: 460, title: "转换" },
- [CanvasNodeType.BatchTable]: { width: 900, height: 520, title: "批量创作表" },
+ [CanvasNodeType.BatchTable]: { width: 1280, height: 560, title: "批量创作表" },
} satisfies Record;
export const NODE_SPECS = {
diff --git a/web/src/layouts/user-layout.tsx b/web/src/layouts/user-layout.tsx
index ed8c457d2..4bc3469b6 100644
--- a/web/src/layouts/user-layout.tsx
+++ b/web/src/layouts/user-layout.tsx
@@ -13,13 +13,14 @@ const workspaceTheme = getWorkspaceAntThemeConfig();
export default function UserLayout({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
+ const adminWorkspace = pathname === "/admin" || pathname.startsWith("/admin/");
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
- const productWorkspace = !/^\/canvas\/[^/]+(?:\/|$)/.test(pathname);
+ const productWorkspace = !adminWorkspace && !/^\/canvas\/[^/]+(?:\/|$)/.test(pathname);
useWorkspaceButtonFeedback(productWorkspace);
useLayoutEffect(() => {
// Ant Design 浮层挂载在 body,必须用路由级标记隔离用户工作台与画布编辑器、运营后台。
- document.body.classList.add("app-user-overlays");
+ document.body.classList.toggle("app-user-overlays", !adminWorkspace);
document.body.classList.toggle("app-spatial-overlays", spatialWorkbench);
document.body.classList.toggle("app-product-overlays", productWorkspace);
return () => {
@@ -27,7 +28,9 @@ export default function UserLayout({ children }: { children: ReactNode }) {
document.body.classList.remove("app-spatial-overlays");
document.body.classList.remove("app-product-overlays");
};
- }, [spatialWorkbench, productWorkspace]);
+ }, [adminWorkspace, spatialWorkbench, productWorkspace]);
+
+ if (adminWorkspace) return children;
return (
diff --git a/web/src/lib/app-theme.ts b/web/src/lib/app-theme.ts
index 219a2315b..d3a0e7155 100644
--- a/web/src/lib/app-theme.ts
+++ b/web/src/lib/app-theme.ts
@@ -164,6 +164,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
colorLinkHover: color.solidHoverBg,
colorLinkActive: color.solidActiveBg,
colorTextLightSolid: color.solidFg,
+ controlOutlineWidth: 0,
colorText: skin.text || undefined,
colorTextSecondary: skin.textMuted || undefined,
colorIcon: skin.icon || undefined,
@@ -196,6 +197,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
},
components: {
Button: {
+ defaultShadow: "none",
primaryShadow: "none",
dangerShadow: "none",
dangerColor: color.dangerForeground,
@@ -235,6 +237,11 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
hoverBorderColor: interactiveBorder,
activeShadow: focusShadow,
},
+ DatePicker: {
+ activeShadow: focusShadow,
+ activeBorderColor: interactiveBorder,
+ hoverBorderColor: interactiveBorder,
+ },
Switch: {
handleBg: color.switchOffHandle,
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
@@ -394,7 +401,7 @@ export function getWorkspaceAntThemeConfig(): ThemeConfig {
defaultShadow: "none", primaryShadow: "none", dangerShadow: "none",
paddingInline: 14, paddingInlineSM: 10,
},
- Input: { borderRadius: 12, activeShadow: "0 0 0 2px var(--user-accent-soft)", paddingInline: 12 },
+ Input: { borderRadius: 12, paddingInline: 12 },
Select: { borderRadius: 12, optionSelectedBg: "var(--user-accent-soft)", optionSelectedColor: "var(--user-ink)", optionActiveBg: "var(--user-surface-muted)", optionPadding: "9px 12px" },
Dropdown: { borderRadiusLG: 16, controlItemBgHover: "var(--user-surface-muted)", paddingBlock: 6 },
Modal: { borderRadiusLG: 20 },
diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts
index b1c765dc4..5ee32d398 100644
--- a/web/src/lib/canvas/canvas-batch-table.ts
+++ b/web/src/lib/canvas/canvas-batch-table.ts
@@ -5,82 +5,17 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow,
export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。";
export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。";
export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:";
-export const BATCH_REFERENCE_HANDLE_TOP = 112;
-export const BATCH_REFERENCE_HANDLE_GAP = 38;
-export const MAX_BATCH_REFERENCE_COLUMNS = 10;
-export const MAX_BATCH_TEXT_COLUMNS = 4;
-export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:";
+/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */
+export const BATCH_REFERENCE_HANDLE_TOP = 102;
+export const BATCH_REFERENCE_HANDLE_GAP = 40;
+export const MIN_BATCH_REFERENCE_COLUMNS = 1;
+export const MAX_BATCH_REFERENCE_COLUMNS = 6;
+const LEGACY_BATCH_TABLE_WIDTH = 900;
-export function batchGridTemplateColumns(referenceCount: number, textCount: number) {
- return [
- "36px",
- "68px",
- `repeat(${referenceCount}, 72px)`,
- textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined,
- "minmax(280px, 1fr)",
- "148px",
- "36px",
- ].filter(Boolean).join(" ");
-}
-
-export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
- const columns = batchReferenceColumns(table);
- const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
- const toIndex = columns.findIndex((column) => column.id === toColumnId);
- if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
-
- const nextColumns = [...columns];
- const [moved] = nextColumns.splice(fromIndex, 1);
- nextColumns.splice(toIndex, 0, moved);
- const nextRows = table.rows.map((row) => ({
- ...row,
- inputNodeIds: nextColumns.map((column) => {
- const oldIndex = columns.findIndex((item) => item.id === column.id);
- return row.inputNodeIds[oldIndex] || "";
- }),
- }));
-
- return {
- ...table,
- referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
- rows: nextRows,
- };
-}
-
-export function moveBatchReferenceCell(
- table: CanvasBatchTableData,
- sourceRowId: string,
- sourceColumnIndex: number,
- targetRowId: string,
- targetColumnIndex: number,
-): CanvasBatchTableData {
- if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
- if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
-
- const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
- const sourceRow = table.rows.find((row) => row.id === sourceRowId);
- const targetRow = table.rows.find((row) => row.id === targetRowId);
- if (!sourceRow || !targetRow) return table;
-
- const nextRows = table.rows.map((row) => ({
- ...row,
- inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""),
- }));
- const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!;
- const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!;
- const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex];
- if (!sourceNodeId) return table;
-
- const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex];
- nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId;
- nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
-
- return { ...table, rows: nextRows };
-}
-
-// Reserve one slot for adding a reference and a separate gap before text inputs.
-export function batchTextHandleTop(referenceCount: number) {
- return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24;
+/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */
+export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData {
+ if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node;
+ return { ...node, width: 1280, height: Math.max(node.height, 560) };
}
export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
@@ -94,10 +29,6 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] {
export function batchReferenceColumns(table?: CanvasBatchTableData) {
const columns = table?.referenceColumns;
if (!columns?.length) return defaultBatchReferenceColumns();
- // Older tables were created before the second default slot existed. They
- // never had a remove-column action, so extending this exact legacy shape is
- // a safe migration and makes reference slot 2 immediately selectable.
- if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns();
return columns;
}
@@ -105,47 +36,16 @@ export function batchReferenceHandleId(columnId: string) {
return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`;
}
+export function batchReferenceMentionToken(index: number) {
+ return `@参考图${index + 1}`;
+}
+
export function batchReferenceColumnId(handleId?: string) {
return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined;
}
-export function batchTextColumns(table?: CanvasBatchTableData) {
- return table?.textColumns || [];
-}
-
-export function batchTextHandleId(columnId: string) {
- return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`;
-}
-
-export function batchTextColumnId(handleId?: string) {
- return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined;
-}
-
-export function batchTextHandleY(node: CanvasNodeData, handleId?: string) {
- if (node.type !== "batch-table") return undefined;
- const columnId = batchTextColumnId(handleId);
- const columns = batchTextColumns(node.metadata?.batchTable);
- const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1;
- if (index < 0) return undefined;
- return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP;
-}
-
-export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
- if (node.type !== "batch-table") return undefined;
- const columns = batchTextColumns(node.metadata?.batchTable);
- const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length;
- let nearestIndex = -1;
- let nearestDistance = Number.POSITIVE_INFINITY;
- columns.forEach((_, index) => {
- const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP));
- if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; }
- });
- return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined;
-}
export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
if (node.type !== "batch-table") return undefined;
- const textY = batchTextHandleY(node, handleId);
- if (textY !== undefined) return textY;
const columnId = batchReferenceColumnId(handleId);
const columns = batchReferenceColumns(node.metadata?.batchTable);
const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0;
@@ -155,17 +55,71 @@ export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) {
export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) {
if (node.type !== "batch-table") return undefined;
- const referenceColumns = batchReferenceColumns(node.metadata?.batchTable);
- const textColumns = batchTextColumns(node.metadata?.batchTable);
- const handles = [
- ...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })),
- ...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })),
- ];
- const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => {
- const distance = Math.abs(worldY - (node.position.y + handle.y));
- return !current || distance < current.distance ? { id: handle.id, distance } : current;
- }, null);
- return nearest && nearest.distance <= hitRadius ? nearest.id : undefined;
+ const columns = batchReferenceColumns(node.metadata?.batchTable);
+ let nearestIndex = -1;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+ columns.forEach((_, index) => {
+ const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP));
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearestIndex = index;
+ }
+ });
+ return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined;
+}
+
+export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null {
+ const columns = batchReferenceColumns(table);
+ if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null;
+ const nextColumns = columns.slice(0, -1).map((column, index) => ({ ...column, label: `参考图 ${index + 1}` }));
+ return {
+ ...table,
+ referenceColumns: nextColumns,
+ rows: table.rows.map((row) => ({ ...row, inputNodeIds: nextColumns.map((_, index) => row.inputNodeIds[index] || "") })),
+ };
+}
+
+export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData {
+ const columns = batchReferenceColumns(table);
+ const fromIndex = columns.findIndex((column) => column.id === fromColumnId);
+ const toIndex = columns.findIndex((column) => column.id === toColumnId);
+ if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table;
+ const nextColumns = [...columns];
+ const [moved] = nextColumns.splice(fromIndex, 1);
+ nextColumns.splice(toIndex, 0, moved);
+ return {
+ ...table,
+ referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })),
+ rows: table.rows.map((row) => ({
+ ...row,
+ inputNodeIds: nextColumns.map((column) => {
+ const oldIndex = columns.findIndex((item) => item.id === column.id);
+ return row.inputNodeIds[oldIndex] || "";
+ }),
+ })),
+ };
+}
+
+export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData {
+ if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table;
+ if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table;
+ const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1);
+ const sourceRow = table.rows.find((row) => row.id === sourceRowId);
+ const targetRow = table.rows.find((row) => row.id === targetRowId);
+ if (!sourceRow || !targetRow) return table;
+ const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") }));
+ const nextSource = nextRows.find((row) => row.id === sourceRowId)!;
+ const nextTarget = nextRows.find((row) => row.id === targetRowId)!;
+ const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex];
+ if (!sourceNodeId) return table;
+ const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex];
+ nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId;
+ nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || "";
+ return { ...table, rows: nextRows };
+}
+
+export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
+ return table.globalPrompt?.trim() || row.prompt;
}
export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
@@ -181,34 +135,20 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne
return result;
}
-export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) {
- const columns = batchTextColumns(node.metadata?.batchTable);
- const indexById = new Map(columns.map((column, index) => [column.id, index]));
- const result = columns.map(() => [] as string[]);
- connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => {
- const columnId = batchTextColumnId(connection.toHandleId);
- const index = columnId ? indexById.get(columnId) : undefined;
- if (index === undefined || result[index].includes(connection.fromNodeId)) return;
- result[index].push(connection.fromNodeId);
- });
- return result;
-}
-
-/** A non-empty global prompt takes precedence without destroying row-specific prompts. */
-export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) {
- return table.globalPrompt?.trim() || row.prompt;
-}
-
export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow {
return {
id: `batch-row-${nanoid()}`,
enabled: true,
inputNodeIds,
- textNodeIds: [],
prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT,
};
}
+/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */
+export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) {
+ return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]);
+}
+
/**
* Connected try-on inputs follow the reference layout: all model/person images first,
* followed by one shared garment image. Users can still edit any row afterwards.
@@ -220,32 +160,34 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input
return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId]));
}
-/** Zip equally sized columns and broadcast singleton columns across every row. */
+/**
+ * Zip equally sized columns and broadcast singleton columns across every row.
+ *
+ * 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。
+ * 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。
+ */
export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) {
const rowCount = Math.max(0, ...columns.map((column) => column.length));
- const previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : []));
- return Array.from({ length: rowCount }, (_, rowIndex) => {
+ const unmatchedRows = [...previousRows];
+ const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => {
const inputNodeIds = columns.flatMap((column) => {
const input = column.length === 1 ? column[0] : column[rowIndex];
return input ? [input] : [];
});
- const previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[0]) : undefined;
+ let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds));
+ if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]);
+ const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined;
const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]);
return {
...createBatchRow(operation, inputNodeIds),
- ...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}),
+ ...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}),
...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}),
inputNodeIds,
};
});
+ return [...synchronizedRows, ...unmatchedRows];
}
-export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) {
- return rows.map((row, rowIndex) => ({
- ...row,
- textNodeIds: columns.flatMap((column) => {
- const input = column.length === 1 ? column[0] : column[rowIndex];
- return input ? [input] : [];
- }),
- }));
+function sameBatchInputs(left: string[], right: string[]) {
+ return left.length === right.length && left.every((id, index) => id === right[index]);
}
diff --git a/web/src/lib/canvas/canvas-image-batch-retry.ts b/web/src/lib/canvas/canvas-image-batch-retry.ts
index e0fc87f2b..a24d68e3d 100644
--- a/web/src/lib/canvas/canvas-image-batch-retry.ts
+++ b/web/src/lib/canvas/canvas-image-batch-retry.ts
@@ -135,7 +135,20 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
const children = (root.metadata.batchChildIds || [])
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
- if (!children.length) return root;
+ if (!children.length) {
+ const metadata: CanvasNodeMetadata = { ...root.metadata };
+ delete metadata.batchChildIds;
+ delete metadata.batchFailedCount;
+ delete metadata.primaryImageId;
+ delete metadata.isBatchRoot;
+ delete metadata.imageBatchExpanded;
+ delete metadata.errorDetails;
+ delete metadata.generationErrorCode;
+ delete metadata.resourceReloadAvailable;
+ delete metadata.failedPromptFingerprint;
+ if (!metadata.content) metadata.status = "idle";
+ return { ...root, metadata };
+ }
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
const loading = children.some((node) => node.metadata?.status === "loading");
diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts
index 382a8a0cf..090dcf6f9 100644
--- a/web/src/lib/canvas/canvas-project-domain.ts
+++ b/web/src/lib/canvas/canvas-project-domain.ts
@@ -10,6 +10,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
+import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
@@ -430,7 +431,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set removedIds.add(childId));
});
const remainingNodes = nodes.filter((node) => !removedIds.has(node.id));
- const nextNodes = remainingNodes.map((node) => {
+ const cleanedNodes = remainingNodes.map((node) => {
const detached = node.parentId && removedIds.has(node.parentId) ? { ...node, parentId: undefined } : node;
const storyboard = detached.metadata?.storyboard;
const cleaned = storyboard
@@ -458,6 +459,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set reconcileImageBatchRoot(node, cleanedNodes));
return { removedIds, nodes: nextNodes };
}
diff --git a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
index 214a8016c..83f766db8 100644
--- a/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
+++ b/web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
@@ -154,14 +154,7 @@ const BUILTIN_NODE_TRAITS = {
showInCreateMenu: true,
inputKind: "text",
},
- [CanvasNodeType.BatchTable]: {
- label: "批量创作表",
- icon: ,
- minSize: { width: 720, height: 360 },
- showInCreateMenu: true,
- acceptsInputKind: ["image", "text"],
- inputKind: "text",
- },
+ [CanvasNodeType.BatchTable]: { label: "批量创作表", icon: , minSize: { width: 1080, height: 420 }, showInCreateMenu: true, acceptsInputKind: "image", inputKind: "text", resourceKind: () => null },
[CanvasNodeType.MediaConversion]: { label: "转换", icon: , minSize: { width: 400, height: 360 }, showInCreateMenu: true, resourceKind: (node: CanvasNodeData) => { const c = node.metadata?.mediaConversion; return c?.status === "completed" && c.resultStorageKey ? (c.outputKind === "video" ? "video" : "image") : null; }, acceptsInputKind: ["image", "video"], maxInputCount: 1, inputKind: "image" },
[CanvasNodeType.ColorGrade]: {
label: "调色",
diff --git a/web/src/lib/model-picker-groups.ts b/web/src/lib/model-picker-groups.ts
new file mode 100644
index 000000000..7b09732dc
--- /dev/null
+++ b/web/src/lib/model-picker-groups.ts
@@ -0,0 +1,56 @@
+import { configuredModelDisplayName, groupModelsByDisplayName, type DisplayModelGroup } from "@/lib/model-selection";
+import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
+
+export type ModelPickerGroup = {
+ key: string;
+ label: string;
+ icon: string;
+ scope: string;
+ kind: "product" | "channel";
+ models: DisplayModelGroup[];
+};
+
+export function isDirectSystemModel(config: AiConfig, value: string) {
+ if (!value) return false;
+ const channel = resolveModelChannel(config, value);
+ const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
+ return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
+}
+
+export function modelChannelLabel(config: AiConfig, value: string) {
+ const channel = resolveModelChannel(config, value);
+ const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
+ return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
+}
+
+// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。
+export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] {
+ const groups = new Map();
+ for (const channel of config.channels) {
+ const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id);
+ const directModels = models.filter((value) => isDirectSystemModel(config, value));
+ for (const value of directModels) {
+ const label = configuredModelDisplayName(config, value);
+ const key = JSON.stringify(["product", label]);
+ let group = groups.get(key);
+ if (!group) {
+ group = { key, label, icon: modelIcon(config, value), scope: "平台服务", kind: "product", models: [] };
+ groups.set(key, group);
+ }
+ group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] });
+ }
+ const otherModels = models.filter((value) => !isDirectSystemModel(config, value));
+ if (otherModels.length) {
+ const key = JSON.stringify(["channel", channel.id]);
+ groups.set(key, {
+ key,
+ label: channel.name || "未命名渠道",
+ icon: modelIcon(config, otherModels[0]),
+ scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : "我的模型",
+ kind: "channel",
+ models: groupModelsByDisplayName(config, otherModels),
+ });
+ }
+ }
+ return Array.from(groups.values());
+}
diff --git a/web/src/lib/model-pricing.ts b/web/src/lib/model-pricing.ts
index 1e32d798c..d61e8fa14 100644
--- a/web/src/lib/model-pricing.ts
+++ b/web/src/lib/model-pricing.ts
@@ -1,7 +1,7 @@
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
-import type { ModelRequestIntent } from "@/services/api/logical-models";
+import type { LogicalModelQuote, ModelQuoteRequest, ModelRequestIntent } from "@/services/api/logical-models";
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
export type ModelPriceTier = NonNullable[number]["logicalPriceTiers"]>[number];
@@ -52,7 +52,7 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
return matched;
}
-export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
+export function priceTierSummaryLabel(tiers: ModelPriceTier[], capability?: ModelCapability) {
const unitPrices = (billingMode: ModelPriceTier["billingMode"]) =>
tiers
.filter((tier) => tier.billingMode === billingMode)
@@ -60,23 +60,41 @@ export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
.filter((value) => Number.isFinite(value) && value >= 0);
const fixedRequestValues = unitPrices("fixed_request");
const perSecondValues = unitPrices("per_second");
- if (fixedRequestValues.length) return formatPriceRange(fixedRequestValues, "积分");
- if (perSecondValues.length) return formatPriceRange(perSecondValues, "积分/秒");
- return tiers.some((tier) => tier.billingMode === "token") ? "按量预估" : "未配置";
+ const tokenValues = tiers.filter((tier) => tier.billingMode === "token").map((tier) => tier.outputTokenPriceMicrocredits / 1_000_000).filter((value) => Number.isFinite(value) && value >= 0);
+ return [
+ fixedRequestValues.length ? formatPriceRange(fixedRequestValues, "积分") : "",
+ perSecondValues.length ? formatPriceRange(perSecondValues, "积分/秒") : "",
+ tokenValues.length ? `${capability === "video" ? "" : "输出 "}${formatPriceRange(tokenValues, capability === "video" ? "积分/百万视频 Token" : "积分/百万 Token")}` : "",
+ ].filter(Boolean).join(" · ") || "未配置";
}
export function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
- const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
+ const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
-export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
+export function modelQuoteDescription(quote: LogicalModelQuote) {
+ const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
+ const descriptions = [`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`];
+ const estimate = quote.videoTokenEstimate;
+ if (estimate) {
+ descriptions.push(`公式预估 ${estimate.formulaTokens.toLocaleString("zh-CN")} 视频 Token,平台额外预留 ${estimate.reservationMarginPercent}%(共 ${estimate.reservedTokens.toLocaleString("zh-CN")} Token)`);
+ if (estimate.referenceDurationEstimated) descriptions.push("未知的参考视频时长按 15 秒估算");
+ if (estimate.dimensionsEstimated) descriptions.push("实际画幅尚未确定,尺寸按当前参数估算");
+ }
+ if (quote.billingMode === "token") descriptions.push(estimate
+ ? "预估金额并非费用上限,成功任务优先按有效的实际用量结算;未返回用量时按火山引擎公式结算,额外预留不计入公式结算,可能补扣或退回差额"
+ : "预估金额并非费用上限,最终以成功任务返回的实际用量结算,可能补扣或退回差额");
+ return descriptions.join(";");
+}
+
+export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): ModelQuoteRequest | undefined {
if (!capability || !value) return undefined;
const channel = resolveModelChannel(config, value);
if (channel.scope !== "system") return undefined;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
- if (!cost?.logicalModelId) return undefined;
+ if (!cost || (!cost.logicalModelId && capability !== "video")) return undefined;
const input = requirements?.input;
const intent: ModelRequestIntent = {
capability,
@@ -93,7 +111,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
},
};
- return { logicalModelID: cost.logicalModelId, intent };
+ return cost.logicalModelId ? { logicalModelID: cost.logicalModelId, intent } : { channelId: channel.id, modelKey: modelOptionName(value), intent };
}
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
@@ -109,11 +127,14 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
const imageCount = (input.imageCount || 0) + (input.characterCount || 0);
requested.operation = input.videoCount > 0 ? "video_to_video" : imageCount > 0 ? "image_to_video" : resolveVideoOperation(input, requirements?.videoOperation);
if (imageCount > 0) requested.imageCount = String(imageCount);
- }
- const resolution = normalizeTierResolution(config.vquality);
+ } else if (requirements?.videoOperation) requested.operation = requirements.videoOperation;
+ const options: Record = { ...modelRequestOptions(config, "video"), ...requirements?.options, ...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}) };
+ const resolution = normalizeTierResolution(String(options.vquality ?? ""));
if (resolution !== "*") requested.vquality = resolution;
- const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
+ const seconds = Math.max(0, Math.floor(Number(options.videoSeconds) || 0));
if (seconds > 0) requested.videoSeconds = String(seconds);
+ if (options.videoGenerateAudio === true || options.videoGenerateAudio === "true") requested.videoGenerateAudio = "true";
+ if (options.videoGenerateAudio === false || options.videoGenerateAudio === "false") requested.videoGenerateAudio = "false";
}
if (capability === "image") {
requested.operation = imagePriceOperation(requirements);
diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts
index 729bd8b8b..209e8ffc4 100644
--- a/web/src/lib/model-protocols.ts
+++ b/web/src/lib/model-protocols.ts
@@ -17,8 +17,8 @@ export function isVolcengineArkVideoProtocol(protocol?: string) {
return protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video";
}
-export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) {
- return capability === "text" || (capability === "video" && isVolcengineArkVideoProtocol(protocol));
+export function modelProtocolSupportsTokenBilling(capability?: string, _protocol?: string) {
+ return capability === "text" || capability === "video";
}
export function protocolForModelCatalog(_endpointTypes: string[] = []): ModelProtocol | undefined {
diff --git a/web/src/lib/skin-themes.ts b/web/src/lib/skin-themes.ts
index 11babf1d6..d08e62eb0 100644
--- a/web/src/lib/skin-themes.ts
+++ b/web/src/lib/skin-themes.ts
@@ -1,6 +1,32 @@
export type SkinThemeMode = "light" | "dark";
export type SkinShadowStyle = "none" | "soft" | "strong";
+export type SkinButtonFill = {
+ mode: "solid" | "gradient";
+ angle: number;
+ start: string;
+ end: string;
+ hoverStart: string;
+ hoverEnd: string;
+ activeStart: string;
+ activeEnd: string;
+ foreground: string;
+};
+
+export const DEFAULT_BUTTON_GRADIENT: SkinButtonFill = {
+ mode: "gradient", angle: 115,
+ start: "#6554df", end: "#386fbc",
+ hoverStart: "#5744cf", hoverEnd: "#356bbb",
+ activeStart: "#4938b8", activeEnd: "#2c5da5", foreground: "#ffffff",
+};
+
+export const SKIN_BUTTON_COLOR_FIELDS = [
+ { key: "start", label: "渐变起色" }, { key: "end", label: "渐变终色" },
+ { key: "hoverStart", label: "悬停起色" }, { key: "hoverEnd", label: "悬停终色" },
+ { key: "activeStart", label: "按下起色" }, { key: "activeEnd", label: "按下终色" },
+ { key: "foreground", label: "渐变按钮文字" },
+] as const;
+
export type SkinModeTokens = {
canvas: string;
surface: string;
@@ -78,6 +104,7 @@ export type SkinTokens = {
light: SkinModeTokens;
dark: SkinModeTokens;
components: SkinComponentTokens;
+ buttons: Record;
};
export type SkinDefinition = {
@@ -197,7 +224,7 @@ export const SKIN_COLOR_GROUPS: readonly SkinColorGroup[] = [
{ key: "switchUnchecked", label: "开关关闭", help: "Switch 关闭轨道" },
{ key: "switchUncheckedHover", label: "关闭悬停", help: "关闭状态 hover 轨道" },
{ key: "switchUncheckedHandle", label: "关闭滑块", help: "关闭状态圆形滑块" },
- { key: "primary", label: "主操作", help: "主要按钮、链接和开关" },
+ { key: "primary", label: "主操作", help: "纯色主按钮与链接;开关使用独立颜色" },
{ key: "primaryHover", label: "主操作悬停", help: "主要操作 hover" },
{ key: "primaryActive", label: "主操作按下", help: "主要操作 active" },
{ key: "primaryForeground", label: "主操作前景", help: "主按钮上的文字和图标" },
@@ -371,6 +398,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = {
authAccent: "#93c5fd",
authMuted: "#8a8b91",
},
+ buttons: { light: { ...DEFAULT_BUTTON_GRADIENT }, dark: { ...DEFAULT_BUTTON_GRADIENT } },
components: {
buttonRadius: 6,
inputRadius: 6,
@@ -395,6 +423,7 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = {
const HEX_COLOR = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i;
const MANAGED_VARIABLES = [
+ "--button-primary-bg", "--button-primary-hover-bg", "--button-primary-active-bg", "--button-primary-fg",
"--background",
"--foreground",
"--card",
@@ -504,7 +533,10 @@ export function normalizeSkinDefinition(value: unknown, fallback: SkinDefinition
name: typeof candidate.name === "string" && candidate.name.trim() ? candidate.name.trim().slice(0, 40) : fallback.name,
description: typeof candidate.description === "string" ? candidate.description.trim().slice(0, 100) : fallback.description,
locked: id === "classic",
- tokens: { light, dark, components: { ...modes.components } },
+ tokens: { light, dark, components: { ...modes.components }, buttons: {
+ light: normalizeButtonFill(modes.buttons?.light, id),
+ dark: normalizeButtonFill(modes.buttons?.dark, id),
+ } },
};
}
@@ -662,9 +694,37 @@ export function applySkinTheme(skinValue: unknown, mode: SkinThemeMode, targetDo
const values = skinCSSVariables(skin, mode);
for (const [property, value] of Object.entries(values)) root.style.setProperty(property, value);
}
+ for (const [property, value] of Object.entries(skinButtonCSSVariables(skin, mode))) root.style.setProperty(property, value);
root.dataset.skin = skin.id;
}
+export function isSkinButtonFill(value: unknown): value is SkinButtonFill {
+ if (!value || typeof value !== "object") return false;
+ const fill = value as SkinButtonFill;
+ return ["solid", "gradient"].includes(fill.mode) && Number.isInteger(fill.angle) && fill.angle >= 0 && fill.angle <= 360
+ && SKIN_BUTTON_COLOR_FIELDS.every(({ key }) => typeof fill[key] === "string" && HEX_COLOR.test(fill[key]));
+}
+
+function normalizeButtonFill(value: unknown, skinID: string): SkinButtonFill {
+ return isSkinButtonFill(value) ? { ...value } : { ...DEFAULT_BUTTON_GRADIENT, mode: skinID === "classic" ? "gradient" : "solid" };
+}
+
+export function getSkinButtonAppearance(skin: SkinDefinition, mode: SkinThemeMode) {
+ const fill = normalizeButtonFill(skin.tokens.buttons?.[mode], skin.id);
+ const color = skin.tokens[mode];
+ const gradient = (start: string, end: string) => `linear-gradient(${fill.angle}deg, ${start}, ${end})`;
+ return fill.mode === "gradient" ? {
+ background: gradient(fill.start, fill.end), hover: gradient(fill.hoverStart, fill.hoverEnd),
+ active: gradient(fill.activeStart, fill.activeEnd), foreground: fill.foreground,
+ } : { background: color.primary, hover: color.primaryHover, active: color.primaryActive, foreground: color.primaryForeground };
+}
+
+function skinButtonCSSVariables(skin: SkinDefinition, mode: SkinThemeMode) {
+ const button = getSkinButtonAppearance(skin, mode);
+ return { "--button-primary-bg": button.background, "--button-primary-hover-bg": button.hover,
+ "--button-primary-active-bg": button.active, "--button-primary-fg": button.foreground };
+}
+
export function skinCSSVariables(skin: SkinDefinition, mode: SkinThemeMode): Record {
const color = skin.tokens[mode];
const component = skin.tokens.components;
@@ -777,7 +837,8 @@ function skinShadowVariables(style: SkinShadowStyle, mode: SkinThemeMode) {
}
function cloneSkinTokens(tokens: SkinTokens): SkinTokens {
- return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components } };
+ return { light: { ...tokens.light }, dark: { ...tokens.dark }, components: { ...tokens.components },
+ buttons: { light: { ...tokens.buttons.light }, dark: { ...tokens.buttons.dark } } };
}
function isSkinMode(value: unknown): value is SkinModeTokens {
diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts
index e9a873930..2d656a42b 100644
--- a/web/src/lib/user-session.ts
+++ b/web/src/lib/user-session.ts
@@ -1,12 +1,12 @@
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
-import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
+import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
import { localForageStorage } from "@/lib/localforage-storage";
import { appQueryClient } from "@/lib/query-client";
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
-import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
+import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { imageSizeConfigWithPresets } from "@/lib/image-size-presets";
@@ -90,12 +90,8 @@ export async function refreshSystemChannels() {
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
}
-// 模型目录来源决定数据形状;这里统一做运行时收口,避免畸形响应被当成“空目录”写入用户配置。
+// 目录仅接受系统渠道模型,避免畸形响应被当成空目录写入配置。
function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
- if (catalog.source === "frontend") {
- if (!Array.isArray(catalog.models)) throw new Error("模型目录响应缺少前台模型列表");
- return managedModelChannels(catalog.models);
- }
if (catalog.source === "system") {
if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表");
return systemChannelModelChannels(catalog.channels);
@@ -103,42 +99,6 @@ function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
throw new Error("模型目录响应来源无效");
}
-function managedModelChannels(models: PublicLogicalModel[]) {
- const availableModels = models.filter((item) => item.available);
- if (!availableModels.length) return [];
- const managed: ModelChannel = {
- id: PUBLIC_MODEL_CATALOG_ID,
- name: "平台模型",
- baseUrl: "/api",
- apiKey: "system",
- apiFormat: "openai",
- scope: "system",
- enabled: true,
- models: availableModels.map((item) => item.id),
- modelAliases: Object.fromEntries(availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id]))),
- modelCosts: availableModels.map((item) => ({
- model: item.id,
- displayName: item.name,
- description: item.description,
- icon: item.icon,
- capability: item.capability,
- pricePolicy: item.pricePolicy,
- billingMode: item.billingMode,
- unitPriceMicrocredits: item.unitPriceMicrocredits,
- inputTokenPriceMicrocredits: item.inputPriceMicrocredits,
- outputTokenPriceMicrocredits: item.outputPriceMicrocredits,
- cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits,
- capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions),
- logicalModelId: item.id,
- logicalCapabilitySpec: item.capabilitySpec,
- logicalCapabilityProfiles: item.capabilityProfiles,
- logicalPriceTiers: item.priceTiers,
- defaultOptions: item.defaultOptions,
- })),
- };
- return [managed];
-}
-
// 系统渠道模型转换为前端配置格式
export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
return channels.map((channel) => {
@@ -149,7 +109,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
sortOrder: channel.sortOrder,
// 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径,
// 不能作为模型请求的运行时 Base URL 传给 channelRequest。
- baseUrl: `/api/ai/system/${channel.id}`,
+ baseUrl: `/api/${channel.id}`,
apiKey: "system",
apiFormat: "openai",
scope: "system" as const,
@@ -178,6 +138,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
return {
model: model.modelKey,
displayName: model.displayName,
+ channelLabel: model.channelLabel,
description: model.description || "",
icon: model.icon || "",
capability: model.capability as ModelCapability,
diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx
index b2f4ee9fd..e7cae1bcb 100644
--- a/web/src/pages/admin/channels/channels-page.tsx
+++ b/web/src/pages/admin/channels/channels-page.tsx
@@ -19,7 +19,6 @@ import { ChannelOrderDialog } from "../components/channel-order-dialog";
type ChannelFormValues = {
name: string;
- publicAlias?: string;
baseUrl: string;
apiKey?: string;
secretKey?: string;
@@ -32,7 +31,6 @@ type ChannelFormValues = {
export function adminChannelSavePayload(values: ChannelFormValues) {
return {
name: values.name.trim(),
- publicAlias: values.publicAlias?.trim() || "",
baseUrl: values.baseUrl.trim(),
apiKey: values.apiKey?.trim() || "",
secretKey: values.secretKey?.trim() || "",
@@ -122,7 +120,6 @@ export default function ChannelsPage() {
: { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
);
setDrawerOpen(true);
- form.setFieldsValue({ publicAlias: channel?.publicAlias || "" });
};
const closeDrawer = () => {
@@ -208,7 +205,6 @@ export default function ChannelsPage() {
),
},
- { title: "前台名称", width: 160, render: (_, channel) => {channel.publicAlias || channel.name} },
{ title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : 跟随系统) },
{ title: "凭证", width: 130, align: "center", render: (_, channel) => },
@@ -359,9 +355,6 @@ export default function ChannelsPage() {
-
-
-
diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx
index 8ef978d80..6c91c56d4 100644
--- a/web/src/pages/admin/components/admin-shell.tsx
+++ b/web/src/pages/admin/components/admin-shell.tsx
@@ -139,7 +139,7 @@ export function AdminShell() {
};
return (
- adminPopupContainer(node)}>
+ adminPopupContainer(node)}>
@@ -224,7 +225,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const filteredItems = items.filter((item) => {
const query = keyword.trim().toLowerCase();
- if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName}`.toLowerCase().includes(query)) return false;
+ if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName} ${item.channelLabel || ""}`.toLowerCase().includes(query)) return false;
if (capability !== "all" && item.capability !== capability) return false;
if (status === "enabled" && !item.enabled) return false;
if (status === "disabled" && item.enabled) return false;
@@ -473,14 +474,14 @@ function billingSummary(item: ChannelModel) {
return (
{tiers.slice(0, 3).map((tier) => (
-
{priceTierLabel(tier)}
+
{priceTierLabel(tier, item.capability)}
))}
{tiers.length > 3 ?
另有 {tiers.length - 3} 个规格价格档
: null}
);
}
-function priceTierLabel(tier: ChannelModelPriceTier) {
+function priceTierLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
const selector = tier.selector || {};
const specParts = [
selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式",
@@ -489,9 +490,10 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
+ selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
].filter(Boolean);
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
- if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
+ if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} 积分 / 百万${capability === "video" ? "视频 " : " "}Token`;
return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
}
diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx
index c2753877d..2e1259b89 100644
--- a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx
+++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx
@@ -5,6 +5,7 @@ import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/mod
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
+import { CreditCostFields } from "./credit-cost-fields";
export function PriceTierFields({
index,
@@ -38,7 +39,7 @@ export function PriceTierFields({
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
- const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
+ const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isImage = capability === "image";
return (
@@ -84,6 +85,7 @@ export function PriceTierFields({
+
销售价格
{billingMode === "token" ? (
isVideo ? (
-
-
+
+
) : (
@@ -119,7 +121,13 @@ export function PriceTierFields({
)}
+ {isVideo && billingMode === "token" ? (
+
+ 所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。
+
+ ) : null}
+
{matchMode !== "default" && (
@@ -135,6 +143,11 @@ export function PriceTierFields({
{durationOptions.length ?
) : null}
+ {isVideo ? (
+
+
+
+ ) : null}
{isVideo ? (
diff --git a/web/src/pages/admin/components/channel-model-price-tier-form.ts b/web/src/pages/admin/components/channel-model-price-tier-form.ts
index 4559ab83b..89b5921bc 100644
--- a/web/src/pages/admin/components/channel-model-price-tier-form.ts
+++ b/web/src/pages/admin/components/channel-model-price-tier-form.ts
@@ -15,6 +15,7 @@ export type PriceTierFormValues = {
size: string;
resolution: string;
videoSeconds: number;
+ videoGenerateAudio: string;
imageCount: number;
providerModelKey?: string;
billingMode: ChannelModel["billingMode"];
@@ -22,6 +23,11 @@ export type PriceTierFormValues = {
inputTokenPrice: number;
outputTokenPrice: number;
cachedTokenPrice: number;
+ costConfigured: boolean;
+ costUnitPrice: number;
+ costInputTokenPrice: number;
+ costOutputTokenPrice: number;
+ costCachedTokenPrice: number;
priceConfigured: boolean;
enabled: boolean;
};
@@ -34,6 +40,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
size: "*",
resolution: "*",
videoSeconds: 0,
+ videoGenerateAudio: "*",
imageCount: 0,
providerModelKey: "",
billingMode: "fixed_request",
@@ -41,6 +48,11 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
inputTokenPrice: 0,
outputTokenPrice: 0,
cachedTokenPrice: 0,
+ costConfigured: false,
+ costUnitPrice: 0,
+ costInputTokenPrice: 0,
+ costOutputTokenPrice: 0,
+ costCachedTokenPrice: 0,
priceConfigured: true,
enabled: true,
};
@@ -56,6 +68,7 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
size: selector.size || "*",
resolution: tier.resolution || "*",
videoSeconds: tier.videoSeconds || 0,
+ videoGenerateAudio: selector.videoGenerateAudio || "*",
imageCount: Number(selector.imageCount || 0),
providerModelKey: tier.providerModelKey || "",
billingMode: tier.billingMode,
@@ -63,6 +76,11 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
+ costConfigured: tier.costPricing?.configured === true,
+ costUnitPrice: (tier.costPricing?.unitPriceMicrocredits ?? 0) / 1_000_000,
+ costInputTokenPrice: (tier.costPricing?.inputTokenPriceMicrocredits ?? 0) / 1_000_000,
+ costOutputTokenPrice: (tier.costPricing?.outputTokenPriceMicrocredits ?? 0) / 1_000_000,
+ costCachedTokenPrice: (tier.costPricing?.cachedTokenPriceMicrocredits ?? 0) / 1_000_000,
priceConfigured: tier.priceConfigured,
enabled: tier.enabled,
};
@@ -91,6 +109,7 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
+ if (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio;
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
@@ -106,3 +125,27 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0;
}
+
+export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {
+ const videoTokens = capability === "video" && tier.billingMode === "token";
+ return {
+ selector: skuSelectorFromForm(capability, tier),
+ resolution: priceTierResolutionFromForm(capability, tier),
+ videoSeconds: priceTierVideoSecondsFromForm(capability, tier),
+ providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
+ billingMode: tier.billingMode,
+ unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
+ inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000),
+ outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
+ cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
+ costPricing: {
+ configured: tier.costConfigured,
+ unitPriceMicrocredits: Math.round(tier.costUnitPrice * 1_000_000),
+ inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costInputTokenPrice * 1_000_000),
+ outputTokenPriceMicrocredits: Math.round(tier.costOutputTokenPrice * 1_000_000),
+ cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costCachedTokenPrice * 1_000_000),
+ },
+ priceConfigured: tier.priceConfigured !== false,
+ enabled: tier.enabled !== false,
+ };
+}
diff --git a/web/src/pages/admin/components/credit-cost-fields.tsx b/web/src/pages/admin/components/credit-cost-fields.tsx
new file mode 100644
index 000000000..dce90329d
--- /dev/null
+++ b/web/src/pages/admin/components/credit-cost-fields.tsx
@@ -0,0 +1,32 @@
+import { Form, InputNumber, Switch, type FormInstance } from "antd";
+import type { ChannelModelFormValues } from "./channel-model-editor-form";
+
+export function CreditCostFields({ index, form, billingMode, isVideo }: { index: number; form: FormInstance; billingMode: string; isVideo: boolean }) {
+ const configured = Form.useWatch(["priceTiers", index, "costConfigured"], form) === true;
+ const fields =
+ billingMode === "token"
+ ? isVideo
+ ? [["costOutputTokenPrice", "积分 / 百万视频 Token"]]
+ : [
+ ["costInputTokenPrice", "输入 / 百万 Token"],
+ ["costOutputTokenPrice", "输出 / 百万 Token"],
+ ["costCachedTokenPrice", "缓存 / 百万 Token"],
+ ]
+ : [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]];
+ return (
+
+
+
+
+ {configured ? (
+
+ {fields.map(([field, label]) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/pages/admin/components/credit-operations-panel.tsx b/web/src/pages/admin/components/credit-operations-panel.tsx
index c655eeb5c..ddcc901ae 100644
--- a/web/src/pages/admin/components/credit-operations-panel.tsx
+++ b/web/src/pages/admin/components/credit-operations-panel.tsx
@@ -328,7 +328,11 @@ export default function CreditOperationsPanel({ users, activeOperation, onOperat
{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "等待用量结算"}
- 输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}
+ {order.usageSource === "video_formula"
+ ? `公式结算 · ${order.outputTokens.toLocaleString()} 视频 Token`
+ : order.capability === "video"
+ ? `上游用量 · ${order.outputTokens.toLocaleString()} 视频 Token`
+ : `输入 ${order.inputTokens} · 输出 ${order.outputTokens} · 缓存 ${order.cachedTokens}`}
) : (
diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx
index 2916ae9d8..7a31f42c0 100644
--- a/web/src/pages/admin/logs/logs-page.tsx
+++ b/web/src/pages/admin/logs/logs-page.tsx
@@ -99,7 +99,7 @@ export default function LogsPage() {
),
},
{ title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => {formatDuration(value)} },
- { title: "积分计费", width: 145, render: (_, log) => },
+ { title: "积分计算", width: 195, render: (_, log) => },
{
title: "Tokens",
width: 166,
@@ -269,7 +269,8 @@ function BillingSummary({ log }: { log: ApiCallLog }) {
const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status];
return (
-
{formatCredits(log.billingAmountMicrocredits)} 积分
+
销售价格:{formatCredits(log.billingAmountMicrocredits)} 积分
+
成本价格:{log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"}
{statusLabel}
);
diff --git a/web/src/pages/admin/payments/payments-page.css b/web/src/pages/admin/payments/payments-page.css
index 0b3d10c21..8ecd17de6 100644
--- a/web/src/pages/admin/payments/payments-page.css
+++ b/web/src/pages/admin/payments/payments-page.css
@@ -1,17 +1 @@
-.admin-list-toolbar .ant-btn.admin-toolbar-primary-action {
- border-color: var(--btn-solid-bg) !important;
- background: var(--btn-solid-bg) !important;
- color: var(--btn-solid-fg) !important;
-}
-
-.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):hover {
- border-color: var(--btn-solid-hover-bg) !important;
- background: var(--btn-solid-hover-bg) !important;
- color: var(--btn-solid-fg) !important;
-}
-
-.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):active {
- border-color: var(--btn-solid-active-bg) !important;
- background: var(--btn-solid-active-bg) !important;
- color: var(--btn-solid-fg) !important;
-}
+/* Primary toolbar actions inherit the shared site button fill and disabled state. */
diff --git a/web/src/pages/admin/payments/payments-page.tsx b/web/src/pages/admin/payments/payments-page.tsx
index 92597e4c8..43420f59d 100644
--- a/web/src/pages/admin/payments/payments-page.tsx
+++ b/web/src/pages/admin/payments/payments-page.tsx
@@ -1,4 +1,4 @@
-import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
+import { PaymentBrandIcon } from "@/components/payment-brand-icons";
import { Callout } from "@/pages/admin/ui/controls";
import { App, Button, DatePicker, Descriptions, Drawer, Form, Input, InputNumber, Select, Tabs, Typography } from "antd";
import { AdminDrawer } from "@/pages/admin/ui/overlays";
@@ -912,23 +912,6 @@ export default function AdminPaymentsPage() {
);
}
-function PaymentBrandIcon({ providerId, compact = false }: { providerId: string; compact?: boolean }) {
- const size = compact ? "size-6" : "size-10";
- if (providerId === "wechat-native")
- return (
-
-
-
- );
- if (providerId === "alipay-page-pay")
- return (
-
-
-
- );
- return PAY;
-}
-
function formatDateTime(value: string) {
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
}
diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx
index ebe7d3d09..2d42eb998 100644
--- a/web/src/pages/admin/plugins/plugins-page.tsx
+++ b/web/src/pages/admin/plugins/plugins-page.tsx
@@ -1,6 +1,7 @@
import { App, Button, Input, Select } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
+import { ZHIFUFM_LOGO_SRC } from "@/components/payment-brand-icons";
import type { ColumnsType } from "antd/es/table";
import { CloudUpload, PlugZap, RefreshCw, Search, Trash2, UsersRound } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -350,6 +351,13 @@ function PluginBrandIcon({ pluginId }: { pluginId: string }) {
);
}
+ if (pluginId === "official-payment-zhifufm") {
+ return (
+
+
+
+ );
+ }
return (
diff --git a/web/src/pages/admin/settings/appearance-settings-page.tsx b/web/src/pages/admin/settings/appearance-settings-page.tsx
index 3729f4ffa..fcdfc67fa 100644
--- a/web/src/pages/admin/settings/appearance-settings-page.tsx
+++ b/web/src/pages/admin/settings/appearance-settings-page.tsx
@@ -1,4 +1,4 @@
-import { App, Button, Form, Input, Skeleton } from "antd";
+import { App, Button, Form, Input, Skeleton, Tabs } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { Copyright, Globe2, Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Search, Sun, Type, Undo2, Upload } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
@@ -7,7 +7,7 @@ import { useBlocker } from "react-router";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
import { cn } from "@/lib/utils";
-import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
+import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { deleteAdminResources } from "@/services/api/admin-storage";
@@ -28,6 +28,7 @@ const FILE_RULES: Record(null);
const [brandName, setBrandName] = useState("");
const [brandSlug, setBrandSlug] = useState("");
@@ -79,16 +80,17 @@ export default function AppearanceSettingsPage() {
const blocker = useBlocker(dirty && !saving && !restoring);
const applySetting = useCallback((value: AdminAppearance) => {
- setSetting(value);
+ const themes = value.skinThemes.length ? value.skinThemes.map((theme) => normalizeSkinDefinition(theme)) : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)];
+ const selectedID = themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic";
+ setSetting({ ...value, skinThemes: themes, skinId: selectedID });
setBrandName(value.brandName);
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
setAuthVideoAutoplay(value.authVideoAutoplay);
setLogoFrameEnabled(value.logoFrameEnabled);
- const themes = value.skinThemes.map((theme) => normalizeSkinDefinition(theme));
- setSkinThemes(themes.length ? themes : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]);
- setSkinId(themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic");
+ setSkinThemes(themes);
+ setSkinId(selectedID);
setSeoTitle(value.seoTitle);
setSeoDescription(value.seoDescription);
setSeoKeywords(value.seoKeywords);
@@ -268,39 +270,46 @@ export default function AppearanceSettingsPage() {
const nextFooterCopyright = normalizeSingleLine(footerCopyright);
const nextIcpFilingNumber = normalizeSingleLine(icpFilingNumber);
if (!nextBrandName || Array.from(nextBrandName).length > 40) {
+ setActiveTab("brand");
message.error("品牌名称必须为 1 到 40 个字符");
return;
}
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(nextBrandSlug)) {
+ setActiveTab("brand");
message.error("英文品牌标识须为 1 到 48 位小写字母、数字或连字符");
return;
}
if (!nextAuthHeroTitle || Array.from(nextAuthHeroTitle).length > 80 || hasUnsupportedControlCharacter(nextAuthHeroTitle)) {
+ setActiveTab("auth");
message.error("登录页主标题必须为 1 到 80 个字符,可使用换行");
return;
}
if (Array.from(nextAuthHeroDescription).length > 160 || hasUnsupportedControlCharacter(nextAuthHeroDescription)) {
+ setActiveTab("auth");
message.error("登录页说明文案不能超过 160 个字符,可使用换行");
return;
}
- for (const [value, label, max] of [
- [nextSeoTitle, "SEO 标题", 70],
- [nextSeoDescription, "SEO 描述", 200],
- [nextSeoKeywords, "SEO 关键词", 300],
- [nextFooterCopyright, "版权信息", 160],
- [nextIcpFilingNumber, "备案号", 64],
+ for (const [value, label, max, tab] of [
+ [nextSeoTitle, "SEO 标题", 70, "seo"],
+ [nextSeoDescription, "SEO 描述", 200, "seo"],
+ [nextSeoKeywords, "SEO 关键词", 300, "seo"],
+ [nextFooterCopyright, "版权信息", 160, "footer"],
+ [nextIcpFilingNumber, "备案号", 64, "footer"],
] as const) {
if (Array.from(value).length > max || hasUnsupportedControlCharacter(value)) {
+ setActiveTab(tab);
message.error(`${label}不能超过 ${max} 个字符,且不能包含控制字符`);
return;
}
}
if (icpFilingEnabled && !nextIcpFilingNumber) {
+ setActiveTab("footer");
message.error("显示备案号前请先填写备案号");
return;
}
const skinError = validateSkinDrafts(skinThemes, skinId);
if (skinError) {
+ setActiveTab("skins");
message.error(skinError);
return;
}
@@ -429,7 +438,7 @@ export default function AppearanceSettingsPage() {
{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}
-
{dirty ? "品牌、SEO、备案、皮肤和媒体只在本页预览;保存后才会应用。" : "公开页面会在应用渲染前读取品牌与皮肤,不会先闪现旧站点身份。"}
+
{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}
@@ -450,254 +459,303 @@ export default function AppearanceSettingsPage() {
-
}
- title="1. 设置品牌识别"
- description="中文品牌名用于主要界面,英文品牌标识用于英文角标和可安全品牌化的路径建议;不会改动代码包、数据库或部署标识。"
- status={status}
- >
-
-
- setBrandName(event.target.value)} />
-
-
- setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
- />
-
-
-
-
-
-
-
-
-
禁用 Logo 后面的圆角矩形外框
-
开启后,工作台和管理后台导航只显示 Logo 本身;Logo 会等比放大到原外框的可用区域,导航占位保持不变。
-
-
-
{logoFrameEnabled ? "保留外框" : "已禁用外框"}
-
}
+ title="品牌识别"
+ description="中文品牌名用于主要界面,英文品牌标识用于英文角标和可安全品牌化的路径建议;不会改动代码包、数据库或部署标识。"
+ status={status}
+ >
+
+
+ setBrandName(event.target.value)} />
+
+
+ setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
+ />
+
+
+
+
+
+
+
+
禁用 Logo 后面的圆角矩形外框
+
开启后,工作台和管理后台导航只显示 Logo 本身;Logo 会等比放大到原外框的可用区域,导航占位保持不变。
+
+
+ {logoFrameEnabled ? "保留外框" : "已禁用外框"}
+ setLogoFrameEnabled(!checked)}
+ />
+
+
+
+ } src={previews.logoLight} dark={false} frameEnabled={logoFrameEnabled} />
+ } src={previews.logoDark} dark frameEnabled={logoFrameEnabled} />
+
+
+
+
+ ),
+ },
+ {
+ key: "auth",
+ label: "登录页",
+ destroyOnHidden: true,
+ children: (
+
}
+ title="登录页内容与媒体"
+ description="登录、注册与找回密码共享左侧品牌文案和影片;更换视频会同时取消旧封面,避免品牌串帧。"
+ status={
+
+ }
+ >
+
+
+
+ setAuthHeroTitle(event.target.value)} />
+
+
+ setAuthHeroDescription(event.target.value)}
+ />
+
+
+
+
+
+ 登录页视频自动播放
+ 默认开启并静音循环播放;访客启用“减少动态效果”时仍尊重其系统偏好。
+
+
+ {authVideoAutoplay ? "已开启" : "已关闭"}
+
+
+
+
+
+
+
+
+
+
+ 登录页实时预览
+ {brandName.trim() || "未命名品牌"}
+
+
+
+
+
+
+
+
+ {brandName.trim() || "未命名品牌"}
+
+
+ {(brandSlug.trim() || "brand-studio").replace(/-+/g, " ").toLocaleUpperCase()}
+ {normalizeDraftCopy(authHeroTitle) || "请输入登录页主标题"}
+ {normalizeDraftCopy(authHeroDescription) ? {normalizeDraftCopy(authHeroDescription)} : null}
+
+
+
预览与正式登录页使用同一文案和媒体。正式页面会先解析公开配置,再渲染品牌内容。
+
+
+
+ ),
+ },
+ {
+ key: "welcome",
+ label: "欢迎页",
+ children: (
+
}
+ title="欢迎页"
+ description="控制访客是否可以访问欢迎页,开关修改后立即保存。"
+ >
+
+
+
+
+ ),
+ },
+ {
+ key: "seo",
+ label: "SEO 信息",
+ children: (
+
}
+ title="SEO 信息"
+ description="配置浏览器标题、搜索摘要和关键词;留空时标题与描述会自动跟随当前站点名称。"
+ status={
}
+ >
+
+ setSeoDescription(event.target.value)}
+ />
+
+
+
+ ),
+ },
+ {
+ key: "footer",
+ label: "页尾与备案",
+ children: (
+
}
+ title="首页页尾与备案"
+ description="配置公开登录首页底部的版权和备案信息;备案号启用后固定链接工信部备案管理系统。"
+ status={
}
+ >
+
+
+ }
+ value={footerCopyright}
+ maxLength={160}
+ showCount
+ placeholder={`© ${new Date().getFullYear()} ${draftBrandName}. All rights reserved.`}
+ onChange={(event) => setFooterCopyright(event.target.value)}
+ />
+
+
+ setIcpFilingNumber(event.target.value)} />
+
+
+
+
+
在首页底部显示备案号
+
启用后,备案号会显示在登录、注册和找回密码页底部,并链接至 https://beian.miit.gov.cn/ 供公众查询。
+
+
+ {icpFilingEnabled ? "已显示" : "未显示"}
+
+
+
+
+
+ ),
+ },
+ {
+ key: "skins",
+ label: "皮肤主题",
+ children: (
+
}
+ title="皮肤主题"
+ description="经典黑白采用黑白底色与紫蓝渐变主按钮;复制后可调整浅色、深色、按钮填充及控件参数,所有修改统一保存。"
+ status={
}
+ >
+
setLogoFrameEnabled(!checked)}
+ onSelect={setSkinId}
+ onCreate={() => duplicateSkin("classic")}
+ onDuplicate={duplicateSkin}
+ onDelete={deleteSkin}
+ onChange={changeSkin}
/>
-
-
-
- } src={previews.logoLight} dark={false} frameEnabled={logoFrameEnabled} />
- } src={previews.logoDark} dark frameEnabled={logoFrameEnabled} />
-
-
-
-
-
-
}
- title="2. 设置登录页内容与媒体"
- description="登录、注册与找回密码共享左侧品牌文案和影片;更换视频会同时取消旧封面,避免品牌串帧。"
- status={
-
- }
- >
-
-
-
- setAuthHeroTitle(event.target.value)} />
-
-
- setAuthHeroDescription(event.target.value)}
- />
-
-
-
-
-
- 登录页视频自动播放
- 默认开启并静音循环播放;访客启用“减少动态效果”时仍尊重其系统偏好。
-
-
- {authVideoAutoplay ? "已开启" : "已关闭"}
-
-
-
-
-
-
-
-
-
-
- 登录页实时预览
- {brandName.trim() || "未命名品牌"}
-
-
-
-
-
-
-
-
- {brandName.trim() || "未命名品牌"}
-
-
- {(brandSlug.trim() || "brand-studio").replace(/-+/g, " ").toLocaleUpperCase()}
- {normalizeDraftCopy(authHeroTitle) || "请输入登录页主标题"}
- {normalizeDraftCopy(authHeroDescription) ? {normalizeDraftCopy(authHeroDescription)} : null}
-
-
-
预览与正式登录页使用同一文案和媒体。正式页面会先解析公开配置,再渲染品牌内容。
-
-
-
-
-
}
- title="3. SEO 信息"
- description="配置浏览器标题、搜索摘要和关键词;留空时标题与描述会自动跟随当前站点名称。"
- status={
}
- >
-
- setSeoDescription(event.target.value)}
- />
-
-
-
-
-
}
- title="4. 首页页尾与备案"
- description="配置公开登录首页底部的版权和备案信息;备案号启用后固定链接工信部备案管理系统。"
- status={
}
- >
-
-
- }
- value={footerCopyright}
- maxLength={160}
- showCount
- placeholder={`© ${new Date().getFullYear()} ${draftBrandName}. All rights reserved.`}
- onChange={(event) => setFooterCopyright(event.target.value)}
- />
-
-
- setIcpFilingNumber(event.target.value)} />
-
-
-
-
-
在首页底部显示备案号
-
启用后,备案号会显示在登录、注册和找回密码页底部,并链接至 https://beian.miit.gov.cn/ 供公众查询。
-
-
- {icpFilingEnabled ? "已显示" : "未显示"}
-
-
-
-
-
-
-
}
- title="5. 皮肤主题"
- description="默认主题保持项目原始样式且不可更改;其他主题可新建、复制、改名、删除,并分别定义浅色、深色、控件样式与交互反馈。"
- status={
}
- >
-
duplicateSkin("classic")}
- onDuplicate={duplicateSkin}
- onDelete={deleteSkin}
- onChange={changeSkin}
- />
-
+
+ ),
+ },
+ ]}
+ />
)}
@@ -831,6 +889,7 @@ function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) {
if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符";
const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color));
if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`;
+ if (!isSkinButtonFill(theme.tokens.buttons.light) || !isSkinButtonFill(theme.tokens.buttons.dark)) return `主题“${theme.name}”的主按钮参数无效,请检查颜色和渐变角度(0–360°)`;
if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`;
if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`;
}
diff --git a/web/src/pages/admin/settings/components/skin-theme-editor.tsx b/web/src/pages/admin/settings/components/skin-theme-editor.tsx
index 917756dad..8ec8bacb9 100644
--- a/web/src/pages/admin/settings/components/skin-theme-editor.tsx
+++ b/web/src/pages/admin/settings/components/skin-theme-editor.tsx
@@ -3,7 +3,7 @@ import { IconButton, SegmentedControl, Select, Tooltip } from "@/pages/admin/ui/
import { Check, Copy, LockKeyhole, Moon, Plus, Sparkles, Sun, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
-import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, skinSwatches, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
+import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, SKIN_BUTTON_COLOR_FIELDS, getSkinButtonAppearance, skinSwatches, type SkinButtonFill, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
import { cn } from "@/lib/utils";
import { AdminStatusBadge } from "@/pages/admin/components/admin-ui";
@@ -30,6 +30,10 @@ export function SkinThemeEditor({
const selected = useMemo(() => themes.find((theme) => theme.id === selectedID) || themes[0], [selectedID, themes]);
if (!selected) return null;
const editorDisabled = disabled || selected.locked;
+ const buttonFill = selected.tokens.buttons[mode];
+ const updateButton = (patch: Partial
) => onChange({ ...selected, tokens: { ...selected.tokens,
+ buttons: { ...selected.tokens.buttons, [mode]: { ...buttonFill, ...patch } },
+ } });
const updateIdentity = (patch: Partial>) => onChange({ ...selected, ...patch });
const updateColor = (key: keyof SkinModeTokens, value: string) =>
@@ -115,7 +119,7 @@ export function SkinThemeEditor({
{selected.locked ? (
- 经典黑白始终保留项目原始视觉,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。
+ 经典黑白采用黑白基础配色与紫蓝渐变主按钮,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。
) : null}
@@ -164,6 +168,27 @@ export function SkinThemeEditor({
+
+ 主按钮填充{mode === "light" ? "浅色" : "深色"}模式
+
+
+ {buttonFill.mode === "gradient" ? : null}
+
+ {buttonFill.mode === "gradient" ?
+ {SKIN_BUTTON_COLOR_FIELDS.map(({ key, label }) => updateButton({ [key]: value.trim().toLowerCase() })} />)}
+
: null}
+
+
{SKIN_COLOR_GROUPS.map((group, index) => (
@@ -240,6 +265,7 @@ function ColorTokenField({ label, help, value, disabled, onChange }: { label: st
function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinThemeMode }) {
const color = theme.tokens[mode];
+ const button = getSkinButtonAppearance(theme, mode);
const component = theme.tokens.components;
const shadow = component.shadowStyle === "none" ? "none" : component.shadowStyle === "strong" ? "0 18px 44px #0000003d" : "0 10px 28px #00000024";
return (
@@ -264,7 +290,7 @@ function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinTh
>
输入框状态
-
+
主要操作
diff --git a/web/src/pages/admin/theme/admin-ant-theme.ts b/web/src/pages/admin/theme/admin-ant-theme.ts
index 1978712c0..ae0abd6d7 100644
--- a/web/src/pages/admin/theme/admin-ant-theme.ts
+++ b/web/src/pages/admin/theme/admin-ant-theme.ts
@@ -1,18 +1,20 @@
import type { ThemeConfig } from "antd";
import { theme as antdTheme } from "antd";
+import { getSkinAntOverrides } from "@/lib/skin-themes";
-/** Isolated Ant Design theme for the admin console. Does not read product skins. */
-export function getIsolatedAdminAntTheme(dark: boolean): ThemeConfig {
+/** 后台保留独立密度与表面,主操作颜色继承站点皮肤。 */
+export function getIsolatedAdminAntTheme(dark: boolean, skinID: unknown = "classic"): ThemeConfig {
+ const skin = getSkinAntOverrides(skinID, dark ? "dark" : "light");
const text = dark ? "#f5f5f5" : "#111111";
const textSecondary = dark ? "#c4c4c4" : "#525252";
const canvas = dark ? "#0f0f0f" : "#f5f5f5";
const surface = dark ? "#181818" : "#ffffff";
const surfaceMuted = dark ? "#222222" : "#f6f6f6";
const border = dark ? "rgba(245, 245, 245, 0.12)" : "rgba(17, 17, 17, 0.12)";
- const primary = dark ? "#f5f5f5" : "#111111";
- const primaryFg = dark ? "#0f0f0f" : "#ffffff";
- const hover = dark ? "#ffffff" : "#2a2a2a";
- const active = dark ? "#d4d4d4" : "#404040";
+ const primary = skin.primary || (dark ? "#f5f5f5" : "#171717");
+ const primaryFg = skin.primaryForeground || (dark ? "#171717" : "#ffffff");
+ const hover = skin.primaryHover || (dark ? "#ffffff" : "#303030");
+ const active = skin.primaryActive || (dark ? "#e5e5e5" : "#404040");
const danger = dark ? "#f87171" : "#dc2626";
const success = dark ? "#4ade80" : "#15803d";
const warning = dark ? "#fbbf24" : "#b45309";
@@ -34,6 +36,7 @@ export function getIsolatedAdminAntTheme(dark: boolean): ThemeConfig {
controlHeightSM: 28,
controlHeightLG: 36,
lineWidth: 1,
+ controlOutlineWidth: 0,
colorPrimary: primary,
colorPrimaryHover: hover,
colorPrimaryActive: active,
@@ -65,7 +68,11 @@ export function getIsolatedAdminAntTheme(dark: boolean): ThemeConfig {
paddingInline: 12,
paddingInlineSM: 8,
primaryShadow: "none",
+ defaultShadow: "none",
dangerShadow: "none",
+ colorPrimary: primary,
+ colorPrimaryHover: hover,
+ colorPrimaryActive: active,
defaultBg: surface,
defaultColor: text,
defaultBorderColor: border,
@@ -89,10 +96,14 @@ export function getIsolatedAdminAntTheme(dark: boolean): ThemeConfig {
},
Select: {
borderRadius: 6,
+ activeOutlineColor: "transparent",
optionPadding: "7px 10px",
optionSelectedBg: surfaceMuted,
optionActiveBg: surfaceMuted,
},
+ DatePicker: {
+ activeShadow: "none",
+ },
Switch: {
colorPrimary: switchOn,
colorPrimaryHover: switchOn,
diff --git a/web/src/pages/admin/theme/admin-chrome.css b/web/src/pages/admin/theme/admin-chrome.css
index 99ece36b3..cf3dc8a9a 100644
--- a/web/src/pages/admin/theme/admin-chrome.css
+++ b/web/src/pages/admin/theme/admin-chrome.css
@@ -195,7 +195,6 @@
top: 50%;
left: calc(var(--admin-sidebar-width) - 11px);
z-index: 4;
- display: grid;
width: 22px;
height: 36px;
place-items: center;
diff --git a/web/src/pages/canvas/canvas-project-media-dialogs.tsx b/web/src/pages/canvas/canvas-project-media-dialogs.tsx
index 55fcbc311..6df9bf4dd 100644
--- a/web/src/pages/canvas/canvas-project-media-dialogs.tsx
+++ b/web/src/pages/canvas/canvas-project-media-dialogs.tsx
@@ -31,7 +31,7 @@ type CanvasProjectMediaDialogsProps = {
onAnnotationEdit: (node: CanvasNodeData, payload: CanvasImageAnnotationPayload) => void;
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
- onImageEdit: (node: CanvasNodeData, payload: CanvasImageEditPayload) => void;
+ onImageOperation: (node: CanvasNodeData, payload: CanvasImageEditPayload) => void;
onLayerDecomposition: (node: CanvasNodeData, payload: CanvasImageLayerDecompositionPayload) => void;
onDetectText: () => Promise;
onTextEdit: (node: CanvasNodeData, payload: CanvasImageTextEditPayload) => void;
@@ -61,7 +61,7 @@ export function CanvasProjectMediaDialogs({
onAnnotationEdit,
onMaskEdit,
onUpscale,
- onImageEdit,
+ onImageOperation,
onLayerDecomposition,
onDetectText,
onTextEdit,
@@ -74,7 +74,7 @@ export function CanvasProjectMediaDialogs({
{annotationEditNode?.metadata?.content ? { if (typeof payload !== "string") onAnnotationEdit(annotationEditNode, payload); }} /> : null}
{maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null}
{upscaleNode?.metadata?.content ? onUpscale(upscaleNode, params)} /> : null}
- {imageEditNode?.metadata?.content ? onImageEdit(imageEditNode, payload)} /> : null}
+ {imageEditNode?.metadata?.content ? onImageOperation(imageEditNode, payload)} /> : null}
{layerDecompositionNode?.metadata?.content ? onLayerDecomposition(layerDecompositionNode, payload)} /> : null}
{textEditNode?.metadata?.content ? onTextEdit(textEditNode, payload)} /> : null}
>
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 79b236e92..056630207 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -1,3247 +1,3226 @@
-import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
-import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { Link, useParams, useSearchParams } from "react-router";
-import { loadAssetsForUse } from "@/services/user-data-sync";
-import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
-import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
-import { uploadMediaFile } from "@/services/file-storage";
-import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
-import { getActiveUserScope } from "@/lib/user-scope";
-import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
-import { uploadImage } from "@/services/image-storage";
-import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
-import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
-import { getCachedResourceBlob } from "@/services/resource-blob-cache";
-import copyToClipboard from "copy-to-clipboard";
-import { nanoid } from "nanoid";
-import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
-import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
-import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
-import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
-import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
-import { useAssetStore } from "@/stores/use-asset-store";
-import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
-import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
-import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
-import { useUserStore } from "@/stores/use-user-store";
-import { App, Button } from "antd";
-import { ArrowLeftRight } from "lucide-react";
-import { AppModal } from "@/components/ui/product/app-modal";
-import { getNodeSpec } from "@/constant/canvas";
-import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
-import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
-import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel";
-import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
-import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray";
-import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
-import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
-import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
-import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
-import { WorkspaceState } from "@/components/layout/workspace-state";
-import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
-import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
-import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
-import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog";
-import { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-dialog";
-import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog";
-import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
-import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
-import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
-import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-dialog";
-import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
-import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
-import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
-import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal";
-import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
-import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
-import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal";
-import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
-import { Minimap } from "@/components/canvas/canvas-mini-map";
-import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
-import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
-import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
-import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
-import { getProject } from "@/services/api/projects";
-import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
-import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
-import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
-import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
-import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
-import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
-import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
-import { useFocusMode } from "@/hooks/use-focus-mode";
-import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
-import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
-import {
- applyCanvasConnectionPromptSync,
- buildCanvasAgentMentionReferences,
- canvasResourceMentionToken,
- buildCanvasNodeMentionReferenceMap,
- buildCanvasResourceReferences,
- getContextResourceNodes,
- normalizeCanvasNodeMentionTokens,
- reorderCanvasResourceConnections,
- replaceCanvasReferenceMentions,
- type CanvasResourceReference,
-} from "@/lib/canvas/canvas-resource-references";
-import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
-import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
-import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
-import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
-import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
-import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
-import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
-import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } 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 { batchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
-import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
-import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
-import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
-import { cancelGenerationTask } from "@/services/api/task-center";
-import { CanvasSyncStatus } from "./canvas-sync-status";
-import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
-import { CanvasVersionPreview } from "./canvas-version-preview";
-import { CanvasTopBar } from "./canvas-project-top-bar";
-import { LibTVImportDialog } from "./components/libtv-import-dialog";
-import { TapNowImportDialog } from "./components/tapnow-import-dialog";
-import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
-import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
-import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
-import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog";
-import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
-import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
-import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
-import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
-import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
-import { AiArtCritiqueModal } from "@/components/canvas/art-critique/ai-art-critique-modal";
-import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
-import { CanvasRefreshShell } from "./canvas-refresh-shell";
-import { queryGenerationTask } from "@/services/api/task-center";
-import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
-import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
-import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
-import { useCanvasConnectionController } from "./use-canvas-connection-controller";
-import { useCanvasOperationHistory } from "./use-canvas-operation-history";
-import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility";
-import { useCanvasActiveTasks } from "./use-canvas-active-tasks";
-import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
-import { useCanvasDirector } from "./use-canvas-director";
-import { useCanvasGeneration } from "./use-canvas-generation";
-import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
-import { handleListGenerate } from "./list-mode-generator";
-import { useCanvasBatchTable } from "./use-canvas-batch-table";
-import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
-import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
-import { useCanvasHistory } from "./use-canvas-history";
-import { useCanvasKeyboard } from "./use-canvas-keyboard";
-import { useCanvasMediaTools } from "./use-canvas-media-tools";
-import { useCanvasNodeEditor } from "./use-canvas-node-editor";
-import { useCanvasNodeOperations } from "./use-canvas-node-operations";
-import { useCanvasProjectLifecycle } from "./use-canvas-project-lifecycle";
-import { useCanvasRenderModel } from "./use-canvas-render-model";
-import { useCanvasSelectionController } from "./use-canvas-selection-controller";
-import { useCanvasShortDrama } from "./use-canvas-short-drama";
-import { useCanvasStoryboard } from "./use-canvas-storyboard";
-import { useCanvasUpload } from "./use-canvas-upload";
-import { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert";
-import { useCanvasViewportController } from "./use-canvas-viewport-controller";
-import {
- CanvasNodeType,
- type CanvasAssistantSession,
- type CanvasConnection,
- type CanvasFolderStyle,
- type CanvasFolderTheme,
- type CanvasNodeData,
- type CanvasNodeMetadata,
- type CanvasMediaPerformanceMode,
- type StoryboardColumn,
- type StoryboardShotCount,
- type StoryboardShotDuration,
- type CanvasWorkflowKind,
- type CanvasWorkspaceMode,
- type CanvasToolMode,
- type ContextMenuState,
- type Position,
- type ViewportTransform,
-} from "@/types/canvas";
-import type { ReferenceImage } from "@/types/image";
-import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
-
-const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
-const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
-
-const NODE_STATUS_SUCCESS = "success" as const;
-const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
-
-
-async function copyImageToSystemClipboard(source: string, storageKey?: string) {
- if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
- if (typeof window !== "undefined") window.focus();
-
- const fetchPNG = async (): Promise => {
- let sourceBlob: Blob | null = null;
- if (storageKey) {
- sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
- }
- if (!sourceBlob) {
- const response = await fetch(source);
- if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
- sourceBlob = await response.blob();
- }
- return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
- };
-
- // 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
- // 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
- try {
- const item = new ClipboardItem({ "image/png": fetchPNG() });
- await navigator.clipboard.write([item]);
- return;
- } catch {
- // 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
- }
-
- const blob = await fetchPNG();
- if (typeof window !== "undefined") window.focus();
- await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
-}
-
-async function convertClipboardImageToPNG(blob: Blob) {
- if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
- const bitmap = await createImageBitmap(blob);
- try {
- const canvas = document.createElement("canvas");
- canvas.width = bitmap.width;
- canvas.height = bitmap.height;
- const context = canvas.getContext("2d");
- if (!context) throw new Error("当前浏览器无法处理这张图片");
- context.drawImage(bitmap, 0, 0);
- return await new Promise((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
- } finally {
- bitmap.close();
- }
-}
-
-function visibleGenerationBatch(node: CanvasNodeData) {
- const batches = node.metadata?.generationBatches || [];
- for (let index = batches.length - 1; index >= 0; index -= 1) {
- if (batches[index].status === "queued" || batches[index].status === "running") return batches[index];
- }
- return batches.at(-1);
-}
-
-export default function CanvasPage() {
- useCanvasThemeScope();
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- }, []);
-
- if (!mounted) return ;
-
- return ;
-}
-
-function InfiniteCanvasPage() {
- // 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
- const { message, modal } = App.useApp();
- const queryClient = useQueryClient();
- const params = useParams<{ id: string }>();
- const [searchParams, setSearchParams] = useSearchParams();
- const projectId = params.id || "";
- const canvasStorageScope = getActiveUserScope();
- const containerRef = useRef(null);
- const didInitialCenterRef = useRef(false);
- const toolbarHideTimerRef = useRef | null>(null);
- const assetHandoffRef = useRef("");
-
- const config = useConfigStore((state) => state.config);
- const effectiveConfig = useEffectiveConfig();
- const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
- const assets = useAssetStore((state) => state.assets);
- const assetsHydrated = useAssetStore((state) => state.hydrated);
- const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
- const colorTheme = useCanvasThemeStore((state) => state.theme);
- const setTheme = useCanvasThemeStore((state) => state.setTheme);
- const theme = canvasThemes[colorTheme];
- const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
- const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
- const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
- const nodesRef = useRef([]);
- const [nodes, setNodesState] = useState([]);
- const setNodes = useCallback>>((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 [nodeStackOrder, setNodeStackOrder] = useState([]);
- const bringNodeToFront = useCallback((nodeId: string) => {
- setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
- }, []);
- const [connections, setConnections] = useState([]);
- const [chatSessions, setChatSessions] = useState([]);
- const [activeChatId, setActiveChatId] = useState(null);
- const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 });
- const [size, setSize] = useState({ width: 1200, height: 720 });
- const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set());
- const [selectedConnectionId, setSelectedConnectionId] = useState(null);
- const [hoveredNodeId, setHoveredNodeId] = useState(null);
- const [contextMenu, setContextMenu] = useState(null);
- const [agentPrefillPrompt, setAgentPrefillPrompt] = useState("");
- const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
- const [canvasAppearance, setCanvasAppearance] = useState(() => canvasAppearanceForTheme(colorTheme));
- const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE);
- const [showImageInfo, setShowImageInfo] = useState(false);
- const [canvasTool, setCanvasTool] = useState("box-select");
- const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode);
- const [projectLoaded, setProjectLoaded] = useState(false);
- const workspaceMode: CanvasWorkspaceMode = "professional";
- const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
- const [shareModalOpen, setShareModalOpen] = useState(false);
- const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
- const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
- const [toolbarNodeId, setToolbarNodeId] = useState(null);
- const [arkPrivateAssetUploadNodeId, setArkPrivateAssetUploadNodeId] = useState(null);
- const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
- const [dialogNodeId, setDialogNodeId] = useState(null);
- const [textEditorNodeId, setTextEditorNodeId] = useState(null);
- const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState(null);
- const [drawingNodeId, setDrawingNodeId] = useState(null);
- const [stylePickerOpen, setStylePickerOpen] = useState(false);
- // 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
- const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null);
- const [infoNodeId, setInfoNodeId] = useState(null);
- const [subtitleNodeId, setSubtitleNodeId] = useState(null);
- const [timelineNodeId, setTimelineNodeId] = useState(null);
- const [superResolveNodeId, setSuperResolveNodeId] = useState(null);
- const [previewNodeId, setPreviewNodeId] = useState(null);
- const [scriptEditorNodeId, setScriptEditorNodeId] = useState(null);
- const [artCritiqueNodeId, setArtCritiqueNodeId] = useState(null);
- const artCritiqueRunningRef = useRef(false);
- const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
- const [scriptScrollTopById, setScriptScrollTopById] = useState>({});
- const [directorNodeId, setDirectorNodeId] = useState(null);
- const [versionCompareRootId, setVersionCompareRootId] = useState(null);
- const [libTVImportOpen, setLibTVImportOpen] = useState(false);
- const [titleEditing, setTitleEditing] = useState(false);
- const [titleDraft, setTitleDraft] = useState("");
- const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
- const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
- const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
- const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
-
- const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
- const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
- const [focusDockRevealed, setFocusDockRevealed] = useState(false);
-
- useEffect(() => {
- persistCanvasMediaPerformanceMode(mediaPerformanceMode);
- }, [mediaPerformanceMode]);
-
- useEffect(() => {
- didInitialCenterRef.current = false;
- setNodeStackOrder([]);
- }, [projectId]);
-
- useEffect(() => {
- const nodeIds = new Set(nodes.map((node) => node.id));
- setNodeStackOrder((current) => {
- const next = current.filter((nodeId) => nodeIds.has(nodeId));
- return next.length === current.length ? current : next;
- });
- }, [nodes]);
-
- const connectionsRef = useRef(connections);
- const chatSessionsRef = useRef(chatSessions);
- const activeChatIdRef = useRef(activeChatId);
- const selectedNodeIdsRef = useRef(selectedNodeIds);
- const viewportRef = useRef(viewport);
- const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise) | null>(null);
-
- useEffect(() => {
- if (!projectId) return;
- return registerCanvasGenerationLiveProject({
- scope: canvasStorageScope,
- projectId,
- adapter: createCanvasGenerationLiveProjectAdapter({ nodesRef, connectionsRef, chatSessionsRef, activeChatIdRef, setNodes, setConnections, setChatSessions, setActiveChatId }),
- });
- }, [canvasStorageScope, projectId]);
-
- const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]);
- const applyCanvasAppearance = useCallback(
- (next: CanvasAppearance) => {
- const fallback = canvasAppearanceBaseTheme(next, colorTheme);
- const normalized = normalizeCanvasAppearance(next, fallback);
- setCanvasAppearance(normalized);
- setTheme(canvasAppearanceBaseTheme(normalized, fallback));
- },
- [colorTheme, setTheme],
- );
- const saveCanvasAppearanceDefault = useCallback(
- (next: CanvasAppearance) => {
- writeCanvasAppearanceDefault({ appearance: next, backgroundMode });
- message.success("已保存为当前账号在本机的新建画布默认外观");
- },
- [backgroundMode, message],
- );
-
- const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
- projectLoaded,
- nodes,
- connections,
- chatSessions,
- activeChatId,
- canvasAppearance,
- backgroundMode,
- showImageInfo,
- setNodes,
- setConnections,
- setChatSessions,
- setActiveChatId,
- applyCanvasAppearance,
- setBackgroundMode,
- setShowImageInfo,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- });
-
- const cleanupCanvasFiles = useCallback(
- (extra?: unknown) => {
- cleanupAssetImages({ extra, ...getHistoryCleanupContext() });
- },
- [cleanupAssetImages, getHistoryCleanupContext],
- );
-
- const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
- projectId,
- projectLoaded,
- nodes,
- connections,
- chatSessions,
- activeChatId,
- canvasAppearance,
- backgroundMode,
- showImageInfo,
- viewport,
- nodesRef,
- connectionsRef,
- chatSessionsRef,
- activeChatIdRef,
- viewportRef,
- historyPausedRef,
- setNodes,
- setConnections,
- setChatSessions,
- setActiveChatId,
- setCanvasAppearance,
- setBackgroundMode,
- setShowImageInfo,
- setViewport,
- setProjectLoaded,
- resetHistory,
- cleanupAssetImages,
- cleanupCanvasFiles,
- });
-
- const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
- const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
- const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
-
- const sendSelectionToAgent = useCallback((nodeId?: string) => {
- const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
- const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
- if (!references.length) return;
- setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
- openAgent();
- setContextMenu(null);
- }, [agentMentionReferences, openAgent]);
- // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
- const confirmForceSaveCanvas = useCallback(() => {
- modal.confirm({
- title: "修复素材关联并保存?",
- content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
- okText: "修复并保存",
- cancelText: "取消",
- onOk: () => forceSaveCanvasProject(),
- });
- }, [forceSaveCanvasProject, modal]);
-
- const applyLibTVImport = useCallback(
- async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
- const previousNodes = nodesRef.current;
- const previousConnections = connectionsRef.current;
- const nextNodes = [...nodesRef.current, ...importedNodes];
- const nextConnections = [...connectionsRef.current, ...importedConnections];
- nodesRef.current = nextNodes;
- connectionsRef.current = nextConnections;
- setNodes(nextNodes);
- setConnections(nextConnections);
- const saved = await saveCanvasProject({ requireRemote: false });
- if (!saved) {
- nodesRef.current = previousNodes;
- connectionsRef.current = previousConnections;
- setNodes(previousNodes);
- setConnections(previousConnections);
- throw new Error("画布保存失败,已撤销本次 LibTV 导入");
- }
- },
- [saveCanvasProject, setConnections, setNodes],
- );
- const applyTapNowImport = useCallback(
- async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
- const previousNodes = nodesRef.current;
- const previousConnections = connectionsRef.current;
- const nextNodes = [...nodesRef.current, ...importedNodes];
- const nextConnections = [...connectionsRef.current, ...importedConnections];
- nodesRef.current = nextNodes;
- connectionsRef.current = nextConnections;
- setNodes(nextNodes);
- setConnections(nextConnections);
- const saved = await saveCanvasProject({ requireRemote: false });
- if (!saved) {
- nodesRef.current = previousNodes;
- connectionsRef.current = previousConnections;
- setNodes(previousNodes);
- setConnections(previousConnections);
- throw new Error("画布保存失败,已撤销本次 TapNow 导入");
- }
- },
- [saveCanvasProject, setConnections, setNodes],
- );
- const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
- const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
- const refetchLinkedProject = linkedProjectQuery.refetch;
- const archiveNodesToLinkedFolder = useCallback(
- (folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => {
- const folderId = folder.metadata?.folder?.assetFolderId;
- const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId;
- if (!folderId || !domainProjectId || !droppedNodes.length) return;
- void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" })))
- .then((results) => {
- const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }]));
- setNodes((current) =>
- current.map((node) => {
- const archived = archivedByNodeId.get(node.id);
- if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node;
- return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } };
- }),
- );
- void refetchLinkedProject();
- message.success(`已归档到“${folder.title}”`);
- })
- .catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败"));
- },
- [linkedProjectId, message, projectId, refetchLinkedProject, setNodes],
- );
- useEffect(() => {
- if (!projectLoaded || !linkedProjectQuery.data) return;
- setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
- }, [linkedProjectQuery.data, projectLoaded, setNodes]);
- const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
- // 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发;
- // 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo——
- // 每帧新对象会让所有节点跟着重渲染,错题本里多条崩溃都出在画布高频更新。
- const nodeGraphContext = useMemo(() => ({ getUpstreamNodes: (nodeId: string) => getContextResourceNodes(nodeId, nodes, connections) }), [connections, nodes]);
-
- const { applyGenerationTaskResult, bindGenerationTask, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs } = useCanvasGeneration({
- projectId,
- domainProjectId: linkedProjectId,
- projectLoaded,
- nodes,
- nodesRef,
- setNodes,
- });
-
- const cancelCanvasTask = useCallback(
- (task: import("@/services/api/task-center").GenerationTask) => {
- modal.confirm({
- title: "取消生成任务?",
- content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
- okText: "取消任务",
- okButtonProps: { danger: true },
- cancelText: "继续等待",
- onOk: async () => {
- try {
- const next = await cancelGenerationTask(task.id);
- const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
- if (node) bindGenerationTask(node.id, next);
- setTaskDetail((current) => (current?.id === task.id ? next : current));
- await queryClient.invalidateQueries({ queryKey: ["canvas-active-tasks", projectId] });
- message.success("任务已取消");
- } catch (error) {
- message.error(error instanceof Error ? error.message : "取消任务失败");
- }
- },
- });
- },
- [bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
- );
-
- useEffect(() => {
- const sessionId = searchParams.get("conversation");
- if (!projectLoaded || !sessionId) return;
- if (!chatSessions.some((session) => session.id === sessionId)) {
- message.warning("未找到要接续的会话,请从首页重新进入。");
- } else {
- activeChatIdRef.current = sessionId;
- setActiveChatId(sessionId);
- openAgent();
- }
- const next = new URLSearchParams(searchParams);
- next.delete("conversation");
- setSearchParams(next, { replace: true });
- }, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]);
-
- useEffect(() => {
- if (!projectLoaded || searchParams.get("agent") !== "1") return;
- openAgent();
- const next = new URLSearchParams(searchParams);
- next.delete("agent");
- setSearchParams(next, { replace: true });
- }, [projectLoaded, searchParams, setSearchParams, openAgent]);
-
- // 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
- const prevFocusModeRef = useRef(focusMode);
- useEffect(() => {
- const enteredFocus = focusMode && !prevFocusModeRef.current;
- prevFocusModeRef.current = focusMode;
- if (!enteredFocus) return;
- closeAgent();
- setIsMiniMapOpen(false);
- setFocusDockRevealed(false);
- }, [closeAgent, focusMode]);
-
- useEffect(() => {
- if (!dialogNodeId) setNodeImageSettingsOpen(false);
- }, [dialogNodeId]);
-
- useLayoutEffect(() => {
- nodesRef.current = nodes;
- connectionsRef.current = connections;
- chatSessionsRef.current = chatSessions;
- activeChatIdRef.current = activeChatId;
- selectedNodeIdsRef.current = selectedNodeIds;
- viewportRef.current = viewport;
- }, [activeChatId, chatSessions, nodes, connections, selectedNodeIds, viewport]);
-
- useEffect(() => {
- if (!projectLoaded) return;
- const el = containerRef.current;
- if (!el) return;
-
- const updateSize = () => {
- const rect = el.getBoundingClientRect();
- setSize((current) => (current.width === rect.width && current.height === rect.height ? current : { width: rect.width, height: rect.height }));
- if (!didInitialCenterRef.current) {
- didInitialCenterRef.current = true;
- const current = viewportRef.current;
- if (current.x === 0 && current.y === 0 && current.k === 1) {
- const centered = { x: rect.width / 2, y: rect.height / 2, k: 1 };
- viewportRef.current = centered;
- setViewport(centered);
- }
- }
- };
-
- updateSize();
- const resizeObserver = new ResizeObserver(updateSize);
- resizeObserver.observe(el);
- return () => resizeObserver.disconnect();
- }, [projectLoaded]);
-
- const {
- fitCanvasContent,
- fitCanvasSelection,
- focusCanvasImageNode,
- focusCanvasNode,
- getCanvasCenter,
- handleCanvasDoubleClick,
- handleViewportChange,
- handleViewportPreviewChange,
- previewViewport,
- screenToCanvas,
- setZoomScale,
- zoomCanvasIn,
- zoomCanvasOut,
- zoomToActualSize,
- } = useCanvasViewportController({
- agentCreatedNodes,
- containerRef,
- size,
- viewportRef,
- nodesRef,
- selectedNodeIdsRef,
- setViewport,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- setDialogNodeId,
- setToolbarNodeId,
- });
-
- useEffect(() => {
- const project = linkedProjectQuery.data?.project;
- const preset = resolveProjectCanvasStyle(project?.stylePresetId, project?.styleProfileJson);
- if (!projectLoaded || !preset) return;
- const profile = resolveStyleProfile(project?.stylePresetId, project?.styleProfileJson, preset.profile || createStyleProfileSnapshot(preset));
- if (!profile) return;
- const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
- const nextMetadata = {
- content: profile.prompt,
- prompt: profile.prompt,
- status: NODE_STATUS_SUCCESS,
- workflowKind: "styleboard" as const,
- workflowTitle: "项目画风",
- workflowDescription: profile.description,
- stylePresetId: profile.presetId,
- styleProfileJson: serializeStyleProfile(profile),
- fontSize: 14,
- locked: true,
- };
- if (current) {
- if (current.metadata?.stylePresetId === profile.presetId && current.metadata?.content === profile.prompt && current.metadata?.styleProfileJson === nextMetadata.styleProfileJson && current.metadata?.locked) return;
- setNodes((nodes) => nodes.map((node) => (node.id === current.id ? { ...node, title: `项目画风 · ${profile.title}`, metadata: { ...node.metadata, ...nextMetadata } } : node)));
- return;
- }
- const node = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), nextMetadata);
- node.title = `项目画风 · ${profile.title}`;
- node.width = 420;
- node.height = 240;
- setNodes((nodes) => [...nodes, node]);
- }, [getCanvasCenter, linkedProjectQuery.data?.project, projectLoaded, setNodes]);
-
- const {
- assetPickerOpen,
- closeUploadModal,
- closeAssetPicker,
- createVideoNodeFromBlob,
- createImageAssetNode,
- fileDropActive,
- handleAssetsInsert,
- handleDrop,
- handleFileDragEnter,
- handleFileDragLeave,
- handleFileDragOver,
- handleImageInputChange,
- handleProjectAssetsInsert,
- handleProjectChapterInsert,
- handleUploadFiles,
- handleUploadRequest,
- imageInputRef,
- openAssetsAtPosition,
- pasteAssistantImage,
- pasteSystemClipboard,
- replaceNodeMedia,
- createFileNode,
- startUploadStatus,
- uploadModalOpen,
- uploadTimelineMedia,
- uploadStatus,
- } = useCanvasUpload({
- canvasId: projectId,
- domainProjectId: linkedProjectId,
- nodesRef,
- selectedNodeIdsRef,
- getCanvasCenter,
- screenToCanvas,
- setNodes,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- setDialogNodeId,
- });
- const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
- const {
- timelineAddNodeRef,
- timelineMediaAddRef,
- assetInsertScope,
- projectAssetScope,
- projectAssetOpen,
- projectAssetInitialCategory,
- projectAssetInitialFolderId,
- projectAssetInsertPosition,
- handleLibraryAssetsInsert,
- handleTimelineProjectAssetsInsert,
- openProjectAssets,
- openCanvasAssetLibrary,
- openTimelineAssetLibrary,
- closeProjectAssets,
- } = useCanvasTimelineAssetInsert({
- linkedProjectId,
- refetchLinkedProject,
- handleAssetsInsert,
- handleProjectAssetsInsert,
- openAssetsAtPosition,
- });
-
- useEffect(() => {
- if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
- void loadAssetsForUse(canvasAssetHandoffIds(searchParams)).catch((error) => message.error(error instanceof Error ? error.message : "转入素材读取失败"));
- }, [projectLoaded, searchParams, message]);
-
- useEffect(() => {
- if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
- const attempt = canvasAssetHandoffAttempt(assets, searchParams);
- const { assetIds, payloads } = attempt;
- if (!assetIds.length) return;
- const assetReadiness = assetIds
- .map((assetId) => {
- const asset = assets.find((candidate) => candidate.id === assetId);
- return `${assetId}:${asset?.kind || "missing"}`;
- })
- .join("|");
- const handoffKey = `${projectId}:${assetReadiness}`;
- if (assetHandoffRef.current === handoffKey) return;
- assetHandoffRef.current = handoffKey;
-
- if (attempt.kind === "retry") return;
- const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads);
- const persistHandoff = async (createdNodes: CanvasNodeData[]) => {
- const finalized = await finalizeCanvasAssetHandoff({
- searchParams,
- currentNodes: nodesRef.current,
- createdNodes,
- persist: async (nextNodes) => {
- nodesRef.current = nextNodes;
- updateProject(projectId, { nodes: nextNodes });
- await flushCanvasStorePersistence();
- },
- });
- setSearchParams(finalized.searchParams, { replace: true });
- };
- const insertion = pendingPayloads.length ? handleProjectAssetsInsert(pendingPayloads) : Promise.resolve([] as CanvasNodeData[]);
- void insertion.then(persistHandoff).catch(() => {
- assetHandoffRef.current = "";
- });
- }, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]);
-
- const {
- angleNodeId,
- lightingNodeId,
- emotionNodeId,
- annotationNodeId,
- annotationEditNodeId,
- createImageReversePromptNodes,
- openPortraitTextureEditor,
- cropImageNode,
- cropNodeId,
- closeFrameDialog,
- closeSegmentDialog,
- extractAudioFromVideo,
- extractVideoFrames,
- extractingVideoFramesNodeId,
- frameDialogNodeId,
- generateAngleNode,
- generateLightingNode,
- openPanoramaConfig,
- createPanoramaViewerWithConfig,
- addPanoramaCaptureNode,
- panoramaConfigNodeId,
- setPanoramaConfigNodeId,
- generateEmotionNode,
- handleSegmentConfirm,
- maskEditImageNode,
- maskEditNodeId,
- imageEditNodeId,
- imageEditPreset,
- layerDecompositionNodeId,
- textEditNodeId,
- mergeSelectedVideos,
- mergeVideosByIds,
- mergeVideoProgress,
- saveAnnotatedImageNode,
- segmentDialogMode,
- segmentDialogNodeId,
- segmentRunningMode,
- setFrameDialogNodeId,
- setSegmentDialogNodeId,
- setAngleNodeId,
- setLightingNodeId,
- setEmotionNodeId,
- setAnnotationNodeId,
- setAnnotationEditNodeId,
- setCropNodeId,
- setMaskEditNodeId,
- setImageEditNodeId,
- setImageEditPreset,
- openImageEditNode,
- openBackgroundRemoval,
- openLayerDecomposition,
- decomposeImageLayers,
- setLayerDecompositionNodeId,
- setTextEditNodeId,
- openTextEditNode,
- openAnnotationEditNode,
- detectImageText,
- editTextImageNode,
- editAnnotatedImageNode,
- editImageNode,
- setUpscaleNodeId,
- splitImageNode,
- openVideoFrameExtractor,
- openVideoSegmentExtractor,
- upscaleImageNode,
- upscaleNodeId,
- } = useCanvasMediaTools({
- projectId,
- domainProjectId: linkedProjectId,
- nodesRef,
- connectionsRef,
- selectedNodeIdsRef,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setDialogNodeId,
- setContextMenu,
- setHoveredNodeId,
- setToolbarNodeId,
- setRunningNodeId,
- startUploadStatus,
- startGenerationRequest,
- finishGenerationRequest,
- bindGenerationTask,
- });
-
- const handleNodesDeleted = useCallback(
- (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => {
- const clearDeletedId = (current: string | null) => (current && removedIds.has(current) ? null : current);
- setHoveredNodeId(clearDeletedId);
- setToolbarNodeId(clearDeletedId);
- setDialogNodeId(clearDeletedId);
- setTextEditorNodeId(clearDeletedId);
- setCharacterReferenceNodeId(clearDeletedId);
- setDrawingNodeId(clearDeletedId);
- setInfoNodeId(clearDeletedId);
- setSubtitleNodeId(clearDeletedId);
- setFrameDialogNodeId(clearDeletedId);
- setSegmentDialogNodeId(clearDeletedId);
- setCropNodeId(clearDeletedId);
- setMaskEditNodeId(clearDeletedId);
- setAnnotationNodeId(clearDeletedId);
- setUpscaleNodeId(clearDeletedId);
- setAngleNodeId(clearDeletedId);
- setLightingNodeId(clearDeletedId);
- setEmotionNodeId(clearDeletedId);
- setSuperResolveNodeId(clearDeletedId);
- setPreviewNodeId(clearDeletedId);
- setRunningNodeId(clearDeletedId);
- setScriptEditorNodeId(clearDeletedId);
- setArtCritiqueNodeId(clearDeletedId);
- setDirectorNodeId(clearDeletedId);
- setVersionCompareRootId(clearDeletedId);
- setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
- setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current));
- const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
- if (removedDrawingIds.length) {
- void Promise.all(removedDrawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("绘图节点已删除,但本地绘图缓存清理失败"));
- }
- cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
- },
- [
- chatSessions,
- cleanupCanvasFiles,
- message,
- projectId,
- setAngleNodeId,
- setAnnotationNodeId,
- setArtCritiqueNodeId,
- setCropNodeId,
- setEmotionNodeId,
- setFrameDialogNodeId,
- setLightingNodeId,
- setMaskEditNodeId,
- setSegmentDialogNodeId,
- setUpscaleNodeId,
- setRunningNodeId,
- ],
- );
-
- const {
- alignSelectedNodes,
- autoArrangeCanvasNodes,
- arrangeSelectedNodes,
- spreadSelectedNodes,
- copyNodesToClipboard,
- copySelectedNodes,
- createFolder,
- createNode,
- createReferenceGroup,
- createStoryboardGroup,
- deleteConnection,
- deleteNodes,
- duplicateNode,
- hasCopiedNodes,
- pasteCopiedNodes,
- restoreCopiedNodesFromText,
- releaseCopiedNodesPastePriority,
- setPrimaryVersion,
- shouldPreferCopiedNodes,
- toggleNodeLocked,
- } = useCanvasNodeOperations({
- projectId,
- defaultDrawingEngine,
- nodesRef,
- connectionsRef,
- selectedNodeIdsRef,
- getCanvasCenter,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- setDialogNodeId,
- onNodesDeleted: handleNodesDeleted,
- });
-
- const handleReplaceNodeReference = useCallback(
- (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => {
- const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId);
- if (!sourceNode || sourceNode.id === oldReference.nodeId) return;
-
- const previousNodes = nodesRef.current;
- const previousConnections = connectionsRef.current;
-
- const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId;
- const receiverId = configNodeId || targetNodeId;
-
- let rewired = false;
- const nextConnections = previousConnections.map((c) => {
- if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) {
- rewired = true;
- return { ...c, fromNodeId: sourceNodeId };
- }
- return c;
- });
-
- if (!rewired) {
- nextConnections.push({
- id: nanoid(),
- fromNodeId: sourceNodeId,
- toNodeId: receiverId,
- });
- }
-
- const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes);
- const targetNextReferences = nextReferencesMap.get(targetNodeId) || [];
- const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId);
-
- const targetNode = previousNodes.find((n) => n.id === targetNodeId);
- let nextNodes = previousNodes;
- if (targetNode && newRef) {
- const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? "";
- const replacementToken = `@${newRef.label}`;
- const updatedPrompt = replaceCanvasReferenceMentions(currentPrompt, oldReference, replacementToken, sourceNode.title ? sourceNode.title : newRef.label);
-
- nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n));
- }
-
- nodesRef.current = nextNodes;
- connectionsRef.current = nextConnections;
- setNodes(nextNodes);
- setConnections(nextConnections);
- message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`);
- },
- [connectionsRef, message, nodesRef, setConnections, setNodes],
- );
-
- const handleReplaceNodeReferenceFiles = useCallback(
- (_targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, files: File[]) => {
- const file = files.find((f) => f.type.startsWith("image/"));
- if (!file || !oldReference.nodeId) return;
- void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => {
- if (success) {
- message.success("参考图片已替换");
- }
- });
- },
- [message, replaceNodeMedia],
- );
-
- const {
- cancelPendingConnectionCreate,
- closeConnectionCreateMenu,
- connectionTargetAnchorRatio,
- connectionTargetNodeId,
- connectionApproach,
- connectionReplaceHover,
- connectingParams,
- createConnectedNode,
- getConnectionCreateDisabledReason,
- handleConnectStart,
- handleConnectDrop,
- handleBatchConnectionTargetClick,
- batchConnectionPreview,
- beginBatchConnectionMode,
- startBatchConnection,
- mouseWorld,
- pendingConnectionCreate,
- setConnecting,
- } = useCanvasConnectionController({
- projectId,
- config: effectiveConfig,
- defaultDrawingEngine,
- nodesRef,
- connectionsRef,
- viewportRef,
- scriptScrollTopById,
- screenToCanvas,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- setDialogNodeId,
- setDrawingNodeId,
- onReplaceReference: handleReplaceNodeReference,
- });
-
- const batchSourceNodeIds = useMemo(() => nodes.filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)).map((node) => node.id), [nodes, selectedNodeIds]);
-
- const handleCanvasSelectionStart = useCallback(() => {
- setContextMenu(null);
- }, []);
-
- const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => {
- setContextMenu(null);
- setHoveredNodeId(null);
- setToolbarNodeId(null);
- if (selectionModifier) setDialogNodeId(null);
- }, []);
-
- const handleSelectedNodeClick = useCallback(
- (node: CanvasNodeData) => {
- // Selection is transient, but the LibTV-style paint order survives
- // deselection so a clicked lower node stays above its neighbours.
- if (node.type !== CanvasNodeType.Frame) bringNodeToFront(node.id);
- if (node.type === CanvasNodeType.Drawing) {
- setDialogNodeId(null);
- setDrawingNodeId(node.id);
- } else if (node.type === CanvasNodeType.Script) {
- setDialogNodeId(null);
- } else if (node.type === CanvasNodeType.Text) {
- setDialogNodeId(node.id);
- } else if (node.type === CanvasNodeType.Frame) {
- setDialogNodeId((current) => (current === node.id ? current : null));
- } else if (node.type === ART_CRITIQUE_NODE_TYPE) {
- setDialogNodeId(null);
- setArtCritiqueNodeId(node.id);
- } else if (node.type === CanvasNodeType.Panorama) {
- // 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
- setDialogNodeId(null);
- } else {
- // 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
- // 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
- setDialogNodeId((current) => {
- const currentNode = current ? nodesRef.current.find((item) => item.id === current) : undefined;
- return currentNode?.type === CanvasNodeType.Config ? current : node.id;
- });
- }
- },
- [bringNodeToFront, nodesRef],
- );
-
- const handleNodeBringToFront = useCallback(
- (nodeId: string) => {
- const node = nodesRef.current.find((item) => item.id === nodeId);
- if (node && node.type !== CanvasNodeType.Frame) bringNodeToFront(nodeId);
- },
- [bringNodeToFront, nodesRef],
- );
-
- const handleNodeDragEnd = useCallback(
- (nodeId: string) => {
- const node = nodesRef.current.find((item) => item.id === nodeId);
- if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE) {
- setDialogNodeId(null);
- return;
- }
- // A drag selects a new node even though it is not a click. Keep the
- // generation editor bound to the node most recently moved so a stale
- // panel from the previous node cannot reappear after mouse-up.
- setDialogNodeId(node.id);
- },
- [nodesRef],
- );
-
- const handleCanvasDeselect = useCallback(() => {
- setContextMenu(null);
- setHoveredNodeId(null);
- setToolbarNodeId(null);
- setDialogNodeId(null);
- }, []);
-
- const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({
- containerRef,
- nodesRef,
- viewportRef,
- selectedNodeIdsRef,
- historyPausedRef,
- screenToCanvas,
- setNodes,
- setSelectedNodeIds,
- setSelectedConnectionId,
- cancelPendingConnectionCreate,
- onCanvasSelectionStart: handleCanvasSelectionStart,
- onNodeInteractionStart: handleNodeInteractionStart,
- onNodeBringToFront: handleNodeBringToFront,
- onNodeClick: handleSelectedNodeClick,
- onNodeDragEnd: handleNodeDragEnd,
- onBatchConnectionTarget: handleBatchConnectionTargetClick,
- onLinkedFolderDrop: archiveNodesToLinkedFolder,
- onDeselect: handleCanvasDeselect,
- });
-
- const keepNodeToolbar = useCallback(
- (nodeId: string) => {
- if (nodeDraggingRef.current || nodeImageSettingsOpen) return;
- if (toolbarHideTimerRef.current) {
- clearTimeout(toolbarHideTimerRef.current);
- toolbarHideTimerRef.current = null;
- }
- setToolbarNodeId(nodeId);
- },
- [nodeImageSettingsOpen],
- );
-
- const hideNodeToolbar = useCallback(() => {
- if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current);
- toolbarHideTimerRef.current = setTimeout(() => {
- setToolbarNodeId(null);
- toolbarHideTimerRef.current = null;
- }, 120);
- }, []);
-
- const {
- collapsingBatchIds,
- downloadNodeImage,
- handleConfigNodeChange,
- handleFolderStyleChange,
- handleFolderThemeChange,
- handleFontSizeChange,
- handleNodeContentChange,
- handleNodePromptChange,
- handleNodeResize,
- handleNodeTitleChange,
- openingBatchIds,
- saveNodeAsset,
- setBatchPrimary,
- toggleBatchExpanded,
- toggleFrameCollapsed,
- toggleNodeFreeResize,
- } = useCanvasNodeEditor({
- canvasId: projectId,
- canvasTitle: currentProject?.title || "未命名画布",
- domainProjectId: linkedProjectId,
- nodesRef,
- setNodes,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setDialogNodeId,
- setToolbarNodeId,
- setHoveredNodeId,
- });
-
- const handleRemoveNodeReference = useCallback(
- (targetNodeId: string, reference: CanvasResourceReference) => {
- const referenceNodeId = reference.nodeId;
- if (!referenceNodeId) return;
- // 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。
- const previousNodes = nodesRef.current;
- const previousConnections = connectionsRef.current;
- const configNodeId = previousConnections.find((connection) => {
- if (connection.fromNodeId !== targetNodeId) return false;
- return previousNodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
- })?.toNodeId;
- const removedConnectionIds = new Set(previousConnections.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId)).map((connection) => connection.id));
- if (!removedConnectionIds.size) return;
- const nextConnections = previousConnections.filter((connection) => !removedConnectionIds.has(connection.id));
- const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
- if (nextNodes !== previousNodes) {
- nodesRef.current = nextNodes;
- setNodes(nextNodes);
- }
- connectionsRef.current = nextConnections;
- setConnections(nextConnections);
- setSelectedConnectionId((current) => (current && removedConnectionIds.has(current) ? null : current));
- },
- [connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId],
- );
-
- const handleReorderNodeReferences = useCallback(
- (targetNodeId: string, orderedNodeIds: string[]) => {
- const previousNodes = nodesRef.current;
- const previousConnections = connectionsRef.current;
- const nextConnections = reorderCanvasResourceConnections(targetNodeId, orderedNodeIds, previousNodes, previousConnections);
- if (nextConnections === previousConnections) return;
- const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
- if (nextNodes !== previousNodes) {
- nodesRef.current = nextNodes;
- setNodes(nextNodes);
- }
- connectionsRef.current = nextConnections;
- setConnections(nextConnections);
- },
- [connectionsRef, nodesRef, setConnections, setNodes],
- );
-
- const handleProjectFolderInsert = useCallback(
- (folderId: string) => {
- const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
- if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
- const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
- const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
- createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt });
- },
- [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition],
- );
-
- const handleFrameToggle = useCallback(
- (nodeId: string) => {
- const node = nodesRef.current.find((item) => item.id === nodeId);
- const linkedFolderId = node?.metadata?.folder?.assetFolderId;
- if (linkedFolderId) {
- openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId);
- return;
- }
- toggleFrameCollapsed(nodeId);
- },
- [nodesRef, openProjectAssets, toggleFrameCollapsed],
- );
-
- const linkedFolderPreviewNodesById = useMemo(() => {
- const result = new Map();
- const localById = new Map(assets.map((asset) => [asset.id, asset]));
- for (const asset of linkedProjectQuery.data?.assets || []) {
- if (!asset.folderId) continue;
- const local = localById.get(asset.id);
- const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
- const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
- const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
- const content = characterCover
- ? resourceFileUrl(characterCover.resourceId)
- : local?.kind === "image"
- ? local.data.dataUrl || local.coverUrl
- : local?.kind === "video" || local?.kind === "audio"
- ? local.data.url
- : local?.kind === "text"
- ? local.data.content
- : remoteResourceId
- ? resourceFileUrl(remoteResourceId)
- : asset.previewText || "";
- const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
- const current = result.get(asset.folderId) || [];
- current.push(preview);
- result.set(asset.folderId, current);
- }
- return result;
- }, [assets, linkedProjectQuery.data?.assets]);
-
- useEffect(() => {
- const folders = linkedProjectQuery.data?.assetFolders;
- if (!folders?.length) return;
- const byId = new Map(folders.map((folder) => [folder.id, folder]));
- setNodes((current) => {
- let changed = false;
- const next = current.map((node) => {
- const folderId = node.metadata?.folder?.assetFolderId;
- const folder = folderId ? byId.get(folderId) : undefined;
- if (!folder) return node;
- const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
- const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
- if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node;
- changed = true;
- return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } };
- });
- return changed ? next : current;
- });
- }, [linkedProjectQuery.data?.assetFolders, setNodes]);
-
- const {
- activeDirectorScene,
- activeNodeId,
- activeScriptNode,
- activeStylePresetId,
- angleNode,
- lightingNode,
- emotionNode,
- annotationNode,
- batchChildCountById,
- batchMotionById,
- canvasImageNodes,
- configInputsById,
- connectionLayerBounds,
- contextMenuNode,
- cropNode,
- displayConnections,
- frameChildrenById,
- imageAssets,
- infoNode,
- maskEditNode,
- imageEditNode,
- mentionReferencesByNodeId,
- nodeById,
- previewNode,
- reduceMediaEffects,
- relatedHighlight,
- resourceReferenceByNodeId,
- selectedNodeBounds,
- selectedVideoNodes,
- skillMentionReferences,
- superResolveNode,
- toolbarNode,
- upscaleNode,
- versionCompareNodes,
- visibleNodes,
- } = useCanvasRenderModel({
- nodes,
- connections,
- assets,
- viewport,
- viewportSize: size,
- mediaPerformanceMode,
- selectedNodeIds,
- hoveredNodeId,
- dragPreview,
- collapsingBatchIds,
- addedSkills,
- directorScenes: currentProject?.directorScenes,
- infoNodeId,
- cropNodeId,
- maskEditNodeId,
- imageEditNodeId,
- annotationNodeId,
- splitNodeId: null,
- upscaleNodeId,
- superResolveNodeId,
- angleNodeId,
- lightingNodeId,
- emotionNodeId,
- previewNodeId,
- contextMenu,
- versionCompareRootId,
- directorNodeId,
- scriptEditorNodeId,
- dialogNodeId,
- });
- useEffect(() => {
- setNodes((current) => {
- let changed = false;
- const next = current.map((node) => {
- const references = mentionReferencesByNodeId.get(node.id);
- const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt;
- if (!references?.length || !savedPrompt?.includes("@[node:")) return node;
- const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references);
- if (normalizedPrompt === savedPrompt) return node;
- changed = true;
- return {
- ...node,
- metadata: node.metadata?.composerContent !== undefined ? { ...node.metadata, composerContent: normalizedPrompt } : { ...node.metadata, prompt: normalizedPrompt },
- };
- });
- return changed ? next : current;
- });
- }, [mentionReferencesByNodeId, setNodes]);
- const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
- // dragPreview is published on the same pointer-down frame as isNodeDragging.
- // Treat either signal as moving so floating editors disappear before the
- // first preview transform is painted and never affect drag layout.
- const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size);
- const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
- const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
- const frameNode = frameDialogNodeId ? nodeById.get(frameDialogNodeId) || null : null;
- const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null;
- const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
- const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
- const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
- const artCritiqueNode = artCritiqueNodeId ? nodeById.get(artCritiqueNodeId) || null : null;
- const artCritiqueInputs = artCritiqueNode
- ? connections
- .filter((connection) => connection.toNodeId === artCritiqueNode.id)
- .sort((left, right) => left.id.localeCompare(right.id))
- .map((connection) => nodeById.get(connection.fromNodeId))
- .filter((node): node is CanvasNodeData => Boolean(node))
- : [];
- const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
- const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
-
- const openTextNodeEditor = useCallback((node: CanvasNodeData) => {
- if (node.type !== CanvasNodeType.Text) return;
- setSelectedNodeIds(new Set([node.id]));
- setSelectedConnectionId(null);
- setContextMenu(null);
- setDialogNodeId(null);
- setToolbarNodeId(null);
- if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) {
- setCharacterReferenceNodeId(node.id);
- return;
- }
- setTextEditorNodeId(node.id);
- }, []);
-
- const openDrawingNode = useCallback((node: CanvasNodeData) => {
- if (node.type !== CanvasNodeType.Drawing) return;
- setSelectedNodeIds(new Set([node.id]));
- setSelectedConnectionId(null);
- setContextMenu(null);
- setDialogNodeId(null);
- setToolbarNodeId(null);
- setDrawingNodeId(node.id);
- }, []);
-
- const openArtCritique = useCallback((node: CanvasNodeData) => {
- if (node.type !== ART_CRITIQUE_NODE_TYPE) return;
- setSelectedNodeIds(new Set([node.id]));
- setSelectedConnectionId(null);
- setContextMenu(null);
- setDialogNodeId(null);
- setToolbarNodeId(null);
- setArtCritiqueNodeId(node.id);
- }, []);
- const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
- const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
- const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
- setNodesState((current) => {
- const next = updateCanvasNode(current, nodeId, update);
- nodesRef.current = next;
- return next;
- });
- }, []);
- const pendingMediaUpdatesRef = useRef(new Map CanvasNodeData>());
- const mediaUpdateTimerRef = useRef | null>(null);
- const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
- const previous = pendingMediaUpdatesRef.current.get(nodeId);
- pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
- if (mediaUpdateTimerRef.current) return;
- mediaUpdateTimerRef.current = setTimeout(() => {
- const updates = pendingMediaUpdatesRef.current;
- pendingMediaUpdatesRef.current = new Map();
- mediaUpdateTimerRef.current = null;
- if (!updates.size) return;
- setNodesState((current) => {
- const next = updateCanvasNodes(current, updates);
- nodesRef.current = next;
- return next;
- });
- }, 120);
- }, []);
- const updateNodeMetadataFromContent = useCallback(
- (nodeId: string, patch: CanvasNodeMetadata) => {
- updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
- },
- [updateNodeFromContent],
- );
- const canvasNodeActions = useMemo(
- () => ({
- upload: replaceCanvasNodeMedia,
- download: downloadNodeImage,
- duplicate: duplicateNodeFromContent,
- deleteNode: deleteNodeFromContent,
- updateMetadata: updateNodeMetadataFromContent,
- updateNode: updateNodeFromContent,
- updateMediaNode: updateMediaNodeFromContent,
- openArtCritique,
- addPanoramaCaptureNode,
- }),
- [addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
- );
- const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
- projectId,
- domainProjectId: currentProject?.projectId,
- projectTitle: currentProject?.title || "未命名画布",
- nodes,
- connections,
- selectedNodeIds,
- viewport,
- nodesRef,
- connectionsRef,
- selectedNodeIdsRef,
- viewportRef,
- generateNodeRef,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setViewport,
- setContextMenu,
- focusSelection: fitCanvasSelection,
- });
-
- const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({
- canvasId: projectId,
- domainProjectId: currentProject?.projectId,
- nodesRef,
- selectedNodeIdsRef,
- getCanvasCenter,
- setNodes,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setDialogNodeId,
- setStylePickerOpen,
- });
-
- const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({
- projectId,
- domainProjectId: currentProject?.projectId,
- directorNodeId,
- directorScenes: currentProject?.directorScenes || [],
- nodesRef,
- connectionsRef,
- getCanvasCenter,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setDirectorNodeId,
- updateProject,
- });
-
- const {
- activateStep: activateShortDramaStep,
- createPipeline: createShortDramaPipeline,
- guideCollapsed: shortDramaGuideCollapsed,
- openStoryInput,
- progress: shortDramaProgress,
- setGuideCollapsed: setShortDramaGuideCollapsed,
- skipGuide: skipShortDramaGuide,
- } = useCanvasShortDrama({
- nodes,
- connections,
- nodesRef,
- connectionsRef,
- selectedNodeIdsRef,
- getCanvasCenter,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setStylePickerOpen,
- fitCanvasSelection,
- focusCanvasNode,
- openTextEditor: openTextNodeEditor,
- });
-
- const shortDramaGuide = shortDramaEnabled && !currentProject?.projectId && shortDramaProgress.active ? { progress: shortDramaProgress, collapsed: shortDramaGuideCollapsed, onToggle: () => setShortDramaGuideCollapsed((value) => !value) } : undefined;
-
- const clearCanvas = useCallback(() => {
- const drawingIds = nodesRef.current.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
- if (drawingIds.length) {
- void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("画布已清空,但部分本地绘图缓存清理失败"));
- }
- setNodes([]);
- setConnections([]);
- setTextEditorNodeId(null);
- setDrawingNodeId(null);
- setInfoNodeId(null);
- setSubtitleNodeId(null);
- setCropNodeId(null);
- setMaskEditNodeId(null);
- setAnnotationNodeId(null);
- setAngleNodeId(null);
- setLightingNodeId(null);
- setEmotionNodeId(null);
- setPreviewNodeId(null);
- setRunningNodeId(null);
- setArtCritiqueNodeId(null);
- deselectCanvas();
- setClearConfirmOpen(false);
- clearCanvasFiles();
- }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
-
- useCanvasKeyboard({
- enabled: projectLoaded && !versions.preview,
- nodesRef,
- selectedNodeIdsRef,
- selectedConnectionId,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setContextMenu,
- setShortcutRequestNonce,
- setInfoNodeId,
- setCropNodeId,
- setMaskEditNodeId,
- setAnnotationNodeId,
- saveCanvasProject,
- zoomToActualSize,
- fitCanvasContent,
- fitCanvasSelection,
- undoCanvas,
- redoCanvas,
- cancelSelectionBox,
- copySelectedNodes,
- pasteCopiedNodes,
- restoreCopiedNodesFromText,
- shouldPreferCopiedNodes,
- pasteSystemClipboard,
- deleteNodes,
- deleteConnection,
- deselectCanvas,
- zoomCanvasIn,
- zoomCanvasOut,
- focusMode,
- exitFocusMode,
- toggleFocusMode,
- onOpenSearch: () => setNodeSearchOpen(true),
- beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
- });
-
- const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
- chatSessionsRef.current = sessions;
- activeChatIdRef.current = activeId;
- setChatSessions(sessions);
- setActiveChatId(activeId);
- }, []);
-
- const startTitleEditing = useCallback(() => {
- setTitleDraft(currentProject?.title || "未命名画布");
- setTitleEditing(true);
- }, [currentProject?.title]);
-
- const finishTitleEditing = useCallback(() => {
- const nextTitle = titleDraft.trim();
- if (nextTitle) renameCurrentProject(nextTitle);
- setTitleEditing(false);
- }, [renameCurrentProject, titleDraft]);
-
- const pasteAtPosition = useCallback(
- (position: Position) => {
- if (shouldPreferCopiedNodes() && pasteCopiedNodes(position)) return;
- void (async () => {
- try {
- // 标记写入成功时仍优先系统图片,兼容截图和从外部应用复制的媒体。
- const handled = await pasteSystemClipboard(position);
- if (!handled) pasteCopiedNodes(position);
- } catch {
- if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容");
- }
- })();
- },
- [message, pasteCopiedNodes, pasteSystemClipboard, shouldPreferCopiedNodes],
- );
-
- const copyingNodeContentRef = useRef(false);
- const copyNodeContentToClipboard = useCallback(
- async (node: CanvasNodeData | null) => {
- if (copyingNodeContentRef.current) return;
- copyingNodeContentRef.current = true;
- releaseCopiedNodesPastePriority();
- const content = node?.metadata?.content?.trim();
- const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey);
- const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : "");
- if (!node || !copySource) {
- copyingNodeContentRef.current = false;
- message.warning("没有可复制的内容");
- return;
- }
-
- try {
- if (node.type === CanvasNodeType.Image) {
- try {
- await copyImageToSystemClipboard(copySource, node.metadata?.storageKey);
- message.success("图片已复制到剪贴板");
- return;
- } catch (imageErr) {
- const fallbackUrl = new URL(copySource, window.location.href).toString();
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(fallbackUrl).catch(() => undefined);
- message.info("由于浏览器未获得焦点,已为您复制图片地址");
- return;
- }
- if (await copyToClipboard(fallbackUrl)) {
- message.info("由于浏览器未获得焦点,已为您复制图片地址");
- return;
- }
- throw imageErr;
- }
- }
-
- if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource);
- else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板");
- message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制");
- } catch (error) {
- message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限");
- } finally {
- copyingNodeContentRef.current = false;
- }
- },
- [message, releaseCopiedNodesPastePriority],
- );
-
- const copyNodeMediaUrlToClipboard = useCallback(
- async (node: CanvasNodeData | null) => {
- releaseCopiedNodesPastePriority();
- try {
- const storageKey = node?.metadata?.storageKey;
- const content = node?.metadata?.content?.trim();
- const resourceId = resourceIdFromStorageKey(storageKey);
- const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : "";
- const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
- if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址");
- if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL);
- else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板");
- message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制");
- } catch (error) {
- message.error(error instanceof Error ? error.message : "媒体地址复制失败");
- }
- },
- [message, releaseCopiedNodesPastePriority],
- );
-
- const uploadNodeImageToArkPrivateAsset = useCallback(
- async (node: CanvasNodeData) => {
- if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
- message.warning("请选择一张可用图片后再上传");
- return;
- }
- if (arkPrivateAssetUploadNodeId === node.id) return;
- const feedbackKey = `ark-private-asset-${node.id}`;
- setArkPrivateAssetUploadNodeId(node.id);
- message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 });
- try {
- let resourceID = resourceIdFromStorageKey(node.metadata.storageKey);
- let persistedNode = node;
- if (!resourceID) {
- const uploaded = await uploadImage(node.metadata.content);
- resourceID = resourceIdFromStorageKey(uploaded.storageKey);
- if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试");
- handleConfigNodeChange(node.id, imageMetadata(uploaded));
- persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } };
- }
- const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" });
- handleConfigNodeChange(node.id, { assetId: asset.assetId });
- await syncResourceToArkPrivateAsset(resourceID);
- message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 });
- } catch (error) {
- message.error({ key: feedbackKey, content: error instanceof Error ? error.message : "上传到方舟素材库失败", duration: 5 });
- } finally {
- setArkPrivateAssetUploadNodeId((current) => (current === node.id ? null : current));
- }
- },
- [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId],
- );
-
- const confirmUploadNodeImageToArkPrivateAsset = useCallback(
- (node: CanvasNodeData) => {
- modal.confirm({
- title: "上传到方舟素材库",
- content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。",
- okText: "确认拥有使用权并上传",
- cancelText: "取消",
- onOk: () => uploadNodeImageToArkPrivateAsset(node),
- });
- },
- [modal, uploadNodeImageToArkPrivateAsset],
- );
-
- const handleCanvasContextMenu = useCallback(
- (event: ReactMouseEvent) => {
- const target = event.target instanceof Element ? event.target : null;
- if (target?.closest("[data-node-id],[data-connection-id]")) return;
-
- event.preventDefault();
- event.stopPropagation();
- if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown")) {
- setContextMenu(null);
- return;
- }
-
- closeConnectionCreateMenu();
- setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY) });
- },
- [closeConnectionCreateMenu, screenToCanvas],
- );
-
- const handleNodeContextMenu = useCallback(
- (event: ReactMouseEvent, id: string) => {
- event.preventDefault();
- event.stopPropagation();
- setSelectedNodeIds((current) => {
- if (current.has(id) && current.size > 1) return current;
- return new Set([id]);
- });
- setSelectedConnectionId(null);
- closeConnectionCreateMenu();
- setToolbarNodeId(null);
- setDialogNodeId(null);
- setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
- },
- [closeConnectionCreateMenu],
- );
-
- const handleGenerateNode = useCanvasGenerationExecutor({
- projectId,
- domainProjectId: currentProject?.projectId,
- addedSkills,
- assets,
- nodesRef,
- connectionsRef,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- setSelectedConnectionId,
- setDialogNodeId,
- setRunningNodeId,
- startGenerationRequest,
- finishGenerationRequest,
- bindGenerationTask,
- applyGenerationTaskResult,
- });
- useEffect(() => {
- generateNodeRef.current = handleGenerateNode;
- }, [handleGenerateNode]);
-
- const { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({
- projectId,
- projectLoaded,
- nodes,
- nodesRef,
- setNodes,
- handleGenerateNode,
- });
-
- const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow, batchGenDialogOpen, batchGenDialogRowCount, batchGenDialogConcurrency, batchGenDialogConfig, closeBatchGenDialog, confirmBatchGenDialog } = useCanvasBatchTable({
- nodesRef,
- connectionsRef,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- enqueueGenerationBatch,
- });
-
- useEffect(() => {
- if (!projectLoaded) return;
- nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
- syncRowsFromConnections(node.id, true);
- });
- }, [connections, projectLoaded, syncRowsFromConnections]);
-
- const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
- const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
- const table = tableNode?.metadata?.batchTable;
- const row = table?.rows.find((item) => item.id === rowId);
- if (!tableNode || !table || !row) return;
- const rowIndex = table.rows.findIndex((item) => item.id === rowId);
- const uploadedNodeId = await createFileNode(file, {
- x: tableNode.position.x - 240,
- y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
- });
- if (!uploadedNodeId) return;
- const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
- inputNodeIds[columnIndex] = uploadedNodeId;
- updateBatchRow(tableNodeId, rowId, { inputNodeIds });
- message.success(`已上传并填入参考图 ${columnIndex + 1}`);
- }, [createFileNode, message, nodesRef, updateBatchRow]);
-
- const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
- useCanvasStoryboard({
- projectId,
- addedSkills,
- nodesRef,
- connectionsRef,
- setNodes,
- setConnections,
- setSelectedNodeIds,
- enqueueGenerationBatch,
- });
-
- const handleRetryNode = useCanvasGenerationRetry({
- projectId,
- domainProjectId: currentProject?.projectId,
- addedSkills,
- assets,
- nodesRef,
- connectionsRef,
- setNodes,
- setRunningNodeId,
- startGenerationRequest,
- finishGenerationRequest,
- bindGenerationTask,
- applyGenerationTaskResult,
- });
- const reloadCanvasNodeResource = useCallback(
- async (node: CanvasNodeData) => {
- const taskId = node.metadata?.taskId;
- if (!taskId || !node.metadata?.resourceReloadAvailable) return;
- setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", taskStage: "正在重新加载资源", errorDetails: undefined } } : item)));
- try {
- const task = await queryGenerationTask(taskId);
- if (task.status !== "succeeded") throw new Error("原生成任务尚未成功,无法重新加载资源");
- await applyGenerationTaskResult(node.id, task);
- } catch (error) {
- setNodes((current) =>
- current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "资源重新加载失败", resourceReloadAvailable: true } } : item)),
- );
- }
- },
- [applyGenerationTaskResult, setNodes],
- );
- const reconcileImageBatchRootNode = useCallback(
- (rootId: string) => {
- setNodes((current) => {
- const root = current.find((item) => item.id === rootId);
- if (!root) return current;
- const reconciled = reconcileImageBatchRoot(root, current);
- return current.map((item) => (item.id === root.id ? reconciled : item));
- });
- },
- [setNodes],
- );
- const retryImageBatchChildren = useCallback(
- (rootId: string, children: CanvasNodeData[]) => {
- const childIds = children.map((child) => child.id);
- setNodes((current) => markImageBatchRetrying(rootId, childIds, current));
- void Promise.allSettled(
- children.map(async (child) => {
- await handleRetryNode(child);
- setNodes((current) => current.map((item) => (item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item)));
- }),
- ).finally(() => reconcileImageBatchRootNode(rootId));
- },
- [handleRetryNode, reconcileImageBatchRootNode, setNodes],
- );
-
- const generateImageFromTextNode = useCallback(
- (node: CanvasNodeData) => {
- const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
- if (!prompt) {
- message.warning("文本节点为空,无法生图");
- return;
- }
- const sourceNode = nodesRef.current.find((item) => item.id === node.id);
- if (!sourceNode) return;
- const nodeSize = getNodeSpec(CanvasNodeType.Image);
- const imageNode = createCanvasNode(
- CanvasNodeType.Image,
- {
- x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2,
- y: sourceNode.position.y + sourceNode.height / 2,
- },
- {
- prompt: "@文本1",
- composerContent: "@文本1",
- model: effectiveConfig.imageModel || effectiveConfig.model,
- size: effectiveConfig.size,
- quality: effectiveConfig.quality,
- transparentBackground: effectiveConfig.transparentBackground,
- count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count),
- },
- );
- imageNode.title = "图片生成";
- const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: imageNode.id };
- const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(imageNode);
- const nextConnections = [...connectionsRef.current, connection];
- nodesRef.current = nextNodes;
- connectionsRef.current = nextConnections;
- setNodes(nextNodes);
- setConnections(nextConnections);
- setSelectedNodeIds(new Set([imageNode.id]));
- setSelectedConnectionId(null);
- setDialogNodeId(imageNode.id);
- },
- [effectiveConfig, message],
- );
-
- const renderCanvasNodePanel = useCallback(
- (panelNode: CanvasNodeData) => {
- if (panelNode.type === CanvasNodeType.Script || panelNode.type === CanvasNodeType.Drawing) return null;
- return panelNode.type === CanvasNodeType.Config ? (
- handleConfigNodeChange(panelNode.id, { composerContent })}
- onMetadataChange={(patch) => handleConfigNodeChange(panelNode.id, patch)}
- onClose={() => setDialogNodeId(null)}
- />
- ) : (
- reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)),
- ]}
- onAddReference={(nodeId, reference) => {
- if (reference.active || reference.assetId || reference.kind === "skill") return reference;
- if (reference.kind !== "text" || !reference.nodeId || reference.nodeId === nodeId) return undefined;
- try {
- const linked = connectCanvasTextMention(nodesRef.current, connectionsRef.current, nodeId, reference.nodeId, nanoid());
- nodesRef.current = linked.nodes;
- connectionsRef.current = linked.connections;
- setNodes(linked.nodes);
- setConnections(linked.connections);
- return linked.reference;
- } catch (cause) {
- message.warning(cause instanceof Error ? cause.message : "文本引用失败");
- return undefined;
- }
- }}
- onPromptChange={handleNodePromptChange}
- onConfigChange={handleConfigNodeChange}
- onGenerate={handleGenerateNode}
- onRemoveReference={handleRemoveNodeReference}
- onReorderReferences={handleReorderNodeReferences}
- onReplaceReference={handleReplaceNodeReference}
- onReplaceReferenceFiles={handleReplaceNodeReferenceFiles}
- onClose={() => setDialogNodeId(null)}
- onNodeMouseDown={handleNodeMouseDown}
- workspaceMode={workspaceMode}
- onImageSettingsOpenChange={(open) => {
- setNodeImageSettingsOpen(open);
- if (open) setToolbarNodeId(null);
- }}
- onListGenerate={(nodeId, listPrompt) => {
- void handleListGenerate({
- sourceNodeId: nodeId,
- prompt: listPrompt,
- nodes: nodesRef.current,
- connections: connectionsRef.current,
- config: effectiveConfig,
- projectId,
- setNodes,
- setConnections,
- setRunningNodeId,
- setDialogNodeId,
- });
- }}
- />
- );
- },
- [
- configInputsById,
- handleConfigNodeChange,
- handleGenerateNode,
- handleNodePromptChange,
- handleRemoveNodeReference,
- handleReorderNodeReferences,
- handleReplaceNodeReference,
- handleReplaceNodeReferenceFiles,
- mentionReferencesByNodeId,
- message,
- projectId,
- runningNodeId,
- skillMentionReferences,
- workspaceMode,
- ],
- );
-
- const renderCanvasNodeContent = useCallback(
- (contentNode: CanvasNodeData) => {
- if (contentNode.metadata?.workflowKind === "character" && contentNode.metadata.characterAssetId) {
- return ;
- }
- if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) {
- return setStylePickerOpen(true)} />;
- }
- if (contentNode.metadata?.workflowKind === "story_input") {
- return openStoryInput(contentNode.id)} />;
- }
- if (contentNode.type === CanvasNodeType.BatchTable) {
- return (
- patchBatchTable(contentNode.id, patch)}
- onAddRow={() => addBatchRow(contentNode.id)}
- onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)}
- onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
- onFillRows={() => fillRowsFromConnections(contentNode.id)}
- onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
- onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
- onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
- onAddTextColumn={() => addBatchTextColumn(contentNode.id)}
- onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
- onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
- onReplaceReference={replaceCanvasNodeMedia}
- onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}
- onRemoveReference={(rowId, columnIndex) => {
- const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable;
- if (!table) return;
- const row = table.rows.find((r) => r.id === rowId);
- if (!row) return;
- const newInputNodeIds = [...row.inputNodeIds];
- newInputNodeIds[columnIndex] = "";
- updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds });
- }}
- onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
- onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
- />
- );
- }
- if (contentNode.type === CanvasNodeType.Script) {
- const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
- return (
- setScriptEditorNodeId(contentNode.id)}
- onCreateImageNodes={() => createScriptImageNodes(contentNode.id)}
- onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)}
- onGenerateImages={(rowIds) => void generateScriptImages(contentNode.id, rowIds)}
- onGenerateVideos={(rowIds) => (contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds))}
- onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })}
- onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)}
- onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)}
- onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)}
- onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
- onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)}
- onAddRow={() => addScriptRow(contentNode.id)}
- onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)}
- onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)}
- onPromptChange={(composerContent) => handleConfigNodeChange(contentNode.id, { composerContent })}
- onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)}
- onModelChange={(model) => handleConfigNodeChange(contentNode.id, { model })}
- onShotDurationChange={(duration: StoryboardShotDuration) => handleConfigNodeChange(contentNode.id, { storyboardShotDuration: duration })}
- onShotCountChange={(count: StoryboardShotCount) => handleConfigNodeChange(contentNode.id, { storyboardShotCount: count })}
- workspaceMode={workspaceMode}
- onComposerHeightChange={(height) => {
- if (contentNode.metadata?.storyboardComposerHeight === height) return;
- handleConfigNodeChange(contentNode.id, { storyboardComposerHeight: height });
- const minHeight = storyboardMinNodeHeight(height);
- if (contentNode.height < minHeight) handleNodeResize(contentNode.id, contentNode.width, minHeight);
- }}
- onConnectStart={(event, rowId, handleType) => handleConnectStart(event, contentNode.id, handleType, rowId === "context" ? "storyboard:context" : `row:${rowId}`)}
- onScrollTopChange={(scrollTop) => setScriptScrollTopById((current) => (current[contentNode.id] === scrollTop ? current : { ...current, [contentNode.id]: scrollTop }))}
- />
- );
- }
- if (contentNode.metadata?.directorSceneId) {
- return (
- scene.id === contentNode.metadata?.directorSceneId) || null}
- readNodeContent={(nodeId) => (nodeId ? nodesRef.current.find((item) => item.id === nodeId)?.metadata?.content : undefined)}
- professional={workspaceMode === "professional"}
- onOpen={() => openDirectorWorkbench(contentNode.id)}
- />
- );
- }
- return (
- setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
- onGenerate={(nodeId) => {
- const target = nodesRef.current.find((item) => item.id === nodeId);
- void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
- }}
- workspaceMode={workspaceMode}
- />
- );
- },
- [
- addBatchReferenceColumn,
- addBatchTextColumn,
- addBatchRow,
- addScriptRow,
- configInputsById,
- connections,
- createAndGenerateScriptVideos,
- createScriptActionBoards,
- createScriptImageNodes,
- createScriptVideoNodes,
- currentProject?.directorScenes,
- fillRowsFromConnections,
- generateBatchRows,
- generateScriptImages,
- generateScriptRows,
- generateScriptVideos,
- handleUploadBatchReference,
- moveBatchReferenceCell,
- handleConfigNodeChange,
- handleConnectDrop,
- handleConnectStart,
- handleGenerateNode,
- handleNodeResize,
- mentionReferencesByNodeId,
- mergeVideosByIds,
- openDirectorWorkbench,
- openStoryInput,
- patchBatchTable,
- removeBatchRow,
- reorderBatchReferenceColumns,
- replaceCanvasNodeMedia,
- removeScriptRow,
- retryFailedBatchItems,
- runningNodeId,
- stopRemainingBatchItems,
- theme,
- updateBatchRow,
- updateScriptRow,
- viewport.k,
- workspaceMode,
- ],
- );
-
- const handleCanvasNodeHoverStart = useCallback(
- (nodeId: string) => {
- if (nodeDraggingRef.current) return;
- setHoveredNodeId(nodeId);
- keepNodeToolbar(nodeId);
- },
- [keepNodeToolbar],
- );
- const handleCanvasNodeHoverEnd = useCallback(
- (nodeId: string) => {
- setHoveredNodeId((current) => (current === nodeId ? null : current));
- hideNodeToolbar();
- },
- [hideNodeToolbar],
- );
- const retryCanvasNode = useCallback(
- (node: CanvasNodeData) => {
- if (node.type === CanvasNodeType.Script) {
- const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim();
- if (!prompt) {
- message.warning("分镜脚本缺少剧情内容,无法重试");
- return;
- }
- void generateScriptRows(node.id, prompt);
- return;
- }
- if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) {
- const failedChildren = failedImageBatchChildren(node, nodesRef.current);
- if (!failedChildren.length) {
- message.info("当前批次没有需要重试的失败图片");
- return;
- }
- message.info(`正在重试 ${failedChildren.length} 个失败图片`);
- retryImageBatchChildren(node.id, failedChildren);
- return;
- }
- if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) {
- retryImageBatchChildren(node.metadata.batchRootId, [node]);
- return;
- }
- void handleRetryNode(node);
- },
- [generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren],
- );
- const openCanvasNodeTaskDetails = useCallback(
- (node: CanvasNodeData) => {
- void openNodeTaskDetails(node);
- },
- [openNodeTaskDetails],
- );
- const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []);
- const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
- const editCanvasDirector = useCallback((node: CanvasNodeData) => openDirectorWorkbench(node.id), [openDirectorWorkbench]);
- const locateProjectStyleNode = useCallback(() => {
- const styleNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
- if (!styleNode) {
- message.info("项目画风节点正在同步,请稍后再试");
- return;
- }
- focusCanvasNode(styleNode.id);
- }, [focusCanvasNode, message, nodesRef]);
- const freeformCreateCommands = useCanvasCreateCommands({
- workspaceMode,
- isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId),
- handlers: {
- onAddText: () => createNode(CanvasNodeType.Text),
- onAddImage: () => createNode(CanvasNodeType.Image),
- onAddVideo: () => createNode(CanvasNodeType.Video),
- onAddAudio: () => createNode(CanvasNodeType.Audio),
- onAddScript: () => createNode(CanvasNodeType.Script),
- onAddFrame: () => createNode(CanvasNodeType.Frame),
- onAddFolder: createFolder,
- onAddDrawing: () => createNode(CanvasNodeType.Drawing),
- onAddWorkflow: () => createNode(CanvasNodeType.Config),
- onAddExtensionNode: (type) => createNode(type),
- onChooseStyle: () => setStylePickerOpen(true),
- onOpenDirector: () => setDirectorTemplateRequest({}),
- onUpload: () => handleUploadRequest(),
- onOpenMyAssets: () => openCanvasAssetLibrary(),
- onOpenProjectCharacters: () => openProjectAssets("character"),
- },
- });
- const emptyStateKind = resolveCanvasEmptyStateKind({
- nodeCount: nodes.length,
- shortDramaEnabled,
- isProjectLinked: Boolean(currentProject?.projectId),
- starterMode: currentProject?.starterMode,
- });
- const emptyCanvasState =
- emptyStateKind === "freeform" ? (
-
- ) : emptyStateKind === "linked" ? (
- {
- const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0];
- if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position });
- }}
- onOpenAssets={() => openProjectAssets()}
- onAddText={() => createNode(CanvasNodeType.Text)}
- />
- ) : emptyStateKind === "guided" ? (
- {
- setCinematicAgentEntry(true);
- openAgent();
- }}
- onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })}
- onUpload={() => handleUploadRequest()}
- onAddText={() => createNode(CanvasNodeType.Text)}
- onAddScript={() => createNode(CanvasNodeType.Script)}
- />
- ) : null;
- if (!projectLoaded && loadError)
- return (
-
- {loadError}
- 重新加载
- 返回画布库
-
- );
- if (!projectLoaded) return ;
-
- return (
- <>
-
- 跳转到画布主内容
-
-
- {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
- openProjectAssets()} />
- ) : null}
-
-
-
- {!focusMode ? (
- }
- versionsOpen={versions.open}
- onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
- title={currentProject?.title || "未命名画布"}
- titleDraft={titleDraft}
- isTitleEditing={titleEditing}
- onTitleDraftChange={setTitleDraft}
- onStartTitleEditing={startTitleEditing}
- onFinishTitleEditing={finishTitleEditing}
- onCancelTitleEditing={() => setTitleEditing(false)}
- canUndo={historyState.canUndo}
- canRedo={historyState.canRedo}
- onCreateProject={createAndOpenProject}
- onDeleteProject={deleteCurrentProject}
- onSave={() => void saveCanvasProject()}
- onForceSave={confirmForceSaveCanvas}
- onImportImage={() => handleUploadRequest()}
- onImportLibTV={() => setLibTVImportOpen(true)}
- onImportTapNow={() => setTapNowImportOpen(true)}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onShare={() => setShareModalOpen(true)}
- shortcutRequestNonce={shortcutRequestNonce}
- mediaPerformanceMode={mediaPerformanceMode}
- onMediaPerformanceModeChange={setMediaPerformanceMode}
- onOpenSearch={() => setNodeSearchOpen(true)}
- projectContext={
- shortDramaEnabled && currentProject?.projectId
- ? {
- ...canvasContext,
- projectId: currentProject.projectId,
- projectName: linkedProjectQuery.data?.project.name || currentProject.title,
- }
- : undefined
- }
- onEnterFocusMode={enterFocusMode}
- shortDramaGuide={shortDramaGuide}
- />
- ) : null}
-
- setNodeSearchOpen(false)}
- onFocus={(nodeId) => {
- const target = nodeById.get(nodeId);
- const parent = target?.parentId ? nodeById.get(target.parentId) : null;
- if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
- const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
- if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
- const selection = new Set([nodeId]);
- selectedNodeIdsRef.current = selection;
- setSelectedNodeIds(selection);
- setSelectedConnectionId(null);
- focusCanvasNode(nodeId);
- }}
- />
-
- {!focusMode && shortDramaGuide ? (
-
- ) : null}
-
- setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
- setLibTVImportOpen(false)} onApply={applyLibTVImport} />
- setTapNowImportOpen(false)} onApply={applyTapNowImport} />
-
- setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
-
- setDirectorTemplateRequest(null)} onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)} />
-
-
-
-
- }
- onViewportChange={handleViewportChange}
- onViewportPreviewChange={handleViewportPreviewChange}
- onCanvasMouseDown={handleCanvasMouseDown}
- boxSelectEnabled={canvasTool === "box-select"}
- onCanvasDoubleClick={handleCanvasDoubleClick}
- onCanvasDeselect={deselectCanvas}
- onContextMenu={handleCanvasContextMenu}
- onDrop={handleDrop}
- onFileDragEnter={handleFileDragEnter}
- onFileDragLeave={handleFileDragLeave}
- onFileDragOver={handleFileDragOver}
- >
-
-
- {
- setSelectedConnectionId(connectionId);
- setSelectedNodeIds(new Set());
- setContextMenu(null);
- }}
- onConnectionContextMenu={(event, connectionId) => {
- setSelectedConnectionId(connectionId);
- setSelectedNodeIds(new Set());
- closeConnectionCreateMenu();
- setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId });
- }}
- onNodeMouseDown={handleNodeMouseDown}
- onNodeHoverStart={handleCanvasNodeHoverStart}
- onNodeHoverEnd={handleCanvasNodeHoverEnd}
- onConnectStart={handleConnectStart}
- onNodeResize={handleNodeResize}
- onToggleFrame={handleFrameToggle}
- onFolderStyleChange={handleFolderStyleChange}
- onFolderThemeChange={handleFolderThemeChange}
- onNodeTitleChange={handleNodeTitleChange}
- onNodeContextMenu={handleNodeContextMenu}
- onNodeContentChange={handleNodeContentChange}
- onToggleBatch={toggleBatchExpanded}
- onSetBatchPrimary={setBatchPrimary}
- onRetry={retryCanvasNode}
- onReloadResource={reloadCanvasNodeResource}
- onOpenTaskDetails={openCanvasNodeTaskDetails}
- onOpenVersions={openCanvasNodeVersions}
- onViewImage={viewCanvasNodeImage}
- onReplaceMedia={replaceCanvasNodeMedia}
- onOpenTextEditor={openTextNodeEditor}
- onOpenDirector={editCanvasDirector}
- onOpenDrawing={openDrawingNode}
- onStartBatchConnection={startBatchConnection}
- />
-
-
-
-
-
-
- {focusMode ? (
- }
- versionsOpen={versions.open}
- onToggleVersions={() => { closeAgent(); versions.toggle(); }}
- dockRevealed={focusDockRevealed}
- zoomPercent={viewport.k}
- onToggleDock={() => setFocusDockRevealed((value) => !value)}
- onExit={exitFocusMode}
- onZoomIn={zoomCanvasIn}
- onZoomOut={zoomCanvasOut}
- onFit={fitCanvasContent}
- />
- ) : null}
-
-
-
- {emptyCanvasState}
-
- {!focusMode || focusDockRevealed ? (
- createNode(CanvasNodeType.Image)}
- onAddVideo={() => createNode(CanvasNodeType.Video)}
- onAddAudio={() => createNode(CanvasNodeType.Audio)}
- onAddText={() => createNode(CanvasNodeType.Text)}
- onChooseStyle={() => setStylePickerOpen(true)}
- onAddScript={() => createNode(CanvasNodeType.Script)}
- onAddFrame={() => createNode(CanvasNodeType.Frame)}
- onAddFolder={createFolder}
- onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
- onAddExtensionNode={(type) => createNode(type)}
- onAddWorkflow={() => createNode(CanvasNodeType.Config)}
- onOpenDirector={() => setDirectorTemplateRequest({})}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onUpload={() => handleUploadRequest()}
- onDelete={() => deleteNodes(new Set(selectedNodeIds))}
- onClear={() => setClearConfirmOpen(true)}
- onDeselect={deselectCanvas}
- onAppearanceChange={applyCanvasAppearance}
- onSaveAppearanceDefault={saveCanvasAppearanceDefault}
- onBackgroundModeChange={setBackgroundMode}
- onShowImageInfoChange={setShowImageInfo}
- onOpenMyAssets={() => {
- openCanvasAssetLibrary();
- }}
- onOpenProjectCharacters={() => openProjectAssets("character")}
- />
- ) : null}
-
-
-
- {
- if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
- focusCanvasNode(nodeId);
- }} />
-
-
-
- {angleNode?.metadata?.content ? (
-
- setAngleNodeId(null)}
- onConfirm={(params) => {
- void generateAngleNode(angleNode, params);
- }}
- />
-
- ) : null}
-
- {lightingNode?.metadata?.content ? (
- setLightingNodeId(null)}>
- setLightingNodeId(null)}
- onConfirm={(options, prompt) => {
- generateLightingNode(lightingNode, options, prompt);
- }}
- />
-
- ) : null}
-
- {emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
- setEmotionNodeId(null)}
- onConfirm={(payload: CanvasImageEmotionPayload) => {
- void generateEmotionNode(emotionNode, payload);
- }}
- />
- ) : null}
-
- {dialogNode &&
- !isCanvasImageSourceNode(dialogNode) &&
- !dialogNode.metadata?.fileUpload &&
- dialogNode.type !== CanvasNodeType.Script &&
- dialogNode.type !== CanvasNodeType.BatchTable &&
- dialogNode.type !== CanvasNodeType.Drawing &&
- dialogNode.type !== CanvasNodeType.Panorama &&
- !selectionBox &&
- !isCanvasNodeMoving ? (
-
- {renderCanvasNodePanel(dialogNode)}
-
- ) : null}
-
- {pendingConnectionCreate ? (
- getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
- onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
- onClose={cancelPendingConnectionCreate}
- />
- ) : null}
-
- {connectionReplaceHover ? (
-
-
-
松开替换
-
@{connectionReplaceHover.referenceLabel}
-
- ) : null}
-
- {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
- beginBatchConnectionMode(Array.from(selectedNodeIds))}
- onMergeVideos={() => void mergeSelectedVideos()}
- onSendSelectionToAgent={() => sendSelectionToAgent()}
- />
- ) : null}
-
- {uploadStatus ? : null}
- {mergeVideoProgress ? : null}
- {lastAgentChange ? (
- {
- undoAgentOps();
- }}
- onClose={dismissLastAgentChange}
- />
- ) : null}
-
- (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))}
- onEditText={openTextNodeEditor}
- onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
- onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
- onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
- onGenerateImage={generateImageFromTextNode}
- onUpload={(node) => handleUploadRequest(node.id)}
- onDownload={downloadNodeImage}
- onSaveAsset={(node) => void saveNodeAsset(node)}
- onAnnotate={(node) => setAnnotationNodeId(node.id)}
- onAnnotationEdit={openAnnotationEditNode}
- onMaskEdit={(node) => setMaskEditNodeId(node.id)}
- onImageEdit={openImageEditNode}
- onRemoveBackground={openBackgroundRemoval}
- onLayerDecomposition={openLayerDecomposition}
- onTextEdit={openTextEditNode}
- onEmotion={(node) => {
- setDialogNodeId(null);
- setEmotionNodeId((current) => (current === node.id ? null : node.id));
- }}
- onPortraitTexture={openPortraitTextureEditor}
- onCrop={(node) => setCropNodeId(node.id)}
- onSplit={(node, params) => void splitImageNode(node, params)}
- onUpscale={(node) => setUpscaleNodeId(node.id)}
- onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
- onAngle={(node) => {
- setDialogNodeId(null);
- setAngleNodeId((current) => (current === node.id ? null : node.id));
- }}
- onLighting={(node) => {
- setDialogNodeId(null);
- setLightingNodeId((current) => (current === node.id ? null : node.id));
- }}
- onPanorama={openPanoramaConfig}
- onViewImage={(node) => setPreviewNodeId(node.id)}
- onExtractVideoFrames={openVideoFrameExtractor}
- onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
- onTrimVideoSegments={openVideoSegmentExtractor}
- onSubtitles={(node) => setSubtitleNodeId(node.id)}
- onTimeline={(node) => setTimelineNodeId(node.id)}
- extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
- extractingAudio={segmentRunningMode === "audio"}
- trimmingVideo={segmentRunningMode === "video"}
- onReversePrompt={createImageReversePromptNodes}
- onRetry={retryCanvasNode}
- onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
- onToggleLocked={(node) => toggleNodeLocked(node.id)}
- onDelete={(node) => deleteNodes(new Set([node.id]))}
- />
-
-{isMiniMapOpen && !focusMode ? : null}
-
- {!focusMode ? (
- event.stopPropagation()}
- onPointerDown={(event) => event.stopPropagation()}
- onWheel={(event) => event.stopPropagation()}
- >
- setIsMiniMapOpen((value) => !value)}
- onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
- />
- void createImageAssetNode(asset)}
- onFocusCanvasImage={focusCanvasImageNode}
- />
-
- ) : null}
-
- setContextMenu(null)}
- onAddNode={(type, position) => createNode(type, position)}
- onAddFolder={createFolder}
- onChooseStyle={() => setStylePickerOpen(true)}
- onOpenDirector={(position) => setDirectorTemplateRequest({ position })}
- onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
- onOpenAssets={openCanvasAssetLibrary}
- onOpenProjectCharacters={(position) => openProjectAssets("character", position)}
- onUndo={undoCanvas}
- onRedo={redoCanvas}
- onPaste={pasteAtPosition}
- onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
- onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
- onDuplicate={duplicateNode}
- onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
- onDeleteConnection={deleteConnection}
- onSaveAsset={(node) => {
- void saveNodeAsset(node);
- }}
- onViewMedia={(node) => setPreviewNodeId(node.id)}
- onEditText={openTextNodeEditor}
- onOpenDrawing={openDrawingNode}
- onGenerateImage={generateImageFromTextNode}
- onCopyContent={(node) => {
- void copyNodeContentToClipboard(node);
- }}
- onCopyMediaUrl={(node) => {
- void copyNodeMediaUrlToClipboard(node);
- }}
- onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
- onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
- onToggleFrame={(node) => handleFrameToggle(node.id)}
- onSpreadSelection={spreadSelectedNodes}
- onCopySelection={copySelectedNodes}
- onDeleteSelection={() => deleteNodes(selectedNodeIds)}
- onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
- />
-
-
-
-
-
- setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
-
- {subtitleNode ? (
- setSubtitleNodeId(null)}
- onSave={(nodeId, patch) => {
- handleConfigNodeChange(nodeId, patch);
- const currentTimeline = currentProject?.timeline;
- if (currentTimeline) {
- const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []);
- if (next !== currentTimeline) updateProject(projectId, { timeline: next });
- }
- }}
- />
- ) : null}
-
- {frameNode ? void extractVideoFrames(frameNode, params)} /> : null}
-
- {segmentNode && segmentDialogMode ? (
- void handleSegmentConfirm(segmentNode, params)}
- />
- ) : null}
-
- {timelineNode ? (
- setTimelineNodeId(null)}
- onOpenSubtitleDialog={(subNodeId) => {
- setTimelineNodeId(null);
- setSubtitleNodeId(subNodeId);
- }}
- onSave={(next) => updateProject(projectId, { timeline: next })}
- onSaveSubtitles={(subNodeId, entries) =>
- handleConfigNodeChange(subNodeId, {
- subtitleEntries: entries,
- ...(entries.length ? {} : { subtitleHighlights: [] }),
- subtitleUpdatedAt: new Date().toISOString(),
- })
- }
- onOpenAssetLibrary={openTimelineAssetLibrary}
- onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")}
- onUploadLocalFiles={uploadTimelineMedia}
- addNodeToTimelineRef={timelineAddNodeRef}
- addMediaToTimelineRef={timelineMediaAddRef}
- onCreateAssembledNode={createVideoNodeFromBlob}
- />
- ) : null}
-
- setCharacterReferenceNodeId(null)} />
-
- setTextEditorNodeId(null)}
- onSave={(nodeId, title, content, richText) => {
- setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node)));
- }}
- />
-
- {drawingNode ? (
-
-
-
- }
- >
- setDrawingNodeId(null)}
- onSaved={(nodeId, summary) => {
- setNodes((current) =>
- current.map((node) =>
- node.id === nodeId
- ? {
- ...node,
- metadata: {
- ...node.metadata,
- drawingEngine: summary.engine,
- drawingRevision: summary.revision,
- drawingUpdatedAt: summary.updatedAt,
- drawingShapeCount: summary.shapeCount,
- drawingPageCount: summary.pageCount,
- },
- }
- : node,
- ),
- );
- message.success("绘图已保存");
- }}
- />
-
- ) : null}
-
- {
- artCritiqueRunningRef.current = running;
- }}
- node={artCritiqueNode}
- upstreamNodes={artCritiqueInputs}
- open={Boolean(artCritiqueNode)}
- onClose={() => setArtCritiqueNodeId(null)}
- onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
- />
-
- setPanoramaConfigNodeId(null)}
- onConfirm={(composedPrompt, config) => {
- const node = nodes.find((n) => n.id === panoramaConfigNodeId);
- if (node) {
- createPanoramaViewerWithConfig(node, composedPrompt, config);
- }
- }}
- onCopyPrompt={(prompt) => {
- void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
- }}
- previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined}
- nodes={nodes}
- />
-
- setScriptEditorNodeId(null)}
- onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)}
- onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => {
- if (!activeScriptNode || !visibleColumns.length) return;
- setNodes((prev) =>
- prev.map((node) =>
- node.id === activeScriptNode.id
- ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } }
- : node,
- ),
- );
- }}
- onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
- onGenerateVideos={(rowIds) => {
- if (!activeScriptNode) return;
- if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
- else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
- }}
- onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
- />
-
- {directorNodeId && activeDirectorScene ? (
-
-
-
- }
- >
- node.type === CanvasNodeType.Image && Boolean(node.metadata?.content))}
- onClose={() => setDirectorNodeId(null)}
- onChange={saveDirectorScene}
- onApply={applyDirectorOutput}
- onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
- onFlush={() => flushCanvasStorePersistence()}
- onboardingScope={directorOnboardingScope}
- />
-
- ) : null}
-
- setVersionCompareRootId(null)}
- onSetPrimary={setPrimaryVersion}
- onFocus={(nodeId) => {
- setVersionCompareRootId(null);
- focusCanvasNode(nodeId);
- }}
- />
-
- setCropNodeId(null)}
- onCloseAnnotation={() => setAnnotationNodeId(null)}
- onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)}
- onCloseMaskEdit={() => setMaskEditNodeId(null)}
- onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }}
- onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)}
- onCloseTextEdit={() => setTextEditNodeId(null)}
- onCloseUpscale={() => setUpscaleNodeId(null)}
- onCrop={(node, crop) => void cropImageNode(node, crop)}
- onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
- onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)}
- onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
- onImageEdit={(node, payload) => void editImageNode(node, payload)}
- onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)}
- onDetectText={() => {
- const node = textEditNodeId ? nodeById.get(textEditNodeId) : null;
- return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在"));
- }}
- onTextEdit={(node, payload) => void editTextImageNode(node, payload)}
- onUpscale={(node, params) => void upscaleImageNode(node, params)}
- config={effectiveConfig}
- />
-
-
-
- setTaskDetail(null)}
- onCancelTask={cancelCanvasTask}
- superResolveNode={superResolveNode}
- onCloseSuperResolve={() => setSuperResolveNodeId(null)}
- previewNode={previewNode}
- onClosePreview={() => setPreviewNodeId(null)}
- clearConfirmOpen={clearConfirmOpen}
- onCancelClear={() => setClearConfirmOpen(false)}
- onConfirmClear={clearCanvas}
- />
-
-
-
-
- {versions.preview ? : null}
-
-
-
-
- >
- );
-}
+import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
+import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link, useParams, useSearchParams } from "react-router";
+import { loadAssetsForUse } from "@/services/user-data-sync";
+import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff";
+import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
+import { uploadMediaFile } from "@/services/file-storage";
+import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer";
+import { getActiveUserScope } from "@/lib/user-scope";
+import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
+import { uploadImage } from "@/services/image-storage";
+import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
+import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
+import { getCachedResourceBlob } from "@/services/resource-blob-cache";
+import copyToClipboard from "copy-to-clipboard";
+import { nanoid } from "nanoid";
+import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
+import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
+import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
+import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
+import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
+import { useAssetStore } from "@/stores/use-asset-store";
+import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
+import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
+import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
+import { useUserStore } from "@/stores/use-user-store";
+import { App, Button } from "antd";
+import { ArrowLeftRight } from "lucide-react";
+import { AppModal } from "@/components/ui/product/app-modal";
+import { getNodeSpec } from "@/constant/canvas";
+import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
+import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
+import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel";
+import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
+import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray";
+import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
+import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
+import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
+import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
+import { WorkspaceState } from "@/components/layout/workspace-state";
+import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
+import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
+import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
+import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog";
+import { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-dialog";
+import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog";
+import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog";
+import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build";
+import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
+import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-dialog";
+import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
+import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
+import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
+import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal";
+import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
+import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
+import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal";
+import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
+import { Minimap } from "@/components/canvas/canvas-mini-map";
+import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
+import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
+import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
+import { AssetPickerModal } from "@/components/canvas/asset-picker-modal";
+import { getProject } from "@/services/api/projects";
+import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
+import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
+import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
+import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
+import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table";
+import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
+import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
+import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
+import { useFocusMode } from "@/hooks/use-focus-mode";
+import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
+import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
+import {
+ applyCanvasConnectionPromptSync,
+ buildCanvasAgentMentionReferences,
+ canvasResourceMentionToken,
+ buildCanvasNodeMentionReferenceMap,
+ buildCanvasResourceReferences,
+ getContextResourceNodes,
+ normalizeCanvasNodeMentionTokens,
+ reorderCanvasResourceConnections,
+ replaceCanvasReferenceMentions,
+ type CanvasResourceReference,
+} from "@/lib/canvas/canvas-resource-references";
+import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
+import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
+import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
+import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
+import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
+import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
+import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
+import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } 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";
+import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
+import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
+import { cancelGenerationTask } from "@/services/api/task-center";
+import { CanvasSyncStatus } from "./canvas-sync-status";
+import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
+import { CanvasVersionPreview } from "./canvas-version-preview";
+import { CanvasTopBar } from "./canvas-project-top-bar";
+import { LibTVImportDialog } from "./components/libtv-import-dialog";
+import { TapNowImportDialog } from "./components/tapnow-import-dialog";
+import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
+import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
+import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
+import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
+import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
+import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
+import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
+import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
+import { AiArtCritiqueModal } from "@/components/canvas/art-critique/ai-art-critique-modal";
+import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
+import { CanvasRefreshShell } from "./canvas-refresh-shell";
+import { queryGenerationTask } from "@/services/api/task-center";
+import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
+import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
+import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
+import { useCanvasConnectionController } from "./use-canvas-connection-controller";
+import { useCanvasOperationHistory } from "./use-canvas-operation-history";
+import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility";
+import { useCanvasActiveTasks } from "./use-canvas-active-tasks";
+import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow";
+import { useCanvasDirector } from "./use-canvas-director";
+import { useCanvasGeneration } from "./use-canvas-generation";
+import { useCanvasGenerationBatches } from "./use-canvas-generation-batches";
+import { useCanvasBatchTable } from "./use-canvas-batch-table";
+import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
+import { useCanvasGenerationRetry } from "./use-canvas-generation-retry";
+import { useCanvasHistory } from "./use-canvas-history";
+import { useCanvasKeyboard } from "./use-canvas-keyboard";
+import { useCanvasMediaTools } from "./use-canvas-media-tools";
+import { useCanvasNodeEditor } from "./use-canvas-node-editor";
+import { useCanvasNodeOperations } from "./use-canvas-node-operations";
+import { useCanvasProjectLifecycle } from "./use-canvas-project-lifecycle";
+import { useCanvasRenderModel } from "./use-canvas-render-model";
+import { useCanvasSelectionController } from "./use-canvas-selection-controller";
+import { useCanvasShortDrama } from "./use-canvas-short-drama";
+import { useCanvasStoryboard } from "./use-canvas-storyboard";
+import { useCanvasUpload } from "./use-canvas-upload";
+import { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert";
+import { useCanvasViewportController } from "./use-canvas-viewport-controller";
+import {
+ CanvasNodeType,
+ type CanvasAssistantSession,
+ type CanvasConnection,
+ type CanvasFolderStyle,
+ type CanvasFolderTheme,
+ type CanvasNodeData,
+ type CanvasNodeMetadata,
+ type CanvasMediaPerformanceMode,
+ type StoryboardColumn,
+ type StoryboardShotCount,
+ type StoryboardShotDuration,
+ type CanvasWorkflowKind,
+ type CanvasWorkspaceMode,
+ type CanvasToolMode,
+ type ContextMenuState,
+ type Position,
+ type ViewportTransform,
+} from "@/types/canvas";
+import type { ReferenceImage } from "@/types/image";
+import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
+
+const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench })));
+const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal })));
+
+const NODE_STATUS_SUCCESS = "success" as const;
+const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
+
+
+async function copyImageToSystemClipboard(source: string, storageKey?: string) {
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片");
+ if (typeof window !== "undefined") window.focus();
+
+ const fetchPNG = async (): Promise
=> {
+ let sourceBlob: Blob | null = null;
+ if (storageKey) {
+ sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null);
+ }
+ if (!sourceBlob) {
+ const response = await fetch(source);
+ if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`);
+ sourceBlob = await response.blob();
+ }
+ return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob);
+ };
+
+ // 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入,
+ // 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。
+ try {
+ const item = new ClipboardItem({ "image/png": fetchPNG() });
+ await navigator.clipboard.write([item]);
+ return;
+ } catch {
+ // 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入
+ }
+
+ const blob = await fetchPNG();
+ if (typeof window !== "undefined") window.focus();
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
+}
+
+async function convertClipboardImageToPNG(blob: Blob) {
+ if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式");
+ const bitmap = await createImageBitmap(blob);
+ try {
+ const canvas = document.createElement("canvas");
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("当前浏览器无法处理这张图片");
+ context.drawImage(bitmap, 0, 0);
+ return await new Promise((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png"));
+ } finally {
+ bitmap.close();
+ }
+}
+
+function visibleGenerationBatch(node: CanvasNodeData) {
+ const batches = node.metadata?.generationBatches || [];
+ for (let index = batches.length - 1; index >= 0; index -= 1) {
+ if (batches[index].status === "queued" || batches[index].status === "running") return batches[index];
+ }
+ return batches.at(-1);
+}
+
+export default function CanvasPage() {
+ useCanvasThemeScope();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) return ;
+
+ return ;
+}
+
+function InfiniteCanvasPage() {
+ // 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。
+ const { message, modal } = App.useApp();
+ const queryClient = useQueryClient();
+ const params = useParams<{ id: string }>();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const projectId = params.id || "";
+ const canvasStorageScope = getActiveUserScope();
+ const containerRef = useRef(null);
+ const didInitialCenterRef = useRef(false);
+ const toolbarHideTimerRef = useRef | null>(null);
+ const assetHandoffRef = useRef("");
+
+ const config = useConfigStore((state) => state.config);
+ const effectiveConfig = useEffectiveConfig();
+ const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
+ const assets = useAssetStore((state) => state.assets);
+ const assetsHydrated = useAssetStore((state) => state.hydrated);
+ const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
+ const colorTheme = useCanvasThemeStore((state) => state.theme);
+ const setTheme = useCanvasThemeStore((state) => state.setTheme);
+ const theme = canvasThemes[colorTheme];
+ const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
+ const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
+ const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
+ const nodesRef = useRef([]);
+ const [nodes, setNodesState] = useState([]);
+ const setNodes = useCallback>>((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 [nodeStackOrder, setNodeStackOrder] = useState([]);
+ const bringNodeToFront = useCallback((nodeId: string) => {
+ setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
+ }, []);
+ const [connections, setConnections] = useState([]);
+ const [chatSessions, setChatSessions] = useState([]);
+ const [activeChatId, setActiveChatId] = useState(null);
+ const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 });
+ const [size, setSize] = useState({ width: 1200, height: 720 });
+ const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set());
+ const [selectedConnectionId, setSelectedConnectionId] = useState(null);
+ const [hoveredNodeId, setHoveredNodeId] = useState(null);
+ const [contextMenu, setContextMenu] = useState(null);
+ const [agentPrefillPrompt, setAgentPrefillPrompt] = useState("");
+ const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
+ const [canvasAppearance, setCanvasAppearance] = useState(() => canvasAppearanceForTheme(colorTheme));
+ const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE);
+ const [showImageInfo, setShowImageInfo] = useState(false);
+ const [canvasTool, setCanvasTool] = useState("box-select");
+ const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode);
+ const [projectLoaded, setProjectLoaded] = useState(false);
+ const workspaceMode: CanvasWorkspaceMode = "professional";
+ const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
+ const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
+ const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
+ const [toolbarNodeId, setToolbarNodeId] = useState(null);
+ const [arkPrivateAssetUploadNodeId, setArkPrivateAssetUploadNodeId] = useState(null);
+ const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
+ const [dialogNodeId, setDialogNodeId] = useState(null);
+ const [textEditorNodeId, setTextEditorNodeId] = useState(null);
+ const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState(null);
+ const [drawingNodeId, setDrawingNodeId] = useState(null);
+ const [stylePickerOpen, setStylePickerOpen] = useState(false);
+ // 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
+ const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null);
+ const [infoNodeId, setInfoNodeId] = useState(null);
+ const [subtitleNodeId, setSubtitleNodeId] = useState(null);
+ const [timelineNodeId, setTimelineNodeId] = useState(null);
+ const [superResolveNodeId, setSuperResolveNodeId] = useState(null);
+ const [previewNodeId, setPreviewNodeId] = useState(null);
+ const [scriptEditorNodeId, setScriptEditorNodeId] = useState(null);
+ const [artCritiqueNodeId, setArtCritiqueNodeId] = useState(null);
+ const artCritiqueRunningRef = useRef(false);
+ const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
+ const [scriptScrollTopById, setScriptScrollTopById] = useState>({});
+ const [directorNodeId, setDirectorNodeId] = useState(null);
+ const [versionCompareRootId, setVersionCompareRootId] = useState(null);
+ const [libTVImportOpen, setLibTVImportOpen] = useState(false);
+ const [titleEditing, setTitleEditing] = useState(false);
+ const [titleDraft, setTitleDraft] = useState("");
+ const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
+ const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
+ const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
+ const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
+
+ const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
+ const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
+ const [focusDockRevealed, setFocusDockRevealed] = useState(false);
+
+ useEffect(() => {
+ persistCanvasMediaPerformanceMode(mediaPerformanceMode);
+ }, [mediaPerformanceMode]);
+
+ useEffect(() => {
+ didInitialCenterRef.current = false;
+ setNodeStackOrder([]);
+ }, [projectId]);
+
+ useEffect(() => {
+ const nodeIds = new Set(nodes.map((node) => node.id));
+ setNodeStackOrder((current) => {
+ const next = current.filter((nodeId) => nodeIds.has(nodeId));
+ return next.length === current.length ? current : next;
+ });
+ }, [nodes]);
+
+ const connectionsRef = useRef(connections);
+ const chatSessionsRef = useRef(chatSessions);
+ const activeChatIdRef = useRef(activeChatId);
+ const selectedNodeIdsRef = useRef(selectedNodeIds);
+ const viewportRef = useRef(viewport);
+ const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise) | null>(null);
+
+ useEffect(() => {
+ if (!projectId) return;
+ return registerCanvasGenerationLiveProject({
+ scope: canvasStorageScope,
+ projectId,
+ adapter: createCanvasGenerationLiveProjectAdapter({ nodesRef, connectionsRef, chatSessionsRef, activeChatIdRef, setNodes, setConnections, setChatSessions, setActiveChatId }),
+ });
+ }, [canvasStorageScope, projectId]);
+
+ const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]);
+ const applyCanvasAppearance = useCallback(
+ (next: CanvasAppearance) => {
+ const fallback = canvasAppearanceBaseTheme(next, colorTheme);
+ const normalized = normalizeCanvasAppearance(next, fallback);
+ setCanvasAppearance(normalized);
+ setTheme(canvasAppearanceBaseTheme(normalized, fallback));
+ },
+ [colorTheme, setTheme],
+ );
+ const saveCanvasAppearanceDefault = useCallback(
+ (next: CanvasAppearance) => {
+ writeCanvasAppearanceDefault({ appearance: next, backgroundMode });
+ message.success("已保存为当前账号在本机的新建画布默认外观");
+ },
+ [backgroundMode, message],
+ );
+
+ const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
+ projectLoaded,
+ nodes,
+ connections,
+ chatSessions,
+ activeChatId,
+ canvasAppearance,
+ backgroundMode,
+ showImageInfo,
+ setNodes,
+ setConnections,
+ setChatSessions,
+ setActiveChatId,
+ applyCanvasAppearance,
+ setBackgroundMode,
+ setShowImageInfo,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ });
+
+ const cleanupCanvasFiles = useCallback(
+ (extra?: unknown) => {
+ cleanupAssetImages({ extra, ...getHistoryCleanupContext() });
+ },
+ [cleanupAssetImages, getHistoryCleanupContext],
+ );
+
+ const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
+ projectId,
+ projectLoaded,
+ nodes,
+ connections,
+ chatSessions,
+ activeChatId,
+ canvasAppearance,
+ backgroundMode,
+ showImageInfo,
+ viewport,
+ nodesRef,
+ connectionsRef,
+ chatSessionsRef,
+ activeChatIdRef,
+ viewportRef,
+ historyPausedRef,
+ setNodes,
+ setConnections,
+ setChatSessions,
+ setActiveChatId,
+ setCanvasAppearance,
+ setBackgroundMode,
+ setShowImageInfo,
+ setViewport,
+ setProjectLoaded,
+ resetHistory,
+ cleanupAssetImages,
+ cleanupCanvasFiles,
+ });
+
+ const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
+ const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
+ const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
+
+ const sendSelectionToAgent = useCallback((nodeId?: string) => {
+ const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
+ const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
+ if (!references.length) return;
+ setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
+ openAgent();
+ setContextMenu(null);
+ }, [agentMentionReferences, openAgent]);
+ // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
+ const confirmForceSaveCanvas = useCallback(() => {
+ modal.confirm({
+ title: "修复素材关联并保存?",
+ content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
+ okText: "修复并保存",
+ cancelText: "取消",
+ onOk: () => forceSaveCanvasProject(),
+ });
+ }, [forceSaveCanvasProject, modal]);
+
+ const applyLibTVImport = useCallback(
+ async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+ const nextNodes = [...nodesRef.current, ...importedNodes];
+ const nextConnections = [...connectionsRef.current, ...importedConnections];
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ const saved = await saveCanvasProject({ requireRemote: false });
+ if (!saved) {
+ nodesRef.current = previousNodes;
+ connectionsRef.current = previousConnections;
+ setNodes(previousNodes);
+ setConnections(previousConnections);
+ throw new Error("画布保存失败,已撤销本次 LibTV 导入");
+ }
+ },
+ [saveCanvasProject, setConnections, setNodes],
+ );
+ const applyTapNowImport = useCallback(
+ async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+ const nextNodes = [...nodesRef.current, ...importedNodes];
+ const nextConnections = [...connectionsRef.current, ...importedConnections];
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ const saved = await saveCanvasProject({ requireRemote: false });
+ if (!saved) {
+ nodesRef.current = previousNodes;
+ connectionsRef.current = previousConnections;
+ setNodes(previousNodes);
+ setConnections(previousConnections);
+ throw new Error("画布保存失败,已撤销本次 TapNow 导入");
+ }
+ },
+ [saveCanvasProject, setConnections, setNodes],
+ );
+ const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
+ const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
+ const refetchLinkedProject = linkedProjectQuery.refetch;
+ const archiveNodesToLinkedFolder = useCallback(
+ (folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => {
+ const folderId = folder.metadata?.folder?.assetFolderId;
+ const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId;
+ if (!folderId || !domainProjectId || !droppedNodes.length) return;
+ void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" })))
+ .then((results) => {
+ const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }]));
+ setNodes((current) =>
+ current.map((node) => {
+ const archived = archivedByNodeId.get(node.id);
+ if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node;
+ return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } };
+ }),
+ );
+ void refetchLinkedProject();
+ message.success(`已归档到“${folder.title}”`);
+ })
+ .catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败"));
+ },
+ [linkedProjectId, message, projectId, refetchLinkedProject, setNodes],
+ );
+ useEffect(() => {
+ if (!projectLoaded || !linkedProjectQuery.data) return;
+ setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets));
+ }, [linkedProjectQuery.data, projectLoaded, setNodes]);
+ const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]);
+ // 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发;
+ // 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo——
+ // 每帧新对象会让所有节点跟着重渲染,错题本里多条崩溃都出在画布高频更新。
+ const nodeGraphContext = useMemo(() => ({ getUpstreamNodes: (nodeId: string) => getContextResourceNodes(nodeId, nodes, connections) }), [connections, nodes]);
+
+ const { applyGenerationTaskResult, bindGenerationTask, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs } = useCanvasGeneration({
+ projectId,
+ domainProjectId: linkedProjectId,
+ projectLoaded,
+ nodes,
+ nodesRef,
+ setNodes,
+ });
+
+ const cancelCanvasTask = useCallback(
+ (task: import("@/services/api/task-center").GenerationTask) => {
+ modal.confirm({
+ title: "取消生成任务?",
+ content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
+ okText: "取消任务",
+ okButtonProps: { danger: true },
+ cancelText: "继续等待",
+ onOk: async () => {
+ try {
+ const next = await cancelGenerationTask(task.id);
+ const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
+ if (node) bindGenerationTask(node.id, next);
+ setTaskDetail((current) => (current?.id === task.id ? next : current));
+ await queryClient.invalidateQueries({ queryKey: ["canvas-active-tasks", projectId] });
+ message.success("任务已取消");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "取消任务失败");
+ }
+ },
+ });
+ },
+ [bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
+ );
+
+ useEffect(() => {
+ const sessionId = searchParams.get("conversation");
+ if (!projectLoaded || !sessionId) return;
+ if (!chatSessions.some((session) => session.id === sessionId)) {
+ message.warning("未找到要接续的会话,请从首页重新进入。");
+ } else {
+ activeChatIdRef.current = sessionId;
+ setActiveChatId(sessionId);
+ openAgent();
+ }
+ const next = new URLSearchParams(searchParams);
+ next.delete("conversation");
+ setSearchParams(next, { replace: true });
+ }, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]);
+
+ useEffect(() => {
+ if (!projectLoaded || searchParams.get("agent") !== "1") return;
+ openAgent();
+ const next = new URLSearchParams(searchParams);
+ next.delete("agent");
+ setSearchParams(next, { replace: true });
+ }, [projectLoaded, searchParams, setSearchParams, openAgent]);
+
+ // 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
+ const prevFocusModeRef = useRef(focusMode);
+ useEffect(() => {
+ const enteredFocus = focusMode && !prevFocusModeRef.current;
+ prevFocusModeRef.current = focusMode;
+ if (!enteredFocus) return;
+ closeAgent();
+ setIsMiniMapOpen(false);
+ setFocusDockRevealed(false);
+ }, [closeAgent, focusMode]);
+
+ useEffect(() => {
+ if (!dialogNodeId) setNodeImageSettingsOpen(false);
+ }, [dialogNodeId]);
+
+ useLayoutEffect(() => {
+ nodesRef.current = nodes;
+ connectionsRef.current = connections;
+ chatSessionsRef.current = chatSessions;
+ activeChatIdRef.current = activeChatId;
+ selectedNodeIdsRef.current = selectedNodeIds;
+ viewportRef.current = viewport;
+ }, [activeChatId, chatSessions, nodes, connections, selectedNodeIds, viewport]);
+
+ useEffect(() => {
+ if (!projectLoaded) return;
+ const el = containerRef.current;
+ if (!el) return;
+
+ const updateSize = () => {
+ const rect = el.getBoundingClientRect();
+ setSize((current) => (current.width === rect.width && current.height === rect.height ? current : { width: rect.width, height: rect.height }));
+ if (!didInitialCenterRef.current) {
+ didInitialCenterRef.current = true;
+ const current = viewportRef.current;
+ if (current.x === 0 && current.y === 0 && current.k === 1) {
+ const centered = { x: rect.width / 2, y: rect.height / 2, k: 1 };
+ viewportRef.current = centered;
+ setViewport(centered);
+ }
+ }
+ };
+
+ updateSize();
+ const resizeObserver = new ResizeObserver(updateSize);
+ resizeObserver.observe(el);
+ return () => resizeObserver.disconnect();
+ }, [projectLoaded]);
+
+ const {
+ fitCanvasContent,
+ fitCanvasSelection,
+ focusCanvasImageNode,
+ focusCanvasNode,
+ getCanvasCenter,
+ handleCanvasDoubleClick,
+ handleViewportChange,
+ handleViewportPreviewChange,
+ previewViewport,
+ screenToCanvas,
+ setZoomScale,
+ zoomCanvasIn,
+ zoomCanvasOut,
+ zoomToActualSize,
+ } = useCanvasViewportController({
+ agentCreatedNodes,
+ containerRef,
+ size,
+ viewportRef,
+ nodesRef,
+ selectedNodeIdsRef,
+ setViewport,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ setDialogNodeId,
+ setToolbarNodeId,
+ });
+
+ useEffect(() => {
+ const project = linkedProjectQuery.data?.project;
+ const preset = resolveProjectCanvasStyle(project?.stylePresetId, project?.styleProfileJson);
+ if (!projectLoaded || !preset) return;
+ const profile = resolveStyleProfile(project?.stylePresetId, project?.styleProfileJson, preset.profile || createStyleProfileSnapshot(preset));
+ if (!profile) return;
+ const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
+ const nextMetadata = {
+ content: profile.prompt,
+ prompt: profile.prompt,
+ status: NODE_STATUS_SUCCESS,
+ workflowKind: "styleboard" as const,
+ workflowTitle: "项目画风",
+ workflowDescription: profile.description,
+ stylePresetId: profile.presetId,
+ styleProfileJson: serializeStyleProfile(profile),
+ fontSize: 14,
+ locked: true,
+ };
+ if (current) {
+ if (current.metadata?.stylePresetId === profile.presetId && current.metadata?.content === profile.prompt && current.metadata?.styleProfileJson === nextMetadata.styleProfileJson && current.metadata?.locked) return;
+ setNodes((nodes) => nodes.map((node) => (node.id === current.id ? { ...node, title: `项目画风 · ${profile.title}`, metadata: { ...node.metadata, ...nextMetadata } } : node)));
+ return;
+ }
+ const node = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), nextMetadata);
+ node.title = `项目画风 · ${profile.title}`;
+ node.width = 420;
+ node.height = 240;
+ setNodes((nodes) => [...nodes, node]);
+ }, [getCanvasCenter, linkedProjectQuery.data?.project, projectLoaded, setNodes]);
+
+ const {
+ assetPickerOpen,
+ closeUploadModal,
+ closeAssetPicker,
+ createVideoNodeFromBlob,
+ createImageAssetNode,
+ fileDropActive,
+ handleAssetsInsert,
+ handleDrop,
+ handleFileDragEnter,
+ handleFileDragLeave,
+ handleFileDragOver,
+ handleImageInputChange,
+ handleProjectAssetsInsert,
+ handleProjectChapterInsert,
+ handleUploadFiles,
+ handleUploadRequest,
+ imageInputRef,
+ openAssetsAtPosition,
+ pasteAssistantImage,
+ pasteSystemClipboard,
+ replaceNodeMedia,
+ createFileNode,
+ startUploadStatus,
+ uploadModalOpen,
+ uploadTimelineMedia,
+ uploadStatus,
+ } = useCanvasUpload({
+ canvasId: projectId,
+ domainProjectId: linkedProjectId,
+ nodesRef,
+ selectedNodeIdsRef,
+ getCanvasCenter,
+ screenToCanvas,
+ setNodes,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ setDialogNodeId,
+ });
+ const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
+ const {
+ timelineAddNodeRef,
+ timelineMediaAddRef,
+ assetInsertScope,
+ projectAssetScope,
+ projectAssetOpen,
+ projectAssetInitialCategory,
+ projectAssetInitialFolderId,
+ projectAssetInsertPosition,
+ handleLibraryAssetsInsert,
+ handleTimelineProjectAssetsInsert,
+ openProjectAssets,
+ openCanvasAssetLibrary,
+ openTimelineAssetLibrary,
+ closeProjectAssets,
+ } = useCanvasTimelineAssetInsert({
+ linkedProjectId,
+ refetchLinkedProject,
+ handleAssetsInsert,
+ handleProjectAssetsInsert,
+ openAssetsAtPosition,
+ });
+
+ useEffect(() => {
+ if (!projectLoaded || searchParams.get("mode") !== "handoff") return;
+ void loadAssetsForUse(canvasAssetHandoffIds(searchParams)).catch((error) => message.error(error instanceof Error ? error.message : "转入素材读取失败"));
+ }, [projectLoaded, searchParams, message]);
+
+ useEffect(() => {
+ if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
+ const attempt = canvasAssetHandoffAttempt(assets, searchParams);
+ const { assetIds, payloads } = attempt;
+ if (!assetIds.length) return;
+ const assetReadiness = assetIds
+ .map((assetId) => {
+ const asset = assets.find((candidate) => candidate.id === assetId);
+ return `${assetId}:${asset?.kind || "missing"}`;
+ })
+ .join("|");
+ const handoffKey = `${projectId}:${assetReadiness}`;
+ if (assetHandoffRef.current === handoffKey) return;
+ assetHandoffRef.current = handoffKey;
+
+ if (attempt.kind === "retry") return;
+ const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads);
+ const persistHandoff = async (createdNodes: CanvasNodeData[]) => {
+ const finalized = await finalizeCanvasAssetHandoff({
+ searchParams,
+ currentNodes: nodesRef.current,
+ createdNodes,
+ persist: async (nextNodes) => {
+ nodesRef.current = nextNodes;
+ updateProject(projectId, { nodes: nextNodes });
+ await flushCanvasStorePersistence();
+ },
+ });
+ setSearchParams(finalized.searchParams, { replace: true });
+ };
+ const insertion = pendingPayloads.length ? handleProjectAssetsInsert(pendingPayloads) : Promise.resolve([] as CanvasNodeData[]);
+ void insertion.then(persistHandoff).catch(() => {
+ assetHandoffRef.current = "";
+ });
+ }, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]);
+
+ const {
+ angleNodeId,
+ lightingNodeId,
+ emotionNodeId,
+ annotationNodeId,
+ annotationEditNodeId,
+ createImageReversePromptNodes,
+ openPortraitTextureEditor,
+ cropImageNode,
+ cropNodeId,
+ closeFrameDialog,
+ closeSegmentDialog,
+ extractAudioFromVideo,
+ extractVideoFrames,
+ extractingVideoFramesNodeId,
+ frameDialogNodeId,
+ generateAngleNode,
+ generateLightingNode,
+ openPanoramaConfig,
+ createPanoramaViewerWithConfig,
+ addPanoramaCaptureNode,
+ panoramaConfigNodeId,
+ setPanoramaConfigNodeId,
+ generateEmotionNode,
+ handleSegmentConfirm,
+ maskEditImageNode,
+ maskEditNodeId,
+ imageEditNodeId,
+ imageEditPreset,
+ layerDecompositionNodeId,
+ textEditNodeId,
+ mergeSelectedVideos,
+ mergeVideosByIds,
+ mergeVideoProgress,
+ saveAnnotatedImageNode,
+ segmentDialogMode,
+ segmentDialogNodeId,
+ segmentRunningMode,
+ setFrameDialogNodeId,
+ setSegmentDialogNodeId,
+ setAngleNodeId,
+ setLightingNodeId,
+ setEmotionNodeId,
+ setAnnotationNodeId,
+ setAnnotationEditNodeId,
+ setCropNodeId,
+ setMaskEditNodeId,
+ setImageEditNodeId,
+ setImageEditPreset,
+ openBackgroundRemoval,
+ openLayerDecomposition,
+ decomposeImageLayers,
+ setLayerDecompositionNodeId,
+ setTextEditNodeId,
+ openTextEditNode,
+ openAnnotationEditNode,
+ detectImageText,
+ editTextImageNode,
+ editAnnotatedImageNode,
+ editImageNode,
+ setUpscaleNodeId,
+ splitImageNode,
+ openVideoFrameExtractor,
+ openVideoSegmentExtractor,
+ upscaleImageNode,
+ upscaleNodeId,
+ } = useCanvasMediaTools({
+ projectId,
+ domainProjectId: linkedProjectId,
+ nodesRef,
+ connectionsRef,
+ selectedNodeIdsRef,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setDialogNodeId,
+ setContextMenu,
+ setHoveredNodeId,
+ setToolbarNodeId,
+ setRunningNodeId,
+ startUploadStatus,
+ startGenerationRequest,
+ finishGenerationRequest,
+ bindGenerationTask,
+ });
+
+ const handleNodesDeleted = useCallback(
+ (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => {
+ const clearDeletedId = (current: string | null) => (current && removedIds.has(current) ? null : current);
+ setHoveredNodeId(clearDeletedId);
+ setToolbarNodeId(clearDeletedId);
+ setDialogNodeId(clearDeletedId);
+ setTextEditorNodeId(clearDeletedId);
+ setCharacterReferenceNodeId(clearDeletedId);
+ setDrawingNodeId(clearDeletedId);
+ setInfoNodeId(clearDeletedId);
+ setSubtitleNodeId(clearDeletedId);
+ setFrameDialogNodeId(clearDeletedId);
+ setSegmentDialogNodeId(clearDeletedId);
+ setCropNodeId(clearDeletedId);
+ setMaskEditNodeId(clearDeletedId);
+ setAnnotationNodeId(clearDeletedId);
+ setUpscaleNodeId(clearDeletedId);
+ setAngleNodeId(clearDeletedId);
+ setLightingNodeId(clearDeletedId);
+ setEmotionNodeId(clearDeletedId);
+ setSuperResolveNodeId(clearDeletedId);
+ setPreviewNodeId(clearDeletedId);
+ setRunningNodeId(clearDeletedId);
+ setScriptEditorNodeId(clearDeletedId);
+ setArtCritiqueNodeId(clearDeletedId);
+ setDirectorNodeId(clearDeletedId);
+ setVersionCompareRootId(clearDeletedId);
+ setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id))));
+ setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current));
+ const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
+ if (removedDrawingIds.length) {
+ void Promise.all(removedDrawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("绘图节点已删除,但本地绘图缓存清理失败"));
+ }
+ cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions });
+ },
+ [
+ chatSessions,
+ cleanupCanvasFiles,
+ message,
+ projectId,
+ setAngleNodeId,
+ setAnnotationNodeId,
+ setArtCritiqueNodeId,
+ setCropNodeId,
+ setEmotionNodeId,
+ setFrameDialogNodeId,
+ setLightingNodeId,
+ setMaskEditNodeId,
+ setSegmentDialogNodeId,
+ setUpscaleNodeId,
+ setRunningNodeId,
+ ],
+ );
+
+ const {
+ alignSelectedNodes,
+ autoArrangeCanvasNodes,
+ arrangeSelectedNodes,
+ spreadSelectedNodes,
+ copyNodesToClipboard,
+ copySelectedNodes,
+ createFolder,
+ createNode,
+ createReferenceGroup,
+ createStoryboardGroup,
+ deleteConnection,
+ deleteNodes,
+ duplicateNode,
+ hasCopiedNodes,
+ pasteCopiedNodes,
+ restoreCopiedNodesFromText,
+ releaseCopiedNodesPastePriority,
+ setPrimaryVersion,
+ shouldPreferCopiedNodes,
+ toggleNodeLocked,
+ } = useCanvasNodeOperations({
+ projectId,
+ defaultDrawingEngine,
+ nodesRef,
+ connectionsRef,
+ selectedNodeIdsRef,
+ getCanvasCenter,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ setDialogNodeId,
+ onNodesDeleted: handleNodesDeleted,
+ });
+
+ const handleReplaceNodeReference = useCallback(
+ (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => {
+ const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId);
+ if (!sourceNode || sourceNode.id === oldReference.nodeId) return;
+
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+
+ const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId;
+ const receiverId = configNodeId || targetNodeId;
+
+ let rewired = false;
+ const nextConnections = previousConnections.map((c) => {
+ if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) {
+ rewired = true;
+ return { ...c, fromNodeId: sourceNodeId };
+ }
+ return c;
+ });
+
+ if (!rewired) {
+ nextConnections.push({
+ id: nanoid(),
+ fromNodeId: sourceNodeId,
+ toNodeId: receiverId,
+ });
+ }
+
+ const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes);
+ const targetNextReferences = nextReferencesMap.get(targetNodeId) || [];
+ const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId);
+
+ const targetNode = previousNodes.find((n) => n.id === targetNodeId);
+ let nextNodes = previousNodes;
+ if (targetNode && newRef) {
+ const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? "";
+ const replacementToken = `@${newRef.label}`;
+ const updatedPrompt = replaceCanvasReferenceMentions(currentPrompt, oldReference, replacementToken, sourceNode.title ? sourceNode.title : newRef.label);
+
+ nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n));
+ }
+
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`);
+ },
+ [connectionsRef, message, nodesRef, setConnections, setNodes],
+ );
+
+ const handleReplaceNodeReferenceFiles = useCallback(
+ (_targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, files: File[]) => {
+ const file = files.find((f) => f.type.startsWith("image/"));
+ if (!file || !oldReference.nodeId) return;
+ void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => {
+ if (success) {
+ message.success("参考图片已替换");
+ }
+ });
+ },
+ [message, replaceNodeMedia],
+ );
+
+ const {
+ cancelPendingConnectionCreate,
+ closeConnectionCreateMenu,
+ connectionTargetAnchorRatio,
+ connectionTargetNodeId,
+ connectionApproach,
+ connectionReplaceHover,
+ connectingParams,
+ createConnectedNode,
+ getConnectionCreateDisabledReason,
+ handleConnectStart,
+ handleConnectDrop,
+ handleBatchConnectionTargetClick,
+ batchConnectionPreview,
+ beginBatchConnectionMode,
+ startBatchConnection,
+ mouseWorld,
+ pendingConnectionCreate,
+ setConnecting,
+ } = useCanvasConnectionController({
+ projectId,
+ config: effectiveConfig,
+ defaultDrawingEngine,
+ nodesRef,
+ connectionsRef,
+ viewportRef,
+ scriptScrollTopById,
+ screenToCanvas,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ setDialogNodeId,
+ setDrawingNodeId,
+ onReplaceReference: handleReplaceNodeReference,
+ });
+
+ const batchSourceNodeIds = useMemo(() => nodes.filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)).map((node) => node.id), [nodes, selectedNodeIds]);
+
+ const handleCanvasSelectionStart = useCallback(() => {
+ setContextMenu(null);
+ }, []);
+
+ const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => {
+ setContextMenu(null);
+ setHoveredNodeId(null);
+ setToolbarNodeId(null);
+ if (selectionModifier) setDialogNodeId(null);
+ }, []);
+
+ const handleSelectedNodeClick = useCallback(
+ (node: CanvasNodeData) => {
+ // Selection is transient, but the LibTV-style paint order survives
+ // deselection so a clicked lower node stays above its neighbours.
+ if (node.type !== CanvasNodeType.Frame) bringNodeToFront(node.id);
+ if (node.type === CanvasNodeType.Drawing) {
+ setDialogNodeId(null);
+ setDrawingNodeId(node.id);
+ } else if (node.type === CanvasNodeType.Script) {
+ setDialogNodeId(null);
+ } else if (node.type === CanvasNodeType.Text) {
+ setDialogNodeId(node.id);
+ } else if (node.type === CanvasNodeType.Frame) {
+ setDialogNodeId((current) => (current === node.id ? current : null));
+ } else if (node.type === ART_CRITIQUE_NODE_TYPE) {
+ setDialogNodeId(null);
+ setArtCritiqueNodeId(node.id);
+ } else if (node.type === CanvasNodeType.Panorama) {
+ // 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
+ setDialogNodeId(null);
+ } else {
+ // 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
+ // 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
+ setDialogNodeId((current) => {
+ const currentNode = current ? nodesRef.current.find((item) => item.id === current) : undefined;
+ return currentNode?.type === CanvasNodeType.Config ? current : node.id;
+ });
+ }
+ },
+ [bringNodeToFront, nodesRef],
+ );
+
+ const handleNodeBringToFront = useCallback(
+ (nodeId: string) => {
+ const node = nodesRef.current.find((item) => item.id === nodeId);
+ if (node && node.type !== CanvasNodeType.Frame) bringNodeToFront(nodeId);
+ },
+ [bringNodeToFront, nodesRef],
+ );
+
+ const handleNodeDragEnd = useCallback(
+ (nodeId: string) => {
+ const node = nodesRef.current.find((item) => item.id === nodeId);
+ if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE) {
+ setDialogNodeId(null);
+ return;
+ }
+ // A drag selects a new node even though it is not a click. Keep the
+ // generation editor bound to the node most recently moved so a stale
+ // panel from the previous node cannot reappear after mouse-up.
+ setDialogNodeId(node.id);
+ },
+ [nodesRef],
+ );
+
+ const handleCanvasDeselect = useCallback(() => {
+ setContextMenu(null);
+ setHoveredNodeId(null);
+ setToolbarNodeId(null);
+ setDialogNodeId(null);
+ }, []);
+
+ const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({
+ containerRef,
+ nodesRef,
+ viewportRef,
+ selectedNodeIdsRef,
+ historyPausedRef,
+ screenToCanvas,
+ setNodes,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ cancelPendingConnectionCreate,
+ onCanvasSelectionStart: handleCanvasSelectionStart,
+ onNodeInteractionStart: handleNodeInteractionStart,
+ onNodeBringToFront: handleNodeBringToFront,
+ onNodeClick: handleSelectedNodeClick,
+ onNodeDragEnd: handleNodeDragEnd,
+ onBatchConnectionTarget: handleBatchConnectionTargetClick,
+ onLinkedFolderDrop: archiveNodesToLinkedFolder,
+ onDeselect: handleCanvasDeselect,
+ });
+
+ const keepNodeToolbar = useCallback(
+ (nodeId: string) => {
+ if (nodeDraggingRef.current || nodeImageSettingsOpen) return;
+ if (toolbarHideTimerRef.current) {
+ clearTimeout(toolbarHideTimerRef.current);
+ toolbarHideTimerRef.current = null;
+ }
+ setToolbarNodeId(nodeId);
+ },
+ [nodeImageSettingsOpen],
+ );
+
+ const hideNodeToolbar = useCallback(() => {
+ if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current);
+ toolbarHideTimerRef.current = setTimeout(() => {
+ setToolbarNodeId(null);
+ toolbarHideTimerRef.current = null;
+ }, 120);
+ }, []);
+
+ const {
+ collapsingBatchIds,
+ downloadNodeImage,
+ handleConfigNodeChange,
+ handleFolderStyleChange,
+ handleFolderThemeChange,
+ handleFontSizeChange,
+ handleNodeContentChange,
+ handleNodePromptChange,
+ handleNodeResize,
+ handleNodeTitleChange,
+ openingBatchIds,
+ saveNodeAsset,
+ setBatchPrimary,
+ toggleBatchExpanded,
+ toggleFrameCollapsed,
+ toggleNodeFreeResize,
+ } = useCanvasNodeEditor({
+ canvasId: projectId,
+ canvasTitle: currentProject?.title || "未命名画布",
+ domainProjectId: linkedProjectId,
+ nodesRef,
+ setNodes,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setDialogNodeId,
+ setToolbarNodeId,
+ setHoveredNodeId,
+ });
+
+ const handleRemoveNodeReference = useCallback(
+ (targetNodeId: string, reference: CanvasResourceReference) => {
+ const referenceNodeId = reference.nodeId;
+ if (!referenceNodeId) return;
+ // 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+ const configNodeId = previousConnections.find((connection) => {
+ if (connection.fromNodeId !== targetNodeId) return false;
+ return previousNodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
+ })?.toNodeId;
+ const removedConnectionIds = new Set(previousConnections.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId)).map((connection) => connection.id));
+ if (!removedConnectionIds.size) return;
+ const nextConnections = previousConnections.filter((connection) => !removedConnectionIds.has(connection.id));
+ const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
+ if (nextNodes !== previousNodes) {
+ nodesRef.current = nextNodes;
+ setNodes(nextNodes);
+ }
+ connectionsRef.current = nextConnections;
+ setConnections(nextConnections);
+ setSelectedConnectionId((current) => (current && removedConnectionIds.has(current) ? null : current));
+ },
+ [connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId],
+ );
+
+ const handleReorderNodeReferences = useCallback(
+ (targetNodeId: string, orderedNodeIds: string[]) => {
+ const previousNodes = nodesRef.current;
+ const previousConnections = connectionsRef.current;
+ const nextConnections = reorderCanvasResourceConnections(targetNodeId, orderedNodeIds, previousNodes, previousConnections);
+ if (nextConnections === previousConnections) return;
+ const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections);
+ if (nextNodes !== previousNodes) {
+ nodesRef.current = nextNodes;
+ setNodes(nextNodes);
+ }
+ connectionsRef.current = nextConnections;
+ setConnections(nextConnections);
+ },
+ [connectionsRef, nodesRef, setConnections, setNodes],
+ );
+
+ const handleProjectFolderInsert = useCallback(
+ (folderId: string) => {
+ const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
+ if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
+ const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
+ const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
+ createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt });
+ },
+ [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition],
+ );
+
+ const handleFrameToggle = useCallback(
+ (nodeId: string) => {
+ const node = nodesRef.current.find((item) => item.id === nodeId);
+ const linkedFolderId = node?.metadata?.folder?.assetFolderId;
+ if (linkedFolderId) {
+ openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId);
+ return;
+ }
+ toggleFrameCollapsed(nodeId);
+ },
+ [nodesRef, openProjectAssets, toggleFrameCollapsed],
+ );
+
+ const linkedFolderPreviewNodesById = useMemo(() => {
+ const result = new Map();
+ const localById = new Map(assets.map((asset) => [asset.id, asset]));
+ for (const asset of linkedProjectQuery.data?.assets || []) {
+ if (!asset.folderId) continue;
+ const local = localById.get(asset.id);
+ const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
+ const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
+ const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
+ const content = characterCover
+ ? resourceFileUrl(characterCover.resourceId)
+ : local?.kind === "image"
+ ? local.data.dataUrl || local.coverUrl
+ : local?.kind === "video" || local?.kind === "audio"
+ ? local.data.url
+ : local?.kind === "text"
+ ? local.data.content
+ : remoteResourceId
+ ? resourceFileUrl(remoteResourceId)
+ : asset.previewText || "";
+ const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
+ const current = result.get(asset.folderId) || [];
+ current.push(preview);
+ result.set(asset.folderId, current);
+ }
+ return result;
+ }, [assets, linkedProjectQuery.data?.assets]);
+
+ useEffect(() => {
+ const folders = linkedProjectQuery.data?.assetFolders;
+ if (!folders?.length) return;
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
+ setNodes((current) => {
+ let changed = false;
+ const next = current.map((node) => {
+ const folderId = node.metadata?.folder?.assetFolderId;
+ const folder = folderId ? byId.get(folderId) : undefined;
+ if (!folder) return node;
+ const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass";
+ const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora";
+ if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node;
+ changed = true;
+ return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } };
+ });
+ return changed ? next : current;
+ });
+ }, [linkedProjectQuery.data?.assetFolders, setNodes]);
+
+ const {
+ activeDirectorScene,
+ activeNodeId,
+ activeScriptNode,
+ activeStylePresetId,
+ angleNode,
+ lightingNode,
+ emotionNode,
+ annotationNode,
+ batchChildCountById,
+ batchMotionById,
+ canvasImageNodes,
+ configInputsById,
+ connectionLayerBounds,
+ contextMenuNode,
+ cropNode,
+ displayConnections,
+ frameChildrenById,
+ imageAssets,
+ infoNode,
+ maskEditNode,
+ imageEditNode,
+ mentionReferencesByNodeId,
+ nodeById,
+ previewNode,
+ reduceMediaEffects,
+ relatedHighlight,
+ resourceReferenceByNodeId,
+ selectedNodeBounds,
+ selectedVideoNodes,
+ skillMentionReferences,
+ superResolveNode,
+ toolbarNode,
+ upscaleNode,
+ versionCompareNodes,
+ visibleNodes,
+ } = useCanvasRenderModel({
+ nodes,
+ connections,
+ assets,
+ viewport,
+ viewportSize: size,
+ mediaPerformanceMode,
+ selectedNodeIds,
+ hoveredNodeId,
+ dragPreview,
+ collapsingBatchIds,
+ addedSkills,
+ directorScenes: currentProject?.directorScenes,
+ infoNodeId,
+ cropNodeId,
+ maskEditNodeId,
+ imageEditNodeId,
+ annotationNodeId,
+ splitNodeId: null,
+ upscaleNodeId,
+ superResolveNodeId,
+ angleNodeId,
+ lightingNodeId,
+ emotionNodeId,
+ previewNodeId,
+ contextMenu,
+ versionCompareRootId,
+ directorNodeId,
+ scriptEditorNodeId,
+ dialogNodeId,
+ });
+ useEffect(() => {
+ setNodes((current) => {
+ let changed = false;
+ const next = current.map((node) => {
+ const references = mentionReferencesByNodeId.get(node.id);
+ const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt;
+ if (!references?.length || !savedPrompt?.includes("@[node:")) return node;
+ const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references);
+ if (normalizedPrompt === savedPrompt) return node;
+ changed = true;
+ return {
+ ...node,
+ metadata: node.metadata?.composerContent !== undefined ? { ...node.metadata, composerContent: normalizedPrompt } : { ...node.metadata, prompt: normalizedPrompt },
+ };
+ });
+ return changed ? next : current;
+ });
+ }, [mentionReferencesByNodeId, setNodes]);
+ const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
+ // dragPreview is published on the same pointer-down frame as isNodeDragging.
+ // Treat either signal as moving so floating editors disappear before the
+ // first preview transform is painted and never affect drag layout.
+ const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size);
+ const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
+ const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
+ const frameNode = frameDialogNodeId ? nodeById.get(frameDialogNodeId) || null : null;
+ const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null;
+ const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null;
+ const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
+ const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
+ const artCritiqueNode = artCritiqueNodeId ? nodeById.get(artCritiqueNodeId) || null : null;
+ const artCritiqueInputs = artCritiqueNode
+ ? connections
+ .filter((connection) => connection.toNodeId === artCritiqueNode.id)
+ .sort((left, right) => left.id.localeCompare(right.id))
+ .map((connection) => nodeById.get(connection.fromNodeId))
+ .filter((node): node is CanvasNodeData => Boolean(node))
+ : [];
+ const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
+ const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
+
+ const openTextNodeEditor = useCallback((node: CanvasNodeData) => {
+ if (node.type !== CanvasNodeType.Text) return;
+ setSelectedNodeIds(new Set([node.id]));
+ setSelectedConnectionId(null);
+ setContextMenu(null);
+ setDialogNodeId(null);
+ setToolbarNodeId(null);
+ if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) {
+ setCharacterReferenceNodeId(node.id);
+ return;
+ }
+ setTextEditorNodeId(node.id);
+ }, []);
+
+ const openDrawingNode = useCallback((node: CanvasNodeData) => {
+ if (node.type !== CanvasNodeType.Drawing) return;
+ setSelectedNodeIds(new Set([node.id]));
+ setSelectedConnectionId(null);
+ setContextMenu(null);
+ setDialogNodeId(null);
+ setToolbarNodeId(null);
+ setDrawingNodeId(node.id);
+ }, []);
+
+ const openArtCritique = useCallback((node: CanvasNodeData) => {
+ if (node.type !== ART_CRITIQUE_NODE_TYPE) return;
+ setSelectedNodeIds(new Set([node.id]));
+ setSelectedConnectionId(null);
+ setContextMenu(null);
+ setDialogNodeId(null);
+ setToolbarNodeId(null);
+ setArtCritiqueNodeId(node.id);
+ }, []);
+ const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
+ const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
+ const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
+ setNodesState((current) => {
+ const next = updateCanvasNode(current, nodeId, update);
+ nodesRef.current = next;
+ return next;
+ });
+ }, []);
+ const pendingMediaUpdatesRef = useRef(new Map CanvasNodeData>());
+ const mediaUpdateTimerRef = useRef | null>(null);
+ const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
+ const previous = pendingMediaUpdatesRef.current.get(nodeId);
+ pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
+ if (mediaUpdateTimerRef.current) return;
+ mediaUpdateTimerRef.current = setTimeout(() => {
+ const updates = pendingMediaUpdatesRef.current;
+ pendingMediaUpdatesRef.current = new Map();
+ mediaUpdateTimerRef.current = null;
+ if (!updates.size) return;
+ setNodesState((current) => {
+ const next = updateCanvasNodes(current, updates);
+ nodesRef.current = next;
+ return next;
+ });
+ }, 120);
+ }, []);
+ const updateNodeMetadataFromContent = useCallback(
+ (nodeId: string, patch: CanvasNodeMetadata) => {
+ updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
+ },
+ [updateNodeFromContent],
+ );
+ const canvasNodeActions = useMemo(
+ () => ({
+ upload: replaceCanvasNodeMedia,
+ download: downloadNodeImage,
+ duplicate: duplicateNodeFromContent,
+ deleteNode: deleteNodeFromContent,
+ updateMetadata: updateNodeMetadataFromContent,
+ updateNode: updateNodeFromContent,
+ updateMediaNode: updateMediaNodeFromContent,
+ openArtCritique,
+ addPanoramaCaptureNode,
+ }),
+ [addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent],
+ );
+ const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({
+ projectId,
+ domainProjectId: currentProject?.projectId,
+ projectTitle: currentProject?.title || "未命名画布",
+ nodes,
+ connections,
+ selectedNodeIds,
+ viewport,
+ nodesRef,
+ connectionsRef,
+ selectedNodeIdsRef,
+ viewportRef,
+ generateNodeRef,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setViewport,
+ setContextMenu,
+ focusSelection: fitCanvasSelection,
+ });
+
+ const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({
+ canvasId: projectId,
+ domainProjectId: currentProject?.projectId,
+ nodesRef,
+ selectedNodeIdsRef,
+ getCanvasCenter,
+ setNodes,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setDialogNodeId,
+ setStylePickerOpen,
+ });
+
+ const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({
+ projectId,
+ domainProjectId: currentProject?.projectId,
+ directorNodeId,
+ directorScenes: currentProject?.directorScenes || [],
+ nodesRef,
+ connectionsRef,
+ getCanvasCenter,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setDirectorNodeId,
+ updateProject,
+ });
+
+ const {
+ activateStep: activateShortDramaStep,
+ createPipeline: createShortDramaPipeline,
+ guideCollapsed: shortDramaGuideCollapsed,
+ openStoryInput,
+ progress: shortDramaProgress,
+ setGuideCollapsed: setShortDramaGuideCollapsed,
+ skipGuide: skipShortDramaGuide,
+ } = useCanvasShortDrama({
+ nodes,
+ connections,
+ nodesRef,
+ connectionsRef,
+ selectedNodeIdsRef,
+ getCanvasCenter,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setStylePickerOpen,
+ fitCanvasSelection,
+ focusCanvasNode,
+ openTextEditor: openTextNodeEditor,
+ });
+
+ const shortDramaGuide = shortDramaEnabled && !currentProject?.projectId && shortDramaProgress.active ? { progress: shortDramaProgress, collapsed: shortDramaGuideCollapsed, onToggle: () => setShortDramaGuideCollapsed((value) => !value) } : undefined;
+
+ const clearCanvas = useCallback(() => {
+ const drawingIds = nodesRef.current.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
+ if (drawingIds.length) {
+ void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("画布已清空,但部分本地绘图缓存清理失败"));
+ }
+ setNodes([]);
+ setConnections([]);
+ setTextEditorNodeId(null);
+ setDrawingNodeId(null);
+ setInfoNodeId(null);
+ setSubtitleNodeId(null);
+ setCropNodeId(null);
+ setMaskEditNodeId(null);
+ setAnnotationNodeId(null);
+ setAngleNodeId(null);
+ setLightingNodeId(null);
+ setEmotionNodeId(null);
+ setPreviewNodeId(null);
+ setRunningNodeId(null);
+ setArtCritiqueNodeId(null);
+ deselectCanvas();
+ setClearConfirmOpen(false);
+ clearCanvasFiles();
+ }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
+
+ useCanvasKeyboard({
+ enabled: projectLoaded && !versions.preview,
+ nodesRef,
+ selectedNodeIdsRef,
+ selectedConnectionId,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setContextMenu,
+ setShortcutRequestNonce,
+ setInfoNodeId,
+ setCropNodeId,
+ setMaskEditNodeId,
+ setAnnotationNodeId,
+ saveCanvasProject,
+ zoomToActualSize,
+ fitCanvasContent,
+ fitCanvasSelection,
+ undoCanvas,
+ redoCanvas,
+ cancelSelectionBox,
+ copySelectedNodes,
+ pasteCopiedNodes,
+ restoreCopiedNodesFromText,
+ shouldPreferCopiedNodes,
+ pasteSystemClipboard,
+ deleteNodes,
+ deleteConnection,
+ deselectCanvas,
+ zoomCanvasIn,
+ zoomCanvasOut,
+ focusMode,
+ exitFocusMode,
+ toggleFocusMode,
+ onOpenSearch: () => setNodeSearchOpen(true),
+ beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
+ });
+
+ const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
+ chatSessionsRef.current = sessions;
+ activeChatIdRef.current = activeId;
+ setChatSessions(sessions);
+ setActiveChatId(activeId);
+ }, []);
+
+ const startTitleEditing = useCallback(() => {
+ setTitleDraft(currentProject?.title || "未命名画布");
+ setTitleEditing(true);
+ }, [currentProject?.title]);
+
+ const finishTitleEditing = useCallback(() => {
+ const nextTitle = titleDraft.trim();
+ if (nextTitle) renameCurrentProject(nextTitle);
+ setTitleEditing(false);
+ }, [renameCurrentProject, titleDraft]);
+
+ const pasteAtPosition = useCallback(
+ (position: Position) => {
+ if (shouldPreferCopiedNodes() && pasteCopiedNodes(position)) return;
+ void (async () => {
+ try {
+ // 标记写入成功时仍优先系统图片,兼容截图和从外部应用复制的媒体。
+ const handled = await pasteSystemClipboard(position);
+ if (!handled) pasteCopiedNodes(position);
+ } catch {
+ if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容");
+ }
+ })();
+ },
+ [message, pasteCopiedNodes, pasteSystemClipboard, shouldPreferCopiedNodes],
+ );
+
+ const copyingNodeContentRef = useRef(false);
+ const copyNodeContentToClipboard = useCallback(
+ async (node: CanvasNodeData | null) => {
+ if (copyingNodeContentRef.current) return;
+ copyingNodeContentRef.current = true;
+ releaseCopiedNodesPastePriority();
+ const content = node?.metadata?.content?.trim();
+ const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey);
+ const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : "");
+ if (!node || !copySource) {
+ copyingNodeContentRef.current = false;
+ message.warning("没有可复制的内容");
+ return;
+ }
+
+ try {
+ if (node.type === CanvasNodeType.Image) {
+ try {
+ await copyImageToSystemClipboard(copySource, node.metadata?.storageKey);
+ message.success("图片已复制到剪贴板");
+ return;
+ } catch (imageErr) {
+ const fallbackUrl = new URL(copySource, window.location.href).toString();
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(fallbackUrl).catch(() => undefined);
+ message.info("由于浏览器未获得焦点,已为您复制图片地址");
+ return;
+ }
+ if (await copyToClipboard(fallbackUrl)) {
+ message.info("由于浏览器未获得焦点,已为您复制图片地址");
+ return;
+ }
+ throw imageErr;
+ }
+ }
+
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource);
+ else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板");
+ message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限");
+ } finally {
+ copyingNodeContentRef.current = false;
+ }
+ },
+ [message, releaseCopiedNodesPastePriority],
+ );
+
+ const copyNodeMediaUrlToClipboard = useCallback(
+ async (node: CanvasNodeData | null) => {
+ releaseCopiedNodesPastePriority();
+ try {
+ const storageKey = node?.metadata?.storageKey;
+ const content = node?.metadata?.content?.trim();
+ const resourceId = resourceIdFromStorageKey(storageKey);
+ const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : "";
+ const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : "";
+ if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址");
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL);
+ else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板");
+ message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "媒体地址复制失败");
+ }
+ },
+ [message, releaseCopiedNodesPastePriority],
+ );
+
+ const uploadNodeImageToArkPrivateAsset = useCallback(
+ async (node: CanvasNodeData) => {
+ if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
+ message.warning("请选择一张可用图片后再上传");
+ return;
+ }
+ if (arkPrivateAssetUploadNodeId === node.id) return;
+ const feedbackKey = `ark-private-asset-${node.id}`;
+ setArkPrivateAssetUploadNodeId(node.id);
+ message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 });
+ try {
+ let resourceID = resourceIdFromStorageKey(node.metadata.storageKey);
+ let persistedNode = node;
+ if (!resourceID) {
+ const uploaded = await uploadImage(node.metadata.content);
+ resourceID = resourceIdFromStorageKey(uploaded.storageKey);
+ if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试");
+ handleConfigNodeChange(node.id, imageMetadata(uploaded));
+ persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } };
+ }
+ const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" });
+ handleConfigNodeChange(node.id, { assetId: asset.assetId });
+ await syncResourceToArkPrivateAsset(resourceID);
+ message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 });
+ } catch (error) {
+ message.error({ key: feedbackKey, content: error instanceof Error ? error.message : "上传到方舟素材库失败", duration: 5 });
+ } finally {
+ setArkPrivateAssetUploadNodeId((current) => (current === node.id ? null : current));
+ }
+ },
+ [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId],
+ );
+
+ const confirmUploadNodeImageToArkPrivateAsset = useCallback(
+ (node: CanvasNodeData) => {
+ modal.confirm({
+ title: "上传到方舟素材库",
+ content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。",
+ okText: "确认拥有使用权并上传",
+ cancelText: "取消",
+ onOk: () => uploadNodeImageToArkPrivateAsset(node),
+ });
+ },
+ [modal, uploadNodeImageToArkPrivateAsset],
+ );
+
+ const handleCanvasContextMenu = useCallback(
+ (event: ReactMouseEvent) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (target?.closest("[data-node-id],[data-connection-id]")) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown")) {
+ setContextMenu(null);
+ return;
+ }
+
+ closeConnectionCreateMenu();
+ setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY) });
+ },
+ [closeConnectionCreateMenu, screenToCanvas],
+ );
+
+ const handleNodeContextMenu = useCallback(
+ (event: ReactMouseEvent, id: string) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setSelectedNodeIds((current) => {
+ if (current.has(id) && current.size > 1) return current;
+ return new Set([id]);
+ });
+ setSelectedConnectionId(null);
+ closeConnectionCreateMenu();
+ setToolbarNodeId(null);
+ setDialogNodeId(null);
+ setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
+ },
+ [closeConnectionCreateMenu],
+ );
+
+ const handleGenerateNode = useCanvasGenerationExecutor({
+ projectId,
+ domainProjectId: currentProject?.projectId,
+ addedSkills,
+ assets,
+ nodesRef,
+ connectionsRef,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ setSelectedConnectionId,
+ setDialogNodeId,
+ setRunningNodeId,
+ startGenerationRequest,
+ finishGenerationRequest,
+ bindGenerationTask,
+ applyGenerationTaskResult,
+ });
+ useEffect(() => {
+ generateNodeRef.current = handleGenerateNode;
+ }, [handleGenerateNode]);
+
+ const { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({
+ projectId,
+ projectLoaded,
+ nodes,
+ nodesRef,
+ setNodes,
+ handleGenerateNode,
+ });
+
+ const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({
+ nodesRef,
+ connectionsRef,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ enqueueGenerationBatch,
+ });
+
+ useEffect(() => {
+ if (!projectLoaded) return;
+ setNodes((current) => {
+ let changed = false;
+ const next = current.map((node) => {
+ const promoted = promoteLegacyBatchTableSize(node);
+ if (promoted !== node) changed = true;
+ return promoted;
+ });
+ return changed ? next : current;
+ });
+ nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => {
+ syncRowsFromConnections(node.id, true);
+ });
+ }, [connections, projectLoaded, setNodes, syncRowsFromConnections]);
+
+ const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => {
+ const tableNode = nodesRef.current.find((item) => item.id === tableNodeId);
+ const table = tableNode?.metadata?.batchTable;
+ const row = table?.rows.find((item) => item.id === rowId);
+ if (!tableNode || !table || !row) return;
+ const existingId = row.inputNodeIds[columnIndex];
+ if (existingId) {
+ await replaceNodeMedia(existingId, file);
+ return;
+ }
+ const rowIndex = table.rows.findIndex((item) => item.id === rowId);
+ const uploadedNodeId = await createFileNode(file, {
+ x: tableNode.position.x - 240,
+ y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220,
+ });
+ if (!uploadedNodeId) return;
+ const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || "");
+ inputNodeIds[columnIndex] = uploadedNodeId;
+ updateBatchRow(tableNodeId, rowId, { inputNodeIds });
+ message.success(`已上传并填入参考图 ${columnIndex + 1}`);
+ }, [createFileNode, message, nodesRef, replaceNodeMedia, updateBatchRow]);
+
+ const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } =
+ useCanvasStoryboard({
+ projectId,
+ addedSkills,
+ nodesRef,
+ connectionsRef,
+ setNodes,
+ setConnections,
+ setSelectedNodeIds,
+ enqueueGenerationBatch,
+ });
+
+ const handleRetryNode = useCanvasGenerationRetry({
+ projectId,
+ domainProjectId: currentProject?.projectId,
+ addedSkills,
+ assets,
+ nodesRef,
+ connectionsRef,
+ setNodes,
+ setRunningNodeId,
+ startGenerationRequest,
+ finishGenerationRequest,
+ bindGenerationTask,
+ applyGenerationTaskResult,
+ });
+ const reloadCanvasNodeResource = useCallback(
+ async (node: CanvasNodeData) => {
+ const taskId = node.metadata?.taskId;
+ if (!taskId || !node.metadata?.resourceReloadAvailable) return;
+ setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", taskStage: "正在重新加载资源", errorDetails: undefined } } : item)));
+ try {
+ const task = await queryGenerationTask(taskId);
+ if (task.status !== "succeeded") throw new Error("原生成任务尚未成功,无法重新加载资源");
+ await applyGenerationTaskResult(node.id, task);
+ } catch (error) {
+ setNodes((current) =>
+ current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "资源重新加载失败", resourceReloadAvailable: true } } : item)),
+ );
+ }
+ },
+ [applyGenerationTaskResult, setNodes],
+ );
+ const reconcileImageBatchRootNode = useCallback(
+ (rootId: string) => {
+ setNodes((current) => {
+ const root = current.find((item) => item.id === rootId);
+ if (!root) return current;
+ const reconciled = reconcileImageBatchRoot(root, current);
+ return current.map((item) => (item.id === root.id ? reconciled : item));
+ });
+ },
+ [setNodes],
+ );
+ const retryImageBatchChildren = useCallback(
+ (rootId: string, children: CanvasNodeData[]) => {
+ const childIds = children.map((child) => child.id);
+ setNodes((current) => markImageBatchRetrying(rootId, childIds, current));
+ void Promise.allSettled(
+ children.map(async (child) => {
+ await handleRetryNode(child);
+ setNodes((current) => current.map((item) => (item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item)));
+ }),
+ ).finally(() => reconcileImageBatchRootNode(rootId));
+ },
+ [handleRetryNode, reconcileImageBatchRootNode, setNodes],
+ );
+
+ const generateImageFromTextNode = useCallback(
+ (node: CanvasNodeData) => {
+ const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
+ if (!prompt) {
+ message.warning("文本节点为空,无法生图");
+ return;
+ }
+ const sourceNode = nodesRef.current.find((item) => item.id === node.id);
+ if (!sourceNode) return;
+ const nodeSize = getNodeSpec(CanvasNodeType.Image);
+ const imageNode = createCanvasNode(
+ CanvasNodeType.Image,
+ {
+ x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2,
+ y: sourceNode.position.y + sourceNode.height / 2,
+ },
+ {
+ prompt: "@文本1",
+ composerContent: "@文本1",
+ model: effectiveConfig.imageModel || effectiveConfig.model,
+ size: effectiveConfig.size,
+ quality: effectiveConfig.quality,
+ transparentBackground: effectiveConfig.transparentBackground,
+ count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count),
+ },
+ );
+ imageNode.title = "图片生成";
+ const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: imageNode.id };
+ const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(imageNode);
+ const nextConnections = [...connectionsRef.current, connection];
+ nodesRef.current = nextNodes;
+ connectionsRef.current = nextConnections;
+ setNodes(nextNodes);
+ setConnections(nextConnections);
+ setSelectedNodeIds(new Set([imageNode.id]));
+ setSelectedConnectionId(null);
+ setDialogNodeId(imageNode.id);
+ },
+ [effectiveConfig, message],
+ );
+
+ const renderCanvasNodePanel = useCallback(
+ (panelNode: CanvasNodeData) => {
+ if (panelNode.type === CanvasNodeType.Script || panelNode.type === CanvasNodeType.Drawing) return null;
+ return panelNode.type === CanvasNodeType.Config ? (
+ handleConfigNodeChange(panelNode.id, { composerContent })}
+ onMetadataChange={(patch) => handleConfigNodeChange(panelNode.id, patch)}
+ onClose={() => setDialogNodeId(null)}
+ />
+ ) : (
+ reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)),
+ ]}
+ onAddReference={(nodeId, reference) => {
+ if (reference.active || reference.assetId || reference.kind === "skill") return reference;
+ if (reference.kind !== "text" || !reference.nodeId || reference.nodeId === nodeId) return undefined;
+ try {
+ const linked = connectCanvasTextMention(nodesRef.current, connectionsRef.current, nodeId, reference.nodeId, nanoid());
+ nodesRef.current = linked.nodes;
+ connectionsRef.current = linked.connections;
+ setNodes(linked.nodes);
+ setConnections(linked.connections);
+ return linked.reference;
+ } catch (cause) {
+ message.warning(cause instanceof Error ? cause.message : "文本引用失败");
+ return undefined;
+ }
+ }}
+ onPromptChange={handleNodePromptChange}
+ onConfigChange={handleConfigNodeChange}
+ onGenerate={handleGenerateNode}
+ onRemoveReference={handleRemoveNodeReference}
+ onReorderReferences={handleReorderNodeReferences}
+ onReplaceReference={handleReplaceNodeReference}
+ onReplaceReferenceFiles={handleReplaceNodeReferenceFiles}
+ onClose={() => setDialogNodeId(null)}
+ onNodeMouseDown={handleNodeMouseDown}
+ workspaceMode={workspaceMode}
+ onImageSettingsOpenChange={(open) => {
+ setNodeImageSettingsOpen(open);
+ if (open) setToolbarNodeId(null);
+ }}
+ />
+ );
+ },
+ [
+ configInputsById,
+ handleConfigNodeChange,
+ handleGenerateNode,
+ handleNodePromptChange,
+ handleRemoveNodeReference,
+ handleReorderNodeReferences,
+ handleReplaceNodeReference,
+ handleReplaceNodeReferenceFiles,
+ mentionReferencesByNodeId,
+ message,
+ projectId,
+ runningNodeId,
+ skillMentionReferences,
+ workspaceMode,
+ ],
+ );
+
+ const renderCanvasNodeContent = useCallback(
+ (contentNode: CanvasNodeData) => {
+ if (contentNode.metadata?.workflowKind === "character" && contentNode.metadata.characterAssetId) {
+ return ;
+ }
+ if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) {
+ return setStylePickerOpen(true)} />;
+ }
+ if (contentNode.metadata?.workflowKind === "story_input") {
+ return openStoryInput(contentNode.id)} />;
+ }
+ if (contentNode.type === CanvasNodeType.BatchTable) {
+ return (
+ patchBatchTable(contentNode.id, patch)}
+ onAddRow={() => addBatchRow(contentNode.id)}
+ onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)}
+ onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)}
+ onFillRows={() => fillRowsFromConnections(contentNode.id)}
+ onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)}
+ onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
+ onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)}
+ onRemoveReferenceColumn={() => removeBatchReferenceColumn(contentNode.id)}
+ onFocusOutput={(nodeId) => focusCanvasImageNode(nodeId)}
+ onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)}
+ onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)}
+ onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}
+ onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)}
+ onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)}
+ />
+ );
+ }
+ if (contentNode.type === CanvasNodeType.Script) {
+ const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current);
+ return (
+ setScriptEditorNodeId(contentNode.id)}
+ onCreateImageNodes={() => createScriptImageNodes(contentNode.id)}
+ onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)}
+ onGenerateImages={(rowIds) => void generateScriptImages(contentNode.id, rowIds)}
+ onGenerateVideos={(rowIds) => (contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds))}
+ onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })}
+ onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)}
+ onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)}
+ onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)}
+ onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)}
+ onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)}
+ onAddRow={() => addScriptRow(contentNode.id)}
+ onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)}
+ onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)}
+ onPromptChange={(composerContent) => handleConfigNodeChange(contentNode.id, { composerContent })}
+ onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)}
+ onModelChange={(model) => handleConfigNodeChange(contentNode.id, { model })}
+ onShotDurationChange={(duration: StoryboardShotDuration) => handleConfigNodeChange(contentNode.id, { storyboardShotDuration: duration })}
+ onShotCountChange={(count: StoryboardShotCount) => handleConfigNodeChange(contentNode.id, { storyboardShotCount: count })}
+ workspaceMode={workspaceMode}
+ onComposerHeightChange={(height) => {
+ if (contentNode.metadata?.storyboardComposerHeight === height) return;
+ handleConfigNodeChange(contentNode.id, { storyboardComposerHeight: height });
+ const minHeight = storyboardMinNodeHeight(height);
+ if (contentNode.height < minHeight) handleNodeResize(contentNode.id, contentNode.width, minHeight);
+ }}
+ onConnectStart={(event, rowId, handleType) => handleConnectStart(event, contentNode.id, handleType, rowId === "context" ? "storyboard:context" : `row:${rowId}`)}
+ onScrollTopChange={(scrollTop) => setScriptScrollTopById((current) => (current[contentNode.id] === scrollTop ? current : { ...current, [contentNode.id]: scrollTop }))}
+ />
+ );
+ }
+ if (contentNode.metadata?.directorSceneId) {
+ return (
+ scene.id === contentNode.metadata?.directorSceneId) || null}
+ readNodeContent={(nodeId) => (nodeId ? nodesRef.current.find((item) => item.id === nodeId)?.metadata?.content : undefined)}
+ professional={workspaceMode === "professional"}
+ onOpen={() => openDirectorWorkbench(contentNode.id)}
+ />
+ );
+ }
+ return (
+ setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
+ onGenerate={(nodeId) => {
+ const target = nodesRef.current.find((item) => item.id === nodeId);
+ void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
+ }}
+ workspaceMode={workspaceMode}
+ />
+ );
+ },
+ [
+ addBatchReferenceColumn,
+ addBatchRow,
+ addScriptRow,
+ configInputsById,
+ connections,
+ createAndGenerateScriptVideos,
+ createScriptActionBoards,
+ createScriptImageNodes,
+ createScriptVideoNodes,
+ currentProject?.directorScenes,
+ fillRowsFromConnections,
+ focusCanvasImageNode,
+ generateBatchRows,
+ generateScriptImages,
+ generateScriptRows,
+ generateScriptVideos,
+ handleUploadBatchReference,
+ moveBatchReferenceCell,
+ handleConfigNodeChange,
+ handleConnectDrop,
+ handleConnectStart,
+ handleGenerateNode,
+ handleNodeResize,
+ mentionReferencesByNodeId,
+ mergeVideosByIds,
+ openDirectorWorkbench,
+ openStoryInput,
+ patchBatchTable,
+ removeBatchReferenceColumn,
+ removeBatchRow,
+ reorderBatchReferenceColumns,
+ replaceCanvasNodeMedia,
+ removeScriptRow,
+ retryFailedBatchItems,
+ runningNodeId,
+ stopRemainingBatchItems,
+ theme,
+ updateBatchRow,
+ updateScriptRow,
+ viewport.k,
+ workspaceMode,
+ ],
+ );
+
+ const handleCanvasNodeHoverStart = useCallback(
+ (nodeId: string) => {
+ if (nodeDraggingRef.current) return;
+ setHoveredNodeId(nodeId);
+ keepNodeToolbar(nodeId);
+ },
+ [keepNodeToolbar],
+ );
+ const handleCanvasNodeHoverEnd = useCallback(
+ (nodeId: string) => {
+ setHoveredNodeId((current) => (current === nodeId ? null : current));
+ hideNodeToolbar();
+ },
+ [hideNodeToolbar],
+ );
+ const retryCanvasNode = useCallback(
+ (node: CanvasNodeData) => {
+ if (node.type === CanvasNodeType.Script) {
+ const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim();
+ if (!prompt) {
+ message.warning("分镜脚本缺少剧情内容,无法重试");
+ return;
+ }
+ void generateScriptRows(node.id, prompt);
+ return;
+ }
+ if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) {
+ const failedChildren = failedImageBatchChildren(node, nodesRef.current);
+ if (!failedChildren.length) {
+ message.info("当前批次没有需要重试的失败图片");
+ return;
+ }
+ message.info(`正在重试 ${failedChildren.length} 个失败图片`);
+ retryImageBatchChildren(node.id, failedChildren);
+ return;
+ }
+ if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) {
+ retryImageBatchChildren(node.metadata.batchRootId, [node]);
+ return;
+ }
+ void handleRetryNode(node);
+ },
+ [generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren],
+ );
+ const openCanvasNodeTaskDetails = useCallback(
+ (node: CanvasNodeData) => {
+ void openNodeTaskDetails(node);
+ },
+ [openNodeTaskDetails],
+ );
+ const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []);
+ const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
+ const editCanvasDirector = useCallback((node: CanvasNodeData) => openDirectorWorkbench(node.id), [openDirectorWorkbench]);
+ const locateProjectStyleNode = useCallback(() => {
+ const styleNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
+ if (!styleNode) {
+ message.info("项目画风节点正在同步,请稍后再试");
+ return;
+ }
+ focusCanvasNode(styleNode.id);
+ }, [focusCanvasNode, message, nodesRef]);
+ const freeformCreateCommands = useCanvasCreateCommands({
+ workspaceMode,
+ isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId),
+ handlers: {
+ onAddText: () => createNode(CanvasNodeType.Text),
+ onAddImage: () => createNode(CanvasNodeType.Image),
+ onAddVideo: () => createNode(CanvasNodeType.Video),
+ onAddAudio: () => createNode(CanvasNodeType.Audio),
+ onAddScript: () => createNode(CanvasNodeType.Script),
+ onAddFrame: () => createNode(CanvasNodeType.Frame),
+ onAddFolder: createFolder,
+ onAddDrawing: () => createNode(CanvasNodeType.Drawing),
+ onAddWorkflow: () => createNode(CanvasNodeType.Config),
+ onAddExtensionNode: (type) => createNode(type),
+ onChooseStyle: () => setStylePickerOpen(true),
+ onOpenDirector: () => setDirectorTemplateRequest({}),
+ onUpload: () => handleUploadRequest(),
+ onOpenMyAssets: () => openCanvasAssetLibrary(),
+ onOpenProjectCharacters: () => openProjectAssets("character"),
+ },
+ });
+ const emptyStateKind = resolveCanvasEmptyStateKind({
+ nodeCount: nodes.length,
+ shortDramaEnabled,
+ isProjectLinked: Boolean(currentProject?.projectId),
+ starterMode: currentProject?.starterMode,
+ });
+ const emptyCanvasState =
+ emptyStateKind === "freeform" ? (
+
+ ) : emptyStateKind === "linked" ? (
+ {
+ const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0];
+ if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position });
+ }}
+ onOpenAssets={() => openProjectAssets()}
+ onAddText={() => createNode(CanvasNodeType.Text)}
+ />
+ ) : emptyStateKind === "guided" ? (
+ {
+ setCinematicAgentEntry(true);
+ openAgent();
+ }}
+ onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })}
+ onUpload={() => handleUploadRequest()}
+ onAddText={() => createNode(CanvasNodeType.Text)}
+ onAddScript={() => createNode(CanvasNodeType.Script)}
+ />
+ ) : null;
+ if (!projectLoaded && loadError)
+ return (
+
+ {loadError}
+ 重新加载
+ 返回画布库
+
+ );
+ if (!projectLoaded) return ;
+
+ return (
+ <>
+
+ 跳转到画布主内容
+
+
+ {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
+ openProjectAssets()} />
+ ) : null}
+
+
+
+ {!focusMode ? (
+ }
+ versionsOpen={versions.open}
+ onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
+ title={currentProject?.title || "未命名画布"}
+ titleDraft={titleDraft}
+ isTitleEditing={titleEditing}
+ onTitleDraftChange={setTitleDraft}
+ onStartTitleEditing={startTitleEditing}
+ onFinishTitleEditing={finishTitleEditing}
+ onCancelTitleEditing={() => setTitleEditing(false)}
+ canUndo={historyState.canUndo}
+ canRedo={historyState.canRedo}
+ onCreateProject={createAndOpenProject}
+ onDeleteProject={deleteCurrentProject}
+ onSave={() => void saveCanvasProject()}
+ onForceSave={confirmForceSaveCanvas}
+ onImportImage={() => handleUploadRequest()}
+ onImportLibTV={() => setLibTVImportOpen(true)}
+ onImportTapNow={() => setTapNowImportOpen(true)}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onShare={() => setShareModalOpen(true)}
+ shortcutRequestNonce={shortcutRequestNonce}
+ mediaPerformanceMode={mediaPerformanceMode}
+ onMediaPerformanceModeChange={setMediaPerformanceMode}
+ onOpenSearch={() => setNodeSearchOpen(true)}
+ projectContext={
+ shortDramaEnabled && currentProject?.projectId
+ ? {
+ ...canvasContext,
+ projectId: currentProject.projectId,
+ projectName: linkedProjectQuery.data?.project.name || currentProject.title,
+ }
+ : undefined
+ }
+ onEnterFocusMode={enterFocusMode}
+ shortDramaGuide={shortDramaGuide}
+ />
+ ) : null}
+
+ setNodeSearchOpen(false)}
+ onFocus={(nodeId) => {
+ const target = nodeById.get(nodeId);
+ const parent = target?.parentId ? nodeById.get(target.parentId) : null;
+ if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
+ const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
+ if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
+ const selection = new Set([nodeId]);
+ selectedNodeIdsRef.current = selection;
+ setSelectedNodeIds(selection);
+ setSelectedConnectionId(null);
+ focusCanvasNode(nodeId);
+ }}
+ />
+
+ {!focusMode && shortDramaGuide ? (
+
+ ) : null}
+
+ setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
+ setLibTVImportOpen(false)} onApply={applyLibTVImport} />
+ setTapNowImportOpen(false)} onApply={applyTapNowImport} />
+
+ setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
+
+ setDirectorTemplateRequest(null)} onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)} />
+
+
+
+
+ }
+ onViewportChange={handleViewportChange}
+ onViewportPreviewChange={handleViewportPreviewChange}
+ onCanvasMouseDown={handleCanvasMouseDown}
+ boxSelectEnabled={canvasTool === "box-select"}
+ onCanvasDoubleClick={handleCanvasDoubleClick}
+ onCanvasDeselect={deselectCanvas}
+ onContextMenu={handleCanvasContextMenu}
+ onDrop={handleDrop}
+ onFileDragEnter={handleFileDragEnter}
+ onFileDragLeave={handleFileDragLeave}
+ onFileDragOver={handleFileDragOver}
+ >
+
+
+ {
+ setSelectedConnectionId(connectionId);
+ setSelectedNodeIds(new Set());
+ setContextMenu(null);
+ }}
+ onConnectionContextMenu={(event, connectionId) => {
+ setSelectedConnectionId(connectionId);
+ setSelectedNodeIds(new Set());
+ closeConnectionCreateMenu();
+ setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId });
+ }}
+ onNodeMouseDown={handleNodeMouseDown}
+ onNodeHoverStart={handleCanvasNodeHoverStart}
+ onNodeHoverEnd={handleCanvasNodeHoverEnd}
+ onConnectStart={handleConnectStart}
+ onNodeResize={handleNodeResize}
+ onToggleFrame={handleFrameToggle}
+ onFolderStyleChange={handleFolderStyleChange}
+ onFolderThemeChange={handleFolderThemeChange}
+ onNodeTitleChange={handleNodeTitleChange}
+ onNodeContextMenu={handleNodeContextMenu}
+ onNodeContentChange={handleNodeContentChange}
+ onToggleBatch={toggleBatchExpanded}
+ onSetBatchPrimary={setBatchPrimary}
+ onRetry={retryCanvasNode}
+ onReloadResource={reloadCanvasNodeResource}
+ onOpenTaskDetails={openCanvasNodeTaskDetails}
+ onOpenVersions={openCanvasNodeVersions}
+ onViewImage={viewCanvasNodeImage}
+ onReplaceMedia={replaceCanvasNodeMedia}
+ onOpenTextEditor={openTextNodeEditor}
+ onOpenDirector={editCanvasDirector}
+ onOpenDrawing={openDrawingNode}
+ onStartBatchConnection={startBatchConnection}
+ />
+
+
+
+
+
+
+ {focusMode ? (
+ }
+ versionsOpen={versions.open}
+ onToggleVersions={() => { closeAgent(); versions.toggle(); }}
+ dockRevealed={focusDockRevealed}
+ zoomPercent={viewport.k}
+ onToggleDock={() => setFocusDockRevealed((value) => !value)}
+ onExit={exitFocusMode}
+ onZoomIn={zoomCanvasIn}
+ onZoomOut={zoomCanvasOut}
+ onFit={fitCanvasContent}
+ />
+ ) : null}
+
+
+
+ {emptyCanvasState}
+
+ {!focusMode || focusDockRevealed ? (
+ createNode(CanvasNodeType.Image)}
+ onAddVideo={() => createNode(CanvasNodeType.Video)}
+ onAddAudio={() => createNode(CanvasNodeType.Audio)}
+ onAddText={() => createNode(CanvasNodeType.Text)}
+ onChooseStyle={() => setStylePickerOpen(true)}
+ onAddScript={() => createNode(CanvasNodeType.Script)}
+ onAddFrame={() => createNode(CanvasNodeType.Frame)}
+ onAddFolder={createFolder}
+ onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
+ onAddExtensionNode={(type) => createNode(type)}
+ onAddWorkflow={() => createNode(CanvasNodeType.Config)}
+ onOpenDirector={() => setDirectorTemplateRequest({})}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onUpload={() => handleUploadRequest()}
+ onDelete={() => deleteNodes(new Set(selectedNodeIds))}
+ onClear={() => setClearConfirmOpen(true)}
+ onDeselect={deselectCanvas}
+ onAppearanceChange={applyCanvasAppearance}
+ onSaveAppearanceDefault={saveCanvasAppearanceDefault}
+ onBackgroundModeChange={setBackgroundMode}
+ onShowImageInfoChange={setShowImageInfo}
+ onOpenMyAssets={() => {
+ openCanvasAssetLibrary();
+ }}
+ onOpenProjectCharacters={() => openProjectAssets("character")}
+ />
+ ) : null}
+
+
+
+ {
+ if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
+ focusCanvasNode(nodeId);
+ }} />
+
+
+
+ {angleNode?.metadata?.content ? (
+
+ setAngleNodeId(null)}
+ onConfirm={(params) => {
+ void generateAngleNode(angleNode, params);
+ }}
+ />
+
+ ) : null}
+
+ {lightingNode?.metadata?.content ? (
+ setLightingNodeId(null)}>
+ setLightingNodeId(null)}
+ onConfirm={(options, prompt) => {
+ generateLightingNode(lightingNode, options, prompt);
+ }}
+ />
+
+ ) : null}
+
+ {emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
+ setEmotionNodeId(null)}
+ onConfirm={(payload: CanvasImageEmotionPayload) => {
+ void generateEmotionNode(emotionNode, payload);
+ }}
+ />
+ ) : null}
+
+ {dialogNode &&
+ !isCanvasImageSourceNode(dialogNode) &&
+ !dialogNode.metadata?.fileUpload &&
+ dialogNode.type !== CanvasNodeType.Script &&
+ dialogNode.type !== CanvasNodeType.BatchTable &&
+ dialogNode.type !== CanvasNodeType.Drawing &&
+ dialogNode.type !== CanvasNodeType.Panorama &&
+ !selectionBox &&
+ !isCanvasNodeMoving ? (
+
+ {renderCanvasNodePanel(dialogNode)}
+
+ ) : null}
+
+ {pendingConnectionCreate ? (
+ getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
+ onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
+ onClose={cancelPendingConnectionCreate}
+ />
+ ) : null}
+
+ {connectionReplaceHover ? (
+
+
+
松开替换
+
@{connectionReplaceHover.referenceLabel}
+
+ ) : null}
+
+ {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
+ beginBatchConnectionMode(Array.from(selectedNodeIds))}
+ onMergeVideos={() => void mergeSelectedVideos()}
+ onSendSelectionToAgent={() => sendSelectionToAgent()}
+ />
+ ) : null}
+
+ {uploadStatus ? : null}
+ {mergeVideoProgress ? : null}
+ {lastAgentChange ? (
+ {
+ undoAgentOps();
+ }}
+ onClose={dismissLastAgentChange}
+ />
+ ) : null}
+
+ (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))}
+ onEditText={openTextNodeEditor}
+ onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
+ onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
+ onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
+ onGenerateImage={generateImageFromTextNode}
+ onUpload={(node) => handleUploadRequest(node.id)}
+ onDownload={downloadNodeImage}
+ onSaveAsset={(node) => void saveNodeAsset(node)}
+ onAnnotate={(node) => setAnnotationNodeId(node.id)}
+ onAnnotationEdit={openAnnotationEditNode}
+ onMaskEdit={(node) => setMaskEditNodeId(node.id)}
+ onRemoveBackground={openBackgroundRemoval}
+ onLayerDecomposition={openLayerDecomposition}
+ onTextEdit={openTextEditNode}
+ onEmotion={(node) => {
+ setDialogNodeId(null);
+ setEmotionNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onPortraitTexture={openPortraitTextureEditor}
+ onCrop={(node) => setCropNodeId(node.id)}
+ onSplit={(node, params) => void splitImageNode(node, params)}
+ onUpscale={(node) => setUpscaleNodeId(node.id)}
+ onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
+ onAngle={(node) => {
+ setDialogNodeId(null);
+ setAngleNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onLighting={(node) => {
+ setDialogNodeId(null);
+ setLightingNodeId((current) => (current === node.id ? null : node.id));
+ }}
+ onPanorama={openPanoramaConfig}
+ onViewImage={(node) => setPreviewNodeId(node.id)}
+ onExtractVideoFrames={openVideoFrameExtractor}
+ onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
+ onTrimVideoSegments={openVideoSegmentExtractor}
+ onSubtitles={(node) => setSubtitleNodeId(node.id)}
+ onTimeline={(node) => setTimelineNodeId(node.id)}
+ extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
+ extractingAudio={segmentRunningMode === "audio"}
+ trimmingVideo={segmentRunningMode === "video"}
+ onReversePrompt={createImageReversePromptNodes}
+ onRetry={retryCanvasNode}
+ onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
+ onToggleLocked={(node) => toggleNodeLocked(node.id)}
+ onDelete={(node) => deleteNodes(new Set([node.id]))}
+ />
+
+{isMiniMapOpen && !focusMode ? : null}
+
+ {!focusMode ? (
+ event.stopPropagation()}
+ onPointerDown={(event) => event.stopPropagation()}
+ onWheel={(event) => event.stopPropagation()}
+ >
+ setIsMiniMapOpen((value) => !value)}
+ onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
+ />
+ void createImageAssetNode(asset)}
+ onFocusCanvasImage={focusCanvasImageNode}
+ />
+
+ ) : null}
+
+ setContextMenu(null)}
+ onAddNode={(type, position) => createNode(type, position)}
+ onAddFolder={createFolder}
+ onChooseStyle={() => setStylePickerOpen(true)}
+ onOpenDirector={(position) => setDirectorTemplateRequest({ position })}
+ onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
+ onOpenAssets={openCanvasAssetLibrary}
+ onOpenProjectCharacters={(position) => openProjectAssets("character", position)}
+ onUndo={undoCanvas}
+ onRedo={redoCanvas}
+ onPaste={pasteAtPosition}
+ onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
+ onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
+ onDuplicate={duplicateNode}
+ onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
+ onDeleteConnection={deleteConnection}
+ onSaveAsset={(node) => {
+ void saveNodeAsset(node);
+ }}
+ onViewMedia={(node) => setPreviewNodeId(node.id)}
+ onEditText={openTextNodeEditor}
+ onOpenDrawing={openDrawingNode}
+ onGenerateImage={generateImageFromTextNode}
+ onCopyContent={(node) => {
+ void copyNodeContentToClipboard(node);
+ }}
+ onCopyMediaUrl={(node) => {
+ void copyNodeMediaUrlToClipboard(node);
+ }}
+ onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
+ onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
+ onToggleFrame={(node) => handleFrameToggle(node.id)}
+ onSpreadSelection={spreadSelectedNodes}
+ onCopySelection={copySelectedNodes}
+ onDeleteSelection={() => deleteNodes(selectedNodeIds)}
+ onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
+ />
+
+
+
+
+
+ setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} />
+
+ {subtitleNode ? (
+ setSubtitleNodeId(null)}
+ onSave={(nodeId, patch) => {
+ handleConfigNodeChange(nodeId, patch);
+ const currentTimeline = currentProject?.timeline;
+ if (currentTimeline) {
+ const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []);
+ if (next !== currentTimeline) updateProject(projectId, { timeline: next });
+ }
+ }}
+ />
+ ) : null}
+
+ {frameNode ? void extractVideoFrames(frameNode, params)} /> : null}
+
+ {segmentNode && segmentDialogMode ? (
+ void handleSegmentConfirm(segmentNode, params)}
+ />
+ ) : null}
+
+ {timelineNode ? (
+ setTimelineNodeId(null)}
+ onOpenSubtitleDialog={(subNodeId) => {
+ setTimelineNodeId(null);
+ setSubtitleNodeId(subNodeId);
+ }}
+ onSave={(next) => updateProject(projectId, { timeline: next })}
+ onSaveSubtitles={(subNodeId, entries) =>
+ handleConfigNodeChange(subNodeId, {
+ subtitleEntries: entries,
+ ...(entries.length ? {} : { subtitleHighlights: [] }),
+ subtitleUpdatedAt: new Date().toISOString(),
+ })
+ }
+ onOpenAssetLibrary={openTimelineAssetLibrary}
+ onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")}
+ onUploadLocalFiles={uploadTimelineMedia}
+ addNodeToTimelineRef={timelineAddNodeRef}
+ addMediaToTimelineRef={timelineMediaAddRef}
+ onCreateAssembledNode={createVideoNodeFromBlob}
+ />
+ ) : null}
+
+ setCharacterReferenceNodeId(null)} />
+
+ setTextEditorNodeId(null)}
+ onSave={(nodeId, title, content, richText) => {
+ setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node)));
+ }}
+ />
+
+ {drawingNode ? (
+
+
+
+ }
+ >
+ setDrawingNodeId(null)}
+ onSaved={(nodeId, summary) => {
+ setNodes((current) =>
+ current.map((node) =>
+ node.id === nodeId
+ ? {
+ ...node,
+ metadata: {
+ ...node.metadata,
+ drawingEngine: summary.engine,
+ drawingRevision: summary.revision,
+ drawingUpdatedAt: summary.updatedAt,
+ drawingShapeCount: summary.shapeCount,
+ drawingPageCount: summary.pageCount,
+ },
+ }
+ : node,
+ ),
+ );
+ message.success("绘图已保存");
+ }}
+ />
+
+ ) : null}
+
+ {
+ artCritiqueRunningRef.current = running;
+ }}
+ node={artCritiqueNode}
+ upstreamNodes={artCritiqueInputs}
+ open={Boolean(artCritiqueNode)}
+ onClose={() => setArtCritiqueNodeId(null)}
+ onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
+ />
+
+ setPanoramaConfigNodeId(null)}
+ onConfirm={(composedPrompt, config) => {
+ const node = nodes.find((n) => n.id === panoramaConfigNodeId);
+ if (node) {
+ createPanoramaViewerWithConfig(node, composedPrompt, config);
+ }
+ }}
+ onCopyPrompt={(prompt) => {
+ void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
+ }}
+ previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined}
+ nodes={nodes}
+ />
+
+ setScriptEditorNodeId(null)}
+ onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)}
+ onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => {
+ if (!activeScriptNode || !visibleColumns.length) return;
+ setNodes((prev) =>
+ prev.map((node) =>
+ node.id === activeScriptNode.id
+ ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } }
+ : node,
+ ),
+ );
+ }}
+ onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
+ onGenerateVideos={(rowIds) => {
+ if (!activeScriptNode) return;
+ if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
+ else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
+ }}
+ onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
+ />
+
+ {directorNodeId && activeDirectorScene ? (
+
+
+
+ }
+ >
+