feat(context): §8.8 Phase 2 长会话中段折叠摘要 + 头部压缩指示环(bump 0.54.1)

- 折叠(0.54.0):run 起点窗口体量达 reliable_context×85% → core/context_fold.py
  把中段折叠为固定模板结构化摘要(路径/ID/数值原文保留),写 tasks.context_summary
  (migration 0021)+ 推进 context_base_idx;Session.load 注入仅内存「前情摘要」
  (_n_head/_base_idx 维护 idx 映射);摘要调用复用会话 prepare 后前缀 + 末尾追加
  指令,与上一轮 chat 缓存字节一致近全程 hit;失败零阻塞(warn+跳过);「新话题」
  硬重置 / web 清空对话顺带清摘要,gap 软重置保留
- 前端(0.54.0):新 SSE context_fold 事件(start 状态行提示 / done 流内分隔条);
  补 warn 事件渲染 —— 此前 loop 熔断/重复拦截提示在 web 端被静默丢弃
- 压缩指示环(0.54.1):头部金额撤下明面(挪 hover tooltip),换 SVG donut
  (绿<50% 未压缩 / 琥珀 50-85% 压缩区 / 红≥85% 达折叠阈值)+ 已整理×N;
  GET /v1/tasks/{id} 详情新增 context_window_chars/limit_chars/pressure/folds
  (additive,列表端点不加);折叠计费 kind="context_fold"(record_chat_usage
  加 kind 参数,折叠次数 = count 该 kind,零 migration);llm_start 事件补
  context_limit_chars,run 中实时刷环
- DESIGN:§8.11 新增「最小子循环 delegate」预留(上下文隔离而非多 agent 编排,
  按 diag 数据触发,无信号不实施);§1/§6 "不做 subagent" 收窄为编排型;
  §8.8 Phase 2 标  沉淀六条取舍;§7.2 SSE 清单补 warn/context_fold
- tests/test_context_fold.py 12 项(切点/映射/apply_fold/maybe_fold 打桩),
  全量 198 过;本地 DB 已升 0021

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-09 12:39:31 +08:00
parent f1c7f70e3c
commit 66a515aa3d
15 changed files with 721 additions and 37 deletions

View File

@ -8,7 +8,7 @@
## 1. 边界 ## 1. 边界
**做**:PPT / 申报书 / 编码(读写文件 + shell + 迭代验证)。 **做**:PPT / 申报书 / 编码(读写文件 + shell + 迭代验证)。
**不做**:子 agent / 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。 **不做**:子 agent(编排型;上下文隔离的最小子循环另见 §8.11)/ 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。
**关键约束**:模型自由(LiteLLM,默认 DeepSeek V4);任务持久化(任意时刻关机可恢复);演化性(模型升级不大改架构);**形态兼容**——本地与 SaaS 共享同一份 core / PG / web `/v1` API,无 CLI REPL 分叉(§7.9)。 **关键约束**:模型自由(LiteLLM,默认 DeepSeek V4);任务持久化(任意时刻关机可恢复);演化性(模型升级不大改架构);**形态兼容**——本地与 SaaS 共享同一份 core / PG / web `/v1` API,无 CLI REPL 分叉(§7.9)。
@ -113,7 +113,7 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
| long context 退化 | probe 探测可靠 ceiling | | long context 退化 | probe 探测可靠 ceiling |
| 本地 PG 离线 | docker compose 起本地 PG / 连远端 | | 本地 PG 离线 | docker compose 起本地 PG / 连远端 |
**Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent**:状态管理爆炸,单 agent + skill 覆盖 95%。**不做 Eval Suite**:单用户 dogfooding 信号更强。 **Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent(编排型)**:状态管理爆炸,单 agent + skill 覆盖 95%;上下文隔离的最小子循环是另一个问题,见 §8.11(有触发条件,无信号不实施)。**不做 Eval Suite**:单用户 dogfooding 信号更强。
--- ---
@ -150,7 +150,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/
Export GET /v1/tasks/{id}/export(docx) Export GET /v1/tasks/{id}/export(docx)
``` ```
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。 **SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。 **版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
### 7.3 认证 ### 7.3 认证
@ -295,14 +295,14 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- **渠道 B 企业微信**:出站推送先行("和邮箱似的"),公测需求明确后补入站——**与 ClawBot 本质不同**:回调 webhook 而非长轮询(无后台 task,只加 HTTP 端点 + `wecom_crypto.py` 验签解密);agent >5s 超被动窗口 → 回复走 message/send 主动推回。绑定两路:手填 userid(推送是出站直连**不需要域名**)+ OAuth 扫码(需 HTTPS 可信域名,用 wwlogin 扫码端点非网页授权端点)。 - **渠道 B 企业微信**:出站推送先行("和邮箱似的"),公测需求明确后补入站——**与 ClawBot 本质不同**:回调 webhook 而非长轮询(无后台 task,只加 HTTP 端点 + `wecom_crypto.py` 验签解密);agent >5s 超被动窗口 → 回复走 message/send 主动推回。绑定两路:手填 userid(推送是出站直连**不需要域名**)+ OAuth 扫码(需 HTTPS 可信域名,用 wwlogin 扫码端点非网页授权端点)。
- **不选**:wechaty(违规);富排版卡片(个微能力存疑,统一纯文本+文件直推);bot_token 不落库(它是长期凭据必须持久化,安全靠加密列)。 - **不选**:wechaty(违规);富排版卡片(个微能力存疑,统一纯文本+文件直推);bot_token 不落库(它是长期凭据必须持久化,安全靠加密列)。
### 8.8 channel 长会话上下文治理(Phase 1 ✅ / 2-3 design) ### 8.8 channel 长会话上下文治理(Phase 1-2 ✅ / 3 design)
**根因**:IM 常驻 task 只增不减(web 任务"做完即止"有天然边界,IM 没有),全量历史越用越贵终撞 context window;§8.2 压缩只摘 tool 正文挡不住跨时段累积。业界(OpenClaw/Hermes/Claude Code)都是"阈值摘要+头尾保护",但都是单次 session,不解"IM 用三个月"——IM 独有的**会话分段**最高杠杆且零信息损失,自补。 **根因**:IM 常驻 task 只增不减(web 任务"做完即止"有天然边界,IM 没有),全量历史越用越贵终撞 context window;§8.2 压缩只摘 tool 正文挡不住跨时段累积。业界(OpenClaw/Hermes/Claude Code)都是"阈值摘要+头尾保护",但都是单次 session,不解"IM 用三个月"——IM 独有的**会话分段**最高杠杆且零信息损失,自补。
**心智:边界而非删除**——一条消息都不删,只移动喂给模型的窗口起点;全历史留 DB,web 照旧翻完整记录。 **心智:边界而非删除**——一条消息都不删,只移动喂给模型的窗口起点;全历史留 DB,web 照旧翻完整记录。
- **Phase 1(✅):`context_base_idx` 软重置**。`Session.load` 只装 `idx>=base`;自动 gap(默 6h,base=最后一条 user 消息——**不是失忆墙**,留上一轮做续聊锚点)+ 手动「新话题」硬重置(base=总数)。**关键不变量**:append 续号取 DB 真实总条数而非加载条数,否则撞 unique 约束。**不选**"每次 gap 开新 task"(堆文件夹+task 卡片)、"boundary 标记消息"(混进消息流要处理 tool 配对);列是纯元数据零侵入。 - **Phase 1(✅):`context_base_idx` 软重置**。`Session.load` 只装 `idx>=base`;自动 gap(默 6h,base=最后一条 user 消息——**不是失忆墙**,留上一轮做续聊锚点)+ 手动「新话题」硬重置(base=总数)。**关键不变量**:append 续号取 DB 真实总条数而非加载条数,否则撞 unique 约束。**不选**"每次 gap 开新 task"(堆文件夹+task 卡片)、"boundary 标记消息"(混进消息流要处理 tool 配对);列是纯元数据零侵入。
- **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交 - **Phase 2(✅ 2026-07-09):阈值结构化摘要**(补 Hermes 阶段③,`core/context_fold.py`):run 起点窗口体量达 `reliable_context×85%` → 中段折叠成固定模板摘要(目标/约束决定/进展/待办 + path/ID/数值**原文保留**,mem0 实测自由摘要会静默丢精确值),存 `tasks.context_summary`(0021)+ 推进 `context_base_idx`,`Session.load` 注入仅内存的「前情摘要」user 消息。双层门槛 50%(压缩)+85%(折叠)正交:分段砍跨话题累积、摘要兜单段超长。关键取舍:① **run 起点触发而非轮间**(轮间要处理 tool 配对切割 + 已加载窗口一致性,回合制下 run 起点是自然缝隙,代价是命中那一回合首 token 慢几秒、每分段一两次);② **摘要存 tasks 列不入消息流**(boundary 消息会混进 tool 配对处理,Phase 1 已拒过;列是纯元数据零侵入);③ **前缀缓存友好**:摘要调用复用会话 prepare 后的消息前缀 + 末尾追加指令 → 与上一轮 chat 缓存字节一致近全程 hit;增量更新 = 旧摘要本在被折前缀里,指令要求合并,不重读全史;折叠后窗口字节稳定至下次折叠,且体量回落 50% 门槛以下、压缩关闭,前缀比折叠前更稳;④ **失败零阻塞**(warn + 跳过,85% 距硬上限有垫);⑤ 切点必落 user 消息(不劈 tool 配对,窗口恒以 user 开头,与 Phase 1 锚点同语义);⑥ 「新话题」硬重置/清空对话清摘要,gap 软重置保留;对全部 task 生效(机制通用,web 短任务不达阈值零影响)
- **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。 - **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。
### 8.9 产物机检门 + 提示层禁令纪律(✅ 2026-07-06) ### 8.9 产物机检门 + 提示层禁令纪律(✅ 2026-07-06)
@ -313,6 +313,20 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
**推翻 0.36.0 的渲图验收闭环**(svg_preview 渲 PNG → look_at_image 逐页过目 → accept_pages 标 pass → 导出 gate 校验 sha1)。三条根因:①**成本**——逐页 vision 26-42s/页、每 deck 8-25 次调用,烧 token 大头;②**环境脆弱**——沙箱 chromium "找到了但渲染崩"没有回退路径,硬门+死路逼模型即兴发挥(pip install cairosvg、手写渲染循环,正是 8.9 要防的行为);③**验收环节的"看"本身无法机检**——gate 只能强制"渲过",看没看/看得准不准全凭模型自觉,0.36.0 就写明了这个边界。**替代**:质检器 check 13/14 的精确几何检测(越界/压字/错位/网格漂移/图表退化)已覆盖"正确性"面,且在导出边界自动复跑;**主动放弃美学验收**(配色观感/页面空挤,单调门除外)——底线正确性机检可保,观感交给用户反馈迭代。svg_preview 保留为手动工具(chromium 失败自动回退 cairosvg)但 **SKILL 提示面零提及**——不给渲染入口模型就不会主动渲(同 8.9"禁令不带配方"逻辑,正面只写"导出唯一入口 svg_to_pptx");accept_pages/acceptance.json/--allow-unreviewed 整套删除。**回退信号**:若"几何全对但观感翻车"成为用户改稿主因,再考虑便宜的整本拼图单次 vision 抽检,而非逐页。 **推翻 0.36.0 的渲图验收闭环**(svg_preview 渲 PNG → look_at_image 逐页过目 → accept_pages 标 pass → 导出 gate 校验 sha1)。三条根因:①**成本**——逐页 vision 26-42s/页、每 deck 8-25 次调用,烧 token 大头;②**环境脆弱**——沙箱 chromium "找到了但渲染崩"没有回退路径,硬门+死路逼模型即兴发挥(pip install cairosvg、手写渲染循环,正是 8.9 要防的行为);③**验收环节的"看"本身无法机检**——gate 只能强制"渲过",看没看/看得准不准全凭模型自觉,0.36.0 就写明了这个边界。**替代**:质检器 check 13/14 的精确几何检测(越界/压字/错位/网格漂移/图表退化)已覆盖"正确性"面,且在导出边界自动复跑;**主动放弃美学验收**(配色观感/页面空挤,单调门除外)——底线正确性机检可保,观感交给用户反馈迭代。svg_preview 保留为手动工具(chromium 失败自动回退 cairosvg)但 **SKILL 提示面零提及**——不给渲染入口模型就不会主动渲(同 8.9"禁令不带配方"逻辑,正面只写"导出唯一入口 svg_to_pptx");accept_pages/acceptance.json/--allow-unreviewed 整套删除。**回退信号**:若"几何全对但观感翻车"成为用户改稿主因,再考虑便宜的整本拼图单次 vision 抽检,而非逐页。
### 8.11 最小子循环 delegate:上下文隔离而非多 agent 编排(design,2026-07-08,按诊断数据触发)
**根因**:检索/扫文类工作(文献 brief、document_search、批量读文件)的形态是"中间数据量大、最终只要结论"——在主循环里跑,中间数据必然流经主上下文,污染 + 膨胀是**架构性的**。已踩实例:38 篇 abstract 反复 dump 烧 2.5M token、`document_search` 同参调 122 次不收敛。现有缓解(`_RepeatGuard` 熔断、§8.2 context 压缩、brief skill 的 context 纪律)全是**行为约束**——劝模型别乱来,不改变"中间数据必须过主上下文"这个结构;同 8.9 的教训,提示层纪律挡不住结构性问题。
**决策**:§6"不做 subagent"针对的是**编排型多 agent**(并行、状态共享、agent 间通信、任务分解),该结论不变。本条预留的是**一个工具**:`delegate(instruction) -> str 摘要`——复用现成 `AgentLoop` 起一个全新空上下文的子循环,只注只读工具(检索/读文件类白名单),硬轮数上限(~20),跑完只把文字摘要返回主循环。主循环视角就是一次普通 tool call,零状态共享、零并行、零 agent 间协议;实现量约一个文件。
**触发条件(无信号不实施)**:RepeatGuard + brief 纪律上线后,`scripts/diag_*.py` 数据仍显示检索型 task 是烧 token 大户 / 检索中间数据占上下文大头。数据收敛则本条永久搁置。
**不选**:
- 完整多 agent 编排:状态管理爆炸(§6),且 zcbot 无真实并行需求——用户没有"同时审 3 篇"诉求,职责隔离已由 skill 体系覆盖。
- 继续加码 skill 纪律/熔断规则:行为约束对抗不了架构性上下文污染,规则越堆越像 §7.8 的黑名单(fundamentally broken)。
**实施时对账清单**:①子循环禁注 `delegate` 自身(防自我繁殖,同 8.5 定时 run 禁 schedule_create);②工具集只读白名单,不给 shell/fs 写/run_python;③计费入 usage_events 归属主 task(kind 区分);④主 run 的 cancel_check 传导进子循环;⑤子循环事件不直播 SSE(或只 emit 一条聚合摘要事件),防前端刷屏;⑥子循环产出若超长仍走"落文件留路径"纪律(§8.2 质量边界)。
--- ---
## 附录:DeepSeek V4 关键事实(2026-04-24) ## 附录:DeepSeek V4 关键事实(2026-04-24)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-08(个人微信 ClawBot 语音接入 + 企微语音路径补日志,bump 0.52.0) 最后更新:2026-07-09(头部压缩指示环替换金额显示,bump 0.54.1)
--- ---
@ -13,7 +13,7 @@
| 1-3 | 骨架 + Skill + run_python | ✅ | 多 skill(coding/proposal/ppt/research/documents/imagegen/videogen/review/patent);CoreCoder 唯一匹配 edit;敏感 env 过滤 | | 1-3 | 骨架 + Skill + run_python | ✅ | 多 skill(coding/proposal/ppt/research/documents/imagegen/videogen/review/patent);CoreCoder 唯一匹配 edit;敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 | | 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛) | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛);长会话中段折叠摘要 ✅(§8.8 Phase 2) |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额 ✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**(还需 egress proxy + xfs project quota OS 层硬化,§7.5 落地清单 #2 #4)。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额 ✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**(还需 egress proxy + xfs project quota OS 层硬化,§7.5 落地清单 #2 #4)。 |
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-09 / 0.54.1**:**对话头部压缩指示环**(用户提出:金额撤下明面,换编辑器式上下文环)——头部用量区改为 `N tok + 环形指示器`:环 = 窗口体量 / 模型容量(`reliable_context×2.5` chars),色阶映射三段机制(绿 <50% 未压缩 / 琥珀 50-85% 压缩区 / 85% 达折叠阈值),折叠过附 `已整理×N`;金额/缓存命中率挪 hover tooltip(taskUsageTooltip 本就有,信息不丢)。数据链: `GET /v1/tasks/{id}` 详情端点(列表不加, 100 ×聚合)新增 `context_window_chars/limit_chars/pressure/folds` 四个 additive 字段(窗口体量 = `sum(length(payload::text)) where idx>=base` 单聚合;容量按 task 模型档案折算,读不出置 None 前端画灰环);② 折叠计费 kind `"chat"→"context_fold"`(`record_chat_usage` kind 参数,prompt_optimize 同范式;折叠次数 = count kind, migration,admin kind 对账折叠成本)——0.54.0 未部署无存量,改名零成本;③ `llm_start` 事件补 `context_limit_chars`,run 中每轮实时刷环,`context_fold` done 事件折叠计数 +1 + 体量骤降环回绿;④ PATCH 模型/清空对话两处 `taskMeta` 整体覆盖点改 merge,保住环数据(清空顺带归零)。`format.js` SVG donut(14px,stroke-dasharray)无依赖。**§8.8 Phase 2 长会话中段折叠摘要**(补齐 IM 常驻会话"单段超长"缺口;Phase 1 gap 分段只砍跨话题累积)——run 起点窗口体量达 `reliable_context×85%`(chars×2.5 折算, 50% 压缩门槛同源常量, `core/context.py`)→ 新模块 `core/context_fold.py` 把窗口中段(保留尾部 ~30%,切点必落 user 消息不劈 tool 配对)折叠为固定模板结构化摘要(目标/约束决定/进展/待办/关键路径·ID·数值原文保留), `tasks.context_summary`(migration 0021)+ 推进 `context_base_idx`,`Session.load` system 后注入仅内存的「[前情摘要]」消息(`_n_head/_base_idx` 元数据维护 idx 映射不变量)。**前缀缓存友好**:摘要调用复用会话自身 prepare 后的消息前缀 + 末尾追加指令(与上一轮 chat 缓存字节一致,近全程 cache hit;切点跨列表用" k user"对齐);增量更新 = 旧摘要本在被折前缀里,指令要求合并。失败零阻塞(warn + 跳过);计费入 usage_events(message_id=NULL);「新话题」硬重置/web 清空对话顺带清摘要,gap 软重置保留前端:新 SSE `context_fold` 事件(start 状态行提示 / done 流内分隔条),顺手补 `warn` 事件渲染(此前 loop 熔断/重复拦截提示在 web 端被静默丢弃)。单测 `tests/test_context_fold.py` 12 (切点/映射/apply_fold/maybe_fold 打桩),全量 198
- **07-08 / 0.53.1**:**新增 rebuttal skill(审稿意见回复 / 修回)+ paper·review 增强**——调研 GitHub nature skill 生态(Yuan1z0825/nature-skills 26.9k★ Apache-2.0、Boom5426/Nature-Paper-Skills MIT)后移植三块:① 新 skill `rebuttal`(填投稿后环节空白):决定信 / 审稿意见 → 逐点回复信 + 稿件修改清单 + 修回 cover letter,拆条分诊(E.x/Rn.m 编号 → 8 类 × 4 档严重度 × 12 动作标签 tracker)→ 逐条策略确认 → **先改稿后写信** → QA 渲染(复用 --profile paper,中英双语);零编造铁律(不虚构实验/行号/图号/"已改"声明,含糊腹稿一律 AUTHOR_INPUT_NEEDED 追问,中文备注九种含糊转换表)、语气纪律(不同意五步窄口径、拒实验只用范围边界不用时间经费)、疑难案例库(两审稿人打架/统计大修/合规 blocking/appeal 识别)、readiness 四档诚实标注;② paper 补 `figure_discipline.md`(一图一论断/panel 角色/锚 panel/主图 vs 补充材料/图例四规则,阶段三必读)+ `submission_audit.md`(六步通读审计抓 quality_check 机检不到的跨章漂移 + 数据可用性声明模式含企业受限场景,阶段六必读);③ review 补 `sci_polish.md` 英文 SCI 润色子模式(四层递进诊断:全文策略→章节职责→段落逻辑→句子润色,章节职责错了不做句子润色;claim/evidence/boundary;中译英先抽命题不逐句翻)。未装:nature 系文献检索/下载类(自有 research/documents/brief 后端对不上)、paper2ppt/paper-to-patent/proposal-writer(已有国内定制版更强)。 - **07-08 / 0.53.1**:**新增 rebuttal skill(审稿意见回复 / 修回)+ paper·review 增强**——调研 GitHub nature skill 生态(Yuan1z0825/nature-skills 26.9k★ Apache-2.0、Boom5426/Nature-Paper-Skills MIT)后移植三块:① 新 skill `rebuttal`(填投稿后环节空白):决定信 / 审稿意见 → 逐点回复信 + 稿件修改清单 + 修回 cover letter,拆条分诊(E.x/Rn.m 编号 → 8 类 × 4 档严重度 × 12 动作标签 tracker)→ 逐条策略确认 → **先改稿后写信** → QA 渲染(复用 --profile paper,中英双语);零编造铁律(不虚构实验/行号/图号/"已改"声明,含糊腹稿一律 AUTHOR_INPUT_NEEDED 追问,中文备注九种含糊转换表)、语气纪律(不同意五步窄口径、拒实验只用范围边界不用时间经费)、疑难案例库(两审稿人打架/统计大修/合规 blocking/appeal 识别)、readiness 四档诚实标注;② paper 补 `figure_discipline.md`(一图一论断/panel 角色/锚 panel/主图 vs 补充材料/图例四规则,阶段三必读)+ `submission_audit.md`(六步通读审计抓 quality_check 机检不到的跨章漂移 + 数据可用性声明模式含企业受限场景,阶段六必读);③ review 补 `sci_polish.md` 英文 SCI 润色子模式(四层递进诊断:全文策略→章节职责→段落逻辑→句子润色,章节职责错了不做句子润色;claim/evidence/boundary;中译英先抽命题不逐句翻)。未装:nature 系文献检索/下载类(自有 research/documents/brief 后端对不上)、paper2ppt/paper-to-patent/proposal-writer(已有国内定制版更强)。
- **07-08 / 0.53.0**:**录音文件转写工具 `transcribe_audio`**(用户上传录音要"转文字/总结"的需求):新 `core/asr_lfasr.py`(讯飞录音文件转写标准版,raasr v2:signa 签名 + upload/getResult 异步订单 + lattice 解析 + 说话人分离格式化)+ `tools/transcribe_audio.py`(look_at_image 同形态 host-side tool,路径复用 image_ref.resolve_in_root)。选 LFASR 弃 IAT 分片方案:单文件 5h/500MB、服务端解码常见格式(本地不需要 ffmpeg)、roleType=1 自带说话人分离(会议场景直接可用);全文落 `<音频名>.transcript.txt` 只回预览+路径,不撑爆 context。凭据 `XFYUN_APPID`(共用)+ 新 env `XFYUN_LFASR_SECRET_KEY`(独立 SecretKey,控制台「录音文件转写标准版」页),没配不挂工具;轮询期响应停止按钮。实测:TTS 合成 12.6s 中文语音 13s 出稿逐字全对;orderInfo.originalDuration 是上传参数回显、realDuration 才是真实时长(踩坑记入代码注释)。smoke `scripts/smoke_transcribe_audio.py`(离线 12 项)+ 诊断 `scripts/diag_lfasr.py` - **07-08 / 0.53.0**:**录音文件转写工具 `transcribe_audio`**(用户上传录音要"转文字/总结"的需求):新 `core/asr_lfasr.py`(讯飞录音文件转写标准版,raasr v2:signa 签名 + upload/getResult 异步订单 + lattice 解析 + 说话人分离格式化)+ `tools/transcribe_audio.py`(look_at_image 同形态 host-side tool,路径复用 image_ref.resolve_in_root)。选 LFASR 弃 IAT 分片方案:单文件 5h/500MB、服务端解码常见格式(本地不需要 ffmpeg)、roleType=1 自带说话人分离(会议场景直接可用);全文落 `<音频名>.transcript.txt` 只回预览+路径,不撑爆 context。凭据 `XFYUN_APPID`(共用)+ 新 env `XFYUN_LFASR_SECRET_KEY`(独立 SecretKey,控制台「录音文件转写标准版」页),没配不挂工具;轮询期响应停止按钮。实测:TTS 合成 12.6s 中文语音 13s 出稿逐字全对;orderInfo.originalDuration 是上传参数回显、realDuration 才是真实时长(踩坑记入代码注释)。smoke `scripts/smoke_transcribe_audio.py`(离线 12 项)+ 诊断 `scripts/diag_lfasr.py`
- **07-08 / 0.52.6**:企微语音落地收尾——"文字/语音全无反应"根因定位:**回调注册在企微平台侧失效**(服务器/代码/env 全正常),后台重新保存「接收消息」API 配置即恢复;RUN 故障兜底补该行(判据:`[wecom] inbound` 行没有=平台没投递)。至此两渠道语音全链路用户实测跑通:个人微信(SILK→pilk)+ 企业微信(AMR→ffmpeg)→ 讯飞 IAT → 回显「已识别」→ 进对话。 - **07-08 / 0.52.6**:企微语音落地收尾——"文字/语音全无反应"根因定位:**回调注册在企微平台侧失效**(服务器/代码/env 全正常),后台重新保存「接收消息」API 配置即恢复;RUN 故障兜底补该行(判据:`[wecom] inbound` 行没有=平台没投递)。至此两渠道语音全链路用户实测跑通:个人微信(SILK→pilk)+ 企业微信(AMR→ffmpeg)→ 讯飞 IAT → 回显「已识别」→ 进对话。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.53.1" __version__ = "0.54.1"

View File

@ -14,6 +14,12 @@ from typing import Any, List
import json import json
import re import re
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token)与压缩触发比例。
# loop(50% 压缩门槛)与 context_fold(85% 折叠门槛)共用,单一事实源;压缩/折叠是
# 成本/安全优化、非正确性关键,估算粗糙无妨。
CHARS_PER_TOKEN = 2.5
COMPACT_CONTEXT_RATIO = 0.5
def _compact_old_tool_content(content: str, max_chars: int) -> str: def _compact_old_tool_content(content: str, max_chars: int) -> str:
if len(content) <= max_chars: if len(content) <= max_chars:

196
core/context_fold.py Normal file
View File

@ -0,0 +1,196 @@
"""§8.8 Phase 2:阈值结构化摘要 —— 长会话窗口中段折叠。
Phase 1(context_base_idx 软重置)解决"跨话题累积";单个分段内持续对话,窗口仍只增
不减 50% 门槛的旧 tool 压缩(core/context.py)只摘工具正文,user/assistant 对话
本体一字不少,最终仍会撞 reliable_context本模块在 run 起点检测窗口体量,
reliable_context × FOLD_TRIGGER_RATIO 时把窗口中段(保留尾部约 FOLD_TAIL_RATIO)
折叠为一份固定模板的结构化摘要
设计要点(取舍详 DESIGN §8.8):
- **边界而非删除**:原文全量留 messages ,只写 tasks.context_summary + 推进
context_base_idx;web 端照旧翻完整历史
- **增量更新**:旧摘要作为注入消息本就在被折叠的前缀里,摘要指令要求合并
新摘要 = f(旧摘要, 新折叠段),不重读全史
- **前缀缓存友好**:摘要调用复用会话自身的消息前缀(prepare 同款压缩后取 [:切点]),
末尾追加一条摘要指令 user 消息 前缀与上一轮 chat 的缓存字节一致,几乎全程
cache hit;折叠后的窗口(system + 摘要 + 尾段)在下次折叠前字节稳定,缓存恢复全暖
- **精确值原文保留**:模板硬性要求路径/ID/数值不得改写(mem0 实测自由摘要会静默丢
精确值), §8.2 质量边界(确认过的需求/结论不删)
- **失败零阻塞**:摘要调用失败只 warn + 跳过本次折叠,run 照常跑(85% 距硬上限有垫)
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from uuid import UUID
from sqlalchemy import update
from .context import (
CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO,
_message_chars,
prepare_messages_with_stats,
)
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
# 窗口体量达 reliable_context 的此比例 → 触发折叠(50% 压缩、85% 折叠双层门槛)。
FOLD_TRIGGER_RATIO = 0.85
# 折叠后保留的尾部体量占比(近期对话原文,续聊手感;其余折进摘要)。
FOLD_TAIL_RATIO = 0.30
# 可折叠段少于这么多条消息就不折 —— 摘要调用本身有成本,段太短折了省不了几个字。
MIN_FOLD_MESSAGES = 8
SUMMARY_NOTE_HEADER = "[前情摘要]"
# 摘要指令:作为最后一条 user 消息追加在会话前缀之后(前缀缓存复用,见模块注释)。
FOLD_INSTRUCTION = (
"请暂停手头任务,现在只做一件事:把以上全部对话内容压缩成一份结构化摘要,"
"它将替代这些消息作为后续对话的上下文。若开头已有「[前情摘要]」,把它合并进来。\n"
"严格按以下模板输出,除模板内容外不要输出任何别的文字:\n"
"## 目标\n## 已确认的约束与决定\n## 进展\n## 待办\n## 关键路径 / ID / 数值\n"
"硬性要求:文件路径、URL、ID、数字、代码符号、命令等精确值必须原文保留,"
"不得改写、四舍五入或省略;用户明确确认过的需求与结论必须保留;"
"不确定的内容标注[待确认]。"
)
def build_summary_note(summary: str) -> dict:
"""把摘要包成注入消息(user 角色,仅内存不落库;Session.load 与折叠现场共用)。"""
return {
"role": "user",
"content": (
f"{SUMMARY_NOTE_HEADER} 以下是本对话更早内容的结构化摘要"
"(原始消息已归档,不在当前上下文中);请把它当作已发生的事实,"
"直接继续对话,不必复述:\n\n" + summary
),
}
def find_cutoff(messages: List[dict], head_len: int) -> Optional[int]:
"""选折叠切点:返回内存 index(切点本身保留在窗口里),不可折返回 None。
从尾部向前累计体量,第一条使尾部 FOLD_TAIL_RATIO **user 消息**即切点
切在 user 消息上天然不劈开 assistant tool_calls / tool 配对, Phase 1 软重置
的锚点语义一致(窗口永远以 user 消息开头)user 消息稀疏时尾部占比可能超过
FOLD_TAIL_RATIO,保守方向,可接受
"""
n = len(messages)
if n <= head_len:
return None
total = sum(_message_chars(m) for m in messages[head_len:])
tail_target = total * FOLD_TAIL_RATIO
acc = 0
cutoff: Optional[int] = None
for i in range(n - 1, head_len - 1, -1):
acc += _message_chars(messages[i])
if acc >= tail_target and messages[i].get("role") == "user":
cutoff = i
break
if cutoff is None or cutoff - head_len < MIN_FOLD_MESSAGES:
return None
return cutoff
def _user_ordinal(messages: List[dict], idx: int) -> int:
"""messages[idx] 是从头数第几条 user 消息(1-based)。"""
return sum(1 for m in messages[: idx + 1] if m.get("role") == "user")
def _kth_user_index(messages: List[dict], k: int) -> Optional[int]:
"""从头数第 k 条(1-based)user 消息的 index;不存在返回 None。"""
seen = 0
for i, m in enumerate(messages):
if m.get("role") == "user":
seen += 1
if seen == k:
return i
return None
def persist_fold(task_id: UUID, new_base: int, summary: str) -> None:
"""折叠结果落库:摘要 + 窗口起点,一个事务。先于内存变更执行 —— DB 写失败时
内存未动,本次折叠白跑一次摘要调用但状态零污染,下次 run 重试"""
with session_scope() as s:
s.execute(
update(Task)
.where(Task.task_id == task_id)
.values(context_base_idx=new_base, context_summary=summary)
)
def maybe_fold(
session: Any,
llm: Any,
caps: Any,
*,
user_id: UUID,
emit: Callable[[dict], None],
) -> Optional[dict]:
"""run 起点调用:体量达阈值则折叠,返回统计 dict;未触发/不可折返回 None。
session 鸭子类型,需要:messages / task_id / context_head_len / context_base /
apply_fold(cutoff, summary)异常向上抛, loop try (失败零阻塞纪律)
"""
msgs = session.messages
before_chars = sum(_message_chars(m) for m in msgs)
threshold = int(caps.reliable_context * FOLD_TRIGGER_RATIO * CHARS_PER_TOKEN)
if before_chars < threshold:
return None
head_len = session.context_head_len
cutoff = find_cutoff(msgs, head_len)
if cutoff is None:
return None
folded_count = cutoff - head_len
emit({"type": "context_fold", "phase": "start", "folding_messages": folded_count})
# 复用 loop 同款 prepare(50% 门槛旧 tool 压缩)再取前缀 —— 字节与上一轮 chat 的
# 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐:
# prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。
compact_threshold = int(caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN)
prepared, _ = prepare_messages_with_stats(msgs, compact_threshold_chars=compact_threshold)
p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff))
if p_cut is None: # 理论不可达,防御
return None
fold_input = prepared[:p_cut] + [{"role": "user", "content": FOLD_INSTRUCTION}]
response = llm.chat(messages=fold_input)
choices = getattr(response, "choices", None) or []
summary = ((choices[0].message.content if choices else "") or "").strip()
if not summary:
emit({"type": "warn", "msg": "context fold: 摘要调用返回空,本轮跳过折叠"})
return None
usage = getattr(response, "usage", None)
try:
record_chat_usage(
task_id=session.task_id,
user_id=user_id,
message_id=None, # 摘要不入 messages,event 挂 task 维度
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
response=response,
# 独立 kind:按 kind 对账"折叠烧多少钱",且折叠次数 = count(该 kind)
# (任务详情 context_folds 字段的数据源,免加计数列)
kind="context_fold",
)
except Exception:
pass # 记账失败不阻塞折叠(与 loop 记账同纪律)
new_base = session.context_base + folded_count
persist_fold(session.task_id, new_base, summary)
session.apply_fold(cutoff, summary)
after_chars = sum(_message_chars(m) for m in session.messages)
stats = {
"type": "context_fold",
"phase": "done",
"folded_messages": folded_count,
"before_chars": before_chars,
"after_chars": after_chars,
"new_base": new_base,
}
emit(stats)
return stats

View File

@ -20,7 +20,8 @@ from uuid import UUID
import litellm import litellm
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .context import prepare_messages_with_stats from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_with_stats
from .context_fold import maybe_fold
from .executor import ExecCtx, Executor from .executor import ExecCtx, Executor
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
@ -274,6 +275,7 @@ class AgentLoop:
}) })
def run(self, user_message: str) -> str: def run(self, user_message: str) -> str:
self._maybe_fold_context()
self.session.append({"role": "user", "content": user_message}) self.session.append({"role": "user", "content": user_message})
for _ in range(self.max_iterations): for _ in range(self.max_iterations):
@ -400,10 +402,30 @@ class AgentLoop:
# 上下文压缩门槛:历史体量未到 reliable_context 的此比例前不压缩 —— 短任务不丢旧工具细节, # 上下文压缩门槛:历史体量未到 reliable_context 的此比例前不压缩 —— 短任务不丢旧工具细节,
# 且 prompt 前缀逐轮字节一致、DeepSeek 前缀缓存全程命中。50% 留足上下文安全垫。 # 且 prompt 前缀逐轮字节一致、DeepSeek 前缀缓存全程命中。50% 留足上下文安全垫。
_COMPACT_CONTEXT_RATIO = 0.5 # 常量本体在 core/context.py(与 context_fold 的 85% 折叠门槛共用折算,单一事实源)。
_COMPACT_CONTEXT_RATIO = COMPACT_CONTEXT_RATIO
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token);压缩是成本/安全优化、 # chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token);压缩是成本/安全优化、
# 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。 # 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。
_CHARS_PER_TOKEN = 2.5 _CHARS_PER_TOKEN = CHARS_PER_TOKEN
def _maybe_fold_context(self) -> None:
"""§8.8 Phase 2:run 起点检查窗口体量,达 reliable_context×85% 则把窗口中段
折叠成结构化摘要(core/context_fold.py,SSE 事件 context_fold)
run 起点而非轮间:轮间折叠要处理 tool 配对切割与已加载窗口的一致性,复杂
一档;对话是回合制,run 起点是自然缝隙,代价只是命中阈值那一回合首 token
几秒(每分段一两次)失败只 warn + 跳过,绝不阻塞本次 run(85% 距硬上限有垫)
"""
try:
maybe_fold(
self.session, self.llm, self.caps,
user_id=self.user_id, emit=self._emit,
)
except Exception as e:
self._emit({
"type": "warn",
"msg": f"context fold failed: {type(e).__name__}: {e};本轮跳过折叠",
})
def _stream_llm(self) -> Tuple[Optional[Any], bool]: def _stream_llm(self) -> Tuple[Optional[Any], bool]:
"""拉一轮 LLM 并保证返回的 tool_call arguments 可解析。 """拉一轮 LLM 并保证返回的 tool_call arguments 可解析。
@ -429,6 +451,9 @@ class AgentLoop:
self._emit({ self._emit({
"type": "llm_start", "type": "llm_start",
**{f"context_{k}": v for k, v in context_stats.items()}, **{f"context_{k}": v for k, v in context_stats.items()},
# 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比;
# 50% 进压缩区、85% 触发折叠(context_fold)。
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
}) })
for attempt in range(self._MAX_MALFORMED_RETRIES + 1): for attempt in range(self._MAX_MALFORMED_RETRIES + 1):
use_nonstream = attempt == self._MAX_MALFORMED_RETRIES use_nonstream = attempt == self._MAX_MALFORMED_RETRIES

View File

@ -66,8 +66,14 @@ class Session:
self.messages: List[dict] = [] self.messages: List[dict] = []
self.meta: Dict[str, Any] = dict(meta or {}) self.meta: Dict[str, Any] = dict(meta or {})
self._db_idx: int = 0 # 下一条要写 DB 的 idx self._db_idx: int = 0 # 下一条要写 DB 的 idx
# 上下文窗口元数据(0019/0021):_base_idx = 窗口起点的 DB idx;_n_head = 内存
# 头部"不在 DB 里"的消息条数(system + 可选注入的前情摘要)。
# 映射不变量:messages[i] 的 DB idx = _base_idx + (i - _n_head),i >= _n_head。
self._base_idx: int = 0
self._n_head: int = 0
if system_prompt: if system_prompt:
self.messages.append({"role": "system", "content": system_prompt}) self.messages.append({"role": "system", "content": system_prompt})
self._n_head = 1
def append(self, msg: Any) -> Optional[UUID]: def append(self, msg: Any) -> Optional[UUID]:
"""追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。 """追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。
@ -95,15 +101,48 @@ class Session:
self._db_idx += 1 self._db_idx += 1
return msg_id return msg_id
@property
def context_head_len(self) -> int:
"""内存头部不落 DB 的消息条数(system + 可选前情摘要),窗口 idx 映射用。"""
return self._n_head
@property
def context_base(self) -> int:
"""当前窗口起点的 DB idx(tasks.context_base_idx 的内存镜像)。"""
return self._base_idx
def apply_fold(self, cutoff: int, summary: str) -> None:
"""折叠内存窗口(§8.8 Phase 2):裁掉 head 与 cutoff 之间的消息,注入新前情摘要。
只动内存;DB 持久化(tasks.context_summary + context_base_idx)
context_fold.persist_fold 先行完成cutoff 为内存 index,必须指向 user 消息
(find_cutoff 保证),折叠后窗口 = [system?] + [前情摘要] + messages[cutoff:]
_db_idx 不变(总条数没变,append 续号不受影响)
"""
from .context_fold import build_summary_note
new_base = self._base_idx + (cutoff - self._n_head)
head = (
[self.messages[0]]
if self._n_head and self.messages and self.messages[0].get("role") == "system"
else []
)
self.messages = head + [build_summary_note(summary)] + self.messages[cutoff:]
self._n_head = len(head) + 1
self._base_idx = new_base
def reset(self, keep_system: bool = True) -> None: def reset(self, keep_system: bool = True) -> None:
"""清空消息。keep_system 仅影响内存(system 本来就不在 DB)。""" """清空消息。keep_system 仅影响内存(system 本来就不在 DB)。"""
if keep_system and self.messages and self.messages[0].get("role") == "system": if keep_system and self.messages and self.messages[0].get("role") == "system":
self.messages = [self.messages[0]] self.messages = [self.messages[0]]
self._n_head = 1
else: else:
self.messages = [] self.messages = []
self._n_head = 0
with session_scope() as s: with session_scope() as s:
s.execute(delete(Message).where(Message.task_id == self.task_id)) s.execute(delete(Message).where(Message.task_id == self.task_id))
self._db_idx = 0 self._db_idx = 0
self._base_idx = 0
@classmethod @classmethod
def load( def load(
@ -124,9 +163,18 @@ class Session:
""" """
sess = cls(task_id=task_id, system_prompt=system_prompt, meta=meta) sess = cls(task_id=task_id, system_prompt=system_prompt, meta=meta)
with session_scope() as s: with session_scope() as s:
base = s.execute( task_row = s.execute(
select(Task.context_base_idx).where(Task.task_id == task_id) select(Task.context_base_idx, Task.context_summary)
).scalar_one_or_none() or 0 .where(Task.task_id == task_id)
).first()
base = (task_row.context_base_idx if task_row else 0) or 0
summary = (task_row.context_summary if task_row else "") or ""
# 前情摘要(0021,§8.8 Phase 2):仅内存注入在 system 之后,不占 DB idx。
if summary:
from .context_fold import build_summary_note
sess.messages.append(build_summary_note(summary))
sess._n_head += 1
sess._base_idx = base
rows = s.execute( rows = s.execute(
select(Message) select(Message)
.where(Message.task_id == task_id, Message.idx >= base) .where(Message.task_id == task_id, Message.idx >= base)
@ -159,5 +207,6 @@ class Session:
return cnt is not None return cnt is not None
def n_user_msgs(self) -> int: def n_user_msgs(self) -> int:
"""内存里 user 消息数,用于 _cleanup_if_empty 守门(避免回 DB)。""" """内存里 user 消息数,用于 _cleanup_if_empty 守门(避免回 DB)。
return sum(1 for m in self.messages if m.get("role") == "user") 跳过头部注入消息 前情摘要也是 user 角色,不算真实用户发言"""
return sum(1 for m in self.messages[self._n_head:] if m.get("role") == "user")

View File

@ -95,6 +95,10 @@ class Task(Base):
context_base_idx: Mapped[int] = mapped_column( context_base_idx: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0" Integer, nullable=False, default=0, server_default="0"
) )
# 窗口中段折叠后的结构化摘要(0021,§8.8 Phase 2)。非空时 Session.load 在 system 之后
# 注入一条仅内存的「前情摘要」消息;与 context_base_idx 配套推进。NULL = 从未折叠。
# 「新话题」硬重置 / 清空对话时一并清 NULL;gap 软重置保留(续聊锚点语义)。
context_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -90,12 +90,15 @@ def record_chat_usage(
cache_hit_cny_per_mtoken: float = 0.0, cache_hit_cny_per_mtoken: float = 0.0,
extra_units: Optional[Mapping[str, Any]] = None, extra_units: Optional[Mapping[str, Any]] = None,
response: Any = None, response: Any = None,
kind: str = "chat",
) -> Decimal: ) -> Decimal:
"""记一次 chat 调用:写 usage_events 行 + 回填 messages.model_profile/tokens_in/out。 """记一次 chat 调用:写 usage_events 行 + 回填 messages.model_profile/tokens_in/out。
`message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到) `message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到)
usage_events 仍写但 message_id=NULL,messages 列不回填 usage_events 仍写但 message_id=NULL,messages 列不回填
`model_profile` 形如 `"deepseek_v4.pro"`(family.variant) `model_profile` 形如 `"deepseek_v4.pro"`(family.variant)
`kind` 默认 "chat";chat 形态的辅助调用可传别的标签( "context_fold"),
kind GROUP BY 对账 + 派生统计(折叠次数 = count) prompt_optimize 同范式
返回算出的 cost_cny(已落库),调用方可用作 SSE 显示 返回算出的 cost_cny(已落库),调用方可用作 SSE 显示
""" """
cost_usd = _safe_chat_cost_usd(response) cost_usd = _safe_chat_cost_usd(response)
@ -128,7 +131,7 @@ def record_chat_usage(
user_id=user_id, user_id=user_id,
task_id=task_id, task_id=task_id,
message_id=message_id, message_id=message_id,
kind="chat", kind=kind,
model_profile=model_profile, model_profile=model_profile,
units=units, units=units,
cost_cny=cost_cny, cost_cny=cost_cny,

View File

@ -372,9 +372,11 @@ NEW_TOPIC_COMMANDS = frozenset({"新话题", "新会话", "/new", "清空上下
def reset_channel_context(task_id: UUID, *, hard: bool) -> int: def reset_channel_context(task_id: UUID, *, hard: bool) -> int:
"""推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。 """推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。
hard=True(手动新话题):base = 总消息数 下一条入站起彻底新会话 hard=True(手动新话题):base = 总消息数 下一条入站起彻底新会话;
顺带清 context_summary(0021) 彻底从零,旧摘要不再注入
hard=False(自动 gap):base = 最后一条 user 消息 idx 新窗口仍带上上一轮原文, hard=False(自动 gap):base = 最后一条 user 消息 idx 新窗口仍带上上一轮原文,
续聊接得上; user 消息(理论上不会)退化为总数 续聊接得上; user 消息(理论上不会)退化为总数
摘要保留("留上一轮做续聊锚点"同语义)
""" """
with session_scope() as s: with session_scope() as s:
total = s.execute( total = s.execute(
@ -390,9 +392,10 @@ def reset_channel_context(task_id: UUID, *, hard: bool) -> int:
) )
).scalar_one_or_none() ).scalar_one_or_none()
new_base = int(last_user_idx) if last_user_idx is not None else int(total) new_base = int(last_user_idx) if last_user_idx is not None else int(total)
s.execute( vals: dict = {"context_base_idx": new_base}
update(Task).where(Task.task_id == task_id).values(context_base_idx=new_base) if hard:
) vals["context_summary"] = None
s.execute(update(Task).where(Task.task_id == task_id).values(**vals))
return new_base return new_base

View File

@ -0,0 +1,33 @@
"""tasks.context_summary 列(§8.8 Phase 2 阈值结构化摘要).
Revision ID: 0021
Revises: 0020
Create Date: 2026-07-09
tasks context_summary(TEXT NULL):窗口中段折叠后的结构化摘要
Session.load 时若非空, system 之后注入一条仅内存的前情摘要消息;
context_base_idx(0019)配套 折叠 = 摘要写这列 + base 推进到切点
原始消息仍全量留 messages (边界而非删除),web 端照旧翻完整历史
NULL = 从未折叠,行为与 0019 之前完全一致additive,无数据迁移
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0021"
down_revision: Union[str, None] = "0020"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("context_summary", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("tasks", "context_summary")

204
tests/test_context_fold.py Normal file
View File

@ -0,0 +1,204 @@
"""§8.8 Phase 2 折叠逻辑单测:切点选择 / 内存-prepared 映射 / Session.apply_fold /
maybe_fold 编排(DB LLM 全打桩,不碰真实 PG)"""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
import core.context_fold as cf
from core.context_fold import (
FOLD_TAIL_RATIO,
MIN_FOLD_MESSAGES,
SUMMARY_NOTE_HEADER,
_kth_user_index,
_user_ordinal,
build_summary_note,
find_cutoff,
)
from core.session import Session
def _mk_window(n_turns: int, content_chars: int = 200) -> list:
"""构造 n_turns 轮 user/assistant 对话(等长消息,方便按条数推算体量占比)。"""
msgs = []
for i in range(n_turns):
msgs.append({"role": "user", "content": f"u{i:03d}" + "x" * content_chars})
msgs.append({"role": "assistant", "content": f"a{i:03d}" + "y" * content_chars})
return msgs
class FindCutoffTests(unittest.TestCase):
def test_cutoff_is_user_message_and_tail_ratio_respected(self) -> None:
messages = [{"role": "system", "content": "rules"}] + _mk_window(30)
cutoff = find_cutoff(messages, head_len=1)
self.assertIsNotNone(cutoff)
self.assertEqual(messages[cutoff].get("role"), "user")
# 尾部(切点起)体量 >= FOLD_TAIL_RATIO(等长消息按条数近似)
tail = len(messages) - cutoff
window = len(messages) - 1
self.assertGreaterEqual(tail / window, FOLD_TAIL_RATIO * 0.9)
# 可折叠段不为空且够长
self.assertGreaterEqual(cutoff - 1, MIN_FOLD_MESSAGES)
def test_too_few_messages_returns_none(self) -> None:
messages = [{"role": "system", "content": "rules"}] + _mk_window(3)
self.assertIsNone(find_cutoff(messages, head_len=1))
def test_empty_window_returns_none(self) -> None:
self.assertIsNone(find_cutoff([{"role": "system", "content": "s"}], head_len=1))
def test_cutoff_never_splits_tool_pairing(self) -> None:
# assistant.tool_calls 后面跟 tool 结果 —— 切点是 user 消息,天然不落在中间
messages = [{"role": "system", "content": "rules"}]
for i in range(20):
messages.append({"role": "user", "content": f"u{i}" + "x" * 200})
messages.append({
"role": "assistant", "content": None,
"tool_calls": [{"id": f"tc{i}", "function": {"name": "shell"}}],
})
messages.append({"role": "tool", "tool_call_id": f"tc{i}", "content": "r" * 200})
cutoff = find_cutoff(messages, head_len=1)
self.assertIsNotNone(cutoff)
self.assertEqual(messages[cutoff]["role"], "user")
class UserOrdinalMappingTests(unittest.TestCase):
def test_ordinal_roundtrip_across_prepared_list(self) -> None:
"""prepare 补占位 tool 消息后 index 平移,但「第 k 条 user」两边一致。"""
messages = [
{"role": "system", "content": "s"},
{"role": "user", "content": "q1"},
# 悬空 tool_call:prepare 的 repair 会在其后补一条占位 tool 消息
{"role": "assistant", "content": None,
"tool_calls": [{"id": "tc1", "function": {"name": "shell"}}]},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": "a2"},
]
from core.context import prepare_messages_with_stats
prepared, stats = prepare_messages_with_stats(messages)
self.assertEqual(stats["repaired_tool_calls"], 1)
k = _user_ordinal(messages, 3) # q2 是第 2 条 user
self.assertEqual(k, 2)
p_idx = _kth_user_index(prepared, k)
self.assertEqual(prepared[p_idx]["content"], "q2")
self.assertEqual(p_idx, 4) # 占位 tool 插入使 index +1
def test_kth_user_missing_returns_none(self) -> None:
self.assertIsNone(_kth_user_index([{"role": "assistant", "content": "a"}], 1))
class ApplyFoldTests(unittest.TestCase):
def test_apply_fold_rebuilds_head_and_advances_base(self) -> None:
sess = Session(task_id=uuid4(), system_prompt="SYS")
window = _mk_window(10)
sess.messages.extend(window) # 模拟 load:base=0,head=1(system)
cutoff = 11 # 内存 index,某条 user 消息
self.assertEqual(sess.messages[cutoff]["role"], "user")
sess.apply_fold(cutoff, "SUMMARY-TEXT")
# 窗口 = [system] + [摘要注入] + 尾段
self.assertEqual(sess.messages[0]["role"], "system")
self.assertTrue(sess.messages[1]["content"].startswith(SUMMARY_NOTE_HEADER))
self.assertIn("SUMMARY-TEXT", sess.messages[1]["content"])
self.assertEqual(sess.messages[2]["content"], window[cutoff - 1]["content"])
# base 推进 = 折叠条数(cutoff - 旧 head 1);head 变 2(system + 摘要)
self.assertEqual(sess.context_base, cutoff - 1)
self.assertEqual(sess.context_head_len, 2)
def test_second_fold_replaces_summary_note(self) -> None:
sess = Session(task_id=uuid4(), system_prompt="SYS")
sess.messages.extend(_mk_window(10))
sess.apply_fold(11, "FIRST")
base_after_first = sess.context_base
sess.messages.extend(_mk_window(10))
# head=2,再折 10 条(切到 index 12 的 user 消息)
self.assertEqual(sess.messages[12]["role"], "user")
sess.apply_fold(12, "SECOND")
notes = [m for m in sess.messages if str(m.get("content", "")).startswith(SUMMARY_NOTE_HEADER)]
self.assertEqual(len(notes), 1) # 旧摘要被折进新摘要,不叠加
self.assertIn("SECOND", notes[0]["content"])
self.assertEqual(sess.context_base, base_after_first + 10)
self.assertEqual(sess.context_head_len, 2)
def test_summary_note_not_counted_as_user_msg(self) -> None:
sess = Session(task_id=uuid4(), system_prompt="SYS")
sess.messages.extend(_mk_window(10))
before = sess.n_user_msgs()
sess.apply_fold(11, "S")
# 折掉 5 轮 user(index 1..10 里 5 条 user),注入的摘要不计入
self.assertEqual(sess.n_user_msgs(), before - 5)
class _FakeLLM:
def __init__(self, reply: str) -> None:
self.reply = reply
self.last_messages = None
def chat(self, messages, **kwargs):
self.last_messages = messages
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))],
usage=SimpleNamespace(prompt_tokens=100, completion_tokens=20),
)
_FAKE_CAPS = SimpleNamespace(
reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符
family="deepseek_v4", variant="flash",
input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0,
)
class MaybeFoldTests(unittest.TestCase):
def _session(self) -> Session:
sess = Session(task_id=uuid4(), system_prompt="SYS")
sess.messages.extend(_mk_window(30))
return sess
def test_fold_happy_path(self) -> None:
sess = self._session()
llm = _FakeLLM("## 目标\n...")
events = []
with patch.object(cf, "persist_fold") as persist, \
patch.object(cf, "record_chat_usage") as usage:
stats = cf.maybe_fold(sess, llm, _FAKE_CAPS, user_id=uuid4(), emit=events.append)
self.assertIsNotNone(stats)
self.assertEqual(stats["phase"], "done")
# 摘要指令是折叠调用的最后一条消息(前缀复用构造)
self.assertEqual(llm.last_messages[-1]["content"], cf.FOLD_INSTRUCTION)
# DB 持久化先行,参数与内存变更一致
persist.assert_called_once_with(sess.task_id, stats["new_base"], "## 目标\n...")
usage.assert_called_once()
# 独立 kind:折叠次数 = count(usage_events kind='context_fold'),task 详情接口依赖
self.assertEqual(usage.call_args.kwargs.get("kind"), "context_fold")
self.assertEqual(sess.context_base, stats["new_base"])
phases = [e.get("phase") for e in events if e.get("type") == "context_fold"]
self.assertEqual(phases, ["start", "done"])
self.assertLess(stats["after_chars"], stats["before_chars"])
def test_below_threshold_no_fold(self) -> None:
sess = self._session()
caps = SimpleNamespace(**{**_FAKE_CAPS.__dict__, "reliable_context": 10_000_000})
llm = _FakeLLM("unused")
with patch.object(cf, "persist_fold") as persist:
self.assertIsNone(cf.maybe_fold(sess, llm, caps, user_id=uuid4(), emit=lambda e: None))
persist.assert_not_called()
self.assertIsNone(llm.last_messages)
def test_empty_summary_skips_fold(self) -> None:
sess = self._session()
before = list(sess.messages)
events = []
with patch.object(cf, "persist_fold") as persist:
result = cf.maybe_fold(
sess, _FakeLLM(" "), _FAKE_CAPS, user_id=uuid4(), emit=events.append
)
self.assertIsNone(result)
persist.assert_not_called()
self.assertEqual(sess.messages, before) # 内存零污染
self.assertTrue(any(e.get("type") == "warn" for e in events))
if __name__ == "__main__":
unittest.main()

View File

@ -2117,7 +2117,19 @@ def create_app() -> FastAPI:
@app.get("/v1/tasks/{task_id}", tags=["tasks"]) @app.get("/v1/tasks/{task_id}", tags=["tasks"])
def get_task(task_id: str, user_id: UUID = Depends(require_user)): def get_task(task_id: str, user_id: UUID = Depends(require_user)):
"""单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。""" """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。
额外带上下文压力字段(仅详情端点,列表不加 task 一条 sum 聚合,列表
100 ×聚合不值得):`context_window_chars`(当前窗口体量,idx>=base
payload 字节和)`context_limit_chars`(reliable_context×2.5 折算容量)
`context_pressure`(前者/后者,0-1+)`context_folds`(折叠次数,=
usage_events kind='context_fold' 计数)前端头部压缩指示环用
"""
from sqlalchemy import Text as SAText, cast as sa_cast
from core.context import CHARS_PER_TOKEN
from core.storage.models import UsageEvent
try: try:
tid = UUID(task_id) tid = UUID(task_id)
except ValueError: except ValueError:
@ -2132,7 +2144,35 @@ def create_app() -> FastAPI:
select(func.count()).select_from(Message).where(Message.task_id == tid) select(func.count()).select_from(Message).where(Message.task_id == tid)
).scalar_one() ).scalar_one()
usage = _usage_aggregates(s, [tid]) usage = _usage_aggregates(s, [tid])
return _task_dict(row, n_messages=n, usage=usage.get(tid)) window_chars = s.execute(
select(func.coalesce(func.sum(func.length(sa_cast(Message.payload, SAText))), 0))
.where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0))
).scalar_one()
folds = s.execute(
select(func.count()).select_from(UsageEvent)
.where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold")
).scalar_one()
d = _task_dict(row, n_messages=n, usage=usage.get(tid))
# 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None,
# 前端画灰环,不 500。
limit_chars = None
try:
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.paths import ROOT
cfg = load_config()
profile = d.get("model_profile") or cfg["default_model"]
caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
limit_chars = int(caps.reliable_context * CHARS_PER_TOKEN)
except Exception:
pass
d["context_window_chars"] = int(window_chars)
d["context_limit_chars"] = limit_chars
d["context_pressure"] = (
round(int(window_chars) / limit_chars, 4) if limit_chars else None
)
d["context_folds"] = int(folds)
return d
@app.get("/v1/folders", tags=["folders"]) @app.get("/v1/folders", tags=["folders"])
def list_folders(user_id: UUID = Depends(require_user)): def list_folders(user_id: UUID = Depends(require_user)):
@ -2753,8 +2793,10 @@ def create_app() -> FastAPI:
cost_cny=0, cost_cny=0,
run_status="idle", run_status="idle",
run_error=None, run_error=None,
# 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019) # 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019);
# 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021)
context_base_idx=0, context_base_idx=0,
context_summary=None,
) )
) )
task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one()

View File

@ -7,7 +7,7 @@
import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js"; import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu } from "./dom.js"; import { $, showMenu } from "./dom.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats } from "./format.js"; import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js"; import { renderMd, highlightIn } from "./markdown.js";
import { mqPhone, setMobileView } from "./layout.js"; import { mqPhone, setMobileView } from "./layout.js";
@ -470,6 +470,13 @@ function renderConcurrentWarning() {
el.style.display = "block"; el.style.display = "block";
} }
// 压缩指示环就地重绘(run 中 llm_start / context_fold 事件驱动,不整行重渲 meta —
// 避免打断模型下拉等交互态)。taskMeta 上的 context_* 字段是唯一数据源。
function refreshCtxRing() {
const el = $("ctx-ring");
if (el && state.taskMeta) el.outerHTML = ctxRingHtml(state.taskMeta);
}
function renderChatMeta() { function renderChatMeta() {
const t = state.taskMeta; const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; } if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
@ -489,6 +496,7 @@ function renderChatMeta() {
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span> <span class="tid">${t.task_id.slice(0, 8)}</span>
${formatTaskUsage(t)} ${formatTaskUsage(t)}
${ctxRingHtml(t)}
${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""} ${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span> <span class="spacer"></span>
${renderModelDropdown(t)} ${renderModelDropdown(t)}
@ -598,7 +606,9 @@ async function onChangeModel(ev) {
const oldProfile = t.model_profile || ""; const oldProfile = t.model_profile || "";
try { try {
const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile }); const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
state.taskMeta = updated; // PATCH 返回不含 context_* 字段(仅详情端点算),merge 保住指示环数据;
// 换模型后容量略陈旧,下一次 llm_start / 重开任务自动校正
state.taskMeta = { ...t, ...updated };
const running = updated.run_status === "running" || updated.run_status === "cancelling"; const running = updated.run_status === "running" || updated.run_status === "cancelling";
$("chat-hint").textContent = running $("chat-hint").textContent = running
? `已切到 ${newProfile} · 当前 run 跑完后生效` ? `已切到 ${newProfile} · 当前 run 跑完后生效`
@ -2115,6 +2125,15 @@ function handleSseEvent(ev, asstCard, ctx) {
if (t === "llm_start") { if (t === "llm_start") {
ctx.contextStats = ev.data || {}; ctx.contextStats = ev.data || {};
setRunHint(ctx, formatContextStats(ctx.contextStats)); setRunHint(ctx, formatContextStats(ctx.contextStats));
// 每轮 LLM 起点带最新窗口体量 + 容量 → 实时刷压缩指示环
const cs = ctx.contextStats;
if (visible && state.taskMeta && cs.context_limit_chars) {
state.taskMeta.context_window_chars = cs.context_original_chars || 0;
state.taskMeta.context_limit_chars = cs.context_limit_chars;
state.taskMeta.context_pressure =
(cs.context_original_chars || 0) / cs.context_limit_chars;
refreshCtxRing();
}
// 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示 // 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示
// 「思考中 · Ns」,而不是页面静止 // 「思考中 · Ns」,而不是页面静止
ensureTextSeg(ctx); ensureTextSeg(ctx);
@ -2215,6 +2234,31 @@ function handleSseEvent(ev, asstCard, ctx) {
if (isProducer) upgradeMediaArtifacts(asstCard); if (isProducer) upgradeMediaArtifacts(asstCard);
} }
if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
} else if (t === "context_fold") {
// 长会话中段折叠(§8.8 Phase 2):start 阶段在状态行提示(摘要调用要几秒,别静止),
// done 阶段插一条与 model_switch 同款的分隔线,让"上下文被整理过"在流里可见。
const d = ev.data || {};
if (d.phase === "start") {
setRunHint(ctx, `整理长对话上下文中…(折叠 ${d.folding_messages || 0} 条早期消息)`);
} else if (d.phase === "done") {
closeTextSeg(ctx);
const sep = document.createElement("div");
sep.className = "context-fold muted";
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
sep.textContent = `── 已整理长对话:${d.folded_messages || 0} 条早期消息折叠为摘要`
+ `(ctx ${fmtTokens(d.before_chars || 0)}${fmtTokens(d.after_chars || 0)} chars)──`;
asstCard.appendChild(sep);
// 折叠计数 + 折叠后体量同步到指示环(limit 不变,窗口骤降 → 环回绿)
if (visible && state.taskMeta) {
state.taskMeta.context_folds = (state.taskMeta.context_folds || 0) + 1;
if (d.after_chars && state.taskMeta.context_limit_chars) {
state.taskMeta.context_window_chars = d.after_chars;
state.taskMeta.context_pressure =
d.after_chars / state.taskMeta.context_limit_chars;
}
refreshCtxRing();
}
}
} else if (t === "model_switch") { } else if (t === "model_switch") {
// skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线, // skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线,
// 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。 // 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。
@ -2242,6 +2286,18 @@ function handleSseEvent(ev, asstCard, ctx) {
$("chat-hint").textContent = `skill 指定模型 → ${dn}`; $("chat-hint").textContent = `skill 指定模型 → ${dn}`;
} }
} }
} else if (t === "warn") {
// loop 的运行时提醒(无进展熔断/重复拦截/折叠失败等)。此前被静默丢弃,与"停下
// 不静默"的意图相悖 —— 渲染成流内 muted 单行,不打断当前文字段的排版节奏。
const msg = (ev.data && ev.data.msg) || "";
if (msg) {
closeTextSeg(ctx);
const w = document.createElement("div");
w.className = "run-warn muted";
w.style.cssText = "margin:6px 0;font-size:12px;color:var(--warn,#b8860b);";
w.textContent = `${msg}`;
asstCard.appendChild(w);
}
} else if (t === "cancelled") { } else if (t === "cancelled") {
const badge = document.createElement("div"); const badge = document.createElement("div");
badge.className = "cancelled-badge"; badge.className = "cancelled-badge";
@ -2276,7 +2332,11 @@ async function clearMessages(tid, name, nMsg) {
try { try {
const updated = await api("POST", "/v1/tasks/" + tid + "/clear"); const updated = await api("POST", "/v1/tasks/" + tid + "/clear");
if (state.taskId === tid) { if (state.taskId === tid) {
state.taskMeta = updated; // clear 返回不含 context_* 字段;全删后窗口/折叠归零,指示环回空
state.taskMeta = {
...state.taskMeta, ...updated,
context_window_chars: 0, context_pressure: 0, context_folds: 0,
};
renderChatMeta(); renderChatMeta();
renderMessages([]); renderMessages([]);
state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空 state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空

View File

@ -53,20 +53,64 @@ export function taskUsageTooltip(t) {
return lines.join("\n"); return lines.join("\n");
} }
// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费;详情走 taskUsageTooltip。 // 任务级累计用量(顶栏):只内联总 token;金额/缓存命中率挪 hover 详情
// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱 // (taskUsageTooltip 本就有),明面槽位让给压缩指示环(ctxRingHtml,0.54.1)
export function formatTaskUsage(t) { export function formatTaskUsage(t) {
const tok = t.tokens || 0; const tok = t.tokens || 0;
if (!tok) return ""; if (!tok) return "";
const hit = t.tokens_cache_hit || 0; return `<span class="muted" title="${escapeHtml(taskUsageTooltip(t))}" style="white-space:nowrap;">${fmtTokens(tok)} tok</span>`;
const pin = t.tokens_prompt || 0; }
const bits = [`${fmtTokens(tok)} tok`];
if (pin > 0 && hit > 0) { // ───── 压缩指示环(§8.8 上下文治理的头部可视化)─────
bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`); // 环 = 当前窗口体量 / 模型容量(reliable_context×2.5 chars)。色阶对应三段机制:
// 绿 <50% 未压缩(上下文原样发送);琥珀 50-85% 压缩区(发送时旧工具正文被压);
// 红 ≥85% 达折叠阈值(下次发消息 run 起点自动整理成摘要)。容量未知 → 灰空环。
// 数据源:打开任务时 GET /v1/tasks/{id} 的 context_* 字段;run 中由 llm_start /
// context_fold 事件实时刷(chat.js refreshCtxRing)。
const _RING_C = 2 * Math.PI * 6; // r=6 的周长,dasharray 基准
export function ctxRingTitle(t) {
const p = t.context_pressure;
const lines = [];
if (typeof p === "number") {
lines.push(`上下文窗口占用 ${Math.round(p * 100)}%`
+ `${fmtTokens(t.context_window_chars || 0)} / ${fmtTokens(t.context_limit_chars || 0)} chars`);
if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要");
else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩");
else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送");
} else {
lines.push("窗口占用未知(模型容量档案不可用)");
} }
const cost = fmtCost(t.cost_cny); if (t.context_folds > 0) {
if (cost) bits.push(cost); lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`);
return `<span class="muted" title="${escapeHtml(taskUsageTooltip(t))}" style="white-space:nowrap;">${bits.join(" · ")}</span>`; }
return lines.join("\n");
}
export function ctxRingHtml(t) {
t = t || {};
const p = t.context_pressure;
const known = typeof p === "number" && p >= 0;
const pct = known ? Math.min(p, 1) : 0;
const color = !known ? "#999"
: p >= 0.85 ? "#c0392b"
: p >= 0.5 ? "#d99a1b"
: "#3a9d5d";
const label = known ? `${Math.round(p * 100)}%` : "";
const folds = t.context_folds || 0;
const foldBadge = folds > 0
? `<span style="font-size:11px;">已整理×${folds}</span>` : "";
return `<span id="ctx-ring" class="muted" title="${escapeHtml(ctxRingTitle(t))}"`
+ ` style="display:inline-flex;align-items:center;gap:3px;white-space:nowrap;cursor:default;">`
+ `<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" style="flex:none;">`
+ `<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-opacity="0.18" stroke-width="2.5"></circle>`
+ (pct > 0
? `<circle cx="8" cy="8" r="6" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round"`
+ ` stroke-dasharray="${Math.max(pct * _RING_C, 0.8).toFixed(2)} ${_RING_C.toFixed(2)}"`
+ ` transform="rotate(-90 8 8)"></circle>`
: "")
+ `</svg><span style="font-size:11px;">${label}</span>${foldBadge}</span>`;
} }
export function formatContextStats(d) { export function formatContextStats(d) {