cc vor 3 Tagen
Ursprung
Commit
55bb6f7fdf
1 geänderte Dateien mit 316 neuen und 0 gelöschten Zeilen
  1. 316 0
      .zcode/plans/plan-session_2cf58e5d5e921c6afc2f1792cc225777.md

+ 316 - 0
.zcode/plans/plan-session_2cf58e5d5e921c6afc2f1792cc225777.md

@@ -0,0 +1,316 @@
+# 智能模式独立窗口改造计划 v3
+
+## 0. 本次关键简化
+
+按你的要求,**智能窗口不承担任何案件/账号生命周期操作**:
+
+- 移除工作台顶栏的「退出案件」(不再传 `show-case-exit`,删掉 `onExitCase` 与确认框)。
+- 移除工作台顶栏的 `UserDropdown`(没有退出登录、修改密码、用户管理)。
+- 保留:只读的案件名胶囊、模式开关、下载管理、窗口控制(最小化/最大化/关闭)。
+
+由此,「子窗口退出案件 → 主窗口怎么办」「子窗口退出登录 → 主窗口怎么办」这类场景**从结构上被消除**,而不是靠事件互相协调。智能窗口对共享生命周期状态变成**纯只读消费端**,所有写操作只发生在主窗口。
+
+用户想退出案件时:智能窗口点「自主」→ 主窗口被聚焦到前台 → 在主窗口退出。模式开关就是逃生通道。
+
+---
+
+## 1. 设计基线
+
+```
+Electron 主进程
+├── mainWindow        传统/自主壳,始终存在;唯一能开案/退案/退出登录的窗口
+├── smartWindow       单例、独立顶层窗口(不设 parent),#/workbench;只读消费端
+├── windowCtl         开/聚焦/关/查角色/广播
+└── 共享:ai-server(HTTP + SSE,会话与产物的唯一事实源)、localStorage、local-settings.json
+
+Renderer(同一份 Vite bundle)
+├── windowRole = 'main' | 'smart'(bootstrap 时经 IPC 解析一次)
+├── main:业务页面;遇到 /workbench → 打开智能窗口,自己留在原页
+└── smart:只渲染 /workbench;其他路由一律拉回 /workbench
+```
+
+关键取舍:同一份 bundle、不新增 Vite entry;独立顶层窗口而不是 `parent`(Windows 上 parent 会永远压在主窗口上方);窗口角色不靠 URL query(会被 `router.replace` 冲掉)。
+
+VS Code 的参考价值在于分工:Agent Host 独立进程拥有会话,编辑器窗口和 Agents window 都只是客户端。本项目的 `ai-server` 已经就是 Host,SSE 就是 action 流,所以这次不造协议,只做「第二个只读客户端窗口 + 明确的共享状态规则」。参考:[Agent Host 文档](https://raw.githubusercontent.com/microsoft/vscode-docs/main/docs/agents/concepts/agent-host.md)、[架构博客](https://code.visualstudio.com/blogs/2026/08/26/agent-host-architecture)。
+
+---
+
+## 2. 共享数据分层与唯一事实源
+
+| 数据 | 唯一事实源 | 每个窗口有内存副本 | 同步方式 |
+|---|---|---|---|
+| token / userInfo | localStorage 的 `COMMON_LOCAL__` 加密 blob | 有(`Persistent.localMemory`) | 重新读盘 |
+| 当前案件 `caseInfo` | 同上 + `openCase` 接口返回 | 有 | 重新读盘 / 换案后整窗重建 |
+| 动态路由 / 权限 | `menu.json`(静态)+ 各窗口自己 build | 各自一份 | 重置后按需重建 |
+| 会话 / 消息 / 产物 | ai-server | 聊天 store(仅智能窗口用) | 换案后整窗重建再拉取 |
+| 工作台内部视图、草稿、产物栏开合 | 窗口自身 | 仅本窗口 | 不共享 |
+| 窗口是否打开、bounds | 主进程 | 主进程 | IPC |
+| 下载任务与进度 | 主进程 | 无 | 广播到所有窗口 |
+| 主题 / 语言 / workbenchMode 偏好 | 独立 localStorage key | 有 | `storage` 事件(可选) |
+
+`workbenchMode` 只是「开案后落到哪个界面」的偏好,不决定主窗口渲染什么。智能窗口开没开,以主进程单例为准。
+
+---
+
+## 3. 先修存储层:不修这两处,同步一定出错
+
+### 3.1 `persistent.ts` 的 storage 事件目前是失效的
+
+- 实际写入的 key 是 `前缀__COMMON_LOCAL__`(`storageCache.getKey` 会拼 `getStorageShortName()` 并转大写),而 `storageChange` 比较的是裸的 `APP_LOCAL_CACHE_KEY`,**永远匹配不上**。
+- 即使匹配上,处理方式是 `clearLocal()` 清空内存而不是重新读取,之后 `getToken()` / `getCaseInfo()` 会读到 `undefined`。
+- 还要求 `oldValue && newValue` 同时存在,首次写入会被忽略。
+
+修法:用拼接后的真实 key 比较,命中后重新读盘:
+
+```ts
+const LOCAL_BLOB = `${getStorageShortName()}${APP_LOCAL_CACHE_KEY}`.toUpperCase();
+
+function reloadLocalMemory() {
+  localMemory.clear();
+  const blob = ls.get(APP_LOCAL_CACHE_KEY);
+  blob && localMemory.resetCache(blob);
+}
+
+window.addEventListener('storage', (e) => {
+  if (!e.key || e.key === LOCAL_BLOB) reloadLocalMemory();
+});
+```
+
+这是地基:主窗口写入后,智能窗口下一次读取就是新值。
+
+### 3.2 智能窗口不能在 unload 时回写整份快照
+
+`beforeunload` 会把本窗口整份 `localMemory` 写回 localStorage。主窗口刚换完案件、主进程让智能窗口 reload 时,智能窗口 unload 会拿**旧案件**覆盖新案件。
+
+修法(两层保险):
+
+1. 加 `setPersistOnUnload(enabled)`;`appWindow.ts` 在角色确定后调用 `setPersistOnUnload(!isSmartWindow())`。智能窗口不靠 unload 快照保存任何东西。
+2. `beforeunload` 的合并列表补上 `CASE_INFO_KEY`,保证盘上更新的案件信息总是赢。
+
+### 3.3 已知残余风险
+
+`Persistent` 是整块 blob 读改写,理论上有极小的覆盖窗口。本轮共享写入都来自主窗口的低频动作,智能窗口只读且 unload 不回写,风险可接受。彻底解决要把 blob 拆成按 key 独立存储,列为后续重构。
+
+---
+
+## 4. 传输:主进程事件总线
+
+- 渲染进程 → 主进程:`controller/windowCtl/syncState`,参数 `{ type, payload }`。
+- 主进程 → 所有窗口(排除发送者):`app:state`。
+- 发送前检查 `!win.isDestroyed()`,try/catch 包住。
+- **不做事件重放**:智能窗口晚创建或错过事件时,bootstrap 从 localStorage/后端读到的事实本来就是新的。
+
+只有两个事件类型,因为智能窗口没有会触发同步的写操作:
+
+| type | payload | 谁发 | 智能窗口收到 | 主窗口收到 |
+|---|---|---|---|---|
+| `APP_RESET` | `{ reason: 'case-exited' \| 'account-logged-out' }` | 仅主窗口 | 关闭自己 | 不处理(发送者被排除) |
+| `AUTH_INVALID` | `{ kind: 'unauthorized' \| 'license', message }` | 任一窗口 | 关闭自己 + 聚焦主窗口 | 弹框 + 跳授权页(仅当来自智能窗口) |
+
+`CASE_OPENED` 不需要事件:主窗口开案成功后直接调 `openSmartWindow({ reloadIfOpen: true })`,由主进程控制重载。
+
+---
+
+## 5. 三条规则
+
+1. **案件/账号生命周期只由主窗口发起**。智能窗口没有退出案件、退出登录、修改密码、用户管理入口;所有 `exitCase` / `logoutApi` / `openCase` 调用只可能来自主窗口。
+2. **主窗口是唯一的登录/授权提示方**。智能窗口遇到 401 或许可证失效,只广播 + 关闭自己,由主窗口弹框并跳转。
+3. **智能窗口是案件作用域的从属窗口**。案件没了、token 没了、授权没了 → 关闭智能窗口,不展示 `workspaceId` 为空的空壳。
+
+---
+
+## 6. 场景定义
+
+### S1 主窗口退出案件 → 智能窗口
+
+1. 主窗口 `userStore.logout(true)`:调后端 `exitCase()` → 清案件态 → `tabStore.resetState()` → `resetRouter()` → 广播 `APP_RESET { reason:'case-exited' }` → `router.replace('/case')`。
+2. 智能窗口收到:`closeSmartWindow()`。正在流式输出的话,renderer 销毁会自动中止 SSE。
+3. 为什么不显示空状态:工作台的会话、产物、`workspaceId` 全部绑定案件,案件没了窗口没有可做的事;这也与现有「切换案件必须退出后从案件管理重进」的规则一致。
+4. 体验补充:主窗口弹退出确认框时,如果智能窗口开着,文案追加「智能工作台将同时关闭」。确认前先 `isSmartWindowOpen()` 查询。
+
+### S2 主窗口打开 / 切换案件 → 智能窗口
+
+- 主窗口 `openCase` 成功后:`setCaseInfo` → 重建动态路由 → 按偏好落到 `BASE_HOME`。
+- 然后:
+  - 智能窗口已打开 → `openSmartWindow({ reloadIfOpen: true })`,整窗重载;
+  - 没开、偏好智能 → `openSmartWindow()`;
+  - 没开、偏好自主 → 什么都不做。
+- 为什么整窗重载:聊天 store、会话列表、产物面板、`workspaceId` 全要重建,重载是唯一能保证无旧案件残留的做法,且换案是低频动作。
+- 必须配合 3.2 的 unload 修复,否则重载时会把旧案件写回盘上。
+
+### S3 主窗口退出登录 → 智能窗口
+
+1. 主窗口:`caseStore.resetState()` → `accountStore.logout()`(`logoutApi` + 清 token/userInfo)→ 广播 `APP_RESET { reason:'account-logged-out' }` → `router.replace('/authorization')`。
+2. 智能窗口收到:关闭自己。登录页只保留一个。
+
+### S4 401 / token 失效(智能窗口发起)
+
+1. 智能窗口的 axios 拦截器进入 `handleUnauthorizedRedirect`。
+2. 角色为 smart:不弹框,广播 `AUTH_INVALID { kind:'unauthorized', message }` → `focusMainWindow()` → `closeSmartWindow()`。
+3. 主窗口收到:调用现有 `handleUnauthorizedRedirect(message, { remote: true })`,清状态、弹「登录状态已失效」、确定后跳授权页。
+4. 主窗口自己触发 401 时:现有逻辑不变(本地弹框),同时广播 `AUTH_INVALID`;智能窗口收到后关闭。
+
+### S5 许可证失效(双向)
+
+与 S4 同构,`kind:'license'`,主窗口调用 `handleLicenseRedirect(message, { remote: true })`。智能窗口永远不展示授权页。
+
+补充:智能窗口 bootstrap 如果被判定到 `/authorization`(例如开窗瞬间 license 刚失效),`main.ts` 在 `ensureLoggedIn` 之后检测「角色=smart 且落在授权页」,直接关闭自己并聚焦主窗口。
+
+### S6 智能窗口被用户关闭 / 崩溃
+
+- 正常关闭:`closed` → 单例置空 → 广播 `smart-window:closed`;共享数据不变。
+- 渲染进程崩溃:`render-process-gone` 做同样处理。
+- 下次点「智能」重新创建,恢复上次 bounds,路由回 `/workbench`(视图记忆改 localStorage,能回到上次的 chat/model/agent/plugin)。
+
+### S7 主窗口最小化到托盘 / 第二实例
+
+- 主窗口 X 仍然 hide 到托盘,智能窗口不受影响,可继续工作。
+- 托盘「显示」显示主窗口;「退出」退出整个应用。
+- 第二实例(双击图标):恢复主窗口,并 focus 已存在的智能窗口。
+- `window-all-closed` 只在所有窗口销毁时触发退出;主窗口是 hide,不会误退。
+
+### S8 UI 偏好(可选,P2)
+
+主题 / 语言存在独立 localStorage key。监听 `storage` 后调用对应 store setter 即可。`workbenchMode` 不需要严格同步(只影响下次开案落点)。
+
+### S9 案件内数据变化
+
+不做同步。会话、消息、产物的事实源在 ai-server,智能窗口按需请求,没有跨窗口共享缓存会变脏。
+
+---
+
+## 7. 幂等、顺序与失败处理
+
+- 智能窗口的 `APP_RESET` / `AUTH_INVALID` 处理器都只需关窗,天然幂等;对已关闭窗口是 no-op。
+- 主窗口的 `AUTH_INVALID` 处理沿用 `isHandlingUnauthorized` / `isHandlingLicense` 防重入,且加 `{ remote: true }` 避免再次广播形成回声。
+- 事件错过:不做重放。智能窗口下一次 bootstrap 从 localStorage/后端读到最新事实。
+- 窗口已销毁:发送前判 `isDestroyed`,try/catch 吞掉。
+- 双窗口同时 401:智能窗口先关;主窗口的防重入保证只弹一个框。
+- 浏览器环境(无 Electron):`syncState` / `openSmartWindow` 全部 no-op 或退化为同窗导航,单窗口行为不变。
+- 智能窗口流式输出中被远程关闭:SSE 随 renderer 销毁而中止;服务端已落库内容不受影响。关窗后重新接上同一场 turn 需要后端支持,列为后续。
+
+---
+
+## 8. 明确不共享的内容
+
+智能窗口的 `chatStream` store、当前选中会话、输入草稿、产物栏开合、滚动位置,全部是窗口私有状态。重新打开智能窗口时从后端重新拉取,得到干净的当前案件视图。
+
+---
+
+## 9. 主进程改造
+
+1. 新增 `electron/service/window/smartWindow.ts`
+   - 单例;`open({ fullPath, reloadIfOpen })`,已存在则 restore/show/focus,`reloadIfOpen` 时 `webContents.reload()`。
+   - 窗口参数复用共享 `webPreferences`(`contextIsolation:false`、`nodeIntegration:true`)、`frame:false`、`show:false`、最小 900×600。
+   - dev 载入 `http://localhost:3100${fullPath}`(端口读 `cmd/bin.js`);prod `loadFile(public/dist/index.html, { hash: fullPath })`。
+   - bounds 持久化到 `local-settings.json`;`closed` / `render-process-gone` 清空单例并广播。
+2. 新增 `electron/controller/windowCtl.ts`
+   - `openSmartWindow` / `closeSmartWindow` / `isSmartWindowOpen` / `focusMainWindow` / `getWindowRole` / `syncState`。
+   - ee-core 已确认 `fn.call(controller, params, event)`,能拿到 `event.sender`。
+3. `os.js` + `osCtl.js`:窗口控制按 sender 定位(`BrowserWindow.fromWebContents(event.sender) ?? getMainWindow()`),否则智能窗口的按钮会操作主窗口。
+4. `downloadCtl.js`:进度广播到所有窗口(智能窗口顶栏有下载管理器)。
+5. `tray.js` / `lifecycle.ts`:托盘显示主窗口、可选「智能工作台」项;second-instance 恢复主窗口并 focus 智能窗口。
+6. `config.default.ts`:抽出共享 `webPreferences` 常量。
+
+---
+
+## 10. 渲染层改造
+
+1. 新增 `core/logics/appWindow.ts`:`initWindowRole` / `getWindowRole` / `isSmartWindow` / `openSmartWindow` / `closeSmartWindow` / `focusMainWindow` / `broadcastSync` / `onSyncEvent`。
+2. 新增 `core/logics/sessionSync.ts`:`initSessionSync` + 两个事件的接收处理(很薄,智能窗口只关窗)。
+3. `main.ts`:`await initWindowRole()` → `initSessionSync()` → 再 `setupRouter`/guards;`ensureLoggedIn` 后检测「smart 且落在授权页」→ 关窗。
+4. 守卫:
+   - `role=smart`:只放行 `/workbench`、`/authorization`,其余拉回 `/workbench`。
+   - `role=main`:`/workbench` → `openSmartWindow(to.fullPath)` 后 redirect 回 `from`;旧 AI 地址重定向到智能窗口对应视图;`/codexWorkbench` 保持回首页。
+   - 无 Electron 时全部跳过,保持同窗渲染。
+   - 开窗前检查 `getCaseInfo().id`,没有案件提示「请先打开案件」。
+5. `workbenchMode.ts`:`setMode('smart')` = 开/聚焦智能窗口 + 写偏好;`setMode('traditional')` = 只写偏好,智能窗口里额外 `focusMainWindow()`(不关闭)。删除 `getModeHomePath`,视图记忆改 localStorage。
+6. 开关:`current` = 窗口角色;主窗口高亮自主,智能窗口高亮智能。
+7. 菜单:`menu.json` 删除 `/10263` 整组;`menus/index.ts` 删除 `AI_WORKBENCH_PATHS` / `filterByWorkbenchMode`;`routeHelper.ts` 对应映射可清理。
+8. `case.ts`:`logout()` 末尾加 `broadcastSync(APP_RESET, { reason:'case-exited' })`,不需要角色判断。
+9. `user-dropdown`:`doLogout` 末尾加 `broadcastSync(APP_RESET, { reason:'account-logged-out' })`。
+10. `unauthorized.ts`:开头按角色分流(smart:广播 + 聚焦主窗口 + 关窗;main:原逻辑 + 广播),新增 `{ remote?: boolean }` 参数防回声。
+11. `persistent.ts`:3.1 的重新读盘 + 3.2 的 unload 开关。
+12. `AppTopBar.vue`:新增 `showUserDropdown` prop(默认 true);工作台传 false。
+13. `ai/views/aiWorkbench/index.vue`:移除 `show-case-exit`、`@exit-case`、`onExitCase` 及 `exitCaseConfirmOptions` 相关代码;传 `:show-user-dropdown="false"`。
+
+---
+
+## 11. 智能窗口顶栏最终形态
+
+```
+[Logo 清鉴·智能工作台] [案件:xxx(只读)] ...... [下载管理] [自主|智能] | [— □ ×]
+```
+
+- 没有「退出」,案件胶囊只展示。
+- 没有用户头像/菜单。
+- 窗口控制操作的是自己(主进程按 sender 定位)。
+- 点「自主」→ 写偏好 + 聚焦主窗口(窗口保留,按你之前的选择)。
+
+---
+
+## 12. 实施顺序
+
+1. **存储层修复**(`persistent.ts`):开两个 dev 窗口手工验证 token/case 互读。
+2. **主进程窗口能力**:smartWindow + windowCtl + osCtl sender 化 + 下载广播。
+3. **角色与路由**:appWindow + main.ts + 守卫 + 开关 + 菜单移除 + 顶栏裁剪。
+4. **同步总线**:sessionSync + `APP_RESET` / `AUTH_INVALID` 接入 + 双向验证。
+5. **打磨与回归**:bounds、确认框文案、浏览器 fallback、dev/prod 双模式。
+
+---
+
+## 13. 测试矩阵
+
+| 场景 | 期望 |
+|---|---|
+| 智能窗口顶栏 | 无退出案件、无用户菜单;案件名只读;窗口控制正常 |
+| 主窗口退出案件 | 智能窗口关闭;主窗口在案件管理页 |
+| 主窗口换案 | 智能窗口若开着则整窗重载到新案;若没开且偏好智能则打开;无旧案件残留 |
+| 主窗口退出登录 | 智能窗口关闭;主窗口在授权页 |
+| 主窗口 401 | 主窗口弹一个框;智能窗口关闭 |
+| 智能窗口触发 401 | 智能窗口关闭;主窗口被拉到前台并弹一个失效提示,且只有一个框 |
+| 许可证失效 | 智能窗口关闭;主窗口进授权页 |
+| 智能窗口点 X 后再开 | 单例重建,恢复 bounds 与上次视图,数据从后端重新拉 |
+| 主窗口最小化到托盘 | 智能窗口继续可对话;托盘恢复主窗口 |
+| 双击桌面图标 | 主窗口恢复,已有智能窗口被聚焦 |
+| 关案后立刻换案 | 新案数据正确,旧案不被 unload 快照覆盖 |
+| 纯浏览器打开前端 | 点「智能」仍在同窗进工作台,无 IPC 报错 |
+| dev(history)/ prod(hash + file://) | 都能开窗、SSE 流式、下载进度正常 |
+
+---
+
+## 14. 风险与后续
+
+- **智能窗口失去所有生命周期入口**是有意为之:所有写操作集中在主窗口,同步规则才能收敛到两个事件。代价是用户必须经「自主」开关回主窗口操作,这一点会在开关的 tooltip 里写明。
+- **关闭而不是空状态**:若以后要保留智能窗口显示「未选择案件」占位,只需把 `APP_RESET` 的 smart 分支从 `closeSmartWindow` 改成跳占位路由。
+- **不做事件重放**:依赖每次从事实源重读;后续新增共享状态必须遵守。
+- **整块 blob 读改写**:残余覆盖窗口很小,后续可拆成按 key 存储。
+- **SSE 不可续传**:关窗即断,服务端数据不丢,但 UI 无法重连未完成的 turn;需要后端按 session 支持续传。
+- **`contextIsolation:false / nodeIntegration:true`** 与主窗口保持一致,安全加固作为独立议题。
+
+## 15. 文件清单
+
+**新增**
+- `ai-electron/electron/service/window/smartWindow.ts`
+- `ai-electron/electron/controller/windowCtl.ts`
+- `ai-electron/frontend/src/core/logics/appWindow.ts`
+- `ai-electron/frontend/src/core/logics/sessionSync.ts`
+
+**修改**
+- `ai-electron/electron/service/os/os.js`、`controller/osCtl.js`、`controller/downloadCtl.js`
+- `ai-electron/electron/service/os/tray.js`、`preload/lifecycle.ts`
+- `ai-electron/electron/config/config.default.ts`
+- `ai-electron/frontend/src/main.ts`
+- `ai-electron/frontend/src/core/utils/cache/persistent.ts`
+- `ai-electron/frontend/src/core/router/guard/index.ts`
+- `ai-electron/frontend/src/core/logics/workbenchMode.ts`
+- `ai-electron/frontend/src/core/router/menus/index.ts`
+- `ai-electron/frontend/src/core/store/modules/menu.json`
+- `ai-electron/frontend/src/core/store/modules/case.ts`
+- `ai-electron/frontend/src/core/utils/http/axios/unauthorized.ts`
+- `ai-electron/frontend/src/core/components/Application/src/AppTopBar.vue`
+- `ai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vue`
+- `ai-electron/frontend/src/core/layouts/default/header/index.vue`
+- `ai-electron/frontend/src/core/layouts/default/header/components/user-dropdown/index.vue`(仅加广播)
+- `ai-electron/frontend/src/ai/views/aiWorkbench/index.vue`
+- `ai-electron/frontend/src/core/hooks/desktop/useElectronWindow.ts`(仅文案)