Skip to content

Commit cddc28f

Browse files
feat(frontend): 状态栏展示 prompt 用量与上下文占比 / status bar prompt usage vs context cap
Co-authored-by: Yales Peter <noisystreet@users.noreply.github.com>
1 parent ec16c11 commit cddc28f

10 files changed

Lines changed: 254 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
## 功能概览
3131

3232
- **对话与工具**:OpenAI 兼容 `chat/completions`;内置文件/工作区、**`run_command`**(白名单;默认含 **`bash`/`sh`**,复合命令用 **`bash -c`/`sh -c`**)、HTTP/搜索、工作区**代码检索**(关键字 + 可选语义/向量)等;完整列表见 [docs/工具说明.md](docs/工具说明.md)**`run_command`** 等子进程工具输出默认按 **`command_max_output_len`**(嵌入默认 **512KiB**`CM_COMMAND_MAX_OUTPUT_LEN` 可覆盖)做字节截断,详见 **`config/tools.toml`**[docs/配置说明.md](docs/配置说明.md)
33-
- **Web UI**:侧栏会话与工作区;须**显式选择工作区**后工具与 **`@相对路径`** 才生效;浏览器内会话列表按**当前工作区根路径**分桶保存在 `localStorage`,切换工作区会加载该路径下曾保存的会话(未设置工作区前仍使用与旧版相同的默认键)。助手 **Markdown**;支持 **`@` 引用**、图片附件(须视觉模型)、会话导出等。全屏「设置」中 **「会话」** 页可切换本进程是否将服务端会话写入 SQLite(与配置文件中 **`conversation_store_sqlite_path`** 是否可启用一致;**重启 `serve`** 后仍以配置文件为准)。详细路由与行为见 [docs/命令行与路由.md](docs/命令行与路由.md)
33+
- **Web UI**:侧栏会话与工作区;须**显式选择工作区**后工具与 **`@相对路径`** 才生效;浏览器内会话列表按**当前工作区根路径**分桶保存在 `localStorage`,切换工作区会加载该路径下曾保存的会话(未设置工作区前仍使用与旧版相同的默认键)。助手 **Markdown**;支持 **`@` 引用**、图片附件(须视觉模型)、会话导出等。与服务器会话同步后,底栏可显示当前消息的 **prompt** tiktoken 粗估用量及相对 **`llm_context_tokens`** 上限的占比(详见 `title` 提示)。全屏「设置」中 **「会话」** 页可切换本进程是否将服务端会话写入 SQLite(与配置文件中 **`conversation_store_sqlite_path`** 是否可启用一致;**重启 `serve`** 后仍以配置文件为准)。详细路由与行为见 [docs/命令行与路由.md](docs/命令行与路由.md)
3434
- **终端****`repl`**(交互)、**`chat`**(单次)、**`serve`**(HTTP + 静态 UI)、**`tui`**(实验性**全屏**,须真实 TTY,见下文)。流式 **SSE**、工具审批与取消约定见 [docs/SSE协议.md](docs/SSE协议.md)
3535
- **会话与导出**:嵌入默认在**当前工作区** **`.crabmate/conversations.db`** 持久化 **Web `serve`**(及配置了同路径的 **`tui`**)对话,**`serve` 重启**后仍可按 **`conversation_id`** 续聊;不需要时在配置里将 **`conversation_store_sqlite_path`** 置空。Web 或 CLI **`save-session`**(别名 **`export-session`**)导出 JSON/Markdown,形状见 [docs/命令行与路由.md](docs/命令行与路由.md)
3636
- **进阶(默认不必读)**:分阶段规划时间线、澄清问卷、调试台 **`thinking_trace`**、长期记忆、活文档注入、**MCP**、工作区 **`plugins/*.json`** 等见 [docs/配置说明.md](docs/配置说明.md)[docs/工具说明.md](docs/工具说明.md)

frontend/src/app/app_signals/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ impl AppSignals {
9191
stream_last_sse_event_seq: RwSignal::new(0),
9292
reasoning_preserved: RwSignal::new(HashMap::new()),
9393
stream_text_overlay: RwSignal::new(None),
94+
conversation_prompt_tokens: RwSignal::new(None),
9495
};
9596

9697
Self {

frontend/src/app/chat/session_hydrate.rs

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,11 @@ fn messages_contain_loading(messages: &[StoredMessage]) -> bool {
2424
.any(|m| m.state.as_ref().is_some_and(|s| s.is_loading()))
2525
}
2626

27-
fn trimmed_server_conversation_id(session: &ChatSession) -> Option<&str> {
28-
session
29-
.server_conversation_id
30-
.as_deref()
31-
.map(str::trim)
32-
.filter(|x| !x.is_empty())
33-
}
34-
35-
/// 当前会话是否允许发起「拉取服务端消息」水合:已绑定 `conversation_id` 且无 `Loading` 占位。
3627
fn conversation_server_id_if_hydratable_for_wire(s: &ChatSession) -> Option<String> {
3728
if messages_contain_loading(&s.messages) {
3829
return None;
3930
}
40-
trimmed_server_conversation_id(s).map(str::to_string)
31+
s.trimmed_server_conversation_id().map(str::to_string)
4132
}
4233

4334
/// 服务端快照中不存在的本地消息:工具卡与 TimelineLog,须保留并与快照合并。
@@ -112,7 +103,7 @@ fn try_hydration_merge_precheck(
112103
if messages_contain_loading(&session.messages) {
113104
return Err(SessionHydrationMergeOutcome::SkippedLoadingPlaceholders);
114105
}
115-
let still = trimmed_server_conversation_id(session);
106+
let still = session.trimmed_server_conversation_id();
116107
if still != Some(cid) {
117108
return Err(SessionHydrationMergeOutcome::SkippedConversationIdMismatch);
118109
}
@@ -260,7 +251,7 @@ pub(crate) mod conversation_hydration_cycle {
260251
use leptos::prelude::*;
261252

262253
use crate::api::fetch_conversation_messages;
263-
use crate::chat_session_state::ChatSessionSignals;
254+
use crate::chat_session_state::{ChatSessionSignals, ConversationPromptTokenHydrate};
264255
use crate::conversation_hydrate::stored_messages_from_conversation_api;
265256

266257
use super::{
@@ -322,6 +313,12 @@ pub(crate) mod conversation_hydration_cycle {
322313
return;
323314
}
324315

316+
chat.conversation_prompt_tokens
317+
.set(Some(ConversationPromptTokenHydrate {
318+
conversation_id: cid.clone(),
319+
tiktoken: resp.tiktoken_prompt_tokens.clone(),
320+
}));
321+
325322
restore_reasoning_after_hydration(&chat, &aid, nonce_at_start);
326323
apply_saved_revision_if_same_conversation(&chat, cid.as_str(), resp.revision);
327324
}
@@ -336,6 +333,27 @@ async fn run_conversation_hydration_cycle(
336333
conversation_hydration_cycle::run(snap, chat, selected_agent_role).await;
337334
}
338335

336+
fn clear_conversation_prompt_tokens_if_no_server_conversation(chat: ChatSessionSignals) {
337+
let aid = chat.active_id.get_untracked();
338+
if aid.is_empty() {
339+
chat.conversation_prompt_tokens.set(None);
340+
return;
341+
}
342+
let Some(sess) = chat
343+
.sessions
344+
.with_untracked(|list| list.iter().find(|s| s.id == aid).cloned())
345+
else {
346+
chat.conversation_prompt_tokens.set(None);
347+
return;
348+
};
349+
if messages_contain_loading(&sess.messages) {
350+
return;
351+
}
352+
if sess.trimmed_server_conversation_id().is_none() {
353+
chat.conversation_prompt_tokens.set(None);
354+
}
355+
}
356+
339357
/// 订阅 `chat.session_hydrate_nonce`:流结束后由 composer 递增,拉取服务端快照并写回当前会话。
340358
///
341359
/// 门闸与 [`crate::app::app_bootstrap_phase::AppBootstrapPhase::hydration_effects_enabled`] 一致(`initialized` + `web_ui_config_loaded`)。
@@ -358,6 +376,7 @@ pub fn wire_session_hydration(
358376
}
359377
let loc = locale_sig.get_untracked();
360378
let Some(snap) = try_hydration_wire_snapshot(chat, loc) else {
379+
clear_conversation_prompt_tokens_if_no_server_conversation(chat);
361380
return;
362381
};
363382
spawn_local(run_conversation_hydration_cycle(

frontend/src/app/status_bar.rs

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ use leptos::prelude::*;
66
use leptos_dom::helpers::event_target_value;
77

88
use crate::api::load_client_llm_text_fields_from_storage;
9-
use crate::app_prefs::{status_bar_effective_api_base, status_bar_effective_model};
10-
use crate::chat_session_state::ChatStreamBusyMemos;
9+
use crate::app_prefs::{
10+
status_bar_effective_api_base, status_bar_effective_llm_context_tokens,
11+
status_bar_effective_model,
12+
};
13+
use crate::chat_session_state::{ChatSessionSignals, ChatStreamBusyMemos};
1114

1215
use super::app_shell_ctx::StatusBarFooterSignals;
1316
use super::shell_runtime_context::expect_chat_shell_ctx;
@@ -50,6 +53,115 @@ struct StatusBarChipsSignals {
5053
locale: RwSignal<Locale>,
5154
}
5255

56+
fn status_bar_context_chip_visible(chat: ChatSessionSignals) -> bool {
57+
let Some(snap) = chat.conversation_prompt_tokens.get() else {
58+
return false;
59+
};
60+
let aid = chat.active_id.get();
61+
chat.sessions.with(|list| {
62+
list.iter().find(|s| s.id == aid).is_some_and(|s| {
63+
s.trimmed_server_conversation_id()
64+
.is_some_and(|c| c == snap.conversation_id.as_str())
65+
})
66+
})
67+
}
68+
69+
fn status_bar_context_cap_and_used(
70+
chat: ChatSessionSignals,
71+
st: StatusTasksSignals,
72+
client_llm_storage_tick: RwSignal<u64>,
73+
) -> (u32, Option<u32>) {
74+
let _tick = client_llm_storage_tick.get();
75+
let sd = st.status_data.get();
76+
let (_, _, _, stored_ctx, _) = load_client_llm_text_fields_from_storage();
77+
let cap = status_bar_effective_llm_context_tokens(sd.as_ref(), stored_ctx.as_str());
78+
let used = chat
79+
.conversation_prompt_tokens
80+
.get()
81+
.and_then(|s| s.tiktoken.as_ref().map(|t| t.prompt_tokens));
82+
(cap, used)
83+
}
84+
85+
fn status_bar_context_value_text(cap: u32, used: Option<u32>) -> String {
86+
match (used, cap > 0) {
87+
(Some(u), true) => {
88+
let pct = (u as f64 / cap as f64) * 100.0;
89+
format!("{u} / {cap} ({:.1}%)", pct.min(999.9))
90+
}
91+
(Some(u), false) => format!("{u}"),
92+
(None, true) => format!("— / {cap}"),
93+
(None, false) => "—".to_string(),
94+
}
95+
}
96+
97+
#[component]
98+
fn StatusBarContextChip(
99+
st: StatusTasksSignals,
100+
chat: ChatSessionSignals,
101+
client_llm_storage_tick: RwSignal<u64>,
102+
locale: RwSignal<Locale>,
103+
) -> impl IntoView {
104+
view! {
105+
<Show when=move || status_bar_context_chip_visible(chat)>
106+
<span
107+
class="status-chip status-chip-context"
108+
prop:title=move || i18n::status_chip_context_tooltip(locale.get())
109+
>
110+
<span class="status-chip-context-row">
111+
<span class="status-chip-label">
112+
{move || i18n::status_chip_context(locale.get())}
113+
</span>
114+
<span class="status-chip-value">{move || {
115+
let (cap, used) = status_bar_context_cap_and_used(
116+
chat,
117+
st,
118+
client_llm_storage_tick,
119+
);
120+
status_bar_context_value_text(cap, used)
121+
}}</span>
122+
</span>
123+
<Show when=move || {
124+
let (cap, used) = status_bar_context_cap_and_used(
125+
chat,
126+
st,
127+
client_llm_storage_tick,
128+
);
129+
cap > 0 && used.is_some()
130+
}>
131+
<div
132+
class="status-context-meter"
133+
style=move || {
134+
let (cap, used) = status_bar_context_cap_and_used(
135+
chat,
136+
st,
137+
client_llm_storage_tick,
138+
);
139+
let u = used.unwrap_or(0);
140+
let pct = ((u as f64 / cap as f64) * 100.0).min(100.0);
141+
format!("--status-context-pct: {pct:.2}%")
142+
}
143+
>
144+
<div class=move || {
145+
let (cap, used) = status_bar_context_cap_and_used(
146+
chat,
147+
st,
148+
client_llm_storage_tick,
149+
);
150+
let u = used.unwrap_or(0);
151+
let pct = (u as f64 / cap as f64) * 100.0;
152+
if pct >= 90.0 {
153+
"status-context-meter-fill status-context-meter-fill--warn"
154+
} else {
155+
"status-context-meter-fill"
156+
}
157+
}></div>
158+
</div>
159+
</Show>
160+
</span>
161+
</Show>
162+
}
163+
}
164+
53165
#[component]
54166
fn StatusBarChipsSkeleton(locale: RwSignal<Locale>) -> impl IntoView {
55167
view! {
@@ -162,6 +274,12 @@ fn StatusBarChipsLoaded(
162274
}}
163275
</select>
164276
</label>
277+
<StatusBarContextChip
278+
st=st
279+
chat=chat
280+
client_llm_storage_tick=client_llm_storage_tick
281+
locale=locale
282+
/>
165283
</>
166284
}
167285
}

frontend/src/app_prefs.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ pub fn status_bar_effective_api_base(server: Option<&StatusData>, stored_api_bas
120120
}
121121
}
122122

123+
/// 状态栏「上下文窗口 token 上限」:本机 `client_llm.llm_context_tokens` 非空且可解析为正数时优先,否则用 `/status`。
124+
#[must_use]
125+
pub fn status_bar_effective_llm_context_tokens(
126+
server: Option<&StatusData>,
127+
stored_llm_context_tokens: &str,
128+
) -> u32 {
129+
let t = stored_llm_context_tokens.trim();
130+
if !t.is_empty() {
131+
if let Ok(n) = t.parse::<u32>() {
132+
if n > 0 {
133+
return n;
134+
}
135+
}
136+
}
137+
server.map(|d| d.llm_context_tokens).unwrap_or(0)
138+
}
139+
123140
pub fn clamp_side_width_for_viewport(w: f64) -> f64 {
124141
let win = web_sys::window()
125142
.and_then(|win| win.inner_width().ok())

frontend/src/chat_session_state.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//! - **`stream_transport`**:[`ChatStreamTransport`] 单 **`RwSignal`** 收敛 **attach 代际** + **Idle / 已绑定会话**(内含 **`job_id`**);[`ChatSessionSignals::clear_stream_resume_handles`] 将车道置回 Idle 并重置 [`Self::stream_last_sse_event_seq`];[`ChatSessionSignals::bind_stream_to_session`] 在 bump 后写入 Bound。SSE `id:` 序号单独为 **`stream_last_sse_event_seq`**,避免热路径上整包传输状态无效化。与 **`stream_text_overlay`** 的联合象限见 [`StreamLaneOverlayPhase`] / [`ChatSessionSignals::stream_lane_overlay_phase_untracked`]。
77
//! - **代际门闩**:每次发起新 `/chat/stream` attach 时递增 [`ChatStreamTransport::attach_generation`];[`crate::app::chat::composer_stream::context::ChatStreamCallbackCtx`] 捕获该值,回调内若与当前不一致则视为陈旧。
88
//! - **`session_sync`**:服务端 `conversation_id` / revision,与 `POST /chat/branch` 等对齐。
9-
//! - **`session_hydrate_nonce` / `reasoning_preserved`**:拉取会话正文与水合时的补偿字段。
9+
//! - **`session_hydrate_nonce` / `reasoning_preserved` / `conversation_prompt_tokens`**:拉取会话正文与水合时的补偿字段;后者供底栏展示 prompt token 粗估
1010
//! - **`stream_text_overlay`**:尾条 `loading` 助手消息的流式正文/思维链旁路缓冲(字段见 [`ChatSessionSignals`])。
1111
//!
1212
//! # `sessions` 向量的写入通道(命名封装)
@@ -26,10 +26,18 @@ use std::sync::Arc;
2626

2727
use leptos::prelude::*;
2828

29+
use crate::conversation_hydrate::TiktokenPromptTokensSnapshot;
2930
use crate::session_sync::SessionSyncState;
3031
use crate::storage::ChatSession;
3132
use crate::stream_text_overlay::StreamTextOverlay;
3233

34+
/// 水合成功后与会话绑定的 tiktoken prompt 粗估(见 `GET /conversation/messages`)。
35+
#[derive(Clone, Debug, PartialEq, Eq)]
36+
pub struct ConversationPromptTokenHydrate {
37+
pub conversation_id: String,
38+
pub tiktoken: Option<TiktokenPromptTokensSnapshot>,
39+
}
40+
3341
/// `/chat/stream` 传输层:单调 **`attach_generation`** 与 **`lane`**(Idle | 已绑定会话及重连句柄)。
3442
#[derive(Clone, Debug, PartialEq, Eq, Default)]
3543
pub struct ChatStreamTransport {
@@ -239,6 +247,8 @@ pub struct ChatSessionSignals {
239247
pub reasoning_preserved: RwSignal<HashMap<String, String>>,
240248
/// 当前尾条 `loading` 助手消息的流式正文/思维链增量;**不**写入 [`Self::sessions`],减少历史行重算。
241249
pub stream_text_overlay: RwSignal<Option<StreamTextOverlay>>,
250+
/// 最近一次成功水合的 tiktoken prompt 计数(与 [`ConversationPromptTokenHydrate::conversation_id`] 对齐,防串会话)。
251+
pub conversation_prompt_tokens: RwSignal<Option<ConversationPromptTokenHydrate>>,
242252
}
243253

244254
impl ChatSessionSignals {

frontend/src/conversation_hydrate.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,14 @@ pub struct ConversationMessagesResponse {
2020
pub revision: u64,
2121
#[serde(default)]
2222
pub active_agent_role: Option<String>,
23-
/// 服务端可选返回;水合路径当前未消费,保留供 UI/调试读取。
2423
#[serde(default)]
25-
#[allow(dead_code)]
2624
pub tiktoken_prompt_tokens: Option<TiktokenPromptTokensSnapshot>,
2725
pub messages: Vec<Value>,
2826
}
2927

30-
#[derive(Debug, Clone, Deserialize)]
28+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
3129
pub struct TiktokenPromptTokensSnapshot {
32-
#[allow(dead_code)]
3330
pub prompt_tokens: u32,
34-
#[allow(dead_code)]
3531
pub tiktoken_model: String,
3632
}
3733

frontend/src/i18n/status.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ pub fn status_chip_base_url(_l: Locale) -> &'static str {
3434
"base_url"
3535
}
3636

37+
pub fn status_chip_context(l: Locale) -> &'static str {
38+
match l {
39+
Locale::ZhHans => "上下文",
40+
Locale::En => "Context",
41+
}
42+
}
43+
44+
/// 状态栏「上下文」芯片 `title`:说明 tiktoken 粗估与上限含义。
45+
pub fn status_chip_context_tooltip(l: Locale) -> &'static str {
46+
match l {
47+
Locale::ZhHans => {
48+
"当前会话消息体的 prompt tokens(tiktoken 粗估,与出站消息一致)相对 llm_context_tokens 上限;不含工具 JSON,与网关真实计费可能有偏差。"
49+
}
50+
Locale::En => {
51+
"Prompt tokens for stored message bodies (tiktoken estimate, aligned with outbound messages) vs llm_context_tokens cap; excludes tool JSON and may differ from provider billing."
52+
}
53+
}
54+
}
55+
3756
pub fn status_role_label(l: Locale) -> &'static str {
3857
match l {
3958
Locale::ZhHans => "角色",

frontend/src/storage.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ pub struct ChatSession {
180180
pub workspace_root: Option<String>,
181181
}
182182

183+
impl ChatSession {
184+
/// 非空且 trim 后的 `server_conversation_id`;与 `GET /conversation/messages` 路径参数对齐。
185+
#[must_use]
186+
pub fn trimmed_server_conversation_id(&self) -> Option<&str> {
187+
self.server_conversation_id
188+
.as_deref()
189+
.map(str::trim)
190+
.filter(|x| !x.is_empty())
191+
}
192+
}
193+
183194
/// 进程重启后不再有挂起的 SSE;本地持久化的助手 `loading` 占位若不清理会永久显示「生成中」。
184195
/// 在从 `localStorage` 恢复会话列表时调用(见 `wire_initial_sessions_from_storage`)。
185196
pub fn clear_stale_assistant_loading_states(messages: &mut [StoredMessage]) {

0 commit comments

Comments
 (0)