All checks were successful
Build AppImage / build (push) Successful in 7m51s
- KB-Hints werden automatisch in jeden Claude-Prompt injiziert - SQL-Queries berücksichtigen jetzt Priority (DESC) - Voice-zu-Claude-Pipeline: Sprache → Transkription → Claude → TTS - Hook-System feuert echte Events (SessionStart, Pre/PostToolUse) - Pattern-Detektion bei Tool-Fehlern aktiviert - Slash-Command Autocomplete mit CommandPalette - Updater abgesichert: Lock-Datei, Prozess-Guard, Bestätigungs-Dialog - ROADMAP.md und CHANGELOG.md aktualisiert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1754 lines
43 KiB
Svelte
1754 lines
43 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { emit } from '@tauri-apps/api/event';
|
|
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, type Message } from '$lib/stores/app';
|
|
import { marked, type Tokens } from 'marked';
|
|
import { tick, onDestroy, onMount } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import CommandPalette from './CommandPalette.svelte';
|
|
|
|
// 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
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;
|
|
|
|
// Voice-Interface State
|
|
let isRecording = $state(false);
|
|
let audioLevel = $state(0);
|
|
let liveTranscript = $state('');
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
let audioContext: AudioContext | null = null;
|
|
let analyser: AnalyserNode | null = null;
|
|
let audioChunks: Blob[] = [];
|
|
let levelAnimationFrame: number | null = null;
|
|
|
|
// Slash-Command Autocomplete State
|
|
let showCommandPalette = $state(false);
|
|
let commandQuery = $state('');
|
|
let commandPaletteRef: CommandPalette | undefined = $state(undefined);
|
|
|
|
// Slash-Command Erkennung im Input
|
|
$effect(() => {
|
|
const text = $currentInput;
|
|
if (text.startsWith('/')) {
|
|
showCommandPalette = true;
|
|
commandQuery = text.slice(1);
|
|
} else {
|
|
showCommandPalette = false;
|
|
commandQuery = '';
|
|
}
|
|
});
|
|
|
|
// Command auswählen: Text ersetzen
|
|
function handleCommandSelect(cmd: { name: string; description: string; category: string }) {
|
|
if (!cmd.name) {
|
|
// Escape gedrückt — Palette schliessen, Text behalten
|
|
showCommandPalette = false;
|
|
return;
|
|
}
|
|
$currentInput = '/' + cmd.name + ' ';
|
|
showCommandPalette = false;
|
|
// Focus zurück aufs Input
|
|
inputTextarea?.focus();
|
|
}
|
|
|
|
// VAD (Voice Activity Detection) — automatisches Stoppen nach Sprechpause
|
|
const VAD_SILENCE_THRESHOLD = 15; // Pegel unter dem als Stille gilt
|
|
const VAD_SILENCE_DURATION = 1500; // ms Stille vor Auto-Stopp
|
|
let silenceStartTime: number | null = null;
|
|
let vadEnabled = $state(true); // VAD ein/aus
|
|
|
|
async function scrollToBottom() {
|
|
await tick();
|
|
if (messagesContainer) {
|
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if ($messages.length) scrollToBottom();
|
|
});
|
|
|
|
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
|
|
$effect(() => {
|
|
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 {
|
|
// Zuerst: Kritischen Kontext extrahieren und archivieren
|
|
const currentMessages = get(messages);
|
|
const messagesJson = JSON.stringify(currentMessages.map(m => ({
|
|
role: m.role,
|
|
content: m.content
|
|
})));
|
|
|
|
try {
|
|
const extracted = await invoke('extract_context_before_compacting', {
|
|
sessionId,
|
|
messagesJson
|
|
});
|
|
console.log('📦 Kontext extrahiert vor Compacting:', extracted);
|
|
} catch (extractErr) {
|
|
console.warn('Context-Extraction fehlgeschlagen (nicht kritisch):', extractErr);
|
|
}
|
|
|
|
// Dann: Compacting durchführen
|
|
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. Kritischer Kontext wurde archiviert.`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Compacting fehlgeschlagen:', err);
|
|
addMessage('system', `⚠️ Compacting fehlgeschlagen: ${err}`);
|
|
}
|
|
}
|
|
|
|
function dismissCompactingDialog() {
|
|
showCompactingDialog = false;
|
|
// Warnung für diese Session nicht erneut zeigen
|
|
}
|
|
|
|
// ============ Voice Interface ============
|
|
|
|
async function startRecording() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
// Audio-Analyse für Pegel-Anzeige
|
|
audioContext = new AudioContext();
|
|
analyser = audioContext.createAnalyser();
|
|
const source = audioContext.createMediaStreamSource(stream);
|
|
source.connect(analyser);
|
|
analyser.fftSize = 256;
|
|
|
|
// Pegel-Animation starten
|
|
updateAudioLevel();
|
|
|
|
// MediaRecorder für Aufnahme
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
|
audioChunks = [];
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
audioChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
// Aufnahme beendet — Audio an Whisper senden
|
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
await transcribeAudio(audioBlob);
|
|
|
|
// Stream stoppen
|
|
stream.getTracks().forEach(track => track.stop());
|
|
};
|
|
|
|
mediaRecorder.start(100); // Chunks alle 100ms
|
|
isRecording = true;
|
|
liveTranscript = '';
|
|
silenceStartTime = null; // VAD-Timer zurücksetzen
|
|
console.log('🎤 Aufnahme gestartet' + (vadEnabled ? ' (VAD aktiv)' : ''));
|
|
} catch (err) {
|
|
console.error('Mikrofon-Zugriff fehlgeschlagen:', err);
|
|
addMessage('system', `⚠️ Mikrofon-Zugriff fehlgeschlagen: ${err}`);
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
mediaRecorder.stop();
|
|
}
|
|
|
|
// Pegel-Animation stoppen
|
|
if (levelAnimationFrame) {
|
|
cancelAnimationFrame(levelAnimationFrame);
|
|
levelAnimationFrame = null;
|
|
}
|
|
|
|
// Audio-Context schließen
|
|
if (audioContext) {
|
|
audioContext.close();
|
|
audioContext = null;
|
|
}
|
|
|
|
isRecording = false;
|
|
audioLevel = 0;
|
|
console.log('🎤 Aufnahme gestoppt');
|
|
}
|
|
|
|
function updateAudioLevel() {
|
|
if (!analyser || !isRecording) return;
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
analyser.getByteFrequencyData(dataArray);
|
|
|
|
// Durchschnittspegel berechnen
|
|
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
|
audioLevel = Math.min(100, average * 1.5); // Normalisieren auf 0-100
|
|
|
|
// VAD: Stille erkennen und nach Pause automatisch stoppen
|
|
if (vadEnabled && audioChunks.length > 0) {
|
|
if (audioLevel < VAD_SILENCE_THRESHOLD) {
|
|
// Stille beginnt oder dauert an
|
|
if (silenceStartTime === null) {
|
|
silenceStartTime = Date.now();
|
|
} else if (Date.now() - silenceStartTime > VAD_SILENCE_DURATION) {
|
|
// Lange genug still — Aufnahme automatisch stoppen
|
|
console.log('🔇 VAD: Stille erkannt, stoppe Aufnahme');
|
|
stopRecording();
|
|
return;
|
|
}
|
|
} else {
|
|
// Sprache erkannt — Stille-Timer zurücksetzen
|
|
silenceStartTime = null;
|
|
}
|
|
}
|
|
|
|
levelAnimationFrame = requestAnimationFrame(updateAudioLevel);
|
|
}
|
|
|
|
async function transcribeAudio(audioBlob: Blob) {
|
|
liveTranscript = 'Transkribiere...';
|
|
|
|
try {
|
|
// Audio als Base64 für Tauri-Command
|
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
|
|
|
// An Backend senden für Whisper-Transkription
|
|
const transcript: string = await invoke('transcribe_audio', {
|
|
audioBase64: base64,
|
|
format: 'webm'
|
|
});
|
|
|
|
if (transcript && transcript.trim()) {
|
|
// Transkript in Input-Feld einfügen
|
|
$currentInput = ($currentInput + ' ' + transcript).trim();
|
|
liveTranscript = '';
|
|
console.log('📝 Transkript:', transcript);
|
|
} else {
|
|
liveTranscript = '';
|
|
}
|
|
} catch (err) {
|
|
console.error('Transkription fehlgeschlagen:', err);
|
|
liveTranscript = `Fehler: ${err}`;
|
|
// Nach 3s ausblenden
|
|
setTimeout(() => { liveTranscript = ''; }, 3000);
|
|
}
|
|
}
|
|
|
|
function toggleRecording() {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
unsubscribe();
|
|
// Voice-Aufnahme stoppen falls aktiv
|
|
if (isRecording) {
|
|
stopRecording();
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt
|
|
// und ein Puffer vorhanden ist, wird er automatisch abgeschickt.
|
|
let lastProcessing = false;
|
|
const unsubProcessing = isProcessing.subscribe((val) => {
|
|
if (lastProcessing && !val) {
|
|
const queued = get(queuedMessage);
|
|
if (queued) {
|
|
$queuedMessage = null;
|
|
dispatchMessage(queued).catch((e) => console.error('Queue-Dispatch fehlgeschlagen:', e));
|
|
}
|
|
}
|
|
lastProcessing = val;
|
|
});
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleGlobalKeydown);
|
|
unsubProcessing();
|
|
};
|
|
});
|
|
|
|
function cancelQueued() {
|
|
$queuedMessage = null;
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const text = $currentInput.trim();
|
|
if (!text) return;
|
|
|
|
// Waehrend Claude antwortet: Single-Slot-Puffer statt Doppel-Send.
|
|
// Der Subscriber weiter unten sendet den Puffer automatisch ab, sobald
|
|
// isProcessing von true auf false wechselt.
|
|
if ($isProcessing) {
|
|
$queuedMessage = text;
|
|
$currentInput = '';
|
|
return;
|
|
}
|
|
|
|
await dispatchMessage(text);
|
|
}
|
|
|
|
// Den eigentlichen Send-Flow ausgelagert, damit er auch fuer die Queue genutzt wird.
|
|
async function dispatchMessage(text: string) {
|
|
// Auto-Session erstellen falls keine aktiv
|
|
let sessionId = get(currentSessionId);
|
|
if (!sessionId) {
|
|
try {
|
|
const title = `Session ${new Date().toLocaleDateString('de-DE')} ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
|
const newSession = await invoke<{ id: string }>('create_session', { title });
|
|
sessionId = newSession.id;
|
|
$currentSessionId = sessionId;
|
|
console.log('📂 Auto-Session erstellt:', title);
|
|
// SessionList benachrichtigen
|
|
await emit('session-created', { id: sessionId });
|
|
} catch (err) {
|
|
console.error('Session-Erstellung fehlgeschlagen:', err);
|
|
addMessage('system', `Fehler: Konnte keine Session erstellen - ${err}`);
|
|
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) {
|
|
// CommandPalette hat Vorrang bei Tastatur-Events
|
|
if (showCommandPalette && commandPaletteRef) {
|
|
const handled = commandPaletteRef.handleKey(event);
|
|
if (handled) return;
|
|
}
|
|
|
|
// 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' },
|
|
];
|
|
|
|
let copyFeedback = $state<string | null>(null);
|
|
|
|
async function copyMessage(message: Message) {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
copyFeedback = message.id;
|
|
setTimeout(() => {
|
|
if (copyFeedback === message.id) copyFeedback = null;
|
|
}, 1500);
|
|
} catch (err) {
|
|
console.error('Kopieren fehlgeschlagen:', err);
|
|
}
|
|
}
|
|
|
|
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>
|
|
<div class="header-stats">
|
|
<span class="msg-count">{$messages.length} Nachrichten</span>
|
|
<span class="token-count" class:warning={estimatedTokens > 20000} class:danger={estimatedTokens > TOKEN_WARNING_THRESHOLD}>
|
|
~{(estimatedTokens / 1000).toFixed(1)}k Token
|
|
</span>
|
|
</div>
|
|
</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={() => copyMessage(message)} title="Nachricht kopieren">
|
|
{copyFeedback === message.id ? '✓' : '📋'}
|
|
</button>
|
|
<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'}
|
|
{#if message.content}
|
|
{@html renderMarkdown(message.content)}
|
|
{:else if $isProcessing}
|
|
<span class="typing">
|
|
<span class="dot"></span>
|
|
<span class="dot"></span>
|
|
<span class="dot"></span>
|
|
</span>
|
|
{/if}
|
|
{:else}
|
|
{message.content}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if $isProcessing}
|
|
{@const lastMsg = $messages.at(-1)}
|
|
{#if !lastMsg || lastMsg.role !== 'assistant'}
|
|
<!-- Nur zeigen wenn noch gar keine assistant-message da ist -->
|
|
<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">
|
|
<CommandPalette
|
|
bind:this={commandPaletteRef}
|
|
query={commandQuery}
|
|
visible={showCommandPalette}
|
|
onSelect={handleCommandSelect}
|
|
/>
|
|
{#if liveTranscript}
|
|
<div class="live-transcript">
|
|
<span class="transcript-icon">🎤</span>
|
|
<span class="transcript-text">{liveTranscript}</span>
|
|
</div>
|
|
{/if}
|
|
{#if $queuedMessage}
|
|
<div class="queued-pill" title="Wird gesendet sobald Claude die aktuelle Antwort fertig hat">
|
|
<span class="queued-icon">📬</span>
|
|
<span class="queued-text">Nachricht wartet: „{$queuedMessage.slice(0, 80)}{$queuedMessage.length > 80 ? '…' : ''}"</span>
|
|
<button class="queued-cancel" onclick={cancelQueued} aria-label="Wartende Nachricht verwerfen">✕</button>
|
|
</div>
|
|
{/if}
|
|
<textarea
|
|
bind:this={inputTextarea}
|
|
bind:value={$currentInput}
|
|
onkeydown={handleKeydown}
|
|
placeholder={$isProcessing ? 'Nachricht wird nach aktueller Antwort gesendet...' : 'Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)'}
|
|
disabled={isRecording}
|
|
rows="3"
|
|
></textarea>
|
|
<div class="input-buttons">
|
|
<button
|
|
class="mic-button"
|
|
class:recording={isRecording}
|
|
onclick={toggleRecording}
|
|
disabled={$isProcessing}
|
|
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
|
|
>
|
|
{#if isRecording}
|
|
<span class="mic-icon recording">⏹</span>
|
|
<div class="audio-level" style="height: {audioLevel}%"></div>
|
|
{:else}
|
|
<span class="mic-icon">🎤</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="send-button"
|
|
onclick={sendMessage}
|
|
disabled={!$currentInput.trim() || isRecording}
|
|
>
|
|
{#if $isProcessing}
|
|
⏳
|
|
{:else}
|
|
➤
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</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;
|
|
}
|
|
|
|
.header-stats {
|
|
display: flex;
|
|
gap: var(--spacing-sm);
|
|
align-items: center;
|
|
}
|
|
|
|
.msg-count {
|
|
font-size: 0.625rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.token-count {
|
|
font-size: 0.625rem;
|
|
padding: 0.15rem 0.4rem;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg-tertiary);
|
|
color: var(--success);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.token-count.warning {
|
|
background: rgba(234, 179, 8, 0.15);
|
|
color: #eab308;
|
|
}
|
|
|
|
.token-count.danger {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: var(--error);
|
|
}
|
|
|
|
.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);
|
|
position: relative;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Voice Interface */
|
|
.input-buttons {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
.mic-button {
|
|
width: 48px;
|
|
height: 48px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
font-size: 1.25rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mic-button:hover:not(:disabled) {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.mic-button.recording {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
border-color: #ef4444;
|
|
animation: pulse-recording 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse-recording {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
|
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
|
}
|
|
|
|
.mic-icon {
|
|
z-index: 1;
|
|
}
|
|
|
|
.mic-icon.recording {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.audio-level {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: linear-gradient(to top, rgba(239, 68, 68, 0.4), rgba(239, 68, 68, 0.1));
|
|
transition: height 0.05s ease-out;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.mic-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.live-transcript {
|
|
position: absolute;
|
|
top: -32px;
|
|
left: 0;
|
|
right: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
padding: var(--spacing-xs) var(--spacing-sm);
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.75rem;
|
|
color: #ef4444;
|
|
}
|
|
|
|
.queued-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
padding: var(--spacing-xs) var(--spacing-sm);
|
|
margin-bottom: var(--spacing-xs);
|
|
background: rgba(96, 165, 250, 0.08);
|
|
border: 1px solid rgba(96, 165, 250, 0.25);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.75rem;
|
|
color: #93c5fd;
|
|
}
|
|
|
|
.queued-icon {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.queued-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.queued-cancel {
|
|
background: none;
|
|
border: none;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
padding: 0 4px;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.queued-cancel:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
.transcript-icon {
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.transcript-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 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>
|