feat: collapsible messages, German cost format, stats persistence [appimage]
Some checks failed
Build AppImage / build (push) Has been cancelled

- Long messages (>25 lines) auto-collapse with expand/collapse button
- Cost display uses German format: "16,23$" instead of "$16.230"
- Session stats (tokens, cost, count) persist to DB after each response
  via new update_session_stats command — survives app restart
- Small costs shown as cents (e.g. "3,2¢")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 22:31:25 +02:00
parent 50d46dca79
commit 8a7e0d87f3
6 changed files with 101 additions and 5 deletions

View file

@ -77,6 +77,7 @@ pub fn run() {
session::resume_session, session::resume_session,
session::get_active_session, session::get_active_session,
session::set_claude_session_id, session::set_claude_session_id,
session::update_session_stats,
// Messages // Messages
db::save_message, db::save_message,
db::load_messages, db::load_messages,

View file

@ -153,3 +153,30 @@ pub async fn set_claude_session_id(
Ok(()) Ok(())
} }
/// Aktualisiert nur die Statistiken einer Session (Token, Kosten, Message-Count)
#[tauri::command]
pub async fn update_session_stats(
app: AppHandle,
session_id: String,
token_input: i64,
token_output: i64,
cost_usd: f64,
message_count: i32,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.conn.execute(
"UPDATE sessions SET token_input = ?1, token_output = ?2, cost_usd = ?3,
message_count = ?4, updated_at = ?5 WHERE id = ?6",
rusqlite::params![
token_input,
token_output,
cost_usd,
message_count,
chrono::Utc::now().to_rfc3339(),
session_id,
],
).map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -23,6 +23,23 @@
marked.setOptions({ breaks: true, gfm: true, renderer }); marked.setOptions({ breaks: true, gfm: true, renderer });
// Collapse: Nachrichten mit > 25 Zeilen werden eingeklappt
const COLLAPSE_LINES = 25;
let expandedMessages = new Set<string>();
function toggleExpand(msgId: string) {
if (expandedMessages.has(msgId)) {
expandedMessages.delete(msgId);
} else {
expandedMessages.add(msgId);
}
expandedMessages = expandedMessages;
}
function shouldCollapse(content: string): boolean {
return content.split('\n').length > COLLAPSE_LINES;
}
function renderMarkdown(text: string): string { function renderMarkdown(text: string): string {
try { try {
return marked.parse(text) as string; return marked.parse(text) as string;
@ -831,7 +848,21 @@
</div> </div>
{:else if message.role === 'assistant'} {:else if message.role === 'assistant'}
{#if message.content} {#if message.content}
{@html renderMarkdown(message.content)} {#if shouldCollapse(message.content) && !expandedMessages.has(message.id)}
<div class="msg-collapsed">
{@html renderMarkdown(message.content.split('\n').slice(0, COLLAPSE_LINES).join('\n') + '\n...')}
</div>
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▼ Mehr anzeigen ({message.content.split('\n').length} Zeilen)
</button>
{:else}
{@html renderMarkdown(message.content)}
{#if shouldCollapse(message.content)}
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▲ Einklappen
</button>
{/if}
{/if}
{:else if $isProcessing} {:else if $isProcessing}
<span class="typing"> <span class="typing">
<span class="dot"></span> <span class="dot"></span>
@ -1270,6 +1301,29 @@
line-height: 1.6; line-height: 1.6;
} }
.msg-collapsed {
overflow: hidden;
}
.expand-btn {
display: block;
width: 100%;
padding: 0.35rem;
margin-top: 0.3rem;
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
color: var(--accent, #6aa3ff);
font-size: 0.75rem;
cursor: pointer;
text-align: center;
transition: background 0.15s;
}
.expand-btn:hover {
background: var(--bg-hover, #333);
}
/* Markdown-Styles innerhalb von Nachrichten */ /* Markdown-Styles innerhalb von Nachrichten */
.message-content :global(p) { .message-content :global(p) {
margin: 0.3em 0; margin: 0.3em 0;

View file

@ -126,7 +126,8 @@
function formatCost(usd: number): string { function formatCost(usd: number): string {
if (usd === 0) return ''; if (usd === 0) return '';
return `$${usd.toFixed(3)}`; if (usd < 0.01) return `${(usd * 100).toFixed(1)}¢`;
return `${usd.toFixed(2).replace('.', ',')}$`;
} }
</script> </script>

View file

@ -381,6 +381,19 @@ export async function initEventListeners(): Promise<void> {
outputTokens: tokens.output || 0, outputTokens: tokens.output || 0,
})); }));
} }
// Session-Stats in DB persistieren (überlebt App-Neustart)
const appSessionId = get(currentSessionId);
if (appSessionId) {
const stats = get(sessionStats);
invoke('update_session_stats', {
sessionId: appSessionId,
tokenInput: stats.totalTokensIn,
tokenOutput: stats.totalTokensOut,
costUsd: stats.totalCost,
messageCount: stats.messageCount,
}).catch((err: unknown) => console.warn('Session-Stats speichern fehlgeschlagen:', err));
}
} }
}) })
); );

View file

@ -136,9 +136,9 @@
} }
function formatCost(usd: number): string { function formatCost(usd: number): string {
if (usd === 0) return '$0'; if (usd === 0) return '0$';
if (usd < 0.01) return `$${usd.toFixed(4)}`; if (usd < 0.01) return `${(usd * 100).toFixed(1)}¢`;
return `$${usd.toFixed(2)}`; return `${usd.toFixed(2).replace('.', ',')}$`;
} }
function formatTokens(n: number): string { function formatTokens(n: number): string {