Compare commits
No commits in common. "75653447e2ee7848d0512193420037c2b8c00b82" and "b0c24d64f0d99233cd276b86cad11d49ed1b6829" have entirely different histories.
75653447e2
...
b0c24d64f0
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1728,7 +1728,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.4.4"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.4.4"
|
version = "0.4.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@ -2,106 +2,6 @@
|
|||||||
|
|
||||||
本文件记录 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` 默认值由 3600(60 分钟)改为 0(不限制),避免长任务被单次执行时间上限中断;需要时可在 `config.json` 的 `agents` 段显式配置。
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
|
|
||||||
#### 第一批性能修复
|
|
||||||
- 飞书通道 HTTP client:新增 connect/全局超时,token 刷新与 reaction 叠加紧超时,消除无限挂起风险。
|
|
||||||
- SQLite:WAL 模式下 `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 迭代,聚焦 **安全漏洞修复**、**全链路性能优化** 与 **前端健壮性** 三大方向。
|
||||||
|
|||||||
@ -258,43 +258,6 @@ 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];
|
||||||
@ -400,7 +363,6 @@ fn filter_images_by_age_and_count_inner(
|
|||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1170,8 +1132,7 @@ impl AgentLoop {
|
|||||||
let tools = if tool_defs.is_empty() {
|
let tools = if tool_defs.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Arc 共享:process() 内多轮迭代只读复用,避免每轮深拷贝全部工具定义
|
Some(tool_defs)
|
||||||
Some(std::sync::Arc::new(tool_defs))
|
|
||||||
};
|
};
|
||||||
// 工具 token 估算在循环外算一次(tool_defs 在 process() 期间不变),
|
// 工具 token 估算在循环外算一次(tool_defs 在 process() 期间不变),
|
||||||
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
||||||
@ -1180,7 +1141,6 @@ 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");
|
||||||
@ -1196,28 +1156,6 @@ 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,
|
||||||
@ -1309,7 +1247,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, stream_callback.clone()) => {
|
result = self.provider.chat_with_streaming(request.clone(), stream_callback.clone()) => {
|
||||||
llm_result = result;
|
llm_result = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1317,7 +1255,7 @@ impl AgentLoop {
|
|||||||
} else {
|
} else {
|
||||||
llm_result = self
|
llm_result = self
|
||||||
.provider
|
.provider
|
||||||
.chat_with_streaming(&request, stream_callback)
|
.chat_with_streaming(request.clone(), stream_callback)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1698,7 +1636,7 @@ impl AgentLoop {
|
|||||||
&self,
|
&self,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
system_prompt_context: Option<&SystemPromptContext>,
|
system_prompt_context: Option<&SystemPromptContext>,
|
||||||
tools: Option<std::sync::Arc<Vec<crate::domain::tools::Tool>>>,
|
tools: Option<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(
|
||||||
@ -1889,12 +1827,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) => {
|
result = self.provider.chat(request.clone()) => {
|
||||||
final_result = result;
|
final_result = result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final_result = self.provider.chat(&request).await;
|
final_result = self.provider.chat(request.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match final_result {
|
match final_result {
|
||||||
@ -2054,29 +1992,6 @@ 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;
|
||||||
@ -2348,7 +2263,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -3406,7 +3320,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() {
|
||||||
|
|||||||
@ -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,7 +1410,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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, DEFAULT_MAX_RUN_SECS};
|
pub use runtime_config::AgentRuntimeConfig;
|
||||||
pub use system_prompt::{
|
pub use system_prompt::{
|
||||||
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
||||||
generate_system_env_prompt,
|
generate_system_env_prompt,
|
||||||
|
|||||||
@ -15,16 +15,8 @@ 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();
|
||||||
@ -51,7 +43,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
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
|
||||||
|
|||||||
@ -53,10 +53,6 @@ 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")]
|
||||||
@ -126,7 +122,6 @@ impl ChatMessage {
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,7 +140,6 @@ impl ChatMessage {
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +158,6 @@ impl ChatMessage {
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,7 +188,6 @@ impl ChatMessage {
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_calls: Some(tool_calls),
|
tool_calls: Some(tool_calls),
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,7 +223,6 @@ impl ChatMessage {
|
|||||||
tool_state: None,
|
tool_state: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,7 +259,6 @@ impl ChatMessage {
|
|||||||
tool_state: Some(tool_state),
|
tool_state: Some(tool_state),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
seq: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,16 +300,7 @@ 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.
|
||||||
@ -470,7 +451,6 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences_with_ids(
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -500,7 +480,6 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences_with_ids(
|
|||||||
"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;
|
||||||
@ -510,7 +489,7 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences_with_ids(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(removed, removed_ids)
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -2,9 +2,8 @@ pub mod message;
|
|||||||
|
|
||||||
pub use crate::domain::messages::ContentBlock;
|
pub use crate::domain::messages::ContentBlock;
|
||||||
pub use message::{
|
pub use message::{
|
||||||
ChatMessage, InboundMessage, MediaItem, MessageUsage, OutboundMessage,
|
ChatMessage, InboundMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_AGENT_PROMPT,
|
||||||
SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
|
SYSTEM_CONTEXT_HISTORY_COMPACTION, SYSTEM_CONTEXT_SCHEDULED_PROMPT,
|
||||||
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@ -30,17 +30,6 @@ 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)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -192,18 +181,10 @@ 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,
|
http_client: reqwest::Client::new(),
|
||||||
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)),
|
||||||
@ -284,7 +265,6 @@ 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,
|
||||||
@ -692,7 +672,6 @@ 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 }
|
||||||
|
|||||||
@ -564,7 +564,6 @@ 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();
|
||||||
@ -630,7 +629,6 @@ 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();
|
||||||
|
|||||||
@ -85,7 +85,6 @@ 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 => {
|
||||||
// 根据元数据判断具体类型
|
// 根据元数据判断具体类型
|
||||||
@ -121,7 +120,6 @@ 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") {
|
||||||
@ -147,7 +145,6 @@ 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") {
|
||||||
@ -196,7 +193,6 @@ 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") {
|
||||||
@ -222,7 +218,6 @@ 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") {
|
||||||
@ -248,7 +243,6 @@ 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 {
|
||||||
@ -263,7 +257,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -283,7 +276,6 @@ 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);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
@ -81,12 +82,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 topic_summaries =
|
let topics = handler
|
||||||
crate::command::handlers::list_topics::list_topic_summaries_blocking(
|
.store
|
||||||
handler.store.clone(),
|
.list_topics(session_id)
|
||||||
session_id,
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
||||||
)
|
|
||||||
.await?;
|
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
|
||||||
|
|
||||||
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()))?;
|
||||||
|
|||||||
@ -73,9 +73,11 @@ 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 查询
|
// 使用辅助方法获取消息数量
|
||||||
// (message_count 与 list_topics 的展示口径一致)
|
let msg_count = handler
|
||||||
let msg_count = topic.message_count;
|
.store
|
||||||
|
.get_topic_message_count(&topic.id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"{}. {}{} ({})",
|
"{}. {}{} ({})",
|
||||||
|
|||||||
@ -97,28 +97,6 @@ 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>,
|
||||||
@ -161,8 +139,12 @@ async fn handle_list_topics(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
let summaries =
|
let topics = handler
|
||||||
list_topic_summaries_blocking(handler.store.clone(), &session_id).await?;
|
.store
|
||||||
|
.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()))?;
|
||||||
|
|||||||
@ -63,64 +63,3 @@ 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!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 record = store
|
let sessions = store
|
||||||
.find_first_session_by_id_suffix(&format!(":{}", task_id))
|
.find_sessions_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()))?;
|
||||||
|
|
||||||
let record = match record {
|
if sessions.is_empty() {
|
||||||
Some(record) => record,
|
return Ok(None);
|
||||||
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}"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
@ -83,12 +84,11 @@ async fn handle_rename_topic(
|
|||||||
|
|
||||||
// 标题未变化时直接返回当前列表,避免无意义写入
|
// 标题未变化时直接返回当前列表,避免无意义写入
|
||||||
if old_display == trimmed_title {
|
if old_display == trimmed_title {
|
||||||
let topic_summaries =
|
let topics = handler
|
||||||
crate::command::handlers::list_topics::list_topic_summaries_blocking(
|
.store
|
||||||
handler.store.clone(),
|
.list_topics(session_id)
|
||||||
session_id,
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
||||||
)
|
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 topic_summaries =
|
let topics = handler
|
||||||
crate::command::handlers::list_topics::list_topic_summaries_blocking(
|
.store
|
||||||
handler.store.clone(),
|
.list_topics(session_id)
|
||||||
session_id,
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
||||||
)
|
|
||||||
.await?;
|
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
|
||||||
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()))?;
|
||||||
|
|
||||||
|
|||||||
@ -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, usize), String> {
|
) -> Result<PathBuf, String> {
|
||||||
// 获取会话记录
|
// 获取会话记录
|
||||||
let record = store
|
let record = store
|
||||||
.get_session(session_id)
|
.get_session(session_id)
|
||||||
@ -83,8 +83,7 @@ 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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存会话命令处理器
|
/// 保存会话命令处理器
|
||||||
@ -188,8 +187,8 @@ async fn handle_save_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用公共函数(返回路径与已加载消息数,避免二次全量加载只为计数)
|
// 调用公共函数
|
||||||
let (output_path, message_count) = save_session_to_file(
|
let output_path = save_session_to_file(
|
||||||
session_id,
|
session_id,
|
||||||
filepath,
|
filepath,
|
||||||
include_all,
|
include_all,
|
||||||
@ -201,6 +200,15 @@ 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,
|
||||||
@ -708,7 +716,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
|||||||
|
|
||||||
// 返回成功或失败消息
|
// 返回成功或失败消息
|
||||||
match result {
|
match result {
|
||||||
Ok((output_path, _message_count)) => {
|
Ok(output_path) => {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"Session saved to: {}",
|
"Session saved to: {}",
|
||||||
output_path.display().to_string().replace('\\', "/")
|
output_path.display().to_string().replace('\\', "/")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
@ -103,12 +104,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 topic_summaries =
|
let topics = handler
|
||||||
crate::command::handlers::list_topics::list_topic_summaries_blocking(
|
.store
|
||||||
handler.store.clone(),
|
.list_topics(session_id)
|
||||||
session_id,
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
||||||
)
|
|
||||||
.await?;
|
let topic_summaries = build_topic_summaries(handler.store.as_ref(), topics)?;
|
||||||
|
|
||||||
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()))?;
|
||||||
|
|||||||
@ -68,13 +68,10 @@ 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 store = handler.store.clone();
|
let topics = handler
|
||||||
let session_id_bg = session_id.to_string();
|
.store
|
||||||
tokio::task::spawn_blocking(move || -> Result<String, CommandError> {
|
.list_topics(session_id)
|
||||||
let topics = store
|
|
||||||
.list_topics(&session_id_bg)
|
|
||||||
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
let index = index.saturating_sub(1);
|
let index = index.saturating_sub(1);
|
||||||
@ -88,32 +85,22 @@ async fn handle_switch_topic(
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(topics[index].id.clone())
|
topics[index].id.clone()
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))??
|
|
||||||
} else {
|
} else {
|
||||||
topic_id
|
topic_id
|
||||||
};
|
};
|
||||||
|
|
||||||
// 验证目标话题存在
|
// 验证目标话题存在
|
||||||
let topic = {
|
let topic = handler
|
||||||
let store = handler.store.clone();
|
.store
|
||||||
let topic_id_bg = target_topic_id.clone();
|
.get_topic(&target_topic_id)
|
||||||
tokio::task::spawn_blocking(move || {
|
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?
|
||||||
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
|
||||||
@ -126,15 +113,10 @@ async fn handle_switch_topic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用辅助方法获取消息数量
|
// 使用辅助方法获取消息数量
|
||||||
let msg_count = {
|
let msg_count = handler
|
||||||
let store = handler.store.clone();
|
.store
|
||||||
let topic_id_bg = target_topic_id.clone();
|
.get_topic_message_count(&target_topic_id)
|
||||||
tokio::task::spawn_blocking(move || store.get_topic_message_count(&topic_id_bg))
|
.unwrap_or(0);
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|r| r.ok())
|
|
||||||
.unwrap_or(0)
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = format!(
|
let message = format!(
|
||||||
"✓ Switched to topic: {} ({} messages)",
|
"✓ Switched to topic: {} ({} messages)",
|
||||||
|
|||||||
@ -49,11 +49,6 @@ 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 },
|
||||||
/// 重命名指定话题
|
/// 重命名指定话题
|
||||||
@ -100,7 +95,6 @@ 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",
|
||||||
|
|||||||
@ -571,10 +571,6 @@ 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 {
|
||||||
@ -601,10 +597,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -990,8 +982,6 @@ 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,
|
||||||
@ -1140,7 +1130,6 @@ 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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -129,7 +129,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -311,46 +311,7 @@ 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_slot:wait_coordinator 通过此 slot 释放/重获取 serial_lock。
|
// guard_slot:wait_coordinator 通过此 slot 释放/重获取 serial_lock。
|
||||||
// 正常执行时 guard 留在 slot 中(锁持有);wait 工具调用时 take guard 释放锁,
|
// 正常执行时 guard 留在 slot 中(锁持有);wait 工具调用时 take guard 释放锁,
|
||||||
|
|||||||
@ -1246,14 +1246,9 @@ 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_lookup = {
|
let topic_session_id = match store.get_topic(&req.topic_id) {
|
||||||
let store_bg = store.clone();
|
Ok(Some(topic)) => topic.session_id,
|
||||||
let topic_id_bg = req.topic_id.clone();
|
Ok(None) => {
|
||||||
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 {
|
||||||
@ -1262,17 +1257,12 @@ pub async fn topic_select_model(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => {
|
Err(e) => {
|
||||||
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: {}", err)),
|
error: Some(format!("failed to load topic: {}", e)),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1305,17 +1295,7 @@ 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();
|
||||||
let update_result = {
|
if let Err(e) = store.update_topic_model(&req.topic_id, provider.as_deref(), model.as_deref()) {
|
||||||
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 {
|
||||||
@ -1366,15 +1346,8 @@ 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 => {
|
None => match state.session_manager.store().get_topic(&q.topic_id) {
|
||||||
// 同步 rusqlite 查询移入 blocking 线程池:本路由在每次打开/切换
|
Ok(Some(topic)) => {
|
||||||
// 话题且缓存 miss 时被调用,不得阻塞 axum async worker
|
|
||||||
let store = state.session_manager.store().clone();
|
|
||||||
let topic_id_bg = q.topic_id.clone();
|
|
||||||
let topic_lookup =
|
|
||||||
tokio::task::spawn_blocking(move || store.get_topic(&topic_id_bg)).await;
|
|
||||||
match topic_lookup {
|
|
||||||
Ok(Ok(Some(topic))) => {
|
|
||||||
// 双保险:SQLite 有物化值但缓存 miss(理论上不会发生)时回填缓存
|
// 双保险:SQLite 有物化值但缓存 miss(理论上不会发生)时回填缓存
|
||||||
if topic.provider.is_some() || topic.model.is_some() {
|
if topic.provider.is_some() || topic.model.is_some() {
|
||||||
state.topic_model_selections.set(
|
state.topic_model_selections.set(
|
||||||
@ -1389,8 +1362,7 @@ pub async fn topic_selected_model(
|
|||||||
.unwrap_or((None, None))
|
.unwrap_or((None, None))
|
||||||
}
|
}
|
||||||
_ => (None, None),
|
_ => (None, None),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
};
|
};
|
||||||
Json(SessionSelectedModelResponse { provider, model })
|
Json(SessionSelectedModelResponse { provider, model })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,7 +192,7 @@ impl MemoryMaintenanceService {
|
|||||||
.chain(std::iter::once(None))
|
.chain(std::iter::once(None))
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
let response = match provider.chat(&request).await {
|
let response = match provider.chat(request.clone()).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).await {
|
match provider.chat(request.clone()).await {
|
||||||
Ok(success) => {
|
Ok(success) => {
|
||||||
response = Some(success);
|
response = Some(success);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -40,8 +40,6 @@ 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 {
|
||||||
@ -122,7 +120,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.clone(),
|
subagent_executor,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@ -133,7 +131,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,12 +180,6 @@ 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,
|
||||||
@ -211,82 +202,11 @@ 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 = %panic_msg,
|
error = %crate::utils::panic_payload_message(&payload),
|
||||||
"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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -418,43 +338,7 @@ impl InboundProcessor {
|
|||||||
// 阻塞获取 serial_lock
|
// 阻塞获取 serial_lock
|
||||||
// - agent 正常运行:阻塞至其完成(天然串行化)
|
// - agent 正常运行:阻塞至其完成(天然串行化)
|
||||||
// - agent 在 wait 中:wait 已释放锁,可立即获取
|
// - agent 在 wait 中:wait 已释放锁,可立即获取
|
||||||
// 每 30s 打 warn 标记等待进展(与 execution.rs 主路径一致的可观测性)
|
let _inject_guard = serial_lock.clone().lock_owned().await;
|
||||||
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 = {
|
||||||
@ -714,13 +598,6 @@ 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();
|
||||||
|
|||||||
@ -68,7 +68,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1372,7 +1372,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1697,7 +1696,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -1756,7 +1754,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -1836,7 +1833,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -1914,7 +1910,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2056,7 +2051,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2129,7 +2123,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2212,7 +2205,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2304,7 +2296,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2395,7 +2386,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2468,7 +2458,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2551,7 +2540,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -2620,7 +2608,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -256,43 +256,13 @@ 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, removed_ids) =
|
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut history);
|
||||||
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);
|
||||||
|
|||||||
@ -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::debug!(
|
tracing::info!(
|
||||||
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::debug!(
|
tracing::info!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
chat_id = %chat_id,
|
chat_id = %chat_id,
|
||||||
media_type = %media_type,
|
media_type = %media_type,
|
||||||
|
|||||||
@ -117,10 +117,11 @@ impl WaitCoordinator for SessionWaitCoordinator {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3.5. 记录等待前的用户消息数量(用于 wakeup 后提取新注入的消息)
|
// 3.5. 记录等待前的用户消息数量(用于 wakeup 后提取新注入的消息)
|
||||||
// 直接从 SQLite 读取(定向 COUNT,避免全量加载消息体),不持有任何锁
|
// 直接从 SQLite 读取,不持有任何锁
|
||||||
let user_msg_count_before = self
|
let user_msg_count_before = self
|
||||||
.store
|
.store
|
||||||
.count_user_messages_for_topic(&self.topic_id)
|
.load_messages_for_topic(&self.topic_id, None)
|
||||||
|
.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)
|
||||||
|
|||||||
@ -186,43 +186,23 @@ 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 通道创建的会话)
|
||||||
// SQLite 同步查询移入 blocking 线程池,避免连接建立时阻塞 async worker。
|
let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default();
|
||||||
let store_for_init = store.clone();
|
let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default();
|
||||||
let cli_sessions_for_init = cli_sessions.clone();
|
|
||||||
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.extend(cli_channel_sessions);
|
||||||
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
|
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_for_init.create_with_channel("websocket", Some("默认会话")) {
|
match cli_sessions.create_with_channel("websocket", Some("默认会话")) {
|
||||||
Ok(record) => Some(record),
|
Ok(record) => 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");
|
||||||
None
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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();
|
||||||
@ -434,14 +414,7 @@ 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
|
||||||
// base64 解码 + 同步写盘是 CPU/IO 密集操作(单条消息最大约 67MB),
|
let media = process_attachments_with_base64(attachments)?;
|
||||||
// 移入 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
|
||||||
@ -588,10 +561,6 @@ 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(),
|
||||||
@ -751,17 +720,11 @@ async fn handle_inbound(
|
|||||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记忆 CRUD 后自动刷新列表(SQLite 同步查询移入 blocking 线程池)
|
// 记忆 CRUD 后自动刷新列表
|
||||||
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 store_bg = store.clone();
|
&& let Ok(records) =
|
||||||
let records = tokio::task::spawn_blocking(move || {
|
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
|
||||||
store_bg.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
|
{
|
||||||
})
|
|
||||||
.await;
|
|
||||||
if let Err(e) = &records {
|
|
||||||
tracing::warn!(error = %e, "Memory list task failed");
|
|
||||||
}
|
|
||||||
if let Ok(Ok(records)) = records {
|
|
||||||
let memories: Vec<crate::protocol::MemorySummary> = records
|
let memories: Vec<crate::protocol::MemorySummary> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|m| m.namespace != "_meta")
|
.filter(|m| m.namespace != "_meta")
|
||||||
@ -776,7 +739,6 @@ async fn handle_inbound(
|
|||||||
.collect();
|
.collect();
|
||||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理加载聊天消息请求
|
// 处理加载聊天消息请求
|
||||||
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
||||||
@ -801,31 +763,6 @@ 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")
|
||||||
{
|
{
|
||||||
@ -884,10 +821,6 @@ 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,
|
||||||
@ -895,40 +828,21 @@ 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 过滤,避免混入子智能体消息
|
||||||
// SQLite 同步加载 + running 占位对账移入 blocking 线程池,避免阻塞 async worker。
|
let mut messages = store.load_messages_for_topic_full(topic_id, Some(session_id))?;
|
||||||
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))??;
|
|
||||||
|
|
||||||
tracing::info!(topic_id = %topic_id, message_count = messages.len(), has_more, "Sending topic history (paged)");
|
// 对账 running 占位:DB 中的 task tool_result 永远保持 spawn 时的 running 状态
|
||||||
|
// (实时完成信号只更新前端内存与 pending_subagents 表),若不替换,
|
||||||
|
// 刷新/切话题后前端卡片会永远显示"运行中"。与 Session::reconcile_running_placeholders
|
||||||
|
// 同语义,仅改发送副本,不落库。
|
||||||
|
reconcile_running_in_messages(&mut messages, store, topic_id);
|
||||||
|
|
||||||
// 收集已加载页内的 tool_call/tool_result id 集合,用于判断任务是否已有结果。
|
tracing::info!(topic_id = %topic_id, message_count = messages.len(), "Sending topic history");
|
||||||
// 分页后集合仅覆盖已加载窗口:窗口外的任务不补发 TaskStarted
|
|
||||||
// (其 tool_call 气泡同样不在窗口内,补发会造成前端凭空出现孤立卡片)。
|
// 收集已有 tool_result 的 tool_call_id 集合,用于判断任务是否已有结果
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -936,34 +850,12 @@ async fn send_topic_history(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将消息转换为 WsOutbound 并发送。
|
// 将消息转换为 WsOutbound 并发送
|
||||||
// 转换过程对每条媒体引用做同步文件读取 + base64 编码(CPU/IO 密集),
|
for msg in messages {
|
||||||
// 整体移入 blocking 线程池一次性产出,避免阻塞 async worker。
|
for outbound in chat_message_to_ws_outbound(&msg) {
|
||||||
// (闭包 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;
|
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 丢失的问题
|
||||||
@ -977,17 +869,8 @@ async fn send_topic_history(
|
|||||||
|
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
// 判断是否需要补发 TaskStarted:
|
// 判断是否需要补发 TaskStarted:
|
||||||
// - 任务的 tool_call 不在已加载窗口内 → 不补发(前端无对应气泡,补发会产生孤立卡片)
|
// - 如果该任务的 tool_call_id 已有对应的 tool_result,前端会显示结果,不需要补发
|
||||||
// - tool_call 在窗口内且已有 tool_result → 前端会显示结果,不补发
|
// - 否则(Running 状态或已完成但结果未进入历史),补发 TaskStarted 以便前端显示"查看实时进度"
|
||||||
// - tool_call 在窗口内但无 tool_result(Running 或结果未入历史)→ 补发以显示"查看实时进度"
|
|
||||||
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()
|
||||||
@ -1023,63 +906,6 @@ 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 状态,
|
||||||
@ -1137,12 +963,7 @@ 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>> {
|
||||||
// SQLite 同步加载移入 blocking 线程池,避免阻塞 async worker。
|
let messages = store.load_messages(session_id)?;
|
||||||
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");
|
||||||
|
|
||||||
@ -1157,29 +978,17 @@ async fn send_task_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换(含媒体文件同步读取 + base64 编码)移入 blocking 线程池一次性产出
|
for msg in messages {
|
||||||
let subagent_task_id_bg = subagent_task_id.clone();
|
let mut outbounds = chat_message_to_ws_outbound(&msg);
|
||||||
let outbound_batches: Vec<Vec<WsOutbound>> = tokio::task::spawn_blocking(move || {
|
if let Some(ref task_id) = subagent_task_id {
|
||||||
messages
|
|
||||||
.iter()
|
|
||||||
.map(|msg| {
|
|
||||||
// 任务会话消息不属于主话题历史:topic_id 传 None,
|
|
||||||
// 前端按 topic_id 过滤历史批次时不会误收。
|
|
||||||
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 {
|
for ob in &mut outbounds {
|
||||||
set_subagent_task_id(ob, task_id);
|
set_subagent_task_id(ob, task_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outbounds
|
for outbound in 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;
|
let _ = sender.send(outbound).await;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 补发子任务(孙智能体)的 TaskStarted 事件
|
// 补发子任务(孙智能体)的 TaskStarted 事件
|
||||||
// 解决重新进入子智能体视图后 navigateToTaskId 丢失的问题
|
// 解决重新进入子智能体视图后 navigateToTaskId 丢失的问题
|
||||||
@ -1267,12 +1076,8 @@ fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Opti
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 将 ChatMessage 转换为 WsOutbound 列表
|
/// 将 ChatMessage 转换为 WsOutbound 列表
|
||||||
fn chat_message_to_ws_outbound(
|
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> {
|
||||||
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 {
|
||||||
@ -1343,11 +1148,10 @@ fn chat_message_to_ws_outbound(
|
|||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
subagent_task_id: None,
|
subagent_task_id: None,
|
||||||
topic_id: topic_id_out.clone(),
|
topic_id: None,
|
||||||
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 不再重复
|
||||||
@ -1365,11 +1169,10 @@ fn chat_message_to_ws_outbound(
|
|||||||
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: topic_id_out.clone(),
|
topic_id: None,
|
||||||
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
|
||||||
@ -1381,11 +1184,10 @@ fn chat_message_to_ws_outbound(
|
|||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
subagent_task_id: None,
|
subagent_task_id: None,
|
||||||
topic_id: topic_id_out.clone(),
|
topic_id: None,
|
||||||
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,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1405,10 +1207,9 @@ fn chat_message_to_ws_outbound(
|
|||||||
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: topic_id_out.clone(),
|
topic_id: None,
|
||||||
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
|
||||||
@ -1421,9 +1222,8 @@ fn chat_message_to_ws_outbound(
|
|||||||
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: topic_id_out.clone(),
|
topic_id: None,
|
||||||
timestamp: Some(msg.timestamp / 1000),
|
timestamp: Some(msg.timestamp / 1000),
|
||||||
seq: msg.seq,
|
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1433,11 +1233,10 @@ fn chat_message_to_ws_outbound(
|
|||||||
role: msg.role.clone(),
|
role: msg.role.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
subagent_task_id: None,
|
subagent_task_id: None,
|
||||||
topic_id: topic_id_out.clone(),
|
topic_id: None,
|
||||||
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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
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,
|
||||||
@ -10,13 +8,6 @@ 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,
|
||||||
@ -83,24 +74,13 @@ 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_writer)
|
.with_writer(file_appender)
|
||||||
.with_timer(ConfiguredTimestamp { timezone })
|
.with_timer(ConfiguredTimestamp { timezone })
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
@ -109,7 +89,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_writer)
|
.with_writer(file_appender)
|
||||||
.with_timer(ConfiguredTimestamp { timezone })
|
.with_timer(ConfiguredTimestamp { timezone })
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
@ -120,7 +100,6 @@ 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);
|
||||||
@ -142,13 +121,7 @@ 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);
|
||||||
|
|||||||
@ -49,7 +49,6 @@ 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)
|
||||||
}
|
}
|
||||||
@ -62,13 +61,6 @@ 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 宏
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -164,20 +156,3 @@ 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:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -329,99 +329,6 @@ 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:
|
||||||
|
|||||||
@ -176,9 +176,6 @@ 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 {
|
||||||
@ -198,9 +195,6 @@ 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 {
|
||||||
@ -217,9 +211,6 @@ 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 {
|
||||||
@ -235,18 +226,6 @@ 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 {
|
||||||
|
|||||||
@ -28,7 +28,6 @@ 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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +49,6 @@ 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 {
|
||||||
@ -64,7 +62,6 @@ 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,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,7 +83,6 @@ 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
|
||||||
@ -101,7 +97,6 @@ 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(),
|
||||||
@ -135,7 +130,6 @@ 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 {
|
||||||
@ -156,7 +150,6 @@ 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
|
||||||
@ -174,7 +167,6 @@ 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
|
||||||
@ -189,7 +181,6 @@ 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(),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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};
|
||||||
@ -143,9 +144,10 @@ 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 {
|
||||||
// 复用按超时配置的共享 client(TLS 上下文 + 连接池),
|
let client = Client::builder()
|
||||||
// 避免每条消息重建 Provider 时重复构造。
|
.timeout(Duration::from_secs(llm_timeout_secs))
|
||||||
let client = crate::providers::shared_llm_http_client(llm_timeout_secs);
|
.build()
|
||||||
|
.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();
|
||||||
@ -256,7 +258,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);
|
||||||
@ -269,7 +271,7 @@ impl LLMProvider for AnthropicProvider {
|
|||||||
"Anthropic: sending chat completion request"
|
"Anthropic: sending chat completion request"
|
||||||
);
|
);
|
||||||
|
|
||||||
let tools = request.tools.as_ref().map(|tools| {
|
let tools = request.tools.map(|tools| {
|
||||||
tools
|
tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t: &Tool| AnthropicTool {
|
.map(|t: &Tool| AnthropicTool {
|
||||||
|
|||||||
@ -12,41 +12,6 @@ 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> {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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};
|
||||||
@ -277,9 +278,10 @@ 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 {
|
||||||
// 复用按超时配置的共享 client(TLS 上下文 + 连接池),
|
let client = Client::builder()
|
||||||
// 避免每条消息重建 Provider 时重复构造。
|
.timeout(Duration::from_secs(llm_timeout_secs))
|
||||||
let client = crate::providers::shared_llm_http_client(llm_timeout_secs);
|
.build()
|
||||||
|
.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
|
||||||
@ -1101,12 +1103,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!(
|
||||||
@ -1125,7 +1127,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)]
|
||||||
@ -1264,11 +1266,14 @@ 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.chat_streaming_internal(request, Some(&callback)).await {
|
match self
|
||||||
|
.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!(
|
||||||
|
|||||||
@ -117,10 +117,7 @@ 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>,
|
||||||
/// 工具定义在单次 process() 内跨多轮 LLM 迭代只读复用,
|
pub tools: Option<Vec<Tool>>,
|
||||||
/// 用 Arc 共享避免每轮深拷贝(含完整 JSON schema,可达数十 KB)。
|
|
||||||
/// serde 对 Arc 透明序列化,线上请求格式不变。
|
|
||||||
pub tools: Option<Arc<Vec<Tool>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -161,7 +158,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。
|
||||||
@ -169,7 +166,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
|
||||||
|
|||||||
@ -107,7 +107,7 @@ impl Scheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = self.sync_config_jobs().await {
|
if let Err(error) = self.sync_config_jobs() {
|
||||||
tracing::error!(error = %error, "Failed to sync scheduler config jobs");
|
tracing::error!(error = %error, "Failed to sync scheduler config jobs");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,41 +137,25 @@ impl Scheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步配置中的 job 定义到 DB。整体移入 spawn_blocking:
|
fn sync_config_jobs(&self) -> anyhow::Result<()> {
|
||||||
/// 方法内是同步 SQLite 调用(含锁等待),直接在 async worker 上执行
|
|
||||||
/// 会阻塞同 worker 的其他任务。
|
|
||||||
async fn sync_config_jobs(&self) -> anyhow::Result<()> {
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let config = self.config.clone();
|
for job in self.config.effective_jobs(&crate::config::TimeConfig {
|
||||||
let jobs = self.jobs.clone();
|
timezone: self.timezone.name().to_string(),
|
||||||
let timezone = self.timezone;
|
|
||||||
let misfire_policy = config.misfire_policy;
|
|
||||||
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
|
||||||
for job in config.effective_jobs(&crate::config::TimeConfig {
|
|
||||||
timezone: timezone.name().to_string(),
|
|
||||||
}) {
|
}) {
|
||||||
let runtime = RuntimeJob::from_config(&job, now, misfire_policy, timezone)?;
|
let runtime =
|
||||||
|
RuntimeJob::from_config(&job, now, self.config.misfire_policy, self.timezone)?;
|
||||||
let mut upsert = runtime.to_upsert();
|
let mut upsert = runtime.to_upsert();
|
||||||
if let Some(existing) = jobs.get_scheduler_job(&runtime.id)? {
|
if let Some(existing) = self.jobs.get_scheduler_job(&runtime.id)? {
|
||||||
preserve_persisted_runtime(&mut upsert, &existing);
|
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 jobs_repo = self.jobs.clone();
|
let running_jobs = self.jobs.list_running_scheduler_jobs()?;
|
||||||
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 {
|
||||||
@ -208,19 +192,17 @@ impl Scheduler {
|
|||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::persist_job_runtime_off_worker(
|
self.jobs.update_scheduler_job_runtime(
|
||||||
self.jobs.clone(),
|
&record.id,
|
||||||
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!(
|
||||||
@ -244,10 +226,7 @@ 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_repo = self.jobs.clone();
|
let jobs = self.jobs.list_scheduler_jobs(true)?;
|
||||||
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) =
|
||||||
@ -257,19 +236,17 @@ 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::persist_job_runtime_off_worker(
|
self.jobs.update_scheduler_job_runtime(
|
||||||
self.jobs.clone(),
|
&job.id,
|
||||||
job.id.clone(),
|
|
||||||
job.state.clone(),
|
job.state.clone(),
|
||||||
job.last_status.clone(),
|
job.last_status.clone(),
|
||||||
job.last_error.clone(),
|
job.last_error.as_deref(),
|
||||||
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) {
|
||||||
@ -288,19 +265,17 @@ impl Scheduler {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::persist_job_runtime_off_worker(
|
self.jobs.update_scheduler_job_runtime(
|
||||||
self.jobs.clone(),
|
&job.id,
|
||||||
job.id.clone(),
|
|
||||||
SchedulerJobState::Running,
|
SchedulerJobState::Running,
|
||||||
job.last_status.clone(),
|
job.last_status.clone(),
|
||||||
job.last_error.clone(),
|
job.last_error.as_deref(),
|
||||||
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 的触发。
|
||||||
@ -339,20 +314,17 @@ impl Scheduler {
|
|||||||
timezone,
|
timezone,
|
||||||
) {
|
) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(error) = Scheduler::persist_job_runtime_off_worker(
|
if let Err(error) = jobs_repo.update_scheduler_job_runtime(
|
||||||
jobs_repo.clone(),
|
&job.id,
|
||||||
job.id.clone(),
|
|
||||||
job.state.clone(),
|
job.state.clone(),
|
||||||
status,
|
status,
|
||||||
job.last_error.clone(),
|
job.last_error.as_deref(),
|
||||||
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,
|
||||||
@ -367,20 +339,17 @@ 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) = Scheduler::persist_job_runtime_off_worker(
|
if let Err(update_error) = jobs_repo.update_scheduler_job_runtime(
|
||||||
jobs_repo,
|
&job.id,
|
||||||
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,
|
||||||
@ -410,39 +379,6 @@ 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>,
|
||||||
@ -1665,8 +1601,8 @@ mod tests {
|
|||||||
assert_eq!(saved.state, SchedulerJobState::Scheduled);
|
assert_eq!(saved.state, SchedulerJobState::Scheduled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn sync_config_jobs_persists_builtin_memory_maintenance_job() {
|
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();
|
||||||
@ -1679,7 +1615,7 @@ mod tests {
|
|||||||
maintenance_service,
|
maintenance_service,
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduler.sync_config_jobs().await.unwrap();
|
scheduler.sync_config_jobs().unwrap();
|
||||||
|
|
||||||
let saved = store
|
let saved = store
|
||||||
.get_scheduler_job(BUILTIN_MEMORY_MAINTENANCE_JOB_ID)
|
.get_scheduler_job(BUILTIN_MEMORY_MAINTENANCE_JOB_ID)
|
||||||
@ -1711,8 +1647,8 @@ mod tests {
|
|||||||
assert!(saved.next_fire_at.is_some());
|
assert!(saved.next_fire_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn sync_config_jobs_preserves_persisted_next_fire_at_for_matching_jobs() {
|
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 {
|
||||||
@ -1799,7 +1735,7 @@ mod tests {
|
|||||||
maintenance_service,
|
maintenance_service,
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduler.sync_config_jobs().await.unwrap();
|
scheduler.sync_config_jobs().unwrap();
|
||||||
|
|
||||||
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
||||||
|
|
||||||
|
|||||||
@ -110,135 +110,6 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, Transaction, TransactionBehavior, params};
|
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
|
||||||
|
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
|
|
||||||
@ -70,7 +70,6 @@ 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 (
|
||||||
@ -240,21 +239,11 @@ 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 NULL(topics/skill_events/messages 引用
|
|
||||||
// sessions,messages 引用 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)?;
|
||||||
@ -394,37 +383,6 @@ 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,
|
||||||
@ -636,120 +594,6 @@ 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()?;
|
||||||
@ -769,21 +613,6 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -866,10 +695,6 @@ 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()?;
|
||||||
@ -895,15 +720,6 @@ 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
|
||||||
@ -911,7 +727,15 @@ impl SessionStore {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
insert_stmt.execute(params![
|
tx.execute(
|
||||||
|
"
|
||||||
|
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)
|
||||||
|
",
|
||||||
|
params![
|
||||||
message.id,
|
message.id,
|
||||||
session_id,
|
session_id,
|
||||||
topic_id,
|
topic_id,
|
||||||
@ -928,16 +752,13 @@ impl SessionStore {
|
|||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
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().map(|u| u.total_tokens as i64),
|
||||||
message
|
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
.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.usage.as_ref().map(|u| u.cached_tokens as i64),
|
||||||
message.timestamp,
|
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
|
||||||
@ -966,67 +787,12 @@ 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_tokens(MessageUsage
|
|
||||||
// 由 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,
|
||||||
@ -1084,16 +850,14 @@ 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(&mut insert_stmt, session_id, next_seq, message)?;
|
insert_message_with_seq(&tx, 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(
|
||||||
@ -1115,11 +879,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1146,15 +905,13 @@ 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(&mut insert_stmt, session_id, seq, message)?;
|
insert_message_with_seq(&tx, session_id, seq, message)?;
|
||||||
}
|
}
|
||||||
drop(insert_stmt);
|
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"
|
"
|
||||||
@ -1175,11 +932,6 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1220,12 +972,10 @@ 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(&mut insert_stmt, session_id, topic_id, seq, message)?;
|
insert_message_with_topic_seq(&tx, 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(
|
||||||
@ -1250,8 +1000,6 @@ impl SessionStore {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
// 该 topic 消息被整体替换——重算统计列自愈
|
|
||||||
self.recompute_topic_usage_stats(&[topic_id.to_string()])?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1322,12 +1070,10 @@ 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(&mut insert_stmt, session_id, topic_id, seq, message)?;
|
insert_message_with_topic_seq(&tx, 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(
|
||||||
@ -1353,8 +1099,6 @@ impl SessionStore {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
// 摘要消息可能携带 usage,且旧摘要被删除——重算该 topic 统计列自愈
|
|
||||||
self.recompute_topic_usage_stats(&[topic_id.to_string()])?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2043,68 +1787,6 @@ 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`,避免为取单条消息全量加载并反序列化整个话题历史
|
||||||
@ -2147,21 +1829,6 @@ 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)
|
||||||
@ -2181,19 +1848,16 @@ impl SessionStore {
|
|||||||
.map_err(StorageError::from)
|
.map_err(StorageError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量读取 topic 的 token 用量统计(cost 累计 + context 瞬时)。
|
/// 批量查询多个 topic 的 token 消耗统计(cost 累计 + context 瞬时)。
|
||||||
///
|
///
|
||||||
/// 直接读取 topics 表的增量维护列(stat_*),不再对 messages 全量聚合——
|
/// 按 `topic_id` 聚合而非 `session_id`:一个 session 可包含多个 topic,
|
||||||
/// 话题列表每次刷新都调用本函数,大库下聚合是显著瓶颈。
|
/// 若按 session_id 聚合会导致同 session 下的所有 topic 显示相同的总和。
|
||||||
/// 增量列由 append 路径累加、变更路径(删除/替换/清空)重算自愈、
|
|
||||||
/// 老数据由 backfill_topic_usage_stats 一次性回填。
|
|
||||||
///
|
///
|
||||||
/// 子代理隔离在写入/重算侧保证:子代理消息持久化时 session_id='sub:...',
|
/// 子代理隔离:子代理消息持久化时 session_id='sub:...',topic_id=父 topic_id
|
||||||
/// topic_id=父 topic_id,累加与重算均以 `session_id NOT LIKE 'sub:%'`
|
/// (见 task::runtime PersistingEmittedMessageHandler 构造),因此不能仅靠
|
||||||
/// 排除,子代理 token 不计入父 topic。
|
/// topic_id 隔离。此处用 `session_id NOT LIKE 'sub:%'` 显式排除子代理消息,
|
||||||
///
|
/// 与项目约定一致(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],
|
||||||
@ -2207,132 +1871,81 @@ impl SessionStore {
|
|||||||
.map(|i| format!("?{}", i + 1))
|
.map(|i| format!("?{}", i + 1))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let sql = format!(
|
// topic_id IN (...) 自动排除 NULL topic_id 的旧消息;
|
||||||
"SELECT id, stat_prompt_tokens, stat_completion_tokens, stat_total_tokens, \
|
// session_id NOT LIKE 'sub:%' 排除子代理消息(其 topic_id=父 topic_id)。
|
||||||
stat_cached_tokens, stat_last_prompt_tokens, stat_context_window_tokens \
|
// SUM 列清单与行映射见 USAGE_SUM_COLUMNS / read_usage_sum_row(共享于子代理查询)。
|
||||||
FROM topics \
|
let sum_sql = format!(
|
||||||
WHERE id IN ({placeholders}) AND stat_has_usage = 1"
|
"SELECT topic_id, {USAGE_SUM_COLUMNS} \
|
||||||
|
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 rows = stmt.query_map(params.as_slice(), |row| {
|
let sum_rows = stmt.query_map(params.as_slice(), |row| {
|
||||||
Ok((
|
Ok((row.get::<_, String>(0)?, read_usage_sum_row(row, 1)?))
|
||||||
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 rows {
|
for row in sum_rows {
|
||||||
let (tid, s) = row?;
|
let (tid, s) = row?;
|
||||||
stats.insert(tid, s);
|
stats.insert(tid, s);
|
||||||
}
|
}
|
||||||
Ok(stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 messages 表重算指定 topic 的 token 统计增量列(自愈漂移)。
|
// 查找每个 topic 中最新的**有 usage 数据的** assistant 消息,
|
||||||
///
|
// 读取其 prompt_tokens 和 context_window_tokens。
|
||||||
/// 用于变更消息行的路径:sanitize 删除、话题历史替换、会话清空等。
|
// 过滤 prompt_tokens IS NOT NULL 确保跳过 error/cancel 消息(usage 为 NULL);
|
||||||
/// 聚合口径与 backfill_topic_usage_stats 完全一致(排除 sub:% 子代理消息)。
|
// session_id NOT LIKE 'sub:%' 排除子代理消息,避免取到子代理的 context_window。
|
||||||
pub fn recompute_topic_usage_stats(&self, topic_ids: &[String]) -> Result<(), StorageError> {
|
//
|
||||||
if topic_ids.is_empty() {
|
// 注意:seq 是 session 级递增(见 append_message_with_topic),主 session 与
|
||||||
return Ok(());
|
// 子代理 session 各自独立计数,可能存在相同 seq。外层 WHERE 必须再次过滤
|
||||||
}
|
// session_id NOT LIKE 'sub:%',否则 JOIN 会同时匹配主消息和子代理消息,
|
||||||
let conn = self.pool.get()?;
|
// 导致重复行并使 stats.entry(tid) 被覆盖,结果不确定。
|
||||||
let placeholders = (0..topic_ids.len())
|
let last_sql = format!(
|
||||||
.map(|i| format!("?{}", i + 1))
|
"SELECT m.topic_id, m.prompt_tokens, m.context_window_tokens \
|
||||||
.collect::<Vec<_>>()
|
FROM messages m \
|
||||||
.join(", ");
|
INNER JOIN ( \
|
||||||
let filter = format!("id IN ({placeholders})");
|
SELECT topic_id, MAX(seq) AS max_seq \
|
||||||
let params: Vec<&dyn rusqlite::ToSql> = topic_ids
|
FROM messages \
|
||||||
|
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| {
|
||||||
conn.execute(
|
Ok((
|
||||||
&format!(
|
row.get::<_, String>(0)?,
|
||||||
"UPDATE topics SET
|
row.get::<_, Option<i64>>(1)?,
|
||||||
stat_prompt_tokens = COALESCE((
|
row.get::<_, Option<i64>>(2)?,
|
||||||
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),
|
for row in last_rows {
|
||||||
stat_completion_tokens = COALESCE((
|
let (tid, last_prompt, last_ctx_window) = row?;
|
||||||
SELECT SUM(m.completion_tokens) FROM messages m
|
let entry = stats.entry(tid).or_insert(SessionTokenStats {
|
||||||
WHERE m.topic_id = topics.id AND m.role = 'assistant'
|
prompt_tokens: 0,
|
||||||
AND m.session_id NOT LIKE 'sub:%'), 0),
|
completion_tokens: 0,
|
||||||
stat_total_tokens = COALESCE((
|
total_tokens: 0,
|
||||||
SELECT SUM(m.total_tokens) FROM messages m
|
cached_tokens: 0,
|
||||||
WHERE m.topic_id = topics.id AND m.role = 'assistant'
|
last_prompt_tokens: None,
|
||||||
AND m.session_id NOT LIKE 'sub:%'), 0),
|
context_window_tokens: None,
|
||||||
stat_cached_tokens = COALESCE((
|
});
|
||||||
SELECT SUM(m.cached_tokens) FROM messages m
|
entry.last_prompt_tokens = last_prompt.map(|v| v as u32);
|
||||||
WHERE m.topic_id = topics.id AND m.role = 'assistant'
|
entry.context_window_tokens = last_ctx_window.map(|v| v as u32);
|
||||||
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 统计增量列(自愈漂移)。
|
Ok(stats)
|
||||||
///
|
|
||||||
/// 用于整会话级变更路径(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 瞬时)。
|
||||||
@ -2418,12 +2031,11 @@ 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(
|
for item in items {
|
||||||
|
tx.execute(
|
||||||
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
)?;
|
params![
|
||||||
for item in items {
|
|
||||||
insert_stmt.execute(params![
|
|
||||||
item.id,
|
item.id,
|
||||||
scope_key,
|
scope_key,
|
||||||
item.session_id,
|
item.session_id,
|
||||||
@ -2434,9 +2046,9 @@ impl SessionStore {
|
|||||||
item.created_at,
|
item.created_at,
|
||||||
now,
|
now,
|
||||||
item.created_by_message_id,
|
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(
|
||||||
@ -2655,26 +2267,8 @@ 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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量插入消息的预编译 SQL(17 列,无 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)
|
|
||||||
";
|
|
||||||
|
|
||||||
/// 批量插入消息的预编译 SQL(19 列,含 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(
|
||||||
stmt: &mut rusqlite::Statement<'_>,
|
conn: &rusqlite::Transaction<'_>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
seq: i64,
|
seq: i64,
|
||||||
message: &ChatMessage,
|
message: &ChatMessage,
|
||||||
@ -2685,7 +2279,14 @@ fn insert_message_with_seq(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
stmt.execute(params![
|
conn.execute(
|
||||||
|
"
|
||||||
|
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, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||||
|
",
|
||||||
|
params![
|
||||||
message.id,
|
message.id,
|
||||||
session_id,
|
session_id,
|
||||||
seq,
|
seq,
|
||||||
@ -2701,13 +2302,10 @@ fn insert_message_with_seq(
|
|||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
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().map(|u| u.total_tokens as i64),
|
||||||
message
|
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
.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,
|
message.timestamp,
|
||||||
])?;
|
],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2717,7 +2315,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(
|
||||||
stmt: &mut rusqlite::Statement<'_>,
|
conn: &rusqlite::Transaction<'_>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
topic_id: &str,
|
topic_id: &str,
|
||||||
seq: i64,
|
seq: i64,
|
||||||
@ -2729,7 +2327,14 @@ fn insert_message_with_topic_seq(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(serde_json::to_string)
|
.map(serde_json::to_string)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
stmt.execute(params![
|
conn.execute(
|
||||||
|
"
|
||||||
|
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, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||||
|
",
|
||||||
|
params![
|
||||||
message.id,
|
message.id,
|
||||||
session_id,
|
session_id,
|
||||||
topic_id,
|
topic_id,
|
||||||
@ -2746,13 +2351,10 @@ fn insert_message_with_topic_seq(
|
|||||||
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
message.usage.as_ref().map(|u| u.prompt_tokens as i64),
|
||||||
message.usage.as_ref().map(|u| u.completion_tokens as i64),
|
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().map(|u| u.total_tokens as i64),
|
||||||
message
|
message.usage.as_ref().and_then(|u| u.context_window_tokens.map(|v| v as i64)),
|
||||||
.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,
|
message.timestamp,
|
||||||
])?;
|
],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2772,7 +2374,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2831,7 +2432,6 @@ 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)?,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@ -2894,7 +2494,6 @@ 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)?,
|
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@ -58,13 +58,6 @@ 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,
|
||||||
@ -266,14 +259,6 @@ 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,
|
||||||
|
|||||||
@ -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, seq";
|
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";
|
||||||
|
|
||||||
/// 从指定列索引读取 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,7 +209,6 @@ 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)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -718,64 +718,6 @@ 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 index:topic_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 index(WHERE 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();
|
||||||
@ -882,475 +824,3 @@ 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -465,11 +465,6 @@ 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 而永久存活,每次取消泄漏一组资源。
|
|
||||||
// 进程被杀后管道到达 EOF,read_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))?;
|
||||||
|
|||||||
@ -90,15 +90,9 @@ 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;
|
||||||
// 同步 SQLite 查询移入 spawn_blocking,避免阻塞 tokio worker
|
let memories = self
|
||||||
let memories_repo = self.memories.clone();
|
.memories
|
||||||
let scope = scope_key.clone();
|
.list_memories("user", &scope_key, namespace, limit)?;
|
||||||
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<_>>()
|
||||||
@ -144,22 +138,9 @@ 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_repo = self.memories.clone();
|
let memories = self
|
||||||
let scope = scope_key.clone();
|
.memories
|
||||||
let ns = namespace.map(str::to_string);
|
.search_memories_any("user", &scope_key, &queries, namespace, limit)?;
|
||||||
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(),
|
||||||
@ -176,17 +157,10 @@ impl Tool for MemorySearchTool {
|
|||||||
None => return Ok(error_result("Missing required parameter: key")),
|
None => return Ok(error_result("Missing required parameter: key")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let memories_repo = self.memories.clone();
|
match self
|
||||||
let scope = scope_key.clone();
|
.memories
|
||||||
let ns = namespace.to_string();
|
.get_memory("user", &scope_key, namespace, key)?
|
||||||
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!(
|
||||||
|
|||||||
@ -134,7 +134,6 @@ 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,
|
||||||
},
|
},
|
||||||
@ -169,7 +168,6 @@ 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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -51,37 +51,6 @@ impl Drop for CancelRegistryGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 超时保护地发送 SubagentResult。
|
|
||||||
///
|
|
||||||
/// sub_done_q 容量有限(32)且仅由 wait 协调器消费。若主代理从不调用 wait,
|
|
||||||
/// 无超时的 `send().await` 会永久阻塞——而本函数运行在持有全局并发许可
|
|
||||||
/// (Semaphore permit)的 spawn 任务末尾,阻塞会永久占住 permit,8 个 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,
|
||||||
@ -179,19 +148,6 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 静态系统提示词提供者(用于子代理)
|
/// 静态系统提示词提供者(用于子代理)
|
||||||
@ -1070,18 +1026,7 @@ 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. 注册 cancel_registry(必须先于 DB 写入 running 记录)。
|
// 8a. INSERT pending_subagents 记录
|
||||||
// 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(),
|
||||||
@ -1100,7 +1045,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8c. tokio::spawn 后台执行子代理
|
// 8b. 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();
|
||||||
@ -1112,10 +1057,16 @@ 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 guard:spawn 任务退出时(正常/early return/panic)确定性清理 registry
|
// RAII guard:spawn 任务退出时(正常/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());
|
||||||
|
|
||||||
@ -1149,7 +1100,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
let _ = send_sub_done_timeout(&sub_done_sender, result).await;
|
let _ = sub_done_sender.send(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;
|
||||||
@ -1214,7 +1165,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
output,
|
output,
|
||||||
pending_task_ids,
|
pending_task_ids,
|
||||||
};
|
};
|
||||||
send_sub_done_timeout(&sub_done_sender, result).await;
|
if let Err(e) = sub_done_sender.send(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 {
|
||||||
@ -1444,12 +1401,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
"Cancelling pending subagents for topic"
|
"Cancelling pending subagents for topic"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 触发每个子代理的 CancellationToken。
|
// 触发每个子代理的 CancellationToken
|
||||||
// 锁内只做内存操作(cancel + 收集),SQLite 调用移到锁外——
|
|
||||||
// registry 是 parking_lot 同步锁,持锁跨 DB IO 会阻塞所有并发的
|
|
||||||
// 注册/Guard::drop,SQLite 卡顿时放大 /stop 延迟。
|
|
||||||
let mut missing_from_registry: Vec<String> = Vec::new();
|
|
||||||
{
|
|
||||||
let registry = self.cancel_registry.lock();
|
let registry = self.cancel_registry.lock();
|
||||||
for record in &running {
|
for record in &running {
|
||||||
if let Some(token) = registry.get(&record.task_id) {
|
if let Some(token) = registry.get(&record.task_id) {
|
||||||
@ -1459,106 +1411,40 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
"Cancelled subagent token"
|
"Cancelled subagent token"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
missing_from_registry.push(record.task_id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for task_id in &missing_from_registry {
|
|
||||||
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
||||||
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
||||||
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
||||||
match self
|
match self.store.try_update_pending_subagent_status(
|
||||||
.store
|
&record.task_id,
|
||||||
.try_update_pending_subagent_status(task_id, "running", "cancelled")
|
"running",
|
||||||
{
|
"cancelled",
|
||||||
|
) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
task_id = %task_id,
|
task_id = %record.task_id,
|
||||||
"Marked subagent as cancelled in DB (token not in registry)"
|
"Marked subagent as cancelled in DB (token not in registry)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
task_id = %task_id,
|
task_id = %record.task_id,
|
||||||
"Subagent status already updated by another path, skip cancel"
|
"Subagent status already updated by another path, skip cancel"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
task_id = %task_id,
|
task_id = %record.task_id,
|
||||||
"Failed to mark subagent as cancelled in DB"
|
"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_id,SQLite 条件 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 子代理定义目录
|
/// 子代理定义目录
|
||||||
|
|||||||
@ -1,38 +1,24 @@
|
|||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,27 +61,24 @@ impl WebFetchTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 下载字节上限:字符上限 × 4(UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
|
|
||||||
/// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
|
|
||||||
fn download_byte_limit(&self) -> usize {
|
|
||||||
if self.max_response_size == 0 {
|
|
||||||
HARD_DOWNLOAD_CAP_BYTES
|
|
||||||
} else {
|
|
||||||
self.max_response_size
|
|
||||||
.saturating_mul(4)
|
|
||||||
.min(HARD_DOWNLOAD_CAP_BYTES)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_content(&self, url: &str) -> Result<String, String> {
|
async fn fetch_content(&self, url: &str) -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(self.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()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
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 = self
|
let response = client
|
||||||
.client
|
|
||||||
.get(url)
|
.get(url)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.send()
|
.send()
|
||||||
@ -110,13 +93,19 @@ impl WebFetchTool {
|
|||||||
|
|
||||||
// Handle HTML content
|
// Handle HTML content
|
||||||
if content_type.contains("text/html") {
|
if content_type.contains("text/html") {
|
||||||
let html = read_body_limited(response, self.download_byte_limit()).await?;
|
let html = response
|
||||||
|
.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 = read_body_limited(response, self.download_byte_limit()).await?;
|
let text = response
|
||||||
|
.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));
|
||||||
@ -125,7 +114,10 @@ impl WebFetchTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For other content types, return raw text
|
// For other content types, return raw text
|
||||||
read_body_limited(response, self.download_byte_limit()).await
|
response
|
||||||
|
.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 {
|
||||||
@ -183,29 +175,6 @@ 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);
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -45,7 +45,6 @@ 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,
|
||||||
})
|
})
|
||||||
@ -66,7 +65,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());
|
||||||
@ -92,7 +91,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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -129,7 +129,6 @@ 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();
|
||||||
@ -165,7 +164,6 @@ 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();
|
||||||
|
|||||||
@ -47,7 +47,6 @@ 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,
|
||||||
})
|
})
|
||||||
@ -84,10 +83,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(std::sync::Arc::new(vec![make_weather_tool()])),
|
tools: Some(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!(
|
||||||
@ -113,10 +112,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(std::sync::Arc::new(vec![make_weather_tool()])),
|
tools: Some(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");
|
||||||
|
|
||||||
@ -128,10 +127,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(std::sync::Arc::new(vec![make_weather_tool()])),
|
tools: Some(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());
|
||||||
@ -151,7 +150,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());
|
||||||
|
|||||||
@ -65,10 +65,6 @@ function App() {
|
|||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
// 历史分页
|
|
||||||
hasMoreOlder,
|
|
||||||
loadingOlder,
|
|
||||||
loadOlderMessages,
|
|
||||||
// 子智能体视图
|
// 子智能体视图
|
||||||
subAgentView,
|
subAgentView,
|
||||||
subAgentStack,
|
subAgentStack,
|
||||||
@ -126,11 +122,6 @@ 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,
|
||||||
@ -328,9 +319,9 @@ function App() {
|
|||||||
if (status !== 'connected') return;
|
if (status !== 'connected') return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (subAgentTaskId) {
|
if (subAgentView) {
|
||||||
// 子代理视图:发 load_task_messages 刷新子代理 token_stats
|
// 子代理视图:发 load_task_messages 刷新子代理 token_stats
|
||||||
const cmd = { type: 'load_task_messages' as const, task_id: subAgentTaskId };
|
const cmd = { type: 'load_task_messages' as const, task_id: subAgentView.taskId };
|
||||||
handleCommand(cmd);
|
handleCommand(cmd);
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||||
} else {
|
} else {
|
||||||
@ -343,7 +334,7 @@ function App() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [topicRefreshTrigger, status, subAgentTaskId, handleCommand, sendMessage, requestTopicList]);
|
}, [topicRefreshTrigger, status, subAgentView, handleCommand, sendMessage, requestTopicList]);
|
||||||
|
|
||||||
// 当前选中 topic(用于右侧 Sidebar token 统计面板)
|
// 当前选中 topic(用于右侧 Sidebar token 统计面板)
|
||||||
const currentTopic = useMemo(
|
const currentTopic = useMemo(
|
||||||
@ -545,19 +536,19 @@ function App() {
|
|||||||
const prevTodoTriggerRef = useRef<string>('');
|
const prevTodoTriggerRef = useRef<string>('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'connected') return;
|
if (status !== 'connected') return;
|
||||||
const key = `${selectedTopic ?? ''}|${subAgentTaskId ?? ''}`;
|
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`;
|
||||||
if (key === prevTodoTriggerRef.current) return;
|
if (key === prevTodoTriggerRef.current) return;
|
||||||
prevTodoTriggerRef.current = key;
|
prevTodoTriggerRef.current = key;
|
||||||
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
|
setTodos([]); // 先清空,防止切换时短暂显示旧 scope 的 todos
|
||||||
const todoCmd = subAgentTaskId
|
const todoCmd = subAgentView?.taskId
|
||||||
? requestSubAgentTodoList(subAgentTaskId)
|
? requestSubAgentTodoList(subAgentView.taskId)
|
||||||
: requestTodoList();
|
: requestTodoList();
|
||||||
handleCommand(todoCmd);
|
handleCommand(todoCmd);
|
||||||
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
|
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) });
|
||||||
}, [
|
}, [
|
||||||
status,
|
status,
|
||||||
selectedTopic,
|
selectedTopic,
|
||||||
subAgentTaskId,
|
subAgentView,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
requestTodoList,
|
requestTodoList,
|
||||||
@ -587,8 +578,8 @@ function App() {
|
|||||||
|
|
||||||
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
|
// 根据当前视图(主会话/子代理)返回正确的 todo 请求命令
|
||||||
const refreshTodoList = useCallback((): Command => {
|
const refreshTodoList = useCallback((): Command => {
|
||||||
return subAgentTaskId ? requestSubAgentTodoList(subAgentTaskId) : requestTodoList();
|
return subAgentView?.taskId ? requestSubAgentTodoList(subAgentView.taskId) : requestTodoList();
|
||||||
}, [subAgentTaskId, requestTodoList, requestSubAgentTodoList]);
|
}, [subAgentView, requestTodoList, requestSubAgentTodoList]);
|
||||||
|
|
||||||
// 点击待办项后滚动到对应消息
|
// 点击待办项后滚动到对应消息
|
||||||
const handleTodoClick = useCallback(
|
const handleTodoClick = useCallback(
|
||||||
@ -649,11 +640,6 @@ 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>();
|
||||||
@ -692,28 +678,6 @@ 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_tool:result 到达后才显示保留;calling/pending 有 callContent 也保留
|
// 过滤无实质内容的 merged_tool:result 到达后才显示保留;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;
|
||||||
@ -722,14 +686,12 @@ 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 (subAgentTaskId) return `subagent:${subAgentTaskId}`;
|
if (subAgentView) return `subagent:${subAgentView.taskId}`;
|
||||||
return `main:${selectedTopic ?? ''}`;
|
return `main:${selectedTopic ?? ''}`;
|
||||||
}, [schedulerView, subAgentTaskId, selectedTopic]);
|
}, [schedulerView, subAgentView, 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)]">
|
||||||
@ -1064,9 +1026,6 @@ 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>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, memo } from 'react';
|
import { useState, useCallback } 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,17 +27,9 @@ interface ChatContainerProps {
|
|||||||
settingsClosedTick?: number;
|
settingsClosedTick?: number;
|
||||||
/** 当前话题 ID,用于切换话题时清空输入框草稿 */
|
/** 当前话题 ID,用于切换话题时清空输入框草稿 */
|
||||||
topicId?: string | null;
|
topicId?: string | null;
|
||||||
/** 历史分页:是否还有更早的消息可加载 */
|
|
||||||
hasMoreOlder?: boolean;
|
|
||||||
/** 历史分页:是否正在加载更早一页 */
|
|
||||||
loadingOlder?: boolean;
|
|
||||||
/** 触顶时请求加载更早的历史消息 */
|
|
||||||
onLoadOlder?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 除 messages 外全部稳定(useCallback/原始值),
|
export function ChatContainer({
|
||||||
// App 因非消息类 state(侧栏折叠、主题等)重渲染时跳过整个聊天子树。
|
|
||||||
export const ChatContainer = memo(function ChatContainer({
|
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
@ -52,9 +44,6 @@ export const ChatContainer = memo(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;
|
||||||
@ -136,9 +125,6 @@ export const ChatContainer = memo(function ChatContainer({
|
|||||||
viewKey={viewKey}
|
viewKey={viewKey}
|
||||||
highlightedMessageId={highlightedMessageId}
|
highlightedMessageId={highlightedMessageId}
|
||||||
effectiveModel={effectiveModel}
|
effectiveModel={effectiveModel}
|
||||||
hasMoreOlder={hasMoreOlder}
|
|
||||||
loadingOlder={loadingOlder}
|
|
||||||
onLoadOlder={onLoadOlder}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -150,4 +136,4 @@ export const ChatContainer = memo(function ChatContainer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
import { useState, useEffect, useRef, useCallback } 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,8 +24,7 @@ interface ExpertSelectorProps {
|
|||||||
settingsClosedTick?: number;
|
settingsClosedTick?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 全部稳定(useCallback/原始值),流式期间跳过重渲染
|
export function ExpertSelector({
|
||||||
export const ExpertSelector = memo(function ExpertSelector({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
onManageExperts,
|
onManageExperts,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@ -279,4 +278,4 @@ export const ExpertSelector = memo(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>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, memo } from 'react';
|
import { useState, useEffect, memo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -21,96 +20,10 @@ 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,
|
||||||
@ -320,14 +233,9 @@ function ImageLightbox({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 必须 portal 到 body:本组件渲染在虚拟化消息行内,行元素带
|
return (
|
||||||
// transform(translateY),transformed 祖先会成为 fixed 后代的包含块,
|
|
||||||
// 导致遮罩只覆盖该行区域、工具栏定位到行盒而非视口、其他行内容叠在
|
|
||||||
// 遮罩之上。portal 后 fixed/z-index 相对视口全局生效。
|
|
||||||
// z-[10000]:高于下拉选择器(z-[9999])等全局浮层。
|
|
||||||
return createPortal(
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
@ -355,8 +263,7 @@ function ImageLightbox({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
alt="图片预览"
|
alt="图片预览"
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -776,7 +683,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={REMARK_PLUGINS}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{taskResult.output}
|
{taskResult.output}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@ -918,8 +825,101 @@ 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={REMARK_PLUGINS}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
MusicIcon,
|
MusicIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useRef, useEffect, memo } from 'react';
|
import { useState, useRef, useEffect } 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,9 +77,7 @@ function getMediaType(mimeType: string): string {
|
|||||||
return 'file';
|
return 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 全部稳定(回调 useCallback、对象 prop 来自父组件 state),
|
export function MessageInput({
|
||||||
// 流式期间父组件每帧重渲染时完全跳过输入区子树。
|
|
||||||
export const MessageInput = memo(function MessageInput({
|
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@ -530,4 +528,4 @@ export const MessageInput = memo(function MessageInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo, memo } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } 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, Loader2 } from 'lucide-react';
|
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@ -14,26 +14,15 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 与 ChatContainer 同源(messages 变化时才需重渲染),
|
export function MessageList({
|
||||||
// 阻断父组件非消息类重渲染向虚拟化列表的传导。
|
|
||||||
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);
|
||||||
@ -43,17 +32,6 @@ export const MessageList = memo(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。
|
||||||
@ -62,17 +40,6 @@ export const MessageList = memo(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);
|
||||||
|
|
||||||
@ -83,24 +50,12 @@ export const MessageList = memo(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(() => {
|
||||||
@ -121,7 +76,6 @@ export const MessageList = memo(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);
|
||||||
@ -140,13 +94,8 @@ export const MessageList = memo(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) {
|
if (from <= 0) return;
|
||||||
// 已在已加载内容的顶部:直接补触发一次分页(有更早消息则进入链式贴顶)
|
|
||||||
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) =>
|
||||||
@ -154,16 +103,7 @@ export const MessageList = memo(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));
|
||||||
if (p < 1) {
|
scrollTopRafRef.current = p < 1 ? requestAnimationFrame(step) : 0;
|
||||||
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]);
|
||||||
@ -174,27 +114,12 @@ export const MessageList = memo(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 && !restoringRef.current) {
|
if (key) {
|
||||||
scrollPositionsRef.current.set(key, el.scrollTop);
|
scrollPositionsRef.current.set(key, el.scrollTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,74 +139,25 @@ export const MessageList = memo(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;
|
||||||
@ -312,18 +188,6 @@ export const MessageList = memo(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(() => {
|
||||||
@ -341,7 +205,6 @@ export const MessageList = memo(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 渲染后操作
|
||||||
@ -359,26 +222,18 @@ export const MessageList = memo(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', handleUserScrollInput, { passive: true });
|
el.addEventListener('wheel', stopScrollTopAnimation, { passive: true });
|
||||||
el.addEventListener('touchstart', handleUserScrollInput, { passive: true });
|
el.addEventListener('touchstart', stopScrollTopAnimation, { passive: true });
|
||||||
el.addEventListener('pointerdown', handleUserScrollInput, { passive: true });
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('wheel', handleUserScrollInput);
|
el.removeEventListener('wheel', stopScrollTopAnimation);
|
||||||
el.removeEventListener('touchstart', handleUserScrollInput);
|
el.removeEventListener('touchstart', stopScrollTopAnimation);
|
||||||
el.removeEventListener('pointerdown', handleUserScrollInput);
|
|
||||||
};
|
};
|
||||||
}, [hasMessages, handleUserScrollInput]);
|
}, [hasMessages, stopScrollTopAnimation]);
|
||||||
|
|
||||||
// 卸载时取消未完成的动画
|
// 卸载时取消未完成的动画
|
||||||
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
|
useEffect(() => stopScrollTopAnimation, [stopScrollTopAnimation]);
|
||||||
@ -446,12 +301,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div ref={containerRef} onScroll={handleScroll} className="h-full overflow-y-auto p-6">
|
||||||
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) => {
|
||||||
@ -485,19 +335,6 @@ export const MessageList = memo(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">
|
||||||
@ -548,4 +385,4 @@ export const MessageList = memo(function MessageList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
|
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
listModelOptions,
|
listModelOptions,
|
||||||
@ -19,8 +19,7 @@ interface ModelSelectorProps {
|
|||||||
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
|
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 全部稳定(useCallback/原始值),流式期间跳过重渲染
|
export function ModelSelector({
|
||||||
export const ModelSelector = memo(function ModelSelector({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
topicId,
|
topicId,
|
||||||
settingsClosedTick,
|
settingsClosedTick,
|
||||||
@ -285,4 +284,4 @@ export const ModelSelector = memo(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>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
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;
|
||||||
@ -66,12 +62,9 @@ export function ToolDetailModal({
|
|||||||
? 'var(--accent-amber)'
|
? 'var(--accent-amber)'
|
||||||
: 'var(--text-muted)';
|
: 'var(--text-muted)';
|
||||||
|
|
||||||
// portal 到 body:在虚拟化消息行内打开时,行元素的 transform 会成为
|
return (
|
||||||
// fixed 后代的包含块,遮罩/定位全部失效(同 ImageLightbox)。
|
|
||||||
// z-[10000]:高于下拉选择器(z-[9999])等全局浮层。
|
|
||||||
return createPortal(
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* Modal container */}
|
{/* Modal container */}
|
||||||
@ -134,7 +127,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={REMARK_PLUGINS}>{formattedContent}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formattedContent}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -149,7 +142,6 @@ export function ToolDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, memo } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
User,
|
User,
|
||||||
@ -310,9 +310,7 @@ function SectionHeader({
|
|||||||
|
|
||||||
/* ── main component ────────────────────────────────────── */
|
/* ── main component ────────────────────────────────────── */
|
||||||
|
|
||||||
// memo:props 全部稳定(memories 仅在刷新时换引用、回调均 useCallback),
|
export function MemoryPanel({
|
||||||
// 主视图流式期间 App 每帧重渲染时跳过面板重渲染与分组/排序重算。
|
|
||||||
export const MemoryPanel = memo(function MemoryPanel({
|
|
||||||
memories,
|
memories,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
@ -344,8 +342,6 @@ export const MemoryPanel = memo(function MemoryPanel({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// memories 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
|
|
||||||
const { grouped, sorted } = useMemo(() => {
|
|
||||||
const grouped = new Map<string, MemorySummary[]>();
|
const grouped = new Map<string, MemorySummary[]>();
|
||||||
for (const m of memories) {
|
for (const m of memories) {
|
||||||
const l = grouped.get(m.namespace) || [];
|
const l = grouped.get(m.namespace) || [];
|
||||||
@ -362,8 +358,6 @@ export const MemoryPanel = memo(function MemoryPanel({
|
|||||||
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));
|
||||||
@ -475,4 +469,4 @@ export const MemoryPanel = memo(function MemoryPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, memo } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
User,
|
User,
|
||||||
@ -100,9 +100,7 @@ function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfi
|
|||||||
|
|
||||||
/* ── main component ────────────────────────────────────── */
|
/* ── main component ────────────────────────────────────── */
|
||||||
|
|
||||||
// memo:props 全部稳定(skills 仅在刷新时换引用、onRefresh 为 useCallback),
|
export function SkillList({ skills, onRefresh }: SkillListProps) {
|
||||||
// 主视图流式期间 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');
|
||||||
@ -125,8 +123,6 @@ export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillLis
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// skills 引用未变时跳过分组/排序重算(流式期间 App 每帧重渲染)
|
|
||||||
const { grouped, sorted } = useMemo(() => {
|
|
||||||
const grouped = new Map<string, SkillSummary[]>();
|
const grouped = new Map<string, SkillSummary[]>();
|
||||||
for (const s of skills) {
|
for (const s of skills) {
|
||||||
const l = grouped.get(s.source) || [];
|
const l = grouped.get(s.source) || [];
|
||||||
@ -134,14 +130,7 @@ export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillLis
|
|||||||
grouped.set(s.source, l);
|
grouped.set(s.source, l);
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = [
|
const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw'];
|
||||||
'user',
|
|
||||||
'useragent',
|
|
||||||
'useropenclaw',
|
|
||||||
'project',
|
|
||||||
'projectagent',
|
|
||||||
'projectopenclaw',
|
|
||||||
];
|
|
||||||
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);
|
||||||
@ -150,8 +139,6 @@ export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillLis
|
|||||||
if (bi !== -1) return 1;
|
if (bi !== -1) return 1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
});
|
});
|
||||||
return { grouped, sorted };
|
|
||||||
}, [skills]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@ -222,4 +209,4 @@ export const SkillList = memo(function SkillList({ skills, onRefresh }: SkillLis
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo, memo } from 'react';
|
import { useState, useCallback, useEffect, useRef } 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,14 +53,7 @@ function PulseDot() {
|
|||||||
|
|
||||||
/* ── TodoPanel ────────────────────────────────────────── */
|
/* ── TodoPanel ────────────────────────────────────────── */
|
||||||
|
|
||||||
// memo:props 全部稳定(todos 仅在 todo_list 消息到达时换引用、回调均 useCallback),
|
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
|
||||||
// 主视图流式期间 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']),
|
||||||
);
|
);
|
||||||
@ -82,8 +75,7 @@ export const TodoPanel = memo(function TodoPanel({
|
|||||||
prevTodoIdsRef.current = newIds;
|
prevTodoIdsRef.current = newIds;
|
||||||
}, [todos]);
|
}, [todos]);
|
||||||
|
|
||||||
// todos 引用未变时跳过分组重算(流式期间 App 每帧重渲染)
|
const grouped = groupTodos(todos);
|
||||||
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;
|
||||||
|
|
||||||
@ -188,4 +180,4 @@ export const TodoPanel = memo(function TodoPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@ -13,11 +12,7 @@ interface TopicTokenStatsPanelProps {
|
|||||||
tokenStats?: TopicTokenStats | null;
|
tokenStats?: TopicTokenStats | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:tokenStats 引用稳定(仅 topic 刷新时换引用),
|
export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps) {
|
||||||
// 流式期间 App 每帧重渲染时跳过面板重渲染。
|
|
||||||
export const TopicTokenStatsPanel = memo(function TopicTokenStatsPanel({
|
|
||||||
tokenStats,
|
|
||||||
}: TopicTokenStatsPanelProps) {
|
|
||||||
// 无数据态
|
// 无数据态
|
||||||
if (!tokenStats || tokenStats.total_tokens === 0) {
|
if (!tokenStats || tokenStats.total_tokens === 0) {
|
||||||
return (
|
return (
|
||||||
@ -131,4 +126,4 @@ export const TopicTokenStatsPanel = memo(function TopicTokenStatsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -93,14 +92,7 @@ function lastStatusIcon(lastStatus: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 全部稳定(jobs 仅在刷新时换引用、回调均 useCallback),
|
export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: SchedulerJobListProps) {
|
||||||
// 阻断 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 */}
|
||||||
@ -236,4 +228,4 @@ export const SchedulerJobList = memo(function SchedulerJobList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@ -43,9 +43,7 @@ function formatTime(timestamp: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// memo:props 全部稳定(topics 仅在 topic_list 消息到达时换引用、回调均 useCallback),
|
export function TopicList({
|
||||||
// 流式期间 App 每帧重渲染时跳过话题列表重渲染与分页重算。
|
|
||||||
export const TopicList = memo(function TopicList({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
topics,
|
topics,
|
||||||
currentTopicId,
|
currentTopicId,
|
||||||
@ -359,4 +357,4 @@ export const TopicList = memo(function TopicList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import type {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolPending,
|
ToolPending,
|
||||||
TopicHistoryEnd,
|
|
||||||
ExecutionCompleted,
|
ExecutionCompleted,
|
||||||
WsError,
|
WsError,
|
||||||
TaskStarted,
|
TaskStarted,
|
||||||
@ -52,14 +51,6 @@ export interface UseMessagesReturn {
|
|||||||
handleStop: () => Command;
|
handleStop: () => Command;
|
||||||
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
/** 处理主视图的消息类 case(task_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 {
|
||||||
@ -232,162 +223,22 @@ 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 的流式内容落盘,避免被后续消息覆盖或丢失
|
||||||
// 例外:历史分页批次(带 seq)与 topic_history_end 是历史数据回放,
|
if (message.type !== 'stream_delta') {
|
||||||
// 与活动流无关——若在流式输出进行中触顶加载历史,误清累加器会导致
|
|
||||||
// 后续 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,避免跨话题串扰
|
||||||
@ -508,7 +359,6 @@ 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';
|
||||||
@ -540,7 +390,6 @@ 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) => [
|
||||||
@ -563,7 +412,6 @@ 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) => [
|
||||||
@ -584,7 +432,6 @@ 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) => [
|
||||||
@ -664,9 +511,5 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
finishStreaming,
|
finishStreaming,
|
||||||
handleStop,
|
handleStop,
|
||||||
handleMainViewMessage,
|
handleMainViewMessage,
|
||||||
hasMoreOlder: olderHistory.hasMore,
|
|
||||||
oldestSeq: olderHistory.oldestSeq,
|
|
||||||
loadingOlder: olderHistory.loading,
|
|
||||||
requestLoadOlder,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,10 +20,8 @@ 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 自动聚焦,返回是否自动聚焦了新话题 */
|
||||||
* currentSessionId 用于丢弃其他 session 的迟到响应(乱序覆盖防护)。
|
handleTopicList: (msg: TopicList) => boolean;
|
||||||
* 返回是否自动聚焦了新话题 */
|
|
||||||
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;
|
||||||
@ -69,36 +67,20 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
setTopicRefreshTrigger((n) => n + 1);
|
setTopicRefreshTrigger((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTopicList = useCallback(
|
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
||||||
(msg: TopicList, currentSessionId: string | null): boolean => {
|
|
||||||
// 归属校验:topic_list 有多个发射源(初始加载/token 防抖刷新/手动刷新/
|
|
||||||
// 重连恢复),切换 session 瞬间旧 session 的在途响应可能晚到。
|
|
||||||
// 协议携带 session_id,不匹配当前 session 的响应直接丢弃,
|
|
||||||
// 防止旧列表覆盖新列表并触发链式的错误自动选中。
|
|
||||||
if (msg.session_id && currentSessionId && msg.session_id !== currentSessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTopics = mapTopicSummaries(msg.topics);
|
const newTopics = mapTopicSummaries(msg.topics);
|
||||||
setTopics(newTopics);
|
setTopics(newTopics);
|
||||||
|
|
||||||
// 新建话题后自动聚焦:用差分找出本响应新增的话题(而非盲取第一项)——
|
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
||||||
// 仅当响应确实携带新话题时才消费 pending 标志,避免被恰好撞上的
|
|
||||||
// token 刷新/重连刷新响应错误消费。
|
|
||||||
if (pendingNewTopicRef.current) {
|
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;
|
pendingNewTopicRef.current = false;
|
||||||
setSelectedTopic(fresh.id);
|
if (newTopics.length > 0) {
|
||||||
selectedTopicRef.current = fresh.id;
|
setSelectedTopic(newTopics[0].id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
||||||
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
||||||
|
|||||||
@ -23,7 +23,6 @@ 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 {
|
||||||
@ -45,11 +44,6 @@ interface UseChatReturn {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
// 历史分页
|
|
||||||
hasMoreOlder: boolean;
|
|
||||||
loadingOlder: boolean;
|
|
||||||
loadOlderMessages: () => void;
|
|
||||||
|
|
||||||
// 通道状态
|
// 通道状态
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
selectedChannel: string;
|
selectedChannel: string;
|
||||||
@ -143,11 +137,13 @@ interface UseChatReturn {
|
|||||||
async function reconcileProcessingTopics(
|
async function reconcileProcessingTopics(
|
||||||
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
|
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
|
||||||
) {
|
) {
|
||||||
// 走统一 apiGetSilent:自动注入认证头(远程部署带 token 时裸 fetch 会 401,
|
try {
|
||||||
// 导致对账静默失败、话题永久卡 loading)。返回 null 时保留前端现有状态。
|
const res = await fetch('/api/executions');
|
||||||
const data = await apiGetSilent<{ topic_ids?: string[] }>('/api/executions');
|
if (!res.ok) return;
|
||||||
if (data) {
|
const data = (await res.json()) as { topic_ids?: string[] };
|
||||||
setProcessingTopicIds(new Set(data.topic_ids ?? []));
|
setProcessingTopicIds(new Set(data.topic_ids ?? []));
|
||||||
|
} catch {
|
||||||
|
// 查询失败:保留前端现有状态
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,11 +193,6 @@ 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_id(ChatMessage 结构无此字段),
|
// 注意:不发 load_chat_messages——历史消息不带 topic_id(ChatMessage 结构无此字段),
|
||||||
@ -242,10 +233,7 @@ export function useChat(): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case 'topic_list': {
|
case 'topic_list': {
|
||||||
const autoFocused = topics.handleTopicList(
|
const autoFocused = topics.handleTopicList(message);
|
||||||
message,
|
|
||||||
sessions.selectedSessionIdRef.current,
|
|
||||||
);
|
|
||||||
if (autoFocused) messages.clearMessages();
|
if (autoFocused) messages.clearMessages();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -318,10 +306,6 @@ export function useChat(): UseChatReturn {
|
|||||||
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
||||||
const selectTopic = useCallback((topicId: string) => {
|
const selectTopic = useCallback((topicId: string) => {
|
||||||
topics.setSelectedTopic(topicId);
|
topics.setSelectedTopic(topicId);
|
||||||
// 同步双写 ref:setState 后的 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;
|
||||||
@ -339,10 +323,6 @@ 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 = [];
|
||||||
@ -361,10 +341,6 @@ 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 = [];
|
||||||
@ -376,12 +352,6 @@ 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);
|
||||||
@ -418,9 +388,6 @@ 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,
|
||||||
|
|||||||
@ -47,8 +47,6 @@ 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 {
|
||||||
@ -64,8 +62,6 @@ 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 {
|
||||||
@ -79,8 +75,6 @@ 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 {
|
||||||
@ -94,16 +88,6 @@ 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 {
|
||||||
@ -331,7 +315,6 @@ export type WsOutbound =
|
|||||||
| ToolCall
|
| ToolCall
|
||||||
| ToolResult
|
| ToolResult
|
||||||
| ToolPending
|
| ToolPending
|
||||||
| TopicHistoryEnd
|
|
||||||
| WsError
|
| WsError
|
||||||
| TaskStarted
|
| TaskStarted
|
||||||
| StreamDelta
|
| StreamDelta
|
||||||
@ -428,12 +411,6 @@ 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;
|
||||||
@ -495,7 +472,6 @@ export type Command =
|
|||||||
| LoadTaskMessagesCommand
|
| LoadTaskMessagesCommand
|
||||||
| ListSchedulerJobsCommand
|
| ListSchedulerJobsCommand
|
||||||
| LoadChatMessagesCommand
|
| LoadChatMessagesCommand
|
||||||
| LoadOlderMessagesCommand
|
|
||||||
| DeleteTopicCommand
|
| DeleteTopicCommand
|
||||||
| RenameTopicCommand
|
| RenameTopicCommand
|
||||||
| StopExecutionCommand
|
| StopExecutionCommand
|
||||||
@ -515,8 +491,6 @@ 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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user