合并 AI 设计图生图客户端

This commit is contained in:
2026-08-13 15:41:45 +08:00
12 changed files with 301 additions and 69 deletions

View File

@@ -16,6 +16,7 @@
| AI 编程模型代理错误 | OpenCode provider 请求 | Main Host AI proxy → Works 模型上游 | 配额耗尽保持独立终止态;只有明确的分组上游饱和才投影为 OpenCode 终止状态,通用限速仍保留 `429` |
| 设计会话创建/切换 | AI 绘画页面或侧栏 | Renderer API → Main → Workspace Conversation API | 新会话属于现有 Workspace读取独立消息、Brief、Quote 和 `turnRevision` |
| 设计消息与确认 | 当前 Conversation | Main → 持久 Agent Gateway Session → Conversation 快照 | 请求和流式结果同时绑定 Workspace + Conversation |
| 设计单图来源选择 | 当前 Workspace 已完成图片 / 本地图片 | 现有 Asset 上传或选择 → `attachmentAssetIds` → 当前 Conversation Turn | 图片 Brief 用作图生图参考图;视频 Brief 用作首帧;只提交一个真实 Workspace Asset ID |
| 设计任务同步 | 任一 Conversation 的事件流 / REST | Renderer Workspace 任务列表 | Task 和 Asset 按 Workspace 归并,切换 Conversation 后仍可见 |
## State Ownership
@@ -26,6 +27,7 @@
- 本地构建临时目录、HTTP origin 和预检 WebContents/partition 只属于一次调用;预检读取与 `built_archive` 相同的内存字节,但结果不写为可信上传 receipt也不覆盖生产 opaque-origin。
- 旧 schema v1 `submitted` 记录迁移并保留;旧 `armed``waiting_for_package``waiting_for_login``uploading``failed` 归一为 `legacy_retired`,不再启动 watcher 或上传任务。
- AI 绘画 Conversation 持有消息、Brief、Quote、`turnRevision` 和服务端 Session 绑定Workspace 持有 Conversation 列表、生成任务和资产。
- 图生图参考图与视频首帧都先归一为当前 Workspace 的 Asset从作品选择时复用生成结果 Asset本地选择时先走既有上传接口再把唯一 Asset ID 随 Turn 提交。选择或上传成功后关闭选择器。
- 本地开发适配器将旧单会话 schema v2 原子迁移为带默认 Conversation 的 schema v3打包应用不使用该本地适配器作为云端失败回退。
- 注销和退出会关闭本地事件流并清除本机 Conversation Session-id 缓存;服务端持久 Session 保留,下一次访问从 Conversation API 重新读取。
- AI 编程 Store 的 `sessionMessagesBySessionId` own-key 是加载状态契约:键缺失表示历史未知,存在且值为 `[]` 表示已知为空。只有后者可使用不读取历史的快速选择;普通历史会话选择继续刷新消息。
@@ -41,4 +43,4 @@
## Last Updated
2026-08-12
2026-08-13

View File

@@ -25,7 +25,7 @@
| `electron/image-workspace/works-square-workspace.ts` | Works Square 多 Conversation 云端适配器与 Gateway 事件映射 | 使用服务端持久 Session本地清理不 DELETE 远端 Session |
| `electron/image-workspace/local-workspace.ts` | 未打包开发模式的本地 Workspace 适配器和 v2→v3 迁移 | 仅开发使用,不得成为打包回退 |
| `src/stores/image-workspace.ts` | 当前 Workspace/Conversation、项目任务及流式更新状态 | 异步结果按 Workspace + Conversation generation/revision 防护 |
| `src/pages/ImageCanvas/index.tsx` | Conversation 对话、Quote 确认、统一任务列表新会话入口 | Enter 发送、Shift+Enter 换行、IME 保护 |
| `src/pages/ImageCanvas/index.tsx` | Conversation 对话、Quote 确认、统一任务列表新会话入口与单图来源选择器 | 图片 Brief 选择/上传图生图参考图;视频 Brief 绑定首帧;均提交一个 Workspace Asset ID |
| `src/components/layout/ImageWorkspaceSidebar.tsx` | Workspace 与近期 Conversation 切换/创建 | 切换会话不清空项目级任务 |
## Dependency Direction
@@ -45,7 +45,8 @@
- `ai-proxy.ts` 的上游饱和状态投影依赖当前固定 OpenCode 的重试语义和窄化错误文案;升级 runtime 或调整上游错误格式时必须复核,不能把所有 `429` 统一终止。
- 多 Conversation 事件处理必须区分对话快照与 Workspace 任务更新;不得用任务时间戳推进 Conversation 流水位,也不得让旧会话的迟到流覆盖当前会话。
- `closeEventSessions` 只负责本地流和缓存生命周期;远端 Conversation Session 是服务端持久资源。
- 单图来源选择器当前仍由精确中文 quick reply 触发,并以 Brief medium 判断图生图或视频首帧用途;扩展更多输入用途前应先把消息协议升级为结构化 action/purpose避免展示文案与行为继续耦合。
## Last Updated
2026-08-12
2026-08-13

View File

@@ -19,6 +19,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
| Built Artifact Preflight | 在上传前对最终 built snapshot 做桌面/移动 UX fail-fast | Main 使用临时 loopback origin 与 Electron WebContents/CDP不使用 Playwright、不生成可信证明 |
| AI Design Workspace | 保存项目身份、Conversation 列表、生成任务和资产 | 任务和资产在切换 Conversation 后继续可见 |
| AI Design Conversation | 保存消息、Brief、Quote、`turnRevision` 与服务端 Agent Session 绑定 | 同一 Workspace 内互相隔离Session 由服务端持久化 |
| AI Design Image Source Picker | 当前 Workspace 已完成图片或本地上传 | 图片 Brief 绑定图生图参考图;视频 Brief 绑定首帧;提交一个真实 Asset ID |
| AI Design Event Routing | Main 云端适配器 → Host API/SSE → Renderer store | Conversation 更新按 Workspace + Conversation 路由;任务更新按 Workspace 归并 |
## Important Boundaries
@@ -37,11 +38,13 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
- AI 绘画中,一个 Workspace 可包含多条 Conversation消息、Brief、Quote 和 `turnRevision` 属于 Conversation生成任务和资产属于 Workspace。新建 Conversation 不得隐式创建新 Workspace。
- 每条 Conversation 复用服务端持久 Agent Gateway Session。客户端不得在注销或退出时 DELETE 该 Session只关闭本地事件流并清除本机 Session-id 缓存,重新访问时从 Conversation API 刷新。
- Renderer 的异步与流式状态必须同时校验 Workspace + Conversation 身份;项目任务事件只按 Workspace 归并,避免切换会话时丢失任务或接收迟到消息。
- 图片与视频复用同一个单图来源选择器。图片 Brief 可从当前 Workspace 的已完成作品或本地上传中选择一张参考图继续生成;视频 Brief 使用同一入口绑定首帧。两条路径都必须通过现有 Workspace Asset 上传/选择契约提交一个真实 `attachmentAssetIds`,不得用本地路径或自然语言描述代替资产身份。
- 当前兼容协议仍通过精确 quick reply `从作品列表选择图片` 打开选择器,并由当前 Brief medium 区分用途;仅 `medium === image` 使用图生图语义,`video`、null 或缺失 medium 保持视频首帧语义。该展示文案耦合不得扩散到更多用途,后续应升级为结构化 action/purpose。
## Related Decisions
- 当前长期边界记录于 README、ADR-001、集成任务 `20260807-integrate-login-client-a4f8`、源任务 `20260810-static-release-only-a91c``20260812-client-built-release-makelore-7e5b` 及本次 Integration Gate后续如改变唯一入口、凭据所有权、构建执行边界、Conversation 状态归属或重新引入客户端部署协调器,应新增 ADR。
- 当前长期边界记录于 README、ADR-001、集成任务 `20260807-integrate-login-client-a4f8`、源任务 `20260810-static-release-only-a91c``20260812-client-built-release-makelore-7e5b``20260812-design-image-to-image-client-c91e` 及本次 Integration Gate后续如改变唯一入口、凭据所有权、构建执行边界、Conversation 状态归属或重新引入客户端部署协调器,应新增 ADR。
## Last Updated
2026-08-12
2026-08-13

View File

@@ -13,12 +13,13 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
- `926056a`Makelore 内置 Electron WebContents/CDP 发布前本地预览检查。
- `5b44864`Main-owned 本地 npm/Vite 构建、同字节 Electron 双视口预检、source+built 双归档与 artifact contract 上传。
- `08da976` / `0ee5254`AI 编程首次发送已知空 session 快速路径与明确上游饱和终止态投影。
- `809364e` / `88281b8` / `7a807a2`AI 绘画单参考图图生图选择/上传交互、最新客户端主线集成及旧版 Brief medium 缺失兼容。
## Current Focus
客户端面向非专业用户提供“创建小游戏或小程序 → 项目配置中一键提交 → Main 本地 npm/Vite 构建 → Electron 双视口预检最终产物 → 上传 source+built 双归档与 contract → 运营审核”的唯一创建者链路。Main 对安全源码快照运行安装包内固定 npm 11.6.2 的 `npm ci --ignore-scripts`,再显式调用项目 `package-lock.json` 锁定的 ViteVite config/plugins 以当前桌面用户权限执行,因此只适用于用户信任的本地项目,不是 sandbox。预检由 Main 以临时 loopback origin 和 Electron WebContents/CDP 检查与最终 `built_archive` 相同的内存文件字节,覆盖桌面/移动视口、运行错误、白屏和外域访问;不使用 Playwright。该检查仍可由非官方客户端绕过不产生可信 receipt也不复刻生产 opaque-origin。服务端不再替客户端运行项目 Vite而是把源码、构建归档和 contract 视为不可信输入,逐字节重算与校验并固化不可变 Release人工审核仍不可绕过。自定义和缺少类型字段的旧项目不提供该入口。已发布作品优先使用安全投影后的 `play_url``runtime_url` 仅保留一个客户端版本的兼容回退。
AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。每条 Conversation 使用服务端持久 Agent Gateway Session切换会话只重连对应事件流。
AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session切换会话只重连对应事件流。
AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取prompt 可直接进入 Host API普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`
@@ -32,6 +33,7 @@ AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗
- 2026-08-12一键提交前复用当前项目的内置浏览器 loopback preview以 fresh 临时 Electron WebContents/CDP 执行桌面/移动 UX 预检;服务端仍执行唯一受控构建、不可变 Release 安全门禁和人工审核。
- 2026-08-12发布架构反转为 Main-owned 本地构建;固定 npm 11.6.2 按项目 lockfile 安装并执行项目 ViteElectron 预检最终上传同字节产物,再上传 source+built 双归档和严格 contract。服务端仅校验并固化不可信字节不再承担项目 Vite 构建。
- 2026-08-12AI 编程新 session 的首条 prompt 不再被已知空历史读取阻塞;明确上游分组饱和会快速终止,通用 `429` 仍保持原有限速语义。
- 2026-08-13AI 绘画把原视频首帧选择器泛化为单图来源选择器;图片 Brief 可选择项目作品或上传本地图作为图生图参考图,视频及未决 medium 保持历史首帧行为,成功提交后关闭弹窗。
## In Progress
@@ -62,7 +64,8 @@ AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗
- 服务端持久 Conversation Session 不由客户端在注销或退出时删除Main 只关闭本地流、清除本机缓存并撤销本地凭据。
- 客户端对最终构建字节的 loopback 检查没有可信 receipt且不复刻生产 opaque-origin服务端必须独立重算 source/built/contract、校验不可变 Release人工审核仍不可绕过。如未来需要不可绕过的 runtime gate必须引入可信 verifier 并绑定精确构建产物。
- AI proxy 的明确饱和 `429 → 400` 是与当前 OpenCode 重试契约匹配的内部投影;升级 OpenCode 或上游饱和错误文案变化时必须复核。不得把通用 `429` 或仅含 `rate_limit_exceeded` 的响应误分类为终止饱和。
- AI 绘画单图选择器仍精确匹配 quick reply `从作品列表选择图片`,用途再从当前 Brief medium 推断;本地化或协议文案变化可能破坏入口。扩展更多素材输入前应升级为结构化 action/purpose。
## Last Updated
2026-08-12
2026-08-13

View File

@@ -24,6 +24,9 @@
- Conversation 持有消息、Brief、Quote 和 `turnRevision`;生成任务和资产属于 Workspace切换 Conversation 后必须继续可见。
- 每条 Conversation 使用服务端持久 Agent Gateway Session。客户端注销或退出时只关闭本地流并清除本机 Session-id 缓存,不删除服务端 Session。
- Conversation 异步与流式更新必须同时核对 Workspace 和 Conversation 身份;任务事件按 Workspace 归并。
- 图片生成支持无参考图的文生图和单参考图图生图;参考图必须来自当前 Workspace 的已完成图片 Asset或先通过现有 Workspace 上传接口把本地图片转成 Asset再随当前 Conversation Turn 提交唯一 Asset ID。
- 视频首帧与图生图参考图复用单图选择器,但用途由当前 Brief medium 决定:只有明确 `image` 使用图生图语义,`video`、null 或缺失值保持视频首帧兼容语义。选择作品或上传成功后应关闭弹窗。
- 当前 quick reply 文案匹配只是兼容契约;新增更多素材输入用途前,应改为结构化 action/purpose不得继续依赖本地化展示字符串推断行为。
## Open Questions
@@ -31,7 +34,8 @@
- 是否需要在生产发布链引入绑定精确 Release/digest 的可信 runtime verifier当前客户端 loopback preflight 不能承担该职责。
- 在一个客户端兼容版本且服务端与存量数据稳定提供 `play_url` 后,移除 `runtime_url` 回退。
- 多 Conversation 客户端发布前需确认服务端迁移 `0033`、Conversation API 与持久 Session 契约已经部署。
- 图生图随客户端发布前,需确认相匹配的服务端 `image_to_image` Brief/Quote/Task 冻结、私有源图复核与 Bailian edit 执行链已部署,并使用真实 Workspace Asset 完成生产 smoke。
## Last Reviewed
2026-08-12
2026-08-13

View File

@@ -9,6 +9,7 @@ Use this index for searchable, traceable evidence records.
| 2026-08-12 | Makelore 内置浏览器发布前预检 | 本地功能验证通过;不构成可信发布证明 | `30-worklog/tasks/20260812-client-browser-preflight-c8e2.md``926056a` | 121 项聚焦回归、typecheck、scoped lint、三端 build 与真实 Electron 双视口 E2E 通过loopback UX 预检无 receipt/provenance/opaque-origin parity服务端门禁仍需生产整链验收 |
| 2026-08-12 | Makelore Main-owned 本地静态 Release 构建 | 本地功能与 Windows 安装产物验证通过;安装器未发布 | `30-worklog/tasks/20260812-client-built-release-makelore-7e5b.md``5b44864` | 6 files / 129 focused tests、typecheck、scoped lint、Vite build、真实 Electron 双视口 E2E 与实际 npm/Vite smoke 通过;安装器 223,547,912 bytesSHA-256 `08A0BB7BC66EE4AD8B120E8955B149951CD86AF53CC966C7973AD4D77A5815C1`,内置 npm 11.6.2 已由 packaged verifier 验证,但未上传或发布 |
| 2026-08-12 | AI 编程首次会话等待与上游饱和重试 | 本地功能验证通过;真实冷启动各阶段耗时尚未遥测 | 源任务 `20260812-first-chat-fix-b84fd29c``08da976``0ee5254` | 4 个相关单测文件 228/228、typecheck、scoped lint、Vite build 与真实 Electron E2E 通过;全量单测的 3 个失败与 `main` 基线一致,修复证明 prompt 不等待已知空历史,但不宣称消除所有 runtime 冷启动耗时 |
| 2026-08-13 | AI 绘画单参考图图生图客户端 | 本地功能验证通过;未执行安装包或真实 Provider smoke | 源任务 `20260812-design-image-to-image-client-c91e``809364e`、集成 `88281b8`、兼容修复 `7a807a2` | 25 项页面回归覆盖图片作品选择、本地上传、视频首帧及 null-medium 兼容22 项云端适配器回归覆盖 REST/WebSocket 缺失 medium 归一化与非法值拒绝typecheck、scoped lint 与 Vite build 通过,生产部署与付费生成另行验收 |
## When To Add Evidence

View File

@@ -9,6 +9,8 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks,
| 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 |
| 2026-08-11 | 部署并验收 AI 绘画多 Conversation 服务端契约 | 发布包含多会话客户端之前 | 客户端/服务端集成 | Pending | 确认迁移 `0033`、Conversation list/create/read/turn/confirm API、持久 Agent Session 与事件字段已上线 |
| 2026-08-12 | 复核 AI 编程上游饱和终止态投影 | 升级 bundled OpenCode 或上游饱和错误格式时 | 客户端/OpenCode 集成 | Pending | 重新验证 retry contract继续区分配额耗尽、明确分组饱和与通用 `429`,必要时调整或移除 `429 → 400` 内部投影 |
| 2026-08-13 | 将 AI 绘画素材选择 quick reply 升级为结构化 action/purpose | 新增第三种素材输入用途、调整本地化文案或重构 Design Message 协议时 | 客户端/服务端集成 | Pending | 用 `select_image_asset` + `image_reference | video_first_frame` 等稳定字段替代精确中文字符串匹配,并覆盖延迟 Brief 快照兼容 |
| 2026-08-13 | 完成图生图客户端与服务端生产整链 smoke | 相匹配客户端与服务端部署后 | 客户端/服务端集成 | Pending | 用当前 Workspace 已完成作品与本地上传各执行一次图生图,核对 Asset 所有权、Quote、任务创建、结果展示与下载不以本地单测替代付费 Provider 验收 |
## Use

View File

@@ -5,7 +5,7 @@
Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口:
- `Makelore CodeAI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时并为服务端可运行版本提供真机扫码预览。
- `Makelore CanvasAI 绘画`以设计项目Workspace组织 Agent 对话、方向确认、图片/视频生成任务和私有结果
- `Makelore CanvasAI 绘画`以设计项目Workspace组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传
- `Makelore LearningAI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Electron Main 对源码快照运行安装包内固定 npm 和项目 `package-lock.json` 锁定的 Vite使用内置浏览器预检同一份输出字节再将源码归档、构建归档与校验清单一并提交。平台独立重算摘要、固化不可变 Release 并进入运营审核;客户端预检不构成可信审核凭据。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
@@ -17,7 +17,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版
- 共享开发浏览器AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network支持本地与公网开发地址。
- 真机预览AI 编程项目空间可读取当前静态 Release 状态;待审版本使用短时预览地址,已发布版本使用同源 HTTPS `play_url`,供手机直接测试触控、屏幕和移动网络环境。
- 后端边界Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。
- AI 绘画:每个设计项目固定一个设计 Agent并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation图片/视频生成任务与资产属于 Workspace切换或新建会话不会创建新项目也不会隐藏项目任务。每条 Conversation 复用服务端持久 Agent Gateway Session任务进度通过实时流推送并在统一列表中展示断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。
- AI 绘画:每个设计项目固定一个设计 Agent并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation图片/视频生成任务与资产属于 Workspace切换或新建会话不会创建新项目也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session任务进度通过实时流推送并在统一列表中展示断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。
- 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。
- 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`

View File

@@ -36,7 +36,7 @@ import {
type ServerBrief = {
version: number;
status: DesignBrief['status'];
medium: DesignBrief['medium'];
medium?: DesignBrief['medium'];
summary: string;
ready: boolean;
missing_decision: string | null;
@@ -219,10 +219,18 @@ const AGENT_RUN_TIMEOUT_MS = 10 * 60_000;
const DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS = 1_000;
function mapBrief(brief: ServerBrief): DesignBrief {
const medium = brief.medium ?? null;
if (medium !== null && medium !== 'image' && medium !== 'video') {
throw new DesignWorkspaceModuleError(
502,
'DESIGN_WORKSPACE_RESPONSE_INVALID',
'AI 设计服务返回了无效的媒介类型',
);
}
return {
version: brief.version,
status: brief.status,
medium: brief.medium,
medium,
summary: brief.summary,
ready: brief.ready,
missingDecision: brief.missing_decision,
@@ -357,7 +365,10 @@ function isServerConversation(value: unknown): value is ServerConversation {
&& Boolean(brief)
&& Number.isInteger(brief?.version)
&& ['draft', 'ready', 'confirmed'].includes(String(brief?.status))
&& (brief?.medium === null || brief?.medium === 'image' || brief?.medium === 'video')
&& (brief?.medium === undefined
|| brief.medium === null
|| brief.medium === 'image'
|| brief.medium === 'video')
&& typeof brief?.summary === 'string'
&& typeof brief?.ready === 'boolean'
&& (brief?.missing_decision === null || typeof brief?.missing_decision === 'string')

View File

@@ -78,7 +78,7 @@ const GENERATION_CONFIRMATION_INTENTS = new Set([
'确认开始制作',
'确认并开始制作',
]);
const VIDEO_FIRST_FRAME_PICKER_REPLY = '从作品列表选择图片';
const IMAGE_SOURCE_PICKER_REPLY = '从作品列表选择图片';
function isGenerationConfirmationIntent(message: string): boolean {
const normalized = message.trim().replace(/[\s,。.!??、]/g, '');
@@ -259,7 +259,7 @@ function AssetPreview({ asset }: { asset: DesignAsset }) {
);
}
function FirstFrameAssetOption({
function ImageSourceAssetOption({
asset,
busy,
onSelect,
@@ -424,10 +424,10 @@ export function ImageCanvas() {
const [submittingConversationId, setSubmittingConversationId] = useState<string | null>(null);
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false);
const [firstFrameBusyKey, setFirstFrameBusyKey] = useState<string | null>(null);
const [firstFrameError, setFirstFrameError] = useState<string | null>(null);
const [uploadedFirstFrameAsset, setUploadedFirstFrameAsset] = useState<DesignAsset | null>(null);
const [imageSourcePickerOpen, setImageSourcePickerOpen] = useState(false);
const [imageSourceBusyKey, setImageSourceBusyKey] = useState<string | null>(null);
const [imageSourceError, setImageSourceError] = useState<string | null>(null);
const [uploadedImageSourceAsset, setUploadedImageSourceAsset] = useState<DesignAsset | null>(null);
const conversationEndRef = useRef<HTMLDivElement | null>(null);
const quote = useMemo(
@@ -435,20 +435,21 @@ export function ImageCanvas() {
[conversation],
);
const generationAvailable = bootstrap?.capabilities.generation ?? false;
const firstFrameAssets = useMemo(
const imageSourceAssets = useMemo(
() => {
const assets = tasks
.filter((task) => task.status === 'succeeded')
.flatMap((task) => task.resultAssets)
.filter((asset) => asset.mediaType === 'image' && asset.mimeType === 'image/png');
if (uploadedFirstFrameAsset
&& uploadedFirstFrameAsset.workspaceId === workspace?.workspaceId) {
assets.unshift(uploadedFirstFrameAsset);
if (uploadedImageSourceAsset
&& uploadedImageSourceAsset.workspaceId === workspace?.workspaceId) {
assets.unshift(uploadedImageSourceAsset);
}
return [...new Map(assets.map((asset) => [asset.assetId, asset])).values()];
},
[tasks, uploadedFirstFrameAsset, workspace?.workspaceId],
[tasks, uploadedImageSourceAsset, workspace?.workspaceId],
);
const selectingImageReference = conversation?.brief.medium === 'image';
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
if (!conversation) return [];
const messages: RenderedDesignMessage[] = [...conversation.messages];
@@ -499,8 +500,8 @@ export function ImageCanvas() {
setPrompt('');
setActionError(null);
setConfirmingQuoteId(null);
setFirstFramePickerOpen(false);
setFirstFrameError(null);
setImageSourcePickerOpen(false);
setImageSourceError(null);
}, [taskConversationId]);
useEffect(() => {
@@ -569,17 +570,22 @@ export function ImageCanvas() {
void handleSend();
};
const handleFirstFrameSelect = async (asset: DesignAsset) => {
if (!workspace || !conversation || submitting || firstFrameBusyKey) return;
const handleImageSourceSelect = async (asset: DesignAsset) => {
if (!workspace || !conversation || submitting || imageSourceBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
const requestedConversationId = conversation.conversationId;
setFirstFrameBusyKey(asset.assetId);
setFirstFrameError(null);
setImageSourceBusyKey(asset.assetId);
setImageSourceError(null);
setActionError(null);
setSubmittingConversationId(requestedConversationId);
setFirstFramePickerOpen(false);
setImageSourcePickerOpen(false);
try {
await sendMessage('使用作品图片作为视频首帧', [asset.assetId]);
await sendMessage(
selectingImageReference
? '使用作品图片作为图生图参考图'
: '使用作品图片作为视频首帧',
[asset.assetId],
);
} catch (error) {
const current = useImageWorkspaceStore.getState();
if (current.activeWorkspaceId === requestedWorkspaceId
@@ -587,39 +593,44 @@ export function ImageCanvas() {
setActionError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setImageSourceBusyKey(null);
setSubmittingConversationId((current) => (
current === requestedConversationId ? null : current
));
}
};
const handleFirstFrameUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const handleImageSourceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = '';
if (!workspace || !conversation || !file || submitting || firstFrameBusyKey) return;
if (!workspace || !conversation || !file || submitting || imageSourceBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
const requestedConversationId = conversation.conversationId;
setFirstFrameBusyKey('upload');
setFirstFrameError(null);
setImageSourceBusyKey('upload');
setImageSourceError(null);
setSubmittingConversationId(requestedConversationId);
try {
const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file);
setUploadedFirstFrameAsset(asset);
await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]);
setUploadedImageSourceAsset(asset);
await sendMessage(
selectingImageReference
? '使用上传图片作为图生图参考图'
: '使用上传的图片作为视频首帧',
[asset.assetId],
);
const current = useImageWorkspaceStore.getState();
if (current.activeWorkspaceId === requestedWorkspaceId
&& current.activeConversationId === requestedConversationId) {
setFirstFramePickerOpen(false);
setImageSourcePickerOpen(false);
}
} catch (error) {
const current = useImageWorkspaceStore.getState();
if (current.activeWorkspaceId === requestedWorkspaceId
&& current.activeConversationId === requestedConversationId) {
setFirstFrameError(error instanceof Error ? error.message : String(error));
setImageSourceError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setImageSourceBusyKey(null);
setSubmittingConversationId((current) => (
current === requestedConversationId ? null : current
));
@@ -837,9 +848,9 @@ export function ImageCanvas() {
size="sm"
className="h-8 rounded-full px-3 text-xs font-semibold"
onClick={() => {
if (reply === VIDEO_FIRST_FRAME_PICKER_REPLY) {
setFirstFrameError(null);
setFirstFramePickerOpen(true);
if (reply === IMAGE_SOURCE_PICKER_REPLY) {
setImageSourceError(null);
setImageSourcePickerOpen(true);
return;
}
if (!isGenerationConfirmationIntent(reply)) {
@@ -935,19 +946,23 @@ export function ImageCanvas() {
</div>
<Dialog
open={firstFramePickerOpen}
open={imageSourcePickerOpen}
onOpenChange={(open) => {
if (!open && firstFrameBusyKey) return;
setFirstFramePickerOpen(open);
if (!open) setFirstFrameError(null);
if (!open && imageSourceBusyKey) return;
setImageSourcePickerOpen(open);
if (!open) setImageSourceError(null);
}}
>
<DialogContent className="max-h-[88vh] max-w-4xl grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
<DialogHeader className="flex-row items-start justify-between space-y-0 px-5 py-4 sm:px-6">
<div className="min-w-0 text-left">
<DialogTitle className="text-balance text-base"></DialogTitle>
<DialogTitle className="text-balance text-base">
{selectingImageReference ? '选择图生图参考图' : '选择视频首帧'}
</DialogTitle>
<DialogDescription className="mt-1 text-pretty text-xs leading-5">
{selectingImageReference
? '从本项目作品中选择一张已完成图片,或上传本地图片作为图生图参考。'
: '从本项目作品中选择一张已完成图片,或上传本地图片作为视频起始画面。'}
</DialogDescription>
</div>
<DialogClose asChild>
@@ -955,9 +970,9 @@ export function ImageCanvas() {
type="button"
size="icon"
variant="ghost"
aria-label="关闭首帧选择"
aria-label={selectingImageReference ? '关闭参考图选择' : '关闭首帧选择'}
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
disabled={Boolean(firstFrameBusyKey)}
disabled={Boolean(imageSourceBusyKey)}
>
<X className="h-4 w-4" />
</Button>
@@ -973,43 +988,43 @@ export function ImageCanvas() {
</p>
</div>
<label
htmlFor="design-first-frame-upload"
aria-busy={firstFrameBusyKey === 'upload'}
htmlFor="design-image-source-upload"
aria-busy={imageSourceBusyKey === 'upload'}
className={cn(
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm transition-transform active:scale-[0.96]',
firstFrameBusyKey && 'pointer-events-none opacity-50',
imageSourceBusyKey && 'pointer-events-none opacity-50',
)}
>
{firstFrameBusyKey === 'upload'
{imageSourceBusyKey === 'upload'
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <Upload className="mr-1.5 h-4 w-4" />}
{firstFrameBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
{imageSourceBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
<input
id="design-first-frame-upload"
id="design-image-source-upload"
type="file"
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
aria-label="上传本地图片"
className="sr-only"
disabled={Boolean(firstFrameBusyKey)}
onChange={(event) => void handleFirstFrameUpload(event)}
disabled={Boolean(imageSourceBusyKey)}
onChange={(event) => void handleImageSourceUpload(event)}
/>
</label>
</div>
{firstFrameError ? (
{imageSourceError ? (
<p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">
{firstFrameError}
{imageSourceError}
</p>
) : null}
{firstFrameAssets.length > 0 ? (
{imageSourceAssets.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{firstFrameAssets.map((asset) => (
<FirstFrameAssetOption
{imageSourceAssets.map((asset) => (
<ImageSourceAssetOption
key={asset.assetId}
asset={asset}
busy={Boolean(firstFrameBusyKey)}
onSelect={() => void handleFirstFrameSelect(asset)}
busy={Boolean(imageSourceBusyKey)}
onSelect={() => void handleImageSourceSelect(asset)}
/>
))}
</div>

View File

@@ -711,11 +711,152 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
});
it('keeps the historical video picker behavior when the brief medium is not decided yet', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: null,
summary: '',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-undecided-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' }))
.not.toBeInTheDocument();
});
it('opens the image reference picker and submits a successful work Asset id', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'image',
summary: '参考海洋公益海报生成新图',
ready: false,
missingDecision: '请选择参考图来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
expect(within(picker).getByText(/作为图生图参考/)).toBeInTheDocument();
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择图生图参考图' }))
.not.toBeInTheDocument();
});
it('uploads a local image reference and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择图生图参考图' }),
).not.toBeInTheDocument());
});
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
const pendingSelection = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
turnRevision: 2,
messages: [
...conversationFixture().messages,
@@ -759,6 +900,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
messages: [
...conversationFixture().messages,
{
@@ -793,6 +938,9 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择视频首帧' }),
).not.toBeInTheDocument());
});
it('renders policy-blocked generation tasks with a user-facing design message', async () => {

View File

@@ -232,6 +232,30 @@ describe('Works Square AI design adapter', () => {
);
});
it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => {
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
const legacyConversation = {
...serverConversation,
brief: legacyBrief,
};
const invalidConversation = {
...serverConversation,
brief: { ...serverConversation.brief, medium: 'audio' },
};
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(legacyConversation))
.mockResolvedValueOnce(jsonResponse(invalidConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
});
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
.resolves.toMatchObject({ brief: { medium: null } });
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
.rejects.toMatchObject({ code: 'DESIGN_WORKSPACE_RESPONSE_INVALID' });
});
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(serverConversation))
@@ -940,6 +964,11 @@ describe('Works Square AI design adapter', () => {
});
it('reuses one design Agent Session and normalizes matching task events from fresh WebSocket tickets', async () => {
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
const legacyConversation = {
...serverConversation,
brief: legacyBrief,
};
const snapshotTask = {
task_id: 'task-snapshot',
workspace_id: 'workspace-one',
@@ -964,10 +993,21 @@ describe('Works Square AI design adapter', () => {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 2,
conversation: serverConversation,
conversation: legacyConversation,
generation_tasks: [snapshotTask],
},
};
const invalidSnapshotEvent = {
...snapshotEvent,
sequence: 98,
payload: {
...snapshotEvent.payload,
conversation: {
...serverConversation,
brief: { ...serverConversation.brief, medium: 'audio' },
},
},
};
const assistantDeltaEvent = {
session_id: 'session-one',
sequence: 2,
@@ -1038,6 +1078,7 @@ describe('Works Square AI design adapter', () => {
const { sockets, webSocketFactory } = scriptedSockets([
{
frames: [
{ type: 'event', event: invalidSnapshotEvent },
{ type: 'event', event: snapshotEvent },
{ type: 'event', event: malformedDeltaEvent },
{ type: 'event', event: assistantDeltaEvent },
@@ -1077,6 +1118,7 @@ describe('Works Square AI design adapter', () => {
conversation: expect.objectContaining({
conversationId: 'conversation-one',
title: serverConversation.title,
brief: expect.objectContaining({ medium: null }),
messages: [expect.objectContaining({
text: serverConversation.messages[0].text,
})],