plan-session_2cf58e5d5e921c6afc2f1792cc225777.md 20 KB

智能模式独立窗口改造计划 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 文档、架构博客。


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 比较,命中后重新读盘:

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(仅文案)