feat: add session token and context statistics

This commit is contained in:
xiaoxixi 2026-08-05 16:45:56 +08:00
parent b1b8e2d923
commit da5ee05311
25 changed files with 883 additions and 165 deletions

View File

@ -97,6 +97,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts - **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables - **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source - **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
- **Session token statistics** persist Provider-reported usage atomically with each completed Turn; WebUI `session_stats` and `/info [--json]` must consume the same SessionStats projection, and context occupancy must use the final request's prompt usage rather than accumulated Turn totals
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible - **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths - **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
- **WebUI/TUI same-turn media delivery** stages same-session `send_message(files=...)` media on the active Turn and commits it on the final assistant message, after durable tool-call history; safe raster formats should render as an inline preview with download fallback - **WebUI/TUI same-turn media delivery** stages same-session `send_message(files=...)` media on the active Turn and commits it on the final assistant message, after durable tool-call history; safe raster formats should render as an inline preview with download fallback

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "1.3.1" version = "1.4.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -12,6 +12,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。 - 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。 - 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。 - 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
- 在 WebUI 顶栏查看当前会话的累计输入/输出 Token、上下文窗口和占用比例。
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent聊天页侧栏实时显示进度。 - 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent聊天页侧栏实时显示进度。
- 将同一套 Agent 能力接入飞书/Lark并可选用单张卡片实时更新回复。 - 将同一套 Agent 能力接入飞书/Lark并可选用单张卡片实时更新回复。
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。 - 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
@ -306,7 +307,7 @@ Session ID 使用三段式:
| `/rename <title>` | 重命名当前 dialog | | `/rename <title>` | 重命名当前 dialog |
| `/delete` | 删除当前 dialog 并创建新 dialog | | `/delete` | 删除当前 dialog 并创建新 dialog |
| `/compact` | 手动压缩上下文 | | `/compact` | 手动压缩上下文 |
| `/info` | 查看当前 dialog 信息 | | `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
| `/dump` | 导出当前 dialog 为 Markdown | | `/dump` | 导出当前 dialog 为 Markdown |
| `/mcp` | 查看 MCP 服务器和工具状态 | | `/mcp` | 查看 MCP 服务器和工具状态 |
| `/health` | 检查 PicoBot 运行依赖 | | `/health` | 检查 PicoBot 运行依赖 |

View File

@ -210,7 +210,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。 - 5 秒 busy timeout。
- schema version 迁移。 - schema version 迁移。
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status私有 Provider 状态不进入 WebSocket/Channel且只允许回放给同一 Provider。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要不保存正文、reasoning 或签名 payload。修改 schema 时应: 持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status私有 Provider 状态不进入 WebSocket/Channel且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要不保存正文、reasoning 或签名 payload。修改 schema 时应:
1. 更新集中式 schema/迁移逻辑。 1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。 2. 保留已有数据库的升级路径。
@ -247,7 +247,7 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快
### WebUI 与管理 API ### WebUI 与管理 API
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage``theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme``data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws``cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane会话历史帧保留工具调用 ID、名称、参数和工具结果角色WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。 Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage``theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme``data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws``cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane会话历史帧保留工具调用 ID、名称、参数和工具结果角色WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;活动状态栏通过结构化 `session_stats` 展示当前 session 的已提交 Turn 用量与上下文占用,累计量来自 Provider usage窗口占用明确区分 API 基准上的混合估算与纯字符估算;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。 WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。

View File

@ -54,6 +54,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划 - 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
- WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入 - WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
- WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物 - WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
- WebUI 通过 `get_session_stats`/`session_stats` 显示当前会话累计输入输出 Token 和上下文窗口占用;`/info [--json]` 读取同一份 SessionStats
## 关键约束 ## 关键约束
@ -293,7 +294,7 @@ Gateway 关停顺序:
| `/rename <title>` | 重命名当前对话 | | `/rename <title>` | 重命名当前对话 |
| `/delete` | 删除当前对话 | | `/delete` | 删除当前对话 |
| `/compact` | 手动触发上下文压缩 | | `/compact` | 手动触发上下文压缩 |
| `/info` | 显示当前对话信息 | | `/info [--json]` | 显示当前对话、累计 Token 与上下文窗口信息;可选 JSON 输出 |
| `/dump` | 保存当前对话为 markdown | | `/dump` | 保存当前对话为 markdown |
| `/?`, `/help` | 显示帮助 | | `/?`, `/help` | 显示帮助 |
| `/mcp` | 显示 MCP 状态 | | `/mcp` | 显示 MCP 状态 |

View File

@ -24,6 +24,8 @@
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 | | `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 | | `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
`session_turn_usage``turn_id` 幂等保存已提交 Turn 的 Provider usage包括累计输入、输出、缓存输入、请求数和最后一次请求的 prompt tokens。它与 Turn 消息批次在同一事务中提交,供 WebUI 状态栏和 `/info` 使用;升级前历史无法可靠回填,因此统计起点以首条 usage 记录为准。
`(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。 `(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。
## messages 表 ## messages 表

View File

@ -314,6 +314,10 @@ pub struct AgentProcessResult {
pub emitted_messages: Vec<ChatMessage>, pub emitted_messages: Vec<ChatMessage>,
pub total_tokens: Option<u32>, pub total_tokens: Option<u32>,
pub usage: Option<crate::providers::Usage>, pub usage: Option<crate::providers::Usage>,
/// Provider usage for the final successful request in this Turn. This is
/// the correct basis for context-window occupancy; `usage` is accumulated
/// across every tool iteration.
pub last_request_usage: Option<crate::providers::Usage>,
} }
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) { fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
@ -691,6 +695,7 @@ impl AgentLoop {
let mut emitted_messages = Vec::new(); let mut emitted_messages = Vec::new();
let mut accumulated_tokens: u32 = 0; let mut accumulated_tokens: u32 = 0;
let mut accumulated_usage = crate::providers::Usage::default(); let mut accumulated_usage = crate::providers::Usage::default();
let mut last_request_usage = None;
for iteration in 0..self.max_iterations { for iteration in 0..self.max_iterations {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -741,6 +746,7 @@ impl AgentLoop {
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens); accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
merge_usage(&mut accumulated_usage, &response.usage); merge_usage(&mut accumulated_usage, &response.usage);
last_request_usage = Some(response.usage.clone());
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!( tracing::debug!(
@ -766,6 +772,7 @@ impl AgentLoop {
emitted_messages, emitted_messages,
total_tokens: Some(accumulated_tokens), total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage), usage: Some(accumulated_usage),
last_request_usage,
}); });
} }
@ -897,6 +904,7 @@ impl AgentLoop {
Ok(response) => { Ok(response) => {
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens); accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
merge_usage(&mut accumulated_usage, &response.usage); merge_usage(&mut accumulated_usage, &response.usage);
last_request_usage = Some(response.usage.clone());
let mut assistant_message = ChatMessage::assistant(response.content); let mut assistant_message = ChatMessage::assistant(response.content);
assistant_message.reasoning_content = response.reasoning_content; assistant_message.reasoning_content = response.reasoning_content;
assistant_message.provider_state = response.provider_state; assistant_message.provider_state = response.provider_state;
@ -916,6 +924,7 @@ impl AgentLoop {
emitted_messages, emitted_messages,
total_tokens: Some(accumulated_tokens), total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage), usage: Some(accumulated_usage),
last_request_usage,
}) })
} }
Err(e) => { Err(e) => {
@ -944,10 +953,8 @@ impl AgentLoop {
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true); Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
emitted_messages.push(final_message.clone()); emitted_messages.push(final_message.clone());
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage); let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
crate::observability::metrics::global_metrics().record_turn( crate::observability::metrics::global_metrics()
turn_usage, .record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
turn_start.elapsed().as_millis() as u64,
);
Ok(AgentProcessResult { Ok(AgentProcessResult {
final_response: final_message, final_response: final_message,
emitted_messages, emitted_messages,
@ -957,6 +964,7 @@ impl AgentLoop {
None None
}, },
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage), usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
last_request_usage,
}) })
} }
} }

View File

@ -70,8 +70,8 @@ pub struct ContextCompressor {
session_id: Option<String>, session_id: Option<String>,
/// Message count sent in the last LLM call (used to split known/new history). /// Message count sent in the last LLM call (used to split known/new history).
last_sent_message_count: Option<usize>, last_sent_message_count: Option<usize>,
/// Real total_tokens from the last API response. /// Real prompt_tokens from the final API request in the last completed Turn.
last_api_total_tokens: Option<u32>, last_api_prompt_tokens: Option<u32>,
} }
/// Result of context compression. /// Result of context compression.
@ -85,7 +85,7 @@ pub struct TokenInfo {
pub context_window: usize, pub context_window: usize,
pub threshold: usize, pub threshold: usize,
pub estimated_tokens: usize, pub estimated_tokens: usize,
pub last_api_tokens: Option<u32>, pub last_prompt_tokens: Option<u32>,
pub cache_active: bool, pub cache_active: bool,
} }
@ -104,7 +104,7 @@ impl ContextCompressor {
memory, memory,
session_id: None, session_id: None,
last_sent_message_count: None, last_sent_message_count: None,
last_api_total_tokens: None, last_api_prompt_tokens: None,
} }
} }
@ -123,7 +123,7 @@ impl ContextCompressor {
memory, memory,
session_id: None, session_id: None,
last_sent_message_count: None, last_sent_message_count: None,
last_api_total_tokens: None, last_api_prompt_tokens: None,
} }
} }
@ -137,24 +137,28 @@ impl ContextCompressor {
self.context_window = window; self.context_window = window;
} }
pub fn context_window(&self) -> usize {
self.context_window
}
/// Record the API's reported token usage from the last completed turn. /// Record the API's reported token usage from the last completed turn.
/// `msg_count`: number of messages sent to LLM in that call. /// `msg_count`: number of messages sent to LLM in that call.
/// `tokens`: `total_tokens` from the API response. /// `tokens`: `prompt_tokens` from the final API request in the Turn.
pub fn set_last_api_info(&mut self, msg_count: usize, tokens: Option<u32>) { pub fn set_last_api_info(&mut self, msg_count: usize, tokens: Option<u32>) {
self.last_sent_message_count = Some(msg_count); self.last_sent_message_count = Some(msg_count);
self.last_api_total_tokens = tokens; self.last_api_prompt_tokens = tokens;
} }
/// Invalidate the cached API token info — called after compression modifies messages. /// Invalidate the cached API token info — called after compression modifies messages.
fn invalidate_token_cache(&mut self) { fn invalidate_token_cache(&mut self) {
self.last_sent_message_count = None; self.last_sent_message_count = None;
self.last_api_total_tokens = None; self.last_api_prompt_tokens = None;
} }
/// Hybrid token estimation: API-reported tokens for known history + /// Hybrid token estimation: API-reported tokens for known history +
/// char/4 estimate for new messages since last API call. /// char/4 estimate for new messages since last API call.
fn token_estimate_with_history(&self, messages: &[ChatMessage]) -> usize { fn token_estimate_with_history(&self, messages: &[ChatMessage]) -> usize {
match (self.last_api_total_tokens, self.last_sent_message_count) { match (self.last_api_prompt_tokens, self.last_sent_message_count) {
(Some(known), Some(known_count)) if messages.len() > known_count => { (Some(known), Some(known_count)) if messages.len() > known_count => {
let delta = &messages[known_count..]; let delta = &messages[known_count..];
known as usize + estimate_tokens(delta) known as usize + estimate_tokens(delta)
@ -175,8 +179,8 @@ impl ContextCompressor {
context_window: self.context_window, context_window: self.context_window,
threshold: self.threshold(), threshold: self.threshold(),
estimated_tokens: self.token_estimate_with_history(messages), estimated_tokens: self.token_estimate_with_history(messages),
last_api_tokens: self.last_api_total_tokens, last_prompt_tokens: self.last_api_prompt_tokens,
cache_active: self.last_api_total_tokens.is_some(), cache_active: self.last_api_prompt_tokens.is_some(),
} }
} }
@ -797,6 +801,21 @@ mod tests {
assert_eq!(compressor.threshold(), 89_600); assert_eq!(compressor.threshold(), 89_600);
} }
#[test]
fn api_prompt_usage_is_the_base_for_new_history_estimates() {
let mut compressor =
ContextCompressor::new(mock_provider(), 128_000, test_memory_manager());
compressor.set_last_api_info(1, Some(100));
let messages = vec![
ChatMessage::user("known"),
ChatMessage::assistant("new response"),
];
let info = compressor.token_info(&messages);
assert_eq!(info.last_prompt_tokens, Some(100));
assert_eq!(info.estimated_tokens, 100 + estimate_tokens(&messages[1..]));
}
#[tokio::test] #[tokio::test]
async fn test_compress_if_needed_fast_trims_tool_results() { async fn test_compress_if_needed_fast_trims_tool_results() {
// context_window=200 → threshold=100. // context_window=200 → threshold=100.

View File

@ -475,6 +475,25 @@ impl CliChatChannel {
None => return Err(ChannelError::Other("Control channel closed".to_string())), None => return Err(ChannelError::Other("Control channel closed".to_string())),
} }
} }
WsInbound::GetSessionStats { session_id } => {
let unified_id = Self::parse_client_session(&client, &session_id)?;
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::GetSessionStats {
session_id: unified_id,
},
reply_tx,
})
.await?;
match reply_rx.recv().await {
Some(Ok(SessionEvent::SessionStats { stats })) => {
let _ = client.sender.send(WsOutbound::SessionStats { stats }).await;
}
Some(Ok(_)) => {}
Some(Err(error)) => return Err(error),
None => return Err(ChannelError::Other("Control channel closed".to_string())),
}
}
WsInbound::RenameSession { session_id, title } => { WsInbound::RenameSession { session_id, title } => {
let target = session_id let target = session_id
.or(current_session_guard.clone()) .or(current_session_guard.clone())

View File

@ -380,7 +380,9 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
} => app.set_history(&session_id, messages), } => app.set_history(&session_id, messages),
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task // The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
// notifications and may inspect plans through /todo. // notifications and may inspect plans through /todo.
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {} WsOutbound::SessionPlan { .. }
| WsOutbound::SessionStats { .. }
| WsOutbound::PlanUpdated { .. } => {}
WsOutbound::SessionRenamed { session_id, title } => { WsOutbound::SessionRenamed { session_id, title } => {
if let Some(session) = app if let Some(session) = app
.sessions .sessions

View File

@ -325,6 +325,11 @@ async fn handle_control_message(session_manager: &SessionManager, message: Contr
.await .await
.map(|plan| SessionEvent::TaskPlan { session_id, plan }) .map(|plan| SessionEvent::TaskPlan { session_id, plan })
.map_err(|error| ChannelError::Other(error.to_string())), .map_err(|error| ChannelError::Other(error.to_string())),
GetSessionStats { session_id } => session_manager
.get_session_stats(&session_id)
.await
.map(|stats| SessionEvent::SessionStats { stats })
.map_err(|error| ChannelError::Other(error.to_string())),
RenameDialog { session_id, title } => session_manager RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title) .rename_dialog(&session_id, &title)
.await .await

View File

@ -172,6 +172,8 @@ pub enum WsInbound {
}, },
#[serde(rename = "get_session_plan")] #[serde(rename = "get_session_plan")]
GetSessionPlan { session_id: String }, GetSessionPlan { session_id: String },
#[serde(rename = "get_session_stats")]
GetSessionStats { session_id: String },
#[serde(rename = "rename_session")] #[serde(rename = "rename_session")]
RenameSession { RenameSession {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -249,6 +251,8 @@ pub enum WsOutbound {
session_id: String, session_id: String,
plan: Option<crate::work::TaskPlan>, plan: Option<crate::work::TaskPlan>,
}, },
#[serde(rename = "session_stats")]
SessionStats { stats: crate::session::SessionStats },
#[serde(rename = "plan_updated")] #[serde(rename = "plan_updated")]
PlanUpdated { PlanUpdated {
session_id: String, session_id: String,
@ -362,4 +366,47 @@ mod tests {
crate::bus::CompletionStatus::Completed crate::bus::CompletionStatus::Completed
); );
} }
#[test]
fn session_stats_request_and_response_use_structured_frames() {
let inbound =
parse_inbound(r#"{"type":"get_session_stats","session_id":"cli_chat:client:dialog"}"#)
.unwrap();
assert!(matches!(inbound, WsInbound::GetSessionStats { .. }));
let stats = crate::session::SessionStats {
session_id: "cli_chat:client:dialog".into(),
title: "stats".into(),
provider: "provider".into(),
model: "model".into(),
user_message_count: 1,
history_message_count: 2,
lifetime_usage: crate::session::LifetimeUsage {
input_tokens: 100,
output_tokens: 20,
total_tokens: 120,
cached_input_tokens: Some(40),
request_count: 1,
turn_count: 1,
tracked_since: Some(1),
},
context: crate::session::ContextUsage {
configured_window_tokens: 128_000,
effective_window_tokens: 128_000,
used_tokens: 100,
remaining_tokens: 127_900,
compression_threshold_tokens: 89_600,
source: crate::session::ContextUsageSource::Hybrid,
last_observed_prompt_tokens: Some(90),
observed_at: Some(1),
},
created_at: 1,
last_active_at: 2,
updated_at: 3,
};
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
assert_eq!(value["type"], "session_stats");
assert_eq!(value["stats"]["context"]["source"], "hybrid");
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
}
} }

View File

@ -28,6 +28,8 @@ pub enum SessionCommand {
}, },
/// Load the active task plan for a dialog. /// Load the active task plan for a dialog.
GetTaskPlan { session_id: UnifiedSessionId }, GetTaskPlan { session_id: UnifiedSessionId },
/// Load token totals and context-window state for a dialog.
GetSessionStats { session_id: UnifiedSessionId },
/// Get the current dialog for a chat /// Get the current dialog for a chat
GetCurrentDialog { channel: String, chat_id: String }, GetCurrentDialog { channel: String, chat_id: String },
/// Rename a dialog /// Rename a dialog

View File

@ -41,6 +41,8 @@ pub enum SessionEvent {
session_id: UnifiedSessionId, session_id: UnifiedSessionId,
plan: Option<crate::work::TaskPlan>, plan: Option<crate::work::TaskPlan>,
}, },
/// Provider usage totals and current context-window state.
SessionStats { stats: crate::session::SessionStats },
/// Dialog renamed /// Dialog renamed
DialogRenamed { DialogRenamed {
session_id: UnifiedSessionId, session_id: UnifiedSessionId,

View File

@ -8,6 +8,7 @@ mod turn_input;
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
pub mod session; pub mod session;
pub mod session_id; pub mod session_id;
pub mod stats;
pub mod turn; pub mod turn;
pub use commands::SessionCommand; pub use commands::SessionCommand;
@ -15,6 +16,7 @@ pub use error::SessionError;
pub use events::{DialogInfo, SessionEvent}; pub use events::{DialogInfo, SessionEvent};
pub use session::{SLASH_COMMANDS, Session, SessionManager, SessionManagerServices, SlashCommand}; pub use session::{SLASH_COMMANDS, Session, SessionManager, SessionManagerServices, SlashCommand};
pub use session_id::UnifiedSessionId; pub use session_id::UnifiedSessionId;
pub use stats::{ContextUsage, ContextUsageSource, LifetimeUsage, SessionStats};
pub use turn::{ pub use turn::{
BlockId, ToolStatus, TurnBlock, TurnController, TurnId, TurnPhase, TurnSnapshot, TurnState, BlockId, ToolStatus, TurnBlock, TurnController, TurnId, TurnPhase, TurnSnapshot, TurnState,
TurnStatus, TurnStatus,

View File

@ -10,6 +10,7 @@ use crate::{providers::Usage, session::TurnController};
async fn persist_added_messages( async fn persist_added_messages(
snapshots: Vec<Option<MessagePersistSnapshot>>, snapshots: Vec<Option<MessagePersistSnapshot>>,
usage: Option<&crate::storage::TurnUsageRecord>,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let mut storage = None; let mut storage = None;
let mut session_id = None; let mut session_id = None;
@ -35,9 +36,15 @@ async fn persist_added_messages(
else { else {
return Ok(()); return Ok(());
}; };
if let Some(usage) = usage {
storage
.persist_turn_batch_with_retry(&session_id, &messages, &final_meta, usage)
.await
} else {
storage storage
.persist_message_batch_with_retry(&session_id, &messages, &final_meta) .persist_message_batch_with_retry(&session_id, &messages, &final_meta)
.await .await
}
} }
pub(super) async fn append_persisted_messages( pub(super) async fn append_persisted_messages(
@ -69,6 +76,7 @@ pub(super) async fn append_active_turn_message(
session, session,
vec![message], vec![message],
VersionPolicy::PreserveForOwnedTurn(turn_id), VersionPolicy::PreserveForOwnedTurn(turn_id),
None,
) )
.await .await
.map(|_| ()) .map(|_| ())
@ -78,13 +86,22 @@ pub(super) async fn append_persisted_messages_with_meta(
session: &Arc<Mutex<Session>>, session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> { ) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
append_persisted_messages_inner(session, messages, VersionPolicy::Advance).await append_persisted_messages_inner(session, messages, VersionPolicy::Advance, None).await
}
pub(super) async fn append_persisted_turn_messages(
session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>,
usage: crate::storage::TurnUsageRecord,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, Some(usage)).await
} }
async fn append_persisted_messages_inner( async fn append_persisted_messages_inner(
session: &Arc<Mutex<Session>>, session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>, messages: Vec<ChatMessage>,
version_policy: VersionPolicy, version_policy: VersionPolicy,
usage: Option<crate::storage::TurnUsageRecord>,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> { ) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
if messages.is_empty() { if messages.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
@ -113,7 +130,7 @@ async fn append_persisted_messages_inner(
.map(|(_, _, message, _)| message.clone()) .map(|(_, _, message, _)| message.clone())
.collect(); .collect();
if let Err(error) = persist_added_messages(snapshots).await { if let Err(error) = persist_added_messages(snapshots, usage.as_ref()).await {
session session
.lock() .lock()
.await .await

View File

@ -4,7 +4,8 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::sync::{Mutex, mpsc, oneshot};
use super::persistence::{ use super::persistence::{
append_persisted_messages, append_persisted_messages_with_meta, finalize_turn_after_persistence, append_persisted_messages, append_persisted_messages_with_meta, append_persisted_turn_messages,
finalize_turn_after_persistence,
}; };
use super::turn::{TurnBlock, TurnController, TurnSnapshot}; use super::turn::{TurnBlock, TurnController, TurnSnapshot};
use super::turn_input::prepare_turn_input; use super::turn_input::prepare_turn_input;
@ -498,6 +499,7 @@ mod cancelled_partial_tests {
], ],
total_tokens: None, total_tokens: None,
usage: None, usage: None,
last_request_usage: None,
}; };
attach_pending_turn_deliveries( attach_pending_turn_deliveries(
@ -1113,7 +1115,7 @@ impl Session {
}; };
let mut compressor = ContextCompressor::with_config( let mut compressor = ContextCompressor::with_config(
self.provider.clone(), self.provider.clone(),
self.provider_config.token_limit, self.compressor.context_window(),
compressor_config, compressor_config,
self.memory_manager.clone(), self.memory_manager.clone(),
); );
@ -1155,7 +1157,7 @@ impl Session {
self.provider_config.workspace_dir.clone(), self.provider_config.workspace_dir.clone(),
self.provider_config.input_types.clone(), self.provider_config.input_types.clone(),
) )
.with_context_window(self.provider_config.token_limit)) .with_context_window(self.compressor.context_window()))
} }
/// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory /// 构建系统提示词(包含 AgentLoop 的基础提示词 + skills + memory
@ -1856,87 +1858,15 @@ impl SessionManager {
} }
"info" => { "info" => {
if let Some(sid) = current_session_id { if let Some(sid) = current_session_id {
let session = self.get_or_create_session(sid).await?; let stats = self.get_session_stats(sid).await?;
let session_guard = session.lock().await; let output = if args.is_some_and(|value| value.trim() == "--json") {
let history = session_guard.get_history(); serde_json::to_string_pretty(&stats).map_err(|error| {
let message_count = history.len(); AgentError::Other(format!("failed to serialize session info: {error}"))
let session_id_str = session_guard.session_id(); })?
let title = &session_guard.title;
let model_name = &session_guard.provider_config.name;
let created_at =
chrono::DateTime::from_timestamp_millis(session_guard.created_at)
.map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_default();
let last_active_at =
chrono::DateTime::from_timestamp_millis(session_guard.last_active_at)
.map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_default();
let token_info = session_guard.compressor.token_info(history);
let cache_info = if token_info.cache_active {
format!(
"API精确: {} tokens",
token_info.last_api_tokens.unwrap_or(0)
)
} else { } else {
"无API精确缓存".to_string() stats.render_text()
}; };
let threshold_pct = if token_info.context_window > 0 { Ok((None, output))
(token_info.threshold as f64 / token_info.context_window as f64 * 100.0)
as usize
} else {
0
};
let usage_pct = if token_info.context_window > 0 {
(token_info.estimated_tokens as f64 / token_info.context_window as f64
* 100.0)
.min(100.0) as usize
} else {
0
};
let usage_bar = if token_info.context_window > 0 {
format!(
"{}/{} tokens ({}%)",
token_info.estimated_tokens, token_info.context_window, usage_pct
)
} else {
"未设置".to_string()
};
let compression_status = if token_info.estimated_tokens > token_info.threshold {
"[即将压缩]"
} else {
"[正常]"
};
let ctx_info = format!(
"[窗口] {} [阈值] {}/{} ({}) [状态] {} {}",
usage_bar,
token_info.threshold,
token_info.context_window,
threshold_pct,
compression_status,
cache_info,
);
Ok((
None,
format!(
"对话标题: {}\nSession ID: {}\n模型: {}\n用户消息: {} / 总消息: {}\n创建时间: {}\n最后活跃: {}\n\n上下文: {}",
title,
session_id_str,
model_name,
session_guard.message_count,
message_count,
created_at,
last_active_at,
ctx_info,
),
))
} else { } else {
Ok((None, "No active session.".to_string())) Ok((None, "No active session.".to_string()))
} }
@ -2430,6 +2360,81 @@ impl SessionManager {
.map_err(|error| AgentError::Other(format!("failed to load task plan: {error}"))) .map_err(|error| AgentError::Other(format!("failed to load task plan: {error}")))
} }
pub async fn get_session_stats(
&self,
session_id: &UnifiedSessionId,
) -> Result<crate::session::SessionStats, AgentError> {
let session = self.get_or_create_session(session_id).await?;
let (
title,
provider,
model,
user_message_count,
history_message_count,
created_at,
last_active_at,
configured_window,
token_info,
) = {
let guard = session.lock().await;
let history = guard.get_history();
(
guard.title.clone(),
guard.provider_config.name.clone(),
guard.provider_config.model_id.clone(),
u64::try_from(guard.message_count).unwrap_or_default(),
u64::try_from(history.len()).unwrap_or(u64::MAX),
guard.created_at,
guard.last_active_at,
guard.provider_config.token_limit,
guard.compressor.token_info(history),
)
};
let totals = self
.storage
.get_session_usage_totals(&session_id.to_string())
.await
.map_err(|error| AgentError::Other(format!("failed to load session usage: {error}")))?;
let effective_window = u64::try_from(token_info.context_window).unwrap_or(u64::MAX);
let used_tokens = u64::try_from(token_info.estimated_tokens).unwrap_or(u64::MAX);
Ok(crate::session::SessionStats {
session_id: session_id.to_string(),
title,
provider,
model,
user_message_count,
history_message_count,
lifetime_usage: crate::session::LifetimeUsage {
input_tokens: totals.prompt_tokens,
output_tokens: totals.completion_tokens,
total_tokens: totals.total_tokens,
cached_input_tokens: totals.cached_input_tokens,
request_count: totals.request_count,
turn_count: totals.turn_count,
tracked_since: totals.tracked_since,
},
context: crate::session::ContextUsage {
configured_window_tokens: u64::try_from(configured_window).unwrap_or(u64::MAX),
effective_window_tokens: effective_window,
used_tokens,
remaining_tokens: effective_window.saturating_sub(used_tokens),
compression_threshold_tokens: u64::try_from(token_info.threshold)
.unwrap_or(u64::MAX),
source: if token_info.cache_active {
crate::session::ContextUsageSource::Hybrid
} else {
crate::session::ContextUsageSource::Estimated
},
last_observed_prompt_tokens: totals.last_prompt_tokens,
observed_at: totals.last_observed_at,
},
created_at,
last_active_at,
updated_at: chrono::Utc::now().timestamp_millis(),
})
}
pub async fn list_dialogs( pub async fn list_dialogs(
&self, &self,
channel: &str, channel: &str,
@ -3246,9 +3251,12 @@ fn spawn_agent_worker(
let pending = take_current_turn_deliveries(); let pending = take_current_turn_deliveries();
attach_pending_turn_deliveries(&mut result, pending); attach_pending_turn_deliveries(&mut result, pending);
let response_content = result.final_response.content; let response_content = result.final_response.content;
let total_tokens = result.total_tokens;
let usage = result.usage; let usage = result.usage;
{ let last_prompt_tokens = result
.last_request_usage
.as_ref()
.map(|value| value.prompt_tokens);
let (provider_name, model_name) = {
let guard = session2.lock().await; let guard = session2.lock().await;
if guard.worker_generation != worker_gen if guard.worker_generation != worker_gen
|| guard.state_version != base_version || guard.state_version != base_version
@ -3258,21 +3266,48 @@ fn spawn_agent_worker(
)); ));
return; return;
} }
(
guard.provider_config.name.clone(),
guard.provider_config.model_id.clone(),
)
};
let usage_record = usage.as_ref().map(|turn_usage| {
crate::storage::TurnUsageRecord {
session_id: response_session_id.clone(),
turn_id: agent_turn.turn_id.clone(),
provider: provider_name,
model: model_name,
usage: turn_usage.clone(),
last_prompt_tokens: last_prompt_tokens.unwrap_or_default(),
created_at: chrono::Utc::now().timestamp_millis(),
} }
});
let emitted_messages = result.emitted_messages;
let response = match finalize_turn_after_persistence( let response = match finalize_turn_after_persistence(
turn_lifecycle, turn_lifecycle,
usage, usage.clone(),
append_persisted_messages_with_meta( async {
if let Some(usage_record) = usage_record {
append_persisted_turn_messages(
&session2, &session2,
result.emitted_messages, emitted_messages,
), usage_record,
)
.await
} else {
append_persisted_messages_with_meta(&session2, emitted_messages)
.await
}
},
) )
.await .await
{ {
Ok(committed_messages) => { Ok(committed_messages) => {
let mut guard = session2.lock().await; let mut guard = session2.lock().await;
let sent_count = guard.messages.len(); let prompt_message_count = guard.messages.len().saturating_sub(1);
guard.compressor.set_last_api_info(sent_count, total_tokens); guard
.compressor
.set_last_api_info(prompt_message_count, last_prompt_tokens);
Some((response_content, committed_messages)) Some((response_content, committed_messages))
} }
Err(e) => { Err(e) => {

142
src/session/stats.rs Normal file
View File

@ -0,0 +1,142 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionStats {
pub session_id: String,
pub title: String,
pub provider: String,
pub model: String,
pub user_message_count: u64,
pub history_message_count: u64,
pub lifetime_usage: LifetimeUsage,
pub context: ContextUsage,
pub created_at: i64,
pub last_active_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LifetimeUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub request_count: u64,
pub turn_count: u64,
pub tracked_since: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextUsage {
pub configured_window_tokens: u64,
pub effective_window_tokens: u64,
pub used_tokens: u64,
pub remaining_tokens: u64,
pub compression_threshold_tokens: u64,
pub source: ContextUsageSource,
pub last_observed_prompt_tokens: Option<u64>,
pub observed_at: Option<i64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextUsageSource {
Hybrid,
Estimated,
}
impl ContextUsageSource {
pub fn label(self) -> &'static str {
match self {
Self::Hybrid => "混合估算",
Self::Estimated => "字符估算",
}
}
}
impl SessionStats {
pub fn render_text(&self) -> String {
let percent = if self.context.effective_window_tokens == 0 {
0.0
} else {
self.context.used_tokens as f64 / self.context.effective_window_tokens as f64 * 100.0
};
let created_at = format_timestamp(self.created_at);
let last_active_at = format_timestamp(self.last_active_at);
let tracked_since = self
.lifetime_usage
.tracked_since
.map(format_timestamp)
.unwrap_or_else(|| "尚无已完成模型请求".to_string());
let cached = self
.lifetime_usage
.cached_input_tokens
.map(format_tokens)
.unwrap_or_else(|| "".to_string());
let observed = self
.context
.last_observed_prompt_tokens
.map(format_tokens)
.unwrap_or_else(|| "".to_string());
format!(
"会话\n 标题 {}\n ID {}\n 模型 {} / {}\n 消息 {} 条用户消息,{} 条历史消息\n 创建 {}\n 最后活跃 {}\n\nToken 用量 · 已提交 Turns\n 输入 {}\n 输出 {}\n 合计 {}\n 缓存输入 {}\n 请求 {}\n Turns {}\n 统计起点 {}\n\n上下文窗口 · {}\n 占用 {} / {}{:.1}%\n 剩余 {}\n 压缩阈值 {}70%\n 最近实测 {}",
self.title,
self.session_id,
self.provider,
self.model,
self.user_message_count,
self.history_message_count,
created_at,
last_active_at,
format_tokens(self.lifetime_usage.input_tokens),
format_tokens(self.lifetime_usage.output_tokens),
format_tokens(self.lifetime_usage.total_tokens),
cached,
format_tokens(self.lifetime_usage.request_count),
format_tokens(self.lifetime_usage.turn_count),
tracked_since,
self.context.source.label(),
format_tokens(self.context.used_tokens),
format_tokens(self.context.effective_window_tokens),
percent,
format_tokens(self.context.remaining_tokens),
format_tokens(self.context.compression_threshold_tokens),
observed,
)
}
}
fn format_timestamp(value: i64) -> String {
chrono::DateTime::from_timestamp_millis(value)
.map(|timestamp| {
timestamp
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| "".to_string())
}
fn format_tokens(value: u64) -> String {
let digits = value.to_string();
let mut formatted = String::with_capacity(digits.len() + digits.len() / 3);
for (index, ch) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
formatted.push(',');
}
formatted.push(ch);
}
formatted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_large_token_counts() {
assert_eq!(format_tokens(1_234_567), "1,234,567");
assert_eq!(format_tokens(12), "12");
}
}

View File

@ -4,10 +4,12 @@ pub mod memory;
pub mod message; pub mod message;
pub mod scheduler; pub mod scheduler;
pub mod session; pub mod session;
pub mod usage;
pub use background_task::BackgroundTask; pub use background_task::BackgroundTask;
pub use error::StorageError; pub use error::StorageError;
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob}; pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
pub use usage::{SessionUsageTotals, TurnUsageRecord};
use sqlx::sqlite::{ use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous, SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous,
@ -16,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path; use std::path::Path;
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 4; const SCHEMA_VERSION: i64 = 5;
const INSERT_MESSAGE_SQL: &str = r#" const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages ( INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state, id, session_id, seq, role, content, reasoning_content, provider_state,
@ -362,6 +364,33 @@ impl Storage {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS session_turn_usage (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
cached_input_tokens INTEGER,
request_count INTEGER NOT NULL,
last_prompt_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
)
.execute(&self.pool)
.await?;
Self::init_scheduler_schema(&self.pool).await?; Self::init_scheduler_schema(&self.pool).await?;
self.migrate_schema().await?; self.migrate_schema().await?;
@ -467,6 +496,31 @@ impl Storage {
) )
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS session_turn_usage (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
cached_input_tokens INTEGER,
request_count INTEGER NOT NULL,
last_prompt_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
)
.execute(&mut *tx)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!( sqlx::query(sqlx::AssertSqlSafe(format!(
"PRAGMA user_version = {SCHEMA_VERSION}" "PRAGMA user_version = {SCHEMA_VERSION}"
))) )))
@ -803,11 +857,12 @@ impl Storage {
/// Atomically persist all messages produced by one logical turn together /// Atomically persist all messages produced by one logical turn together
/// with the resulting session metadata. A turn is either fully visible /// with the resulting session metadata. A turn is either fully visible
/// after restart or not visible at all. /// after restart or not visible at all.
pub async fn persist_message_batch( async fn persist_message_batch_inner(
&self, &self,
session_id: &str, session_id: &str,
msgs: &[crate::storage::message::MessageMeta], msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta, meta: &crate::storage::session::SessionMeta,
usage: Option<&crate::storage::TurnUsageRecord>,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
@ -848,10 +903,63 @@ impl Storage {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
if let Some(usage) = usage {
debug_assert_eq!(session_id, usage.session_id);
let request_count = msgs
.iter()
.filter_map(|message| message.iteration)
.max()
.map_or(1_i64, |iteration| iteration.saturating_add(1));
sqlx::query(
r#"
INSERT INTO session_turn_usage (
turn_id, session_id, provider, model, prompt_tokens,
completion_tokens, total_tokens, cached_input_tokens,
request_count, last_prompt_tokens, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO NOTHING
"#,
)
.bind(&usage.turn_id)
.bind(&usage.session_id)
.bind(&usage.provider)
.bind(&usage.model)
.bind(i64::from(usage.usage.prompt_tokens))
.bind(i64::from(usage.usage.completion_tokens))
.bind(i64::from(usage.usage.total_tokens))
.bind(usage.usage.cached_tokens.map(i64::from))
.bind(request_count)
.bind(i64::from(usage.last_prompt_tokens))
.bind(usage.created_at)
.execute(&mut *tx)
.await?;
}
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
} }
pub async fn persist_message_batch(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
self.persist_message_batch_inner(session_id, msgs, meta, None)
.await
}
pub async fn persist_turn_batch(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
) -> Result<(), StorageError> {
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage))
.await
}
/// Persist a turn with bounded retry. Retrying the whole transaction keeps /// Persist a turn with bounded retry. Retrying the whole transaction keeps
/// message rows and metadata consistent on transient SQLite failures. /// message rows and metadata consistent on transient SQLite failures.
pub async fn persist_message_batch_with_retry( pub async fn persist_message_batch_with_retry(
@ -874,6 +982,80 @@ impl Storage {
unreachable!() unreachable!()
} }
pub async fn persist_turn_batch_with_retry(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
) -> Result<(), StorageError> {
let delays = [100, 200, 300];
for (attempt, delay) in delays.iter().enumerate() {
match self.persist_turn_batch(session_id, msgs, meta, usage).await {
Ok(()) => return Ok(()),
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
sleep(Duration::from_millis(*delay)).await;
}
Err(error) => return Err(error),
}
}
unreachable!()
}
pub async fn get_session_usage_totals(
&self,
session_id: &str,
) -> Result<crate::storage::SessionUsageTotals, StorageError> {
let row = sqlx::query(
r#"
SELECT
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
SUM(cached_input_tokens) AS cached_input_tokens,
COALESCE(SUM(request_count), 0) AS request_count,
COUNT(*) AS turn_count,
MIN(created_at) AS tracked_since
FROM session_turn_usage
WHERE session_id = ?
"#,
)
.bind(session_id)
.fetch_one(self.pool())
.await?;
let last = sqlx::query(
r#"
SELECT last_prompt_tokens, created_at
FROM session_turn_usage
WHERE session_id = ?
ORDER BY created_at DESC, rowid DESC
LIMIT 1
"#,
)
.bind(session_id)
.fetch_optional(self.pool())
.await?;
Ok(crate::storage::SessionUsageTotals {
prompt_tokens: u64::try_from(row.get::<i64, _>("prompt_tokens")).unwrap_or_default(),
completion_tokens: u64::try_from(row.get::<i64, _>("completion_tokens"))
.unwrap_or_default(),
total_tokens: u64::try_from(row.get::<i64, _>("total_tokens")).unwrap_or_default(),
cached_input_tokens: row
.get::<Option<i64>, _>("cached_input_tokens")
.and_then(|value| u64::try_from(value).ok()),
request_count: u64::try_from(row.get::<i64, _>("request_count")).unwrap_or_default(),
turn_count: u64::try_from(row.get::<i64, _>("turn_count")).unwrap_or_default(),
tracked_since: row.get("tracked_since"),
last_prompt_tokens: last
.as_ref()
.and_then(|value| u64::try_from(value.get::<i64, _>("last_prompt_tokens")).ok()),
last_observed_at: last.map(|value| value.get("created_at")),
})
}
pub async fn load_messages( pub async fn load_messages(
&self, &self,
session_id: &str, session_id: &str,
@ -1382,6 +1564,80 @@ mod tests {
assert!(orphan.is_err()); assert!(orphan.is_err());
} }
#[tokio::test]
async fn committed_turn_usage_is_aggregated_and_idempotent() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "cli_chat:chat:dialog".to_string(),
channel: "cli_chat".to_string(),
chat_id: "chat".to_string(),
dialog_id: "dialog".to_string(),
title: "usage".to_string(),
created_at: 1,
last_active_at: 2,
message_count: 1,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
let first = crate::storage::TurnUsageRecord {
session_id: meta.id.clone(),
turn_id: "turn-1".to_string(),
provider: "test".to_string(),
model: "model".to_string(),
usage: crate::providers::Usage {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
cached_tokens: Some(40),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
last_prompt_tokens: 75,
created_at: 10,
};
storage
.persist_turn_batch(&meta.id, &[], &meta, &first)
.await
.unwrap();
storage
.persist_turn_batch(&meta.id, &[], &meta, &first)
.await
.unwrap();
let second = crate::storage::TurnUsageRecord {
turn_id: "turn-2".to_string(),
usage: crate::providers::Usage {
prompt_tokens: 50,
completion_tokens: 10,
total_tokens: 60,
cached_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
last_prompt_tokens: 45,
created_at: 20,
..first
};
storage
.persist_turn_batch(&meta.id, &[], &meta, &second)
.await
.unwrap();
let totals = storage.get_session_usage_totals(&meta.id).await.unwrap();
assert_eq!(totals.prompt_tokens, 150);
assert_eq!(totals.completion_tokens, 30);
assert_eq!(totals.total_tokens, 180);
assert_eq!(totals.cached_input_tokens, Some(40));
assert_eq!(totals.request_count, 2);
assert_eq!(totals.turn_count, 2);
assert_eq!(totals.tracked_since, Some(10));
assert_eq!(totals.last_prompt_tokens, Some(45));
assert_eq!(totals.last_observed_at, Some(20));
}
#[tokio::test] #[tokio::test]
async fn reopening_database_does_not_rebuild_existing_fts_index() { async fn reopening_database_does_not_rebuild_existing_fts_index() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -1629,7 +1885,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(schema_version, SCHEMA_VERSION); assert_eq!(schema_version, SCHEMA_VERSION);
for table in ["task_plans", "task_items"] { for table in ["task_plans", "task_items", "session_turn_usage"] {
let exists: i64 = sqlx::query_scalar( let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
) )

28
src/storage/usage.rs Normal file
View File

@ -0,0 +1,28 @@
use crate::providers::Usage;
/// Provider-reported usage committed with one durable assistant Turn.
#[derive(Debug, Clone)]
pub struct TurnUsageRecord {
pub session_id: String,
pub turn_id: String,
pub provider: String,
pub model: String,
pub usage: Usage,
/// Prompt usage from the final provider request in the Turn. Unlike
/// `usage.prompt_tokens`, this is not accumulated across tool iterations.
pub last_prompt_tokens: u32,
pub created_at: i64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionUsageTotals {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub request_count: u64,
pub turn_count: u64,
pub tracked_since: Option<i64>,
pub last_prompt_tokens: Option<u64>,
pub last_observed_at: Option<i64>,
}

View File

@ -1,12 +1,12 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.3.1", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.3.1", "version": "1.4.0",
"dependencies": { "dependencies": {
"bits-ui": "^2.0.0", "bits-ui": "^2.0.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"private": true, "private": true,
"version": "1.3.1", "version": "1.4.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -3,6 +3,8 @@ import { clientId } from "./api.js";
class ChatClient { class ChatClient {
connected = $state(false); connected = $state(false);
turn = $state(null); // 最新 turn 快照(任意 session供活动脊 turn = $state(null); // 最新 turn 快照(任意 session供活动脊
currentSessionId = $state(null);
statsBySession = $state({});
#socket = null; #socket = null;
#handlers = new Set(); #handlers = new Set();
#reconnectTimer = null; #reconnectTimer = null;
@ -33,6 +35,9 @@ class ChatClient {
let frame; let frame;
try { frame = JSON.parse(event.data); } catch { return; } try { frame = JSON.parse(event.data); } catch { return; }
if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot; if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
if (frame.type === "session_stats" && frame.stats?.session_id) {
this.statsBySession[frame.stats.session_id] = frame.stats;
}
this.#dispatch(frame); this.#dispatch(frame);
}; };
} }
@ -53,6 +58,10 @@ class ChatClient {
return false; return false;
} }
get currentStats() {
return this.currentSessionId ? this.statsBySession[this.currentSessionId] ?? null : null;
}
subscribe(handler) { subscribe(handler) {
this.#handlers.add(handler); this.#handlers.add(handler);
return () => this.#handlers.delete(handler); return () => this.#handlers.delete(handler);

View File

@ -2,51 +2,151 @@
import { chat } from "../chat.svelte.js"; import { chat } from "../chat.svelte.js";
let { version = "" } = $props(); let { version = "" } = $props();
let lastTokens = null; // { at, completion } — plain bookkeeping, NOT reactive
let rate = $state(null);
$effect(() => { const running = $derived(chat.turn?.status === "running" && chat.turn?.session_id === chat.currentSessionId);
const turn = chat.turn; const turnLabel = $derived(running ? `Turn ${String(chat.turn?.id ?? "").slice(0, 6).toUpperCase()}` : "");
if (!turn || turn.status !== "running") { rate = null; lastTokens = null; return; } const stats = $derived(chat.currentStats);
const completion = turn.usage?.completion_tokens; const context = $derived(stats?.context ?? null);
const now = Date.now(); const lifetime = $derived(stats?.lifetime_usage ?? null);
if (completion != null && lastTokens && now > lastTokens.at) { const percent = $derived(context?.effective_window_tokens
const delta = completion - lastTokens.completion; ? context.used_tokens / context.effective_window_tokens * 100
const secs = (now - lastTokens.at) / 1000; : 0);
if (delta >= 0 && secs > 0) rate = Math.round(delta / secs); const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
const pressure = $derived(percent >= 90 ? "danger" : percent >= 70 ? "warning" : "normal");
function compactTokens(value) {
if (!Number.isFinite(value)) return "—";
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 1 : 2)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
return String(value);
} }
if (completion != null) lastTokens = { at: now, completion };
});
const running = $derived(chat.turn?.status === "running"); function exactTokens(value) {
const turnLabel = $derived(chat.turn ? `Turn ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : ""); return Number.isFinite(value) ? new Intl.NumberFormat("zh-CN").format(value) : "—";
const ctx = $derived(chat.turn?.usage?.prompt_tokens != null }
? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
function sourceLabel(value) {
return value === "hybrid" ? "混合估算" : "字符估算";
}
function trackedSince(value) {
if (!Number.isFinite(value)) return "尚无已提交 Turn";
return `自 ${new Date(value).toLocaleString("zh-CN", { hour12: false })}`;
}
</script> </script>
<div class="spine mono" title={version}> <details class="spine-shell mono" data-pressure={pressure} title={version}>
<summary class="spine">
{#if running} {#if running}
<span class="spine-turn active"><i class="pulse-dot active"></i>{turnLabel} · 生成中</span> <span class="spine-turn active"><i class="pulse-dot active"></i>{turnLabel} · 生成中</span>
{#if rate != null}<span class="spine-rate">{rate} tok/s</span>{/if}
{#if ctx}<span>ctx {ctx}</span>{/if}
{:else if chat.turn}
<span class="spine-turn idle"><i class="pulse-dot idle"></i>空闲</span>
<span class="spine-context">最近 {turnLabel}</span>
{:else} {:else}
<span class="spine-turn idle"><i class="pulse-dot idle"></i>就绪</span> <span class="spine-turn idle"><i class="pulse-dot idle"></i>{chat.connected ? "空闲" : "重连中"}</span>
{/if} {/if}
<span class="spine-right">
<span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span> {#if context}
<span class="context-readout">
<span class="context-label">上下文</span>
<span
class="context-rail"
role="progressbar"
aria-label="当前上下文窗口占用"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(boundedPercent)}
style={`--context-fill: ${boundedPercent}%`}
><i></i><b></b></span>
<span class="context-value">{compactTokens(context.used_tokens)} / {compactTokens(context.effective_window_tokens)}</span>
<strong class="context-percent">{percent.toFixed(1)}%</strong>
</span> </span>
</div> <span class="usage-summary"><span>{compactTokens(lifetime?.input_tokens)}</span><span>{compactTokens(lifetime?.output_tokens)}</span></span>
{:else}
<span class="context-pending">等待会话统计</span>
{/if}
<span class="spine-right" class:spine-ok={chat.connected} class:spine-down={!chat.connected}>
{chat.connected ? "已连接" : "重连中"}
</span>
</summary>
{#if stats && context && lifetime}
<section class="stats-panel" aria-label="会话 Token 与上下文详情">
<header>
<div><span>当前上下文</span><strong>{percent.toFixed(1)}%</strong></div>
<small>{sourceLabel(context.source)}</small>
</header>
<div class="detail-rail" role="presentation" style={`--context-fill: ${boundedPercent}%`}><i></i><b></b></div>
<dl class="context-grid">
<div><dt>占用</dt><dd>{exactTokens(context.used_tokens)} / {exactTokens(context.effective_window_tokens)}</dd></div>
<div><dt>剩余</dt><dd>{exactTokens(context.remaining_tokens)}</dd></div>
<div><dt>压缩阈值</dt><dd>{exactTokens(context.compression_threshold_tokens)} · 70%</dd></div>
<div><dt>最近实测</dt><dd>{exactTokens(context.last_observed_prompt_tokens)}</dd></div>
</dl>
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
<dl class="usage-grid">
<div><dt>输入</dt><dd>{exactTokens(lifetime.input_tokens)}</dd></div>
<div><dt>输出</dt><dd>{exactTokens(lifetime.output_tokens)}</dd></div>
<div><dt>缓存输入</dt><dd>{exactTokens(lifetime.cached_input_tokens)}</dd></div>
<div><dt>请求 / Turns</dt><dd>{exactTokens(lifetime.request_count)} / {exactTokens(lifetime.turn_count)}</dd></div>
</dl>
<footer>{stats.provider} / {stats.model}<span>{trackedSince(lifetime.tracked_since)}</span></footer>
</section>
{/if}
</details>
<style> <style>
.spine { display: flex; align-items: center; gap: 10px; min-height: 32px; margin-left: auto; padding: 0 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 11.5px; color: var(--text-soft); background: var(--panel-2); font-variant-numeric: tabular-nums; } .spine-shell { position: relative; margin-left: auto; min-width: 0; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; } .spine-shell summary { list-style: none; }
.spine-turn.active, .spine-rate { color: var(--accent); } .spine-shell summary::-webkit-details-marker { display: none; }
.spine { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 0 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 11.5px; color: var(--text-soft); background: var(--panel-2); font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; }
.spine:hover { border-color: var(--line-strong); background: var(--color-neutral-background-4); }
.spine:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; white-space: nowrap; }
.spine-turn.active { color: var(--accent); }
.spine-turn.idle, .spine-ok { color: var(--signal); } .spine-turn.idle, .spine-ok { color: var(--signal); }
.spine-right { display: inline-flex; gap: 10px; padding-left: 10px; border-left: 1px solid var(--line); color: var(--muted); } .context-readout { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.context-label { color: var(--muted); }
.context-rail, .detail-rail { position: relative; overflow: hidden; background: var(--color-neutral-background-4); }
.context-rail { width: 74px; height: 6px; border-radius: 99px; }
.context-rail i, .detail-rail i { position: absolute; inset: 0 auto 0 0; width: var(--context-fill); background: var(--accent); }
.context-rail b, .detail-rail b { position: absolute; inset: 0 auto 0 70%; width: 1px; background: var(--warning); }
.context-value { color: var(--text-soft); }
.context-percent { color: var(--accent); font-weight: 700; }
[data-pressure="warning"] .context-percent, [data-pressure="warning"] .context-rail i, [data-pressure="warning"] .detail-rail i { color: var(--warning); background: var(--warning); }
[data-pressure="danger"] .context-percent, [data-pressure="danger"] .context-rail i, [data-pressure="danger"] .detail-rail i { color: var(--danger); background: var(--danger); }
.usage-summary { display: inline-flex; gap: 8px; padding-left: 10px; border-left: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.spine-right { padding-left: 10px; border-left: 1px solid var(--line); white-space: nowrap; }
.spine-down { color: var(--danger); } .spine-down { color: var(--danger); }
@media (max-width: 1040px) { .spine-context, .spine-rate, .spine > span:not(.spine-turn):not(.spine-right) { display: none; } } .context-pending { color: var(--muted); }
@media (max-width: 680px) { .spine { display: none; } }
.stats-panel { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(390px, calc(100vw - 28px)); padding: 17px; border: 1px solid var(--line-strong); border-radius: 10px; color: var(--text); background: var(--overlay); box-shadow: var(--shadow-16); font-family: var(--font-ui); }
.stats-panel header, .usage-heading, .stats-panel footer { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.stats-panel header div { display: flex; align-items: baseline; gap: 10px; }
.stats-panel header span, .usage-heading span { font-size: 12px; font-weight: 700; }
.stats-panel header strong { font-family: var(--font-mono); font-size: 21px; letter-spacing: -0.04em; }
.stats-panel small, .stats-panel footer { color: var(--muted); font-size: 10.5px; }
.detail-rail { height: 8px; margin: 12px 0 15px; border-radius: 99px; }
.context-grid, .usage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: var(--line); }
.context-grid div, .usage-grid div { min-width: 0; padding: 9px 10px; background: var(--panel); }
dt { color: var(--muted); font-size: 10.5px; }
dd { margin: 3px 0 0; overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.usage-heading { margin: 16px 0 8px; }
.stats-panel footer { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--line); }
.stats-panel footer span { text-align: right; }
@media (max-width: 1180px) {
.context-value, .usage-summary { display: none; }
.context-rail { width: 58px; }
}
@media (max-width: 760px) {
.spine-shell { margin-left: 0; }
.spine { padding: 0 9px; }
.spine-turn, .context-label, .context-rail, .spine-right, .context-pending { display: none; }
.context-readout { gap: 4px; }
.context-percent::before { content: "上下文 "; color: var(--muted); font-weight: 400; }
.stats-panel { position: fixed; top: auto; right: 14px; bottom: 14px; left: 14px; width: auto; }
}
@media (prefers-reduced-motion: reduce) {
.context-rail i, .detail-rail i { transition: none; }
}
</style> </style>

View File

@ -55,14 +55,22 @@
function handleFrame(frame) { function handleFrame(frame) {
switch (frame.type) { switch (frame.type) {
case "session_established": currentId = frame.session_id; activeTurn = null; historyRevision = 0; break; case "session_established":
currentId = frame.session_id;
chat.currentSessionId = currentId;
activeTurn = null;
historyRevision = 0;
send({ type: "get_session_stats", session_id: currentId });
break;
case "session_list": case "session_list":
sessions = frame.sessions || []; sessions = frame.sessions || [];
if (frame.current_session_id) currentId = frame.current_session_id; if (frame.current_session_id) currentId = frame.current_session_id;
chat.currentSessionId = currentId;
if (currentId && messages.length === 0) loadSession(currentId); if (currentId && messages.length === 0) loadSession(currentId);
break; break;
case "session_created": case "session_created":
currentId = frame.session_id; currentId = frame.session_id;
chat.currentSessionId = currentId;
messages = []; messages = [];
activeTurn = null; activeTurn = null;
historyRevision = 0; historyRevision = 0;
@ -70,10 +78,12 @@
break; break;
case "session_loaded": case "session_loaded":
currentId = frame.session_id; currentId = frame.session_id;
chat.currentSessionId = currentId;
activeTurn = null; activeTurn = null;
historyRevision = 0; historyRevision = 0;
send({ type: "get_session_history", session_id: currentId, limit: 1000 }); send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "get_session_plan", session_id: currentId }); send({ type: "get_session_plan", session_id: currentId });
send({ type: "get_session_stats", session_id: currentId });
break; break;
case "session_history": case "session_history":
if (frame.session_id === currentId) { if (frame.session_id === currentId) {
@ -118,13 +128,16 @@
if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 }); if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 });
} }
send({ type: "list_sessions", include_archived: false }); send({ type: "list_sessions", include_archived: false });
if (currentId) send({ type: "get_session_stats", session_id: currentId });
break; break;
case "turn_updated": { case "turn_updated": {
const next = frame.snapshot; const next = frame.snapshot;
if (!next || next.session_id !== currentId) break; if (!next || next.session_id !== currentId) break;
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break; if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
const firstSnapshotForTurn = activeTurn?.id !== next.id;
activeTurn = next; activeTurn = next;
thinking = next.status === "running"; thinking = next.status === "running";
if (firstSnapshotForTurn) send({ type: "get_session_stats", session_id: currentId });
if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) { if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) {
activeTurn = null; activeTurn = null;
} }
@ -148,12 +161,18 @@
activeTurn = null; activeTurn = null;
} }
scrollToBottom(); scrollToBottom();
send({ type: "get_session_stats", session_id: currentId });
break; break;
} }
case "session_stats": break;
case "system_notification": case "system_notification":
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content); if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
break; break;
case "command_executed": thinking = false; appendMessage("assistant", frame.message); break; case "command_executed":
thinking = false;
appendMessage("assistant", frame.message);
if (currentId) send({ type: "get_session_stats", session_id: currentId });
break;
case "error": thinking = false; notify(frame.message || frame.code, true); break; case "error": thinking = false; notify(frame.message || frame.code, true); break;
} }
} }
@ -171,6 +190,7 @@
function loadSession(id) { function loadSession(id) {
if (!id) return; if (!id) return;
currentId = id; currentId = id;
chat.currentSessionId = id;
historyRevision = 0; historyRevision = 0;
clearPendingUploads(); clearPendingUploads();
messages = []; messages = [];
@ -399,7 +419,7 @@
</button> </button>
{/if} {/if}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) send({ type: "get_session_plan", session_id: currentId }); }}><Icon name="refresh" size={18} /></Tooltip.Trigger> <Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) { send({ type: "get_session_plan", session_id: currentId }); send({ type: "get_session_stats", session_id: currentId }); } }}><Icon name="refresh" size={18} /></Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal> <Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
</div> </div>