# 2026-09-24
## Codex 数据分析新包 · v1.0.0(包骨架 + 入口打通)
**交付**
- 新建 `ai-electron/frontend/src/codex/` 包(所有文件 codex 前缀):
- `styles/codexTheme.less` — `--codex-*` 设计 token + 页面布局基元;色值全部映射到项目既有 token
(`@cb-*` 色板 + antd legacy 变量),不引入新色系;布局=顶部状态条 + 会话栏 + 消息区(唯一滚动容器)+ 底部固定输入区 + 产物栏,窄屏(<1200px)收产物栏。
- `codexIndex.ts` — 包出口,只装载全局样式(注意:连入口都不能叫 index.ts,用户要求文件名全部 codex 前缀)。
- `views/codexAnalysis/codexAnalysis.vue` — 三栏骨架 + 空态示例。
- 改 2 处登记(无逻辑改动):`core/router/helper/routeHelper.ts` 加 `explicitDynamicViewMap['/codexAnalysis/codexAnalysis']`;
`core/store/modules/menu.json` 在 10263「AI 数据分析」分组下加菜单项(id 10267,标题「Codex 分析」)。
- 计划文档 `codex-analysis-plan.md`(v1.0.0 → v1.9.0,逐版本 review)。
**验证**
- `pnpm build` exit=0,产出 `dist/assets/codexAnalysis-*.{js,css}`。
- `pnpm type:check` 共 89 条报错=仓库既有基线数(`graph/trans/otg/call/person/case`),**codex 包 0 报错**。
**环境修复(worktree 首次可用)**
- 本 worktree 原本没装依赖,且 `pnpm install` 因 esbuild postinstall `EBUSY`(沙箱拦孙进程 spawn node)失败后
`.bin` 不生成 → 改用 `pnpm install --ignore-scripts` 解决。
- 两个被 gitignore 误伤的目录从主仓库 `E:\workspace\zsjz-ai` 拷入本 worktree(都 ignore,不污染 git status):
`ai-electron/frontend/build/`、`ai-electron/frontend/src/case/views/data/`(51 个文件,路由直接引用)。
**下一步**:v1.1.0 起继续(已当场做完,见下)。
## Codex 数据分析新包 · v1.1.0 → v1.9.0(一次做完)
**前端**:`src/codex/` 共 31 个文件(全 `codex` 前缀)
- api:`codexApi.ts`(搬家 + 补 `onCodexApproval` / `onCodexApprovalClosed`)、`codexArtifactApi.ts`
- store:`codexChatStore.ts`(会话 localStorage 自持、事件按 threadId 缓存、审批队列、产物目录、诊断)
- utils:`codexEventReducer` / `codexBlockParser` / `codexMarkdown`(markdown-it + 最小类型 shim)/
`codexEcharts` / `codexFullscreen` / `codexArtifact` / `codexReportTemplate` / `codexReportExport` / `codexFormat`
- components:工具栏 / 会话栏 / 消息流 / 消息项 / 输入区 / 过程时间线 / 工具卡 / 审批弹窗 /
块分发器 + markdown·代码·表格·JSON·图表·关系图六个块 / 产物栏 + 预览 / 诊断抽屉
- 视图:`views/codexAnalysis/codexAnalysis.vue`(重写为装配层)
**主进程**
- 新增 `service/codex/codexArtifactService.ts`:root 白名单(落盘 `codex-data/artifact-roots.json`,
重启后历史会话仍可读)+ 路径穿越校验;只读不写不删;文本/二进制分别限额。
- 新增 `controller/codexArtifactCtl.ts`:`list / readText / readBinary / saveAs / reveal / open / openRoot`。
- `codexCtl.ts` 增量:`threadStart` 返回 `{threadId, cwd}` 并登记 root;`turnRun` 登记可选 cwd;新增 `defaultWorkspace`。
**验证(全绿)**
- 前端:`pnpm type:check` 89 条 = 基线、本包 0 报错;`pnpm build` exit=0,产出 `codexAnalysis-*.{js,css}`。
- 主进程:`npx tsc --noEmit` 0 报错;`npm test` 6 文件 / 91 用例全过;`npm run build-electron` exit=0 且
`codexArtifactCtl` 已进 `public/electron/main.js`。
**环境修复(本轮新增)**
- `ai-electron` 是 **npm** 项目(只有 `package-lock.json`,无 `.pnpm`),pnpm 装不了(`esbuild@^0.28.0`
在镜像元数据里不存在)。从主仓库拷 `package-lock.json` 后 `npm install --ignore-scripts` 成功。
## 收尾补漏(同日)
复核计划时发现并修掉两处:
1. **真缺口**:提问只存内存,`eventsRead` 回读不到用户侧文本 → 刷新后只剩回复。
已加 `codex.prompts.v1` 落盘(每会话上限 200 条、单条 2 万字)。
2. **计划有但没做**:产物栏「新产物角标」。已在 store 里按「上一轮快照对比」实现,
手动刷新即清除。
如实记录在 `codex-analysis-plan.md` 的「与计划的偏差」表里(关系图导出是 JSON 不是图、
HTML 报告里图表是折叠 option、未做行级 diff 染色、未真机 GUI 端到端验证)。
## v1.9.1 + v2.0.0(数据分析增强)
**v1.9.1(补齐 v1.x 两处偏差)**
- 新增 `utils/codexVisualRegistry.ts`:内容哈希(djb2+长度)→ 图片提供者的注册表,
解决「HTML 报告导出在模块层、拿不到组件里的 ECharts 实例」。
- 关系图导出图片:html2canvas 整块栅格化(节点是 HTML、连线是 SVG,手撸 SVG 只能拿半张图);
导出前 `zoomToFit()`;报告内嵌用的缓存图 `scale=1` 且不改用户当前视图(延迟 600ms 后台截)。
- HTML 报告 `visualBlockToHtml()` 内嵌真实 `
`,取不到回退折叠 JSON。
- `downloadCodexDataUrl()` 收敛三处重复的 base64→Blob 逻辑。
- Markdown 报告**仍保留围栏块**(data URL 会让 .md 涨到几十 MB,不适合再加工场景)。
**v2.0.1 分析模板库**:`utils/codexTemplateLibrary.ts`(8 条预置模板,围栏协议写进提示词当教学)
+ `components/codexTemplatePanel.vue`(搜索/分组/插入/存自定义/删自定义)。
**v2.0.2 工作目录管理**:store `recentCwds`(去重上限 10、Windows 路径大小写不敏感)
+ `components/codexWorkspacePicker.vue`(最近目录一键开新会话)。
**v2.0.3 多会话并行**:`submitting: boolean` → `runningThreads: string[]`,
只拦同一会话重复提交;侧栏运行中呼吸点、工具栏运行中会话数。
**v2.0.4 会话内检索**:粘顶检索条 + 命中计数 + 上/下一条居中跳转;命中判在消息粒度
(不染色文本,避免与 markdown 的 v-html 打架),命中项加左侧强调条。
**踩坑**:Electron 渲染进程**不支持 `window.prompt`** —— 模板命名改成抽屉里的行内表单。
**验证**:`pnpm type:check` 89 条基线 / 本包 0 报错;`pnpm build` exit=0。
(本轮未改 electron 代码,沿用上一轮 tsc + 91 单测 + build-electron 全绿。)
## v2.0.5 案件工作空间联动(唯一一次动 ai-server)
**背景事实(值得记住)**
- 案件工作空间 = `/QingJian/workspace/<案件ID>`(`PathConst.WORKSPACE.resolve(id)`),
`CaseInfoService.create()` 建案时就已经 `FileUtil.mkdir` 建好,duckdb 也放在里面。
- `CaseInfo` 的 `db` / `db_Path` 都标了 `@JsonIgnore`,所以**工作空间一直没回传给前端**。
- 后端 `/case/open` 与 `/case/current` 都会返回 `CaseInfo`,前端 `useUserStore.openCase()` 里
`{...res}` 展开 → 新字段会自动进案件缓存(`CASE_INFO_KEY`),不用改 case store。
**改动**
- 后端(3 处纯增量):`CaseInfo` 加 `@TableField(exist = false) String workspacePath`;
`CaseInfoService` 加 `workspacePath(caseId)`(顺带 mkdir 兜底)+ `fillWorkspacePath(caseInfo)`,
在 `listByOwner()` / `current()` 返回前调用;`CaseInfoController.open()` 回传前补一次
(该接口原本又 `getById` 取了一次,正好在返回前补)。
- 前端:`plat/api/case/caseApi.ts` 的 `CaseInfo` 加 `workspacePath?: string`;
codex store 增 `caseId` / `caseWorkspace` / `cwdOverrides` / `syncCaseContext()` / `activeCwdSource`;
cwd 优先级改为 **会话 cwd → 案件手动覆盖 → 案件工作空间 → 客户端默认**;
`setDefaultCwd` 删除,改为 `setWorkspaceDir()`(按案件记忆,键 = 案件ID 或 `no-case`,
旧版全局值会迁移到 `no-case` 下);视图与目录弹窗显示来源徽标。
**验证**:前端 `pnpm type:check` 89 基线 / 本包 0 报错 + `pnpm build` exit=0;
后端 `mvn -o -DskipTests compile` exit=0,并用 `javap` 核验新字段/方法进了 class。
## v2.0.4 补漏:文本级关键词高亮
对照计划复审时发现 v2.0.4 少做了「关键词高亮」(当时只做了命中计数与上下跳转)。
补 `utils/codexHighlight.ts`:
- `splitCodexHighlight(text, keyword)` → 切段,给**插值渲染**的地方用(提问气泡、代码块,加 class);
- `highlightCodexHtml(html, keyword)` → 给 **v-html** 的 markdown 用,逐段跳过 `<...>` 只在文本里插 ``;
关键词按「原始 + HTML 转义」两种形式各匹配一次(markdown-it 会把 `&` 输出成 `&`)。
- **踩坑认知**:绝对不能在 `onUpdated` 里手工替换已渲染 DOM 的文本节点来高亮 ——
Vue 缓存的 vnode 仍指向被替换掉的旧节点,下次 patch 会把新文本写进已脱离文档的节点,
表现为「内容卡住不更新」。v-html 整块重建 innerHTML,所以字符串替换才安全。
## v2.1.0 可执行计划(只出计划,未实施)
写进 `codex-analysis-plan.md` 第八节(并顺手把该文档编号从乱的「七插在四前」理顺成一→八):
- **v2.1.1 运行参数显式化**(sandbox / approvalPolicy 目前是我硬编码的 `workspace-write`+`on-request`,
属实现缺口,最该先修;危险选项要二次确认且不许作为全局默认静默继承)
- **v2.1.5 产物增强**(搜索 / 类型筛选 / 本轮变更 + patch 展开;patch 路径解析失败必须静默降级)
- **v2.1.2 块级钉到看板**(复用 `codexTextKey` 按内容哈希恢复;折叠时不渲染,最多 6 个)
- **v2.1.3 结论快照与对比**(只存文本与表格、不存图;行级 LCS diff)
- **v2.1.4 模板 / 会话列表导入导出**(导入的会话本机没有记录,要明确提示,不装作能恢复)
- 8.7 记了**评估后不做**的三项(图表联动筛选做不出可靠交互、SQL 直连预览、消息级删除),附理由
- 8.8 汇总 D1–D5 决策点待确认;8.9 登记 v2.2.0 候选
## v2.1.0 实施完成(v2.1.1 → v2.1.5,五个子版本一次做完)
新增 5 个文件:`components/codexRunOptionsPanel.vue`、`components/codexBoardPanel.vue`、
`components/codexSnapshotPanel.vue`、`utils/codexArtifactChange.ts`、`utils/codexTextDiff.ts`、`utils/codexSnapshot.ts`。
- **v2.1.1**:`sandbox` / `approvalPolicy` 从硬编码改为按会话 + 全局默认;危险取值二次确认;
顶栏出「只读 / 全权访问 / 免审批 / 每步确认」徽标。类型归位到 `types/codexTypes.ts`(api 只转出)。
- **v2.1.5**:`collectCodexArtifactChanges` 按 `*** Add/Update/Delete File:` 段落头提取路径,
**认不出就不猜**;产物栏重构为独立容器(`.codex-aside` 交回页面)+ 搜索 + 类型筛选 + 本轮变更。
- **v2.1.2**:看板只存内容哈希(`codexTextKey`),正文按哈希从当前会话找回;找不到就标失效;
折叠时 `v-if` 不渲染(避免图表重复渲染);最多 6 个。
- **v2.1.3**:快照只存文本与表格(不存图,localStorage 配额撑不住);diff 用**统一视图**而非并排
(并排要两列行号对齐,长报告一折行就错位);LCS 限长 1200 行。
- **v2.1.4**:模板与会话列表都能导出 / 导入;导入的会话标 `external`,打开时如实提示「本机没有执行记录」。
**验证**:`pnpm type:check` 89 条基线 / 本包 0 报错;`pnpm build` exit=0。
### 顺带发现的既有缺陷(不是本次引入)
`vite-plugin-theme-vite3` 生成的暗色主题文件里**自定义属性的 `--` 前缀被抹掉**
(`dist/assets/app-antd-dark-theme-style.*.css` 里搜 `--codex-bg` / `--ai-text` 均为 0 处,
只有 `codex-bg` / `ai-text`)→ 暗色模式下 `--ai-*`、`--codex-*`、`--jeesite-*` 都不会被覆盖。
对既有 ai 模块与新 codex 模块**同等影响**;修它要动构建期主题插件,风险收益不匹配,
本次只记录不动手(已写进计划文档 9.4)。
## 合并到 dev_3
- 在 worktree 提交:`ef86c64 feat(codex): 新增 Codex 数据分析模块(v1.0 → v2.1)`
(59 文件 / +11570 −391,无二进制、无 node_modules/dist/target)。
- `git -C E:/workspace/zsjz-ai merge --ff-only workbuddy/dev-3-6ce6fa87` → **快进成功**,
`dev_3`:`985e7ff → ef86c64`。
- 在**主仓库**(合并后的真实状态)复验:`pnpm type:check` 89 条基线 / codex 0 报错,
`pnpm build` exit=0,产出 `codexAnalysis-*.{js,css}`。
- **远端情况**:`origin` 存在(`http://81.68.143.144:9239/vectorSeek/zsjz-ai.git`),
但远端只有 `dev` / `dev_1` / `master`,**没有 dev_3**,`dev_3` 也没配上游 →
这条提交目前只在本地,要不要推送(`git push -u origin dev_3`)由用户决定。
- **注意**:主仓库工作区里有一个**别人暂存未提交**的
`ai-server/QingJian/uy76tkp8/data.db`(`AM` 状态,二进制 DB)以及未跟踪的
`ai-server/QingJian/conf/` —— 与本次改动无关,**没有碰**;若有人在该仓库直接
`git commit` 会把那个 db 一起提交进去,值得提醒。
- Hook 说明:`.git/hooks/post-commit`、`post-checkout` 是 **Qoder AI tracker**(带 `|| true`,
不会让提交失败);它会尝试起 `reg.exe` / `sc.exe`,被 WorkBuddy 沙箱黑名单拦掉,属正常现象。
## 推送 dev_3 到远端
- `git -C E:/workspace/zsjz-ai push -u origin dev_3` → `* [new branch] dev_3 -> dev_3`,
已建立上游跟踪 `dev_3...origin/dev_3`;远端 ref = `4bf2ad0`(与本地一致)。
- 该分支相对 `origin/master` **领先 78 个提交**(大多是既有开发历史,不只本次 codex 那两条)。
- 推送前用 `git fetch origin --prune` + `git ls-remote --heads origin dev_3` 确认远端原本没有该分支,
并设 `GIT_TERMINAL_PROMPT=0` 防止凭据提示把命令挂住 —— **这是个可复用的做法**。
- 主仓库那个暂存未提交的 `data.db` 用户明确说「不用管」,推送不受影响(push 只发提交,不发索引)。
- 本条笔记**刻意没有提交、没有推送**:用户授权的是「推送 dev_3」这一次对外动作,
为一条笔记再打提交会给他们共享分支添噪音。需要时再补。
## 工作目录改为代码决定 + 修「已拒绝访问」
**用户两条明确要求**:① 工作空间路径由代码设置,**不允许在界面上修改**;② 修掉
「该目录不是本客户端的会话工作目录,已拒绝访问」。
**报错根因**:`codexArtifactService.requireAllowedRoot` 只接受 `threadStart` / `turnRun`
登记过的目录,而产物栏在**页面进入时**(还没有会话)就拿 `activeCwd`(= 案件工作空间)
去查询 → 必然被拒。v2.0.5 让案件工作空间成为默认 cwd 后,这个时序问题从偶发变成必现。
**修法**
- 主进程:`requireAllowedRoot` 改为「不在白名单但**是绝对路径且真实存在的目录**」→ 补登记并放行
(`logger.warn` 记一条),否则报「工作目录不存在或不可访问」。
理由:工作目录现在由代码决定,可能的取值只有案件工作空间与客户端默认目录两种;
真正防越界的仍是 `resolveInside` 的路径穿越校验。白名单里「只有会话用过才登记」的时序假设不成立。
- 前端:**删掉整个工作目录修改入口** —— 删除 `codexWorkspacePicker.vue`、store 的
`cwdOverrides` / `recentCwds` / `setWorkspaceDir` / `rememberCwd` / `workspaceOverride` /
`hasWorkspaceOverride` / 无人读取的 `caseId`,cwd 优先级简化为
**会话 cwd → 案件工作空间 → 客户端默认**(纯代码决定);空态工作目录卡片改为只读展示 +
「目录由当前案件的工作空间自动决定」提示;composer 去掉「目录」按钮与 workspace emit。
- `selectDirectory()` 保留在 `codex/api/codexApi.ts`:`ai/views/aiPlugin/index.vue` 还在用(OS 能力,非 codex 专属)。
**验证**:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0;
主进程 `npx tsc --noEmit` 0 报错 + `npm test` 全过 + `npm run build-electron` exit=0。
(产物服务没有单测覆盖 requireAllowedRoot —— 它依赖 Electron 的 dialog/shell。)
## 移除「检索」与「快照」
用户要求下线这两个功能:删 `codexMessageList` 检索条、`codexMessageItem` 命中态、
markdown/代码块的关键词高亮透传、工具栏两个按钮,删除 4 个文件
(`codexHighlight.ts` / `codexSnapshot.ts` / `codexTextDiff.ts` / `codexSnapshotPanel.vue`)。
净删约 780 行;包 45 → 41 文件。
保留产物栏文件名筛选、模板库搜索、关系图节点搜索(各自的过滤功能,与会话检索无关)。
`listRef` 仍需要(PNG 长图导出要 `getCaptureElement`)。
提交 `ba68d45`;连同前一条 `0a39a60`(工作目录代码决定 + 修拒绝访问)与用户自己的
`6070d6f`,worktree 分支领先 dev_3/远端 3 个提交,**尚未合并推送**(用户未授权本次推送)。
## 再次合并到 dev_3(含移除检索/快照)
- `git -C E:/workspace/zsjz-ai merge --ff-only workbuddy/dev-3-6ce6fa87` 快进成功,
`dev_3`:`4bf2ad0 → 396cba5`(含用户自己的 `6070d6f`、工作目录修复 `0a39a60`、移除检索/快照 `396cba5`)。
- 在主仓库复验:`pnpm type:check` 89 条基线 / codex 0 报错;`pnpm build` exit=0,
产出 `codexAnalysis-*.{js,css}`。
- **未推送**:用户本轮只说「合并」,没说推送;`origin/dev_3` 停在 `4bf2ad0`,落后 4 个提交。
上游跟踪已建好(`dev_3...origin/dev_3`),要推就是一句 `git push`。
## 聊天页不再展示工作空间路径
用户要求「聊天页面上所有地方都不展示工作空间路径」。清理了 5 处:
- 工具栏:整块「工作目录」状态(路径 + 复制按钮)删除,含 `cwd` 计算属性、`onCopyCwd`、`__path/__icon` 样式;
- 空态:整个工作目录卡片(来源徽标 + 路径 + 提示)删除,连同 `cwdSource/cwdSourceLabel/caseName`;
- 产物栏:面包屑的 `:title="root"` 悬浮提示去掉(悬停会露出绝对路径);
- 侧栏:建会话成功提示 `已创建会话(路径)` → `已创建会话`;
- 输入区:提示语去掉「未设置工作目录」分支。
保留(非展示):store 的 `activeCwd` 仍作为 threadStart/报告导出元数据使用;
composer 的 `cwd` prop 仅用于「切会话清空草稿」的 watch,不渲染。
顺带删了无人使用的 `activeCwdSource` getter。
验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
## 删除会话改为弹框确认
原来侧栏的「移除」是行内二次确认(先点移除 → 出现 删除/取消 两个小按钮),改为 `Modal.confirm` 弹框:
标题「删除会话」、正文带会话名、`okText: '删除'` + `okType: 'danger'`(与 `ai/components/SessionList.vue` 的既有风格一致)。
弹框文案明确「只从本机列表移除,Codex 侧已落盘的历史不受影响」,避免用户误以为会删服务器数据。
顺带删掉行内确认的 `pendingDeleteId` 状态与 `__confirm` 样式。
验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
## 移除会话导入导出 + 新建会话入口调整
用户三条要求:① 移除会话导入导出功能;② 放大新建会话按钮;③ 移除顶栏右上角的「新建会话」。
- **导入导出**:侧栏「导出 / 导入」按钮 + 隐藏 file input + `onExportSessions/onImportClick/onFileChange`
全删;store 的 `exportSessionsJson / importSessionsJson` 一并删除(已无调用方)。
`CodexSessionMeta.external` 标记与侧栏「外部」徽标保留(兼容已导入过的存量数据)。
- **新建会话**:侧栏 head 里的「新建」小按钮移出,改成 head 下方**整行主按钮**
(`__create`:34px 高、主色虚线边框、hover 变实心主色、创建中禁用并显示「创建中…」)。
- **顶栏**:右上角「新建会话」按钮删除,emit `'create'` 一并移除;视图里工具栏的 `@create` 解绑。
侧栏按钮成为唯一入口。
验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
## 会话防重:不允许堆空白会话
用户反馈「会话可以一直建,没有聊天记录也能一直建」。优化规则(store 新增 `startNewSession`):
1. 当前会话是空的(`isSessionEmpty` = 无提问无回复)→ 不再新建,提示「已有空白会话,直接输入问题即可」(info);
2. 列表里已有空会话 → 直接切过去,不新建;
3. 都没有才真正 `createSession`。
——保证任意时刻**至多存在一个空会话**。侧栏「新建会话」改走 `startNewSession`;
`send()` 的 `ensureSession` 仍直接 `createSession`(首条消息的懒建是合法路径,不受护栏影响)。
**启动清账**:`bootstrap` 里新增 `pruneEmptySessions()` —— 历史版本允许无限建,
存量的一堆空会话在下次打开页面时自动清理(只保留最新一个,其余从列表移除,
不碰 Codex 侧落盘历史,与 removeSession 同一原则)。
验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
## 产物栏双视图:本会话产物(默认)/ 全部文件
用户反馈:右侧展示的是当前工作空间,没有展示**当前会话**的产物——所有会话共用同一个
案件工作空间,看到的文件列表一模一样。
方案(保留 cwd = 案件工作空间不变,不动 Codex 的取数流程):
- **主进程**:`codexArtifactService.listAllArtifacts(root)` 递归列举工作空间全部文件
(深度 ≤6、文件 ≤3000,异常子目录跳过),`codexArtifactCtl.listAll` 暴露。
- **判定口径**:`mtime ≥ session.createdAt`。会话创建后 Codex 产出或改过的文件都会命中;
别的会话更早生成的不会混进来。并行会话改同一个文件会同时出现在两边,属可接受误差。
- **前端**:store 增 `sessionFiles` + `loadSessionFiles()`(reloadArtifacts 与回合结束时刷新);
产物栏加「本会话产物(默认)/ 全部文件」切换条,关键词与类型筛选两个视图共用;
「全部文件」保留原目录浏览 + 上一级 + 新角标;「本轮变更」两个视图都在。
- 面板默认视图 = 本会话产物;空态文案引导「想看工作空间里的全部文件请切到全部文件」。
验证:前端 type:check 89 基线 / 本包 0 报错 + build exit=0;
主进程 tsc --noEmit 0 报错 + 91 单测全过 + build-electron exit=0(listAll 已进 bundle)。
## 本会话产物升级为混合口径(C+A)
mtime 过滤的盲区:**后来的会话产出的新文件会混进更早会话的列表**(时间戳只回答「什么时候改的」,
回答不了「谁改的」)。升级为 C+A 混合:
- **C(精确)**:`CodexSessionMeta.artifactManifest` —— 每回合结束时
`accumulateArtifactManifest(threadId)` 把该会话**历次**事件流里 `*** Add/Update/Delete File:`
段落头提取的路径累计进清单(Set 语义、去重、随会话持久化)。以事件流为准而非只看最后一轮:
文件一旦被改过就一直归属该会话。
- **A(兜底)**:`loadSessionFiles` 取**并集** —— `manifest.has(relative) || mtime >= createdAt`。
清单覆盖不到的场景(升级前的历史会话 / exec 直接写文件 / patch 解析失败)由 mtime 兜住。
方案讨论时对比过 B(每会话产物目录:协议没有输出目录概念,靠 prompt 约束不可靠)、
D(主进程 watcher:只覆盖活会话,要养常驻进程)、E(按回合组织:交互重做)——都否了。
提交 `(本次)`;验证 type:check 89 基线 / 本包 0 报错 + build exit=0。
## Codex 支持本地 Ollama / chat-only 端点(P1-P4 全部落地)
**前提修正(调研取证)**:Codex 0.155+ 已物理移除 wire_api="chat"(WireApi 只剩 Responses,
配 chat 硬报错,官方 Discussion #7782);Ollama ≥ 0.13.3 原生支持非状态化 /v1/responses,
而 Codex core 每轮重建完整 input(无状态调用),两者天然兼容。我们应用永远不需要给 Codex
做消息转换;chat-only 端点的转换只能在 Codex 与端点之间的代理层。
**四期落地**:
- P1 `cf328ab`:probeEndpoint 四态(responses/chat-only/unsupported/unreachable),
真实 modelId 修 Ollama 404 误伤(Ollama 对未拉取模型也返回 404!),/api/version 消歧。
- P2 `b029cbb`:chatBridgeTranslate.ts 纯函数翻译层 + ResponsesSseTranslator 流式状态机。
- P3 `b9075dd`:chatBridgeService(127.0.0.1 随机端口、只接 POST /v1/responses、不持 key);
applyProvider 判 chat-only 自动桥接 + **Ollama 桥接必须降级为自定义 provider**(内置
provider 的 buildArgs 只允许覆盖 base_url,不降级会绕过桥接直连);AppliedProviderInfo
加 bridged(落盘存上游真实地址,桥接 URL 不落盘,重启靠重新「应用」重建)。
- P4 `0d765ae`:运行时面板桥接行 + 已应用「桥接」标记。
**关键经验**:
- 冒烟(npm run smoke:codex)**真实验证了 Codex 无状态调用**:第二轮 turn 上游收到的
messages 含第一轮全部内容(user+assistant 回放)——这是 Ollama 非状态化兼容的根基。
- 冒烟有两个**基线既有失败**(git stash 对照证实,与本改动无关):`status.version` 在沙箱里
拿到 object(spawnSync 怪癖)、codexCtl.itest.ts 的 electron 二进制未装好。
- SSE 事件序列按 OpenAI Responses 规范合成(created→item.added→part.added→delta→done
系列→completed),真实 Codex 一次跑通,说明规范实现即可被接受。
- provider.json 的 AppliedProviderInfo.bridged 只在桥接时写入;重启后 bridge 不自动重建,
页面重新「应用」时自然恢复(与 apiKey 注入同一流程)。
验证:tsc 0 报错、114 单测全过、smoke 桥接 turn 通、build-electron exit=0(chat-bridge 已进包)、
前端 type:check 89 基线 0 新增 + build exit=0。
## 聊天页模型选择器:数据源换后端模型管理 + 会话中锁定
需求:选择器用「模型管理」里配置的模型;会话过程中不能切换模型。已拍板:选中即应用、有消息即锁。
- store:`models` 由 codexListModels(内置目录)改为 `modelApi.listModels()`(过滤 llm)+
`listProviders()`(查 providerType);新 action `applySessionModel(recordId)` —— 组装
ApplyProviderInput 调 codexApplyProvider(重启运行时),成功后回写 `this.runtime = result.status`
+ `setSessionModel(activeThreadId, record.modelId)`;失败抛错由页面提示。
- 选择器 value 用**后端记录 id**(防同 modelId 不同端点重名);会话 model 仍存 modelId
(turnRun/导出直接用,语义不变)。页面弃用 v-model 改 `:model-value + @update`——
应用是 async 可失败,显示值由 selectedRecordId 反查驱动,失败天然回退。
- composer:props.models 改轻量 `{value,label}[]`(组件不感知 AiModel);新增
`modelLocked`(有消息即锁,title 提示)/`modelLoading`;删「跟随 Codex 默认模型」空值项。
- 清理:codexApi.codexListModels / CodexModelOption 已删(前端零引用);
主进程 codexCtl.listModels handler 保留未动。
- 边界:未登录后端 → models=[] → 「未配置可用模型」(同现状静默降级)。
验证:pnpm type:check 89 基线 / codex 包 0 报错;pnpm build exit=0。主进程未动。
## Codex「协议流已关闭」:内置 provider 覆盖是保留字,兜底整体移除
现象:聊天页选模型(Ollama 记录)后 codex 起不来,报 `Codex 协议流已关闭`,之后点多少次启动都是同一句。
根因(用 vendor 里的 codex.exe 0.155.1 实跑复现):`buildArgs` 对内置 provider 发
`-c model_providers.ollama.base_url=…`,而 0.155.1 把内置 id 列为保留字——
`model_providers contains reserved built-in provider IDs: ollama` → **app-server exit 1**,
stdout 随之关闭,`jsonRpcPeer` 把挂着的 `initialize` reject 成那句没信息量的话。
(同法验证:`model=""` 报 provider name must not be empty;不带地址的 `model_provider="ollama"` 正常。)
- 二次伤害:`CodexRuntime.applyProvider` 先写 `#provider` 再 start,失败不回滚 →
坏 spec 常驻内存,之后每次 start 重放同一组参数,日志里连着一排 `start failed`,
只有重启应用或「清除模型」才能解锁。
- 按拍板**删掉内置 provider 这条路**(不做任何 localhost 兜底):
`resolveBuiltinProvider` / `normalizeOssBaseUrl`(含自动补 `/v1`)/ `toProviderSpec` 内置分支 /
`buildArgs` 内置分支 / `CodexProviderSpec.builtinProvider` / `ApplyProviderInput.builtinProvider` /
`AppliedProviderInfo.builtinProvider` / codexCtl 里两处「Ollama 未配地址就探 localhost」全删;
一律翻译成自定义 provider `zsjz`,`id`/`baseUrl` 收紧为必填。
`probeEndpoint` 不再收 builtinProvider,Ollama 消歧只看 `providerType`。
- 新增校验:缺 base_url → 「模型「x」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)」;
聊天页未选模型点发送 → 拦下发并提示(有候选提示去选,无候选提示去模型管理加)。
理由:未应用 provider 时 Codex 打它自己的默认端点,失败信息完全不可读。
- 可诊断性:`createExitWatcher` 统一 stdout 关闭 / exit / spawn error 三个出口,等 close(≤300ms)
收完 stderr 再拼 `Codex App Server 退出(1):`,`#start` 的 catch 用它改写
`error.message`(保留错误对象与 stack);`pushDiagnostic` 同步 `logger.warn` 落 ee.log
(原来只进内存环形缓冲,进程一没就查不到)。
- **行为变化**:库里把 Ollama 存成 `http://host:11434`(缺 `/v1`)的记录不再被自动补齐,
探测会判 unsupported,需去模型管理补版本段(`baseUrlHint` 本来就一直在提示)。
验证:tsc 0 报错;vitest 111 passed(内置 provider 相关 6 条用例随功能删除,新增保留 id 断言);
前端 vue-tsc 在 src/codex 与 src/ai/views/aiPlugin 上 0 报错(93 条存量在别处);
另用真实 codex.exe + 临时 codexHome 跑过三条端到端(错误信息含 exit 1 与原文、
applyProvider 失败后 provider 回滚且下次 start 到 ready、Ollama 记录生成的参数不含保留 id 且进程起得来),
临时用例已删。未跑 build / itest。
## 端点探测收敛为 Chat 单协议 + 模型默认选中与延迟应用
拍板:**后端「模型管理」里的记录一律是 `/chat/completions` 端点**,不会有 `/responses`;
Responses 只属于 Codex 自己的模型。据此把四态判定砍成 `chat | unsupported | unreachable`。
实测数据(`10.66.66.66:8080`,llama.cpp 挂 35B GGUF,推翻了我先前两次判断):
| 请求 | 结果 |
| --- | --- |
| `GET /v1/models`、`/health` | 14–29ms |
| `POST /v1/responses` 非流式 1 token | 11.0 秒 |
| 同上 16 token(原探测参数) | >30 秒 |
| 同上 `stream:true` | 20 秒不吐首字节 |
| `POST /v1/chat/completions` `max_tokens:1` | 13.99 秒 |
| `POST /v1/chat/completions` 只带 `{model}` | **400,15–27ms** |
| `GET /v1/models`(被一次进行中的生成堵住时) | 21 秒无响应 |
结论:**单槽推理服务会把整个 HTTP 服务占住**,所以探测既不能要求生成完成、也不能用短超时,
`GET /models` 探活同样会被堵(先加的 2 秒探活因此撤掉)。
- `probeEndpoint`:只 `POST {base}/chat/completions`,body 故意只带 `model`、不带 `messages`
→ 端点在校验阶段回 400,实测 15–27ms;非 404/405 即判 `chat`。超时留 15 秒作排队余量。
代价说清楚:**模型名写错不再被探测拦住**,要等真正提问时才暴露。
报错文案带具体 URL 与耗时(`端点不可达:http://… 在 15.0 秒内没有响应…`)。
- 删除:`/responses` 探测、`/models` 探活、Ollama 版本消歧整条链
(`probeOllamaVersion`/`parseVersion`/`versionLt`/最低版本常量)、
`responsesStatus`/`ollamaVersion`/`ollamaNeedsUpgrade` 字段、恒真的「桥接」chip 与其 CSS、
`providerType` 全链路(含 store 里为它而加的 `providers` 与 `listProviders()` 请求)。
- `applyProvider` 成功即桥接,去掉 `bridged` 分支与「直通时 stop 桥接」的分支。
- 模型选择:**默认选中后端 `defaultModel` 那条,但只在首次发送时应用**
(应用=重启子进程+探测,进页面就做会白等)。store 新增 `sessionModelRecordId`
(会话记过就反查、新会话退默认模型、记录被删返回空)、`modelApplied`、`modelGuardMessage`;
`send()` 开头 `ensureModelApplied()`;选择器改动只写进会话并提示「下次提问时生效」;
`modelLocked` 加「记录被删则解锁」,否则既发不出也换不了;未选模型点发送给提示不发。
验证:tsc 0 报错;vitest 109 passed(新断言:探测请求体不得带 `messages`、不可达提示必须含 URL);
vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;用应用自身 `probeEndpoint` 打真实端点 27ms 判 `chat`。
itest / build / 客户端 UI 未跑(渲染进程要 Electron 的 codex IPC 桥,浏览器里进不去)。
## `turn/start: thread not found` 与「正在处理中」不退
日志时间线(pid 18984):`21:11:20` codex 进程 #1 起来(`createSession → thread/start` 用的它)
→ `21:11:26` `chat-bridge 已启动`(= 我在 `send()` 里的 `ensureModelApplied` 重启了子进程)
→ `21:11:27` `turnRun failed: thread not found`。
**根因是自己引入的顺序错误**:`send()` 原先是「建线程 → 应用模型 → turnRun」,
而应用模型会 stop+start app-server,刚建的线程当场作废。线程只存在于当前进程;
Codex 只在**成功跑完一轮**后才落盘(`state_5.sqlite` 的 `threads` 表),所以从没成功过的会话
连 `thread/resume` 都报 `no rollout found`。另外 `resumeThread` 此前全仓零调用。
「正在处理中」不退是第二处:`resolveTurnStatus` 只认 `turn/completed`,而 `send()` 失败时
自己造的 `local-error:` 分组没有该事件 → 错误气泡本身被判 running;
`stop()` 又专挑 running 的助手消息去 interrupt → 连打 5 次 `thread not found`。
修复(A+B+C+D1,已拍板):
- **A** `send()`:`ensureModelApplied()` 提前到 `ensureSession()` 之前,并加「当前会话在跑就返回」守卫。
- **B** `resolveTurnStatus`:无 `turn/completed` 但有 error 事件 ⇒ `error`;
新增 store action `noteTurnFailure`,把失败挂回卡在 running 的那一轮 turnId(找不到才自造分组),
`send()`/`stop()` 共用;`streaming` getter 读同一状态,因此能退出转圈。
- **C** `stop()`:interrupt 失败也先本地收尾再抛给页面提示。
- **D1** `codexCtl.turnRun`:`thread not found` → `resumeThread` → 重发本轮;
resume 也失败 → 「该会话的 Codex 线程已失效(中途换过模型或重启过应用),请新建会话继续」。
不自动换线程:事件缓存、产物归属、重新生成都按 threadId 索引,会话↔线程 1:1 是现有前提。
实测(真 codex.exe + 桥接 + mock 上游):跑完一轮 → 重启 → `thread not found` 原文复现 →
`resumeThread` 成功且第二轮完成、历史带回。顺带验掉一个疑点:`thread/resume` 的
`excludeTurns` true/false **对上游收到的历史没有差别**(都是 8 条、含前两轮 user/assistant),
所以没加这个开关,只在 `resumeThread` 里留一行注释记录结论。
验证:tsc 0 报错;vitest 108 passed;vue-tsc 93 存量、src/codex 与 aiPlugin 命中 0
(中途一次「0 报错」是我拆掉前端 junction 造成的假数,已重跑取实数)。
未跑 itest(会写 data/codex-home 与 provider.json)、build、真实界面点击。
## 「request timed out」排查:四个假设全被实测否掉 + 桥接补请求级诊断
现场:codex stderr 每 ~15.0 秒一条 `stream disconnected - retrying sampling request (n/5)`、
`sampling_error: "request timed out"`,5 次之后 `falling back to HTTP`;那一轮 21:34 完成、
`ee.log` 里没有 `turnRun failed` —— 不是连不上,是被重试拖死。
逐个否掉(全部真 codex.exe + 真桥接 + 受控 mock 上游):
| 假设 | 实测 |
| --- | --- |
| 桥接有超时 | `chatBridgeService`/`chatBridgeTranslate` 无任何 timeout 常量 |
| `stream_idle_timeout_ms` 默认 15s | 首事件前静默 26s、首事件后静默 30s,默认与 `=120000` 均 1 次请求、零重试 |
| Codex 走 WebSocket、桥接无 `upgrade` 监听导致挂起 | 旁路嗅探 TCP 首包:零 upgrade,只有 `POST /v1/responses` |
| 单槽排队堵死 | 复刻 100 秒排队:1 请求、同时在飞 1、100.5s 成功、零重试 |
端点侧事实(`GET /props`):`total_slots: 1`、`n_ctx: 262144`、build b1005;
流式 600 token 实测首字节 98.8 秒(生成只 10.5 秒、prefill 268ms)→ 那 98.8 秒是排队。
结论:**15 秒只能来自那一轮真实请求**,而桥接此前只记过三行「已启动」,无法判断谁先放手。
本轮只加日志(不改行为):`chat-bridge: #N 起 round model/stream/tools/messages/输入字数 → url`、
`转发完成(耗时,上游字节→事件数)`、`Codex 提前断开(耗时,已发 N 个事件)`、
`上游 HTTP xxx(耗时)`。下次真实提问即可定死:出现「Codex 提前断开 @15s」= Codex 侧阈值;
出现「上游 HTTP/不可达」= 端点侧。
顺带记下与 cc-switch「路由」的对照:那类开关做的是「声明上游接口格式 + 本地代理转协议」,
我们的对应物就是内置桥接(应用模型时自动开,Codex 侧 `wire_api="responses"`)。
差距在可配面:0.155.1 的 provider 认 `request_max_retries / stream_max_retries /
stream_idle_timeout_ms / websocket_connect_timeout_ms / supports_websockets / query_params /
http_headers …`,我们白名单只放行 4 个,所以「关 websocket」这类操作现在配不出来 ——
等 15 秒归属确认后再决定要不要放开,别提前改。
另记 F1(未做):Codex 挂断后桥接不取消上游生成,会一路读到 `[DONE]`,
被放弃的请求继续占唯一的槽 —— 这是重试互相排队的放大器,加 AbortController 绑 `res` 关闭即可。
验证:tsc 0 报错;vitest 109 passed;诊断行样例由受控 mock 实跑取得。未跑 build / itest。
## 超时根因收口:先建流 + 不重发 + 挂断即取消上游(对照 cc-switch)
被实测否掉的四个假设(都是真 codex.exe + 真桥接 + 受控 mock 跑出来的):桥接层无任何超时常量;
「首事件前静默 26s」「首事件后静默 30s」在默认与 `stream_idle_timeout_ms=120s` 下都是
1 请求零重试;Codex 对本地 provider 零 WebSocket upgrade;复刻 100s 单槽排队也正常完成。
`/slots` 才给出真相:一轮 prompt **46,122 token**,20 秒只 prefill 了 589 token(≈29 tok/s),
`is_processing` 恒真 —— 一次要二十几分钟;而 Codex 每 15 秒掐一次并重发 5 次,
等于把同样的几分钟重复五遍,还会挤掉前缀缓存(同一请求三次实测 53.0s / 3.9s / 23.1s,证明缓存确实生效)。
读 cc-switch(`proxy/forwarder.rs`、`proxy/providers/codex.rs`、`codex_responses_sse.rs`)得到的经验:
它把超时拆成 `non_streaming_timeout` + `streaming_first_byte_timeout`,而 `_streaming_idle_timeout`
显式不用(流建起来就不靠 idle 杀);`max_retries` 是跨厂商 failover 而非对同一上游重发;
连接生命周期靠 Drop 释放;路由判定是 `meta.api_format` 优先、URL 特征兜底。
据此三处改动(**每条都先写测试跑红再实现**):
1. 流式请求一到就先回 `response.created`/`response.in_progress`(新增 `ResponsesSseTranslator.begin()`,
复用其 `ensureStarted()` 幂等保护),不再等上游首字节 —— 测试首帧 6017ms → 立即。
2. Codex 挂断即 `AbortController` 取消上游,单槽立刻归还(测试:上游 <1s 观察到中止)。
3. provider 默认 `stream_max_retries=0`、`request_max_retries=0`、`stream_idle_timeout_ms=300s`、
`supports_websockets=false`(白名单补 `supports_websockets`;模型管理 config 显式值可覆盖)。
真机 smoke `chatBridge.live.itest.ts` 保留(`npm test` 不跑,`npm run smoke:codex` 跑;端点不可达自动跳过)。
注意 `describe.skipIf` 在收集阶段求值,可达性探测必须放模块顶层 await,放 beforeAll 会永远跳过 —— 我自己踩过。
连跑两次真机:71.2s → **38.8s**,均 1 请求、0 重试、回复正确。
下一步(未做,先记):学 cc-switch 在模型管理加显式 `api_format` 字段,
「一律按 Chat」迟早要能被「这条记录就是原生 Responses」覆盖,而不是靠探测猜。
验证:vitest 113 passed;tsc 0 报错;vue-tsc 93 存量、codex/aiPlugin 0;真机 smoke 连过两次。未跑 build。