Skip to content

✨ feat(session-agent): 智能体会话管理——只读浏览本机 Codex/Claude 会话 - #1589

Merged
Syngnat merged 88 commits into
Syngnat:devfrom
kunghim:feature/session-assets
Oct 27, 2026
Merged

Syngnat merged 88 commits into
Syngnat:devfrom
kunghim:feature/session-assets

Conversation

@kunghim

@kunghim kunghim commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

做什么

给 GoNavi 加一个「智能体会话管理」:添加本机装好的 Codex / Claude Code 之后,直接浏览它们的历史会话——按项目和日期分组、看完整对话过程与文件改动、导出 Markdown、复制恢复命令、删除指定会话记录。

纯只读浏览(参考 cc-switch 的会话管理器方案),唯一会写盘的动作是「删除会话记录」,且删除前有确认。

主要能力

  • 侧边栏:智能体与数据连接同级,每个工具一个顶层节点(带品牌图标);右键菜单 = 打开会话管理 / 编辑智能体 / 移除智能体。
  • 检测与添加:检测本机安装(版本、数据目录可读性),数据目录可手动指定;添加/移除只改 GoNavi 自己的配置,不动工具自身数据。
  • 列表:按项目或按日期分组;搜索标题/项目/会话 ID;按工具筛选;fork 会话单独标记;分页按分组翻。
  • 详情:聊天式时间线(用户气泡 / 助手带头像 / 工具调用收进「执行过程」折叠条),统计条,全部/对话/改动三视图,会话内搜索高亮 + 上下跳转。
  • 导出与恢复:导出 Markdown;复制 codex resume <id> / claude --resume <id>。
  • 删除:删会话源文件(Codex 的 sessions/ + archived_sessions/ 双副本联动)。

实现里踩到的坑

这几条都是拿真实 ~/.codex 数据打出来的,不是推测:

  1. .zst 压缩会话从不解压:309/357 个 Codex 会话是 *.jsonl.zst,原来按明文读,读出的是 zstd 二进制垃圾。改走流式解压扫描后,列表从 357 条降到 162 条(去掉 177 条子代理 + 归档副本),空标题 218 → 0。
  2. session_meta.source 形态漂移:真实值是字符串 "vscode",旧结构体反序列化失败 → 整条 session_meta 被跳过 → 所有会话的 projectDir / createdAt 恒为空。
  3. 子代理识别失效:现代标记是 source.subagent.thread_spawn + thread_source="subagent",旧标记是文件名 rollout-<父ID>_<子ID>.jsonl。
  4. 归档副本重复:Codex 归档是复制不是移动,同一会话会同时出现在两个目录里。
  5. 压缩会话按需取全文必失败:ContentRef 偏移在解压流上算,原实现却在压缩字节上 seek —— 2767 次取全文全部失败,现在全部成功。
  6. 详情加载期间没有返回入口:loading 时整块面板被转圈替换,页头连同「返回列表」按钮一起消失;真实会话解析要十几秒,这段时间用户是困住的。
  7. 正文段距被撑到两倍:react-markdown 会在块级元素之间插入 "\n" 文本节点,被祖先的 white-space: pre-wrap 继承后渲染成真实空行。正文总高 1171px → 773px。
  8. 本机图片加载不了:webview 里 /E:/...、file:///E:/...、file://E:/... 三种写法实测全部加载失败,改为由后端读成 data URL(只读、绝对路径、扩展名白名单、单文件 ≤ 8MB)。
  9. 添加智能体时终端闪一下:探测命令直接 exec.Command 会弹控制台窗口,改用 HideWindow + CREATE_NO_WINDOW 启动。

安全边界

  • SessionAgent* 系列方法全部登记进 desktopOnlyAppMethods 黑名单,独立 web 服务模式下不暴露——避免已认证的 web 客户端读/删本机会话文件、读任意本机图片。
  • 本机图片读取限定扩展名白名单与 8MB 上限。

验证

  • 后端:go build ./...、go vet、internal/sessionagent、internal/appdata 全绿。
  • 前端:tsc --noEmit 通过;会话管理 + 连接类型目录共 13 个测试文件 82 项全过。
  • 真实数据回归:列表 357 → 162 条、空标题 218 → 0、重复 ID 12 → 0、有项目目录/创建时间 0 → 162、大内容回读 2767/2767 成功(修复前 2767 全败)。
  • 桌面端实机走查:检测添加 → 侧边栏节点 → 分组列表 → 详情 → 会话内搜索 → 导出 → 删除。

(截图见下方评论)

kunghim added 30 commits October 6, 2026 04:55
- internal/sessionassets:Integration/Source/Asset/SourceRef/CaptureJob/Manifest 领域模型,
  catalog.sqlite(WAL + user_version 迁移,唯一工具约束)与自包含材料库布局
- 保全事务:staging 流式复制+哈希校验→清单→原子提交 current 指针→投影修复→旧代次回收;
  部分候选不替换完整 latest,失败保留旧副本(V-11 五类切点)
- 启动核对:恢复完整暂存候选、放弃不完整/损坏候选、以提交清单修复索引投影、
  提升未落指针的完整代次、重试删除清理;单写入者库锁(appdata 新增非阻塞 TryAcquireFileLock)
- 用户删除:持久化抑制标记防扫描复活,清理失败保持可见,子资产自包含不受影响(V-14/V-20/V-21)
- 覆盖测试 17 项(含 -race),虚构夹具与临时目录,不依赖真实会话
M2 检测启用与主页接线:
- appdata:session-assets 材料库位置配置(默认数据根子目录,启用时可自选);
  新增非阻塞 TryAcquireFileLock(库占用快速失败可见,V-05/V-13)
- codex 适配器:安装探测(PATH/显式位置+版本证据+数据目录可读性),
  区分未安装/未初始化/不可读/就绪(V-02/AC-13)
- App 绑定:SessionAssetsDetect/Enable/Disable/ListIntegrations 幂等启用,
  生命周期接入 startup/shutdown(退出即停止保护,S-13)
- app.go 拆出 app_i18n.go,该债务文件净减 75 行
- 前端:新建连接智能体分类+Codex 卡片;检测/启用面板替代数据库表单;
  主页固定智能体分组(首次启用后出现);agent-assets 唯一管理页签;
  agentAssetsSlice 视图状态;i18n 6 语言 39 键;tsc 与 vitest 通过

M3 来源发现与观察基础:
- codex rollout 只读发现(0.160 本机实测格式:session_meta 头,
  sessions/YYYY/MM/DD + archived_sessions 布局),损坏文件单列不中断
- SyncSource:幂等纳入、归档补保存(任务去重)、完整扫描成功才标明确删除
  (不可达不误报,V-22);被抑制身份不重新纳入(FR-20)
- fsnotify 目录观察(提升为直接依赖):归档事件立即入队、安静窗口合并
  重复事件;活跃事件只维护元数据不复制正文(FR-05)
- 领域测试 24 项(含 -race)通过
- App 层:启动恢复与启用成功后对每个启用来源执行只读发现+归档补保存,
  并启动运行期间目录观察;观察启动失败仅记录可见原因,不显示为保护中
- M3 检查点测试:发现 → 归档保全提交 → 移除隔离原源 → 明确删除标记 →
  仅凭副本核对清单/hash/范围/投影(V-06~V-10、V-12、V-19 领域面)
- 全量扫描的归档捕获:首次发现、状态转归档、归档材料版本变化(路径/修改
  时间)才入队;重启核对中未变化的已归档会话跳过(FR-06/FR-07/V-08)
- 修复旧引用状态在纳入后被覆盖导致重归档事件丢失的问题
- 领域测试:取消归档保留副本+重归档替换 latest;fork 祖先固定前缀保全
  且分叉后父内容不混入、祖先缺失不标完整;附件外部复制/缺失处置不伪造
  完整(24 项全绿,-race 通过)
M4 查找、阅读、导出、删除与离线利用:
- QueryAssets 筛选分页(标题/项目/provider/备份状态,按源更新时间倒序,
  备份时间不扰动排序,V-07);reading.go 读源判定 live→backup→missing,
  覆盖时间结构化返回(文案由前端渲染)
- codex history.go 流式分页解析(消息/成对工具/推理/事件分类;加密与
  未知条目标不透明不伪解码,V-17);ScanHistoryStats 范围统计
- 导出:提交代次 zip(自包含原始材料)与可读历史 Markdown,均不含
  认证文件(FR-19/V-24);目录选择走系统对话框
- 前端工作台完整 UI:主从布局、状态双维度标签、读源带、历史分页加载、
  材料清单、删除确认列明清理范围(FR-03/04/08/20 的 UI 面)

M5 交接:
- codex resume 原生续聊入口启动(能力判定+终端启动器 windows/darwin/其他,
  分级报告 entry_launched,不冒充会话创建,V-25/V-29)
- 两阶段交接数据面:HandoffScope 范围(全部/最近轮次/自定义)+ 范围统计
  预览 + 范围化可读历史导出;ReadableHistoryReader 接口保持领域与
  adapter 分层
- PrepareHandoff 手动材料包与核验操作说明(明确尚未创建新会话),
  sanitizeID 防路径逃逸;能力判定驱动前端按钮禁用与降级提示(FR-16)

sessionassets 领域测试 35 项(含 -race)通过;前端 tsc + vitest 通过
(唯一失败为上游基线既有的重渲染计时测试)
- 两阶段面板:阶段一选择历史范围(最近轮次[默认]/全部/自定义行区间)、
  目标工作目录与可选当前请求;阶段二预览解析区间、可读消息与成对工具
  统计、不透明条目省略说明(不造 token 估算,FR-18/V-28)
- SessionAssetsPreviewHandoff / PrepareHandoff 绑定接受范围;
  codex ReadableHistoryAdapter 实现 ReadableHistoryReader 与
  HistoryStatsScanner(领域/适配器分层,V-26/V-28 领域面)
- 交接动作分级:准备材料 → 打开 Codex 交互入口(entry_launched ≠
  会话已创建,V-29);手动材料与说明必需可用(FR-22)
- i18n 6 语言 +19 键(new_session 域);tsc/vitest 通过(唯一失败为
  上游基线既有重渲染计时测试,单独运行其余噪声用例均通过)
- 捕获基准:流式复制+哈希+清单+提交 512MiB = 1.34s,累计分配 2 MiB,
  副本与源字节数一致(不整份进内存的实现验证)
- 分页基准:深偏移读取 512MiB 文件 1.20s(顺序扫描至偏移);ctx 取消
  立即返回(ReadHistoryPage 补充逐行 ctx 检查)
- 基准显式 -run 触发,常规 -short 套件跳过;结果记入本地台账
- 来源重定位:RelocateSource 更新目录与身份键,冲突身份拒绝不覆盖
  (V-22);绑定后立即补核对并重启观察
- 导出 zip 往返校验测试(固定前缀+内容一致,V-24 导出面)
- 修复:headless/web/启动前调用导出目录对话框会使进程 log.Fatal——
  拦截为可展示错误(对话框需桌面运行时)
- 列表原记录状态未聚合:QueryAssets 后按来源引用聚合并填充
  SourceStatus(此前恒为未知,前端显示来源未知)
- 侧栏挂载时未拉取智能体集成:重启后固定分组消失;挂载时调用一次
  refreshAgentIntegrations
- 导出目标目录不存在时报错:改为自动创建(0700)

以上均由隔离数据目录的真实页面自动化预检发现并复验通过:
启用→发现→归档保全→列表双维度状态→详情读源带(live)→删除原文件
→重扫→读源带切换备份+覆盖时间→zip/md 双格式导出→删除确认范围
文案→删除后抑制不复活
- 禁用时停止全部来源观察器、取消排队任务(观察器记录集成归属)
- 观察事件回调在处理前检查集成启用状态,禁用后丢弃事件
- 提交落点前再次检查启用状态,禁用后不写入新结果(旧 latest 保留)
- EnqueueCapture 拒绝已禁用集成的入队;RefreshSources 拒绝禁用集成
- 端到端测试 4 项:停观察器/拒绝入队/提交前禁用保留旧 latest/事件丢弃
- codex DiscoverSessions 返回扫描质量:来源根或归档目录权限/IO 不可达
  =unreachable,部分目录不可读=partial,全部可读=complete;目录不存在
  按布局合法性区分(活跃缺失不算降级)
- SyncSource 签名改为接受 SourceScanResult:complete 才做删除推断;
  partial 只处理发现项;unreachable 把引用改标不可达并保留最后观测
  与副本(SourceStatusUnreachable 首次真实生效)
- 扫描循环逐文件检查 ctx 取消;目录不存在错误包装为 os.ErrNotExist
  供调用方识别合法布局
- 新增 unreachable/partial 语义测试;既有 6 项 SyncSource 测试更新
  并全部通过
- schema v2:source_refs 增加 source_size/source_mtime,记录上次观测
  的源文件版本
- shouldCaptureArchive 比较源版本(路径/大小/修改时间任一变化即重新
  保全),不再用 GoNavi 扫描时间冒充源版本;旧引用无版本信息时宁可
  重新保全
- 修复:取消归档→同路径修改内容→再次归档时旧副本滞留为 latest
- current 指向的代次清单可读但文件损坏(hash 不符)时:不再直接清空
  全部代次,改为扫描全部代次找完整可验证者回退提升,确认后才清理
  其余损坏代次
- newestVerifiableGeneration 升级为逐文件 hash 校验,损坏代次不再被
  提升为 latest
- 无有效代次时回退为无副本投影(不伪造恢复)
- 新增回退与拒绝提升两个场景测试
- ResolveSessionDependencies 流式扫描 rollout 正文:识别对其它 rollout
  文件的引用(fork/恢复线索)与含 path 的图片/文件元素(附件线索)
- 同来源内定位被引用文件:存在 → Ancestors 前缀副本(发现时点边界)
  + ExtraFiles 进入捕获;缺失 → Missing 记录,候选不得标完整
- Service.SetDependencyResolver 注入点(未注入按自包含处理);App 启用
  流程注入 codex.Discoverer
- BuildSessionCaptureRequest 接受 DependencyResolution,未落地祖先转
  Missing;captureArchivedAsset 传递 source 供解析
- 测试:引用缺失标 Missing/引用存在解析为祖先/自包含为空 3 项
- schema v3:重建 assets 与 suppressed_sources 表,资产身份与抑制标记
  改为 (integration_id, origin_identity_key, native_thread_id) 三元组;
  旧数据 origin_identity_key 迁移为空串(行为与旧约束一致)
- UpsertAsset/suppression 按 (集成, 来源身份, 会话 ID) 查找与写入;
  SyncSource 传入来源 IdentityKey
- 新增测试:跨来源同 ID 保留独立资产、provider 互不覆盖
- codex CurrentProvider:只读解析 config.toml 的 model_provider(行级
  保守解析,不读会话内容)
- 原生续聊 provider guard:历史与当前运行 provider 不一致时拒绝原生
  续聊并提示改走携带历史新建(S-15/AC-12),capability 返回双 provider
  供前端展示
- 当前请求纳入交接:PrepareHandoffMaterials/绑定接收 currentRequest,
  写入 handoff-notes 与 handoff_records(FR-15/17);前端两阶段面板
  传递用户输入的当前请求
- handoff_test 调用点更新;i18n 6 语言 +1 键
- worker 认领任务时投影置 saving(无 latest 的资产同样可见进行中状态)
- RetryCapture:最近失败任务按 manual_retry 重新入队;禁用/抑制/已删除
  分别拒绝(抑制检查优先于启用检查,删除后重试返回 ErrSuppressed)
- 禁用取消提交后按 current 指针恢复投影,清除认领时的 saving 残留
- 详情展示最近失败任务与错误(Alert);失败时显示重试保全按钮
- 绑定 SessionAssetsRetryCapture;前端 seam/面板/i18n 6 语言接线
- 测试:重试成功提交、禁用拒绝、抑制拒绝
- 来源重定位先停旧观察器再启动新观察器(P1-09)
- 目录观察:watch/ReadDir 错误不再静默;来源根不存在时回退监听父目录
  并在目录创建事件补挂(P1-10 部分)
- 导出 zip 原子发布:临时文件写完后 AtomicReplaceFile 落位,失败清理
  半成品且重试不被 O_EXCL 阻塞(P1-11)
- 历史分页跳过与统计尊重 ctx 取消;统计超防御上限标记 truncated
  不静默截断(P1-12/V-23)
- 列表筛选实现 source_id 与 source_status(基于来源引用 EXISTS,
  unknown=无有效引用)(P1-13/FR-03/V-07)
- 历史视图工具调用与输出按 callId 成组渲染(P1-14/交互 §5.2)
- models.ts 生成后清理行尾空白(P1-15,git diff --check 通过)
- N1(P0):祖先文件同时进入 Ancestors 与 ExtraFiles 导致同 RelPath
  双写、O_EXCL 冲突使引用其他 rollout 的会话保全必败——移除 ExtraFiles
  重复加入,补 resolver→capture 端到端回归测试
- N2(高):观察器路径 UpsertAsset 缺 OriginIdentityKey/SourceSize/
  SourceModTime,产生重复资产并绕过删除抑制——补齐三元组与源版本
- N3(中):v3 迁移 DROP TABLE assets 触发外键级联清空 source_refs 与
  capture_jobs——迁移改为单连接、外键关闭、手动事务,补保留性测试
- N4:saving 状态在抑制取消/部分丢弃/代次冲突路径未恢复,资产卡在
  保存中——各取消路径按 current 指针恢复投影
- N5:checkIntegrationStillEnabled 区分查询失败与确认禁用,查询失败
  保留候选待重启不误删
- N6:current 指针读取移入 finalizeMu 锁内消除 TOCTOU
- P0-01 前端缺口:启用面板补禁用按钮(SessionAssetsDisable + 状态
  刷新),i18n 6 语言
- 独立审查确认其余 P0-02~08 修复真实有效
- Windows 探测命令隐藏控制台窗口(CREATE_NO_WINDOW),消除每步操作
  闪终端(用户实测反馈)
- 探测结果缓存 5 分钟:选择资产触发的 capability 查询不再反复执行
  codex --version;启用/禁用等显式动作强制重新探测
- 会话标题:发现时提取首条用户消息前 80 字符作为标题,列表/详情
  显示有价值的会话内容而非 ID/路径(用户实测反馈)
- fork 祖先解析改用 session_meta 头部的 forked_from_id/parent_thread_id
  权威字段(0.16x 真实格式实测存在),正文文件名引用作为补充
- SyncSource 纳入标题与 fork 父 ID;观察器/适配器/领域签名对齐
- extractSessionTitle 跳过 AGENTS.md 注入包装的 user 消息,取用户
  真实输入的首条消息(否则标题全是指令文本)
- SessionMeta.Provider 只映射 model_provider,不再误用 source 字段
  (source 是入口来源如 vscode,不是模型 provider)
- 0.16x 新格式实测:session_meta payload.source 可能为对象,头部解析
  已逐字段容错
- 历史视图重构:主线只渲染用户真实输入与助手回复(聊天气泡式);
  工具调用按 callId 成对折叠;注入包装(环境上下文/指令)折叠为
  上下文块;事件默认隐藏可展开——解决主线被注入内容与事件噪音
  淹没的问题(用户目标:看看最近聊了什么)
- 标题提取跳过 environment_context 包装;会话列表/详情标题改为
  用户真实首问(用户实测反馈:全部显示 environment_context)
- ReadHistoryPage 分页跳过与统计尊重 ctx;统计超限标记 truncated
  不静默截断(V-23)
- 读源带/provider 修正:provider 只映射 model_provider,不再误用
  source(入口来源 vscode)
- wrapper 字段全链路(Go→JSON→TS)透传
列表:
- 放弃 antd Table 的跨行分组列(rc-table 每行固定渲染一列 <td>,做不出
  分组表头),改为自定义分组区:项目名独占一行(文件夹图标 + 14px/700 +
  会话数,滚动吸顶),下方才是该组的会话卡片。分页改为按分组分页。
- 搜索防抖 350ms + 标题来源 10 秒缓存;工具条显示「显示 N / 共 M」并提供
  清除筛选,筛选是否生效一眼可见。
- 区分 fork 会话(列表行加分叉标签)。

详情:
- 加载期间保留页头(此前整块面板被转圈替换,「返回列表」按钮一起消失,
  压缩会话解析十几秒期间用户没有返回入口);新增 Esc 返回列表。
- 修正正文段距:.gn-session-agent-atext 上的 pre-wrap 被 markdown 继承,
  react-markdown 插入的 "\n" 分隔符被渲染成真实空行(正文总高 1171px →
  773px,块间距 21~36px → 6px)。
- 复用 AI 面板的 normalizeAiMarkdown 做代码围栏修正。
- 本机文件引用渲染成文件卡片;指向本机图片的 ![alt](/E:/path/x.png) 走
  后端 data URL 显示(webview 加载不了本机路径,实测三种 src 全部失败)。
- 智能体与数据连接同级:不再自动建「智能体」分组,每个工具作为顶层节点
  与连接分组并列。
- 节点右键菜单:打开会话管理 / 编辑智能体 / 移除智能体。编辑弹窗预填
  显示名与数据目录并带「重新检测」,明确「只修改 GoNavi 里的配置,不会
  改动工具自身的会话数据」;移除有二次确认。
- 检测面板重做:修正失效的 CSS 类名(此前样式几乎为零),文案从与事实
  不符的「添加到智能体分组」改为「检测本机安装并添加为智能体」,并换用
  ChatGPT / Claude 品牌图标。
- 添加智能体时终端闪一下:探测命令改用 HideWindow + CREATE_NO_WINDOW
  启动,不再弹出控制台窗口。
绑定层 sessionAgentService 只在「配置不可读」的异常分支调用 SetEnabled,
正常路径下 Service.Enabled 保持 nil。isEnabled 对 nil 的语义是「全部启用」,
而未添加工具的数据目录为空串,会被解析成默认家目录(~/.codex、~/.claude)——
于是没添加的智能体照样出现在会话列表里,删除链路也照样可达(UnderRoots
校验用的也是同一个默认根目录)。

成功路径现在也显式置启用集,kind 比较同时做大小写与空白归一。

回归测试:绑定层新增 2 个用例(只添加 Claude 时不得扫描 codex 默认目录、
不得对 codex 执行删除),均已验证在修复前失败、修复后通过。
真实会话里四种记录形态此前都解析不出内容,工具行只剩一个没有信息量的
工具名:

- function_call 的 arguments 是「JSON 字符串里套 JSON」
  ("arguments":"{\"cmd\":\"rg --files /tmp\"}"),按对象解析必然失败,
  cmd/workdir/query/title 全部读不到。实测本机 207/207 条 function_call
  都是这个形态。新增 codexToolArgsText/codexToolArgsMap 剥掉外层包装。
- function_call 的 namespace 是独立字段("namespace":"mcp__cua_repl"),
  name 往往只是 "js" 这类短名,只按 name 前缀判 MCP 会漏掉全部 MCP 工具。
  payload 结构体补上 Namespace,classifyCodexTool 改用它。
- custom_tool_call 的 input 不是 JSON,而是工具调用脚本文本
  (text(await tools.exec_command({cmd:"git status",...}))),且 name 恒为
  "exec"——命令只存在于这段文本里。实测近 40 个会话 522 条记录全是这个
  形态。新增 codexCustomToolCmds/codexCustomToolName 从文本里抠命令;
  多条命令标注「+N 条命令」,没有 cmd 的调用回落到被调工具名。
- Claude 的 tool_result 把输出写在 content 字段(字符串或内容项数组),
  旧实现读 c.Text,两种形态都取不到,工具输出在界面上彻底不可达。
- Claude 的图片块载荷在 source 里(base64 或文件路径),旧实现同样读
  c.Text,粘贴的截图只剩 alt 文本。base64 走内联 ContentRef,file 走本机
  路径;内联图片按 ContentRef 回源取完整载荷(新增绑定
  SessionAgentImageInlineDataURL,图片不走分页,base64 截断就渲染不出来)。

顺带修两处契约问题:
- BuildTurns 的 hasFinalReply:助手正文无论以块还是以 Content 承载都算
  有最终回复,否则解析器把正文并进已有 thinking/tool_call 块时轮次索引
  几乎恒报「无最终回复」。
- sortSessionsDesc 的 O(n²) 插入排序换成 sort.SliceStable(1 万个会话时
  插入排序约 369ms)。

回归测试:新增 Codex 8 例、Claude 5 例,覆盖双层编码、对象形态、namespace
分类、MCP 前缀兼容、custom_tool_call 命令抽取与搜索分类、tool_result 两种
content 形态、图片两种 source 形态,以及内联图片引用能真的回源取到 base64。
智能体会话管理是桌面本机文件浏览器:读 ~/.codex、~/.claude 的会话正文,
还能删源文件。独立 web 服务模式下唯一的闸门就是 desktopOnlyAppMethods
黑名单(共享运行时 allowDesktopMethods=true 是桌面进程本身,另有分支),
新增的 15 个 SessionAgent* 方法此前一个都没登记,认证后的 web 客户端可以
调 SessionAgentDelete 删本机会话文件、调 SessionAgentDetail /
SessionAgentExportMarkdown 读完整对话内容,调 SessionAgentImageDataURL
读任意本机图片(后端只校验扩展名与 8MB 上限,没有根目录限制)。

服务绑在 127.0.0.1 且 /api/invoke 有 requireWebAuth,不是无鉴权暴露,
但功能定位与暴露面不匹配,本次全部登记进黑名单。
SessionAgentWorkbench.css 819 行,超了 AGENTS.md §1.1「新建生产源文件
800 行」硬上限 19 行。按组件归位拆成三个文件:

- SessionAgentWorkbench.css(191 行):页面骨架、页头、工具条卡片、
  文件引用卡片、主体两栏
- SessionAgentListPage.css(186 行):会话分组列表、拖拽分割条
- SessionAgentDetailPanel.css(447 行):详情两栏、聊天时间线、
  执行过程折叠条

拆分前核对过三段之间没有重复选择器,层叠顺序不受影响;内容与原文件
逐行比对一致,仅多出两个文件头注释。导入语句跟着组件走。
内联图片(Claude 的 base64 图片块)此前没有渲染路径:后端丢掉 source
字段,产出的 ImageRef 既没有 path 也没有 content,详情页只按
block.image.path 渲染,粘贴的截图只剩 alt 文本。新增
SessionAgentInlineImage 组件,按 ContentRef 回源取完整载荷后渲染成
data URL;sourcePath 从工作台沿时间线逐层透传。

后端失败此前被 API seam 吞掉:unwrap 在 success=false 时返回
null/空数组,调用方的 catch 永远不触发,用户只看到「空列表」,分不清
是没有会话、智能体没添加还是扫描失败。后端已经 i18n 化并带了 detail 的
message 被丢弃。unwrap 改为失败即抛错并带上后端 message,配套给导出、
复制恢复命令、展开全文三处补上 catch(否则会变成未处理 rejection)。

回归测试:新增 API seam 5 例(失败抛错并带后端 message、无返回值也抛错、
成功路径正常、内联图片取 data URL),工作台新增 1 例(后端失败时
message.error 必须被调用且带上后端 detail)。
连接弹窗标题栏的徽标按数据库品牌图标渲染,而智能体类型(ChatGPT /
Claude Code)没有注册品牌资源,getDbIcon 回退成通用图标、底色又取自定义
类型的白色——于是标题栏左侧渲染出一个「白底白图」的空方块。品牌图标在
面板正文里已经有了,标题栏不该再重复展示。

智能体类型下不再渲染徽标。回归测试新增 ConnectionModal.agent-header
(智能体类型无徽标、普通数据库类型仍有一个),共享的 antd / 绑定 mock
补齐了智能体面板挂载时用到的 Spin 与 SessionAgent* 方法。
弹窗内容区(.ant-modal-body)本身没有内边距,普通数据库表单走
.gn-conn-form-main 的 padding,智能体面板直接挂在 body 里,左右完全
贴着弹窗边缘。

- 面板补上 16px/20px/18px 的内边距,与表单的横向留白一致
- 操作区的说明文字用 order:-1 排到左侧、按钮组靠右。原来
  margin-right:auto 落在最后一个子元素(说明文字)上,把按钮组顶到了左边
工作台只在挂载或改筛选时扫一次会话,移除智能体后界面仍留着旧会话
(后端此时已无该智能体可扫,启用集为空)。

- 工作台订阅已添加智能体列表,变化时重新扫描
- 列表为空时一并清掉详情残留,别让已移除会话的正文继续挂在右侧
- 工具筛选下拉只列已添加的智能体;筛选值指向已移除的工具时清掉它。
  判断必须等集成列表真的加载完——「还没加载」和「一个都没添加」都是空
  列表,store 因此补了 agentIntegrationsLoaded 标记

回归测试新增 SessionAgentWorkbench.agent-integrations:移除智能体后
必须重新扫描、列表为空时详情面板消失、集成列表未加载时不得清掉筛选。
智能体会话工作台页签绑在智能体上(tab.agentIntegrationId)。之前移除
智能体只清空了列表内容,页签还留在页签栏里,成了一个「暂无会话记录」的
空页面——用户已经把智能体移除了,这个页签没有存在意义。

- tabSlice 新增 closeStaleAgentAssetsTabs:关闭 agentIntegrationId 不在
  存活列表里的智能体页签,并按既有规则重算活动页签与活动上下文
- 智能体列表刷新成功后调用它。移除走的是同一份 agents.json,列表刷新
  即代表真实状态,所以侧栏右键移除与弹窗移除两条路径都会生效

回归测试新增 store.agentAssetsTabBinding:智能体消失后页签关闭、仍存在
的智能体页签保留、被关掉的页签是活动页签时活动页签要顺延。
Codex 的子 Agent(审批 / 交付验证 / 市场审查)把结构化结论直接作为助手
消息发出,例如:

  {"summary":"审查被环境能力阻塞:…","verdict":"blocked","findings":[]}

这是机器对机器的协议,不是对话。按普通段落渲染就是一坨单行 JSON,连
换行都没有。本机 269 个 rollout / 11182 条消息里,473 条(4.2%)是这
类消息。

- 整条内容是 JSON 对象时拆成两部分:正文取顶层最长的字符串值(承载
  自然语言结论的那个字段),其余字段收成一行可展开的摘要行
- 枚举显示原值、数组/对象显示条目数,展开后是原始内容,信息不丢
- 枚举值类的短正文(allow / blocked)带上字段名,避免只剩一个孤零零
  的 ok
- 工具结果预览里整段是 JSON 的同样处理(占工具输出的 1.9%)

判定刻意保守,避免把工具的原始输出改头换面:只处理顶层对象;对象至少
两个字段,单字段时必须是字符串值——`{"port":8080}` 这类原始输出保持
原样。导出 Markdown 仍按原文输出,不受展示策略影响。

不硬编码任何工具的 schema:换个工具出现新的字段名同样能通用。

新增文案走 shared/i18n(6 语言),已重新生成 catalog.zip。
回滚 97c8dda。该改造会让会话详情页打不开:选中会话后列表收成窄条,
右侧详情一片空白(连「返回列表」都不显示)。

排查结论:
- 点击确实到达前端、绑定也确实成功(临时在 SessionAgentDetail 里加日志
  验证:err=<nil>,且命中的是 .zst 压缩会话)
- 真实会话载荷单独渲染是对的:把真实 transcript(149KB、13 条 JSON 正文
  消息)灌进 SessionAgentDetailPanel,102KB 正文正常渲染,JSON 消息的
  prose 与摘要行都在
- 载荷体积正常(17KB~407KB),不是 Wails IPC 体积问题
- 浏览器 dev 与生产构建里都能正常打开详情

即:前端渲染路径在隔离环境下可复现为正确,真实 app 里却整块消失,触发
条件落在该提交的前端改动上。定位成本高于收益,先回滚恢复可用状态,
JSON 展示另起一版重做(建议把拆分放到 Go 解析侧,前端只渲染普通正文 +
摘要行,前端不再做 JSON 识别)。

同时补上浏览器 mock 缺失的 SessionAgentListIntegrations:侧栏会调它,
mock 里没有该方法,浏览器模式下整条链路是抛异常的。
子 Agent(审批 / 交付验证 / 市场审查)把结构化结论直接当助手消息发出:

  {"summary":"审查被环境能力阻塞:…","verdict":"blocked","findings":[]}

这是机器对机器的协议,按段落渲染就是一坨单行 JSON。上一版把识别放在
前端渲染期(97c8dda8),会让整个详情面板打不开——选中会话后列表收成
窄条、右侧一片空白,连「返回列表」都不显示。已回滚该版(ecb12587),
改在 Go 解析阶段拆好:

- internal/sessionagent 新增 StructuredBody:正文 + 其余字段摘要
- splitStructuredBody 判定「整条正文是一个 JSON 对象」并拆解:
  正文取顶层最长的字符串值(承载自然语言结论的那个字段),其余字段
  收成摘要行(标量给原值,数组/对象给条目数)
- 判定刻意保守:只认顶层对象;至少两个字段,单字段时必须是字符串值。
  {"port":8080} 这类工具原始输出保持原样,{"outcome":"allow"} 这类纯结论
  才处理
- 原始正文仍留在 SessionMessage.Content 里,导出与搜索不受影响;
  Structured 只是浏览端的展示提示
- 注入内容(工具塞给模型的上下文)不做拆解

前端只新增 SessionAgentStructuredBody 组件并读 msg.structured:正文按
普通对话渲染,其余字段收成一行可展开的摘要。**前端不再做任何 JSON 识别**,
识别规则只有 Go 侧一份。

真实数据验证:本机 269 个 rollout / 332 条消息里 158 条是结构化结论,正文
字段判定全对(rationale 99 条、summary 57 条、outcome 2 条)。生产构建
实测渲染正确:正文是普通 Markdown 段落,其余字段收成一行
outcome allow · risk_level low · user_authorization high。

新增文案走 shared/i18n(6 语言),已重新生成 catalog.zip。
SessionAgentStructuredBody.css 的 font-family 少了一个右括号:

  font-family: var(--gn-font-mono, var(--gn-font-sans);

CSS 解析器遇到未闭合的函数,会把后面内容一直当成它的值吞到文件末尾。Vite 把
SessionAgentWorkbench.css 与 SessionAgentDetailPanel.css 合并进同一个 chunk,
于是这两个文件的规则全部失效:工作台两栏塌成纵向堆叠,详情面板被排到列表
下方看不见。构建成功、单元测试全绿,只有肉眼能看出问题。

实测 1600px 视口:修复前 .gn-session-agent-workbench-columns 为 display:block,
列表与详情上下堆叠;修复后为 display:flex / row,列表 340px 在左、详情 856px
在右并排显示,正文正常渲染。
CSS 少一个右括号不会让构建失败,却会让同一 chunk 里排在它后面的规则静默失效,
整片样式消失且没有任何报错,只能靠肉眼定位。新增覆盖全仓库 .css 的扫描型用例
(与 i18n/keyResolution.test.ts 同属全局不变式扫描):花括号与圆括号不配平即
失败,把这类回归从"肉眼定位"变成"测试即拦截"。
新建连接弹窗第一步的卡片按 hasDbIconAsset() 取图:品牌资源表里没有 codex /
claude,卡片就回退成通用数据库圆柱,认不出是哪个智能体。

仓库里其实一直有现成的官方图标——db-icons/codex.svg(OpenAI 花结)与
icons/ai/claudecode.svg(Claude Code,AI 供应商 logo 一直在用),只是从没
登记进品牌资源表。把两个类型登记进去即可,无需新增图标资源。

同时在 DB_ICON_MAP 里接上,使 getDbIcon() 在其它界面也取到品牌图标而不是
通用圆柱;默认色按品牌取 OpenAI 黑 / Anthropic 橙。
重写智能体会话内容区的信息架构:列表以任务索引呈现(优先摘要、
所属项目、Provider、时间),详情改为沉浸式单页,只保留「会话」与
「改动」两个主 Tab;会话按轮次组织用户要求/执行结果/执行过程,
执行过程可展开工具行。

改动页按文件聚合真实 patch:
- 解析 exec 包裹的 apply_patch 脚本并记录改动行数;
- patch 文本内联给前端完整渲染 diff,不再依赖倒序预览+展开。

未改动菜单栏、数据源树与顶部图标栏。
原实现的「会话内搜索」有搜索词时整段降级为纯文本显示,表格、加粗等
Markdown 结构全变成源码,命中也只是标出数量、没有定位。

改为对已渲染的 DOM 做浏览器式查找:

- 命中文本节点切开包上 <mark>,React 持有的原文本节点保持原位(还原时
  按记录拼回,并识别 React 是否已改写),不干扰 reconciliation。
- 上下跳转按钮 + 命中计数,当前命中高亮。
- 执行过程懒加载、思考块折叠等内容变化由 MutationObserver 兜底重扫。
- 上游的 src/plugins/permissionLabel.test.ts 用了 node:fs,但
  frontend/package.json 没有 @types/node —— npm run build 的第一步就是
  tsc,缺它会直接卡住构建。补为 devDependency。
- 合并上游后连接类型分组顺序变为 task_scheduler 在 agent 之前(前者是
  动态注入的),同步修正断言。
@kunghim

kunghim commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

界面截图

侧边栏:智能体与数据连接同级,每个工具一个顶层节点(带品牌图标)。

侧边栏的智能体节点

会话管理列表:按日期分组(当前 171 个会话),卡片带分叉标记、项目名与首问摘要。

会话管理列表

两张图已随本 PR 提交到 assets/screenshots/,沿用上游既有的产品截图目录约定。

package.go 读包出错时用 fmt.Errorf("read package file failed") 重建了错误,
把 copyErr 整个丢掉 —— copyErr 在 ctx 取消时正是 context.DeadlineExceeded。
调用方因此分不清「超时取消」和「磁盘读失败」。

Windows CI 上 TestStateWriteCancellationRollsBackInstall 就是这么红的:
runner 慢,30ms 超时落在读包文件阶段,错误被吞成 read package file failed,
断言 errors.Is(err, context.DeadlineExceeded) 不成立。同一份代码在本地快
机器上超时落在更早的早退分支,所以本地一直绿(连跑 5 次全过)、macOS
也绿,只有 Windows runner 红 —— 典型的速度相关 flaky。

改为 %w 包装后,无论超时落在哪一步都能识别为 DeadlineExceeded。
@kunghim

kunghim commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

关于 Windows CI 上那个失败

第一次 CI 跑出来 Platform-sensitive tests (windows-latest) 红了,失败的是:

--- FAIL: TestStateWriteCancellationRollsBackInstall (0.26s)
    package_limits_test.go:99: installation was not canceled: read package file failed
FAIL	GoNavi-Wails/internal/plugin	41.132s

这个失败不是本 PR 引入的,是上游插件体系里一个速度相关的 flaky:

  • internal/plugin 不依赖 internal/app / internal/sessionagent,本 PR 没碰过这个包;
  • 该测试来自上游提交 88aa617a ✨ feat(plugin): 落地可独立开发分发安装运行的开放插件体系;
  • 同一份代码:本地 Windows 连跑 5 次全过、macOS runner 绿、只有 Windows runner 红 —— 上游 dev 最新提交(7d6e8223,代码完全相同)的 Windows job 也是绿的。

根因:internal/plugin/package.go 读包出错时用 fmt.Errorf("read package file failed") 重建了错误,把 copyErr 整个丢掉 —— 而 copyErr 在 ctx 取消时正是 context.DeadlineExceeded。调用方因此分不清「超时取消」和「磁盘读失败」。

这个测试靠 30ms 超时取消安装、并断言 errors.Is(installErr, context.DeadlineExceeded):

  • 本地快机器:超时落在安装更早的早退分支,那里保留了 ctx.Err() → 断言成立 → 绿;
  • Windows CI runner 慢:超时落在读包文件这一步 → 错误被吞成 read package file failed → errors.Is 不成立 → 红。

本 PR 里已一并修掉(0ebb4d3c):改成 %w 包装后,无论超时落在哪一步都能被识别为 DeadlineExceeded。这是一行改动,不改变任何行为语义,只是把原因透出来。

本地验证:go build ./... 通过、internal/plugin 与 internal/pluginmarket 全量测试通过、tsc --noEmit 通过。

说明:本地没能复现出这个 flaky(连跑 5 次 + -race 都是绿的),它需要 CI runner 那个特定的慢速窗口才能触发。修复是按根因做的,不依赖复现。

顺带把上游最新的 7d6e8223(titlebar 悬停底色修复)也 merge 进来了,本分支现在相对 upstream/dev 是 0 behind。

@Syngnat
Syngnat merged commit 414eabe into Syngnat:dev Oct 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants