diff --git a/assets/i18n/desktop/en.json b/assets/i18n/desktop/en.json index dbd600a..45bd2cc 100644 --- a/assets/i18n/desktop/en.json +++ b/assets/i18n/desktop/en.json @@ -268,6 +268,10 @@ "ChatRequestFailed": "Request failed", "ChatSessionRefreshFailed": "Refreshing the session failed.", "ChatSessionStatsCacheHit": "cache hit {percent}%", + "ChatSessionStatsCostEstimatedTooltip": "Estimated from each model's token counts and prices, subagents included.", + "ChatSessionStatsCostBilledTooltip": "The cost {agent} recorded.", + "ChatSessionStatsCostSubagents": "Subagents: {usd}", + "ChatSessionStatsCostUnpriced": "No price, left out: {models}", "ChatSessionStatsTokens": "{tokens} tok", "ChatSessionStatsTokensTooltip": "Tokens billed across this conversation. The cache hit is the share of the newest request's input served from cache.", "ChatSessionStatsTurns": "{count} turns", @@ -606,6 +610,9 @@ "TitlebarOpenFolder": "Open folder…", "TitlebarNoProject": "No project", "TitlebarSearchGoTo": "Go to", + "TitlebarSearchInConversations": "In chats", + "TitlebarSearchIndexing": "Indexing chats…", + "TitlebarSearchNoContentMatches": "No chat mentions these words", "TitlebarSearchPlaceholder": "Search chats, files or screens", "ExplorerNoProject": "Pick a project or open a folder in the title bar to browse its files.", "ExplorerEmptyFolder": "This folder is empty", diff --git a/assets/i18n/desktop/ja.json b/assets/i18n/desktop/ja.json index 928a3a9..11b5383 100644 --- a/assets/i18n/desktop/ja.json +++ b/assets/i18n/desktop/ja.json @@ -268,6 +268,10 @@ "ChatRequestFailed": "リクエストに失敗しました", "ChatSessionRefreshFailed": "セッションの更新に失敗しました。", "ChatSessionStatsCacheHit": "キャッシュヒット {percent}%", + "ChatSessionStatsCostEstimatedTooltip": "各モデルのトークン数と価格から推定した費用です。サブエージェントも含みます。", + "ChatSessionStatsCostBilledTooltip": "{agent} が記録した費用です。", + "ChatSessionStatsCostSubagents": "うちサブエージェント {usd}", + "ChatSessionStatsCostUnpriced": "価格がなく除外:{models}", "ChatSessionStatsTokens": "{tokens} tok", "ChatSessionStatsTokensTooltip": "この会話全体で課金されたトークン数。キャッシュヒットは最新リクエストの入力のうちキャッシュから読まれた割合です。", "ChatSessionStatsTurns": "{count} ターン", @@ -606,6 +610,9 @@ "TitlebarOpenFolder": "フォルダーを開く…", "TitlebarNoProject": "プロジェクト未選択", "TitlebarSearchGoTo": "移動", + "TitlebarSearchInConversations": "チャットの内容", + "TitlebarSearchIndexing": "チャットをインデックス中…", + "TitlebarSearchNoContentMatches": "これらの語を含むチャットはありません", "TitlebarSearchPlaceholder": "会話・ファイル・画面を検索", "ExplorerNoProject": "タイトルバーでプロジェクトを選ぶかフォルダーを開くと、ファイルを表示できます。", "ExplorerEmptyFolder": "このフォルダーは空です", diff --git a/assets/i18n/desktop/zh-CN.json b/assets/i18n/desktop/zh-CN.json index 3b8a935..08a962b 100644 --- a/assets/i18n/desktop/zh-CN.json +++ b/assets/i18n/desktop/zh-CN.json @@ -268,6 +268,10 @@ "ChatRequestFailed": "请求失败", "ChatSessionRefreshFailed": "刷新会话失败。", "ChatSessionStatsCacheHit": "缓存命中 {percent}%", + "ChatSessionStatsCostEstimatedTooltip": "按各模型的 token 数和价格估算,子代理也计算在内。", + "ChatSessionStatsCostBilledTooltip": "{agent} 记录的花费。", + "ChatSessionStatsCostSubagents": "其中子代理 {usd}", + "ChatSessionStatsCostUnpriced": "没有价格、未计入:{models}", "ChatSessionStatsTokens": "{tokens} tok", "ChatSessionStatsTokensTooltip": "本会话累计计费的 tokens。缓存命中是最新一次请求的输入中由缓存提供的比例。", "ChatSessionStatsTurns": "{count} 轮", @@ -606,6 +610,9 @@ "TitlebarOpenFolder": "打开文件夹…", "TitlebarNoProject": "未选择项目", "TitlebarSearchGoTo": "前往", + "TitlebarSearchInConversations": "会话内容", + "TitlebarSearchIndexing": "正在索引会话…", + "TitlebarSearchNoContentMatches": "没有会话提到这些词", "TitlebarSearchPlaceholder": "搜索会话、文件或页面", "ExplorerNoProject": "在标题栏选择项目或打开文件夹,即可浏览其中的文件。", "ExplorerEmptyFolder": "此文件夹为空", diff --git a/crates/rebon-config/src/lib.rs b/crates/rebon-config/src/lib.rs index 7745bd0..e478d7b 100644 --- a/crates/rebon-config/src/lib.rs +++ b/crates/rebon-config/src/lib.rs @@ -172,6 +172,71 @@ pub struct ModelTokenPricing { pub cache_write: f64, } +impl ModelTokenPricing { + /// What one usage record, or a total of several, costs at these rates, + /// in USD. + /// + /// The one place token counts become money: `/cost` and the desktop's + /// session cost both price through it, so the two cannot disagree. + pub fn usd_for(&self, usage: &rebon_types::Usage) -> f64 { + let billed = BilledTokens::of(usage); + let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0; + cost(billed.input, self.input) + + cost(billed.output, self.output) + + cost(billed.cache_read, self.cache_read) + + cost(billed.cache_write, self.cache_write) + } +} + +/// The four token counts a bill is made of, after the per-vendor shapes are +/// reconciled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BilledTokens { + pub input: u64, + pub output: u64, + pub cache_read: u64, + pub cache_write: u64, +} + +impl BilledTokens { + /// Split one usage record into what each rate applies to. + /// + /// openai-shaped usage already counts hits and misses inside + /// `input_tokens`; only the hit slice moves to the cache-read rate, and + /// misses stay billed as plain input. Anthropic-shaped usage reports the + /// cache counts alongside `input_tokens` instead. + /// + /// One function because two callers price the same session in two + /// currencies, and a second copy of this arithmetic would eventually + /// disagree with the first. + pub fn of(usage: &rebon_types::Usage) -> Self { + let openai_shaped = usage.input_tokens > 0 + && (usage.prompt_cache_hit_tokens > 0 || usage.prompt_cache_miss_tokens > 0); + Self { + input: u64::from(if openai_shaped { + usage + .input_tokens + .saturating_sub(usage.prompt_cache_hit_tokens) + } else { + usage.input_tokens + }), + output: u64::from(usage.output_tokens), + cache_read: u64::from( + usage + .cache_read_input_tokens + .saturating_add(usage.prompt_cache_hit_tokens), + ), + cache_write: u64::from(if openai_shaped { + usage.cache_creation_input_tokens + } else { + usage + .cache_creation_input_tokens + .saturating_add(usage.prompt_cache_miss_tokens) + }), + } + } +} + pub type ModelPricingCatalog = BTreeMap>; /// Built-in offline pricing. @@ -268,6 +333,96 @@ pub fn resolve_model_pricing( }) } +#[cfg(test)] +mod usage_pricing_tests { + use super::*; + use rebon_types::Usage; + + const RATES: ModelTokenPricing = ModelTokenPricing { + input: 10.0, + output: 50.0, + cache_read: 1.0, + cache_write: 12.5, + }; + + fn usage(input: u32, output: u32, read: u32, write: u32, hit: u32, miss: u32) -> Usage { + Usage { + input_tokens: input, + output_tokens: output, + cache_read_input_tokens: read, + cache_creation_input_tokens: write, + prompt_cache_hit_tokens: hit, + prompt_cache_miss_tokens: miss, + ..Usage::default() + } + } + + fn billed(input: u64, output: u64, cache_read: u64, cache_write: u64) -> BilledTokens { + BilledTokens { + input, + output, + cache_read, + cache_write, + } + } + + #[test] + fn anthropic_shape_bills_cache_counts_beside_input() { + assert_eq!( + BilledTokens::of(&usage(10, 20, 300, 40, 0, 0)), + billed(10, 20, 300, 40) + ); + } + + #[test] + fn openai_shape_moves_only_the_hit_slice_out_of_input() { + assert_eq!( + BilledTokens::of(&usage(1000, 5, 0, 0, 600, 400)), + billed(400, 5, 600, 0) + ); + } + + #[test] + fn openai_misses_alone_stay_plain_input() { + assert_eq!( + BilledTokens::of(&usage(1000, 5, 0, 7, 0, 400)), + billed(1000, 5, 0, 7) + ); + } + + #[test] + fn hits_without_input_are_anthropic_shaped() { + // No input to carve the hit slice out of: the miss counts as a write. + assert_eq!( + BilledTokens::of(&usage(0, 5, 2, 3, 600, 400)), + billed(0, 5, 602, 403) + ); + } + + #[test] + fn a_hit_larger_than_input_saturates() { + assert_eq!(BilledTokens::of(&usage(100, 0, 0, 0, 150, 0)).input, 0); + } + + #[test] + fn usd_prices_every_category_per_million() { + let one_million_each = usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 0, 0); + assert!((RATES.usd_for(&one_million_each) - (10.0 + 50.0 + 1.0 + 12.5)).abs() < 1e-9); + assert_eq!(RATES.usd_for(&Usage::default()), 0.0); + } + + #[test] + fn a_running_total_costs_what_its_records_cost() { + let records = [usage(10, 20, 300, 40, 0, 0), usage(7, 1, 900, 0, 0, 0)]; + let mut total = Usage::default(); + for r in records { + total.accumulate(r); + } + let separately: f64 = records.iter().map(|r| RATES.usd_for(r)).sum(); + assert!((RATES.usd_for(&total) - separately).abs() < 1e-12); + } +} + #[cfg(test)] mod model_pricing_tests { use super::*; diff --git a/crates/rebon-session-runtime/src/commands/cost.rs b/crates/rebon-session-runtime/src/commands/cost.rs index abda409..db14051 100644 --- a/crates/rebon-session-runtime/src/commands/cost.rs +++ b/crates/rebon-session-runtime/src/commands/cost.rs @@ -136,7 +136,7 @@ fn codex_credit_summary( continue; }; priced_any = true; - credits += usd_for(usage, &price) * CODEX_CREDITS_PER_USD; + credits += price.usd_for(usage) * CODEX_CREDITS_PER_USD; } if !priced_any { return None; @@ -182,62 +182,6 @@ fn observed_cost_keys( } } -/// The four token counts a bill is made of, after the per-vendor shapes are -/// reconciled. -struct BilledTokens { - input: u64, - output: u64, - cache_read: u64, - cache_write: u64, -} - -/// Split one usage record into what each rate applies to. -/// -/// openai-shaped usage already counts hits and misses inside -/// `input_tokens`; only the hit slice moves to the cache-read rate, and -/// misses stay billed as plain input. Anthropic-shaped usage reports the -/// cache counts alongside `input_tokens` instead. -/// -/// One function because two callers price the same session in two -/// currencies, and a second copy of this arithmetic would eventually -/// disagree with the first. -fn billed_tokens(usage: &rebon_types::Usage) -> BilledTokens { - let openai_shaped = usage.input_tokens > 0 - && (usage.prompt_cache_hit_tokens > 0 || usage.prompt_cache_miss_tokens > 0); - BilledTokens { - input: u64::from(if openai_shaped { - usage - .input_tokens - .saturating_sub(usage.prompt_cache_hit_tokens) - } else { - usage.input_tokens - }), - output: u64::from(usage.output_tokens), - cache_read: u64::from( - usage - .cache_read_input_tokens - .saturating_add(usage.prompt_cache_hit_tokens), - ), - cache_write: u64::from(if openai_shaped { - usage.cache_creation_input_tokens - } else { - usage - .cache_creation_input_tokens - .saturating_add(usage.prompt_cache_miss_tokens) - }), - } -} - -/// What one usage record costs at these rates, in USD. -fn usd_for(usage: &rebon_types::Usage, price: &crate::rebon_config::ModelTokenPricing) -> f64 { - let billed = billed_tokens(usage); - let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0; - cost(billed.input, price.input) - + cost(billed.output, price.output) - + cost(billed.cache_read, price.cache_read) - + cost(billed.cache_write, price.cache_write) -} - pub fn format_historical_usd_cost( usage_by_model: &std::collections::BTreeMap<(String, String), rebon_types::Usage>, fallback: (&str, &str, rebon_types::Usage), @@ -262,12 +206,12 @@ pub fn format_historical_usd_cost( continue; }; - let BilledTokens { + let crate::rebon_config::BilledTokens { input: input_tokens, output: output_tokens, cache_read: cache_read_tokens, cache_write: cache_write_tokens, - } = billed_tokens(usage); + } = crate::rebon_config::BilledTokens::of(usage); let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0; let input = cost(input_tokens, price.input); let output = cost(output_tokens, price.output); diff --git a/crates/rebon-session-runtime/src/usage.rs b/crates/rebon-session-runtime/src/usage.rs index 2109c4b..a99be5e 100644 --- a/crates/rebon-session-runtime/src/usage.rs +++ b/crates/rebon-session-runtime/src/usage.rs @@ -68,9 +68,10 @@ impl UsageLedger { /// twice would double it -- one call site per surface, and no call from /// anything that merely observes a turn. /// - /// The folding is [`add`], not `rebon_types::Usage::merge`: `merge` takes - /// the non-zero fields of the newer reading, which is what a stream of - /// deltas within one turn needs and the opposite of a running total. + /// The folding is [`Usage::accumulate`], not `rebon_types::Usage::merge`: + /// `merge` takes the non-zero fields of the newer reading, which is what a + /// stream of deltas within one turn needs and the opposite of a running + /// total. pub fn add_turn( &mut self, provider: impl Into, @@ -78,12 +79,12 @@ impl UsageLedger { usage: Usage, ) { self.last_turn = usage; - add(&mut self.total, usage); + self.total.accumulate(usage); let bucket = self .by_model .entry((provider.into(), model.into())) .or_default(); - add(bucket, usage); + bucket.accumulate(usage); } /// What the model has reported so far *within* the turn that is running. @@ -137,45 +138,6 @@ impl UsageLedger { } } -/// Add one turn's counters into a running total. -/// -/// Saturating rather than wrapping: a session large enough to overflow these -/// counters does not exist, and a pinned ceiling is a better failure than a -/// total that wraps to zero and reads as a session that cost nothing. -/// -/// The billed totals come from the `Usage` helpers rather than the reported -/// fields, because what a provider bills for an input is not always what it -/// reports as one. -fn add(target: &mut Usage, usage: Usage) { - target.input_tokens = target.input_tokens.saturating_add(usage.input_tokens); - target.output_tokens = target.output_tokens.saturating_add(usage.output_tokens); - target.total_input_tokens = target - .total_input_tokens - .saturating_add(usage.billed_input_tokens()); - target.total_output_tokens = target - .total_output_tokens - .saturating_add(usage.billed_output_tokens()); - target.cache_read_input_tokens = target - .cache_read_input_tokens - .saturating_add(usage.cache_read_input_tokens); - target.cache_creation_input_tokens = target - .cache_creation_input_tokens - .saturating_add(usage.cache_creation_input_tokens); - target.prompt_cache_hit_tokens = target - .prompt_cache_hit_tokens - .saturating_add(usage.prompt_cache_hit_tokens); - target.prompt_cache_miss_tokens = target - .prompt_cache_miss_tokens - .saturating_add(usage.prompt_cache_miss_tokens); - // The count this ledger replaced folded eight counters and dropped this - // one. Nothing in the cli renders reasoning tokens from a total, so - // folding it changes no output; what it changes is that the count exists - // at all for whoever asks next. - target.reasoning_tokens = target - .reasoning_tokens - .saturating_add(usage.reasoning_tokens); -} - #[cfg(any(test, feature = "test-support"))] impl UsageLedger { /// A ledger already holding numbers a fixture chose. diff --git a/crates/rebon-types/src/types.rs b/crates/rebon-types/src/types.rs index 8d88aeb..5b29ccd 100644 --- a/crates/rebon-types/src/types.rs +++ b/crates/rebon-types/src/types.rs @@ -235,6 +235,145 @@ impl Usage { self.reasoning_tokens = other.reasoning_tokens; } } + + /// Add one request's or turn's counters into a running total. + /// + /// Not [`Self::merge`]: `merge` takes the non-zero fields of the newer + /// reading, which is what a stream of deltas within one turn needs and + /// the opposite of a running total. + /// + /// Saturating rather than wrapping: a session large enough to overflow these + /// counters does not exist, and a pinned ceiling is a better failure than a + /// total that wraps to zero and reads as a session that cost nothing. + /// + /// The billed totals come from the `Usage` helpers rather than the reported + /// fields, because what a provider bills for an input is not always what it + /// reports as one. + pub fn accumulate(&mut self, usage: Usage) { + self.input_tokens = self.input_tokens.saturating_add(usage.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(usage.output_tokens); + self.total_input_tokens = self + .total_input_tokens + .saturating_add(usage.billed_input_tokens()); + self.total_output_tokens = self + .total_output_tokens + .saturating_add(usage.billed_output_tokens()); + self.cache_read_input_tokens = self + .cache_read_input_tokens + .saturating_add(usage.cache_read_input_tokens); + self.cache_creation_input_tokens = self + .cache_creation_input_tokens + .saturating_add(usage.cache_creation_input_tokens); + self.prompt_cache_hit_tokens = self + .prompt_cache_hit_tokens + .saturating_add(usage.prompt_cache_hit_tokens); + self.prompt_cache_miss_tokens = self + .prompt_cache_miss_tokens + .saturating_add(usage.prompt_cache_miss_tokens); + // The count this ledger replaced folded eight counters and dropped this + // one. Nothing in the cli renders reasoning tokens from a total, so + // folding it changes no output; what it changes is that the count exists + // at all for whoever asks next. + self.reasoning_tokens = self.reasoning_tokens.saturating_add(usage.reasoning_tokens); + } +} + +#[cfg(test)] +mod usage_accumulate_tests { + use super::*; + + fn anthropic(input: u32, output: u32, read: u32, write: u32) -> Usage { + Usage { + input_tokens: input, + output_tokens: output, + cache_read_input_tokens: read, + cache_creation_input_tokens: write, + ..Usage::default() + } + } + + #[test] + fn every_counter_is_summed() { + let mut total = Usage::default(); + total.accumulate(Usage { + prompt_cache_hit_tokens: 7, + prompt_cache_miss_tokens: 3, + reasoning_tokens: 5, + ..anthropic(10, 20, 30, 40) + }); + total.accumulate(Usage { + prompt_cache_hit_tokens: 1, + prompt_cache_miss_tokens: 2, + reasoning_tokens: 4, + ..anthropic(1, 2, 3, 4) + }); + assert_eq!(total.input_tokens, 11); + assert_eq!(total.output_tokens, 22); + assert_eq!(total.cache_read_input_tokens, 33); + assert_eq!(total.cache_creation_input_tokens, 44); + assert_eq!(total.prompt_cache_hit_tokens, 8); + assert_eq!(total.prompt_cache_miss_tokens, 5); + assert_eq!(total.reasoning_tokens, 9); + } + + #[test] + fn billed_totals_come_from_the_billed_helpers() { + let mut total = Usage::default(); + // Anthropic shape: the context is input plus both cache counts. + total.accumulate(anthropic(10, 20, 30, 40)); + // OpenAI shape: input already holds its cached prefix. + total.accumulate(Usage { + input_tokens: 100, + output_tokens: 5, + prompt_cache_hit_tokens: 60, + prompt_cache_miss_tokens: 40, + ..Usage::default() + }); + assert_eq!(total.total_input_tokens, 80 + 100); + assert_eq!(total.total_output_tokens, 25); + } + + #[test] + fn a_reported_total_is_what_is_billed() { + let mut total = Usage::default(); + total.accumulate(Usage { + total_input_tokens: 500, + total_output_tokens: 70, + ..anthropic(10, 20, 0, 0) + }); + assert_eq!( + (total.total_input_tokens, total.total_output_tokens), + (500, 70) + ); + } + + #[test] + fn counters_pin_at_the_ceiling_instead_of_wrapping() { + let mut total = anthropic(u32::MAX - 1, u32::MAX, 0, 0); + total.accumulate(anthropic(5, 5, 0, 0)); + assert_eq!(total.input_tokens, u32::MAX); + assert_eq!(total.output_tokens, u32::MAX); + } + + #[test] + fn accumulating_nothing_changes_nothing() { + let mut total = anthropic(1, 2, 3, 4); + let before = total; + total.accumulate(Usage::default()); + assert_eq!(total.input_tokens, before.input_tokens); + assert_eq!( + total.cache_creation_input_tokens, + before.cache_creation_input_tokens + ); + } + + #[test] + fn accumulate_adds_where_merge_replaces() { + let (mut summed, mut merged) = (anthropic(10, 1, 0, 0), anthropic(10, 1, 0, 0)); + summed.accumulate(anthropic(4, 1, 0, 0)); + merged.merge(&anthropic(4, 1, 0, 0)); + assert_eq!((summed.input_tokens, merged.input_tokens), (14, 4)); + } } #[cfg(test)] diff --git a/i18n/en.json b/i18n/en.json index 070819a..2be0993 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -268,6 +268,10 @@ "app.ChatRequestFailed": "Request failed", "app.ChatSessionRefreshFailed": "Refreshing the session failed.", "app.ChatSessionStatsCacheHit": "cache hit {percent}%", + "app.ChatSessionStatsCostEstimatedTooltip": "Estimated from each model's token counts and prices, subagents included.", + "app.ChatSessionStatsCostBilledTooltip": "The cost {agent} recorded.", + "app.ChatSessionStatsCostSubagents": "Subagents: {usd}", + "app.ChatSessionStatsCostUnpriced": "No price, left out: {models}", "app.ChatSessionStatsTokens": "{tokens} tok", "app.ChatSessionStatsTokensTooltip": "Tokens billed across this conversation. The cache hit is the share of the newest request's input served from cache.", "app.ChatSessionStatsTurns": "{count} turns", @@ -606,6 +610,9 @@ "app.TitlebarOpenFolder": "Open folder…", "app.TitlebarNoProject": "No project", "app.TitlebarSearchGoTo": "Go to", + "app.TitlebarSearchInConversations": "In chats", + "app.TitlebarSearchIndexing": "Indexing chats…", + "app.TitlebarSearchNoContentMatches": "No chat mentions these words", "app.TitlebarSearchPlaceholder": "Search chats, files or screens", "app.ExplorerNoProject": "Pick a project or open a folder in the title bar to browse its files.", "app.ExplorerEmptyFolder": "This folder is empty", diff --git a/i18n/ja.json b/i18n/ja.json index 870895d..5a1770c 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -268,6 +268,10 @@ "app.ChatRequestFailed": "リクエストに失敗しました", "app.ChatSessionRefreshFailed": "セッションの更新に失敗しました。", "app.ChatSessionStatsCacheHit": "キャッシュヒット {percent}%", + "app.ChatSessionStatsCostEstimatedTooltip": "各モデルのトークン数と価格から推定した費用です。サブエージェントも含みます。", + "app.ChatSessionStatsCostBilledTooltip": "{agent} が記録した費用です。", + "app.ChatSessionStatsCostSubagents": "うちサブエージェント {usd}", + "app.ChatSessionStatsCostUnpriced": "価格がなく除外:{models}", "app.ChatSessionStatsTokens": "{tokens} tok", "app.ChatSessionStatsTokensTooltip": "この会話全体で課金されたトークン数。キャッシュヒットは最新リクエストの入力のうちキャッシュから読まれた割合です。", "app.ChatSessionStatsTurns": "{count} ターン", @@ -606,6 +610,9 @@ "app.TitlebarOpenFolder": "フォルダーを開く…", "app.TitlebarNoProject": "プロジェクト未選択", "app.TitlebarSearchGoTo": "移動", + "app.TitlebarSearchInConversations": "チャットの内容", + "app.TitlebarSearchIndexing": "チャットをインデックス中…", + "app.TitlebarSearchNoContentMatches": "これらの語を含むチャットはありません", "app.TitlebarSearchPlaceholder": "会話・ファイル・画面を検索", "app.ExplorerNoProject": "タイトルバーでプロジェクトを選ぶかフォルダーを開くと、ファイルを表示できます。", "app.ExplorerEmptyFolder": "このフォルダーは空です", diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 194fd0a..0ac62e2 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -268,6 +268,10 @@ "app.ChatRequestFailed": "请求失败", "app.ChatSessionRefreshFailed": "刷新会话失败。", "app.ChatSessionStatsCacheHit": "缓存命中 {percent}%", + "app.ChatSessionStatsCostEstimatedTooltip": "按各模型的 token 数和价格估算,子代理也计算在内。", + "app.ChatSessionStatsCostBilledTooltip": "{agent} 记录的花费。", + "app.ChatSessionStatsCostSubagents": "其中子代理 {usd}", + "app.ChatSessionStatsCostUnpriced": "没有价格、未计入:{models}", "app.ChatSessionStatsTokens": "{tokens} tok", "app.ChatSessionStatsTokensTooltip": "本会话累计计费的 tokens。缓存命中是最新一次请求的输入中由缓存提供的比例。", "app.ChatSessionStatsTurns": "{count} 轮", @@ -606,6 +610,9 @@ "app.TitlebarOpenFolder": "打开文件夹…", "app.TitlebarNoProject": "未选择项目", "app.TitlebarSearchGoTo": "前往", + "app.TitlebarSearchInConversations": "会话内容", + "app.TitlebarSearchIndexing": "正在索引会话…", + "app.TitlebarSearchNoContentMatches": "没有会话提到这些词", "app.TitlebarSearchPlaceholder": "搜索会话、文件或页面", "app.ExplorerNoProject": "在标题栏选择项目或打开文件夹,即可浏览其中的文件。", "app.ExplorerEmptyFolder": "此文件夹为空",