- 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>
570 lines
13 KiB
Svelte
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>
|