按你的要求,智能窗口不承担任何案件/账号生命周期操作:
show-case-exit,删掉 onExitCase 与确认框)。UserDropdown(没有退出登录、修改密码、用户管理)。由此,「子窗口退出案件 → 主窗口怎么办」「子窗口退出登录 → 主窗口怎么办」这类场景从结构上被消除,而不是靠事件互相协调。智能窗口对共享生命周期状态变成纯只读消费端,所有写操作只发生在主窗口。
用户想退出案件时:智能窗口点「自主」→ 主窗口被聚焦到前台 → 在主窗口退出。模式开关就是逃生通道。
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 文档、架构博客。
| 数据 | 唯一事实源 | 每个窗口有内存副本 | 同步方式 |
|---|---|---|---|
| token / userInfo | localStorage 的 COMMON_LOCAL__ 加密 blob |
有(Persistent.localMemory) |
重新读盘 |
当前案件 caseInfo |
同上 + openCase 接口返回 |
有 | 重新读盘 / 换案后整窗重建 |
| 动态路由 / 权限 | menu.json(静态)+ 各窗口自己 build |
各自一份 | 重置后按需重建 |
| 会话 / 消息 / 产物 | ai-server | 聊天 store(仅智能窗口用) | 换案后整窗重建再拉取 |
| 工作台内部视图、草稿、产物栏开合 | 窗口自身 | 仅本窗口 | 不共享 |
| 窗口是否打开、bounds | 主进程 | 主进程 | IPC |
| 下载任务与进度 | 主进程 | 无 | 广播到所有窗口 |
| 主题 / 语言 / workbenchMode 偏好 | 独立 localStorage key | 有 | storage 事件(可选) |
workbenchMode 只是「开案后落到哪个界面」的偏好,不决定主窗口渲染什么。智能窗口开没开,以主进程单例为准。
persistent.ts 的 storage 事件目前是失效的前缀__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();
});
这是地基:主窗口写入后,智能窗口下一次读取就是新值。
beforeunload 会把本窗口整份 localMemory 写回 localStorage。主窗口刚换完案件、主进程让智能窗口 reload 时,智能窗口 unload 会拿旧案件覆盖新案件。
修法(两层保险):
setPersistOnUnload(enabled);appWindow.ts 在角色确定后调用 setPersistOnUnload(!isSmartWindow())。智能窗口不靠 unload 快照保存任何东西。beforeunload 的合并列表补上 CASE_INFO_KEY,保证盘上更新的案件信息总是赢。Persistent 是整块 blob 读改写,理论上有极小的覆盖窗口。本轮共享写入都来自主窗口的低频动作,智能窗口只读且 unload 不回写,风险可接受。彻底解决要把 blob 拆成按 key 独立存储,列为后续重构。
controller/windowCtl/syncState,参数 { type, payload }。app:state。!win.isDestroyed(),try/catch 包住。只有两个事件类型,因为智能窗口没有会触发同步的写操作:
| type | payload | 谁发 | 智能窗口收到 | 主窗口收到 |
|---|---|---|---|---|
APP_RESET |
{ reason: 'case-exited' \| 'account-logged-out' } |
仅主窗口 | 关闭自己 | 不处理(发送者被排除) |
AUTH_INVALID |
{ kind: 'unauthorized' \| 'license', message } |
任一窗口 | 关闭自己 + 聚焦主窗口 | 弹框 + 跳授权页(仅当来自智能窗口) |
CASE_OPENED 不需要事件:主窗口开案成功后直接调 openSmartWindow({ reloadIfOpen: true }),由主进程控制重载。
exitCase / logoutApi / openCase 调用只可能来自主窗口。workspaceId 为空的空壳。userStore.logout(true):调后端 exitCase() → 清案件态 → tabStore.resetState() → resetRouter() → 广播 APP_RESET { reason:'case-exited' } → router.replace('/case')。closeSmartWindow()。正在流式输出的话,renderer 销毁会自动中止 SSE。workspaceId 全部绑定案件,案件没了窗口没有可做的事;这也与现有「切换案件必须退出后从案件管理重进」的规则一致。isSmartWindowOpen() 查询。openCase 成功后:setCaseInfo → 重建动态路由 → 按偏好落到 BASE_HOME。openSmartWindow({ reloadIfOpen: true }),整窗重载;openSmartWindow();workspaceId 全要重建,重载是唯一能保证无旧案件残留的做法,且换案是低频动作。caseStore.resetState() → accountStore.logout()(logoutApi + 清 token/userInfo)→ 广播 APP_RESET { reason:'account-logged-out' } → router.replace('/authorization')。handleUnauthorizedRedirect。AUTH_INVALID { kind:'unauthorized', message } → focusMainWindow() → closeSmartWindow()。handleUnauthorizedRedirect(message, { remote: true }),清状态、弹「登录状态已失效」、确定后跳授权页。AUTH_INVALID;智能窗口收到后关闭。与 S4 同构,kind:'license',主窗口调用 handleLicenseRedirect(message, { remote: true })。智能窗口永远不展示授权页。
补充:智能窗口 bootstrap 如果被判定到 /authorization(例如开窗瞬间 license 刚失效),main.ts 在 ensureLoggedIn 之后检测「角色=smart 且落在授权页」,直接关闭自己并聚焦主窗口。
closed → 单例置空 → 广播 smart-window:closed;共享数据不变。render-process-gone 做同样处理。/workbench(视图记忆改 localStorage,能回到上次的 chat/model/agent/plugin)。window-all-closed 只在所有窗口销毁时触发退出;主窗口是 hide,不会误退。主题 / 语言存在独立 localStorage key。监听 storage 后调用对应 store setter 即可。workbenchMode 不需要严格同步(只影响下次开案落点)。
不做同步。会话、消息、产物的事实源在 ai-server,智能窗口按需请求,没有跨窗口共享缓存会变脏。
APP_RESET / AUTH_INVALID 处理器都只需关窗,天然幂等;对已关闭窗口是 no-op。AUTH_INVALID 处理沿用 isHandlingUnauthorized / isHandlingLicense 防重入,且加 { remote: true } 避免再次广播形成回声。isDestroyed,try/catch 吞掉。syncState / openSmartWindow 全部 no-op 或退化为同窗导航,单窗口行为不变。智能窗口的 chatStream store、当前选中会话、输入草稿、产物栏开合、滚动位置,全部是窗口私有状态。重新打开智能窗口时从后端重新拉取,得到干净的当前案件视图。
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。http://localhost:3100${fullPath}(端口读 cmd/bin.js);prod loadFile(public/dist/index.html, { hash: fullPath })。local-settings.json;closed / render-process-gone 清空单例并广播。electron/controller/windowCtl.ts
openSmartWindow / closeSmartWindow / isSmartWindowOpen / focusMainWindow / getWindowRole / syncState。fn.call(controller, params, event),能拿到 event.sender。os.js + osCtl.js:窗口控制按 sender 定位(BrowserWindow.fromWebContents(event.sender) ?? getMainWindow()),否则智能窗口的按钮会操作主窗口。downloadCtl.js:进度广播到所有窗口(智能窗口顶栏有下载管理器)。tray.js / lifecycle.ts:托盘显示主窗口、可选「智能工作台」项;second-instance 恢复主窗口并 focus 智能窗口。config.default.ts:抽出共享 webPreferences 常量。core/logics/appWindow.ts:initWindowRole / getWindowRole / isSmartWindow / openSmartWindow / closeSmartWindow / focusMainWindow / broadcastSync / onSyncEvent。core/logics/sessionSync.ts:initSessionSync + 两个事件的接收处理(很薄,智能窗口只关窗)。main.ts:await initWindowRole() → initSessionSync() → 再 setupRouter/guards;ensureLoggedIn 后检测「smart 且落在授权页」→ 关窗。role=smart:只放行 /workbench、/authorization,其余拉回 /workbench。role=main:/workbench → openSmartWindow(to.fullPath) 后 redirect 回 from;旧 AI 地址重定向到智能窗口对应视图;/codexWorkbench 保持回首页。getCaseInfo().id,没有案件提示「请先打开案件」。workbenchMode.ts:setMode('smart') = 开/聚焦智能窗口 + 写偏好;setMode('traditional') = 只写偏好,智能窗口里额外 focusMainWindow()(不关闭)。删除 getModeHomePath,视图记忆改 localStorage。current = 窗口角色;主窗口高亮自主,智能窗口高亮智能。menu.json 删除 /10263 整组;menus/index.ts 删除 AI_WORKBENCH_PATHS / filterByWorkbenchMode;routeHelper.ts 对应映射可清理。case.ts:logout() 末尾加 broadcastSync(APP_RESET, { reason:'case-exited' }),不需要角色判断。user-dropdown:doLogout 末尾加 broadcastSync(APP_RESET, { reason:'account-logged-out' })。unauthorized.ts:开头按角色分流(smart:广播 + 聚焦主窗口 + 关窗;main:原逻辑 + 广播),新增 { remote?: boolean } 参数防回声。persistent.ts:3.1 的重新读盘 + 3.2 的 unload 开关。AppTopBar.vue:新增 showUserDropdown prop(默认 true);工作台传 false。ai/views/aiWorkbench/index.vue:移除 show-case-exit、@exit-case、onExitCase 及 exitCaseConfirmOptions 相关代码;传 :show-user-dropdown="false"。[Logo 清鉴·智能工作台] [案件:xxx(只读)] ...... [下载管理] [自主|智能] | [— □ ×]
persistent.ts):开两个 dev 窗口手工验证 token/case 互读。APP_RESET / AUTH_INVALID 接入 + 双向验证。| 场景 | 期望 |
|---|---|
| 智能窗口顶栏 | 无退出案件、无用户菜单;案件名只读;窗口控制正常 |
| 主窗口退出案件 | 智能窗口关闭;主窗口在案件管理页 |
| 主窗口换案 | 智能窗口若开着则整窗重载到新案;若没开且偏好智能则打开;无旧案件残留 |
| 主窗口退出登录 | 智能窗口关闭;主窗口在授权页 |
| 主窗口 401 | 主窗口弹一个框;智能窗口关闭 |
| 智能窗口触发 401 | 智能窗口关闭;主窗口被拉到前台并弹一个失效提示,且只有一个框 |
| 许可证失效 | 智能窗口关闭;主窗口进授权页 |
| 智能窗口点 X 后再开 | 单例重建,恢复 bounds 与上次视图,数据从后端重新拉 |
| 主窗口最小化到托盘 | 智能窗口继续可对话;托盘恢复主窗口 |
| 双击桌面图标 | 主窗口恢复,已有智能窗口被聚焦 |
| 关案后立刻换案 | 新案数据正确,旧案不被 unload 快照覆盖 |
| 纯浏览器打开前端 | 点「智能」仍在同窗进工作台,无 IPC 报错 |
| dev(history)/ prod(hash + file://) | 都能开窗、SSE 流式、下载进度正常 |
APP_RESET 的 smart 分支从 closeSmartWindow 改成跳占位路由。contextIsolation:false / nodeIntegration:true 与主窗口保持一致,安全加固作为独立议题。新增
ai-electron/electron/service/window/smartWindow.tsai-electron/electron/controller/windowCtl.tsai-electron/frontend/src/core/logics/appWindow.tsai-electron/frontend/src/core/logics/sessionSync.ts修改
ai-electron/electron/service/os/os.js、controller/osCtl.js、controller/downloadCtl.jsai-electron/electron/service/os/tray.js、preload/lifecycle.tsai-electron/electron/config/config.default.tsai-electron/frontend/src/main.tsai-electron/frontend/src/core/utils/cache/persistent.tsai-electron/frontend/src/core/router/guard/index.tsai-electron/frontend/src/core/logics/workbenchMode.tsai-electron/frontend/src/core/router/menus/index.tsai-electron/frontend/src/core/store/modules/menu.jsonai-electron/frontend/src/core/store/modules/case.tsai-electron/frontend/src/core/utils/http/axios/unauthorized.tsai-electron/frontend/src/core/components/Application/src/AppTopBar.vueai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vueai-electron/frontend/src/core/layouts/default/header/index.vueai-electron/frontend/src/core/layouts/default/header/components/user-dropdown/index.vue(仅加广播)ai-electron/frontend/src/ai/views/aiWorkbench/index.vueai-electron/frontend/src/core/hooks/desktop/useElectronWindow.ts(仅文案)