From 37ba9ecc27ed3fbf8dee336ddd28e06cc0d5ff09 Mon Sep 17 00:00:00 2001 From: Lothar Hoffmann Date: Tue, 8 Sep 2026 12:29:17 +0200 Subject: [PATCH 1/5] feat(debug): add prompt token breakdown to message debug panel Add a "Prompt Token Breakdown" section to the developer message debug page that shows how the reconstructed prompt is distributed across categories: System & Persona, Memories, Lorebook, Author's Note, Companion State and Chat History. Categories are derived from the snapshot's system prompt entries via their stable id/name, chat history from the user/assistant request messages. Token counts use the existing tiktoken (o200k) command, so the figures are an approximation of the model's own tokenizer. Shows per-category tokens, share, total, and context-window usage. --- src/core/i18n/locales/en.ts | 16 ++ src/ui/pages/chats/MessageDebugPage.tsx | 219 +++++++++++++++++++++++- 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index 70e5b1c27..dc8ee7a8a 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -1562,6 +1562,22 @@ export const enMessages = { fullSessionJson: "Full Session JSON", storedMessageJson: "Stored Message JSON", fullTraceEvents: "Full Trace Events", + tokenBreakdown: "Prompt Token Breakdown", + tokenBreakdownEstimate: "Estimate — tiktoken o200k (approximate; the model's own tokenizer will differ)", + tokenBreakdownTotal: "Total prompt", + tokenBreakdownContext: "Context window", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Counting tokens…", + tokenBreakdownNote: + "Reconstructed from the current session state and may differ slightly from the exact bytes sent to the provider. System buckets share the same content when the prompt is condensed into a single message.", + tokenBreakdownCat: { + systemPersona: "System & Persona", + memory: "Memories", + lorebook: "Lorebook / World Info", + authorNote: "Author's Note", + companion: "Companion State", + history: "Chat History", + }, }, companionMemoryPage: { backLabel: "Back", diff --git a/src/ui/pages/chats/MessageDebugPage.tsx b/src/ui/pages/chats/MessageDebugPage.tsx index c934e2a3d..26ca4028e 100644 --- a/src/ui/pages/chats/MessageDebugPage.tsx +++ b/src/ui/pages/chats/MessageDebugPage.tsx @@ -4,7 +4,8 @@ import { ArrowLeft, Copy, ChevronRight, ChevronDown } from "lucide-react"; import { getMessageDebugSnapshot, type ChatMessageDebugSnapshot } from "../../../core/chat/manager"; import { getSession, readSettings } from "../../../core/storage/repo"; -import { useI18n } from "../../../core/i18n/context"; +import { useI18n, type TranslationKey } from "../../../core/i18n/context"; +import { countTokensBatch } from "../../../core/tokens"; import type { Session, Settings, StoredMessage } from "../../../core/storage/schemas"; import { getMessageDebugTrace, @@ -70,6 +71,94 @@ function extractAttempts(trace: ChatRequestDebugTrace | null): DebugAttempt[] { return attempts; } +type PromptEntryLike = { + id?: unknown; + name?: unknown; + role?: unknown; + content?: unknown; +}; + +type BreakdownCategory = + | "systemPersona" + | "memory" + | "lorebook" + | "authorNote" + | "companion" + | "history"; + +const CATEGORY_ORDER: BreakdownCategory[] = [ + "systemPersona", + "memory", + "lorebook", + "authorNote", + "companion", + "history", +]; + +const CATEGORY_LABEL_KEYS: Record = { + systemPersona: "chats.debugPage.tokenBreakdownCat.systemPersona", + 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", +}; + +/** 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 "systemPersona"; +} + +type TokenBreakdown = { + rows: { category: BreakdownCategory; tokens: number }[]; + total: number; +}; + function Collapsible({ title, defaultOpen = false, @@ -210,6 +299,8 @@ export function MessageDebugPage() { const [settings, setSettings] = useState(null); const [snapshot, setSnapshot] = useState(null); const [snapshotError, setSnapshotError] = useState(null); + const [breakdown, setBreakdown] = useState(null); + const [breakdownLoading, setBreakdownLoading] = useState(false); useEffect(() => { if (!sessionId) return; @@ -257,6 +348,73 @@ export function MessageDebugPage() { }; }, [messageId, sessionId]); + useEffect(() => { + if (!snapshot) { + setBreakdown(null); + setBreakdownLoading(false); + return; + } + let cancelled = false; + + // Category buckets come from the reconstructed system prompt entries (which + // still carry their id/name), while 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 items: { category: BreakdownCategory; text: string }[] = []; + for (const raw of snapshot.promptEntries as PromptEntryLike[]) { + const text = contentToText(raw?.content); + if (!text) continue; + items.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; + items.push({ category: "history", text }); + } + + if (items.length === 0) { + setBreakdown({ rows: [], total: 0 }); + setBreakdownLoading(false); + return; + } + + setBreakdownLoading(true); + void countTokensBatch(items.map((item) => item.text)) + .then((counts) => { + if (cancelled) return; + const totals = new Map(); + counts.forEach((count, index) => { + const category = items[index]?.category; + if (!category) return; + totals.set(category, (totals.get(category) ?? 0) + count); + }); + const rows = CATEGORY_ORDER.filter((category) => totals.has(category)).map( + (category) => ({ category, tokens: totals.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) setBreakdownLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [snapshot]); + + const contextLength = useMemo(() => { + const settings = snapshot?.requestSettings as Record | undefined; + const value = settings?.contextLength; + return typeof value === "number" && value > 0 ? value : null; + }, [snapshot]); + const trace = useSyncExternalStore( subscribeChatDebugStore, () => (sessionId && messageId ? getMessageDebugTrace(sessionId, messageId) : null), @@ -360,6 +518,65 @@ export function MessageDebugPage() { + {snapshot && (breakdown || breakdownLoading) ? ( + +
+ {breakdown && breakdown.rows.length > 0 ? ( + <> +
+ {t("chats.debugPage.tokenBreakdownEstimate")} +
+
+ {breakdown.rows.map((row) => { + const pct = + breakdown.total > 0 + ? Math.round((row.tokens / breakdown.total) * 100) + : 0; + return ( +
+
+ {t(CATEGORY_LABEL_KEYS[row.category])} + + {row.tokens} · {pct}% + +
+
+
+
+
+ ); + })} +
+
+ {t("chats.debugPage.tokenBreakdownTotal")} + {breakdown.total} +
+ {contextLength ? ( +
+ {t("chats.debugPage.tokenBreakdownContext")} + + {t("chats.debugPage.tokenBreakdownUsage", { + used: String(breakdown.total), + limit: String(contextLength), + percent: String(Math.round((breakdown.total / contextLength) * 100)), + })} + +
+ ) : null} +
+ {t("chats.debugPage.tokenBreakdownNote")} +
+ + ) : ( +
{t("chats.debugPage.tokenBreakdownComputing")}
+ )} +
+ + ) : null} + {!trace ? (
{t("chats.debugPage.noTraceFound")} From c776a7d5f58ed1facc0388d29fe1e0b1453fb104 Mon Sep 17 00:00:00 2001 From: Lothar Hoffmann Date: Wed, 9 Sep 2026 15:06:35 +0200 Subject: [PATCH 2/5] feat(chat): add per-message context usage breakdown to the message sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a "Context Usage" panel on assistant messages: a context-window bar (occupied / reserved for response / free) plus a breakdown bar of what fills the prompt — system, character, persona, memories, lorebook, author's note, companion state and chat history. Per-category shares are estimated with tiktoken and scaled onto the provider's real prompt tokens; for llama.cpp / Ollama the reserved response budget (response + reasoning) is read from the actual request body. Backend: the debug snapshot now surfaces the individual placeholder source contents (character profile, persona, lorebook, memories, author note, companion state, scheduled notes) and a memory entry count via a new prompt_engine::debug_prompt_source_contents helper, so inline- rendered sources are attributed separately. The companion-state instruction preamble stays in the system bucket. Shared PromptTokenBreakdown component reused by the debug page and the message sheet. Strings translated across all 20 locales. --- src-tauri/src/chat_manager/commands/mod.rs | 31 + .../chat_manager/prompting/prompt_engine.rs | 133 ++++ src/core/chat/manager.ts | 11 + src/core/i18n/locales/de.ts | 24 + src/core/i18n/locales/el.ts | 24 + src/core/i18n/locales/en.ts | 18 +- src/core/i18n/locales/es.ts | 24 + src/core/i18n/locales/fil.ts | 24 + src/core/i18n/locales/fr.ts | 24 + src/core/i18n/locales/hi.ts | 24 + src/core/i18n/locales/id.ts | 24 + src/core/i18n/locales/it.ts | 24 + src/core/i18n/locales/ja.ts | 24 + src/core/i18n/locales/ko.ts | 24 + src/core/i18n/locales/nl.ts | 24 + src/core/i18n/locales/no.ts | 24 + src/core/i18n/locales/pl.ts | 24 + src/core/i18n/locales/pt.ts | 24 + src/core/i18n/locales/ru.ts | 24 + src/core/i18n/locales/tr.ts | 24 + src/core/i18n/locales/vi.ts | 24 + src/core/i18n/locales/zh-Hans.ts | 24 + src/core/i18n/locales/zh-Hant.ts | 24 + src/ui/pages/chats/MessageDebugPage.tsx | 219 +------ .../components/MessageActionsBottomSheet.tsx | 32 + .../chats/components/PromptTokenBreakdown.tsx | 595 ++++++++++++++++++ 26 files changed, 1272 insertions(+), 223 deletions(-) create mode 100644 src/ui/pages/chats/components/PromptTokenBreakdown.tsx diff --git a/src-tauri/src/chat_manager/commands/mod.rs b/src-tauri/src/chat_manager/commands/mod.rs index 510a27391..401fe1fc7 100644 --- a/src-tauri/src/chat_manager/commands/mod.rs +++ b/src-tauri/src/chat_manager/commands/mod.rs @@ -67,6 +67,20 @@ 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, } #[derive(Clone, Copy)] @@ -518,6 +532,14 @@ pub fn chat_message_debug_snapshot( 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, + ); + 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 +663,15 @@ 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, }) } diff --git a/src-tauri/src/chat_manager/prompting/prompt_engine.rs b/src-tauri/src/chat_manager/prompting/prompt_engine.rs index 1ababe7f2..9b925d2e0 100644 --- a/src-tauri/src/chat_manager/prompting/prompt_engine.rs +++ b/src-tauri/src/chat_manager/prompting/prompt_engine.rs @@ -3379,6 +3379,139 @@ 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, +) -> 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 { + session + .memory_embeddings + .iter() + .filter(|mem| (!mem.is_cold || mem.is_pinned) && mem.superseded_by.is_none()) + .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 { + session + .memory_embeddings + .iter() + .filter(|mem| (!mem.is_cold || mem.is_pinned) && mem.superseded_by.is_none()) + .count() 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, diff --git a/src/core/chat/manager.ts b/src/core/chat/manager.ts index da51c89b7..640457cad 100644 --- a/src/core/chat/manager.ts +++ b/src/core/chat/manager.ts @@ -48,6 +48,17 @@ 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; } export interface LorebookEntryDraft { diff --git a/src/core/i18n/locales/de.ts b/src/core/i18n/locales/de.ts index 6e2367b48..a2c40d99d 100644 --- a/src/core/i18n/locales/de.ts +++ b/src/core/i18n/locales/de.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Token werden gezählt…", + tokenBreakdownCompanionSuffix: "(Innenleben / geplante Notizen)", + tokenBreakdownHistorySuffix: "(die letzten {{count}} Nachrichten)", + tokenBreakdownEntriesSuffix: "({{count}} Einträge)", + tokenBreakdownCat: { + system: "System", + character: "Charakter", + persona: "Persona", + 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..8bddb755f 100644 --- a/src/core/i18n/locales/el.ts +++ b/src/core/i18n/locales/el.ts @@ -1455,6 +1455,30 @@ export const elMessages: DeepPartialMessageTree = { fullSessionJson: "Πλήρες JSON συνεδρίας", storedMessageJson: "JSON αποθηκευμένου μηνύματος", fullTraceEvents: "Πλήρη συμβάντα ίχνους", + tokenBreakdown: "Χρήση πλαισίου", + tokenBreakdownEstimate: "Εκτίμηση — tiktoken o200k (κατά προσέγγιση· ο δικός του tokenizer του μοντέλου θα διαφέρει)", + tokenBreakdownCalibrated: "Ανάλυση", + tokenBreakdownTotal: "Συνολική προτροπή", + tokenBreakdownContext: "Παράθυρο πλαισίου", + tokenBreakdownFree: "Ελεύθερο", + tokenBreakdownReserved: "Δεσμευμένο για την απάντηση", + tokenBreakdownReservedSplit: "(απάντηση: {{response}} / συλλογισμός: {{reasoning}})", + tokenBreakdownOccupied: "Κατειλημμένο", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Καταμέτρηση token…", + tokenBreakdownCompanionSuffix: "(εσωτερική ζωή / προγραμματισμένες σημειώσεις)", + tokenBreakdownHistorySuffix: "(τελευταία {{count}} μηνύματα)", + tokenBreakdownEntriesSuffix: "({{count}} καταχωρίσεις)", + tokenBreakdownCat: { + system: "Σύστημα", + character: "Χαρακτήρας", + persona: "Περσόνα", + memory: "Αναμνήσεις", + lorebook: "Βιβλίο Γνώσης", + authorNote: "Σημείωση Συγγραφέα", + companion: "Κατάσταση Συντρόφου", + history: "Ιστορικό Συνομιλιών", + }, }, companionMemoryPage: { backLabel: "Πίσω", diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index dc8ee7a8a..1182d74d9 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -1562,18 +1562,26 @@ export const enMessages = { fullSessionJson: "Full Session JSON", storedMessageJson: "Stored Message JSON", fullTraceEvents: "Full Trace Events", - tokenBreakdown: "Prompt Token Breakdown", + 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", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "Counting tokens…", - tokenBreakdownNote: - "Reconstructed from the current session state and may differ slightly from the exact bytes sent to the provider. System buckets share the same content when the prompt is condensed into a single message.", + tokenBreakdownCompanionSuffix: "(inner life / scheduled notes)", + tokenBreakdownHistorySuffix: "(last {{count}} messages)", + tokenBreakdownEntriesSuffix: "({{count}} entries)", tokenBreakdownCat: { - systemPersona: "System & Persona", + system: "System", + character: "Character", + persona: "Persona", memory: "Memories", - lorebook: "Lorebook / World Info", + lorebook: "Lorebook", authorNote: "Author's Note", companion: "Companion State", history: "Chat History", diff --git a/src/core/i18n/locales/es.ts b/src/core/i18n/locales/es.ts index a7ed8aab9..388472252 100644 --- a/src/core/i18n/locales/es.ts +++ b/src/core/i18n/locales/es.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Contando tokens…", + tokenBreakdownCompanionSuffix: "(vida interior / notas programadas)", + tokenBreakdownHistorySuffix: "(últimos {{count}} mensajes)", + tokenBreakdownEntriesSuffix: "({{count}} entradas)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personaje", + persona: "Persona", + 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..40db59f5d 100644 --- a/src/core/i18n/locales/fil.ts +++ b/src/core/i18n/locales/fil.ts @@ -1455,6 +1455,30 @@ 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", + 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)", + tokenBreakdownCat: { + system: "System", + character: "Karakter", + persona: "Persona", + 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..9d404fd1a 100644 --- a/src/core/i18n/locales/fr.ts +++ b/src/core/i18n/locales/fr.ts @@ -1455,6 +1455,30 @@ 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é", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Comptage des tokens…", + tokenBreakdownCompanionSuffix: "(vie intérieure / notes programmées)", + tokenBreakdownHistorySuffix: "(les {{count}} derniers messages)", + tokenBreakdownEntriesSuffix: "({{count}} entrées)", + tokenBreakdownCat: { + system: "Système", + character: "Personnage", + persona: "Persona", + 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..ba4150361 100644 --- a/src/core/i18n/locales/hi.ts +++ b/src/core/i18n/locales/hi.ts @@ -1455,6 +1455,30 @@ export const hiMessages: DeepPartialMessageTree = { fullSessionJson: "पूर्ण सेशन JSON", storedMessageJson: "स्टोर्ड मैसेज JSON", fullTraceEvents: "पूर्ण ट्रेस इवेंट्स", + tokenBreakdown: "कॉन्टेक्स्ट उपयोग", + tokenBreakdownEstimate: "अनुमान — tiktoken o200k (अनुमानित; मॉडल का अपना tokenizer भिन्न होगा)", + tokenBreakdownCalibrated: "विवरण", + tokenBreakdownTotal: "कुल प्रॉम्प्ट", + tokenBreakdownContext: "कॉन्टेक्स्ट विंडो", + tokenBreakdownFree: "मुक्त", + tokenBreakdownReserved: "प्रतिक्रिया के लिए आरक्षित", + tokenBreakdownReservedSplit: "(प्रतिक्रिया: {{response}} / reasoning: {{reasoning}})", + tokenBreakdownOccupied: "उपयोग में", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "टोकन गिने जा रहे हैं…", + tokenBreakdownCompanionSuffix: "(आंतरिक जीवन / शेड्यूल्ड नोट्स)", + tokenBreakdownHistorySuffix: "(अंतिम {{count}} संदेश)", + tokenBreakdownEntriesSuffix: "({{count}} प्रविष्टियाँ)", + tokenBreakdownCat: { + system: "सिस्टम", + character: "कैरेक्टर", + persona: "पर्सोना", + 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..39770d7a9 100644 --- a/src/core/i18n/locales/id.ts +++ b/src/core/i18n/locales/id.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Menghitung token…", + tokenBreakdownCompanionSuffix: "(kehidupan batin / catatan terjadwal)", + tokenBreakdownHistorySuffix: "({{count}} pesan terakhir)", + tokenBreakdownEntriesSuffix: "({{count}} entri)", + tokenBreakdownCat: { + system: "Sistem", + character: "Karakter", + persona: "Persona", + 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..e6d3fbd0a 100644 --- a/src/core/i18n/locales/it.ts +++ b/src/core/i18n/locales/it.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Conteggio token…", + tokenBreakdownCompanionSuffix: "(vita interiore / note pianificate)", + tokenBreakdownHistorySuffix: "(ultimi {{count}} messaggi)", + tokenBreakdownEntriesSuffix: "({{count}} voci)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personaggio", + persona: "Persona", + 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..88a4be830 100644 --- a/src/core/i18n/locales/ja.ts +++ b/src/core/i18n/locales/ja.ts @@ -1455,6 +1455,30 @@ export const jaMessages: DeepPartialMessageTree = { fullSessionJson: "完全なセッションJSON", storedMessageJson: "保存されたメッセージJSON", fullTraceEvents: "完全なトレースイベント", + tokenBreakdown: "コンテキスト使用状況", + tokenBreakdownEstimate: "推定 — tiktoken o200k(概算。モデル独自のトークナイザーとは異なります)", + tokenBreakdownCalibrated: "内訳", + tokenBreakdownTotal: "プロンプト合計", + tokenBreakdownContext: "コンテキストウィンドウ", + tokenBreakdownFree: "空き", + tokenBreakdownReserved: "応答用に予約", + tokenBreakdownReservedSplit: "(応答: {{response}} / 推論: {{reasoning}})", + tokenBreakdownOccupied: "使用中", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "トークンを計算中…", + tokenBreakdownCompanionSuffix: "(内面 / 予約ノート)", + tokenBreakdownHistorySuffix: "(直近 {{count}} 件のメッセージ)", + tokenBreakdownEntriesSuffix: "({{count}} 件のエントリ)", + tokenBreakdownCat: { + system: "システム", + character: "キャラクター", + persona: "ペルソナ", + 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..ebdc3d5c2 100644 --- a/src/core/i18n/locales/ko.ts +++ b/src/core/i18n/locales/ko.ts @@ -1455,6 +1455,30 @@ export const koMessages: DeepPartialMessageTree = { fullSessionJson: "전체 세션 JSON", storedMessageJson: "저장된 메시지 JSON", fullTraceEvents: "전체 추적 이벤트", + tokenBreakdown: "컨텍스트 사용량", + tokenBreakdownEstimate: "추정치 — tiktoken o200k (근사치이며 모델 자체 토크나이저와는 다를 수 있습니다)", + tokenBreakdownCalibrated: "세부 내역", + tokenBreakdownTotal: "전체 프롬프트", + tokenBreakdownContext: "컨텍스트 창", + tokenBreakdownFree: "여유", + tokenBreakdownReserved: "응답용 예약", + tokenBreakdownReservedSplit: "(응답: {{response}} / 추론: {{reasoning}})", + tokenBreakdownOccupied: "사용 중", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "토큰 계산 중…", + tokenBreakdownCompanionSuffix: "(내면 / 예약된 노트)", + tokenBreakdownHistorySuffix: "(최근 {{count}}개 메시지)", + tokenBreakdownEntriesSuffix: "({{count}}개 항목)", + tokenBreakdownCat: { + system: "시스템", + character: "캐릭터", + persona: "페르소나", + 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..1f5e1140d 100644 --- a/src/core/i18n/locales/nl.ts +++ b/src/core/i18n/locales/nl.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Tokens tellen…", + tokenBreakdownCompanionSuffix: "(innerlijk leven / geplande notities)", + tokenBreakdownHistorySuffix: "(laatste {{count}} berichten)", + tokenBreakdownEntriesSuffix: "({{count}} vermeldingen)", + tokenBreakdownCat: { + system: "Systeem", + character: "Karakter", + persona: "Persona", + 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..b674b4515 100644 --- a/src/core/i18n/locales/no.ts +++ b/src/core/i18n/locales/no.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Teller tokens…", + tokenBreakdownCompanionSuffix: "(indre liv / planlagte notater)", + tokenBreakdownHistorySuffix: "(siste {{count}} meldinger)", + tokenBreakdownEntriesSuffix: "({{count}} oppføringer)", + tokenBreakdownCat: { + system: "System", + character: "Karakter", + persona: "Persona", + 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..c2b1dcb7e 100644 --- a/src/core/i18n/locales/pl.ts +++ b/src/core/i18n/locales/pl.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Liczenie tokenów…", + tokenBreakdownCompanionSuffix: "(życie wewnętrzne / zaplanowane notatki)", + tokenBreakdownHistorySuffix: "(ostatnie {{count}} wiadomości)", + tokenBreakdownEntriesSuffix: "({{count}} wpisów)", + tokenBreakdownCat: { + system: "System", + character: "Postać", + persona: "Persona", + 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..487ceb6f7 100644 --- a/src/core/i18n/locales/pt.ts +++ b/src/core/i18n/locales/pt.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Contando tokens…", + tokenBreakdownCompanionSuffix: "(vida interior / notas agendadas)", + tokenBreakdownHistorySuffix: "(últimas {{count}} mensagens)", + tokenBreakdownEntriesSuffix: "({{count}} entradas)", + tokenBreakdownCat: { + system: "Sistema", + character: "Personagem", + persona: "Persona", + 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..2775a0b98 100644 --- a/src/core/i18n/locales/ru.ts +++ b/src/core/i18n/locales/ru.ts @@ -1455,6 +1455,30 @@ export const ruMessages: DeepPartialMessageTree = { fullSessionJson: "Полный JSON сессии", storedMessageJson: "Сохранённый JSON сообщения", fullTraceEvents: "Все события трассировки", + tokenBreakdown: "Использование контекста", + tokenBreakdownEstimate: "Оценка — tiktoken o200k (приблизительно; собственный токенизатор модели будет отличаться)", + tokenBreakdownCalibrated: "Разбивка", + tokenBreakdownTotal: "Всего в промпте", + tokenBreakdownContext: "Окно контекста", + tokenBreakdownFree: "Свободно", + tokenBreakdownReserved: "Зарезервировано для ответа", + tokenBreakdownReservedSplit: "(ответ: {{response}} / рассуждение: {{reasoning}})", + tokenBreakdownOccupied: "Занято", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Подсчёт токенов…", + tokenBreakdownCompanionSuffix: "(внутренняя жизнь / запланированные заметки)", + tokenBreakdownHistorySuffix: "(последние {{count}} сообщений)", + tokenBreakdownEntriesSuffix: "({{count}} записей)", + tokenBreakdownCat: { + system: "Система", + character: "Персонаж", + persona: "Персона", + 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..6d2bf5696 100644 --- a/src/core/i18n/locales/tr.ts +++ b/src/core/i18n/locales/tr.ts @@ -1455,6 +1455,30 @@ 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", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "Tokenlar sayılıyor…", + tokenBreakdownCompanionSuffix: "(iç dünya / planlanmış notlar)", + tokenBreakdownHistorySuffix: "(son {{count}} mesaj)", + tokenBreakdownEntriesSuffix: "({{count}} girdi)", + tokenBreakdownCat: { + system: "Sistem", + character: "Karakter", + persona: "Persona", + 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..640da0ddf 100644 --- a/src/core/i18n/locales/vi.ts +++ b/src/core/i18n/locales/vi.ts @@ -1455,6 +1455,30 @@ 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", + 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)", + tokenBreakdownCat: { + system: "Hệ thống", + character: "Nhân vật", + persona: "Persona", + 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..7ed242fb2 100644 --- a/src/core/i18n/locales/zh-Hans.ts +++ b/src/core/i18n/locales/zh-Hans.ts @@ -1455,6 +1455,30 @@ export const zhHansMessages: DeepPartialMessageTree = { fullSessionJson: "完整会话 JSON", storedMessageJson: "存储的消息 JSON", fullTraceEvents: "完整追踪事件", + tokenBreakdown: "上下文用量", + tokenBreakdownEstimate: "估算 — tiktoken o200k(近似值;模型自身的分词器会有所不同)", + tokenBreakdownCalibrated: "明细", + tokenBreakdownTotal: "提示总量", + tokenBreakdownContext: "上下文窗口", + tokenBreakdownFree: "空闲", + tokenBreakdownReserved: "为回复保留", + tokenBreakdownReservedSplit: "(回复: {{response}} / 推理: {{reasoning}})", + tokenBreakdownOccupied: "已占用", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "正在计算 token…", + tokenBreakdownCompanionSuffix: "(内心 / 计划笔记)", + tokenBreakdownHistorySuffix: "(最近 {{count}} 条消息)", + tokenBreakdownEntriesSuffix: "({{count}} 个条目)", + tokenBreakdownCat: { + system: "系统", + character: "角色", + persona: "人设", + 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..6d4926ecf 100644 --- a/src/core/i18n/locales/zh-Hant.ts +++ b/src/core/i18n/locales/zh-Hant.ts @@ -1455,6 +1455,30 @@ 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)", + tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", + tokenBreakdownComputing: "正在計算 token…", + tokenBreakdownCompanionSuffix: "(內心 / 排程筆記 (inner life / scheduled notes))", + tokenBreakdownHistorySuffix: "(最近 {{count}} 則訊息 (last {{count}} messages))", + tokenBreakdownEntriesSuffix: "({{count}} 個條目 (entries))", + tokenBreakdownCat: { + system: "系統 (System)", + character: "角色 (Character)", + persona: "人設 (Persona)", + memory: "記憶 (Memories)", + lorebook: "設定集 (Lorebook)", + authorNote: "作者備注 (Author's Note)", + companion: "同伴狀態 (Companion State)", + history: "對話歷史 (Chat History)", + }, }, companionMemoryPage: { backLabel: "返回", diff --git a/src/ui/pages/chats/MessageDebugPage.tsx b/src/ui/pages/chats/MessageDebugPage.tsx index 26ca4028e..c934e2a3d 100644 --- a/src/ui/pages/chats/MessageDebugPage.tsx +++ b/src/ui/pages/chats/MessageDebugPage.tsx @@ -4,8 +4,7 @@ import { ArrowLeft, Copy, ChevronRight, ChevronDown } from "lucide-react"; import { getMessageDebugSnapshot, type ChatMessageDebugSnapshot } from "../../../core/chat/manager"; import { getSession, readSettings } from "../../../core/storage/repo"; -import { useI18n, type TranslationKey } from "../../../core/i18n/context"; -import { countTokensBatch } from "../../../core/tokens"; +import { useI18n } from "../../../core/i18n/context"; import type { Session, Settings, StoredMessage } from "../../../core/storage/schemas"; import { getMessageDebugTrace, @@ -71,94 +70,6 @@ function extractAttempts(trace: ChatRequestDebugTrace | null): DebugAttempt[] { return attempts; } -type PromptEntryLike = { - id?: unknown; - name?: unknown; - role?: unknown; - content?: unknown; -}; - -type BreakdownCategory = - | "systemPersona" - | "memory" - | "lorebook" - | "authorNote" - | "companion" - | "history"; - -const CATEGORY_ORDER: BreakdownCategory[] = [ - "systemPersona", - "memory", - "lorebook", - "authorNote", - "companion", - "history", -]; - -const CATEGORY_LABEL_KEYS: Record = { - systemPersona: "chats.debugPage.tokenBreakdownCat.systemPersona", - 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", -}; - -/** 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 "systemPersona"; -} - -type TokenBreakdown = { - rows: { category: BreakdownCategory; tokens: number }[]; - total: number; -}; - function Collapsible({ title, defaultOpen = false, @@ -299,8 +210,6 @@ export function MessageDebugPage() { const [settings, setSettings] = useState(null); const [snapshot, setSnapshot] = useState(null); const [snapshotError, setSnapshotError] = useState(null); - const [breakdown, setBreakdown] = useState(null); - const [breakdownLoading, setBreakdownLoading] = useState(false); useEffect(() => { if (!sessionId) return; @@ -348,73 +257,6 @@ export function MessageDebugPage() { }; }, [messageId, sessionId]); - useEffect(() => { - if (!snapshot) { - setBreakdown(null); - setBreakdownLoading(false); - return; - } - let cancelled = false; - - // Category buckets come from the reconstructed system prompt entries (which - // still carry their id/name), while 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 items: { category: BreakdownCategory; text: string }[] = []; - for (const raw of snapshot.promptEntries as PromptEntryLike[]) { - const text = contentToText(raw?.content); - if (!text) continue; - items.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; - items.push({ category: "history", text }); - } - - if (items.length === 0) { - setBreakdown({ rows: [], total: 0 }); - setBreakdownLoading(false); - return; - } - - setBreakdownLoading(true); - void countTokensBatch(items.map((item) => item.text)) - .then((counts) => { - if (cancelled) return; - const totals = new Map(); - counts.forEach((count, index) => { - const category = items[index]?.category; - if (!category) return; - totals.set(category, (totals.get(category) ?? 0) + count); - }); - const rows = CATEGORY_ORDER.filter((category) => totals.has(category)).map( - (category) => ({ category, tokens: totals.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) setBreakdownLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [snapshot]); - - const contextLength = useMemo(() => { - const settings = snapshot?.requestSettings as Record | undefined; - const value = settings?.contextLength; - return typeof value === "number" && value > 0 ? value : null; - }, [snapshot]); - const trace = useSyncExternalStore( subscribeChatDebugStore, () => (sessionId && messageId ? getMessageDebugTrace(sessionId, messageId) : null), @@ -518,65 +360,6 @@ export function MessageDebugPage() {
- {snapshot && (breakdown || breakdownLoading) ? ( - -
- {breakdown && breakdown.rows.length > 0 ? ( - <> -
- {t("chats.debugPage.tokenBreakdownEstimate")} -
-
- {breakdown.rows.map((row) => { - const pct = - breakdown.total > 0 - ? Math.round((row.tokens / breakdown.total) * 100) - : 0; - return ( -
-
- {t(CATEGORY_LABEL_KEYS[row.category])} - - {row.tokens} · {pct}% - -
-
-
-
-
- ); - })} -
-
- {t("chats.debugPage.tokenBreakdownTotal")} - {breakdown.total} -
- {contextLength ? ( -
- {t("chats.debugPage.tokenBreakdownContext")} - - {t("chats.debugPage.tokenBreakdownUsage", { - used: String(breakdown.total), - limit: String(contextLength), - percent: String(Math.round((breakdown.total / contextLength) * 100)), - })} - -
- ) : null} -
- {t("chats.debugPage.tokenBreakdownNote")} -
- - ) : ( -
{t("chats.debugPage.tokenBreakdownComputing")}
- )} -
- - ) : null} - {!trace ? (
{t("chats.debugPage.noTraceFound")} 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", + 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 + 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 + +/** 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; + 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, +): ChatMessageDebugSnapshot | null { + const [snapshot, setSnapshot] = useState(null); + + useEffect(() => { + if (!enabled || !sessionId || !messageId) { + setSnapshot(null); + return; + } + let cancelled = false; + void getMessageDebugSnapshot({ sessionId, messageId }) + .then((next) => { + if (!cancelled) setSnapshot(next); + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }); + return () => { + cancelled = true; + }; + }, [sessionId, messageId, enabled]); + + 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: StoredMessage | 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("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", + "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 ?? "").toLowerCase(); + const reservesCompletion = providerId === "llamacpp" || providerId === "ollama"; + 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; + + // 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, + 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, + 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 "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)), + ), + })} + +
+
+
+ ) : 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} +
+
+
+
+ ); +} From 6f7a80e1aa30fdbf2990e0893745f81af9ded00f Mon Sep 17 00:00:00 2001 From: Lothar Hoffmann Date: Wed, 9 Sep 2026 16:41:29 +0200 Subject: [PATCH 3/5] feat(group-chats): show context usage breakdown on group messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the per-message "Context Usage" panel to group chat messages via a dedicated synchronous `group_chat_message_debug_snapshot` command that reconstructs the group prompt (build_group_system_prompt + assembled messages) from the stored message data — speaker character, model, memory refs and used lorebook entries — without re-running retrieval. `build_group_system_prompt` now also returns the raw placeholder source texts, so the breakdown attributes character profile, persona, lorebook, memories, author note and the `{{group_characters}}` cast block. Adds a new "Group cast" category (with a "(X characters)" count) for the other participants' profiles. The reserved-for-response segment stays provider-gated (llama.cpp / Ollama only) via a shared RESERVING_PROVIDER_IDS set, so remote providers never show it. Shared PromptTokenBreakdown component and all 20 locales extended accordingly. --- src-tauri/src/app/commands.rs | 1 + src-tauri/src/chat_manager/commands/mod.rs | 8 + src-tauri/src/chat_manager/mod.rs | 2 +- src-tauri/src/group_chat_manager/mod.rs | 313 +++++++++++++++++- src/core/chat/manager.ts | 15 + src/core/i18n/locales/de.ts | 2 + src/core/i18n/locales/el.ts | 2 + src/core/i18n/locales/en.ts | 2 + src/core/i18n/locales/es.ts | 2 + src/core/i18n/locales/fil.ts | 2 + src/core/i18n/locales/fr.ts | 2 + src/core/i18n/locales/hi.ts | 2 + src/core/i18n/locales/id.ts | 2 + src/core/i18n/locales/it.ts | 2 + src/core/i18n/locales/ja.ts | 2 + src/core/i18n/locales/ko.ts | 2 + src/core/i18n/locales/nl.ts | 2 + src/core/i18n/locales/no.ts | 2 + src/core/i18n/locales/pl.ts | 2 + src/core/i18n/locales/pt.ts | 2 + src/core/i18n/locales/ru.ts | 2 + src/core/i18n/locales/tr.ts | 2 + src/core/i18n/locales/vi.ts | 2 + src/core/i18n/locales/zh-Hans.ts | 2 + src/core/i18n/locales/zh-Hant.ts | 2 + .../chats/components/PromptTokenBreakdown.tsx | 42 ++- src/ui/pages/group-chats/GroupChatPage.tsx | 1 + .../GroupChatMessageActionsBottomSheet.tsx | 31 +- 28 files changed, 441 insertions(+), 12 deletions(-) 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 401fe1fc7..811119a64 100644 --- a/src-tauri/src/chat_manager/commands/mod.rs +++ b/src-tauri/src/chat_manager/commands/mod.rs @@ -81,6 +81,12 @@ pub struct ChatMessageDebugSnapshot { 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)] @@ -672,6 +678,8 @@ pub fn chat_message_debug_snapshot( 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/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/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 640457cad..2b2d083c0 100644 --- a/src/core/chat/manager.ts +++ b/src/core/chat/manager.ts @@ -59,6 +59,9 @@ export interface ChatMessageDebugSnapshot { 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 { @@ -333,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 a2c40d99d..3a774865a 100644 --- a/src/core/i18n/locales/de.ts +++ b/src/core/i18n/locales/de.ts @@ -1469,10 +1469,12 @@ export const deMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/el.ts b/src/core/i18n/locales/el.ts index 8bddb755f..a2947bd6d 100644 --- a/src/core/i18n/locales/el.ts +++ b/src/core/i18n/locales/el.ts @@ -1469,10 +1469,12 @@ export const elMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(εσωτερική ζωή / προγραμματισμένες σημειώσεις)", tokenBreakdownHistorySuffix: "(τελευταία {{count}} μηνύματα)", tokenBreakdownEntriesSuffix: "({{count}} καταχωρίσεις)", + tokenBreakdownCharactersSuffix: "({{count}} χαρακτήρες)", tokenBreakdownCat: { system: "Σύστημα", character: "Χαρακτήρας", persona: "Περσόνα", + groupCast: "Καστ ομάδας", memory: "Αναμνήσεις", lorebook: "Βιβλίο Γνώσης", authorNote: "Σημείωση Συγγραφέα", diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index 1182d74d9..851cb5e9f 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -1576,10 +1576,12 @@ export const enMessages = { 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", diff --git a/src/core/i18n/locales/es.ts b/src/core/i18n/locales/es.ts index 388472252..7b0d340cd 100644 --- a/src/core/i18n/locales/es.ts +++ b/src/core/i18n/locales/es.ts @@ -1469,10 +1469,12 @@ export const esMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/fil.ts b/src/core/i18n/locales/fil.ts index 40db59f5d..43e659ad7 100644 --- a/src/core/i18n/locales/fil.ts +++ b/src/core/i18n/locales/fil.ts @@ -1469,10 +1469,12 @@ export const filMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/fr.ts b/src/core/i18n/locales/fr.ts index 9d404fd1a..07f8be654 100644 --- a/src/core/i18n/locales/fr.ts +++ b/src/core/i18n/locales/fr.ts @@ -1469,10 +1469,12 @@ export const frMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/hi.ts b/src/core/i18n/locales/hi.ts index ba4150361..7e7a4ebd4 100644 --- a/src/core/i18n/locales/hi.ts +++ b/src/core/i18n/locales/hi.ts @@ -1469,10 +1469,12 @@ export const hiMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(आंतरिक जीवन / शेड्यूल्ड नोट्स)", tokenBreakdownHistorySuffix: "(अंतिम {{count}} संदेश)", tokenBreakdownEntriesSuffix: "({{count}} प्रविष्टियाँ)", + tokenBreakdownCharactersSuffix: "({{count}} कैरेक्टर)", tokenBreakdownCat: { system: "सिस्टम", character: "कैरेक्टर", persona: "पर्सोना", + groupCast: "ग्रुप कास्ट", memory: "यादें", lorebook: "लोरबुक", authorNote: "लेखक की टिप्पणी", diff --git a/src/core/i18n/locales/id.ts b/src/core/i18n/locales/id.ts index 39770d7a9..fee664907 100644 --- a/src/core/i18n/locales/id.ts +++ b/src/core/i18n/locales/id.ts @@ -1469,10 +1469,12 @@ export const idMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/it.ts b/src/core/i18n/locales/it.ts index e6d3fbd0a..5c9833521 100644 --- a/src/core/i18n/locales/it.ts +++ b/src/core/i18n/locales/it.ts @@ -1469,10 +1469,12 @@ export const itMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/ja.ts b/src/core/i18n/locales/ja.ts index 88a4be830..5d52dcd49 100644 --- a/src/core/i18n/locales/ja.ts +++ b/src/core/i18n/locales/ja.ts @@ -1469,10 +1469,12 @@ export const jaMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(内面 / 予約ノート)", tokenBreakdownHistorySuffix: "(直近 {{count}} 件のメッセージ)", tokenBreakdownEntriesSuffix: "({{count}} 件のエントリ)", + tokenBreakdownCharactersSuffix: "({{count}} 人のキャラクター)", tokenBreakdownCat: { system: "システム", character: "キャラクター", persona: "ペルソナ", + groupCast: "グループキャスト", memory: "メモリ", lorebook: "ロアブック", authorNote: "作者ノート", diff --git a/src/core/i18n/locales/ko.ts b/src/core/i18n/locales/ko.ts index ebdc3d5c2..da77e3aa9 100644 --- a/src/core/i18n/locales/ko.ts +++ b/src/core/i18n/locales/ko.ts @@ -1469,10 +1469,12 @@ export const koMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(내면 / 예약된 노트)", tokenBreakdownHistorySuffix: "(최근 {{count}}개 메시지)", tokenBreakdownEntriesSuffix: "({{count}}개 항목)", + tokenBreakdownCharactersSuffix: "({{count}}명 캐릭터)", tokenBreakdownCat: { system: "시스템", character: "캐릭터", persona: "페르소나", + groupCast: "그룹 캐스트", memory: "기억", lorebook: "로어북", authorNote: "작가 노트", diff --git a/src/core/i18n/locales/nl.ts b/src/core/i18n/locales/nl.ts index 1f5e1140d..ca00537a8 100644 --- a/src/core/i18n/locales/nl.ts +++ b/src/core/i18n/locales/nl.ts @@ -1469,10 +1469,12 @@ export const nlMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/no.ts b/src/core/i18n/locales/no.ts index b674b4515..631dd60bf 100644 --- a/src/core/i18n/locales/no.ts +++ b/src/core/i18n/locales/no.ts @@ -1469,10 +1469,12 @@ export const noMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/pl.ts b/src/core/i18n/locales/pl.ts index c2b1dcb7e..6f002b7e3 100644 --- a/src/core/i18n/locales/pl.ts +++ b/src/core/i18n/locales/pl.ts @@ -1469,10 +1469,12 @@ export const plMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/pt.ts b/src/core/i18n/locales/pt.ts index 487ceb6f7..92200ccb7 100644 --- a/src/core/i18n/locales/pt.ts +++ b/src/core/i18n/locales/pt.ts @@ -1469,10 +1469,12 @@ export const ptMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/ru.ts b/src/core/i18n/locales/ru.ts index 2775a0b98..9f2c6f4c3 100644 --- a/src/core/i18n/locales/ru.ts +++ b/src/core/i18n/locales/ru.ts @@ -1469,10 +1469,12 @@ export const ruMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(внутренняя жизнь / запланированные заметки)", tokenBreakdownHistorySuffix: "(последние {{count}} сообщений)", tokenBreakdownEntriesSuffix: "({{count}} записей)", + tokenBreakdownCharactersSuffix: "({{count}} персонажей)", tokenBreakdownCat: { system: "Система", character: "Персонаж", persona: "Персона", + groupCast: "Состав группы", memory: "Воспоминания", lorebook: "Лорбук", authorNote: "Заметка автора", diff --git a/src/core/i18n/locales/tr.ts b/src/core/i18n/locales/tr.ts index 6d2bf5696..16ac4203f 100644 --- a/src/core/i18n/locales/tr.ts +++ b/src/core/i18n/locales/tr.ts @@ -1469,10 +1469,12 @@ export const trMessages: DeepPartialMessageTree = { 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", diff --git a/src/core/i18n/locales/vi.ts b/src/core/i18n/locales/vi.ts index 640da0ddf..d1212f311 100644 --- a/src/core/i18n/locales/vi.ts +++ b/src/core/i18n/locales/vi.ts @@ -1469,10 +1469,12 @@ export const viMessages: DeepPartialMessageTree = { 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ả", diff --git a/src/core/i18n/locales/zh-Hans.ts b/src/core/i18n/locales/zh-Hans.ts index 7ed242fb2..dfadee0fe 100644 --- a/src/core/i18n/locales/zh-Hans.ts +++ b/src/core/i18n/locales/zh-Hans.ts @@ -1469,10 +1469,12 @@ export const zhHansMessages: DeepPartialMessageTree = { tokenBreakdownCompanionSuffix: "(内心 / 计划笔记)", tokenBreakdownHistorySuffix: "(最近 {{count}} 条消息)", tokenBreakdownEntriesSuffix: "({{count}} 个条目)", + tokenBreakdownCharactersSuffix: "({{count}} 个角色)", tokenBreakdownCat: { system: "系统", character: "角色", persona: "人设", + groupCast: "群组角色", memory: "记忆", lorebook: "知识库", authorNote: "作者注释", diff --git a/src/core/i18n/locales/zh-Hant.ts b/src/core/i18n/locales/zh-Hant.ts index 6d4926ecf..154dd4c2c 100644 --- a/src/core/i18n/locales/zh-Hant.ts +++ b/src/core/i18n/locales/zh-Hant.ts @@ -1469,10 +1469,12 @@ export const zhHantMessages: DeepPartialMessageTree = { 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)", diff --git a/src/ui/pages/chats/components/PromptTokenBreakdown.tsx b/src/ui/pages/chats/components/PromptTokenBreakdown.tsx index e3e769935..967b12b0d 100644 --- a/src/ui/pages/chats/components/PromptTokenBreakdown.tsx +++ b/src/ui/pages/chats/components/PromptTokenBreakdown.tsx @@ -2,11 +2,17 @@ import { useEffect, useMemo, useState } from "react"; import { getMessageDebugSnapshot, + getGroupChatMessageDebugSnapshot, type ChatMessageDebugSnapshot, } from "../../../../core/chat/manager"; import { useI18n, type TranslationKey } from "../../../../core/i18n/context"; import { countTokensBatch } from "../../../../core/tokens"; -import type { StoredMessage } from "../../../../core/storage/schemas"; +// Minimal shape the breakdown needs from a message — satisfied by both the 1:1 +// StoredMessage and the group-chat GroupMessage. +type BreakdownMessage = { + usage?: { promptTokens?: number | null } | null; + usedLorebookEntries?: string[] | null; +}; type PromptEntryLike = { id?: unknown; @@ -19,6 +25,7 @@ export type BreakdownCategory = | "system" | "character" | "persona" + | "groupCast" | "memory" | "lorebook" | "authorNote" @@ -29,6 +36,7 @@ const CATEGORY_ORDER: BreakdownCategory[] = [ "system", "character", "persona", + "groupCast", "memory", "lorebook", "authorNote", @@ -40,6 +48,7 @@ const CATEGORY_LABEL_KEYS: Record = { 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", @@ -52,6 +61,7 @@ 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) @@ -67,6 +77,13 @@ 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; @@ -142,6 +159,7 @@ export type TokenBreakdownData = { historyCount: number; memoryEntryCount: number; lorebookEntryCount: number; + groupCastCount: number; segments: BreakdownSegment[]; }; @@ -154,6 +172,7 @@ export function useMessageDebugSnapshot( sessionId: string | null | undefined, messageId: string | null | undefined, enabled: boolean, + variant: "direct" | "group" = "direct", ): ChatMessageDebugSnapshot | null { const [snapshot, setSnapshot] = useState(null); @@ -163,7 +182,9 @@ export function useMessageDebugSnapshot( return; } let cancelled = false; - void getMessageDebugSnapshot({ sessionId, messageId }) + const load = + variant === "group" ? getGroupChatMessageDebugSnapshot : getMessageDebugSnapshot; + void load({ sessionId, messageId }) .then((next) => { if (!cancelled) setSnapshot(next); }) @@ -173,7 +194,7 @@ export function useMessageDebugSnapshot( return () => { cancelled = true; }; - }, [sessionId, messageId, enabled]); + }, [sessionId, messageId, enabled, variant]); return snapshot; } @@ -185,7 +206,7 @@ export function useMessageDebugSnapshot( */ export function useTokenBreakdown( snapshot: ChatMessageDebugSnapshot | null, - message: StoredMessage | null, + message: BreakdownMessage | null, ): TokenBreakdownData { const [breakdown, setBreakdown] = useState(null); const [loading, setLoading] = useState(false); @@ -227,6 +248,7 @@ export function useTokenBreakdown( }; addInline("character", snapshot.characterProfileContent); addInline("persona", snapshot.personaContent); + addInline("groupCast", snapshot.groupCastContent); addInline("lorebook", snapshot.lorebookContent); addInline("memory", snapshot.contextSummaryContent); addInline("memory", snapshot.keyMemoriesContent); @@ -243,6 +265,7 @@ export function useTokenBreakdown( const INLINE_CATEGORIES: BreakdownCategory[] = [ "character", "persona", + "groupCast", "lorebook", "memory", "authorNote", @@ -310,8 +333,8 @@ export function useTokenBreakdown( // 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 ?? "").toLowerCase(); - const reservesCompletion = providerId === "llamacpp" || providerId === "ollama"; + 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" @@ -357,6 +380,7 @@ export function useTokenBreakdown( }, [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 — @@ -388,6 +412,7 @@ export function useTokenBreakdown( historyCount, memoryEntryCount, lorebookEntryCount, + groupCastCount, segments, }; } @@ -411,6 +436,7 @@ export function PromptTokenBreakdownBars({ data }: { data: TokenBreakdownData }) historyCount, memoryEntryCount, lorebookEntryCount, + groupCastCount, segments, } = data; @@ -419,6 +445,10 @@ export function PromptTokenBreakdownBars({ data }: { data: TokenBreakdownData }) 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), 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")} + +
+ +
+ )} + Date: Wed, 9 Sep 2026 23:59:41 +0200 Subject: [PATCH 4/5] fix(memory): ship only retrieved memories in 1:1 dynamic chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dynamic-memory mode the 1:1 chat path filled {{key_memories}} with the entire hot memory bank on every prompt, in addition to the retrieved relevant memories injected as a separate block — bloating the prompt with redundant context. Now {{key_memories}} is resolved from the retrieved relevant memories before the shared renderer runs, and the redundant separate block is dropped. Covers RP characters and companions (completion, regenerate, continue) and the debug snapshot, which reconstructs the injected memories from the message's stored refs. The shared render_with_context, group chat, manual-memory mode, and memory-management prompts are untouched. --- src-tauri/src/chat_manager/commands/mod.rs | 50 +++++++- .../src/chat_manager/flows/completion.rs | 107 +++++++++--------- .../src/chat_manager/flows/continuation.rs | 9 +- .../src/chat_manager/flows/regenerate.rs | 9 +- .../src/chat_manager/persistence/storage.rs | 15 ++- .../chat_manager/prompting/prompt_engine.rs | 57 ++++++---- src-tauri/src/chat_manager/service.rs | 6 +- 7 files changed, 171 insertions(+), 82 deletions(-) diff --git a/src-tauri/src/chat_manager/commands/mod.rs b/src-tauri/src/chat_manager/commands/mod.rs index 811119a64..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}; @@ -475,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, @@ -530,8 +542,39 @@ 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) = @@ -544,6 +587,7 @@ pub fn chat_message_debug_snapshot( persona.as_ref(), &prompt_session, &context.settings, + &retrieved_memories, ); let system_role = crate::chat_manager::request_builder::system_role_for(&credential); 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/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 9b925d2e0..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}; @@ -3403,6 +3403,7 @@ pub(crate) fn debug_prompt_source_contents( 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); @@ -3448,10 +3449,8 @@ pub(crate) fn debug_prompt_source_contents( }; let key_memories = if dynamic_memory_active { - 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, companion_now)) .collect::>() .join("\n") @@ -3474,11 +3473,7 @@ pub(crate) fn debug_prompt_source_contents( .unwrap_or_default(); let memory_entry_count = if dynamic_memory_active { - session - .memory_embeddings - .iter() - .filter(|mem| (!mem.is_cold || mem.is_pinned) && mem.superseded_by.is_none()) - .count() as u32 + retrieved_memories.len() as u32 } else { session .memories @@ -3519,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); @@ -3667,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() @@ -3740,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, @@ -3791,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, ) } From 57f1808c794a8f1c11217494b02c6f0a010da3d6 Mon Sep 17 00:00:00 2001 From: Lothar Hoffmann Date: Thu, 10 Sep 2026 08:07:30 +0200 Subject: [PATCH 5/5] feat(debug): warn when prompt + reserved response overflow the context window The context-usage bars capped Free at 0 when occupied + reserved exceeded the context window, silently hiding the over-reservation (e.g. llama.cpp n_ctx = prompt + completion budget). Add a red alert under the context-window bar showing the overflow in tokens so the user can react (lower max tokens or context content) before the model truncates the prompt or cuts the response short. Translated across all 20 locales. --- src/core/i18n/locales/de.ts | 2 ++ src/core/i18n/locales/el.ts | 2 ++ src/core/i18n/locales/en.ts | 2 ++ src/core/i18n/locales/es.ts | 2 ++ src/core/i18n/locales/fil.ts | 2 ++ src/core/i18n/locales/fr.ts | 2 ++ src/core/i18n/locales/hi.ts | 2 ++ src/core/i18n/locales/id.ts | 2 ++ src/core/i18n/locales/it.ts | 2 ++ src/core/i18n/locales/ja.ts | 2 ++ src/core/i18n/locales/ko.ts | 2 ++ src/core/i18n/locales/nl.ts | 2 ++ src/core/i18n/locales/no.ts | 2 ++ src/core/i18n/locales/pl.ts | 2 ++ src/core/i18n/locales/pt.ts | 2 ++ src/core/i18n/locales/ru.ts | 2 ++ src/core/i18n/locales/tr.ts | 2 ++ src/core/i18n/locales/vi.ts | 2 ++ src/core/i18n/locales/zh-Hans.ts | 2 ++ src/core/i18n/locales/zh-Hant.ts | 2 ++ .../chats/components/PromptTokenBreakdown.tsx | 15 +++++++++++++++ 21 files changed, 55 insertions(+) diff --git a/src/core/i18n/locales/de.ts b/src/core/i18n/locales/de.ts index 3a774865a..906a622dc 100644 --- a/src/core/i18n/locales/de.ts +++ b/src/core/i18n/locales/de.ts @@ -1464,6 +1464,8 @@ export const deMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/el.ts b/src/core/i18n/locales/el.ts index a2947bd6d..a69c71a64 100644 --- a/src/core/i18n/locales/el.ts +++ b/src/core/i18n/locales/el.ts @@ -1464,6 +1464,8 @@ export const elMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "Δεσμευμένο για την απάντηση", tokenBreakdownReservedSplit: "(απάντηση: {{response}} / συλλογισμός: {{reasoning}})", tokenBreakdownOccupied: "Κατειλημμένο", + tokenBreakdownOverReserved: + "Υπερδέσμευση: το prompt + η δεσμευμένη απάντηση υπερβαίνουν το παράθυρο περιβάλλοντος κατά {{overflow}} token. Το μοντέλο θα περικόψει το prompt ή θα κόψει απότομα την απάντηση — μειώστε τα μέγιστα token ή το περιεχόμενο περιβάλλοντος.", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "Καταμέτρηση token…", tokenBreakdownCompanionSuffix: "(εσωτερική ζωή / προγραμματισμένες σημειώσεις)", diff --git a/src/core/i18n/locales/en.ts b/src/core/i18n/locales/en.ts index 851cb5e9f..24c3fb806 100644 --- a/src/core/i18n/locales/en.ts +++ b/src/core/i18n/locales/en.ts @@ -1571,6 +1571,8 @@ export const enMessages = { 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)", diff --git a/src/core/i18n/locales/es.ts b/src/core/i18n/locales/es.ts index 7b0d340cd..5db271bf5 100644 --- a/src/core/i18n/locales/es.ts +++ b/src/core/i18n/locales/es.ts @@ -1464,6 +1464,8 @@ export const esMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/fil.ts b/src/core/i18n/locales/fil.ts index 43e659ad7..eda79ebd6 100644 --- a/src/core/i18n/locales/fil.ts +++ b/src/core/i18n/locales/fil.ts @@ -1464,6 +1464,8 @@ export const filMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/fr.ts b/src/core/i18n/locales/fr.ts index 07f8be654..434fc95df 100644 --- a/src/core/i18n/locales/fr.ts +++ b/src/core/i18n/locales/fr.ts @@ -1464,6 +1464,8 @@ export const frMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/hi.ts b/src/core/i18n/locales/hi.ts index 7e7a4ebd4..54b59e909 100644 --- a/src/core/i18n/locales/hi.ts +++ b/src/core/i18n/locales/hi.ts @@ -1464,6 +1464,8 @@ export const hiMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "प्रतिक्रिया के लिए आरक्षित", tokenBreakdownReservedSplit: "(प्रतिक्रिया: {{response}} / reasoning: {{reasoning}})", tokenBreakdownOccupied: "उपयोग में", + tokenBreakdownOverReserved: + "अति-आरक्षण: प्रॉम्प्ट + आरक्षित उत्तर संदर्भ विंडो को {{overflow}} टोकन से पार कर जाते हैं। मॉडल प्रॉम्प्ट को छोटा कर देगा या उत्तर को बीच में काट देगा — अधिकतम टोकन या संदर्भ सामग्री कम करें।", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "टोकन गिने जा रहे हैं…", tokenBreakdownCompanionSuffix: "(आंतरिक जीवन / शेड्यूल्ड नोट्स)", diff --git a/src/core/i18n/locales/id.ts b/src/core/i18n/locales/id.ts index fee664907..f89e70cab 100644 --- a/src/core/i18n/locales/id.ts +++ b/src/core/i18n/locales/id.ts @@ -1464,6 +1464,8 @@ export const idMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/it.ts b/src/core/i18n/locales/it.ts index 5c9833521..1becdf199 100644 --- a/src/core/i18n/locales/it.ts +++ b/src/core/i18n/locales/it.ts @@ -1464,6 +1464,8 @@ export const itMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/ja.ts b/src/core/i18n/locales/ja.ts index 5d52dcd49..12df14b64 100644 --- a/src/core/i18n/locales/ja.ts +++ b/src/core/i18n/locales/ja.ts @@ -1464,6 +1464,8 @@ export const jaMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "応答用に予約", tokenBreakdownReservedSplit: "(応答: {{response}} / 推論: {{reasoning}})", tokenBreakdownOccupied: "使用中", + tokenBreakdownOverReserved: + "過剰予約: プロンプトと予約済みの応答がコンテキストウィンドウを {{overflow}} トークン超過しています。モデルはプロンプトを切り詰めるか、応答を途中で打ち切ります。最大トークン数またはコンテキストの内容を減らしてください。", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "トークンを計算中…", tokenBreakdownCompanionSuffix: "(内面 / 予約ノート)", diff --git a/src/core/i18n/locales/ko.ts b/src/core/i18n/locales/ko.ts index da77e3aa9..36dd9059d 100644 --- a/src/core/i18n/locales/ko.ts +++ b/src/core/i18n/locales/ko.ts @@ -1464,6 +1464,8 @@ export const koMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "응답용 예약", tokenBreakdownReservedSplit: "(응답: {{response}} / 추론: {{reasoning}})", tokenBreakdownOccupied: "사용 중", + tokenBreakdownOverReserved: + "과잉 예약: 프롬프트 + 예약된 응답이 컨텍스트 창을 {{overflow}} 토큰만큼 초과합니다. 모델이 프롬프트를 잘라내거나 응답을 도중에 끊습니다 — 최대 토큰 수나 컨텍스트 내용을 줄이세요.", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "토큰 계산 중…", tokenBreakdownCompanionSuffix: "(내면 / 예약된 노트)", diff --git a/src/core/i18n/locales/nl.ts b/src/core/i18n/locales/nl.ts index ca00537a8..5e794e7a4 100644 --- a/src/core/i18n/locales/nl.ts +++ b/src/core/i18n/locales/nl.ts @@ -1464,6 +1464,8 @@ export const nlMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/no.ts b/src/core/i18n/locales/no.ts index 631dd60bf..d7d049eb9 100644 --- a/src/core/i18n/locales/no.ts +++ b/src/core/i18n/locales/no.ts @@ -1464,6 +1464,8 @@ export const noMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/pl.ts b/src/core/i18n/locales/pl.ts index 6f002b7e3..26bfb0a33 100644 --- a/src/core/i18n/locales/pl.ts +++ b/src/core/i18n/locales/pl.ts @@ -1464,6 +1464,8 @@ export const plMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/pt.ts b/src/core/i18n/locales/pt.ts index 92200ccb7..aa05e0a50 100644 --- a/src/core/i18n/locales/pt.ts +++ b/src/core/i18n/locales/pt.ts @@ -1464,6 +1464,8 @@ export const ptMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/ru.ts b/src/core/i18n/locales/ru.ts index 9f2c6f4c3..b03d892ff 100644 --- a/src/core/i18n/locales/ru.ts +++ b/src/core/i18n/locales/ru.ts @@ -1464,6 +1464,8 @@ export const ruMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "Зарезервировано для ответа", tokenBreakdownReservedSplit: "(ответ: {{response}} / рассуждение: {{reasoning}})", tokenBreakdownOccupied: "Занято", + tokenBreakdownOverReserved: + "Избыточное резервирование: промпт + зарезервированный ответ превышают контекстное окно на {{overflow}} токенов. Модель обрежет промпт или оборвёт ответ — уменьшите максимум токенов или содержимое контекста.", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "Подсчёт токенов…", tokenBreakdownCompanionSuffix: "(внутренняя жизнь / запланированные заметки)", diff --git a/src/core/i18n/locales/tr.ts b/src/core/i18n/locales/tr.ts index 16ac4203f..b6639d788 100644 --- a/src/core/i18n/locales/tr.ts +++ b/src/core/i18n/locales/tr.ts @@ -1464,6 +1464,8 @@ export const trMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/vi.ts b/src/core/i18n/locales/vi.ts index d1212f311..da83f31a4 100644 --- a/src/core/i18n/locales/vi.ts +++ b/src/core/i18n/locales/vi.ts @@ -1464,6 +1464,8 @@ export const viMessages: DeepPartialMessageTree = { 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)", diff --git a/src/core/i18n/locales/zh-Hans.ts b/src/core/i18n/locales/zh-Hans.ts index dfadee0fe..f5dcb75ce 100644 --- a/src/core/i18n/locales/zh-Hans.ts +++ b/src/core/i18n/locales/zh-Hans.ts @@ -1464,6 +1464,8 @@ export const zhHansMessages: DeepPartialMessageTree = { tokenBreakdownReserved: "为回复保留", tokenBreakdownReservedSplit: "(回复: {{response}} / 推理: {{reasoning}})", tokenBreakdownOccupied: "已占用", + tokenBreakdownOverReserved: + "超额预留:提示词 + 预留的回复超出上下文窗口 {{overflow}} 个 token。模型将截断提示词或提前结束回复——请减少最大 token 数或上下文内容。", tokenBreakdownUsage: "{{used}} / {{limit}} ({{percent}}%)", tokenBreakdownComputing: "正在计算 token…", tokenBreakdownCompanionSuffix: "(内心 / 计划笔记)", diff --git a/src/core/i18n/locales/zh-Hant.ts b/src/core/i18n/locales/zh-Hant.ts index 154dd4c2c..392a837b2 100644 --- a/src/core/i18n/locales/zh-Hant.ts +++ b/src/core/i18n/locales/zh-Hant.ts @@ -1464,6 +1464,8 @@ export const zhHantMessages: DeepPartialMessageTree = { 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))", diff --git a/src/ui/pages/chats/components/PromptTokenBreakdown.tsx b/src/ui/pages/chats/components/PromptTokenBreakdown.tsx index 967b12b0d..253b001b4 100644 --- a/src/ui/pages/chats/components/PromptTokenBreakdown.tsx +++ b/src/ui/pages/chats/components/PromptTokenBreakdown.tsx @@ -569,6 +569,21 @@ export function PromptTokenBreakdownBars({ data }: { data: TokenBreakdownData })
+ {occupiedTotal > contextLength ? ( +
+ + ⚠ + + + {t("chats.debugPage.tokenBreakdownOverReserved", { + overflow: String(occupiedTotal - contextLength), + })} + +
+ ) : null}
) : null}