diff --git a/backend/internal/protocol/autodl_plugin_artifact_test.go b/backend/internal/protocol/autodl_plugin_artifact_test.go index ecff94c6e..acb417725 100644 --- a/backend/internal/protocol/autodl_plugin_artifact_test.go +++ b/backend/internal/protocol/autodl_plugin_artifact_test.go @@ -25,7 +25,7 @@ func TestAutoDLPluginArtifact(t *testing.T) { t.Fatalf("loaded adapters = %d", len(adapters)) } adapter := adapters[0] - if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Version != "1.0.1" || adapter.Metadata().Execution != "declarative" { + if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Version != "1.0.2" || adapter.Metadata().Execution != "declarative" { t.Fatalf("metadata = %#v", adapter.Metadata()) } if !adapter.Metadata().RequiresPublicMediaURLs { @@ -33,7 +33,7 @@ func TestAutoDLPluginArtifact(t *testing.T) { } create, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ - Model: "minimax_h3_lightx2v_no_pic", Prompt: "a cinematic test", Duration: 3, Resolution: "768", Images: []MediaReference{{URL: "https://cdn.example/reference.png"}}, + Model: "minimax_h3_lightx2v_no_pic", Prompt: "a cinematic test", Duration: 3, Resolution: "768P竖", Images: []MediaReference{{URL: "https://cdn.example/reference.png"}}, }}) if err != nil { t.Fatal(err) @@ -42,7 +42,7 @@ func TestAutoDLPluginArtifact(t *testing.T) { t.Fatalf("create spec = %#v", create) } body, ok := create.Body.(map[string]any) - if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p" || body["ref_image_0"] != "https://cdn.example/reference.png" { + if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p竖" || body["ref_image_0"] != "https://cdn.example/reference.png" { t.Fatalf("create body = %#v", create.Body) } if _, exists := body["ref_image_1"]; exists { diff --git a/backend/internal/protocol/builtin_test.go b/backend/internal/protocol/builtin_test.go index 10426f1da..72d65cc9c 100644 --- a/backend/internal/protocol/builtin_test.go +++ b/backend/internal/protocol/builtin_test.go @@ -365,19 +365,15 @@ func TestDeclarativeManifestSupportsMediaPathsTransformsAndErrors(t *testing.T) } } -func TestDeclarativeManifestNormalizesVideoResolutionWithoutBreakingWorkflowValues(t *testing.T) { +func TestDeclarativeManifestKeepsLegacyVideoResolutionTransformCompatible(t *testing.T) { tests := map[string]string{ - "768": "768p", - "768p": "768p", - "768P": "768p", - " 768P ": "768p", - "768p竖": "768p竖", - "768P横": "768p横", - "2K": "2k", + "768": "768p", + "768P": "768p", + "768P横": "768p横", } for input, expected := range tests { if actual := applyManifestTransform(input, "video-resolution"); actual != expected { - t.Errorf("video resolution transform %q = %#v, want %q", input, actual, expected) + t.Errorf("legacy video resolution transform %q = %#v, want %q", input, actual, expected) } } } diff --git a/backend/internal/protocol/docs/autodl-comfyui.md b/backend/internal/protocol/docs/autodl-comfyui.md index 275293e9e..ff72a1927 100644 --- a/backend/internal/protocol/docs/autodl-comfyui.md +++ b/backend/internal/protocol/docs/autodl-comfyui.md @@ -10,7 +10,7 @@ AutoDL 的模型选择对应 ComfyUI 工作流 ID。不同工作流可以声明 ## 参数 -请求体为 JSON。常用字段包括 `prompt`、`duration` 和 `resolution`;实际工作流可以扩展自己的参数 schema。插件使用 `video-resolution` 字段变换:历史状态中的纯数字 `768` 会发送为 `768p`,已经带后缀的 `768p` 以及工作流值 `768p竖`、`768p横` 保持其语义。状态返回值位于 `data.status`,任务 ID 位于 `data.task_id`,成功结果位于 `data.results`。结果数组中的地址是短期 URL,成功后必须立即由宿主下载到影策资源存储,不能把临时地址直接写入画布长期数据。 +请求体为 JSON。常用字段包括 `prompt`、`duration` 和 `resolution`;实际工作流可以扩展自己的参数 schema。工作流声明的分辨率选项属于供应商枚举,画布必须保留其完整值,例如 `768p竖`、`768p横`,插件仅在发送前统一转换为小写,不在宿主中增加 AutoDL 专用归一化。状态返回值位于 `data.status`,任务 ID 位于 `data.task_id`,成功结果位于 `data.results`。结果数组中的地址是短期 URL,成功后必须立即由宿主下载到影策资源存储,不能把临时地址直接写入画布长期数据。 ## 鉴权 diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index e20def879..67a29899a 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -555,13 +555,15 @@ func applyManifestTransform(value any, transform string) any { case "upper", "uppercase": return strings.ToUpper(text) case "video-resolution", "video_resolution", "videoresolution": - return normalizeManifestVideoResolution(text) + // Compatibility for already-installed 1.0.1 manifests. New plugins + // should declare exact enum values and use generic string transforms. + return normalizeLegacyManifestVideoResolution(text) default: return value } } -func normalizeManifestVideoResolution(value string) string { +func normalizeLegacyManifestVideoResolution(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized == "" { return "" diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 6ebe589f3..e01dac2e9 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,48 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布节点搜索 + +- [x] 搜索结果改为紧凑媒体行,图片、视频、绘图优先显示真实缩略图,文本节点显示内容片段;素材类型、尺寸、时长、文件大小和模型按可用信息摘要展示。 +- [x] 节点新增可持久化创建时间与最后编辑时间;旧画布按项目时间建立迁移基线,素材 URL/尺寸后台恢复不会误记为用户编辑。 +- [x] 默认按最近编辑排序,支持节点、章节、镜头、模型和标签搜索,以及方向键选择、Enter 定位、Esc 关闭。 +- [ ] 在含图片、视频、音频、绘图、文本、分镜和插件节点的真实画布验证缩略图、摘要、时间和排序;确认失效媒体能降级为类型图标。 +- [ ] 在窄屏、常规桌面、浅色和深色主题验证行密度、长标题截断、时间列布局、滚动与键盘焦点。 + +## 画布素材批量插入与右键菜单动效 + +- [x] 画布素材库复用既有多选能力,一次确认后按三列网格批量创建节点并选中全部新增节点;本地素材与插件来源共用同一 payload 转换路径。 +- [x] 时间线素材入口继续保持单选,图片和文本不会被误当作可直接入轨媒体。 +- [x] 右键主菜单、添加节点子菜单、菜单项、图标与箭头已移除位移和缩放动画,只保留淡入、颜色变化与 Spotlight 光影跟随;专项测试、TypeScript 检查与生产构建通过。 +- [ ] 在画布空白处右键后缓慢移动光标经过各菜单项,确认菜单、文字、图标和箭头位置稳定,光影仍跟随光标,主菜单与子菜单打开时不跳动。 +- [ ] 从画布工具栏和右键菜单分别打开素材库,混选图片、视频、音频、文本及插件来源素材,确认一次插入全部节点、顺序稳定、节点不重叠且全部保持选中。 +- [ ] 从时间线打开素材库,确认仍只能单选;音视频可直接入轨,图片或文本会保留弹窗并显示明确错误。 + +## 画布本机 Agent 连接教程 + +- [x] 未连接、连接中和连接失败时,Agent 对话正文由连接教程替代;连接成功后自动恢复原聊天或历史区域,不清空消息和线程状态。 +- [x] 教程按 macOS/Linux 与 Windows 生成安装、构建和启动命令,并将当前网页 Origin 写入 `FRAMEFIELD_TRUSTED_WEB_ORIGINS`;命令生成专项测试、前端类型检查与生产构建通过。 +- [x] 页面明确区分网页侧边栏 Codex、外部 Codex/Claude Code MCP 与尚未验证的 Hermes/WorkBuddy,不再把 Codex 插件误写成本机侧边栏前置条件。 +- [x] 画布全局 `Cmd/Ctrl+C` 在存在真实文字选区时交还浏览器处理,不再把 Agent 消息、日志或 Markdown 文本复制误判成节点复制;快捷键边界专项测试通过。 +- [ ] 在 Windows、macOS 和 Linux 分别复制并执行教程命令,确认 Runtime 能连接当前线上 Origin,平台自动识别和手动切换均正确。 +- [ ] 在未连接、自动重试、Origin 未授权、连接成功和运行中断后重连状态下验证教程与对话区域切换;确认原消息、历史会话和待确认工具调用不丢失。 +- [ ] 在 360px 窄面板、常规宽度、浅色和深色主题下确认命令块可横向滚动、复制按钮可见、教程不遮挡画布。 +- [ ] 在 Agent 回复、运行日志、代码块和普通 Markdown 中分别框选文字后使用右键复制及 `Cmd/Ctrl+C`,确认系统剪贴板内容正确;无文字选区时 `Cmd/Ctrl+C` 仍复制所选画布节点。 + +## 画布复制粘贴命名 + +- [x] 节点首次复制粘贴使用“原名称_copy1”,后续按当前画布已有最大序号递增。 +- [x] 复制已有 `_copyN` 副本时回到原始名称计算,避免生成 `_copy1_copy1`;旧版空格 `Copy` 后缀已有兼容测试。 +- [ ] 在画布中连续粘贴单个节点、多个同名节点和包含子节点的背板,确认名称依次递增且节点关系不变。 + +## 画布媒体下载文件名 + +- [x] 图片、视频和音频下载统一使用“画布名_节点名_yyyyMMdd.扩展名”,中文名称和跨平台非法字符已有专项测试覆盖。 +- [x] 扩展名优先按媒体 MIME 类型判断,并兼容远程 URL 后缀;JPEG、WebM 和 MOV 已有专项测试覆盖。 +- [x] 前端 TypeScript 检查与生产构建通过。 +- [ ] 在浏览器中分别下载本地上传、生成结果、LibTV 导入和 TapNow 导入的图片与视频,确认文件名、日期和实际媒体格式一致。 +- [ ] 在同一天重复下载同名节点,确认浏览器的重名处理不会覆盖用户已有文件。 + ## 创作输入区参考内容管理 - [x] 自动化覆盖首个、中间和末尾参考内容按稳定 ID 独立删除,且删除后其余顺序保持不变。 @@ -309,3 +351,35 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] 参数校验、协议构造、路由准备等请求尚未发出的最终失败会补记 `request_not_sent`,且不保存任务输入、API Key 或请求正文。 - [x] 已有真实 HTTP 调用日志的任务不会重复补记请求前失败日志。 - [ ] 在画布分别触发一次成功请求、一次上游 400 和一次本地参数校验失败,确认 `/admin/logs` 均能看到对应记录,并可按节点错误文案搜索。 + +## 渠道视频分辨率枚举兼容 + +画布已将渠道声明的分辨率视为不透明枚举,不再把带方向或本地化后缀的值静默回退为 `720p`;以下场景仍需浏览器确认: + +- [x] 前端分辨率规范化、画布选中态、价格匹配与请求映射专项测试通过。 +- [x] AutoDL 插件 `1.0.2` 已改用通用 `lower` 并通过协议包测试;宿主仅保留旧 `1.0.1` 清单所需的兼容 shim。 +- [x] `web` TypeScript 检查与生产构建通过。 +- [ ] 在画布选择 `480p竖`、`768p竖`、`480p横`、`768p横`,确认按钮选中态、底部摘要和重新打开后的状态一致。 +- [ ] 分别提交四个枚举值,确认请求中的 `resolution` 与插件声明一致,且其他标准 `720p`、`1080p` 渠道不受影响。 + +## 画布生成重复提交保护 + +画布生成入口已增加节点级同步提交锁,并为手动生成记录“提示词、模型、参数、参考素材”指纹;以下场景仍需浏览器确认: + +- [x] 连续三次调用同一节点只执行一次的专项测试通过;提示词、模型、参数或参考素材变化会产生不同指纹。 +- [x] `web` TypeScript 检查、21 个相关专项测试与生产构建通过。 +- [ ] 在同一节点快速连续点击三次生成,确认任务中心只新增一个任务,且不会重复扣除积分。 +- [ ] 首次任务完成后不修改提示词、模型、参数和参考素材,再次点击生成,确认出现“再次生成相同内容”二次确认;取消不创建任务,确认后只创建一个新任务。 +- [ ] 分别修改提示词、模型、分辨率/时长和参考素材后生成,确认视为新内容,不弹重复确认。 +- [ ] 执行分镜批量生成、Agent 自动生成和显式重试,确认不会被手动重复确认弹窗阻塞。 + +## 画布快捷键中心 + +画布搜索已统一为 `Ctrl/Cmd + F`,专注模式调整为 `Shift + Ctrl/Cmd + F`;原快捷键长列表已改为可搜索、可分类的快捷键中心。以下场景仍需浏览器确认: + +- [x] 快捷键目录筛选专项测试、`web` TypeScript 检查与生产构建通过。 +- [ ] 在 Windows 和 macOS 分别按 `Ctrl/Cmd + F`,确认打开画布节点搜索且不会触发浏览器页内查找。 +- [ ] 按 `Shift + Ctrl/Cmd + F`,确认进入或退出专注模式;按 `Ctrl/Cmd + K` 不再打开节点搜索,并保留工作区命令面板入口。 +- [ ] 打开快捷键中心,分别搜索“粘贴”“缩放”和“Alt L”,确认结果与分类计数正确,清空搜索后恢复完整列表。 +- [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。 +- [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。 diff --git a/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin index 87d348c87..b2e5e1912 100644 Binary files a/plugin-packages/autodl-comfyui.yingce-plugin and b/plugin-packages/autodl-comfyui.yingce-plugin differ diff --git a/plugin-packages/autodl-comfyui/manifest.json b/plugin-packages/autodl-comfyui/manifest.json index 89ec4c079..c509b9ad4 100644 --- a/plugin-packages/autodl-comfyui/manifest.json +++ b/plugin-packages/autodl-comfyui/manifest.json @@ -2,10 +2,10 @@ "apiVersion": "yingce.plugin/v1", "id": "autodl-comfyui", "name": "AutoDL ComfyUI 视频", - "version": "1.0.1", + "version": "1.0.2", "author": "影策社区", "description": "通过 AutoDL.Art ComfyUI 工作流 API 接入异步视频生成。", - "documentation": "# AutoDL ComfyUI 视频\n\n## 接口\n\n创建任务:`POST /api/v1/comfyui/comfyui_workflow/{workflow_id}`。清单把渠道中的模型字段作为 `workflow_id`,因此每个 ComfyUI 工作流都可以通过工作流贡献加入,不需要修改宿主代码。\n\n查询任务:`GET /api/v1/comfyui/comfyui_workflow/result/{task_id}`。\n\n## 工作流\n\n示例 `minimax_h3_lightx2v_no_pic` 作为 MiniMax H3 文生视频入口,默认时长为 5 秒、分辨率为 `768p竖`。支持 `480p竖`、`768p竖`、`480p横`、`768p横`。上传后可以在渠道中直接选择该工作流;新增工作流只需扩展本清单。\n\n## 请求与鉴权\n\n创建请求为 JSON,发送 `prompt`、`duration`、`resolution`,并按存在的参考图片发送 `ref_image_0` 至 `ref_image_8`。分辨率字段由插件在发送前统一 trim 并转换为小写;纯数字值会自动补上 `p`,因此 `768` 与 `768p` 都会发送为 `768p`,`768p竖`、`768p横` 等工作流值保持不变。参考图片由宿主转换为短期签名公网 URL。AutoDL Token 在渠道密钥字段中配置,宿主以后端出站请求发送 `Authorization: Bearer `;清单不包含密钥。\n\n## 状态与结果\n\n`QUEUED` 和 `RUNNING` 映射为统一任务的排队与处理中,`SUCCESS` 和 `completed` 映射为成功,`FAILED` 映射为失败。错误响应的 `code` 和 `msg` 会保留为任务错误。成功结果从 `data.results[].url` 读取为视频地址。AutoDL 地址是短期地址,清单标记为临时结果,宿主会在完成时下载并保存到影策资源存储。\n\n官方文档:[AutoDL ComfyUI API](https://autodl.art/docs/comfyui_api/)。", + "documentation": "# AutoDL ComfyUI 视频\n\n## 接口\n\n创建任务:`POST /api/v1/comfyui/comfyui_workflow/{workflow_id}`。清单把渠道中的模型字段作为 `workflow_id`,因此每个 ComfyUI 工作流都可以通过工作流贡献加入,不需要修改宿主代码。\n\n查询任务:`GET /api/v1/comfyui/comfyui_workflow/result/{task_id}`。\n\n## 工作流\n\n示例 `minimax_h3_lightx2v_no_pic` 作为 MiniMax H3 文生视频入口,默认时长为 5 秒、分辨率为 `768p竖`。支持 `480p竖`、`768p竖`、`480p横`、`768p横`。上传后可以在渠道中直接选择该工作流;新增工作流只需扩展本清单。\n\n## 请求与鉴权\n\n创建请求为 JSON,发送 `prompt`、`duration`、`resolution`,并按存在的参考图片发送 `ref_image_0` 至 `ref_image_8`。分辨率选项由画布按工作流声明完整保留,插件在发送前仅统一转换为小写;`768p竖`、`768p横` 等值不会被宿主折叠为通用数字档位。参考图片由宿主转换为短期签名公网 URL。AutoDL Token 在渠道密钥字段中配置,宿主以后端出站请求发送 `Authorization: Bearer `;清单不包含密钥。\n\n## 状态与结果\n\n`QUEUED` 和 `RUNNING` 映射为统一任务的排队与处理中,`SUCCESS` 和 `completed` 映射为成功,`FAILED` 映射为失败。错误响应的 `code` 和 `msg` 会保留为任务错误。成功结果从 `data.results[].url` 读取为视频地址。AutoDL 地址是短期地址,清单标记为临时结果,宿主会在完成时下载并保存到影策资源存储。\n\n官方文档:[AutoDL ComfyUI API](https://autodl.art/docs/comfyui_api/)。", "permissions": [ "generation.run", "media.read" @@ -70,7 +70,7 @@ "fields": { "prompt": "request.prompt", "duration": "request.duration", - "resolution": "request.resolution|video-resolution", + "resolution": "request.resolution|lower", "ref_image_0": "request.images.0.url", "ref_image_1": "request.images.1.url", "ref_image_2": "request.images.2.url", diff --git a/web/package.json b/web/package.json index 23357104e..f8dd53359 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "bun run build:bridge && tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", diff --git a/web/src/components/canvas/asset-picker-modal.tsx b/web/src/components/canvas/asset-picker-modal.tsx index e9039f539..12a812ef5 100644 --- a/web/src/components/canvas/asset-picker-modal.tsx +++ b/web/src/components/canvas/asset-picker-modal.tsx @@ -16,13 +16,14 @@ export type InsertAssetPayload = type Props = { open: boolean; - onInsert: (payload: InsertAssetPayload) => void; + multiple?: boolean; + onInsert: (payloads: InsertAssetPayload[]) => Promise | void; onClose: () => void; }; const categoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; -export function AssetPickerModal({ open, onInsert, onClose }: Props) { +export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) { const assets = useAssetStore((state) => state.assets); const externalAssetSources = useExternalAssetSources(open); const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]); @@ -38,22 +39,6 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) { ...externalAssetSources.items, ], [externalAssetSources.items, insertableAssets]); - const insert = (id: string) => { - const pickerItem = items.find((item) => item.id === id); - if (pickerItem?.external) { - onInsert(externalAssetToInsertPayload(pickerItem.external)); - onClose(); - return; - } - const asset = insertableAssets.find((item) => item.id === id); - if (!asset) throw new Error("所选素材已不存在,请重新选择"); - if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id }); - else if (asset.kind === "audio") onInsert({ kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id }); - else if (asset.kind === "video") onInsert({ kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id }); - else onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id }); - onClose(); - }; - return ( "插入所选素材"} + multiple={multiple} + confirmLabel={(count) => `插入已选素材${count ? `(${count})` : ""}`} emptyDescription="先在素材库中添加图片、视频、音频或文本。" onClose={onClose} - onConfirm={(ids) => insert(ids[0])} + onConfirm={async (ids) => { + await onInsert(assetPickerItemsToInsertPayloads(ids, items)); + onClose(); + }} /> ); } +export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibraryPickerItem[]): InsertAssetPayload[] { + const itemsById = new Map(items.map((item) => [item.id, item])); + return ids.map((id) => { + const pickerItem = itemsById.get(id); + if (!pickerItem) throw new Error("所选素材已不存在,请重新选择"); + if (pickerItem.external) return externalAssetToInsertPayload(pickerItem.external); + const asset = pickerItem.asset; + if (!asset || (asset.kind !== "text" && asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio")) { + throw new Error(`“${pickerItem.title}”不是可插入画布的素材`); + } + return localAssetToInsertPayload(asset); + }); +} + +function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload { + if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id }; + if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id }; + if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id }; + return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id }; +} + export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload { const item = reference.item; const url = item.fileUrl || ""; diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 62cb34c29..32a6b1927 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence, useReducedMotion } from "motion/react"; +import { AnimatePresence } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; @@ -93,7 +93,6 @@ export function CanvasNodeContextMenu({ onToggleFrame, }: CanvasNodeContextMenuProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const reducedMotion = useReducedMotion(); const [addOpen, setAddOpen] = useState(false); const [categoryOpen, setCategoryOpen] = useState(false); @@ -148,8 +147,8 @@ export function CanvasNodeContextMenu({ runAction(() => onAddNode(type))} onAddFolder={() => runAction(onAddFolder)} onChooseStyle={() => runAction(onChooseStyle)} @@ -260,7 +258,7 @@ export function CanvasNodeContextMenu({ ); } -function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { +function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const installations = usePluginStore((state) => state.installations); const pluginStates = usePluginStore((state) => state.pluginStates); @@ -299,9 +297,9 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re return ( parentPosition.left ? -5 : 5, scale: 0.97 }} - animate={{ opacity: 1, x: 0, scale: 1 }} - exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl" style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} @@ -342,7 +340,7 @@ function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, act {icon} {label}{badge ? {badge} : null}{detail ? {detail} : null} {shortcut ? {shortcut} : null} - {chevron ? : null} + {chevron ? : null} ); } diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 0ef63f8b5..181d3fd3d 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { App, Button, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; -import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react"; +import { CheckCircle2, Copy, ExternalLink, FolderOpen, History, LoaderCircle, PlugZap, Plus, RefreshCw, Terminal, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import { canvasThemes } from "@/lib/canvas-theme"; @@ -25,6 +25,7 @@ import { import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context"; import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references"; +import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup"; import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools"; @@ -35,12 +36,6 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome"; const PANEL_MOTION_SECONDS = 0.5; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; -const AGENT_CONNECT_STEPS = [ - { title: "从仓库安装插件", text: "插件暂未上架公共目录,请按项目 README 添加仓库 marketplace;安装后新建 Codex 对话。" }, - { title: "自动连接本机 Runtime", text: "首次使用时,已信任的 Framefield 网页会自动建立当前浏览器的本机安全会话。" }, - { title: "打开画布连接", text: "回到这里点击连接,画布会复用同一个本机 Runtime 安全会话。" }, -]; - type AgentEventPayload = { agent?: string; type?: string; @@ -755,7 +750,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ - {activeTab === "history" ? ( + {!connected ? ( + + ) : activeTab === "history" ? ( void; }) { - const statusText = canvasAgentConnectionStatusText({ enabled, connected, activity, connectError }); - const statusColor = statusText === "连接失败" ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted; + const { message } = App.useApp(); + const [platform, setPlatform] = useState(() => detectLocalAgentSetupPlatform()); + const origin = typeof window === "undefined" ? "http://localhost:3000" : window.location.origin; + const commands = buildLocalAgentSetupCommands(origin, platform); + const statusText = canvasAgentConnectionStatusText({ enabled, connected: false, activity, connectError }); + const statusColor = connectError ? "#dc2626" : enabled ? "#d97706" : theme.node.muted; + const copyCommand = async (value: string, label: string) => { + if (await copyToClipboard(value)) { + message.success(`${label}已复制`); + return; + } + message.warning("复制失败,请手动复制"); + }; return (
-
+
连接本地 Agent
- 安装仓库自带的 Codex 插件后,画布会优先自动连接本机 Agent。 + 在这台电脑启动 Canvas Agent 后,网页会通过 127.0.0.1 建立本机安全连接。连接成功后,这里会自动恢复原对话。
-
- {AGENT_CONNECT_STEPS.map((step) => ( -
-
{step.title}
-
- {step.text} -
-
- ))} -
网页连接 - + {enabled ? : } {statusText}
- 自动复用“设置 → 本机工具”建立的短期安全会话。 + {enabled ? "正在检测本机 127.0.0.1:17371;服务启动后会自动连接。" : "先完成下面两步,再点击连接。"}
@@ -984,11 +986,88 @@ function AgentConnectView({ ) : null}
+ +
+
+
+ 1 +
+
+
+
安装本机 Runtime
+
需要 Git、Node.js 18+。当前版本从 GitHub 源码安装。
+
+ setPlatform(value as LocalAgentSetupPlatform)} + /> +
+ copyCommand(commands.install, "安装命令")} /> +
+
+
+ +
+
+ 2 +
+
启动并授权当前站点
+
+ 命令已绑定 {origin}。保持终端运行,再回到上方开始连接。 +
+ copyCommand(commands.start, "启动命令")} /> +
+
+
+ +
+
+ +
+
当前支持范围
+
网页侧边栏使用 Codex;外部 MCP 已提供 Codex 与 Claude Code 接入。Hermes、WorkBuddy 尚未验证。Codex 需要已登录或配置可用凭据。
+ +
+
+
+
); } +function CommandBlock({ + value, + theme, + onCopy, +}: { + value: string; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + onCopy: () => void | Promise; +}) { + return ( +
+
+ + + 终端命令 + + +
+
{value}
+
+ ); +} + function AgentHistoryView({ theme, threads, diff --git a/web/src/components/canvas/canvas-node-search-modal.tsx b/web/src/components/canvas/canvas-node-search-modal.tsx index adc981884..28093fbbf 100644 --- a/web/src/components/canvas/canvas-node-search-modal.tsx +++ b/web/src/components/canvas/canvas-node-search-modal.tsx @@ -1,49 +1,160 @@ -import { useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState, type KeyboardEvent } from "react"; import { Input, Modal } from "antd"; -import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react"; +import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Video } from "lucide-react"; import { WorkspaceState } from "@/components/layout/workspace-state"; +import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search"; import { getNodeListLabel } from "@/lib/canvas/node-registry"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +const RESULT_LIST_ID = "canvas-node-search-results"; + export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) { const [query, setQuery] = useState(""); - const results = useMemo(() => { - const keyword = query.trim().toLocaleLowerCase(); - if (!keyword) return nodes.slice(0, 40); - return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])] - .some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80); - }, [nodes, query]); + const [activeIndex, setActiveIndex] = useState(0); + const results = useMemo(() => searchCanvasNodes(nodes, query), [nodes, query]); + + useEffect(() => setActiveIndex(0), [query, open]); + useEffect(() => setActiveIndex((current) => Math.min(current, Math.max(0, results.length - 1))), [results.length]); + + const focusNode = (node: CanvasNodeData | undefined) => { + if (!node) return; + onFocus(node.id); + onClose(); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((current) => Math.min(results.length - 1, current + 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => Math.max(0, current - 1)); + } else if (event.key === "Enter") { + event.preventDefault(); + focusNode(results[activeIndex]); + } + }; return ( - setQuery("")} centered> - setQuery(event.target.value)} prefix={} placeholder="搜索节点、章节、镜头、模型或标签" /> -
- {results.length ? results.map((node) => ( - + node={node} + active={index === activeIndex} + onActivate={() => setActiveIndex(index)} + onSelect={() => focusNode(node)} + /> )) : }
); } -function nodeContextLabel(node: CanvasNodeData) { - const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · "); - return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type); +const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, active, onActivate, onSelect }: { node: CanvasNodeData; active: boolean; onActivate: () => void; onSelect: () => void }) { + const times = canvasNodeSearchTimes(node); + const materialSummary = canvasNodeMaterialSummary(node); + const context = canvasNodeSearchContext(node); + return ( + + ); +}); + +function CanvasNodeSearchThumbnail({ node }: { node: CanvasNodeData }) { + const [failed, setFailed] = useState(false); + const mediaSource = node.metadata?.drawingPreviewUrl + || node.metadata?.characterCoverUrl + || node.metadata?.folder?.themeCover + || ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined); + const commonClass = "h-11 w-16 rounded-[var(--r-sm)] border object-cover"; + const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" }; + + if (mediaSource && !failed && node.type === CanvasNodeType.Video) { + return
- setShortcutsOpen(false)} footer={null} centered> -
- - - - - - - - - - - - - - - - - - - - -
-
+ setShortcutsOpen(false)} /> ); } @@ -428,21 +406,3 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted; return ; } - -function Shortcut({ keys, value }: { keys: string[]; value: string }) { - return ( -
- - {keys.map((key, index) => ( - - {index ? + : null} - - {key} - - - ))} - - {value} -
- ); -} diff --git a/web/src/pages/canvas/canvas-shortcuts-modal.tsx b/web/src/pages/canvas/canvas-shortcuts-modal.tsx new file mode 100644 index 000000000..ac2ba5a58 --- /dev/null +++ b/web/src/pages/canvas/canvas-shortcuts-modal.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Command, Search } from "lucide-react"; +import { Modal } from "antd"; + +import { + CANVAS_SHORTCUT_CATEGORIES, + CANVAS_SHORTCUTS, + filterCanvasShortcuts, + type CanvasShortcutCategoryId, + type CanvasShortcutItem, +} from "@/lib/canvas/canvas-shortcuts"; + +type ShortcutCategoryFilter = CanvasShortcutCategoryId | "all"; + +export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("all"); + + useEffect(() => { + if (!open) return; + setQuery(""); + setCategory("all"); + }, [open]); + + const results = useMemo(() => filterCanvasShortcuts(query, category), [category, query]); + const categoryCounts = useMemo( + () => new Map(CANVAS_SHORTCUT_CATEGORIES.map((entry) => [entry.id, CANVAS_SHORTCUTS.filter((shortcut) => shortcut.category === entry.id).length])), + [], + ); + + return ( + { + if (visible) window.requestAnimationFrame(() => inputRef.current?.focus()); + }} + > +
+
+
+ + + + + 画布快捷键 + 快速找到键盘、鼠标和视图操作 + +
+ +
+ +
+ + +
+ {results.length ? ( +
+ {results.map((shortcut) => ( + + ))} +
+ ) : ( +
+ + 没有找到相关操作 + 试试搜索“粘贴”“缩放”或按键名称 +
+ )} +
+
+ +
+ 共 {results.length} 个快捷键 + Esc 关闭 +
+
+
+ ); +} + +function CategoryButton({ label, count, active, onClick }: { label: string; count: number; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function ShortcutRow({ shortcut, showCategory }: { shortcut: CanvasShortcutItem; showCategory: boolean }) { + const categoryLabel = CANVAS_SHORTCUT_CATEGORIES.find((entry) => entry.id === shortcut.category)?.label; + + return ( +
+
+ + {shortcut.title} + {showCategory ? {categoryLabel} : null} + +

{shortcut.description}

+
+
combination.join(" 加 ")).join(" 或 ")}> + {shortcut.keys.map((combination, combinationIndex) => ( + + {combinationIndex ? 或 : null} + {combination.map((key, keyIndex) => ( + + {keyIndex ? + : null} + {key} + + ))} + + ))} +
+
+ ); +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 68a50be14..ec2a46889 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { MouseEvent as ReactMouseEvent } from "react"; +import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams, useSearchParams } from "react-router"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; @@ -72,6 +72,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry"; import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain"; +import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps"; import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"; import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection"; import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress"; @@ -217,7 +218,21 @@ function InfiniteCanvasPage() { const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || ""); - const [nodes, setNodes] = useState([]); + 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 [connections, setConnections] = useState([]); const [chatSessions, setChatSessions] = useState([]); const [activeChatId, setActiveChatId] = useState(null); @@ -303,19 +318,6 @@ function InfiniteCanvasPage() { didInitialCenterRef.current = false; }, [projectId]); - useEffect(() => { - const openSearch = (event: KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey) || event.key.toLocaleLowerCase() !== "k") return; - const target = event.target; - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) return; - event.preventDefault(); - setNodeSearchOpen(true); - }; - window.addEventListener("keydown", openSearch); - return () => window.removeEventListener("keydown", openSearch); - }, []); - - const nodesRef = useRef(nodes); const connectionsRef = useRef(connections); const chatSessionsRef = useRef(chatSessions); const activeChatIdRef = useRef(activeChatId); @@ -625,7 +627,7 @@ function InfiniteCanvasPage() { createVideoNodeFromBlob, createImageAssetNode, fileDropActive, - handleAssetInsert, + handleAssetsInsert, handleDrop, handleFileDragEnter, handleFileDragLeave, @@ -725,22 +727,18 @@ function InfiniteCanvasPage() { return null; }; - const handleTimelineAssetInsert = useCallback( - async (payload: InsertAssetPayload) => { + const handleLibraryAssetsInsert = useCallback( + async (payloads: InsertAssetPayload[]) => { if (assetInsertScope === "timeline") { - const media = payloadToTimelineMedia(payload); - if (media) { - timelineMediaAddRef.current?.(media); - closeAssetPicker(); - } else { - message.info("图片/文本素材暂不支持直接入轨,请先在画布中添加节点"); - } + const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item)); + if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布"); + media.forEach((item) => timelineMediaAddRef.current?.(item)); return; } - const node = await handleAssetInsert(payload, { openDialog: false }); - if (node) timelineAddNodeRef.current?.(node); + const created = await handleAssetsInsert(payloads); + created.forEach((node) => timelineAddNodeRef.current?.(node)); }, - [assetInsertScope, closeAssetPicker, handleAssetInsert], + [assetInsertScope, handleAssetsInsert], ); // 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。 @@ -1063,6 +1061,7 @@ function InfiniteCanvasPage() { toggleNodeFreeResize, } = useCanvasNodeEditor({ canvasId: projectId, + canvasTitle: currentProject?.title || "未命名画布", domainProjectId: linkedProjectId, nodesRef, setNodes, @@ -1450,6 +1449,7 @@ function InfiniteCanvasPage() { focusMode, exitFocusMode, toggleFocusMode, + onOpenSearch: () => setNodeSearchOpen(true), beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)), }); @@ -2697,7 +2697,7 @@ function InfiniteCanvasPage() { onConfirmClear={clearCanvas} /> - + {codexCompactAgent && !assistantMounted ? ( diff --git a/web/src/pages/canvas/use-canvas-agent-operations.ts b/web/src/pages/canvas/use-canvas-agent-operations.ts index cc64e0c9e..7182e6af5 100644 --- a/web/src/pages/canvas/use-canvas-agent-operations.ts +++ b/web/src/pages/canvas/use-canvas-agent-operations.ts @@ -2,20 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; -import { createGenerationRetryContext, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; +import { createGenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer"; import { consumeGenerationTaskAgent } from "@/services/project-asset-sync"; import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas"; +import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor"; + export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" }; -type CanvasAgentRetryContext = GenerationRetryContext; type CanvasAgentGenerationContinuation = NonNullable["agentGenerationContinuation"]>; -type CanvasAgentGenerationOptions = { - context?: CanvasAgentGenerationContext; - retryContext?: CanvasAgentRetryContext; - onTaskUpdate?: (task: GenerationTask) => void; -}; +type CanvasAgentGenerationOptions = Pick; type CanvasAgentGenerationContinuationDependencies = { consumeAgent?: typeof consumeGenerationTaskAgent; persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect; @@ -149,6 +146,7 @@ export async function runCanvasAgentGenerationOps({ let continuationTaskId = ""; const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, { context: context ? { conversationId: context.conversationId, messageId: context.messageId } : undefined, + skipDuplicateConfirmation: true, ...(retryContext ? { retryContext } : {}), onTaskUpdate: (task) => { if (continuationTaskId) return; diff --git a/web/src/pages/canvas/use-canvas-generation-batches.ts b/web/src/pages/canvas/use-canvas-generation-batches.ts index 1dbcfab46..6b3ca3209 100644 --- a/web/src/pages/canvas/use-canvas-generation-batches.ts +++ b/web/src/pages/canvas/use-canvas-generation-batches.ts @@ -213,6 +213,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no void handleGenerateNode(node.id, generationMode, prompt, { controller, waitForTaskCapacity: true, + skipDuplicateConfirmation: true, retryContext: node.metadata?.retryOf && node.metadata.attemptGroupId && node.metadata.taskClientOperationId ? { retryOf: node.metadata.retryOf, attemptGroupId: node.metadata.attemptGroupId, clientOperationId: node.metadata.taskClientOperationId } diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index 67ef3d0fa..c51181848 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -1,9 +1,10 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useRef, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation"; +import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission"; import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch"; import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; @@ -50,6 +51,7 @@ export type CanvasNodeGenerationOptions = { context?: { conversationId?: string; messageId?: string }; retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string }; onTaskUpdate?: (task: GenerationTask) => void; + skipDuplicateConfirmation?: boolean; }; export function useCanvasGenerationExecutor({ @@ -70,251 +72,280 @@ export function useCanvasGenerationExecutor({ bindGenerationTask, applyGenerationTaskResult, }: UseCanvasGenerationExecutorOptions) { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const submissionLocksRef = useRef(new Map>()); + const confirmDuplicateSubmission = useCallback( + () => + new Promise((resolve) => { + modal.confirm({ + title: "再次生成相同内容?", + content: "当前节点已使用相同提示词、模型、参数和参考素材提交过任务。再次生成会新建任务,并可能再次消耗积分。", + okText: "仍然生成", + cancelText: "取消", + centered: true, + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }), + [modal], + ); return useCallback( - async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => { - const sourceNode = nodesRef.current.find((node) => node.id === nodeId); - if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") { - message.info("合并成片节点不直接重新生成,请重新选择源视频合并"); - return; - } - let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); - const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id)); - const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren; - if (hasStaleImageBatchState) { - setNodes((current) => - current.map((node) => { - if (node.id !== sourceNode.id) return node; - const metadata = { ...node.metadata }; - delete metadata.isBatchRoot; - delete metadata.batchChildIds; - delete metadata.primaryImageId; - delete metadata.imageBatchExpanded; - delete metadata.batchUsesReferenceImages; - return { ...node, metadata }; - }), - ); - } - if (!isAiConfigReady(generationConfig, generationConfig.model)) { - navigateToSettings({ continueCreation: true }); - return; - } - - setRunningNodeId(nodeId); - const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller); - const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; - const editingTextNode = mode === "text" && Boolean(sourceTextContent); - const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt; - const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content; - if (isPreparingEmptyImage) { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - metadata: { - ...node.metadata, - prompt, - status: NODE_STATUS_LOADING, - taskStage: "正在准备生成任务", - taskProgress: 0, - taskCreatedAt: new Date().toISOString(), - errorDetails: undefined, - generationErrorCode: undefined, - resourceReloadAvailable: undefined, - failedPromptFingerprint: undefined, - }, - } - : node, - ), - ); - } - - let rawGenerationContext: Awaited>; - // 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。 - const promptOnly = mode === "video"; - const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"); - try { - const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly); - const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true); - generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements); - const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements); - if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`); - const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements); - rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits); - const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig); - generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements); - const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements); - if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`); - } catch (error) { - const errorDetails = generationErrorMessage(error); - if (isPreparingEmptyImage) { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - metadata: { - ...node.metadata, - status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR, - taskStage: undefined, - taskProgress: undefined, - taskCreatedAt: undefined, - taskStartedAt: undefined, - taskCompletedAt: undefined, - taskDurationMs: undefined, - errorDetails: controller.signal.aborted ? undefined : errorDetails, - }, - } - : node, - ), - ); - } - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - if (!controller.signal.aborted) message.error(errorDetails); - return; - } - - const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills); - let effectivePrompt = expandedPrompt.trim(); - let styleMetadata = {}; - if (mode === "image") { - try { - const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode); - if (styleRuntime) { - effectivePrompt = styleRuntime.prompt; - styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan }; - } - } catch (error) { - const errorDetails = generationErrorMessage(error); - if (isPreparingEmptyImage) - setNodes((current) => - current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)), - ); - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - message.error(errorDetails); - return; - } - } - const generationContext = { ...rawGenerationContext, prompt: effectivePrompt }; - if (mode === "audio" && generationContext.characterReferences.length) { - if (generationContext.characterReferences.length !== 1) { - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - message.error("角色配音一次只能引用一个角色卡"); - return; - } - const voice = generationContext.resolvedCharacterVoices[0]; - if (!voice) { - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - message.error("角色尚未绑定可用声音,无法创建角色配音任务"); - return; - } - generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") }; - } - if (controller.signal.aborted) { - if (isPreparingEmptyImage) - setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node))); - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - return; - } - - // 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。 - const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode; - const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; - if (!effectivePrompt && (mode === "text" || mode === "audio")) { - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - return; - } - if (markSourceStatus) - setNodes((current) => - current.map((node) => - node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node, - ), - ); - - let pendingNodeIds: string[] = []; - const execution = { - projectId, + (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => + runCanvasGenerationSubmissionOnce( + submissionLocksRef.current, nodeId, - sourceNode, - canvasNodes: nodesRef.current, - canvasConnections: connectionsRef.current, - prompt, - effectivePrompt, - generationConfig, - generationContext, - controller, - editingTextNode, - styleMetadata, - taskContext: options?.context, - retryContext: options?.retryContext, - setNodes, - setConnections, - setSelectedNodeIds, - setSelectedConnectionId, - setDialogNodeId, - startGenerationRequest, - finishGenerationRequest, - bindGenerationTask: (targetNodeId: string, task: GenerationTask) => { - bindGenerationTask(targetNodeId, task); - options?.onTaskUpdate?.(task); - }, - applyGenerationTaskResult, - showError: (content: string) => message.error(content), - registerPendingNodeIds: (nodeIds: string[]) => { - pendingNodeIds = nodeIds; - }, - }; + async () => { + const sourceNode = nodesRef.current.find((node) => node.id === nodeId); + if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") { + message.info("合并成片节点不直接重新生成,请重新选择源视频合并"); + return; + } + let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); + const hasLiveBatchChildren = + sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id)); + const hasStaleImageBatchState = + mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren; + if (hasStaleImageBatchState) { + setNodes((current) => + current.map((node) => { + if (node.id !== sourceNode.id) return node; + const metadata = { ...node.metadata }; + delete metadata.isBatchRoot; + delete metadata.batchChildIds; + delete metadata.primaryImageId; + delete metadata.imageBatchExpanded; + delete metadata.batchUsesReferenceImages; + return { ...node, metadata }; + }), + ); + } + if (!isAiConfigReady(generationConfig, generationConfig.model)) { + navigateToSettings({ continueCreation: true }); + return; + } - try { - if (mode === "image") await executeImageGeneration(execution); - else if (mode === "video") await executeVideoGeneration(execution); - else if (mode === "audio") await executeAudioGeneration(execution); - else await executeTextGeneration(execution); - } catch (error) { - if (isGenerationCanceled(error)) return; - const failure = generationFailureMetadata(error, prompt); - if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) { - setNodes((current) => - current.map((node) => { - if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node; - const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined }; - delete metadata.taskId; - delete metadata.taskStatus; - delete metadata.taskProgress; - delete metadata.taskStage; - delete metadata.taskCreatedAt; - delete metadata.taskUpdatedAt; - delete metadata.taskStartedAt; - delete metadata.taskCompletedAt; - delete metadata.taskDurationMs; - return { ...node, metadata }; - }), - ); - return; - } - message.error(failure.errorDetails); - setNodes((current) => - current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)), - ); - } finally { - finishGenerationRequest(nodeId, controller); - setRunningNodeId(null); - } - }, + const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; + const editingTextNode = mode === "text" && Boolean(sourceTextContent); + const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt; + const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content; + + let rawGenerationContext: Awaited>; + // 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。 + const promptOnly = mode === "video"; + const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"); + try { + const baseContext = buildNodeGenerationContext( + nodeId, + nodesRef.current, + connectionsRef.current, + editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, + assets, + promptOnly, + ); + const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true); + generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements); + const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements); + if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`); + const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements); + rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits); + const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig); + generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements); + const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements); + if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`); + } catch (error) { + const errorDetails = generationErrorMessage(error); + message.error(errorDetails); + return; + } + + const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills); + let effectivePrompt = expandedPrompt.trim(); + let styleMetadata = {}; + if (mode === "image") { + try { + const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode); + if (styleRuntime) { + effectivePrompt = styleRuntime.prompt; + styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan }; + } + } catch (error) { + const errorDetails = generationErrorMessage(error); + message.error(errorDetails); + return; + } + } + const generationContext = { ...rawGenerationContext, prompt: effectivePrompt }; + if (mode === "audio" && generationContext.characterReferences.length) { + if (generationContext.characterReferences.length !== 1) { + message.error("角色配音一次只能引用一个角色卡"); + return; + } + const voice = generationContext.resolvedCharacterVoices[0]; + if (!voice) { + message.error("角色尚未绑定可用声音,无法创建角色配音任务"); + return; + } + generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") }; + } + if (!effectivePrompt && (mode === "text" || mode === "audio")) { + return; + } + + const requestFingerprint = canvasGenerationRequestFingerprint({ + nodeId, + mode, + prompt: effectivePrompt, + model: generationConfig.model, + options: modelRequestOptions(generationConfig, mode), + workflow: + generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model" + ? { + provider: generationConfig.taskWorkflowProvider, + workflowId: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.workflowId : generationConfig.comfyBridge.workflowId, + workflowKind: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.selectedKind : undefined, + parameters: sourceNode?.metadata?.workflowParameters, + } + : undefined, + operation: sourceNode?.metadata?.videoEditOperation, + audioInstructions: generationConfig.audioInstructions, + promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, + promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables, + context: generationContext, + }); + const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint; + if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return; + + setRunningNodeId(nodeId); + const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller); + if (controller.signal.aborted) { + finishGenerationRequest(nodeId, controller); + setRunningNodeId(null); + return; + } + if (isPreparingEmptyImage) { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + metadata: { + ...node.metadata, + prompt, + status: NODE_STATUS_LOADING, + taskStage: "正在准备生成任务", + taskProgress: 0, + taskCreatedAt: new Date().toISOString(), + errorDetails: undefined, + generationErrorCode: undefined, + resourceReloadAvailable: undefined, + failedPromptFingerprint: undefined, + }, + } + : node, + ), + ); + } + + // 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。 + const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode; + const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; + if (markSourceStatus) + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined }, + } + : node, + ), + ); + + let pendingNodeIds: string[] = []; + const execution = { + projectId, + nodeId, + sourceNode, + canvasNodes: nodesRef.current, + canvasConnections: connectionsRef.current, + prompt, + effectivePrompt, + generationConfig, + generationContext, + controller, + editingTextNode, + styleMetadata, + taskContext: options?.context, + retryContext: options?.retryContext, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setDialogNodeId, + startGenerationRequest, + finishGenerationRequest, + bindGenerationTask: (targetNodeId: string, task: GenerationTask) => { + bindGenerationTask(targetNodeId, task); + setNodes((current) => { + const source = current.find((node) => node.id === nodeId); + if (!source || source.metadata?.lastGenerationRequestFingerprint === requestFingerprint) return current; + return current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, lastGenerationRequestFingerprint: requestFingerprint } } : node)); + }); + options?.onTaskUpdate?.(task); + }, + applyGenerationTaskResult, + showError: (content: string) => message.error(content), + registerPendingNodeIds: (nodeIds: string[]) => { + pendingNodeIds = nodeIds; + }, + }; + + try { + if (mode === "image") await executeImageGeneration(execution); + else if (mode === "video") await executeVideoGeneration(execution); + else if (mode === "audio") await executeAudioGeneration(execution); + else await executeTextGeneration(execution); + } catch (error) { + if (isGenerationCanceled(error)) return; + const failure = generationFailureMetadata(error, prompt); + if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) { + setNodes((current) => + current.map((node) => { + if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node; + const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined }; + delete metadata.taskId; + delete metadata.taskStatus; + delete metadata.taskProgress; + delete metadata.taskStage; + delete metadata.taskCreatedAt; + delete metadata.taskUpdatedAt; + delete metadata.taskStartedAt; + delete metadata.taskCompletedAt; + delete metadata.taskDurationMs; + return { ...node, metadata }; + }), + ); + return; + } + message.error(failure.errorDetails); + setNodes((current) => + current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)), + ); + } finally { + finishGenerationRequest(nodeId, controller); + setRunningNodeId(null); + } + }, + () => message.info({ key: `canvas-generation-submission-${nodeId}`, content: "该节点已有生成请求正在提交或执行,请勿重复点击" }), + ), [ addedSkills, applyGenerationTaskResult, bindGenerationTask, + confirmDuplicateSubmission, domainProjectId, effectiveConfig, finishGenerationRequest, diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 8b0ddce18..0137c45df 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -34,9 +34,20 @@ type UseCanvasKeyboardOptions = { focusMode: boolean; exitFocusMode: () => void; toggleFocusMode: () => void; + onOpenSearch: () => void; beginBatchConnection: () => void; }; +type TextSelectionLike = { + isCollapsed: boolean; + rangeCount: number; + toString(): string; +}; + +export function hasCanvasTextSelection(selection: TextSelectionLike | null | undefined) { + return Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.toString()); +} + export function useCanvasKeyboard({ nodesRef, selectedNodeIdsRef, @@ -69,6 +80,7 @@ export function useCanvasKeyboard({ focusMode, exitFocusMode, toggleFocusMode, + onOpenSearch, beginBatchConnection, }: UseCanvasKeyboardOptions) { useEffect(() => { @@ -101,8 +113,13 @@ export function useCanvasKeyboard({ return; } if (isModifierShortcut && !event.altKey && key === "f") { + if (target?.closest(".ant-modal-wrap, .ant-dropdown, .ant-popover")) return; event.preventDefault(); - if (!event.repeat) toggleFocusMode(); + event.stopPropagation(); + if (!event.repeat) { + if (event.shiftKey) toggleFocusMode(); + else onOpenSearch(); + } return; } if (isTextEditingTarget) return; @@ -145,6 +162,7 @@ export function useCanvasKeyboard({ return; } if (isModifierShortcut && !event.altKey && key === "c") { + if (hasCanvasTextSelection(window.getSelection())) return; event.preventDefault(); copySelectedNodes(); return; @@ -200,5 +218,5 @@ export function useCanvasKeyboard({ window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("paste", handlePaste, true); }; - }, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); + }, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-node-editor.ts b/web/src/pages/canvas/use-canvas-node-editor.ts index 29bc52661..d6df5323a 100644 --- a/web/src/pages/canvas/use-canvas-node-editor.ts +++ b/web/src/pages/canvas/use-canvas-node-editor.ts @@ -5,14 +5,16 @@ import { saveAs } from "file-saver"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame"; +import { buildCanvasMediaDownloadFileName } from "@/lib/canvas/canvas-media-download"; import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain"; -import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; +import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas"; type UseCanvasNodeEditorOptions = { canvasId: string; + canvasTitle: string; domainProjectId?: string; nodesRef: { current: CanvasNodeData[] }; setNodes: Dispatch>; @@ -25,6 +27,7 @@ type UseCanvasNodeEditorOptions = { export function useCanvasNodeEditor({ canvasId, + canvasTitle, domainProjectId, nodesRef, setNodes, @@ -180,8 +183,8 @@ export function useCanvasNodeEditor({ const downloadNodeImage = useCallback((node: CanvasNodeData) => { if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return; - saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`); - }, []); + saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node)); + }, [canvasTitle]); const saveNodeAsset = useCallback(async (node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材"); diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index e5dff9846..f6d0aa686 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -7,7 +7,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame"; import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain"; -import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy"; +import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy"; import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas"; import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine"; @@ -461,8 +461,11 @@ export function useCanvasNodeOperations({ const dy = center.y - (bounds.top + bounds.bottom) / 2; const idMap = new Map(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`])); const copiedSourceIds = new Set(clipboard.nodes.map((node) => node.id)); + const reservedTitles = new Set(nodesRef.current.map((node) => node.title)); const nextNodes = clipboard.nodes.map((node) => { const metadata = isolateCopiedNodeMetadata(node, idMap); + const title = nextCopiedNodeTitle(node.title, reservedTitles); + reservedTitles.add(title); if (node.type === CanvasNodeType.Drawing && metadata) { metadata.drawingId = `${idMap.get(node.id)}-document`; metadata.drawingRevision = 0; @@ -473,7 +476,7 @@ export function useCanvasNodeOperations({ return { ...node, id: idMap.get(node.id)!, - title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, + title, position: { x: node.position.x + dx, y: node.position.y + dy }, parentId: node.parentId ? idMap.get(node.parentId) : undefined, metadata, diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index db40ae164..99c7741ab 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -4,6 +4,7 @@ import { useNavigate } from "react-router"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; +import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; @@ -109,7 +110,10 @@ export function useCanvasProjectLifecycle({ }; const restore = async () => { - const initialNodes = resetInterruptedGeneration(project.nodes); + const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), { + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }); const initialSessions = project.chatSessions || []; // 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。 diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 241d19716..46032ffd5 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -714,7 +714,7 @@ export function useCanvasUpload({ const storedImage = payload.url ? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" } : payload.storageKey - ? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } + ? { url: payload.dataUrl, storageKey: payload.storageKey, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" } : await uploadImage(payload.dataUrl); const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage; const size = fitNodeSize(meta.width, meta.height); @@ -725,38 +725,33 @@ export function useCanvasUpload({ return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData; }, []); - const handleAssetInsert = useCallback(async (payload: InsertAssetPayload, options: { openDialog?: boolean } = {}): Promise => { - const center = assetInsertPositionRef.current || getCanvasCenter(); - try { - const node = await createAssetPayloadNode(payload, center); - setNodes((current) => [...current, node]); - selectInsertedNode(node.id, options.openDialog === false ? "preserve" : payload.kind === "image" || payload.kind === "video" ? "open" : "preserve"); - closeAssetPicker(); - return node; - } catch (error) { - message.error(error instanceof Error ? error.message : "素材插入失败"); - return null; - } - }, [closeAssetPicker, createAssetPayloadNode, getCanvasCenter, message, selectInsertedNode, setNodes]); - - const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise => { - const origin = position || getCanvasCenter(); + const insertAssetPayloads = useCallback(async (payloads: InsertAssetPayload[], origin: Position, successMessage: string, failureMessage: string): Promise => { try { const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, { - x: origin.x + (index % 3) * 380, - y: origin.y + Math.floor(index / 3) * 300, + x: origin.x + (index % BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_COLUMN_GAP, + y: origin.y + Math.floor(index / BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_ROW_GAP, }))); setNodes((current) => [...current, ...created]); setSelectedNodeIds(new Set(created.map((node) => node.id))); setSelectedConnectionId(null); setDialogNodeId(null); - message.success(`已引入 ${created.length} 项项目资产`); + message.success(successMessage); return created; } catch (error) { - message.error(error instanceof Error ? error.message : "项目资产引入失败"); + message.error(error instanceof Error ? error.message : failureMessage); throw error; } - }, [createAssetPayloadNode, getCanvasCenter, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + }, [createAssetPayloadNode, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); + + const handleAssetsInsert = useCallback(async (payloads: InsertAssetPayload[]): Promise => { + const origin = assetInsertPositionRef.current || getCanvasCenter(); + return insertAssetPayloads(payloads, origin, `已插入 ${payloads.length} 项素材`, "素材插入失败"); + }, [getCanvasCenter, insertAssetPayloads]); + + const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise => { + const origin = position || getCanvasCenter(); + return insertAssetPayloads(payloads, origin, `已引入 ${payloads.length} 项项目资产`, "项目资产引入失败"); + }, [getCanvasCenter, insertAssetPayloads]); return { assetPickerOpen, @@ -765,7 +760,7 @@ export function useCanvasUpload({ createAssetPayloadNode, createImageAssetNode, fileDropActive, - handleAssetInsert, + handleAssetsInsert, handleDrop, handleFileDragEnter, handleFileDragLeave, diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index d2ecc3cc7..81d53136f 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -20,7 +20,7 @@ import { generationErrorCode, generationErrorMessage } from "@/lib/generation-er import { useCopyText } from "@/hooks/use-copy-text"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; -import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; +import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import { backendModelRuntimeRequired, isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; @@ -1761,10 +1761,6 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasP ; } -function videoResolutionLabel(value: string | number) { - return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`; -} - function formatMessageTime(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : ""; diff --git a/web/src/pages/plugins/plugin-development-guide.md b/web/src/pages/plugins/plugin-development-guide.md index 699d9c290..8db0bf771 100644 --- a/web/src/pages/plugins/plugin-development-guide.md +++ b/web/src/pages/plugins/plugin-development-guide.md @@ -65,7 +65,7 @@ my-plugin.yingce-plugin ## 4. Provider 与工作流 -请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和字符串变换(`|trim`、`|lower`、`|upper`、`|video-resolution`),不执行插件代码。`video-resolution` 会 trim 并转为小写,仅在输入是纯数字时补上 `p`,因此同时兼容历史状态中的 `768`、标准值 `768p` 和工作流值 `768p竖`。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。 +请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和通用字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。渠道声明的枚举值由画布完整保留,插件可使用通用大小写变换适配上游,不应要求宿主增加渠道专用归一化。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。 结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存影策资源引用。 diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 9abc781f8..291b7a113 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7016,7 +7016,6 @@ } .canvas-tool-button, - .canvas-menu-item, .canvas-node-tool-button { transform: translate3d(0, 0, 0) scale(1); transition: @@ -7027,6 +7026,15 @@ transform 160ms var(--motion-ease-out); } + .canvas-menu-item { + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + opacity 140ms ease, + box-shadow 140ms ease; + } + .canvas-connection-handle { touch-action: none; } @@ -7044,25 +7052,22 @@ } .canvas-tool-button:not(:disabled):active, - .canvas-node-tool-button:not(:disabled):active, - .canvas-menu-item:not(:disabled):active { + .canvas-node-tool-button:not(:disabled):active { transform: translate3d(0, 0, 0) scale(0.94); } - .canvas-menu-item:not(:disabled):hover { - transform: translate3d(4px, 0, 0); - } - - .canvas-menu-item:not(:disabled):hover .canvas-menu-item-icon, .canvas-node-tool-button:not(:disabled):hover .canvas-node-tool-icon { transform: scale(1.08); } - .canvas-menu-item-icon, .canvas-node-tool-icon { transition: transform 160ms var(--motion-ease-out); } + .canvas-menu-item-icon { + transition: opacity 140ms ease; + } + .canvas-node-shell, .canvas-frame-shell, .canvas-folder-shell { @@ -12838,6 +12843,375 @@ html:not(.dark) .creation-history-drawer-root { background: var(--popover) !important; box-shadow: var(--elevation-overlay) !important; } +.canvas-shortcuts-modal .ant-modal-close { + top: 16px; + right: 16px; + color: color-mix(in srgb, var(--foreground) 48%, transparent); +} +.canvas-shortcuts-modal .ant-modal-close:hover { + background: var(--surface-hover); + color: var(--foreground); +} +.canvas-shortcuts-shell { + display: flex; + height: min(680px, 78dvh); + min-height: 500px; + flex-direction: column; + color: var(--foreground); + overscroll-behavior: contain; +} +.canvas-shortcuts-header { + display: grid; + flex-shrink: 0; + grid-template-columns: minmax(220px, 1fr) minmax(260px, 360px); + align-items: center; + gap: var(--space-6); + border-bottom: 1px solid var(--border); + padding: 18px 56px 18px 20px; +} +.canvas-shortcuts-heading { + display: flex; + min-width: 0; + align-items: center; + gap: 12px; +} +.canvas-shortcuts-heading-icon { + display: grid; + width: 38px; + height: 38px; + flex-shrink: 0; + place-items: center; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: color-mix(in srgb, var(--foreground) 76%, transparent); +} +.canvas-shortcuts-heading-icon svg { + width: 18px; + height: 18px; + stroke-width: 1.7; +} +.canvas-shortcuts-heading > span:last-child { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.canvas-shortcuts-heading strong { + font-size: var(--fs-body); + font-weight: 680; + line-height: 1.2; +} +.canvas-shortcuts-heading small { + overflow: hidden; + color: color-mix(in srgb, var(--foreground) 45%, transparent); + font-size: var(--fs-micro); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.canvas-shortcuts-search { + display: flex; + height: 38px; + min-width: 0; + align-items: center; + gap: 9px; + border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--foreground) 5%, transparent); + padding: 0 10px; + transition: border-color var(--motion-state) ease, background-color var(--motion-state) ease, box-shadow var(--motion-state) ease; +} +.canvas-shortcuts-search:focus-within { + border-color: color-mix(in srgb, var(--ring) 48%, transparent); + background: color-mix(in srgb, var(--foreground) 7%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 18%, transparent); +} +.canvas-shortcuts-search > svg { + width: 15px; + height: 15px; + flex-shrink: 0; + color: color-mix(in srgb, var(--foreground) 40%, transparent); + stroke-width: 1.8; +} +.canvas-shortcuts-search input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--foreground); + font-size: var(--fs-label); +} +.canvas-shortcuts-search input::placeholder { + color: color-mix(in srgb, var(--foreground) 36%, transparent); +} +.canvas-shortcuts-search button { + flex-shrink: 0; + border: 0; + border-radius: var(--r-xs); + background: transparent; + padding: 3px 5px; + color: color-mix(in srgb, var(--foreground) 48%, transparent); + font-size: var(--fs-micro); + cursor: pointer; +} +.canvas-shortcuts-search button:hover { + background: var(--surface-hover); + color: var(--foreground); +} +.canvas-shortcuts-search button:focus-visible, +.canvas-shortcuts-categories button:focus-visible { + outline: 2px solid var(--control-focus-ring); + outline-offset: -2px; +} +.canvas-shortcuts-close-hint kbd { + display: inline-flex; + min-width: 28px; + height: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); + border-radius: var(--r-xs); + background: color-mix(in srgb, var(--foreground) 5%, transparent); + padding: 0 6px; + color: color-mix(in srgb, var(--foreground) 48%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--fs-tiny); + font-weight: 600; +} +.canvas-shortcuts-body { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: 174px minmax(0, 1fr); +} +.canvas-shortcuts-categories { + display: flex; + min-height: 0; + flex-direction: column; + gap: 3px; + border-right: 1px solid var(--border); + padding: 14px 10px; +} +.canvas-shortcuts-categories button { + display: flex; + width: 100%; + min-height: 36px; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + border-radius: var(--r-sm); + background: transparent; + padding: 0 10px; + color: color-mix(in srgb, var(--foreground) 58%, transparent); + font-size: var(--fs-label); + text-align: left; + cursor: pointer; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease; +} +.canvas-shortcuts-categories button:hover { + background: var(--surface-hover); + color: var(--foreground); +} +.canvas-shortcuts-categories button.is-active { + background: var(--control-selected-bg); + color: var(--control-selected-fg); + font-weight: 620; +} +.canvas-shortcuts-categories button small { + color: color-mix(in srgb, currentColor 52%, transparent); + font-size: var(--fs-tiny); + font-variant-numeric: tabular-nums; +} +.canvas-shortcuts-results { + min-width: 0; + overflow-y: auto; + padding: 8px 18px 18px; + scrollbar-gutter: stable; +} +.canvas-shortcuts-list { + display: flex; + flex-direction: column; +} +.canvas-shortcuts-row { + display: grid; + min-height: 66px; + grid-template-columns: minmax(180px, 1fr) minmax(220px, auto); + align-items: center; + gap: var(--space-5); + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + padding: 11px 8px; +} +.canvas-shortcuts-row:last-child { + border-bottom: 0; +} +.canvas-shortcuts-row:hover { + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--foreground) 3.5%, transparent); +} +.canvas-shortcuts-row-copy { + min-width: 0; +} +.canvas-shortcuts-row-title { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} +.canvas-shortcuts-row-title strong { + overflow: hidden; + font-size: var(--fs-label); + font-weight: 640; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.canvas-shortcuts-row-title small { + flex-shrink: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + padding: 2px 6px; + color: color-mix(in srgb, var(--foreground) 44%, transparent); + font-size: var(--fs-tiny); + line-height: 1.3; +} +.canvas-shortcuts-row-copy p { + margin: 4px 0 0; + overflow: hidden; + color: color-mix(in srgb, var(--foreground) 43%, transparent); + font-size: var(--fs-micro); + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} +.canvas-shortcuts-keys { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; +} +.canvas-shortcuts-combination, +.canvas-shortcuts-key-part { + display: inline-flex; + align-items: center; + gap: 4px; +} +.canvas-shortcuts-combination em, +.canvas-shortcuts-key-part i { + color: color-mix(in srgb, var(--foreground) 28%, transparent); + font-size: var(--fs-tiny); + font-style: normal; +} +.canvas-shortcuts-combination em { + margin: 0 2px; +} +.canvas-shortcuts-key-part kbd { + display: inline-flex; + min-width: 28px; + min-height: 26px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--foreground) 7%, transparent); + padding: 4px 7px; + color: color-mix(in srgb, var(--foreground) 72%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--fs-tiny); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} +.canvas-shortcuts-empty { + display: grid; + min-height: 300px; + place-items: center; + align-content: center; + gap: 8px; + text-align: center; +} +.canvas-shortcuts-empty svg { + width: 26px; + height: 26px; + margin-bottom: 3px; + color: color-mix(in srgb, var(--foreground) 28%, transparent); + stroke-width: 1.5; +} +.canvas-shortcuts-empty strong { + font-size: var(--fs-label); + font-weight: 620; +} +.canvas-shortcuts-empty span { + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: var(--fs-micro); +} +.canvas-shortcuts-footer { + display: flex; + min-height: 44px; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + border-top: 1px solid var(--border); + padding: 8px 18px; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: var(--fs-micro); +} +.canvas-shortcuts-close-hint { + display: inline-flex; + align-items: center; + gap: 6px; +} +@media (max-width: 680px) { + .canvas-shortcuts-shell { + height: min(720px, 88dvh); + min-height: 440px; + } + .canvas-shortcuts-header { + grid-template-columns: 1fr; + gap: 12px; + padding: 16px 48px 14px 14px; + } + .canvas-shortcuts-body { + display: flex; + flex-direction: column; + } + .canvas-shortcuts-categories { + flex-shrink: 0; + flex-direction: row; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 8px 10px; + } + .canvas-shortcuts-categories button { + width: auto; + min-width: max-content; + gap: 7px; + padding: 0 10px; + } + .canvas-shortcuts-results { + padding: 6px 10px 14px; + } + .canvas-shortcuts-row { + min-height: 0; + grid-template-columns: 1fr; + gap: 9px; + padding: 12px 6px; + } + .canvas-shortcuts-row-copy p { + white-space: normal; + } + .canvas-shortcuts-keys { + justify-content: flex-start; + } +} .asset-library-picker-modal { margin: 0 auto !important; } .asset-library-picker-modal .ant-modal-container { padding: 0 !important; } .asset-library-picker-modal .ant-modal-body { padding: 0 !important; } diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 40d3f5112..a0d6ae70e 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -205,6 +205,7 @@ export type CanvasNodeMetadata = { generationErrorCode?: string; resourceReloadAvailable?: boolean; failedPromptFingerprint?: string; + lastGenerationRequestFingerprint?: string; fontSize?: number; generationMode?: CanvasGenerationMode; generationType?: CanvasImageGenerationType; @@ -407,6 +408,8 @@ export type CanvasNodeData = { id: string; type: CanvasNodeTypeId; title: string; + createdAt?: string; + updatedAt?: string; position: Position; width: number; height: number; diff --git a/web/test/canvas-asset-picker.test.ts b/web/test/canvas-asset-picker.test.ts new file mode 100644 index 000000000..af02a6869 --- /dev/null +++ b/web/test/canvas-asset-picker.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal"; +import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; +import type { ImageAsset, TextAsset } from "@/stores/use-asset-store"; + +const timestamp = "2026-08-28T00:00:00.000Z"; + +describe("canvas asset picker", () => { + test("maps multiple local and external selections in the requested order", () => { + const image: ImageAsset = { + id: "image-1", + kind: "image", + title: "场景图", + coverUrl: "", + tags: [], + createdAt: timestamp, + updatedAt: timestamp, + data: { dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", width: 1280, height: 720, bytes: 1024, mimeType: "image/png" }, + }; + const text: TextAsset = { + id: "text-1", + kind: "text", + title: "旁白", + coverUrl: "", + tags: [], + createdAt: timestamp, + updatedAt: timestamp, + data: { content: "夜色渐深。" }, + }; + const items: AssetLibraryPickerItem[] = [ + { id: image.id, title: image.title, category: "environment", kindLabel: "图片", asset: image }, + { id: text.id, title: text.title, category: "other", kindLabel: "文本", asset: text }, + { + id: "external-video", + title: "外部视频", + category: "external:eagle", + kindLabel: "视频", + external: { + sourceId: "eagle", + sourceName: "Eagle", + item: { id: "video-1", title: "外部视频", kind: "video", fileUrl: "http://127.0.0.1/video.mp4", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4" }, + }, + }, + ]; + + expect(assetPickerItemsToInsertPayloads(["external-video", image.id, text.id], items)).toEqual([ + { kind: "video", url: "http://127.0.0.1/video.mp4", title: "外部视频", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4", assetId: "external:eagle:video-1" }, + { kind: "image", dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", title: "场景图", width: 1280, height: 720, bytes: 1024, mimeType: "image/png", assetId: "image-1" }, + { kind: "text", content: "夜色渐深。", title: "旁白", assetId: "text-1" }, + ]); + }); + + test("rejects stale selections instead of silently dropping them", () => { + expect(() => assetPickerItemsToInsertPayloads(["missing"], [])).toThrow("所选素材已不存在"); + }); +}); + +describe("canvas context menu motion", () => { + test("keeps hover feedback stationary while retaining spotlight surfaces", () => { + const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); + const menu = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-context-menu.tsx"), "utf8"); + const menuRule = styles.match(/\.canvas-menu-item\s*\{([\s\S]*?)\}/)?.[1] || ""; + + expect(menuRule).not.toContain("transform"); + expect(styles).not.toContain(".canvas-menu-item:not(:disabled):hover {"); + expect(styles).not.toContain(".canvas-menu-item:not(:disabled):active"); + expect(menu).not.toContain("group-hover:translate-x"); + expect(menu).not.toContain("scale: 0.97"); + expect(menu).toContain("[0]> = {}) { + return canvasGenerationRequestFingerprint({ + nodeId: "node-1", + mode: "video", + prompt: "夜晚的城市", + model: "video-model", + options: { size: "16:9", videoSeconds: 10, vquality: "768p" }, + context: { + referenceImages: [{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", storageKey: "resource:image-1" }], + referenceVideos: [], + referenceAudios: [], + characterReferences: [], + resolvedCharacterVersions: [], + resolvedCharacterVoices: [], + }, + ...overrides, + }); +} + +describe("canvas generation submission", () => { + test("同一节点的并发提交只执行一次", async () => { + const locks = new Map>(); + let executions = 0; + let duplicates = 0; + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const run = () => + runCanvasGenerationSubmissionOnce( + locks, + "node-1", + async () => { + executions += 1; + await pending; + return "done"; + }, + () => { + duplicates += 1; + }, + ); + + const submissions = [run(), run(), run()]; + await Promise.resolve(); + expect(executions).toBe(1); + expect(duplicates).toBe(2); + release(); + expect(await Promise.all(submissions)).toEqual(["done", "done", "done"]); + + await run(); + expect(executions).toBe(2); + }); + + test("相同输入生成稳定指纹,关键内容变化会改变指纹", () => { + expect(fingerprint({ options: { vquality: "768p", videoSeconds: 10, size: "16:9" } })).toBe(fingerprint()); + expect(fingerprint({ prompt: "白天的城市" })).not.toBe(fingerprint()); + expect(fingerprint({ model: "another-model" })).not.toBe(fingerprint()); + expect(fingerprint({ options: { size: "9:16", videoSeconds: 10, vquality: "768p" } })).not.toBe(fingerprint()); + expect( + fingerprint({ + context: { + referenceImages: [{ id: "image-2", name: "other.png", type: "image/png", dataUrl: "", storageKey: "resource:image-2" }], + referenceVideos: [], + referenceAudios: [], + characterReferences: [], + resolvedCharacterVersions: [], + resolvedCharacterVoices: [], + }, + }), + ).not.toBe(fingerprint()); + }); +}); diff --git a/web/test/canvas-keyboard.test.ts b/web/test/canvas-keyboard.test.ts new file mode 100644 index 000000000..4c4e6ccf6 --- /dev/null +++ b/web/test/canvas-keyboard.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; + +import { hasCanvasTextSelection } from "../src/pages/canvas/use-canvas-keyboard"; + +test("Canvas copy shortcut yields to a real browser text selection", () => { + expect(hasCanvasTextSelection(null)).toBe(false); + expect(hasCanvasTextSelection({ isCollapsed: true, rangeCount: 1, toString: () => "Agent 文本" })).toBe(false); + expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 0, toString: () => "Agent 文本" })).toBe(false); + expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "" })).toBe(false); + expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "Agent 文本" })).toBe(true); +}); + +test("Canvas keyboard keeps node copy as the fallback when no text is selected", async () => { + const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-keyboard.ts", import.meta.url)).text(); + expect(source).toContain("if (hasCanvasTextSelection(window.getSelection())) return;"); + expect(source).toContain("event.preventDefault();\n copySelectedNodes();"); +}); diff --git a/web/test/canvas-local-runtime.test.ts b/web/test/canvas-local-runtime.test.ts index 898b6951d..f3b5f8743 100644 --- a/web/test/canvas-local-runtime.test.ts +++ b/web/test/canvas-local-runtime.test.ts @@ -230,6 +230,27 @@ test("Canvas launch mode auto-connects after rejected legacy deep-link secrets a expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false); }); +test("Canvas local Agent setup generates exact-origin commands for Unix and Windows", async () => { + const module = await import("../src/lib/canvas/local-agent-setup"); + const unix = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "unix"); + const windows = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "windows"); + + expect(unix.install).toContain("git clone https://github.com/ddcat-ai/open-ai-canvas.git"); + expect(unix.start).toContain("FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'"); + expect(unix.start).toContain("node dist/index.js"); + expect(windows.start).toContain("$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'"); + expect(windows.start).toContain("node .\\dist\\index.js"); + expect(module.detectLocalAgentSetupPlatform("Windows NT 10.0")).toBe("windows"); + expect(module.detectLocalAgentSetupPlatform("Macintosh Intel Mac OS X")).toBe("unix"); +}); + +test("Canvas local Agent tutorial replaces the conversation only while disconnected", async () => { + const source = await Bun.file(new URL("../src/components/canvas/canvas-local-agent-panel.tsx", import.meta.url)).text(); + expect(source).toContain('!connected ? (\n { const module = await import("../src/lib/canvas/local-runtime-connection"); const waitForReconnect = ( diff --git a/web/test/canvas-media-download.test.ts b/web/test/canvas-media-download.test.ts new file mode 100644 index 000000000..c919d2c1f --- /dev/null +++ b/web/test/canvas-media-download.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { buildCanvasMediaDownloadFileName, canvasMediaFileExtension } from "@/lib/canvas/canvas-media-download"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; + +function mediaNode(overrides: Partial = {}): CanvasNodeData { + return { + id: "image-1", + type: CanvasNodeType.Image, + title: "女明星角色三视图", + position: { x: 0, y: 0 }, + width: 1024, + height: 1024, + metadata: { content: "data:image/png;base64,aW1hZ2U=", mimeType: "image/png", status: "success" }, + ...overrides, + }; +} + +describe("canvas media download", () => { + test("按画布名、节点名和本地日期生成文件名", () => { + expect(buildCanvasMediaDownloadFileName("写给阿妈的情书", mediaNode(), new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_女明星角色三视图_20260828.png"); + }); + + test("清理跨平台非法字符且保留中文可读名称", () => { + const node = mediaNode({ title: "女明星/角色:三视图?" }); + expect(buildCanvasMediaDownloadFileName("写给阿妈的情书|终稿", node, new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_终稿_女明星_角色_三视图_20260828.png"); + }); + + test("截断长名称后不会留下 Windows 不接受的结尾句点", () => { + const node = mediaNode({ title: `${"a".repeat(79)}.extra` }); + expect(buildCanvasMediaDownloadFileName("画布", node, new Date(2026, 7, 28, 12))).toBe(`画布_${"a".repeat(79)}_20260828.png`); + }); + + test("优先按媒体 MIME 类型确定视频扩展名", () => { + expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/webm" } }))).toBe("webm"); + expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/quicktime" } }))).toBe("mov"); + }); + + test("MIME 类型不明确时从远程资源 URL 识别扩展名", () => { + expect(canvasMediaFileExtension(mediaNode({ metadata: { content: "https://example.com/result.jpeg?token=hidden", mimeType: "image/*" } }))).toBe("jpg"); + }); +}); diff --git a/web/test/canvas-node-copy.test.ts b/web/test/canvas-node-copy.test.ts new file mode 100644 index 000000000..fa3c813c8 --- /dev/null +++ b/web/test/canvas-node-copy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy"; + +describe("canvas node copy title", () => { + test("首次复制使用 copy1,后续按已有最大序号递增", () => { + expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图"])).toBe("女明星角色三视图_copy1"); + expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2"); + expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图_copy1", "女明星角色三视图_copy3"])).toBe("女明星角色三视图_copy4"); + }); + + test("复制已有副本时仍使用原始名称计算下一序号", () => { + expect(nextCopiedNodeTitle("女明星角色三视图_copy1", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2"); + }); + + test("兼容旧版空格 Copy 后缀", () => { + expect(nextCopiedNodeTitle("女明星角色三视图 Copy", ["女明星角色三视图", "女明星角色三视图 Copy"])).toBe("女明星角色三视图_copy1"); + }); + + test("同一批粘贴可以通过预留标题连续分配序号", () => { + const titles = new Set(["节点", "节点_copy1"]); + const first = nextCopiedNodeTitle("节点", titles); + titles.add(first); + const second = nextCopiedNodeTitle("节点", titles); + expect([first, second]).toEqual(["节点_copy2", "节点_copy3"]); + }); +}); diff --git a/web/test/canvas-node-search.test.ts b/web/test/canvas-node-search.test.ts new file mode 100644 index 000000000..0e17219f7 --- /dev/null +++ b/web/test/canvas-node-search.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search"; +import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; + +function node(id: string, patch: Partial = {}): CanvasNodeData { + return { + id, + type: CanvasNodeType.Image, + title: id, + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: {}, + ...patch, + }; +} + +describe("canvas node timestamps", () => { + test("legacy nodes receive the project timestamp baseline", () => { + const normalized = normalizeCanvasNodeTimestamps([node("legacy")], { + createdAt: "2026-08-01T01:00:00.000Z", + updatedAt: "2026-08-20T02:00:00.000Z", + }); + expect(normalized[0]?.createdAt).toBe("2026-08-01T01:00:00.000Z"); + expect(normalized[0]?.updatedAt).toBe("2026-08-20T02:00:00.000Z"); + }); + + test("new nodes and meaningful edits receive timestamps without touching unchanged nodes", () => { + const created = stampCanvasNodeChanges([], [node("new")], "2026-08-28T01:00:00.000Z"); + expect(created[0]).toMatchObject({ createdAt: "2026-08-28T01:00:00.000Z", updatedAt: "2026-08-28T01:00:00.000Z" }); + + const unchanged = created[0]!; + const edited = { ...unchanged, title: "新标题" }; + const updated = stampCanvasNodeChanges(created, [edited], "2026-08-28T02:00:00.000Z"); + expect(updated[0]?.createdAt).toBe("2026-08-28T01:00:00.000Z"); + expect(updated[0]?.updatedAt).toBe("2026-08-28T02:00:00.000Z"); + }); + + test("media hydration does not pretend to be a user edit", () => { + const previous = node("image", { + createdAt: "2026-08-28T01:00:00.000Z", + updatedAt: "2026-08-28T01:00:00.000Z", + metadata: { storageKey: "resource:1", content: "resource:1" }, + }); + const hydrated = { ...previous, metadata: { ...previous.metadata, content: "blob:preview", naturalWidth: 1920, naturalHeight: 1080 } }; + const updated = stampCanvasNodeChanges([previous], [hydrated], "2026-08-28T02:00:00.000Z"); + expect(updated[0]?.updatedAt).toBe("2026-08-28T01:00:00.000Z"); + }); +}); + +describe("canvas node search", () => { + test("defaults to recently edited order and searches model or tags", () => { + const older = node("older", { updatedAt: "2026-08-20T01:00:00.000Z", metadata: { model: "wan-video", assetTags: ["夜景"] } }); + const newer = node("newer", { updatedAt: "2026-08-28T01:00:00.000Z", metadata: { model: "seedance" } }); + expect(searchCanvasNodes([older, newer], "").map((item) => item.id)).toEqual(["newer", "older"]); + expect(searchCanvasNodes([older, newer], "夜景").map((item) => item.id)).toEqual(["older"]); + expect(searchCanvasNodes([older, newer], "seedance").map((item) => item.id)).toEqual(["newer"]); + }); + + test("summaries expose useful media metadata without leaking data URLs into context", () => { + const image = node("image", { + createdAt: "2026-08-28T01:00:00.000Z", + updatedAt: "2026-08-28T02:00:00.000Z", + metadata: { content: "data:image/png;base64,AAAA", naturalWidth: 1920, naturalHeight: 1080, bytes: 2 * 1024 * 1024, model: "image-model" }, + }); + expect(canvasNodeMaterialSummary(image)).toContain("1920×1080"); + expect(canvasNodeSearchContext(image)).toBe("图片节点"); + expect(canvasNodeSearchTimes(image)).toMatchObject({ createdAt: image.createdAt, updatedAt: image.updatedAt }); + }); +}); diff --git a/web/test/canvas-shortcuts.test.ts b/web/test/canvas-shortcuts.test.ts new file mode 100644 index 000000000..894160886 --- /dev/null +++ b/web/test/canvas-shortcuts.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { CANVAS_SHORTCUTS, filterCanvasShortcuts } from "../src/lib/canvas/canvas-shortcuts"; + +describe("canvas shortcuts", () => { + test("uses Ctrl/Cmd + F for search and Shift + Ctrl/Cmd + F for focus mode", () => { + expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "search")?.keys).toEqual([["Ctrl / Cmd", "F"]]); + expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "focus")?.keys).toEqual([["Shift", "Ctrl / Cmd", "F"]]); + }); + + test("searches titles, descriptions, keys and keywords", () => { + expect(filterCanvasShortcuts("粘贴").map((shortcut) => shortcut.id)).toContain("paste"); + expect(filterCanvasShortcuts("缩放").map((shortcut) => shortcut.id)).toEqual(expect.arrayContaining(["zoom-wheel", "zoom-controls", "zoom-presets"])); + expect(filterCanvasShortcuts("Alt L").map((shortcut) => shortcut.id)).toContain("batch-connect"); + }); + + test("filters by category without losing the full catalog", () => { + expect(filterCanvasShortcuts("", "common").every((shortcut) => shortcut.category === "common")).toBe(true); + expect(filterCanvasShortcuts("", "all")).toHaveLength(CANVAS_SHORTCUTS.length); + }); +}); diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts index deebed5d3..37ede388d 100644 --- a/web/test/channel-model-catalog.test.ts +++ b/web/test/channel-model-catalog.test.ts @@ -5,6 +5,8 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover"; +import { VideoSettingsPanel } from "../src/components/video-settings-panel"; +import { canvasThemes } from "../src/lib/canvas-theme"; import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing"; @@ -328,4 +330,32 @@ describe("public channel model catalog", () => { expect(body.seconds).toBe("8"); expect(body.size).toBe("1280x720"); }); + + test("preserves provider resolution enums with direction suffixes across Canvas display and requests", async () => { + let body: Record = {}; + axios.post = (async (_url: string, requestBody: unknown) => { + body = formEntries(requestBody); + return { data: { id: "synthetic-directional" } }; + }) as typeof axios.post; + const catalog: ChannelModelCatalogItem = { + ...omniCatalog, + id: "directional-video", + displayName: "Directional Video", + defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "768p竖" }, + options: { + ...omniCatalog.options, + durationSeconds: [{ value: "8" }], + resolution: [{ value: "480p竖" }, { value: "768p竖" }, { value: "480p横" }, { value: "768p横" }], + }, + }; + const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "768P竖" }); + + const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined })); + const panelHtml = renderToStaticMarkup(React.createElement(VideoSettingsPanel, { config, onConfigChange: () => undefined, theme: canvasThemes.dark })); + await createVideoGenerationTask(config, "synthetic prompt"); + + expect(html).toContain("768P竖 · 16:9 · 8s"); + expect(panelHtml).toMatch(/aria-pressed="true"[^>]*>768P竖<\/button>/); + expect(body.resolution_name).toBe("768p竖"); + }); }); diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts index 5e26a1cdd..1e0a00bc4 100644 --- a/web/test/model-pricing.test.ts +++ b/web/test/model-pricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { modelQuoteRequest, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; +import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing"; import type { ModelRequirements } from "../src/lib/model-selection"; import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store"; @@ -58,6 +58,11 @@ const textVideoRequirements: ModelRequirements = { }; describe("model request pricing", () => { + test("preserves provider-specific resolution enums when matching price tiers", () => { + expect(normalizeTierResolution("768P竖")).toBe("768p竖"); + expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait"); + }); + test("matches the current Agnes resolution and duration tier and totals per-second credits", () => { const config = systemConfig({ tiers: [ diff --git a/web/test/video-generation-options.test.ts b/web/test/video-generation-options.test.ts index a385e7d62..714d63147 100644 --- a/web/test/video-generation-options.test.ts +++ b/web/test/video-generation-options.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; -import { normalizeVideoResolution, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options"; +import { formatVideoResolutionLabel, normalizeVideoResolution, videoResolutionComparisonKey, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options"; describe("video generation resolution options", () => { test("统一档位包含 1440P 与 4K,并识别常见别名", () => { @@ -14,6 +14,10 @@ describe("video generation resolution options", () => { test("保留模型声明的非标准档位而不是静默降级", () => { expect(normalizeVideoResolution("768p")).toBe("768"); + expect(normalizeVideoResolution("768p竖")).toBe("768p竖"); + expect(normalizeVideoResolution("HD_Portrait")).toBe("HD_Portrait"); + expect(videoResolutionComparisonKey("768P竖")).toBe("768p竖"); + expect(formatVideoResolutionLabel("768p竖")).toBe("768P竖"); }); test("按协议限制实际可选档位", () => {