apps/buddy/src/
Vue 界面:窗口、任务页、设置页"] + B["② 主进程
apps/buddy/electron/main/
桌面程序总控:窗口、托盘、配置文件"] + C["③ 本地服务
apps/buddy/service/
对话、模型调用、审批、命令执行"] + D[("buddy.sqlite3
本机 SQLite 数据库")] + E["~/.lexora/buddy/
会话文件、附件、事件日志"] + F["~/.lexora/config.toml
用户偏好设置"] + S["apps/buddy/shared/
三边共享的类型与常量"] + + A -->|"Electron IPC
ipcRenderer.invoke"| B + B -->|"utilityProcess.fork
进程名 Buddy Local Service"| C + B <-->|"消息式 RPC
utilityProcess.postMessage"| C + C --> D + C --> E + B --> F + S -.-> A + S -.-> B + S -.-> C +``` + +| 层 | 目录 | 干什么 | 关键入口 | +|---|---|---|---| +| ① 界面层 | `apps/buddy/src/` | 画界面。用户点下拉框、点开关,都是这一层在响应 | `src/main.ts`、`src/modules/` | +| ② 主进程 | `apps/buddy/electron/main/` | 桌面程序的"总控"。管窗口、托盘、开机启动,读写 `config.toml`,并监管本地服务的启动与重启 | `electron/main/app/DesktopApplication.ts`、`electron/main/runtime/BuddyServiceSupervisor.ts` | +| ③ 本地服务 | `apps/buddy/service/` | 干重活。存对话、调模型、跑命令、审批操作,读写 `buddy.sqlite3` | `service/src/index.ts`、`service/src/BuddyService.ts` | + +**为什么界面层不直接读写数据库:** 界面层只能通过"预加载脚本"(`apps/buddy/electron/preload/`)暴露出来的受限接口发请求,由主进程转给本地服务。这样界面层拿不到文件系统和数据库的任意访问权。 + +## 3. 数据存在哪里(最关键的一节) + +| 位置 | 存什么 | 归谁管 | 代码入口 | +|---|---|---|---| +| `~/.lexora/config.toml` | 用户偏好:语言、主题、窗口行为、聊天偏好、快捷键 | 主进程 | `electron/main/config/LexoraConfigStore.ts` | +| `~/.lexora/buddy/buddy.sqlite3` | 对话、草稿、任务、运行记录、用量 | 本地服务 | `service/src/storage/database.ts` | +| `~/.lexora/buddy/conversations/`、`spaces/`、`drafts/` | 会话文件、附件、事件日志(`.jsonl`) | 本地服务 | `service/src/storage/BuddyDataPaths.ts` | + +数据根目录由 `electron/main/paths.ts` 决定:正式版是 `~/.lexora`,开发版是 `~/.lexora-dev`。 + +### 为什么这件事很重要 + +**一个"设置"经常横跨两边。** 举例:开关本身是"偏好"(存在 `config.toml`),但它要影响的对象 —— 每个任务的权限模式 —— 是 `buddy.sqlite3` 里的一条条草稿记录。 + +而且这两个文件归**两个不同的进程**管。所以一个设置值要"过几道门"才能生效: + +1. 界面层把新值通过 IPC 发给主进程; +2. 主进程校验后写进 `config.toml`; +3. 如果这个设置还要影响任务状态,值必须再往下传到本地服务,由本地服务写进数据库。 + +漏掉第 3 步,就会出现"设置改了但完全没生效,而且不报错"。具体案例见 `docs/specs/feature-005-default-permission-mode.md`。 + +## 4. 核心业务流程 + +### 4.1 发送一条消息 + +```mermaid +sequenceDiagram + participant UI as ① 界面层 + participant Main as ② 主进程 + participant Svc as ③ 本地服务 + participant DB as buddy.sqlite3 + participant API as 模型服务商 + + UI->>Main: ipcRenderer.invoke(Electron IPC) + Main->>Svc: RPC 请求(utilityProcess.postMessage) + Svc->>DB: 读取草稿的权限设置与模型选择 + Svc->>Svc: 提交草稿,创建会话与回合 + Svc->>API: 发起模型请求 + API-->>Svc: 流式返回 + Svc->>DB: 写入运行记录与事件 + Svc-->>Main: 推送运行事件 + Main-->>UI: 转发事件(通道 lexora:buddy:runs:event) + UI->>UI: 渲染消息、审批卡片、变更集 +``` + +界面层收到的是"推送"而不是"轮询":主进程用 `send()` 把运行事件发给窗口(`electron/main/local-chat/notifications.ts`),预加载脚本把它包装成订阅接口(`electron/preload/local-chat/conversation.ts`)。历史事件另有一条拉取通道 `runs.listEvents`。 + +### 4.2 修改一个设置 + +```mermaid +sequenceDiagram + participant UI as ① 界面层 + participant Main as ② 主进程 + participant Toml as ~/.lexora/config.toml + participant Svc as ③ 本地服务 + participant DB as buddy.sqlite3 + + UI->>Main: settings.update(patch) + Main->>Main: 校验(lexoraConfigPatchSchema) + Main->>Toml: 合并后写回(mergeConfig / encodeConfig) + Main-->>UI: 返回完整新配置 + + Note over UI,DB: 如果这个设置还要影响任务状态,需要第二步 + + UI->>Main: 任务相关请求(携带新值) + Main->>Svc: RPC 请求 + Svc->>DB: 写入该任务的记录 +``` + +## 5. 目录职责 + +| 目录 | 职责 | +|---|---| +| `apps/buddy/src/` | 界面层。Vue 组件、路由、设置页、任务页 | +| `apps/buddy/src/modules/` | 界面层的功能模块:`tasks/`(对话与任务)、`settings/`(设置页)、`prompt-input/`(输入框)、`automations/`(定时任务)等 | +| `apps/buddy/electron/main/` | 主进程。窗口、托盘、IPC 注册、配置文件读写、本地服务监管 | +| `apps/buddy/electron/preload/` | 预加载脚本。把受限的接口暴露给界面层 | +| `apps/buddy/electron/shared/` | 主进程与界面层共享的 IPC 契约:通道名、类型、校验规则 | +| `apps/buddy/service/` | 本地服务。对话、模型、审批、命令、存储、技能、插件 | +| `apps/buddy/shared/` | 三个进程共享的纯类型与常量(不含副作用代码) | +| `apps/buddy/platform/` | 平台适配层。把各操作系统的差异封装起来 | +| `apps/buddy/native/` | 原生代码(Rust)。桌宠等需要原生进程的能力 | +| `packages/` | 仓库内共享包 | + +## 6. 外部依赖 + +| 依赖 | 用途 | 接入层 | +|---|---|---| +| 模型服务商 API | 对话推理 | 本地服务 `service/src/providers/` | +| `@earendil-works/pi-coding-agent` | 编码代理运行时 | 本地服务 `service/src/agent/` | +| MCP 连接器 | 外部工具接入 | 本地服务 `service/src/connectors/` | +| 本机 SQLite(Node 内置 `node:sqlite`) | 数据持久化 | 本地服务 `service/src/storage/` | + +## 7. 不变量(当前代码中已固化的约束) + +以下约束来自代码本身,改动时必须保持: + +- **权限不能向上越级。** 子运行、子会话的权限不得超过所属会话的权限上限,由 `isExecutionProfileWithin()` 限制。见 `service/src/storage/turnRequestRepository.ts`、`service/src/chat/ChatTurnService.ts`、`service/src/agent/sessions/BuddySessionFactory.ts`。 +- **主进程每个 IPC 入口都校验发送方窗口。** 全部 `register*Ipc` 处理器都调用 `assertTrustedSender()`。见 `electron/main/ipc.ts`。 +- **新任务的出厂默认权限是"智能审批"。** 由 `BUDDY_DEFAULT_APPROVAL_POLICY = 'policy'` 和 `BUDDY_DEFAULT_EXECUTION_PROFILE = 'workspace_write'` 决定,经 `resolveBuddyPermissionMode()` 解析为 `policy_approval`。见 `apps/buddy/shared/permissions/permissionMode.ts`。 +- **配置文件权限为 `0o600`。** 只有当前用户可以读写 `config.toml`。见 `electron/main/config/LexoraConfigStore.ts`。 + +> 说明:本仓库当前**没有** `docs/adr/` 目录,因此以上不变量直接引用代码位置,而不是引用 ADR 编号。 + +## 8. 相关文档 + +| 类型 | 路径 | 说明 | +|---|---|---| +| 功能规格 | `docs/specs/` | 个人功能规格、方案与决定记录 | +| 架构概览 | `docs/architecture/overview.md` | 本文件 | diff --git a/docs/bugs/bug-20260928-windows-private-directory-read-acl.md b/docs/bugs/bug-20260928-windows-private-directory-read-acl.md new file mode 100644 index 00000000..69a4ca38 --- /dev/null +++ b/docs/bugs/bug-20260928-windows-private-directory-read-acl.md @@ -0,0 +1,46 @@ +# Bug:Windows 私有目录拒绝 Agent 的只读权限 + +**日期:** 2026-09-28
+**优先级:** 中 +**状态:** 已修复 + +## 复现步骤 + +1. 在 Windows 安装并启动 Lexora Buddy。 +2. 使用 Codex Windows 沙盒,让其为用户配置目录下的 `.lexora` 添加沙盒用户读取权限。 +3. 启动 Lexora Buddy;移除该权限后,Codex 沙盒再次补回权限时,问题会重现。 + +## 实际结果 + +应用启动失败并提示 `PRIVATE_DIRECTORIES_UNSAFE`,失败步骤为 `validate_acl / lexora_home`。本机诊断中的 ACL 为:`principal=other`、`mask=0x1200a9`、`flags=0x3`。这条允许读取和遍历目录的权限被当成不安全权限拒绝。 + +## 预期结果 + +仅有读取、列目录、读取属性和遍历权限的额外主体不应阻止应用启动。额外主体仍不得通过 ACL 获得写入、删除或其他修改能力。 + +## 影响范围 + +Windows 桌面版启动时对 `lexora_home`(Lexora 用户数据根目录)的 ACL 检查。任何 Agent 或其他工具为该目录添加只读权限时,都可能触发原问题。 + +## 初步判断 + +`CodexSandboxUsers` 是 Codex Windows 沙盒使用的主体。Codex 为用户配置目录补充读取权限后,Lexora 原先只接受元数据读取权限,并拒绝可读取目录内容的权限,因此两种安全策略发生冲突。ACL 表示该主体具备读取能力,不代表它实际读取过目录内容。 + +| 名称 | 含义 | +|---|---| +| `lexora_home` | Lexora 保存用户数据的根目录 | +| `validate_acl` | 检查 Windows 目录访问控制列表的启动步骤 | +| `CodexSandboxUsers` | Codex Windows 沙盒使用的本地组 | +| `mask=0x1200a9` | 该权限允许读取目录内容、读取属性、遍历目录等,不含写入权限 | +| `flags=0x3` | 权限可继承给子文件和子目录 | + +## 处理方式 + +Windows ACL 校验现在允许额外主体拥有只读、列目录、读取属性和执行/遍历权限;仍拒绝含写入等修改权限的 ACL。规则按权限类型生效,不专门信任 Codex 组。 + +**安全影响:** 获得这些只读权限的主体可以读取 `.lexora` 中的文件。此修复没有迁移或另行保护目录中的数据。 + +**验收记录:** Windows 原生测试 43 项通过;Rust 格式检查通过;Windows 安装包已重新生成。 + +相关实现:`apps/buddy/native/host/src/private_directories/windows/security.rs`。 +相关测试:`apps/buddy/native/host/__tests__/private_security_windows.rs`。 diff --git a/docs/bugs/bug-20260929-desktop-notification-navigation.md b/docs/bugs/bug-20260929-desktop-notification-navigation.md new file mode 100644 index 00000000..6122f071 --- /dev/null +++ b/docs/bugs/bug-20260929-desktop-notification-navigation.md @@ -0,0 +1,290 @@ +# Bug:桌面通知点击后打开目标会话不稳定 + +**日期:** 2026-09-29
+**优先级:** 高
+**状态:** 通知生命周期与导航修复已完成;Windows 开发版跨会话通知真实点击已由用户确认通过
+**代码基准:** 修复前为 `369dbacf`;修复现随本次提交记录。此前核查基准为 `6dd38bf3`,与修复前基准的关键实现相同。 + +## 用户反馈与预期结果 + +用户反馈:桌面通知点击后表现不稳定,有时桌面窗口没有出现;应用窗口已经存在时,也可能没有切到对应会话。用户转述,作者曾实现此功能,后续分屏改造后出现失效现象。 + +预期:点击通知后显示并聚焦桌面窗口,打开目标会话;存在关联执行记录时,切到对应分支并定位关联消息。若目标不可用或操作被阻止,应说明原因,避免无反馈地结束。 + +本文区分三个结果:窗口是否显示、会话是否打开、分支与消息是否定位。消息定位失败不等于会话没有打开,也不能用来解释窗口为什么没有出现。 + +## 核查范围与证据等级 + +- **用户反馈:** 上述不稳定现象;具体安装版本、发生频率和日志尚未收集。 +- **代码确认:** 可以直接看到的条件、返回路径及状态处理。 +- **时序推导:** 多处代码组合后存在的失败路径,尚未完成界面复现。 +- **待实测:** Windows 是否发出点击回调、窗口是否实际获得前台焦点,以及退出后旧通知的行为。 + +首次核查只阅读代码和 Git 历史。随后用户授权修复,修改与验证记录见文末。此前对话提到的临时探针已删除,无法独立核验,不作为本文的实测证据。本文所列代码行号对应修复前基准。 + +## 功能链路 + +系统通知点击 → 主进程打开窗口 → 发送会话目标 → 页面等待初始化 → 工作台打开会话 → 查询关联执行记录 → 激活对应分支 → 消息视图加载历史并滚动定位。 + +其中“执行记录”是一次任务运行,代码称为 `run`;“分支”是同一会话中的不同对话路径;“IPC”是主进程向页面发送消息的机制。 + +## 修复前失败路径记录 + +### N01:打开窗口失败后,通知目标丢弃,点击异常未单独处理 + +**证据:** 代码确认;是否为用户遇到的窗口不出现原因,待实测。 + +`DesktopWindowHost.openTarget` 先等待 `manager.open()`,成功后才发送目标。窗口加载失败或加载期间退出会使等待失败;窗口管理器不存在时直接返回;自动恢复已用尽时只显示恢复界面,不保留这次目标。 + +通知点击使用 `void openTarget(...)`,没有捕获该异步操作的失败。普通 `show()` 有 `window.activate_failed` 日志,通知打开路径没有相同处理。`DesktopIntegrations` 中的 `notification.failed` 捕获的是通知生成过程,不能覆盖之后点击回调的异步失败。 + +**用户表现:** 主窗口没有出现,或只出现恢复界面;后续会话跳转不执行。 + +**依据:** + +- `apps/buddy/electron/main/DesktopNotificationService.ts:86` +- `apps/buddy/electron/main/app/DesktopWindowHost.ts:97`、`:107` +- `apps/buddy/electron/main/DesktopWindowManager.ts:51`、`:86` +- `apps/buddy/electron/main/app/DesktopIntegrations.ts:158` + +### N02:目标只发送一次,没有接收与完成确认 + +**证据:** 代码确认机制缺口;具体丢消息场景和频率待实测。 + +主进程只调用一次 `webContents.send`,页面侧只注册事件监听。没有目标暂存、接收确认、跳转结果回传或重放机制。如果消息发送时监听不可用,或接收后页面重载、崩溃,没有恢复这次点击的路径。不能据此认定正常运行时监听一定尚未注册。 + +**用户表现:** 窗口可能出现,但没有切到目标会话;主进程无法判断页面是否收到或完成。 + +**依据:** `apps/buddy/electron/main/app/DesktopWindowHost.ts:115`;`apps/buddy/electron/preload/subscribe.ts:3`;`apps/buddy/src/app/bootstrap/DesktopAppProvider.vue:206`。 + +### N03:导航取消条件不能区分启动恢复与加载过渡,可能取消通知自身 + +**证据:** 代码确认取消条件;以下完整触发过程属于时序推导。 + +通知开始时记录导航版本。之后只要版本变化且当前会话不同于目标,就调用 `cancel()`,中止请求并清除定位目标。该判断没有识别变化来源。 + +**路径 A:启动恢复。** 通知在初始化结束前进入等待;没有保存的工作台布局时,启动恢复调用 `openTask` 或 `newTask`,增加导航版本。如果恢复出的当前会话不同于通知目标,待处理通知会被取消。普通布局恢复不必然增加版本,因此不是所有冷启动都会触发。 + +**路径 B:分屏加载。** 当前是会话 A,目标 B 已在另一窗格但尚未加载完成。通知打开 B 时增加导航版本,并聚焦 B 的现有视图。`ActiveTaskProjection` 对未恢复完成的会话返回空值,取消判断于是看到“当前不是 B”,中止通知后续处理。B 后来可能显示出来,但分支激活和消息定位已经取消。 + +**用户表现:** 停留在恢复出的会话;或目标窗格出现,但没有继续定位通知关联的分支、消息。 + +**依据:** + +- `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:31`、`:42`、`:84` +- `apps/buddy/src/app/workbench/useDesktopWorkbench.ts:253`、`:409` +- `apps/buddy/src/workbench/services/WorkbenchController.ts:263` +- `apps/buddy/src/app/workbench/ActiveTaskProjection.ts:23` + +### N04:启动等待没有超时,失败后也没有通知专属恢复流程 + +**证据:** 代码确认;完整启动失败场景待验证。 + +通知等待 `options.ready`,没有超时。生命周期的 `ready` 在运行服务就绪后触发的初始化流程结束时才解决;若启动失败发生在该流程开始之前,等待可能长期不结束。另一方面,该流程即使失败也会在 `finally` 中解决 `ready`,通知随后仍会尝试打开会话;后续失败没有自动重试机制。 + +**用户表现:** 窗口已经出现,但跳转一直等待;或初始化失败后打开失败,需要再次点击。 + +**依据:** `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:92`;`apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts:30`、`:112`、`:120`。 + +### N05:需要切换到另一分支时,现有操作限制阻止定位 + +**证据:** 代码确认;这是有条件的限制,不是审批通知必然失败。 + +通知入口先判断是否已经在目标分支;同一分支直接成功。需要切换到另一分支时,则要求运行服务就绪、没有活动任务、没有发送或分支修改、没有权限设置更新、没有正在编辑的消息,且目标分支存在于列表中。否则返回失败,通知不再设置消息定位目标。 + +分支列表刷新失败或状态不完整时,列表检查也可能阻止切换。正常打开会话会等待初始化与分支刷新,不能简单把“列表还没加载”视为必然路径。 + +**用户表现:** 目标会话已打开,但仍是原分支,或者没有定位关联消息。 + +**依据:** `apps/buddy/src/app/bootstrap/DesktopAppProvider.vue:196`;`apps/buddy/src/modules/tasks/state/conversations/useChatBranchMutations.ts:65`、`:76`;`apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:73`。 + +### N06:固定三秒计时清除尚未完成的消息定位 + +**证据:** 代码确认。 + +打开会话、激活分支后,通知设置目标消息并启动三秒计时器。计时器清除整个定位目标,而不只是结束视觉高亮。消息视图收到空目标时会中止待定位操作。因此,历史消息加载超过三秒或消息列表仍不可用时,定位会丢失。 + +**用户表现:** 会话、分支正确,但没有滚动到目标消息。 + +**依据:** `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:27`、`:75`;`apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts:88`、`:141`。 + +### N07:滚动未找到目标,也会结束操作并丢弃重试机会 + +**证据:** 代码确认。 + +定位过程中加载旧消息失败或返回 `false`,会停止分页并尝试滚动。实际滚动函数找不到目标消息时返回空值,上层没有把该结果作为定位失败处理,仍结束操作;待定位目标随后被清除。一次临时加载错误、历史未加载到目标或目标不存在,都可能导致本次定位结束,不会在加载恢复后自动继续。 + +**用户表现:** 会话已经打开,停留在其他消息位置。 + +**依据:** `apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts:141`、`:328`;`apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatTranscriptViewport.vue:113`;`apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts:175`。 + +## 其他会阻止跳转的现有规则与异常 + +下列情况需要记录原因,但不能全部按程序缺陷处理: + +| 条件 | 当前行为 | +|---|---| +| 当前是有未发送内容的新会话草稿,目标尚未打开 | 等待草稿处理选择;关闭弹窗、按 Esc 或取消会保留原页面 | +| 目标会话已删除、不可用或恢复失败 | 保留原页面或显示加载失败 | +| 同一窗格出现更新的打开请求 | 取消此前请求,优先后来的操作 | +| 用户离开任务页面或切换到其他会话 | 可能取消通知导航或消息定位 | +| 查询关联执行记录失败 | 会话可能已打开,但后续分支与消息定位停止;记录不存在时服务返回错误 | +| 用户主动滚动、改变阅读布局或返回最新消息 | 取消尚未完成的消息定位 | + +依据:`useDesktopWorkbench.ts:228`、`:238`;`TaskWorkspacePool.ts:53`;`WorkbenchController.ts:209`;`useDesktopNavigation.ts:38`、`:65`;`useChatViewport.ts:167`、`:209`、`:243`;`apps/buddy/service/src/runs/registerRunRpc.ts:48`。上述未带目录的文件分别位于本文前述工作台、导航及消息视图目录中。 + +## 尚不能认定的原因与历史纠正 + +- 隐藏到托盘与彻底退出进程不同。当前通知目标存在于运行进程的点击回调中,没有实现携带会话目标的重新启动路径;`DesktopApplication.ts:99` 的 `second-instance` 只显示窗口。但退出后旧通知是否重启应用、是否走此事件,需要 Windows 实测。 +- 窗口显示使用最小化恢复、`show()` 与 `focus()`。仅凭没有调用 `app.focus()` 或临时置顶,不能认定它就是窗口未弹出的原因。 +- `1467c3fe`(2026-09-18)引入工作台与分屏相关接入,但同时已有“目标分支已激活则直接成功”的判断。直接测试 `activateBranch()` 返回失败,不能证明实际通知入口在同一分支失败。 +- 三秒计时至少在 `f585dd68`(2026-09-08)已存在,不能归因于分屏重构。 +- 现有 `useDesktopNavigation.spec.ts:136` 的启动恢复用例只修改会话,不增加导航版本,未覆盖 N03 路径 A。导航测试使用模拟的分支激活方法,未验证完整工作台接入。 + +## 原始修复建议 + +1. **高:** 处理 N03,区分主动导航、启动恢复与加载过渡,避免通知取消自身。 +2. **高:** 处理 N06、N07,把定位完成与视觉高亮计时分开;定位失败时保留明确结果和可恢复目标。 +3. **高:** 处理 N01、N02,保存待打开目标,记录点击、接收、打开结果;窗口恢复或页面重建后仍能处理有效目标。 +4. **中:** 处理 N04、N05,明确展示等待或受阻原因,并提供恢复路径;不要为通知跳转直接放宽分支修改限制。 + +实现采用以下取舍:新的通知点击覆盖旧请求;用户主动导航或改变阅读位置取消待定位目标;会话、执行记录不可用或分支切换受阻时提示失败并结束本次请求。窗口或页面暂时不可用时保留最新目标,恢复后继续处理。 + +## Windows 实机验收范围 + +单元测试使用模拟窗口与页面状态;后续已运行真实 Windows Electron 窗口与页面,并用模拟数据验证三轮场景,结果见下文。系统通知浮层物理点击尚未完成。完整验收范围如下: + +1. 已运行且隐藏到托盘:点击另一会话通知,应显示窗口并打开目标。 +2. 初始化恢复期间点击通知:使用独立测试配置,确认恢复操作不会丢弃通知目标。 +3. 分屏中目标窗格尚未加载完成:点击后应等待目标可用,再完成分支与消息定位。 +4. 同一分支仍在运行:点击审批通知应能打开和定位;另一分支受阻时应说明原因。 +5. 历史加载超过三秒、单次加载失败或找不到消息:不应把未完成的定位当作成功。 +6. 页面恢复或重建期间点击:确认目标是否收到、保留,以及恢复后的处理结果。 + +验证应分别记录窗口、会话、分支、消息定位四个结果。系统点击回调是否到达也应单独记录;不要只用“点击没反应”作为最终结果。 + +## 修复记录(2026-09-29) + +| 问题 | 已实施处理 | +|---|---| +| N01:窗口打开失败 | 点击时立即保存最新目标;窗口尚未创建时也保留。打开失败记录诊断,并重试一次;恢复用尽时目标保留到窗口恢复。 | +| N02:消息没有完成确认 | 增加带请求编号的待处理目标与完成回传。页面就绪后主动领取目标;页面重建后可重新领取。旧请求的回传不能清除新请求。 | +| N03:恢复或分屏加载导致取消 | 系统通知等工作台初始化完成后再处理;导航判断使用当前窗格的会话资源,不依赖尚未加载完成的会话实例。用户主动切换会话仍会取消。 | +| N04:初始化失败后永久等待 | 生命周期出现明确失败时结束等待;通知在工作台未就绪时由主进程保留,加载恢复后再领取,不提前尝试打开。 | +| N05:分支受阻后无反馈 | 保留原有分支操作限制,以及已在目标分支时的成功判断。切换失败显示原因,结束本次请求,由用户在解除限制后重试。 | +| N06:三秒清除未完成定位 | 删除固定三秒清除;实际滚动成功后清除目标并回传完成。用户主动滚动、切换分支、返回最新或发送消息时取消待定位目标。 | +| N07:滚动失败被当作完成 | 滚动函数返回是否成功。失败时保留目标并提示一次;列表、加载状态或消息更新后继续尝试,成功后停止重试。 | + +主要修改位于 `DesktopWindowHost.ts`、主进程 IPC 与预加载桥接、`DesktopAppProvider.vue`、`useDesktopNavigation.ts`、`ActiveTaskProjection.ts`、`useChatViewport.ts` 及其消息定位回调连接处。新增提示同时覆盖中文与英文。 + +### 已执行验证 + +- 相关 5 个测试文件共 61 条用例通过:窗口目标暂存与重试、IPC、导航、生命周期、消息定位。后续补齐取消回传后,仅重跑受影响的导航与消息定位两组,53 条用例通过。 +- 已完成修改文件的 ESLint 检查。 +- `pnpm --filter @uselexora/lexora-buddy build:electron` 通过,包含 `vue-tsc` 类型检查及主进程、预加载、页面的 Electron 构建。 + +### 尚未验证与范围边界 + +- 真实 Windows 窗口显示与获得焦点已验证。系统通知浮层未暴露为 Computer Use 可操作窗口,因此模拟触发了实际 `Notification` 对象的 `click` 事件;不能据此确认鼠标点击系统浮层后的事件投递。 +- 未新增“彻底退出应用后点击旧通知,携带目标重新启动”的支持;本文已说明该场景需要系统实测,不能按托盘隐藏处理。 +- 没有绕过运行中的分支切换限制;这类受阻操作会明确提示。 +- 本次没有提交代码或生成 Windows 安装包。 + +## 模拟数据与真实桌面验证(2026-09-29) + +**环境:** 当前工作树的 Electron 构建,独立 `test` 配置与 SQLite 数据库;真实主进程、预加载桥接、工作台、分屏与消息列表。未使用用户现有会话或模型供应商,也未调用外部模型。 + +**数据:** 会话 A 有 3 条消息,会话 B 初始有 100 条,第三轮扩展到 300 条。模拟完成记录 `notify-run-b` 指向 `notify-test-b`、`notify-branch-b` 和第 5 条消息 `notify-message-b-5`,页面显示“通知定位目标 B-005”。 + +**操作方式:** 在构建副本加入仅用于观察的实例入口,直接注入模拟数据库记录;通过真实 `DesktopNotificationService.handle(run.event)` 生成系统通知。已观察到 `Notification` 的 `show` 回调。因系统浮层不在可操作窗口列表中,触发实际通知对象的 `click` 事件,随后走原有窗口打开、IPC、导航、分支与滚动链路。第二、三轮仅对测试实例的指定查询加入五秒延迟。 + +| 场景 | 观察结果 | 耗时 | +|---|---|---| +| 当前会话 A,点击关闭按钮隐藏到托盘,再模拟点击 B 通知 | 隐藏前 `isVisible=false`、`isFocused=false`;点击后两者均为 `true`,切到 B,并定位第 5 条消息;收到 `notification.target.opened`。 | 310 毫秒 | +| 左侧 B、右侧 A,页面重建时 B 会话查询延迟五秒,期间模拟点击通知 | 保留两个窗格;恢复后左侧 B 成为活动窗格,第 5 条消息位于可视区;收到完成回传。 | 5,277 毫秒 | +| 分屏中 B 有 300 条历史,目标位于未加载的旧消息;首个历史分页延迟五秒 | 点击前目标消息未渲染;第 3,508 毫秒只有点击事件,未误报完成;分页结束后继续获取更旧一页,并定位第 5 条消息,B 窗格获得焦点。 | 5,406 毫秒 | + +三轮最终目标消息的上边缘约为窗口内 `y=76`,均在消息可视区中。上述耗时是点击日志到页面完成回传之间的时间,不代表所有机器的性能。 + +**证据目录:** `C:\Users\Lenovo\AppData\Local\Temp\lexora-notification-1790686278941`,包含 `notification-test-results.json`、运行日志与三张截图:`hidden-notification-result.png`、`split-loading-result.png`、`slow-history-result.png`。测试实例与测试通知已关闭,临时构建入口已删除;测试缓存已归档到 `apps/buddy/.output/notification-cache-20260929`。失败启动时使用的独立测试目录保留,不影响项目源代码。 + +**结论边界:** 已验证“通知点击回调到达后,真实窗口显示、聚焦、会话切换和定位”的链路,包括超过三秒的加载。尚未验证 Windows 浮层鼠标点击、NSIS 安装版、进程完全退出后的旧通知、运行中另一分支受阻以及真实分页失败后的恢复。 + +## 开发版不显示通知的补充修复(2026-09-29) + +用户确认任务完成时开发窗口已最小化,因此不能用“前台通知”默认关闭解释本次现象。现有开发配置为 `notifications_enabled=true`、`notify_when_focused=false`;通知策略没有禁用开发模式。 + +**确认的代码缺口:** Windows 开发版只设置 `AppUserModelID`,没有创建携带该标识和 `ToastActivatorCLSID` 的开始菜单快捷方式。修复前未找到 `Lexora Buddy Dev.lnk`。Electron 的 [Windows 通知文档](https://www.electronjs.org/docs/latest/tutorial/notifications)要求配置这些信息;这是一项已确认的登记缺口,不能据此认定所有通知不显示都由它引起。 + +**修改:** `prepareDesktopReady` 仅在 Windows 未打包的 `development` 配置下创建或更新开发版快捷方式,并固定开发版通知激活器标识。登记失败记日志,不阻止启动。实际业务通知补充 `notification.shown` 与 `notification.delivery_failed` 回调日志;不修改用户通知开关或会话数据。 + +**实测:** 重启使用 `C:\Users\Lenovo\.lexora-dev` 的真实开发实例,核对快捷方式名称、目标、应用标识和激活器均正确。通过临时本地调试连接,在该进程中创建“Lexora 开发版通知测试”,收到真实 `show` 回调,无 `failed` 回调;用户明确回复“看到了”右下角通知横幅。测试未调用模型或写入聊天记录;临时通知及调试入口已清理,开发预览继续运行。 + +**验证范围:** 本轮构建(含类型检查)、两处代码的 ESLint 和 `git diff --check` 通过。本轮确认了真实 Windows 横幅显示;通知是直接创建的投递探针,未伪造任务完成事件,未测试鼠标点击后导航,因此不能替代业务完成事件与通知点击的后续验收。 + +## 跨会话点击失败的补充排查(2026-09-29) + +用户新反馈:留在生成会话时,最小化后点击通知可以展开;准确失败顺序为 A 开始生成 → 用户切换到 B → 最小化 → A 完成并弹出系统通知 → 点击通知既没有展开窗口,也没有跳回 A。通知是在切到 B 并最小化之后才创建的,不能把此反馈改写成“先显示 A 通知,再切到 B”。 + +### 开发实例日志 + +同一启动 `e111df22-d232-46d7-b06a-980254f61f12`,本地时间 UTC+8: + +| 时间 | 事件 | 含义 | +|---|---|---| +| 21:46:38.234 | `notification.shown` | 业务通知已显示 | +| 21:46:39.737 | `notification.clicked` | 通知点击回调到达主进程 | +| 21:46:39.797 | `notification.target.opened` | 页面完成目标定位 | +| 21:47:45.231 | `notification.shown` | 第二次业务通知已显示 | +| 后续 | 没有对应的 `notification.clicked`、目标完成或窗口打开失败记录 | 第二次未进入现有点击打开入口 | + +日志来源:`C:\Users\Lenovo\AppData\Local\Lexora Buddy Dev\state\logs\application.jsonl`。现有事件不带通知唯一标识,关联依赖时间顺序;未据此断言第二次的底层 Windows 激活事件完全没有发生。 + +### 已确认的缺口与根因候选 + +- `DesktopNotificationService.handle` 内的系统通知只是局部变量,类只保存已显示事件的字符串集合;`DesktopIntegrations` 的原生 `Notification` 也是局部变量。发送结束后没有持有通知对象的长期容器。 +- 点击只绑定在该对象的 `click` 事件上;仓库没有注册 `Notification.handleActivation`,也没有独立保存系统通知标识到会话、run 的映射。 +- Electron 44.4.5 的 [Windows 激活实现](https://github.com/electron/electron/blob/v44.4.5/shell/browser/notifications/win/windows_toast_activator.cc)会寻找对应通知对象,找不到就无法派发对象点击;[官方 API](https://www.electronjs.org/docs/latest/api/notification#notificationhandleactivationcallback-windows)专门提供对象被回收、应用重启和冷启动情况下的统一激活接收。 +- 因而“通知对象被回收或旧通知失去对象对应关系”是当前最可疑的失败原因,能够解释窗口和导航同时没有动作。此次尚无对象回收的直接记录,不能把“切换会话必然触发回收”写成实测结论。 +- `DesktopWindowHost.openTarget` 在记录 `notification.clicked` 之后才打开窗口;页面路由和分支失败不能解释这次缺少该入口日志。切换会话也不会移除全局主进程的 `service.onNotification` 订阅。 + +### 同类衍生场景 + +1. 横幅显示后等待较久、切换多次会话或发生内存回收,再点击通知:同样可能失去对象回调。尚未分别实测。 +2. 从 Windows 通知中心点击旧通知:不能假定横幅已经消失就不需要保留点击接收;对象生命周期必须覆盖旧通知。尚未单独实测。 +3. 进程完全退出或开发主进程重启后点击旧通知:原对象已不存在,现有 `second-instance` 仅显示窗口,不恢复会话目标。代码缺口确认,系统表现待实测。 +4. A 在生成,用户在前台看 B:通知策略只检查整个窗口的焦点,不检查当前可见会话,所以 `notifyWhenFocused=false` 时 A 的完成通知也被抑制;随后最小化不会补发。代码行为确认,与“已显示但点不动”分开记录。 + +### 验证不足的更正 + +此前模拟测试保留通知对象并主动触发它的 `click` 事件,因此只证明回调到达后的窗口、IPC、页面链路,无法检验真实对象生命周期或 Windows 点击入口。这轮未修改业务代码,未扩大自动测试;下一步应针对通知对象持有、Windows 统一激活接收与目标映射做最小修复,再验证真实跨会话点击。 + +## 对照 XTLaw 后的通知生命周期优化(2026-09-29) + +**对照结果:** `D:\code\XTLaw\apps\desktop\electron\main\ipc\notification-ipc.ts` 用主进程级 `Map` / `Set` 保存原生通知,避免随当前会话变化而丢失对象;点击后先恢复窗口,再发送固定会话目标。其 `close` 回调无条件释放对象,不能直接用于 Lexora 的 Windows 通知中心旧通知:横幅超时和隐藏也可能触发 `close`。 + +**本轮修改:** + +- 在现有 `DesktopNotificationService` 保存每条未清理通知的包装对象;该对象通过回调持有真实 Electron 通知,不依赖页面或选中会话。 +- Windows `timedOut`、`applicationHidden` 或缺失原因的 `close` 不释放通知;明确 `userCanceled` 才释放。其他平台的原生关闭映射为用户关闭,维持其清理行为。 +- 点击使用创建时绑定的会话和 run,且只处理一次;异步或同步打开失败由 `notification.target.failed` 记录。 +- 主进程退出时先阻止新投递、清空持有关系,再关闭所有仍保存的通知;窗口隐藏、最小化和切换会话不触发此清理。查询仍在进行时退出,也不会随后弹出新通知。 +- 并发到达的同一事件只创建一条通知;投递失败释放对象并允许事件重放重试。 + +**本轮验证:** `build:electron`(含类型检查及三端构建)、三个代码文件的 ESLint、`git diff --check` 均通过;通知服务与窗口目标两组共 7 条用例通过。其中回归路径验证了 A 通知出现后收到 B 通知,A 横幅超时/隐藏后点击仍使用 A 的原目标;还覆盖重复投递、重复点击、退出清理与投递失败。测试的系统通知是外部边界 mock,不代表 Windows 鼠标点击已经通过。 + +**复验步骤:** 使用新开发实例在 A 发起任务,生成中切换到 B 并最小化;A 完成后点击系统通知,应显示窗口、回到 A 并定位对应消息。另一次等待横幅消失,再从 Windows 通知中心点击,也应打开 A。分屏时已打开的 A 应在原窗格获得焦点。 + +### 用户实测结果(2026-09-29) + +用户按真实开发版使用流程复验:会话 A 生成内容时切换到会话 B 并最小化;A 完成并显示系统通知后,点击通知测试通过。该结果确认本次报告的跨会话场景可以恢复桌面窗口并跳回 A。此项是用户在 Windows 开发版中的实际操作结果,不是自动化模拟。 + +横幅消失后从通知中心点击、进程完全退出后点击旧通知、以及分屏中原窗格恢复等场景没有包含在这次用户确认中,仍按各自范围处理;模拟消息列表定位与慢加载结果见前文自动化验证记录。 + +**范围:** 本轮修复应用运行期间的通知生命周期,继续复用已实现的目标暂存、分屏导航和完成回传;不新增进程彻底退出后点击旧通知的冷启动恢复,不调整前台通知策略,不修改 XTLaw。 + +## 记录验收 + +- 已记录用户现象、失败条件、代码位置、证据等级与修复建议。 +- 已区分正常取消规则、代码缺口和待实测推断。 +- 已将修复内容、自动化验证结果、Windows 开发版跨会话真实点击结果和未覆盖范围补入文档;相关代码与本文档一并提交。 diff --git a/docs/specs/assets/feature-005-default-permission-mode.png b/docs/specs/assets/feature-005-default-permission-mode.png new file mode 100644 index 00000000..5566b31f Binary files /dev/null and b/docs/specs/assets/feature-005-default-permission-mode.png differ diff --git a/docs/specs/feature-001-turn-process-folding.md b/docs/specs/feature-001-turn-process-folding.md new file mode 100644 index 00000000..d50ca987 --- /dev/null +++ b/docs/specs/feature-001-turn-process-folding.md @@ -0,0 +1,183 @@ +# Spec-001:AI 回答过程分段折叠方案 + +**日期:** 2026-09-29 +**状态:** 已审核通过;按分段独立折叠方案实现 + +## 1. 背景 + +目前对话回答中的思考、工具调用及其他过程内容需要清楚地折叠和展开。用户可能在 AI 思考过程中插入新消息,时间线会把同一轮回答的过程拆成多个片段;每个片段需要保留自己的折叠状态,同时保持消息顺序和最终回答可见。 + +原方案按一次完整回答(Turn)使用单一折叠状态。当前方案按转录时间线中的过程片段分别折叠,不把插入消息前后的片段强制合并成一个控制器。本方案只描述目标行为,不要求照搬其他项目的架构或代码。 + +## 2. 目标与核心规则 + +**目标:** 每个连续的 AI 过程片段都有独立折叠入口;插入的用户消息保持原位,最终回答始终可见。 + +**核心规则:** + +1. 过程节点按其在聊天时间线中的连续片段显示;用户在运行中插入消息时,消息前后的过程属于不同片段。 +2. 每个片段独立折叠该片段内的思考、工具调用、工具结果及其他过程内容;操作一个片段不改变其他片段。 +3. 最终回答始终单独显示,不纳入过程折叠范围。 +4. 首个带 AI 身份的片段将状态入口放在头像栏;没有重复头像的后续片段,在自身时间位置显示紧凑入口。 +5. 不同回答和同一回答中的不同片段都各自维护折叠状态;新一轮回答不继承上一轮的状态。 +6. 运行中的片段默认展开,用户可以手动折叠;完成时,未被手动操作的片段默认收起。失败或中断片段默认展开。 + +## 3. 场景与预期行为 + +### 3.1 正常完成 + +- AI 正在生成时,过程片段默认展开;用户可单独折叠当前片段,片段更新时保留该片段的选择。 +- AI 正常结束后,未手动操作的过程片段默认收起;最终回答完整可见。 +- 带头像的片段在头像栏显示状态和耗时入口;没有重复头像的后续片段,在过程位置显示独立、紧凑的入口。 +- 折叠或展开一个片段不影响同一回答的其他片段,最终回答保持原位和可见。 + +### 3.2 只有思考、只有调用或多段过程 + +- 即使只有一段思考或一次调用,也应服从其过程片段的折叠入口,不因内容“只有一项”而绕过入口并默认铺开。 +- 同一个连续片段内的多段思考和多个工具调用由该片段入口整体控制。 +- 用户插入消息后,消息前后形成的过程片段可分别展开或收起;插入的用户消息始终保留在时间线中。 +- 过程内容为空时,不显示无意义的折叠入口。 + +### 3.3 中断与失败 + +- 仍在运行中的片段默认展开,但用户可以手动收起;后续过程更新不应重置手动选择。 +- 中断或失败的片段默认展开,以便查看诊断信息;失败详情独立显示,不因过程收起而消失。 + +### 3.4 特殊消息边界 + +- 用户消息、回答前后独立的系统提示/状态提示、审批控件等,不应仅因时间上邻近回答过程而被误收起。 +- 多轮工具调用、模型继续生成及最终回答边界按运行数据确定;新用户消息不应被过程折叠隐藏。 +- 用户在思考中插入消息时,前后过程片段可以独立折叠;AI 完成后用户再发送的新消息属于新一轮,状态彼此独立。 +- 若一次运行产生多个 assistant 回合/中间回复,需依据当前运行数据确定哪些节点属于“过程”,并确保最终面向用户的回答保留显示。 + +## 4. 需求边界 + +**包含:** + +- 对话转录区中连续过程片段的分组与独立折叠交互。 +- 折叠范围覆盖该片段中的思考、工具调用及对应过程内容。 +- 最终回答独立呈现且在折叠状态下可见。 +- 运行中、完成、中断、失败等状态对应的折叠策略。 +- 键盘操作、无障碍状态(例如展开按钮的 `aria-expanded`)与折叠状态测试。 + +**不包含:** + +- 修改模型推理、工具执行、流式协议或对话持久化格式,除非实现阶段证明这是划分边界所必需的;如需变更,应另行说明并审阅。 +- 删除现有逐条思考/工具细节展开能力。 +- 改造独立的对话画布、运行日志、调试轨迹等非聊天转录视图。 +- 直接复制参考项目的数据结构、状态管理或组件实现。 + +## 5. 交互与界面示意 + +以下示意为当前 UI 结构。首个显示 AI 身份的片段在头像栏提供折叠入口;后续没有重复头像的片段在各自时间位置提供紧凑入口。分隔线只跟随可见的 AI 身份栏,不为无头像的续接片段重复绘制。 + +### 5.1 思考进行中 + +```text +[头像] Lexora Buddy 思考中 · 15 秒 > +──────────────────────────────────────────── +当前片段的思考内容…… +当前片段的工具调用…… +``` + +- 头像栏显示当前片段状态;运行中仍可点击折叠。 +- 运行中的当前片段默认展开;其他片段保持各自状态。 +- 头像栏下方显示浅色细分隔线。 + +### 5.2 最终回答完成,过程默认收起 + +```text +[头像] Lexora Buddy 已完成 · 18 秒 > +──────────────────────────────────────────── +最终回答正文…… +``` + +- 头像栏显示完成状态和耗时;点击只控制该片段。 +- 未手动操作的过程片段默认收起;最终回答始终完整显示。 +- 同一轮回答的其他片段不受此入口影响。 + +### 5.3 插入消息后的各过程片段分别展开 + +```text +[头像] Lexora Buddy 已完成 · 18 秒 ⌄ +──────────────────────────────────────────── +当前片段的思考内容…… +工具调用…… + + 还有长沙 + +[思考图标] 思考完成 · 6 秒 ⌄ +后续片段的思考内容…… +最终回答正文…… +``` + +- 头像栏入口只展开或收起当前片段;后续片段有自己的入口和状态。 +- 插入的用户消息保持在原时间位置,不被过程折叠隐藏。 +- 展开/收起只影响对应片段,不隐藏最终回答。 + +### 5.4 交互规则 + +- 有头像的过程片段使用顶部头像栏入口;无头像的续接片段使用紧凑的行内入口,不铺满灰色按钮背景。 +- 默认策略:运行中片段展开、完成后片段收起;用户可在运行中手动折叠。 +- 手动展开/收起后,当前片段在本次组件生命周期内尊重其选择;不同片段和不同回答互不影响。 +- 摘要只显示回答状态和耗时,不显示思考全文或调用数量。 +- 审批卡、思考、工具调用、工具结果、旁白和面板操作属于过程区;失败详情独立保留并始终可见。 + +## 6. 实现方向与结果 + +过程节点按消息时间线分段后分别呈现。本项目在展示层为每个 `BuddyChatAgentTurn` 维护本地折叠状态,不改动模型事件协议或持久化结构。 + +实现时已验证: + +1. `turn.nodes` 按时间线拆分后,每个片段独立渲染其活动组、叙述文本、面板操作和审批提示;最终回答独立显示。 +2. 运行中默认展开且可手动折叠;完成后未手动操作的片段默认收起;失败/中断默认展开。 +3. 有身份头像的片段使用头像栏入口;没有重复头像的续接片段显示紧凑的独立入口。 +4. 折叠状态由每个 `BuddyChatAgentTurn` 独立维护,不影响同轮的其他片段或相邻回答。 + +不得仅将现有 `BuddyChatActivityGroup.vue` 的每组折叠逻辑当作片段级折叠;片段入口必须覆盖该连续片段内的全部过程节点。 + +## 7. 验收标准 + +- [x] 同一连续片段内的思考、调用、调用结果和叙述文本由一个入口控制。 +- [x] 用户插入消息后,前后过程片段显示独立入口;收起一个片段不影响其他片段。 +- [x] 无重复头像的续接片段在其时间位置显示紧凑入口,不显示默认灰底的整行按钮。 +- [x] 折叠过程时,插入的用户消息和最终回答仍完整可见。 +- [x] 运行中片段默认展开且可手动折叠;完成后未手动操作的片段默认收起。 +- [x] 一个片段的展开状态不会影响同一轮其他片段或下一轮回答。 +- [x] 中断、失败、审批等待及重试场景均有可理解且可操作的展示,不丢失错误/审批信息。 +- [x] 键盘可操作每个折叠入口;展开状态通过无障碍属性正确暴露。 +- [x] 已覆盖完成态默认折叠、运行态转换和过程活动展示的自动化测试。 + +## 8. 已确认的设计决策 + +1. **最终回答边界:** 继续沿用当前 `ChatAgentTurn` 的节点投影,最终回答独立显示,不纳入过程内容的隐藏范围。 +2. **折叠范围:** 按聊天时间线中的连续过程片段划分;插入消息前后的片段不共用状态。 +3. **运行时策略:** 运行中默认展开且允许手动折叠;完成后未手动操作的片段默认收起。 +4. **失败/中断默认状态:** 默认保留过程展开状态;失败详情独立显示,方便诊断。 +5. **折叠状态记忆:** 每个片段独立维护当前组件生命周期内的选择,不跨会话持久化。 +6. **摘要内容:** 有身份头像的片段在头像栏显示状态和耗时;续接片段在行内显示状态和耗时。 +7. **特殊节点归属:** 审批卡、面板操作、过程旁白、思考、工具调用及工具结果纳入各自片段的折叠;失败详情保持独立可见。 + +## 9. 相关代码与参考 + +| 原名称 | 中文含义 | 用途 | +|---|---|---| +| `BuddyChatAgentTurn.vue` | AI 回合片段容器 | 为当前过程片段提供独立折叠状态和入口,并独立呈现最终回答。 | +| `BuddyChatAgentIdentity.vue` | AI 头像和名称 | 支持在顶部可点击区域内以行内形式呈现头像和名称。 | +| `BuddyChatAgentTurnFlow.vue` | AI 回合过程流组件 | 渲染当前片段的活动组、过程文本及面板操作,并为无头像的续接片段提供紧凑入口。 | +| `BuddyChatActivityGroup.vue` | 单个活动组组件 | 维护活动组内部的局部折叠状态;它与片段级折叠相互独立。 | +| `ChatAgentTurn` | AI 回合数据结构 | 包含一次运行及其过程节点;转录投影可按插入消息将节点显示为多个片段。 | +| `TurnProcessNodeView.tsx` | 参考项目的回合过程控制组件 | 提供覆盖整个 Turn 的过程折叠入口。 | +| `ChatNodeSeat.tsx` | 参考项目的聊天节点容器 | 根据过程边界隐藏过程节点,并将最终回答与过程区分。 | + +参考实现位置:`D:\code\zcode XTLAW\deepseek-harness\packages\client\ui-chat\src\client\chat\TurnProcessNodeView.tsx`、`ChatNodeSeat.tsx`。参考实现仅用于说明交互和分组思路,不构成本项目代码依赖。 + +## 10. 审阅结论 + +**审核结论:已通过(2026-09-29)** + +- 采用“运行中默认展开且可手动折叠、完成后未手动操作的片段默认收起、最终回答始终显示”的策略。 +- 首个有头像的片段在头像栏显示入口;后续片段使用紧凑、透明背景的独立入口。 +- 插入消息前后的过程片段以及相邻回答不共享折叠状态。 +- 失败/中断片段默认展开;失败详情独立显示,方便诊断。 +- 折叠选择只在当前组件生命周期内有效,不做跨会话持久化。 diff --git a/docs/specs/feature-002-resource-chat-pane-swap.md b/docs/specs/feature-002-resource-chat-pane-swap.md new file mode 100644 index 00000000..e52f27c5 --- /dev/null +++ b/docs/specs/feature-002-resource-chat-pane-swap.md @@ -0,0 +1,104 @@ +# Spec-002:资源面板与聊天区域位置切换 + +**状态:** 基础交换功能已验收;本次补充的布局与状态规则已实现,待手动验收 + +## 1. 背景 + +桌面端标题栏已有“展开资源面板”按钮。用户希望资源面板展开后,在该按钮旁提供一个切换视窗按钮;点击后,资源面板与聊天区域交换左右位置,方便按当前阅读/操作习惯安排主区域。 + +参考项目 `D:\code\XTLaw` 的工作区布局切换交互。该项目的实现仅作为交互参考;Lexora 当前使用 Vue 工作台布局,不能直接照搬其 React 实现。 + +## 2. 当前结构与建议 + +经代码检查,当前相关入口包括: + +| 文件 | 中文说明 | 当前职责 | +|---|---|---| +| `apps/buddy/src/app/shell/DesktopShell.vue` | 桌面端外壳 | 协调位置交换、聊天区隐藏和任务/浏览模式状态 | +| `apps/buddy/src/app/shell/window/DesktopTitleBar.vue` | 桌面端标题栏 | 提供位置交换及当前右侧区域展开/收起控制 | +| `apps/buddy/src/app/workbench/DesktopWorkbenchArea.vue` | 桌面端工作区区域 | 将位置与可见状态传给聊天/资源面板布局 | +| `apps/buddy/src/workbench/browser/layout/WorkbenchLayout.vue` | 工作台布局组件 | 安排侧栏、聊天区和资源面板;隐藏聊天时让资源面板临时填充空间 | +| `apps/buddy/src/app/shell/contextPanePlacement.ts` | 位置状态策略 | 根据任务联动/独立浏览模式决定是否重置位置状态 | + +`DesktopShell.vue` 持有位置交换状态并传给标题栏与 `DesktopWorkbenchArea.vue`;`WorkbenchLayout.vue` 调整聊天区和资源面板的顺序,任务侧栏不参与交换,侧栏控件仍锚定在侧栏边缘。标题栏按钮顺序为左侧“交换位置”、右侧“展开/收起当前右侧区域”。 + +位置状态遵循资源面板的浏览模式(设置项 `desktop.contextPanelMode`),但不改变该设置本身的资源选择语义: + +- **任务联动(`task`):** 位置只在当前活动任务中临时有效。活动任务切换时,位置恢复默认(聊天区左、资源面板右);同一任务内导航离开再返回,保留本次运行中的位置状态。 +- **独立浏览(`independent`):** 资源面板不随任务切换,位置交换也不因任务或页面导航而重置;仍只保留到应用关闭。 +- **应用重启:** 两种模式都恢复默认位置,不持久化交换状态。 + +聊天区在资源面板位于左侧时可通过标题栏按钮临时隐藏。聊天区隐藏期间,资源面板扩展填满聊天区释放的空间,隐藏无效的宽度拖动分隔线;聊天区重新显示时,恢复此前的资源面板可调整宽度,不把临时全宽写入宽度偏好。 + +图标使用项目现有 `@vicons/fluent` 中的 `PanelRightExpand20Regular`、`PanelRightContract20Regular` 和 `ArrowSwap20Regular`。 + +## 3. 需求边界 + +**包含:** +- 资源面板已展开且当前为任务/聊天工作区时,在标题栏资源面板开关旁显示位置切换按钮。 +- 点击切换按钮后交换聊天区域和资源面板的位置;再次点击恢复原位置。 +- 资源面板收起时不显示位置切换按钮;打开资源面板后再次显示。 +- 为两个按钮提供中英文提示和可访问名称;交换按钮提供可判读的切换状态。 +- 资源面板开关在收起/展开状态间切换展开/收起图标。 +- 保持已有的资源面板开关、任务侧栏及其收起/展开控件、面板宽度调整和其他页面导航行为。 +- 聊天区隐藏时让资源面板临时填满可用宽度;聊天区恢复后还原原宽度。 +- 任务联动模式下,任务切换重置位置;独立浏览模式下,任务/页面切换保留位置。两种模式下切换任务均重新显示聊天区。 + +**不包含:** +- 记住用户上次选择并跨重启恢复。 +- 改动 XTLaw 或复用其实现代码。 +- 改变资源面板内部标签、任务侧栏或其他页面的布局。 +- 将资源面板移至独立窗口。 + +## 4. 交互流程 + +### 正常流程 + +1. 用户进入任务/聊天工作区。 +2. 用户点击“展开资源面板”。 +3. 标题栏在资源面板开关旁显示位置切换按钮;从左到右依次为“交换位置”和“展开/收起资源面板”。资源面板开关图标显示为收起面板状态对应的图标。 +4. 用户点击“交换位置”,聊天区域与资源面板交换左右位置,任务侧栏及其收起/展开控件保持在最左侧及侧栏边缘不动。 +5. 用户再次点击切换按钮,布局恢复;用户也可收起资源面板,切换按钮随之隐藏。 + +### 边界流程 + +- 用户切换到非任务页面时,不显示位置切换按钮。 +- 任务联动模式下,活动任务变化时位置恢复默认;离开并返回同一任务不重置位置。 +- 独立浏览模式下,切换任务或页面都不重置位置;应用重启仍回到默认布局。 +- 资源面板关闭期间不允许通过隐藏的切换按钮修改位置。 +- 交换后隐藏聊天区时,资源面板填满可用区域且不遗留空白;恢复聊天区后恢复此前宽度。 +- 切换过程中应避免分隔线错位、面板宽度丢失、焦点落入不可见内容等问题。 + +## 5. 验收标准 + +- [ ] 任务/聊天页面中,资源面板收起时只显示资源面板开关,不显示位置切换按钮;开关显示展开面板图标。 +- [ ] 资源面板展开后,从左到右依次显示位置切换按钮和资源面板开关;开关显示收起面板图标,中英文提示正确。 +- [ ] 点击按钮能交换聊天区与资源面板的位置,再次点击能恢复。 +- [ ] 任务侧栏在交换前后均保持在最左侧,其收起/展开控件保持锚定于侧栏边缘,不跟随聊天区域移动。 +- [ ] 资源面板在任一侧时均可正常拖动调整宽度,且分隔线/边框位于正确一侧。 +- [ ] 收起/展开资源面板、页面导航和已有资源面板操作不回归。 +- [ ] 聊天区隐藏时资源面板填满可用宽度;恢复聊天区后还原原宽度。 +- [ ] 任务联动模式切换任务时位置恢复默认;独立浏览模式跨任务/页面保持位置;切换任务后聊天区重新显示。 +- [ ] 键盘操作与辅助技术可识别该按钮的用途及当前状态。 +- [ ] 应用重启后两种浏览模式都回到默认布局。 + +## 6. 验证建议 + +已验证:`vue-tsc` 类型检查通过;位置状态策略测试 3 项、工作台面板布局测试 4 项通过;`git diff --check` 通过。隐藏聊天时资源面板填满与恢复宽度的视觉行为仍需手动验收。 + +## 7. 已确认行为 + +- 位置交换状态不跨应用重启持久化。 +- `contextPanelMode` 的“任务联动/独立浏览”控制位置状态的作用范围,不改变资源面板本身的资源选择逻辑。 +- 任务联动模式只在活动任务变化时重置位置;独立浏览模式跨任务和页面保留位置。切换任务时两种模式都显示聊天区。 +- 图标库核对已完成:项目当前依赖包含 `PanelRightExpand20Regular`、`PanelRightContract20Regular` 和 `ArrowSwap20Regular`。 + +## 8. 文件与术语说明 + +| 原名称 | 中文含义 | 用途 | +|---|---|---| +| `DesktopShell.vue` | 桌面端外壳组件 | 放置跨标题栏与工作区共享的临时切换状态 | +| `DesktopTitleBar.vue` | 桌面端标题栏组件 | 在现有资源面板开关旁提供切换按钮 | +| `DesktopWorkbenchArea.vue` | 桌面端工作区区域组件 | 将切换状态传给工作台布局 | +| `WorkbenchLayout.vue` | 工作台布局组件 | 按切换状态安排聊天区域和资源面板;隐藏聊天时临时扩展资源面板并保留原宽度偏好 | +| `contextPanePlacement.ts` | 位置状态策略模块 | 任务联动模式在任务变化时重置位置;独立浏览模式保留位置状态 | diff --git a/docs/specs/feature-003-session-reference.md b/docs/specs/feature-003-session-reference.md new file mode 100644 index 00000000..32f2ecde --- /dev/null +++ b/docs/specs/feature-003-session-reference.md @@ -0,0 +1,174 @@ +# Spec-003:引用历史会话并按需检索 + +**日期:** 2026-09-28 +**状态:** 桌面端已实现,用户预览验收通过(2026-09-29) +**修订:** 2026-09-29 用户预览验收通过;自动保存不再重置行内引用位置。任一 Space 中的会话均可被显式引用;目标会话仍需存在且未删除;“复制会话引用”位于侧栏“更多”菜单的“重命名”上方 +**参考:** XTLaw;Pi 社区包 [`pi-session-ask`](https://github.com/lajarre/pi-session-ask) + +## 1. 背景 + +**产品范围:Lexora Buddy 桌面端(`apps/buddy`)。** 本规格不是 Lexora 网站(`apps/web`)功能;实现、验收和文案均以 Buddy 的本地聊天工作区为准。桌面端会话保存在 Buddy 本地服务管理的数据库中,界面通过受限 runtime RPC 访问;不得让 renderer 或模型直接读取数据库、Pi JSONL 文件或本地路径。 + +当前聊天没有引用历史会话的交互。用户希望从会话菜单复制引用,在另一段聊天的输入框粘贴并发送,让 AI 能将被引用会话作为回答上下文。 + +Pi 社区包 `pi-session-ask` 提供了值得借鉴的机制:把目标会话交给隔离的子代理,按当前问题检索会话历史,再将相关答案返回给主 agent;它不把整份历史预先塞进当前上下文。该包读取 Pi 的 JSONL 会话文件,不能直接安装后访问 Lexora 的会话数据,因此本项目复用其**按需检索思路**,而不是复用包代码或存储适配器。 + +## 2. 目标与方案结论 + +**目标:** 用户能显式附加一个或多个历史会话;发送后 AI 能在必要时检索其中与当前问题有关的信息,并据此回答。 + +**推荐方案:轻量引用元数据 + agent 按需读取。** + +- 从侧栏会话项的“更多”菜单中选择位于“重命名”上方的“复制会话引用”,复制可识别的会话引用协议;协议仅包含会话 ID、标题等展示元数据,不包含会话全文。 +- 粘贴时沿用文件粘贴的双重呈现:输入框上方显示可移除的会话引用 chip,光标处显示同款蓝色 `@会话标题` 行内 token;引用可去重,并随草稿保留。 +- 消息提交时将引用作为结构化元数据传递并持久化。 +- UI 复用现有文件引用的行内 token 样式和上下文标签布局:composer 中同时显示行内 token 与可移除引用标签;发送后用户气泡显示只读引用标签,行内 token 仅存在于 composer 编辑器状态。 +- Agent 只有在回答需要历史细节时,才调用受权限约束的会话检索能力,搜索/读取被引用会话的相关内容。 +- 检索结果仅进入当前这一轮模型上下文,不修改当前会话历史,也不自动把目标会话全文复制进消息。 + +**不建议**默认将整段历史直接展开进提示词:这实现虽短,但会产生上下文膨胀、隐私暴露面增大、引用无法追踪等问题,也失去了 `pi-session-ask` 的核心优势。 + +## 3. 用户流程 + +1. 用户在可引用的历史会话项上打开“更多”菜单,选择位于“重命名”上方的“复制会话引用”。复制成功后按现有菜单行为关闭菜单,不新增成功 toast;复制失败时复用项目现有复制失败反馈。 +2. 用户切换到目标聊天,在输入框粘贴;composer 识别引用格式,在光标处插入蓝色 `@会话标题` token,并在输入框上方显示可移除的引用 chip,行为与文件粘贴一致。 +3. 用户输入问题并发送。引用只是上下文标签,不能单独发送:正文必须非空,正文为空时 composer 的发送按钮保持禁用(只贴引用、没有正文的情况同样禁用),服务端也拒绝空正文请求。用户需要自己写出问题或概括要求。 +4. Agent 收到正文和引用 ID。必要时调用会话检索工具,先搜索摘要/消息,再读取少量相关片段;无需读取时可直接作答。 +5. AI 在回答中依据取回的内容作答;检索无结果或无权访问时,明确说明,不能假装读过。 + +## 4. 数据与交互协议 + +### 4.1 剪贴板 + +剪贴板采用带稳定标记的纯文本协议,便于跨输入框粘贴和调试。字段为 `id`、`title` 两项,首版不写入 `workspaceId` 等额外字段,**不得把剪贴板字段当授权凭证**。解析时做字段清洗(去首尾空白、折叠标题换行)与格式校验(标记与字段完整性);标题长度上限沿用会话标题上限,且服务端始终用数据库中的真实会话标题覆盖剪贴板标题;普通文本粘贴仍沿用现有逻辑。 + +协议需要支持一条或多条引用。重复 ID 在同一个草稿中静默跳过:不新增标签,也不弹提示。错误或不完整标记按普通文本处理,避免吞掉用户剪贴板内容。 + +### 4.2 消息数据 + +在聊天消息提交契约中增加可选的结构化 `sessionReferences` 字段,元素只包含必要 ID 和展示标题。创建消息与编辑重发都需定义一致行为。引用元数据应持久化到用户消息 metadata,并在消息回放、分支、失败重试、草稿恢复时保持正确。 + +Agent 提示应将用户正文与“已引用会话列表”分区表达,清楚说明引用是上下文材料而非切换当前会话的指令。提示中列出的标题/ID仅供发现目标,不把它们视为可访问性的证明。 + +### 4.3 UI 展示与组件复用 + +**粘贴后(输入框):** + +- 复用 Buddy 文件引用已有的蓝色行内 token 样式,在粘贴光标处显示 `@会话标题`;token 只存在于编辑器状态,不序列化进消息正文。 +- 同时复用 Buddy composer 上方的会话引用标签区域、尺寸、圆角、长标题省略与移除交互。点移除会从草稿中同步删除该引用及行内 token;重复粘贴按 ID 静默去重。草稿自动保存不会重建编辑器或重置当前粘贴位置;重新加载草稿时,行内 token 默认恢复在第一段开头,因为草稿只持久化引用元数据、不保存 token 的精确位置。 +- 草稿与消息只通过结构化 `sessionReferences` 元数据保存会话引用,模型正文不包含会话标题或机器协议。 + +**发送后(用户气泡):** + +- 发送后沿用只读会话引用标签;composer 中的行内 token 不写入消息正文。引用存在而正文为空时,气泡仍要显示引用,不能被空消息判断隐藏。 +- 样式可沿用当前 user bubble 的浅色边框/半透明底色、最大宽度和单行省略规则;标签 hover/title 显示完整标题。 +- 不渲染移除按钮;引用是否可点击跳转到原会话属于后续增强,首版可先只读展示。 +- 用户消息编辑时,应把同一引用重新加载成可移除的 composer 标签;取消编辑不改动原消息引用。 + +**不复用的现有结构:** + +`ChatReference` / Tiptap `chatReference` 是针对行内文档附件引用设计的原子节点,要求 attachmentId 指向当前消息的 inline document attachment,并参与 attachment 顺序及附件校验。会话引用是消息级上下文元数据,不是正文行内文档引用;不应伪装成该节点或混入文档附件数组。可以复用视觉样式和通用标签交互,数据协议与持久化类型保持独立。 + +桌面端对应落点(实现时以这些 Buddy 模块为准): + +- 侧栏会话“更多”菜单:`apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue`;保留现有“更多”按钮和重命名流程,将“复制会话引用”放在“重命名”上方,不在网站菜单或单条消息菜单增加入口。 +- Composer、粘贴、草稿:`apps/buddy/src/modules/tasks/widgets/composer/DesktopChatComposer.vue`、`useChatComposer.ts`、`useChatComposerEditor.ts`,以及 `apps/buddy/src/modules/prompt-input/model/chatComposerDocument.ts`。Buddy 的附件和引用视觉组件不同于 web,复用 Buddy 自己的引用标签/资源条布局,不引入 web 组件。 +- 桌面用户消息气泡:`apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatMessageContent.vue`,消息投影位于 `apps/buddy/src/modules/tasks/model/transcript/chatMessageContent.ts`。 +- 消息提交、校验与持久化:`apps/buddy/service/src/chat/ChatTurnService.ts`、`apps/buddy/service/src/storage/turnRequestRepository.ts` 及 `apps/buddy/shared/conversation/buddyUserContent.ts`。引用应作为 Buddy 用户消息结构化内容中的独立消息级元数据保存,不加入行内文件节点或附件列表。 +- Agent 侧检索:Buddy 本地 service 的 session extension / capability;查询通过会话仓储读取活动分支消息,并由 service 执行访问检查。不能调用网站 API,也不能把 SQLite / Pi JSONL 路径暴露给 renderer 或模型。 + +Buddy 端工作区可跨全局会话和多个 Space 会话。用户可在任一当前会话中显式引用另一段仍存在的本地会话,不按 `spaceId` 限制。服务端在发送时和 Agent 读取时仍会查询目标会话,并拒绝不存在或已删除的会话;剪贴板中的 ID/title 不是授权凭证。 + +### 4.4 检索接口 + +给 Agent 提供 `session_ask` 或等价内建工具,输入为当前问题。工具只能访问本轮显式引用的会话,不额外接受任意会话 ID、检索深度或条数参数,也不向模型暴露数据库或本地文件路径。 + +Buddy 桌面端使用主 Agent 内建只读工具 `lexora_session_ask`:工具在 Buddy service 内重新校验本轮用户消息显式引用的会话,再按页检索目标会话活动分支;只返回匹配的用户/助手消息片段,最多 12 条,每条最多 4,000 字符。工具不读取工具输出、隐藏思考、附件或数据库文件,也不额外启动隔离模型。`pi-session-ask` 仅作为按需检索思路参考。 + +## 5. 权限、安全与隐私 + +- 会话读取必须重新执行服务端授权校验;不可仅凭 UI 中看见会话、剪贴板内容、会话 ID 或 agent 参数读取。 +- 首版允许在任一 Space 和全局会话之间引用;被引用会话必须存在且未删除。 +- 剪贴板中的 ID/title 只用于传递和展示,不是授权凭证;发送时和 Agent 读取时都要重新查询服务端数据。 +- 通过 API/Agent 内部服务访问持久化数据,不让桌面渲染层或模型直接读取任意 JSONL、数据库文件或磁盘路径。 +- 查询只允许访问消息正文及必要的时间/角色元数据;是否纳入附件、工具输出、隐藏思考、文档快照,首版默认不纳入,需单独评审。 +- 引用内容视为不可信历史数据。历史消息中的指令不能覆盖当前系统/开发策略,也不能因此获得额外工具权限。 +- 检索工具自行搜索和读取,不向用户或工具调用方暴露检索深度/条数配置;只保留实现及模型上下文所必需的正常资源保护。 +- 当前 Buddy 工具返回被引用会话标题、角色、实际取回的消息 ID 和片段文本;日志不复制完整历史内容。更细粒度的审计事件属于后续增强。 + +## 6. 异常与边界行为 + +- **会话删除或不存在:** 不读取该会话,返回统一的不可读取结果;AI 告知无法读取引用。 +- **空会话/无匹配:** 返回空结果,由模型说明没有找到相关内容,不能虚构。 +- **多条引用:** 发送阶段严格校验——任一条引用不可访问(不存在或已删除)即整条消息被拒绝,不落库、不静默丢弃;发送成功后如果某条引用在运行前失效,Agent 读取阶段跳过该引用,不阻断其他有效引用。 +- **重复引用:** 输入端按 ID 去重;服务端再次规范化和去重。 +- **超长会话:** 按问题搜索活动分支,并限制返回最多 12 条、每条最多 4,000 个字符;不额外设计检索深度或条数配置。 +- **编辑/重试/分支:** 引用要随原用户消息语义保留;重试不得意外扩大到未引用会话。 +- **粘贴普通文本:** 不改变现有文本、文件、图片粘贴能力。 +- **粘贴引用后移除 chip:** 提交消息和 Agent 上下文都不应保留该引用。 + +## 7. 需求边界 + +**包含:** +- 会话行“更多”菜单中位于“重命名”上方的“复制会话引用”。 +- 剪贴板引用协议及 composer 粘贴解析、chip 展示/删除/去重。 +- 引用随草稿、提交、消息持久化和恢复。 +- Agent 侧按需且授权的被引用会话检索能力,检索交互参考 `pi-session-ask`。 +- 检索内容进入当轮回答上下文;无权/无结果时安全降级。 +- 基本中英文文案和测试。 + +**不包含:** +- 直接移植/安装 `pi-session-ask`;它读取 Pi JSONL,与 Lexora 存储接口不同。 +- 自动引用所有历史会话、全局会话搜索或任意 ID 查询。 +- 默认注入被引用会话全文。 +- 首版构建独立向量索引、长期记忆、会话摘要后台任务。 +- 将隐藏思考或未经授权的附件作为引用内容。 + +## 8. 分阶段实现建议 + +### Phase 1:引用交互与消息契约 + +- 盘点会话菜单、composer、草稿、提交 API、消息 metadata 的具体落点。 +- 加入纯文本引用协议和解析/格式化单元测试。 +- 加入引用 chip、移除、去重、复制菜单和粘贴行为。 +- 扩展创建/编辑消息的请求校验与前端类型,并持久化引用 metadata。 +- 先验证消息回放和失败恢复;此阶段不宣称 AI 已能读取被引用内容。 + +### Phase 2:Agent 按需检索 + +- 增加仅供 agent 使用的授权检索端点或服务接口。 +- 增加内建 `session_ask` 能力,限定只能查询本轮显式引用的会话。 +- 加入引用内容与普通指令的隔离提示;检索与读取方式遵循 `pi-session-ask`,不增加调用方可配置的检索参数。 +- 测试跨 Space 引用、删除/无权会话、超长内容、无匹配、多引用等边界。 + +### Phase 3:体验与评估 + +- 评估答案准确性、工具调用必要性、上下文消耗和检索延迟。 +- 依据数据决定是否添加摘要缓存、全文检索或独立子 Agent;没有指标证明前不引入。 +- 需要时增加消息来源标注/点击跳转到被引用会话。 + +## 9. 验收标准 + +- [x] 会话行“更多”菜单中,“复制会话引用”位于“重命名”上方;粘贴有效引用后,composer 同时显示上方可移除 chip 和光标处蓝色 `@会话标题` token,重复引用可去重。 +- [x] 普通文本粘贴不受影响;重复引用可去重(静默跳过,不弹提示);chip 可移除。 +- [x] 引用不能单独发送:正文为空时发送按钮禁用,服务端同样拒绝空正文请求。 +- [x] 草稿切换/恢复、提交、消息回放、失败恢复、编辑重发时引用元数据保持一致;当前编辑期间自动保存不会重置 token 位置。 +- [x] 消息请求和持久化仅包含结构化引用元数据,不包含自动展开的完整历史。 +- [x] Agent 能基于当前问题检索被引用会话中的相关历史,并能在回答中使用。 +- [x] 服务端仅允许显式引用;任一 Space 中仍存在的会话都可引用;拒绝已删除或不存在的会话。 +- [x] 超长会话、多条引用和无匹配按检索工具的默认搜索/读取方式处理,且不会虚构读取结果。 +- [x] 检索失败可解释降级;聊天常规发送和文件/图片粘贴无回归。 +- [x] 单元测试覆盖协议、授权、消息契约、工具检索和主要 composer 交互。 + +## 10. 实现前需确认 + +1. 首版权限规则是否支持任意跨 Space 会话引用?支持;目标仍需存在且未删除。 +2. 引用入口是否只针对侧栏会话项的“更多”菜单?本方案按此范围设计;单条消息引用属于另一个范围。 +3. 是否需要跨 Space 引用?首版支持用户显式引用任一 Space 的会话。 +4. 是否需要回答提供引用来源并可跳转到原会话?建议首版先保留来源 ID 供内部诊断,UI 跳转后续做。 + +## 11. 参考资料 + +- Pi 社区包 [`pi-session-ask`](https://github.com/lajarre/pi-session-ask):隔离子代理按问题检索 Pi JSONL 会话,并只返回相关答案/引用。 +- [`pi-session-ask` 架构说明](https://raw.githubusercontent.com/lajarre/pi-session-ask/main/extensions/session-ask/README.md):默认当前会话,可按路径/UUID 指定目标;内部以搜索与读取工具处理历史。 +- XTLaw 项目中的会话引用剪贴板协议与 composer 行为:可作为协议/交互参考;不直接复制其实现代码。 diff --git a/docs/specs/feature-004-skill-lazy-loading.md b/docs/specs/feature-004-skill-lazy-loading.md new file mode 100644 index 00000000..91ac2039 --- /dev/null +++ b/docs/specs/feature-004-skill-lazy-loading.md @@ -0,0 +1,121 @@ +# 规格 004:移除消息发送前的 Skills 全库扫描 + +**日期:** 2026-09-27 +**状态:** 已完成最小实现,验收清单待复核 + +**目标:** 普通消息发送和会话准备阶段只发现技能并读取各自 `SKILL.md` 的必要信息,不递归检查技能附属文件。完整技能包检查仅在安装、更新、明确刷新技能列表或读取用户选中的技能时执行。保持现有 AI 使用技能的方式不变。 + +## 1. 背景与问题定位 + +新会话准备会调用 `resolveBuddySessionResources()`,并等待 `loadForSpace()` 完成。随后 `SkillService` 遍历已发现的技能,为每个技能调用 `SkillPackageCache.load()`。缓存检查函数 `inspectPackage()` 会递归遍历技能目录中的文件;缓存未命中时,`readSkill()` 还会读取整套技能文件。 + +因此,即使用户发送普通消息且没有选择技能,发送流程也可能先等待整个技能库检查完成。用户在输入框中手动选择技能时,`materializeForSpace()` 也会重新解析候选目录,可能再次处理未选中的技能。 + +涉及的现有调用点: + +- `apps/buddy/service/src/agent/sessions/BuddySessionBlueprintService.ts:128`:创建会话资源。 +- `apps/buddy/service/src/agent/resources/BuddySessionResources.ts:32-38`:并行等待技能目录与项目上下文。 +- `apps/buddy/service/src/skills/SkillService.ts:90-114`:准备技能列表并组装已选择技能的内容。 +- `apps/buddy/service/src/skills/SkillService.ts:347-367`:发现技能并逐个加载技能包。 +- `apps/buddy/service/src/skills/SkillPackageCache.ts:38-99`:缓存命中前递归检查包内文件;未命中时解析完整技能包。 +- `apps/buddy/service/src/skills/skillFiles.ts:78-100,151-163`:读取技能包文件并计算版本信息。 +- `apps/buddy/service/src/chat/ChatTurnService.ts:558-565,667-690,773-789`:发送前展开输入框中的技能选择或技能上下文。 + +## 2. 本次范围 + +本次只处理导致发送卡顿的直接原因:**会话准备阶段只扫描技能目录并读取各个 `SKILL.md` 的必要信息,不遍历附属文件。**用户没有选择技能时,不应因技能库中存在大量附属文件而等待;用户明确选择技能时,只处理所选技能,不为此重新检查其他技能包。 + +本次保留现有技能目录、手动选择和模型使用技能的方式。实际读取技能时仍执行必要的授权和路径安全检查;完整技能包检查放在安装、更新或用户明确要求检查时执行。 + +## 3. 不在本次范围 + +- 不新增 `load_skill`、`read_skill_resource` 等模型工具。 +- 不改变 AI 自动发现、选择或读取技能的方式。 +- 不将技能说明和附属文件改造成分阶段读取,也不改变手动选择技能后注入对话的现有语义。 +- 不改动聊天界面、模型选择、消息展示或发送状态交互。 + +这些属于后续 AI 技能使用体验的改造,可在确有需求时另行设计;它们不是消除本次全库扫描卡顿的前提。 + +## 4. 目标流程 + +```mermaid +sequenceDiagram + participant UI as 聊天界面 + participant Session as 会话准备 + participant Skills as 技能服务 + participant Model as 模型 + + UI->>Session: 创建会话并发送消息 + Session->>Skills: 获取技能目录 + Note over Skills: 不递归检查每个技能包的所有文件 + Skills-->>Session: 返回当前会话所需的技能信息 + Session-->>Model: 提供用户消息和现有技能上下文 + opt 用户明确选择技能 + Session->>Skills: 处理所选技能 + Note over Skills: 只校验和读取所选技能,不重新扫描其他技能包 + end +``` + +普通消息不再等待所有技能包的文件检查。用户明确选择技能时仍沿用当前产品行为,只避免为了取出所选技能而重新扫描全库。 + +## 5. 方案 + +### 5.1 会话准备阶段不递归检查技能包 + +- 首次准备会话时发现技能目录,并读取各个 `SKILL.md` 的元数据;不计算包内完整文件签名,也不读取附属文件。结果按空间缓存,后续发送复用。 +- 保留必要的基本格式检查、授权来源检查和路径规范化;记录诊断并跳过不合法的目录条目。 +- 技能包缓存不能在每次发送前通过遍历包内所有文件来确认命中。安装、更新、删除和启用状态变化会触发缓存更新;打开技能目录列表时也执行完整刷新。 + +### 5.2 用户选中技能时只处理该技能 + +- `materializeForSpace()` 使用会话准备阶段已有的技能索引或等效引用定位所选技能,不重新枚举并加载全部技能包。 +- 保留当前显式选择的交互语义。所选技能仍按现有方式提供给对话;本次不要求只读取 `SKILL.md` 或新增模型工具。 +- 读取所选技能时,仍验证其授权范围、规范化路径和必要的文件边界;读取一个技能时不递归检查其他技能。 +- 技能引用使用 `SKILL.md` 内容版本;用户明确选择技能时还携带完整包版本,以便发现选择后技能包被替换或修改。 + +### 5.3 技能变化与完整校验 + +- 完整技能包校验保留在技能导入、安装、更新或用户明确刷新技能目录时执行,不放在每轮发送的同步路径上。 +- 普通发送复用进程内缓存的技能目录。外部目录中的技能新增或删除,会在用户刷新技能目录或重启应用后被发现;本次不增加文件系统监听器。 +- 用户选中技能时仍重新校验该技能包。如果 `SKILL.md` 或授权路径自目录缓存后发生变化,则返回明确的 `SKILL_CHANGED` 错误并使目录缓存失效;不会静默切换到其他同名技能。 + +## 6. 预期改动范围 + +按现有实现,改动集中在技能服务和缓存逻辑: + +| 文件或模块 | 计划调整 | +|---|---| +| `apps/buddy/service/src/skills/SkillService.ts` | 让会话目录准备走轻量路径;用户选择技能时通过已有目录索引直接处理所选项 | +| `apps/buddy/service/src/skills/SkillPackageCache.ts` | 增加只读取 `SKILL.md` 元数据的路径;完整包检查用于明确刷新或读取所选技能 | +| `apps/buddy/service/src/skills/skillFiles.ts` | 为 `SKILL.md` 计算轻量引用版本;保留所选技能的路径和文件安全检查 | +| `apps/buddy/service/src/agent/resources/BuddySessionResources.ts` | 如有必要,调整为传递轻量技能目录或会话内已有引用 | +| `apps/buddy/shared/skills/skillApi.ts` 和 `apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts` | 让手动选择同时携带轻量说明版本和完整包版本 | + +不新增 Agent 技能工具或新的技能调用协议。 + +## 7. 验收标准 + +- [ ] 不选择技能发送普通消息时,会话准备只读取技能元数据,不递归检查任何技能包的附属文件。 +- [ ] 会话准备耗时不随技能包附属文件总数成比例增加。 +- [ ] 手动选择一个技能时,只处理所选技能,不重新加载或校验其他技能包。 +- [ ] 所选技能仍受现有授权、路径穿越、软链接边界及文件类型限制保护。 +- [ ] 所选技能的说明或授权路径在会话期间发生变化或失效时,返回明确错误,不静默切换到其他同名技能。 +- [ ] 安装、更新或用户明确检查时,仍可执行完整技能包校验。 + +## 8. 风险和取舍 + +- 外部技能目录中的新增和删除不会在每轮普通发送时自动发现,需要刷新技能目录或重启应用。这是为避免每轮重新扫描全库而接受的取舍。 +- 本次不解决“AI 是否能自动挑选技能”或“附属资料是否按需进入上下文”。若后续需要这些能力,应单独评估模型工具、上下文和交互变化。 +- 为避免发送变慢而移除同步全库检查,不代表放弃安全校验:实际读取技能时仍检查授权和路径边界,安装或明确检查时仍可执行完整包检查。 + +## 9. 后续可选方向 + +若未来希望 AI 更灵活地使用 Skills,可另行评估分层读取方案:先提供技能名称和简介,由 AI 判断是否需要读取技能说明,并在需要时读取单个附属文件。Pi、Agent Skills 规范和 Codex 均介绍了类似方式,但这不是本次性能修复的验收条件。 + +- [Pi Skills 文档](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md) +- [Agent Skills 规范](https://agentskills.io/specification) +- [Codex Skills 文档](https://developers.openai.com/plugins/concepts/skills) + +--- + +**状态说明:** 普通发送的轻量目录缓存、用户选择技能时的单技能校验,以及技能列表刷新路径已按本方案实现。当前 Windows 环境无法创建符号链接,因此依赖符号链接的安全测试尚未通过;验收清单待在具备相应权限的环境中复核。 diff --git a/docs/specs/feature-005-default-permission-mode.md b/docs/specs/feature-005-default-permission-mode.md new file mode 100644 index 00000000..5d53514b --- /dev/null +++ b/docs/specs/feature-005-default-permission-mode.md @@ -0,0 +1,196 @@ +# Spec-005:新增"新任务默认权限"设置 + +**日期:** 2026-09-28 + +**状态:** 已实现;自动化测试覆盖核心配置与草稿逻辑,待手动验收 + +**目标:** 在设置页新增一项"新任务默认权限",让用户把新任务的默认审批模式从"智能审批"改成自己偏好的模式(例如"完全访问")。该设置只影响此后新建的任务,不追溯已有会话。 + +--- + +## 1. 背景与问题定位 + +### 1.1 现状 + +每个任务的权限模式由"草稿"(composer draft)携带,底层字段是两个: + +- `approvalPolicy`(审批策略):`manual` / `policy` +- `executionProfile`(执行档位):`read_only` / `workspace_write` / `full_access` + +界面上看到的 4 个模式,是由这两个字段反推出来的(`resolveBuddyPermissionMode()`): + +| 界面名称 | `approvalPolicy` | `executionProfile` | +|---|---|---| +| 只读 | `policy` | `read_only` | +| 人工审批 | `manual` | `workspace_write` | +| 智能审批 | `policy` | `workspace_write` | +| 完全访问 | `policy` | `full_access` | + +相关代码: + +- `apps/buddy/shared/permissions/permissionMode.ts`:模式定义与双向转换。 +- `apps/buddy/src/modules/tasks/state/drafts/useChatDrafts.ts`:`emptyDraft()` 决定新草稿的初值。 +- `apps/buddy/src/modules/prompt-input/components/DesktopPermissionModeSelector.vue`:输入框里的权限弹窗,当前唯一的修改入口。 + +### 1.2 问题 + +新草稿的初值是**硬编码**的: + +- `BUDDY_DEFAULT_APPROVAL_POLICY = 'policy'` +- `BUDDY_DEFAULT_EXECUTION_PROFILE = 'workspace_write'` + +两者组合即"智能审批"。用户可以在输入框的权限弹窗里逐个任务改成"完全访问",但**没有任何全局默认值**。不认同"智能审批"的用户,每新建一个任务都要手动改一次。 + +### 1.3 本功能要解决的事 + +提供一个全局默认值,让用户在设置页改一次,之后新建的任务自动使用该模式。 + +## 2. 已确认的决定 + +### 决定 1:出厂默认保持"智能审批" + +`config.toml` 中该设置的默认值定为 `policy_approval`,不是 `full_access`。 + +理由:如果出厂默认改成"完全访问",所有**已经安装** Lexora 的用户在升级后会静默变成完全访问 —— 用户没有点过任何按钮,权限就被放开了。需要完全访问的用户自己在设置里改一次即可。 + +### 决定 2:设置变化不追溯已有任务 + +修改设置后,**只有此后新建的任务**使用新默认值;已经打开或已经存在的任务保持原模式不变。 + +原因见 4.2 节(草稿在打开时就已经落库)。当前那个"还没开始用的空任务"不会跟着变,设置行下方需要一句说明文案。 + +## 3. 需求边界 + +**包含:** + +- 设置页新增一项"新任务默认权限",可选 4 个模式(只读 / 人工审批 / 智能审批 / 完全访问)。 +- 该值持久化到 `~/.lexora/config.toml`。 +- 新建任务的草稿初值使用该设置。 +- 选择"完全访问"作为默认值时,弹出确认对话框(复用现有组件)。 + +**不包含:** + +- 不改变已有会话、已有草稿、正在运行任务的权限。 +- 不改变输入框权限弹窗的逐个任务覆盖行为(它仍然是每个任务的临时开关)。 +- **不作用到后台定时任务。** 定时任务有自己的 `executionProfile`(`service/src/automations/createAutomationTool.ts`,默认 `workspace_write`)。"无人值守 + 完全访问"风险更高,应由独立开关控制,不跟随本设置。 +- 不新增权限模式,不修改权限判定与越级限制逻辑。 + +## 4. 设计方案 + +### 4.1 数据模型:存"模式",不存两个底层字段 + +配置项存 `BuddyPermissionMode`(即 4 个模式之一),而不是分别存 `approvalPolicy` 和 `executionProfile`。 + +理由: + +- "模式"才是用户看得见的概念; +- 分别存两个字段会产生 UI 里根本不存在的组合(例如 `manual` + `read_only`); +- 已有 `resolveBuddyPermissionSettings(mode)` 可以直接把模式展开成两个底层字段,不需要新增转换逻辑。 + +配置落点:`desktop.chat.permissionMode`,与 `desktop.chat.welcome`、`desktop.chat.outlinePosition` 同级。在 TOML 文件里写作 `permission_mode`。 + +同时在 `apps/buddy/shared/permissions/permissionMode.ts` 新增常量: + +```ts +export const BUDDY_DEFAULT_PERMISSION_MODE: BuddyPermissionMode = 'policy_approval' +``` + +由它作为出厂默认的唯一定义处,现有两个 `BUDDY_DEFAULT_*` 常量继续作为配置加载完成前的兜底值。 + +### 4.2 生效路径 + +新草稿的初值只在一个地方产生:`useChatDrafts()` 里的 `emptyDraft()`。方案是在这里注入配置值: + +1. `useChatDrafts()` 增加入参 `defaultPermissionSettings`,`emptyDraft()` 用它填初值。 +2. `apps/buddy/src/modules/tasks/state/useTaskCapability.ts` 从 `applicationSettings.config` 计算该值: + `resolveBuddyPermissionSettings(config?.desktop.chat.permissionMode ?? BUDDY_DEFAULT_PERMISSION_MODE)` +3. 往下全部复用现有链路:草稿落库时写入 `initialExecutionConfig`,发送消息时会话继承草稿的权限设置。 +4. 本地服务侧不需要改动。`isExecutionProfileWithin()` 的越级限制照旧生效。 + +#### 为什么"不追溯已有任务"是自然结果 + +按现在的实现,草稿在任务工作区挂载时就会被写入数据库(`useTaskWorkspacePersistence.restoreWorkspace()` → `ensureDraft()` → `composerDrafts.open()`)。 + +因此会出现这个现象: + +1. 用户打开软件,进入任务页 → 当前空任务的权限被存成"智能审批"; +2. 用户去设置页改成"完全访问"; +3. 用户回到刚才那个任务 → 仍然是"智能审批"(它的记录已经存在); +4. 用户新建任务 → 才是"完全访问"。 + +配置尚未加载时创建的草稿也按此规则处理:先用 `BUDDY_DEFAULT_PERMISSION_MODE`("智能审批")创建;如果该草稿随后已落库,配置加载完成或用户修改设置后都不追溯刷新它。这里的"新建任务使用新默认值"指创建草稿时默认配置已经可用的任务;配置加载前创建的草稿属于已存在草稿。 + +这不是缺陷,而是"已落库的记录不被追溯修改"。本方案接受该行为(决定 2),但必须在设置页写清说明文案。 + +如果将来要改成"立刻跟随",需要额外引入"该草稿是否被用户手动改过权限"的标记,只在"内容为空 + 未手动改过"时刷新。这属于后续可选方向,不在本次范围。 + +### 4.3 界面 + +最小方案:在"常规"设置页新增一行。 + +- 位置:`apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue`,复用现有的 `desktop-settings-row` 结构与样式。 +- 控件:`NSelect`,4 个选项。 +- 文案:**复用**输入框权限弹窗已有的标签和描述文案键,不新写一套,避免以后改文案要改两处。权限弹窗会根据沙盒状态切换 sandbox 描述;设置页不展示沙盒状态专属描述,只展示下列通用描述,因为此设置表达的是默认权限模式,而非当前任务的实际沙盒边界: + - 标签:`desktop.chat.executionProfileReadOnly`、`desktop.chat.permissionModeManual`、`desktop.chat.permissionModePolicy`、`desktop.chat.executionProfileFull` + - 描述:`desktop.chat.permissionModeReadOnlyDescription`、`desktop.chat.permissionModeManualDescription`、`desktop.chat.permissionModePolicyDescription`、`desktop.chat.permissionModeFullDescription` +- 行下方常驻说明文案(新增键):"只影响新建任务,已有会话保持原模式。" +- 选择"完全访问"时,先弹出 `apps/buddy/src/modules/prompt-input/components/DesktopFullAccessConfirmationDialog.vue`(现有组件,带勾选确认),确认后才写入设置。 + +若后续还要加入沙盒状态、审批规则、工具白名单等,再考虑独立开一个"权限"设置分类(新增路由 + i18n + 侧边栏项)。只加这一个开关时,常规页改动面更小。 + +### 4.4 预期改动范围 + +| 文件 | 计划调整 | +|---|---| +| `apps/buddy/shared/permissions/permissionMode.ts` | 新增 `BUDDY_DEFAULT_PERMISSION_MODE` 常量 | +| `apps/buddy/electron/shared/desktopApi.ts` | `DesktopChatPreferences` 与 `DEFAULT_DESKTOP_CHAT_PREFERENCES` 增加 `permissionMode` | +| `apps/buddy/electron/shared/desktopApiSchemas.ts` | `lexoraConfigPatchSchema.desktop.chat` 增加 `permissionMode`(该对象是 `.strict()`,不加会直接报错) | +| `apps/buddy/electron/main/config/LexoraConfigStore.ts` | `desktopConfigSchema.chat` 增加 `permission_mode`;`decodeConfig`、`encodeConfig` 的 chat 映射各加一行;schema 默认值块同步 | +| `apps/buddy/src/modules/tasks/state/drafts/useChatDrafts.ts` | `emptyDraft()` 接收注入的默认权限设置 | +| `apps/buddy/src/modules/tasks/state/useTaskCapability.ts` | 从配置计算默认权限设置并传入 | +| `apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue` | 新增设置行与完全访问确认 | +| `apps/buddy/src/i18n/locales/zh-CN/settings.ts`、`en-US/settings.ts` | 新增行标题与说明文案 | +| 相关类型与 schema 测试 | 验证配置类型、默认值、合法/非法值校验及读写映射 | +| `useChatDrafts`、设置组件及配置存储相关测试 | 覆盖默认注入、配置加载前回退、既有草稿不追溯、确认取消与保存失败等边界 | + +**最容易漏的一处:** `desktopConfigSchema.chat` 使用了 `.passthrough()`。只往 TOML 里写 `permission_mode` 而不同步 `decodeConfig` / `encodeConfig` 的映射,值会被静默保留但读不出来 —— 表现为"设置改了但完全没生效,而且不报错"。 + +## 5. 验收标准 + +- [ ] 设置页出现"新任务默认权限",可选 4 个模式,默认显示"智能审批"。 +- [ ] 选择"完全访问"时先弹出确认对话框,取消则不写入。 +- [ ] 设置值写入 `~/.lexora/config.toml` 的 `permission_mode`,重启应用后仍生效。 +- [ ] 修改设置后,新建任务的权限弹窗显示新模式。 +- [ ] 修改设置后,已有会话与已有草稿的权限模式保持不变。 +- [ ] 设置页的说明文案明确写出"只影响新建任务"。 +- [ ] 后台定时任务的 `executionProfile` 不受该设置影响。 +- [ ] 配置更新入口拒绝非法 `permissionMode`,不产生半写入状态;配置文件加载时遇到非法 `permission_mode`,按配置 store 的既有 schema 校验策略处理,不得静默接受非法值,并应覆盖相应行为的测试。 +- [ ] 配置加载完成前(`config` 为 `null`)新建的草稿回退到"智能审批",不报错;该草稿之后不因配置加载完成而自动刷新权限。 +- [ ] 完全访问确认对话框取消时不写入设置;确认后才提交更新。 +- [ ] 设置保存失败时界面显示失败反馈,选择器恢复为配置中的实际值;保存进行中禁用重复提交。 + +## 6. 风险和取舍 + +- **完全访问的暴露面变大。** 之前它是"单个任务的临时选择",现在会成为"每个新任务的起点"。因此保留确认对话框是必要的,且输入框的红色危险样式继续保留,让用户在每个新任务上都能看到当前是完全访问。 +- **设置与当前任务不一致的观感。** 用户改完设置回到刚才的任务,模式没变(见 4.2)。这是决定 2 的已知代价,用说明文案缓解。 +- **出厂默认不能跟着改。** 决定 1 会让"想要完全访问"的用户仍需手动改一次。这是为了保护已有用户不被静默放开权限,属于有意取舍。 +- **本功能不提升权限上限。** 完全访问仍然受操作系统权限约束,不会获得管理员权限,部分敏感操作仍需确认。 + +## 7. 未决事项 + +- 是否在输入框权限弹窗底部增加"设为默认"入口(让用户不必跳到设置页)。当前未纳入本次范围。 +- 是否需要在将来让"还没开始用的空任务"立刻跟随新默认值(4.2 末尾描述的可选方向)。 + +## 8. 实现记录 + +已实现本方案的配置持久化、设置页入口和新草稿默认值注入。配置仍以 `desktop.chat.permissionMode` 表示,并通过既有双向转换得到底层审批策略与执行档位;后台自动化不读取该设置。选择"完全访问"时复用现有确认对话框,保存失败时复用设置页错误提示,选择器由配置值控制。 + +设置页效果截图: + + + +自动化测试覆盖配置默认值、TOML 读写、非法值拒绝及新建草稿使用配置模式/既有草稿不追溯。验收清单暂不勾选:设置页完整交互、应用重启后生效及相关边界仍需手动验收;规范中未列出的组件级 UI 自动化测试也未补充。 + +--- + +**状态说明:** 实现已随本 PR 提交;验收清单保留为待验证项,合并前后可继续补充手动验收结果。 diff --git a/docs/specs/feature-006-fixed-primary-navigation.md b/docs/specs/feature-006-fixed-primary-navigation.md new file mode 100644 index 00000000..57b5d6aa --- /dev/null +++ b/docs/specs/feature-006-fixed-primary-navigation.md @@ -0,0 +1,152 @@ +# Spec-006:合并侧栏与底部功能入口 + +**日期:** 2026-09-28 +**状态:** 已实现(分支 `codex/feature-006-fixed-primary-navigation`),本文档随实现同步 +**设计基线:** 分支 `codex/feature-006-fixed-primary-navigation`,基于提交 `73496281` + +## 1. 目标 + +桌面端原本左侧有两根并排竖栏:全局常驻的「任务 / 自动化 / 插件 / 设置」主导航侧栏,以及只在任务页显示的空间侧栏。本方案把两者**合并为一根侧栏**,并细化侧栏内部的操作入口: + +- 删除独立的主导航侧栏,其底部功能入口并入空间侧栏底部。 +- 空间侧栏成为桌面端唯一的左侧竖栏,保留「置顶 / 空间 / 任务」三段结构。 +- 设置、插件、自动化作为底部入口;通知固定在底部行尾。 +- 头像不显示在侧栏中,移至设置页面。 +- 非任务页面(自动化、插件、设置)在左下角提供「返回」入口。 + +## 2. 界面结构 + +任务页(只有一根侧栏): + +```text +┌──────────────────┬─────────────────────────────────┐ +│ 任务 🏷标记 🔍搜索 │ 顶栏 / 页面操作区 │ +├──────────────────┼─────────────────────────────────┤ +│ 置顶 │ │ +│ │ │ +│ 空间 + │ 主内容区 │ +│ ▾ 空间 A │ │ +│ · 会话一 │ │ +│ · 会话二 │ │ +│ ▸ 空间 B │ │ +│ │ │ +│ 任务 + │ │ +│ · 会话 │ │ +│ · 会话 │ │ +│ 展开其余 3 个 │ │ +├──────────────────┤ │ +│ ⚙设置 🔌插件 ◷自动化 ♧通知 │ │ +└──────────────────┴─────────────────────────────────┘ +``` + +非任务页面(自动化 / 插件 / 设置): + +```text +┌──────────────────┬─────────────────────────────────┐ +│ │ 顶栏 / 页面操作区 │ +│ │ │ +│ │ 页面内容 │ +│ │ │ +│ ← 返回 │ │ +└──────────────────┴─────────────────────────────────┘ +``` + +## 3. 行为 + +### 侧栏合并 + +- 删除原「任务 / 自动化 / 插件 / 设置」主导航侧栏。 +- 空间侧栏保留「置顶、空间、任务」三段:空间可展开、收起并展示其下会话;置顶支持拖拽排序。 +- 空间侧栏同时承载底部功能入口,成为桌面端唯一的左侧竖栏。 + +### 侧栏头部 + +- 头部自左至右为:模块标题(任务)、管理标记、搜索。 +- 搜索入口位于最右,随侧栏宽度贴住右边缘。 +- 不再提供全局「新增」按钮;新建会话由分组标题行的加号承担。 + +### 分组标题行 + +- 鼠标悬停(或键盘聚焦)时,标题行右侧出现加号: + - 「空间」分组加号:新建空间。 + - 「任务」分组加号:新建一个不属于任何空间的会话。 + +### 空间行 + +- 悬停时行内显示「更多」与「新任务」(加号);新任务在该空间内新建会话。 +- 「更多」菜单:新建任务 / 打开工作目录(有主目录时)/ 置顶·取消置顶 / 管理 Skills / 编辑 / 删除。 + +### 会话行 + +- 空间内的会话与「任务」分组内的会话**操作一致**:悬停时行内显示「更多」与「删除」。 +- 「更多」菜单:标记(含未读、清除、管理标记)/ 置顶·取消置顶 / 重命名。 +- 删除保留二次确认弹窗。 +- **空间内的会话同样可以置顶**;置顶后从所属空间的列表中移出,不会出现两份。 + +### 会话列表条数 + +- 每个分组默认只显示 **5 条**会话;任务分组与每个空间各自独立计算。 +- 超过 5 条时,列表末尾显示「展开其余 N 个」;点击后该分组全部展开。 +- **不提供「收起」按钮**,改由以下时机自动收回默认 5 条: + 1. 展开另一个分组时(手风琴:同时只允许一个分组处于展开全部状态)。 + 2. 折叠该分组本身时(折叠空间、或折叠所属分组)。 + 3. 离开任务页或重启应用时(展开状态**不持久化**)。 +- 不足或刚好 5 条时不显示展开入口。 +- 「置顶」分组不截断。 + +### 底部功能入口 + +- 侧栏底部排列图标入口:设置、插件、自动化靠左依次排列;**通知固定在行尾**,贴住侧栏右边缘,随侧栏宽度变化跟随右边缘。 +- 点击图标直接进入对应功能,不通过头像弹出菜单。 +- 通知入口保留未读数徽标与现有通知面板交互。 +- 悬停提示使用应用自绘提示(naive-ui Tooltip),不使用浏览器原生 `title`。 + +### 非任务页面 + +- 自动化、插件、插件详情页在左下角提供「返回」入口;设置页的「返回」位于设置侧栏底部。 +- 「返回」为图标 + 文字按钮,用于回到任务页。 + +### 头像与个人资料 + +- 头像不放在空间侧栏底部,也不作为功能菜单触发器。 +- 头像与个人资料入口位于设置 → 通用页的「个人资料」区块,通过「编辑资料」打开原有资料弹窗。 + +### 顶部隐藏按钮 + +- 顶部隐藏按钮只负责显示或隐藏合并后的空间侧栏,不负责切换底部功能入口。 +- 按钮图标随状态切换(侧栏展开时显示「收起」,收起时显示「展开」),与右侧资源面板按钮同一套约定。 +- 侧栏边缘原先悬停出现的折叠箭头已移除;侧栏折叠只由顶部按钮控制,宽度拖拽保持不变。 + +### 标题栏提示 + +- 标题栏中三个纯图标功能按钮使用应用自绘提示:侧栏显示/隐藏、资源面板显示/隐藏、面板位置切换;文案复用现有 i18n 键,并随状态变化(收起/展开)。 +- 最小化、最大化/还原、关闭保持无提示,与系统窗口控制习惯一致。 +- 应用 / 窗口 / 帮助三个文字菜单有文字标签,不加提示。 + +## 4. 视觉约束 + +- 以紧凑侧栏为方向,不额外增加装饰、卡片层级或新的配色体系。 +- 继续使用产品现有主题颜色、图标、分隔线和交互状态;不在本方案中指定新的颜色值。 +- 空间侧栏宽度沿用现有布局体系。 +- 底部图标入口、分组加号、行内操作的尺寸与悬停/选中态复用现有图标按钮样式。 +- 悬浮提示统一使用应用自绘提示,避免与原生提示叠加。 + +## 5. 验收标准 + +- [x] 原「任务 / 自动化 / 插件 / 设置」独立主导航侧栏已移除。 +- [x] 任务页左侧只剩一根侧栏,保留置顶、空间、任务三段结构与空间展开/收起能力。 +- [x] 侧栏底部显示设置、插件、自动化入口,通知贴住右边缘。 +- [x] 点击底部图标直接进入对应功能,不弹出头像菜单;通知入口保留未读徽标。 +- [x] 分组标题行悬停出现加号:空间分组新建空间,任务分组新建会话。 +- [x] 空间行行内为「更多 + 新任务」,置顶收进更多菜单。 +- [x] 会话行行内为「更多 + 删除」,置顶收进更多菜单;空间内与任务内的会话操作一致。 +- [x] 空间内的会话可以置顶,且不在原空间列表中重复出现。 +- [x] 会话列表默认显示 5 条,超出时显示「展开其余 N 个」,且按手风琴规则自动收回。 +- [x] 自动化、插件、设置页面有「返回」入口,可回到任务页。 +- [x] 顶部隐藏按钮只控制合并后的侧栏,且图标随状态切换。 +- [x] 头像和个人资料入口位于设置 → 通用页,不显示在侧栏中。 +- [x] 界面继续使用现有主题与图标样式,不引入额外装饰或独立色板。 + +## 6. 未决事项 + +- **置顶分组的条数:** 当前不截断;若后续希望同样限制条数,再单独确认。 diff --git a/docs/specs/feature-007-chat-agent-identity.md b/docs/specs/feature-007-chat-agent-identity.md new file mode 100644 index 00000000..79170d24 --- /dev/null +++ b/docs/specs/feature-007-chat-agent-identity.md @@ -0,0 +1,105 @@ +# Spec-007:聊天窗口 AI 身份(头像与名称) + +**日期:** 2026-09-29 +**状态:** 已实现(分支 `codex/feature-006-fixed-primary-navigation`) +**设计基线:** 与 Spec-006 同一分支,基于提交 `73496281` + +## 1. 目标 + +聊天窗口里 AI 回复上方的身份标识(头像 + 名称)此前是固定值:品牌头像加 i18n 文案 `Lexora Buddy`。本方案让它可以自定义,并提供「跟随用户资料」的选项: + +- 在设置中新增「AI 身份」区块,可自定义 AI 的头像与名称。 +- 提供开关「同步用户头像与名称」:开启后直接使用「通用 → 个人资料」里的头像与昵称。 +- 聊天窗口与设置中的预览保持一致。 +- 聊天窗口里的 AI 名称沿用应用系统文字样式,不使用品牌展示字体或额外的字号、字重。 + +## 2. 配置 + +新增 `desktop.agentProfile`,落盘为 `[desktop.agent_profile]`: + +```toml +[desktop.agent_profile] +name = "" +avatar = "" +sync_with_user_profile = false +``` + +| 字段 | 说明 | +|---|---| +| `name` | 自定义名称,最长 30 字符;留空使用默认名称 | +| `avatar` | 自定义头像,data URL;留空使用品牌头像 | +| `syncWithUserProfile` | 是否跟随「通用 → 个人资料」的头像与昵称 | + +头像大小限制沿用用户资料既有规则(≤ 2MB,data URL 长度上限同 `DESKTOP_PROFILE_AVATAR_MAX_DATA_URL_LENGTH`)。 + +## 3. 界面位置与形态 + +设置在 **外观** 分类下,位于外观设置之后: + +```text +外观 +├── 主题 / 大纲位置 / 欢迎语 …(既有外观设置) +└── AI 身份 + ┌──────────────────────────────────────────────────────────┐ + │ [头像] 名字 │ + │ [输入框] 同步用户头像与名称 ● │ + │ 显示在聊天窗口 AI 回复上方… [保存] │ + └──────────────────────────────────────────────────────────┘ +``` + +- 开关是行内的小控件,与头像/名称同处一行,不单独占一行,也没有额外说明文案。 +- 开关立即生效(与其他设置开关一致),名称与头像通过「保存」提交。 +- 开关开启时隐藏手动编辑控件,改为显示「当前使用个人资料里的头像与名称」与当前名称。 + +## 4. 行为 + +### 默认(未同步、未自定义) + +- 头像:品牌头像 `resources/brand/lexora-avatar.png`。 +- 名称:i18n `desktop.chat.agentName`(`Lexora Buddy`)。 + +### 自定义(未同步) + +- 可上传头像(图片类型、≤ 2MB,读取为 data URL)、恢复默认头像。 +- 可填写名称(≤ 30 字符,留空则回到默认名称)。 +- 任一为空时按「默认」规则回退。 + +### 同步用户资料 + +- 开启后,AI 身份**完全照搬个人资料的解析结果**(复用 `resolveUserProfile`): + - 用户设置了自定义头像 → 使用同一张图; + - 用户没有头像(含系统头像缺失)→ 使用**同一个首字母圆形头像**(同样的首字母与配色算法),而不是回退到品牌图标; + - 名称使用个人资料解析出的昵称(自定义昵称 → 系统显示名 → 系统用户名)。 +- 聊天窗口与设置预览都按上述规则渲染,两处一致。 +- 关闭同步后回到「自定义 / 默认」规则。 +- 聊天窗口显示名称时继承所在界面的系统字体、字号、字重、行高和文字颜色;头像仍按身份配置显示。 + +## 5. 验收标准 + +- [x] 设置 → 外观 下存在「AI 身份」区块。 +- [x] 可自定义 AI 头像与名称,保存后聊天窗口立即使用新值。 +- [x] 未自定义时使用品牌头像与默认名称。 +- [x] 存在「同步用户头像与名称」开关,且为行内小控件、不单独占行。 +- [x] 开关开启后,聊天窗口与设置预览都使用个人资料的头像与昵称。 +- [x] 用户没有自定义头像时,AI 身份显示与个人资料一致的首字母圆,而非品牌图标。 +- [x] 聊天窗口里的 AI 名称使用所在界面的系统文字样式,不使用品牌字体样式。 +- [x] 开关关闭后恢复自定义 / 默认规则。 + +## 6. 未决事项 + +- 是否把设备名等其他资料字段也纳入同步,当前不做。 +- 是否允许为不同空间设置不同 AI 身份,当前不做(全局单份配置)。 + +## 7. 相关实现 + +| 位置 | 说明 | +|---|---| +| `electron/shared/desktopApi.ts` | `DesktopAgentProfileConfig` 与配置/补丁类型 | +| `electron/shared/desktopApiSchemas.ts` | 配置补丁校验 | +| `electron/main/config/LexoraConfigStore.ts` | `agent_profile` 读写、默认值与补丁合并 | +| `src/app/bootstrap/DesktopAppProvider.vue` | 解析 `agentIdentity`(含同步分支) | +| `src/shared/ui/desktopUiContext.ts` | 向界面提供解析后的身份 | +| `src/modules/settings/widgets/app/DesktopAgentIdentitySettings.vue` | 设置区块 | +| `src/modules/settings/pages/DesktopAppearanceSettingsView.vue` | 挂载位置 | +| `src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue` | 聊天窗口渲染 | +| `src/modules/tasks/widgets/transcript/__tests__/BuddyChatAgentIdentity.spec.ts` | 品牌兜底 / 自定义头像 / 首字母圆 | diff --git a/pr-assets/session-reference-answer.png b/pr-assets/session-reference-answer.png new file mode 100644 index 00000000..4f4e2a68 Binary files /dev/null and b/pr-assets/session-reference-answer.png differ diff --git a/pr-assets/session-reference-composer.png b/pr-assets/session-reference-composer.png new file mode 100644 index 00000000..641e631f Binary files /dev/null and b/pr-assets/session-reference-composer.png differ diff --git a/pr-assets/session-reference-more-menu.png b/pr-assets/session-reference-more-menu.png new file mode 100644 index 00000000..752c8555 Binary files /dev/null and b/pr-assets/session-reference-more-menu.png differ diff --git a/pr-assets/turn-process-header.png b/pr-assets/turn-process-header.png new file mode 100644 index 00000000..55a908b4 Binary files /dev/null and b/pr-assets/turn-process-header.png differ