Compare commits

...
6 Commits
Author SHA1 Message Date
brother7 b723c7220b docs: record image preview integration and worktree recovery
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
2026-09-15 15:13:03 +08:00
brother7 9b64dd9b39 Merge commit '41664b2' 2026-09-15 15:08:16 +08:00
brother7 52305c18a4 docs: record agent tabs worktree cleanup failure 2026-09-15 15:00:02 +08:00
brother7 41664b2ae3 feat(canvas): preview generated task images in app 2026-09-15 14:58:21 +08:00
brother7 bc25f4d29e docs: integrate agent editor peer tabs 2026-09-15 14:56:49 +08:00
brother7 868ab54be6 fix(agents): make editor settings peer tabs 2026-09-15 14:50:06 +08:00
16 changed files with 621 additions and 26 deletions

No files matched your search

@@ -32,15 +32,16 @@ Accepted / implemented,2026-09-11 集成。产品范围、三端分工、创
## Desktop Workspace(2026-09-12 accepted / implemented)
- Agents 使用账号级全高桌面工作区,配置与试用独立滚动;分栏可调整、展开及恢复,小窗口切换配置/试用面板。基础要求优先展示,能力、知识、限制等高级配置可展开,正式对话和自动任务保留独立入口。
- Agents 使用账号级全高桌面工作区,配置与试用独立滚动;分栏可调整、展开及恢复,小窗口切换配置/试用面板。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认展示基础要求;能力、知识、限制位于高级设置内,正式对话和自动任务保留独立入口。
- 编辑草稿、已保存配置与当前试用修订分别持有状态。明确保存及页内切换保留未发送内容、附件、历史和未确认请求;最新草稿经用户明确选择后开始新试用并保留未发送文字。旧请求重试继续使用原请求身份及修订,不能因保存而重新创建收费请求。隐藏但继续运行的对话不提前标记已读或覆盖最近访问位置。
- 费用上限位于限制分类并独立保存,仍是服务端即时政策。首版不包含自动保存或离线草稿同步;退出工作区仍提示未完成输入。
- 实现源 `dca6deab292028c9f3cfd097a8c52ba0d43b35a9`,见[源任务](../30-worklog/tasks/20260912-agent-draft-design-9d82c9b4.md)。最终 65 项相关测试、类型检查、scoped ESLint(无错误)、Vite 构建与 Windows Electron 验收通过;覆盖大小窗口和 Electron 125%/150% 内容缩放。云端使用 Host API fixture,不等于真实收费链路或 macOS/OS DPI 验收。
## Guided First Helper(2026-09-14 accepted / implemented)
## Guided First Helper(2026-09-14 accepted / implemented,2026-09-15 amended)
- 智能体交互面向非专业用户和 8–15 岁学生;老师引导后由学生独立创建、试用和改进第一个助手。这是已确认的体验目标,不增加教师/学生权限或课堂管理模型。
- 创建使用用途选择、填写要求、保存试用的短引导。用途示例可编辑,也可自定义;基础要求包含名字、用途、回答方式和补充说明,高级能力/知识/限制按需展开。
- 创建使用用途选择、填写要求、保存试用的短引导。用途示例可编辑,也可自定义;基础要求包含名字、用途、回答方式和补充说明,高级能力/知识/限制通过“高级设置”Tab 进入。
- “我的要求”和“高级设置”采用明确选中态并支持方向键切换。首次进入高级设置显示能力,之后保留上次查看的高级分类;再次点击当前 Tab 不收起或重置内容。切换回到内容顶部,同时保留未保存的要求、资源搜索、知识表单、费用输入和当前试用;切换本身不保存或发送。实现源 `868ab54be6575ea6bd170d2ece7b7c8d4f6c0007`,验证见[源任务](../30-worklog/tasks/20260915-agent-editor-tabs-8c46f6f5.md)。
- `system_prompt` 仍是保存指令的唯一来源。回答方式只增删明确的自然语言指令行,其余自由文本原样保留。仅新建助手首次选择真实目录中的首个可用模型,用户可以修改;既有助手不自动换模型,不虚构推荐标记。
- 示例问题只填入输入框。“保存并重新试用”成功后使用保存返回的修订,保留输入文字或填回上一问题,不自动发送;旧试用可对比,文件仍按原会话下载。运行、排队、审批、附件或未确认操作阻止替换试用;单独保存继续保留当前会话。
- 执行确认展示实际动作和参数,并可展开完整详情;既有审批决策和操作身份保持。首次“自己使用”先确认可用状态与创建者付费,再调用既有发布操作并打开独立正式对话;分享/API/渠道及版本管理仍在后续入口提供。
+1 -1
View File
@@ -10,7 +10,7 @@
- [官方模型能力与原生思考选项](ADR-2026-09-12-official-model-capabilities.md):2026-09-12 accepted / implemented;one-api 官方事实、Works v2 授权投影、受管模型图片输入与 Main-owned reasoningChoice,取代受管模型的静态推测与 Pi 枚举映射。
- [个人云智能体平台](ADR-2026-09-11-personal-cloud-agents.md):2026-09-14 amended / implemented;三端分工、创建者付费及独立预算保留,桌面工作区增加面向非专业用户与 8–15 岁学生的创建引导、基础要求、明确保存后重新试用和首次自用确认。保存/试用版本与恢复身份分离,验证及发布边界见决策。
- [个人云智能体平台](ADR-2026-09-11-personal-cloud-agents.md):2026-09-15 amended / implemented;三端分工、创建者付费及独立预算保留,桌面工作区面向非专业用户与 8–15 岁学生提供创建引导、基础要求、明确保存后重新试用和首次自用确认。“我的要求”与“高级设置”采用双 Tab,保留高级分类与未保存输入。保存/试用版本与恢复身份分离,验证及发布边界见决策。
| ID | Decision | Status | Date | Applies To | Detail |
|---|---|---|---|---|---|
@@ -14,7 +14,7 @@
个人云智能体职责见 [已接受决策](../10-decisions/ADR-2026-09-11-personal-cloud-agents.md):Yuxi 拥有云执行,WS 拥有账号和词元点数,MakeLore Main 提供 Host API;Code/Pi、Canvas、Robot 保持既有体系。
Agents 的 Renderer 使用账号级桌面工作区:`DraftEditor` 分别持有编辑内容和已保存草稿,`CloudChat` 固定当前试用修订与请求身份,`DesktopSplit` 负责分栏和窗口适配。草稿保存不重建试用组件,页内隐藏会话保留运行状态但不写已读/最近位置;新试用由用户明确选择。费用上限经既有 Host API 独立保存,布局偏好仅保存尺寸,不充当离线内容存储。
Agents 的 Renderer 使用账号级桌面工作区:`DraftEditor` 分别持有编辑内容和已保存草稿,`CloudChat` 固定当前试用修订与请求身份,`DesktopSplit` 负责分栏和窗口适配。编辑器以“我的要求”与“高级设置”两个 Tab 控制同一持久挂载的表单,基础要求默认展示,高级分类单独记忆;切换回到内容顶部并保留局部表单及试用输入。草稿保存不重建试用组件,页内隐藏会话保留运行状态但不写已读/最近位置;新试用由用户明确选择。费用上限经既有 Host API 独立保存,布局偏好仅保存尺寸,不充当离线内容存储。
Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展示;Electron Main 持有认证、文件系统、安全打包、Works Square 网络访问和发布安全校验。
@@ -4,6 +4,10 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- 2026-09-15:任务 `20260915-merge-image-preview-56ab481e` 将图片预览源 `41664b2ae3b1139f7f00401e2aeb5b483319d29e` 无冲突合入本地 `main`,合并提交 `9b64dd9b3917999fd1f10683ee78ac1746de6f81`。AI 设计任务图片新增放大查看,支持完整适配窗口、原始尺寸滚动、关闭/Esc 和焦点返回,桌面与紧凑布局共用且不触发下载;Main-owned Asset 契约保持。合并后的类型检查、31 项相关单测、Vite 全目标构建和 Windows Electron 交互验收通过;产品及源任务记录与已验证源一致,三份原有未跟踪文档原样保留。用户授权的旧 Agent-tabs 残留目录已改名 `.retained` 保留,并用 bundled scripts 恢复登记。未推送、打包或安装;详见[集成记录](tasks/20260915-merge-image-preview-56ab481e.md)。
- 2026-09-15:任务 `20260915-merge-agent-tabs-2fd41184` 经用户确认登记移交,将源 `868ab54be6575ea6bd170d2ece7b7c8d4f6c0007` 无冲突快进合入本地 `main`。智能体编辑区改为“我的要求”与“高级设置”双 Tab,选中态与方向键切换明确,高级分类记忆、切换回顶、未保存输入及试用状态保留。产品树与已验证源一致,沿用 47 项相关单测、类型检查、scoped lint、Vite 全目标构建及 Electron 交互验收,详见[源任务](tasks/20260915-agent-editor-tabs-8c46f6f5.md)。源记录原样导入,三份原有未跟踪文档保留;未推送、打包或安装客户端。
- 2026-09-15:任务 `20260915-merge-knowledge-status-ml-7a69bec0` 经用户确认登记移交,将已独立审查源 `fee2f4c` 无冲突快进合入本地 `main`。知识文档上传、导入与替换自动衔接解析索引,明确阶段、真实可用性和失败恢复;每 5 秒串行刷新已加载页面,旧可用内容在替换清理重试时保留。沿用源 49 项相关单测、类型检查、scoped lint、Vite 全目标构建、真实 Electron E2E 1 passed,以及独立 Reviewer Standards/Spec PASS。全量唯一 Pi runtime v6/v7 旧断言失败与本改动无关,详见[源任务](tasks/20260915-knowledge-status-ml-3065956f.md)。产品树与已审查源一致;原有三份文档保留,未推送、打包或部署。需配套更新 Yuxi API/worker 与客户端后验收实际上传。
- 2026-09-15:任务 `20260915-merge-windows-titlebar-bde0505c` 经用户授权移交主目录登记,将修复源 `dae64e52b94f54c61b14d744ef6e6d0580af2d3a` 无冲突快进合入本地 `main`。Windows Code 对话标题栏按共用的 148 px 窗口按钮与 96 px Logo 尺寸限制可交互区域,浏览器、中止和恢复按钮保持在 Logo 左侧。产品树与已验证源一致,沿用 42 项相关单测、类型检查、scoped lint、Vite 全目标构建及 Electron 标题栏/Code 交互验证;覆盖最小 1024 px 窗口、125% 缩放和窗口最大化/还原。详见[源任务](tasks/20260915-windows-logo-overlap-5106511f.md)。原有三份未跟踪文档保持,源记录原样导入;未推送、打包或安装客户端。
@@ -0,0 +1,51 @@
# Task: 核对我的要求与高级设置的切换逻辑
## Identity
- Task ID: 20260915-agent-editor-tabs-8c46f6f5
- Mode: Feature
- Branch: codex/20260915-agent-editor-tabs-8c46f6f5-agent-editor-tabs
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-agent-editor-tabs-8c46f6f5
- Base commit: 80e52496d2696c3b579c911ee7c07f9588b368a6
- Owner: codex
- Status: Ready for Integration
## Scope
- Implement the user-approved peer “我的要求” / “高级设置” tabs in the personal cloud Agent editor.
- Own DraftEditor, focused unit/Electron regressions, README and this task record. No Main, API, persistence or billing changes.
## Intent And Constraints
- Source inspection established that the former advanced control only revealed category navigation and reset the content to instructions. The user accepted replacing it with a peer tab.
- Concurrent Task Gate passed through bundled check/start/status, first isolating from occupied main, then resuming this same task. Feature identity, absolute worktree, branch and recorded base match the ownership registry. No subagents were created.
- Planning Gate passed. Read the entry instructions, active record, memory index, positioning, integrated current state, decision index, system overview, planning gate, business rules, success criteria, personal cloud Agent ADR and guided UX source record.
- Project Context Loaded: Main-owned Electron product; integrated focus includes guided Agent requirements and knowledge processing. This task changes only local editor presentation. Relevant files are DraftEditor.tsx, cloud-agents-page.test.tsx, cloud-agents.spec.ts and README.md. No additional evidence, reflection or commitment dependency applies.
- Peer inspection used registered task records only. Guided UX and knowledge changes overlap in domain but are integrated; remaining channel diagnosis and historical reviews do not own this tab decision. Historical placeholder records and template positioning remain unknown coordination state, with no concrete dependency on this implementation.
- Preserve basic requirements as the default, saved/preview/published separation, unfinished requirements/resources/knowledge/budget input, independent budget saving and current preview. Use the existing light-system Tabs component.
- The user's approval supersedes the prior advanced-disclosure interaction for this scope; accepted canonical memory remains read-only in feature mode.
## Outcome
- Existing Radix-based Tabs provide peer selection, associated panel semantics, keyboard navigation and a grouped, clearly highlighted selection. Advanced settings initially opens capabilities; capability/knowledge/limit selection is remembered across top-level tab switches. Selecting the active tab does not collapse or reset it.
- One shared panel and ConfigurationFields instance remain mounted. Switching changes the visible category without discarding resource search, knowledge creation, independent budget edits, basic requirements or preview input; switching itself does not save or send.
- Changing the top-level tab resets the shared form scroll to the top so the previous category's scroll offset does not hide the new form heading. Category selection and entered values still remain.
- README describes the final peer-tab behavior. Focused unit and Electron specs cover selection, defaults, repeated selection, remembered categories, hidden navigation, keyboard movement, scroll reset, preserved inputs and existing save/preview flows.
## Verification
- pnpm 10.33.4 matches packageManager; frozen-lockfile install passed without dependency changes.
- Typecheck and scoped ESLint passed; production build:vite passed for Renderer, Main, Preload and utility. Repeated these checks after adding the scroll reset; the final Electron run also passed (1 test, 24.7 seconds), including the explicit real-scroll reset assertions.
- Four focused unit files: 46/47 initially passed; the new test used a string expectation for a numeric input, corrected to numeric 12.5. The affected page file then passed all 19 tests. Combined final focused coverage: 47 tests (19 page + 28 unchanged guidance/workflows/management).
- First Electron cloud-agents.spec.ts run passed with the real desktop shell and test-owned Host API fixture, including arrow-key switching, category memory and preservation of knowledge/budget input. Existing wide/narrow windows, 125%/150% zoom, save/retrial and independent costs/schedules paths passed.
- Visually inspected the advanced and requirements tab screenshots. Both use the existing light palette and grouped selection; the advanced view hides the requirements form.
- Working and committed changes were inspected from the recorded base; only the four intended product/document/test paths and this task record changed. Bundled documentation drift and whitespace checks passed.
- No full repository suite or live cloud/provider requests were needed for this local navigation change. Production deployment, installation and main integration are outside this task's result.
## Follow-ups
- Integrate this task branch through the normal Integration Gate; rebuild/install the client for the currently installed application to receive the change.
## Promotion Candidates
- Target: ADR-2026-09-11-personal-cloud-agents.md Desktop Workspace / Guided First Helper and canonical Agent UI description. Proposal: replace “advanced configuration expands below requirements” with two peer editor tabs; preserve basic-default guidance, advanced category memory, unsaved input and preview separation. Evidence: explicit user acceptance, source implementation, focused unit and Electron regressions. Future impact: later UI work should preserve this navigation model. Semantic conflict: replaces the earlier disclosure description only; Main/cloud/data semantics stay unchanged. Human confirmation: interaction change already approved, no additional product decision needed during normal integration.
@@ -0,0 +1,51 @@
# Task: Add enlarged image preview to AI Design task results
## Identity
- Task ID: 20260915-design-image-preview-b24997a6
- Mode: Feature
- Branch: codex/20260915-design-image-preview-b24997a6-design-image-preview
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-design-image-preview-b24997a6
- Base commit: 80e52496d2696c3b579c911ee7c07f9588b368a6
- Owner: codex
- Status: Ready for Integration
## Scope
- Add an in-app enlarged image viewer to generated images in AI Design task history, on desktop and compact layouts. Preserve the existing download action.
- Own DesignPlanHistory, its new image-preview component, focused unit/Electron coverage, README, and this record.
## Intent And Constraints
- Concurrent Gate Passed: bundled check/start succeeded and status confirmed the exact task, feature mode, branch, worktree and base above. Main contains three pre-existing untracked task records, so work is isolated from main.
- Planning Gate Passed: read agent entry, own task, memory index, positioning, integrated state, decision index, architecture, domain/success criteria, ADR-007, evidence/reflection/commitment/stale indexes and peer task scopes. Most registered peers are completed packaging/integration or historical reviews; active known work is unrelated, except an old Enter-key E2E test in a retired file. Placeholder peer scopes remain unknown, with no concrete semantic dependency on this feature.
- Positioning contains placeholders; use README, executable code and accepted V2 rules. Asset URL resolution remains through the existing Main-owned Host API helper. No contract, generation, billing, provider, persistence or global theme change.
- User prohibits subagents without consent; implementation and verification are performed directly.
- Implement a persistent enlarge icon beside download, a light modal that fits the complete image, optional native-size scrolling, load/error state, and accessible close/Escape behavior. Reset preview across Workspace switches.
- Verify selected-asset correctness, absence of download side effects, failures and dismissal with focused tests; verify rendered dimensions and desktop/compact interaction in Electron, then typecheck/lint/build for renderer integration.
## Outcome
- Generated image thumbnails now expose a persistent enlarge button beside the existing download button in task history. Desktop rail and compact conversation history share the same implementation.
- The light in-app dialog uses the existing canonical Asset content URL, initially fits the complete image, and supports native-size scrolling, dimensions, loading/error feedback, close button, Escape, and focus restoration. Viewing never calls the download endpoint. Video actions remain unchanged.
- Workspace changes remount history content to dismiss the old preview; reopening resets the viewer and permits retry after a load error. README reflects the new behavior.
- Feature complete in the isolated task branch; no main merge, push, package or installed-client update performed.
## Verification
- Exact pinned pnpm 10.33.4; frozen-lockfile install succeeded without lockfile changes.
- `pnpm run typecheck`: passed.
- `pnpm exec vitest run tests/unit/design-image-preview.test.tsx tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-api.test.ts --maxWorkers=4`: 31 passed, including five new preview cases. Covers selected result, image-only action, fit/native mode, reopening, close/Escape/focus, independent download, URL/image failures, and Workspace changes.
- Scoped ESLint over both changed product files and both test files: passed with no warnings or errors.
- `pnpm run build:vite`: all Renderer/Main/Preload/utility targets passed. Existing Browserslist age, chunk-size and mixed-import warnings remain; none prevented compilation.
- `pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts -g 'keeps conversation'`: 1 passed on Windows Electron. Existing V2 edit/confirmation flow remains covered; completed task preview verifies a 1600×2400 fixture, fit behavior, exact native dimensions and scrolling, Escape/focus restoration, 375px compact bounds and close action, with zero download requests.
- Electron uses deterministic local Host API/media fixtures; no paid generation or production asset access is claimed. The full unrelated unit suite was not run.
- Task Documentation Gate: reviewed changes against recorded base; `git diff --check` and the bundled task-aware documentation drift check passed.
## Follow-ups
- Integrate this feature branch and rebuild the desktop client to deliver the button to installed users.
## Promotion Candidates
- None. This is a local presentation improvement within the accepted V2 Asset and Main-network boundaries; README documents the user-visible behavior without changing canonical architecture or domain rules.
@@ -0,0 +1,42 @@
# Task: 合并智能体编辑设置双 Tab
## Identity
- Task ID: 20260915-merge-agent-tabs-2fd41184
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: 80e52496d2696c3b579c911ee7c07f9588b368a6
- Owner: codex
- Status: Ready for Integration
## Scope
- 将源提交 `868ab54be6575ea6bd170d2ece7b7c8d4f6c0007` 快进合入本地 main,并晋升已获用户同意的双 Tab 交互说明。
## Intent And Constraints
- Concurrent / Planning Gates Passed:官方 check/start/status 匹配本 integration 任务、main、主目录及记录基线,独占集成锁已取得。
- 初次 start 被旧 integration 登记阻止。用户随后明确确认移交任务 `20260915-merge-knowledge-status-ml-7a69bec0` 占用并原样保留三份历史文档;通过 bundled release --force 和 start --adopt-existing 完成登记移交。
- 已加载入口、任务记录、项目定位、集成状态、决策、架构、领域规则、成功标准、源任务及 peer Scope/Intent/Promotion。历史模板记录为未知协调状态,未发现本次具体语义冲突;源分支的验证与用户批准支持此集成。
- 源任务记录保持只读;仅更新本任务记录及相关 canonical 说明。无子智能体、推送、打包或安装操作。
## Outcome
- 已将 main 从 `80e52496d2696c3b579c911ee7c07f9588b368a6` 无冲突快进到 `868ab54be6575ea6bd170d2ece7b7c8d4f6c0007`,产品改动与源一致。
- 已更新个人云智能体 ADR、决策索引、系统概览与 current-state,明确两个 Tab、默认基础要求、高级分类记忆、切换回顶、输入与当前试用保留。草稿、预览、费用保存与 Main/cloud 合同保持。
- 主目录原有三份未跟踪文档保留。用户已明确批准清理源 feature 工作区;从 main 通过 bundled retire 执行,但 Git 返回 Directory not empty。源 checkout 的 Git 登记及 .git 已移除,部分源码、依赖与报告仍残留,task registry 因命令失败而保留;分支和完整提交仍在,合并不受影响。遵守技能要求,未绕过 retire 强制删除。
## Verification
- 源验证已完成:47 项相关单测、类型检查、scoped ESLint、Renderer/Main/Preload/utility 生产构建、Electron 用例通过;涵盖键盘、表单状态、滚动、小窗口及缩放。此集成仅快进及 canonical 文档修改,复用源验证。
- 集成验证全部通过:产品树及源任务记录与源提交直接比对一致;三份旧文档原始字节保持;git diff --check、官方 check_project_docs.py 和任务感知 check_doc_drift.py 通过。源工作区干净,HEAD 为已合入 main 的最新源结果。
## Follow-ups
- 已授权的工作区清理受 Git Directory not empty 错误阻断:`D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-agent-editor-tabs-8c46f6f5`。保留残留目录及任务登记;分支 `codex/20260915-agent-editor-tabs-8c46f6f5-agent-editor-tabs` 仍指向 `868ab54`。当前目录存在但已不是完整 Git checkout,原 retire 前提不再成立,需要受支持的失败恢复流程;本次未擅自修复登记或绕过 retire。
- 第一次被旧 integration 锁拒绝的 start 留下干净、未登记的临时检出 `D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-integrate-agent-tabs-c297c6b1`(同名 codex 分支,基于 `80e5249`)。此次清理授权仅覆盖源 feature 工作区,故保留这个检出并如实记录;未将其伪装为成功登记任务。
## Promotion Candidates
- 源任务的双 Tab 说明已在本次 integration 晋升;用户已经确认交互方向和合并,无未决语义冲突。
@@ -0,0 +1,49 @@
# Task: Merge AI Design task image preview into main
## Identity
- Task ID: 20260915-merge-image-preview-56ab481e
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: 52305c18a4be87fbc84991e4159feb328e5662a1
- Owner: codex
- Status: Ready for Integration
## Scope
- Merge the completed AI Design task-image preview source `41664b2` into local main, retaining the already integrated Agent editor tabs and all unrelated files.
- Recover the user-approved stale Agent-tabs registration without deleting residual files; document this integration in the owned task record and current-state.
## Intent And Constraints
- Concurrent Gate Passed after explicit human recovery consent: renamed the invalid old checkout to `D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-agent-editor-tabs-8c46f6f5.retained`, removed its exact stale registry via bundled `release-missing --force`, released completed main owner `20260915-merge-agent-tabs-2fd41184`, and claimed this integration with `start --adopt-existing`. Status matches this task, main, absolute worktree, baseline and integration mode.
- Three pre-existing untracked task records (package-122, package-123 and client-hang-diagnosis) were explicitly adopted for unchanged preservation; their original bytes are retained for direct comparison. No task source records are editable.
- Planning Gate Passed: reused the loaded skill, memory/positioning, decisions/ADR-007, architecture, domain/success criteria and evidence/reflection/commitment context; read the new integration record, latest current-state and source record, and checked updated peer scopes. Known active peers are unrelated; historical placeholder scopes remain unknown with no concrete dependency. The recovered peer failure is no longer a registry blocker.
- Preview remains presentation-only under the existing Main-owned V2 Asset URL helper. No business or architecture decision changes; source has no canonical promotion candidates.
- Merge preflight is conflict-free. Merge main and the exact source, verify the combined result and original-file preservation, record the outcome, then offer cleanup only for this newly integrated source worktree. No push, packaging, deployment or unapproved cleanup.
## Outcome
- Merged source `41664b2ae3b1139f7f00401e2aeb5b483319d29e` into main without conflict as `9b64dd9b3917999fd1f10683ee78ac1746de6f81`. README combines the prior Agent-tab text and the new image-preview text; both product changes are retained.
- Source image-preview product/tests and source task record match the validated source exactly. Generated images can be enlarged inside desktop/compact task history, fit or shown at original size, and dismissed without downloading.
- Restored registry health using the explicitly approved residual-directory retention and bundled release operations. Prior Agent-tabs residual files remain at the `.retained` path above; the old branch and all commits remain available.
- Three pre-existing untracked documents remain byte-for-byte unchanged. No push, package, deployment or installed-client update performed.
- User subsequently approved source-worktree cleanup. The bundled `retire` passed eligibility checks but Git unlinked the checkout and returned `Directory not empty`, leaving residual files and the skill registry. Confirmed `.git` and Git worktree registration were gone, and the branch still pointed at the merged source. A bounded native residual-delete command was rejected before execution by automatic approval review (`blocked by policy`, no further reason). No alternate deletion method was attempted. Retained the residual directory as `D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-design-image-preview-b24997a6.retained`, then used bundled `release-missing --force` to clear the now-missing original-path registry. Branch/commits remain; residual disk cleanup is incomplete.
## Verification
- Merge-tree preflight succeeded; actual committed merge tree exactly matches the preflight tree. Exact source product/test/task-record comparison is unchanged.
- Combined main: `pnpm run typecheck` passed; focused preview/Canvas/API Vitest run passed 31 tests; `pnpm run build:vite` passed all targets with existing build warnings; targeted Windows Electron V2 confirmation/preview case passed, including native 1600×2400 scrolling and 375px layout.
- Source scoped lint remains applicable because the relevant files were imported unchanged. Original document contents were compared directly before and after merging and match exactly.
- Before approved retirement, source worktree was clean, registry status was ready_for_integration/managed, HEAD was the latest source `41664b2`, and that exact source was an ancestor of actual main. Branch/commit preservation was rechecked after the partial Git removal.
## Follow-ups
- Source residual files are retained at `D:\Datas\OthersProjects\.codex-worktrees\makelore\20260915-design-image-preview-b24997a6.retained` because automatic approval review blocked deletion. The old worktree path and task registry are released; the feature branch and commits remain. No repeated deletion or cleanup approval request in this continuation.
- Rebuild/install a desktop package when the feature should reach installed clients.
## Promotion Candidates
- No new architecture/domain promotion. Current-state records the completed integration; README is synchronized with visible image-preview behavior.
- Workflow candidate for the external maintain-project-docs skill: add an explicit supported recovery for Windows `git worktree remove` partially unlinking a checkout before failing directory removal. Both Agent-tabs cleanup and this source cleanup exhibited it; an invalid worktree registry then blocks every `start`. Preserve residual data and require the existing human recovery/cleanup authority; do not automatically simulate ownership. This candidate is documented only and no external skill code was changed.
+2 -2
View File
@@ -7,13 +7,13 @@
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页:
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。能力、知识与限制折叠到高级设置。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。
- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认显示基础要求;高级设置包含能力、知识与限制,返回时保留上次分类,切换保留未保存的输入及当前试用。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。Agent 的「使用与分享」只展示已关联渠道并提供定位入口。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。用户与应用分享不属于微信渠道账号管理范围。
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
+21 -10
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Check, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent, type CloudThread, type CloudScheduleProposal } from '../../../shared/cloud-agents';
@@ -18,7 +19,7 @@ import { AgentRequirements } from './AgentRequirements';
import { agentExamples } from './agent-guidance';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type Category = 'instructions' | 'capabilities' | 'knowledge' | 'limits';
type AdvancedCategory = 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
@@ -29,7 +30,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(() => startingPrompt === undefined ? initial : { ...initial, system_prompt: startingPrompt });
const [advanced, setAdvanced] = useState(false);
const [editorTab, setEditorTab] = useState('instructions');
const [advancedCategory, setAdvancedCategory] = useState<AdvancedCategory>('capabilities');
const [previewActivation, setPreviewActivation] = useState(0);
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
@@ -39,7 +41,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
const [tab, setTab] = useState<Tab>(initialTab);
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
const [category, setCategory] = useState<Category>('instructions');
const category = editorTab === 'instructions' ? 'instructions' : advancedCategory;
const [openedThread, setOpenedThread] = useState<CloudThread>();
const [conversationSource, setConversationSource] = useState<'desktop' | 'wechat'>('desktop');
const [wechatVisited, setWechatVisited] = useState(false);
@@ -49,6 +51,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
const hasPending = pending.hasPending || formalPending.hasPending;
const alive = useRef(true);
const saving = useRef(false);
const configurationScroll = useRef<HTMLDivElement>(null);
const dirty = hasDraftChanges(draft, saved);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const navigate = (next: Tab) => { setVisited(current => new Set([...current, next])); setTab(next); };
@@ -128,14 +131,21 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
</div>}
<div hidden={tab !== 'configuration'} className="agent-workspace-view">
{visited.has('configuration') && <DesktopSplit previewActivation={previewActivation} configuration={<>
{visited.has('configuration') && <DesktopSplit previewActivation={previewActivation} configuration={<Tabs value={editorTab} onValueChange={next => {
setEditorTab(next);
if (configurationScroll.current) configurationScroll.current.scrollTop = 0;
}} className="flex min-h-0 flex-1 flex-col">
<div className="agent-config-tabs">
<Button variant={category === 'instructions' ? 'secondary' : 'ghost'} onClick={() => setCategory('instructions')}>我的要求</Button>
<Button variant="ghost" aria-expanded={advanced} onClick={() => { setAdvanced(!advanced); setCategory('instructions'); }}>高级设置</Button>
<TabsList aria-label="编辑设置" className="h-auto w-full justify-start">
<TabsTrigger value="instructions" className="flex-1 data-[state=active]:text-primary">我的要求</TabsTrigger>
<TabsTrigger value="advanced" className="flex-1 data-[state=active]:text-primary">高级设置</TabsTrigger>
</TabsList>
</div>
{advanced && <nav aria-label="配置分类" className="agent-config-tabs">{([['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
<Button key={key} variant={category === key ? 'secondary' : 'ghost'} aria-current={category === key ? 'page' : undefined} onClick={() => setCategory(key)}>{label}</Button>)}</nav>}
<div className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
{/* Keep the shared form mounted so switching tabs preserves unfinished resource and budget edits. */}
<TabsContent value={editorTab} className="m-0 flex min-h-0 flex-1 flex-col">
{editorTab === 'advanced' && <nav aria-label="配置分类" className="agent-config-tabs pt-2">{([['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
<Button key={key} variant={advancedCategory === key ? 'secondary' : 'ghost'} aria-current={advancedCategory === key ? 'page' : undefined} onClick={() => setAdvancedCategory(key)}>{label}</Button>)}</nav>}
<div ref={configurationScroll} className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
<div hidden={category !== 'instructions'} className="mb-6 space-y-6">
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea aria-label="用途" className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
@@ -144,7 +154,8 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
<ConfigurationFields slug={saved.slug} section={category} chooseInitialModel={startingPrompt !== undefined} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</fieldset></div>
</>} preview={expandAction => <>
</TabsContent>
</Tabs>} preview={expandAction => <>
{!saved.configuration?.model && !previewCreated ? <div className="my-auto p-6 text-sm leading-7">{expandAction}<h2 className="font-medium">试试它是否懂你</h2>
<p className="mt-2 text-muted-foreground">看看你填好的要求,准备好后就能和它聊两句。</p>
<Button className="mt-4 min-h-10" disabled={!canSave || !draft.configuration?.model} onClick={async () => { if (await save() !== undefined) setPreviewActivation(v => v + 1); }}>保存并开始试用</Button>
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { ImageIcon, Loader2, Maximize2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
import type { DesignAsset } from '../../../shared/image-workspace';
function ImagePreviewContent({ asset }: { asset: DesignAsset }) {
const [source, setSource] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const [originalSize, setOriginalSize] = useState(false);
useEffect(() => {
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => { if (active) setSource(url); })
.catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, [asset.contentPath]);
return (
<>
<header className="flex items-center justify-between gap-3 border-b border-border/70 px-4 py-3">
<div className="min-w-0">
<DialogTitle className="text-base">图片预览</DialogTitle>
<DialogDescription className="mt-1 text-xs">
{asset.width} × {asset.height}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!loaded || failed}
onClick={() => setOriginalSize((value) => !value)}
>
{originalSize ? '适应窗口' : '原始尺寸'}
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon" aria-label="关闭图片预览">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</DialogClose>
</div>
</header>
<div className="relative min-h-0 overflow-auto bg-surface-subtle/50" data-testid="design-image-preview-viewport">
{failed ? (
<div role="alert" className="flex h-full flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground">
<ImageIcon className="h-8 w-8" aria-hidden="true" />
图片暂时无法加载,请关闭后重试。
</div>
) : (
<>
{!loaded && (
<div role="status" className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
正在加载图片…
</div>
)}
{source && (
<img
src={source}
alt="AI 作品大图"
className={originalSize ? 'block max-w-none' : 'h-full w-full object-contain'}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
)}
</>
)}
</div>
</>
);
}
export function DesignImagePreviewButton({ asset }: { asset: DesignAsset }) {
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
aria-label="放大查看图片"
title="放大查看图片"
className="absolute bottom-1 left-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</DialogTrigger>
<DialogContent className="h-[85vh] max-w-[min(94vw,100rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
<ImagePreviewContent key={asset.contentPath} asset={asset} />
</DialogContent>
</Dialog>
);
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { DesignImagePreviewButton } from './DesignImagePreview';
import { youthTaskIssueMessage } from './youth-issue-copy';
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
@@ -41,7 +42,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
if (tasks.length === 0) return null;
return (
<div className="space-y-2" data-testid="design-plan-history">
<div key={workspace.workspace.workspaceId} className="space-y-2" data-testid="design-plan-history">
{tasks.map((task) => {
const resultAssets = task.resultAssetIds
.map((assetId) => workspace.assets.find((asset) => asset.assetId === assetId) ?? null)
@@ -111,6 +112,7 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
{resultAssets.map((asset) => (
<div key={asset.assetId} className="group/result relative">
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
{asset.mediaType === 'image' && <DesignImagePreviewButton asset={asset} />}
<button
type="button"
aria-label="保存作品到电脑"
+38 -3
View File
@@ -117,7 +117,29 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByRole('button', { name: '保存并开始试用', exact: true }).click();
await expect(page.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
await expect(page.getByTestId('titlebar-page-title')).toHaveText('智能体工作区');
await page.getByRole('button', { name: '高级设置', exact: true }).click();
const requirementsTab = page.getByRole('tab', { name: '我的要求', exact: true });
const advancedTab = page.getByRole('tab', { name: '高级设置', exact: true });
await expect(requirementsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('navigation', { name: '配置分类' })).toHaveCount(0);
await requirementsTab.focus();
await requirementsTab.press('ArrowRight');
await expect(advancedTab).toBeFocused();
await expect(advancedTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: '高级设置' })).toBeVisible();
await expect(page.getByLabel('特别要求')).toBeHidden();
await expect(page.getByRole('button', { name: '能力', exact: true })).toHaveAttribute('aria-current', 'page');
await advancedTab.click();
await expect(advancedTab).toHaveAttribute('aria-selected', 'true');
await page.screenshot({ path: testInfo.outputPath('cloud-agent-advanced-tab.png') });
const configurationScroll = page.getByRole('tabpanel').locator('.agent-config-scroll');
await configurationScroll.evaluate(el => { el.scrollTop = el.scrollHeight; });
await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBeGreaterThan(0);
await requirementsTab.click();
await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBe(0);
await expect(page.getByLabel('用途', { exact: true })).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath('cloud-agent-requirements-tab.png') });
await advancedTab.click();
await expect.poll(() => configurationScroll.evaluate(el => el.scrollTop)).toBe(0);
await page.getByRole('button', { name: '知识', exact: true }).click();
await page.getByText('管理我的知识文档', { exact: true }).click();
const knowledgeDescription = page.getByText(/上传到知识库的文档可供/);
@@ -131,6 +153,12 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await expect(page.getByText(/Yuxi 后台/)).toContainText('服务地址和 API Key');
await expect(page.getByText(/Works Square 运营后台|one-api/)).toHaveCount(0);
await page.getByLabel('知识库名称', { exact: true }).fill('我的创作资料');
await requirementsTab.click();
await expect(page.getByRole('navigation', { name: '配置分类' })).toHaveCount(0);
await expect(page.getByLabel('特别要求')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。');
await advancedTab.click();
await expect(page.getByRole('button', { name: '知识', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByLabel('知识库名称', { exact: true })).toHaveValue('我的创作资料');
await page.screenshot({ path: testInfo.outputPath('knowledge-empty.png') });
await page.getByRole('button', { name: '刷新知识库与模型', exact: true }).click();
await page.getByLabel('向量模型', { exact: true }).selectOption('fixture:test-embedding');
@@ -152,7 +180,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await document.scrollIntoViewIfNeeded();
expect(await document.evaluate(el => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath('knowledge-ready.png') });
await page.getByRole('button', { name: '我的要求', exact: true }).click();
await page.getByRole('tab', { name: '我的要求', exact: true }).click();
const preview = page.getByRole('region', { name: '草稿预览', exact: true });
await preview.getByRole('button', { name: '保存并重新试用', exact: true }).click();
await expect(preview.getByText('当前试用 · 修订 3')).toBeVisible();
@@ -225,10 +253,17 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
await page.getByTestId('ai-module-option-cloud_agents').click();
await expect(page.getByTestId('cloud-agent-editor')).toBeVisible();
await expect(page.getByLabel('特别要求')).toHaveValue('使用简洁自然的中文。先理解我的目的,再协助梳理结构。补充具体例子。');
await page.getByRole('button', { name: '高级设置', exact: true }).click();
await page.getByRole('tab', { name: '高级设置', exact: true }).click();
await page.getByRole('button', { name: '限制', exact: true }).click();
await page.getByLabel('每次任务上限(词元点数)').fill('2.50');
await page.getByLabel('每日上限(词元点数)').fill('10.00');
await advancedTab.press('ArrowLeft');
await expect(requirementsTab).toBeFocused();
await expect(requirementsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.getByLabel('每日上限(词元点数)')).toBeHidden();
await requirementsTab.press('ArrowRight');
await expect(page.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByLabel('每日上限(词元点数)')).toHaveValue('10.00');
await page.getByRole('button', { name: '保存费用上限' }).click();
await expect(page.getByRole('button', { name: '保存费用上限' })).toBeDisabled();
await page.getByRole('button', { name: '使用与分享', exact: true }).click();
+73 -1
View File
@@ -145,7 +145,7 @@ test.describe('AI Design V2 workspace', () => {
assistantMessage: '明白了:你想做一张适合小红书的便携咖啡机图片。',
}],
tasks: [] as Array<Record<string, unknown>>,
assets: [],
assets: [] as Array<Record<string, unknown>>,
};
const respond = (json: unknown) => ({
ok: true,
@@ -162,9 +162,29 @@ test.describe('AI Design V2 workspace', () => {
const e2eGlobal = globalThis as typeof globalThis & {
__designE2eAuthReady?: boolean;
__designE2eProfileReady?: boolean;
__designE2eCompleteTask?: () => void;
__designE2eDownloadCount?: number;
};
e2eGlobal.__designE2eAuthReady = false;
e2eGlobal.__designE2eProfileReady = false;
e2eGlobal.__designE2eDownloadCount = 0;
e2eGlobal.__designE2eCompleteTask = () => {
workspace.tasks[0] = {
...workspace.tasks[0],
status: 'succeeded',
taskRevision: 2,
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
resultAssetIds: ['result-1'],
nextActions: ['view_result'],
};
workspace.assets = [{
assetId: 'result-1', workspaceId: 'workspace-1', role: 'generated',
mediaType: 'image', mimeType: 'image/svg+xml', width: 1600, height: 2400,
durationMilliseconds: null, generationTaskId: 'task-1',
createdAt: '2026-08-30T10:02:00.000Z',
contentPath: '/api/works/image-workspace/workspaces/workspace-1/assets/result-1/content',
}];
};
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: {
@@ -173,6 +193,10 @@ test.describe('AI Design V2 workspace', () => {
body?: string | null;
}) => {
const path = request.path ?? '';
if (path.endsWith('/assets/result-1/download')) {
e2eGlobal.__designE2eDownloadCount! += 1;
return envelope({ status: 'saved' });
}
if (path === '/api/auth/session/sync') {
return respond({ success: true, session: e2eSession });
}
@@ -465,6 +489,54 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByText('等待制作')).toBeVisible();
await expect(page.getByTestId('youth-creation-card')).toHaveCount(0);
await expect(page.getByText('1200', { exact: true })).toHaveCount(0);
// Complete the existing task without a paid provider or native download.
await page.route('**/assets/result-1/content?*', (route) => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="2400"><rect width="1600" height="2400" fill="#f4efe5"/><rect x="80" y="80" width="1440" height="2240" rx="80" fill="#d6e5dc"/><circle cx="800" cy="1050" r="450" fill="#47665b"/><text x="800" y="1850" text-anchor="middle" font-size="120" fill="#203b30">DESIGN PREVIEW</text></svg>',
}));
await app.evaluate(() => {
(globalThis as typeof globalThis & { __designE2eCompleteTask?: () => void }).__designE2eCompleteTask?.();
});
await page.reload();
const historyTask = page.getByTestId('design-plan-history-task-1');
await expect(historyTask).toContainText('已完成');
await historyTask.locator('summary').click();
const previewButton = historyTask.getByRole('button', { name: '放大查看图片' });
await previewButton.click();
const previewDialog = page.getByRole('dialog', { name: '图片预览' });
const previewImage = previewDialog.getByRole('img', { name: 'AI 作品大图' });
await expect(previewDialog).toBeVisible();
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
expect(await previewImage.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1600);
await expect(previewImage).toHaveCSS('object-fit', 'contain');
const previewBounds = await previewDialog.boundingBox();
expect(previewBounds!.height).toBeGreaterThan(400);
expect(previewBounds!.width).toBeGreaterThan(800);
await previewDialog.getByRole('button', { name: '原始尺寸' }).click();
expect((await previewImage.boundingBox())!.width).toBe(1600);
expect((await previewImage.boundingBox())!.height).toBe(2400);
const scrollArea = previewDialog.getByTestId('design-image-preview-viewport');
expect(await scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
await previewDialog.getByRole('button', { name: '适应窗口' }).click();
await page.keyboard.press('Escape');
await expect(previewDialog).toHaveCount(0);
await expect(previewButton).toBeFocused();
await page.setViewportSize({ width: 375, height: 800 });
const compactTask = page.getByTestId('image-workspace-conversation').getByTestId('design-plan-history-task-1');
await compactTask.locator('summary').click();
await compactTask.getByRole('button', { name: '放大查看图片' }).click();
await expect(previewDialog.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
const compactBounds = await previewDialog.boundingBox();
expect(compactBounds!.x).toBeGreaterThanOrEqual(0);
expect(compactBounds!.x + compactBounds!.width).toBeLessThanOrEqual(375);
await previewDialog.getByRole('button', { name: '关闭图片预览' }).click();
await expect(previewDialog).toHaveCount(0);
expect(await app.evaluate(() => (
globalThis as typeof globalThis & { __designE2eDownloadCount?: number }
).__designE2eDownloadCount)).toBe(0);
} finally {
await closeElectronApp(app);
}
+52 -4
View File
@@ -308,6 +308,51 @@ it('keeps unsent preview text when configuration changes and waits before switch
});
it('switches peer editor tabs while retaining the advanced category and unfinished form inputs', async () => {
api.list.mockResolvedValue({ agents: [{ ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }], next_cursor: null });
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const requirements = screen.getByRole('tab', { name: '我的要求', exact: true });
const advanced = screen.getByRole('tab', { name: '高级设置', exact: true });
expect(requirements).toHaveAttribute('aria-selected', 'true');
expect(advanced).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tabpanel', { name: '我的要求' })).toBeVisible();
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
const instructions = screen.getByLabelText('特别要求');
const preview = screen.getByLabelText('消息');
fireEvent.change(instructions, { target: { value: '没有保存的要求' } });
fireEvent.change(preview, { target: { value: '没有发送的问题' } });
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
expect(advanced).toHaveAttribute('aria-selected', 'true');
expect(requirements).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tabpanel', { name: '高级设置' })).toBeVisible();
expect(instructions).not.toBeVisible();
expect(screen.getByRole('button', { name: '能力', exact: true })).toHaveAttribute('aria-current', 'page');
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
const budgetInput = screen.getByLabelText('每日上限(词元点数)');
await waitFor(() => expect(budgetInput).toBeEnabled());
fireEvent.change(budgetInput, { target: { value: '12.50' } });
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
expect(screen.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
expect(budgetInput).toBeVisible();
fireEvent.mouseDown(requirements, { button: 0, ctrlKey: false });
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
expect(budgetInput).not.toBeVisible();
expect(screen.getByLabelText('特别要求')).toBe(instructions);
expect(instructions).toHaveValue('没有保存的要求');
fireEvent.mouseDown(advanced, { button: 0, ctrlKey: false });
expect(screen.getByRole('button', { name: '限制', exact: true })).toHaveAttribute('aria-current', 'page');
expect(screen.getByLabelText('每日上限(词元点数)')).toBe(budgetInput);
expect(budgetInput).toHaveValue(12.5);
expect(screen.getByLabelText('消息')).toBe(preview);
expect(preview).toHaveValue('没有发送的问题');
expect(api.save).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(([operation]) => ['saveBudget', 'preview', 'submit'].includes(operation))).toBe(false);
});
it('keeps preview text across mode switches and saves a budget independently of the draft', async () => {
const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } };
api.list.mockResolvedValue({ agents: [configured], next_cursor: null });
@@ -322,13 +367,13 @@ it('keeps preview text across mode switches and saves a budget independently of
fireEvent.click(screen.getByRole('button', { name: '编辑', exact: true }));
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
expect(screen.getByLabelText('特别要求')).toHaveValue('未保存的指令');
fireEvent.click(screen.getByRole('button', { name: '高级设置' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '限制', exact: true }));
fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '12' } });
fireEvent.click(screen.getByRole('button', { name: '保存费用上限' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', expect.objectContaining({ daily_limit_points: '12' })));
expect(api.save).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '我的要求', exact: true }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '我的要求', exact: true }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('特别要求')).toHaveValue('未保存的指令');
expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题');
});
@@ -343,7 +388,7 @@ it('keeps an existing preview when the saved draft clears its model selection',
await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled());
const prompt = screen.getByLabelText('消息');
fireEvent.change(prompt, { target: { value: '还没有发送' } });
fireEvent.click(screen.getByRole('button', { name: '高级设置' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
@@ -364,11 +409,14 @@ it('searches capabilities while retaining existing selections outside the result
api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 }));
open();
fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ }));
fireEvent.click(screen.getByRole('button', { name: '高级设置' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole('button', { name: '能力', exact: true }));
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true }));
fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: '我的要求' }), { button: 0, ctrlKey: false });
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('搜索工具')).toHaveValue('资料');
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ }));
fireEvent.click(screen.getByRole('button', { name: '完成选择' }));
+126
View File
@@ -0,0 +1,126 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { DesignHistoryRail } from '@/pages/ImageCanvas/DesignHistoryRail';
import type { DesignAsset } from '../../shared/image-workspace';
import { designQuoteFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2';
vi.mock('@/lib/image-workspace', () => ({
resolveImageWorkspaceAssetUrl: vi.fn(),
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
}));
function assetFixture(assetId: string, mediaType: DesignAsset['mediaType'] = 'image'): DesignAsset {
return {
assetId, workspaceId: 'workspace-1', role: 'generated', mediaType,
mimeType: mediaType === 'image' ? 'image/png' : 'video/mp4',
width: 1600, height: 2400, durationMilliseconds: null,
generationTaskId: 'task-1', createdAt: '2026-09-15T06:00:00.000Z',
contentPath: `/api/works/image-workspace/workspaces/workspace-1/assets/${assetId}/content`,
};
}
function completedWorkspace(assets = [assetFixture('image-1')]) {
const quote = designQuoteFixture();
return designWorkspaceFixture({
assets,
tasks: [{
taskId: 'task-1', quoteId: quote.quoteId, status: 'succeeded', taskRevision: 2,
specificationRevision: quote.specificationRevision,
specificationRevisionId: quote.specificationRevisionId,
specificationDigest: quote.specificationDigest,
compilerVersion: quote.compilerVersion, medium: 'image',
outputSummary: { ...quote.outputSummary, outputCount: assets.length },
maximumCustomerChargeAtoms: 1200,
customerBilling: { quotedAtoms: 1200, heldAtoms: 0, chargedAtoms: 1200, refundedAtoms: 0, pendingResolutionAtoms: 0 },
customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null,
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
executionHealth: 'normal', cancellation: { state: 'unavailable', outcome: null },
issues: [], resultAssetIds: assets.map((asset) => asset.assetId), nextActions: ['view_result'],
createdAt: '2026-09-15T06:00:00.000Z',
}],
});
}
function expandTask() {
screen.getByTestId('design-plan-history-task-1').setAttribute('open', '');
}
describe('AI Design task image preview', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `http://localhost${path}`);
});
it('opens the selected result without downloading and restores fit mode when reopened', async () => {
const workspace = completedWorkspace([assetFixture('image-1'), assetFixture('image-2'), assetFixture('video-1', 'video')]);
render(<DesignHistoryRail workspace={workspace} />);
expandTask();
const buttons = screen.getAllByRole('button', { name: '放大查看图片' });
expect(buttons).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '保存作品到电脑' })).toHaveLength(3);
fireEvent.click(buttons[1]);
const dialog = screen.getByRole('dialog', { name: '图片预览' });
const image = await within(dialog).findByRole('img', { name: 'AI 作品大图' });
expect(image).toHaveAttribute('src', `http://localhost${workspace.assets[1].contentPath}`);
expect(image).toHaveClass('object-contain');
expect(within(dialog).getByRole('status')).toHaveTextContent('正在加载图片');
fireEvent.load(image);
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
expect(image).toHaveClass('max-w-none');
fireEvent.click(within(dialog).getByRole('button', { name: '适应窗口' }));
expect(image).toHaveClass('object-contain');
fireEvent.click(within(dialog).getByRole('button', { name: '原始尺寸' }));
fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片预览' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(buttons[0]);
const reopenedImage = await screen.findByRole('img', { name: 'AI 作品大图' });
expect(reopenedImage).toHaveAttribute('src', `http://localhost${workspace.assets[0].contentPath}`);
expect(reopenedImage).toHaveClass('object-contain');
expect(saveImageWorkspaceAsset).not.toHaveBeenCalled();
});
it('closes with Escape and keeps the existing download action independent', async () => {
const workspace = completedWorkspace();
render(<DesignHistoryRail workspace={workspace} />);
expandTask();
const trigger = screen.getByRole('button', { name: '放大查看图片' });
fireEvent.click(trigger);
await screen.findByRole('img', { name: 'AI 作品大图' });
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: '保存作品到电脑' }));
expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(workspace.assets[0]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it.each(['url', 'image'] as const)('shows a recoverable message for %s loading failures', async (failure) => {
render(<DesignHistoryRail workspace={completedWorkspace()} />);
expandTask();
if (failure === 'url') {
vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('unavailable'));
}
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
if (failure === 'image') fireEvent.error(await screen.findByRole('img', { name: 'AI 作品大图' }));
expect(await screen.findByRole('alert')).toHaveTextContent('图片暂时无法加载,请关闭后重试');
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '关闭图片预览' }));
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
fireEvent.load(await screen.findByRole('img', { name: 'AI 作品大图' }));
expect(screen.getByRole('button', { name: '原始尺寸' })).toBeEnabled();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('removes the open preview when switching design projects', async () => {
const workspace = completedWorkspace();
const { rerender } = render(<DesignHistoryRail workspace={workspace} />);
expandTask();
fireEvent.click(screen.getByRole('button', { name: '放大查看图片' }));
await screen.findByRole('img', { name: 'AI 作品大图' });
rerender(<DesignHistoryRail workspace={{ ...workspace, workspace: { ...workspace.workspace, workspaceId: 'workspace-2' } }} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});