claude-desktop/src/lib/components/ChatPanel.svelte
Eddy ab95af24ae Token-basiertes Compacting mit Bestätigungs-Dialog
- Warnung bei ~40k Token (statt plumpe Nachrichtenanzahl)
- Dialog zeigt aktuelle Token-Schätzung
- User entscheidet ob kompaktiert wird
- Zeigt was nach Compacting übrig bleibt (30 neueste)
- "Später" Button um Dialog zu schließen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-14 14:03:31 +02:00

1261 lines
30 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, type Message } from '$lib/stores/app';
import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
// Input-Referenz für Focus-Shortcuts
let inputTextarea: HTMLTextAreaElement;
// Custom Renderer für Code-Blöcke mit Wrapper
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: Tokens.Code): string {
const language = lang || '';
const escapedCode = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<div class="code-block-wrapper" data-lang="${language}"><pre><code class="language-${language}">${escapedCode}</code></pre></div>`;
};
marked.setOptions({ breaks: true, gfm: true, renderer });
function renderMarkdown(text: string): string {
try {
return marked.parse(text) as string;
} catch {
return text;
}
}
// Svelte Action: Copy-Buttons zu Code-Blöcken hinzufügen
function addCopyButtons(node: HTMLElement) {
function processCodeBlocks() {
const wrappers = node.querySelectorAll('.code-block-wrapper:not([data-copy-added])');
wrappers.forEach((wrapper) => {
wrapper.setAttribute('data-copy-added', 'true');
const lang = wrapper.getAttribute('data-lang') || '';
const codeEl = wrapper.querySelector('code');
const codeText = codeEl?.textContent || '';
// Header mit Sprache und Copy-Button erstellen
const header = document.createElement('div');
header.className = 'code-header';
if (lang) {
const langSpan = document.createElement('span');
langSpan.className = 'code-lang';
langSpan.textContent = lang;
header.appendChild(langSpan);
}
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.title = 'Code kopieren';
copyBtn.innerHTML = '📋';
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeText);
copyBtn.innerHTML = '<span class="copied">✓</span>';
setTimeout(() => (copyBtn.innerHTML = '📋'), 2000);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
};
header.appendChild(copyBtn);
wrapper.insertBefore(header, wrapper.firstChild);
});
}
// Initial verarbeiten
processCodeBlocks();
// MutationObserver für dynamische Inhalte (Streaming)
const observer = new MutationObserver(processCodeBlocks);
observer.observe(node, { childList: true, subtree: true });
return {
destroy() {
observer.disconnect();
}
};
}
let messagesContainer: HTMLDivElement;
// Edit-Modus State
let editingMessageId: string | null = $state(null);
let editingContent: string = $state('');
// Auto-Compacting State
let lastMessageCount = 0;
let compactingWarningShown = false;
let showCompactingDialog = $state(false);
let estimatedTokens = $state(0);
const TOKEN_WARNING_THRESHOLD = 40000; // ~40k Token = Warnung zeigen
const KEEP_LAST_MESSAGES = 30;
async function scrollToBottom() {
await tick();
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
$: if ($messages.length) scrollToBottom();
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
$: if ($currentSessionId) {
compactingWarningShown = false;
showCompactingDialog = false;
}
// Token-Schätzung: ~4 Zeichen pro Token
function estimateTokensForMessages(msgs: Message[]): number {
return msgs.reduce((total, msg) => total + Math.ceil(msg.content.length / 4), 0);
}
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
if (!sessionId) return;
try {
const dbMsg = messageToDb(msg, sessionId);
await invoke('save_message', { message: dbMsg });
} catch (err) {
console.error('Fehler beim Speichern der Nachricht:', err);
}
}
// Neue Nachrichten automatisch speichern + Token-Warnung
const unsubscribe = messages.subscribe(async (msgs) => {
if (msgs.length > lastMessageCount && lastMessageCount > 0) {
// Neue Nachricht(en) hinzugefügt
const newMessages = msgs.slice(lastMessageCount);
for (const msg of newMessages) {
// Nur speichern wenn Nachricht Content hat (nicht die leere Streaming-Nachricht)
if (msg.content && msg.content.trim()) {
await saveMessageToDb(msg);
}
}
}
// Token-Schätzung aktualisieren
estimatedTokens = estimateTokensForMessages(msgs);
// Warnung zeigen wenn Token-Schwelle überschritten (einmalig pro Session)
if (!compactingWarningShown && estimatedTokens > TOKEN_WARNING_THRESHOLD && !$isProcessing) {
compactingWarningShown = true;
showCompactingDialog = true;
}
lastMessageCount = msgs.length;
});
async function performCompacting() {
const sessionId = get(currentSessionId);
if (!sessionId) return;
showCompactingDialog = false;
try {
const compacted: number = await invoke('compact_session', {
sessionId,
keepLast: KEEP_LAST_MESSAGES
});
if (compacted > 0) {
addMessage('system', `📦 Compacting: ${compacted} ältere Nachrichten wurden zusammengefasst. Die letzten ${KEEP_LAST_MESSAGES} bleiben erhalten.`);
}
} catch (err) {
console.error('Compacting fehlgeschlagen:', err);
addMessage('system', `⚠️ Compacting fehlgeschlagen: ${err}`);
}
}
function dismissCompactingDialog() {
showCompactingDialog = false;
// Warnung für diese Session nicht erneut zeigen
}
onDestroy(() => {
unsubscribe();
});
// Globale Keyboard Shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl+K: Focus auf Input
if (event.ctrlKey && event.key === 'k') {
event.preventDefault();
inputTextarea?.focus();
return;
}
// Ctrl+Shift+K: Input leeren und Focus
if (event.ctrlKey && event.shiftKey && event.key === 'K') {
event.preventDefault();
$currentInput = '';
inputTextarea?.focus();
return;
}
}
onMount(() => {
window.addEventListener('keydown', handleGlobalKeydown);
return () => {
window.removeEventListener('keydown', handleGlobalKeydown);
};
});
async function sendMessage() {
const text = $currentInput.trim();
if (!text || $isProcessing) return;
// Nachricht hinzufügen (wird durch den Store-Subscriber gespeichert)
const msgId = crypto.randomUUID();
const msg: Message = {
id: msgId,
role: 'user',
content: text,
timestamp: new Date(),
};
messages.update((msgs) => [...msgs, msg]);
// Sofort speichern (nicht auf Subscriber warten)
await saveMessageToDb(msg);
$currentInput = '';
$isProcessing = true;
try {
await invoke('send_message', { message: text });
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
function handleKeydown(event: KeyboardEvent) {
// Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text)
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
sendMessage();
return;
}
// Enter (ohne Shift): Senden
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// Edit-Funktionen
function startEdit(message: Message) {
editingMessageId = message.id;
editingContent = message.content;
}
function cancelEdit() {
editingMessageId = null;
editingContent = '';
}
async function confirmEdit() {
if (!editingMessageId || !editingContent.trim()) return;
const msgIndex = $messages.findIndex((m) => m.id === editingMessageId);
if (msgIndex === -1) return;
const updatedMessage = {
...$messages[msgIndex],
content: editingContent.trim(),
timestamp: new Date()
};
// Nachricht aktualisieren und alle folgenden entfernen
// (da sie auf der alten Nachricht basieren)
messages.update((msgs) => {
const newMsgs = [...msgs.slice(0, msgIndex), updatedMessage];
return newMsgs;
});
// Nachricht in DB aktualisieren
await saveMessageToDb(updatedMessage);
// Edit-Modus beenden
const newContent = editingContent.trim();
editingMessageId = null;
editingContent = '';
// Nachricht neu an Claude senden
$isProcessing = true;
try {
await invoke('send_message', { message: newContent });
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
confirmEdit();
} else if (event.key === 'Escape') {
cancelEdit();
}
}
// Regenerate: Antwort neu generieren
async function regenerateResponse(assistantMsgIndex: number) {
if ($isProcessing) return;
// Vorherige User-Nachricht finden
let userMsg: Message | null = null;
for (let i = assistantMsgIndex - 1; i >= 0; i--) {
if ($messages[i].role === 'user') {
userMsg = $messages[i];
break;
}
}
if (!userMsg) {
console.error('Keine User-Nachricht zum Regenerieren gefunden');
return;
}
// Alle Nachrichten ab der Assistant-Nachricht entfernen
messages.update((msgs) => msgs.slice(0, assistantMsgIndex));
// User-Nachricht erneut senden
$isProcessing = true;
try {
await invoke('send_message', { message: userMsg.content });
} catch (err) {
console.error('Fehler beim Regenerieren:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
// Prüfen ob eine Nachricht die letzte Assistant-Nachricht ist
function isLastAssistantMessage(index: number): boolean {
// Finde die letzte Assistant-Nachricht
for (let i = $messages.length - 1; i >= 0; i--) {
if ($messages[i].role === 'assistant' && $messages[i].content) {
return i === index;
}
}
return false;
}
// "Das merken" - Speichern in Wissensbasis
let rememberDialogOpen = $state(false);
let rememberContent = $state('');
let rememberEntry = $state({
category: 'pattern',
title: '',
tags: '',
priority: 2,
});
let rememberSaving = $state(false);
let rememberError = $state('');
const knowledgeCategories = [
{ id: 'dolibarr', label: 'Dolibarr' },
{ id: 'fints', label: 'FinTS/Banking' },
{ id: 'pattern', label: 'Pattern/Code' },
{ id: 'projekt', label: 'Projekt' },
{ id: 'setup', label: 'Setup/Config' },
{ id: 'ids', label: 'IDs/Referenzen' },
];
function openRememberDialog(message: Message) {
rememberContent = message.content;
rememberEntry = {
category: 'pattern',
title: '',
tags: '',
priority: 2,
};
rememberError = '';
rememberDialogOpen = true;
}
function closeRememberDialog() {
rememberDialogOpen = false;
rememberContent = '';
}
async function saveToKnowledge() {
if (!rememberEntry.title.trim() || !rememberContent.trim()) {
rememberError = 'Titel und Inhalt sind erforderlich';
return;
}
rememberSaving = true;
rememberError = '';
try {
await invoke('save_knowledge', {
entry: {
category: rememberEntry.category,
title: rememberEntry.title.trim(),
content: rememberContent.trim(),
tags: rememberEntry.tags.trim() || null,
priority: rememberEntry.priority,
status: 'active',
source: 'chat',
related_ids: null,
}
});
closeRememberDialog();
} catch (err) {
rememberError = `Fehler: ${err}`;
} finally {
rememberSaving = false;
}
}
</script>
<div class="chat-panel">
<div class="chat-header">
<h2>💬 Chat</h2>
<span class="msg-count">{$messages.length} Nachrichten</span>
</div>
<div class="chat-messages" bind:this={messagesContainer} use:addCopyButtons>
{#if $messages.length === 0}
<div class="empty-state">
<div class="empty-icon">🤖</div>
<p>Starte eine Konversation mit Claude.</p>
<p class="hint">Enter/Ctrl+Enter = Senden, Shift+Enter = Neue Zeile, Ctrl+K = Focus, Escape = Stopp</p>
</div>
{:else}
{#each $messages as message, index}
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:editing={editingMessageId === message.id}>
<div class="message-header">
<span class="message-role">
{#if message.role === 'user'}
👤 Du
{:else if message.role === 'assistant'}
🤖 {#if message.model}{message.model.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}{:else}Claude{/if}
{:else}
⚙️ System
{/if}
</span>
<div class="message-actions">
{#if message.role === 'user' && !$isProcessing && editingMessageId !== message.id}
<button class="action-btn" onclick={() => startEdit(message)} title="Bearbeiten">✏️</button>
{/if}
{#if message.role === 'assistant' && !$isProcessing && isLastAssistantMessage(index) && message.content}
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
{/if}
{#if message.content && message.role !== 'system'}
<button class="action-btn" onclick={() => openRememberDialog(message)} title="Das merken">💡</button>
{/if}
<span class="message-time">
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
<div class="message-content">
{#if editingMessageId === message.id}
<textarea
class="edit-textarea"
bind:value={editingContent}
onkeydown={handleEditKeydown}
rows="3"
></textarea>
<div class="edit-actions">
<button class="edit-btn cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn confirm" onclick={confirmEdit} disabled={!editingContent.trim()}>
Speichern & Senden
</button>
</div>
{:else if message.role === 'assistant'}
{@html renderMarkdown(message.content)}
{:else}
{message.content}
{/if}
</div>
</div>
{/each}
{/if}
{#if $isProcessing}
{@const lastMsg = $messages.at(-1)}
{#if !lastMsg || lastMsg.role !== 'assistant' || lastMsg.content === ''}
<div class="message assistant typing-msg">
<div class="message-header">
<span class="message-role">🤖 Claude</span>
</div>
<div class="message-content typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
{/if}
{/if}
</div>
<div class="chat-input">
<textarea
bind:this={inputTextarea}
bind:value={$currentInput}
on:keydown={handleKeydown}
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
disabled={$isProcessing}
rows="3"
></textarea>
<button
class="send-button"
on:click={sendMessage}
disabled={!$currentInput.trim() || $isProcessing}
>
{#if $isProcessing}
{:else}
{/if}
</button>
</div>
</div>
<!-- "Das merken" Dialog -->
{#if rememberDialogOpen}
<div class="modal-backdrop" onclick={closeRememberDialog}>
<div class="modal-dialog" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>💡 Das merken</h3>
<button class="close-btn" onclick={closeRememberDialog}>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="rem-title">Titel *</label>
<input
type="text"
id="rem-title"
bind:value={rememberEntry.title}
placeholder="Kurze Beschreibung der Erkenntnis"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="rem-category">Kategorie</label>
<select id="rem-category" bind:value={rememberEntry.category}>
{#each knowledgeCategories as cat}
<option value={cat.id}>{cat.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="rem-priority">Priorität</label>
<select id="rem-priority" bind:value={rememberEntry.priority}>
<option value={1}>1 - Kritisch</option>
<option value={2}>2 - Wichtig</option>
<option value={3}>3 - Normal</option>
<option value={4}>4 - Niedrig</option>
</select>
</div>
</div>
<div class="form-group">
<label for="rem-tags">Tags (kommagetrennt)</label>
<input
type="text"
id="rem-tags"
bind:value={rememberEntry.tags}
placeholder="z.B. rust, tauri, async"
/>
</div>
<div class="form-group">
<label for="rem-content">Inhalt *</label>
<textarea
id="rem-content"
bind:value={rememberContent}
rows="8"
placeholder="Der zu merkende Inhalt..."
></textarea>
</div>
{#if rememberError}
<div class="form-error">{rememberError}</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={closeRememberDialog}>Abbrechen</button>
<button
class="btn-primary"
onclick={saveToKnowledge}
disabled={rememberSaving || !rememberEntry.title.trim()}
>
{rememberSaving ? 'Speichern...' : 'In Wissensbasis speichern'}
</button>
</div>
</div>
</div>
{/if}
<!-- Compacting-Warnung Dialog -->
{#if showCompactingDialog}
<div class="modal-backdrop" onclick={dismissCompactingDialog}>
<div class="modal-dialog compact-dialog" onclick={(e) => e.stopPropagation()}>
<div class="modal-header warning">
<h3>⚠️ Hohe Token-Anzahl</h3>
<button class="close-btn" onclick={dismissCompactingDialog}>✕</button>
</div>
<div class="modal-body">
<p class="warning-text">
Diese Konversation hat bereits <strong>~{(estimatedTokens / 1000).toFixed(0)}k Token</strong>.
</p>
<p>
Um Kosten zu sparen und das Context-Limit nicht zu überschreiten, kannst du ältere Nachrichten zusammenfassen lassen.
</p>
<div class="compact-info">
<div class="info-row">
<span>Aktuelle Nachrichten:</span>
<strong>{$messages.length}</strong>
</div>
<div class="info-row">
<span>Nach Compacting behalten:</span>
<strong>{KEEP_LAST_MESSAGES} neueste</strong>
</div>
<div class="info-row">
<span>Geschätzte Token danach:</span>
<strong>~{Math.ceil(estimatedTokens * (KEEP_LAST_MESSAGES / $messages.length) / 1000)}k</strong>
</div>
</div>
<p class="hint">
Die älteren Nachrichten werden zu einer Zusammenfassung komprimiert, bleiben aber in der Datenbank erhalten.
</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={dismissCompactingDialog}>Später</button>
<button class="btn-primary" onclick={performCompacting}>
📦 Jetzt kompaktieren
</button>
</div>
</div>
</div>
{/if}
<style>
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary);
}
.chat-header h2 {
font-size: 0.875rem;
font-weight: 600;
}
.msg-count {
font-size: 0.625rem;
color: var(--text-secondary);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
}
.empty-icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state .hint {
font-size: 0.75rem;
margin-top: var(--spacing-sm);
color: var(--text-secondary);
opacity: 0.7;
}
.message {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
background: var(--bg-secondary);
max-width: 85%;
}
.message.user {
background: var(--bg-tertiary);
margin-left: auto;
}
.message.assistant {
margin-right: auto;
}
.message.system {
background: rgba(233, 69, 96, 0.1);
border-left: 3px solid var(--accent);
max-width: 100%;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.message-role {
font-size: 0.7rem;
font-weight: 600;
}
.message-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.action-btn {
padding: 0.15rem 0.3rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.message:hover .action-btn {
opacity: 0.6;
}
.action-btn:hover {
opacity: 1 !important;
background: var(--bg-tertiary);
}
.message-time {
font-size: 0.6rem;
color: var(--text-secondary);
}
/* Edit-Modus */
.message.editing {
border: 1px solid var(--accent);
}
.edit-textarea {
width: 100%;
min-height: 60px;
padding: var(--spacing-sm);
font-size: 0.85rem;
line-height: 1.5;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
resize: vertical;
font-family: inherit;
}
.edit-textarea:focus {
outline: none;
border-color: var(--accent);
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.edit-btn {
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.edit-btn.cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.edit-btn.cancel:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.edit-btn.confirm {
background: var(--accent);
border: 1px solid var(--accent);
color: white;
}
.edit-btn.confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.edit-btn.confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.message-content {
font-size: 0.85rem;
line-height: 1.6;
}
/* Markdown-Styles innerhalb von Nachrichten */
.message-content :global(p) {
margin: 0.3em 0;
}
.message-content :global(p:first-child) {
margin-top: 0;
}
.message-content :global(p:last-child) {
margin-bottom: 0;
}
.message-content :global(code) {
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.1em 0.35em;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
/* Code-Block mit Header und Copy-Button */
.message-content :global(.code-block-wrapper) {
margin: 0.5em 0;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
overflow: hidden;
}
.message-content :global(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0.75rem;
background: rgba(0, 0, 0, 0.15);
font-size: 0.65rem;
gap: 0.5rem;
}
.message-content :global(.code-lang) {
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-content :global(.copy-btn) {
margin-left: auto;
padding: 0.2rem 0.5rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.message-content :global(.copy-btn:hover) {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.message-content :global(.copy-btn .copied) {
color: var(--success);
}
.message-content :global(.code-block-wrapper pre) {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.5;
}
.message-content :global(.code-block-wrapper pre code) {
padding: 0;
background: none;
}
/* Fallback für inline pre (ohne wrapper) */
.message-content :global(pre:not(.code-block-wrapper pre)) {
margin: 0.5em 0;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.4;
}
.message-content :global(pre:not(.code-block-wrapper pre) code) {
padding: 0;
background: none;
}
.message-content :global(ul), .message-content :global(ol) {
margin: 0.3em 0;
padding-left: 1.5em;
}
.message-content :global(li) {
margin: 0.15em 0;
}
.message-content :global(strong) {
color: var(--text-primary);
}
.message-content :global(a) {
color: var(--accent);
text-decoration: none;
}
.message-content :global(a:hover) {
text-decoration: underline;
}
.message-content :global(blockquote) {
border-left: 3px solid var(--bg-tertiary);
padding-left: var(--spacing-sm);
margin: 0.3em 0;
color: var(--text-secondary);
}
.message-content :global(h1), .message-content :global(h2), .message-content :global(h3) {
margin: 0.5em 0 0.2em;
font-size: 0.9rem;
}
.message-content :global(table) {
border-collapse: collapse;
margin: 0.3em 0;
font-size: 0.75rem;
width: 100%;
}
.message-content :global(th), .message-content :global(td) {
border: 1px solid var(--bg-tertiary);
padding: 0.3em 0.5em;
}
.message-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Typing-Animation */
.typing {
display: flex;
gap: 4px;
}
.dot {
width: 8px;
height: 8px;
background: var(--text-secondary);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Input-Bereich */
.chat-input {
display: flex;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--bg-tertiary);
}
.chat-input textarea {
flex: 1;
resize: none;
font-size: 0.85rem;
line-height: 1.4;
}
.send-button {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: white;
font-size: 1.2rem;
font-weight: 600;
border-radius: var(--radius-md);
transition: all 0.2s ease;
align-self: flex-end;
}
.send-button:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.05);
}
.send-button:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: not-allowed;
transform: none;
}
/* Modal Styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: var(--bg-primary);
border-radius: var(--radius-md);
width: 90%;
max-width: 500px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 0.9rem;
}
.close-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-size: 1rem;
}
.close-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-body {
padding: var(--spacing-md);
overflow-y: auto;
flex: 1;
}
.form-group {
margin-bottom: var(--spacing-sm);
}
.form-group label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.form-group textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.form-error {
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: var(--radius-sm);
color: var(--error);
font-size: 0.75rem;
margin-top: var(--spacing-sm);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.btn-secondary {
padding: 0.5rem 1rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
}
.btn-secondary:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-primary {
padding: 0.5rem 1rem;
font-size: 0.8rem;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: white;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Compacting Dialog */
.compact-dialog {
max-width: 420px;
}
.modal-header.warning {
background: rgba(234, 179, 8, 0.15);
border-bottom-color: rgba(234, 179, 8, 0.3);
}
.modal-header.warning h3 {
color: #eab308;
}
.warning-text {
font-size: 1rem;
margin-bottom: var(--spacing-sm);
}
.compact-info {
background: var(--bg-secondary);
border-radius: var(--radius-sm);
padding: var(--spacing-sm);
margin: var(--spacing-sm) 0;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8rem;
}
.info-row span {
color: var(--text-secondary);
}
.modal-body .hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
</style>