Compare commits

...

18 Commits

Author SHA1 Message Date
oudecheng
75653447e2 chore: 升级版本号至 0.4.4 并更新 CHANGELOG 2026-08-19 08:32:49 +08:00
oudecheng
4218c83890 fix(web): 扫描修复——列表响应乱序覆盖与话题切换竞态
- topic_list/session_list 响应增加归属校验(比对 session_id/channel_name),切换瞬间的迟到大响应不再覆盖当前列表
- pendingNewTopic 改为差分消费:仅当响应真正携带新话题时消费聚焦标志,不再被恰好撞上的刷新响应劫持;切换 session/channel 时清除残留标志
- selectTopic 同步双写 selectedTopicRef,消除 setState→effect 同步窗口内旧话题流式消息污染新列表的竞态
- reconcileProcessingTopics 改走 apiGetSilent:裸 fetch 丢认证头导致远程部署 401、对账静默失败话题卡 loading
2026-08-19 00:09:01 +08:00
oudecheng
f010006a66 fix(concurrency): 扫描修复——bash 取消泄漏与子代理队列阻塞
- bash: Command 补 kill_on_drop(true)。原先 /stop 的 select! 竞速 drop run_command future 时,Child detach 不杀进程、read_stream 任务因管道永不 EOF 而永久存活,每次取消泄漏孤儿进程+2任务;进程被杀后管道 EOF 同时解决读取任务泄漏
- runtime: sub_done send().await 改 5s 超时保护。队列满且无消费者时永久阻塞会占住全局 Semaphore permit,8 permit 耗尽后整个子代理子系统停摆;超时放弃结果由 DB 状态 + wait 对账兜底
- runtime: cancel_pending_for_topic / reap_orphan_subagents 的 parking_lot registry 锁不再跨 SQLite 调用持有(锁内收集、锁外落库),SQLite 卡顿时不再放大 /stop 延迟
2026-08-19 00:09:00 +08:00
oudecheng
5bc3f64ba2 fix(metrics): 初始化时注册基线 counter 避免启动后渲染为空
- install_recorder 后立即以 absolute(0) 注册 MESSAGE_PROCESSING_ERRORS / AGENT_ITERATIONS / LLM_TOKENS_USED / TOOL_EXECUTION_TOTAL,确保首次 Prometheus 抓取即可见指标
- 新增测试验证初始化后 render 输出包含基线 counter
2026-08-18 23:34:40 +08:00
oudecheng
1cde659bb6 fix(storage): 全项目扫描修复——外键约束失效根因与计数一致性
- [根因] 连接池补 PRAGMA foreign_keys=ON:原先仅引导连接开启,运行时池连接 FK 强制从未生效,delete_topic 的 SET NULL / delete_session 的 CASCADE 形同虚设(悬空 topic_id、topics 孤儿残留)
- clear_messages 归零 topics.message_count(原先只清 token 列,/clear 后话题列表显示虚假消息数)
- delete_messages_by_ids 补重算 sessions.user_turn_count(sanitize 删除 user 消息后计数永久偏高)
- compact/replace_active_history 新增 recompute_session_topic_message_counts(重插消息无 topic_id,topics.message_count 漂移)
- switch_topic 三连查与 http 模型选择路由包 spawn_blocking(侧边栏主交互高频路径)
- 新增 3 个测试:FK 生效、clear 归零、user_turn_count 重算
2026-08-18 23:31:11 +08:00
oudecheng
985904b95e perf(topics): 新建/删除/重命名话题同样走 spawn_blocking 统一提速
- 抽取 list_topic_summaries_blocking 公共 helper(list_topics + build_topic_summaries 在 blocking 线程池执行)
- create/delete/rename 三个话题命令返回侧边栏刷新列表时统一走 helper,DB 查询不再阻塞 tokio worker
- 结合增量统计列,四个话题命令的列表构建均从 O(全部消息) 降为 O(话题数)
2026-08-18 23:06:19 +08:00
oudecheng
f711948902 fix(chat): 修复图片放大与工具详情浮层被覆盖及定位错乱
图片放大(ImageLightbox)与工具详情(ToolDetailModal)渲染在虚拟化消息行内,行元素的 transform 成为 fixed 后代的包含块,导致遮罩只覆盖行区域、工具栏定位偏移、其他内容叠在其上。

改用 createPortal 渲染到 document.body,并将 z-index 提升到 z-[10000] 超过下拉浮层 z-[9999],恢复视口级覆盖与右上角按钮定位。
2026-08-18 23:00:16 +08:00
oudecheng
e2841ce791 perf(topics): 话题列表刷新提速——token 统计增量维护列替代全表聚合
- topics 表新增 stat_* 增量列,append 时同事务累加(排除 sub:% 子代理消息)
- batch_topic_token_stats 从 messages 全表 SUM+MAX(seq) 聚合改为读 topics 列,刷新耗时与消息总量解耦
- 变更路径重算自愈:delete_by_ids/replace_topic/compact_topic 按 topic 重算,replace_active/compact_active 按 session 重算,clear_messages 归零
- backfill_topic_usage_stats 一次性回填老数据(user_version=4 守卫,仅首启执行)
- list_topics handler 移入 spawn_blocking,不再阻塞 tokio worker
- 修复既有 bug:压缩/替换路径的 INSERT SQL 漏写 cached_tokens 列
- 新增 5 个测试:增量维护、子代理隔离、删除/清空重算、替换重算、回填迁移幂等
2026-08-18 22:59:01 +08:00
oudecheng
6037c79b9c perf(log): 会话消息投递成功日志降级为 debug
文本/附件投递成功的 info 日志降级为 debug,减少每条消息的运行噪音;失败路径的 warn 日志不变。
2026-08-18 22:12:48 +08:00
oudecheng
725efe3f85 docs(changelog): 0.4.3 补充 fix(chat) 滚动位置修复并修正 commit 数量为 14 2026-08-18 15:37:35 +08:00
oudecheng
3cef9da443 chore(release): 0.4.3——max_run_secs 默认改为不限制
max_run_secs 默认值由 3600(60 分钟)改为 0(不限制),避免长任务被单次执行时间上限中断;需要时可在 config.json 的 agents 段显式配置。同步版本号提升至 0.4.3 并更新 CHANGELOG。
2026-08-18 15:35:22 +08:00
oudecheng
028f60557e fix(chat): 修复视图切换滚动位置丢失与顶部按钮无法滚到首条消息
动态行高缓存按消息 id 键控,避免子智能体视图高度污染主视图;恢复后两帧 rAF 内不回写保存位置。

顶部按钮链式贴顶:动画落定显式续链,分页 prepend 时贴到新顶部,直到真正首条消息;关闭 virtualizer 行高修正的 scrollTop 自动补偿。

用户滚动输入/回底/待办高亮/视图切换/分页耗尽时清除贴顶意图。
2026-08-18 15:33:59 +08:00
oudecheng
6ac653bf51 fix(review): 对抗性审查修复——spawn 竞态窗口、max_run_secs 配置化、删除计数一致性
- 子代理 spawn 顺序调整为先注册 cancel_registry 再写 DB running 记录,消除 reap 误杀刚启动子代理的微秒级竞态窗口
- max_run_secs 接入 AgentConfig 配置项(config.json agents 段),不再硬编码;From 透传 + 全部字面构造点补齐
- delete_messages_by_ids 删除后按活 COUNT 重算 sessions/topics 的 message_count,修复计数列漂移
- cli init 修改 agent 路径保留既有 max_run_secs 而非重置默认
2026-08-18 09:46:21 +08:00
oudecheng
452c5ad0ef fix(gateway): 修复长会话后新消息无响应——僵尸子代理清理、panic 兜底与运行预算
- 发送 ExecutionCompleted 前惰性清理僵尸 running 子代理(DB running 但执行任务已消失),解除 pending 阻塞
- processor panic 路径补发 error 通知 + ExecutionCompleted,防止前端永久 loading
- agent 单轮增加墙钟预算 max_run_secs,超时优雅退出释放 topic 串行锁
- topic 串行锁等待增加 info/warn 日志,长等待可观测
- sanitize 清理结果回写 DB(delete_messages_by_ids),消除每次加载的重复修复
- storage 新增 keyset 分页与按 ID 批量删除的单元测试
2026-08-18 09:33:07 +08:00
oudecheng
a629486ad3 perf(chat): 长话题历史分页——messages 按 seq keyset 增量加载
后端新增 load_messages_for_topic_page(seq < cursor + limit 分页,走 (session_id, seq) 索引替代 OFFSET 深翻页),ChatMessage 增加 seq 游标;历史批次消息带 topic_id 下发,前端以 seq+topic_id 双重判定批次归属,规避切话题瞬间在途旧批次污染。

前端触顶增量加载:批次缓存在 pendingHistoryRef,收到 topic_history_end 一次性去重 prepend,scrollTop 按新增高度补偿锚定原头部消息;不足一屏自动补页,loading 超时 10s 自愈;流式输出中加载历史不清空流式累加器,避免已流出文本丢失。
2026-08-18 07:51:31 +08:00
oudecheng
0159227828 perf: 第二批性能修复——blocking 线程池隔离、HTTP 客户端复用、前端 memo 与流式节流
- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用)
  移入 spawn_blocking,避免占用 async worker
- LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销
- agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化
- 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建)
- 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存;
  流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
2026-08-18 06:55:21 +08:00
oudecheng
e7deac6950 perf: 第一批性能修复——HTTP 超时、SQLite 写入优化、流式限长、前端减少无效重跑
后端:

- 飞书通道 HTTP client 增加 connect/全局超时,token 刷新与 reaction 叠加紧超时,消除无限挂起风险

- SQLite WAL 下设置 synchronous=NORMAL(per-connection with_init),6 处批量写入循环改用 prepare_cached 复用预编译语句

- web_fetch 复用长生命周期 HTTP client,响应体改为流式限长读取(上限=字符限额x4,封顶 32MB),替代全量读完再截断

前端:

- App.tsx effect/callback 依赖由 subAgentView 对象改为 subAgentTaskId 原始值,修复子代理流式期间 token 统计 500ms 定时器被每帧重置永不触发的问题

- MessageBubble/ToolDetailModal 的 ReactMarkdown components 与 remarkPlugins 提升为模块级常量,避免流式渲染期间每帧重建
2026-08-17 22:29:14 +08:00
oudecheng
accdfeed3b fix(gateway): 禁用 Windows 控制台 QuickEdit 并改为非阻塞日志,修复终端输出卡住需按回车的问题 2026-08-17 20:16:44 +08:00
77 changed files with 3575 additions and 754 deletions

2
Cargo.lock generated
View File

@ -1728,7 +1728,7 @@ dependencies = [
[[package]] [[package]]
name = "picobot" name = "picobot"
version = "0.4.2" version = "0.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "0.4.2" version = "0.4.4"
edition = "2024" edition = "2024"
[lints.rust] [lints.rust]

View File

@ -2,6 +2,106 @@
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。 本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
## [0.4.4] - 2026-08-18
较 [0.4.3] 的 8 个 commit 迭代,聚焦 **全项目扫描修复**、**话题列表刷新提速** 与 **前端竞态与浮层修复** 三大方向。
### 性能优化
#### 话题列表刷新提速
- token 统计改为增量维护列(`topics` 表新增统计列并由运行期增量归因),替代每次刷新的全表聚合,话题列表刷新显著变快;配套 129 行迁移与 262 行单测。
- 新建/删除/重命名话题同样走 `spawn_blocking` 移出 async worker与批量操作统一提速。
#### 日志降噪
- 会话消息投递成功日志由 info 降级为 debug减少高频运行时日志输出。
### 修复
#### 存储数据一致性(全项目扫描 F0
- 修复外键约束失效根因:切换/删除话题与 http 清理路径补全被动删除(级联删除内存态与关联行),避免遗留孤立行导致外键约束在批量删除时触发失败。
- 计数一致性:删除后按活 COUNT 重算 `sessions/topics``message_count`,新增 106 行回归单测覆盖。
#### 并发健壮性
- bash 取消泄漏:取消时正确终止子进程并将运行中标记回收,避免 bash 工具取消后进程残留、占住资源。
- 子代理队列阻塞subagent 等待队列在取消/异常路径下正确清理修复子代理完成通知丢失导致主代理等待队列长期阻塞。task runtime 重构 158 行并补充取消语义。
#### 前端竞态与浮层
- `list_topics` / `switch_topic` 响应乱序覆盖:为话题列表与视图切换增加请求序号/响应对账,过期响应不再覆盖新状态,修复快速切换话题时列表与消息被旧响应覆盖。
- 图片放大与工具详情浮层修复被相邻内容覆盖及定位错乱问题MessageBubble 图片放大、ToolDetailModal 浮层)。
#### 指标可观测
- 初始化时即注册基线 counter避免各指标启动后首次渲染为空、直到首个事件才出现。
---
## [0.4.3] - 2026-08-18
较 [0.4.2] 的 14 个 commit 迭代,聚焦 **长话题历史分页**、**全链路性能优化(两批)**、**长会话稳定性兜底** 与 **对抗性审查修复** 四大方向。
### 新增功能
#### 长话题历史 keyset 分页加载
- 后端新增 `load_messages_for_topic_page`,按 `seq < cursor + limit``(session_id, seq)` 复合索引替代 OFFSET 深翻页,`ChatMessage` 增加 `seq` 游标字段,支持超大量历史消息。
- 前端触顶增量加载:批次缓存在 `pendingHistoryRef`,收到 `topic_history_end` 一次性去重 prepend`scrollTop` 按新增高度补偿锚定原头部消息不足一屏自动补页loading 超时 10s 自愈;流式输出中加载历史不清空流式累加器。
#### 设置页字段 UX 改进
- 设置页字段提示改为问号图标悬停气泡,避免长说明文本挤占布局宽度。
#### 子代理运行时长配置化
- `max_run_secs` 接入 `AgentConfig``config.json``agents` 段),不再硬编码;`From` 透传与所有字面构造点已补齐。
- `cli init` 写入 agent 路径时保留既有 `max_run_secs` 而非重置默认。
- `max_run_secs` 默认值由 360060 分钟)改为 0不限制避免长任务被单次执行时间上限中断需要时可在 `config.json``agents` 段显式配置。
### 性能优化
#### 第一批性能修复
- 飞书通道 HTTP client新增 connect/全局超时token 刷新与 reaction 叠加紧超时,消除无限挂起风险。
- SQLiteWAL 模式下 `synchronous=NORMAL`per-connection `with_init`6 处批量写入循环改用 `prepare_cached` 复用预编译语句,写入吞吐显著提升。
- `web_fetch`:复用长生命周期 HTTP client响应体改为流式限长读取上限=字符限额×4封顶 32MB替代全量读完再截断。
- 前端:`App.tsx` effect/callback 依赖由 `subAgentView` 对象改为 `subAgentTaskId` 原始值,修复子代理流式期间 token 统计 500ms 定时器被每帧重置永不触发;`MessageBubble` / `ToolDetailModal``ReactMarkdown` components 与 remarkPlugins 提升为模块级常量,避免流式渲染期间每帧重建。
#### 第二批性能修复
- 同步阻塞操作附件处理、历史加载、scheduler/memory_search 的 SQLite 调用)全部移入 `spawn_blocking`,避免占用 async worker 线程。
- LLM Provider `reqwest::Client` 按超时配置缓存复用,减少 TLS/连接建立开销。
- Agent loop图片过滤加廉价预判避免全量深拷贝请求克隆改借用工具定义 `Arc` 化减少大对象复制。
- 定向 `COUNT / LIMIT 1` 查询替代全量加载计数(`wait_coordinator``task session` 重建)。
- 前端:面板/侧栏/聊天组件 `memo` 化;`merged_tool` 对象按值复用缓存;流式 delta rAF 节流批量 flush`useMemo` 缓存分组排序结果。
#### MCP 可观测
- MCP tool 调用全链路往返耗时入口→MCP server→返回新增 debug 日志,便于定位慢调用。
### 修复
#### 长会话后新消息无响应(核心稳定性)
- 发送 `ExecutionCompleted` 前惰性清理僵尸 running 子代理DB 标记 running 但执行任务已消失),解除 pending 消息长期阻塞。
- Processor panic 路径补发 error 通知 + `ExecutionCompleted`,防止前端永久 loading。
- Agent 单轮增加墙钟预算 `max_run_secs`,超时优雅退出并释放 topic 串行锁。
- Topic 串行锁等待增加 info/warn 日志,长等待可观测。
- Sanitize 清理结果回写 DB`delete_messages_by_ids`),消除每次历史加载的重复修复开销。
- Storage 新增 keyset 分页与按 ID 批量删除的单元测试,回归有保障。
#### 对抗性审查修复
- 子代理 spawn 顺序调整为「先注册 `cancel_registry` → 再写 DB running 记录」,消除 reap 误杀刚启动子代理的微秒级竞态窗口。
- `delete_messages_by_ids` 删除后按活 COUNT 重算 `sessions/topics``message_count`,修复计数列漂移。
#### 存储数据正确性
- 归一化历史前缀累积污染的会话 ID一次性迁移修复会话 ID 被多次加前缀导致查询找不到对应行。
- 清理旧版本遗留的空 CLI 会话,`list_sessions` 日志级别从 info 降级 debug减少运行噪音。
#### 前端交互
- 回到顶部改用 rAF 自驱动动画,修复消息多时平滑滚动被 virtualizer 纠正写入打断停在半路。
- 视图切换滚动位置丢失:动态行高缓存按消息 id 键控,避免子智能体视图高度污染主视图,恢复后两帧 rAF 内不回写保存位置。
- 顶部按钮链式贴顶:动画落定显式续链,分页 prepend 时贴到新顶部直到真正首条消息,关闭 virtualizer 行高修正的 scrollTop 自动补偿;用户滚动输入/回底/待办高亮/视图切换/分页耗尽时清除贴顶意图。
#### Provider 请求兼容
- Anthropic 型 provider `max_tokens` 留空兜底从默认值提高至 8192。
- 模型未配置 Temperature 时,请求体 JSON 不再携带该字段,避免部分供应商对显式 null/空值报错。
#### Windows 终端卡死
- 启动时禁用 Windows 控制台 QuickEdit 模式并改为非阻塞日志写入,修复 stdout 输出被 QuickEdit 选中阻塞、需按回车才继续的历史顽疾。
---
## [0.4.2] - 2026-08-17 ## [0.4.2] - 2026-08-17
较 [0.4.1] 的 8 个 commit 迭代,聚焦 **安全漏洞修复**、**全链路性能优化** 与 **前端健壮性** 三大方向。 较 [0.4.1] 的 8 个 commit 迭代,聚焦 **安全漏洞修复**、**全链路性能优化** 与 **前端健壮性** 三大方向。

View File

@ -258,6 +258,43 @@ fn filter_images_by_age_and_count(
// 消息列表顺序:[old, ..., new],所以末尾是最新的 // 消息列表顺序:[old, ..., new],所以末尾是最新的
let msg_count = messages.len(); let msg_count = messages.len();
// 廉价预判:完整复刻下方保留逻辑,仅判断是否"确有图片会被移除"。
// 有图片但无需过滤时(常见情形)直接返回借用,避免每轮 LLM 迭代
// 对整个历史做全量深拷贝。
let mut dry_run_kept = 0usize;
for (idx, message) in messages.iter().enumerate().rev() {
let age_from_end = msg_count.saturating_sub(idx).saturating_sub(1);
let exceeds_age_limit = max_age_rounds > 0 && age_from_end >= max_age_rounds;
let image_count_in_msg = message
.media_refs
.iter()
.filter(|p| supported_image_mime_type(p).is_some())
.count();
if image_count_in_msg == 0 {
continue;
}
if exceeds_age_limit {
// 该消息的图片全部会被过滤
return filter_images_by_age_and_count_inner(messages, max_age_rounds, max_images);
}
let can_keep = std::cmp::min(image_count_in_msg, max_images.saturating_sub(dry_run_kept));
if can_keep < image_count_in_msg {
// 超出数量上限的图片会被过滤
return filter_images_by_age_and_count_inner(messages, max_age_rounds, max_images);
}
dry_run_kept += can_keep;
}
Cow::Borrowed(messages)
}
/// `filter_images_by_age_and_count` 的重建实现:仅在预判确认有图片需要过滤时调用。
fn filter_images_by_age_and_count_inner(
messages: &[ChatMessage],
max_age_rounds: usize,
max_images: usize,
) -> Cow<'_, [ChatMessage]> {
let msg_count = messages.len();
// 先从后向前遍历,计算每条消息应该保留多少张图片 // 先从后向前遍历,计算每条消息应该保留多少张图片
// 使用 Vec<usize> 存储每条消息应该保留的图片数量 // 使用 Vec<usize> 存储每条消息应该保留的图片数量
let mut images_to_keep_per_msg: Vec<usize> = vec![0; msg_count]; let mut images_to_keep_per_msg: Vec<usize> = vec![0; msg_count];
@ -363,6 +400,7 @@ fn filter_images_by_age_and_count(
tool_duration_ms: message.tool_duration_ms, tool_duration_ms: message.tool_duration_ms,
tool_calls: message.tool_calls.clone(), tool_calls: message.tool_calls.clone(),
usage: message.usage.clone(), usage: message.usage.clone(),
seq: None,
}); });
} }
@ -1132,7 +1170,8 @@ impl AgentLoop {
let tools = if tool_defs.is_empty() { let tools = if tool_defs.is_empty() {
None None
} else { } else {
Some(tool_defs) // Arc 共享process() 内多轮迭代只读复用,避免每轮深拷贝全部工具定义
Some(std::sync::Arc::new(tool_defs))
}; };
// 工具 token 估算在循环外算一次tool_defs 在 process() 期间不变), // 工具 token 估算在循环外算一次tool_defs 在 process() 期间不变),
// 避免每轮 serde_json::to_string 全量序列化工具定义。 // 避免每轮 serde_json::to_string 全量序列化工具定义。
@ -1141,6 +1180,7 @@ impl AgentLoop {
.map(estimate_tokens_from_serialized_json) .map(estimate_tokens_from_serialized_json)
.unwrap_or_default(); .unwrap_or_default();
let run_started = Instant::now();
for iteration in 0..self.max_iterations { for iteration in 0..self.max_iterations {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(iteration, "Agent iteration started"); tracing::debug!(iteration, "Agent iteration started");
@ -1156,6 +1196,28 @@ impl AgentLoop {
return Ok(cancel); return Ok(cancel);
} }
// 墙钟预算max_tool_iterations 很大(默认 1000单轮合法长跑
// 可能占住 topic serial lock 数小时,期间所有新用户消息无限排队。
// 超预算优雅退出并说明,让用户可以继续交互。
if self.runtime_config.max_run_secs > 0 {
let elapsed = run_started.elapsed();
let budget = Duration::from_secs(self.runtime_config.max_run_secs);
if elapsed >= budget {
tracing::warn!(
iteration,
elapsed_secs = elapsed.as_secs(),
max_run_secs = self.runtime_config.max_run_secs,
emitted_count = emitted_messages.len(),
"Agent run wall-clock budget exhausted, exiting gracefully"
);
let exhausted =
Self::build_budget_exhausted_result(iteration, elapsed, emitted_messages);
self.emit_live_tool_call_message(exhausted.final_response.clone())
.await;
return Ok(exhausted);
}
}
// Defense-in-depth: sanitize incomplete tool call sequences // Defense-in-depth: sanitize incomplete tool call sequences
// before EVERY LLM request, not just once at process() entry. // before EVERY LLM request, not just once at process() entry.
// This catches edge cases where compression, persistence races, // This catches edge cases where compression, persistence races,
@ -1247,7 +1309,7 @@ impl AgentLoop {
self.emit_live_tool_call_message(cancel.final_response.clone()).await; self.emit_live_tool_call_message(cancel.final_response.clone()).await;
return Ok(cancel); return Ok(cancel);
} }
result = self.provider.chat_with_streaming(request.clone(), stream_callback.clone()) => { result = self.provider.chat_with_streaming(&request, stream_callback.clone()) => {
llm_result = result; llm_result = result;
} }
} }
@ -1255,7 +1317,7 @@ impl AgentLoop {
} else { } else {
llm_result = self llm_result = self
.provider .provider
.chat_with_streaming(request.clone(), stream_callback) .chat_with_streaming(&request, stream_callback)
.await; .await;
} }
@ -1636,7 +1698,7 @@ impl AgentLoop {
&self, &self,
messages: &[ChatMessage], messages: &[ChatMessage],
system_prompt_context: Option<&SystemPromptContext>, system_prompt_context: Option<&SystemPromptContext>,
tools: Option<Vec<crate::domain::tools::Tool>>, tools: Option<std::sync::Arc<Vec<crate::domain::tools::Tool>>>,
tools_tokens: usize, tools_tokens: usize,
) -> ChatCompletionRequest { ) -> ChatCompletionRequest {
let filtered_messages = filter_images_by_age_and_count( let filtered_messages = filter_images_by_age_and_count(
@ -1827,12 +1889,12 @@ impl AgentLoop {
self.emit_live_tool_call_message(cancel.final_response.clone()).await; self.emit_live_tool_call_message(cancel.final_response.clone()).await;
return cancel; return cancel;
} }
result = self.provider.chat(request.clone()) => { result = self.provider.chat(&request) => {
final_result = result; final_result = result;
} }
} }
} else { } else {
final_result = self.provider.chat(request.clone()).await; final_result = self.provider.chat(&request).await;
} }
match final_result { match final_result {
@ -1992,6 +2054,29 @@ impl AgentLoop {
} }
} }
/// 墙钟预算耗尽的优雅退出结果。
fn build_budget_exhausted_result(
iteration: usize,
elapsed: std::time::Duration,
mut emitted_messages: Vec<ChatMessage>,
) -> AgentProcessResult {
let emitted_count = emitted_messages.len();
let message = format!(
"\n\n[本轮运行已达单次执行时间上限({} 分钟,实际迭代 {} 次,生成 {} 条消息),已自动停止以释放会话。任务如有未完成部分,请发送新消息继续。]",
elapsed.as_secs() / 60,
iteration,
emitted_count
);
let assistant_message = ChatMessage::assistant(message);
emitted_messages.push(assistant_message.clone());
AgentProcessResult {
final_response: assistant_message,
emitted_messages,
compaction_performed: false,
engineering_compaction_applied: false,
}
}
async fn emit_live_tool_call_message(&self, message: ChatMessage) { async fn emit_live_tool_call_message(&self, message: ChatMessage) {
if let Some(handler) = &self.emitted_message_handler { if let Some(handler) = &self.emitted_message_handler {
handler.handle(message).await; handler.handle(message).await;
@ -2263,6 +2348,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }
@ -3320,7 +3406,7 @@ mod tests {
impl LLMProvider for MockProvider { impl LLMProvider for MockProvider {
async fn chat( async fn chat(
&self, &self,
_request: ChatCompletionRequest, _request: &ChatCompletionRequest,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
let mut responses = self.responses.lock().unwrap(); let mut responses = self.responses.lock().unwrap();
if responses.is_empty() { if responses.is_empty() {

View File

@ -458,7 +458,7 @@ OLDER SEGMENT (events from earlier in the session):
}; };
let response = provider let response = provider
.chat(request) .chat(&request)
.await .await
.map_err(|e| AgentError::LlmError(e.to_string()))?; .map_err(|e| AgentError::LlmError(e.to_string()))?;
Ok(response.content) Ok(response.content)
@ -540,7 +540,7 @@ OLDER SEGMENT (events from earlier in the session):
}; };
let response = provider let response = provider
.chat(request) .chat(&request)
.await .await
.map_err(|e| AgentError::LlmError(e.to_string()))?; .map_err(|e| AgentError::LlmError(e.to_string()))?;
Ok(response.content) Ok(response.content)
@ -1410,6 +1410,7 @@ mod tests {
max_tool_iterations: 100, max_tool_iterations: 100,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 2_000, context_tool_result_trim_chars: 2_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 10, max_images_in_context: 10,
max_image_age_rounds: 50, max_image_age_rounds: 50,
}; };

View File

@ -8,7 +8,7 @@ pub use agent_loop::{
PersistingEmittedMessageHandler, SkillProvider, PersistingEmittedMessageHandler, SkillProvider,
}; };
pub use context_compressor::ContextCompressor; pub use context_compressor::ContextCompressor;
pub use runtime_config::AgentRuntimeConfig; pub use runtime_config::{AgentRuntimeConfig, DEFAULT_MAX_RUN_SECS};
pub use system_prompt::{ pub use system_prompt::{
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider, CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
generate_system_env_prompt, generate_system_env_prompt,

View File

@ -15,8 +15,16 @@ pub struct AgentRuntimeConfig {
/// LLM 请求瞬态失败的最大重试次数(仅对 timeout/502/503/504/429 等可恢复错误重试)。 /// LLM 请求瞬态失败的最大重试次数(仅对 timeout/502/503/504/429 等可恢复错误重试)。
/// 0 表示不重试。归属 agent 行为层,不进 ProviderRuntimeConfig保持 provider 构造包纯净)。 /// 0 表示不重试。归属 agent 行为层,不进 ProviderRuntimeConfig保持 provider 构造包纯净)。
pub max_retries: u32, pub max_retries: u32,
/// 单次 process() 的墙钟预算(秒)。超时后 agent 优雅退出并给出说明。
/// 防止 max_tool_iterations 很大(默认 1000单轮合法长跑占住
/// topic serial lock 数小时,期间所有新用户消息无限排队。
/// 0 表示不限制。默认见 DEFAULT_MAX_RUN_SECS。
pub max_run_secs: u64,
} }
/// 单次 agent run 默认墙钟预算。默认 0 表示不限制,需要时可在 config.json 的 agents 段按 agent 设置。
pub const DEFAULT_MAX_RUN_SECS: u64 = 0;
impl From<LLMProviderConfig> for AgentRuntimeConfig { impl From<LLMProviderConfig> for AgentRuntimeConfig {
fn from(config: LLMProviderConfig) -> Self { fn from(config: LLMProviderConfig) -> Self {
let context_window_tokens = config.context_window_tokens(); let context_window_tokens = config.context_window_tokens();
@ -43,6 +51,7 @@ impl From<LLMProviderConfig> for AgentRuntimeConfig {
max_images_in_context: config.max_images_in_context, max_images_in_context: config.max_images_in_context,
max_image_age_rounds: config.max_image_age_rounds, max_image_age_rounds: config.max_image_age_rounds,
max_retries: config.max_retries, max_retries: config.max_retries,
max_run_secs: config.max_run_secs,
} }
} }
} }

View File

@ -1,4 +1,5 @@
pub fn initialize_process_runtime() { pub fn initialize_process_runtime() {
crate::platform::disable_console_quick_edit();
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
// Install a global panic hook so that any panic in a spawned task // Install a global panic hook so that any panic in a spawned task

View File

@ -53,6 +53,10 @@ pub struct ChatMessage {
pub content: String, pub content: String,
pub media_refs: Vec<String>, // Paths to media files for context pub media_refs: Vec<String>, // Paths to media files for context
pub timestamp: i64, pub timestamp: i64,
/// 会话内单调递增序号DB 分配)。仅历史加载路径填充;
/// 实时推送路径不填,前端以此区分"历史批次"与"实时消息"。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system_context: Option<String>, pub system_context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -122,6 +126,7 @@ impl ChatMessage {
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
usage: None, usage: None,
seq: None,
} }
} }
@ -140,6 +145,7 @@ impl ChatMessage {
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
usage: None, usage: None,
seq: None,
} }
} }
@ -158,6 +164,7 @@ impl ChatMessage {
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
usage: None, usage: None,
seq: None,
} }
} }
@ -188,6 +195,7 @@ impl ChatMessage {
tool_state: None, tool_state: None,
tool_calls: Some(tool_calls), tool_calls: Some(tool_calls),
usage: None, usage: None,
seq: None,
} }
} }
@ -223,6 +231,7 @@ impl ChatMessage {
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
usage: None, usage: None,
seq: None,
} }
} }
@ -259,6 +268,7 @@ impl ChatMessage {
tool_state: Some(tool_state), tool_state: Some(tool_state),
tool_calls: None, tool_calls: None,
usage: None, usage: None,
seq: None,
} }
} }
@ -300,7 +310,16 @@ impl ChatMessage {
/// ///
/// Returns the number of messages removed. /// Returns the number of messages removed.
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize { pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
sanitize_incomplete_tool_call_sequences_with_ids(messages).0
}
/// 同 [sanitize_incomplete_tool_call_sequences],但额外返回被移除消息的 id 列表。
/// 调用方(历史加载路径)可据此把修复结果回写 DB避免每次加载重复修复。
pub(crate) fn sanitize_incomplete_tool_call_sequences_with_ids(
messages: &mut Vec<ChatMessage>,
) -> (usize, Vec<String>) {
let mut removed = 0; let mut removed = 0;
let mut removed_ids: Vec<String> = Vec::new();
// Phase 1: Single reverse pass to find ALL assistant messages with // Phase 1: Single reverse pass to find ALL assistant messages with
// incomplete tool_calls, regardless of position. // incomplete tool_calls, regardless of position.
@ -451,6 +470,7 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
remove_indices.sort_unstable_by(|a, b| b.cmp(a)); remove_indices.sort_unstable_by(|a, b| b.cmp(a));
remove_indices.dedup(); remove_indices.dedup();
for &idx in &remove_indices { for &idx in &remove_indices {
removed_ids.push(messages[idx].id.clone());
messages.remove(idx); messages.remove(idx);
removed += 1; removed += 1;
} }
@ -480,6 +500,7 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
"Removing orphaned tool result message — its parent assistant \ "Removing orphaned tool result message — its parent assistant \
tool_calls message was removed or never persisted" tool_calls message was removed or never persisted"
); );
removed_ids.push(msg.id.clone());
messages.remove(i); messages.remove(i);
removed += 1; removed += 1;
continue; continue;
@ -489,7 +510,7 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
} }
} }
removed (removed, removed_ids)
} }
// ============================================================================ // ============================================================================

View File

@ -2,8 +2,9 @@ pub mod message;
pub use crate::domain::messages::ContentBlock; pub use crate::domain::messages::ContentBlock;
pub use message::{ pub use message::{
ChatMessage, InboundMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_AGENT_PROMPT, ChatMessage, InboundMessage, MediaItem, MessageUsage, OutboundMessage,
SYSTEM_CONTEXT_HISTORY_COMPACTION, SYSTEM_CONTEXT_SCHEDULED_PROMPT, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
}; };
use std::sync::Arc; use std::sync::Arc;

View File

@ -30,6 +30,17 @@ const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
/// Dedup cache TTL (30 minutes). /// Dedup cache TTL (30 minutes).
const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60); const DEDUP_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
/// TCP/TLS 建连超时:避免半开连接或黑洞路由导致无限等待。
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// 全局请求超时上限:覆盖媒体上传/下载等大体积传输,
/// 保证任何请求(含数十 MB 文件)的等待时间有界。
const HTTP_TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
/// 控制面小请求token 刷新)的紧超时:挂起会阻塞所有出站消息。
const HTTP_API_TIMEOUT: Duration = Duration::from_secs(30);
/// reaction 请求的紧超时:该调用发生在 WS 帧处理循环内,
/// 挂起会导致后续消息无法在飞书要求的 3 秒内 ACK。
const HTTP_REACTION_TIMEOUT: Duration = Duration::from_secs(15);
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Protobuf types for Feishu WebSocket protocol (pbbp2.proto) // Protobuf types for Feishu WebSocket protocol (pbbp2.proto)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -181,10 +192,18 @@ impl FeishuChannel {
config: FeishuChannelConfig, config: FeishuChannelConfig,
_provider_config: LLMProviderConfig, _provider_config: LLMProviderConfig,
) -> Result<Self, ChannelError> { ) -> Result<Self, ChannelError> {
let http_client = reqwest::Client::builder()
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.timeout(HTTP_TOTAL_TIMEOUT)
.build()
.map_err(|e| {
ChannelError::Other(format!("Failed to build Feishu HTTP client: {}", e))
})?;
Ok(Self { Ok(Self {
name, name,
config, config,
http_client: reqwest::Client::new(), http_client,
running: Arc::new(RwLock::new(false)), running: Arc::new(RwLock::new(false)),
shutdown_tx: Arc::new(RwLock::new(None)), shutdown_tx: Arc::new(RwLock::new(None)),
connected: Arc::new(RwLock::new(false)), connected: Arc::new(RwLock::new(false)),
@ -265,6 +284,7 @@ impl FeishuChannel {
"{}/auth/v3/tenant_access_token/internal", "{}/auth/v3/tenant_access_token/internal",
FEISHU_API_BASE FEISHU_API_BASE
)) ))
.timeout(HTTP_API_TIMEOUT)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&serde_json::json!({ .json(&serde_json::json!({
"app_id": self.config.app_id, "app_id": self.config.app_id,
@ -672,6 +692,7 @@ impl FeishuChannel {
"{}/im/v1/messages/{}/reactions", "{}/im/v1/messages/{}/reactions",
FEISHU_API_BASE, message_id FEISHU_API_BASE, message_id
)) ))
.timeout(HTTP_REACTION_TIMEOUT)
.header("Authorization", format!("Bearer {}", token)) .header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ .json(&serde_json::json!({
"reaction_type": { "emoji_type": emoji } "reaction_type": { "emoji_type": emoji }

View File

@ -564,6 +564,7 @@ impl InitWizard {
max_tool_iterations: 1000, max_tool_iterations: 1000,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 2000, context_tool_result_trim_chars: 2000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
}; };
let mut agents = existing.agents.clone(); let mut agents = existing.agents.clone();
@ -629,6 +630,7 @@ impl InitWizard {
max_tool_iterations: current_agent.max_tool_iterations, max_tool_iterations: current_agent.max_tool_iterations,
tool_result_max_chars: current_agent.tool_result_max_chars, tool_result_max_chars: current_agent.tool_result_max_chars,
context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars, context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars,
max_run_secs: current_agent.max_run_secs,
}; };
let mut agents = existing.agents.clone(); let mut agents = existing.agents.clone();

View File

@ -85,6 +85,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
@ -120,6 +121,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
@ -145,6 +147,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
} }
} else if let Some(session_id) = response.metadata.get("session_id") { } else if let Some(session_id) = response.metadata.get("session_id") {
@ -193,6 +196,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
} }
} else if let Some(sessions_json) = response.metadata.get("sessions") { } else if let Some(sessions_json) = response.metadata.get("sessions") {
@ -218,6 +222,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
@ -243,6 +248,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
} }
} else { } else {
@ -257,6 +263,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
} }
} }
} }
@ -276,6 +283,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -1,7 +1,6 @@
use crate::command::Command; use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use crate::storage::SessionStore; use crate::storage::SessionStore;
@ -82,12 +81,12 @@ async fn handle_delete_topic(
.map_err(|e| CommandError::new("DELETE_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("DELETE_TOPIC_ERROR", e.to_string()))?;
// 查询更新后的话题列表,返回给前端刷新侧边栏 // 查询更新后的话题列表,返回给前端刷新侧边栏
let topics = handler let topic_summaries =
.store crate::command::handlers::list_topics::list_topic_summaries_blocking(
.list_topics(session_id) handler.store.clone(),
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; session_id,
)
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?; .await?;
let topics_json = serde_json::to_string(&topic_summaries) let topics_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -73,11 +73,9 @@ async fn handle_list_sessions(
let is_current = topic.id == current_topic_id; let is_current = topic.id == current_topic_id;
let marker = if is_current { " *" } else { "" }; let marker = if is_current { " *" } else { "" };
// 使用辅助方法获取消息数量 // 使用 topics 表预计算列,避免循环内 N+1 次 COUNT 查询
let msg_count = handler // message_count 与 list_topics 的展示口径一致)
.store let msg_count = topic.message_count;
.get_topic_message_count(&topic.id)
.unwrap_or(0);
lines.push(format!( lines.push(format!(
"{}. {}{} ({})", "{}. {}{} ({})",

View File

@ -97,6 +97,28 @@ pub fn build_topic_summaries(
Ok(summaries) Ok(summaries)
} }
/// 在 blocking 线程池中执行 list_topics + build_topic_summaries。
///
/// 同步 rusqlite 查询不得直接跑在 tokio worker 上,否则大库查询会饿死
/// 同运行时上的其他任务。list / create / delete / rename 四个话题命令
/// 都返回完整的 TopicSummary 列表供前端刷新侧边栏,统一走本 helper。
pub async fn list_topic_summaries_blocking(
store: Arc<SessionStore>,
session_id: &str,
) -> Result<Vec<TopicSummary>, CommandError> {
let session_id_bg = session_id.to_string();
tokio::task::spawn_blocking(
move || -> Result<Vec<TopicSummary>, CommandError> {
let topics = store
.list_topics(&session_id_bg)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
build_topic_summaries(store.as_ref(), topics)
},
)
.await
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?
}
/// 列出 Session 的 Topics 命令处理器 /// 列出 Session 的 Topics 命令处理器
pub struct ListTopicsCommandHandler { pub struct ListTopicsCommandHandler {
store: Arc<SessionStore>, store: Arc<SessionStore>,
@ -139,12 +161,8 @@ async fn handle_list_topics(
session_id: String, session_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
let topics = handler let summaries =
.store list_topic_summaries_blocking(handler.store.clone(), &session_id).await?;
.list_topics(&session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
let topics_json = serde_json::to_string(&summaries) let topics_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -63,3 +63,64 @@ impl CommandHandler for LoadChatMessagesCommandHandler {
} }
} }
} }
/// 分页加载话题更早历史消息。校验参数后由 ws.rs 读取 metadata 执行加载。
pub struct LoadOlderMessagesCommandHandler;
impl LoadOlderMessagesCommandHandler {
pub fn new() -> Self {
Self
}
}
impl Default for LoadOlderMessagesCommandHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CommandHandler for LoadOlderMessagesCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::LoadOlderMessages { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "load_older_messages",
description: "分页加载话题更早的历史消息",
usage: "/load_older_messages <topic_id> <before_seq>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::LoadOlderMessages {
topic_id,
before_seq,
} => {
if topic_id.is_empty() {
return Err(CommandError::new(
"INVALID_ARGUMENT",
"topic_id must not be empty".to_string(),
));
}
if before_seq < 0 {
return Err(CommandError::new(
"INVALID_ARGUMENT",
"before_seq must be non-negative".to_string(),
));
}
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("load_older_topic_id", &topic_id)
.with_metadata("load_older_before_seq", before_seq.to_string()))
}
_ => unreachable!(),
}
}
}

View File

@ -141,15 +141,15 @@ fn reconstruct_task_from_db(
store: &SessionStore, store: &SessionStore,
task_id: &str, task_id: &str,
) -> Result<Option<TaskSession>, CommandError> { ) -> Result<Option<TaskSession>, CommandError> {
let sessions = store let record = store
.find_sessions_by_id_suffix(&format!(":{}", task_id)) .find_first_session_by_id_suffix(&format!(":{}", task_id))
.map_err(|e| CommandError::new("DB_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("DB_ERROR", e.to_string()))?;
if sessions.is_empty() { let record = match record {
return Ok(None); Some(record) => record,
} None => return Ok(None),
};
let record = &sessions[0];
let session_id = record.id.clone(); let session_id = record.id.clone();
// Extract parent_session_id from session_id: "sub:{parent}:task:{uuid}" // Extract parent_session_id from session_id: "sub:{parent}:task:{uuid}"

View File

@ -1,7 +1,6 @@
use crate::command::Command; use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
@ -84,11 +83,12 @@ async fn handle_rename_topic(
// 标题未变化时直接返回当前列表,避免无意义写入 // 标题未变化时直接返回当前列表,避免无意义写入
if old_display == trimmed_title { if old_display == trimmed_title {
let topics = handler let topic_summaries =
.store crate::command::handlers::list_topics::list_topic_summaries_blocking(
.list_topics(session_id) handler.store.clone(),
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; session_id,
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?; )
.await?;
let topic_summaries_json = serde_json::to_string(&topic_summaries) let topic_summaries_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
@ -110,12 +110,12 @@ async fn handle_rename_topic(
.map_err(|e| CommandError::new("RENAME_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("RENAME_TOPIC_ERROR", e.to_string()))?;
// 查询更新后的话题列表,返回给前端刷新侧边栏 // 查询更新后的话题列表,返回给前端刷新侧边栏
let topics = handler let topic_summaries =
.store crate::command::handlers::list_topics::list_topic_summaries_blocking(
.list_topics(session_id) handler.store.clone(),
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; session_id,
)
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?; .await?;
let topic_summaries_json = serde_json::to_string(&topic_summaries) let topic_summaries_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -33,7 +33,7 @@ pub async fn save_session_to_file(
store: &SessionStore, store: &SessionStore,
task_repository: Option<&dyn TaskRepository>, task_repository: Option<&dyn TaskRepository>,
system_prompt_provider: &dyn SystemPromptProvider, system_prompt_provider: &dyn SystemPromptProvider,
) -> Result<PathBuf, String> { ) -> Result<(PathBuf, usize), String> {
// 获取会话记录 // 获取会话记录
let record = store let record = store
.get_session(session_id) .get_session(session_id)
@ -83,7 +83,8 @@ pub async fn save_session_to_file(
// 写入文件 // 写入文件
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?; std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path) // 返回已加载的消息数,调用方无需为此再次全量加载消息
Ok((output_path, messages.len()))
} }
/// 保存会话命令处理器 /// 保存会话命令处理器
@ -187,8 +188,8 @@ async fn handle_save_session(
} }
} }
// 调用公共函数 // 调用公共函数(返回路径与已加载消息数,避免二次全量加载只为计数)
let output_path = save_session_to_file( let (output_path, message_count) = save_session_to_file(
session_id, session_id,
filepath, filepath,
include_all, include_all,
@ -200,15 +201,6 @@ async fn handle_save_session(
.await .await
.map_err(|e| CommandError::new("SAVE_ERROR", e))?; .map_err(|e| CommandError::new("SAVE_ERROR", e))?;
// 根据 include_all 获取消息数量
let message_count = if include_all {
handler.store.load_all_messages(session_id)
} else {
handler.store.load_messages(session_id)
}
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
.len();
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_message( .with_message(
MessageKind::Notification, MessageKind::Notification,
@ -716,7 +708,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
// 返回成功或失败消息 // 返回成功或失败消息
match result { match result {
Ok(output_path) => { Ok((output_path, _message_count)) => {
let msg = format!( let msg = format!(
"Session saved to: {}", "Session saved to: {}",
output_path.display().to_string().replace('\\', "/") output_path.display().to_string().replace('\\', "/")

View File

@ -1,7 +1,6 @@
use crate::command::Command; use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::build_topic_summaries;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use crate::storage::SessionStore; use crate::storage::SessionStore;
@ -104,12 +103,12 @@ async fn handle_create_session(
} }
// Query the full topic list so the frontend sidebar can update // Query the full topic list so the frontend sidebar can update
let topics = handler let topic_summaries =
.store crate::command::handlers::list_topics::list_topic_summaries_blocking(
.list_topics(session_id) handler.store.clone(),
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; session_id,
)
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?; .await?;
let topics_json = serde_json::to_string(&topic_summaries) let topics_json = serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;

View File

@ -68,39 +68,52 @@ async fn handle_switch_topic(
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 尝试解析为序号 // 尝试解析为序号
// 同步 rusqlite 查询统一移入 blocking 线程池(切换话题是侧边栏主交互)
let target_topic_id = if let Ok(index) = topic_id.parse::<usize>() { let target_topic_id = if let Ok(index) = topic_id.parse::<usize>() {
let topics = handler let store = handler.store.clone();
.store let session_id_bg = session_id.to_string();
.list_topics(session_id) tokio::task::spawn_blocking(move || -> Result<String, CommandError> {
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; let topics = store
.list_topics(&session_id_bg)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let index = index.saturating_sub(1); let index = index.saturating_sub(1);
if index >= topics.len() { if index >= topics.len() {
return Err(CommandError::new( return Err(CommandError::new(
"INVALID_TOPIC_INDEX", "INVALID_TOPIC_INDEX",
format!( format!(
"Topic index {} is out of range (1-{})", "Topic index {} is out of range (1-{})",
index + 1, index + 1,
topics.len() topics.len()
), ),
)); ));
} }
topics[index].id.clone() Ok(topics[index].id.clone())
})
.await
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))??
} else { } else {
topic_id topic_id
}; };
// 验证目标话题存在 // 验证目标话题存在
let topic = handler let topic = {
.store let store = handler.store.clone();
.get_topic(&target_topic_id) let topic_id_bg = target_topic_id.clone();
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))? tokio::task::spawn_blocking(move || {
store
.get_topic(&topic_id_bg)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))
})
.await
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))??
.ok_or_else(|| { .ok_or_else(|| {
CommandError::new( CommandError::new(
"TOPIC_NOT_FOUND", "TOPIC_NOT_FOUND",
format!("Topic not found: {}", target_topic_id), format!("Topic not found: {}", target_topic_id),
) )
})?; })?
};
// 如果有 SessionManager实际切换话题历史 // 如果有 SessionManager实际切换话题历史
if let Some(ref session_manager) = handler.session_manager if let Some(ref session_manager) = handler.session_manager
@ -113,10 +126,15 @@ async fn handle_switch_topic(
} }
// 使用辅助方法获取消息数量 // 使用辅助方法获取消息数量
let msg_count = handler let msg_count = {
.store let store = handler.store.clone();
.get_topic_message_count(&target_topic_id) let topic_id_bg = target_topic_id.clone();
.unwrap_or(0); tokio::task::spawn_blocking(move || store.get_topic_message_count(&topic_id_bg))
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(0)
};
let message = format!( let message = format!(
"✓ Switched to topic: {} ({} messages)", "✓ Switched to topic: {} ({} messages)",

View File

@ -49,6 +49,11 @@ pub enum Command {
ListSchedulerJobs, ListSchedulerJobs,
/// 加载指定 channel + chat_id 的对话消息 /// 加载指定 channel + chat_id 的对话消息
LoadChatMessages { channel: String, chat_id: String }, LoadChatMessages { channel: String, chat_id: String },
/// 分页加载话题更早的历史消息(用户向上滚动触发)
LoadOlderMessages {
topic_id: String,
before_seq: i64,
},
/// 删除指定话题 /// 删除指定话题
DeleteTopic { topic_id: String }, DeleteTopic { topic_id: String },
/// 重命名指定话题 /// 重命名指定话题
@ -95,6 +100,7 @@ impl Command {
Command::LoadTaskMessages { .. } => "load_task_messages", Command::LoadTaskMessages { .. } => "load_task_messages",
Command::ListSchedulerJobs => "list_scheduler_jobs", Command::ListSchedulerJobs => "list_scheduler_jobs",
Command::LoadChatMessages { .. } => "load_chat_messages", Command::LoadChatMessages { .. } => "load_chat_messages",
Command::LoadOlderMessages { .. } => "load_older_messages",
Command::DeleteTopic { .. } => "delete_topic", Command::DeleteTopic { .. } => "delete_topic",
Command::RenameTopic { .. } => "rename_topic", Command::RenameTopic { .. } => "rename_topic",
Command::StopExecution => "stop_execution", Command::StopExecution => "stop_execution",

View File

@ -571,6 +571,10 @@ pub struct AgentConfig {
pub tool_result_max_chars: usize, pub tool_result_max_chars: usize,
#[serde(default = "default_context_tool_result_trim_chars")] #[serde(default = "default_context_tool_result_trim_chars")]
pub context_tool_result_trim_chars: usize, pub context_tool_result_trim_chars: usize,
/// 单次 agent run 的墙钟预算(秒)。超时后优雅退出并提示用户可继续。
/// 0 表示不限制。默认见 default_max_run_secs。
#[serde(default = "default_max_run_secs")]
pub max_run_secs: u64,
} }
fn default_max_tool_iterations() -> usize { fn default_max_tool_iterations() -> usize {
@ -597,6 +601,10 @@ fn default_max_retries() -> u32 {
3 3
} }
fn default_max_run_secs() -> u64 {
crate::agent::DEFAULT_MAX_RUN_SECS
}
fn default_mcp_tool_timeout_secs() -> u64 { fn default_mcp_tool_timeout_secs() -> u64 {
300 300
} }
@ -982,6 +990,8 @@ pub struct LLMProviderConfig {
pub max_tool_iterations: usize, pub max_tool_iterations: usize,
pub tool_result_max_chars: usize, pub tool_result_max_chars: usize,
pub context_tool_result_trim_chars: usize, pub context_tool_result_trim_chars: usize,
/// 单次 agent run 墙钟预算(秒,透传自 AgentConfig。0 表示不限制。
pub max_run_secs: u64,
/// 图片上下文限制配置 /// 图片上下文限制配置
pub max_images_in_context: usize, pub max_images_in_context: usize,
pub max_image_age_rounds: usize, pub max_image_age_rounds: usize,
@ -1130,6 +1140,7 @@ impl Config {
max_tool_iterations: agent.max_tool_iterations, max_tool_iterations: agent.max_tool_iterations,
tool_result_max_chars: agent.tool_result_max_chars, tool_result_max_chars: agent.tool_result_max_chars,
context_tool_result_trim_chars: agent.context_tool_result_trim_chars, context_tool_result_trim_chars: agent.context_tool_result_trim_chars,
max_run_secs: agent.max_run_secs,
max_images_in_context: self.image_context.max_images_in_context, max_images_in_context: self.image_context.max_images_in_context,
max_image_age_rounds: self.image_context.max_image_age_rounds, max_image_age_rounds: self.image_context.max_image_age_rounds,
}) })

View File

@ -129,6 +129,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }

View File

@ -311,7 +311,46 @@ impl AgentExecutionService {
// await 串行锁时不持有 session 锁,其他 topic 的消息可以正常处理 // await 串行锁时不持有 session 锁,其他 topic 的消息可以正常处理
// 使用 lock_owned 获取 OwnedMutexGuard存入 guard_slot 供 wait_coordinator 释放/重获取 // 使用 lock_owned 获取 OwnedMutexGuard存入 guard_slot 供 wait_coordinator 释放/重获取
// 注意lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用 // 注意lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用
let serial_guard = serial_lock.clone().lock_owned().await; //
// 可观测性:此前等待完全静默(无日志无反馈),前序 run 若长时间不结束,
// 用户视角即"发消息无任何响应"。此处每 30s 打一条 warn 标记等待进展,
// 拿到锁后打 info 收尾,便于从日志定位"卡在等锁"还是"卡在 run 内部"。
let serial_guard = {
let lock_for_wait = serial_lock.clone();
let mut waited = false;
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
lock_for_wait.clone().lock_owned(),
)
.await
{
Ok(guard) => {
if waited {
tracing::info!(
topic_id = %lock_key,
"Topic serial lock acquired after waiting"
);
}
break guard;
}
Err(_) => {
if !waited {
tracing::info!(
topic_id = %lock_key,
"Waiting for topic serial lock (previous run still in progress)"
);
waited = true;
} else {
tracing::warn!(
topic_id = %lock_key,
"Still waiting for topic serial lock (previous run still in progress)"
);
}
}
}
}
};
// guard_slotwait_coordinator 通过此 slot 释放/重获取 serial_lock。 // guard_slotwait_coordinator 通过此 slot 释放/重获取 serial_lock。
// 正常执行时 guard 留在 slot 中锁持有wait 工具调用时 take guard 释放锁, // 正常执行时 guard 留在 slot 中锁持有wait 工具调用时 take guard 释放锁,

View File

@ -1246,9 +1246,14 @@ pub async fn topic_select_model(
// 同时取 topic 行自带的 session_id 作为双写目标——不信任请求体中的 session_id // 同时取 topic 行自带的 session_id 作为双写目标——不信任请求体中的 session_id
// 防止客户端误传导致污染其他 session 的默认模型。 // 防止客户端误传导致污染其他 session 的默认模型。
let store = state.session_manager.store(); let store = state.session_manager.store();
let topic_session_id = match store.get_topic(&req.topic_id) { let topic_lookup = {
Ok(Some(topic)) => topic.session_id, let store_bg = store.clone();
Ok(None) => { let topic_id_bg = req.topic_id.clone();
tokio::task::spawn_blocking(move || store_bg.get_topic(&topic_id_bg)).await
};
let topic_session_id = match topic_lookup {
Ok(Ok(Some(topic))) => topic.session_id,
Ok(Ok(None)) => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1257,12 +1262,17 @@ pub async fn topic_select_model(
}), }),
); );
} }
Err(e) => { other => {
let err = match other {
Ok(Err(e)) => e.to_string(),
Err(e) => e.to_string(),
_ => unreachable!(),
};
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(SelectModelResponse { Json(SelectModelResponse {
success: false, success: false,
error: Some(format!("failed to load topic: {}", e)), error: Some(format!("failed to load topic: {}", err)),
}), }),
); );
} }
@ -1295,7 +1305,17 @@ pub async fn topic_select_model(
drop(config); drop(config);
let is_clear = provider.is_none() && model.is_none(); let is_clear = provider.is_none() && model.is_none();
if let Err(e) = store.update_topic_model(&req.topic_id, provider.as_deref(), model.as_deref()) { let update_result = {
let store_bg = store.clone();
let topic_id_bg = req.topic_id.clone();
let provider_bg = provider.clone();
let model_bg = model.clone();
tokio::task::spawn_blocking(move || {
store_bg.update_topic_model(&topic_id_bg, provider_bg.as_deref(), model_bg.as_deref())
})
.await
};
if let Err(e) = update_result.map_err(|e| e.to_string()).and_then(|r| r.map_err(|e| e.to_string())) {
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(SelectModelResponse { Json(SelectModelResponse {
@ -1346,23 +1366,31 @@ pub async fn topic_selected_model(
// topic 级命中直接返回miss 时按 topic 行的 session_id 回退 session 级 // topic 级命中直接返回miss 时按 topic 行的 session_id 回退 session 级
let (provider, model) = match state.topic_model_selections.get(&q.topic_id) { let (provider, model) = match state.topic_model_selections.get(&q.topic_id) {
Some(selection) => selection, Some(selection) => selection,
None => match state.session_manager.store().get_topic(&q.topic_id) { None => {
Ok(Some(topic)) => { // 同步 rusqlite 查询移入 blocking 线程池:本路由在每次打开/切换
// 双保险SQLite 有物化值但缓存 miss理论上不会发生时回填缓存 // 话题且缓存 miss 时被调用,不得阻塞 axum async worker
if topic.provider.is_some() || topic.model.is_some() { let store = state.session_manager.store().clone();
state.topic_model_selections.set( let topic_id_bg = q.topic_id.clone();
&q.topic_id, let topic_lookup =
topic.provider.clone(), tokio::task::spawn_blocking(move || store.get_topic(&topic_id_bg)).await;
topic.model.clone(), match topic_lookup {
); Ok(Ok(Some(topic))) => {
// 双保险SQLite 有物化值但缓存 miss理论上不会发生时回填缓存
if topic.provider.is_some() || topic.model.is_some() {
state.topic_model_selections.set(
&q.topic_id,
topic.provider.clone(),
topic.model.clone(),
);
}
state
.model_selections
.get(&topic.session_id)
.unwrap_or((None, None))
} }
state _ => (None, None),
.model_selections
.get(&topic.session_id)
.unwrap_or((None, None))
} }
_ => (None, None), }
},
}; };
Json(SessionSelectedModelResponse { provider, model }) Json(SessionSelectedModelResponse { provider, model })
} }

View File

@ -192,7 +192,7 @@ impl MemoryMaintenanceService {
.chain(std::iter::once(None)) .chain(std::iter::once(None))
.enumerate() .enumerate()
{ {
let response = match provider.chat(request.clone()).await { let response = match provider.chat(&request).await {
Ok(success) => success, Ok(success) => success,
Err(err) => { Err(err) => {
let error_text = err.to_string(); let error_text = err.to_string();
@ -313,7 +313,7 @@ impl MemoryMaintenanceService {
.chain(std::iter::once(None)) .chain(std::iter::once(None))
.enumerate() .enumerate()
{ {
match provider.chat(request.clone()).await { match provider.chat(&request).await {
Ok(success) => { Ok(success) => {
response = Some(success); response = Some(success);
break; break;

View File

@ -40,6 +40,8 @@ pub struct InboundProcessor {
command_router: Arc<CommandRouter>, command_router: Arc<CommandRouter>,
cancel_manager: CancelManager, cancel_manager: CancelManager,
description_generation_in_flight: Arc<Mutex<HashSet<String>>>, description_generation_in_flight: Arc<Mutex<HashSet<String>>>,
/// 子代理运行时(用于 ExecutionCompleted 兜底前的僵尸清理)
subagent_executor: Option<Arc<dyn crate::tools::SubAgentRuntime>>,
} }
impl InboundProcessor { impl InboundProcessor {
@ -120,7 +122,7 @@ impl InboundProcessor {
command_router.register(Box::new(StopExecutionCommandHandler::new( command_router.register(Box::new(StopExecutionCommandHandler::new(
cancel_manager.clone(), cancel_manager.clone(),
session_manager.clone(), session_manager.clone(),
subagent_executor, subagent_executor.clone(),
))); )));
Self { Self {
@ -131,6 +133,7 @@ impl InboundProcessor {
command_router: Arc::new(command_router), command_router: Arc::new(command_router),
cancel_manager, cancel_manager,
description_generation_in_flight: Arc::new(Mutex::new(HashSet::new())), description_generation_in_flight: Arc::new(Mutex::new(HashSet::new())),
subagent_executor,
} }
} }
@ -180,6 +183,12 @@ impl InboundProcessor {
let chat_id_for_span = inbound.chat_id.clone(); let chat_id_for_span = inbound.chat_id.clone();
let session_id_for_span = let session_id_for_span =
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id); crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
// panic 兜底需用panic 时 inbound 已被 process_one 消费,提前克隆路由字段
let panic_channel = inbound.channel.clone();
let panic_chat_id = inbound.chat_id.clone();
let panic_trace_id = inbound.trace_id.clone();
let panic_session_id = session_id_for_span.clone();
let panic_forwarded_metadata = inbound.forwarded_metadata.clone();
tokio::spawn(crate::observability::tracing_ctx::traced( tokio::spawn(crate::observability::tracing_ctx::traced(
&trace_id, &trace_id,
&chat_id_for_span, &chat_id_for_span,
@ -202,11 +211,82 @@ impl InboundProcessor {
crate::observability::metrics::record_message_processing_error(); crate::observability::metrics::record_message_processing_error();
} }
Err(payload) => { Err(payload) => {
let panic_msg = crate::utils::panic_payload_message(&payload);
tracing::error!( tracing::error!(
error = %crate::utils::panic_payload_message(&payload), error = %panic_msg,
"Message processing panicked" "Message processing panicked"
); );
crate::observability::metrics::record_message_processing_error(); crate::observability::metrics::record_message_processing_error();
// panic 兜底process_one 中途夭折,既不会发错误提示、
// 也不会发 ExecutionCompleted前端将永久停在 loading
// 且输入框被禁用(用户视角"卡死")。此处补发两者,
// 并尽力清理该 topic 的取消信号注册。
let current_topic = processor
.session_manager
.get_current_topic(&panic_channel, &panic_chat_id)
.await
.ok()
.flatten();
if let Some(ref topic_id) = current_topic {
processor.cancel_manager.remove_by_topic(topic_id).await;
}
let mut error_metadata = panic_forwarded_metadata.clone();
error_metadata
.insert("error_kind".to_string(), "panic".to_string());
if let Err(publish_error) = processor
.bus
.publish_outbound(
OutboundMessage::error_notification(
panic_channel.clone(),
panic_chat_id.clone(),
None,
format!("内部处理错误panic{panic_msg}"),
None,
error_metadata,
)
.with_trace_id(&panic_trace_id),
)
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish panic error outbound");
}
}
}
let mut completion_metadata = panic_forwarded_metadata;
if let Some(ref topic_id) = current_topic {
completion_metadata
.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(publish_error) = processor
.bus
.publish_outbound(
OutboundMessage::execution_completed(
panic_channel,
panic_chat_id,
Some(panic_session_id),
completion_metadata,
)
.with_trace_id(&panic_trace_id),
)
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish panic execution_completed");
}
}
}
} }
} }
}, },
@ -338,7 +418,43 @@ impl InboundProcessor {
// 阻塞获取 serial_lock // 阻塞获取 serial_lock
// - agent 正常运行:阻塞至其完成(天然串行化) // - agent 正常运行:阻塞至其完成(天然串行化)
// - agent 在 wait 中wait 已释放锁,可立即获取 // - agent 在 wait 中wait 已释放锁,可立即获取
let _inject_guard = serial_lock.clone().lock_owned().await; // 每 30s 打 warn 标记等待进展(与 execution.rs 主路径一致的可观测性)
let _inject_guard = {
let lock_for_wait = serial_lock.clone();
let mut waited = false;
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
lock_for_wait.clone().lock_owned(),
)
.await
{
Ok(guard) => {
if waited {
tracing::info!(
topic_id = %lock_key,
"Injection path serial lock acquired after waiting"
);
}
break guard;
}
Err(_) => {
if !waited {
tracing::info!(
topic_id = %lock_key,
"Injection path waiting for topic serial lock"
);
waited = true;
} else {
tracing::warn!(
topic_id = %lock_key,
"Injection path still waiting for topic serial lock"
);
}
}
}
}
};
// 检查 is_waiting持锁状态下安全 // 检查 is_waiting持锁状态下安全
let is_waiting = { let is_waiting = {
@ -598,6 +714,13 @@ impl InboundProcessor {
// 前端过早停止 loading 导致子代理结果"丢失"的观感。 // 前端过早停止 loading 导致子代理结果"丢失"的观感。
// 恢复路径:下一条用户消息触发新的 process_one → 加载 history → // 恢复路径:下一条用户消息触发新的 process_one → 加载 history →
// LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。 // LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。
//
// 僵尸清理:先剔除"DB=running 但执行任务已消失"的僵尸记录,
// 否则它们会让 pending 永远非空 → ExecutionCompleted 永远被跳过
// → 前端 loading 永不停止(用户视角"卡死")。
if let (Some(ref topic_id), Some(ref runtime)) = (current_topic.as_ref(), self.subagent_executor.as_ref()) {
runtime.reap_orphan_subagents(topic_id).await;
}
let has_pending_subagents = if let Some(ref topic_id) = current_topic { let has_pending_subagents = if let Some(ref topic_id) = current_topic {
// SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker // SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
let store = self.session_manager.store(); let store = self.session_manager.store();

View File

@ -68,6 +68,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }

View File

@ -1372,6 +1372,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
} }
@ -1696,6 +1697,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1754,6 +1756,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1833,6 +1836,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -1910,6 +1914,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2051,6 +2056,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2123,6 +2129,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2205,6 +2212,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2296,6 +2304,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2386,6 +2395,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2458,6 +2468,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2540,6 +2551,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };
@ -2608,6 +2620,7 @@ mod tests {
max_retries: 3, max_retries: 3,
tool_result_max_chars: 100_000, tool_result_max_chars: 100_000,
context_tool_result_trim_chars: 100_000, context_tool_result_trim_chars: 100_000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}; };

View File

@ -256,13 +256,43 @@ impl SessionHistory {
.load_messages_for_topic(tid, Some(&sid)) .load_messages_for_topic(tid, Some(&sid))
.map_err(|err| AgentError::Other(format!("session history load error: {}", err)))?; .map_err(|err| AgentError::Other(format!("session history load error: {}", err)))?;
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut history); let (removed, removed_ids) =
crate::bus::message::sanitize_incomplete_tool_call_sequences_with_ids(&mut history);
if removed > 0 { if removed > 0 {
tracing::warn!( tracing::warn!(
topic_id = %tid, topic_id = %tid,
removed_count = removed, removed_count = removed,
"Sanitized incomplete tool_call sequences on history load" "Sanitized incomplete tool_call sequences on history load"
); );
// 修复回写 DB否则损坏序列在 DB 中永久残留,每次加载都重复
// sanitize日志噪音 + 内存/DB 漂移)。失败不阻断加载,下次加载重试。
match self
.conversations
.delete_messages_by_ids(&sid, &removed_ids)
{
Ok(deleted) if deleted == removed => {
tracing::info!(
topic_id = %tid,
deleted_count = deleted,
"Persisted sanitize repair to DB (deleted broken message rows)"
);
}
Ok(deleted) => {
tracing::warn!(
topic_id = %tid,
deleted_count = deleted,
expected = removed,
"Partial sanitize repair persisted to DB"
);
}
Err(e) => {
tracing::warn!(
error = %e,
topic_id = %tid,
"Failed to persist sanitize repair to DB; will retry on next load"
);
}
}
} }
self.topic_histories.insert(tid.to_string(), history); self.topic_histories.insert(tid.to_string(), history);

View File

@ -59,7 +59,7 @@ impl SessionMessageSender for BusSessionMessageSender {
match self.bus.publish_outbound(outbound).await { match self.bus.publish_outbound(outbound).await {
Ok(()) => { Ok(()) => {
published_messages += 1; published_messages += 1;
tracing::info!( tracing::debug!(
channel = %channel_name, channel = %channel_name,
chat_id = %chat_id, chat_id = %chat_id,
content_len = content_len, content_len = content_len,
@ -95,7 +95,7 @@ impl SessionMessageSender for BusSessionMessageSender {
match self.bus.publish_outbound(outbound).await { match self.bus.publish_outbound(outbound).await {
Ok(()) => { Ok(()) => {
published_messages += 1; published_messages += 1;
tracing::info!( tracing::debug!(
channel = %channel_name, channel = %channel_name,
chat_id = %chat_id, chat_id = %chat_id,
media_type = %media_type, media_type = %media_type,

View File

@ -117,11 +117,10 @@ impl WaitCoordinator for SessionWaitCoordinator {
}; };
// 3.5. 记录等待前的用户消息数量(用于 wakeup 后提取新注入的消息) // 3.5. 记录等待前的用户消息数量(用于 wakeup 后提取新注入的消息)
// 直接从 SQLite 读取,不持有任何锁 // 直接从 SQLite 读取(定向 COUNT避免全量加载消息体,不持有任何锁
let user_msg_count_before = self let user_msg_count_before = self
.store .store
.load_messages_for_topic(&self.topic_id, None) .count_user_messages_for_topic(&self.topic_id)
.map(|msgs| msgs.iter().filter(|m| m.role == "user").count())
.unwrap_or(0); .unwrap_or(0);
// 4. 释放 serial_lock取出 guard 并 drop // 4. 释放 serial_lock取出 guard 并 drop

View File

@ -186,23 +186,43 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let store = state.session_manager.store(); let store = state.session_manager.store();
// 1. 查询 websocket 和 cli 两个通道的 Sessions兼容旧版本 cli 通道创建的会话) // 1. 查询 websocket 和 cli 两个通道的 Sessions兼容旧版本 cli 通道创建的会话)
let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default(); // SQLite 同步查询移入 blocking 线程池,避免连接建立时阻塞 async worker。
let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default(); let store_for_init = store.clone();
websocket_sessions.extend(cli_channel_sessions); let cli_sessions_for_init = cli_sessions.clone();
websocket_sessions.sort_by_key(|s| -(s.last_active_at)); let (mut websocket_sessions, initial_result) = tokio::task::spawn_blocking(move || {
let mut websocket_sessions = store_for_init
.list_sessions("websocket", false)
.unwrap_or_default();
let cli_channel_sessions = store_for_init
.list_sessions("cli", false)
.unwrap_or_default();
websocket_sessions.extend(cli_channel_sessions);
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
// 2. 如果没有,自动创建一个默认 Session // 2. 如果没有,自动创建一个默认 Session
let initial_record = if websocket_sessions.is_empty() { let initial_record = if websocket_sessions.is_empty() {
match cli_sessions.create_with_channel("websocket", Some("默认会话")) { match cli_sessions_for_init.create_with_channel("websocket", Some("默认会话")) {
Ok(record) => record, Ok(record) => Some(record),
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Failed to create initial WebSocket session"); tracing::error!(error = %e, "Failed to create initial WebSocket session");
return; None
}
} }
} } else {
} else { // 使用最新的 Session
// 使用最新的 Session Some(websocket_sessions[0].clone())
websocket_sessions[0].clone() };
(websocket_sessions, initial_record)
})
.await
.unwrap_or_else(|e| {
tracing::error!(error = %e, "WebSocket session init task failed");
(Vec::new(), None)
});
let initial_record = match initial_result {
Some(record) => record,
None => return,
}; };
let runtime_session_id = uuid::Uuid::new_v4().to_string(); let runtime_session_id = uuid::Uuid::new_v4().to_string();
@ -414,7 +434,14 @@ async fn handle_inbound(
.await; .await;
// Process attachments: save base64 content to local files and build MediaItems with correct paths // Process attachments: save base64 content to local files and build MediaItems with correct paths
let media = process_attachments_with_base64(attachments)?; // base64 解码 + 同步写盘是 CPU/IO 密集操作(单条消息最大约 67MB
// 移入 blocking 线程池,避免长时间霸占 async worker。
let media =
tokio::task::spawn_blocking(move || process_attachments_with_base64(attachments))
.await
.map_err(|e| {
AgentError::Other(format!("Attachment processing task failed: {}", e))
})??;
state state
.bus .bus
@ -561,6 +588,10 @@ async fn handle_inbound(
router.register(Box::new(MemoryCrudCommandHandler::new(store.clone()))); router.register(Box::new(MemoryCrudCommandHandler::new(store.clone())));
// 注册 load_chat_messages 处理器 // 注册 load_chat_messages 处理器
router.register(Box::new(LoadChatMessagesCommandHandler::new())); router.register(Box::new(LoadChatMessagesCommandHandler::new()));
// 注册 load_older_messages 处理器(历史分页)
router.register(Box::new(
crate::command::handlers::load_chat_messages::LoadOlderMessagesCommandHandler::new(),
));
// 注册 stop_execution 处理器 // 注册 stop_execution 处理器
router.register(Box::new(StopExecutionCommandHandler::new( router.register(Box::new(StopExecutionCommandHandler::new(
state.cancel_manager.clone(), state.cancel_manager.clone(),
@ -720,24 +751,31 @@ async fn handle_inbound(
let _ = sender.send(WsOutbound::MemoryList { memories }).await; let _ = sender.send(WsOutbound::MemoryList { memories }).await;
} }
// 记忆 CRUD 后自动刷新列表 // 记忆 CRUD 后自动刷新列表SQLite 同步查询移入 blocking 线程池)
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
&& let Ok(records) = let store_bg = store.clone();
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) let records = tokio::task::spawn_blocking(move || {
{ store_bg.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
let memories: Vec<crate::protocol::MemorySummary> = records })
.into_iter() .await;
.filter(|m| m.namespace != "_meta") if let Err(e) = &records {
.map(|m| crate::protocol::MemorySummary { tracing::warn!(error = %e, "Memory list task failed");
id: m.id, }
namespace: m.namespace, if let Ok(Ok(records)) = records {
memory_key: m.memory_key, let memories: Vec<crate::protocol::MemorySummary> = records
content: m.content, .into_iter()
created_at: m.created_at, .filter(|m| m.namespace != "_meta")
updated_at: m.updated_at, .map(|m| crate::protocol::MemorySummary {
}) id: m.id,
.collect(); namespace: m.namespace,
let _ = sender.send(WsOutbound::MemoryList { memories }).await; memory_key: m.memory_key,
content: m.content,
created_at: m.created_at,
updated_at: m.updated_at,
})
.collect();
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
} }
// 处理加载聊天消息请求 // 处理加载聊天消息请求
@ -763,6 +801,31 @@ async fn handle_inbound(
} }
} }
// 分页加载话题更早历史(前端向上滚动触发)
if let Some(topic_id) = response.metadata.get("load_older_topic_id") {
let before_seq = response
.metadata
.get("load_older_before_seq")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(i64::MAX);
if let Err(e) = send_older_messages(
&store,
current_session_id,
topic_id,
before_seq,
sender,
)
.await
{
tracing::warn!(
error = %e,
topic_id = %topic_id,
before_seq,
"Failed to send older messages"
);
}
}
if current_topic_id.is_none() if current_topic_id.is_none()
&& let Some(topics_json) = response.metadata.get("topics") && let Some(topics_json) = response.metadata.get("topics")
{ {
@ -821,6 +884,10 @@ fn resolve_ws_sender_id(sender_id: Option<&str>, runtime_session_id: &str) -> St
} }
/// 加载并发送话题历史消息 /// 加载并发送话题历史消息
/// 话题历史初始页大小:切换话题时仅加载最新 N 条,
/// 更早消息由前端滚动触发 load_older_messages 增量加载。
const TOPIC_HISTORY_PAGE_SIZE: usize = 200;
async fn send_topic_history( async fn send_topic_history(
store: &Arc<crate::storage::SessionStore>, store: &Arc<crate::storage::SessionStore>,
session_id: &str, session_id: &str,
@ -828,21 +895,40 @@ async fn send_topic_history(
sender: &mpsc::Sender<WsOutbound>, sender: &mpsc::Sender<WsOutbound>,
task_repository: &Arc<dyn TaskRepository>, task_repository: &Arc<dyn TaskRepository>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// 加载话题消息,按 session_id 过滤,避免混入子智能体消息 // 分页加载最新一页,避免长话题全量传输/解析阻塞首屏。
let mut messages = store.load_messages_for_topic_full(topic_id, Some(session_id))?; // SQLite 同步加载 + running 占位对账移入 blocking 线程池,避免阻塞 async worker。
let store_bg = store.clone();
let topic_id_bg = topic_id.to_string();
let session_id_bg = session_id.to_string();
let (messages, has_more) = tokio::task::spawn_blocking(move || {
let (mut messages, has_more) = store_bg.load_messages_for_topic_page(
&topic_id_bg,
Some(&session_id_bg),
None,
TOPIC_HISTORY_PAGE_SIZE,
)?;
reconcile_running_in_messages(&mut messages, &store_bg, &topic_id_bg);
Ok::<_, crate::storage::StorageError>((messages, has_more))
})
.await
.map_err(|e| format!("Topic history load task failed: {}", e))??;
// 对账 running 占位DB 中的 task tool_result 永远保持 spawn 时的 running 状态 tracing::info!(topic_id = %topic_id, message_count = messages.len(), has_more, "Sending topic history (paged)");
// (实时完成信号只更新前端内存与 pending_subagents 表),若不替换,
// 刷新/切话题后前端卡片会永远显示"运行中"。与 Session::reconcile_running_placeholders
// 同语义,仅改发送副本,不落库。
reconcile_running_in_messages(&mut messages, store, topic_id);
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history"); // 收集已加载页内的 tool_call/tool_result id 集合,用于判断任务是否已有结果。
// 分页后集合仅覆盖已加载窗口:窗口外的任务不补发 TaskStarted
// 收集已有 tool_result 的 tool_call_id 集合,用于判断任务是否已有结果 // (其 tool_call 气泡同样不在窗口内,补发会造成前端凭空出现孤立卡片)。
let mut tool_call_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut tool_call_ids_with_results: std::collections::HashSet<String> = let mut tool_call_ids_with_results: std::collections::HashSet<String> =
std::collections::HashSet::new(); std::collections::HashSet::new();
for msg in &messages { for msg in &messages {
if msg.role == "assistant"
&& let Some(ref tool_calls) = msg.tool_calls
{
for tc in tool_calls {
tool_call_ids.insert(tc.id.clone());
}
}
if msg.role == "tool" if msg.role == "tool"
&& let Some(ref tcid) = msg.tool_call_id && let Some(ref tcid) = msg.tool_call_id
{ {
@ -850,13 +936,35 @@ async fn send_topic_history(
} }
} }
// 将消息转换为 WsOutbound 并发送 // 将消息转换为 WsOutbound 并发送。
for msg in messages { // 转换过程对每条媒体引用做同步文件读取 + base64 编码CPU/IO 密集),
for outbound in chat_message_to_ws_outbound(&msg) { // 整体移入 blocking 线程池一次性产出,避免阻塞 async worker。
let _ = sender.send(outbound).await; // (闭包 move 捕获 messages先提取分页游标
} // topic_id 随批次下发:前端以 seq+topic_id 双重判定历史批次归属,
// 规避切话题瞬间在途旧批次污染新话题列表。
let oldest_seq = messages.first().and_then(|m| m.seq);
let topic_id_for_convert = topic_id.to_string();
let outbound_batches: Vec<Vec<WsOutbound>> = tokio::task::spawn_blocking(move || {
messages
.iter()
.map(|m| chat_message_to_ws_outbound(m, Some(&topic_id_for_convert)))
.collect::<Vec<_>>()
})
.await
.map_err(|e| format!("Topic history convert task failed: {}", e))?;
for outbound in outbound_batches.into_iter().flatten() {
let _ = sender.send(outbound).await;
} }
// 批次结束标记:前端据此初始化分页游标与 has_more 状态
let _ = sender
.send(WsOutbound::TopicHistoryEnd {
topic_id: topic_id.to_string(),
has_more,
oldest_seq,
})
.await;
// 查询该话题下所有子智能体任务,补发 TaskStarted 事件 // 查询该话题下所有子智能体任务,补发 TaskStarted 事件
// 解决页面刷新后 navigateToTaskId 丢失的问题 // 解决页面刷新后 navigateToTaskId 丢失的问题
let tasks = match task_repository.list_tasks_for_topic(topic_id).await { let tasks = match task_repository.list_tasks_for_topic(topic_id).await {
@ -869,8 +977,17 @@ async fn send_topic_history(
for task in tasks { for task in tasks {
// 判断是否需要补发 TaskStarted // 判断是否需要补发 TaskStarted
// - 如果该任务的 tool_call_id 已有对应的 tool_result前端会显示结果不需要补发 // - 任务的 tool_call 不在已加载窗口内 → 不补发(前端无对应气泡,补发会产生孤立卡片)
// - 否则Running 状态或已完成但结果未进入历史),补发 TaskStarted 以便前端显示"查看实时进度" // - tool_call 在窗口内且已有 tool_result → 前端会显示结果,不补发
// - tool_call 在窗口内但无 tool_resultRunning 或结果未入历史)→ 补发以显示"查看实时进度"
let task_call_in_window = task
.tool_call_id
.as_ref()
.map(|tcid| tool_call_ids.contains(tcid))
.unwrap_or(false);
if !task_call_in_window {
continue;
}
let has_tool_result = task let has_tool_result = task
.tool_call_id .tool_call_id
.as_ref() .as_ref()
@ -906,6 +1023,63 @@ async fn send_topic_history(
Ok(()) Ok(())
} }
/// 分页加载并发送话题更早的历史消息load_older_messages 命令)。
/// 与初始页不同:不补发 TaskStarted运行中任务必在最新页
/// 仅发送消息批次 + TopicHistoryEnd 游标标记。
async fn send_older_messages(
store: &Arc<crate::storage::SessionStore>,
session_id: &str,
topic_id: &str,
before_seq: i64,
sender: &mpsc::Sender<WsOutbound>,
) -> Result<(), Box<dyn std::error::Error>> {
let store_bg = store.clone();
let topic_id_bg = topic_id.to_string();
let session_id_bg = session_id.to_string();
let (messages, has_more) = tokio::task::spawn_blocking(move || {
store_bg.load_messages_for_topic_page(
&topic_id_bg,
Some(&session_id_bg),
Some(before_seq),
TOPIC_HISTORY_PAGE_SIZE,
)
})
.await
.map_err(|e| format!("Older messages load task failed: {}", e))??;
tracing::debug!(
topic_id = %topic_id,
before_seq,
message_count = messages.len(),
has_more,
"Sending older messages (paged)"
);
let oldest_seq = messages.first().and_then(|m| m.seq);
let topic_id_for_convert = topic_id.to_string();
let outbound_batches: Vec<Vec<WsOutbound>> = tokio::task::spawn_blocking(move || {
messages
.iter()
.map(|m| chat_message_to_ws_outbound(m, Some(&topic_id_for_convert)))
.collect::<Vec<_>>()
})
.await
.map_err(|e| format!("Older messages convert task failed: {}", e))?;
for outbound in outbound_batches.into_iter().flatten() {
let _ = sender.send(outbound).await;
}
let _ = sender
.send(WsOutbound::TopicHistoryEnd {
topic_id: topic_id.to_string(),
has_more,
oldest_seq,
})
.await;
Ok(())
}
/// 发送前对账消息列表中的 task "running" 占位。 /// 发送前对账消息列表中的 task "running" 占位。
/// ///
/// DB messages 表中的 task tool_result 行固化在 spawn 时刻的 running 状态, /// DB messages 表中的 task tool_result 行固化在 spawn 时刻的 running 状态,
@ -963,7 +1137,12 @@ async fn send_task_messages(
subagent_task_id: Option<String>, subagent_task_id: Option<String>,
task_repository: Option<&Arc<dyn TaskRepository>>, task_repository: Option<&Arc<dyn TaskRepository>>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let messages = store.load_messages(session_id)?; // SQLite 同步加载移入 blocking 线程池,避免阻塞 async worker。
let store_bg = store.clone();
let session_id_bg = session_id.to_string();
let messages = tokio::task::spawn_blocking(move || store_bg.load_messages(&session_id_bg))
.await
.map_err(|e| format!("Task messages load task failed: {}", e))??;
tracing::info!(session_id = %session_id, message_count = messages.len(), "Sending task messages"); tracing::info!(session_id = %session_id, message_count = messages.len(), "Sending task messages");
@ -978,16 +1157,28 @@ async fn send_task_messages(
} }
} }
for msg in messages { // 转换(含媒体文件同步读取 + base64 编码)移入 blocking 线程池一次性产出
let mut outbounds = chat_message_to_ws_outbound(&msg); let subagent_task_id_bg = subagent_task_id.clone();
if let Some(ref task_id) = subagent_task_id { let outbound_batches: Vec<Vec<WsOutbound>> = tokio::task::spawn_blocking(move || {
for ob in &mut outbounds { messages
set_subagent_task_id(ob, task_id); .iter()
} .map(|msg| {
} // 任务会话消息不属于主话题历史topic_id 传 None
for outbound in outbounds { // 前端按 topic_id 过滤历史批次时不会误收。
let _ = sender.send(outbound).await; let mut outbounds = chat_message_to_ws_outbound(msg, None);
} if let Some(ref task_id) = subagent_task_id_bg {
for ob in &mut outbounds {
set_subagent_task_id(ob, task_id);
}
}
outbounds
})
.collect::<Vec<_>>()
})
.await
.map_err(|e| format!("Task messages convert task failed: {}", e))?;
for outbound in outbound_batches.into_iter().flatten() {
let _ = sender.send(outbound).await;
} }
// 补发子任务(孙智能体)的 TaskStarted 事件 // 补发子任务(孙智能体)的 TaskStarted 事件
@ -1076,8 +1267,12 @@ fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Opti
} }
/// 将 ChatMessage 转换为 WsOutbound 列表 /// 将 ChatMessage 转换为 WsOutbound 列表
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> { fn chat_message_to_ws_outbound(
msg: &crate::bus::ChatMessage,
topic_id: Option<&str>,
) -> Vec<WsOutbound> {
use crate::bus::message::ToolMessageState; use crate::bus::message::ToolMessageState;
let topic_id_out = topic_id.map(str::to_string);
// Helper function to strip media_refs_json from content // Helper function to strip media_refs_json from content
fn strip_media_refs_json(content: &str) -> String { fn strip_media_refs_json(content: &str) -> String {
@ -1148,10 +1343,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
role: msg.role.clone(), role: msg.role.clone(),
attachments: Vec::new(), attachments: Vec::new(),
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: msg.reasoning_content.clone(), reasoning_content: msg.reasoning_content.clone(),
user_message_id: None, user_message_id: None,
seq: msg.seq,
}); });
} }
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复 // AssistantResponse 已携带 reasoning 时ToolCall 不再重复
@ -1169,10 +1365,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments), content: format!("{}\nargs: {}", tool_call.name, tool_call.arguments),
role: msg.role.clone(), role: msg.role.clone(),
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: tc_reasoning.clone(), reasoning_content: tc_reasoning.clone(),
user_message_id: None, user_message_id: None,
seq: msg.seq,
}); });
} }
outbound outbound
@ -1184,10 +1381,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
role: msg.role.clone(), role: msg.role.clone(),
attachments: Vec::new(), attachments: Vec::new(),
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: msg.reasoning_content.clone(), reasoning_content: msg.reasoning_content.clone(),
user_message_id: None, user_message_id: None,
seq: msg.seq,
}] }]
} }
} }
@ -1207,9 +1405,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
content: msg.content.clone(), content: msg.content.clone(),
role: msg.role.clone(), role: msg.role.clone(),
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
duration_ms: msg.tool_duration_ms, duration_ms: msg.tool_duration_ms,
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
seq: msg.seq,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: msg id: msg
@ -1222,8 +1421,9 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
role: msg.role.clone(), role: msg.role.clone(),
resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(), resume_hint: "完成外部操作后,直接发一条继续消息即可。".to_string(),
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
seq: msg.seq,
}], }],
} }
} }
@ -1233,10 +1433,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
role: msg.role.clone(), role: msg.role.clone(),
attachments, attachments,
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: topic_id_out.clone(),
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: msg.seq,
}], }],
_ => Vec::new(), _ => Vec::new(),
} }

View File

@ -1,6 +1,8 @@
use chrono::Utc; use chrono::Utc;
use chrono_tz::Tz; use chrono_tz::Tz;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{ use tracing_subscriber::{
EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
@ -8,6 +10,13 @@ use tracing_subscriber::{
use crate::config::LogFormat; use crate::config::LogFormat;
/// Keeps the non-blocking writer worker threads alive for the process lifetime.
///
/// Dropping a [`WorkerGuard`] shuts down its worker, after which all log
/// writes are silently discarded. `init_logging` runs exactly once per
/// process, so the guards are parked in a process-wide static.
static NON_BLOCKING_GUARDS: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ConfiguredTimestamp { struct ConfiguredTimestamp {
timezone: Tz, timezone: Tz,
@ -74,13 +83,24 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
// Create file appender with daily rotation // Create file appender with daily rotation
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log"); let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
// Wrap both writers in non-blocking mode: the actual console/file writes
// happen on dedicated worker threads. On Windows the console can freeze
// (QuickEdit/mark mode, or a selection in Windows Terminal blocks every
// console write until Enter is pressed); with synchronous writers that
// would stall whichever tokio worker thread emits a log and cascade into
// a full gateway freeze. Here only the log worker thread blocks while
// the runtime keeps serving requests.
let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender);
let (console_writer, console_guard) = tracing_appender::non_blocking(std::io::stdout());
let _ = NON_BLOCKING_GUARDS.set(vec![file_guard, console_guard]);
// Build subscriber with both console and file output // Build subscriber with both console and file output
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
// 文件层:根据 log_format 选择 text 或 json // 文件层:根据 log_format 选择 text 或 json
let file_layer = match log_format { let file_layer = match log_format {
LogFormat::Json => fmt::layer() LogFormat::Json => fmt::layer()
.with_writer(file_appender) .with_writer(file_writer)
.with_timer(ConfiguredTimestamp { timezone }) .with_timer(ConfiguredTimestamp { timezone })
.with_ansi(false) .with_ansi(false)
.with_target(true) .with_target(true)
@ -89,7 +109,7 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
.json() .json()
.boxed(), .boxed(),
LogFormat::Text => fmt::layer() LogFormat::Text => fmt::layer()
.with_writer(file_appender) .with_writer(file_writer)
.with_timer(ConfiguredTimestamp { timezone }) .with_timer(ConfiguredTimestamp { timezone })
.with_ansi(false) .with_ansi(false)
.with_target(true) .with_target(true)
@ -100,6 +120,7 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
// 控制台层:始终文本格式 // 控制台层:始终文本格式
let console_layer = fmt::layer() let console_layer = fmt::layer()
.with_writer(console_writer)
.with_timer(ConfiguredTimestamp { timezone }) .with_timer(ConfiguredTimestamp { timezone })
.with_target(true) .with_target(true)
.with_level(true); .with_level(true);
@ -121,7 +142,13 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
pub fn init_logging_console_only(timezone: Tz) { pub fn init_logging_console_only(timezone: Tz) {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
// See init_logging: non-blocking writes keep a frozen Windows console
// from stalling the runtime.
let (console_writer, console_guard) = tracing_appender::non_blocking(std::io::stdout());
let _ = NON_BLOCKING_GUARDS.set(vec![console_guard]);
let console_layer = fmt::layer() let console_layer = fmt::layer()
.with_writer(console_writer)
.with_timer(ConfiguredTimestamp { timezone }) .with_timer(ConfiguredTimestamp { timezone })
.with_target(true) .with_target(true)
.with_level(true); .with_level(true);

View File

@ -49,6 +49,7 @@ pub fn init_recorder() -> Option<PrometheusHandle> {
let builder = PrometheusBuilder::new(); let builder = PrometheusBuilder::new();
match builder.install_recorder() { match builder.install_recorder() {
Ok(handle) => { Ok(handle) => {
register_baseline_counters();
tracing::info!("Prometheus metrics recorder installed"); tracing::info!("Prometheus metrics recorder installed");
Some(handle) Some(handle)
} }
@ -61,6 +62,13 @@ pub fn init_recorder() -> Option<PrometheusHandle> {
.clone() .clone()
} }
fn register_baseline_counters() {
metrics::counter!(MESSAGE_PROCESSING_ERRORS).absolute(0);
metrics::counter!(AGENT_ITERATIONS).absolute(0);
metrics::counter!(LLM_TOKENS_USED).absolute(0);
metrics::counter!(TOOL_EXECUTION_TOTAL).absolute(0);
}
// ============================================================================ // ============================================================================
// MetricsObserver — 桥接 ObserverEvent 到 metrics 宏 // MetricsObserver — 桥接 ObserverEvent 到 metrics 宏
// ============================================================================ // ============================================================================
@ -156,3 +164,20 @@ pub fn default_observer() -> Arc<dyn Observer> {
pub fn record_message_processing_error() { pub fn record_message_processing_error() {
metrics::counter!(MESSAGE_PROCESSING_ERRORS).increment(1); metrics::counter!(MESSAGE_PROCESSING_ERRORS).increment(1);
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metrics_render_is_not_blank_right_after_init() {
let Some(handle) = init_recorder() else {
return;
};
let rendered = handle.render();
assert!(
rendered.contains(MESSAGE_PROCESSING_ERRORS),
"expected baseline counters in render output, got: {rendered:?}"
);
}
}

View File

@ -329,6 +329,99 @@ fn windows_is_process_waiting_on_stdin(pid: u32) -> Option<bool> {
} }
} }
/// Disable Windows console QuickEdit mode.
///
/// With QuickEdit enabled (the default), clicking the console window enters
/// mark/selection mode and silently blocks every console write from this
/// process until the user presses Enter — making the gateway look frozen.
/// Disabling it keeps keyboard input working while preventing mouse-click
/// freezes. No-op on non-Windows platforms or when no console is attached.
///
/// If stdin is redirected (so `GetStdHandle(STD_INPUT_HANDLE)` is not a
/// console handle), falls back to opening `CONIN$` to reach the attached
/// console directly. Note that a manual "Edit → Mark" from the title-bar
/// menu can still freeze console writes; non-blocking log writers in
/// `logging.rs` keep the runtime alive in that case.
pub fn disable_console_quick_edit() {
#[cfg(target_os = "windows")]
windows_disable_console_quick_edit();
}
#[cfg(target_os = "windows")]
fn windows_disable_console_quick_edit() {
const STD_INPUT_HANDLE: i32 = -10;
const ENABLE_QUICK_EDIT_MODE: u32 = 0x0040;
const ENABLE_EXTENDED_FLAGS: u32 = 0x0080;
const GENERIC_READ: u32 = 0x8000_0000;
const GENERIC_WRITE: u32 = 0x4000_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const OPEN_EXISTING: u32 = 3;
const INVALID_HANDLE_VALUE: isize = -1;
#[allow(non_snake_case)]
unsafe extern "system" {
fn GetStdHandle(n_std_handle: i32) -> isize;
fn GetConsoleMode(h_console_handle: isize, lp_mode: *mut u32) -> i32;
fn SetConsoleMode(h_console_handle: isize, dw_mode: u32) -> i32;
fn CreateFileW(
lp_file_name: *const u16,
dw_desired_access: u32,
dw_share_mode: u32,
lp_security_attributes: *const std::ffi::c_void,
dw_creation_disposition: u32,
dw_flags_and_attributes: u32,
h_template_file: isize,
) -> isize;
fn CloseHandle(h_object: isize) -> i32;
}
unsafe {
let mut mode: u32 = 0;
let mut opened_conin = false;
let stdin = GetStdHandle(STD_INPUT_HANDLE);
let console =
if stdin != 0 && stdin != INVALID_HANDLE_VALUE && GetConsoleMode(stdin, &mut mode) != 0
{
stdin
} else {
// stdin is redirected/closed but a console may still be attached
// (e.g. output shown in a window started via `start /B`). Open
// CONIN$ to reach the console input buffer directly.
let name: Vec<u16> = "CONIN$\0".encode_utf16().collect();
let conin = CreateFileW(
name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
0,
);
if conin == INVALID_HANDLE_VALUE || GetConsoleMode(conin, &mut mode) == 0 {
if conin != INVALID_HANDLE_VALUE {
let _ = CloseHandle(conin);
}
return;
}
opened_conin = true;
conin
};
if mode & ENABLE_QUICK_EDIT_MODE != 0 {
let new_mode = (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS;
let _ = SetConsoleMode(console, new_mode);
}
// The mode change is a property of the console itself, so the
// CONIN$ handle can be released immediately.
if opened_conin {
let _ = CloseHandle(console);
}
}
}
/// Get the user's home directory. /// Get the user's home directory.
/// ///
/// Supports environment variable overrides for testing: /// Supports environment variable overrides for testing:

View File

@ -176,6 +176,9 @@ pub enum WsOutbound {
reasoning_content: Option<String>, reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
user_message_id: Option<String>, user_message_id: Option<String>,
/// 历史分页游标:仅历史加载批次填充(实时推送为 None
#[serde(default, skip_serializing_if = "Option::is_none")]
seq: Option<i64>,
}, },
#[serde(rename = "tool_call")] #[serde(rename = "tool_call")]
ToolCall { ToolCall {
@ -195,6 +198,9 @@ pub enum WsOutbound {
reasoning_content: Option<String>, reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
user_message_id: Option<String>, user_message_id: Option<String>,
/// 历史分页游标:仅历史加载批次填充(实时推送为 None
#[serde(default, skip_serializing_if = "Option::is_none")]
seq: Option<i64>,
}, },
#[serde(rename = "tool_result")] #[serde(rename = "tool_result")]
ToolResult { ToolResult {
@ -211,6 +217,9 @@ pub enum WsOutbound {
duration_ms: Option<u64>, duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>, timestamp: Option<i64>,
/// 历史分页游标:仅历史加载批次填充(实时推送为 None
#[serde(default, skip_serializing_if = "Option::is_none")]
seq: Option<i64>,
}, },
#[serde(rename = "tool_pending")] #[serde(rename = "tool_pending")]
ToolPending { ToolPending {
@ -226,6 +235,18 @@ pub enum WsOutbound {
topic_id: Option<String>, topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>, timestamp: Option<i64>,
/// 历史分页游标:仅历史加载批次填充(实时推送为 None
#[serde(default, skip_serializing_if = "Option::is_none")]
seq: Option<i64>,
},
/// 话题历史批次结束标记:随每批历史消息末尾发送,
/// 前端据此更新分页游标oldest_seq与 has_more 状态
#[serde(rename = "topic_history_end")]
TopicHistoryEnd {
topic_id: String,
has_more: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
oldest_seq: Option<i64>,
}, },
#[serde(rename = "error")] #[serde(rename = "error")]
Error { Error {

View File

@ -28,6 +28,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None, timestamp: None,
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: None, user_message_id: None,
seq: None,
}); });
} }
@ -49,6 +50,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None, timestamp: None,
reasoning_content: tc_reasoning.clone(), reasoning_content: tc_reasoning.clone(),
user_message_id: None, user_message_id: None,
seq: None,
})); }));
outbound outbound
} else { } else {
@ -62,6 +64,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None, timestamp: None,
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: None, user_message_id: None,
seq: None,
}] }]
} }
} }
@ -83,6 +86,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
topic_id: None, topic_id: None,
duration_ms: None, duration_ms: None,
timestamp: None, timestamp: None,
seq: None,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message id: message
@ -97,6 +101,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
subagent_task_id: None, subagent_task_id: None,
topic_id: None, topic_id: None,
timestamp: None, timestamp: None,
seq: None,
}], }],
}, },
_ => Vec::new(), _ => Vec::new(),
@ -130,6 +135,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: message.metadata.get("user_message_id").cloned(), user_message_id: message.metadata.get("user_message_id").cloned(),
seq: None,
}] }]
} }
OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall { OutboundEventKind::ToolCall => vec![WsOutbound::ToolCall {
@ -150,6 +156,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: message.reasoning_content.clone(), reasoning_content: message.reasoning_content.clone(),
user_message_id: message.metadata.get("user_message_id").cloned(), user_message_id: message.metadata.get("user_message_id").cloned(),
seq: None,
}], }],
OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult { OutboundEventKind::ToolResult => vec![WsOutbound::ToolResult {
id: message id: message
@ -167,6 +174,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
.get("tool_duration_ms") .get("tool_duration_ms")
.and_then(|v| v.parse().ok()), .and_then(|v| v.parse().ok()),
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
seq: None,
}], }],
OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending { OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
id: message id: message
@ -181,6 +189,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
subagent_task_id: message.metadata.get("subagent_task_id").cloned(), subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()), timestamp: Some(crate::protocol::now_timestamp()),
seq: None,
}], }],
OutboundEventKind::ErrorNotification => vec![WsOutbound::Error { OutboundEventKind::ErrorNotification => vec![WsOutbound::Error {
code: "AGENT_ERROR".to_string(), code: "AGENT_ERROR".to_string(),

View File

@ -3,7 +3,6 @@ use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::Duration;
use super::traits::Usage; use super::traits::Usage;
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Tool, ToolCall}; use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Tool, ToolCall};
@ -144,10 +143,9 @@ impl AnthropicProvider {
max_tokens: Option<u32>, max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>, model_extra: HashMap<String, serde_json::Value>,
) -> Self { ) -> Self {
let client = Client::builder() // 复用按超时配置的共享 clientTLS 上下文 + 连接池),
.timeout(Duration::from_secs(llm_timeout_secs)) // 避免每条消息重建 Provider 时重复构造。
.build() let client = crate::providers::shared_llm_http_client(llm_timeout_secs);
.unwrap_or_else(|_| Client::new());
// 兼容带末尾斜杠的 base_url避免 format!("{}/v1/messages", base_url) 产生双斜杠 // 兼容带末尾斜杠的 base_url避免 format!("{}/v1/messages", base_url) 产生双斜杠
let base_url = base_url.trim_end_matches('/').to_string(); let base_url = base_url.trim_end_matches('/').to_string();
@ -258,7 +256,7 @@ impl LLMProvider for AnthropicProvider {
#[tracing::instrument(skip(self, request), fields(provider = %self.name, model = %self.model_id))] #[tracing::instrument(skip(self, request), fields(provider = %self.name, model = %self.model_id))]
async fn chat( async fn chat(
&self, &self,
request: ChatCompletionRequest, request: &ChatCompletionRequest,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/v1/messages", self.base_url); let url = format!("{}/v1/messages", self.base_url);
let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(8192); let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(8192);
@ -271,7 +269,7 @@ impl LLMProvider for AnthropicProvider {
"Anthropic: sending chat completion request" "Anthropic: sending chat completion request"
); );
let tools = request.tools.map(|tools| { let tools = request.tools.as_ref().map(|tools| {
tools tools
.iter() .iter()
.map(|t: &Tool| AnthropicTool { .map(|t: &Tool| AnthropicTool {

View File

@ -12,6 +12,41 @@ pub use traits::{
StreamCallback, StreamDelta, Usage, StreamCallback, StreamDelta, Usage,
}; };
/// 共享 HTTP client 缓存:按超时配置复用 reqwest::Client。
///
/// reqwest::Client 持有 TLS 上下文与连接池,构造成本高;每条消息重建
/// Provider 会导致每次 LLM 请求都无法复用 keep-alive 连接。
/// api_key / base_url / extra_headers 均为 per-request 应用(不影响 client
/// 构造),因此缓存键只需超时值,不同 provider/模型可安全共享同一 client。
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::Mutex;
static SHARED_HTTP_CLIENTS: LazyLock<Mutex<HashMap<u64, reqwest::Client>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 缓存条目上限:超时配置的取值种类极少(来自模型配置的 llm_timeout_secs
/// 超过上限说明配置在频繁变动,直接清空重建(代价仅为缓存失效)。
const SHARED_HTTP_CLIENT_CACHE_CAP: usize = 16;
pub(crate) fn shared_llm_http_client(timeout_secs: u64) -> reqwest::Client {
let mut cache = SHARED_HTTP_CLIENTS
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(client) = cache.get(&timeout_secs) {
return client.clone();
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
if cache.len() >= SHARED_HTTP_CLIENT_CACHE_CAP {
cache.clear();
}
cache.insert(timeout_secs, client.clone());
client
}
pub fn create_provider( pub fn create_provider(
config: ProviderRuntimeConfig, config: ProviderRuntimeConfig,
) -> Result<Box<dyn LLMProvider>, ProviderError> { ) -> Result<Box<dyn LLMProvider>, ProviderError> {

View File

@ -5,7 +5,6 @@ use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
use super::traits::{StreamCallback, StreamDelta, Usage}; use super::traits::{StreamCallback, StreamDelta, Usage};
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall}; use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
@ -278,10 +277,9 @@ impl OpenAIProvider {
max_tokens: Option<u32>, max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>, model_extra: HashMap<String, serde_json::Value>,
) -> Self { ) -> Self {
let client = Client::builder() // 复用按超时配置的共享 clientTLS 上下文 + 连接池),
.timeout(Duration::from_secs(llm_timeout_secs)) // 避免每条消息重建 Provider 时重复构造。
.build() let client = crate::providers::shared_llm_http_client(llm_timeout_secs);
.unwrap_or_else(|_| Client::new());
// 兼容带末尾斜杠的 base_url如 https://opencode.ai/zen/go/v1/ // 兼容带末尾斜杠的 base_url如 https://opencode.ai/zen/go/v1/
// 否则 format!("{}/chat/completions", base_url) 会产生双斜杠导致 404 // 否则 format!("{}/chat/completions", base_url) 会产生双斜杠导致 404
@ -1103,12 +1101,12 @@ impl OpenAIUsage {
impl LLMProvider for OpenAIProvider { impl LLMProvider for OpenAIProvider {
async fn chat( async fn chat(
&self, &self,
request: ChatCompletionRequest, request: &ChatCompletionRequest,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
// 检查是否启用流式输出 // 检查是否启用流式输出
if self.is_streaming_enabled() { if self.is_streaming_enabled() {
// 优先尝试流式输出(无回调) // 优先尝试流式输出(无回调)
match self.chat_streaming_internal(&request, None).await { match self.chat_streaming_internal(request, None).await {
Ok(response) => return Ok(response), Ok(response) => return Ok(response),
Err(e) => { Err(e) => {
tracing::debug!( tracing::debug!(
@ -1127,7 +1125,7 @@ impl LLMProvider for OpenAIProvider {
// 非流式回退实现 // 非流式回退实现
let url = format!("{}/chat/completions", self.base_url); let url = format!("{}/chat/completions", self.base_url);
let body = self.build_request_body(&request); let body = self.build_request_body(request);
// Debug: Log LLM request summary (only in debug builds) // Debug: Log LLM request summary (only in debug builds)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -1266,14 +1264,11 @@ impl LLMProvider for OpenAIProvider {
async fn chat_with_streaming( async fn chat_with_streaming(
&self, &self,
request: ChatCompletionRequest, request: &ChatCompletionRequest,
callback: StreamCallback, callback: StreamCallback,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
if self.is_streaming_enabled() { if self.is_streaming_enabled() {
match self match self.chat_streaming_internal(request, Some(&callback)).await {
.chat_streaming_internal(&request, Some(&callback))
.await
{
Ok(response) => return Ok(response), Ok(response) => return Ok(response),
Err(e) => { Err(e) => {
tracing::debug!( tracing::debug!(

View File

@ -117,7 +117,10 @@ pub struct ChatCompletionRequest {
pub messages: Vec<Message>, pub messages: Vec<Message>,
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
pub tools: Option<Vec<Tool>>, /// 工具定义在单次 process() 内跨多轮 LLM 迭代只读复用,
/// 用 Arc 共享避免每轮深拷贝(含完整 JSON schema可达数十 KB
/// serde 对 Arc 透明序列化,线上请求格式不变。
pub tools: Option<Arc<Vec<Tool>>>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -158,7 +161,7 @@ pub type StreamCallback = Arc<dyn Fn(StreamDelta) + Send + Sync>;
pub trait LLMProvider: Send + Sync { pub trait LLMProvider: Send + Sync {
async fn chat( async fn chat(
&self, &self,
request: ChatCompletionRequest, request: &ChatCompletionRequest,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>>; ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>>;
/// 带流式回调的 chat每收到一个 SSE delta 就调用 callback。 /// 带流式回调的 chat每收到一个 SSE delta 就调用 callback。
@ -166,7 +169,7 @@ pub trait LLMProvider: Send + Sync {
/// 默认实现忽略 callback直接调用 chat()。 /// 默认实现忽略 callback直接调用 chat()。
async fn chat_with_streaming( async fn chat_with_streaming(
&self, &self,
request: ChatCompletionRequest, request: &ChatCompletionRequest,
_callback: StreamCallback, _callback: StreamCallback,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
self.chat(request).await self.chat(request).await

View File

@ -107,7 +107,7 @@ impl Scheduler {
return; return;
} }
if let Err(error) = self.sync_config_jobs() { if let Err(error) = self.sync_config_jobs().await {
tracing::error!(error = %error, "Failed to sync scheduler config jobs"); tracing::error!(error = %error, "Failed to sync scheduler config jobs");
} }
@ -137,25 +137,41 @@ impl Scheduler {
} }
} }
fn sync_config_jobs(&self) -> anyhow::Result<()> { /// 同步配置中的 job 定义到 DB。整体移入 spawn_blocking
/// 方法内是同步 SQLite 调用(含锁等待),直接在 async worker 上执行
/// 会阻塞同 worker 的其他任务。
async fn sync_config_jobs(&self) -> anyhow::Result<()> {
let now = Utc::now(); let now = Utc::now();
for job in self.config.effective_jobs(&crate::config::TimeConfig { let config = self.config.clone();
timezone: self.timezone.name().to_string(), let jobs = self.jobs.clone();
}) { let timezone = self.timezone;
let runtime = let misfire_policy = config.misfire_policy;
RuntimeJob::from_config(&job, now, self.config.misfire_policy, self.timezone)?; tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let mut upsert = runtime.to_upsert(); for job in config.effective_jobs(&crate::config::TimeConfig {
if let Some(existing) = self.jobs.get_scheduler_job(&runtime.id)? { timezone: timezone.name().to_string(),
preserve_persisted_runtime(&mut upsert, &existing); }) {
let runtime = RuntimeJob::from_config(&job, now, misfire_policy, timezone)?;
let mut upsert = runtime.to_upsert();
if let Some(existing) = jobs.get_scheduler_job(&runtime.id)? {
preserve_persisted_runtime(&mut upsert, &existing);
}
jobs.upsert_scheduler_job(&upsert)?;
} }
self.jobs.upsert_scheduler_job(&upsert)?; Ok(())
} })
Ok(()) .await
.map_err(|e| anyhow::anyhow!("scheduler config sync task failed: {e}"))?
} }
async fn recover_interrupted_jobs(&self) -> anyhow::Result<usize> { async fn recover_interrupted_jobs(&self) -> anyhow::Result<usize> {
let now = Utc::now(); let now = Utc::now();
let running_jobs = self.jobs.list_running_scheduler_jobs()?; let jobs_repo = self.jobs.clone();
let running_jobs =
tokio::task::spawn_blocking(move || jobs_repo.list_running_scheduler_jobs())
.await
.map_err(|e| {
anyhow::anyhow!("scheduler interrupted jobs load task failed: {e}")
})??;
let mut recovered_count = 0; let mut recovered_count = 0;
for record in running_jobs { for record in running_jobs {
@ -192,17 +208,19 @@ impl Scheduler {
other => other, other => other,
}; };
self.jobs.update_scheduler_job_runtime( Self::persist_job_runtime_off_worker(
&record.id, self.jobs.clone(),
record.id.clone(),
SchedulerJobState::Scheduled, SchedulerJobState::Scheduled,
Some(SchedulerJobStatus::Error), Some(SchedulerJobStatus::Error),
Some(&error_msg), Some(error_msg),
record.run_count, record.run_count,
record.last_fired_at, record.last_fired_at,
next_fire_at, next_fire_at,
record.paused_at, record.paused_at,
record.completed_at, record.completed_at,
)?; )
.await?;
recovered_count += 1; recovered_count += 1;
tracing::info!( tracing::info!(
@ -226,7 +244,10 @@ impl Scheduler {
async fn process_tick(&self) -> anyhow::Result<()> { async fn process_tick(&self) -> anyhow::Result<()> {
let now = Utc::now(); let now = Utc::now();
let jobs = self.jobs.list_scheduler_jobs(true)?; let jobs_repo = self.jobs.clone();
let jobs = tokio::task::spawn_blocking(move || jobs_repo.list_scheduler_jobs(true))
.await
.map_err(|e| anyhow::anyhow!("scheduler jobs load task failed: {e}"))??;
for record in jobs { for record in jobs {
let Some(job) = let Some(job) =
@ -236,17 +257,19 @@ impl Scheduler {
}; };
if record.next_fire_at.is_none() && job.next_fire_at.is_some() { if record.next_fire_at.is_none() && job.next_fire_at.is_some() {
self.jobs.update_scheduler_job_runtime( Self::persist_job_runtime_off_worker(
&job.id, self.jobs.clone(),
job.id.clone(),
job.state.clone(), job.state.clone(),
job.last_status.clone(), job.last_status.clone(),
job.last_error.as_deref(), job.last_error.clone(),
job.run_count, job.run_count,
job.last_fired_at, job.last_fired_at,
job.next_fire_at, job.next_fire_at,
job.paused_at, job.paused_at,
job.completed_at, job.completed_at,
)?; )
.await?;
} }
if !job.is_due(now) { if !job.is_due(now) {
@ -265,17 +288,19 @@ impl Scheduler {
continue; continue;
}; };
self.jobs.update_scheduler_job_runtime( Self::persist_job_runtime_off_worker(
&job.id, self.jobs.clone(),
job.id.clone(),
SchedulerJobState::Running, SchedulerJobState::Running,
job.last_status.clone(), job.last_status.clone(),
job.last_error.as_deref(), job.last_error.clone(),
job.run_count, job.run_count,
job.last_fired_at, job.last_fired_at,
job.next_fire_at, job.next_fire_at,
job.paused_at, job.paused_at,
job.completed_at, job.completed_at,
)?; )
.await?;
// 执行与事后状态写入移入后台任务tick 循环只做派发, // 执行与事后状态写入移入后台任务tick 循环只做派发,
// 长耗时任务agent_task 可能长达数分钟)不再串行阻塞其他 job 的触发。 // 长耗时任务agent_task 可能长达数分钟)不再串行阻塞其他 job 的触发。
@ -314,17 +339,20 @@ impl Scheduler {
timezone, timezone,
) { ) {
Ok(()) => { Ok(()) => {
if let Err(error) = jobs_repo.update_scheduler_job_runtime( if let Err(error) = Scheduler::persist_job_runtime_off_worker(
&job.id, jobs_repo.clone(),
job.id.clone(),
job.state.clone(), job.state.clone(),
status, status,
job.last_error.as_deref(), job.last_error.clone(),
job.run_count, job.run_count,
job.last_fired_at, job.last_fired_at,
job.next_fire_at, job.next_fire_at,
job.paused_at, job.paused_at,
job.completed_at, job.completed_at,
) { )
.await
{
tracing::error!( tracing::error!(
job_id = %job.id, job_id = %job.id,
error = %error, error = %error,
@ -339,17 +367,20 @@ impl Scheduler {
error = %error, error = %error,
"Failed to compute post-execution scheduler state, resetting to Scheduled" "Failed to compute post-execution scheduler state, resetting to Scheduled"
); );
if let Err(update_error) = jobs_repo.update_scheduler_job_runtime( if let Err(update_error) = Scheduler::persist_job_runtime_off_worker(
&job.id, jobs_repo,
job.id.clone(),
SchedulerJobState::Scheduled, SchedulerJobState::Scheduled,
Some(SchedulerJobStatus::Error), Some(SchedulerJobStatus::Error),
Some(&error.to_string()), Some(error.to_string()),
job.run_count, job.run_count,
job.last_fired_at, job.last_fired_at,
job.next_fire_at, job.next_fire_at,
job.paused_at, job.paused_at,
job.completed_at, job.completed_at,
) { )
.await
{
tracing::error!( tracing::error!(
job_id = %job.id, job_id = %job.id,
error = %update_error, error = %update_error,
@ -379,6 +410,39 @@ impl Scheduler {
.await .await
} }
/// 将 job 运行时状态写入移入 spawn_blocking
/// update_scheduler_job_runtime 是同步 SQLite 调用DB 锁竞争时
/// 的等待时间不可控,直接 await 点外执行会阻塞 tokio async worker。
async fn persist_job_runtime_off_worker(
jobs: Arc<dyn SchedulerJobRepository>,
job_id: String,
state: SchedulerJobState,
last_status: Option<SchedulerJobStatus>,
last_error: Option<String>,
run_count: i64,
last_fired_at: Option<i64>,
next_fire_at: Option<i64>,
paused_at: Option<i64>,
completed_at: Option<i64>,
) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || {
jobs.update_scheduler_job_runtime(
&job_id,
state,
last_status,
last_error.as_deref(),
run_count,
last_fired_at,
next_fire_at,
paused_at,
completed_at,
)
})
.await
.map_err(|e| anyhow::anyhow!("scheduler job state persist task failed: {e}"))?
.map_err(anyhow::Error::from)
}
/// job 执行主体:不依赖 &self便于移入 tokio::spawn 的后台任务。 /// job 执行主体:不依赖 &self便于移入 tokio::spawn 的后台任务。
async fn execute_job_inner( async fn execute_job_inner(
bus: &Arc<MessageBus>, bus: &Arc<MessageBus>,
@ -1601,8 +1665,8 @@ mod tests {
assert_eq!(saved.state, SchedulerJobState::Scheduled); assert_eq!(saved.state, SchedulerJobState::Scheduled);
} }
#[test] #[tokio::test]
fn sync_config_jobs_persists_builtin_memory_maintenance_job() { async fn sync_config_jobs_persists_builtin_memory_maintenance_job() {
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
let (agent_task_executor, maintenance_service) = test_scheduler_services(); let (agent_task_executor, maintenance_service) = test_scheduler_services();
@ -1615,7 +1679,7 @@ mod tests {
maintenance_service, maintenance_service,
); );
scheduler.sync_config_jobs().unwrap(); scheduler.sync_config_jobs().await.unwrap();
let saved = store let saved = store
.get_scheduler_job(BUILTIN_MEMORY_MAINTENANCE_JOB_ID) .get_scheduler_job(BUILTIN_MEMORY_MAINTENANCE_JOB_ID)
@ -1647,8 +1711,8 @@ mod tests {
assert!(saved.next_fire_at.is_some()); assert!(saved.next_fire_at.is_some());
} }
#[test] #[tokio::test]
fn sync_config_jobs_preserves_persisted_next_fire_at_for_matching_jobs() { async fn sync_config_jobs_preserves_persisted_next_fire_at_for_matching_jobs() {
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
let persisted_next_fire_at = 1_700_000_300_000; let persisted_next_fire_at = 1_700_000_300_000;
let config_job = SchedulerJobConfig { let config_job = SchedulerJobConfig {
@ -1735,7 +1799,7 @@ mod tests {
maintenance_service, maintenance_service,
); );
scheduler.sync_config_jobs().unwrap(); scheduler.sync_config_jobs().await.unwrap();
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap(); let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();

View File

@ -110,6 +110,135 @@ pub(super) fn ensure_topics_schema(conn: &Connection) -> Result<(), StorageError
if !has_column(conn, "topics", "model")? { if !has_column(conn, "topics", "model")? {
add_column_if_missing(conn, "ALTER TABLE topics ADD COLUMN model TEXT")?; add_column_if_missing(conn, "ALTER TABLE topics ADD COLUMN model TEXT")?;
} }
// Token 用量增量维护列。topics 列表加载(每次前端刷新)原先对 messages
// 全量 SUM + MAX(seq) 聚合,大库下显著变慢。改为与 message_count 相同的
// 增量维护模式append 时累加,列表加载直接读列。
// stat_has_usage=0 表示该话题从无 usage 数据(前端不显示 token 标签,
// 与旧逻辑"stats_map 无条目 → token_stats=None"语义一致)。
if !has_column(conn, "topics", "stat_prompt_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_prompt_tokens INTEGER NOT NULL DEFAULT 0",
)?;
}
if !has_column(conn, "topics", "stat_completion_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_completion_tokens INTEGER NOT NULL DEFAULT 0",
)?;
}
if !has_column(conn, "topics", "stat_total_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_total_tokens INTEGER NOT NULL DEFAULT 0",
)?;
}
if !has_column(conn, "topics", "stat_cached_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_cached_tokens INTEGER NOT NULL DEFAULT 0",
)?;
}
if !has_column(conn, "topics", "stat_last_prompt_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_last_prompt_tokens INTEGER",
)?;
}
if !has_column(conn, "topics", "stat_context_window_tokens")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_context_window_tokens INTEGER",
)?;
}
if !has_column(conn, "topics", "stat_has_usage")? {
add_column_if_missing(
conn,
"ALTER TABLE topics ADD COLUMN stat_has_usage INTEGER NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 从 messages 表一次性回填 topics 的 token 统计增量列user_version 4一次性迁移
///
/// 聚合口径与旧 batch_topic_token_stats 完全一致:
/// - 仅 role='assistant' 且 session_id NOT LIKE 'sub:%'(子代理消息的 topic_id
/// 指向父 topic必须显式排除否则父话题统计被污染
/// - last_prompt_tokens / context_window_tokens 取每个 topic 最新一条
/// prompt_tokens IS NOT NULL 的 assistant 消息(按 seq 倒序)。
///
/// 相关子查询命中 idx_messages_topic_seq(topic_id, seq),总复杂度 O(全部消息)
/// 仅首启执行一次。事务包裹,失败回滚后可安全重跑。
pub(super) fn backfill_topic_usage_stats(conn: &mut Connection) -> Result<(), StorageError> {
const TOPIC_USAGE_BACKFILL_VERSION: i64 = 4;
let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if current_version >= TOPIC_USAGE_BACKFILL_VERSION {
return Ok(());
}
let tx = conn.transaction()?;
// SUM 累计列:四个相关子查询共享同一过滤条件
tx.execute(
"UPDATE topics SET
stat_prompt_tokens = COALESCE((
SELECT SUM(m.prompt_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_completion_tokens = COALESCE((
SELECT SUM(m.completion_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_total_tokens = COALESCE((
SELECT SUM(m.total_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_cached_tokens = COALESCE((
SELECT SUM(m.cached_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0)",
[],
)?;
// 最新 usage 瞬时列:每个 topic 最新一条有 prompt_tokens 的 assistant 消息
tx.execute(
"UPDATE topics SET
stat_last_prompt_tokens = (
SELECT m.prompt_tokens FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1),
stat_context_window_tokens = (
SELECT m.context_window_tokens FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1)",
[],
)?;
// 有 assistant 消息的话题打标(与旧聚合"GROUP BY 有条目"语义一致,
// 前端以此决定是否显示 token 标签)
tx.execute(
"UPDATE topics SET stat_has_usage = 1
WHERE EXISTS (
SELECT 1 FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%')",
[],
)?;
tx.execute(
&format!("PRAGMA user_version = {TOPIC_USAGE_BACKFILL_VERSION}"),
[],
)?;
tx.commit()?;
Ok(()) Ok(())
} }

View File

@ -7,7 +7,7 @@ use crate::utils::current_timestamp;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
@ -70,6 +70,7 @@ impl SessionStore {
conn.execute_batch( conn.execute_batch(
" "
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
@ -239,11 +240,21 @@ impl SessionStore {
ensure_pending_subagents_schema(&conn)?; ensure_pending_subagents_schema(&conn)?;
repair_session_id_prefix_pollution(&mut conn)?; repair_session_id_prefix_pollution(&mut conn)?;
cleanup_legacy_empty_cli_sessions(&mut conn)?; cleanup_legacy_empty_cli_sessions(&mut conn)?;
backfill_topic_usage_stats(&mut conn)?;
drop(conn); drop(conn);
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| { let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
c.busy_timeout(std::time::Duration::from_secs(30))?; c.busy_timeout(std::time::Duration::from_secs(30))?;
// synchronous 是 per-connection PRAGMA不随库文件持久化
// 池内每个连接都必须单独设置。WAL + NORMAL 是 SQLite 官方推荐组合:
// 消除每次 commit 的 WAL full fsync仅 checkpoint 时同步。
c.pragma_update(None, "synchronous", "NORMAL")?;
// foreign_keys 同样是 per-connection 且默认关闭。schema 声明的
// ON DELETE CASCADE / SET NULLtopics/skill_events/messages 引用
// sessionsmessages 引用 topics只有在该 PRAGMA 开启时才执行——
// 缺少它会导致删除会话/话题后子表行孤儿残留、消息留下悬空 topic_id。
c.pragma_update(None, "foreign_keys", "ON")?;
Ok(()) Ok(())
}); });
let pool = Pool::builder().max_size(8).build(manager)?; let pool = Pool::builder().max_size(8).build(manager)?;
@ -383,6 +394,37 @@ impl SessionStore {
Ok(sessions) Ok(sessions)
} }
/// 按 id 后缀查找最近活跃的一条 session`LIMIT 1` 定向查询)。
///
/// 调用方只需要"最新一条"时使用本方法,避免 LIKE 匹配多行时
/// 全部反序列化后仅取 `[0]`。
pub fn find_first_session_by_id_suffix(
&self,
suffix: &str,
) -> Result<Option<SessionRecord>, StorageError> {
let conn = self.pool.get()?;
let pattern = format!("%{}", suffix);
conn.query_row(
"
SELECT id, title, channel_name, chat_id, summary,
created_at, updated_at, last_active_at,
archived_at, deleted_at, message_count,
user_turn_count, agent_prompt_reinjection_count
FROM sessions
WHERE id LIKE ?1 AND deleted_at IS NULL
ORDER BY last_active_at DESC
LIMIT 1
",
params![pattern],
map_session_record,
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other.into()),
})
}
pub fn list_sessions( pub fn list_sessions(
&self, &self,
channel_name: &str, channel_name: &str,
@ -594,6 +636,120 @@ impl SessionStore {
Ok(()) Ok(())
} }
/// 按 id 批量删除指定 session 的消息行(历史加载时 sanitize 修复回写用)。
/// 返回实际删除的行数。
pub fn delete_messages_by_ids(
&self,
session_id: &str,
ids: &[String],
) -> Result<usize, StorageError> {
if ids.is_empty() {
return Ok(0);
}
let conn = self.pool.get()?;
let mut total = 0;
let mut affected_topics: Vec<String> = Vec::new();
// SQLite 绑定变量上限SQLITE_MAX_VARIABLE_NUMBER 默认 999
// 分批构造 IN 子句,避免超限。
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
// 先收集将被删除行的 topic_id用于删除后重算 topics.message_count
let topic_sql = format!(
"SELECT DISTINCT topic_id FROM messages
WHERE session_id = ? AND topic_id IS NOT NULL AND id IN ({placeholders})"
);
let mut topic_params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1);
topic_params.push(&session_id);
for id in chunk {
topic_params.push(id);
}
let mut stmt = conn.prepare(&topic_sql)?;
let rows = stmt.query_map(topic_params.as_slice(), |row| {
row.get::<_, String>(0)
})?;
for tid in rows {
affected_topics.push(tid?);
}
let sql = format!(
"DELETE FROM messages WHERE session_id = ? AND id IN ({placeholders})"
);
let mut params_vec: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1);
params_vec.push(&session_id);
for id in chunk {
params_vec.push(id);
}
total += conn.execute(&sql, params_vec.as_slice())?;
}
// 计数列与消息行的一致性sessions/topics 的 message_count 是增量维护列
// append +1、clear 归零),删除后必须同步。用活 COUNT 重算而非减法,
// 自愈历史漂移且不会减为负数。
if total > 0 {
conn.execute(
"UPDATE sessions SET message_count =
(SELECT COUNT(*) FROM messages WHERE session_id = ?1),
user_turn_count =
(SELECT COUNT(*) FROM messages
WHERE session_id = ?1 AND role = 'user')
WHERE id = ?1",
params![session_id],
)?;
affected_topics.sort_unstable();
affected_topics.dedup();
for tid in &affected_topics {
conn.execute(
"UPDATE topics SET message_count =
(SELECT COUNT(*) FROM messages WHERE topic_id = ?1)
WHERE id = ?1",
params![tid],
)?;
// token 统计增量列同样按活查询重算(含 has_usage 打标),
// 口径与 backfill_topic_usage_stats 一致(排除 sub:% 子代理消息)。
conn.execute(
"UPDATE topics SET
stat_prompt_tokens = COALESCE((
SELECT SUM(m.prompt_tokens) FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_completion_tokens = COALESCE((
SELECT SUM(m.completion_tokens) FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_total_tokens = COALESCE((
SELECT SUM(m.total_tokens) FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_cached_tokens = COALESCE((
SELECT SUM(m.cached_tokens) FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_last_prompt_tokens = (
SELECT m.prompt_tokens FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1),
stat_context_window_tokens = (
SELECT m.context_window_tokens FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1),
stat_has_usage = CASE WHEN EXISTS (
SELECT 1 FROM messages m
WHERE m.topic_id = ?1 AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%')
THEN 1 ELSE 0 END
WHERE id = ?1",
params![tid],
)?;
}
}
Ok(total)
}
pub fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> { pub fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
let now = current_timestamp(); let now = current_timestamp();
let conn = self.pool.get()?; let conn = self.pool.get()?;
@ -613,6 +769,21 @@ impl SessionStore {
", ",
params![session_id, now], params![session_id, now],
)?; )?;
// topics 增量列随消息清空归零(消息已全部删除,无需重算):
// message_count 必须一并归零,否则前端话题列表显示虚假消息数
conn.execute(
"UPDATE topics SET
message_count = 0,
stat_prompt_tokens = 0,
stat_completion_tokens = 0,
stat_total_tokens = 0,
stat_cached_tokens = 0,
stat_last_prompt_tokens = NULL,
stat_context_window_tokens = NULL,
stat_has_usage = 0
WHERE session_id = ?1",
params![session_id],
)?;
Ok(()) Ok(())
} }
@ -695,6 +866,10 @@ impl SessionStore {
"UPDATE topics SET message_count = message_count + 1, last_active_at = ?2 WHERE id = ?1", "UPDATE topics SET message_count = message_count + 1, last_active_at = ?2 WHERE id = ?1",
params![tid, now], params![tid, now],
)?; )?;
// token 统计增量维护(与 batch_topic_token_stats 读列口径一致):
// 仅主代理 assistant 消息计入(子代理 session_id 以 'sub:' 开头,
// 其消息 topic_id 指向父 topic不计入父话题统计
Self::accumulate_topic_usage_in_tx(&tx, tid, session_id, message)?;
} }
tx.commit()?; tx.commit()?;
@ -720,6 +895,15 @@ impl SessionStore {
|row| row.get(0), |row| row.get(0),
)?; )?;
let mut insert_stmt = tx.prepare_cached(
"
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json,
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
",
)?;
for message in messages { for message in messages {
let media_refs_json = serde_json::to_string(&message.media_refs)?; let media_refs_json = serde_json::to_string(&message.media_refs)?;
let tool_calls_json = message let tool_calls_json = message
@ -727,38 +911,33 @@ impl SessionStore {
.as_ref() .as_ref()
.map(serde_json::to_string) .map(serde_json::to_string)
.transpose()?; .transpose()?;
tx.execute( insert_stmt.execute(params![
" message.id,
INSERT INTO messages ( session_id,
id, session_id, topic_id, seq, role, content, topic_id,
system_context, reasoning_content, media_refs_json, seq,
tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at message.role,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) message.content,
", message.system_context,
params![ message.reasoning_content,
message.id, media_refs_json,
session_id, message.tool_call_id,
topic_id, message.tool_name,
seq, tool_calls_json,
message.role, message.tool_duration_ms.map(|v| v as i64),
message.content, message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.system_context, message.usage.as_ref().map(|u| u.completion_tokens as i64),
message.reasoning_content, message.usage.as_ref().map(|u| u.total_tokens as i64),
media_refs_json, message
message.tool_call_id, .usage
message.tool_name, .as_ref()
tool_calls_json, .and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.tool_duration_ms.map(|v| v as i64), message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.usage.as_ref().map(|u| u.prompt_tokens as i64), message.timestamp,
message.usage.as_ref().map(|u| u.completion_tokens as i64), ])?;
message.usage.as_ref().map(|u| u.total_tokens as i64),
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.timestamp,
],
)?;
seq += 1; seq += 1;
} }
drop(insert_stmt);
let now = current_timestamp(); let now = current_timestamp();
let user_msg_count: i64 = messages let user_msg_count: i64 = messages
@ -787,12 +966,67 @@ impl SessionStore {
"UPDATE topics SET message_count = message_count + ?2, last_active_at = ?3 WHERE id = ?1", "UPDATE topics SET message_count = message_count + ?2, last_active_at = ?3 WHERE id = ?1",
params![tid, msg_count, now], params![tid, msg_count, now],
)?; )?;
// token 统计增量维护:逐条累加(批次内 assistant usage 消息通常仅 1-2 条)。
// 批次按 seq 递增追加,最后一条有 usage 的 assistant 消息即"最新"。
for message in messages {
Self::accumulate_topic_usage_in_tx(&tx, tid, session_id, message)?;
}
} }
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
/// 在给定事务中将单条消息的 usage 累加到 topic 统计列。
///
/// 过滤口径与回填迁移/旧聚合查询一致仅主代理session_id 不以 'sub:' 开头)
/// 的 assistant 消息且携带 usage 时计入。last_prompt_tokens /
/// context_window_tokens 仅当该消息 prompt_tokens 非空时覆盖
/// (对齐旧 SQL 的 `prompt_tokens IS NOT NULL` 过滤)。
fn accumulate_topic_usage_in_tx(
tx: &Transaction,
topic_id: &str,
session_id: &str,
message: &ChatMessage,
) -> Result<(), StorageError> {
if session_id.starts_with("sub:") || message.role != "assistant" {
return Ok(());
}
let Some(usage) = message.usage.as_ref() else {
return Ok(());
};
let prompt_tokens = usage.prompt_tokens as i64;
let completion_tokens = usage.completion_tokens as i64;
let total_tokens = usage.total_tokens as i64;
let cached_tokens = usage.cached_tokens as i64;
// 与旧聚合一致last_* 仅取 prompt_tokens IS NOT NULL 的消息。
// usage 存在即代表本条 assistant 消息有 prompt_tokensMessageUsage
// 由 provider 响应构造),故直接覆盖。
let last_prompt = prompt_tokens;
let last_ctx_window: Option<i64> = usage.context_window_tokens.map(|v| v as i64);
tx.execute(
"UPDATE topics SET
stat_prompt_tokens = stat_prompt_tokens + ?2,
stat_completion_tokens = stat_completion_tokens + ?3,
stat_total_tokens = stat_total_tokens + ?4,
stat_cached_tokens = stat_cached_tokens + ?5,
stat_last_prompt_tokens = ?6,
stat_context_window_tokens = ?7,
stat_has_usage = 1
WHERE id = ?1",
params![
topic_id,
prompt_tokens,
completion_tokens,
total_tokens,
cached_tokens,
last_prompt,
last_ctx_window,
],
)?;
Ok(())
}
pub fn compact_active_history( pub fn compact_active_history(
&self, &self,
session_id: &str, session_id: &str,
@ -850,14 +1084,16 @@ impl SessionStore {
let mut inserted_count = 0_i64; let mut inserted_count = 0_i64;
let mut active_user_turn_count = 0_i64; let mut active_user_turn_count = 0_i64;
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_SQL)?;
for message in &new_messages { for message in &new_messages {
if message.role == "user" { if message.role == "user" {
active_user_turn_count += 1; active_user_turn_count += 1;
} }
insert_message_with_seq(&tx, session_id, next_seq, message)?; insert_message_with_seq(&mut insert_stmt, session_id, next_seq, message)?;
next_seq += 1; next_seq += 1;
inserted_count += 1; inserted_count += 1;
} }
drop(insert_stmt);
// Delete all old messages (including delta messages that were just re-inserted) // Delete all old messages (including delta messages that were just re-inserted)
tx.execute( tx.execute(
@ -879,6 +1115,11 @@ impl SessionStore {
)?; )?;
tx.commit()?; tx.commit()?;
// 会话级压缩重组了消息行(标记/删除/插入摘要),所有 topic 统计可能漂移
self.recompute_session_topic_usage_stats(session_id)?;
// 新插入消息经 INSERT_MESSAGE_SQL 写入(无 topic_id 列),被删的旧行
// 可能带 topic_id——按活查询重算 topics.message_count 防漂移
self.recompute_session_topic_message_counts(session_id)?;
Ok(true) Ok(true)
} }
@ -905,13 +1146,15 @@ impl SessionStore {
// Insert new messages with sequential seq numbers // Insert new messages with sequential seq numbers
let mut active_user_turn_count = 0_i64; let mut active_user_turn_count = 0_i64;
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_SQL)?;
for (i, message) in messages.iter().enumerate() { for (i, message) in messages.iter().enumerate() {
let seq = (i + 1) as i64; let seq = (i + 1) as i64;
if message.role == "user" { if message.role == "user" {
active_user_turn_count += 1; active_user_turn_count += 1;
} }
insert_message_with_seq(&tx, session_id, seq, message)?; insert_message_with_seq(&mut insert_stmt, session_id, seq, message)?;
} }
drop(insert_stmt);
tx.execute( tx.execute(
" "
@ -932,6 +1175,11 @@ impl SessionStore {
)?; )?;
tx.commit()?; tx.commit()?;
// 整会话消息被替换,所有 topic 统计可能漂移——重算自愈
self.recompute_session_topic_usage_stats(session_id)?;
// 新插入消息经 INSERT_MESSAGE_SQL 写入(无 topic_id 列topic 消息数
// 按活查询重算防漂移
self.recompute_session_topic_message_counts(session_id)?;
Ok(()) Ok(())
} }
@ -972,10 +1220,12 @@ impl SessionStore {
|row| row.get(0), |row| row.get(0),
)?; )?;
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
for (i, message) in messages.iter().enumerate() { for (i, message) in messages.iter().enumerate() {
let seq = start_seq + i as i64; let seq = start_seq + i as i64;
insert_message_with_topic_seq(&tx, session_id, topic_id, seq, message)?; insert_message_with_topic_seq(&mut insert_stmt, session_id, topic_id, seq, message)?;
} }
drop(insert_stmt);
// Update this topic's message_count and timestamps. // Update this topic's message_count and timestamps.
tx.execute( tx.execute(
@ -1000,6 +1250,8 @@ impl SessionStore {
)?; )?;
tx.commit()?; tx.commit()?;
// 该 topic 消息被整体替换——重算统计列自愈
self.recompute_topic_usage_stats(&[topic_id.to_string()])?;
Ok(()) Ok(())
} }
@ -1070,10 +1322,12 @@ impl SessionStore {
params![session_id], params![session_id],
|row| row.get(0), |row| row.get(0),
)?; )?;
let mut insert_stmt = tx.prepare_cached(INSERT_MESSAGE_TOPIC_SQL)?;
for (i, message) in summaries.iter().enumerate() { for (i, message) in summaries.iter().enumerate() {
let seq = start_seq + i as i64; let seq = start_seq + i as i64;
insert_message_with_topic_seq(&tx, session_id, topic_id, seq, message)?; insert_message_with_topic_seq(&mut insert_stmt, session_id, topic_id, seq, message)?;
} }
drop(insert_stmt);
// 更新 topic / session 计数(基于该 topic 全部消息,含被压缩的原始消息) // 更新 topic / session 计数(基于该 topic 全部消息,含被压缩的原始消息)
let topic_count: i64 = tx.query_row( let topic_count: i64 = tx.query_row(
@ -1099,6 +1353,8 @@ impl SessionStore {
)?; )?;
tx.commit()?; tx.commit()?;
// 摘要消息可能携带 usage且旧摘要被删除——重算该 topic 统计列自愈
self.recompute_topic_usage_stats(&[topic_id.to_string()])?;
Ok(()) Ok(())
} }
@ -1787,6 +2043,68 @@ impl SessionStore {
} }
} }
/// 按话题分页加载消息keyset 分页,从最新端向前取页)。
///
/// 切换话题时只加载最新 `limit` 条,更早消息由用户向上滚动时按
/// `before_seq` 游标增量加载,避免长话题全量传输/解析阻塞首屏。
/// 过滤条件与 [`Self::load_messages_for_topic_full`] 一致
/// (排除 history_compaction 摘要行)。返回正序消息 + 是否还有更早页。
pub fn load_messages_for_topic_page(
&self,
topic_id: &str,
session_id: Option<&str>,
before_seq: Option<i64>,
limit: usize,
) -> Result<(Vec<ChatMessage>, bool), StorageError> {
let conn = self.pool.get()?;
// 多取 1 条仅用于判断 has_more截断后返回 limit 条
let fetch = limit + 1;
let mut messages: Vec<ChatMessage> = if let Some(sid) = session_id {
let mut stmt = conn.prepare(&format!(
"
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1 AND session_id = ?2
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
AND (?3 IS NULL OR seq < ?3)
ORDER BY seq DESC
LIMIT {fetch}
",
))?;
let rows = stmt.query_map(params![topic_id, sid, before_seq], map_chat_message_row)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
out
} else {
let mut stmt = conn.prepare(&format!(
"
SELECT {MESSAGE_LOAD_COLUMNS}
FROM messages
WHERE topic_id = ?1
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
AND (?2 IS NULL OR seq < ?2)
ORDER BY seq DESC
LIMIT {fetch}
",
))?;
let rows = stmt.query_map(params![topic_id, before_seq], map_chat_message_row)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
out
};
let has_more = messages.len() > limit;
if has_more {
messages.truncate(limit);
}
// DESC 取页后反转为正序,与全量加载的顺序语义一致
messages.reverse();
Ok((messages, has_more))
}
/// 定向查询指定话题的第一条 user 消息内容。 /// 定向查询指定话题的第一条 user 消息内容。
/// ///
/// 数据库侧 `LIMIT 1`,避免为取单条消息全量加载并反序列化整个话题历史 /// 数据库侧 `LIMIT 1`,避免为取单条消息全量加载并反序列化整个话题历史
@ -1829,6 +2147,21 @@ impl SessionStore {
Ok(count as usize) Ok(count as usize)
} }
/// 按 topic 统计 user 角色消息数(定向 `COUNT(*)`)。
///
/// 与 `get_topic_message_count` 同理:数据库侧计数,
/// 避免为得到数量而全量加载消息体content、tool_calls_json 等大字段)。
/// 过滤条件与 `load_messages_for_topic(_, None)` 一致(排除已压缩消息)。
pub fn count_user_messages_for_topic(&self, topic_id: &str) -> Result<usize, StorageError> {
let conn = self.pool.get()?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages WHERE topic_id = ?1 AND role = 'user' AND is_compacted = 0",
params![topic_id],
|row| row.get(0),
)?;
Ok(count as usize)
}
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> { pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
let conn = self.pool.get()?; let conn = self.pool.get()?;
load_messages_after(&conn, session_id, 0) load_messages_after(&conn, session_id, 0)
@ -1848,16 +2181,19 @@ impl SessionStore {
.map_err(StorageError::from) .map_err(StorageError::from)
} }
/// 批量查询多个 topic 的 token 消耗统计cost 累计 + context 瞬时)。 /// 批量读取 topic 的 token 用量统计cost 累计 + context 瞬时)。
/// ///
/// 按 `topic_id` 聚合而非 `session_id`:一个 session 可包含多个 topic /// 直接读取 topics 表的增量维护列stat_*),不再对 messages 全量聚合——
/// 若按 session_id 聚合会导致同 session 下的所有 topic 显示相同的总和。 /// 话题列表每次刷新都调用本函数,大库下聚合是显著瓶颈。
/// 增量列由 append 路径累加、变更路径(删除/替换/清空)重算自愈、
/// 老数据由 backfill_topic_usage_stats 一次性回填。
/// ///
/// 子代理隔离:子代理消息持久化时 session_id='sub:...'topic_id=父 topic_id /// 子代理隔离在写入/重算侧保证:子代理消息持久化时 session_id='sub:...'
/// (见 task::runtime PersistingEmittedMessageHandler 构造),因此不能仅靠 /// topic_id=父 topic_id累加与重算均以 `session_id NOT LIKE 'sub:%'`
/// topic_id 隔离。此处用 `session_id NOT LIKE 'sub:%'` 显式排除子代理消息, /// 排除,子代理 token 不计入父 topic。
/// 与项目约定一致session 列表同样过滤 'sub:%')。子代理 token 不计入父 topic ///
/// 保持"子代理分别计算"语义。 /// `stat_has_usage = 0` 的话题不返回条目,保持旧语义
/// stats_map 无条目 → token_stats=None前端不显示 token 标签)。
pub fn batch_topic_token_stats( pub fn batch_topic_token_stats(
&self, &self,
topic_ids: &[&str], topic_ids: &[&str],
@ -1871,81 +2207,132 @@ impl SessionStore {
.map(|i| format!("?{}", i + 1)) .map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息; let sql = format!(
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id "SELECT id, stat_prompt_tokens, stat_completion_tokens, stat_total_tokens, \
// SUM 列清单与行映射见 USAGE_SUM_COLUMNS / read_usage_sum_row共享于子代理查询 stat_cached_tokens, stat_last_prompt_tokens, stat_context_window_tokens \
let sum_sql = format!( FROM topics \
"SELECT topic_id, {USAGE_SUM_COLUMNS} \ WHERE id IN ({placeholders}) AND stat_has_usage = 1"
FROM messages \
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND session_id NOT LIKE 'sub:%' \
GROUP BY topic_id"
); );
let mut stmt = conn.prepare(&sql)?;
let mut stmt = conn.prepare(&sum_sql)?;
let params: Vec<&dyn rusqlite::ToSql> = topic_ids let params: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter() .iter()
.map(|s| s as &dyn rusqlite::ToSql) .map(|s| s as &dyn rusqlite::ToSql)
.collect(); .collect();
let sum_rows = stmt.query_map(params.as_slice(), |row| { let rows = stmt.query_map(params.as_slice(), |row| {
Ok((row.get::<_, String>(0)?, read_usage_sum_row(row, 1)?)) Ok((
row.get::<_, String>(0)?,
SessionTokenStats {
prompt_tokens: row.get::<_, i64>(1)? as u64,
completion_tokens: row.get::<_, i64>(2)? as u64,
total_tokens: row.get::<_, i64>(3)? as u64,
cached_tokens: row.get::<_, i64>(4)? as u64,
last_prompt_tokens: row.get::<_, Option<i64>>(5)?.map(|v| v as u32),
context_window_tokens: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
},
))
})?; })?;
let mut stats: HashMap<String, SessionTokenStats> = HashMap::new(); let mut stats: HashMap<String, SessionTokenStats> = HashMap::new();
for row in sum_rows { for row in rows {
let (tid, s) = row?; let (tid, s) = row?;
stats.insert(tid, s); stats.insert(tid, s);
} }
Ok(stats)
}
// 查找每个 topic 中最新的**有 usage 数据的** assistant 消息, /// 从 messages 表重算指定 topic 的 token 统计增量列(自愈漂移)。
// 读取其 prompt_tokens 和 context_window_tokens。 ///
// 过滤 prompt_tokens IS NOT NULL 确保跳过 error/cancel 消息usage 为 NULL /// 用于变更消息行的路径sanitize 删除、话题历史替换、会话清空等。
// session_id NOT LIKE 'sub:%' 排除子代理消息,避免取到子代理的 context_window。 /// 聚合口径与 backfill_topic_usage_stats 完全一致(排除 sub:% 子代理消息)。
// pub fn recompute_topic_usage_stats(&self, topic_ids: &[String]) -> Result<(), StorageError> {
// 注意seq 是 session 级递增(见 append_message_with_topic主 session 与 if topic_ids.is_empty() {
// 子代理 session 各自独立计数,可能存在相同 seq。外层 WHERE 必须再次过滤 return Ok(());
// session_id NOT LIKE 'sub:%',否则 JOIN 会同时匹配主消息和子代理消息, }
// 导致重复行并使 stats.entry(tid) 被覆盖,结果不确定。 let conn = self.pool.get()?;
let last_sql = format!( let placeholders = (0..topic_ids.len())
"SELECT m.topic_id, m.prompt_tokens, m.context_window_tokens \ .map(|i| format!("?{}", i + 1))
FROM messages m \ .collect::<Vec<_>>()
INNER JOIN ( \ .join(", ");
SELECT topic_id, MAX(seq) AS max_seq \ let filter = format!("id IN ({placeholders})");
FROM messages \ let params: Vec<&dyn rusqlite::ToSql> = topic_ids
WHERE topic_id IN ({placeholders}) AND role = 'assistant' \
AND prompt_tokens IS NOT NULL \
AND session_id NOT LIKE 'sub:%' \
GROUP BY topic_id \
) latest ON m.topic_id = latest.topic_id AND m.seq = latest.max_seq \
WHERE m.session_id NOT LIKE 'sub:%'"
);
let mut stmt2 = conn.prepare(&last_sql)?;
let params2: Vec<&dyn rusqlite::ToSql> = topic_ids
.iter() .iter()
.map(|s| s as &dyn rusqlite::ToSql) .map(|s| s as &dyn rusqlite::ToSql)
.collect(); .collect();
let last_rows = stmt2.query_map(params2.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, Option<i64>>(2)?,
))
})?;
for row in last_rows {
let (tid, last_prompt, last_ctx_window) = row?;
let entry = stats.entry(tid).or_insert(SessionTokenStats {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cached_tokens: 0,
last_prompt_tokens: None,
context_window_tokens: None,
});
entry.last_prompt_tokens = last_prompt.map(|v| v as u32);
entry.context_window_tokens = last_ctx_window.map(|v| v as u32);
}
Ok(stats) conn.execute(
&format!(
"UPDATE topics SET
stat_prompt_tokens = COALESCE((
SELECT SUM(m.prompt_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_completion_tokens = COALESCE((
SELECT SUM(m.completion_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_total_tokens = COALESCE((
SELECT SUM(m.total_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_cached_tokens = COALESCE((
SELECT SUM(m.cached_tokens) FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%'), 0),
stat_last_prompt_tokens = (
SELECT m.prompt_tokens FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1),
stat_context_window_tokens = (
SELECT m.context_window_tokens FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.prompt_tokens IS NOT NULL
AND m.session_id NOT LIKE 'sub:%'
ORDER BY m.seq DESC LIMIT 1),
stat_has_usage = CASE WHEN EXISTS (
SELECT 1 FROM messages m
WHERE m.topic_id = topics.id AND m.role = 'assistant'
AND m.session_id NOT LIKE 'sub:%')
THEN 1 ELSE 0 END
WHERE {filter}"
),
params.as_slice(),
)?;
Ok(())
}
/// 重算指定 session 下所有 topic 的 token 统计增量列(自愈漂移)。
///
/// 用于整会话级变更路径replace_active_history / compact_active_history
/// 这些路径会删除或重组整个 session 的消息行,所有 topic 的统计都可能漂移。
pub fn recompute_session_topic_usage_stats(&self, session_id: &str) -> Result<(), StorageError> {
let ids: Vec<String> = {
let conn = self.pool.get()?;
let mut stmt = conn.prepare("SELECT id FROM topics WHERE session_id = ?1")?;
stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?
.collect::<Result<_, _>>()?
};
self.recompute_topic_usage_stats(&ids)
}
/// 按活查询重算指定 session 下所有 topic 的 message_count自愈漂移
///
/// 用于整会话级变更路径replace_active_history / compact_active_history
/// 这些路径重插的消息不带 topic_id被删的旧行可能带 topic_id
/// topics.message_count 增量列无法跟随,必须重算。
pub fn recompute_session_topic_message_counts(
&self,
session_id: &str,
) -> Result<(), StorageError> {
let conn = self.pool.get()?;
conn.execute(
"UPDATE topics SET message_count =
(SELECT COUNT(*) FROM messages WHERE topic_id = topics.id)
WHERE session_id = ?1",
params![session_id],
)?;
Ok(())
} }
/// 查询单个 session 的 token 消耗统计cost 累计 + context 瞬时)。 /// 查询单个 session 的 token 消耗统计cost 累计 + context 瞬时)。
@ -2031,24 +2418,25 @@ impl SessionStore {
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?; tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
// Insert new todos // Insert new todos
let mut insert_stmt = tx.prepare_cached(
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
)?;
for item in items { for item in items {
tx.execute( insert_stmt.execute(params![
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id) item.id,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", scope_key,
params![ item.session_id,
item.id, item.topic_id,
scope_key, item.content,
item.session_id, item.status,
item.topic_id, item.priority,
item.content, item.created_at,
item.status, now,
item.priority, item.created_by_message_id,
item.created_at, ])?;
now,
item.created_by_message_id,
],
)?;
} }
drop(insert_stmt);
// 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。 // 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
@ -2267,8 +2655,26 @@ fn default_session_db_path() -> Result<PathBuf, std::io::Error> {
Ok(home.join(".picobot").join("storage").join("sessions.db")) Ok(home.join(".picobot").join("storage").join("sessions.db"))
} }
/// 批量插入消息的预编译 SQL17 列,无 topic_id / cached_tokens
/// 由 `insert_message_with_seq` 使用,循环写入前 `prepare_cached` 一次复用。
const INSERT_MESSAGE_SQL: &str = "
INSERT INTO messages (
id, session_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
";
/// 批量插入消息的预编译 SQL19 列,含 topic_id 与 cached_tokens
/// 由 `insert_message_with_topic_seq` 使用,循环写入前 `prepare_cached` 一次复用。
const INSERT_MESSAGE_TOPIC_SQL: &str = "
INSERT INTO messages (
id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
";
fn insert_message_with_seq( fn insert_message_with_seq(
conn: &rusqlite::Transaction<'_>, stmt: &mut rusqlite::Statement<'_>,
session_id: &str, session_id: &str,
seq: i64, seq: i64,
message: &ChatMessage, message: &ChatMessage,
@ -2279,33 +2685,29 @@ fn insert_message_with_seq(
.as_ref() .as_ref()
.map(serde_json::to_string) .map(serde_json::to_string)
.transpose()?; .transpose()?;
conn.execute( stmt.execute(params![
" message.id,
INSERT INTO messages ( session_id,
id, session_id, seq, role, content, seq,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at message.role,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) message.content,
", message.system_context,
params![ message.reasoning_content,
message.id, media_refs_json,
session_id, message.tool_call_id,
seq, message.tool_name,
message.role, tool_calls_json,
message.content, message.tool_duration_ms.map(|v| v as i64),
message.system_context, message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.reasoning_content, message.usage.as_ref().map(|u| u.completion_tokens as i64),
media_refs_json, message.usage.as_ref().map(|u| u.total_tokens as i64),
message.tool_call_id, message
message.tool_name, .usage
tool_calls_json, .as_ref()
message.tool_duration_ms.map(|v| v as i64), .and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.usage.as_ref().map(|u| u.prompt_tokens as i64), message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64), message.timestamp,
message.usage.as_ref().map(|u| u.total_tokens as i64), ])?;
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
Ok(()) Ok(())
} }
@ -2315,7 +2717,7 @@ fn insert_message_with_seq(
/// preserving topic association (the plain `insert_message_with_seq` would /// preserving topic association (the plain `insert_message_with_seq` would
/// set topic_id to NULL). /// set topic_id to NULL).
fn insert_message_with_topic_seq( fn insert_message_with_topic_seq(
conn: &rusqlite::Transaction<'_>, stmt: &mut rusqlite::Statement<'_>,
session_id: &str, session_id: &str,
topic_id: &str, topic_id: &str,
seq: i64, seq: i64,
@ -2327,34 +2729,30 @@ fn insert_message_with_topic_seq(
.as_ref() .as_ref()
.map(serde_json::to_string) .map(serde_json::to_string)
.transpose()?; .transpose()?;
conn.execute( stmt.execute(params![
" message.id,
INSERT INTO messages ( session_id,
id, session_id, topic_id, seq, role, content, topic_id,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, created_at seq,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) message.role,
", message.content,
params![ message.system_context,
message.id, message.reasoning_content,
session_id, media_refs_json,
topic_id, message.tool_call_id,
seq, message.tool_name,
message.role, tool_calls_json,
message.content, message.tool_duration_ms.map(|v| v as i64),
message.system_context, message.usage.as_ref().map(|u| u.prompt_tokens as i64),
message.reasoning_content, message.usage.as_ref().map(|u| u.completion_tokens as i64),
media_refs_json, message.usage.as_ref().map(|u| u.total_tokens as i64),
message.tool_call_id, message
message.tool_name, .usage
tool_calls_json, .as_ref()
message.tool_duration_ms.map(|v| v as i64), .and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.usage.as_ref().map(|u| u.prompt_tokens as i64), message.usage.as_ref().map(|u| u.cached_tokens as i64),
message.usage.as_ref().map(|u| u.completion_tokens as i64), message.timestamp,
message.usage.as_ref().map(|u| u.total_tokens as i64), ])?;
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
message.timestamp,
],
)?;
Ok(()) Ok(())
} }
@ -2374,6 +2772,7 @@ fn clone_message_for_compaction(message: &ChatMessage, timestamp: i64) -> ChatMe
tool_calls: message.tool_calls.clone(), tool_calls: message.tool_calls.clone(),
// 压缩克隆不保留 usage压缩产生的是合成消息不代表真实 LLM 调用 // 压缩克隆不保留 usage压缩产生的是合成消息不代表真实 LLM 调用
usage: None, usage: None,
seq: None,
} }
} }
@ -2432,6 +2831,7 @@ fn load_messages_between(
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64), tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?, usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
seq: row.get(16)?,
}) })
}, },
)?; )?;
@ -2494,6 +2894,7 @@ fn load_messages_after(
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64), tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?, usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
seq: row.get(16)?,
}) })
})?; })?;

View File

@ -58,6 +58,13 @@ pub trait ConversationRepository: Send + Sync + 'static {
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>; fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>;
/// 按 id 批量删除指定 session 的消息行(历史加载时 sanitize 修复回写用)。
fn delete_messages_by_ids(
&self,
session_id: &str,
ids: &[String],
) -> Result<usize, StorageError>;
fn compact_active_history( fn compact_active_history(
&self, &self,
session_id: &str, session_id: &str,
@ -259,6 +266,14 @@ impl ConversationRepository for super::SessionStore {
super::SessionStore::clear_messages(self, session_id) super::SessionStore::clear_messages(self, session_id)
} }
fn delete_messages_by_ids(
&self,
session_id: &str,
ids: &[String],
) -> Result<usize, StorageError> {
super::SessionStore::delete_messages_by_ids(self, session_id, ids)
}
fn compact_active_history( fn compact_active_history(
&self, &self,
session_id: &str, session_id: &str,

View File

@ -17,7 +17,7 @@ use super::{
/// 消息加载查询的共享列清单(列序与 map_chat_message_row / map_usage_row 的下标一一对应)。 /// 消息加载查询的共享列清单(列序与 map_chat_message_row / map_usage_row 的下标一一对应)。
/// 新增 usage 列时只需改这里 + map_usage_row无需逐条 SELECT 手工对齐。 /// 新增 usage 列时只需改这里 + map_usage_row无需逐条 SELECT 手工对齐。
pub(super) const MESSAGE_LOAD_COLUMNS: &str = "id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens"; pub(super) const MESSAGE_LOAD_COLUMNS: &str = "id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, prompt_tokens, completion_tokens, total_tokens, context_window_tokens, cached_tokens, seq";
/// 从指定列索引读取 token usage 五元组(含 context_window_tokens、cached_tokens /// 从指定列索引读取 token usage 五元组(含 context_window_tokens、cached_tokens
pub(super) fn map_usage_row( pub(super) fn map_usage_row(
@ -209,6 +209,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64), tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
usage: map_usage_row(row, 11, 12, 13, 14, 15)?, usage: map_usage_row(row, 11, 12, 13, 14, 15)?,
seq: row.get(16)?,
}) })
} }

View File

@ -718,6 +718,64 @@ fn test_get_topic_message_count_uses_count_query() {
); );
} }
/// 回归防护:所有按 topic_id 过滤的查询必须命中索引idx_messages_topic_seq
/// 或 idx_messages_session_*),不允许退化为全表扫描。
/// 消息表是最大的表,长会话场景下全表扫描是数据层主要退化点。
#[test]
fn test_topic_id_queries_use_index() {
let store = SessionStore::in_memory().unwrap();
// 索引必须存在partial indextopic_id IS NOT NULL
let conn = store.pool.get().unwrap();
let index_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_messages_topic_seq'",
[],
|row| row.get(0),
)
.unwrap();
assert!(index_sql.contains("topic_id"));
// 覆盖所有 topic_id 查询形态(与 mod.rs 中实际 SQL 一致)
let queries: &[&str] = &[
// load_messages_for_topic带/不带 session_id 分支)
"SELECT id FROM messages WHERE topic_id = ?1 AND session_id = ?2 AND is_compacted = 0 ORDER BY seq ASC",
"SELECT id FROM messages WHERE topic_id = ?1 AND is_compacted = 0 ORDER BY seq ASC",
// load_messages_for_topic_full
"SELECT id FROM messages WHERE topic_id = ?1 AND session_id = ?2 ORDER BY seq ASC",
"SELECT id FROM messages WHERE topic_id = ?1 ORDER BY seq ASC",
// count_user_messages_for_topic / get_topic_message_count
"SELECT COUNT(*) FROM messages WHERE topic_id = ?1",
"SELECT COUNT(*) FROM messages WHERE topic_id = ?1 AND role = 'user' AND is_compacted = 0",
"SELECT COUNT(*) FROM messages WHERE topic_id = ?1 AND role = 'user'",
// 按会话+话题删除
"DELETE FROM messages WHERE session_id = ?1 AND topic_id = ?2",
// 话题聚合IN 列表)
"SELECT topic_id, COUNT(*) FROM messages WHERE topic_id IN (?1, ?2) AND role = 'assistant' GROUP BY topic_id",
];
for sql in queries {
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
// EXPLAIN 不执行语句,但 rusqlite 要求参数计数匹配;按占位符数量传哑参数
let param_count = sql.matches('?').count();
let mut rows = stmt
.query(rusqlite::params_from_iter(vec!["x"; param_count]))
.unwrap();
let mut plan = String::new();
while let Some(row) = rows.next().unwrap() {
let detail: String = row.get(3).unwrap();
plan.push_str(&detail);
plan.push_str(" | ");
}
// partial indexWHERE topic_id IS NOT NULL可被 topic_id = ? / IN
// 等值查询使用;复合条件查询允许规划器选择 session 前缀索引。
assert!(
plan.contains("idx_messages_topic_seq") || plan.contains("idx_messages_session"),
"query degrades to full scan: {sql}\nplan: {plan}"
);
}
}
#[test] #[test]
fn test_repair_session_id_prefix_pollution() { fn test_repair_session_id_prefix_pollution() {
let store = SessionStore::in_memory().unwrap(); let store = SessionStore::in_memory().unwrap();
@ -824,3 +882,475 @@ fn test_cleanup_legacy_empty_cli_sessions() {
assert!(store.get_session(&with_data.id).unwrap().is_some()); assert!(store.get_session(&with_data.id).unwrap().is_some());
assert!(store.get_session(&ws.id).unwrap().is_some()); assert!(store.get_session(&ws.id).unwrap().is_some());
} }
#[test]
fn test_load_messages_for_topic_page_keyset_pagination() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("paged")).unwrap();
let topic = store.create_topic(&session.id, "topic-page", None).unwrap();
for i in 1..=10 {
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&ChatMessage::user(format!("m{i}")),
)
.unwrap();
}
// 首页:无游标,取最新 4 条(正序),且标记还有更早消息
let (page1, has_more1) = store
.load_messages_for_topic_page(&topic.id, Some(&session.id), None, 4)
.unwrap();
assert!(has_more1);
let contents: Vec<_> = page1.iter().map(|m| m.content.as_str()).collect();
assert_eq!(contents, vec!["m7", "m8", "m9", "m10"]);
// 历史加载路径必须填充 seq 作为下一页游标
let oldest1 = page1.first().and_then(|m| m.seq).expect("seq populated");
// 第二页before_seq 游标,取更早的 4 条
let (page2, has_more2) = store
.load_messages_for_topic_page(&topic.id, Some(&session.id), Some(oldest1), 4)
.unwrap();
assert!(has_more2);
let contents: Vec<_> = page2.iter().map(|m| m.content.as_str()).collect();
assert_eq!(contents, vec!["m3", "m4", "m5", "m6"]);
let oldest2 = page2.first().and_then(|m| m.seq).expect("seq populated");
// 末页:剩余 2 条has_more=false
let (page3, has_more3) = store
.load_messages_for_topic_page(&topic.id, Some(&session.id), Some(oldest2), 4)
.unwrap();
assert!(!has_more3);
let contents: Vec<_> = page3.iter().map(|m| m.content.as_str()).collect();
assert_eq!(contents, vec!["m1", "m2"]);
// 游标越过最老消息:空页且 has_more=false
let oldest3 = page3.first().and_then(|m| m.seq).unwrap();
let (page4, has_more4) = store
.load_messages_for_topic_page(&topic.id, Some(&session.id), Some(oldest3), 4)
.unwrap();
assert!(!has_more4);
assert!(page4.is_empty());
// 拼接所有页 == 全量加载(顺序与内容一致)
let mut combined = page3;
combined.extend(page2);
combined.extend(page1);
let full = store.load_messages_for_topic(&topic.id, None).unwrap();
let combined_contents: Vec<_> = combined.iter().map(|m| m.content.as_str()).collect();
let full_contents: Vec<_> = full.iter().map(|m| m.content.as_str()).collect();
assert_eq!(combined_contents, full_contents);
}
#[test]
fn test_delete_messages_by_ids_removes_only_target_rows() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("del")).unwrap();
let topic = store.create_topic(&session.id, "topic-del", None).unwrap();
for i in 1..=5 {
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&ChatMessage::user(format!("m{i}")),
)
.unwrap();
}
let all = store.load_messages_for_topic(&topic.id, None).unwrap();
assert_eq!(all.len(), 5);
// 删除中间 2 条sanitize 回写场景:只删被清理的消息)
let to_delete: Vec<String> = all[1..3].iter().map(|m| m.id.clone()).collect();
let deleted = store.delete_messages_by_ids(&session.id, &to_delete).unwrap();
assert_eq!(deleted, 2);
let remaining = store.load_messages_for_topic(&topic.id, None).unwrap();
assert_eq!(remaining.len(), 3);
let contents: Vec<_> = remaining.iter().map(|m| m.content.as_str()).collect();
assert_eq!(contents, vec!["m1", "m4", "m5"]);
// 空列表 no-op不存在的 id 返回 0
assert_eq!(store.delete_messages_by_ids(&session.id, &[]).unwrap(), 0);
assert_eq!(
store
.delete_messages_by_ids(&session.id, &["nonexistent".to_string()])
.unwrap(),
0
);
// 消息计数同步修正(活查询 + 计数列均一致)
assert_eq!(store.get_topic_message_count(&topic.id).unwrap(), 3);
let session_after = store.get_session(&session.id).unwrap().unwrap();
assert_eq!(session_after.message_count, 3);
}
fn assistant_with_usage(
content: &str,
prompt: u32,
completion: u32,
cached: u32,
ctx_window: Option<u32>,
) -> ChatMessage {
let mut msg = ChatMessage::assistant(content);
msg.usage = Some(crate::bus::MessageUsage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
cached_tokens: cached,
context_window_tokens: ctx_window,
});
msg
}
#[test]
fn test_topic_token_stats_incremental_maintenance() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("stats")).unwrap();
let topic = store.create_topic(&session.id, "topic-stats", None).unwrap();
// 无 usage 数据时不返回条目(前端不显示 token 标签)
store
.append_message_with_topic(&session.id, Some(&topic.id), &ChatMessage::user("q1"))
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
assert!(stats.is_empty());
// 两条带 usage 的 assistant 消息SUM 累加last_* 取最新一条
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a1", 100, 50, 20, Some(128_000)),
)
.unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a2", 200, 80, 100, Some(200_000)),
)
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
let s = stats.get(&topic.id).expect("topic stats entry");
assert_eq!(s.prompt_tokens, 300);
assert_eq!(s.completion_tokens, 130);
assert_eq!(s.total_tokens, 430);
assert_eq!(s.cached_tokens, 120);
assert_eq!(s.last_prompt_tokens, Some(200));
assert_eq!(s.context_window_tokens, Some(200_000));
}
#[test]
fn test_topic_token_stats_excludes_subagent_messages() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("stats-sub")).unwrap();
let topic = store.create_topic(&session.id, "topic-sub", None).unwrap();
// 主代理消息计入
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("main", 100, 50, 0, Some(128_000)),
)
.unwrap();
// 子代理消息 topic_id 指向父 topic 但 session_id 以 'sub:' 开头——不得计入
let sub_session = store
.ensure_session("sub:child-1", "cli", "sub-chat", "sub")
.unwrap();
store
.append_message_with_topic(
&sub_session.id,
Some(&topic.id),
&assistant_with_usage("sub", 999, 999, 999, Some(999_999)),
)
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
let s = stats.get(&topic.id).expect("topic stats entry");
assert_eq!(s.prompt_tokens, 100);
assert_eq!(s.total_tokens, 150);
assert_eq!(s.last_prompt_tokens, Some(100));
assert_eq!(s.context_window_tokens, Some(128_000));
}
#[test]
fn test_topic_token_stats_recompute_after_delete_and_clear() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("stats-recompute")).unwrap();
let topic = store.create_topic(&session.id, "topic-rc", None).unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a1", 100, 50, 0, Some(128_000)),
)
.unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a2", 200, 80, 0, Some(128_000)),
)
.unwrap();
// 删除第二条 assistant 消息sanitize 回写场景):统计重算为仅第一条
let msgs = store.load_messages_for_topic(&topic.id, None).unwrap();
let assistant_ids: Vec<String> = msgs
.iter()
.filter(|m| m.role == "assistant")
.map(|m| m.id.clone())
.collect();
store
.delete_messages_by_ids(&session.id, &assistant_ids[1..2])
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
let s = stats.get(&topic.id).expect("topic stats entry");
assert_eq!(s.prompt_tokens, 100);
assert_eq!(s.total_tokens, 150);
assert_eq!(s.last_prompt_tokens, Some(100));
// 删除全部 assistant 消息后无条目has_usage=0
store
.delete_messages_by_ids(&session.id, &assistant_ids[0..1])
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
assert!(stats.is_empty());
// 再次写入后恢复clear_messages 归零
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a3", 300, 100, 0, None),
)
.unwrap();
assert!(!store
.batch_topic_token_stats(&[topic.id.as_str()])
.unwrap()
.is_empty());
store.clear_messages(&session.id).unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
assert!(stats.is_empty());
}
#[test]
fn test_topic_token_stats_recompute_after_replace_topic_history() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("stats-replace")).unwrap();
let topic = store.create_topic(&session.id, "topic-rp", None).unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("old", 500, 200, 0, Some(128_000)),
)
.unwrap();
// 整体替换该 topic 历史(压缩器场景):统计应反映新消息
store
.replace_topic_history(
&session.id,
&topic.id,
&[
ChatMessage::user("new-q"),
assistant_with_usage("new-a", 40, 10, 5, Some(64_000)),
],
)
.unwrap();
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
let s = stats.get(&topic.id).expect("topic stats entry");
assert_eq!(s.prompt_tokens, 40);
assert_eq!(s.completion_tokens, 10);
assert_eq!(s.total_tokens, 50);
assert_eq!(s.cached_tokens, 5);
assert_eq!(s.last_prompt_tokens, Some(40));
assert_eq!(s.context_window_tokens, Some(64_000));
}
#[test]
fn test_backfill_topic_usage_stats_restores_incremental_columns() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("backfill")).unwrap();
let topic = store.create_topic(&session.id, "topic-bf", None).unwrap();
// 主代理 usage 消息两条(增量列此时已有值)
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a1", 100, 50, 20, Some(128_000)),
)
.unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a2", 200, 80, 0, Some(200_000)),
)
.unwrap();
// 子代理消息topic_id 指向父 topic回填必须排除
let sub_session = store
.ensure_session("sub:bf-child", "cli", "sub-chat", "sub")
.unwrap();
store
.append_message_with_topic(
&sub_session.id,
Some(&topic.id),
&assistant_with_usage("sub", 999, 999, 999, Some(999_999)),
)
.unwrap();
// 模拟老库:增量列全部清零,版本回退到 3尚未回填
let mut conn = store.pool.get().unwrap();
conn.execute(
"UPDATE topics SET stat_prompt_tokens = 0, stat_completion_tokens = 0, \
stat_total_tokens = 0, stat_cached_tokens = 0, \
stat_last_prompt_tokens = NULL, stat_context_window_tokens = NULL, \
stat_has_usage = 0",
[],
)
.unwrap();
conn.execute("PRAGMA user_version = 3", []).unwrap();
super::migrations::backfill_topic_usage_stats(&mut conn).unwrap();
// 版本守卫推进到 4
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 4);
drop(conn);
// 回填后与增量维护的值一致(子代理被排除)
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
let s = stats.get(&topic.id).expect("topic stats entry");
assert_eq!(s.prompt_tokens, 300);
assert_eq!(s.completion_tokens, 130);
assert_eq!(s.total_tokens, 430);
assert_eq!(s.cached_tokens, 20);
assert_eq!(s.last_prompt_tokens, Some(200));
assert_eq!(s.context_window_tokens, Some(200_000));
// 幂等:版本守卫使第二次调用直接跳过,结果不变
let mut conn = store.pool.get().unwrap();
super::migrations::backfill_topic_usage_stats(&mut conn).unwrap();
drop(conn);
let stats = store.batch_topic_token_stats(&[topic.id.as_str()]).unwrap();
assert_eq!(stats.get(&topic.id).unwrap().total_tokens, 430);
}
#[test]
fn test_foreign_keys_enforced_on_pool_connections() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("fk")).unwrap();
let topic = store.create_topic(&session.id, "topic-fk", None).unwrap();
store
.append_message_with_topic(&session.id, Some(&topic.id), &ChatMessage::user("m1"))
.unwrap();
// 删除话题ON DELETE SET NULL 必须生效消息保留topic_id 置空)
store.delete_topic(&topic.id).unwrap();
let conn = store.pool.get().unwrap();
let dangling: i64 = conn
.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND topic_id IS NOT NULL",
params![session.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(dangling, 0, "SET NULL 未生效:删除话题后消息仍持悬空 topic_id");
let remaining: i64 = conn
.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
params![session.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(remaining, 1, "消息本身应保留,仅 topic_id 置空");
drop(conn);
// 删除会话topics 行必须级联删除,不得孤儿残留
store.delete_session(&session.id).unwrap();
let conn = store.pool.get().unwrap();
let orphan_topics: i64 = conn
.query_row(
"SELECT COUNT(*) FROM topics WHERE session_id = ?1",
params![session.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(orphan_topics, 0, "CASCADE 未生效:删除会话后 topics 孤儿残留");
}
#[test]
fn test_clear_messages_resets_topic_message_count() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("clear-count")).unwrap();
let topic = store.create_topic(&session.id, "topic-clear", None).unwrap();
for i in 1..=3 {
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&ChatMessage::user(format!("m{i}")),
)
.unwrap();
}
assert_eq!(store.get_topic_message_count(&topic.id).unwrap(), 3);
store.clear_messages(&session.id).unwrap();
// 清空后话题消息数必须归零,前端不得显示虚假计数
assert_eq!(store.get_topic_message_count(&topic.id).unwrap(), 0);
let topic_row = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(topic_row.message_count, 0);
}
#[test]
fn test_delete_messages_by_ids_recomputes_user_turn_count() {
let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("turn-count")).unwrap();
let topic = store.create_topic(&session.id, "topic-turn", None).unwrap();
store
.append_message_with_topic(&session.id, Some(&topic.id), &ChatMessage::user("u1"))
.unwrap();
store
.append_message_with_topic(
&session.id,
Some(&topic.id),
&assistant_with_usage("a1", 10, 5, 0, None),
)
.unwrap();
store
.append_message_with_topic(&session.id, Some(&topic.id), &ChatMessage::user("u2"))
.unwrap();
let before = store.get_session(&session.id).unwrap().unwrap();
assert_eq!(before.user_turn_count, 2);
// 删除 user 消息sanitize 场景user_turn_count 必须同步重算
let msgs = store.load_messages_for_topic(&topic.id, None).unwrap();
let user_ids: Vec<String> = msgs
.iter()
.filter(|m| m.role == "user")
.map(|m| m.id.clone())
.collect();
store
.delete_messages_by_ids(&session.id, &user_ids[..1])
.unwrap();
let after = store.get_session(&session.id).unwrap().unwrap();
assert_eq!(after.user_turn_count, 1);
assert_eq!(after.message_count, 2);
}

View File

@ -465,6 +465,11 @@ impl BashTool {
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
// 外部取消安全run_command future 被 drop 时(/stop 的 select! 竞速、
// 子代理超时等Child drop 会终止 OS 进程。否则子进程变孤儿、
// read_stream 任务因管道永不 EOF 而永久存活,每次取消泄漏一组资源。
// 进程被杀后管道到达 EOFread_stream 任务也随之自然退出。
.kill_on_drop(true)
.current_dir(cwd); .current_dir(cwd);
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?; let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;

View File

@ -90,9 +90,15 @@ impl Tool for MemorySearchTool {
let payload = match action { let payload = match action {
"list" => { "list" => {
let limit = extract_u64(&args, "limit").unwrap_or(10) as usize; let limit = extract_u64(&args, "limit").unwrap_or(10) as usize;
let memories = self // 同步 SQLite 查询移入 spawn_blocking避免阻塞 tokio worker
.memories let memories_repo = self.memories.clone();
.list_memories("user", &scope_key, namespace, limit)?; let scope = scope_key.clone();
let ns = namespace.map(str::to_string);
let memories = tokio::task::spawn_blocking(move || {
memories_repo.list_memories("user", &scope, ns.as_deref(), limit)
})
.await
.map_err(|e| anyhow::anyhow!("memory list task failed: {e}"))??;
json!({ json!({
"count": memories.len(), "count": memories.len(),
"memories": memories.into_iter().map(memory_to_json).collect::<Vec<_>>() "memories": memories.into_iter().map(memory_to_json).collect::<Vec<_>>()
@ -138,9 +144,22 @@ impl Tool for MemorySearchTool {
return Ok(error_result("Missing required parameter: queries")); return Ok(error_result("Missing required parameter: queries"));
} }
let limit = extract_u64(&args, "limit").unwrap_or(10) as usize; let limit = extract_u64(&args, "limit").unwrap_or(10) as usize;
let memories = self let memories_repo = self.memories.clone();
.memories let scope = scope_key.clone();
.search_memories_any("user", &scope_key, &queries, namespace, limit)?; let ns = namespace.map(str::to_string);
let query_terms = queries.clone();
// 同步 SQLite 多关键词查询LIKE 扫描)移入 spawn_blocking
let memories = tokio::task::spawn_blocking(move || {
memories_repo.search_memories_any(
"user",
&scope,
&query_terms,
ns.as_deref(),
limit,
)
})
.await
.map_err(|e| anyhow::anyhow!("memory search task failed: {e}"))??;
json!({ json!({
"queries": queries, "queries": queries,
"count": memories.len(), "count": memories.len(),
@ -157,10 +176,17 @@ impl Tool for MemorySearchTool {
None => return Ok(error_result("Missing required parameter: key")), None => return Ok(error_result("Missing required parameter: key")),
}; };
match self let memories_repo = self.memories.clone();
.memories let scope = scope_key.clone();
.get_memory("user", &scope_key, namespace, key)? let ns = namespace.to_string();
{ let memory_key = key.to_string();
// 同步 SQLite 查询移入 spawn_blocking
let memory = tokio::task::spawn_blocking(move || {
memories_repo.get_memory("user", &scope, &ns, &memory_key)
})
.await
.map_err(|e| anyhow::anyhow!("memory get task failed: {e}"))??;
match memory {
Some(memory) => memory_to_json(memory), Some(memory) => memory_to_json(memory),
None => { None => {
return Ok(error_result(&format!( return Ok(error_result(&format!(

View File

@ -134,6 +134,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 1000, tool_result_max_chars: 1000,
context_tool_result_trim_chars: 1000, context_tool_result_trim_chars: 1000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}, },
@ -168,6 +169,7 @@ mod tests {
max_tool_iterations: 1, max_tool_iterations: 1,
tool_result_max_chars: 1000, tool_result_max_chars: 1000,
context_tool_result_trim_chars: 1000, context_tool_result_trim_chars: 1000,
max_run_secs: crate::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}, },

View File

@ -51,6 +51,37 @@ impl Drop for CancelRegistryGuard {
} }
} }
/// 超时保护地发送 SubagentResult。
///
/// sub_done_q 容量有限32且仅由 wait 协调器消费。若主代理从不调用 wait
/// 无超时的 `send().await` 会永久阻塞——而本函数运行在持有全局并发许可
/// Semaphore permit的 spawn 任务末尾,阻塞会永久占住 permit8 个 permit
/// 耗尽后整个子代理子系统停摆。超时后放弃结果DB 状态照常更新,
/// wait 侧另有 query_pending_task_ids 对账兜底,不会丢失完成事实。
async fn send_sub_done_timeout(
sender: &tokio::sync::mpsc::Sender<SubagentResult>,
result: SubagentResult,
) {
let task_id = result.task_id.clone();
match tokio::time::timeout(Duration::from_secs(5), sender.send(result)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(
error = %e,
task_id = %task_id,
"Failed to send SubagentResult to sub_done_q (receiver dropped?)"
);
}
Err(_) => {
tracing::warn!(
task_id = %task_id,
"Timed out sending SubagentResult (queue full, no consumer); \
dropping result DB status update + wait reconciliation cover this"
);
}
}
}
use crate::agent::{ use crate::agent::{
AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler,
SystemPrompt, SystemPromptContext, SystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
@ -148,6 +179,19 @@ pub trait SubAgentRuntime: Send + Sync + 'static {
/// 用于 /stop 命令传播:用户取消主 agent 时,同步取消其后台子代理。 /// 用于 /stop 命令传播:用户取消主 agent 时,同步取消其后台子代理。
/// 返回被触发取消的子代理数量。 /// 返回被触发取消的子代理数量。
async fn cancel_pending_for_topic(&self, topic_id: &str) -> usize; async fn cancel_pending_for_topic(&self, topic_id: &str) -> usize;
/// 清理指定 topic 下的"僵尸"子代理DB 状态为 running
/// 但其执行任务已不存在(不在 cancel_registry 中——执行器 panic/被杀/
/// 写库失败后残留)。将这些记录标记为 interrupted。
///
/// 用于 ExecutionCompleted 兜底判定前的惰性对账:
/// 僵尸记录会让"pending running 非空"永远成立,从而永远跳过
/// ExecutionCompleted前端 loading 永不停止(用户视角即"卡死")。
/// 返回被清理的记录数。
async fn reap_orphan_subagents(&self, topic_id: &str) -> usize {
let _ = topic_id;
0
}
} }
/// 静态系统提示词提供者(用于子代理) /// 静态系统提示词提供者(用于子代理)
@ -1026,7 +1070,18 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
let topic_id = session.parent_topic_id.clone().unwrap_or_default(); let topic_id = session.parent_topic_id.clone().unwrap_or_default();
let task_id = session.id.clone(); let task_id = session.id.clone();
// 8a. INSERT pending_subagents 记录 // 8a. 注册 cancel_registry必须先于 DB 写入 running 记录)。
// reap_orphan_subagents 以"registry 中存在条目"判定执行任务存活,
// 因此不变量为DB 出现 running 记录时 registry 必然已有条目。
// 若顺序颠倒spawn 间隙DB 已写、registry 未写)的并发 reap
// 会把刚启动的子代理误标为 interrupted。
let cancel_registry = self.cancel_registry.clone();
let cancel_token = tokio_util::sync::CancellationToken::new();
cancel_registry
.lock()
.insert(task_id.clone(), cancel_token.clone());
// 8b. INSERT pending_subagents 记录
let pending_record = PendingSubagentRecord { let pending_record = PendingSubagentRecord {
task_id: task_id.clone(), task_id: task_id.clone(),
parent_session_id: session.parent_session_id.clone(), parent_session_id: session.parent_session_id.clone(),
@ -1045,7 +1100,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
); );
} }
// 8b. tokio::spawn 后台执行子代理 // 8c. tokio::spawn 后台执行子代理
let store = self.store.clone(); let store = self.store.clone();
let task_repository = self.task_repository.clone(); let task_repository = self.task_repository.clone();
let bus = self.bus.clone(); let bus = self.bus.clone();
@ -1057,16 +1112,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
let max_secs = self.effective_max_execution_secs(&def); let max_secs = self.effective_max_execution_secs(&def);
let task_id_for_spawn = task_id.clone(); let task_id_for_spawn = task_id.clone();
let semaphore = self.semaphore.clone(); let semaphore = self.semaphore.clone();
let cancel_registry = self.cancel_registry.clone();
// 创建 CancellationToken 并注册到 registry供 /stop 取消传播)
let cancel_token = tokio_util::sync::CancellationToken::new();
cancel_registry
.lock()
.insert(task_id_for_spawn.clone(), cancel_token.clone());
// RAII guardspawn 任务退出时(正常/early return/panic确定性清理 registry // RAII guardspawn 任务退出时(正常/early return/panic确定性清理 registry
// 不变量 3清理与作用域绑定避免末行清理被 panic 绕过 // 不变量 3清理与作用域绑定避免末行清理被 panic 绕过
// cancel_token 已在 8a 注册,此处仅绑定生命周期清理)
let registry_guard = let registry_guard =
CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone()); CancelRegistryGuard::new(task_id_for_spawn.clone(), cancel_registry.clone());
@ -1100,7 +1149,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
}) })
.unwrap_or_default(), .unwrap_or_default(),
}; };
let _ = sub_done_sender.send(result).await; let _ = send_sub_done_timeout(&sub_done_sender, result).await;
let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed"); let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed");
// _registry_guard drop 时清理 registry 条目 // _registry_guard drop 时清理 registry 条目
return; return;
@ -1165,13 +1214,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
output, output,
pending_task_ids, pending_task_ids,
}; };
if let Err(e) = sub_done_sender.send(result).await { send_sub_done_timeout(&sub_done_sender, result).await;
tracing::warn!(
error = %e,
task_id = %task_id_for_spawn,
"Failed to send SubagentResult to sub_done_q (receiver dropped?)"
);
}
// UPDATE pending_subagents 状态 // UPDATE pending_subagents 状态
let status_str = match status { let status_str = match status {
@ -1401,50 +1444,121 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
"Cancelling pending subagents for topic" "Cancelling pending subagents for topic"
); );
// 触发每个子代理的 CancellationToken // 触发每个子代理的 CancellationToken。
let registry = self.cancel_registry.lock(); // 锁内只做内存操作cancel + 收集SQLite 调用移到锁外——
for record in &running { // registry 是 parking_lot 同步锁,持锁跨 DB IO 会阻塞所有并发的
if let Some(token) = registry.get(&record.task_id) { // 注册/Guard::dropSQLite 卡顿时放大 /stop 延迟。
token.cancel(); let mut missing_from_registry: Vec<String> = Vec::new();
tracing::info!( {
task_id = %record.task_id, let registry = self.cancel_registry.lock();
"Cancelled subagent token" for record in &running {
); if let Some(token) = registry.get(&record.task_id) {
} else { token.cancel();
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失) tracing::info!(
// 不变量 1条件 UPDATE仅在 status='running' 时转为 cancelled task_id = %record.task_id,
// 避免 spawn 已完成的终态被覆盖completed → cancelled 是非法转换) "Cancelled subagent token"
match self.store.try_update_pending_subagent_status( );
&record.task_id, } else {
"running", missing_from_registry.push(record.task_id.clone());
"cancelled", }
) { }
Ok(true) => { }
tracing::info!(
task_id = %record.task_id, for task_id in &missing_from_registry {
"Marked subagent as cancelled in DB (token not in registry)" // token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
); // 不变量 1条件 UPDATE仅在 status='running' 时转为 cancelled
} // 避免 spawn 已完成的终态被覆盖completed → cancelled 是非法转换)
Ok(false) => { match self
tracing::info!( .store
task_id = %record.task_id, .try_update_pending_subagent_status(task_id, "running", "cancelled")
"Subagent status already updated by another path, skip cancel" {
); Ok(true) => {
} tracing::info!(
Err(e) => { task_id = %task_id,
tracing::warn!( "Marked subagent as cancelled in DB (token not in registry)"
error = %e, );
task_id = %record.task_id, }
"Failed to mark subagent as cancelled in DB" Ok(false) => {
); tracing::info!(
} task_id = %task_id,
"Subagent status already updated by another path, skip cancel"
);
}
Err(e) => {
tracing::warn!(
error = %e,
task_id = %task_id,
"Failed to mark subagent as cancelled in DB"
);
} }
} }
} }
drop(registry);
count count
} }
async fn reap_orphan_subagents(&self, topic_id: &str) -> usize {
let running = match self.store.list_pending_subagents(topic_id, Some("running")) {
Ok(records) => records,
Err(e) => {
tracing::warn!(
error = %e,
topic_id = %topic_id,
"Failed to list pending subagents for zombie reaping"
);
return 0;
}
};
if running.is_empty() {
return 0;
}
let mut reaped = 0;
// 锁内只收集僵尸 task_idSQLite 条件 UPDATE 移到锁外(理由同
// cancel_pending_for_topic避免持 parking_lot 锁跨 DB IO
let zombie_ids: Vec<String> = {
let registry = self.cancel_registry.lock();
running
.iter()
.filter(|record| !registry.contains_key(&record.task_id))
.map(|record| record.task_id.clone())
.collect()
};
for task_id in &zombie_ids {
// 僵尸DB=running 但执行任务已消失。
// 条件 UPDATE仅 running→interrupted不覆盖已终态。
match self
.store
.try_update_pending_subagent_status(task_id, "running", "interrupted")
{
Ok(true) => {
reaped += 1;
tracing::warn!(
task_id = %task_id,
"Reaped zombie subagent (DB running but executor gone); marked interrupted"
);
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
error = %e,
task_id = %task_id,
"Failed to reap zombie subagent"
);
}
}
}
if reaped > 0 {
tracing::info!(
topic_id = %topic_id,
reaped,
remaining_running = running.len() - reaped,
"Zombie subagent reaping finished"
);
}
reaped
}
} }
/// 子代理定义目录 /// 子代理定义目录

View File

@ -1,24 +1,38 @@
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::header::HeaderMap; use reqwest::header::HeaderMap;
use serde_json::json; use serde_json::json;
use crate::text::take_prefix_chars; use crate::text::take_prefix_chars;
use crate::tools::traits::{Tool, ToolResult}; use crate::tools::traits::{Tool, ToolResult};
/// 未配置响应大小限制时的硬性下载上限(防止无限响应打满内存)。
const HARD_DOWNLOAD_CAP_BYTES: usize = 32 * 1024 * 1024;
pub struct WebFetchTool { pub struct WebFetchTool {
max_response_size: usize, max_response_size: usize,
timeout_secs: u64,
user_agent: String, user_agent: String,
/// 长生命周期 HTTP 客户端(连接池 + TLS 上下文 + 超时配置),构造一次全程复用。
client: reqwest::Client,
} }
impl WebFetchTool { impl WebFetchTool {
pub fn new(max_response_size: usize, timeout_secs: u64) -> Self { pub fn new(max_response_size: usize, timeout_secs: u64) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
// 禁用重定向validate_url 只校验初始 URL 的 host
// 若跟随 302 跳转,攻击者可用公网 URL 重定向到
// 169.254.169.254(云元数据)或 127.0.0.1 等内网地址,
// 绕过 is_private_host 的 SSRF 防护。
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("valid HTTP client configuration");
Self { Self {
max_response_size, max_response_size,
timeout_secs,
user_agent: "Mozilla/5.0 (compatible; Picobot/1.0)".to_string(), user_agent: "Mozilla/5.0 (compatible; Picobot/1.0)".to_string(),
client,
} }
} }
@ -61,24 +75,27 @@ impl WebFetchTool {
} }
} }
async fn fetch_content(&self, url: &str) -> Result<String, String> { /// 下载字节上限:字符上限 × 4UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
let client = reqwest::Client::builder() /// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
.timeout(Duration::from_secs(self.timeout_secs)) fn download_byte_limit(&self) -> usize {
// 禁用重定向validate_url 只校验初始 URL 的 host if self.max_response_size == 0 {
// 若跟随 302 跳转,攻击者可用公网 URL 重定向到 HARD_DOWNLOAD_CAP_BYTES
// 169.254.169.254(云元数据)或 127.0.0.1 等内网地址, } else {
// 绕过 is_private_host 的 SSRF 防护。 self.max_response_size
.redirect(reqwest::redirect::Policy::none()) .saturating_mul(4)
.build() .min(HARD_DOWNLOAD_CAP_BYTES)
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; }
}
async fn fetch_content(&self, url: &str) -> Result<String, String> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(
reqwest::header::USER_AGENT, reqwest::header::USER_AGENT,
self.user_agent.parse().unwrap(), self.user_agent.parse().unwrap(),
); );
let response = client let response = self
.client
.get(url) .get(url)
.headers(headers) .headers(headers)
.send() .send()
@ -93,19 +110,13 @@ impl WebFetchTool {
// Handle HTML content // Handle HTML content
if content_type.contains("text/html") { if content_type.contains("text/html") {
let html = response let html = read_body_limited(response, self.download_byte_limit()).await?;
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
return Ok(self.extract_text_from_html(&html)); return Ok(self.extract_text_from_html(&html));
} }
// Handle JSON content // Handle JSON content
if content_type.contains("application/json") { if content_type.contains("application/json") {
let text = response let text = read_body_limited(response, self.download_byte_limit()).await?;
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
// Pretty print JSON // Pretty print JSON
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) { if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
return Ok(serde_json::to_string_pretty(&parsed).unwrap_or(text)); return Ok(serde_json::to_string_pretty(&parsed).unwrap_or(text));
@ -114,10 +125,7 @@ impl WebFetchTool {
} }
// For other content types, return raw text // For other content types, return raw text
response read_body_limited(response, self.download_byte_limit()).await
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))
} }
fn extract_text_from_html(&self, html: &str) -> String { fn extract_text_from_html(&self, html: &str) -> String {
@ -175,6 +183,29 @@ impl WebFetchTool {
} }
} }
/// 流式读取响应体,累计达到 `max_bytes` 即提前中止下载。
/// 限制在下载过程中生效(而非全量载入后截断),防止超大响应耗尽内存。
async fn read_body_limited(
response: reqwest::Response,
max_bytes: usize,
) -> Result<String, String> {
let mut stream = response.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read response: {}", e))?;
let remaining = max_bytes.saturating_sub(body.len());
if remaining == 0 {
break;
}
if chunk.len() > remaining {
body.extend_from_slice(&chunk[..remaining]);
break;
}
body.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&body).into_owned())
}
fn strip_tag(s: &str, tag_name: &str) -> String { fn strip_tag(s: &str, tag_name: &str) -> String {
let open = format!("<{}>", tag_name); let open = format!("<{}>", tag_name);
let close = format!("</{}>", tag_name); let close = format!("</{}>", tag_name);

View File

@ -15,7 +15,7 @@ pub async fn generate_topic_description(
tools: None, tools: None,
}; };
let response = provider.chat(request).await?; let response = provider.chat(&request).await?;
let description = response.content.trim().to_string(); let description = response.content.trim().to_string();
if description.is_empty() { if description.is_empty() {

View File

@ -45,6 +45,7 @@ fn load_config() -> Option<LLMProviderConfig> {
max_tool_iterations: 20, max_tool_iterations: 20,
tool_result_max_chars: 20_000, tool_result_max_chars: 20_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}) })
@ -65,7 +66,7 @@ async fn test_openai_simple_completion() {
let config = load_config().expect("Please configure tests/test.env with valid API keys"); let config = load_config().expect("Please configure tests/test.env with valid API keys");
let provider = create_provider(to_runtime_config(config)).expect("Failed to create provider"); let provider = create_provider(to_runtime_config(config)).expect("Failed to create provider");
let response = provider.chat(create_request("Say 'ok'")).await.unwrap(); let response = provider.chat(&create_request("Say 'ok'")).await.unwrap();
assert!(!response.id.is_empty()); assert!(!response.id.is_empty());
assert!(!response.content.is_empty()); assert!(!response.content.is_empty());
@ -91,7 +92,7 @@ async fn test_openai_conversation() {
tools: None, tools: None,
}; };
let response = provider.chat(request).await.unwrap(); let response = provider.chat(&request).await.unwrap();
assert!(response.content.to_lowercase().contains("alice")); assert!(response.content.to_lowercase().contains("alice"));
} }

View File

@ -129,6 +129,7 @@ fn test_tool_call_outbound_serialization() {
timestamp: None, timestamp: None,
reasoning_content: None, reasoning_content: None,
user_message_id: None, user_message_id: None,
seq: None,
}; };
let json = serde_json::to_string(&msg).unwrap(); let json = serde_json::to_string(&msg).unwrap();
@ -164,6 +165,7 @@ fn test_tool_result_outbound_serialization() {
duration_ms: None, duration_ms: None,
topic_id: None, topic_id: None,
timestamp: None, timestamp: None,
seq: None,
}; };
let json = serde_json::to_string(&msg).unwrap(); let json = serde_json::to_string(&msg).unwrap();

View File

@ -47,6 +47,7 @@ fn load_openai_config() -> Option<LLMProviderConfig> {
max_tool_iterations: 20, max_tool_iterations: 20,
tool_result_max_chars: 20_000, tool_result_max_chars: 20_000,
context_tool_result_trim_chars: 20_000, context_tool_result_trim_chars: 20_000,
max_run_secs: picobot::agent::DEFAULT_MAX_RUN_SECS,
max_images_in_context: 1, max_images_in_context: 1,
max_image_age_rounds: 10, max_image_age_rounds: 10,
}) })
@ -83,10 +84,10 @@ async fn test_openai_tool_call() {
messages: vec![Message::user("What is the weather in Tokyo?")], messages: vec![Message::user("What is the weather in Tokyo?")],
temperature: Some(0.0), temperature: Some(0.0),
max_tokens: Some(200), max_tokens: Some(200),
tools: Some(vec![make_weather_tool()]), tools: Some(std::sync::Arc::new(vec![make_weather_tool()])),
}; };
let response = provider.chat(request).await.unwrap(); let response = provider.chat(&request).await.unwrap();
// Should have tool calls // Should have tool calls
assert!( assert!(
@ -112,10 +113,10 @@ async fn test_openai_tool_call_with_manual_execution() {
messages: vec![Message::user("What is the weather in Tokyo?")], messages: vec![Message::user("What is the weather in Tokyo?")],
temperature: Some(0.0), temperature: Some(0.0),
max_tokens: Some(200), max_tokens: Some(200),
tools: Some(vec![make_weather_tool()]), tools: Some(std::sync::Arc::new(vec![make_weather_tool()])),
}; };
let response1 = provider.chat(request1).await.unwrap(); let response1 = provider.chat(&request1).await.unwrap();
let tool_call = response1.tool_calls.first().expect("Expected tool call"); let tool_call = response1.tool_calls.first().expect("Expected tool call");
assert_eq!(tool_call.name, "get_weather"); assert_eq!(tool_call.name, "get_weather");
@ -127,10 +128,10 @@ async fn test_openai_tool_call_with_manual_execution() {
], ],
temperature: Some(0.0), temperature: Some(0.0),
max_tokens: Some(200), max_tokens: Some(200),
tools: Some(vec![make_weather_tool()]), tools: Some(std::sync::Arc::new(vec![make_weather_tool()])),
}; };
let response2 = provider.chat(request2).await.unwrap(); let response2 = provider.chat(&request2).await.unwrap();
// Should have a response // Should have a response
assert!(!response2.content.is_empty() || !response2.tool_calls.is_empty()); assert!(!response2.content.is_empty() || !response2.tool_calls.is_empty());
@ -150,7 +151,7 @@ async fn test_openai_no_tool_when_not_provided() {
tools: None, tools: None,
}; };
let response = provider.chat(request).await.unwrap(); let response = provider.chat(&request).await.unwrap();
// Should NOT have tool calls // Should NOT have tool calls
assert!(response.tool_calls.is_empty()); assert!(response.tool_calls.is_empty());

View File

@ -65,6 +65,10 @@ function App() {
messages, messages,
isLoading, isLoading,
isReadOnly, isReadOnly,
// 历史分页
hasMoreOlder,
loadingOlder,
loadOlderMessages,
// 子智能体视图 // 子智能体视图
subAgentView, subAgentView,
subAgentStack, subAgentStack,
@ -122,6 +126,11 @@ function App() {
finishStreaming, finishStreaming,
} = useChat(); } = useChat();
// 子代理视图的语义身份taskId。
// subAgentView 对象在流式期间每帧都会因 messages 更新而换引用,
// effect/callback 的依赖必须用此原始值,否则会被每帧无效重跑。
const subAgentTaskId = subAgentView?.taskId;
const { status, sendMessage } = useWebSocket({ const { status, sendMessage } = useWebSocket({
url: wsUrl, url: wsUrl,
onMessage: handleServerMessage, onMessage: handleServerMessage,
@ -319,9 +328,9 @@ function App() {
if (status !== 'connected') return; if (status !== 'connected') return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (subAgentView) { if (subAgentTaskId) {
// 子代理视图:发 load_task_messages 刷新子代理 token_stats // 子代理视图:发 load_task_messages 刷新子代理 token_stats
const cmd = { type: 'load_task_messages' as const, task_id: subAgentView.taskId }; const cmd = { type: 'load_task_messages' as const, task_id: subAgentTaskId };
handleCommand(cmd); handleCommand(cmd);
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }); sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
} else { } else {
@ -334,7 +343,7 @@ function App() {
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [topicRefreshTrigger, status, subAgentView, handleCommand, sendMessage, requestTopicList]); }, [topicRefreshTrigger, status, subAgentTaskId, handleCommand, sendMessage, requestTopicList]);
// 当前选中 topic用于右侧 Sidebar token 统计面板) // 当前选中 topic用于右侧 Sidebar token 统计面板)
const currentTopic = useMemo( const currentTopic = useMemo(
@ -536,19 +545,19 @@ function App() {
const prevTodoTriggerRef = useRef<string>(''); const prevTodoTriggerRef = useRef<string>('');
useEffect(() => { useEffect(() => {
if (status !== 'connected') return; if (status !== 'connected') return;
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`; const key = `${selectedTopic ?? ''}|${subAgentTaskId ?? ''}`;
if (key === prevTodoTriggerRef.current) return; if (key === prevTodoTriggerRef.current) return;
prevTodoTriggerRef.current = key; prevTodoTriggerRef.current = key;
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
const todoCmd = subAgentView?.taskId const todoCmd = subAgentTaskId
? requestSubAgentTodoList(subAgentView.taskId) ? requestSubAgentTodoList(subAgentTaskId)
: requestTodoList(); : requestTodoList();
handleCommand(todoCmd); handleCommand(todoCmd);
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) }); sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
}, [ }, [
status, status,
selectedTopic, selectedTopic,
subAgentView, subAgentTaskId,
handleCommand, handleCommand,
sendMessage, sendMessage,
requestTodoList, requestTodoList,
@ -578,8 +587,8 @@ function App() {
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令 // 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
const refreshTodoList = useCallback((): Command => { const refreshTodoList = useCallback((): Command => {
return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList(); return subAgentTaskId ? requestSubAgentTodoList(subAgentTaskId) : requestTodoList();
}, [subAgentView, requestTodoList, requestSubAgentTodoList]); }, [subAgentTaskId, requestTodoList, requestSubAgentTodoList]);
// 点击待办项后滚动到对应消息 // 点击待办项后滚动到对应消息
const handleTodoClick = useCallback( const handleTodoClick = useCallback(
@ -640,6 +649,11 @@ function App() {
[selectedSessionId, selectSession], [selectedSessionId, selectSession],
); );
// merged_tool 对象复用缓存:流式期间 messages 每帧换引用,但绝大多数
// tool_call/tool_result 输入未变;按值相等复用上次对象,使 MessageBubble
// 的 memo 浅比较命中,避免所有工具气泡每帧重渲染(含 ReactMarkdown 重解析)。
const mergedToolCacheRef = useRef(new Map<string, ChatMessage>());
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
const result: ChatMessage[] = []; const result: ChatMessage[] = [];
const toolCallIndex = new Map<string, number>(); const toolCallIndex = new Map<string, number>();
@ -678,6 +692,28 @@ function App() {
} }
} }
// 按决定性字段值相等复用上次的 merged_tool 对象(渲染结果相同 → 引用可复用)
const cache = mergedToolCacheRef.current;
const nextCache = new Map<string, ChatMessage>();
for (let i = 0; i < result.length; i++) {
const cur = result[i];
if (cur.type !== 'merged_tool') continue;
const key = cur.toolCallId || cur.id;
const prev = cache.get(key);
if (
prev &&
prev.id === cur.id &&
prev.status === cur.status &&
prev.callContent === cur.callContent &&
prev.resultContent === cur.resultContent &&
prev.durationMs === cur.durationMs
) {
result[i] = prev;
}
nextCache.set(key, result[i]);
}
mergedToolCacheRef.current = nextCache;
// 过滤无实质内容的 merged_toolresult 到达后才显示保留calling/pending 有 callContent 也保留 // 过滤无实质内容的 merged_toolresult 到达后才显示保留calling/pending 有 callContent 也保留
return result.filter((msg) => { return result.filter((msg) => {
if (msg.type !== 'merged_tool') return true; if (msg.type !== 'merged_tool') return true;
@ -686,12 +722,14 @@ function App() {
}); });
}, [messages]); }, [messages]);
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置 // 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置。
// 依赖 subAgentTaskId原始值而非 subAgentView 对象,避免子代理流式
// 期间每帧无效重算(对象每帧换引用但 taskId 不变)。
const viewKey = useMemo(() => { const viewKey = useMemo(() => {
if (schedulerView) return `scheduler:${schedulerView.jobId}`; if (schedulerView) return `scheduler:${schedulerView.jobId}`;
if (subAgentView) return `subagent:${subAgentView.taskId}`; if (subAgentTaskId) return `subagent:${subAgentTaskId}`;
return `main:${selectedTopic ?? ''}`; return `main:${selectedTopic ?? ''}`;
}, [schedulerView, subAgentView, selectedTopic]); }, [schedulerView, subAgentTaskId, selectedTopic]);
return ( return (
<div className="flex h-screen overflow-hidden bg-[var(--bg-primary)] text-[var(--text-primary)]"> <div className="flex h-screen overflow-hidden bg-[var(--bg-primary)] text-[var(--text-primary)]">
@ -1026,6 +1064,9 @@ function App() {
sessionId={sessionId} sessionId={sessionId}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
onOpenSettings={openExpertsSettings} onOpenSettings={openExpertsSettings}
hasMoreOlder={!subAgentView && !schedulerView ? hasMoreOlder : false}
loadingOlder={!subAgentView && !schedulerView ? loadingOlder : false}
onLoadOlder={!subAgentView && !schedulerView ? loadOlderMessages : undefined}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, memo } from 'react';
import { MessageList } from './MessageList'; import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput'; import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector'; import { ExpertSelector } from './ExpertSelector';
@ -27,9 +27,17 @@ interface ChatContainerProps {
settingsClosedTick?: number; settingsClosedTick?: number;
/** 当前话题 ID用于切换话题时清空输入框草稿 */ /** 当前话题 ID用于切换话题时清空输入框草稿 */
topicId?: string | null; topicId?: string | null;
/** 历史分页:是否还有更早的消息可加载 */
hasMoreOlder?: boolean;
/** 历史分页:是否正在加载更早一页 */
loadingOlder?: boolean;
/** 触顶时请求加载更早的历史消息 */
onLoadOlder?: () => void;
} }
export function ChatContainer({ // memoprops 除 messages 外全部稳定useCallback/原始值),
// App 因非消息类 state侧栏折叠、主题等重渲染时跳过整个聊天子树。
export const ChatContainer = memo(function ChatContainer({
messages, messages,
isLoading, isLoading,
isReadOnly = false, isReadOnly = false,
@ -44,6 +52,9 @@ export function ChatContainer({
onOpenSettings, onOpenSettings,
settingsClosedTick, settingsClosedTick,
topicId, topicId,
hasMoreOlder,
loadingOlder,
onLoadOlder,
}: ChatContainerProps) { }: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{ const [selectedExpert, setSelectedExpert] = useState<{
name: string; name: string;
@ -125,6 +136,9 @@ export function ChatContainer({
viewKey={viewKey} viewKey={viewKey}
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
effectiveModel={effectiveModel} effectiveModel={effectiveModel}
hasMoreOlder={hasMoreOlder}
loadingOlder={loadingOlder}
onLoadOlder={onLoadOlder}
/> />
</div> </div>
)} )}
@ -136,4 +150,4 @@ export function ChatContainer({
</div> </div>
</div> </div>
); );
} });

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'; import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'; import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
@ -24,7 +24,8 @@ interface ExpertSelectorProps {
settingsClosedTick?: number; settingsClosedTick?: number;
} }
export function ExpertSelector({ // memoprops 全部稳定useCallback/原始值),流式期间跳过重渲染
export const ExpertSelector = memo(function ExpertSelector({
sessionId, sessionId,
onManageExperts, onManageExperts,
onSelectionChange, onSelectionChange,
@ -278,4 +279,4 @@ export function ExpertSelector({
{error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>} {error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div> </div>
); );
} });

View File

@ -1,4 +1,5 @@
import { useState, useEffect, memo } from 'react'; import { useState, useEffect, memo } from 'react';
import { createPortal } from 'react-dom';
import { import {
CheckCircle, CheckCircle,
ChevronRight, ChevronRight,
@ -20,10 +21,96 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'; import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol';
import { ToolDetailModal } from './ToolDetailModal'; import { ToolDetailModal } from './ToolDetailModal';
// 模块级常量:保持引用稳定,避免流式渲染期间每帧重建
// react-markdown 收到新的 components/plugins 引用会重走内部映射与解析)。
const REMARK_PLUGINS = [remarkGfm];
const MARKDOWN_COMPONENTS: Components = {
// 自定义代码块渲染
code({ className, children, ...props }) {
const isInline = !className;
if (isInline) {
return (
<code
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
{...props}
>
{children}
</code>
);
}
return (
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
<code className={`${className} font-mono text-xs`} {...props}>
{children}
</code>
</pre>
);
},
// 标题样式
h1: ({ children }) => (
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">{children}</h3>
),
// 段落
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// 列表
ul: ({ children }) => (
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">{children}</ol>
),
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
// 链接
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-cyan)] hover:underline"
>
{children}
</a>
),
// 表格
table: ({ children }) => (
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
),
thead: ({ children }) => <thead className="bg-[var(--overlay-subtle)]">{children}</thead>,
th: ({ children }) => (
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-[var(--border-color)] px-2 py-1">{children}</td>
),
// 引用块
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
{children}
</blockquote>
),
// 分隔线
hr: () => <hr className="border-[var(--border-color)] my-3" />,
// 加粗和斜体
strong: ({ children }) => (
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
),
em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
};
// 状态图标组件 // 状态图标组件
function StatusIcon({ function StatusIcon({
status, status,
@ -233,9 +320,14 @@ function ImageLightbox({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
return ( // 必须 portal 到 body本组件渲染在虚拟化消息行内行元素带
// transformtranslateYtransformed 祖先会成为 fixed 后代的包含块,
// 导致遮罩只覆盖该行区域、工具栏定位到行盒而非视口、其他行内容叠在
// 遮罩之上。portal 后 fixed/z-index 相对视口全局生效。
// z-[10000]高于下拉选择器z-[9999])等全局浮层。
return createPortal(
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
onClick={onClose} onClick={onClose}
> >
{/* 顶部工具栏 */} {/* 顶部工具栏 */}
@ -263,7 +355,8 @@ function ImageLightbox({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
alt="图片预览" alt="图片预览"
/> />
</div> </div>,
document.body,
); );
} }
@ -683,7 +776,7 @@ export const MessageBubble = memo(function MessageBubble({
<div> <div>
<div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div> <div className="text-xs font-medium text-[var(--text-muted)] mb-1"></div>
<div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto"> <div className="markdown-content text-sm leading-relaxed bg-[var(--overlay-dim)] rounded-lg p-3 max-h-96 overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
{taskResult.output} {taskResult.output}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
@ -825,101 +918,8 @@ export const MessageBubble = memo(function MessageBubble({
{message.content.trim() && ( {message.content.trim() && (
<div className="markdown-content text-[15px] leading-6"> <div className="markdown-content text-[15px] leading-6">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={REMARK_PLUGINS}
components={{ components={MARKDOWN_COMPONENTS}
// 自定义代码块渲染
code({ className, children, ...props }) {
const isInline = !className;
if (isInline) {
return (
<code
className="bg-[var(--overlay-code)] px-1.5 py-0.5 rounded text-[var(--accent-cyan)] font-mono text-xs"
{...props}
>
{children}
</code>
);
}
return (
<pre className="bg-[var(--overlay-dim-heavy)] rounded-lg p-3 overflow-x-auto my-2">
<code className={`${className} font-mono text-xs`} {...props}>
{children}
</code>
</pre>
);
},
// 标题样式
h1: ({ children }) => (
<h1 className="text-xl font-bold text-[var(--text-primary)] mb-2 mt-4">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold text-[var(--text-primary)] mb-2 mt-3">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold text-[var(--text-primary)] mb-1 mt-2">
{children}
</h3>
),
// 段落
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
// 列表
ul: ({ children }) => (
<ul className="list-disc list-outside mb-2 space-y-1 pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside mb-2 space-y-1 pl-5">
{children}
</ol>
),
li: ({ children }) => <li className="[&>p]:m-0">{children}</li>,
// 链接
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-cyan)] hover:underline"
>
{children}
</a>
),
// 表格
table: ({ children }) => (
<table className="w-full border-collapse mb-2 text-xs">{children}</table>
),
thead: ({ children }) => (
<thead className="bg-[var(--overlay-subtle)]">{children}</thead>
),
th: ({ children }) => (
<th className="border border-[var(--border-color)] px-2 py-1 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-[var(--border-color)] px-2 py-1">
{children}
</td>
),
// 引用块
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-[var(--accent-cyan)]/50 pl-3 my-2 text-[var(--text-secondary)]">
{children}
</blockquote>
),
// 分隔线
hr: () => <hr className="border-[var(--border-color)] my-3" />,
// 加粗和斜体
strong: ({ children }) => (
<strong className="font-bold text-[var(--text-primary)]">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--text-secondary)]">{children}</em>
),
}}
> >
{message.content} {message.content}
</ReactMarkdown> </ReactMarkdown>

View File

@ -10,7 +10,7 @@ import {
MusicIcon, MusicIcon,
VideoIcon, VideoIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, memo } from 'react';
import type { Attachment } from '../../types/protocol'; import type { Attachment } from '../../types/protocol';
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
@ -77,7 +77,9 @@ function getMediaType(mimeType: string): string {
return 'file'; return 'file';
} }
export function MessageInput({ // memoprops 全部稳定(回调 useCallback、对象 prop 来自父组件 state
// 流式期间父组件每帧重渲染时完全跳过输入区子树。
export const MessageInput = memo(function MessageInput({
onSend, onSend,
onStop, onStop,
disabled = false, disabled = false,
@ -528,4 +530,4 @@ export function MessageInput({
</div> </div>
</div> </div>
); );
} });

View File

@ -1,8 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo, memo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageBubble } from './MessageBubble'; import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol'; import type { ChatMessage } from '../../types/protocol';
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'; import { Sparkles, ArrowDown, ArrowUp, Loader2 } from 'lucide-react';
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[]; messages: ChatMessage[];
@ -14,15 +14,26 @@ interface MessageListProps {
highlightedMessageId?: string | null; highlightedMessageId?: string | null;
/** 主代理当前生效模型(透传给 MessageBubble 做 Task 卡片差异显示) */ /** 主代理当前生效模型(透传给 MessageBubble 做 Task 卡片差异显示) */
effectiveModel?: { provider: string; model: string } | null; effectiveModel?: { provider: string; model: string } | null;
/** 历史分页:是否还有更早的消息可加载 */
hasMoreOlder?: boolean;
/** 历史分页:是否正在加载更早一页 */
loadingOlder?: boolean;
/** 触顶时请求加载更早的历史消息 */
onLoadOlder?: () => void;
} }
export function MessageList({ // memoprops 与 ChatContainer 同源messages 变化时才需重渲染),
// 阻断父组件非消息类重渲染向虚拟化列表的传导。
export const MessageList = memo(function MessageList({
messages, messages,
onNavigateToSubAgent, onNavigateToSubAgent,
showThinking = true, showThinking = true,
viewKey, viewKey,
highlightedMessageId, highlightedMessageId,
effectiveModel, effectiveModel,
hasMoreOlder = false,
loadingOlder = false,
onLoadOlder,
}: MessageListProps) { }: MessageListProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
@ -32,6 +43,17 @@ export function MessageList({
const viewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey);
viewKeyRef.current = viewKey; viewKeyRef.current = viewKey;
// 历史分页onLoadOlder 经 ref 转发,保持 handleScroll 空依赖稳定
const onLoadOlderRef = useRef(onLoadOlder);
onLoadOlderRef.current = onLoadOlder;
// hasMoreOlder 镜像:供 layout effect 的 prepend 分支判断分页是否耗尽。
// 不进依赖数组——否则 hasMoreOlder 单独变化会重跑 effect落入同视图
// 分支的滚底逻辑lastMessage 为 user 时 scrollToIndex 到底),把
// 正在顶部浏览历史的用户拽到底部。
const hasMoreOlderRef = useRef(hasMoreOlder);
hasMoreOlderRef.current = hasMoreOlder;
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。 // 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变, // 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
// 不应计入 newMessageCount。 // 不应计入 newMessageCount。
@ -40,6 +62,17 @@ export function MessageList({
// Per-view scroll position memory // Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map()); const scrollPositionsRef = useRef<Map<string, number>>(new Map());
// 滚动位置恢复期标志:恢复 scrollTop 后 virtualizer 会对可见行重测并做
// 滚动纠正,期间的 scroll 事件不应回写 scrollPositions否则中间值会
// 覆盖掉原始保存位置,下次切回时锚点已丢失)。
const restoringRef = useRef(false);
// "顶部"按钮的链式贴顶意图:滚到已加载内容的顶部只是中间态——触顶
// 加载的历史消息 prepend 后不做锚定补偿,而是继续贴到新顶部,链式
// 加载直到真正首条消息hasMoreOlder 耗尽)。用户滚动输入或视图切换
// 即取消意图,恢复正常锚定语义。
const scrollToTopIntentRef = useRef(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [newMessageCount, setNewMessageCount] = useState(0); const [newMessageCount, setNewMessageCount] = useState(0);
@ -50,12 +83,24 @@ export function MessageList({
getScrollElement: () => containerRef.current, getScrollElement: () => containerRef.current,
estimateSize: () => 120, estimateSize: () => 120,
overscan: 6, overscan: 6,
// 动态高度缓存按消息 id而非 index存取主/子智能体视图共用同一个
// virtualizer 实例,若按 index 键控,访问子智能体视图会用其行高污染主
// 视图的高度缓存,返回主视图恢复 scrollTop 时像素偏移无法映射回原消息。
getItemKey: (i) => messages[i]?.id ?? i,
measureElement: measureElement:
typeof window !== 'undefined' && navigator.userAgent.includes('Firefox') typeof window !== 'undefined' && navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height ? (el) => el.getBoundingClientRect().height
: undefined, : undefined,
}); });
// 禁用 virtualizer 在行高实测修正时对 scrollTop 的自动补偿写入
// __resizeItem → applyScrollAdjustment该写入会打断滚顶动画、把链式
// 贴顶从 scrollTop=0 推离,且其触发的 scroll 事件会被误判为用户接管。
// scrollTop 的写手全部由本组件显式管理prepend 锚定(+=)、链式贴顶
// =0、滚底scrollToIndex。注意该开关是实例属性而非 options
// 字段virtual-core 3.x 类型声明),故在此赋值。
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。 // 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。 // useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
const messageIdToIndex = useMemo(() => { const messageIdToIndex = useMemo(() => {
@ -76,6 +121,7 @@ export function MessageList({
const scrollToBottom = useCallback( const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => { (behavior: ScrollBehavior = 'smooth') => {
stopScrollTopAnimation(); stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
isAtBottomRef.current = true; isAtBottomRef.current = true;
setShowScrollToBottom(false); setShowScrollToBottom(false);
setNewMessageCount(0); setNewMessageCount(0);
@ -94,8 +140,13 @@ export function MessageList({
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
stopScrollTopAnimation(); stopScrollTopAnimation();
scrollToTopIntentRef.current = true;
const from = el.scrollTop; const from = el.scrollTop;
if (from <= 0) return; if (from <= 0) {
// 已在已加载内容的顶部:直接补触发一次分页(有更早消息则进入链式贴顶)
onLoadOlderRef.current?.();
return;
}
const duration = Math.min(800, 250 + from / 8); const duration = Math.min(800, 250 + from / 8);
const start = performance.now(); const start = performance.now();
const easeInOutCubic = (t: number) => const easeInOutCubic = (t: number) =>
@ -103,7 +154,16 @@ export function MessageList({
const step = (now: number) => { const step = (now: number) => {
const p = Math.min(1, (now - start) / duration); const p = Math.min(1, (now - start) / duration);
el.scrollTop = from * (1 - easeInOutCubic(p)); el.scrollTop = from * (1 - easeInOutCubic(p));
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0; if (p < 1) {
scrollTopRafRef.current = requestAnimationFrame(step);
} else {
scrollTopRafRef.current = 0;
// 陷阱ease 末段每帧位移是亚像素,浏览器将 scrollTop snap 到 0 后,
// 最终的 scrollTop = 0 是无变化赋值,不派发 scroll 事件 → 触顶加载
// 永远不会被 handleScroll 触发。动画落定后必须显式续链。
// (与 scroll 事件路径可能双发一次requestLoadOlder 内部幂等,无害)
if (scrollToTopIntentRef.current) onLoadOlderRef.current?.();
}
}; };
scrollTopRafRef.current = requestAnimationFrame(step); scrollTopRafRef.current = requestAnimationFrame(step);
}, [stopScrollTopAnimation]); }, [stopScrollTopAnimation]);
@ -114,12 +174,27 @@ export function MessageList({
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
// 注意:此处不做"scrollTop>0 即取消贴顶意图"的启发式判断——程序化
// scrollTop 写入锚定补偿、virtualizer 纠正残留)也会产生离开顶部的
// scroll 事件,启发式无法区分用户与程序,会误杀链式贴顶。意图的取消
// 只由显式用户输入事件wheel/touchstart/pointerdown见下方监听
// scrollToBottom、待办高亮、视图切换与分页耗尽负责。
// 触顶加载更早历史:滚顶动画运行中不触发(动画会持续覆写 scrollTop
// 与 prepend 锚定补偿互相打架);动画落定在 scrollTop=0 时会发出最后一次
// scroll 事件,此刻 rafRef 已清零,可正常触发。重复触发由 hook 内
// loading/hasMore 守卫吸收。
if (el.scrollTop <= 0 && scrollTopRafRef.current === 0) {
onLoadOlderRef.current?.();
}
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const nearBottom = distanceFromBottom < 120; const nearBottom = distanceFromBottom < 120;
// Save scroll position for current view // Save scroll position for current view
// 恢复期不回写:此时 scrollTop 是刚恢复的旧值或重测纠正的中间值
const key = viewKeyRef.current; const key = viewKeyRef.current;
if (key) { if (key && !restoringRef.current) {
scrollPositionsRef.current.set(key, el.scrollTop); scrollPositionsRef.current.set(key, el.scrollTop);
} }
@ -139,25 +214,74 @@ export function MessageList({
}, []); }, []);
// ---- auto-scroll: handle view switches and message updates ---- // ---- auto-scroll: handle view switches and message updates ----
// 同时承担历史分页 prepend 的滚动锚定:头部插入更早消息后,
// 按新增高度补偿 scrollTop让用户视野停在原头部消息上。
// 头部 prepend 检测:记录上次渲染的头部消息 id 与滚动内容总高
const prevFirstIdRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = containerRef.current;
const prevKey = prevViewKeyRef.current; const prevKey = prevViewKeyRef.current;
const viewChanged = prevKey !== viewKey; const viewChanged = prevKey !== viewKey;
prevViewKeyRef.current = viewKey; prevViewKeyRef.current = viewKey;
const firstId = messages[0]?.id ?? null;
const prevFirstId = prevFirstIdRef.current;
prevFirstIdRef.current = firstId;
if (messages.length === 0) { if (messages.length === 0) {
isAtBottomRef.current = true; isAtBottomRef.current = true;
prevMessageCountRef.current = 0; prevMessageCountRef.current = 0;
if (el) prevScrollHeightRef.current = el.scrollHeight;
return; return;
} }
// 同视图内头部变化 = 历史消息 prepend锚定原头部消息的视觉位置。
// 不触发自动滚底,也不计入 newMessageCount历史消息不是"新消息")。
const isHeadPrepend = !viewChanged && el !== null && prevFirstId !== null && firstId !== prevFirstId;
if (isHeadPrepend) {
const prevH = prevScrollHeightRef.current;
const newH = el!.scrollHeight;
if (newH > prevH) {
if (scrollToTopIntentRef.current) {
// "顶部"链式贴顶:不做锚定补偿,直接贴到新顶部
const wasAtTop = el!.scrollTop === 0;
el!.scrollTop = 0;
// 陷阱scrollTop 已为 0 时赋值不触发 scroll 事件,触顶加载链会
// 在首批后静默中断。此处显式续链loading/hasMore 守卫在
// requestLoadOlder 内部吸收wasAtTop=false 时赋值会派发
// scroll 事件,走 handleScroll 正常续链,避免双重请求。
if (wasAtTop && hasMoreOlderRef.current) onLoadOlderRef.current?.();
} else {
el!.scrollTop += newH - prevH;
}
}
// 分页耗尽:贴顶意图已达成,清除标志恢复正常锚定语义
if (!hasMoreOlderRef.current) scrollToTopIntentRef.current = false;
prevScrollHeightRef.current = newH;
prevMessageCountRef.current = messages.length;
return;
}
if (el) prevScrollHeightRef.current = el.scrollHeight;
if (viewChanged) { if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position // View switched (e.g. breadcrumb navigation): restore saved scroll position
stopScrollTopAnimation(); stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
prevMessageCountRef.current = messages.length; prevMessageCountRef.current = messages.length;
const key = viewKey ?? ''; const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key); const savedPos = scrollPositionsRef.current.get(key);
if (savedPos !== undefined && containerRef.current) { if (savedPos !== undefined && containerRef.current) {
// 恢复期跨越两帧 rAF第一帧完成布局与可见行重测第二帧让
// virtualizer 的滚动纠正落定,期间 handleScroll 不回写保存位置
restoringRef.current = true;
requestAnimationFrame(() =>
requestAnimationFrame(() => {
restoringRef.current = false;
}),
);
containerRef.current.scrollTop = savedPos; containerRef.current.scrollTop = savedPos;
const el = containerRef.current; const el = containerRef.current;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
@ -188,6 +312,18 @@ export function MessageList({
} }
}, [messages, viewKey, virtualizer, stopScrollTopAnimation]); }, [messages, viewKey, virtualizer, stopScrollTopAnimation]);
// ---- 历史分页:内容不足一屏时自动补页 ----
// 首屏/补页后若无滚动条scrollTop 恒为 0永远不触发触顶事件
// 只要还有更早的消息就继续请求,直到出现滚动条或加载完毕。
useEffect(() => {
if (!hasMoreOlder || loadingOlder) return;
const el = containerRef.current;
if (!el || messages.length === 0) return;
if (el.scrollHeight <= el.clientHeight) {
onLoadOlderRef.current?.();
}
}, [messages, hasMoreOlder, loadingOlder]);
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----
useEffect(() => { useEffect(() => {
@ -205,6 +341,7 @@ export function MessageList({
if (idx === undefined) return; if (idx === undefined) return;
stopScrollTopAnimation(); stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' }); virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
// 高亮 class 需等 DOM 渲染后操作 // 高亮 class 需等 DOM 渲染后操作
@ -222,18 +359,26 @@ export function MessageList({
// ---- 滚顶动画生命周期 ---- // ---- 滚顶动画生命周期 ----
// 用户手动滚动(滚轮/触摸)时打断滚顶动画,交还滚动控制权 // 用户手动滚动输入(滚轮/触摸/点击)时打断滚顶动画并取消链式贴顶意图,
// 交还滚动控制权
const handleUserScrollInput = useCallback(() => {
stopScrollTopAnimation();
scrollToTopIntentRef.current = false;
}, [stopScrollTopAnimation]);
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true }); el.addEventListener('wheel', handleUserScrollInput, { passive: true });
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true }); el.addEventListener('touchstart', handleUserScrollInput, { passive: true });
el.addEventListener('pointerdown', handleUserScrollInput, { passive: true });
return () => { return () => {
el.removeEventListener('wheel', stopScrollTopAnimation); el.removeEventListener('wheel', handleUserScrollInput);
el.removeEventListener('touchstart', stopScrollTopAnimation); el.removeEventListener('touchstart', handleUserScrollInput);
el.removeEventListener('pointerdown', handleUserScrollInput);
}; };
}, [hasMessages, stopScrollTopAnimation]); }, [hasMessages, handleUserScrollInput]);
// 卸载时取消未完成的动画 // 卸载时取消未完成的动画
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]); useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
@ -301,7 +446,12 @@ export function MessageList({
return ( return (
<div className="relative h-full"> <div className="relative h-full">
<div ref={containerRef} onScroll={handleScroll} className="h-full overflow-y-auto p-6"> <div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-6"
style={{ overflowAnchor: 'none' }}
>
{/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */} {/* 虚拟化容器:总高度撑开滚动条,子项绝对定位 */}
<div style={{ height: `${totalSize}px`, position: 'relative' }}> <div style={{ height: `${totalSize}px`, position: 'relative' }}>
{virtualItems.map((vi) => { {virtualItems.map((vi) => {
@ -335,6 +485,19 @@ export function MessageList({
</div> </div>
</div> </div>
{/* 历史分页加载指示 */}
{loadingOlder && (
<div
className="absolute top-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5
rounded-full border border-[var(--border-color)] bg-[var(--bg-tertiary)]/90
px-3 py-1.5 shadow-sm backdrop-blur-md"
aria-live="polite"
>
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--accent-cyan)]" />
<span className="text-xs text-[var(--text-secondary)]"></span>
</div>
)}
{/* 浮动导航按钮 — 底部居中并排 */} {/* 浮动导航按钮 — 底部居中并排 */}
{showScrollToBottom && ( {showScrollToBottom && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
@ -385,4 +548,4 @@ export function MessageList({
)} )}
</div> </div>
); );
} });

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'; import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
import { import {
listModelOptions, listModelOptions,
@ -19,7 +19,8 @@ interface ModelSelectorProps {
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void; onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
} }
export function ModelSelector({ // memoprops 全部稳定useCallback/原始值),流式期间跳过重渲染
export const ModelSelector = memo(function ModelSelector({
sessionId, sessionId,
topicId, topicId,
settingsClosedTick, settingsClosedTick,
@ -284,4 +285,4 @@ export function ModelSelector({
{error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>} {error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div> </div>
); );
} });

View File

@ -1,8 +1,12 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Terminal, Clock, Maximize2 } from 'lucide-react'; import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
// 模块级常量:保持引用稳定,避免每次渲染重建数组
const REMARK_PLUGINS = [remarkGfm];
interface ToolDetailModalProps { interface ToolDetailModalProps {
toolName: string; toolName: string;
status: string; status: string;
@ -62,9 +66,12 @@ export function ToolDetailModal({
? 'var(--accent-amber)' ? 'var(--accent-amber)'
: 'var(--text-muted)'; : 'var(--text-muted)';
return ( // portal 到 body在虚拟化消息行内打开时行元素的 transform 会成为
// fixed 后代的包含块,遮罩/定位全部失效(同 ImageLightbox
// z-[10000]高于下拉选择器z-[9999])等全局浮层。
return createPortal(
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
onClick={onClose} onClick={onClose}
> >
{/* Modal container */} {/* Modal container */}
@ -127,7 +134,7 @@ export function ToolDetailModal({
{resultContent ? '结果' : '输出'} {resultContent ? '结果' : '输出'}
</div> </div>
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content"> <div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formattedContent}</ReactMarkdown> <ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{formattedContent}</ReactMarkdown>
</div> </div>
</div> </div>
)} )}
@ -142,6 +149,7 @@ export function ToolDetailModal({
</span> </span>
</div> </div>
</div> </div>
</div> </div>,
document.body,
); );
} }

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo, memo } from 'react';
import { import {
Brain, Brain,
User, User,
@ -310,7 +310,9 @@ function SectionHeader({
/* ── main component ────────────────────────────────────── */ /* ── main component ────────────────────────────────────── */
export function MemoryPanel({ // memoprops 全部稳定memories 仅在刷新时换引用、回调均 useCallback
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组/排序重算。
export const MemoryPanel = memo(function MemoryPanel({
memories, memories,
onRefresh, onRefresh,
onClose, onClose,
@ -342,22 +344,26 @@ export function MemoryPanel({
}); });
}; };
const grouped = new Map<string, MemorySummary[]>(); // memories 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
for (const m of memories) { const { grouped, sorted } = useMemo(() => {
const l = grouped.get(m.namespace) || []; const grouped = new Map<string, MemorySummary[]>();
l.push(m); for (const m of memories) {
grouped.set(m.namespace, l); const l = grouped.get(m.namespace) || [];
} l.push(m);
grouped.set(m.namespace, l);
}
const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other']; const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other'];
const sorted = Array.from(grouped.keys()).sort((a, b) => { const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a); const ai = order.indexOf(a);
const bi = order.indexOf(b); const bi = order.indexOf(b);
if (ai !== -1 && bi !== -1) return ai - bi; if (ai !== -1 && bi !== -1) return ai - bi;
if (ai !== -1) return -1; if (ai !== -1) return -1;
if (bi !== -1) return 1; if (bi !== -1) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
return { grouped, sorted };
}, [memories]);
const handleCreate = (ns: string, key: string, content: string) => { const handleCreate = (ns: string, key: string, content: string) => {
sendCommand(onCreateMemory(ns, key, content)); sendCommand(onCreateMemory(ns, key, content));
@ -469,4 +475,4 @@ export function MemoryPanel({
)} )}
</div> </div>
); );
} });

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo, memo } from 'react';
import { import {
Package, Package,
User, User,
@ -100,7 +100,9 @@ function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfi
/* ── main component ────────────────────────────────────── */ /* ── main component ────────────────────────────────────── */
export function SkillList({ skills, onRefresh }: SkillListProps) { // memoprops 全部稳定skills 仅在刷新时换引用、onRefresh 为 useCallback
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组/排序重算。
export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillListProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => { const [collapsed, setCollapsed] = useState<Set<string>>(() => {
try { try {
const s = localStorage.getItem('picobot-skill-collapsed'); const s = localStorage.getItem('picobot-skill-collapsed');
@ -123,22 +125,33 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
}); });
}; };
const grouped = new Map<string, SkillSummary[]>(); // skills 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
for (const s of skills) { const { grouped, sorted } = useMemo(() => {
const l = grouped.get(s.source) || []; const grouped = new Map<string, SkillSummary[]>();
l.push(s); for (const s of skills) {
grouped.set(s.source, l); const l = grouped.get(s.source) || [];
} l.push(s);
grouped.set(s.source, l);
}
const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw']; const order = [
const sorted = Array.from(grouped.keys()).sort((a, b) => { 'user',
const ai = order.indexOf(a); 'useragent',
const bi = order.indexOf(b); 'useropenclaw',
if (ai !== -1 && bi !== -1) return ai - bi; 'project',
if (ai !== -1) return -1; 'projectagent',
if (bi !== -1) return 1; 'projectopenclaw',
return a.localeCompare(b); ];
}); const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai !== -1 && bi !== -1) return ai - bi;
if (ai !== -1) return -1;
if (bi !== -1) return 1;
return a.localeCompare(b);
});
return { grouped, sorted };
}, [skills]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -209,4 +222,4 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
)} )}
</div> </div>
); );
} });

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef, useMemo, memo } from 'react';
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react'; import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react';
import type { TodoItemSummary, Command } from '../../types/protocol'; import type { TodoItemSummary, Command } from '../../types/protocol';
@ -53,7 +53,14 @@ function PulseDot() {
/* ── TodoPanel ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) { // memoprops 全部稳定todos 仅在 todo_list 消息到达时换引用、回调均 useCallback
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组重算。
export const TodoPanel = memo(function TodoPanel({
todos,
requestTodoList,
sendCommand,
onTodoClick,
}: TodoPanelProps) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>( const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
() => new Set(['completed', 'cancelled']), () => new Set(['completed', 'cancelled']),
); );
@ -75,7 +82,8 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
prevTodoIdsRef.current = newIds; prevTodoIdsRef.current = newIds;
}, [todos]); }, [todos]);
const grouped = groupTodos(todos); // todos 引用未变时跳过分组重算(流式期间 App 每帧重渲染)
const grouped = useMemo(() => groupTodos(todos), [todos]);
const inProgressCount = grouped.get('in_progress')?.length ?? 0; const inProgressCount = grouped.get('in_progress')?.length ?? 0;
const totalCount = todos.length; const totalCount = todos.length;
@ -180,4 +188,4 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
</div> </div>
</div> </div>
); );
} });

View File

@ -1,3 +1,4 @@
import { memo } from 'react';
import { Coins } from 'lucide-react'; import { Coins } from 'lucide-react';
import type { TopicTokenStats } from '../../types/protocol'; import type { TopicTokenStats } from '../../types/protocol';
import { import {
@ -12,7 +13,11 @@ interface TopicTokenStatsPanelProps {
tokenStats?: TopicTokenStats | null; tokenStats?: TopicTokenStats | null;
} }
export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps) { // memotokenStats 引用稳定(仅 topic 刷新时换引用),
// 流式期间 App 每帧重渲染时跳过面板重渲染。
export const TopicTokenStatsPanel = memo(function TopicTokenStatsPanel({
tokenStats,
}: TopicTokenStatsPanelProps) {
// 无数据态 // 无数据态
if (!tokenStats || tokenStats.total_tokens === 0) { if (!tokenStats || tokenStats.total_tokens === 0) {
return ( return (
@ -126,4 +131,4 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
)} )}
</div> </div>
); );
} });

View File

@ -1,3 +1,4 @@
import { memo } from 'react';
import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react'; import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react';
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol'; import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol';
@ -92,7 +93,14 @@ function lastStatusIcon(lastStatus: string | undefined) {
} }
} }
export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: SchedulerJobListProps) { // memoprops 全部稳定jobs 仅在刷新时换引用、回调均 useCallback
// 阻断 App 重渲染向定时任务列表的无效传导。
export const SchedulerJobList = memo(function SchedulerJobList({
jobs,
onRefresh,
onViewJob,
sessionId,
}: SchedulerJobListProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header */} {/* Header */}
@ -228,4 +236,4 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
</div> </div>
</div> </div>
); );
} });

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
import { import {
Plus, Plus,
MessageSquare, MessageSquare,
@ -43,7 +43,9 @@ function formatTime(timestamp: number): string {
} }
} }
export function TopicList({ // memoprops 全部稳定topics 仅在 topic_list 消息到达时换引用、回调均 useCallback
// 流式期间 App 每帧重渲染时跳过话题列表重渲染与分页重算。
export const TopicList = memo(function TopicList({
sessionId, sessionId,
topics, topics,
currentTopicId, currentTopicId,
@ -357,4 +359,4 @@ export function TopicList({
)} )}
</div> </div>
); );
} });

View File

@ -16,6 +16,7 @@ import type {
ToolCall, ToolCall,
ToolResult, ToolResult,
ToolPending, ToolPending,
TopicHistoryEnd,
ExecutionCompleted, ExecutionCompleted,
WsError, WsError,
TaskStarted, TaskStarted,
@ -51,6 +52,14 @@ export interface UseMessagesReturn {
handleStop: () => Command; handleStop: () => Command;
/** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */ /** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */
handleMainViewMessage: (message: WsOutbound) => boolean; handleMainViewMessage: (message: WsOutbound) => boolean;
/** 历史分页:是否还有更早的消息可加载 */
hasMoreOlder: boolean;
/** 历史分页:当前最早已加载消息的 seq 游标 */
oldestSeq: number | null;
/** 历史分页:是否正在加载更早一页 */
loadingOlder: boolean;
/** 触顶时请求加载更早一页(返回待发送命令,由调用方发送) */
requestLoadOlder: () => Command | null;
} }
export function useMessages(options: UseMessagesOptions): UseMessagesReturn { export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
@ -223,22 +232,162 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
if (selectedTopicRef.current) markTopicProcessing(selectedTopicRef.current); if (selectedTopicRef.current) markTopicProcessing(selectedTopicRef.current);
}, [selectedTopicRef, markTopicProcessing]); }, [selectedTopicRef, markTopicProcessing]);
// ---- 历史分页load_older_messages ----
// 历史批次消息(带 seq先缓存在 ref收到 topic_history_end 时一次性
// 去重后 prepend 到列表头部:避免逐条 setState 且保证批次内顺序稳定。
const pendingHistoryRef = useRef<ChatMessage[]>([]);
const [olderHistory, setOlderHistory] = useState<{
hasMore: boolean;
oldestSeq: number | null;
loading: boolean;
}>({ hasMore: false, oldestSeq: null, loading: false });
const oldestSeqRef = useRef<number | null>(null);
oldestSeqRef.current = olderHistory.oldestSeq;
const clearMessages = useCallback(() => { const clearMessages = useCallback(() => {
clearStreaming(); clearStreaming();
setMessages([]); setMessages([]);
// 重置历史分页状态与未 flush 的批次缓存
pendingHistoryRef.current = [];
setOlderHistory({ hasMore: false, oldestSeq: null, loading: false });
}, [clearStreaming]); }, [clearStreaming]);
const requestLoadOlder = useCallback((): Command | null => {
const topicId = selectedTopicRef.current;
const before = oldestSeqRef.current;
if (!topicId || before === null || olderHistory.loading || !olderHistory.hasMore) {
return null;
}
setOlderHistory((prev) => (prev.loading ? prev : { ...prev, loading: true }));
// 超时自愈topic_history_end 因断连/异常永不到达时解除 loading 锁,
// 避免历史分页永久卡死幂等end 先到则 loading 已为 false无副作用
setTimeout(() => {
setOlderHistory((prev) => (prev.loading ? { ...prev, loading: false } : prev));
}, 10000);
return { type: 'load_older_messages', topic_id: topicId, before_seq: before };
}, [olderHistory.loading, olderHistory.hasMore, selectedTopicRef]);
/** topic_history_end 到达flush 历史批次 + 更新分页游标 */
const handleTopicHistoryEnd = useCallback((msg: TopicHistoryEnd) => {
if (msg.topic_id !== selectedTopicRef.current) return;
const batch = pendingHistoryRef.current;
pendingHistoryRef.current = [];
if (batch.length > 0) {
setMessages((prev) => {
const existing = new Set(prev.map((m) => m.id));
const fresh = batch.filter((m) => !existing.has(m.id));
if (fresh.length === 0) return prev;
return [...fresh, ...prev];
});
}
setOlderHistory({
hasMore: msg.has_more,
oldestSeq: msg.oldest_seq ?? null,
loading: false,
});
}, [selectedTopicRef]);
/** 历史批次消息(带 seq转 ChatMessage 缓存;返回 true 表示已处理 */
const tryCollectHistoryMessage = useCallback(
(message: WsOutbound): boolean => {
const seq = (message as { seq?: number }).seq;
if (seq === undefined) return false;
// 历史批次必带 topic_id后端历史路径填充。不匹配切话题瞬间在途的
// 旧批次)或不带(任务会话消息)的交回常规分支:前者被 per-case 的
// topic_id 检查丢弃,后者维持原有的实时消息处理路径。
const batchTopicId = (message as { topic_id?: string }).topic_id;
if (batchTopicId !== selectedTopicRef.current) return false;
let converted: ChatMessage | null = null;
const m = message as
| AssistantResponse
| ToolCall
| ToolResult
| ToolPending;
switch (m.type) {
case 'assistant_response':
converted = {
id: m.id,
role: m.role === 'user' || m.role === 'tool' ? m.role : 'assistant',
content: m.content,
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
seq,
type: 'message',
attachments: m.attachments,
reasoningContent: m.reasoning_content,
};
break;
case 'tool_call':
converted = {
id: m.id,
role: 'tool',
content: m.content,
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
seq,
type: 'tool_call',
toolName: m.tool_name,
toolCallId: m.tool_call_id,
arguments: m.arguments,
reasoningContent: m.reasoning_content,
};
break;
case 'tool_result':
converted = {
id: m.id,
role: 'tool',
content: m.content,
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
seq,
type: 'tool_result',
toolName: m.tool_name,
toolCallId: m.tool_call_id,
durationMs: m.duration_ms,
};
break;
case 'tool_pending':
converted = {
id: m.id,
role: 'tool',
content: `${m.content}\n\n${m.resume_hint}`,
timestamp: m.timestamp ?? Math.floor(Date.now() / 1000),
seq,
type: 'tool_pending',
toolName: m.tool_name,
toolCallId: m.tool_call_id,
};
break;
default:
return false;
}
pendingHistoryRef.current.push(converted);
return true;
},
[],
);
const handleStop = useCallback((): Command => { const handleStop = useCallback((): Command => {
return { type: 'stop_execution' }; return { type: 'stop_execution' };
}, []); }, []);
const handleMainViewMessage = useCallback( const handleMainViewMessage = useCallback(
(message: WsOutbound): boolean => { (message: WsOutbound): boolean => {
// 非流式消息到达前,先把 pending 的流式内容落盘,避免被后续消息覆盖或丢失 // 非流式消息到达前,先把 pending 的流式内容落盘,避免被后续消息覆盖或丢失。
if (message.type !== 'stream_delta') { // 例外:历史分页批次(带 seq与 topic_history_end 是历史数据回放,
// 与活动流无关——若在流式输出进行中触顶加载历史,误清累加器会导致
// 后续 delta 从零累积并覆写壳内容,造成已流出文本丢失。
if (
message.type !== 'stream_delta' &&
message.type !== 'topic_history_end' &&
(message as { seq?: number }).seq === undefined
) {
finishStreaming(); finishStreaming();
} }
switch (message.type) { switch (message.type) {
case 'topic_history_end': {
handleTopicHistoryEnd(message as TopicHistoryEnd);
return true;
}
case 'task_started': { case 'task_started': {
const msg = message as TaskStarted; const msg = message as TaskStarted;
// 只 backfill 当前话题的 task tool_call避免跨话题串扰 // 只 backfill 当前话题的 task tool_call避免跨话题串扰
@ -359,6 +508,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
} }
case 'assistant_response': { case 'assistant_response': {
if (tryCollectHistoryMessage(message)) return true;
const msg = message as AssistantResponse; const msg = message as AssistantResponse;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true; if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'; const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
@ -390,6 +540,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
} }
case 'tool_call': { case 'tool_call': {
if (tryCollectHistoryMessage(message)) return true;
const msg = message as ToolCall; const msg = message as ToolCall;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true; if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [ setMessages((prev) => [
@ -412,6 +563,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
} }
case 'tool_result': { case 'tool_result': {
if (tryCollectHistoryMessage(message)) return true;
const msg = message as ToolResult; const msg = message as ToolResult;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true; if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [ setMessages((prev) => [
@ -432,6 +584,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
} }
case 'tool_pending': { case 'tool_pending': {
if (tryCollectHistoryMessage(message)) return true;
const msg = message as ToolPending; const msg = message as ToolPending;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true; if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [ setMessages((prev) => [
@ -511,5 +664,9 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
finishStreaming, finishStreaming,
handleStop, handleStop,
handleMainViewMessage, handleMainViewMessage,
hasMoreOlder: olderHistory.hasMore,
oldestSeq: olderHistory.oldestSeq,
loadingOlder: olderHistory.loading,
requestLoadOlder,
}; };
} }

View File

@ -20,8 +20,10 @@ export interface UseTopicsReturn {
topicsRef: MutableRefObject<Topic[]>; topicsRef: MutableRefObject<Topic[]>;
selectedTopicRef: MutableRefObject<string | null>; selectedTopicRef: MutableRefObject<string | null>;
pendingNewTopicRef: MutableRefObject<boolean>; pendingNewTopicRef: MutableRefObject<boolean>;
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ /** topic_list pendingNewTopic
handleTopicList: (msg: TopicList) => boolean; * currentSessionId session
* */
handleTopicList: (msg: TopicList, currentSessionId: string | null) => boolean;
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */ /** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */
handleTopicRenamed: (msg: TopicRenamed) => void; handleTopicRenamed: (msg: TopicRenamed) => void;
createTopic: (title?: string) => Command; createTopic: (title?: string) => Command;
@ -67,20 +69,36 @@ export function useTopics(): UseTopicsReturn {
setTopicRefreshTrigger((n) => n + 1); setTopicRefreshTrigger((n) => n + 1);
}, []); }, []);
const handleTopicList = useCallback((msg: TopicList): boolean => { const handleTopicList = useCallback(
const newTopics = mapTopicSummaries(msg.topics); (msg: TopicList, currentSessionId: string | null): boolean => {
setTopics(newTopics); // 归属校验topic_list 有多个发射源(初始加载/token 防抖刷新/手动刷新/
// 重连恢复),切换 session 瞬间旧 session 的在途响应可能晚到。
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) // 协议携带 session_id不匹配当前 session 的响应直接丢弃,
if (pendingNewTopicRef.current) { // 防止旧列表覆盖新列表并触发链式的错误自动选中。
pendingNewTopicRef.current = false; if (msg.session_id && currentSessionId && msg.session_id !== currentSessionId) {
if (newTopics.length > 0) { return false;
setSelectedTopic(newTopics[0].id);
return true;
} }
}
return false; const newTopics = mapTopicSummaries(msg.topics);
}, []); setTopics(newTopics);
// 新建话题后自动聚焦:用差分找出本响应新增的话题(而非盲取第一项)——
// 仅当响应确实携带新话题时才消费 pending 标志,避免被恰好撞上的
// token 刷新/重连刷新响应错误消费。
if (pendingNewTopicRef.current) {
const knownIds = new Set(topicsRef.current.map((t) => t.id));
const fresh = newTopics.find((t) => !knownIds.has(t.id));
if (fresh) {
pendingNewTopicRef.current = false;
setSelectedTopic(fresh.id);
selectedTopicRef.current = fresh.id;
return true;
}
}
return false;
},
[],
);
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => { const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
// 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整 // 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整

View File

@ -23,6 +23,7 @@ import { useTopics } from './chat/useTopics';
import { useMessages } from './chat/useMessages'; import { useMessages } from './chat/useMessages';
import { useSubAgentView } from './chat/useSubAgentView'; import { useSubAgentView } from './chat/useSubAgentView';
import { useSchedulerView } from './chat/useSchedulerView'; import { useSchedulerView } from './chat/useSchedulerView';
import { apiGetSilent } from '../api/client';
// 简化后的层级状态 // 简化后的层级状态
interface UseChatReturn { interface UseChatReturn {
@ -44,6 +45,11 @@ interface UseChatReturn {
messages: ChatMessage[]; messages: ChatMessage[];
isLoading: boolean; isLoading: boolean;
// 历史分页
hasMoreOlder: boolean;
loadingOlder: boolean;
loadOlderMessages: () => void;
// 通道状态 // 通道状态
channels: Channel[]; channels: Channel[];
selectedChannel: string; selectedChannel: string;
@ -137,13 +143,11 @@ interface UseChatReturn {
async function reconcileProcessingTopics( async function reconcileProcessingTopics(
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>, setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
) { ) {
try { // 走统一 apiGetSilent自动注入认证头远程部署带 token 时裸 fetch 会 401
const res = await fetch('/api/executions'); // 导致对账静默失败、话题永久卡 loading。返回 null 时保留前端现有状态。
if (!res.ok) return; const data = await apiGetSilent<{ topic_ids?: string[] }>('/api/executions');
const data = (await res.json()) as { topic_ids?: string[] }; if (data) {
setProcessingTopicIds(new Set(data.topic_ids ?? [])); setProcessingTopicIds(new Set(data.topic_ids ?? []));
} catch {
// 查询失败:保留前端现有状态
} }
} }
@ -193,6 +197,11 @@ export function useChat(): UseChatReturn {
return; return;
case 'session_list': { case 'session_list': {
// 归属校验:切换通道后旧通道的在途 session_list 可能晚到,
// 不得用旧通道的会话列表覆盖当前通道(协议携带 channel_name
if (message.channel_name && message.channel_name !== selectedChannelRef.current) {
return;
}
// 重连恢复:重连前已有选中 topic且原 session 仍存在于新列表中 // 重连恢复:重连前已有选中 topic且原 session 仍存在于新列表中
// 保留断连前的 messages用户仍可查看之前的对话仅刷新 session/topic 列表 // 保留断连前的 messages用户仍可查看之前的对话仅刷新 session/topic 列表
// 注意:不发 load_chat_messages——历史消息不带 topic_idChatMessage 结构无此字段), // 注意:不发 load_chat_messages——历史消息不带 topic_idChatMessage 结构无此字段),
@ -233,7 +242,10 @@ export function useChat(): UseChatReturn {
return; return;
case 'topic_list': { case 'topic_list': {
const autoFocused = topics.handleTopicList(message); const autoFocused = topics.handleTopicList(
message,
sessions.selectedSessionIdRef.current,
);
if (autoFocused) messages.clearMessages(); if (autoFocused) messages.clearMessages();
return; return;
} }
@ -306,6 +318,10 @@ export function useChat(): UseChatReturn {
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- // ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
topics.setSelectedTopic(topicId); topics.setSelectedTopic(topicId);
// 同步双写 refsetState 后的 effect 同步存在竞态窗口,窗口内到达的
// 旧话题 stream_delta/tool_* 消息会按旧 ref 过滤,污染刚清空的消息列表
// (与 enter/exitSubAgentView 的既有模式一致)
topics.selectedTopicRef.current = topicId;
messages.clearMessages(); messages.clearMessages();
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致) // ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
@ -323,6 +339,10 @@ export function useChat(): UseChatReturn {
sessions.setSelectedSessionId(null); sessions.setSelectedSessionId(null);
topics.setTopics([]); topics.setTopics([]);
topics.setSelectedTopic(null); topics.setSelectedTopic(null);
topics.selectedTopicRef.current = null;
// 丢弃未消费的新建话题聚焦标志:新通道的首个 topic_list 中所有话题
// 都是"新话题",残留标志会被差分逻辑错误消费
topics.pendingNewTopicRef.current = false;
messages.clearMessages(); messages.clearMessages();
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = []; subAgent.subAgentStackRef.current = [];
@ -341,6 +361,10 @@ export function useChat(): UseChatReturn {
sessions.setSelectedSessionId(sessionId); sessions.setSelectedSessionId(sessionId);
topics.setTopics([]); topics.setTopics([]);
topics.setSelectedTopic(null); topics.setSelectedTopic(null);
topics.selectedTopicRef.current = null;
// 丢弃未消费的新建话题聚焦标志:新 session 的首个 topic_list 中所有
// 话题都是"新话题",残留标志会被差分逻辑错误消费
topics.pendingNewTopicRef.current = false;
messages.clearMessages(); messages.clearMessages();
subAgent.subAgentViewRef.current = null; subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = []; subAgent.subAgentStackRef.current = [];
@ -352,6 +376,12 @@ export function useChat(): UseChatReturn {
[sessions.selectedSessionId], [sessions.selectedSessionId],
); );
// ---- 历史分页触顶时请求更早一页hook 内部含 loading/hasMore/游标守卫) ----
const loadOlderMessages = useCallback(() => {
const cmd = messages.requestLoadOlder();
if (cmd) conn.sendCommand(cmd);
}, [messages.requestLoadOlder, conn.sendCommand]);
// ---- 委托方法 ---- // ---- 委托方法 ----
const requestSessionList = useCallback((): Command => { const requestSessionList = useCallback((): Command => {
return sessions.requestSessionList(sideData.selectedChannel); return sessions.requestSessionList(sideData.selectedChannel);
@ -388,6 +418,9 @@ export function useChat(): UseChatReturn {
setSelectedTopic: topics.setSelectedTopic, setSelectedTopic: topics.setSelectedTopic,
messages: resolvedMessages, messages: resolvedMessages,
isLoading: messages.isLoading, isLoading: messages.isLoading,
hasMoreOlder: messages.hasMoreOlder,
loadingOlder: messages.loadingOlder,
loadOlderMessages,
isReadOnly, isReadOnly,
isWritable: sideData.isWritable, isWritable: sideData.isWritable,
channels: sideData.channels, channels: sideData.channels,

View File

@ -47,6 +47,8 @@ export interface AssistantResponse {
timestamp?: number; timestamp?: number;
reasoning_content?: string; reasoning_content?: string;
user_message_id?: string; user_message_id?: string;
/** 历史分页游标:仅历史加载批次携带(实时推送无此字段) */
seq?: number;
} }
export interface ToolCall { export interface ToolCall {
@ -62,6 +64,8 @@ export interface ToolCall {
timestamp?: number; timestamp?: number;
reasoning_content?: string; reasoning_content?: string;
user_message_id?: string; user_message_id?: string;
/** 历史分页游标:仅历史加载批次携带(实时推送无此字段) */
seq?: number;
} }
export interface ToolResult { export interface ToolResult {
@ -75,6 +79,8 @@ export interface ToolResult {
topic_id?: string; topic_id?: string;
duration_ms?: number; duration_ms?: number;
timestamp?: number; timestamp?: number;
/** 历史分页游标:仅历史加载批次携带(实时推送无此字段) */
seq?: number;
} }
export interface ToolPending { export interface ToolPending {
@ -88,6 +94,16 @@ export interface ToolPending {
subagent_task_id?: string; subagent_task_id?: string;
topic_id?: string; topic_id?: string;
timestamp?: number; timestamp?: number;
/** 历史分页游标:仅历史加载批次携带(实时推送无此字段) */
seq?: number;
}
/** 话题历史批次结束标记:更新分页游标与 has_more 状态 */
export interface TopicHistoryEnd {
type: 'topic_history_end';
topic_id: string;
has_more: boolean;
oldest_seq?: number;
} }
export interface WsError { export interface WsError {
@ -315,6 +331,7 @@ export type WsOutbound =
| ToolCall | ToolCall
| ToolResult | ToolResult
| ToolPending | ToolPending
| TopicHistoryEnd
| WsError | WsError
| TaskStarted | TaskStarted
| StreamDelta | StreamDelta
@ -411,6 +428,12 @@ export interface LoadChatMessagesCommand {
chat_id: string; chat_id: string;
} }
export interface LoadOlderMessagesCommand {
type: 'load_older_messages';
topic_id: string;
before_seq: number;
}
export interface DeleteTopicCommand { export interface DeleteTopicCommand {
type: 'delete_topic'; type: 'delete_topic';
topic_id: string; topic_id: string;
@ -472,6 +495,7 @@ export type Command =
| LoadTaskMessagesCommand | LoadTaskMessagesCommand
| ListSchedulerJobsCommand | ListSchedulerJobsCommand
| LoadChatMessagesCommand | LoadChatMessagesCommand
| LoadOlderMessagesCommand
| DeleteTopicCommand | DeleteTopicCommand
| RenameTopicCommand | RenameTopicCommand
| StopExecutionCommand | StopExecutionCommand
@ -491,6 +515,8 @@ export interface ChatMessage {
role: 'user' | 'assistant' | 'tool'; role: 'user' | 'assistant' | 'tool';
content: string; content: string;
timestamp: number; timestamp: number;
/** 历史分页游标(仅历史批次消息携带,用于触顶加载更早页) */
seq?: number;
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool'; type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool';
toolName?: string; toolName?: string;
toolCallId?: string; toolCallId?: string;