claude-desktop/src/lib/components/ContextPanel.svelte
Eddy eb91e54ede Phase 9: Intelligentes Context-Management
- context.rs: Drei-Schichten-Gedächtnis (Sticky, Projekt, Wissens-Hints)
- StickyContext für kritische Infos (User, Credentials, Regeln)
- ProjectContext für Entscheidungen und TODOs nach Compacting
- DB-Schema: sticky_context, compacting_archive, context_failures
- ContextPanel.svelte: UI zur Verwaltung des Sticky Context
- Neuer Tab "Context" im rechten Panel

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

570 lines
13 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
// Typen
interface StickyEntry {
key: string;
value: string;
priority: number;
}
interface CredentialHint {
name: string;
host: string;
db_or_path: string | null;
inject_pattern: string | null;
}
interface ProjectInfo {
id: string;
name: string;
current_phase: string | null;
working_dir: string | null;
}
// State
let entries = $state<StickyEntry[]>([]);
let loading = $state(false);
let fullContext = $state('');
let showAddDialog = $state(false);
let showPreview = $state(false);
// Neuer Eintrag
let newEntry = $state({
type: 'rule',
key: '',
value: '',
priority: 2
});
// Vordefinierte Entry-Typen
const entryTypes = [
{ id: 'user_info', label: 'User-Info', prefix: 'user_info', icon: '👤' },
{ id: 'rule', label: 'Kritische Regel', prefix: 'rule:', icon: '⚠️' },
{ id: 'cred', label: 'Zugang (Credential)', prefix: 'cred:', icon: '🔑' },
{ id: 'project', label: 'Projekt-Info', prefix: 'project:', icon: '📁' },
];
// Prioritäts-Labels
const priorityLabels: Record<number, string> = {
1: 'Kritisch (immer zuerst)',
2: 'Hoch',
3: 'Normal',
4: 'Niedrig'
};
onMount(async () => {
await loadEntries();
});
async function loadEntries() {
loading = true;
try {
const result: [string, string, number][] = await invoke('list_sticky_context');
entries = result.map(([key, value, priority]) => ({ key, value, priority }));
// Auch den gerenderten Context laden
fullContext = await invoke('get_full_context', { sessionId: null });
} catch (err) {
console.error('Fehler beim Laden:', err);
}
loading = false;
}
async function addEntry() {
if (!newEntry.value.trim()) return;
const type = entryTypes.find(t => t.id === newEntry.type);
let key = '';
if (newEntry.type === 'user_info') {
key = 'user_info';
} else if (newEntry.type === 'cred') {
// Credential als JSON speichern
const cred: CredentialHint = {
name: newEntry.key || 'Unbenannt',
host: newEntry.value,
db_or_path: null,
inject_pattern: null
};
key = `cred:${newEntry.key || 'default'}`;
newEntry.value = JSON.stringify(cred);
} else if (newEntry.type === 'project') {
const proj: ProjectInfo = {
id: newEntry.key || 'default',
name: newEntry.value,
current_phase: null,
working_dir: null
};
key = `project:${newEntry.key || 'current'}`;
newEntry.value = JSON.stringify(proj);
} else {
key = `${type?.prefix || ''}${newEntry.key || Date.now()}`;
}
try {
await invoke('set_sticky_context', {
key,
value: newEntry.value,
priority: newEntry.priority
});
// Reset und neu laden
newEntry = { type: 'rule', key: '', value: '', priority: 2 };
showAddDialog = false;
await loadEntries();
} catch (err) {
console.error('Fehler beim Speichern:', err);
}
}
async function removeEntry(key: string) {
try {
await invoke('remove_sticky_context', { key });
await loadEntries();
} catch (err) {
console.error('Fehler beim Löschen:', err);
}
}
function getEntryIcon(key: string): string {
if (key === 'user_info') return '👤';
if (key.startsWith('rule:')) return '⚠️';
if (key.startsWith('cred:')) return '🔑';
if (key.startsWith('project:')) return '📁';
return '📌';
}
function getEntryLabel(key: string): string {
if (key === 'user_info') return 'User-Info';
if (key.startsWith('rule:')) return key.replace('rule:', '');
if (key.startsWith('cred:')) return key.replace('cred:', '');
if (key.startsWith('project:')) return key.replace('project:', '');
return key;
}
function formatValue(key: string, value: string): string {
// JSON-Werte formatieren
try {
const parsed = JSON.parse(value);
if (key.startsWith('cred:')) {
return `${parsed.host}${parsed.db_or_path ? ' / ' + parsed.db_or_path : ''}`;
}
if (key.startsWith('project:')) {
return `${parsed.name}${parsed.current_phase ? ' (' + parsed.current_phase + ')' : ''}`;
}
} catch {
// Kein JSON, Wert direkt zurückgeben
}
return value.length > 100 ? value.slice(0, 100) + '...' : value;
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
</script>
<div class="context-panel">
<div class="panel-header">
<h2>📌 Sticky Context</h2>
<div class="header-actions">
<button class="btn-preview" onclick={() => showPreview = !showPreview}>
{showPreview ? '📝 Liste' : '👁️ Vorschau'}
</button>
</div>
</div>
<div class="info-box">
<strong>Schicht 1:</strong> Diese Einträge werden bei JEDEM API-Call an Claude gesendet.
<br>
<span class="token-count">~{estimateTokens(fullContext)} Token</span>
</div>
{#if showPreview}
<!-- Vorschau des gerenderten Contexts -->
<div class="preview-box">
<pre>{fullContext || '(Kein Context konfiguriert)'}</pre>
</div>
{:else}
<!-- Eintrags-Liste -->
<div class="entries-list">
{#if loading}
<div class="loading">Lade...</div>
{:else if entries.length === 0}
<div class="empty-state">
Noch keine Einträge. Füge kritische Informationen hinzu,
die Claude immer kennen soll.
</div>
{:else}
{#each entries as entry}
<div class="entry-item">
<div class="entry-header">
<span class="entry-icon">{getEntryIcon(entry.key)}</span>
<span class="entry-key">{getEntryLabel(entry.key)}</span>
<span class="entry-priority" class:critical={entry.priority === 1}>
P{entry.priority}
</span>
<button class="btn-delete" onclick={() => removeEntry(entry.key)}>✕</button>
</div>
<div class="entry-value">{formatValue(entry.key, entry.value)}</div>
</div>
{/each}
{/if}
</div>
{/if}
<div class="actions">
<button class="btn-add" onclick={() => showAddDialog = true}>
+ Eintrag hinzufügen
</button>
<button class="btn-refresh" onclick={loadEntries} disabled={loading}>
🔄 Aktualisieren
</button>
</div>
</div>
<!-- Dialog: Neuer Eintrag -->
{#if showAddDialog}
<div class="modal-overlay" onclick={() => showAddDialog = false}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>📌 Neuer Sticky-Context-Eintrag</h3>
<button class="btn-close" onclick={() => showAddDialog = false}>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="entry-type">Typ</label>
<select id="entry-type" bind:value={newEntry.type}>
{#each entryTypes as type}
<option value={type.id}>{type.icon} {type.label}</option>
{/each}
</select>
</div>
{#if newEntry.type !== 'user_info'}
<div class="form-group">
<label for="entry-key">
{#if newEntry.type === 'rule'}Name der Regel{:else if newEntry.type === 'cred'}Name des Zugangs{:else}ID{/if}
</label>
<input
id="entry-key"
type="text"
bind:value={newEntry.key}
placeholder={newEntry.type === 'cred' ? 'z.B. dolibarr-test' : 'z.B. keine-force-push'}
/>
</div>
{/if}
<div class="form-group">
<label for="entry-value">
{#if newEntry.type === 'user_info'}User-Info
{:else if newEntry.type === 'cred'}Host (z.B. 192.168.155.11)
{:else if newEntry.type === 'project'}Projektname
{:else}Regel-Text{/if}
</label>
<textarea
id="entry-value"
bind:value={newEntry.value}
rows="3"
placeholder={newEntry.type === 'user_info' ? 'Eddy, Elektroinstallationsbetrieb ALLES WATT LÄUFT' : ''}
></textarea>
</div>
<div class="form-group">
<label for="entry-priority">Priorität</label>
<select id="entry-priority" bind:value={newEntry.priority}>
{#each Object.entries(priorityLabels) as [val, label]}
<option value={Number(val)}>{label}</option>
{/each}
</select>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick={() => showAddDialog = false}>
Abbrechen
</button>
<button
class="btn-confirm"
onclick={addEntry}
disabled={!newEntry.value.trim()}
>
💾 Speichern
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.context-panel {
padding: var(--spacing-md);
height: 100%;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
}
.panel-header h2 {
font-size: 1rem;
font-weight: 600;
}
.btn-preview {
padding: 2px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.7rem;
}
.info-box {
padding: var(--spacing-sm);
background: rgba(59, 130, 246, 0.1);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.token-count {
color: var(--accent);
font-weight: 600;
}
.entries-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.loading, .empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
font-size: 0.875rem;
}
.entry-item {
padding: var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.entry-header {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.entry-icon {
font-size: 0.9rem;
}
.entry-key {
flex: 1;
font-weight: 600;
font-size: 0.875rem;
}
.entry-priority {
font-size: 0.65rem;
padding: 1px 4px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.entry-priority.critical {
background: var(--error);
color: white;
}
.btn-delete {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--text-secondary);
opacity: 0.5;
}
.btn-delete:hover {
background: var(--error);
color: white;
opacity: 1;
}
.entry-value {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.preview-box {
flex: 1;
overflow: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--spacing-sm);
}
.preview-box pre {
font-size: 0.75rem;
white-space: pre-wrap;
font-family: var(--font-mono);
color: var(--text-primary);
margin: 0;
}
.actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
.btn-add {
flex: 1;
padding: var(--spacing-sm);
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
}
.btn-refresh {
padding: var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
max-width: 450px;
width: 90%;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
}
.btn-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--text-secondary);
}
.modal-body {
padding: var(--spacing-md);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--text-primary);
}
.form-group textarea {
resize: vertical;
font-family: var(--font-mono);
}
.form-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
margin-top: var(--spacing-md);
}
.btn-cancel {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.btn-confirm {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
}
.btn-confirm:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
</style>