Ver Fonte

feat(electron): 智能工作台拆分为独立窗口并补齐跨窗口同步

- 新增 smartWindow 单例服务与 windowCtl:独立顶层窗口、bounds 持久化、按 sender 定位窗口控制
- 渲染层按窗口角色分流:主窗口遇到 /workbench 自动开窗,智能窗口只承载工作台
- 跨窗口同步:退案/退出登录/401/许可证失效关闭智能窗口,换案后整窗重载
- 智能窗口移除退案与账号入口,顶栏改为灰色单键切换 + 操作提示
- 移除 AI 菜单组,旧地址由守卫引导到工作台
- 修复 persistent.ts 跨窗口 storage 事件失效与 unload 旧快照覆盖新案件
- 托盘退出增加中止任务确认
cc há 2 dias atrás
pai
commit
d7273bb623
29 ficheiros alterados com 1120 adições e 421 exclusões
  1. 7 6
      ai-electron/electron/config/config.default.ts
  2. 41 0
      ai-electron/electron/config/windowOptions.ts
  3. 14 7
      ai-electron/electron/controller/downloadCtl.js
  4. 26 15
      ai-electron/electron/controller/osCtl.js
  5. 123 0
      ai-electron/electron/controller/windowCtl.ts
  6. 6 2
      ai-electron/electron/preload/lifecycle.ts
  7. 23 15
      ai-electron/electron/service/os/os.js
  8. 52 6
      ai-electron/electron/service/os/tray.js
  9. 232 0
      ai-electron/electron/service/window/smartWindow.ts
  10. 9 21
      ai-electron/frontend/src/ai/views/aiWorkbench/index.vue
  11. 13 6
      ai-electron/frontend/src/core/components/Application/src/AppTopBar.vue
  12. 67 72
      ai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vue
  13. 38 3
      ai-electron/frontend/src/core/components/Application/src/WindowControls.vue
  14. 1 3
      ai-electron/frontend/src/core/enums/cacheEnum.ts
  15. 2 2
      ai-electron/frontend/src/core/hooks/desktop/useElectronWindow.ts
  16. 3 0
      ai-electron/frontend/src/core/layouts/default/header/components/user-dropdown/index.vue
  17. 2 9
      ai-electron/frontend/src/core/layouts/default/header/index.less
  18. 16 18
      ai-electron/frontend/src/core/layouts/default/header/index.vue
  19. 162 0
      ai-electron/frontend/src/core/logics/appWindow.ts
  20. 11 12
      ai-electron/frontend/src/core/logics/exitCaseConfirm.ts
  21. 34 0
      ai-electron/frontend/src/core/logics/sessionSync.ts
  22. 32 81
      ai-electron/frontend/src/core/logics/workbenchMode.ts
  23. 79 16
      ai-electron/frontend/src/core/router/guard/index.ts
  24. 4 24
      ai-electron/frontend/src/core/router/menus/index.ts
  25. 28 6
      ai-electron/frontend/src/core/store/modules/case.ts
  26. 0 82
      ai-electron/frontend/src/core/store/modules/menu.json
  27. 39 13
      ai-electron/frontend/src/core/utils/cache/persistent.ts
  28. 39 2
      ai-electron/frontend/src/core/utils/http/axios/unauthorized.ts
  29. 17 0
      ai-electron/frontend/src/main.ts

+ 7 - 6
ai-electron/electron/config/config.default.ts

@@ -1,6 +1,7 @@
 import path from 'path';
 import { getBaseDir } from 'ee-core/ps';
 import type { Config } from 'ee-core';
+import { sharedWebPreferences } from './windowOptions';
 
 /**
  * 默认配置
@@ -11,14 +12,14 @@ export default (): Partial<Config> => {
     singleLock: true,
     windowsOption: {
       title: 'electron-egg',
-      width: 1080,
-      height: 780,
-      minWidth: 1080,
-      minHeight: 780,
+      width: 1280,
+      height: 860,
+      minWidth: 1280,
+      minHeight: 860,
       webPreferences: {
         //webSecurity: false,
-        contextIsolation: false, // false -> 可在渲染进程中使用electron的api,true->需要bridge.js(contextBridge)
-        nodeIntegration: true,
+        // 与智能工作台窗口共用同一份配置(见 windowOptions.ts),避免两窗口行为漂移
+        ...sharedWebPreferences,
         //preload: path.join(getElectronDir(), 'preload', 'bridge.js'),
       },
       frame: false,

+ 41 - 0
ai-electron/electron/config/windowOptions.ts

@@ -0,0 +1,41 @@
+import path from 'path';
+import { getBaseDir } from 'ee-core/ps';
+import type { BrowserWindowConstructorOptions } from 'electron';
+
+/**
+ * 主窗口与智能工作台窗口共用的 webPreferences。
+ *
+ * 两个窗口必须一致:渲染进程目前依赖 nodeIntegration 直接 require('electron') 调 IPC,
+ * 智能窗口若单独改成 contextIsolation,现有 preload 又没启用,行为会和主窗口不一致。
+ */
+export const sharedWebPreferences = {
+  // webSecurity: false,
+  contextIsolation: false,
+  nodeIntegration: true,
+};
+
+/** 智能工作台窗口的默认尺寸与下限(bounds 落盘恢复时也按这个下限校验) */
+export const smartWindowSize = {
+  width: 1280,
+  height: 860,
+  minWidth: 1280,
+  minHeight: 860,
+};
+
+/**
+ * 智能工作台窗口选项。
+ *
+ * 刻意不设 parent:Windows 下带 parent 的子窗口会始终悬浮在主窗口之上,
+ * 用户无法把主窗口切到前面;VS Code 的 Agents window 也是独立顶层窗口。
+ */
+export function getSmartWindowOptions(): BrowserWindowConstructorOptions {
+  return {
+    title: '清鉴 · 智能工作台',
+    ...smartWindowSize,
+    webPreferences: { ...sharedWebPreferences },
+    frame: false,
+    show: false,
+    backgroundColor: '#ffffff',
+    icon: path.join(getBaseDir(), 'public', 'images', 'logo-32.png'),
+  };
+}

+ 14 - 7
ai-electron/electron/controller/downloadCtl.js

@@ -1,19 +1,26 @@
 'use strict';
 
-import { getMainWindow } from 'ee-core/electron';
+import { BrowserWindow } from 'electron';
 import { downloadService } from '../service/download/downloadService';
 import { logger } from 'ee-core/log';
 
 /** 进度推送通道:界面订阅它刷新"下载中"的进度与状态 */
 export const DOWNLOAD_PROGRESS_CHANNEL = 'downloadCtl:progress';
 
-/** 把 in-flight 状态推给渲染进程(窗口还没建好时忽略) */
+/**
+ * 把 in-flight 状态推给所有渲染进程。
+ *
+ * <p>智能工作台顶栏也挂了下载管理器,且 AI 工具产物多半在那边产生;
+ * 只发主窗口会让智能窗口永远看不到进度。
+ */
 function broadcast(item) {
-  try {
-    const win = getMainWindow();
-    if (win && !win.isDestroyed?.()) win.webContents.send(DOWNLOAD_PROGRESS_CHANNEL, item);
-  } catch {
-    // 窗口已关闭:忽略
+  for (const win of BrowserWindow.getAllWindows()) {
+    if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
+    try {
+      win.webContents.send(DOWNLOAD_PROGRESS_CHANNEL, item);
+    } catch (e) {
+      logger.warn('[downloadCtl] 广播下载进度失败:', e instanceof Error ? e.message : String(e));
+    }
   }
 }
 

+ 26 - 15
ai-electron/electron/controller/osCtl.js

@@ -1,4 +1,5 @@
-import { shell } from 'electron';
+import { BrowserWindow, shell } from 'electron';
+import { getMainWindow } from 'ee-core/electron';
 import { os } from "../service/os/os";
 import { localSettingsService } from "../service/setting/localSettings";
 import path from 'node:path';
@@ -8,6 +9,16 @@ import { logger } from "ee-core/log";
 /** 本机设置里存离线地图目录的键名 */
 const MAP_LOCAL_KEY = 'mapLocal';
 
+/**
+ * 按 IPC 发起方解析要操作的窗口。
+ * 无边框窗口的控制按钮不带窗口参数:智能工作台里点最小化/关闭,必须操作它自己。
+ */
+function resolveSenderWindow(event) {
+  const sender = event?.sender;
+  const win = sender ? BrowserWindow.fromWebContents(sender) : null;
+  return win && !win.isDestroyed() ? win : getMainWindow();
+}
+
 
 /**
  * 窗口控制(用于无边框窗口拖拽)
@@ -17,38 +28,38 @@ class OsCtl {
   /**
    * 最小化窗口
    */
-  async minimizeWindow() {
-    await os.minimizeWindow();
+  async minimizeWindow(params, event) {
+    await os.minimizeWindow(resolveSenderWindow(event));
   }
   /**
    * 最大化窗口
    */
-  async maximizeWindow() {
-    await os.maximizeWindow();
+  async maximizeWindow(params, event) {
+    await os.maximizeWindow(resolveSenderWindow(event));
   }
   /**
    * 取消最大化窗口
    */
-  async unmaximizeWindow() {
-    await os.unmaximizeWindow();
+  async unmaximizeWindow(params, event) {
+    await os.unmaximizeWindow(resolveSenderWindow(event));
   }
   /**
    * 关闭窗口
    */
-  async closeWindow() {
-    await os.closeWindow();
+  async closeWindow(params, event) {
+    await os.closeWindow(resolveSenderWindow(event));
   }
   /**
    * 是否最大化窗口
    */
-  async isMaximized() {
-    return await os.isMaximized();
+  async isMaximized(params, event) {
+    return await os.isMaximized(resolveSenderWindow(event));
   }
   /**
    * 切换开发工具
    */
-  async toggleDevTools() {
-    await os.toggleDevTools();
+  async toggleDevTools(params, event) {
+    await os.toggleDevTools(resolveSenderWindow(event));
   }
   /**
    * 打开用户数据目录
@@ -65,8 +76,8 @@ class OsCtl {
   /**
    * 选择目录(返回目录路径)
    */
-  async selectDirectory() {
-    return await os.selectDirectory();
+  async selectDirectory(params, event) {
+    return await os.selectDirectory(resolveSenderWindow(event));
   }
   /**
    * 分块读本机文件(离线地图瓦片用;params: { filePath, offset?, length })。

+ 123 - 0
ai-electron/electron/controller/windowCtl.ts

@@ -0,0 +1,123 @@
+import { BrowserWindow } from 'electron';
+import type { WebContents } from 'electron';
+import { logger } from 'ee-core/log';
+import {
+  closeSmartWindow,
+  focusMainWindow,
+  isSmartWindow,
+  isSmartWindowOpen,
+  openSmartWindow,
+} from '../service/window/smartWindow';
+
+/** 主进程 → 渲染进程的跨窗口状态事件通道 */
+export const SYNC_EVENT_CHANNEL = 'app:state';
+
+interface IpcEventLike {
+  sender?: WebContents;
+}
+
+interface OpenSmartWindowParams {
+  fullPath?: string;
+  reloadIfOpen?: boolean;
+  navigateIfOpen?: boolean;
+}
+
+interface SyncStateParams {
+  event?: {
+    type?: string;
+    payload?: Record<string, unknown>;
+  };
+}
+
+function toMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * 窗口管理 IPC:通道名 controller/windowCtl/<方法>(ee-core 按文件名自动注册)。
+ *
+ * 统一返回 { success, data|message },失败不抛到 Electron 层。
+ */
+class WindowCtl {
+  /** 打开/聚焦智能工作台窗口;params { fullPath?, reloadIfOpen? } */
+  async openSmartWindow(params: OpenSmartWindowParams = {}) {
+    try {
+      openSmartWindow({
+        fullPath: params?.fullPath,
+        reloadIfOpen: params?.reloadIfOpen === true,
+        navigateIfOpen: params?.navigateIfOpen === true,
+      });
+      return { success: true, data: { open: true } };
+    } catch (e) {
+      const message = toMessage(e);
+      logger.error('[windowCtl] openSmartWindow failed:', message);
+      return { success: false, message };
+    }
+  }
+
+  /** 关闭智能工作台窗口(已关闭时是 no-op) */
+  async closeSmartWindow() {
+    try {
+      closeSmartWindow();
+      return { success: true };
+    } catch (e) {
+      const message = toMessage(e);
+      logger.error('[windowCtl] closeSmartWindow failed:', message);
+      return { success: false, message };
+    }
+  }
+
+  async isSmartWindowOpen() {
+    return { success: true, data: { open: isSmartWindowOpen() } };
+  }
+
+  /** 智能窗口遇到登录失效时,把主窗口拉到前台负责提示 */
+  async focusMainWindow() {
+    try {
+      focusMainWindow();
+      return { success: true };
+    } catch (e) {
+      const message = toMessage(e);
+      logger.error('[windowCtl] focusMainWindow failed:', message);
+      return { success: false, message };
+    }
+  }
+
+  /**
+   * 渲染进程启动时问一次自己的角色。
+   *
+   * <p>ee-core 的 IPC 注册是 fn.call(controller, params, event),
+   * event.sender 就是发起调用的 webContents。
+   */
+  async getWindowRole(_params: unknown, event?: IpcEventLike) {
+    const win = event?.sender ? BrowserWindow.fromWebContents(event.sender) : null;
+    return { success: true, data: { role: isSmartWindow(win) ? 'smart' : 'main' } };
+  }
+
+  /**
+   * 跨窗口状态同步:把语义事件发给除发送者外的所有窗口。
+   *
+   * <p>只做转发,不解释业务;接收方负责按事件类型重读事实源或关闭自己。
+   */
+  async syncState(params: SyncStateParams, event?: IpcEventLike) {
+    const payload = params?.event;
+    if (!payload?.type) {
+      return { success: false, message: '同步事件缺少 type' };
+    }
+
+    const senderId = event?.sender?.id;
+    for (const win of BrowserWindow.getAllWindows()) {
+      if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
+      if (senderId && win.webContents.id === senderId) continue;
+      try {
+        win.webContents.send(SYNC_EVENT_CHANNEL, payload);
+      } catch (e) {
+        logger.warn('[windowCtl] syncState send failed:', toMessage(e));
+      }
+    }
+    return { success: true };
+  }
+}
+
+WindowCtl.toString = () => 'WindowCtl';
+export default WindowCtl;

+ 6 - 2
ai-electron/electron/preload/lifecycle.ts

@@ -4,6 +4,7 @@ import { getConfig } from 'ee-core/config';
 import { getMainWindow } from 'ee-core/electron';
 import { disposeCodex } from '../service/codex';
 import { locateCodexBinary } from '../service/codex/codexLocator';
+import { focusSmartWindow } from '../service/window/smartWindow';
 
 class Lifecycle {
   /**
@@ -25,14 +26,17 @@ class Lifecycle {
   async electronAppReady(): Promise<void> {
     logger.info('[lifecycle] electron-app-ready');
 
-    // When double clicking the icon, display the opened window
+    // When double clicking the icon, display the opened windows
     electronApp.on('second-instance', () => {
       const win = getMainWindow();
       if (win.isMinimized()) {
         win.restore();
       }
       win.show();
-      win.focus();
+      // 智能工作台开着时优先把用户拉回工作台,否则聚焦主窗口
+      if (!focusSmartWindow()) {
+        win.focus();
+      }
     });
   }
 

+ 23 - 15
ai-electron/electron/service/os/os.js

@@ -8,23 +8,31 @@ import https from "https";
 import FormData from "form-data";
 class Os {
 
-  async minimizeWindow() {
-    getMainWindow().minimize();
+  /**
+   * 解析要操作的窗口:优先用调用方传入的窗口(智能工作台),否则回落到主窗口。
+   * 无边框窗口的按钮都走这里,避免智能窗口的最小化/关闭去操作主窗口。
+   */
+  resolveWindow(win) {
+    return win && !win.isDestroyed() ? win : getMainWindow();
+  }
+
+  async minimizeWindow(win) {
+    this.resolveWindow(win).minimize();
   }
-  async maximizeWindow() {
-    getMainWindow().maximize();
+  async maximizeWindow(win) {
+    this.resolveWindow(win).maximize();
   }
-  async unmaximizeWindow() {
-    getMainWindow().unmaximize();
+  async unmaximizeWindow(win) {
+    this.resolveWindow(win).unmaximize();
   }
-  async closeWindow() {
-    getMainWindow().close();
+  async closeWindow(win) {
+    this.resolveWindow(win).close();
   }
-  async isMaximized() {
-    return getMainWindow().isMaximized();
+  async isMaximized(win) {
+    return this.resolveWindow(win).isMaximized();
   }
-  async toggleDevTools() {
-    getMainWindow().webContents.toggleDevTools();
+  async toggleDevTools(win) {
+    this.resolveWindow(win).webContents.toggleDevTools();
   }
   async openUserDataFolder() {
     const userDataPath = app.getPath('userData');
@@ -36,9 +44,9 @@ class Os {
     await shell.openPath(installDir);
   }
 
-  async selectDirectory() {
-    const win = getMainWindow();
-    const result = await dialog.showOpenDialog(win, {
+  async selectDirectory(win) {
+    const parent = this.resolveWindow(win);
+    const result = await dialog.showOpenDialog(parent, {
       title: '选择目录',
       properties: ['openDirectory', 'createDirectory'],
     });

+ 52 - 6
ai-electron/electron/service/os/tray.js

@@ -1,9 +1,10 @@
-import { Tray, Menu } from 'electron';
+import { Tray, Menu, dialog, BrowserWindow } from 'electron';
 import { join } from 'path';
 import { getBaseDir } from 'ee-core/ps';
 import { logger } from 'ee-core/log';
 import { app as electronApp } from 'electron';
 import { getMainWindow, getCloseAndQuit, setCloseAndQuit } from 'ee-core/electron';
+import { isSmartWindowOpen } from '../window/smartWindow';
 
 /**
  * 托盘
@@ -13,6 +14,7 @@ class TrayService {
 
   constructor() {
     this.tray = null;
+    this.quitConfirming = false;
     this.config = {
       title: 'electron-egg',
       icon: '/public/images/tray.png'
@@ -30,19 +32,63 @@ class TrayService {
 
     // tray icon
     const iconPath = join(getBaseDir(), cfg.icon);
-  
+
+    /**
+     * 托盘退出前确认:退出会中止智能工作台正在进行的对话/任务。
+     *
+     * <p>托盘点击发生在主进程,此时主窗口可能已经收进托盘(不可见),
+     * 没法可靠地在渲染层弹 Ant Design 确认框,所以用系统原生对话框。
+     */
+    const quitWithConfirm = async () => {
+      if (this.quitConfirming) return;
+      this.quitConfirming = true;
+      try {
+        const options = {
+          type: 'warning',
+          title: '退出程序',
+          message: '确认退出清鉴?',
+          detail: isSmartWindowOpen()
+            ? '退出程序会中止智能工作台中正在进行的对话或任务,已生成的内容会保留。'
+            : '退出程序会关闭所有窗口。',
+          buttons: ['取消', '退出'],
+          defaultId: 0,
+          cancelId: 0,
+          noLink: true,
+        };
+        // 有聚焦窗口就挂成它的模态;托盘菜单打开时通常没有聚焦窗口,用独立对话框,
+        // 避免始终挂到可能被智能窗口挡住的 mainWindow 后面
+        const focused = BrowserWindow.getFocusedWindow();
+        const parent = focused && !focused.isDestroyed() ? focused : undefined;
+        const result = parent
+          ? await dialog.showMessageBox(parent, options)
+          : await dialog.showMessageBox(options);
+        if (result.response === 1) {
+          electronApp.quit();
+        }
+      } catch (e) {
+        logger.error('[tray] 退出确认失败:', e instanceof Error ? e.message : String(e));
+      } finally {
+        this.quitConfirming = false;
+      }
+    };
+
     // 托盘菜单功能列表
+    // 智能工作台不放入口:顶栏「智能」开关是唯一入口(案件/账号生命周期也只在主窗口)
     const trayMenuTemplate = [
       {
-        label: '显示',
+        label: '显示主窗口',
         click: function () {
+          if (mainWindow.isMinimized()) {
+            mainWindow.restore();
+          }
           mainWindow.show();
+          mainWindow.focus();
         }
       },
       {
-        label: '退出',
-        click: function () {
-          electronApp.quit();
+        label: '退出程序',
+        click: () => {
+          void quitWithConfirm();
         }
       }
     ]

+ 232 - 0
ai-electron/electron/service/window/smartWindow.ts

@@ -0,0 +1,232 @@
+import path from 'path';
+import { BrowserWindow, screen } from 'electron';
+import { getBaseDir, isDev } from 'ee-core/ps';
+import { logger } from 'ee-core/log';
+import { getMainWindow } from 'ee-core/electron';
+import { getSmartWindowOptions, smartWindowSize } from '../../config/windowOptions';
+import { localSettingsService } from '../setting/localSettings';
+
+/** 窗口 bounds 在本机设置里的键名 */
+const BOUNDS_KEY = 'smartWindowBounds';
+/** 智能窗口只承载工作台,非该路径一律改写 */
+const WORKBENCH_PATH = '/workbench';
+/** 主进程 → 主窗口的窗口开关状态通道 */
+export const SMART_WINDOW_STATE_CHANNEL = 'smart-window:state';
+
+export interface OpenSmartWindowOptions {
+  /** 完整路由(含 query),如 /workbench?newChat=1 */
+  fullPath?: string;
+  /** 窗口已存在时是否整窗重载(换案后需要重新读案件上下文) */
+  reloadIfOpen?: boolean;
+  /** 窗口已存在且目标带 query 时,是否导航到该地址(?newChat=1 / ?view=agent 这类明确意图) */
+  navigateIfOpen?: boolean;
+}
+
+interface SavedBounds {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+}
+
+/** 单例:应用内最多一个智能工作台窗口 */
+let smartWindow: BrowserWindow | null = null;
+
+export function getSmartWindow(): BrowserWindow | null {
+  return smartWindow && !smartWindow.isDestroyed() ? smartWindow : null;
+}
+
+export function isSmartWindowOpen(): boolean {
+  return !!getSmartWindow();
+}
+
+/** 判断某个窗口是不是智能工作台窗口(IPC 解析窗口角色用) */
+export function isSmartWindow(win: BrowserWindow | null | undefined): boolean {
+  const current = getSmartWindow();
+  return !!win && !!current && win.id === current.id;
+}
+
+/**
+ * 打开/聚焦智能工作台窗口。
+ *
+ * 已存在时只做 restore/show/focus(reloadIfOpen 时再 reload),保证单例。
+ */
+export function openSmartWindow(options: OpenSmartWindowOptions = {}): BrowserWindow {
+  const fullPath = normalizeFullPath(options.fullPath);
+  const existing = getSmartWindow();
+
+  if (existing) {
+    focusWindow(existing);
+
+    if (options.reloadIfOpen) {
+      // 刚由本次导航创建、还在加载时不用再刷一次;已加载完成的才需要按新案件重建
+      if (!existing.webContents.isLoading()) {
+        existing.webContents.reload();
+      }
+    } else if (options.navigateIfOpen && fullPath.includes('?')) {
+      // 显式带 query(?newChat=1 / ?view=agent)说明是明确的导航意图;
+      // 普通打开/聚焦不动它当前所在的视图
+      loadRenderer(existing, fullPath);
+    }
+    return existing;
+  }
+
+  const win = new BrowserWindow({
+    ...getSmartWindowOptions(),
+    ...restoreBounds(),
+  });
+  smartWindow = win;
+
+  win.once('ready-to-show', () => {
+    if (!win.isDestroyed()) {
+      win.show();
+      win.focus();
+    }
+  });
+
+  win.on('close', () => saveBounds(win));
+  win.on('closed', () => {
+    if (smartWindow === win) smartWindow = null;
+    notifyState(false);
+  });
+
+  // 渲染进程崩溃后留一个空壳没有意义:销毁它,下次点「智能」重新创建
+  win.webContents.on('render-process-gone', (_event, details) => {
+    logger.error('[smartWindow] render-process-gone:', details?.reason);
+    if (!win.isDestroyed()) {
+      saveBounds(win);
+      win.destroy();
+    }
+  });
+
+  loadRenderer(win, fullPath);
+  notifyState(true);
+  return win;
+}
+
+export function closeSmartWindow(): void {
+  const win = getSmartWindow();
+  if (!win) return;
+  win.close();
+}
+
+/** 把已打开的智能窗口拉到前台;没打开返回 false */
+export function focusSmartWindow(): boolean {
+  const win = getSmartWindow();
+  if (!win) return false;
+  focusWindow(win);
+  return true;
+}
+
+/** 把主窗口拉到前台(智能窗口遇到登录/授权失效时,由主窗口负责提示) */
+export function focusMainWindow(): void {
+  try {
+    focusWindow(getMainWindow());
+  } catch (e) {
+    logger.warn('[smartWindow] focusMainWindow failed:', e instanceof Error ? e.message : String(e));
+  }
+}
+
+function focusWindow(win: BrowserWindow): void {
+  if (win.isMinimized()) win.restore();
+  win.show();
+  win.focus();
+}
+
+function normalizeFullPath(fullPath?: string): string {
+  const raw = typeof fullPath === 'string' ? fullPath.trim() : '';
+  if (!raw) return WORKBENCH_PATH;
+  // 只接受工作台地址,避免把业务页塞进智能窗口(如 /workbenchfoo 不算)
+  return /^\/workbench(\/|\?|$)/.test(raw) ? raw : WORKBENCH_PATH;
+}
+
+function loadRenderer(win: BrowserWindow, fullPath: string): void {
+  const devServerUrl = resolveDevServerUrl();
+  if (devServerUrl) {
+    win.loadURL(`${devServerUrl}${fullPath}`).catch((err) => {
+      logger.error('[smartWindow] loadURL failed:', err);
+    });
+    return;
+  }
+
+  // 生产是 file:// + hash 路由:hash 带上完整路径与 query
+  const indexPath = path.join(getBaseDir(), 'public', 'dist', 'index.html');
+  win.loadFile(indexPath, { hash: fullPath }).catch((err) => {
+    logger.error('[smartWindow] loadFile failed:', err);
+  });
+}
+
+/**
+ * 开发环境的前端地址:与 ee-core 加载主窗口同口径,端口来自 cmd/bin.js 的 dev.frontend.port。
+ * 生产环境(或读不到配置)返回 null,走 loadFile。
+ */
+function resolveDevServerUrl(): string | null {
+  if (!isDev()) return null;
+
+  try {
+    // eslint-disable-next-line @typescript-eslint/no-var-requires
+    const binConfig = require(path.join(getBaseDir(), 'cmd', 'bin.js'));
+    const frontend = binConfig?.dev?.frontend || {};
+    const protocol = typeof frontend.protocol === 'string' ? frontend.protocol : 'http://';
+    if (protocol === 'file://') return null;
+    const hostname = typeof frontend.hostname === 'string' ? frontend.hostname : 'localhost';
+    const port = Number(frontend.port) || 3100;
+    return `${protocol}${hostname}:${port}`;
+  } catch (e) {
+    logger.warn('[smartWindow] 读取 cmd/bin.js 失败,回退 http://localhost:3100');
+    return 'http://localhost:3100';
+  }
+}
+
+function saveBounds(win: BrowserWindow): void {
+  try {
+    const { x, y, width, height } = win.getBounds();
+    localSettingsService.set(BOUNDS_KEY, JSON.stringify({ x, y, width, height }));
+  } catch (e) {
+    logger.warn('[smartWindow] 保存窗口位置失败:', e instanceof Error ? e.message : String(e));
+  }
+}
+
+/** 恢复上次的位置与大小;越界或尺寸不合法时交给 Electron 默认放置 */
+function restoreBounds(): Partial<SavedBounds> {
+  try {
+    const raw = localSettingsService.get(BOUNDS_KEY);
+    if (!raw) return {};
+
+    const parsed = JSON.parse(raw) as Partial<SavedBounds>;
+    const { x, y, width, height } = parsed || {};
+    if (![x, y, width, height].every((value) => typeof value === 'number' && Number.isFinite(value))) {
+      return {};
+    }
+    if ((width as number) < smartWindowSize.minWidth || (height as number) < smartWindowSize.minHeight) {
+      return {};
+    }
+
+    // 显示器拔掉/分辨率变化后,旧坐标可能落在屏幕外
+    const visible = screen.getAllDisplays().some((display) => {
+      const area = display.workArea;
+      return (
+        (x as number) < area.x + area.width &&
+        (x as number) + (width as number) > area.x &&
+        (y as number) < area.y + area.height &&
+        (y as number) + (height as number) > area.y
+      );
+    });
+
+    return visible ? { x, y, width, height } : {};
+  } catch (e) {
+    logger.warn('[smartWindow] 恢复窗口位置失败:', e instanceof Error ? e.message : String(e));
+    return {};
+  }
+}
+
+function notifyState(open: boolean): void {
+  try {
+    const main = getMainWindow();
+    if (!main.isDestroyed() && !main.webContents.isDestroyed()) {
+      main.webContents.send(SMART_WINDOW_STATE_CHANNEL, { open });
+    }
+  } catch {
+    // 主窗口尚未创建或应用正在退出:忽略
+  }
+}

+ 9 - 21
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -1,7 +1,11 @@
 <template>
   <div class="awb-page">
-    <!-- 工作台顶栏:与布局壳共用 AppTopBar(结构/模式切换/头像/窗口控制都在里面) -->
-    <AppTopBar :case-name="caseName" :logo-title="'清鉴 · 智能工作台'" show-case-exit @exit-case="onExitCase">
+    <!-- 工作台顶栏:与布局壳共用 AppTopBar;账号操作只留在主窗口,这里不渲染用户菜单 -->
+    <AppTopBar
+      :case-name="caseName"
+      :logo-title="'清鉴 · 智能工作台'"
+      :show-user-dropdown="false"
+    >
       <!-- 下载管理:AI 会话的工具结果导出(CSV 等下载产物)都经它落到本机,入口与传统壳顶栏一致 -->
       <template #actions>
         <AppDownloadManager />
@@ -107,10 +111,7 @@
   import { useRoute } from 'vue-router';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { AppDownloadManager, AppTopBar } from '@/core/components/Application';
-  import { useMessage } from '@/core/hooks/web/useMessage';
-  import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
   import { getLastWorkbenchView, setLastWorkbenchView } from '@/core/logics/workbenchMode';
-  import { useUserStore } from '@/core/store/modules/case';
   import SessionList from '../../components/SessionList.vue';
   import ChatPanel from '../../components/ChatPanel.vue';
   import ArtifactPanel from '../../components/ArtifactPanel.vue';
@@ -122,16 +123,13 @@
   import { extractArtifacts } from '../../utils/artifacts';
   import type { AiExpert } from '../../api/types';
 
-  const userStore = useUserStore();
-  const { createConfirm } = useMessage();
-
   type WorkbenchView = 'chat' | 'model' | 'agent' | 'plugin';
   const WORKBENCH_VIEWS: WorkbenchView[] = ['chat', 'model', 'agent', 'plugin'];
 
   /**
    * 视图恢复,两个来源,前者优先:
    * 1. ?view=xxx —— 从上传流程等外部全屏页返回工作台时带回来的视图;
-   * 2. 上次停留的视图 —— 「自主 ⇄ 智能」来回切时工作台会重新挂载,靠它回到原来那一屏
+   * 2. 上次停留的视图 —— 智能窗口关掉重开、或换案后整窗重载时回到原来那一屏
    *    (工作台是单路由全屏壳,视图不落 URL,只能自己记,见 core/logics/workbenchMode)。
    * 两者都没有才交给下面的「进入工作台默认落点」决定。
    */
@@ -294,19 +292,9 @@
   }
 
   /**
-   * 退出案件:回案件管理页重新选择(工作台不允许直接切案件)。
-   * 破坏性动作(当前会话上下文、工作台状态都会离开)先弹确认框。
-   *
-   * 模式偏好保持不动:用户选的是「我的主界面是工作台」,退出案件只是去挑另一个案件,
-   * 重新开案后仍按偏好回工作台(落点在 case store 的 afteropenCaseAction)。
+   * 工作台不提供「退出案件」:案件/账号生命周期只由主窗口发起。
+   * 用户要退出案件时点顶栏「自主」,模式开关会把主窗口拉到前台。
    */
-  function onExitCase() {
-    createConfirm(
-      exitCaseConfirmOptions(async () => {
-        await userStore.logout(true);
-      }, caseName.value),
-    );
-  }
 </script>
 
 <style lang="less">

+ 13 - 6
ai-electron/frontend/src/core/components/Application/src/AppTopBar.vue

@@ -31,15 +31,15 @@
     <!-- 中间信息区(工作台:数据就绪 / 路由层 / 模型 等 chip) -->
     <slot name="info"></slot>
 
-    <!-- 模式切换:两个壳共用同一个开关与跳转口径;本壳即智能工作台,故 current 固定为 smart -->
-    <AppWorkbenchToggle v-if="showModeSwitch" current="smart" @change="setMode" />
+    <!-- 模式切换:current 由当前窗口角色决定(主窗口=自主,智能窗口=智能) -->
+    <AppWorkbenchToggle v-if="showModeSwitch" :current="toggleCurrent" @change="setMode" />
 
     <!-- 右侧动作区:传统壳放搜索 / 模板库 / 设置 / 退出;工作台放诊断 -->
     <div class="app-topbar__actions jeesite-layout-header-action">
       <slot name="actions"></slot>
 
-      <!-- 用户头像下拉(修改密码 / 用户管理 / 退出登录) -->
-      <UserDropdown />
+      <!-- 用户头像下拉(修改密码 / 用户管理 / 退出登录);智能窗口不承载账号操作 -->
+      <UserDropdown v-if="showUserDropdown" />
 
       <!--
         分割线与窗口控制成对出现:非桌面端没有窗口控制,单留一条竖线是多余的。
@@ -69,21 +69,25 @@
    *
    * 模式切换的跳转口径也收在这里(与路由守卫一致):目标模式与当前页不匹配时才跳转。
    */
+  import { computed } from 'vue';
   import AppLogo from '@/core/components/Application/src/AppLogo.vue';
   import UserDropdown from '@/core/layouts/default/header/components/user-dropdown/index.vue';
   import { AppWorkbenchToggle, WindowControls } from '@/core/components/Application';
   import { useElectronWindow } from '@/core/hooks/desktop/useElectronWindow';
   import { useWorkbenchModeSwitch } from '@/core/logics/workbenchMode';
+  import { isSmartWindow } from '@/core/logics/appWindow';
 
   const props = withDefaults(
     defineProps<{
       /** 顶栏主题(跟随布局设置,默认亮色) */
       theme?: 'light' | 'dark';
-      /** 是否显示模式切换开关(默认显示;开关按「智能工作台」渲染,传统壳自己往 #actions 里放) */
+      /** 是否显示模式切换开关(默认显示;开关按当前窗口角色渲染) */
       showModeSwitch?: boolean;
+      /** 是否显示用户头像下拉(智能工作台不承载账号操作,传 false) */
+      showUserDropdown?: boolean;
       /** 当前案件名(空则不渲染案件区) */
       caseName?: string | null;
-      /** 案件胶囊里是否显示「退出」(工作台用;退出后走案件管理重进) */
+      /** 案件胶囊里是否显示「退出」(传统壳用;智能窗口只读展示,不传) */
       showCaseExit?: boolean;
       /** 是否显示默认(应用)logo;使用方用 #logo 插槽时该开关无效 */
       showLogo?: boolean;
@@ -95,6 +99,7 @@
     {
       theme: 'light',
       showModeSwitch: true,
+      showUserDropdown: true,
       caseName: null,
       showCaseExit: false,
       showLogo: true,
@@ -109,6 +114,8 @@
   }>();
 
   const { setMode } = useWorkbenchModeSwitch();
+  /** 开关高亮跟随当前窗口角色:不是「偏好」,而是「我现在在哪一侧」 */
+  const toggleCurrent = computed(() => (isSmartWindow() ? 'smart' : 'traditional'));
   /** 与 WindowControls 用同一判定:能渲染窗口控制,才需要这条分割线 */
   const { available: windowControlsAvailable } = useElectronWindow();
 </script>

+ 67 - 72
ai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vue

@@ -1,28 +1,25 @@
 <template>
-  <div class="jeesite-workbench-switch" role="group" aria-label="界面模式切换">
-    <button type="button" :class="{ 'is-on': mode === 'traditional' }" @click="setMode('traditional')">
-      <Icon icon="i-mdi:view-dashboard-outline" :size="13" />
-      自主
+  <Tooltip :title="tip" placement="bottom" :mouseEnterDelay="0.4">
+    <button type="button" class="jeesite-workbench-switch" :aria-label="tip" @click="switchMode">
+      <Icon :icon="targetIcon" :size="14" />
+      <span>{{ targetLabel }}</span>
     </button>
-    <button type="button" :class="{ 'is-on': mode === 'smart' }" @click="setMode('smart')">
-      <Icon icon="i-mdi:star-four-points-outline" :size="13" />
-      智能
-    </button>
-  </div>
+  </Tooltip>
 </template>
 
 <script lang="ts" setup>
   /**
-   * 主界面模式切换:传统(现有布局)⇄ 智能(全屏 AI 工作台)。
+   * 主界面模式切换(顶栏文字按钮)。
    *
-   * 使用方通过 `current` 传「当前所在壳」的模式,开关因此不会与实际布局互相矛盾;
-   * 模式偏好的持久化与路由跳转由使用方决定(传统壳里切智能 → /workbench;工作台里切传统 → 首页),
-   * 这里只负责发出意图事件,保持组件无路由依赖。
+   * 展示的是「将要去哪一侧」而不是「现在在哪一侧」:
+   *  - 主窗口(自主)里显示「智能」,点击打开/聚焦独立工作台窗口;
+   *  - 智能窗口(智能)里显示「自主」,点击写偏好并把主窗口拉到前台(不关闭自己)。
    *
-   * 点当前已选中的一侧也会发事件:`current` 是「所在壳」,可能与「偏好」不同
-   * (例如偏好是智能、当前在业务页的传统壳),把偏好对齐同样要让使用方收到这一下。
+   * 不使用胶囊/主色:它和顶栏的搜索、下载、设置一样是普通动作,灰色文字按钮更协调;
+   * 用途靠 Tooltip 说明,避免只看到一个模式名不知道点下去会发生什么。
    */
   import { computed } from 'vue';
+  import { Tooltip } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useAppStoreWithOut } from '@/core/store/modules/app';
   import type { WorkbenchMode } from '@/core/store/modules/app';
@@ -40,81 +37,79 @@
   /** current 未传时直接读全局状态(工作台场景);传了则以受控值为准 */
   const mode = computed<WorkbenchMode>(() => props.current ?? appStore.getWorkbenchMode);
 
-  function setMode(value: WorkbenchMode) {
-    emit('change', value);
+  /** 按钮展示「另一个模式」:点它就是切过去 */
+  const targetMode = computed<WorkbenchMode>(() => (mode.value === 'smart' ? 'traditional' : 'smart'));
+  const targetLabel = computed(() => (targetMode.value === 'smart' ? '智能' : '自主'));
+  const targetIcon = computed(() =>
+    targetMode.value === 'smart' ? 'i-mdi:star-four-points-outline' : 'i-mdi:view-dashboard-outline',
+  );
+
+  /** 提示写「点下去会发生什么」,而不是重复按钮上的模式名 */
+  const tip = computed(() =>
+    targetMode.value === 'smart' ? '打开智能工作台(独立窗口,当前页面保留)' : '回到自主模式主窗口',
+  );
+
+  function switchMode() {
+    emit('change', targetMode.value);
   }
 </script>
 
 <style lang="less" scoped>
   @prefix-cls: ~'jeesite-workbench-switch';
 
-  .jeesite-workbench-switch {
+  .@{prefix-cls} {
     display: inline-flex;
-    gap: 2px;
-    padding: 1px;
-    background: rgba(0, 0, 0, 0.06);
-    border: 1px solid rgba(0, 0, 0, 0.08);
-    border-radius: 8px;
-
-    button {
-      display: inline-flex;
-      align-items: center;
-      gap: 5px;
-      height: 24px;
-      padding: 0 12px;
-      font-size: 12.5px;
-      /* 显式取文字色:布局壳给顶栏设过 color:#fff,继承下来未选中项会白字白底看不见 */
-      color: @text-color-secondary;
-      white-space: nowrap;
-      opacity: 0.85;
-      background: transparent;
-      border: none;
-      border-radius: 6px;
-      cursor: pointer;
-      transition:
-        background 0.15s ease-out,
-        color 0.15s ease-out;
-
-      &:hover {
-        opacity: 1;
-      }
-
-      &.is-on {
-        color: @primary-color;
-        background: #fff;
-        font-weight: 650;
-        opacity: 1;
-        box-shadow: 0 1px 4px rgba(24, 39, 88, 0.14);
-      }
+    align-items: center;
+    gap: 5px;
+    height: 32px;
+    padding: 0 8px;
+    font-size: 12.5px;
+    font-weight: 500;
+    /* 显式取文字色:布局壳给顶栏设过 color:#fff,继承下来会白字白底看不见 */
+    color: @text-color-secondary;
+    white-space: nowrap;
+    background: transparent;
+    border: none;
+    border-radius: 6px;
+    cursor: pointer;
+    transition:
+      background 0.15s ease-out,
+      color 0.15s ease-out;
+
+    &:hover {
+      color: @text-color-base;
+      background: rgba(0, 0, 0, 0.045);
+    }
+
+    &:active {
+      background: rgba(0, 0, 0, 0.065);
     }
   }
 
-  /* 顶栏主题为深色(菜单配色选择深色)时,切换器文字要反白 */
-  .app-topbar--dark .jeesite-workbench-switch {
-    background: rgba(255, 255, 255, 0.1);
-    border-color: rgba(255, 255, 255, 0.16);
+  /* 顶栏主题为深色(菜单配色选择深色)时反白 */
+  .app-topbar--dark .@{prefix-cls} {
+    color: rgba(255, 255, 255, 0.75);
 
-    button {
-      color: rgba(255, 255, 255, 0.75);
+    &:hover {
+      color: #fff;
+      background: rgba(255, 255, 255, 0.1);
+    }
 
-      &.is-on {
-        color: @primary-color;
-        background: rgba(255, 255, 255, 0.92);
-      }
+    &:active {
+      background: rgba(255, 255, 255, 0.14);
     }
   }
 
-  html[data-theme='dark'] .jeesite-workbench-switch {
-    background: rgba(255, 255, 255, 0.08);
-    border-color: rgba(255, 255, 255, 0.12);
+  html[data-theme='dark'] .@{prefix-cls} {
+    color: rgba(255, 255, 255, 0.72);
 
-    button {
-      color: rgba(255, 255, 255, 0.72);
+    &:hover {
+      color: #fff;
+      background: rgba(255, 255, 255, 0.1);
     }
 
-    button.is-on {
-      color: #fff;
-      background: rgba(255, 255, 255, 0.18);
+    &:active {
+      background: rgba(255, 255, 255, 0.14);
     }
   }
 </style>

+ 38 - 3
ai-electron/frontend/src/core/components/Application/src/WindowControls.vue

@@ -18,12 +18,12 @@
       </button>
     </Tooltip>
 
-    <Tooltip title="关闭(最小化到托盘)" placement="bottom" :mouseEnterDelay="0.4">
+    <Tooltip :title="closeTitle" placement="bottom" :mouseEnterDelay="0.4">
       <button
         type="button"
         class="window-control-btn window-control-btn--close"
-        aria-label="关闭窗口"
-        @click="closeToTray"
+        :aria-label="closeTitle"
+        @click="handleClose"
       >
         <CloseOutlined />
       </button>
@@ -32,13 +32,48 @@
 </template>
 
 <script lang="ts" setup>
+  import { computed } from 'vue';
   import { BorderOutlined, CloseOutlined, MinusOutlined, SwitcherOutlined } from '@ant-design/icons-vue';
   import { Tooltip } from 'ant-design-vue';
   import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useMessage } from '@/core/hooks/web/useMessage';
   import { useElectronWindow } from '@/core/hooks/desktop/useElectronWindow';
+  import { isSmartWindow } from '@/core/logics/appWindow';
 
   const { prefixCls } = useDesign('window-controls');
   const { available, maximized, minimize, toggleMaximize, closeToTray } = useElectronWindow();
+  const { createConfirm } = useMessage();
+
+  /** 主窗口关闭是收进托盘;智能窗口关闭就是真关闭,且会中止进行中的任务 */
+  const closeTitle = computed(() =>
+    isSmartWindow() ? '关闭(进行中的对话/任务会中止)' : '关闭(最小化到托盘)',
+  );
+
+  /**
+   * 智能工作台的关闭要先确认:关窗会中止正在流式输出的对话/任务。
+   *
+   * <p>只拦「点关闭按钮」这一条用户路径;案件退出/退出登录/401 等程序化关闭
+   * 走 windowCtl.closeSmartWindow,由主进程直接关闭,不弹这个框。
+   */
+  function handleClose(): void {
+    if (!isSmartWindow()) {
+      closeToTray();
+      return;
+    }
+
+    createConfirm({
+      iconType: 'warning',
+      title: '关闭智能工作台',
+      content: '关闭窗口会中止正在进行的对话或任务,已生成的内容会保留。',
+      okText: '关闭',
+      cancelText: '取消',
+      okType: 'primary',
+      okButtonProps: { danger: true },
+      // 破坏性操作默认聚焦「取消」,避免一打开就误触 Enter
+      autoFocusButton: 'cancel',
+      onOk: () => closeToTray(),
+    });
+  }
 </script>
 
 <style lang="less">

+ 1 - 3
ai-electron/frontend/src/core/enums/cacheEnum.ts

@@ -10,9 +10,7 @@ export const MULTIPLE_TABS_KEY = 'MULTIPLE_TABS__';
 export const LOCALE_KEY = 'LOCALE__';
 export const APP_DARK_MODE_KEY = 'APP_DARK_MODE__';
 export const APP_WORKBENCH_MODE_KEY = 'APP_WORKBENCH_MODE__';
-/** 传统(自主)模式最后停留的路由:切到智能再切回来时恢复,避免每次掉回首页 */
-export const APP_WORKBENCH_PATH_KEY = 'APP_WORKBENCH_PATH__';
-/** 智能工作台最后停留的视图(chat/model/agent/plugin):切到传统再切回来时恢复 */
+/** 智能工作台最后停留的视图(chat/model/agent/plugin):窗口关掉重开时恢复 */
 export const APP_WORKBENCH_VIEW_KEY = 'APP_WORKBENCH_VIEW__';
 export const APP_LOCAL_CACHE_KEY = 'COMMON_LOCAL__';
 export const APP_SESSION_CACHE_KEY = 'COMMON_SESSION__';

+ 2 - 2
ai-electron/frontend/src/core/hooks/desktop/useElectronWindow.ts

@@ -26,8 +26,8 @@ function resolveIpcRenderer(): IpcRendererLike | null {
 /**
  * 无边框窗口(frame: false)的控制能力。
  *
- * <p>关闭走的是「关闭到托盘」:主进程 tray.js 里 setCloseAndQuit(false) 拦截了 close 事件,
- * 窗口只 hide、进程不退,退出要从托盘菜单走。
+ * <p>关闭语义按窗口区分:主窗口被 tray.js 拦截为「收进托盘」;智能工作台窗口没有该拦截,
+ * close 即销毁(下次点「智能」重建)。主进程按 IPC 发起方定位窗口,见 osCtl.resolveSenderWindow。
  */
 export function useElectronWindow() {
   const ipcRenderer = resolveIpcRenderer();

+ 3 - 0
ai-electron/frontend/src/core/layouts/default/header/components/user-dropdown/index.vue

@@ -48,6 +48,7 @@
   import { DownOutlined, LockOutlined, PoweroffOutlined, TeamOutlined } from '@ant-design/icons-vue';
   import { PageEnum } from '@/core/enums/pageEnum';
   import { useMessage } from '@/core/hooks/web/useMessage';
+  import { broadcastSync } from '@/core/logics/appWindow';
   import { useAccountStore } from '@/core/store/modules/account';
   import { useUserStore } from '@/core/store/modules/case';
   import UserManageModal from '@/plat/views/user/UserManageModal.vue';
@@ -89,6 +90,8 @@
       async function doLogout() {
         caseStore.resetState();
         await accountStore.logout();
+        // 智能窗口没有账号入口,收到后关闭自己;主窗口保持唯一的登录提示/跳转方
+        broadcastSync({ type: 'APP_RESET', payload: { reason: 'account-logged-out' } });
         await router.replace({ path: PageEnum.BASE_AUTHORIZATION });
       }
 

+ 2 - 9
ai-electron/frontend/src/core/layouts/default/header/index.less

@@ -305,15 +305,8 @@
     }
   }
 
-  /* 模式切换开关(传统 ⇄ 智能)有组件自己的胶囊外观,上面 > div / > span 那套
-     “图标按钮”通用规则会把它撑成 38px 高、1.2em 字号的块,这里按组件规格还原;
-     图标被强制 20px + 左右 10px 内边距的那条已在浅/深色主题里用 :not 排除,
-     否则图标会和按钮文字被拉开。 */
-  &-action > .jeesite-workbench-switch {
-    height: auto;
-    line-height: normal;
-    font-size: inherit;
-  }
+  /* 模式切换按钮的根元素是 <button>,不会被上面 > div / > span 的图标按钮通用规则命中,
+     尺寸与行高由组件自己负责,这里不再覆盖。 */
 
   &-page-actions {
     display: flex;

+ 16 - 18
ai-electron/frontend/src/core/layouts/default/header/index.vue

@@ -28,7 +28,7 @@
         <AppSearch mode="bar" placeholder="搜索系统内容" />
       </div>
       <span :class="`${prefixCls}-search-divider`"></span>
-      <!-- 传统 ⇄ 智能:放在模板库前面。current 传死为 traditional —— 本壳就是传统壳 -->
+      <!-- 自主 ⇄ 智能:放在模板库前面。本壳是自主,current 传 traditional,胶囊按钮会显示「智能」 -->
       <AppWorkbenchToggle current="traditional" @change="handleWorkbenchModeChange" />
 
       <Tooltip v-if="showTemplateLibraryAction" title="模板库" placement="bottom" :mouseEnterDelay="0.5">
@@ -75,7 +75,7 @@
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useWorkbenchModeSwitch } from '@/core/logics/workbenchMode';
-  import { useAppStoreWithOut } from '@/core/store/modules/app';
+  import { isSmartWindowOpen } from '@/core/logics/appWindow';
   import type { WorkbenchMode } from '@/core/store/modules/app';
   import { exitCaseConfirmOptions } from '@/core/logics/exitCaseConfirm';
   import { useGo } from '@/core/hooks/web/usePage';
@@ -115,7 +115,6 @@
       const { prefixCls } = useDesign('layout-header');
       const { createConfirm } = useMessage();
       const { setMode: setWorkbenchMode } = useWorkbenchModeSwitch();
-      const appStore = useAppStoreWithOut();
       const userStore = useUserStore();
       const emitter = useEmitter();
       const route = useRoute();
@@ -209,30 +208,29 @@
       };
 
       /**
-       * 退出案件:与工作台、头像菜单同一套确认框与同一动作
+       * 退出案件:与头像菜单同一套确认框与同一动作
        * (store.logout(true) = 调后端 exitCase + 清案件态 + 回案件管理;账号登录态保留)。
+       * 智能工作台是案件作用域的从属窗口,开着时会被一并关闭,确认框里写明。
        */
-      const handleExitCase = () => {
+      const handleExitCase = async () => {
+        const smartOpen = await isSmartWindowOpen();
         createConfirm(
-          exitCaseConfirmOptions(async () => {
-            await userStore.logout(true);
-          }, currentCaseName.value),
+          exitCaseConfirmOptions(
+            async () => {
+              await userStore.logout(true);
+            },
+            currentCaseName.value,
+            smartOpen ? '智能工作台将同时关闭。' : '',
+          ),
         );
       };
 
       /**
-       * 传统壳里的模式开关(开关恒显示「传统」为当前,故只会收到"想切去哪一侧")。
-       *
-       * <p>切智能:记偏好 + 跳工作台(走统一口径,见 useWorkbenchModeSwitch)。
-       * <p>点传统:本壳已经是传统,只把偏好对齐成传统 —— 偏好会决定下次开案的落点,
-       * 用户在当前布局下点「传统」,意图就是「以后也走传统」,不能因为开关已高亮就吞掉这次点击。
+       * 传统壳里的模式开关:胶囊展示的是「另一个模式」——本壳是自主,所以按钮显示「智能」。
+       * 点击后打开/聚焦独立工作台窗口(走统一口径,见 useWorkbenchModeSwitch)。
        */
       const handleWorkbenchModeChange = (mode: WorkbenchMode) => {
-        if (mode === 'smart') {
-          setWorkbenchMode('smart');
-          return;
-        }
-        appStore.setWorkbenchMode('traditional');
+        void setWorkbenchMode(mode);
       };
 
       return {

+ 162 - 0
ai-electron/frontend/src/core/logics/appWindow.ts

@@ -0,0 +1,162 @@
+import { router } from '@/core/router';
+import { CASE_INFO_KEY } from '@/core/enums/cacheEnum';
+import { getAuthCache } from '@/core/utils/auth';
+import { setPersistOnUnload } from '@/core/utils/cache/persistent';
+import { invokeIpc, onIpc, resolveIpcRenderer } from '@/core/utils/desktop/ipcRenderer';
+import type { CaseInfo } from '@/plat/api/case/caseApi';
+
+/** 智能工作台的全屏路由 */
+export const WORKBENCH_PATH = '/workbench';
+
+export type AppWindowRole = 'main' | 'smart';
+
+/** openSmartWindow 的结果:调用方据此决定提示或导航 */
+export type OpenSmartWindowResult = 'opened' | 'navigated' | 'no-case' | 'failed';
+
+/** 主进程 → 渲染进程的跨窗口状态事件(只保留两种语义) */
+export type SyncEvent =
+  | { type: 'APP_RESET'; payload: { reason: 'case-exited' | 'account-logged-out' } }
+  | { type: 'AUTH_INVALID'; payload: { kind: 'unauthorized' | 'license'; message: string } };
+
+export interface OpenSmartWindowOptions {
+  /** 完整路由(含 query),默认 /workbench */
+  fullPath?: string;
+  /** 窗口已存在时是否整窗重载(换案后需要重新读案件上下文) */
+  reloadIfOpen?: boolean;
+  /** 窗口已存在且目标带 query 时,是否导航到该地址(显式的新建对话/视图意图) */
+  navigateIfOpen?: boolean;
+}
+
+const WINDOW_CTL = 'windowCtl';
+const SYNC_EVENT_CHANNEL = 'app:state';
+
+let role: AppWindowRole = 'main';
+let roleInitialized = false;
+let desktopAvailable: boolean | null = null;
+
+/** 是否在桌面客户端里(浏览器打开前端时所有窗口能力都退化为单窗口) */
+export function isDesktop(): boolean {
+  if (desktopAvailable === null) {
+    desktopAvailable = !!resolveIpcRenderer();
+  }
+  return desktopAvailable;
+}
+
+export function getWindowRole(): AppWindowRole {
+  return role;
+}
+
+export function isSmartWindow(): boolean {
+  return role === 'smart';
+}
+
+/**
+ * bootstrap 阶段解析一次窗口角色。
+ *
+ * <p>不靠 URL query 判断:路由会被 router.replace 改写,query 会丢;
+ * 由主进程按 IPC 发起方和智能窗口单例比对,最稳。
+ */
+export async function initWindowRole(): Promise<AppWindowRole> {
+  if (roleInitialized) return role;
+
+  if (isDesktop()) {
+    try {
+      const res = await invokeIpc<{ success?: boolean; data?: { role?: string } }>(
+        'getWindowRole',
+        undefined,
+        WINDOW_CTL,
+      );
+      role = res?.data?.role === 'smart' ? 'smart' : 'main';
+    } catch (e) {
+      // IPC 失败按主窗口处理:宁可少一个独立窗口,也不能让页面起不来
+      console.warn('[appWindow] 解析窗口角色失败,按主窗口处理:', e);
+      role = 'main';
+    }
+  }
+
+  roleInitialized = true;
+  // 智能窗口是只读客户端:卸载时不能把旧快照写回共享缓存(会覆盖主窗口刚写入的新案件)
+  setPersistOnUnload(!isSmartWindow());
+  return role;
+}
+
+/** 当前是否已有案件(读持久化缓存,避免与 case store 形成循环依赖) */
+export function hasCurrentCase(): boolean {
+  const info = getAuthCache<CaseInfo>(CASE_INFO_KEY);
+  return !!info?.id;
+}
+
+/**
+ * 打开/聚焦智能工作台窗口。
+ *
+ * <p>桌面端走 IPC 开独立窗口;浏览器端退化为同窗导航,保持改造前的行为。
+ */
+export async function openSmartWindow(options: OpenSmartWindowOptions = {}): Promise<OpenSmartWindowResult> {
+  const fullPath = options.fullPath || WORKBENCH_PATH;
+
+  if (!hasCurrentCase()) {
+    return 'no-case';
+  }
+
+  if (!isDesktop()) {
+    await router.replace(fullPath);
+    return 'navigated';
+  }
+
+  try {
+    const res = await invokeIpc<{ success?: boolean }>(
+      'openSmartWindow',
+      {
+        fullPath,
+        reloadIfOpen: options.reloadIfOpen === true,
+        navigateIfOpen: options.navigateIfOpen === true,
+      },
+      WINDOW_CTL,
+    );
+    return res?.success === false ? 'failed' : 'opened';
+  } catch (e) {
+    console.warn('[appWindow] 打开智能工作台失败:', e);
+    return 'failed';
+  }
+}
+
+export async function closeSmartWindow(): Promise<void> {
+  if (!isDesktop()) return;
+  try {
+    await invokeIpc('closeSmartWindow', undefined, WINDOW_CTL);
+  } catch (e) {
+    console.warn('[appWindow] 关闭智能工作台失败:', e);
+  }
+}
+
+export async function focusMainWindow(): Promise<void> {
+  if (!isDesktop()) return;
+  try {
+    await invokeIpc('focusMainWindow', undefined, WINDOW_CTL);
+  } catch (e) {
+    console.warn('[appWindow] 聚焦主窗口失败:', e);
+  }
+}
+
+export async function isSmartWindowOpen(): Promise<boolean> {
+  if (!isDesktop()) return false;
+  try {
+    const res = await invokeIpc<{ data?: { open?: boolean } }>('isSmartWindowOpen', undefined, WINDOW_CTL);
+    return res?.data?.open === true;
+  } catch {
+    return false;
+  }
+}
+
+/** 广播共享状态变化;主进程会转发给除自己外的所有窗口 */
+export function broadcastSync(event: SyncEvent): void {
+  if (!isDesktop()) return;
+  void invokeIpc('syncState', { event }, WINDOW_CTL).catch((e) => {
+    console.warn('[appWindow] 广播状态事件失败:', e);
+  });
+}
+
+/** 订阅主进程转发来的共享状态事件,返回取消订阅函数 */
+export function onSyncEvent(handler: (event: SyncEvent) => void): () => void {
+  return onIpc<SyncEvent>(SYNC_EVENT_CHANNEL, handler);
+}

+ 11 - 12
ai-electron/frontend/src/core/logics/exitCaseConfirm.ts

@@ -3,25 +3,24 @@ import { h } from 'vue';
 /**
  * 「退出案件」确认框的统一参数。
  *
- * 两处入口共用同一套外观与动作,避免各写一遍出现样式/文案漂移:
- *  - 传统布局壳顶栏的案件胶囊「退出」
- *  - 智能工作台顶栏的案件胶囊「退出」
- *
- * (头像菜单里原先还有一个「退出案件」,与胶囊重复,已移除。)
+ * 入口只剩传统布局壳顶栏的案件胶囊「退出」(智能工作台已移除该操作);
+ * 旧版 Codex 工作台仍复用它。hint 用于追加场景提示(例如智能窗口会同时关闭)。
  *
  * 说明:内容用 VNode 而不是字符串 —— createConfirm 对字符串走 innerHTML,
  * 案件名属于用户数据,不能拼进 HTML。
  */
-export function exitCaseConfirmOptions(onOk: () => void | Promise<void>, caseName?: string | null) {
+export function exitCaseConfirmOptions(
+  onOk: () => void | Promise<void>,
+  caseName?: string | null,
+  hint?: string,
+) {
+  const text = caseName
+    ? `确认退出「${caseName}」?退出后将返回案件管理,需要重新打开案件。`
+    : '确认退出当前案件?退出后将返回案件管理,需要重新打开案件。';
   return {
     iconType: 'warning' as const,
     title: '退出案件',
-    content: h(
-      'div',
-      caseName
-        ? `确认退出「${caseName}」?退出后将返回案件管理,需要重新打开案件。`
-        : '确认退出当前案件?退出后将返回案件管理,需要重新打开案件。',
-    ),
+    content: h('div', `${text}${hint || ''}`),
     okText: '退出案件',
     okType: 'primary' as const,
     okButtonProps: { danger: true },

+ 34 - 0
ai-electron/frontend/src/core/logics/sessionSync.ts

@@ -0,0 +1,34 @@
+import { closeSmartWindow, isSmartWindow, onSyncEvent } from './appWindow';
+import { handleLicenseRedirect, handleUnauthorizedRedirect } from '@/core/utils/http/axios/unauthorized';
+
+let initialized = false;
+
+/**
+ * 注册跨窗口共享状态的事件处理。
+ *
+ * <p>职责很薄:智能窗口是只读客户端,案件/账号/凭据任一失效就关闭自己;
+ * 主窗口只负责转来的凭据失效事件,由它弹唯一的提示并跳授权页。
+ * 不做事件重放:晚创建或错过事件时,bootstrap 从 localStorage/后端读到的事实本来就是新的。
+ */
+export function initSessionSync(): void {
+  if (initialized) return;
+  initialized = true;
+
+  onSyncEvent((event) => {
+    if (!event?.type) return;
+
+    if (isSmartWindow()) {
+      void closeSmartWindow();
+      return;
+    }
+
+    // 主窗口收到 AUTH_INVALID 只可能来自智能窗口:本地已由对方跳过提示,这里补上
+    if (event.type === 'AUTH_INVALID') {
+      if (event.payload.kind === 'license') {
+        handleLicenseRedirect(event.payload.message, { remote: true });
+      } else {
+        handleUnauthorizedRedirect(event.payload.message, { remote: true });
+      }
+    }
+  });
+}

+ 32 - 81
ai-electron/frontend/src/core/logics/workbenchMode.ts

@@ -1,111 +1,62 @@
-import { useRouter } from 'vue-router';
+import { APP_WORKBENCH_VIEW_KEY } from '@/core/enums/cacheEnum';
+import { useMessage } from '@/core/hooks/web/useMessage';
 import { useAppStoreWithOut, type WorkbenchMode } from '@/core/store/modules/app';
-import { PageEnum } from '@/core/enums/pageEnum';
-import { APP_WORKBENCH_PATH_KEY, APP_WORKBENCH_VIEW_KEY } from '@/core/enums/cacheEnum';
-
-/** 智能工作台的全屏路由地址(守卫与切换器同口径) */
-export const WORKBENCH_PATH = '/workbench';
+import { WORKBENCH_PATH, focusMainWindow, isSmartWindow, openSmartWindow } from './appWindow';
 
 /**
- * 全部「工作台」地址:当前工作台 + 保留的旧版工作台(codex 运行时)。
+ * 智能工作台最后停留的视图(chat / model / agent / plugin)。
  *
- * 旧版仅保留代码与地址供对照/回退,不再是智能模式的落点(落点只认 WORKBENCH_PATH);
- * 但两者都是不套布局的全屏壳,传统模式下一律不放行。
+ * 用 localStorage 而不是 sessionStorage:智能窗口关掉再开还要回到上次那一屏;
+ * 换案会整窗重载,但视图本身与案件无关,保留即可。
  */
-export const WORKBENCH_PATHS: string[] = [WORKBENCH_PATH, PageEnum.BASE_CODEX_WORKBENCH];
-
-/**
- * 当前模式下的「主界面」地址(打开案件后的落点)。
- *
- * 传统模式回案件数据首页,智能模式进工作台:用户在开关里选的主界面,
- * 开案后就该落在那里,而不是被固定拉回传统布局。
- */
-export function getModeHomePath(): string {
-  return useAppStoreWithOut().getWorkbenchMode === 'smart' ? WORKBENCH_PATH : PageEnum.BASE_HOME;
-}
-
-/* ------------------------------------------------------------------ *
- * 各模式的「停留位置」记忆
- *
- * 模式来回切不该每次都掉回默认落点:传统模式记路由地址,智能模式记工作台内部视图
- * (工作台是单路由全屏壳,视图不落 URL,只有组件自己知道当前在哪一屏)。
- * 用 sessionStorage 而非 localStorage:只在本次运行内有效,重启后各模式回默认落点,
- * 免得上次遗留的页面在案件上下文变化后变成一次莫名其妙的跳转。
- * ------------------------------------------------------------------ */
-
-/** 传统(自主)模式最后停留的路由;没停留过或记录不可用时返回 null */
-export function getLastTraditionalPath(): string | null {
-  return readSession(APP_WORKBENCH_PATH_KEY);
-}
-
-/** 记下传统模式当前停留的路由;工作台地址不属于传统模式,直接忽略 */
-export function setLastTraditionalPath(path: string): void {
-  if (!path || WORKBENCH_PATHS.includes(path)) {
-    return;
-  }
-  writeSession(APP_WORKBENCH_PATH_KEY, path);
-}
-
-/** 智能工作台最后停留的视图(chat / model / agent / plugin) */
 export function getLastWorkbenchView(): string | null {
-  return readSession(APP_WORKBENCH_VIEW_KEY);
-}
-
-export function setLastWorkbenchView(view: string): void {
-  if (!view) {
-    return;
-  }
-  writeSession(APP_WORKBENCH_VIEW_KEY, view);
-}
-
-/** sessionStorage 兜底:隐私模式 / 存储被禁用时退化成「不记忆」,不影响切换本身 */
-function readSession(key: string): string | null {
   try {
-    return sessionStorage.getItem(key) || null;
+    return localStorage.getItem(APP_WORKBENCH_VIEW_KEY) || null;
   } catch {
     return null;
   }
 }
 
-function writeSession(key: string, value: string): void {
+export function setLastWorkbenchView(view: string): void {
+  if (!view) return;
   try {
-    sessionStorage.setItem(key, value);
+    localStorage.setItem(APP_WORKBENCH_VIEW_KEY, view);
   } catch {
-    // 写不进去就算了:下次切回来走默认落点
+    // 写不进去就用默认视图,不影响切换本身
   }
 }
 
 /**
- * 主界面模式切换的唯一实现:写持久化状态 + 跳到目标模式的「上次停留处」。
+ * 主界面模式切换的唯一实现。
  *
- * 传统布局壳(顶栏里切换)与智能工作台共用,避免两处各写一份跳转口径而后漂移。
- *
- * 切走前先把当前所在页记下来,来回切才不会每次都掉回默认页(传统模式记路由、
- * 智能模式记工作台视图,后者由工作台组件自己写);目标位置就是当前页时不跳转 ——
- * 在传统壳里点「传统」只是把偏好对齐,不该把人从当前页挪走。
+ * <p>智能模式 = 独立窗口:点「智能」打开/聚焦它;点「自主」只写偏好,
+ * 在智能窗口里额外把主窗口拉到前台(按产品要求:不关闭智能窗口)。
+ * 偏好只决定「开案后落到哪个界面」,不决定主窗口渲染什么。
  */
 export function useWorkbenchModeSwitch() {
-  const router = useRouter();
   const appStore = useAppStoreWithOut();
+  const { createMessage, createWarningModal } = useMessage();
 
-  function setMode(mode: WorkbenchMode) {
-    const path = router.currentRoute.value.path;
-    const inWorkbench = WORKBENCH_PATHS.includes(path);
-
-    // 人在传统壳就把这一页记下(智能模式的停留位置由工作台组件自己写)
-    if (!inWorkbench) {
-      setLastTraditionalPath(path);
-    }
-
+  async function setMode(mode: WorkbenchMode) {
     appStore.setWorkbenchMode(mode);
 
     if (mode === 'smart') {
-      if (!inWorkbench) {
-        router.replace(WORKBENCH_PATH);
+      const result = await openSmartWindow({ fullPath: WORKBENCH_PATH });
+      if (result === 'no-case') {
+        createWarningModal({
+          iconType: 'warning',
+          title: '提示',
+          content: '请先打开案件,再进入智能工作台',
+        });
+      } else if (result === 'failed') {
+        createMessage.error('打开智能工作台失败,请重试');
       }
-    } else if (inWorkbench) {
-      // 切回传统:有记忆回记忆,没记忆才回首页
-      router.replace(getLastTraditionalPath() || PageEnum.BASE_HOME);
+      return;
+    }
+
+    // 自主:只切偏好;人还在智能窗口里时把主窗口拉到前台,方便去主窗口操作
+    if (isSmartWindow()) {
+      await focusMainWindow();
     }
   }
 

+ 79 - 16
ai-electron/frontend/src/core/router/guard/index.ts

@@ -15,7 +15,13 @@ import {
 } from '@/core/logics/analysisRefreshPrompt';
 import { createPermissionGuard } from './permissionGuard';
 import { createStateGuard } from './stateGuard';
-import { WORKBENCH_PATH, WORKBENCH_PATHS } from '@/core/logics/workbenchMode';
+import {
+  WORKBENCH_PATH,
+  isDesktop,
+  isSmartWindow,
+  openSmartWindow,
+  type OpenSmartWindowResult,
+} from '@/core/logics/appWindow';
 // import nProgress from 'nprogress';
 import projectSetting from '@/core/settings/projectSetting';
 // import { createParamMenuGuard } from './paramMenuGuard';
@@ -34,34 +40,91 @@ export function setupRouterGuard(router: Router) {
   createStateGuard(router);
 }
 
+/**
+ * 旧 AI 菜单地址:菜单组已移除,老书签/老链接统一引导到智能工作台的对应视图。
+ * 主窗口不再渲染这些页面,智能窗口也不会承载它们。
+ */
+const LEGACY_AI_REDIRECTS: Record<string, { path: string; query?: Record<string, string> }> = {
+  '/aiAnalysis/index': { path: WORKBENCH_PATH },
+  '/aiModel/index': { path: WORKBENCH_PATH, query: { view: 'model' } },
+  '/aiPlugin/index': { path: WORKBENCH_PATH, query: { view: 'plugin' } },
+  '/codexAnalysis/codexAnalysis': { path: WORKBENCH_PATH },
+};
+
+/** 不套布局的全屏壳地址:主窗口/智能窗口都不接受直接渲染 */
+const FULLSCREEN_SHELL_PATHS = [WORKBENCH_PATH, PageEnum.BASE_CODEX_WORKBENCH];
+
 /**
  * 主界面模式守卫:
- * - 传统模式敲工作台地址(当前 /workbench 与保留的旧版 /codexWorkbench)→ 回首页;
- * - 智能模式敲 AI 菜单组页面 → 引导回工作台(AI 能力集中在工作台呈现)。
- * 业务分析各页面两种模式都可直接访问(直接敲 URL 不拦),页面本身自带布局壳,
- * 所以这里**不改写**用户的模式偏好 —— 偏好只由模式开关设置,用来决定开案后的落点。
+ * - 智能窗口只承载工作台;授权页放行,其余路径拉回工作台;
+ * - 主窗口遇到工作台/旧 AI 地址 → 打开智能窗口,自己留在原页;
+ * - 浏览器(无 Electron)保持改造前的同窗行为。
+ *
+ * <p>守卫里开窗口是副作用,但主进程侧是单例 + 幂等,且 redirect 目标绝不会再是
+ * /workbench,不会形成循环。这样数据页 CTA、清洗完成页等 `router.push('/workbench')`
+ * 调用点都不用改。
  */
 function createWorkbenchGuard(router: Router) {
-  const AI_GROUP_PATHS = ['/aiAnalysis/index', '/aiModel/index', '/aiPlugin/index', '/codexAnalysis/codexAnalysis'];
-
-  router.beforeEach((to) => {
-    const mode = useAppStoreWithOut().getWorkbenchMode;
+  router.beforeEach(async (to, from) => {
+    // 浏览器里没有独立窗口:/workbench 同窗渲染;旧 AI 地址仍引导到工作台,避免 404
+    if (!isDesktop()) {
+      const legacy = LEGACY_AI_REDIRECTS[to.path];
+      return legacy ? { ...legacy, replace: true } : true;
+    }
 
-    if (mode === 'smart') {
-      // AI 能力集中在工作台:老的 AI 菜单页一律引导回工作台
-      if (AI_GROUP_PATHS.some((p) => to.path === p || to.path.startsWith(`${p}/`))) {
-        return { path: WORKBENCH_PATH, replace: true };
+    if (isSmartWindow()) {
+      if (to.path === WORKBENCH_PATH || to.path === PageEnum.BASE_AUTHORIZATION) {
+        return true;
       }
-      return;
+      const legacy = LEGACY_AI_REDIRECTS[to.path];
+      if (legacy) {
+        return { ...legacy, replace: true };
+      }
+      return { path: WORKBENCH_PATH, replace: true };
+    }
+
+    // 主窗口:工作台地址(含 ?newChat=1 / ?view=agent)原样交给智能窗口
+    if (to.path === WORKBENCH_PATH) {
+      const result = await openSmartWindow({ fullPath: to.fullPath, navigateIfOpen: true });
+      return handleSmartWindowResult(result, from.path);
+    }
+
+    const legacy = LEGACY_AI_REDIRECTS[to.path];
+    if (legacy) {
+      const fullPath = router.resolve(legacy).fullPath;
+      const result = await openSmartWindow({ fullPath, navigateIfOpen: true });
+      return handleSmartWindowResult(result, from.path);
     }
 
-    // 传统模式不接受工作台地址(当前与旧版都是不套布局的全屏壳)
-    if (WORKBENCH_PATHS.includes(to.path)) {
+    // 旧版 codex 工作台:传统壳不接受(原有行为)
+    if (to.path === PageEnum.BASE_CODEX_WORKBENCH) {
       return { path: PageEnum.BASE_HOME, replace: true };
     }
   });
 }
 
+/** 主窗口打开智能窗口后的落点:留在原业务页;从案件管理/授权页过来时回业务首页 */
+function handleSmartWindowResult(result: OpenSmartWindowResult, fromPath: string) {
+  if (result === 'no-case') {
+    return { path: PageEnum.BASE_CASE, replace: true };
+  }
+  if (result === 'failed') {
+    // 只是提示;导航仍要落回一个可用的业务页,不能停在 /workbench
+    notification.error({ message: '打开智能工作台失败,请重试' });
+  }
+
+  const isBusinessPage =
+    !!fromPath &&
+    !FULLSCREEN_SHELL_PATHS.includes(fromPath) &&
+    !LEGACY_AI_REDIRECTS[fromPath] &&
+    ![PageEnum.BASE_CASE, PageEnum.BASE_AUTHORIZATION, '/'].includes(fromPath);
+
+  return {
+    path: isBusinessPage ? fromPath : PageEnum.BASE_HOME,
+    replace: true,
+  };
+}
+
 /**
  * Hooks for handling page state
  */

+ 4 - 24
ai-electron/frontend/src/core/router/menus/index.ts

@@ -38,33 +38,13 @@ const staticMenus: Menu[] = [];
 })();
 
 /**
- * 「AI 数据分析」菜单组(传统模式):含父级与全部子项的路径。
- * 传统模式下整组隐藏——AI 能力集中在「智能模式」工作台里呈现;
- * 路由仍注册(直接敲 URL 由 workbench 守卫引导切模式)。
+ * 「AI 数据分析」菜单组已从 menu.json 移除:AI 能力统一由顶栏「智能」开关
+ * 打开独立工作台窗口,主窗口不再保留旧的 AI 菜单入口。
+ * 老地址的重定向由 core/router/guard 处理。
  */
-const AI_WORKBENCH_PATHS = [
-  '/10263',
-  '/aiAnalysis/index',
-  '/aiModel/index',
-  '/aiPlugin/index',
-  '/codexAnalysis/codexAnalysis',
-];
-
-function filterByWorkbenchMode(menus: Menu[]): Menu[] {
-  const mode = useAppStoreWithOut().getWorkbenchMode;
-  if (mode !== 'traditional') return menus;
-  return menus
-    .filter((item) => !AI_WORKBENCH_PATHS.includes(item.path))
-    .map((item) =>
-      item.children?.length
-        ? { ...item, children: item.children.filter((child) => !AI_WORKBENCH_PATHS.includes(child.path)) }
-        : item,
-    );
-}
-
 async function getAsyncMenus() {
   const permissionStore = usePermissionStore();
-  return filterByWorkbenchMode(permissionStore.getBackMenuList);
+  return permissionStore.getBackMenuList;
 }
 
 export const getMenus = async (): Promise<Menu[]> => {

+ 28 - 6
ai-electron/frontend/src/core/store/modules/case.ts

@@ -16,7 +16,8 @@ import { RouteRecordRaw } from 'vue-router';
 import { PAGE_NOT_FOUND_ROUTE } from '@/core/router/routes/basic';
 import { mitt, Emitter } from '@/core/utils/mitt';
 import { openCase, exitCase, OpenCaseDTO, CaseInfo, list as listCases } from '@/plat/api/case/caseApi';
-import { getModeHomePath } from '@/core/logics/workbenchMode';
+import { broadcastSync, isSmartWindowOpen, openSmartWindow } from '@/core/logics/appWindow';
+import { useAppStoreWithOut } from './app';
 
 function ensurePageNotFoundRoute() {
   if (!router.hasRoute(PAGE_NOT_FOUND_ROUTE.name as string)) {
@@ -195,24 +196,43 @@ export const useUserStore = defineStore('app-user', {
       }
       if (goHome) {
         const currentRoute = router.currentRoute.value;
-        // 没有明确来源页时按当前模式落主界面:传统 → 案件数据首页;智能 → 工作台
-        const modeHome = getModeHomePath();
+        // 主窗口始终是传统壳:没有明确来源页时落到案件数据首页。
+        // 智能模式的偏好不再改写主窗口路由,而是由 syncSmartWindowAfterCaseOpen 打开独立窗口。
+        const homePath = PageEnum.BASE_HOME;
         let path = currentRoute.query.redirect;
         if (path !== '/') {
-          path = path || modeHome;
+          path = path || homePath;
         } else {
-          path = modeHome;
+          path = homePath;
         }
         const targetPath = ['/test/testData/list'].includes(decodeURIComponent(path as string))
-          ? modeHome
+          ? homePath
           : decodeURIComponent(path as string);
         if (currentRoute.name === PAGE_NOT_FOUND_ROUTE.name && currentRoute.path === targetPath) {
           await router.replace(PageEnum.BASE_CASE);
         }
         await router.replace(targetPath);
       }
+
+      await this.syncSmartWindowAfterCaseOpen();
       return res;
     },
+    /**
+     * 开案后同步智能窗口。
+     *
+     * <p>已开着 → 整窗重载:聊天 store、会话列表、产物面板、workspaceId 都要按新案件重建,
+     * 重载是唯一能保证没有旧案件残留的做法;
+     * 没开且偏好是智能 → 新建(启动时不自动开窗,开案是用户的明确动作)。
+     */
+    async syncSmartWindowAfterCaseOpen() {
+      if (await isSmartWindowOpen()) {
+        await openSmartWindow({ reloadIfOpen: true });
+        return;
+      }
+      if (useAppStoreWithOut().getWorkbenchMode === 'smart') {
+        await openSmartWindow();
+      }
+    },
     initPageCache() {
       this.setPageCache('demoMode', false);
       this.setPageCache('useCorpModel', true);
@@ -234,6 +254,8 @@ export const useUserStore = defineStore('app-user', {
       this.resetState();
       useMultipleTabStore().resetState();
       resetRouter();
+      // 智能窗口是案件作用域的从属窗口:案件没了就关闭它(主进程转发给其它窗口)
+      broadcastSync({ type: 'APP_RESET', payload: { reason: 'case-exited' } });
       goLogin && (await router.replace(PageEnum.BASE_CASE));
     },
     /**

+ 0 - 82
ai-electron/frontend/src/core/store/modules/menu.json

@@ -605,87 +605,5 @@
     "leaf": true,
     "url": "/aiBaseStation/index",
     "target": ""
-  },
-  {
-    "redirect": "/aiAnalysis/index",
-    "path": "/10263",
-    "component": "LAYOUT",
-    "meta": {
-      "icon": "i-simple-line-icons:bubble",
-      "hideMenu": false,
-      "color": "",
-      "title": "AI 数据分析"
-    },
-    "name": "Views10263",
-    "id": "10263",
-    "leaf": false,
-    "url": "",
-    "target": "",
-    "children": [
-      {
-        "path": "/aiAnalysis/index",
-        "component": "/aiAnalysis/index",
-        "children": [],
-        "meta": {
-          "icon": "i-mdi:chart-box-outline",
-          "hideMenu": false,
-          "color": "",
-          "title": "AI 数据分析"
-        },
-        "name": "ViewsAiAnalysisIndex",
-        "id": "10264",
-        "leaf": false,
-        "url": "/aiAnalysis/index",
-        "target": ""
-      },
-      {
-        "path": "/aiModel/index",
-        "component": "/aiModel/index",
-        "children": [],
-        "meta": {
-          "icon": "i-mdi:brain",
-          "hideMenu": false,
-          "color": "",
-          "title": "模型管理"
-        },
-        "name": "ViewsAiModelIndex",
-        "id": "10265",
-        "leaf": false,
-        "url": "/aiModel/index",
-        "target": ""
-      },
-      {
-        "path": "/aiPlugin/index",
-        "component": "/aiPlugin/index",
-        "children": [],
-        "meta": {
-          "icon": "i-mdi:puzzle-outline",
-          "hideMenu": false,
-          "color": "",
-          "title": "插件"
-        },
-        "name": "ViewsAiPluginIndex",
-        "id": "10266",
-        "leaf": false,
-        "url": "/aiPlugin/index",
-        "target": ""
-      },
-      {
-        "path": "/codexAnalysis/codexAnalysis",
-        "component": "/codexAnalysis/codexAnalysis",
-        "children": [],
-        "meta": {
-          "icon": "i-mdi:robot-outline",
-          "hideMenu": false,
-          "color": "",
-          "title": "Codex 分析"
-        },
-        "name": "ViewsCodexAnalysisCodexAnalysis",
-        "id": "10267",
-        "leaf": false,
-        "url": "/codexAnalysis/codexAnalysis",
-        "target": ""
-      }
-    ]
   }
 ]

+ 39 - 13
ai-electron/frontend/src/core/utils/cache/persistent.ts

@@ -18,6 +18,7 @@ import {
   MULTIPLE_TABS_KEY,
 } from '@/core/enums/cacheEnum';
 import { DEFAULT_CACHE_TIME } from '@/core/settings/encryptionSetting';
+import { getStorageShortName } from '@/core/utils/env';
 import { toRaw } from 'vue';
 import { pick, omit } from 'lodash-es';
 
@@ -102,34 +103,59 @@ export class Persistent {
   }
 }
 
+/**
+ * 是否在窗口卸载时回写整份内存快照。
+ *
+ * 智能工作台是只读客户端:token/案件等共享状态由主窗口写入,它卸载时回写只会拿旧快照
+ * 覆盖主窗口刚写入的新值,所以由 appWindow 在角色确定后关掉。
+ */
+let persistOnUnload = true;
+export function setPersistOnUnload(enabled: boolean): void {
+  persistOnUnload = enabled;
+}
+
 window.addEventListener('beforeunload', function () {
-  // TOKEN_KEY 在登录或注销时已经写入到storage了,此处为了解决同时打开多个窗口时token不同步的问题
-  // LOCK_INFO_KEY 在锁屏和解锁时写入,此处也不应修改
+  if (!persistOnUnload) return;
+  // TOKEN/CASE_INFO 在登录、开案、退案时已经写入 storage 了,这里从盘上补回最新值。
+  // 同时解决同时打开多个窗口时 token/案件不同步的问题:
+  // 本窗口内存里的旧快照不能覆盖其它窗口刚写入的新值。
+  // LOCK_INFO_KEY 在锁屏和解锁时写入,此处不应修改。
   ls.set(APP_LOCAL_CACHE_KEY, {
     ...omit(localMemory.getCache, LOCK_INFO_KEY),
-    ...pick(ls.get(APP_LOCAL_CACHE_KEY), [TOKEN_KEY, USER_INFO_KEY, LOCK_INFO_KEY]),
+    ...pick(ls.get(APP_LOCAL_CACHE_KEY), [TOKEN_KEY, USER_INFO_KEY, LOCK_INFO_KEY, CASE_INFO_KEY]),
   });
   ss.set(APP_SESSION_CACHE_KEY, {
     ...omit(sessionMemory.getCache, LOCK_INFO_KEY),
-    ...pick(ss.get(APP_SESSION_CACHE_KEY), [TOKEN_KEY, USER_INFO_KEY, LOCK_INFO_KEY]),
+    ...pick(ss.get(APP_SESSION_CACHE_KEY), [TOKEN_KEY, USER_INFO_KEY, LOCK_INFO_KEY, CASE_INFO_KEY]),
   });
 });
 
-function storageChange(e: any) {
-  const { key, newValue, oldValue } = e;
+/** 实际落盘的 key:storageCache.getKey 会拼前缀并转大写 */
+const LOCAL_BLOB_KEY = `${getStorageShortName()}${APP_LOCAL_CACHE_KEY}`.toUpperCase();
+const SESSION_BLOB_KEY = `${getStorageShortName()}${APP_SESSION_CACHE_KEY}`.toUpperCase();
+
+/**
+ * 另一个窗口写入了共享快照:重新读盘,而不是清空内存。
+ *
+ * 清空后 getToken()/getCaseInfo() 会读到 undefined,等于把本窗口误判成未登录/未开案。
+ */
+function storageChange(e: StorageEvent) {
+  const { key } = e;
 
   if (!key) {
     Persistent.clearAll();
     return;
   }
 
-  if (!!newValue && !!oldValue) {
-    if (APP_LOCAL_CACHE_KEY === key) {
-      Persistent.clearLocal();
-    }
-    if (APP_SESSION_CACHE_KEY === key) {
-      Persistent.clearSession();
-    }
+  if (key === LOCAL_BLOB_KEY) {
+    localMemory.clear();
+    const blob = ls.get(APP_LOCAL_CACHE_KEY);
+    blob && localMemory.resetCache(blob);
+  }
+  if (key === SESSION_BLOB_KEY) {
+    sessionMemory.clear();
+    const blob = ss.get(APP_SESSION_CACHE_KEY);
+    blob && sessionMemory.resetCache(blob);
   }
 }
 

+ 39 - 2
ai-electron/frontend/src/core/utils/http/axios/unauthorized.ts

@@ -1,11 +1,20 @@
 import { useMessage } from '@/core/hooks/web/useMessage';
 import { PageEnum } from '@/core/enums/pageEnum';
 import { resetRouter, router } from '@/core/router';
+import { broadcastSync, closeSmartWindow, focusMainWindow, isSmartWindow } from '@/core/logics/appWindow';
 import { useUserStoreWithOut } from '@/core/store/modules/case';
 import { useMultipleTabWithOutStore } from '@/core/store/modules/multipleTab';
 import { usePermissionStoreWithOut } from '@/core/store/modules/permission';
 import { clearAuthCache } from '@/core/utils/auth';
 
+interface RedirectOptions {
+  /**
+   * 是否是主进程转来的远端事件。
+   * 远端事件不再广播一次,避免两个窗口互相转发形成回声。
+   */
+  remote?: boolean;
+}
+
 let isHandlingUnauthorized = false;
 let isHandlingLicense = false;
 
@@ -30,12 +39,26 @@ function redirectTo(path: string, query?: Record<string, string>) {
  * <p>后端对认证失败刻意返回 **HTTP 200 + body.code=401**(见 GlobalExceptionHandler),
  * 因此这里由 transformRequestHook 触发,而不是 axios 的 catch 分支。
  * <p>`/login` 已合并进 `/authorization`,入口页会根据授权状态自行显示登录卡片。
+ *
+ * <p>多窗口:智能窗口不弹框,只把事件转给主窗口并关闭自己;主窗口是唯一的提示方。
  */
-export function handleUnauthorizedRedirect(message = '登录状态已失效,请重新登录') {
+export function handleUnauthorizedRedirect(message = '登录状态已失效,请重新登录', options: RedirectOptions = {}) {
+  if (isSmartWindow()) {
+    if (isHandlingUnauthorized) return;
+    isHandlingUnauthorized = true;
+    broadcastSync({ type: 'AUTH_INVALID', payload: { kind: 'unauthorized', message } });
+    void focusMainWindow();
+    void closeSmartWindow();
+    return;
+  }
+
   if (isHandlingUnauthorized) return;
 
   isHandlingUnauthorized = true;
   try {
+    if (!options.remote) {
+      broadcastSync({ type: 'AUTH_INVALID', payload: { kind: 'unauthorized', message } });
+    }
     const redirect = router.currentRoute.value.fullPath;
     clearLocalState();
     useMessage().createWarningModal({
@@ -60,12 +83,26 @@ export function handleUnauthorizedRedirect(message = '登录状态已失效,
  * <p>注意:**license 失败码已从 401 改为 40301**(见 sql/ 与用户体系设计文档),
  * 因为 401 的语义已归「未登录」。两者若共用 401,会出现「license 失效跳登录页、
  * 未登录跳授权页」互相打架的情况。
+ *
+ * <p>多窗口规则同 {@link handleUnauthorizedRedirect}:智能窗口只转发 + 关闭。
  */
-export function handleLicenseRedirect(message = '未授权,请先完成授权') {
+export function handleLicenseRedirect(message = '未授权,请先完成授权', options: RedirectOptions = {}) {
+  if (isSmartWindow()) {
+    if (isHandlingLicense) return;
+    isHandlingLicense = true;
+    broadcastSync({ type: 'AUTH_INVALID', payload: { kind: 'license', message } });
+    void focusMainWindow();
+    void closeSmartWindow();
+    return;
+  }
+
   if (isHandlingLicense) return;
 
   isHandlingLicense = true;
   try {
+    if (!options.remote) {
+      broadcastSync({ type: 'AUTH_INVALID', payload: { kind: 'license', message } });
+    }
     clearLocalState();
     useMessage().createWarningModal({
       iconType: 'warning',

+ 17 - 0
ai-electron/frontend/src/main.ts

@@ -31,6 +31,8 @@ import {
 } from '@/core/utils/license';
 import { setupAntdTabsListenerPatch } from './setupAntdTabsListenerPatch';
 import { setupAntdPopupBehavior } from './setupAntdPopupBehavior';
+import { closeSmartWindow, focusMainWindow, initWindowRole, isSmartWindow } from '@/core/logics/appWindow';
+import { initSessionSync } from '@/core/logics/sessionSync';
 
 async function waitForBackendReady(maxRetries: number = 60, interval: number = 2000): Promise<boolean> {
   console.log('[bootstrap] waiting for backend to be ready...');
@@ -201,6 +203,10 @@ async function ensureLoggedIn() {
 }
 
 async function bootstrap() {
+  // 窗口角色必须先解析:路由守卫要靠它区分主窗口(传统壳)与智能工作台窗口。
+  // 浏览器里没有 IPC,initWindowRole 会回落到 'main',一切保持单窗口行为。
+  await initWindowRole();
+
   setupAntdPopupBehavior();
   const app = createApp(App);
 
@@ -210,6 +216,9 @@ async function bootstrap() {
   // Initialize internal system configuration
   initAppConfigStore();
 
+  // 跨窗口共享状态:案件/账号/凭据失效的广播处理(必须在路由初始化前注册)
+  initSessionSync();
+
   // Register global components
   registerGlobComp(app);
 
@@ -237,6 +246,14 @@ async function bootstrap() {
   // 授权通过后再校验登录态:无 token 直接进授权/登录入口页
   await ensureLoggedIn();
 
+  // 智能窗口只承载工作台:bootstrap 若被判定到授权页,说明凭据/授权已失效。
+  // 关闭自己、把主窗口拉到前台,由主窗口做唯一的提示与跳转。
+  if (isSmartWindow() && router.currentRoute.value.path === PageEnum.BASE_AUTHORIZATION) {
+    await focusMainWindow();
+    void closeSmartWindow();
+    return;
+  }
+
   // Register global directive
   setupGlobDirectives(app);