diff --git a/src-tauri/src/app/commands.rs b/src-tauri/src/app/commands.rs index 505f2ef96..31d938c0c 100644 --- a/src-tauri/src/app/commands.rs +++ b/src-tauri/src/app/commands.rs @@ -494,6 +494,7 @@ macro_rules! invoke_handler { crate::storage_manager::group_sessions::group_session_toggle_memory_pin, crate::storage_manager::group_sessions::group_session_set_memory_cold_state, crate::group_chat_manager::group_chat_add_user_message, + crate::group_chat_manager::group_chat_message_debug_snapshot, crate::group_chat_manager::group_chat_send, crate::group_chat_manager::group_chat_regenerate, crate::group_chat_manager::group_chat_continue, diff --git a/src-tauri/src/chat_manager/commands/mod.rs b/src-tauri/src/chat_manager/commands/mod.rs index 510a27391..73c00585d 100644 --- a/src-tauri/src/chat_manager/commands/mod.rs +++ b/src-tauri/src/chat_manager/commands/mod.rs @@ -30,8 +30,8 @@ use super::types::{ ChatGenerateLorebookEntryDraftArgs, ChatGenerateLorebookKeywordDraftArgs, ChatGenerateSceneImageArgs, ChatGenerateScenePromptArgs, ChatRegenerateArgs, ChatTurnResult, ContinueResult, ImageAttachment, LorebookEntryDraftResult, LorebookKeywordDraftResult, - PromptTemplateType, RegenerateResult, Session, Settings, StoredMessage, SystemPromptEntry, - SystemPromptTemplate, + MemoryEmbedding, PromptTemplateType, RegenerateResult, Session, Settings, StoredMessage, + SystemPromptEntry, SystemPromptTemplate, }; use crate::storage_manager::sessions::{messages_upsert_batch_typed, session_upsert_meta_typed}; @@ -67,6 +67,26 @@ pub struct ChatMessageDebugSnapshot { pub request_messages: Vec, pub request_body: Value, pub notes: Vec, + // Raw contents of the individual prompt placeholders. Present so the frontend + // token breakdown can attribute tokens to character profile / persona / + // lorebook / memories / author note / companion state / scheduled notes even + // when a template renders them inline (i.e. they never appear as standalone + // prompt entries). + pub character_profile_content: String, + pub persona_content: String, + pub memory_entry_count: u32, + pub lorebook_content: String, + pub context_summary_content: String, + pub key_memories_content: String, + pub author_note_content: String, + pub companion_state_content: String, + pub scheduled_notes_content: String, + // Raw text of the `{{group_characters}}` cast block. Only populated for group + // chat snapshots; empty for 1:1 chats where that placeholder does not exist. + pub group_cast_content: String, + // Group chat only: number of other participants whose profiles are in the + // group cast block (0 for 1:1 chats). + pub group_cast_count: u32, } #[derive(Clone, Copy)] @@ -455,6 +475,18 @@ pub async fn chat_continue( .await } +/// Strip an optional leading `::` prefix from a stored memory ref, +/// returning the underlying memory text. Refs are persisted either as plain text +/// or as `"{match_score}::{text}"` (see completion/regenerate memory_refs). +fn memory_ref_text(reference: &str) -> &str { + if let Some((prefix, rest)) = reference.split_once("::") { + if prefix.trim().parse::().is_ok() { + return rest; + } + } + reference +} + #[tauri::command] pub fn chat_message_debug_snapshot( app: AppHandle, @@ -510,14 +542,54 @@ pub fn chat_message_debug_snapshot( } }; + // Reconstruct the memories that were injected into {{key_memories}} from the + // stored refs instead of running a live (async) retrieval, so the debug + // snapshot mirrors what generation actually shipped. Refs are persisted as + // plain text or as `"{match_score}::{text}"` (see completion/regenerate). + let retrieved_memories: Vec = target_message + .memory_refs + .iter() + .filter_map(|reference| { + let text = memory_ref_text(reference); + prompt_session + .memory_embeddings + .iter() + .find(|memory| memory.id == *reference || memory.text == text) + .cloned() + .or_else(|| { + serde_json::from_value(json!({ + "id": reference, + "text": text, + "embedding": Vec::::new(), + })) + .ok() + }) + }) + .collect(); + let prompt_entries = append_image_directive_instructions( - context.build_system_prompt(&character, &model, persona.as_ref(), &prompt_session), + context.build_system_prompt( + &character, + &model, + persona.as_ref(), + &prompt_session, + &retrieved_memories, + ), &context.settings, ); let (prompt_template_source, prompt_template_id, prompt_template_name) = resolve_debug_prompt_template(&app, &prompt_session, &character, &context.settings); let (relative_entries, in_chat_entries) = partition_prompt_entries(prompt_entries.clone()); + let prompt_sources = prompt_engine::debug_prompt_source_contents( + &app, + &character, + persona.as_ref(), + &prompt_session, + &context.settings, + &retrieved_memories, + ); + let system_role = crate::chat_manager::request_builder::system_role_for(&credential); let character_name = character.name.as_str(); let persona_name = persona @@ -641,6 +713,17 @@ pub fn chat_message_debug_snapshot( request_messages, request_body: built.body, notes, + character_profile_content: prompt_sources.character_profile, + persona_content: prompt_sources.persona_description, + memory_entry_count: prompt_sources.memory_entry_count, + lorebook_content: prompt_sources.lorebook, + context_summary_content: prompt_sources.context_summary, + key_memories_content: prompt_sources.key_memories, + author_note_content: prompt_sources.author_note, + companion_state_content: prompt_sources.companion_state, + scheduled_notes_content: prompt_sources.scheduled_notes, + group_cast_content: String::new(), + group_cast_count: 0, }) } diff --git a/src-tauri/src/chat_manager/flows/completion.rs b/src-tauri/src/chat_manager/flows/completion.rs index 7986bf1b9..e4d535ce3 100644 --- a/src-tauri/src/chat_manager/flows/completion.rs +++ b/src-tauri/src/chat_manager/flows/completion.rs @@ -26,9 +26,7 @@ use crate::chat_manager::service::{ record_failed_usage, record_usage_if_available, ChatService, PreparedChatTurn, }; use crate::chat_manager::storage::recent_messages; -use crate::chat_manager::temporal::{ - companion_effective_now, companion_time_awareness_enabled, format_memory_for_prompt, -}; +use crate::chat_manager::temporal::{companion_effective_now, companion_time_awareness_enabled}; use crate::chat_manager::turn_builder::{ append_image_directive_instructions, assemble_prompt_messages, build_enriched_query, conversation_window_with_pinned, is_dynamic_memory_active, manual_window_size, @@ -202,44 +200,10 @@ impl CompletionFlow { }), ); - let mut prompt_entries = if swap_places { - let (prompt_character, prompt_persona) = - swapped_prompt_entities(&character, persona.as_ref()); - append_image_directive_instructions( - context.build_system_prompt( - &prompt_character, - &model, - prompt_persona.as_ref(), - &session, - ), - settings, - ) - } else { - append_image_directive_instructions( - context.build_system_prompt(&character, &model, persona.as_ref(), &session), - settings, - ) - }; - - let used_lorebook_entries = - crate::chat_manager::prompt_engine::resolve_used_lorebook_entries( - &app, - &character.id, - persona.as_ref(), - &session, - &prompt_entries, - ); - let (pinned_msgs, recent_msgs) = if dynamic_memory_enabled { - let (pinned, unpinned) = - conversation_window_with_pinned(&session.messages, dynamic_window); - (pinned, unpinned) - } else { - ( - Vec::new(), - recent_messages(&session, manual_window_size(settings)), - ) - }; - + // Dynamic memory retrieval runs before build_system_prompt so the + // retrieved relevant memories fill {{key_memories}} (instead of shipping + // the entire hot memory bank). Retrieval also mutates session state + // (pin/promote/access bookkeeping), which build_system_prompt then reads. let relevant_memories = if dynamic_memory_enabled && !session.memory_embeddings.is_empty() { let fixed = ensure_pinned_hot(&mut session.memory_embeddings); if fixed > 0 { @@ -328,6 +292,51 @@ impl CompletionFlow { } } + let mut prompt_entries = if swap_places { + let (prompt_character, prompt_persona) = + swapped_prompt_entities(&character, persona.as_ref()); + append_image_directive_instructions( + context.build_system_prompt( + &prompt_character, + &model, + prompt_persona.as_ref(), + &session, + &relevant_memories, + ), + settings, + ) + } else { + append_image_directive_instructions( + context.build_system_prompt( + &character, + &model, + persona.as_ref(), + &session, + &relevant_memories, + ), + settings, + ) + }; + + let used_lorebook_entries = + crate::chat_manager::prompt_engine::resolve_used_lorebook_entries( + &app, + &character.id, + persona.as_ref(), + &session, + &prompt_entries, + ); + let (pinned_msgs, recent_msgs) = if dynamic_memory_enabled { + let (pinned, unpinned) = + conversation_window_with_pinned(&session.messages, dynamic_window); + (pinned, unpinned) + } else { + ( + Vec::new(), + recent_messages(&session, manual_window_size(settings)), + ) + }; + let system_role = crate::chat_manager::request_builder::system_role_for(&credential); if swap_places { let persona_title = persona @@ -337,19 +346,11 @@ impl CompletionFlow { prompt_entries.push(swap_places_entry(&character.name, &persona_title)); } + // In dynamic mode the retrieved memories are now injected inline via + // {{key_memories}} inside build_system_prompt, so no separate block is + // needed here. The manual-memory path is unchanged. let memory_block = if dynamic_memory_enabled { - if relevant_memories.is_empty() { - None - } else { - let memory_now = companion_effective_now(&session); - Some( - relevant_memories - .iter() - .map(|mem| format_memory_for_prompt(mem, memory_now)) - .collect::>() - .join("\n"), - ) - } + None } else if has_manual_memories(&session.memories) { Some(render_manual_memory_lines(&session.memories)) } else { diff --git a/src-tauri/src/chat_manager/flows/continuation.rs b/src-tauri/src/chat_manager/flows/continuation.rs index 1b89801c4..6843f6407 100644 --- a/src-tauri/src/chat_manager/flows/continuation.rs +++ b/src-tauri/src/chat_manager/flows/continuation.rs @@ -229,12 +229,19 @@ impl ContinueFlow { &model, prompt_persona.as_ref(), &session, + &relevant_memories, ), settings, ) } else { append_image_directive_instructions( - context.build_system_prompt(&character, &model, persona.as_ref(), &session), + context.build_system_prompt( + &character, + &model, + persona.as_ref(), + &session, + &relevant_memories, + ), settings, ) }; diff --git a/src-tauri/src/chat_manager/flows/regenerate.rs b/src-tauri/src/chat_manager/flows/regenerate.rs index 35918b859..5def3ee9d 100644 --- a/src-tauri/src/chat_manager/flows/regenerate.rs +++ b/src-tauri/src/chat_manager/flows/regenerate.rs @@ -278,12 +278,19 @@ impl RegenerateFlow { &model, prompt_persona.as_ref(), &session, + &relevant_memories, ), settings, ) } else { append_image_directive_instructions( - context.build_system_prompt(&character, &model, persona.as_ref(), &session), + context.build_system_prompt( + &character, + &model, + persona.as_ref(), + &session, + &relevant_memories, + ), settings, ) }; diff --git a/src-tauri/src/chat_manager/mod.rs b/src-tauri/src/chat_manager/mod.rs index fa1e3d749..a97dcd3c9 100644 --- a/src-tauri/src/chat_manager/mod.rs +++ b/src-tauri/src/chat_manager/mod.rs @@ -58,7 +58,7 @@ pub use commands::{ chat_add_message_attachment, chat_completion, chat_continue, chat_generate_companion_soul, chat_generate_design_reference_description, chat_generate_lorebook_entry_draft, chat_generate_lorebook_keyword_draft, chat_generate_scene_image, chat_generate_scene_prompt, - chat_generate_user_reply, chat_message_debug_snapshot, chat_regenerate, + chat_generate_user_reply, chat_message_debug_snapshot, chat_regenerate, ChatMessageDebugSnapshot, chat_template_export_as_usc, companion_clear_soul_growth, companion_remove_soul_growth, companion_set_soul_growth_lock, create_prompt_template, delete_prompt_template, dynamic_memory_cycle_status, diff --git a/src-tauri/src/chat_manager/persistence/storage.rs b/src-tauri/src/chat_manager/persistence/storage.rs index ca40828f1..7e51289a9 100644 --- a/src-tauri/src/chat_manager/persistence/storage.rs +++ b/src-tauri/src/chat_manager/persistence/storage.rs @@ -15,8 +15,8 @@ use crate::storage_manager::{ use crate::chat_manager::prompt_engine; use crate::chat_manager::types::{ AccessibilitySettings, AccessibilitySoundSettings, AdvancedModelSettings, AdvancedSettings, - Character, DynamicMemoryStructuredFallbackFormat, Model, Persona, ProviderCredential, Session, - Settings, StoredMessage, SystemPromptEntry, + Character, DynamicMemoryStructuredFallbackFormat, MemoryEmbedding, Model, Persona, + ProviderCredential, Session, Settings, StoredMessage, SystemPromptEntry, }; #[derive(Debug, Clone, Copy)] @@ -528,8 +528,17 @@ pub fn build_system_prompt( persona: Option<&Persona>, session: &Session, settings: &Settings, + retrieved_memories: &[MemoryEmbedding], ) -> Vec { - prompt_engine::build_system_prompt_entries(app, character, model, persona, session, settings) + prompt_engine::build_system_prompt_entries( + app, + character, + model, + persona, + session, + settings, + retrieved_memories, + ) } pub fn recent_messages(session: &Session, limit: usize) -> Vec { diff --git a/src-tauri/src/chat_manager/prompting/prompt_engine.rs b/src-tauri/src/chat_manager/prompting/prompt_engine.rs index 1ababe7f2..969138a62 100644 --- a/src-tauri/src/chat_manager/prompting/prompt_engine.rs +++ b/src-tauri/src/chat_manager/prompting/prompt_engine.rs @@ -13,9 +13,9 @@ use crate::chat_manager::temporal::{ time_placeholder_values, }; use crate::chat_manager::types::{ - Character, Model, Persona, PromptEntryChatMode, PromptEntryCondition, PromptEntryImageSlot, - PromptEntryInfoSource, PromptEntryPayload, PromptEntryPosition, PromptEntryRole, Session, - Settings, SystemPromptEntry, + Character, MemoryEmbedding, Model, Persona, PromptEntryChatMode, PromptEntryCondition, + PromptEntryImageSlot, PromptEntryInfoSource, PromptEntryPayload, PromptEntryPosition, + PromptEntryRole, Session, Settings, SystemPromptEntry, }; use crate::storage_manager::db::open_db; use crate::storage_manager::lorebook::{get_character_active_lorebook_ids, get_lorebook}; @@ -3379,6 +3379,134 @@ pub(crate) fn scene_generation_uses_local_image_model(settings: &Settings) -> bo .unwrap_or(false) } +/// Raw contents of the individual prompt placeholders, so the debug snapshot +/// can attribute tokens to each source even when a template renders them inline +/// via `{{lorebook}}`, `{{key_memories}}`, etc. (in which case they never become +/// standalone entries). Mirrors the placeholder value computation in +/// `build_system_prompt_entries`. +#[derive(Default)] +pub(crate) struct DebugPromptSourceContents { + pub character_profile: String, + pub persona_description: String, + pub lorebook: String, + pub context_summary: String, + pub key_memories: String, + pub author_note: String, + pub companion_state: String, + pub scheduled_notes: String, + pub memory_entry_count: u32, +} + +pub(crate) fn debug_prompt_source_contents( + app: &AppHandle, + character: &Character, + persona: Option<&Persona>, + session: &Session, + settings: &Settings, + retrieved_memories: &[MemoryEmbedding], +) -> DebugPromptSourceContents { + let dynamic_memory_active = is_dynamic_memory_active(settings, character); + let companion_mode = companion::is_companion_mode(session, character); + let companion_now = companion_effective_now(session); + + // Character profile ("char sheet") = definition, falling back to the UI + // description, with identity name placeholders resolved — byte-identical to + // what {{char.desc}} renders into the prompt. Persona description mirrors + // {{persona.desc}}. Both are inline in the template, so we surface them + // separately for the token breakdown. + let char_name = character.name.as_str(); + let persona_name = persona.map(|item| item.title.as_str()).unwrap_or("user"); + let character_profile = character + .definition + .as_ref() + .or(character.description.as_ref()) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("") + .replace("{{char}}", char_name) + .replace("{{persona}}", persona_name) + .replace("{{user}}", persona_name); + let persona_description = persona + .map(|item| item.description.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("") + .to_string(); + + let lorebook = get_lorebook_content(app, &character.id, persona, session) + .unwrap_or_default() + .trim() + .to_string(); + + let context_summary = if dynamic_memory_active { + session + .memory_summary + .as_deref() + .map(|summary| summary.trim().to_string()) + .filter(|summary| !summary.is_empty()) + .unwrap_or_default() + } else { + String::new() + }; + + let key_memories = if dynamic_memory_active { + retrieved_memories + .iter() + .map(|mem| format_memory_for_prompt(mem, companion_now)) + .collect::>() + .join("\n") + } else if has_manual_memories(&session.memories) { + render_manual_memory_lines(&session.memories) + } else { + String::new() + }; + + let author_note = render_author_note_text(character, persona, session).unwrap_or_default(); + + // render_prompt_state emits a fixed instruction preamble (the first four + // meta lines explaining how to read the relationship metrics) followed by + // the actual live state. That preamble is generic system guidance, not + // companion state — so we drop it here and only attribute the real state to + // the companion bucket. The preamble stays folded into the system bucket + // (it is inline in the template and not reattributed). + let companion_state = companion::render_prompt_state(session, character, persona, companion_now) + .map(|full| full.splitn(5, '\n').nth(4).unwrap_or("").trim().to_string()) + .unwrap_or_default(); + + let memory_entry_count = if dynamic_memory_active { + retrieved_memories.len() as u32 + } else { + session + .memories + .iter() + .filter(|entry| !entry.trim().is_empty()) + .count() as u32 + }; + + let scheduled_notes = if companion_mode { + crate::storage_manager::companion_scheduled_notes::render_scheduled_notes_block( + app, + &character.id, + companion_now, + ) + .unwrap_or(None) + .unwrap_or_default() + } else { + String::new() + }; + + DebugPromptSourceContents { + character_profile, + persona_description, + lorebook, + context_summary, + key_memories, + author_note, + companion_state, + scheduled_notes, + memory_entry_count, + } +} + pub fn build_system_prompt_entries( app: &AppHandle, character: &Character, @@ -3386,6 +3514,12 @@ pub fn build_system_prompt_entries( persona: Option<&Persona>, session: &Session, settings: &Settings, + // In dynamic-memory mode, the memories actually retrieved for this turn. + // {{key_memories}} is filled from these instead of the whole hot bank, so + // the prompt only carries the relevant memories (the separate retrieved + // block in the flows is removed). Empty in manual-memory mode / when nothing + // was retrieved. Non-chat callers pass &[] and keep the manual-memory path. + retrieved_memories: &[MemoryEmbedding], ) -> Vec { let mut debug_parts: Vec = Vec::new(); let dynamic_memory_active = is_dynamic_memory_active(settings, character); @@ -3534,13 +3668,23 @@ pub fn build_system_prompt_entries( let time_awareness_enabled = companion_mode && companion_time_awareness_enabled(session); let has_author_note = author_note_text.is_some(); let has_key_memories = if dynamic_memory_active { - session - .memory_embeddings - .iter() - .any(|memory| (!memory.is_cold || memory.is_pinned) && memory.superseded_by.is_none()) + !retrieved_memories.is_empty() } else { has_manual_memories(&session.memories) }; + // Text used to fill {{key_memories}} in dynamic mode: the retrieved relevant + // memories (not the whole hot bank). Rendered here so we can substitute it + // into template entries before render_with_context runs. + let dynamic_key_memories_text = if dynamic_memory_active { + let memory_now = companion_effective_now(session); + retrieved_memories + .iter() + .map(|memory| format_memory_for_prompt(memory, memory_now)) + .collect::>() + .join("\n") + } else { + String::new() + }; let scene_generation_enabled = settings .advanced_settings .as_ref() @@ -3607,9 +3751,19 @@ pub fn build_system_prompt_entries( if skip_scene_placeholder_entries && has_scene_placeholder(&entry.content) { continue; } + // In dynamic mode, resolve {{key_memories}} here from the retrieved + // memories before render_with_context runs — its all-hot fill then finds + // no placeholder left and is a no-op. Keeps the shared renderer untouched. + let entry_content = if dynamic_memory_active { + entry + .content + .replace("{{key_memories}}", &dynamic_key_memories_text) + } else { + entry.content.clone() + }; let rendered = render_with_context( app, - &entry.content, + &entry_content, character, persona, session, @@ -3658,10 +3812,8 @@ pub fn build_system_prompt_entries( let rendered_key_memories = if dynamic_memory_active { let memory_now = companion_effective_now(session); - session - .memory_embeddings + retrieved_memories .iter() - .filter(|mem| (!mem.is_cold || mem.is_pinned) && mem.superseded_by.is_none()) .map(|mem| format_memory_for_prompt(mem, memory_now)) .collect::>() } else if has_manual_memories(&session.memories) { diff --git a/src-tauri/src/chat_manager/service.rs b/src-tauri/src/chat_manager/service.rs index 46cf41886..aa74ba3b5 100644 --- a/src-tauri/src/chat_manager/service.rs +++ b/src-tauri/src/chat_manager/service.rs @@ -15,8 +15,8 @@ use crate::utils::{log_error, log_info, log_warn, now_millis}; use super::repository::ChatRepository; use super::storage::{build_system_prompt, choose_persona, select_model_with_credential}; use super::types::{ - Character, Model, Persona, ProviderCredential, Session, Settings, SystemPromptEntry, - UsageSummary, + Character, MemoryEmbedding, Model, Persona, ProviderCredential, Session, Settings, + SystemPromptEntry, UsageSummary, }; pub struct ChatContext { @@ -87,6 +87,7 @@ impl ChatContext { model: &Model, persona: Option<&Persona>, session: &Session, + retrieved_memories: &[MemoryEmbedding], ) -> Vec { build_system_prompt( self.app(), @@ -95,6 +96,7 @@ impl ChatContext { persona, session, &self.settings, + retrieved_memories, ) } diff --git a/src-tauri/src/group_chat_manager/mod.rs b/src-tauri/src/group_chat_manager/mod.rs index 6efaa358f..9424fb63a 100644 --- a/src-tauri/src/group_chat_manager/mod.rs +++ b/src-tauri/src/group_chat_manager/mod.rs @@ -5104,6 +5104,21 @@ fn render_group_author_note_text( } } +/// Raw placeholder texts computed while assembling the group system prompt. +/// +/// These mirror the exact strings substituted into `{{...}}` placeholders inside +/// `build_group_system_prompt` so the debug snapshot can attribute tokens to each +/// section even when a template renders them inline. +#[derive(Debug, Clone, Default)] +pub(crate) struct DebugGroupPromptSources { + pub char_desc: String, + pub persona_desc: String, + pub group_chars: String, + pub context_summary: String, + pub key_memories: String, + pub author_note: String, +} + fn build_group_system_prompt( app: &AppHandle, character: &Character, @@ -5115,7 +5130,7 @@ fn build_group_system_prompt( settings: &Settings, retrieved_memories: &[MemoryEmbedding], lorebook_text: &str, -) -> Vec { +) -> (Vec, DebugGroupPromptSources) { use crate::chat_manager::storage::{get_base_prompt, get_base_prompt_entries, PromptType}; use crate::chat_manager::types::PromptTemplateType; @@ -5513,11 +5528,21 @@ fn build_group_system_prompt( } } - if condense_prompt_entries { + let debug_sources = DebugGroupPromptSources { + char_desc: char_desc.to_string(), + persona_desc: persona_desc.to_string(), + group_chars: group_chars.clone(), + context_summary: context_summary_text.clone(), + key_memories: key_memories_text.clone(), + author_note: author_note_text.clone().unwrap_or_default(), + }; + + let entries = if condense_prompt_entries { condense_entries_into_single_system_message(rendered_entries) } else { rendered_entries - } + }; + (entries, debug_sources) } /// Replace character name placeholders in scene content @@ -5962,7 +5987,8 @@ async fn generate_character_response( settings, &retrieved_memories, &lorebook_text, - ); + ) + .0; let used_lorebook_entries = resolve_group_used_lorebook_entries( &conn, &active_lorebook_entries, @@ -6088,6 +6114,285 @@ async fn generate_character_response( } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupChatMessageDebugSnapshotArgs { + pub session_id: String, + pub message_id: String, +} + +/// Strip an optional leading `::` prefix from a stored group memory ref, +/// returning the underlying memory text. Refs are persisted either as plain text +/// or as `"{match_score}::{text}"` (see `generate_character_response`). +fn group_memory_ref_text(reference: &str) -> &str { + if let Some((prefix, rest)) = reference.split_once("::") { + if prefix.trim().parse::().is_ok() { + return rest; + } + } + reference +} + +/// Synchronous debug reconstruction for a group-chat assistant message. Mirrors +/// the 1:1 `chat_message_debug_snapshot` command so the frontend token breakdown +/// works unchanged. Everything is rebuilt from stored session/message data (stored +/// `memory_refs` instead of a live retrieval) so the command stays synchronous. +#[tauri::command] +pub fn group_chat_message_debug_snapshot( + app: AppHandle, + args: GroupChatMessageDebugSnapshotArgs, +) -> Result { + let settings = load_settings(&app)?; + let pool = app.state::(); + let conn = pool.get_connection()?; + + let session = group_sessions::group_session_get_internal_typed(&conn, &args.session_id)?; + + let all_messages = load_recent_group_messages(&conn, &args.session_id, i32::MAX)?; + let target_index = all_messages + .iter() + .position(|message| message.id == args.message_id) + .ok_or_else(|| "Message not found".to_string())?; + let target_message = all_messages[target_index].clone(); + + if target_message.role != "assistant" { + return Err( + "Group debug snapshot currently supports assistant messages only".to_string(), + ); + } + let speaker_id = target_message + .speaker_character_id + .clone() + .ok_or_else(|| "Message has no speaker character".to_string())?; + + let character = load_character(&conn, &speaker_id)?; + let persona = if let Some(ref persona_id) = session.persona_id { + load_persona(&app, persona_id)? + } else { + None + }; + let characters = load_characters_info(&conn, &session.character_ids)?; + + // Model + credential: prefer the model actually stored on the message, then + // fall back to the per-character override / default (mirrors generation). + let (model, credential) = if let Some(model_id) = target_message.model_id.as_ref() { + let model = settings + .models + .iter() + .find(|candidate| candidate.id == *model_id) + .ok_or_else(|| "Stored model not found".to_string())?; + let credential = resolve_credential_for_model(&settings, model) + .ok_or_else(|| "Provider credential not found".to_string())?; + (model, credential) + } else { + let session_model_override = session + .character_model_overrides + .get(&speaker_id) + .map(String::as_str) + .filter(|id| settings.models.iter().any(|model| model.id == *id)); + select_model_with_credential(&settings, &character, session_model_override)? + }; + + // Reconstruct the memories that were injected from the stored refs instead of + // running a live (async) retrieval. + let retrieved_memories: Vec = target_message + .memory_refs + .iter() + .filter_map(|reference| { + let text = group_memory_ref_text(reference); + session + .memory_embeddings + .iter() + .find(|memory| memory.id == *reference || memory.text == text) + .cloned() + .or_else(|| { + serde_json::from_value(json!({ + "id": reference, + "text": text, + "embedding": Vec::::new(), + })) + .ok() + }) + }) + .collect(); + + // Conversation history up to (but not including) the target message. + let recent_messages: Vec = all_messages[..target_index].to_vec(); + + let active_lorebook_entries = + get_group_active_lorebook_entries(&conn, &session, &character.id, &recent_messages)?; + let lorebook_text = format_group_lorebook_content(&app, &character.id, &active_lorebook_entries); + + let (mut system_prompt_entries, debug_sources) = build_group_system_prompt( + &app, + &character, + persona.as_ref(), + &session, + &recent_messages, + &characters, + model, + &settings, + &retrieved_memories, + &lorebook_text, + ); + + // Recreate the conversation window the generator would have used. + let dynamic_settings = effective_group_dynamic_memory_settings(&settings); + let messages_for_generation = if session.memory_type == "dynamic" { + let window_size = dynamic_settings.summary_message_interval.max(1) as usize; + conversation_window(&recent_messages, window_size) + } else { + let manual_window = manual_window_size(&settings).max(1); + conversation_window(&recent_messages, manual_window) + }; + + let selected_char_info = characters + .iter() + .find(|candidate| candidate.id == speaker_id) + .ok_or_else(|| "Selected character not found".to_string())?; + + let api_messages = build_messages_for_api( + &app, + &messages_for_generation, + &characters, + selected_char_info, + persona.as_ref(), + true, + model.input_scopes.iter().any(|scope| scope == "image"), + model.input_scopes.iter().any(|scope| scope == "audio"), + ); + let system_role = crate::chat_manager::request_builder::system_role_for(credential); + + if messages_for_generation.is_empty() { + system_prompt_entries.push(in_chat_user_entry( + "runtime_group_begin", + "Begin Group Conversation", + format!( + "[Begin the conversation. Respond as {}.]", + selected_char_info.name + ), + 0, + )); + } + + let prompt_entries = system_prompt_entries.clone(); + let (relative_entries, in_chat_entries) = + crate::chat_manager::turn_builder::partition_prompt_entries(prompt_entries.clone()); + let request_messages = + assemble_prompt_messages(system_prompt_entries, api_messages, &system_role); + + // Build the outbound request body exactly like execute_group_generation does. + let request_session: crate::chat_manager::types::Session = + serde_json::from_value(json!({ + "id": session.id, + "characterId": character.id, + "title": session.name, + "createdAt": session.created_at.max(0) as u64, + "updatedAt": session.updated_at.max(0) as u64 + })) + .map_err(|error| format!("Failed to prepare group request settings: {error}"))?; + + let request_settings = crate::chat_manager::execution::RequestSettings::resolve( + &request_session, + model, + &settings, + ); + let extra_body_fields = crate::chat_manager::execution::build_provider_extra_fields( + &credential.provider_id, + &request_session, + model, + &settings, + &request_settings, + ); + let built = crate::chat_manager::request_builder::build_chat_request( + credential, + "", + &model.name, + &request_messages, + None, + request_settings.temperature, + request_settings.top_p, + request_settings.max_tokens, + request_settings.context_length, + true, + None, + request_settings.frequency_penalty, + request_settings.presence_penalty, + request_settings.top_k, + None, + request_settings.reasoning_enabled, + request_settings.reasoning_effort.clone(), + request_settings.reasoning_budget, + request_settings.prompt_caching_enabled.unwrap_or(false), + extra_body_fields, + ); + + let request_settings_value = json!({ + "temperature": request_settings.temperature, + "topP": request_settings.top_p, + "maxTokens": request_settings.max_tokens, + "contextLength": request_settings.context_length, + "frequencyPenalty": request_settings.frequency_penalty, + "presencePenalty": request_settings.presence_penalty, + "topK": request_settings.top_k, + "reasoningEnabled": request_settings.reasoning_enabled, + "reasoningEffort": request_settings.reasoning_effort, + "reasoningBudget": request_settings.reasoning_budget, + }); + + let prompt_template_source = if session.chat_type == "roleplay" { + "group_chat_roleplay_template".to_string() + } else { + "group_chat_template".to_string() + }; + + let mut notes = vec![ + "Reconstructed from current group session state; live retrieval, retry timing and the exact provider response still come from the in-memory trace.".to_string(), + ]; + if target_message.model_id.is_none() { + notes.push("This message had no stored model id, so the current character/default model configuration was used for reconstruction.".to_string()); + } + + Ok(crate::chat_manager::ChatMessageDebugSnapshot { + source: "reconstructed".to_string(), + session_id: args.session_id, + message_id: args.message_id, + role: target_message.role, + operation: "completion".to_string(), + provider_id: credential.provider_id.clone(), + credential_id: credential.id.clone(), + model_id: model.id.clone(), + model: model.name.clone(), + model_display_name: model.display_name.clone(), + endpoint: built.url, + stream: built.stream, + request_settings: request_settings_value, + prompt_template_source, + prompt_template_id: None, + prompt_template_name: None, + prompt_entries, + relative_prompt_entries: relative_entries, + in_chat_prompt_entries: in_chat_entries, + request_messages, + request_body: built.body, + notes, + character_profile_content: debug_sources.char_desc, + persona_content: debug_sources.persona_desc, + memory_entry_count: retrieved_memories.len() as u32, + lorebook_content: lorebook_text, + context_summary_content: debug_sources.context_summary, + key_memories_content: debug_sources.key_memories, + author_note_content: debug_sources.author_note, + companion_state_content: String::new(), + scheduled_notes_content: String::new(), + group_cast_content: debug_sources.group_chars, + group_cast_count: characters + .iter() + .filter(|other| other.id != character.id) + .count() as u32, + }) +} + #[tauri::command] pub fn group_chat_add_user_message( session_id: String, diff --git a/src/core/chat/manager.ts b/src/core/chat/manager.ts index da51c89b7..2b2d083c0 100644 --- a/src/core/chat/manager.ts +++ b/src/core/chat/manager.ts @@ -48,6 +48,20 @@ export interface ChatMessageDebugSnapshot { requestMessages: unknown[]; requestBody: unknown; notes: string[]; + // Raw contents of the individual prompt placeholders (empty when unused), + // so the token breakdown can attribute inline-rendered sources. + characterProfileContent: string; + personaContent: string; + memoryEntryCount: number; + lorebookContent: string; + contextSummaryContent: string; + keyMemoriesContent: string; + authorNoteContent: string; + companionStateContent: string; + scheduledNotesContent: string; + // Group chat only: the {{group_characters}} cast block (empty for 1:1 chats). + groupCastContent: string; + groupCastCount: number; } export interface LorebookEntryDraft { @@ -322,3 +336,15 @@ export async function getMessageDebugSnapshot(params: { }, }); } + +export async function getGroupChatMessageDebugSnapshot(params: { + sessionId: string; + messageId: string; +}): Promise { + return invoke("group_chat_message_debug_snapshot", { + args: { + sessionId: params.sessionId, + messageId: params.messageId, + }, + }); +} diff --git a/src/core/i18n/locales/de.ts b/src/core/i18n/locales/de.ts index 6e2367b48..906a622dc 100644 --- a/src/core/i18n/locales/de.ts +++ b/src/core/i18n/locales/de.ts @@ -1455,6 +1455,34 @@ export const deMessages: DeepPartialMessageTree = { fullSessionJson: "Vollständiges Sitzungs-JSON", storedMessageJson: "Gespeichertes Nachrichten-JSON", fullTraceEvents: "Vollständige Trace-Ereignisse", + tokenBreakdown: "Kontextnutzung", + tokenBreakdownEstimate: "Schätzung — tiktoken o200k (ungefähr; der eigene Tokenizer des Modells weicht ab)", + tokenBreakdownCalibrated: "Aufschlüsselung", + tokenBreakdownTotal: "Gesamter Prompt", + tokenBreakdownContext: "Kontextfenster", + tokenBreakdownFree: "Frei", + tokenBreakdownReserved: "Für Antwort reserviert", + tokenBreakdownReservedSplit: "(Antwort: {{response}} / Reasoning: {{reasoning}})", + tokenBreakdownOccupied: "Belegt", + tokenBreakdownOverReserved: + "Überreservierung: Prompt + reservierte Antwort überschreiten das Kontextfenster um {{overflow}} Tokens. Das Modell kürzt den Prompt oder bricht die Antwort ab — reduziere Max-Tokens oder Kontextinhalte.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Token werden gezählt…", + tokenBreakdownCompanionSuffix: "(Innenleben / geplante Notizen)", + tokenBreakdownHistorySuffix: "(die letzten {{count}} Nachrichten)", + tokenBreakdownEntriesSuffix: "({{count}} Einträge)", + tokenBreakdownCharactersSuffix: "({{count}} Charaktere)", + tokenBreakdownCat: { + system: "System", + character: "Charakter", + persona: "Persona", + groupCast: "Gruppen-Cast", + memory: "Erinnerungen", + lorebook: "Lorebook", + authorNote: "Autorenhinweis", + companion: "Companion-Zustand", + history: "Chat-Verlauf", + }, }, companionMemoryPage: { backLabel: "Zurück", diff --git a/src/core/i18n/locales/el.ts b/src/core/i18n/locales/el.ts index 2c0849034..a69c71a64 100644 --- a/src/core/i18n/locales/el.ts +++ b/src/core/i18n/locales/el.ts @@ -1455,6 +1455,34 @@ export const elMessages: DeepPartialMessageTree = { fullSessionJson: "Πλήρες JSON συνεδρίας", storedMessageJson: "JSON αποθηκευμένου μηνύματος", fullTraceEvents: "Πλήρη συμβάντα ίχνους", + tokenBreakdown: "Χρήση πλαισίου", + tokenBreakdownEstimate: "Εκτίμηση — tiktoken o200k (κατά προσέγγιση· ο δικός του tokenizer του μοντέλου θα διαφέρει)", + tokenBreakdownCalibrated: "Ανάλυση", + tokenBreakdownTotal: "Συνολική προτροπή", + tokenBreakdownContext: "Παράθυρο πλαισίου", + tokenBreakdownFree: "Ελεύθερο", + tokenBreakdownReserved: "Δεσμευμένο για την απάντηση", + tokenBreakdownReservedSplit: "(απάντηση: {{response}} / συλλογισμός: {{reasoning}})", + tokenBreakdownOccupied: "Κατειλημμένο", + tokenBreakdownOverReserved: + "Υπερδέσμευση: το prompt + η δεσμευμένη απάντηση υπερβαίνουν το παράθυρο περιβάλλοντος κατά {{overflow}} token. Το μοντέλο θα περικόψει το prompt ή θα κόψει απότομα την απάντηση — μειώστε τα μέγιστα token ή το περιεχόμενο περιβάλλοντος.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Καταμέτρηση token…", + tokenBreakdownCompanionSuffix: "(εσωτερική ζωή / προγραμματισμένες σημειώσεις)", + tokenBreakdownHistorySuffix: "(τελευταία {{count}} μηνύματα)", + tokenBreakdownEntriesSuffix: "({{count}} καταχωρίσεις)", + tokenBreakdownCharactersSuffix: "({{count}} χαρακτήρες)", + tokenBreakdownCat: { + system: "Σύστημα", + character: "Χαρακτήρας", + persona: "Περσόνα", + groupCast: "Καστ ομάδας", + memory: "Αναμνήσεις", + lorebook: "Βιβλίο Γνώσης", + authorNote: "Σημείωση Συγγραφέα", + companion: "Κατάσταση Συντρόφου", + history: "Ιστορικό Συνομιλιών", + }, }, companionMemoryPage: { backLabel: "Πίσω", diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index 70e5b1c27..24c3fb806 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -1562,6 +1562,34 @@ export const enMessages = { fullSessionJson: "Full Session JSON", storedMessageJson: "Stored Message JSON", fullTraceEvents: "Full Trace Events", + tokenBreakdown: "Context Usage", + tokenBreakdownEstimate: "Estimate — tiktoken o200k (approximate; the model's own tokenizer will differ)", + tokenBreakdownCalibrated: "Breakdown", + tokenBreakdownTotal: "Total prompt", + tokenBreakdownContext: "Context window", + tokenBreakdownFree: "Free", + tokenBreakdownReserved: "Reserved for response", + tokenBreakdownReservedSplit: "(response: {{response}} / reasoning: {{reasoning}})", + tokenBreakdownOccupied: "Occupied", + tokenBreakdownOverReserved: + "Over-reservation: prompt + reserved response exceed the context window by {{overflow}} tokens. The model will truncate the prompt or cut the response short — reduce max tokens or context content.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Counting tokens…", + tokenBreakdownCompanionSuffix: "(inner life / scheduled notes)", + tokenBreakdownHistorySuffix: "(last {{count}} messages)", + tokenBreakdownEntriesSuffix: "({{count}} entries)", + tokenBreakdownCharactersSuffix: "({{count}} characters)", + tokenBreakdownCat: { + system: "System", + character: "Character", + persona: "Persona", + groupCast: "Group cast", + memory: "Memories", + lorebook: "Lorebook", + authorNote: "Author's Note", + companion: "Companion State", + history: "Chat History", + }, }, companionMemoryPage: { backLabel: "Back", diff --git a/src/core/i18n/locales/es.ts b/src/core/i18n/locales/es.ts index a7ed8aab9..5db271bf5 100644 --- a/src/core/i18n/locales/es.ts +++ b/src/core/i18n/locales/es.ts @@ -1455,6 +1455,34 @@ export const esMessages: DeepPartialMessageTree = { fullSessionJson: "JSON completo de la sesión", storedMessageJson: "JSON del mensaje almacenado", fullTraceEvents: "Eventos completos del rastro", + tokenBreakdown: "Uso del contexto", + tokenBreakdownEstimate: "Estimación — tiktoken o200k (aproximado; el propio tokenizador del modelo diferirá)", + tokenBreakdownCalibrated: "Desglose", + tokenBreakdownTotal: "Prompt total", + tokenBreakdownContext: "Ventana de contexto", + tokenBreakdownFree: "Libre", + tokenBreakdownReserved: "Reservado para la respuesta", + tokenBreakdownReservedSplit: "(respuesta: {{response}} / razonamiento: {{reasoning}})", + tokenBreakdownOccupied: "Ocupado", + tokenBreakdownOverReserved: + "Sobrerreserva: el prompt + la respuesta reservada superan la ventana de contexto en {{overflow}} tokens. El modelo truncará el prompt o cortará la respuesta — reduce los tokens máximos o el contenido del contexto.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Contando tokens…", + tokenBreakdownCompanionSuffix: "(vida interior / notas programadas)", + tokenBreakdownHistorySuffix: "(últimos {{count}} mensajes)", + tokenBreakdownEntriesSuffix: "({{count}} entradas)", + tokenBreakdownCharactersSuffix: "({{count}} personajes)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personaje", + persona: "Persona", + groupCast: "Reparto del grupo", + memory: "Memorias", + lorebook: "Lorebook", + authorNote: "Nota del autor", + companion: "Estado del compañero", + history: "Historial de chat", + }, }, companionMemoryPage: { backLabel: "Volver", diff --git a/src/core/i18n/locales/fil.ts b/src/core/i18n/locales/fil.ts index 666fd8799..eda79ebd6 100644 --- a/src/core/i18n/locales/fil.ts +++ b/src/core/i18n/locales/fil.ts @@ -1455,6 +1455,34 @@ export const filMessages: DeepPartialMessageTree = { fullSessionJson: "Full Session JSON", storedMessageJson: "Stored Message JSON", fullTraceEvents: "Full Trace Events", + tokenBreakdown: "Paggamit ng Konteksto", + tokenBreakdownEstimate: "Tantiya — tiktoken o200k (tinatantiya lamang; iba ang magiging resulta ng sariling tokenizer ng modelo)", + tokenBreakdownCalibrated: "Breakdown", + tokenBreakdownTotal: "Kabuuang prompt", + tokenBreakdownContext: "Context window", + tokenBreakdownFree: "Libre", + tokenBreakdownReserved: "Nakalaan para sa tugon", + tokenBreakdownReservedSplit: "(tugon: {{response}} / reasoning: {{reasoning}})", + tokenBreakdownOccupied: "Gamit na", + tokenBreakdownOverReserved: + "Sobrang reserba: ang prompt + nakareserbang tugon ay lumalampas sa context window nang {{overflow}} na token. Puputulin ng modelo ang prompt o paiikliin ang tugon — bawasan ang max tokens o nilalaman ng konteksto.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Binibilang ang mga token…", + tokenBreakdownCompanionSuffix: "(panloob na buhay / mga naka-iskedyul na tala)", + tokenBreakdownHistorySuffix: "(huling {{count}} mensahe)", + tokenBreakdownEntriesSuffix: "({{count}} entry)", + tokenBreakdownCharactersSuffix: "({{count}} character)", + tokenBreakdownCat: { + system: "System", + character: "Karakter", + persona: "Persona", + groupCast: "Group cast", + memory: "Mga Alaala", + lorebook: "Lorebook", + authorNote: "Tala ng May-akda", + companion: "Estado ng Companion", + history: "Chat History", + }, }, companionMemoryPage: { backLabel: "Bumalik", diff --git a/src/core/i18n/locales/fr.ts b/src/core/i18n/locales/fr.ts index 6e28540e8..434fc95df 100644 --- a/src/core/i18n/locales/fr.ts +++ b/src/core/i18n/locales/fr.ts @@ -1455,6 +1455,34 @@ export const frMessages: DeepPartialMessageTree = { fullSessionJson: "JSON complet de la session", storedMessageJson: "JSON du message stocké", fullTraceEvents: "Événements de trace complets", + tokenBreakdown: "Utilisation du contexte", + tokenBreakdownEstimate: "Estimation — tiktoken o200k (approximatif ; le tokenizer propre au modèle sera différent)", + tokenBreakdownCalibrated: "Répartition", + tokenBreakdownTotal: "Prompt total", + tokenBreakdownContext: "Fenêtre de contexte", + tokenBreakdownFree: "Libre", + tokenBreakdownReserved: "Réservé pour la réponse", + tokenBreakdownReservedSplit: "(réponse: {{response}} / raisonnement: {{reasoning}})", + tokenBreakdownOccupied: "Occupé", + tokenBreakdownOverReserved: + "Sur-réservation : le prompt + la réponse réservée dépassent la fenêtre de contexte de {{overflow}} tokens. Le modèle tronquera le prompt ou écourtera la réponse — réduisez le nombre max de tokens ou le contenu du contexte.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Comptage des tokens…", + tokenBreakdownCompanionSuffix: "(vie intérieure / notes programmées)", + tokenBreakdownHistorySuffix: "(les {{count}} derniers messages)", + tokenBreakdownEntriesSuffix: "({{count}} entrées)", + tokenBreakdownCharactersSuffix: "({{count}} personnages)", + tokenBreakdownCat: { + system: "Système", + character: "Personnage", + persona: "Persona", + groupCast: "Distribution du groupe", + memory: "Souvenirs", + lorebook: "Encyclopédie", + authorNote: "Note de l'auteur", + companion: "État du compagnon", + history: "Historique des discussions", + }, }, companionMemoryPage: { backLabel: "Retour", diff --git a/src/core/i18n/locales/hi.ts b/src/core/i18n/locales/hi.ts index 20d766ea4..54b59e909 100644 --- a/src/core/i18n/locales/hi.ts +++ b/src/core/i18n/locales/hi.ts @@ -1455,6 +1455,34 @@ export const hiMessages: DeepPartialMessageTree = { fullSessionJson: "पूर्ण सेशन JSON", storedMessageJson: "स्टोर्ड मैसेज JSON", fullTraceEvents: "पूर्ण ट्रेस इवेंट्स", + tokenBreakdown: "कॉन्टेक्स्ट उपयोग", + tokenBreakdownEstimate: "अनुमान — tiktoken o200k (अनुमानित; मॉडल का अपना tokenizer भिन्न होगा)", + tokenBreakdownCalibrated: "विवरण", + tokenBreakdownTotal: "कुल प्रॉम्प्ट", + tokenBreakdownContext: "कॉन्टेक्स्ट विंडो", + tokenBreakdownFree: "मुक्त", + tokenBreakdownReserved: "प्रतिक्रिया के लिए आरक्षित", + tokenBreakdownReservedSplit: "(प्रतिक्रिया: {{response}} / reasoning: {{reasoning}})", + tokenBreakdownOccupied: "उपयोग में", + tokenBreakdownOverReserved: + "अति-आरक्षण: प्रॉम्प्ट + आरक्षित उत्तर संदर्भ विंडो को {{overflow}} टोकन से पार कर जाते हैं। मॉडल प्रॉम्प्ट को छोटा कर देगा या उत्तर को बीच में काट देगा — अधिकतम टोकन या संदर्भ सामग्री कम करें।", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "टोकन गिने जा रहे हैं…", + tokenBreakdownCompanionSuffix: "(आंतरिक जीवन / शेड्यूल्ड नोट्स)", + tokenBreakdownHistorySuffix: "(अंतिम {{count}} संदेश)", + tokenBreakdownEntriesSuffix: "({{count}} प्रविष्टियाँ)", + tokenBreakdownCharactersSuffix: "({{count}} कैरेक्टर)", + tokenBreakdownCat: { + system: "सिस्टम", + character: "कैरेक्टर", + persona: "पर्सोना", + groupCast: "ग्रुप कास्ट", + memory: "यादें", + lorebook: "लोरबुक", + authorNote: "लेखक की टिप्पणी", + companion: "Companion स्थिति", + history: "चैट इतिहास", + }, }, companionMemoryPage: { backLabel: "वापस", diff --git a/src/core/i18n/locales/id.ts b/src/core/i18n/locales/id.ts index d5d5ca577..f89e70cab 100644 --- a/src/core/i18n/locales/id.ts +++ b/src/core/i18n/locales/id.ts @@ -1455,6 +1455,34 @@ export const idMessages: DeepPartialMessageTree = { fullSessionJson: "JSON Sesi Lengkap", storedMessageJson: "JSON Pesan Tersimpan", fullTraceEvents: "Peristiwa Jejak Lengkap", + tokenBreakdown: "Penggunaan Konteks", + tokenBreakdownEstimate: "Perkiraan — tiktoken o200k (perkiraan; tokenizer milik model sendiri akan berbeda)", + tokenBreakdownCalibrated: "Rincian", + tokenBreakdownTotal: "Total prompt", + tokenBreakdownContext: "Jendela konteks", + tokenBreakdownFree: "Bebas", + tokenBreakdownReserved: "Dicadangkan untuk respons", + tokenBreakdownReservedSplit: "(respons: {{response}} / penalaran: {{reasoning}})", + tokenBreakdownOccupied: "Terpakai", + tokenBreakdownOverReserved: + "Kelebihan reservasi: prompt + respons yang dicadangkan melebihi jendela konteks sebesar {{overflow}} token. Model akan memotong prompt atau mempersingkat respons — kurangi token maksimum atau konten konteks.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Menghitung token…", + tokenBreakdownCompanionSuffix: "(kehidupan batin / catatan terjadwal)", + tokenBreakdownHistorySuffix: "({{count}} pesan terakhir)", + tokenBreakdownEntriesSuffix: "({{count}} entri)", + tokenBreakdownCharactersSuffix: "({{count}} karakter)", + tokenBreakdownCat: { + system: "Sistem", + character: "Karakter", + persona: "Persona", + groupCast: "Pemeran grup", + memory: "Memori", + lorebook: "Lorebook", + authorNote: "Catatan Penulis", + companion: "Keadaan Companion", + history: "Riwayat Obrolan", + }, }, companionMemoryPage: { backLabel: "Kembali", diff --git a/src/core/i18n/locales/it.ts b/src/core/i18n/locales/it.ts index c9b2b6b3d..1becdf199 100644 --- a/src/core/i18n/locales/it.ts +++ b/src/core/i18n/locales/it.ts @@ -1455,6 +1455,34 @@ export const itMessages: DeepPartialMessageTree = { fullSessionJson: "JSON completo della sessione", storedMessageJson: "JSON del messaggio memorizzato", fullTraceEvents: "Eventi di traccia completi", + tokenBreakdown: "Utilizzo del contesto", + tokenBreakdownEstimate: "Stima — tiktoken o200k (approssimativa; il tokenizer del modello stesso sarà diverso)", + tokenBreakdownCalibrated: "Ripartizione", + tokenBreakdownTotal: "Prompt totale", + tokenBreakdownContext: "Finestra di contesto", + tokenBreakdownFree: "Libero", + tokenBreakdownReserved: "Riservato alla risposta", + tokenBreakdownReservedSplit: "(risposta: {{response}} / ragionamento: {{reasoning}})", + tokenBreakdownOccupied: "Occupato", + tokenBreakdownOverReserved: + "Sovra-prenotazione: il prompt + la risposta riservata superano la finestra di contesto di {{overflow}} token. Il modello troncherà il prompt o accorcerà la risposta — riduci i token massimi o il contenuto del contesto.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Conteggio token…", + tokenBreakdownCompanionSuffix: "(vita interiore / note pianificate)", + tokenBreakdownHistorySuffix: "(ultimi {{count}} messaggi)", + tokenBreakdownEntriesSuffix: "({{count}} voci)", + tokenBreakdownCharactersSuffix: "({{count}} personaggi)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personaggio", + persona: "Persona", + groupCast: "Cast del gruppo", + memory: "Ricordi", + lorebook: "Lorebook", + authorNote: "Nota autore", + companion: "Stato del compagno", + history: "Cronologia chat", + }, }, companionMemoryPage: { backLabel: "Indietro", diff --git a/src/core/i18n/locales/ja.ts b/src/core/i18n/locales/ja.ts index c6c45230e..12df14b64 100644 --- a/src/core/i18n/locales/ja.ts +++ b/src/core/i18n/locales/ja.ts @@ -1455,6 +1455,34 @@ export const jaMessages: DeepPartialMessageTree = { fullSessionJson: "完全なセッションJSON", storedMessageJson: "保存されたメッセージJSON", fullTraceEvents: "完全なトレースイベント", + tokenBreakdown: "コンテキスト使用状況", + tokenBreakdownEstimate: "推定 — tiktoken o200k(概算。モデル独自のトークナイザーとは異なります)", + tokenBreakdownCalibrated: "内訳", + tokenBreakdownTotal: "プロンプト合計", + tokenBreakdownContext: "コンテキストウィンドウ", + tokenBreakdownFree: "空き", + tokenBreakdownReserved: "応答用に予約", + tokenBreakdownReservedSplit: "(応答: {{response}} / 推論: {{reasoning}})", + tokenBreakdownOccupied: "使用中", + tokenBreakdownOverReserved: + "過剰予約: プロンプトと予約済みの応答がコンテキストウィンドウを {{overflow}} トークン超過しています。モデルはプロンプトを切り詰めるか、応答を途中で打ち切ります。最大トークン数またはコンテキストの内容を減らしてください。", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "トークンを計算中…", + tokenBreakdownCompanionSuffix: "(内面 / 予約ノート)", + tokenBreakdownHistorySuffix: "(直近 {{count}} 件のメッセージ)", + tokenBreakdownEntriesSuffix: "({{count}} 件のエントリ)", + tokenBreakdownCharactersSuffix: "({{count}} 人のキャラクター)", + tokenBreakdownCat: { + system: "システム", + character: "キャラクター", + persona: "ペルソナ", + groupCast: "グループキャスト", + memory: "メモリ", + lorebook: "ロアブック", + authorNote: "作者ノート", + companion: "コンパニオンの状態", + history: "チャット履歴", + }, }, companionMemoryPage: { backLabel: "戻る", diff --git a/src/core/i18n/locales/ko.ts b/src/core/i18n/locales/ko.ts index ea8164e4f..36dd9059d 100644 --- a/src/core/i18n/locales/ko.ts +++ b/src/core/i18n/locales/ko.ts @@ -1455,6 +1455,34 @@ export const koMessages: DeepPartialMessageTree = { fullSessionJson: "전체 세션 JSON", storedMessageJson: "저장된 메시지 JSON", fullTraceEvents: "전체 추적 이벤트", + tokenBreakdown: "컨텍스트 사용량", + tokenBreakdownEstimate: "추정치 — tiktoken o200k (근사치이며 모델 자체 토크나이저와는 다를 수 있습니다)", + tokenBreakdownCalibrated: "세부 내역", + tokenBreakdownTotal: "전체 프롬프트", + tokenBreakdownContext: "컨텍스트 창", + tokenBreakdownFree: "여유", + tokenBreakdownReserved: "응답용 예약", + tokenBreakdownReservedSplit: "(응답: {{response}} / 추론: {{reasoning}})", + tokenBreakdownOccupied: "사용 중", + tokenBreakdownOverReserved: + "과잉 예약: 프롬프트 + 예약된 응답이 컨텍스트 창을 {{overflow}} 토큰만큼 초과합니다. 모델이 프롬프트를 잘라내거나 응답을 도중에 끊습니다 — 최대 토큰 수나 컨텍스트 내용을 줄이세요.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "토큰 계산 중…", + tokenBreakdownCompanionSuffix: "(내면 / 예약된 노트)", + tokenBreakdownHistorySuffix: "(최근 {{count}}개 메시지)", + tokenBreakdownEntriesSuffix: "({{count}}개 항목)", + tokenBreakdownCharactersSuffix: "({{count}}명 캐릭터)", + tokenBreakdownCat: { + system: "시스템", + character: "캐릭터", + persona: "페르소나", + groupCast: "그룹 캐스트", + memory: "기억", + lorebook: "로어북", + authorNote: "작가 노트", + companion: "동반자 상태", + history: "채팅 기록", + }, }, companionMemoryPage: { backLabel: "뒤로", diff --git a/src/core/i18n/locales/nl.ts b/src/core/i18n/locales/nl.ts index 81b27e43d..5e794e7a4 100644 --- a/src/core/i18n/locales/nl.ts +++ b/src/core/i18n/locales/nl.ts @@ -1455,6 +1455,34 @@ export const nlMessages: DeepPartialMessageTree = { fullSessionJson: "Volledige sessie-JSON", storedMessageJson: "Opgeslagen bericht-JSON", fullTraceEvents: "Volledige trace-events", + tokenBreakdown: "Contextgebruik", + tokenBreakdownEstimate: "Schatting — tiktoken o200k (bij benadering; de eigen tokenizer van het model wijkt af)", + tokenBreakdownCalibrated: "Uitsplitsing", + tokenBreakdownTotal: "Totale prompt", + tokenBreakdownContext: "Contextvenster", + tokenBreakdownFree: "Vrij", + tokenBreakdownReserved: "Gereserveerd voor antwoord", + tokenBreakdownReservedSplit: "(antwoord: {{response}} / redenering: {{reasoning}})", + tokenBreakdownOccupied: "Bezet", + tokenBreakdownOverReserved: + "Overreservering: prompt + gereserveerd antwoord overschrijden het contextvenster met {{overflow}} tokens. Het model kapt de prompt af of breekt het antwoord voortijdig af — verlaag het maximale aantal tokens of de contextinhoud.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Tokens tellen…", + tokenBreakdownCompanionSuffix: "(innerlijk leven / geplande notities)", + tokenBreakdownHistorySuffix: "(laatste {{count}} berichten)", + tokenBreakdownEntriesSuffix: "({{count}} vermeldingen)", + tokenBreakdownCharactersSuffix: "({{count}} personages)", + tokenBreakdownCat: { + system: "Systeem", + character: "Karakter", + persona: "Persona", + groupCast: "Groepscast", + memory: "Herinneringen", + lorebook: "Lorebook", + authorNote: "Auteursnotitie", + companion: "Companion-status", + history: "Chatgeschiedenis", + }, }, companionMemoryPage: { backLabel: "Terug", diff --git a/src/core/i18n/locales/no.ts b/src/core/i18n/locales/no.ts index e59b8d60c..d7d049eb9 100644 --- a/src/core/i18n/locales/no.ts +++ b/src/core/i18n/locales/no.ts @@ -1455,6 +1455,34 @@ export const noMessages: DeepPartialMessageTree = { fullSessionJson: "Full økt-JSON", storedMessageJson: "Lagret meldings-JSON", fullTraceEvents: "Fulle sporhendelser", + tokenBreakdown: "Kontekstbruk", + tokenBreakdownEstimate: "Estimat — tiktoken o200k (omtrentlig; modellens egen tokenizer vil avvike)", + tokenBreakdownCalibrated: "Fordeling", + tokenBreakdownTotal: "Total prompt", + tokenBreakdownContext: "Kontekstvindu", + tokenBreakdownFree: "Ledig", + tokenBreakdownReserved: "Reservert for svar", + tokenBreakdownReservedSplit: "(svar: {{response}} / resonnering: {{reasoning}})", + tokenBreakdownOccupied: "Opptatt", + tokenBreakdownOverReserved: + "Overreservering: prompt + reservert svar overskrider kontekstvinduet med {{overflow}} tokens. Modellen vil forkorte prompten eller avbryte svaret — reduser maks tokens eller kontekstinnhold.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Teller tokens…", + tokenBreakdownCompanionSuffix: "(indre liv / planlagte notater)", + tokenBreakdownHistorySuffix: "(siste {{count}} meldinger)", + tokenBreakdownEntriesSuffix: "({{count}} oppføringer)", + tokenBreakdownCharactersSuffix: "({{count}} karakterer)", + tokenBreakdownCat: { + system: "System", + character: "Karakter", + persona: "Persona", + groupCast: "Gruppebesetning", + memory: "Minner", + lorebook: "Lorebok", + authorNote: "Forfatternote", + companion: "Følgesvennstatus", + history: "Chathistorikk", + }, }, companionMemoryPage: { backLabel: "Tilbake", diff --git a/src/core/i18n/locales/pl.ts b/src/core/i18n/locales/pl.ts index 507305a5d..26bfb0a33 100644 --- a/src/core/i18n/locales/pl.ts +++ b/src/core/i18n/locales/pl.ts @@ -1455,6 +1455,34 @@ export const plMessages: DeepPartialMessageTree = { fullSessionJson: "Pełny JSON sesji", storedMessageJson: "Zapisany JSON wiadomości", fullTraceEvents: "Pełne zdarzenia śladu", + tokenBreakdown: "Wykorzystanie kontekstu", + tokenBreakdownEstimate: "Szacunek — tiktoken o200k (przybliżony; własny tokenizer modelu będzie się różnić)", + tokenBreakdownCalibrated: "Podział", + tokenBreakdownTotal: "Prompt łącznie", + tokenBreakdownContext: "Okno kontekstu", + tokenBreakdownFree: "Wolne", + tokenBreakdownReserved: "Zarezerwowane na odpowiedź", + tokenBreakdownReservedSplit: "(odpowiedź: {{response}} / rozumowanie: {{reasoning}})", + tokenBreakdownOccupied: "Zajęte", + tokenBreakdownOverReserved: + "Nadmierna rezerwacja: prompt + zarezerwowana odpowiedź przekraczają okno kontekstu o {{overflow}} tokenów. Model skróci prompt lub urwie odpowiedź — zmniejsz maksymalną liczbę tokenów lub zawartość kontekstu.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Liczenie tokenów…", + tokenBreakdownCompanionSuffix: "(życie wewnętrzne / zaplanowane notatki)", + tokenBreakdownHistorySuffix: "(ostatnie {{count}} wiadomości)", + tokenBreakdownEntriesSuffix: "({{count}} wpisów)", + tokenBreakdownCharactersSuffix: "({{count}} postaci)", + tokenBreakdownCat: { + system: "System", + character: "Postać", + persona: "Persona", + groupCast: "Obsada grupy", + memory: "Wspomnienia", + lorebook: "Lorebook", + authorNote: "Notatka autora", + companion: "Stan towarzysza", + history: "Historia czatu", + }, }, companionMemoryPage: { backLabel: "Wstecz", diff --git a/src/core/i18n/locales/pt.ts b/src/core/i18n/locales/pt.ts index 15df3f406..aa05e0a50 100644 --- a/src/core/i18n/locales/pt.ts +++ b/src/core/i18n/locales/pt.ts @@ -1455,6 +1455,34 @@ export const ptMessages: DeepPartialMessageTree = { fullSessionJson: "JSON Completo da Sessão", storedMessageJson: "JSON da Mensagem Armazenada", fullTraceEvents: "Eventos Completos do Rastreamento", + tokenBreakdown: "Uso do Contexto", + tokenBreakdownEstimate: "Estimativa — tiktoken o200k (aproximado; o próprio tokenizador do modelo será diferente)", + tokenBreakdownCalibrated: "Detalhamento", + tokenBreakdownTotal: "Prompt total", + tokenBreakdownContext: "Janela de Contexto", + tokenBreakdownFree: "Livre", + tokenBreakdownReserved: "Reservado para a resposta", + tokenBreakdownReservedSplit: "(resposta: {{response}} / raciocínio: {{reasoning}})", + tokenBreakdownOccupied: "Ocupado", + tokenBreakdownOverReserved: + "Sobrerreserva: o prompt + a resposta reservada excedem a janela de contexto em {{overflow}} tokens. O modelo truncará o prompt ou encurtará a resposta — reduza o máximo de tokens ou o conteúdo do contexto.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Contando tokens…", + tokenBreakdownCompanionSuffix: "(vida interior / notas agendadas)", + tokenBreakdownHistorySuffix: "(últimas {{count}} mensagens)", + tokenBreakdownEntriesSuffix: "({{count}} entradas)", + tokenBreakdownCharactersSuffix: "({{count}} personagens)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personagem", + persona: "Persona", + groupCast: "Elenco do grupo", + memory: "Memórias", + lorebook: "Lorebook", + authorNote: "Nota do autor", + companion: "Estado do Companion", + history: "Histórico de Conversas", + }, }, companionMemoryPage: { backLabel: "Back", diff --git a/src/core/i18n/locales/ru.ts b/src/core/i18n/locales/ru.ts index bbe48c645..b03d892ff 100644 --- a/src/core/i18n/locales/ru.ts +++ b/src/core/i18n/locales/ru.ts @@ -1455,6 +1455,34 @@ export const ruMessages: DeepPartialMessageTree = { fullSessionJson: "Полный JSON сессии", storedMessageJson: "Сохранённый JSON сообщения", fullTraceEvents: "Все события трассировки", + tokenBreakdown: "Использование контекста", + tokenBreakdownEstimate: "Оценка — tiktoken o200k (приблизительно; собственный токенизатор модели будет отличаться)", + tokenBreakdownCalibrated: "Разбивка", + tokenBreakdownTotal: "Всего в промпте", + tokenBreakdownContext: "Окно контекста", + tokenBreakdownFree: "Свободно", + tokenBreakdownReserved: "Зарезервировано для ответа", + tokenBreakdownReservedSplit: "(ответ: {{response}} / рассуждение: {{reasoning}})", + tokenBreakdownOccupied: "Занято", + tokenBreakdownOverReserved: + "Избыточное резервирование: промпт + зарезервированный ответ превышают контекстное окно на {{overflow}} токенов. Модель обрежет промпт или оборвёт ответ — уменьшите максимум токенов или содержимое контекста.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Подсчёт токенов…", + tokenBreakdownCompanionSuffix: "(внутренняя жизнь / запланированные заметки)", + tokenBreakdownHistorySuffix: "(последние {{count}} сообщений)", + tokenBreakdownEntriesSuffix: "({{count}} записей)", + tokenBreakdownCharactersSuffix: "({{count}} персонажей)", + tokenBreakdownCat: { + system: "Система", + character: "Персонаж", + persona: "Персона", + groupCast: "Состав группы", + memory: "Воспоминания", + lorebook: "Лорбук", + authorNote: "Заметка автора", + companion: "Состояние компаньона", + history: "История чата", + }, }, companionMemoryPage: { backLabel: "Назад", diff --git a/src/core/i18n/locales/tr.ts b/src/core/i18n/locales/tr.ts index 42c3d577c..b6639d788 100644 --- a/src/core/i18n/locales/tr.ts +++ b/src/core/i18n/locales/tr.ts @@ -1455,6 +1455,34 @@ export const trMessages: DeepPartialMessageTree = { fullSessionJson: "Tam Oturum JSON'u", storedMessageJson: "Kaydedilmiş Mesaj JSON'u", fullTraceEvents: "Tam İzleme Olayları", + tokenBreakdown: "Bağlam Kullanımı", + tokenBreakdownEstimate: "Tahmin — tiktoken o200k (yaklaşık; modelin kendi tokenizasyonu farklılık gösterecektir)", + tokenBreakdownCalibrated: "Dağılım", + tokenBreakdownTotal: "Toplam prompt", + tokenBreakdownContext: "Bağlam penceresi", + tokenBreakdownFree: "Boş", + tokenBreakdownReserved: "Yanıt için ayrılan", + tokenBreakdownReservedSplit: "(yanıt: {{response}} / akıl yürütme: {{reasoning}})", + tokenBreakdownOccupied: "Dolu", + tokenBreakdownOverReserved: + "Aşırı rezervasyon: istem + ayrılan yanıt bağlam penceresini {{overflow}} token aşıyor. Model istemi kısaltır veya yanıtı yarıda keser — maksimum token sayısını ya da bağlam içeriğini azaltın.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Tokenlar sayılıyor…", + tokenBreakdownCompanionSuffix: "(iç dünya / planlanmış notlar)", + tokenBreakdownHistorySuffix: "(son {{count}} mesaj)", + tokenBreakdownEntriesSuffix: "({{count}} girdi)", + tokenBreakdownCharactersSuffix: "({{count}} karakter)", + tokenBreakdownCat: { + system: "Sistem", + character: "Karakter", + persona: "Persona", + groupCast: "Grup kadrosu", + memory: "Anılar", + lorebook: "Lore kitabı", + authorNote: "Yazar notu", + companion: "Yoldaş durumu", + history: "Sohbet geçmişi", + }, }, companionMemoryPage: { backLabel: "_Back", diff --git a/src/core/i18n/locales/vi.ts b/src/core/i18n/locales/vi.ts index c0539e518..da83f31a4 100644 --- a/src/core/i18n/locales/vi.ts +++ b/src/core/i18n/locales/vi.ts @@ -1455,6 +1455,34 @@ export const viMessages: DeepPartialMessageTree = { fullSessionJson: "Toàn bộ JSON phiên", storedMessageJson: "JSON tin nhắn đã lưu", fullTraceEvents: "Toàn bộ sự kiện dấu vết", + tokenBreakdown: "Sử dụng ngữ cảnh", + tokenBreakdownEstimate: "Ước tính — tiktoken o200k (chỉ là ước tính; tokenizer riêng của mô hình sẽ khác)", + tokenBreakdownCalibrated: "Phân tích", + tokenBreakdownTotal: "Tổng prompt", + tokenBreakdownContext: "Cửa sổ ngữ cảnh", + tokenBreakdownFree: "Trống", + tokenBreakdownReserved: "Dành cho phản hồi", + tokenBreakdownReservedSplit: "(phản hồi: {{response}} / suy luận: {{reasoning}})", + tokenBreakdownOccupied: "Đã dùng", + tokenBreakdownOverReserved: + "Đặt trước quá mức: prompt + phản hồi được dành riêng vượt quá cửa sổ ngữ cảnh {{overflow}} token. Mô hình sẽ cắt bớt prompt hoặc kết thúc phản hồi sớm — hãy giảm token tối đa hoặc nội dung ngữ cảnh.", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Đang đếm token…", + tokenBreakdownCompanionSuffix: "(đời sống nội tâm / ghi chú theo lịch)", + tokenBreakdownHistorySuffix: "({{count}} tin nhắn gần nhất)", + tokenBreakdownEntriesSuffix: "({{count}} mục)", + tokenBreakdownCharactersSuffix: "({{count}} nhân vật)", + tokenBreakdownCat: { + system: "Hệ thống", + character: "Nhân vật", + persona: "Persona", + groupCast: "Dàn nhân vật nhóm", + memory: "Ký ức", + lorebook: "Sách lore", + authorNote: "Ghi chú tác giả", + companion: "Trạng thái Companion", + history: "Lịch sử trò chuyện", + }, }, companionMemoryPage: { backLabel: "Back", diff --git a/src/core/i18n/locales/zh-Hans.ts b/src/core/i18n/locales/zh-Hans.ts index 73035b32e..f5dcb75ce 100644 --- a/src/core/i18n/locales/zh-Hans.ts +++ b/src/core/i18n/locales/zh-Hans.ts @@ -1455,6 +1455,34 @@ export const zhHansMessages: DeepPartialMessageTree = { fullSessionJson: "完整会话 JSON", storedMessageJson: "存储的消息 JSON", fullTraceEvents: "完整追踪事件", + tokenBreakdown: "上下文用量", + tokenBreakdownEstimate: "估算 — tiktoken o200k(近似值;模型自身的分词器会有所不同)", + tokenBreakdownCalibrated: "明细", + tokenBreakdownTotal: "提示总量", + tokenBreakdownContext: "上下文窗口", + tokenBreakdownFree: "空闲", + tokenBreakdownReserved: "为回复保留", + tokenBreakdownReservedSplit: "(回复: {{response}} / 推理: {{reasoning}})", + tokenBreakdownOccupied: "已占用", + tokenBreakdownOverReserved: + "超额预留:提示词 + 预留的回复超出上下文窗口 {{overflow}} 个 token。模型将截断提示词或提前结束回复——请减少最大 token 数或上下文内容。", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "正在计算 token…", + tokenBreakdownCompanionSuffix: "(内心 / 计划笔记)", + tokenBreakdownHistorySuffix: "(最近 {{count}} 条消息)", + tokenBreakdownEntriesSuffix: "({{count}} 个条目)", + tokenBreakdownCharactersSuffix: "({{count}} 个角色)", + tokenBreakdownCat: { + system: "系统", + character: "角色", + persona: "人设", + groupCast: "群组角色", + memory: "记忆", + lorebook: "知识库", + authorNote: "作者注释", + companion: "伙伴状态", + history: "聊天历史", + }, }, companionMemoryPage: { backLabel: "返回", diff --git a/src/core/i18n/locales/zh-Hant.ts b/src/core/i18n/locales/zh-Hant.ts index 1d0d55bb0..392a837b2 100644 --- a/src/core/i18n/locales/zh-Hant.ts +++ b/src/core/i18n/locales/zh-Hant.ts @@ -1455,6 +1455,34 @@ export const zhHantMessages: DeepPartialMessageTree = { fullSessionJson: "完整對話階段 JSON (Full Session JSON)", storedMessageJson: "儲存的訊息 JSON (Stored Message JSON)", fullTraceEvents: "完整追蹤事件 (Full Trace Events)", + tokenBreakdown: "上下文用量 (Context Usage)", + tokenBreakdownEstimate: "估算 — tiktoken o200k(近似值;模型自身的分詞器會有所不同)", + tokenBreakdownCalibrated: "明細 (Breakdown)", + tokenBreakdownTotal: "提示總量 (Total Prompt)", + tokenBreakdownContext: "上下文視窗 (Context Window)", + tokenBreakdownFree: "剩餘 (Free)", + tokenBreakdownReserved: "為回覆保留 (Reserved for Response)", + tokenBreakdownReservedSplit: "(回覆 Response: {{response}} / 推理 Reasoning: {{reasoning}})", + tokenBreakdownOccupied: "已佔用 (Occupied)", + tokenBreakdownOverReserved: + "超額預留:提示詞 + 預留的回覆超出上下文視窗 {{overflow}} 個 token。模型將截斷提示詞或提前結束回覆——請減少最大 token 數或上下文內容。", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "正在計算 token…", + tokenBreakdownCompanionSuffix: "(內心 / 排程筆記 (inner life / scheduled notes))", + tokenBreakdownHistorySuffix: "(最近 {{count}} 則訊息 (last {{count}} messages))", + tokenBreakdownEntriesSuffix: "({{count}} 個條目 (entries))", + tokenBreakdownCharactersSuffix: "({{count}} 個角色 (characters))", + tokenBreakdownCat: { + system: "系統 (System)", + character: "角色 (Character)", + persona: "人設 (Persona)", + groupCast: "群組角色 (Group cast)", + memory: "記憶 (Memories)", + lorebook: "設定集 (Lorebook)", + authorNote: "作者備注 (Author's Note)", + companion: "同伴狀態 (Companion State)", + history: "對話歷史 (Chat History)", + }, }, companionMemoryPage: { backLabel: "返回", diff --git a/src/ui/pages/chats/components/MessageActionsBottomSheet.tsx b/src/ui/pages/chats/components/MessageActionsBottomSheet.tsx index 25d0de5e0..a953a2a44 100644 --- a/src/ui/pages/chats/components/MessageActionsBottomSheet.tsx +++ b/src/ui/pages/chats/components/MessageActionsBottomSheet.tsx @@ -34,6 +34,11 @@ import { getMessageCompanionEffect, readSettings } from "../../../../core/storag import { useI18n } from "../../../../core/i18n/context"; import { isDevelopmentMode } from "../../../../core/utils/env"; import { useSessionAttachments } from "../../../hooks/useSessionAttachment"; +import { + useMessageDebugSnapshot, + useTokenBreakdown, + PromptTokenBreakdownBars, +} from "./PromptTokenBreakdown"; interface MessageActionState { message: StoredMessage; @@ -336,6 +341,19 @@ export function MessageActionsBottomSheet({ const firstTokenMs = messageAction?.message.usage?.firstTokenMs; const tokensPerSecond = messageAction?.message.usage?.tokensPerSecond; const mtpStats = messageAction?.message.usage?.mtpStats; + + // Prompt / context breakdown bars for assistant messages. Loads the same + // reconstructed snapshot the debug page uses and reuses its shared component. + const isAssistantBreakdown = messageAction?.message.role === "assistant"; + const breakdownSnapshot = useMessageDebugSnapshot( + sessionId, + messageAction?.message.id ?? null, + Boolean(isAssistantBreakdown && sessionId), + ); + const breakdownData = useTokenBreakdown( + breakdownSnapshot, + messageAction?.message ?? null, + ); const loadedEditAttachments = useSessionAttachments(editAttachments); const editingAttachment = loadedEditAttachments.find((attachment) => attachment.id === editingAttachmentId) ?? null; @@ -654,6 +672,20 @@ export function MessageActionsBottomSheet({ )} + {!isSceneMessage && + isAssistantBreakdown && + (breakdownData.breakdown || breakdownData.loading) && ( +
+
+ + + {t("chats.debugPage.tokenBreakdown")} + +
+ +
+ )} + {/* Basic actions */} {canEdit && ( = { + system: "chats.debugPage.tokenBreakdownCat.system", + character: "chats.debugPage.tokenBreakdownCat.character", + persona: "chats.debugPage.tokenBreakdownCat.persona", + groupCast: "chats.debugPage.tokenBreakdownCat.groupCast", + memory: "chats.debugPage.tokenBreakdownCat.memory", + lorebook: "chats.debugPage.tokenBreakdownCat.lorebook", + authorNote: "chats.debugPage.tokenBreakdownCat.authorNote", + companion: "chats.debugPage.tokenBreakdownCat.companion", + history: "chats.debugPage.tokenBreakdownCat.history", +}; + +// Distinct, theme-independent colors for the stacked breakdown bar + legend. +const CATEGORY_COLORS: Record = { + system: "#6366f1", // indigo + character: "#ec4899", // pink + persona: "#14b8a6", // teal + groupCast: "#f97316", // orange + memory: "#10b981", // emerald + lorebook: "#f59e0b", // amber + authorNote: "#84cc16", // lime (kept clear of the red reserved segment) + companion: "#8b5cf6", // violet + history: "#3b82f6", // blue +}; + +// Red for the completion budget reserved inside the context window +// (llama.cpp / Ollama) — it eats into the usable context before generation. +const RESERVED_COLOR = "#ef4444"; // red +// Grey for the still-free, usable remainder of the context window. +const FREE_COLOR = "#9ca3af"; // grey +// Indigo for the occupied portion in the simple context usage bar (bar 2). +const OCCUPIED_COLOR = "#6366f1"; // indigo + +// Only local providers reserve the completion budget inside the context window +// (n_ctx / num_ctx = prompt + completion), so "reserved for response" is shown +// only for them. Remote providers manage the context window server-side and +// treat max_tokens as a pure response cap — no reservation to display. Mirrors +// the backend (chat_manager/execution/provider_fields.rs). +const RESERVING_PROVIDER_IDS = new Set(["llamacpp", "ollama"]); + +/** Flatten an entry/message content (string or multimodal parts) into plain text. */ +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && "text" in part) { + const text = (part as { text?: unknown }).text; + return typeof text === "string" ? text : ""; + } + return ""; + }) + .join(""); + } + return ""; +} + +/** Map a system prompt entry to a breakdown category via its stable id/name. */ +function categorizeEntry(entry: PromptEntryLike): BreakdownCategory { + const id = typeof entry.id === "string" ? entry.id.toLowerCase() : ""; + const name = typeof entry.name === "string" ? entry.name.toLowerCase() : ""; + const has = (needle: string) => id.includes(needle) || name.includes(needle); + + if ( + id === "runtime_retrieved_memories" || + id === "entry_context_summary" || + id === "entry_key_memories" || + has("memor") || + has("context summary") + ) { + return "memory"; + } + if (id === "entry_lorebook" || has("lorebook") || has("world information")) { + return "lorebook"; + } + if (id === "entry_author_note" || has("author")) { + return "authorNote"; + } + if ( + id === "entry_companion_state" || + id === "entry_scheduled_notes" || + has("companion") || + has("scheduled notes") + ) { + return "companion"; + } + return "system"; +} + +type TokenBreakdown = { + rows: { category: BreakdownCategory; tokens: number }[]; + total: number; +}; + +type BreakdownSegment = { + key: string; + labelKey: TranslationKey; + color: string; + tokens: number; +}; + +export type TokenBreakdownData = { + breakdown: TokenBreakdown | null; + loading: boolean; + calibrated: boolean; + displayTotal: number; + reservedTokens: number; + responseTokens: number; + reasoningTokens: number; + occupiedTotal: number; + contextLength: number | null; + historyCount: number; + memoryEntryCount: number; + lorebookEntryCount: number; + groupCastCount: number; + segments: BreakdownSegment[]; +}; + +/** + * Load the reconstructed debug snapshot for an (assistant) message on demand. + * Returns null until loaded / when disabled. Shared by the debug page and the + * user-facing message sheet. + */ +export function useMessageDebugSnapshot( + sessionId: string | null | undefined, + messageId: string | null | undefined, + enabled: boolean, + variant: "direct" | "group" = "direct", +): ChatMessageDebugSnapshot | null { + const [snapshot, setSnapshot] = useState(null); + + useEffect(() => { + if (!enabled || !sessionId || !messageId) { + setSnapshot(null); + return; + } + let cancelled = false; + const load = + variant === "group" ? getGroupChatMessageDebugSnapshot : getMessageDebugSnapshot; + void load({ sessionId, messageId }) + .then((next) => { + if (!cancelled) setSnapshot(next); + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }); + return () => { + cancelled = true; + }; + }, [sessionId, messageId, enabled, variant]); + + return snapshot; +} + +/** + * Compute the prompt token breakdown from a debug snapshot: per-category token + * shares (tiktoken), scaled onto the provider's real prompt tokens when known, + * plus the reserved completion budget (llama.cpp / Ollama) and context usage. + */ +export function useTokenBreakdown( + snapshot: ChatMessageDebugSnapshot | null, + message: BreakdownMessage | null, +): TokenBreakdownData { + const [breakdown, setBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!snapshot) { + setBreakdown(null); + setLoading(false); + return; + } + let cancelled = false; + + // Entry buckets come from the reconstructed system prompt entries (which + // still carry their id/name); chat history is counted from the user/ + // assistant request messages. System-role request messages are skipped to + // avoid double-counting the entries they were assembled from. + const entryItems: { category: BreakdownCategory; text: string }[] = []; + for (const raw of snapshot.promptEntries as PromptEntryLike[]) { + const text = contentToText(raw?.content); + if (!text) continue; + entryItems.push({ category: categorizeEntry(raw), text }); + } + for (const raw of snapshot.requestMessages as PromptEntryLike[]) { + const role = typeof raw?.role === "string" ? raw.role.toLowerCase() : ""; + if (role !== "user" && role !== "assistant") continue; + const text = contentToText(raw?.content); + if (!text) continue; + entryItems.push({ category: "history", text }); + } + + // Raw placeholder sources. When a template renders these inline (via + // {{lorebook}} etc.) they never become standalone entries — their tokens sit + // hidden inside the system/persona bucket. We count them separately and, for + // categories that have no standalone entry, reattribute them out of the + // system bucket so they show up as their own slice. + const inlineSources: { category: BreakdownCategory; text: string }[] = []; + const addInline = (category: BreakdownCategory, text: string | undefined) => { + if (text && text.trim()) inlineSources.push({ category, text }); + }; + addInline("character", snapshot.characterProfileContent); + addInline("persona", snapshot.personaContent); + addInline("groupCast", snapshot.groupCastContent); + addInline("lorebook", snapshot.lorebookContent); + addInline("memory", snapshot.contextSummaryContent); + addInline("memory", snapshot.keyMemoriesContent); + addInline("authorNote", snapshot.authorNoteContent); + addInline("companion", snapshot.companionStateContent); + addInline("companion", snapshot.scheduledNotesContent); + + if (entryItems.length === 0 && inlineSources.length === 0) { + setBreakdown({ rows: [], total: 0 }); + setLoading(false); + return; + } + + const INLINE_CATEGORIES: BreakdownCategory[] = [ + "character", + "persona", + "groupCast", + "lorebook", + "memory", + "authorNote", + "companion", + ]; + + setLoading(true); + const allTexts = [ + ...entryItems.map((item) => item.text), + ...inlineSources.map((item) => item.text), + ]; + void countTokensBatch(allTexts) + .then((counts) => { + if (cancelled) return; + const entryTotals = new Map(); + entryItems.forEach((item, index) => { + entryTotals.set( + item.category, + (entryTotals.get(item.category) ?? 0) + (counts[index] ?? 0), + ); + }); + const inlineTotals = new Map(); + inlineSources.forEach((item, index) => { + const count = counts[entryItems.length + index] ?? 0; + inlineTotals.set(item.category, (inlineTotals.get(item.category) ?? 0) + count); + }); + + const finalTotals = new Map(entryTotals); + for (const category of INLINE_CATEGORIES) { + const inlineTokens = inlineTotals.get(category) ?? 0; + const entryTokens = entryTotals.get(category) ?? 0; + if (inlineTokens > 0 && entryTokens === 0) { + finalTotals.set(category, inlineTokens); + const system = finalTotals.get("system") ?? 0; + finalTotals.set("system", Math.max(0, system - inlineTokens)); + } + } + + const rows = CATEGORY_ORDER.filter( + (category) => (finalTotals.get(category) ?? 0) > 0, + ).map((category) => ({ category, tokens: finalTotals.get(category) ?? 0 })); + const total = rows.reduce((sum, row) => sum + row.tokens, 0); + setBreakdown({ rows, total }); + }) + .catch((error) => { + console.error("Failed to compute prompt token breakdown:", error); + if (!cancelled) setBreakdown(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [snapshot]); + + const reportedPromptTokens = message?.usage?.promptTokens ?? 0; + const calibrated = reportedPromptTokens > 0 && (breakdown?.total ?? 0) > 0; + const displayTotal = calibrated ? reportedPromptTokens : breakdown?.total ?? 0; + + // llama.cpp and Ollama reserve the completion budget inside the context window + // — that space is effectively occupied before generation. The real reserved + // amount is the completion cap in the actual request body, which already + // includes the reasoning budget the adapter adds on top of max_tokens (see + // provider_adapter/llamacpp.rs / ollama). We read that instead of the plain + // configured max_tokens so the bar reflects what really gets held back. + const providerId = (snapshot?.providerId ?? "").trim().toLowerCase(); + const reservesCompletion = RESERVING_PROVIDER_IDS.has(providerId); + const configuredMaxTokens = (() => { + const requestSettings = snapshot?.requestSettings as Record | undefined; + return typeof requestSettings?.maxTokens === "number" + ? (requestSettings.maxTokens as number) + : 0; + })(); + const reservedTokens = (() => { + if (!reservesCompletion) return 0; + const body = snapshot?.requestBody as Record | undefined; + if (providerId === "ollama") { + const options = body?.options as Record | undefined; + const numPredict = options?.num_predict; + if (typeof numPredict === "number" && numPredict > 0) return numPredict; + } else { + // llama.cpp uses the OpenAI-style body: max_completion_tokens when + // reasoning is on, otherwise max_tokens — both already boosted. + const maxCompletion = body?.max_completion_tokens; + if (typeof maxCompletion === "number" && maxCompletion > 0) return maxCompletion; + const maxTokens = body?.max_tokens; + if (typeof maxTokens === "number" && maxTokens > 0) return maxTokens; + } + return configuredMaxTokens; + })(); + // Split reserved into the configured response budget and the reasoning budget + // the adapter adds on top (reserved = response + reasoning). + const responseTokens = Math.min(configuredMaxTokens, reservedTokens); + const reasoningTokens = Math.max(0, reservedTokens - responseTokens); + const occupiedTotal = displayTotal + reservedTokens; + + const contextLength = useMemo(() => { + const settings = snapshot?.requestSettings as Record | undefined; + const value = settings?.contextLength; + return typeof value === "number" && value > 0 ? value : null; + }, [snapshot]); + + // Counts for the category label suffixes. + const historyCount = useMemo(() => { + if (!snapshot) return 0; + return (snapshot.requestMessages as PromptEntryLike[]).filter((raw) => { + const role = typeof raw?.role === "string" ? raw.role.toLowerCase() : ""; + return role === "user" || role === "assistant"; + }).length; + }, [snapshot]); + const memoryEntryCount = snapshot?.memoryEntryCount ?? 0; + const lorebookEntryCount = message?.usedLorebookEntries?.length ?? 0; + const groupCastCount = snapshot?.groupCastCount ?? 0; + + // Breakdown segments are the prompt categories only, sorted by share (largest + // first). The reserved completion budget is no longer part of the breakdown — + // it is shown separately in the context-window bar. + const segments: BreakdownSegment[] = breakdown + ? breakdown.rows + .map((row) => { + const share = breakdown.total > 0 ? row.tokens / breakdown.total : 0; + return { + key: row.category as string, + labelKey: CATEGORY_LABEL_KEYS[row.category], + color: CATEGORY_COLORS[row.category], + tokens: calibrated ? Math.round(reportedPromptTokens * share) : row.tokens, + }; + }) + .sort((a, b) => b.tokens - a.tokens) + : []; + + return { + breakdown, + loading, + calibrated, + displayTotal, + reservedTokens, + responseTokens, + reasoningTokens, + occupiedTotal, + contextLength, + historyCount, + memoryEntryCount, + lorebookEntryCount, + groupCastCount, + segments, + }; +} + +/** + * Renders the two stacked bars (prompt composition + context usage) with legend + * and totals. Presentation only — pass the result of {@link useTokenBreakdown}. + */ +export function PromptTokenBreakdownBars({ data }: { data: TokenBreakdownData }) { + const { t } = useI18n(); + const { + breakdown, + loading, + calibrated, + displayTotal, + reservedTokens, + responseTokens, + reasoningTokens, + occupiedTotal, + contextLength, + historyCount, + memoryEntryCount, + lorebookEntryCount, + groupCastCount, + segments, + } = data; + + // Optional per-category label suffix (dynamic counts / static note). + const segmentSuffix = (key: string): string | null => { + switch (key) { + case "companion": + return t("chats.debugPage.tokenBreakdownCompanionSuffix"); + case "groupCast": + return t("chats.debugPage.tokenBreakdownCharactersSuffix", { + count: String(groupCastCount), + }); + case "history": + return t("chats.debugPage.tokenBreakdownHistorySuffix", { + count: String(historyCount), + }); + case "memory": + return t("chats.debugPage.tokenBreakdownEntriesSuffix", { + count: String(memoryEntryCount), + }); + case "lorebook": + return t("chats.debugPage.tokenBreakdownEntriesSuffix", { + count: String(lorebookEntryCount), + }); + default: + return null; + } + }; + + if (!breakdown && !loading) return null; + + if (!breakdown || breakdown.rows.length === 0) { + return
{t("chats.debugPage.tokenBreakdownComputing")}
; + } + + return ( +
+ {contextLength ? ( +
+
{t("chats.debugPage.tokenBreakdownContext")}
+
+
+ {reservedTokens > 0 ? ( +
+ ) : null} + {occupiedTotal < contextLength ? ( +
+ ) : null} +
+
+
+ + + {t("chats.debugPage.tokenBreakdownOccupied")} + + + {t("chats.debugPage.tokenBreakdownUsage", { + used: String(displayTotal), + limit: String(contextLength), + percent: String(Math.round((displayTotal / contextLength) * 100)), + })} + +
+ {reservedTokens > 0 ? ( +
+ + + + {t("chats.debugPage.tokenBreakdownReserved")}{" "} + + {t("chats.debugPage.tokenBreakdownReservedSplit", { + response: String(responseTokens), + reasoning: String(reasoningTokens), + })} + + + + + {t("chats.debugPage.tokenBreakdownUsage", { + used: String(reservedTokens), + limit: String(contextLength), + percent: String(Math.round((reservedTokens / contextLength) * 100)), + })} + +
+ ) : null} +
+ + + {t("chats.debugPage.tokenBreakdownFree")} + + + {t("chats.debugPage.tokenBreakdownUsage", { + used: String(Math.max(0, contextLength - occupiedTotal)), + limit: String(contextLength), + percent: String( + Math.max(0, 100 - Math.round((occupiedTotal / contextLength) * 100)), + ), + })} + +
+
+ {occupiedTotal > contextLength ? ( +
+ + ⚠ + + + {t("chats.debugPage.tokenBreakdownOverReserved", { + overflow: String(occupiedTotal - contextLength), + })} + +
+ ) : null} +
+ ) : null} +
+
+ {calibrated + ? t("chats.debugPage.tokenBreakdownCalibrated") + : t("chats.debugPage.tokenBreakdownEstimate")} +
+
+ {segments.map((seg) => { + const width = displayTotal > 0 ? (seg.tokens / displayTotal) * 100 : 0; + return ( +
+ ); + })} +
+
+ {segments.map((seg) => { + const pct = displayTotal > 0 ? Math.round((seg.tokens / displayTotal) * 100) : 0; + const suffix = segmentSuffix(seg.key); + return ( +
+ + + + {t(seg.labelKey)} + {suffix ? {suffix} : null} + + + + {seg.tokens} · {pct}% + +
+ ); + })} +
+
+
+ {t("chats.debugPage.tokenBreakdownTotal")} + {displayTotal} +
+
+
+
+ ); +} diff --git a/src/ui/pages/group-chats/GroupChatPage.tsx b/src/ui/pages/group-chats/GroupChatPage.tsx index f25382b3e..c0976dbf1 100644 --- a/src/ui/pages/group-chats/GroupChatPage.tsx +++ b/src/ui/pages/group-chats/GroupChatPage.tsx @@ -2522,6 +2522,7 @@ export function GroupChatPage() { onBranchToCharacter={(charId) => void handleBranchToCharacter(charId)} onOpenChatAppearance={handleOpenAppearance} characters={groupCharacters} + sessionId={groupSessionId} /> {!isMobilePlatform && group && ( diff --git a/src/ui/pages/group-chats/components/GroupChatMessageActionsBottomSheet.tsx b/src/ui/pages/group-chats/components/GroupChatMessageActionsBottomSheet.tsx index ac33c40a6..4fc94b6ae 100644 --- a/src/ui/pages/group-chats/components/GroupChatMessageActionsBottomSheet.tsx +++ b/src/ui/pages/group-chats/components/GroupChatMessageActionsBottomSheet.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Copy, Trash2, RotateCcw, Edit3, Users, Pin, PinOff, BookOpen, Brain, Paintbrush, GitBranch } from "lucide-react"; +import { Copy, Trash2, RotateCcw, Edit3, Users, Pin, PinOff, BookOpen, Brain, Paintbrush, GitBranch, Activity } from "lucide-react"; import type { Character, Settings, Model } from "../../../../core/storage/schemas"; import { useAvatar } from "../../../hooks/useAvatar"; @@ -10,6 +10,11 @@ import { MarkdownRenderer } from "../../chats/components/MarkdownRenderer"; import { radius, cn, interactive } from "../../../design-tokens"; import type { MessageActionState } from "../reducers/groupChatReducer"; import { readSettings } from "../../../../core/storage/repo"; +import { + useMessageDebugSnapshot, + useTokenBreakdown, + PromptTokenBreakdownBars, +} from "../../chats/components/PromptTokenBreakdown"; function ActionRow({ icon: Icon, @@ -71,6 +76,7 @@ export function GroupChatMessageActionsBottomSheet({ onBranchToCharacter, onOpenChatAppearance, characters, + sessionId, }: { messageAction: MessageActionState | null; actionError: string | null; @@ -91,6 +97,7 @@ export function GroupChatMessageActionsBottomSheet({ onBranchToCharacter?: (characterId: string) => void; onOpenChatAppearance?: () => void; characters: Character[]; + sessionId?: string | null; }) { const { t } = useI18n(); const [characterPickerMode, setCharacterPickerMode] = useState<"regenerate" | "branch" | null>( @@ -107,6 +114,16 @@ export function GroupChatMessageActionsBottomSheet({ const isAssistant = messageAction?.message.role === "assistant"; const isScene = messageAction?.message.role === "scene"; + // Prompt / context breakdown bars for assistant messages — reuses the shared + // component, loading the group-chat variant of the reconstructed snapshot. + const breakdownSnapshot = useMessageDebugSnapshot( + sessionId, + messageAction?.message.id ?? null, + Boolean(isAssistant && sessionId), + "group", + ); + const breakdownData = useTokenBreakdown(breakdownSnapshot, messageAction?.message ?? null); + useEffect(() => { readSettings().then(setSettings).catch(console.error); }, []); @@ -266,6 +283,18 @@ export function GroupChatMessageActionsBottomSheet({
)} + {!isScene && isAssistant && (breakdownData.breakdown || breakdownData.loading) && ( +
+
+ + + {t("chats.debugPage.tokenBreakdown")} + +
+ +
+ )} +