diff --git a/.project-docs/10-decisions/adr-001-ai-design-conversation-ownership.md b/.project-docs/10-decisions/adr-001-ai-design-conversation-ownership.md new file mode 100644 index 0000000..27761b9 --- /dev/null +++ b/.project-docs/10-decisions/adr-001-ai-design-conversation-ownership.md @@ -0,0 +1,43 @@ +# ADR-001: AI 绘画 Workspace / Conversation 状态归属 + +## Status + +Accepted + +## Date + +2026-08-11 + +## Context + +旧客户端把一个 AI 绘画 Workspace 等同于一条持续对话,并由客户端按 Workspace 创建和回收临时 Agent Session。多会话产品模型要求同一设计项目内存在多条独立对话,同时保留项目级生成任务与资产。继续沿用旧 Session 所有权会让注销时的远端 DELETE 破坏 Conversation 的持久上下文。 + +## Decision + +- Workspace 持有项目身份、Conversation 列表、生成任务和资产。 +- Conversation 持有消息、Brief、Quote、`turnRevision` 以及服务端持久 Agent Gateway Session 的绑定。 +- 新建 Conversation 不创建新 Workspace;切换 Conversation 只读取该对话并重连其事件流,项目级任务和资产保持可见。 +- Conversation 异步与流式更新必须同时校验 Workspace + Conversation 身份;Task 更新按 Workspace 归并。 +- 客户端不创建、旋转或在注销/退出时 DELETE 服务端持久 Conversation Session。终端清理只关闭本地流、清除本机 Session-id 缓存和本地凭据;后续访问重新从 Conversation API 读取 Session。 +- 未打包的本地开发适配器把旧单会话 schema v2 迁移为带默认 Conversation 的 schema v3;该适配器不得成为打包应用的云端失败回退。 + +## Rationale + +该分层与用户可见的“一个设计项目,多条独立会话,共享生成结果”一致,并让 Session 生命周期由持久化 Conversation 的服务端权威管理。Workspace + Conversation 双重身份和 generation/revision 防护可以阻止切换过程中的迟到异步结果污染当前对话。 + +## Consequences + +- 客户端发布前必须先部署服务端迁移 `0033` 与多 Conversation API。 +- 退出登录不会删除远端 Conversation Session;服务端继续通过认证与授权控制访问。 +- Renderer、Host API、云端适配器和本地开发适配器都必须保持相同的 Workspace / Conversation / Task 所有权语义。 +- Conversation 数量增长后可能需要分页和搜索;当前侧栏只内联展示近期会话。 + +## Supersedes + +- 旧的未文档化“每个 Workspace 由客户端创建并在注销时删除临时事件 Session”实现。 + +## Related + +- `498089401708dc08123141891dba9b54e47cefea` +- `03dae62cf35d643bba253d1013579cc3f4f528e4` +- `.project-docs/30-worklog/tasks/20260806-design-multisession-ui-4e7d.md` diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index a30eae1..c84f5fa 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -4,6 +4,7 @@ | ID | Decision | Status | Date | Applies To | Detail | |---|---|---|---|---|---| +| ADR-001 | AI 绘画采用 Workspace / Conversation / Task 分层状态与服务端持久 Conversation Session | Accepted | 2026-08-11 | AI 绘画客户端、Main 适配器、Works Square API | `adr-001-ai-design-conversation-ownership.md` | ## Superseded Decisions diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index b561745..3f093d9 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -10,6 +10,9 @@ | 提交绑定 | 云端成功上传响应 | Main → submission binding v2 | 只持久化成功的 app/version/review/hash;落盘失败返回固定告警但不反转提交 | | 运营发布 | Works Square 审核与交付 | 公共 `play_url` | 客户端只消费服务端发布结果;真实 Builder → OSS/CDN 生产链仍待整链验收 | | 真机预览 | 项目空间 | Main → Owner preview / `play_url` | 核对 app、version、release 和同源 HTTPS;`runtime_url` 仅一版本兼容回退 | +| 设计会话创建/切换 | AI 绘画页面或侧栏 | Renderer API → Main → Workspace Conversation API | 新会话属于现有 Workspace;读取独立消息、Brief、Quote 和 `turnRevision` | +| 设计消息与确认 | 当前 Conversation | Main → 持久 Agent Gateway Session → Conversation 快照 | 请求和流式结果同时绑定 Workspace + Conversation | +| 设计任务同步 | 任一 Conversation 的事件流 / REST | Renderer Workspace 任务列表 | Task 和 Asset 按 Workspace 归并,切换 Conversation 后仍可见 | ## State Ownership @@ -17,13 +20,17 @@ - 项目内 `.niancode/project.json` 保存 `ProjectType`;Main 在配置写入和目录复用时保持其不可变,并在打包时重新读取校验。 - Renderer 仅持有短效公开会话状态、提交展示状态和安全投影后的公共播放/短时预览 URL。 - 旧 schema v1 `submitted` 记录迁移并保留;旧 `armed`、`waiting_for_package`、`waiting_for_login`、`uploading`、`failed` 归一为 `legacy_retired`,不再启动 watcher 或上传任务。 +- AI 绘画 Conversation 持有消息、Brief、Quote、`turnRevision` 和服务端 Session 绑定;Workspace 持有 Conversation 列表、生成任务和资产。 +- 本地开发适配器将旧单会话 schema v2 原子迁移为带默认 Conversation 的 schema v3;打包应用不使用该本地适配器作为云端失败回退。 +- 注销和退出会关闭本地事件流并清除本机 Conversation Session-id 缓存;服务端持久 Session 保留,下一次访问从 Conversation API 重新读取。 ## External Interfaces - Works Square 项目创建、版本上传、构建状态、Owner 状态与 Release preview API。 - 本机 Host API 的发布与真机预览路由;发布路由要求 Renderer capability。 - 服务端安全投影后的公共 `play_url`;只接受同源 HTTPS、精确 App 路径和可信版本状态。 +- Works Square Workspace/Conversation API、每个 Conversation 的 Agent Gateway Session 与单次 WebSocket ticket。 ## Last Updated -2026-08-10 +2026-08-11 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index ab9aab9..6778282 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -11,6 +11,13 @@ | `electron/services/works-submission-binding.ts` | submission binding v2 持久化与旧 schema 迁移 | 旧中间态终止为 `legacy_retired`;文件名暂作安装兼容 | | `electron/api/works-play-url.ts` | 公共播放与 Owner preview URL 的共享安全校验 | 公共 `play_url` 必须同源 HTTPS 且精确匹配 App 路径 | | `electron/api/routes/device-preview.ts` | 精确提交版本的待审/已发布真机预览解析 | 待审使用短时 Release preview,已发布优先 `play_url` | +| `shared/image-workspace.ts` | AI 绘画 Workspace、Conversation、Task、Asset 与事件共享契约 | Conversation 状态与 Workspace 任务归属必须分离 | +| `electron/api/routes/image-workspace.ts` | AI 绘画 Host API 与本地事件流投影 | Renderer 只通过该路由访问 Main-owned workspace adapter | +| `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/components/layout/ImageWorkspaceSidebar.tsx` | Workspace 与近期 Conversation 切换/创建 | 切换会话不清空项目级任务 | ## Dependency Direction @@ -22,7 +29,9 @@ - `electron/api/routes/works.ts` 同时承担发布 capability、上游安全投影和错误脱敏,变更时必须验证未在拒绝前读取凭据或项目文件。 - `works-cloud-deploy.json` 是兼容文件名;不得因名称重新引入自动部署协调语义。 - `runtime_url` 是一个客户端版本的迁移回退;删除前必须确认服务端和存量数据稳定提供 `play_url`。 +- 多 Conversation 事件处理必须区分对话快照与 Workspace 任务更新;不得用任务时间戳推进 Conversation 流水位,也不得让旧会话的迟到流覆盖当前会话。 +- `closeEventSessions` 只负责本地流和缓存生命周期;远端 Conversation Session 是服务端持久资源。 ## Last Updated -2026-08-10 +2026-08-11 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 66167f6..208b8ea 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -16,6 +16,9 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Submission Binding | 保存云端已接受的精确 app/version/review/hash 绑定 | schema v2 只记录成功提交;旧中间态迁移为 `legacy_retired`,不恢复后台任务 | | Play URL Projection | 校验服务端公共播放地址 | 只接受同源 HTTPS 和精确 `/apps/{app_id}/`;优先 `play_url`,`runtime_url` 仅一版本回退 | | Device Preview | 核对本地绑定、远端版本和 Release | 待审使用短时 Owner preview;已发布使用安全投影后的 `play_url` | +| AI Design Workspace | 保存项目身份、Conversation 列表、生成任务和资产 | 任务和资产在切换 Conversation 后继续可见 | +| AI Design Conversation | 保存消息、Brief、Quote、`turnRevision` 与服务端 Agent Session 绑定 | 同一 Workspace 内互相隔离;Session 由服务端持久化 | +| AI Design Event Routing | Main 云端适配器 → Host API/SSE → Renderer store | Conversation 更新按 Workspace + Conversation 路由;任务更新按 Workspace 归并 | ## Important Boundaries @@ -27,11 +30,14 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - 公共播放投影只有在上游 `playable === true`、版本名非空且 URL 通过同源 HTTPS、无 userinfo/loopback、长度、精确路径和无 query/fragment 校验时才可播放;不可信数据 fail closed。 - Renderer 只能获得安全状态字段和可展示的播放/短时预览 URL,不得持有发布凭据、归档路径或自动部署状态。 - 落盘文件名 `works-cloud-deploy.json` 仅为已安装客户端的数据兼容;领域模型和代码接口是 submission binding,不表示仍存在 cloud deployment coordinator。 +- AI 绘画中,一个 Workspace 可包含多条 Conversation;消息、Brief、Quote 和 `turnRevision` 属于 Conversation,生成任务和资产属于 Workspace。新建 Conversation 不得隐式创建新 Workspace。 +- 每条 Conversation 复用服务端持久 Agent Gateway Session。客户端不得在注销或退出时 DELETE 该 Session;只关闭本地事件流并清除本机 Session-id 缓存,重新访问时从 Conversation API 刷新。 +- Renderer 的异步与流式状态必须同时校验 Workspace + Conversation 身份;项目任务事件只按 Workspace 归并,避免切换会话时丢失任务或接收迟到消息。 ## Related Decisions -- 当前长期边界记录于 README、集成任务 `20260807-integrate-login-client-a4f8`、源任务 `20260810-static-release-only-a91c` 及本次 Integration Gate;后续如改变唯一入口、凭据所有权或重新引入客户端部署协调器,应新增 ADR。 +- 当前长期边界记录于 README、ADR-001、集成任务 `20260807-integrate-login-client-a4f8`、源任务 `20260810-static-release-only-a91c` 及本次 Integration Gate;后续如改变唯一入口、凭据所有权、Conversation 状态归属或重新引入客户端部署协调器,应新增 ADR。 ## Last Updated -2026-08-10 +2026-08-11 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 08392bf..e0a1a7f 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -9,17 +9,21 @@ This file is the integrated default-branch snapshot. Feature tasks record progre - `1a19ad9` / `f02e99e`:项目真机预览、待审 Release Owner preview 及集成提交。 - `493b31c`:客户端三类 `ProjectType`、小游戏/小程序受控发布模板与自定义项目发布隔离。 - `4df0477` / `8dd99c1`:客户端静态发布唯一链路、旧 Compose 协调链退役及安全边界补强。 +- `4980894` / `03dae62`:AI 绘画 Enter 发送及同一 Workspace 下的多 Conversation 客户端模型。 ## Current Focus 客户端面向非专业用户提供“创建小游戏或小程序 → 项目配置中一键提交 → Main 安全打包上传 → 轮询云端构建 → 运营审核”的唯一创建者链路,并以项目级真机预览完成待审版本扫码验收;自定义和缺少类型字段的旧项目不提供该入口。已发布作品优先使用安全投影后的 `play_url`,`runtime_url` 仅保留一个客户端版本的兼容回退。 +AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。每条 Conversation 使用服务端持久 Agent Gateway Session,切换会话只重连对应事件流。 + ## Recently Completed - 2026-08-08:合并登录续期、一键发布和真机预览;发布成功后保存精确 app/version/review 映射,Renderer 不接触 Token、ZIP 或本地路径。 - 2026-08-08:补齐跨平台 Electron E2E fixture、Windows ZIP 预检和异常成功响应安全投影。 - 2026-08-09:新建项目支持小游戏、小程序和自定义三类不可变产品类型;前两类生成固定 Vite 发布模板,自定义及缺少类型字段的旧项目不显示一键提交入口。 - 2026-08-10:删除客户端旧 Compose deploy-check、watcher/arm/upload 协调和手工 ZIP 路由;submission binding v2 保留旧 `submitted` 绑定并把旧中间态归一为 `legacy_retired`,同时补齐 Renderer capability、`play_url` 安全投影和本机绑定失败告警。 +- 2026-08-11:AI 绘画支持在同一设计项目内创建和切换多条独立 Conversation;保留项目级任务/资产,并使用 Workspace + Conversation 身份保护异步与流式更新。退出时关闭本地流并清除本地 Session-id 缓存,不删除服务端持久 Conversation Session。 ## In Progress @@ -44,7 +48,9 @@ This file is the integrated default-branch snapshot. Feature tasks record progre - `works-cloud-deploy.json` 仅是已安装数据的兼容文件名,不代表客户端仍拥有自动部署协调器;旧中间态不得恢复为后台任务。 - 刷新凭据、发布 Token、ZIP、幂等键和重试只能由 Electron Main 持有。 - 本地 `projectType` 只决定产品分流和模板选择,不得作为授权依据或替代 Main/服务端的包体校验。 +- AI 绘画消息、Brief、Quote 和 `turnRevision` 必须按 Workspace + Conversation 隔离;任务事件仍按 Workspace 归并,迟到的异步结果不得污染已切换的会话。 +- 服务端持久 Conversation Session 不由客户端在注销或退出时删除;Main 只关闭本地流、清除本机缓存并撤销本地凭据。 ## Last Updated -2026-08-10 +2026-08-11 diff --git a/.project-docs/30-worklog/tasks/20260811-merge-all-code-a7c91e.md b/.project-docs/30-worklog/tasks/20260811-merge-all-code-a7c91e.md new file mode 100644 index 0000000..a8bac7e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260811-merge-all-code-a7c91e.md @@ -0,0 +1,67 @@ +# Task: Merge all completed code into main + +## Identity + +- Task ID: 20260811-merge-all-code-a7c91e +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 70e0f4054cf88029e3f2f4848e3d17ee3be70b7f +- Owner: codex +- Status: Completed + +## Scope + +- Integrate every completed local branch whose commits are not already contained by `main`. +- Merge `codex/20260806-design-multisession-ui-4e7d-design-multisession-ui`, which already contains the completed Enter-to-send commit from `codex/design-enter-send-b83f`. +- Preserve all behavior already integrated on `main`, resolve overlapping image-workspace changes semantically, and avoid merging stale or already-contained branches a second time. +- Promote the accepted multi-conversation client-state model into canonical project memory and verify the integrated result. + +## Intent And Constraints + +- Treat Git ancestry as the source of truth for merge scope: only two local branch tips are not contained by `main`, and the multi-session branch is a strict descendant of the Enter-to-send branch. +- Preserve Main/Host API ownership boundaries, project-scoped generation tasks, Conversation-scoped messages and streams, and the existing release/login behavior already on `main`. +- Do not resolve meaningful conflicts with blanket `ours`/`theirs`; combine both sides according to their domain ownership. +- Use the exact pnpm version pinned by `packageManager` and run focused tests before repository-level verification. +- Keep unrelated historical encoding damage and stale worktrees out of this integration. + +## Plan + +1. Inspect branch ancestry, overlapping paths, and merge-conflict semantics. +2. Merge the multi-session branch once; resolve conflicts while preserving both feature and integrated-main behavior. +3. Update task-scoped and canonical integration records. +4. Run focused AI Design tests, typecheck, lint, production build, and the full unit suite. +5. Obtain an independent read-only Sol review, finish drift checks, and commit the integration. + +## Outcome + +- Verified that `03dae62` is a descendant of `4980894`; merging the multi-session branch once integrates both completed branch tips without duplicating Enter-to-send. +- Resolved the only two textual conflicts in the Works Square AI Design adapter and its focused test. Human confirmation selected the persistent Conversation Session model: logout and shutdown close local streams and clear the local Session-id cache without deleting the server-owned Session. +- Integrated Conversation create/read/list routing, Conversation-scoped messages and streams, Workspace-scoped generation tasks, local schema v2-to-v3 migration, and the Canvas/sidebar create-and-switch interactions. +- Added focused API, route, adapter, migration, store, page, and sidebar regressions plus an Electron E2E that creates and switches Conversations while preserving project tasks and isolating messages. +- Updated README and canonical project memory, including ADR-001, architecture/data-flow/domain ownership, the production migration commitment, and stale production-verification state. +- Kept the two source-task records read-only in source commit history instead of copying them into the integration tree, as required by the current project-doc ownership gate. + +## Verification + +- Branch ancestry: `03dae62` contains `4980894`; no other local branch tip contains commits absent from the integrated merge scope. +- Focused AI Design regressions: 8 files / 116 tests passed. +- `pnpm run typecheck`: passed. +- Scoped ESLint over all changed TypeScript and TSX files: passed. +- `pnpm run build:vite`: passed; Renderer, Main, and Preload production compilation completed. +- New multi-conversation Electron E2E: 1 passed. +- `pnpm test`: 140/142 files and 1506/1508 tests passed. One failure is the pre-existing missing `.opencode/agent` fixture; the OpenCode port-release test timed out only in the parallel full run and passed alone. +- `pnpm run lint:check`: repository baseline remains red on the pre-existing `MainLayout.tsx` state-in-effect rule and unused `rerender` in `chat-command-dialogs.test.tsx`; changed-file ESLint is green. +- `pnpm run test:e2e`: 11 passed / 11 failed. The new multi-conversation spec passes; stale legacy selectors and a missing provider test IPC handler account for the remaining existing suite failures. +- `check_project_docs.py`, task-aware `check_doc_drift.py`, and `git diff --check`: passed. +- Independent final read-only Sol review: PASS with no blocking findings. + +## Follow-ups + +- Deploy and verify server migration `0033`, persistent Conversation Session creation, and multi-conversation APIs before releasing this client behavior to production. +- Repair the unrelated repository lint, missing youth-skill fixture, timing-sensitive OpenCode unit test, and stale legacy Electron E2E baseline in separate maintenance tasks. +- Tighten the global `creatingConversation` guard with a request generation/token if concurrent Workspace switching during Conversation creation becomes observable; the current edge case can briefly re-enable duplicate creation but does not overwrite data. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index a6fd325..3b32c2b 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -15,12 +15,17 @@ - 已发布作品优先读取 `play_url`,只有字段缺失时才使用一个客户端版本的 `runtime_url` 回退。公共播放 URL 必须是 Works Square 同源 HTTPS、无 userinfo/loopback、精确 `/apps/{encodeURIComponent(app_id)}/`、无 query/fragment,且上游明确 `playable === true` 并提供非空版本名;否则按不可播放处理。 - `works-cloud-deploy.json` 仅是已安装客户端的数据兼容文件名,不表示客户端仍提供 cloud deployment coordinator。 - Works Square 会话按真实键盘、鼠标或触摸活动滑动续期,连续 7 天未使用才要求重新授权。 +- 一个 AI 绘画 Workspace 可以包含多条独立 Conversation;新建 Conversation 不得创建新 Workspace。 +- Conversation 持有消息、Brief、Quote 和 `turnRevision`;生成任务和资产属于 Workspace,切换 Conversation 后必须继续可见。 +- 每条 Conversation 使用服务端持久 Agent Gateway Session。客户端注销或退出时只关闭本地流并清除本机 Session-id 缓存,不删除服务端 Session。 +- Conversation 异步与流式更新必须同时核对 Workspace 和 Conversation 身份;任务事件按 Workspace 归并。 ## Open Questions - 生产整链需要真实账号、Builder、OSS immutable Release、CDN/Edge、运营审核、App 播放与监控环境完成最终验收;当前客户端验证不能替代该验收。 - 在一个客户端兼容版本且服务端与存量数据稳定提供 `play_url` 后,移除 `runtime_url` 回退。 +- 多 Conversation 客户端发布前需确认服务端迁移 `0033`、Conversation API 与持久 Session 契约已经部署。 ## Last Reviewed -2026-08-10 +2026-08-11 diff --git a/.project-docs/40-domain/glossary.md b/.project-docs/40-domain/glossary.md index fe60c37..5d53ae0 100644 --- a/.project-docs/40-domain/glossary.md +++ b/.project-docs/40-domain/glossary.md @@ -9,3 +9,6 @@ | `legacy_retired` | 旧自动部署中间态迁移后的终止状态 | 不恢复 watcher、登录等待或自动上传;提示用户从项目配置重新提交 | | `play_url` | 服务端为已发布作品提供的公共播放地址 | 客户端优先使用并执行同源 HTTPS、精确 App 路径和版本状态校验 | | `runtime_url` | `play_url` 上线期间的旧公共播放字段 | 仅保留一个客户端版本的兼容回退,稳定迁移后删除 | +| AI Design Workspace | 一个 AI 绘画设计项目 | 持有 Conversation 列表、生成任务和资产;不是单条对话 | +| AI Design Conversation | Workspace 内一条独立设计对话 | 持有消息、Brief、Quote、`turnRevision` 和服务端持久 Agent Session 绑定 | +| Persistent Conversation Session | 服务端为 Conversation 持久保存的 Agent Gateway Session | 客户端只重连事件流,不在注销或退出时 DELETE | diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index ecda99d..3543558 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | |---|---|---|---|---| +| 2026-08-11 | AI 设计多会话客户端集成 | 本地功能验证通过;仓库基线仍有既有失败 | `30-worklog/tasks/20260811-merge-all-code-a7c91e.md`、`4980894`、`03dae62` | 8 files / 116 focused tests、typecheck、changed-file ESLint、production build 与新增 Electron E2E 通过;生产 migration 0033/API 尚待验收,全量 lint/unit/E2E 的既有失败已单独记录 | | 2026-08-10 | 客户端静态发布唯一链路 | 本地验证通过 | `30-worklog/tasks/20260810-static-release-only-a91c.md`、`4df0477`、`8dd99c1` | 227 项聚焦回归、typecheck、Vite/Electron build、实际 Electron E2E 与 13 files / 21 tests 收集通过;不等同于真实生产部署验收 | ## When To Add Evidence diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index cf61dd0..05a75b8 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -6,6 +6,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, |---|---|---|---|---|---| | 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | 服务端 Builder、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、提交、浏览器门禁、运营批准、CDN 发布、App 播放与监控核对 | | 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 | +| 2026-08-11 | 部署并验收 AI 绘画多 Conversation 服务端契约 | 发布包含多会话客户端之前 | 客户端/服务端集成 | Pending | 确认迁移 `0033`、Conversation list/create/read/turn/confirm API、持久 Agent Session 与事件字段已上线 | ## Use diff --git a/.project-docs/90-maintenance/stale-items.md b/.project-docs/90-maintenance/stale-items.md index 2b5eb39..92bd313 100644 --- a/.project-docs/90-maintenance/stale-items.md +++ b/.project-docs/90-maintenance/stale-items.md @@ -10,6 +10,7 @@ This is the integrated registry of stale or conflicting canonical memory. Update ## Missing Context - 尚未验证真实生产环境是否已部署 Builder → OSS immutable Release → CDN/Edge,也未完成运营审核到 App `play_url` 播放的真实整链证据;客户端本地与模拟上游验证不能替代该结论。 +- 尚未验证生产 Works Square 已部署迁移 `0033`、多 Conversation API 与对应持久 Agent Session 契约;合并客户端代码不等同于具备生产发布条件。 ## Feature Task Routing diff --git a/README.md b/README.md index 0e42f1a..54e90a6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 真机预览:AI 编程项目空间可读取当前静态 Release 状态;待审版本使用短时预览地址,已发布版本使用同源 HTTPS `play_url`,供手机直接测试触控、屏幕和移动网络环境。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 -- AI 绘画:每个设计项目固定一个设计 Agent。Agent 先通过持续对话收敛 Brief,用户确认服务端 Quote 后才创建图片或视频任务;任务进度通过 Agent Gateway 实时推送并在统一列表中展示,断流时使用低频 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/`。 @@ -83,7 +83,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有文件读取、安全打包、Works Token、版本生成、幂等重试和安全状态投影。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话保持本地。 -- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、可跨异常重启幂等复用的 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态;注销/退出时回收 Session,远端 Token 与 ticket 不进入 Renderer。 +- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 - AI 编程的 Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 6dac961..1d76292 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -11,6 +11,7 @@ import { IMAGE_WORKSPACE_UNAVAILABLE_CODE, IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, type DesignAssetUploadInput, + type DesignCreateConversationInput, type DesignConfirmGenerationInput, type DesignCreateWorkspaceInput, type DesignRenameWorkspaceInput, @@ -283,6 +284,7 @@ async function relayWorkspaceEvents( res: ServerResponse, ctx: HostApiContext, workspaceId: string, + conversationId: string, ): Promise { if (!ctx.imageWorkspace?.openWorkspaceEvents) { throw new DesignWorkspaceModuleError( @@ -295,6 +297,7 @@ async function relayWorkspaceEvents( const afterEventId = Array.isArray(header) ? header[0] : header; const subscription = await ctx.imageWorkspace.openWorkspaceEvents({ workspaceId, + conversationId, ...(afterEventId ? { afterEventId } : {}), }); let closed = false; @@ -399,11 +402,35 @@ export async function handleImageWorkspaceRoutes( if (segments.length === 3 && segments[0] === 'workspaces' - && segments[2] === 'messages' + && segments[2] === 'conversations' + && req.method === 'POST') { + const body = await parseJsonBody>(req); + const input: DesignCreateConversationInput = { + workspaceId: segments[1], + clientConversationId: asString(body.clientConversationId), + title: asString(body.title), + }; + sendData(res, await ctx.imageWorkspace.createConversation(input)); + return true; + } + + if (segments.length === 4 + && segments[0] === 'workspaces' + && segments[2] === 'conversations' + && req.method === 'GET') { + sendData(res, await ctx.imageWorkspace.getConversation(segments[1], segments[3])); + return true; + } + + if (segments.length === 5 + && segments[0] === 'workspaces' + && segments[2] === 'conversations' + && segments[4] === 'messages' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignSubmitMessageInput = { workspaceId: segments[1], + conversationId: segments[3], clientTurnId: asString(body.clientTurnId), expectedTurnRevision: asInteger(body.expectedTurnRevision), message: asString(body.message), @@ -440,23 +467,26 @@ export async function handleImageWorkspaceRoutes( return true; } - if (segments.length === 3 + if (segments.length === 5 && segments[0] === 'workspaces' - && segments[2] === 'events' + && segments[2] === 'conversations' + && segments[4] === 'events' && req.method === 'GET') { - await relayWorkspaceEvents(req, res, ctx, segments[1]); + await relayWorkspaceEvents(req, res, ctx, segments[1], segments[3]); return true; } - if (segments.length === 5 + if (segments.length === 7 && segments[0] === 'workspaces' - && segments[2] === 'quotes' - && segments[4] === 'confirm' + && segments[2] === 'conversations' + && segments[4] === 'quotes' + && segments[6] === 'confirm' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignConfirmGenerationInput = { workspaceId: segments[1], - quoteId: segments[3], + conversationId: segments[3], + quoteId: segments[5], clientTurnId: asString(body.clientTurnId), expectedTurnRevision: asInteger(body.expectedTurnRevision), }; diff --git a/electron/image-workspace/local-workspace.ts b/electron/image-workspace/local-workspace.ts index fa8dddb..410cca3 100644 --- a/electron/image-workspace/local-workspace.ts +++ b/electron/image-workspace/local-workspace.ts @@ -3,7 +3,10 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import type { DesignCapabilities, + DesignConversation, + DesignConversationSummary, DesignConfirmGenerationInput, + DesignCreateConversationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, @@ -12,13 +15,15 @@ import type { DesignSubmitMessageInput, DesignWorkspace, DesignWorkspaceBootstrap, + DesignWorkspaceSummary, } from '../../shared/image-workspace'; import { designAssetContentPath } from '../../shared/image-workspace'; import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module'; -const LOCAL_WORKSPACE_SCHEMA_VERSION = 2; +const LOCAL_WORKSPACE_SCHEMA_VERSION = 3; const LOCAL_WORKSPACE_DIRECTORY = 'image-workspace-development'; -const LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json'; +const LOCAL_WORKSPACE_FILE = 'design-workspace-v3.json'; +const LEGACY_LOCAL_WORKSPACE_FILE = 'design-workspace-v2.json'; const MAX_PROJECT_NAME_LENGTH = 80; const MAX_MESSAGE_LENGTH = 4_000; @@ -29,8 +34,12 @@ const LOCAL_CAPABILITIES: DesignCapabilities = { video: true, }; -type PersistedWorkspace = DesignWorkspace & { +type PersistedWorkspace = DesignWorkspaceSummary & { clientWorkspaceId: string; +}; + +type PersistedConversation = DesignConversation & { + clientConversationId: string; requestHashes: Record; }; @@ -43,6 +52,27 @@ type PersistedAsset = { type PersistedImageWorkspace = { schemaVersion: typeof LOCAL_WORKSPACE_SCHEMA_VERSION; workspaces: PersistedWorkspace[]; + conversationsByWorkspaceId: Record; + tasksByWorkspaceId: Record; + assetsById: Record; +}; + +type LegacyPersistedWorkspace = { + workspaceId: string; + clientWorkspaceId: string; + title: string; + turnRevision: number; + viewRevision: number; + phase: DesignConversation['phase']; + brief: DesignConversation['brief']; + messages: DesignConversation['messages']; + requestHashes: Record; + updatedAt: string; +}; + +type LegacyPersistedImageWorkspace = { + schemaVersion: 2; + workspaces: LegacyPersistedWorkspace[]; tasksByWorkspaceId: Record; assetsById: Record; }; @@ -64,6 +94,7 @@ function createEmptyState(): PersistedImageWorkspace { return { schemaVersion: LOCAL_WORKSPACE_SCHEMA_VERSION, workspaces: [], + conversationsByWorkspaceId: {}, tasksByWorkspaceId: {}, assetsById: {}, }; @@ -81,6 +112,15 @@ function isPersistedWorkspace(value: unknown): value is PersistedImageWorkspace return isRecord(value) && value.schemaVersion === LOCAL_WORKSPACE_SCHEMA_VERSION && Array.isArray(value.workspaces) + && isRecord(value.conversationsByWorkspaceId) + && isRecord(value.tasksByWorkspaceId) + && isRecord(value.assetsById); +} + +function isLegacyPersistedWorkspace(value: unknown): value is LegacyPersistedImageWorkspace { + return isRecord(value) + && value.schemaVersion === 2 + && Array.isArray(value.workspaces) && isRecord(value.tasksByWorkspaceId) && isRecord(value.assetsById); } @@ -161,6 +201,7 @@ export function getLocalImageWorkspaceDirectory(userDataDir: string): string { export class LocalImageWorkspace implements DesignWorkspaceModule { private readonly rootDirectory: string; private readonly stateFile: string; + private readonly legacyStateFile: string; private readonly now: () => Date; private readonly createId: () => string; private state: PersistedImageWorkspace | null = null; @@ -173,6 +214,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { throw new Error('Invalid local AI design workspace directory'); } this.stateFile = join(this.rootDirectory, LOCAL_WORKSPACE_FILE); + this.legacyStateFile = join(this.rootDirectory, LEGACY_LOCAL_WORKSPACE_FILE); this.now = options.now ?? (() => new Date()); this.createId = options.createId ?? randomUUID; } @@ -191,39 +233,71 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { } createWorkspace(input: DesignCreateWorkspaceInput): Promise { - return this.mutate((state) => { + return this.mutateWorkspace((state) => { const existing = state.workspaces.find( (workspace) => workspace.clientWorkspaceId === input.clientWorkspaceId, ); if (existing) return existing; const timestamp = this.now().toISOString(); + const workspaceId = `local-workspace-${this.createId()}`; const workspace: PersistedWorkspace = { - workspaceId: `local-workspace-${this.createId()}`, + workspaceId, clientWorkspaceId: input.clientWorkspaceId, title: validateTitle(input.title), - turnRevision: 0, viewRevision: 0, + conversationCount: 1, phase: 'shaping', - brief: { - version: 0, - status: 'draft', - medium: null, - summary: '正在建立作品的视觉方向', - ready: false, - missingDecision: '作品形式', - }, - messages: [], - requestHashes: {}, updatedAt: timestamp, }; + const conversation = this.createEmptyConversation( + workspaceId, + `local-conversation-${this.createId()}`, + `local-client-conversation-${this.createId()}`, + '新会话', + timestamp, + ); state.workspaces.unshift(workspace); - state.tasksByWorkspaceId[workspace.workspaceId] = []; + state.conversationsByWorkspaceId[workspaceId] = [conversation]; + state.tasksByWorkspaceId[workspaceId] = []; return workspace; }); } + createConversation(input: DesignCreateConversationInput): Promise { + return this.mutateConversation((state) => { + const workspace = this.requireWorkspace(state, input.workspaceId); + const conversations = state.conversationsByWorkspaceId[input.workspaceId] ?? []; + const existing = conversations.find( + (conversation) => conversation.clientConversationId === input.clientConversationId, + ); + if (existing) return existing; + const timestamp = this.now().toISOString(); + const conversation = this.createEmptyConversation( + input.workspaceId, + `local-conversation-${this.createId()}`, + input.clientConversationId, + input.title, + timestamp, + ); + conversations.unshift(conversation); + state.conversationsByWorkspaceId[input.workspaceId] = conversations; + workspace.conversationCount = conversations.length; + workspace.viewRevision += 1; + workspace.updatedAt = timestamp; + return conversation; + }); + } + + async getConversation( + workspaceId: string, + conversationId: string, + ): Promise { + const state = await this.load(); + return clone(this.requireConversation(state, workspaceId, conversationId)); + } + renameWorkspace(input: DesignRenameWorkspaceInput): Promise { - return this.mutate((state) => { + return this.mutateWorkspace((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); workspace.title = validateTitle(input.title); workspace.viewRevision += 1; @@ -234,20 +308,25 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { async getWorkspace(workspaceId: string): Promise { const state = await this.load(); - return clone(this.requireWorkspace(state, workspaceId)); + return this.workspaceView(state, this.requireWorkspace(state, workspaceId)); } - submitMessage(input: DesignSubmitMessageInput): Promise { - return this.mutate((state) => { + submitMessage(input: DesignSubmitMessageInput): Promise { + return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); + const conversation = this.requireConversation( + state, + input.workspaceId, + input.conversationId, + ); const message = validateMessage(input.message); const hash = messageHash(input); - if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace; - this.assertRevision(workspace, input.expectedTurnRevision); - this.supersedeQuotes(workspace); + if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; + this.assertRevision(conversation, input.expectedTurnRevision); + this.supersedeQuotes(conversation); const timestamp = this.now().toISOString(); - const nextRevision = workspace.turnRevision + 1; + const nextRevision = conversation.turnRevision + 1; const medium = detectMedium(message); const quote: DesignGenerationQuote = { quoteId: `local-quote-${this.createId()}`, @@ -258,7 +337,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { quotedDesignPoints: medium === 'video' ? 2 : 1, expiresAt: new Date(this.now().getTime() + 15 * 60 * 1000).toISOString(), }; - workspace.messages.push( + conversation.messages.push( { id: `local-message-${this.createId()}`, role: 'user', @@ -282,10 +361,9 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, }, ); - workspace.turnRevision = nextRevision; - workspace.viewRevision += 1; - workspace.phase = 'awaiting_confirmation'; - workspace.brief = { + conversation.turnRevision = nextRevision; + conversation.phase = 'awaiting_confirmation'; + conversation.brief = { version: nextRevision, status: 'ready', medium, @@ -293,19 +371,27 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { ready: true, missingDecision: null, }; + conversation.updatedAt = timestamp; + conversation.requestHashes[input.clientTurnId] = hash; + workspace.phase = conversation.phase; + workspace.viewRevision += 1; workspace.updatedAt = timestamp; - workspace.requestHashes[input.clientTurnId] = hash; - return workspace; + return conversation; }); } - confirmGeneration(input: DesignConfirmGenerationInput): Promise { - return this.mutate((state) => { + confirmGeneration(input: DesignConfirmGenerationInput): Promise { + return this.mutateConversation((state) => { const workspace = this.requireWorkspace(state, input.workspaceId); + const conversation = this.requireConversation( + state, + input.workspaceId, + input.conversationId, + ); const hash = messageHash(input); - if (this.isRequestReplay(workspace, input.clientTurnId, hash)) return workspace; - this.assertRevision(workspace, input.expectedTurnRevision); - const quote = workspace.messages + if (this.isRequestReplay(conversation, input.clientTurnId, hash)) return conversation; + this.assertRevision(conversation, input.expectedTurnRevision); + const quote = conversation.messages .map((message) => message.generationQuote) .find((candidate) => candidate?.quoteId === input.quoteId); if (!quote || quote.status !== 'active') { @@ -318,8 +404,8 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { quote.status = 'consumed'; const timestamp = this.now().toISOString(); - const nextRevision = workspace.turnRevision + 1; - workspace.messages.push( + const nextRevision = conversation.turnRevision + 1; + conversation.messages.push( { id: `local-message-${this.createId()}`, role: 'user', @@ -341,16 +427,19 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, }, ); - workspace.turnRevision = nextRevision; + conversation.turnRevision = nextRevision; + conversation.phase = 'shaping'; + conversation.brief = { ...conversation.brief, status: 'confirmed' }; + conversation.updatedAt = timestamp; + conversation.requestHashes[input.clientTurnId] = hash; + workspace.phase = conversation.phase; workspace.viewRevision += 1; - workspace.phase = 'shaping'; - workspace.brief = { ...workspace.brief, status: 'confirmed' }; workspace.updatedAt = timestamp; - workspace.requestHashes[input.clientTurnId] = hash; state.tasksByWorkspaceId[workspace.workspaceId] ??= []; state.tasksByWorkspaceId[workspace.workspaceId].unshift({ taskId: `local-task-${this.createId()}`, workspaceId: workspace.workspaceId, + conversationId: conversation.conversationId, medium: quote.medium, status: 'queued', briefVersion: quote.briefVersion, @@ -362,10 +451,39 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { createdAt: timestamp, updatedAt: timestamp, }); - return workspace; + return conversation; }); } + private createEmptyConversation( + workspaceId: string, + conversationId: string, + clientConversationId: string, + title: string, + timestamp: string, + ): PersistedConversation { + return { + conversationId, + clientConversationId, + workspaceId, + title: validateTitle(title), + turnRevision: 0, + phase: 'shaping', + brief: { + version: 0, + status: 'draft', + medium: null, + summary: '正在建立作品的视觉方向', + ready: false, + missingDecision: '作品形式', + }, + messages: [], + requestHashes: {}, + createdAt: timestamp, + updatedAt: timestamp, + }; + } + listTasks(workspaceId: string): Promise { return this.enqueue(async () => { const state = await this.load(); @@ -443,14 +561,25 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { }); } - private mutate( + private mutateWorkspace( operation: (state: PersistedImageWorkspace) => PersistedWorkspace, ): Promise { return this.enqueue(async () => { const state = await this.load(); const workspace = operation(state); await this.persist(state); - return clone(workspace); + return this.workspaceView(state, workspace); + }); + } + + private mutateConversation( + operation: (state: PersistedImageWorkspace) => PersistedConversation, + ): Promise { + return this.enqueue(async () => { + const state = await this.load(); + const conversation = operation(state); + await this.persist(state); + return this.conversationView(conversation); }); } @@ -474,7 +603,7 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { this.state = parsed; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.state = createEmptyState(); + this.state = await this.loadLegacyState(); } else { throw error; } @@ -482,6 +611,62 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return this.state; } + private async loadLegacyState(): Promise { + try { + const parsed = JSON.parse(await readFile(this.legacyStateFile, 'utf8')) as unknown; + if (!isLegacyPersistedWorkspace(parsed)) { + throw new LocalImageWorkspaceError( + 500, + 'IMAGE_WORKSPACE_LOCAL_DATA_INVALID', + 'AI 设计本地数据暂时无法读取', + ); + } + const migrated = this.migrateLegacyState(parsed); + await this.persist(migrated); + return migrated; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyState(); + throw error; + } + } + + private migrateLegacyState(legacy: LegacyPersistedImageWorkspace): PersistedImageWorkspace { + const migrated = createEmptyState(); + migrated.assetsById = clone(legacy.assetsById); + migrated.tasksByWorkspaceId = clone(legacy.tasksByWorkspaceId); + for (const legacyWorkspace of legacy.workspaces) { + const conversationId = `local-conversation-${this.createId()}`; + const createdAt = legacyWorkspace.messages[0]?.createdAt ?? legacyWorkspace.updatedAt; + const conversation: PersistedConversation = { + conversationId, + clientConversationId: `migrated-${legacyWorkspace.workspaceId}`, + workspaceId: legacyWorkspace.workspaceId, + title: '历史会话', + turnRevision: legacyWorkspace.turnRevision, + phase: legacyWorkspace.phase, + brief: clone(legacyWorkspace.brief), + messages: clone(legacyWorkspace.messages), + requestHashes: clone(legacyWorkspace.requestHashes), + createdAt, + updatedAt: legacyWorkspace.updatedAt, + }; + migrated.workspaces.push({ + workspaceId: legacyWorkspace.workspaceId, + clientWorkspaceId: legacyWorkspace.clientWorkspaceId, + title: legacyWorkspace.title, + viewRevision: legacyWorkspace.viewRevision, + conversationCount: 1, + phase: legacyWorkspace.phase, + updatedAt: legacyWorkspace.updatedAt, + }); + migrated.conversationsByWorkspaceId[legacyWorkspace.workspaceId] = [conversation]; + migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] = ( + migrated.tasksByWorkspaceId[legacyWorkspace.workspaceId] ?? [] + ).map((task) => ({ ...task, conversationId })); + } + return migrated; + } + private async persist(state: PersistedImageWorkspace): Promise { await mkdir(this.rootDirectory, { recursive: true }); const temporaryFile = `${this.stateFile}.${process.pid}.tmp`; @@ -508,22 +693,39 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return workspace; } - private assertRevision(workspace: PersistedWorkspace, expectedRevision: number): void { - if (workspace.turnRevision !== expectedRevision) { + private requireConversation( + state: PersistedImageWorkspace, + workspaceId: string, + conversationId: string, + ): PersistedConversation { + this.requireWorkspace(state, workspaceId); + const conversation = (state.conversationsByWorkspaceId[workspaceId] ?? []) + .find((item) => item.conversationId === conversationId); + if (!conversation) { + throw new LocalImageWorkspaceError(404, 'conversation_not_found', '设计会话不存在'); + } + return conversation; + } + + private assertRevision( + conversation: PersistedConversation, + expectedRevision: number, + ): void { + if (conversation.turnRevision !== expectedRevision) { throw new LocalImageWorkspaceError( 409, - 'workspace_revision_conflict', - '设计项目已在其他位置更新,请刷新后重试', + 'conversation_revision_conflict', + '设计会话已在其他位置更新,请刷新后重试', ); } } private isRequestReplay( - workspace: PersistedWorkspace, + conversation: PersistedConversation, clientTurnId: string, hash: string, ): boolean { - const existing = workspace.requestHashes[clientTurnId]; + const existing = conversation.requestHashes[clientTurnId]; if (!existing) return false; if (existing !== hash) { throw new LocalImageWorkspaceError( @@ -535,8 +737,8 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { return true; } - private supersedeQuotes(workspace: PersistedWorkspace): void { - for (const message of workspace.messages) { + private supersedeQuotes(conversation: PersistedConversation): void { + for (const message of conversation.messages) { if (message.generationQuote?.status === 'active') { message.generationQuote.status = 'superseded'; } @@ -546,7 +748,43 @@ export class LocalImageWorkspace implements DesignWorkspaceModule { private bootstrapView(state: PersistedImageWorkspace): DesignWorkspaceBootstrap { return { capabilities: clone(LOCAL_CAPABILITIES), - workspaces: state.workspaces.map(({ messages: _messages, clientWorkspaceId: _clientId, requestHashes: _requests, ...summary }) => clone(summary)), + workspaces: state.workspaces.map(({ clientWorkspaceId: _clientId, ...summary }) => ( + clone(summary) + )), }; } + + private workspaceView( + state: PersistedImageWorkspace, + workspace: PersistedWorkspace, + ): DesignWorkspace { + const { clientWorkspaceId: _clientId, ...summary } = workspace; + return clone({ + ...summary, + conversationCount: state.conversationsByWorkspaceId[workspace.workspaceId]?.length ?? 0, + conversations: (state.conversationsByWorkspaceId[workspace.workspaceId] ?? []) + .map((conversation) => this.conversationSummaryView(conversation)), + }); + } + + private conversationSummaryView( + conversation: PersistedConversation, + ): DesignConversationSummary { + const { + clientConversationId: _clientId, + requestHashes: _requestHashes, + messages: _messages, + ...summary + } = conversation; + return clone(summary); + } + + private conversationView(conversation: PersistedConversation): DesignConversation { + const { + clientConversationId: _clientId, + requestHashes: _requestHashes, + ...view + } = conversation; + return clone(view); + } } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 58b90c3..34e4ea9 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -2,6 +2,8 @@ import type { DesignAsset, DesignAssetUploadInput, DesignCapabilities, + DesignConversation, + DesignCreateConversationInput, DesignConfirmGenerationInput, DesignCreateWorkspaceInput, DesignGenerationTask, @@ -19,6 +21,7 @@ export type DesignWorkspaceEventSubscription = { export type DesignWorkspaceEventSubscriptionInput = { workspaceId: string; + conversationId: string; afterEventId?: string; }; @@ -45,8 +48,10 @@ export interface DesignWorkspaceModule { createWorkspace(input: DesignCreateWorkspaceInput): Promise; renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; - submitMessage(input: DesignSubmitMessageInput): Promise; - confirmGeneration(input: DesignConfirmGenerationInput): Promise; + createConversation(input: DesignCreateConversationInput): Promise; + getConversation(workspaceId: string, conversationId: string): Promise; + submitMessage(input: DesignSubmitMessageInput): Promise; + confirmGeneration(input: DesignConfirmGenerationInput): Promise; listTasks(workspaceId: string): Promise; uploadAsset?(input: DesignAssetUploadInput): Promise; openWorkspaceEvents?( diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index b4e6937..3a5c496 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -4,11 +4,14 @@ import type { DesignAssistantDeltaEvent, DesignBrief, DesignCapabilities, + DesignConversation, + DesignConversationSnapshotEvent, + DesignConversationSummary, DesignConfirmGenerationInput, + DesignCreateConversationInput, DesignCreateWorkspaceInput, DesignGenerationQuote, DesignGenerationTask, - DesignGenerationTasksSnapshotEvent, DesignGenerationTaskUpdatedEvent, DesignMessage, DesignRenameWorkspaceInput, @@ -18,7 +21,6 @@ import type { DesignWorkspaceEvent, DesignWorkspaceSummary, } from '../../shared/image-workspace'; -import { createHash } from 'node:crypto'; import WebSocket from 'ws'; import { designAssetContentPath } from '../../shared/image-workspace'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; @@ -26,7 +28,6 @@ import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from '../services/works-square-session'; import { DesignWorkspaceModuleError, - type CloseEventSessionsOptions, type DesignWorkspaceEventSubscription, type DesignWorkspaceEventSubscriptionInput, type DesignWorkspaceModule, @@ -64,17 +65,36 @@ type ServerMessage = { type ServerWorkspaceSummary = { workspace_id: string; title: string; - turn_revision: number; view_revision: number; + conversation_count?: number; phase: DesignWorkspaceSummary['phase']; - brief: ServerBrief; updated_at: string; }; -type ServerWorkspace = ServerWorkspaceSummary & { +type ServerConversationSummary = { + conversation_id: string; + workspace_id: string; + agent_session_id: string | null; + title: string; + turn_revision: number; + phase: DesignConversationSummary['phase']; + brief: ServerBrief; + created_at: string; + updated_at: string; +}; + +type ServerConversation = ServerConversationSummary & { messages: ServerMessage[]; }; +type ServerWorkspace = ServerWorkspaceSummary & { + conversations?: ServerConversationSummary[]; +}; + +type ServerWorkspaceCreation = ServerWorkspace & { + initial_conversation?: ServerConversation; +}; + type ServerAsset = { asset_id: string; media_type: DesignAsset['mediaType']; @@ -88,6 +108,7 @@ type ServerAsset = { type ServerTask = { task_id: string; workspace_id: string; + conversation_id?: string | null; medium: DesignGenerationTask['medium']; status: DesignGenerationTask['status']; brief_version: number; @@ -108,17 +129,7 @@ type ServerErrorDetail = { type WorksSquareDesignWorkspaceOptions = { apiBaseUrl?: string; fetchImpl?: typeof fetch; - clientInstanceId?: string; webSocketFactory?: AgentWebSocketFactory; - eventSessionClientIdStore?: { - getOrCreate(workspaceId: string): Promise; - rotate(workspaceId: string): Promise; - }; -}; - -type ServerAgentSession = { - session_id: string; - status: 'active' | 'closed'; }; type ServerAgentStreamTicket = { @@ -155,6 +166,8 @@ type ServerAgentRun = { type AgentDesignTurnSubmission = { workspaceId: string; + conversationId: string; + agentSessionId: string; clientTurnId: string; expectedTurnRevision: number; message: string; @@ -233,19 +246,42 @@ function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspace return { workspaceId: workspace.workspace_id, title: workspace.title, - turnRevision: workspace.turn_revision, viewRevision: workspace.view_revision, + conversationCount: workspace.conversation_count ?? 0, phase: workspace.phase, - brief: mapBrief(workspace.brief), updatedAt: workspace.updated_at, }; } function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace { + const conversations = workspace.conversations ?? []; return { ...mapWorkspaceSummary(workspace), - messages: workspace.messages.map((message, index) => ({ - id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`, + conversationCount: workspace.conversation_count ?? conversations.length, + conversations: conversations.map(mapConversationSummary), + }; +} + +function mapConversationSummary( + conversation: ServerConversationSummary, +): DesignConversationSummary { + return { + conversationId: conversation.conversation_id, + workspaceId: conversation.workspace_id, + title: conversation.title, + turnRevision: conversation.turn_revision, + phase: conversation.phase, + brief: mapBrief(conversation.brief), + createdAt: conversation.created_at, + updatedAt: conversation.updated_at, + }; +} + +function mapConversation(conversation: ServerConversation): DesignConversation { + return { + ...mapConversationSummary(conversation), + messages: conversation.messages.map((message, index) => ({ + id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`, role: message.role, kind: message.kind, text: message.text, @@ -261,6 +297,7 @@ function mapTask(task: ServerTask): DesignGenerationTask { return { taskId: task.task_id, workspaceId: task.workspace_id, + conversationId: task.conversation_id ?? null, medium: task.medium, status: task.status, briefVersion: task.brief_version, @@ -288,24 +325,14 @@ function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset { }; } -function createClientId(prefix: string): string { - const id = globalThis.crypto?.randomUUID?.() - ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `${prefix}-${id}`; -} - -function stableWorkspaceSessionClientId(clientInstanceId: string, workspaceId: string): string { - const digest = createHash('sha256') - .update(`${clientInstanceId}\0${workspaceId}`) - .digest('hex'); - return `design-stream-${digest}`; -} - function isServerTask(value: unknown): value is ServerTask { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const task = value as Record; return typeof task.task_id === 'string' && typeof task.workspace_id === 'string' + && (task.conversation_id === undefined + || task.conversation_id === null + || typeof task.conversation_id === 'string') && (task.medium === 'image' || task.medium === 'video') && ['queued', 'running', 'succeeded', 'failed', 'cancelled'].includes(String(task.status)) && typeof task.brief_version === 'number' @@ -315,17 +342,18 @@ function isServerTask(value: unknown): value is ServerTask { && typeof task.updated_at === 'string'; } -function isServerWorkspace(value: unknown): value is ServerWorkspace { +function isServerConversation(value: unknown): value is ServerConversation { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const workspace = value as Record; - const brief = workspace.brief as Record | null; - return typeof workspace.workspace_id === 'string' - && typeof workspace.title === 'string' - && Number.isInteger(workspace.turn_revision) - && Number(workspace.turn_revision) >= 0 - && Number.isInteger(workspace.view_revision) - && Number(workspace.view_revision) >= 0 - && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(workspace.phase)) + const conversation = value as Record; + const brief = conversation.brief as Record | null; + return typeof conversation.conversation_id === 'string' + && typeof conversation.workspace_id === 'string' + && (conversation.agent_session_id === null + || typeof conversation.agent_session_id === 'string') + && typeof conversation.title === 'string' + && Number.isInteger(conversation.turn_revision) + && Number(conversation.turn_revision) >= 0 + && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(conversation.phase)) && Boolean(brief) && Number.isInteger(brief?.version) && ['draft', 'ready', 'confirmed'].includes(String(brief?.status)) @@ -333,8 +361,8 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace { && typeof brief?.summary === 'string' && typeof brief?.ready === 'boolean' && (brief?.missing_decision === null || typeof brief?.missing_decision === 'string') - && Array.isArray(workspace.messages) - && workspace.messages.every((message) => { + && Array.isArray(conversation.messages) + && conversation.messages.every((message) => { if (!message || typeof message !== 'object' || Array.isArray(message)) return false; const item = message as Record; return (item.role === 'user' || item.role === 'assistant') @@ -347,13 +375,15 @@ function isServerWorkspace(value: unknown): value is ServerWorkspace { && Number(item.turn_revision) >= 0 && typeof item.created_at === 'string'; }) - && typeof workspace.updated_at === 'string'; + && typeof conversation.created_at === 'string' + && typeof conversation.updated_at === 'string'; } function normalizeWorkspaceEvent( value: unknown, sessionId: string, workspaceId: string, + conversationId: string, ): DesignWorkspaceEvent | null { try { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; @@ -371,6 +401,7 @@ function normalizeWorkspaceEvent( const payload = event.payload as Record; if (event.type === 'design.assistant.delta') { if (payload.workspace_id !== workspaceId + || payload.conversation_id !== conversationId || typeof payload.client_turn_id !== 'string' || payload.client_turn_id.length < 1 || payload.client_turn_id.length > 128 @@ -387,6 +418,7 @@ function normalizeWorkspaceEvent( id: `${sessionId}:${event.sequence}`, type: 'design.assistant.delta', workspaceId, + conversationId, clientTurnId: payload.client_turn_id, turnRevision: Number(payload.turn_revision), chunkIndex: Number(payload.chunk_index), @@ -411,15 +443,19 @@ function normalizeWorkspaceEvent( } satisfies DesignGenerationTaskUpdatedEvent; } - if (event.type !== 'design.workspace.updated' - || !isServerWorkspace(payload.workspace) + if ((event.type !== 'design.conversation.updated' + && event.type !== 'design.workspace.updated') + || payload.workspace_id !== workspaceId + || payload.conversation_id !== conversationId + || !isServerConversation(payload.conversation) || !Array.isArray(payload.generation_tasks)) { return null; } - const workspace = payload.workspace; - if (workspace.workspace_id !== workspaceId - || !Number.isInteger(workspace.view_revision) - || Number(workspace.view_revision) < 0 + const conversation = payload.conversation; + if (conversation.workspace_id !== workspaceId + || conversation.conversation_id !== conversationId + || !Number.isInteger(payload.workspace_view_revision) + || Number(payload.workspace_view_revision) < 0 || !payload.generation_tasks.every( (task) => isServerTask(task) && task.workspace_id === workspaceId, )) { @@ -427,12 +463,13 @@ function normalizeWorkspaceEvent( } return { id: `${sessionId}:${event.sequence}`, - type: 'design.generation_tasks.snapshot', + type: 'design.conversation.snapshot', workspaceId, - workspaceViewRevision: Number(workspace.view_revision), - workspace: mapWorkspace(workspace), + conversationId, + workspaceViewRevision: Number(payload.workspace_view_revision), + conversation: mapConversation(conversation), generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)), - } satisfies DesignGenerationTasksSnapshotEvent; + } satisfies DesignConversationSnapshotEvent; } catch { return null; } @@ -669,15 +706,17 @@ function agentRunErrorStatus(code: string): number { return 502; } +function isStaleAgentSessionError(error: unknown): error is DesignWorkspaceModuleError { + return error instanceof DesignWorkspaceModuleError + && (error.code === 'agent_session_not_found' + || error.code === 'agent_session_closed'); +} + export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private readonly apiBaseUrl: string; private readonly fetchImpl: typeof fetch; private readonly webSocketFactory: AgentWebSocketFactory; - private readonly eventSessionClientIdStore: NonNullable< - WorksSquareDesignWorkspaceOptions['eventSessionClientIdStore'] - >; - private readonly eventSessions = new Map>(); - private readonly eventSessionClientIds = new Map(); + private readonly conversationSessionIds = new Map(); private readonly eventSubscriptionClosers = new Map void>>(); private readonly activeRunEventStreams = new Map(); private readonly terminalAgentRuns = new Map(); @@ -689,13 +728,6 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; this.webSocketFactory = options.webSocketFactory ?? defaultAgentWebSocketFactory; - const clientInstanceId = options.clientInstanceId?.trim() || createClientId('process'); - this.eventSessionClientIdStore = options.eventSessionClientIdStore ?? { - getOrCreate: async (workspaceId) => ( - stableWorkspaceSessionClientId(clientInstanceId, workspaceId) - ), - rotate: async () => createClientId('design-stream-rotated'), - }; } async bootstrap(): Promise { @@ -715,14 +747,15 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } async createWorkspace(input: DesignCreateWorkspaceInput): Promise { - const workspace = await this.requestJson('/api/design/workspaces', { + const workspace = await this.requestJson('/api/design/workspaces', { method: 'POST', body: JSON.stringify({ client_workspace_id: input.clientWorkspaceId, title: input.title, }), }); - return mapWorkspace(workspace); + if (workspace.initial_conversation) this.rememberConversation(workspace.initial_conversation); + return this.getWorkspace(workspace.workspace_id); } async renameWorkspace(input: DesignRenameWorkspaceInput): Promise { @@ -733,19 +766,53 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { body: JSON.stringify({ title: input.title }), }, ); - return mapWorkspace(workspace); + return this.getWorkspace(workspace.workspace_id); } async getWorkspace(workspaceId: string): Promise { - const workspace = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, - ); - return mapWorkspace(workspace); + const [workspace, conversations] = await Promise.all([ + this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}`, + ), + this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations?limit=100&offset=0`, + ), + ]); + return mapWorkspace({ ...workspace, conversations }); } - async submitMessage(input: DesignSubmitMessageInput): Promise { + async createConversation(input: DesignCreateConversationInput): Promise { + const conversation = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/conversations`, + { + method: 'POST', + body: JSON.stringify({ + client_conversation_id: input.clientConversationId, + title: input.title, + }), + }, + ); + this.rememberConversation(conversation); + return mapConversation(conversation); + } + + async getConversation( + workspaceId: string, + conversationId: string, + ): Promise { + const conversation = await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + ); + this.rememberConversation(conversation); + return mapConversation(conversation); + } + + async submitMessage(input: DesignSubmitMessageInput): Promise { + const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); return this.executeAgentTurn({ workspaceId: input.workspaceId, + conversationId: input.conversationId, + agentSessionId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: input.message, @@ -754,9 +821,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - async confirmGeneration(input: DesignConfirmGenerationInput): Promise { + async confirmGeneration(input: DesignConfirmGenerationInput): Promise { + const agentSessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); return this.executeAgentTurn({ workspaceId: input.workspaceId, + conversationId: input.conversationId, + agentSessionId, clientTurnId: input.clientTurnId, expectedTurnRevision: input.expectedTurnRevision, message: '确认生成', @@ -768,22 +838,23 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { - let session = await this.ensureEventSession(input.workspaceId); - let command: ServerAgentCommand; + private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { try { - command = await this.submitTurnCommand(session.session_id, input); + return await this.executeAgentTurnOnce(input); } catch (error) { - if (!(error instanceof DesignWorkspaceModuleError) - || (error.code !== 'agent_session_not_found' - && error.code !== 'agent_session_closed')) { - throw error; - } - await this.invalidateEventSession(input.workspaceId); - session = await this.ensureEventSession(input.workspaceId); - command = await this.submitTurnCommand(session.session_id, input); + if (!isStaleAgentSessionError(error)) throw error; + this.forgetConversation(input.workspaceId, input.conversationId); + const agentSessionId = await this.getAgentSessionId( + input.workspaceId, + input.conversationId, + ); + return this.executeAgentTurnOnce({ ...input, agentSessionId }); } - const run = await this.waitForAgentRun(session.session_id, command.run_id); + } + + private async executeAgentTurnOnce(input: AgentDesignTurnSubmission): Promise { + const command = await this.submitTurnCommand(input.agentSessionId, input); + const run = await this.waitForAgentRun(input.agentSessionId, command.run_id); if (run.status !== 'succeeded') { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' @@ -799,7 +870,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { userFacingErrorMessage(code, fallback), ); } - return this.getWorkspace(input.workspaceId); + return this.getConversation(input.workspaceId, input.conversationId); } private submitTurnCommand( @@ -936,18 +1007,24 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { async openWorkspaceEvents( input: DesignWorkspaceEventSubscriptionInput, ): Promise { - let session = await this.ensureEventSession(input.workspaceId); + if (!this.eventSessionsEnabled) { + throw new DesignWorkspaceModuleError( + 503, + 'DESIGN_EVENT_STREAM_PAUSED', + 'AI 设计任务状态流已暂停', + ); + } + let sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); let ticket: ServerAgentStreamTicket; try { - ticket = await this.createEventStreamTicket(session.session_id); + ticket = await this.createEventStreamTicket(sessionId); } catch (error) { - if (!(error instanceof DesignWorkspaceModuleError) - || (error.status !== 404 && error.status !== 409)) { + if (!isStaleAgentSessionError(error)) { throw error; } - await this.invalidateEventSession(input.workspaceId); - session = await this.ensureEventSession(input.workspaceId); - ticket = await this.createEventStreamTicket(session.session_id); + this.forgetConversation(input.workspaceId, input.conversationId); + sessionId = await this.getAgentSessionId(input.workspaceId, input.conversationId); + ticket = await this.createEventStreamTicket(sessionId); } const streamUrl = new URL(ticket.stream_url, `${this.apiBaseUrl}/`); if (streamUrl.origin !== new URL(this.apiBaseUrl).origin) { @@ -966,7 +1043,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } streamUrl.searchParams.set( 'after_sequence', - String(eventSequence(input.afterEventId, session.session_id)), + String(eventSequence(input.afterEventId, sessionId)), ); streamUrl.protocol = streamUrl.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -1010,7 +1087,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { if (heartbeat !== null) clearInterval(heartbeat); heartbeat = null; unregister(); - if (didOpen) this.unregisterRunEventStream(session.session_id); + if (didOpen) this.unregisterRunEventStream(sessionId); try { await connection.dispose?.(); } catch { @@ -1053,7 +1130,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { socket.onopen = () => { if (ending) return; didOpen = true; - this.registerRunEventStream(session.session_id); + this.registerRunEventStream(sessionId); heartbeat = setInterval(() => { if (ending || socket.readyState !== AGENT_WEBSOCKET_OPEN) return; try { @@ -1072,16 +1149,17 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { const agentEvent = agentEventFromWebSocketFrame(data); const event = normalizeWorkspaceEvent( agentEvent, - session.session_id, + sessionId, input.workspaceId, + input.conversationId, ); if (event) { latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event)); } - const run = normalizeAgentRunEvent(agentEvent, session.session_id); + const run = normalizeAgentRunEvent(agentEvent, sessionId); if (run) { const deliveryBarrier = latestWorkspaceEventDelivery; - void deliveryBarrier.then(() => this.publishAgentRun(session.session_id, run)); + void deliveryBarrier.then(() => this.publishAgentRun(sessionId, run)); } }; socket.onerror = () => { @@ -1091,23 +1169,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; socket.onclose = ({ code }) => { const error = webSocketCloseError(code); - if (code === 4409) { - beginEnd(error, async () => { - try { - await this.closeEventSession(session.session_id); - } finally { - await this.invalidateEventSession(input.workspaceId); - } + if (code === 4409 || code === 4404) { + beginEnd(error, () => { + this.forgetConversation(input.workspaceId, input.conversationId); }); return; } - if (code === 4404) { - beginEnd(error, () => this.invalidateEventSession(input.workspaceId)); - return; - } beginEnd(error); }; - unregister = this.registerEventSubscription(input.workspaceId, close); + unregister = this.registerEventSubscription( + input.workspaceId, + input.conversationId, + close, + ); await opened; return { events: queue.events, @@ -1115,53 +1189,13 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }; } - async closeEventSessions(options: CloseEventSessionsOptions = {}): Promise { + async closeEventSessions(): Promise { this.eventSessionsEnabled = false; const activeSubscriptions = [...this.eventSubscriptionClosers.values()] .flatMap((closers) => [...closers]); this.eventSubscriptionClosers.clear(); for (const close of activeSubscriptions) close(); - const pendingSessions = [...this.eventSessions.entries()]; - this.eventSessions.clear(); - const sessions = await Promise.all(pendingSessions.map(async ([workspaceId, pending]) => { - try { - return { workspaceId, session: await pending, creationError: null as unknown }; - } catch (error) { - return { workspaceId, session: null, creationError: error }; - } - })); - const closeResults = await Promise.all(sessions.map(async ({ - workspaceId, - session, - creationError, - }) => { - let remoteError = creationError; - if (session) { - try { - await this.closeEventSession(session.session_id, options.accessToken); - } catch (error) { - remoteError = error; - } - } - - let rotationError: unknown = null; - try { - await this.rotateEventSession(workspaceId); - } catch (error) { - rotationError = error; - } - return { remoteError, rotationError }; - })); - const failed = closeResults.filter(({ remoteError, rotationError }) => ( - Boolean(rotationError) - || (!options.tolerateRemoteFailure - && Boolean(remoteError) - && !(remoteError instanceof DesignWorkspaceModuleError - && remoteError.code === 'DESIGN_EVENT_SESSION_CLOSED')) - )).length; - if (failed > 0) { - throw new Error(`Failed to close ${failed} AI design Agent Session(s)`); - } + this.conversationSessionIds.clear(); } openAssetContent(workspaceId: string, assetId: string, range?: string): Promise { @@ -1171,11 +1205,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { ); } - private async requestJson( - path: string, - init: RequestInit = {}, - accessToken?: string, - ): Promise { + private async requestJson(path: string, init: RequestInit = {}): Promise { const response = await this.authorizedFetch(path, { ...init, headers: { @@ -1185,7 +1215,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { : {}), ...(init.headers ?? {}), }, - }, accessToken); + }); const payload = await readPayload(response); if (!response.ok) { const detail = asErrorDetail(payload); @@ -1204,12 +1234,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { return payload as T; } - private async authorizedFetch( - path: string, - init: RequestInit = {}, - accessToken?: string, - ): Promise { - if (accessToken) return this.fetchWithToken(path, accessToken, init); + private async authorizedFetch(path: string, init: RequestInit = {}): Promise { let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl }); if (!token) { throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计'); @@ -1239,50 +1264,40 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { }); } - private ensureEventSession(workspaceId: string): Promise { - if (!this.eventSessionsEnabled) { + private conversationKey(workspaceId: string, conversationId: string): string { + return `${workspaceId}:${conversationId}`; + } + + private rememberConversation(conversation: ServerConversationSummary): void { + if (typeof conversation.agent_session_id !== 'string' + || !conversation.agent_session_id.trim()) return; + this.conversationSessionIds.set( + this.conversationKey(conversation.workspace_id, conversation.conversation_id), + conversation.agent_session_id, + ); + } + + private forgetConversation(workspaceId: string, conversationId: string): void { + this.conversationSessionIds.delete(this.conversationKey(workspaceId, conversationId)); + } + + private async getAgentSessionId( + workspaceId: string, + conversationId: string, + ): Promise { + const key = this.conversationKey(workspaceId, conversationId); + const existing = this.conversationSessionIds.get(key); + if (existing) return existing; + await this.getConversation(workspaceId, conversationId); + const refreshed = this.conversationSessionIds.get(key); + if (!refreshed) { throw new DesignWorkspaceModuleError( 503, - 'DESIGN_EVENT_STREAM_PAUSED', - 'AI 设计任务状态流已暂停', + 'DESIGN_CONVERSATION_SESSION_UNAVAILABLE', + '设计会话暂时无法连接,请稍后重试', ); } - const existing = this.eventSessions.get(workspaceId); - if (existing) return existing; - const pending = (async () => { - const clientSessionId = this.eventSessionClientIds.get(workspaceId) - ?? await this.eventSessionClientIdStore.getOrCreate(workspaceId); - this.eventSessionClientIds.set(workspaceId, clientSessionId); - const session = await this.requestJson('/api/agents/sessions', { - method: 'POST', - body: JSON.stringify({ - client_session_id: clientSessionId, - runtime: 'design', - runtime_version: 'v1', - binding: { - kind: 'design_workspace', - key: workspaceId, - }, - }), - }); - if (session.status !== 'active') { - throw new DesignWorkspaceModuleError( - 409, - 'DESIGN_EVENT_SESSION_CLOSED', - 'AI 设计任务状态会话已关闭', - ); - } - return session; - })().catch(async (error) => { - this.eventSessions.delete(workspaceId); - if (error instanceof DesignWorkspaceModuleError - && error.code === 'DESIGN_EVENT_SESSION_CLOSED') { - await this.rotateEventSession(workspaceId); - } - throw error; - }); - this.eventSessions.set(workspaceId, pending); - return pending; + return refreshed; } private createEventStreamTicket(sessionId: string): Promise { @@ -1295,13 +1310,18 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { ); } - private registerEventSubscription(workspaceId: string, close: () => void): () => void { - const closers = this.eventSubscriptionClosers.get(workspaceId) ?? new Set<() => void>(); + private registerEventSubscription( + workspaceId: string, + conversationId: string, + close: () => void, + ): () => void { + const key = this.conversationKey(workspaceId, conversationId); + const closers = this.eventSubscriptionClosers.get(key) ?? new Set<() => void>(); closers.add(close); - this.eventSubscriptionClosers.set(workspaceId, closers); + this.eventSubscriptionClosers.set(key, closers); return () => { closers.delete(close); - if (closers.size === 0) this.eventSubscriptionClosers.delete(workspaceId); + if (closers.size === 0) this.eventSubscriptionClosers.delete(key); }; } @@ -1339,28 +1359,4 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { for (const resolve of waiters) resolve(run); } - private async closeEventSession(sessionId: string, accessToken?: string): Promise { - try { - await this.requestJson( - `/api/agents/sessions/${encodeURIComponent(sessionId)}`, - { method: 'DELETE' }, - accessToken, - ); - } catch (error) { - if (error instanceof DesignWorkspaceModuleError - && (error.status === 404 || error.status === 409)) return; - throw error; - } - } - - private invalidateEventSession(workspaceId: string): Promise { - return this.rotateEventSession(workspaceId).then(() => undefined); - } - - private async rotateEventSession(workspaceId: string): Promise { - this.eventSessions.delete(workspaceId); - const clientSessionId = await this.eventSessionClientIdStore.rotate(workspaceId); - this.eventSessionClientIds.set(workspaceId, clientSessionId); - return clientSessionId; - } } diff --git a/electron/main/index.ts b/electron/main/index.ts index 2c731fc..80b1eee 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -32,10 +32,6 @@ import { logger } from '../utils/logger'; import { warmupNetworkOptimization } from '../utils/uv-env'; import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime'; import { initTelemetry } from '../utils/telemetry'; -import { - getOrCreateAgentGatewaySessionClientId, - rotateAgentGatewaySessionClientId, -} from '../utils/store'; import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; @@ -456,12 +452,7 @@ async function initialize(): Promise { }); const imageWorkspace = localImageWorkspaceEnabled ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) - : new WorksSquareDesignWorkspace({ - eventSessionClientIdStore: { - getOrCreate: getOrCreateAgentGatewaySessionClientId, - rotate: rotateAgentGatewaySessionClientId, - }, - }); + : new WorksSquareDesignWorkspace(); imageWorkspaceModule = imageWorkspace; if (consumeWorksSquareStartupRuntimeCleanupRequired()) { await clearManagedWorksSquareRuntimeBestEffort({ diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 27fb7e1..8772654 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -47,14 +47,28 @@ export type DesignMessage = { export type DesignWorkspaceSummary = { workspaceId: string; title: string; - turnRevision: number; viewRevision: number; + conversationCount: number; phase: DesignWorkspacePhase; - brief: DesignBrief; updatedAt: string; }; export type DesignWorkspace = DesignWorkspaceSummary & { + conversations: DesignConversationSummary[]; +}; + +export type DesignConversationSummary = { + conversationId: string; + workspaceId: string; + title: string; + turnRevision: number; + phase: DesignWorkspacePhase; + brief: DesignBrief; + createdAt: string; + updatedAt: string; +}; + +export type DesignConversation = DesignConversationSummary & { messages: DesignMessage[]; }; @@ -86,6 +100,7 @@ export type DesignAssetUploadInput = { export type DesignGenerationTask = { taskId: string; workspaceId: string; + conversationId: string | null; medium: DesignMedium; status: DesignTaskStatus; briefVersion: number; @@ -106,12 +121,13 @@ export type DesignGenerationTaskUpdatedEvent = { generationTask: DesignGenerationTask; }; -export type DesignGenerationTasksSnapshotEvent = { +export type DesignConversationSnapshotEvent = { id: string; - type: 'design.generation_tasks.snapshot'; + type: 'design.conversation.snapshot'; workspaceId: string; + conversationId: string; workspaceViewRevision: number; - workspace: DesignWorkspace; + conversation: DesignConversation; generationTasks: DesignGenerationTask[]; }; @@ -119,6 +135,7 @@ export type DesignAssistantDeltaEvent = { id: string; type: 'design.assistant.delta'; workspaceId: string; + conversationId: string; clientTurnId: string; turnRevision: number; chunkIndex: number; @@ -127,7 +144,7 @@ export type DesignAssistantDeltaEvent = { export type DesignWorkspaceEvent = | DesignGenerationTaskUpdatedEvent - | DesignGenerationTasksSnapshotEvent + | DesignConversationSnapshotEvent | DesignAssistantDeltaEvent; export type DesignWorkspaceBootstrap = { @@ -140,6 +157,12 @@ export type DesignCreateWorkspaceInput = { title: string; }; +export type DesignCreateConversationInput = { + workspaceId: string; + clientConversationId: string; + title: string; +}; + export type DesignRenameWorkspaceInput = { workspaceId: string; title: string; @@ -147,6 +170,7 @@ export type DesignRenameWorkspaceInput = { export type DesignSubmitMessageInput = { workspaceId: string; + conversationId: string; clientTurnId: string; expectedTurnRevision: number; message: string; @@ -155,6 +179,7 @@ export type DesignSubmitMessageInput = { export type DesignConfirmGenerationInput = { workspaceId: string; + conversationId: string; clientTurnId: string; expectedTurnRevision: number; quoteId: string; diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 2555cb6..3827dc4 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -2,12 +2,14 @@ import { useEffect, useState } from 'react'; import { FolderKanban, Loader2, + MessageSquareText, Pencil, Plus, RefreshCw, X, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -36,11 +38,16 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId); + const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId); + const workspace = useImageWorkspaceStore((state) => state.workspace); + const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const createProject = useImageWorkspaceStore((state) => state.createProject); + const createConversation = useImageWorkspaceStore((state) => state.createConversation); const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); + const selectConversation = useImageWorkspaceStore((state) => state.selectConversation); const [projectsOpen, setProjectsOpen] = useState(true); const [dialog, setDialog] = useState(null); const [projectName, setProjectName] = useState(''); @@ -107,6 +114,15 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba navigate('/image-canvas'); }; + const handleCreateConversation = async () => { + try { + await createConversation(); + navigate('/image-canvas'); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + return (
@@ -220,6 +236,58 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
+ {active ? ( +
+
+ 设计会话 + +
+
+ {workspace?.workspaceId === project.workspaceId + ? workspace.conversations.slice(0, 6).map((conversation) => { + const selected = conversation.conversationId === activeConversationId; + return ( + + ); + }) + : null} +
+
+ ) : null} ); }) : null} diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index e33ab59..693bf33 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -12,6 +12,7 @@ import { designAssetDownloadPath, type DesignAsset, type DesignAssetSaveResult, + type DesignConversation, type DesignGenerationTask, type DesignWorkspace, type DesignWorkspaceBootstrap, @@ -49,6 +50,10 @@ export function createImageWorkspaceTurnId(): string { return createClientId('turn'); } +export function createImageWorkspaceConversationId(): string { + return createClientId('conversation'); +} + function getErrorStatus(error: unknown): number { if (error instanceof AppError && typeof error.details?.status === 'number') { return error.details.status; @@ -123,15 +128,42 @@ export function fetchImageWorkspaceProject(workspaceId: string): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations`, + { + method: 'POST', + body: JSON.stringify({ + clientConversationId, + title: title.trim(), + }), + }, + ); +} + +export function fetchImageWorkspaceConversation( + workspaceId: string, + conversationId: string, +): Promise { + return requestData( + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`, + ); +} + export function sendImageWorkspaceMessage( workspaceId: string, + conversationId: string, expectedTurnRevision: number, message: string, clientTurnId = createImageWorkspaceTurnId(), attachmentAssetIds: string[] = [], -): Promise { +): Promise { return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/messages`, { method: 'POST', body: JSON.stringify({ @@ -146,12 +178,13 @@ export function sendImageWorkspaceMessage( export function confirmImageWorkspaceGeneration( workspaceId: string, + conversationId: string, expectedTurnRevision: number, quoteId: string, clientTurnId = createImageWorkspaceTurnId(), -): Promise { +): Promise { return requestData( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, { method: 'POST', body: JSON.stringify({ @@ -170,10 +203,13 @@ export function fetchImageWorkspaceTasks( ); } -export async function openImageWorkspaceTaskEvents(workspaceId: string): Promise { +export async function openImageWorkspaceTaskEvents( + workspaceId: string, + conversationId: string, +): Promise { await ensureHostApiToken(); return createHostEventSource( - `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events`, + `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}/events`, ); } diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index 52c4fd6..a733fd3 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -16,6 +16,7 @@ import { ImageIcon, Loader2, Maximize2, + MessageSquarePlus, Plus, RefreshCw, Send, @@ -26,6 +27,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Select } from '@/components/ui/select'; import { Dialog, DialogClose, @@ -402,18 +404,24 @@ export function ImageCanvas() { const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const workspace = useImageWorkspaceStore((state) => state.workspace); + const conversation = useImageWorkspaceStore((state) => state.conversation); + const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId); + const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation); const tasks = useImageWorkspaceStore((state) => state.tasks); const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn); const workspaceError = useImageWorkspaceStore((state) => state.error); const load = useImageWorkspaceStore((state) => state.load); const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace); + const refreshConversation = useImageWorkspaceStore((state) => state.refreshConversation); const refreshTasks = useImageWorkspaceStore((state) => state.refreshTasks); const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream); const disconnectTaskStream = useImageWorkspaceStore((state) => state.disconnectTaskStream); const sendMessage = useImageWorkspaceStore((state) => state.sendMessage); const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration); + const createConversation = useImageWorkspaceStore((state) => state.createConversation); + const selectConversation = useImageWorkspaceStore((state) => state.selectConversation); const [prompt, setPrompt] = useState(''); - const [submitting, setSubmitting] = useState(false); + const [submittingConversationId, setSubmittingConversationId] = useState(null); const [confirmingQuoteId, setConfirmingQuoteId] = useState(null); const [actionError, setActionError] = useState(null); const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false); @@ -423,8 +431,8 @@ export function ImageCanvas() { const conversationEndRef = useRef(null); const quote = useMemo( - () => workspace ? activeQuote(workspace.messages) : null, - [workspace], + () => conversation ? activeQuote(conversation.messages) : null, + [conversation], ); const generationAvailable = bootstrap?.capabilities.generation ?? false; const firstFrameAssets = useMemo( @@ -442,9 +450,11 @@ export function ImageCanvas() { [tasks, uploadedFirstFrameAsset, workspace?.workspaceId], ); const conversationMessages = useMemo(() => { - if (!workspace) return []; - const messages: RenderedDesignMessage[] = [...workspace.messages]; - if (!pendingTurn || pendingTurn.workspaceId !== workspace.workspaceId) return messages; + if (!conversation) return []; + const messages: RenderedDesignMessage[] = [...conversation.messages]; + if (!pendingTurn + || pendingTurn.workspaceId !== conversation.workspaceId + || pendingTurn.conversationId !== conversation.conversationId) return messages; if (pendingTurn.userText) { messages.push({ id: `${pendingTurn.clientTurnId}:user`, @@ -469,8 +479,10 @@ export function ImageCanvas() { streaming: true, }); return messages; - }, [pendingTurn, workspace]); + }, [conversation, pendingTurn]); const taskWorkspaceId = workspace?.workspaceId ?? null; + const taskConversationId = conversation?.conversationId ?? null; + const submitting = submittingConversationId === taskConversationId; const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status)); useEffect(() => { @@ -478,10 +490,18 @@ export function ImageCanvas() { }, [authenticated, load, status]); useEffect(() => { - if (!taskWorkspaceId) return; + if (!taskWorkspaceId || !taskConversationId) return; connectTaskStream(); return disconnectTaskStream; - }, [connectTaskStream, disconnectTaskStream, taskWorkspaceId]); + }, [connectTaskStream, disconnectTaskStream, taskConversationId, taskWorkspaceId]); + + useEffect(() => { + setPrompt(''); + setActionError(null); + setConfirmingQuoteId(null); + setFirstFramePickerOpen(false); + setFirstFrameError(null); + }, [taskConversationId]); useEffect(() => { if (typeof conversationEndRef.current?.scrollIntoView === 'function') { @@ -490,27 +510,30 @@ export function ImageCanvas() { }, [conversationMessages.length, pendingTurn?.assistantText]); const handleConfirm = async (quoteId: string) => { - if (!workspace || confirmingQuoteId) return; + if (!workspace || !conversation || confirmingQuoteId) return; if (!generationAvailable) { setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE); return; } const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setConfirmingQuoteId(quoteId); setActionError(null); try { await confirmGeneration(quoteId); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return; + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId !== requestedWorkspaceId + || current.activeConversationId !== requestedConversationId) return; setActionError(error instanceof Error ? error.message : String(error)); } finally { - setConfirmingQuoteId(null); + setConfirmingQuoteId((current) => current === quoteId ? null : current); } }; const handleSend = async (override?: string) => { const message = (override ?? prompt).trim(); - if (!workspace || !message || submitting) return; + if (!workspace || !conversation || !message || submitting) return; if (isGenerationConfirmationIntent(message)) { setPrompt(''); if (quote && generationAvailable) { @@ -521,69 +544,85 @@ export function ImageCanvas() { return; } const requestedWorkspaceId = workspace.workspaceId; - setSubmitting(true); + const requestedConversationId = conversation.conversationId; + setSubmittingConversationId(requestedConversationId); setActionError(null); setPrompt(''); try { await sendMessage(message); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return; + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId !== requestedWorkspaceId + || current.activeConversationId !== requestedConversationId) return; setPrompt((current) => current.trim() ? current : message); setActionError(error instanceof Error ? error.message : String(error)); } finally { - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; const handleComposerKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { - event.preventDefault(); - void handleSend(); - } + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + void handleSend(); }; const handleFirstFrameSelect = async (asset: DesignAsset) => { - if (!workspace || submitting || firstFrameBusyKey) return; + if (!workspace || !conversation || submitting || firstFrameBusyKey) return; const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setFirstFrameBusyKey(asset.assetId); setFirstFrameError(null); setActionError(null); - setSubmitting(true); + setSubmittingConversationId(requestedConversationId); setFirstFramePickerOpen(false); try { await sendMessage('使用作品图片作为视频首帧', [asset.assetId]); } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setActionError(error instanceof Error ? error.message : String(error)); } } finally { setFirstFrameBusyKey(null); - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; const handleFirstFrameUpload = async (event: ChangeEvent) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; - if (!workspace || !file || submitting || firstFrameBusyKey) return; + if (!workspace || !conversation || !file || submitting || firstFrameBusyKey) return; const requestedWorkspaceId = workspace.workspaceId; + const requestedConversationId = conversation.conversationId; setFirstFrameBusyKey('upload'); setFirstFrameError(null); - setSubmitting(true); + setSubmittingConversationId(requestedConversationId); try { const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file); setUploadedFirstFrameAsset(asset); await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]); - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setFirstFramePickerOpen(false); } } catch (error) { - if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) { + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId === requestedWorkspaceId + && current.activeConversationId === requestedConversationId) { setFirstFrameError(error instanceof Error ? error.message : String(error)); } } finally { setFirstFrameBusyKey(null); - setSubmitting(false); + setSubmittingConversationId((current) => ( + current === requestedConversationId ? null : current + )); } }; @@ -643,7 +682,7 @@ export function ImageCanvas() { ); } - if (!workspace) { + if (!workspace || !conversation) { return (
@@ -668,25 +707,66 @@ export function ImageCanvas() {

- {workspace.brief.ready ? workspace.brief.summary : '设计 Agent 正在与你确认创作方向'} + {conversation.title} · {conversation.brief.ready + ? conversation.brief.summary + : '设计 Agent 正在与你确认创作方向'}

- +
+ + + +
@@ -819,7 +899,7 @@ export function ImageCanvas() { /> {actionError ?

{actionError}

: null}
- ⌘/Ctrl + Enter 发送 + Enter 发送 · Shift+Enter 换行