Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b3f8cded2 | |||
| 16ebaa652f | |||
| 27c04a2874 | |||
| 31a1370050 | |||
| 933dd50367 | |||
| d5531521fa | |||
| de9b7b46f9 | |||
| da4e970a31 | |||
| c677cfed24 | |||
| 331c1437be | |||
| 1e754910ee | |||
| 351c58e88e | |||
| df60bb6d74 | |||
| 24cf40293a | |||
| 5f96ace469 | |||
| 9dd95709b9 | |||
| a2dee3164a | |||
| 01f0ad3a40 | |||
| 6549fcbce8 |
@@ -242,7 +242,8 @@ Danach wird der Proxy gepatcht:
|
||||
| `aria-data/ssh/` | SSH-Key fuer den Zugriff auf aria-wohnung (Brain + Proxy teilen den Key) |
|
||||
| `aria-data/brain/qdrant/` | Vector-DB-Storage (Bind-Mount, gitignored) |
|
||||
| `aria-data/brain/data/` | Skills, Embedding-Modell-Cache (Bind-Mount, gitignored) |
|
||||
| `aria-data/brain-import/` | `AGENT.md`, `USER.md.example`, `TOOLING.md.example` — Quelle fuer den initialen Memory-Import in die Vector-DB |
|
||||
| `aria-data/brain-import/` | **Drop-Folder** fuer Markdown-Saatgut. Inhalt komplett gitignored ausser `.gitkeep` + `README.md`. Stefan kippt MDs rein wenn er was migrieren will, klickt Diagnostic-„Migration aus brain-import/" — sonst leer. DB ist Truth, brain-import nur Cold-Start-Schleuse |
|
||||
| `.claude/aria-vm.env` | **Lokal pro Dev-Maschine** — wie erreicht die Workstation die VM (IP/Hostname). Gitignored, `.example` als Vorlage. Wird genutzt fuer direktes `curl` gegen die Brain-API von ausserhalb der VM |
|
||||
| `aria-data/config/diag-state/` | Diagnostic State (z.B. zuletzt aktive Session) |
|
||||
|
||||
### /shared/config/ (im aria-shared Volume)
|
||||
@@ -316,7 +317,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
### Tabs
|
||||
|
||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons (ℹ) ueberall mit Modal-Erklaerung.
|
||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||
@@ -355,6 +356,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
||||
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||
@@ -867,6 +869,8 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
||||
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
||||
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
||||
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
||||
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
||||
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
||||
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10208
|
||||
versionName "0.1.2.8"
|
||||
versionCode 10209
|
||||
versionName "0.1.2.9"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.2.8",
|
||||
"version": "0.1.2.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -87,6 +87,22 @@ interface ChatMessage {
|
||||
fires_at?: string;
|
||||
condition?: string;
|
||||
};
|
||||
/** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
|
||||
memorySaved?: {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
category?: string;
|
||||
pinned: boolean;
|
||||
preview?: string;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
path?: string; // Server-Pfad /shared/memory-attachments/<id>/<name>
|
||||
localUri?: string; // Nach file_request gefuelltes file://-URI
|
||||
}>;
|
||||
};
|
||||
/** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
|
||||
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
||||
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
||||
@@ -467,6 +483,7 @@ const ChatScreen: React.FC = () => {
|
||||
const localOnly = prev.filter(m =>
|
||||
m.skillCreated ||
|
||||
m.triggerCreated ||
|
||||
m.memorySaved ||
|
||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
||||
);
|
||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||
@@ -521,6 +538,35 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// memory_saved: ARIA hat etwas via memory_save Tool in die Qdrant-DB
|
||||
// gepackt — eigene Bubble (gelb wie trigger/skill).
|
||||
if (message.type === 'memory_saved') {
|
||||
const p = (message.payload || {}) as any;
|
||||
const atts = Array.isArray(p.attachments) ? p.attachments.map((a: any) => ({
|
||||
name: String(a?.name || 'datei'),
|
||||
mime: a?.mime ? String(a.mime) : undefined,
|
||||
size: typeof a?.size === 'number' ? a.size : undefined,
|
||||
path: a?.path ? String(a.path) : undefined,
|
||||
})) : [];
|
||||
const memoryMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'aria',
|
||||
text: '',
|
||||
timestamp: Date.now(),
|
||||
memorySaved: {
|
||||
id: p.id ? String(p.id) : undefined,
|
||||
title: String(p.title || '(ohne Titel)'),
|
||||
type: String(p.type || 'fact'),
|
||||
category: p.category ? String(p.category) : undefined,
|
||||
pinned: !!p.pinned,
|
||||
preview: p.content_preview ? String(p.content_preview) : undefined,
|
||||
attachments: atts.length ? atts : undefined,
|
||||
},
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, memoryMsg]));
|
||||
return;
|
||||
}
|
||||
|
||||
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
||||
if (message.type === 'file_deleted') {
|
||||
const p = (message.payload?.path as string) || '';
|
||||
@@ -565,16 +611,38 @@ const ChatScreen: React.FC = () => {
|
||||
if (b64 && reqId) {
|
||||
const fileName = (message.payload.name as string) || 'download';
|
||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||
setMessages(prev => prev.map(m => ({
|
||||
...m,
|
||||
attachments: m.attachments?.map(a =>
|
||||
setMessages(prev => prev.map(m => {
|
||||
// Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble)
|
||||
const updatedAtts = m.attachments?.map(a =>
|
||||
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
||||
),
|
||||
})));
|
||||
);
|
||||
// Memory-Anhang-Match (Bubble vom memory_saved-Event)
|
||||
const ms = m.memorySaved;
|
||||
let updatedMs = ms;
|
||||
if (ms && Array.isArray(ms.attachments)) {
|
||||
const hit = ms.attachments.some(a => a.path === serverPath);
|
||||
if (hit) {
|
||||
updatedMs = {
|
||||
...ms,
|
||||
attachments: ms.attachments.map(a =>
|
||||
a.path === serverPath ? { ...a, localUri: filePath } : a
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...m, attachments: updatedAtts, memorySaved: updatedMs };
|
||||
}));
|
||||
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
|
||||
// (Bilder werden separat via setFullscreenImage in der memorySaved-
|
||||
// Bubble geoeffnet, das laeuft nicht ueber autoOpenPaths)
|
||||
if (serverPath && autoOpenPaths.current.has(serverPath)) {
|
||||
autoOpenPaths.current.delete(serverPath);
|
||||
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
|
||||
const isImage = (mimeType || '').startsWith('image/');
|
||||
if (isImage) {
|
||||
setFullscreenImage(filePath);
|
||||
} else {
|
||||
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1253,6 +1321,57 @@ const ChatScreen: React.FC = () => {
|
||||
? { borderWidth: 2, borderColor: '#FFD60A' }
|
||||
: null;
|
||||
|
||||
// Spezial-Bubble: ARIA hat etwas via memory_save gespeichert
|
||||
if (item.memorySaved) {
|
||||
const m = item.memorySaved;
|
||||
const catPart = m.category ? ` · [${m.category}]` : '';
|
||||
const atts = m.attachments || [];
|
||||
return (
|
||||
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
||||
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||
{'🧠 ARIA hat etwas gemerkt'}
|
||||
</Text>
|
||||
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
|
||||
<Text style={{fontWeight: 'bold'}}>{m.title}</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`}</Text>
|
||||
</Text>
|
||||
{m.preview ? (
|
||||
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
|
||||
) : null}
|
||||
{atts.map((a, idx) => {
|
||||
const isImage = (a.mime || '').startsWith('image/');
|
||||
const icon = isImage ? '🖼️' : '📄';
|
||||
const sizeStr = a.size ? ` · ${(a.size / 1024).toFixed(0)} KB` : '';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${item.id}-att-${idx}`}
|
||||
style={styles.memoryAttachmentRow}
|
||||
onPress={() => {
|
||||
if (!a.path) return;
|
||||
if (a.localUri) {
|
||||
if (isImage) setFullscreenImage(a.localUri);
|
||||
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
|
||||
} else {
|
||||
// Datei via Bridge nachladen — file_response hat den
|
||||
// memorySaved-Match-Path und cached + zeigt direkt
|
||||
autoOpenPaths.current.add(a.path);
|
||||
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.memoryAttachmentIcon}>{icon}</Text>
|
||||
<Text style={styles.memoryAttachmentName} numberOfLines={1}>{a.name}</Text>
|
||||
<Text style={styles.memoryAttachmentMeta}>
|
||||
{a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
||||
if (item.triggerCreated) {
|
||||
const t = item.triggerCreated;
|
||||
@@ -2014,6 +2133,28 @@ const styles = StyleSheet.create({
|
||||
playButtonText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
memoryAttachmentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
marginTop: 4,
|
||||
gap: 6,
|
||||
},
|
||||
memoryAttachmentIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
memoryAttachmentName: {
|
||||
flex: 1,
|
||||
color: '#E0E0F0',
|
||||
fontSize: 12,
|
||||
},
|
||||
memoryAttachmentMeta: {
|
||||
color: '#555570',
|
||||
fontSize: 10,
|
||||
},
|
||||
bubbleTrash: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
|
||||
+286
-2
@@ -206,6 +206,126 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "memory_search",
|
||||
"description": (
|
||||
"Durchsuche aktiv dein Gedaechtnis (Qdrant-DB). Nutze das wenn:\n"
|
||||
"- der User sagt 'schau in deinem Gedaechtnis' / 'ich hab das Memory aktualisiert'\n"
|
||||
"- du dir bei einer Info aus dem Konversations-Verlauf unsicher bist "
|
||||
"(z.B. ob das noch der aktuelle Stand ist)\n"
|
||||
"- du pruefen willst ob's schon einen Memory zu einem Thema gibt bevor "
|
||||
"du via memory_save einen neuen anlegst (vermeidet Fragmentierung)\n\n"
|
||||
"**WICHTIG: Memory ist Truth ueber dem Conversation-Window.** "
|
||||
"Wenn dort was anders steht als in deinem Gespraechs-Verlauf, gilt das "
|
||||
"was im Memory steht — der User koennte gerade was korrigiert haben.\n\n"
|
||||
"Mode 'text' = Substring (case-insensitive), gut fuer exakte Begriffe "
|
||||
"wie 'cessna'. Mode 'semantic' = Embedder-Search, gut fuer 'wann hatten "
|
||||
"wir ueber X gesprochen'-Fragen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Such-Begriff"},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["text", "semantic"],
|
||||
"description": "Default 'text' (Substring). 'semantic' fuer aehnlichkeits-Suche.",
|
||||
},
|
||||
"k": {"type": "integer", "description": "Wieviele Treffer (Default 5, max 20)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "memory_update",
|
||||
"description": (
|
||||
"Aktualisiere einen existierenden Memory-Eintrag — gibt die ID aus "
|
||||
"memory_search oder dem Cold-Memory an. Nur die uebergebenen Felder werden "
|
||||
"ueberschrieben, der Rest bleibt unangetastet. **Bevorzuge das ueber "
|
||||
"memory_save** wenn der User eine Korrektur macht oder du zusaetzliche "
|
||||
"Details zum gleichen Thema hast — vermeidet doppelte Eintraege."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Memory-ID (UUID, aus memory_search oder Cold-Memory)"},
|
||||
"title": {"type": "string", "description": "Neuer Titel (optional)"},
|
||||
"content": {"type": "string", "description": "Neuer Content — wird neu embedded fuer Search (optional)"},
|
||||
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
|
||||
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "memory_save",
|
||||
"description": (
|
||||
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
|
||||
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
|
||||
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
|
||||
"Type-Wahl:\n"
|
||||
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
|
||||
"- rule: harte Regel / Sicherheit / Werte (PINNED)\n"
|
||||
"- preference: Stefans Vorlieben/Arbeitsweise (PINNED)\n"
|
||||
"- tool: Tool-Freigaben / Infrastruktur (PINNED)\n"
|
||||
"- skill: Faehigkeit / Workflow-Anleitung (PINNED)\n"
|
||||
"- fact: Wissen ueber Stefan/Welt/Sachen (Vorlieben, Besitz, Orte, "
|
||||
"Termine, Personen). Cold Memory, kommt nur via Semantic Search "
|
||||
"rein. **Default fuer 'merk-dir-das'-Anfragen.**\n"
|
||||
"- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n"
|
||||
"Wenn unsicher: type=fact, pinned=false.\n\n"
|
||||
"### Anhaenge\n"
|
||||
"`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` "
|
||||
"an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt "
|
||||
"ein Foto an, du siehst den Pfad in der User-Message).\n\n"
|
||||
"**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST "
|
||||
"an mit `Read <pfad>` (dein Read-Tool ist multi-modal — es liest Bilder "
|
||||
"wie Vision-API). Extrahiere alles Relevante in den content: sichtbare "
|
||||
"Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, "
|
||||
"auffaellige Details. Dann erst memory_save mit dem extrahierten "
|
||||
"content + attach_paths fuer das Bild. So weisst du beim spaeteren "
|
||||
"Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n"
|
||||
"Beispiel-Workflow:\n"
|
||||
"1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n"
|
||||
"2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n"
|
||||
"3. Du: `memory_save(type='fact', title='Stefans Cessna 172', "
|
||||
"content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, "
|
||||
"weiss/rot lackiert, vor Hangar fotografiert.', "
|
||||
"attach_paths=['/shared/uploads/aria_xy.jpg'])`"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"},
|
||||
"content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
||||
"description": "Memory-Typ (siehe oben)",
|
||||
},
|
||||
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
||||
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
||||
"attach_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments/<id>/ kopiert — Originale bleiben.",
|
||||
},
|
||||
},
|
||||
"required": ["title", "content", "type"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -241,6 +361,14 @@ def _skill_to_tool(s: dict) -> dict:
|
||||
|
||||
|
||||
class Agent:
|
||||
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
|
||||
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
|
||||
# Rauschen — die MiniLM-multilingual Embeddings haben fuer "irgendwas
|
||||
# vs. irgendwas anderes" gerne mal 0.10-0.20 Score selbst bei voellig
|
||||
# unverwandten Inhalten. Mit 0.30 als Untergrenze vermeiden wir
|
||||
# Cross-Talk (z.B. 'hab ich ein flugzeug' triggert die Firmenadresse).
|
||||
COLD_SCORE_THRESHOLD = 0.30
|
||||
|
||||
def __init__(self, store: VectorStore, embedder: Embedder,
|
||||
conversation: Conversation, proxy: ProxyClient,
|
||||
cold_k: int = 5):
|
||||
@@ -278,10 +406,13 @@ class Agent:
|
||||
# 2. Hot Memory (alle pinned Punkte)
|
||||
hot = self.store.list_pinned()
|
||||
|
||||
# 3. Cold Memory (Top-K semantic)
|
||||
# 3. Cold Memory (Top-K semantic) — mit Score-Threshold gegen Rauschen
|
||||
try:
|
||||
qvec = self.embedder.embed(user_message)
|
||||
cold = self.store.search(qvec, k=self.cold_k, exclude_pinned=True)
|
||||
cold = self.store.search(
|
||||
qvec, k=self.cold_k, exclude_pinned=True,
|
||||
score_threshold=self.COLD_SCORE_THRESHOLD,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
||||
cold = []
|
||||
@@ -467,6 +598,159 @@ class Agent:
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||
return "\n".join(lines)
|
||||
if name == "memory_search":
|
||||
query = (arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
return "FEHLER: query ist Pflicht."
|
||||
mode = arguments.get("mode") or "text"
|
||||
try:
|
||||
k = int(arguments.get("k", 5))
|
||||
except (TypeError, ValueError):
|
||||
k = 5
|
||||
k = max(1, min(k, 20))
|
||||
try:
|
||||
if mode == "semantic":
|
||||
qvec = self.embedder.embed(query)
|
||||
results = self.store.search(
|
||||
qvec, k=k, exclude_pinned=False, score_threshold=0.30,
|
||||
)
|
||||
else:
|
||||
results = self.store.search_text(query, k=k, exclude_pinned=False)
|
||||
if not results:
|
||||
return f"Keine Treffer fuer '{query}' (mode={mode})."
|
||||
lines = [f"{len(results)} Treffer fuer '{query}' (mode={mode}):"]
|
||||
for m in results:
|
||||
score_part = f" [score={m.score:.2f}]" if m.score is not None else ""
|
||||
pin = "📌 " if m.pinned else ""
|
||||
atts = m.attachments or []
|
||||
att_part = f" 📎{len(atts)}" if atts else ""
|
||||
lines.append("")
|
||||
lines.append(f"## {pin}{m.title} ({m.type}){score_part}{att_part}")
|
||||
lines.append(f"id: {m.id}")
|
||||
lines.append(m.content or "")
|
||||
if atts:
|
||||
for a in atts:
|
||||
lines.append(f" 📎 {a.get('name', '?')} ({a.get('mime', '')}) — {a.get('path', '')}")
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.exception("memory_search fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_update":
|
||||
pid = (arguments.get("id") or "").strip()
|
||||
if not pid:
|
||||
return "FEHLER: id ist Pflicht."
|
||||
existing = self.store.get(pid)
|
||||
if not existing:
|
||||
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
|
||||
try:
|
||||
from memory.vector_store import COLLECTION
|
||||
import datetime as _dt
|
||||
content_changed = False
|
||||
if "title" in arguments and arguments["title"] is not None:
|
||||
existing.title = str(arguments["title"]).strip()
|
||||
if "content" in arguments and arguments["content"] is not None:
|
||||
new_content = str(arguments["content"]).strip()
|
||||
if new_content != existing.content:
|
||||
content_changed = True
|
||||
existing.content = new_content
|
||||
if "category" in arguments and arguments["category"] is not None:
|
||||
existing.category = str(arguments["category"]).strip()
|
||||
if "tags" in arguments and arguments["tags"] is not None:
|
||||
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
|
||||
if "pinned" in arguments and arguments["pinned"] is not None:
|
||||
existing.pinned = bool(arguments["pinned"])
|
||||
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
if content_changed:
|
||||
vec = self.embedder.embed(existing.content)
|
||||
self.store.upsert(existing, vec)
|
||||
else:
|
||||
self.store.client.set_payload(
|
||||
collection_name=COLLECTION,
|
||||
payload=existing.to_payload() | {"updated_at": existing.updated_at},
|
||||
points=[pid],
|
||||
)
|
||||
saved = self.store.get(pid)
|
||||
self._pending_events.append({
|
||||
"type": "memory_saved",
|
||||
"memory": {
|
||||
"id": saved.id, "type": saved.type, "title": saved.title,
|
||||
"content_preview": (saved.content or "")[:140],
|
||||
"category": saved.category, "pinned": saved.pinned,
|
||||
"attachments": saved.attachments or [],
|
||||
},
|
||||
})
|
||||
return f"OK — Memory '{saved.title}' aktualisiert (id={pid[:8]})."
|
||||
except Exception as e:
|
||||
logger.exception("memory_update fehlgeschlagen")
|
||||
return f"FEHLER: {e}"
|
||||
if name == "memory_save":
|
||||
title = (arguments.get("title") or "").strip()
|
||||
content = (arguments.get("content") or "").strip()
|
||||
mem_type = (arguments.get("type") or "fact").strip()
|
||||
if not title or not content:
|
||||
return "FEHLER: title und content sind Pflicht."
|
||||
valid_types = {"identity", "rule", "preference", "tool",
|
||||
"skill", "fact", "conversation", "reminder"}
|
||||
if mem_type not in valid_types:
|
||||
return f"FEHLER: type muss einer von {sorted(valid_types)} sein."
|
||||
category = (arguments.get("category") or "").strip()
|
||||
tags_in = arguments.get("tags") or []
|
||||
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
||||
pinned = bool(arguments.get("pinned", False))
|
||||
attach_paths_in = arguments.get("attach_paths") or []
|
||||
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
|
||||
try:
|
||||
from memory import MemoryPoint
|
||||
vec = self.embedder.embed(content)
|
||||
point = MemoryPoint(
|
||||
id="", type=mem_type, title=title, content=content,
|
||||
pinned=pinned, category=category, source="aria", tags=tags,
|
||||
)
|
||||
pid = self.store.upsert(point, vec)
|
||||
# Anhaenge kopieren + Payload updaten
|
||||
attach_errors: list[str] = []
|
||||
if attach_paths:
|
||||
import memory_attachments as mem_att
|
||||
new_atts = []
|
||||
for src in attach_paths:
|
||||
try:
|
||||
meta = mem_att.attach_from_path(pid, src)
|
||||
new_atts.append(meta)
|
||||
except ValueError as e:
|
||||
attach_errors.append(f"{src}: {e}")
|
||||
if new_atts:
|
||||
from qdrant_client.http import models as qm
|
||||
from memory.vector_store import COLLECTION
|
||||
import datetime as _dt
|
||||
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
current = self.store.get(pid)
|
||||
current.attachments = (current.attachments or []) + new_atts
|
||||
current.updated_at = now
|
||||
self.store.client.set_payload(
|
||||
collection_name=COLLECTION,
|
||||
payload=current.to_payload() | {"updated_at": now},
|
||||
points=[pid],
|
||||
)
|
||||
saved = self.store.get(pid)
|
||||
self._pending_events.append({
|
||||
"type": "memory_saved",
|
||||
"memory": {
|
||||
"id": saved.id, "type": saved.type, "title": saved.title,
|
||||
"content_preview": (saved.content or "")[:140],
|
||||
"category": saved.category, "pinned": saved.pinned,
|
||||
"attachments": saved.attachments or [],
|
||||
},
|
||||
})
|
||||
n_att = len(saved.attachments or [])
|
||||
msg = (f"OK — Memory '{title}' gespeichert "
|
||||
f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}"
|
||||
+ (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").")
|
||||
if attach_errors:
|
||||
msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n - " + "\n - ".join(attach_errors)
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.exception("memory_save fehlgeschlagen")
|
||||
return f"FEHLER beim Speichern: {e}"
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
except Exception as exc:
|
||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||
|
||||
+146
-1
@@ -23,7 +23,7 @@ from typing import List, Optional
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -114,6 +114,10 @@ class MemoryIn(BaseModel):
|
||||
source: str = "manual"
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
conversation_id: Optional[str] = None
|
||||
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
||||
# nach dem Save via /memory/{id}/attachments hinzugefuegt — hier eher fuer
|
||||
# Bootstrap-Import/Restore-Faelle relevant).
|
||||
attachments: List[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MemoryUpdate(BaseModel):
|
||||
@@ -137,12 +141,19 @@ class MemoryOut(BaseModel):
|
||||
updated_at: str
|
||||
conversation_id: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
attachments: List[dict] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_point(cls, p: MemoryPoint) -> "MemoryOut":
|
||||
return cls(**p.__dict__)
|
||||
|
||||
|
||||
class AttachmentUploadBody(BaseModel):
|
||||
"""Base64-Upload via JSON — Diagnostic schickt Files so."""
|
||||
name: str
|
||||
data_base64: str
|
||||
|
||||
|
||||
# ─── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
@@ -181,6 +192,23 @@ def memory_pinned():
|
||||
return [MemoryOut.from_point(p) for p in store().list_pinned()]
|
||||
|
||||
|
||||
@app.get("/memory/search-text", response_model=List[MemoryOut])
|
||||
def memory_search_text(
|
||||
q: str,
|
||||
k: int = 50,
|
||||
type: Optional[str] = None,
|
||||
include_pinned: bool = True,
|
||||
):
|
||||
"""Volltext-Substring-Suche (case-insensitive) ueber Title + Content +
|
||||
Category + Tags. Findet exakte Begriffe — z.B. 'auto' matched 'Stefans Auto'.
|
||||
Im Gegensatz zu /memory/search (semantic) keine 'klingt aehnlich'-Treffer."""
|
||||
points = store().search_text(
|
||||
q, k=k, type_filter=type,
|
||||
exclude_pinned=not include_pinned,
|
||||
)
|
||||
return [MemoryOut.from_point(p) for p in points]
|
||||
|
||||
|
||||
@app.get("/memory/search", response_model=List[MemoryOut])
|
||||
def memory_search(
|
||||
q: str,
|
||||
@@ -214,6 +242,7 @@ def memory_save(body: MemoryIn):
|
||||
source=body.source,
|
||||
tags=body.tags,
|
||||
conversation_id=body.conversation_id,
|
||||
attachments=body.attachments or [],
|
||||
)
|
||||
pid = s.upsert(point, vec)
|
||||
saved = s.get(pid)
|
||||
@@ -262,9 +291,125 @@ def memory_delete(point_id: str):
|
||||
if not s.get(point_id):
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
s.delete(point_id)
|
||||
# Anhaenge mit-loeschen damit nichts verwaist
|
||||
try:
|
||||
import memory_attachments as mem_att
|
||||
n = mem_att.delete_all(point_id)
|
||||
if n:
|
||||
logger.info("Memory %s + %d Anhaenge geloescht", point_id, n)
|
||||
except Exception as exc:
|
||||
logger.warning("Anhang-Cleanup fuer %s fehlgeschlagen: %s", point_id, exc)
|
||||
return {"deleted": point_id}
|
||||
|
||||
|
||||
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
|
||||
|
||||
@app.get("/memory/{point_id}/attachments")
|
||||
def memory_attachments_list(point_id: str):
|
||||
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
|
||||
in der DB, aber wir mergen vorsichtshalber mit dem Filesystem-Stand
|
||||
(falls ein Upload-Restart zwischendrin schiefging)."""
|
||||
import memory_attachments as mem_att
|
||||
s = store()
|
||||
m = s.get(point_id)
|
||||
if not m:
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
return {"memory_id": point_id, "attachments": mem_att.list_attachments(point_id)}
|
||||
|
||||
|
||||
def _commit_attachment_meta(point_id: str, meta: dict) -> MemoryOut:
|
||||
"""Shared-Helper: nach FS-Write das Payload um den neuen Anhang updaten.
|
||||
Duplikat-Name wird ersetzt, sonst hinten dran."""
|
||||
s = store()
|
||||
m = s.get(point_id)
|
||||
if not m:
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
atts = [a for a in (m.attachments or []) if a.get("name") != meta["name"]]
|
||||
atts.append(meta)
|
||||
m.attachments = atts
|
||||
from memory.vector_store import COLLECTION
|
||||
import datetime as _dt
|
||||
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
s.client.set_payload(
|
||||
collection_name=COLLECTION,
|
||||
payload=m.to_payload() | {"updated_at": m.updated_at},
|
||||
points=[point_id],
|
||||
)
|
||||
return MemoryOut.from_point(s.get(point_id))
|
||||
|
||||
|
||||
@app.post("/memory/{point_id}/attachments", response_model=MemoryOut)
|
||||
def memory_attachments_add(point_id: str, body: AttachmentUploadBody):
|
||||
"""Anhang als Base64 hochladen — fuer Diagnostic + interne Tools.
|
||||
Fuer grosse Files lieber multipart-Variante (/upload) nutzen,
|
||||
Base64 sprengt schnell die Bash-ARG_MAX-Grenze beim curl."""
|
||||
import memory_attachments as mem_att
|
||||
if not store().get(point_id):
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
try:
|
||||
meta = mem_att.save_from_base64(point_id, body.name, body.data_base64)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
return _commit_attachment_meta(point_id, meta)
|
||||
|
||||
|
||||
@app.post("/memory/{point_id}/attachments/upload", response_model=MemoryOut)
|
||||
async def memory_attachments_upload(point_id: str, file: UploadFile = File(...)):
|
||||
"""Multipart-Upload — Standard fuer Browser-FormData und curl -F.
|
||||
Verwendung:
|
||||
curl -F file=@foto.jpg "$ARIA_BRAIN_URL/memory/<id>/attachments/upload"
|
||||
"""
|
||||
import memory_attachments as mem_att
|
||||
if not store().get(point_id):
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
data = await file.read()
|
||||
try:
|
||||
meta = mem_att.save_attachment(point_id, file.filename or "datei", data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
return _commit_attachment_meta(point_id, meta)
|
||||
|
||||
|
||||
@app.delete("/memory/{point_id}/attachments/{filename}", response_model=MemoryOut)
|
||||
def memory_attachments_delete(point_id: str, filename: str):
|
||||
"""Einzelnen Anhang loeschen (FS + Payload-Eintrag)."""
|
||||
import memory_attachments as mem_att
|
||||
s = store()
|
||||
m = s.get(point_id)
|
||||
if not m:
|
||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||
removed_fs = mem_att.delete_attachment(point_id, filename)
|
||||
safe = filename # Cleanup synchron mit FS — Payload-Match per name
|
||||
atts = [a for a in (m.attachments or []) if a.get("name") not in (filename, safe)]
|
||||
m.attachments = atts
|
||||
from qdrant_client.http import models as qm
|
||||
from memory.vector_store import COLLECTION
|
||||
import datetime as _dt
|
||||
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
s.client.set_payload(
|
||||
collection_name=COLLECTION,
|
||||
payload=m.to_payload() | {"updated_at": m.updated_at},
|
||||
points=[point_id],
|
||||
)
|
||||
if not removed_fs and not atts:
|
||||
# weder im FS noch im Payload war was — Anhang existierte nicht
|
||||
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
|
||||
return MemoryOut.from_point(s.get(point_id))
|
||||
|
||||
|
||||
@app.get("/memory/{point_id}/attachments/{filename}")
|
||||
def memory_attachments_get(point_id: str, filename: str):
|
||||
"""Liefert die Bytes eines Anhangs. Diagnostic-Server kann das
|
||||
durchproxien zur Vorschau/Download in der UI."""
|
||||
import memory_attachments as mem_att
|
||||
import mimetypes as _mt
|
||||
data = mem_att.read_bytes(point_id, filename)
|
||||
if data is None:
|
||||
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
|
||||
mime = _mt.guess_type(filename)[0] or "application/octet-stream"
|
||||
return Response(content=data, media_type=mime)
|
||||
|
||||
|
||||
# ─── Migration aus brain-import/ ──────────────────────────────────────
|
||||
|
||||
IMPORT_DIR = os.environ.get("IMPORT_DIR", "/import")
|
||||
|
||||
@@ -60,6 +60,11 @@ class MemoryPoint:
|
||||
updated_at: str = ""
|
||||
conversation_id: Optional[str] = None
|
||||
score: Optional[float] = None # nur bei Search gesetzt
|
||||
# Anhaenge: Liste von Dicts {name, mime, size, path} — Dateien liegen
|
||||
# physisch unter /shared/memory-attachments/<memory-id>/<name>.
|
||||
# Hier in der DB nur die Metadaten, damit die Suche/Anzeige sie kennt
|
||||
# ohne Filesystem zu pruefen.
|
||||
attachments: List[dict] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict:
|
||||
p = {
|
||||
@@ -72,6 +77,7 @@ class MemoryPoint:
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"attachments": self.attachments,
|
||||
}
|
||||
if self.conversation_id:
|
||||
p["conversation_id"] = self.conversation_id
|
||||
@@ -92,6 +98,7 @@ class MemoryPoint:
|
||||
created_at=payload.get("created_at", ""),
|
||||
updated_at=payload.get("updated_at", ""),
|
||||
conversation_id=payload.get("conversation_id"),
|
||||
attachments=payload.get("attachments", []) or [],
|
||||
score=getattr(point, "score", None),
|
||||
)
|
||||
|
||||
@@ -213,3 +220,56 @@ class VectorStore:
|
||||
|
||||
def count(self) -> int:
|
||||
return self.client.count(collection_name=COLLECTION, exact=True).count
|
||||
|
||||
def search_text(
|
||||
self,
|
||||
query: str,
|
||||
k: int = 20,
|
||||
type_filter: Optional[str] = None,
|
||||
exclude_pinned: bool = False,
|
||||
) -> List[MemoryPoint]:
|
||||
"""Volltext-Substring-Suche (case-insensitive) ueber Title +
|
||||
Content + Category + Tags. Im Gegensatz zu search() ist das KEIN
|
||||
Semantic-Match — nur exakte Wort-/Teilwort-Treffer.
|
||||
|
||||
Full-Scan ueber alle (gefilteren) Punkte. Bei der erwarteten
|
||||
Groessenordnung (< 1000) unkritisch."""
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return []
|
||||
must = []
|
||||
must_not = []
|
||||
if type_filter:
|
||||
must.append(qm.FieldCondition(key="type", match=qm.MatchValue(value=type_filter)))
|
||||
if exclude_pinned:
|
||||
must_not.append(qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)))
|
||||
flt = qm.Filter(must=must or None, must_not=must_not or None) if (must or must_not) else None
|
||||
|
||||
matches: List[MemoryPoint] = []
|
||||
offset = None
|
||||
while True:
|
||||
points, offset = self.client.scroll(
|
||||
collection_name=COLLECTION,
|
||||
scroll_filter=flt,
|
||||
limit=200,
|
||||
offset=offset,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
for p in points:
|
||||
payload = p.payload or {}
|
||||
tags = payload.get("tags")
|
||||
tags_str = " ".join(tags) if isinstance(tags, list) else ""
|
||||
haystack = " ".join([
|
||||
str(payload.get("title", "")),
|
||||
str(payload.get("content", "")),
|
||||
str(payload.get("category", "")),
|
||||
tags_str,
|
||||
]).lower()
|
||||
if q in haystack:
|
||||
matches.append(MemoryPoint.from_qdrant(p))
|
||||
if len(matches) >= k:
|
||||
return matches
|
||||
if not offset:
|
||||
break
|
||||
return matches
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Anhaenge fuer Memory-Eintraege.
|
||||
|
||||
Storage-Layout:
|
||||
/shared/memory-attachments/<memory-id>/<original-name>
|
||||
|
||||
Eine flache Ordnerstruktur pro Memory — bei Memory-Delete loescht main.py
|
||||
das ganze Verzeichnis. Anhang-Metadaten (name, mime, size, path) liegen
|
||||
zusaetzlich im Qdrant-Payload des Memory-Punkts damit die Listen/Suche
|
||||
sie ohne Filesystem-Lookup zeigen kann.
|
||||
|
||||
Anhaenge sind erstmal nur ueber die Diagnostic-UI hochladbar — ARIA
|
||||
selbst hat in Stufe A kein Tool zum Upload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
|
||||
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
|
||||
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Macht aus einem User-Namen einen filesystem-sicheren String —
|
||||
zerlegt Pfadteile, schneidet Sonderzeichen weg, kuerzt auf 120 Zeichen."""
|
||||
base = Path(name).name or "datei"
|
||||
base = SAFE_NAME_RE.sub("_", base).strip("._-") or "datei"
|
||||
return base[:120]
|
||||
|
||||
|
||||
def memory_dir(memory_id: str) -> Path:
|
||||
return ROOT / memory_id
|
||||
|
||||
|
||||
def list_attachments(memory_id: str) -> List[dict]:
|
||||
"""Liest die Anhaenge fuer eine Memory aus dem Filesystem.
|
||||
Returns [{name, mime, size, path}, ...] — leer wenn nichts da.
|
||||
Source of Truth ist Qdrant-Payload; diese Funktion ist nur fuer
|
||||
Diagnostic-Endpoints wenn Stefan direkt das FS prueft."""
|
||||
d = memory_dir(memory_id)
|
||||
if not d.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for f in sorted(d.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
out.append(_file_meta(memory_id, f))
|
||||
return out
|
||||
|
||||
|
||||
def _file_meta(memory_id: str, f: Path) -> dict:
|
||||
try:
|
||||
size = f.stat().st_size
|
||||
except Exception:
|
||||
size = 0
|
||||
mime = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||
return {
|
||||
"name": f.name,
|
||||
"mime": mime,
|
||||
"size": size,
|
||||
"path": str(f), # absoluter Pfad im Container
|
||||
}
|
||||
|
||||
|
||||
def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
|
||||
"""Schreibt einen Anhang ins FS und gibt seine Metadaten zurueck.
|
||||
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
|
||||
if not memory_id:
|
||||
raise ValueError("memory_id ist Pflicht")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
|
||||
safe = _safe_filename(filename)
|
||||
d = memory_dir(memory_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
target = d / safe
|
||||
target.write_bytes(data)
|
||||
logger.info("[mem-att] %s -> %s (%d Byte)", memory_id, safe, len(data))
|
||||
return _file_meta(memory_id, target)
|
||||
|
||||
|
||||
def save_from_base64(memory_id: str, filename: str, b64: str) -> dict:
|
||||
"""Convenience fuer Base64-Uploads (Diagnostic schickt Files so)."""
|
||||
try:
|
||||
data = base64.b64decode(b64, validate=False)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Base64-Decode fehlgeschlagen: {exc}") from exc
|
||||
return save_attachment(memory_id, filename, data)
|
||||
|
||||
|
||||
def delete_attachment(memory_id: str, filename: str) -> bool:
|
||||
"""Loescht eine einzelne Anhang-Datei. Returns True wenn was weg ist."""
|
||||
safe = _safe_filename(filename)
|
||||
target = memory_dir(memory_id) / safe
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
logger.info("[mem-att] %s/%s geloescht", memory_id, safe)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("[mem-att] Loeschen fehlgeschlagen: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def delete_all(memory_id: str) -> int:
|
||||
"""Loescht das komplette Memory-Verzeichnis. Wird beim Memory-Delete
|
||||
in main.py gerufen damit nichts verwaist."""
|
||||
d = memory_dir(memory_id)
|
||||
if not d.is_dir():
|
||||
return 0
|
||||
count = sum(1 for _ in d.iterdir() if _.is_file())
|
||||
try:
|
||||
shutil.rmtree(d)
|
||||
logger.info("[mem-att] %s komplett entfernt (%d Files)", memory_id, count)
|
||||
except Exception as exc:
|
||||
logger.warning("[mem-att] rmtree fehlgeschlagen: %s", exc)
|
||||
return count
|
||||
|
||||
|
||||
def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
|
||||
"""Liefert die rohen Bytes einer Datei zurueck — fuer Download/Serve."""
|
||||
safe = _safe_filename(filename)
|
||||
target = memory_dir(memory_id) / safe
|
||||
if not target.is_file():
|
||||
return None
|
||||
return target.read_bytes()
|
||||
|
||||
|
||||
# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path —
|
||||
# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files
|
||||
# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf
|
||||
# /root, /etc, /tmp, ssh-Keys, etc.
|
||||
ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/")
|
||||
|
||||
|
||||
def attach_from_path(memory_id: str, source_path: str) -> dict:
|
||||
"""Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis
|
||||
des Memories und gibt die neue Metadaten zurueck.
|
||||
|
||||
Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_<id>.jpg`.
|
||||
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
|
||||
sie es via `memory_save(..., attach_paths=[<src>])` ins Memory-Verzeichnis.
|
||||
|
||||
Pfadschutz: source_path MUSS unter /shared/ liegen — kein Zugriff auf
|
||||
Root-FS, SSH-Keys etc.
|
||||
"""
|
||||
if not memory_id:
|
||||
raise ValueError("memory_id ist Pflicht")
|
||||
if not source_path or not isinstance(source_path, str):
|
||||
raise ValueError("source_path leer")
|
||||
if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
|
||||
raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen")
|
||||
src = Path(source_path)
|
||||
if not src.is_file():
|
||||
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
||||
size = src.stat().st_size
|
||||
if size > MAX_BYTES:
|
||||
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
|
||||
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
||||
data = src.read_bytes()
|
||||
return save_attachment(memory_id, src.name, data)
|
||||
@@ -52,6 +52,44 @@ TYPE_HEADINGS = {
|
||||
}
|
||||
|
||||
|
||||
def _attachments_line(p: MemoryPoint) -> str:
|
||||
"""Eine Zeile die ARIA verraet welche Dateien an einer Memory haengen.
|
||||
Bilder/Files liegen physisch unter /shared/memory-attachments/<id>/<name>.
|
||||
|
||||
Multi-Modal-Hinweis: Claude Code's `Read`-Tool kann Bilder direkt
|
||||
anschauen (PNG/JPG/GIF/WebP) — sie laufen dann durch das gleiche
|
||||
Vision-Modell wie via Anthropic-Vision-API. Heisst: ARIA muss nur
|
||||
`Read /shared/memory-attachments/<id>/foto.jpg` aufrufen und sieht
|
||||
das Bild wirklich, ohne dass wir Multi-Modal-Messages durch den
|
||||
Proxy schleusen muessen. Wir geben ihr den Hinweis in der Zeile mit.
|
||||
"""
|
||||
atts = getattr(p, "attachments", None) or []
|
||||
if not atts:
|
||||
return ""
|
||||
base_dir = f"/shared/memory-attachments/{p.id}/" if p.id else ""
|
||||
items = []
|
||||
has_image = False
|
||||
for a in atts:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
name = a.get("name", "?")
|
||||
mime = a.get("mime", "")
|
||||
if mime.startswith("image/"):
|
||||
has_image = True
|
||||
size = a.get("size")
|
||||
size_part = f", {size // 1024} KB" if isinstance(size, int) and size else ""
|
||||
items.append(f"{name} ({mime}{size_part})")
|
||||
if not items:
|
||||
return ""
|
||||
line = f"📎 Anhaenge: {', '.join(items)}"
|
||||
if base_dir:
|
||||
line += f" — Pfad: {base_dir}"
|
||||
if has_image and base_dir:
|
||||
line += (" — Bilder kannst du via `Read <pfad>` direkt ansehen "
|
||||
"(Claude Code Read ist multi-modal-faehig)")
|
||||
return line
|
||||
|
||||
|
||||
def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
|
||||
grouped: dict[str, List[MemoryPoint]] = {}
|
||||
@@ -69,6 +107,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||
for p in items:
|
||||
parts.append(f"### {p.title}")
|
||||
parts.append(p.content.strip())
|
||||
att_line = _attachments_line(p)
|
||||
if att_line:
|
||||
parts.append(att_line)
|
||||
parts.append("")
|
||||
|
||||
# uebrige Types (falls jemand was anderes als pinned markiert)
|
||||
@@ -77,6 +118,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||
for p in items:
|
||||
parts.append(f"### {p.title}")
|
||||
parts.append(p.content.strip())
|
||||
att_line = _attachments_line(p)
|
||||
if att_line:
|
||||
parts.append(att_line)
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
@@ -91,6 +135,9 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
||||
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
||||
lines.append(f"- **{p.title}**{score}")
|
||||
lines.append(f" {p.content.strip()}")
|
||||
att_line = _attachments_line(p)
|
||||
if att_line:
|
||||
lines.append(f" {att_line}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -1376,6 +1376,17 @@ class ARIABridge:
|
||||
})
|
||||
logger.info("[brain] location_tracking Request: on=%s (%s)",
|
||||
event.get("on"), event.get("reason", ""))
|
||||
elif etype == "memory_saved":
|
||||
# ARIA hat selber etwas in die Vector-DB gespeichert.
|
||||
# Eigene Bubble in App + Diagnostic (gelb wie skill/trigger).
|
||||
await self._send_to_rvs({
|
||||
"type": "memory_saved",
|
||||
"payload": event.get("memory", {}),
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
|
||||
event.get("memory", {}).get("title"),
|
||||
event.get("memory", {}).get("type"))
|
||||
|
||||
# _process_core_response uebernimmt alles weitere:
|
||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||
@@ -2635,6 +2646,12 @@ class ARIABridge:
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
elif etype == "memory_saved":
|
||||
await self._send_to_rvs({
|
||||
"type": "memory_saved",
|
||||
"payload": event.get("memory", {}),
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
|
||||
|
||||
|
||||
+412
-18
@@ -824,11 +824,17 @@
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:8px;">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="brain-search" placeholder="Semantische Suche (z.B. 'Stefan Persönlichkeit')..."
|
||||
<input type="text" id="brain-search" placeholder="Suche (z.B. 'cessna' oder 'Stefan Persönlichkeit')..."
|
||||
style="flex:1;min-width:200px;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"
|
||||
onkeydown="if(event.key==='Enter') runBrainSearch()">
|
||||
<select id="brain-search-mode" onchange="if(document.getElementById('brain-search').value.trim()) runBrainSearch()"
|
||||
title="Wortlich = exakter Substring-Match. Semantisch = 'klingt aehnlich' via Embeddings."
|
||||
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||
<option value="text" selected>📝 Wortlich</option>
|
||||
<option value="semantic">🧠 Semantisch</option>
|
||||
</select>
|
||||
<button class="btn secondary" onclick="runBrainSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
|
||||
<select id="brain-filter-type" onchange="loadBrainMemoryList()"
|
||||
<select id="brain-filter-type" onchange="onBrainFiltersChanged()"
|
||||
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="identity">Identität</option>
|
||||
@@ -840,14 +846,29 @@
|
||||
<option value="conversation">Konversation</option>
|
||||
<option value="reminder">Reminder</option>
|
||||
</select>
|
||||
<select id="brain-filter-pinned" onchange="loadBrainMemoryList()"
|
||||
<select id="brain-filter-pinned" onchange="onBrainFiltersChanged()"
|
||||
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||
<option value="all">Pinned + Cold</option>
|
||||
<option value="pinned">📌 Nur Pinned</option>
|
||||
<option value="cold">Nur Cold</option>
|
||||
</select>
|
||||
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
|
||||
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
|
||||
</div>
|
||||
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
|
||||
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
|
||||
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
|
||||
</div>
|
||||
<div id="adv-rows-container" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<!-- Reihen werden dynamisch via JS gerendert (renderAdvancedRows) -->
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="btn" onclick="runAdvancedSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
|
||||
<button class="btn secondary" onclick="addAdvancedRow()" style="padding:4px 10px;font-size:11px;" title="Weiteres Suchfeld hinzufuegen">+ Feld</button>
|
||||
<button class="btn secondary" onclick="clearAdvancedSearch()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Alle leeren</button>
|
||||
<span style="color:#555570;font-size:10px;margin-left:auto;">Leere Felder werden ignoriert · Min. 1 Feld · ✕ entfernt ein Feld</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="brain-search-info" style="margin-top:6px;font-size:10px;color:#8888AA;display:none;"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -1022,6 +1043,26 @@
|
||||
<input type="checkbox" id="memory-pinned">
|
||||
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
||||
</label>
|
||||
|
||||
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
|
||||
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
|
||||
<span>📎 Anhaenge</span>
|
||||
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span>
|
||||
</label>
|
||||
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<label class="btn secondary" style="padding:4px 10px;font-size:11px;cursor:pointer;margin:0;">
|
||||
⬆ Datei waehlen
|
||||
<input type="file" id="memory-attachment-input" multiple style="display:none;" onchange="uploadMemoryAttachments(this.files)">
|
||||
</label>
|
||||
<span id="memory-attachment-status" style="font-size:11px;color:#555570;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="memory-attachments-hint" style="display:none;margin-top:10px;padding:6px 8px;background:#0D0D1A;border-radius:4px;color:#555570;font-size:11px;">
|
||||
📎 Anhaenge kannst du nach dem Speichern hinzufuegen (brauchen eine Memory-ID).
|
||||
</div>
|
||||
|
||||
<div id="memory-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:10px;display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
||||
@@ -1362,6 +1403,14 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'memory_saved') {
|
||||
addMemorySavedBubble(msg.payload || {});
|
||||
// Falls Gehirn-Tab offen: refreshen
|
||||
if (document.getElementById('tab-brain') && document.getElementById('tab-brain').classList.contains('visible')) {
|
||||
loadBrainMemoryList();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'chat_delta') { return; }
|
||||
if (msg.type === 'chat_error') {
|
||||
addChat('error', msg.error, 'chat:error');
|
||||
@@ -1955,6 +2004,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** ARIA hat eine Memory in die Qdrant-DB gespeichert — als Bubble anzeigen. */
|
||||
function addMemorySavedBubble(memory) {
|
||||
const title = memory.title || '(ohne Titel)';
|
||||
const type = memory.type || 'fact';
|
||||
const cat = memory.category || '';
|
||||
const pinned = !!memory.pinned;
|
||||
const preview = memory.content_preview || '';
|
||||
const typeLabel = (typeof BRAIN_TYPE_LABELS !== 'undefined' && BRAIN_TYPE_LABELS[type]) || type;
|
||||
const pinBadge = pinned ? '<span style="color:#FFD60A;font-size:11px;margin-left:6px;">📌 pinned</span>' : '';
|
||||
const catBadge = cat ? ` <span style="color:#555570;font-size:10px;">[${escapeHtml(cat)}]</span>` : '';
|
||||
const html = `
|
||||
<div style="font-weight:bold;color:#FFD60A;">🧠 ARIA hat etwas gemerkt</div>
|
||||
<div style="margin-top:4px;color:#E0E0F0;">
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(typeLabel)})</span>
|
||||
${pinBadge}${catBadge}
|
||||
</div>
|
||||
${preview ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(preview)}${preview.length >= 140 ? '…' : ''}</div>` : ''}
|
||||
<div class="meta">
|
||||
ARIA-Memory — ${new Date().toLocaleTimeString('de-DE')} ·
|
||||
<a href="#" onclick="event.preventDefault();switchMainTab('brain');" style="color:#FFD60A;">im Gehirn-Tab ansehen</a>
|
||||
</div>`;
|
||||
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||
if (!box) continue;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-msg received';
|
||||
el.style.borderLeft = '3px solid #FFD60A';
|
||||
el.innerHTML = html;
|
||||
box.appendChild(el);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wenn der Server file_deleted broadcastet: alle Bubbles mit
|
||||
diesem serverPath rerendern als "geloescht" markieren. */
|
||||
function markFileDeletedInChat(serverPath) {
|
||||
@@ -3445,6 +3527,192 @@
|
||||
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
|
||||
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
|
||||
brainSearchIds = null;
|
||||
clearAdvancedSearch();
|
||||
}
|
||||
|
||||
function toggleAdvancedSearch() {
|
||||
const panel = document.getElementById('brain-advanced-panel');
|
||||
const btn = document.getElementById('btn-advanced-search');
|
||||
if (!panel) return;
|
||||
const open = panel.style.display !== 'none';
|
||||
panel.style.display = open ? 'none' : 'block';
|
||||
if (btn) btn.textContent = open ? '⌃ Erweitert' : '⌄ Einklappen';
|
||||
if (!open) ensureAdvancedRows();
|
||||
}
|
||||
|
||||
// Dynamische Such-Reihen-Struktur:
|
||||
// advRows = [{term, op}, ...] — die erste Reihe hat op=null,
|
||||
// jede weitere bekommt einen UND/ODER-Selektor links und einen ✕ rechts.
|
||||
let advRows = [{ term: '', op: null }];
|
||||
|
||||
function ensureAdvancedRows() {
|
||||
if (!advRows.length) advRows = [{ term: '', op: null }];
|
||||
renderAdvancedRows();
|
||||
}
|
||||
|
||||
function addAdvancedRow() {
|
||||
// Vor dem Re-render aktuelle Werte aus DOM uebernehmen damit nichts verloren geht
|
||||
syncAdvancedRowsFromDOM();
|
||||
advRows.push({ term: '', op: 'AND' });
|
||||
renderAdvancedRows();
|
||||
// Fokus auf das neue Feld
|
||||
const last = document.querySelector(`#adv-rows-container .adv-row:last-child input.adv-term`);
|
||||
if (last) last.focus();
|
||||
}
|
||||
|
||||
function removeAdvancedRow(idx) {
|
||||
syncAdvancedRowsFromDOM();
|
||||
if (advRows.length <= 1) return; // erste bleibt
|
||||
advRows.splice(idx, 1);
|
||||
// Erste Reihe hat immer op=null
|
||||
if (advRows[0]) advRows[0].op = null;
|
||||
renderAdvancedRows();
|
||||
}
|
||||
|
||||
function syncAdvancedRowsFromDOM() {
|
||||
const rows = document.querySelectorAll('#adv-rows-container .adv-row');
|
||||
const next = [];
|
||||
rows.forEach((row, i) => {
|
||||
const term = (row.querySelector('input.adv-term')?.value || '');
|
||||
const op = i === 0 ? null : (row.querySelector('select.adv-op')?.value || 'AND');
|
||||
next.push({ term, op });
|
||||
});
|
||||
if (next.length) advRows = next;
|
||||
}
|
||||
|
||||
function renderAdvancedRows() {
|
||||
const container = document.getElementById('adv-rows-container');
|
||||
if (!container) return;
|
||||
const inputStyle = 'flex:1;min-width:0;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:12px;';
|
||||
const selectStyle = 'background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;width:70px;';
|
||||
container.innerHTML = advRows.map((r, i) => {
|
||||
const ph = i === 0 ? 'z.B. flugzeug' : 'z.B. cessna';
|
||||
const term = (r.term || '').replace(/"/g, '"');
|
||||
if (i === 0) {
|
||||
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
|
||||
<span style="width:70px;color:#555570;font-size:11px;text-align:center;">Start</span>
|
||||
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
|
||||
<span style="width:24px;"></span>
|
||||
</div>`;
|
||||
}
|
||||
const op = r.op || 'AND';
|
||||
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
|
||||
<select class="adv-op" style="${selectStyle}">
|
||||
<option value="AND"${op === 'AND' ? ' selected' : ''}>UND</option>
|
||||
<option value="OR"${op === 'OR' ? ' selected' : ''}>ODER</option>
|
||||
</select>
|
||||
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
|
||||
<button class="btn secondary" onclick="removeAdvancedRow(${i})" title="Diese Zeile entfernen" style="width:24px;height:24px;padding:0;line-height:20px;font-size:11px;color:#FF6B6B;">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function clearAdvancedSearch() {
|
||||
advRows = [{ term: '', op: null }];
|
||||
renderAdvancedRows();
|
||||
}
|
||||
|
||||
/** Mehrere Volltext-Suchen + Boolean-Kombination (links nach rechts).
|
||||
* Backend bleibt simpel — wir machen N parallele search-text-Calls
|
||||
* und kombinieren die ID-Mengen client-seitig per AND/OR. */
|
||||
async function runAdvancedSearch() {
|
||||
syncAdvancedRowsFromDOM();
|
||||
const info = document.getElementById('brain-search-info');
|
||||
// Nur Reihen mit Inhalt einsammeln. Die erste belegte Reihe wird zum
|
||||
// Start-Term (op=null), egal an welchem Index sie ursprünglich war.
|
||||
const active = [];
|
||||
for (const r of advRows) {
|
||||
const t = (r.term || '').trim();
|
||||
if (!t) continue;
|
||||
active.push({ term: t, op: active.length === 0 ? null : (r.op || 'AND') });
|
||||
}
|
||||
if (active.length === 0) {
|
||||
if (info) info.style.display = 'none';
|
||||
loadBrainMemoryList();
|
||||
return;
|
||||
}
|
||||
|
||||
const typeFilter = document.getElementById('brain-filter-type').value;
|
||||
const baseParams = { k: '500', include_pinned: 'true' };
|
||||
if (typeFilter) baseParams.type = typeFilter;
|
||||
|
||||
try {
|
||||
// Pro Begriff einmal Backend fragen, dann Map<id, memory> + Set<id>
|
||||
const sets = [];
|
||||
for (const a of active) {
|
||||
const params = new URLSearchParams({ ...baseParams, q: a.term });
|
||||
const r = await fetch('/api/brain/memory/search-text?' + params.toString());
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const hits = await r.json();
|
||||
hits.forEach(m => { brainMemoryCache[m.id] = m; });
|
||||
sets.push(new Set(hits.map(m => m.id)));
|
||||
}
|
||||
|
||||
// Links-nach-rechts kombinieren mit den Operatoren
|
||||
let combined = sets[0];
|
||||
for (let i = 1; i < sets.length; i++) {
|
||||
const op = active[i].op;
|
||||
if (op === 'AND') {
|
||||
combined = new Set([...combined].filter(id => sets[i].has(id)));
|
||||
} else {
|
||||
combined = new Set([...combined, ...sets[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
let hits = Array.from(combined).map(id => brainMemoryCache[id]).filter(Boolean);
|
||||
const totalHits = hits.length;
|
||||
hits = applyPinnedFilter(hits);
|
||||
brainSearchIds = hits.map(m => m.id);
|
||||
const desc = active.map((a, i) => i === 0 ? `"${a.term}"` : ` ${a.op} "${a.term}"`).join('');
|
||||
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
|
||||
: pinnedFilter === 'cold' ? ' · nur cold'
|
||||
: '';
|
||||
if (info) {
|
||||
info.style.display = 'block';
|
||||
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
|
||||
if (hits.length === 0) {
|
||||
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
|
||||
info.innerHTML = `🔍 Keine Treffer fuer ${escapeHtml(desc)}${filterDesc}${extra} · 📝 wortlich, Boolean-Kombi`;
|
||||
} else {
|
||||
info.innerHTML = `🔍 ${hits.length} Treffer fuer ${escapeHtml(desc)}${filterDesc} · 📝 wortlich, Boolean-Kombi`;
|
||||
}
|
||||
}
|
||||
renderBrainList(hits, true);
|
||||
} catch (e) {
|
||||
if (info) {
|
||||
info.style.display = 'block';
|
||||
info.innerHTML = `🔴 Erweiterte Suche fehlgeschlagen: ${escapeHtml(e.message)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True wenn aktuell eine Search-Ansicht aktiv ist (Single oder Advanced).
|
||||
* Wird vom Pinned/Type-Filter-onchange genutzt um statt loadBrainMemoryList
|
||||
* die Suche neu auszufuehren — damit Filter auch bei aktiver Suche greifen. */
|
||||
function brainSearchActive() {
|
||||
const q = (document.getElementById('brain-search')?.value || '').trim();
|
||||
if (q) return 'single';
|
||||
const hasAdv = (advRows || []).some(r => (r.term || '').trim());
|
||||
return hasAdv ? 'advanced' : null;
|
||||
}
|
||||
|
||||
/** Wird vom Type+Pinned-Dropdown onchange gerufen. Bei aktiver Suche
|
||||
* re-search ausfuehren, sonst Liste neu laden. */
|
||||
function onBrainFiltersChanged() {
|
||||
const which = brainSearchActive();
|
||||
if (which === 'single') runBrainSearch();
|
||||
else if (which === 'advanced') runAdvancedSearch();
|
||||
else loadBrainMemoryList();
|
||||
}
|
||||
|
||||
/** Filtert eine Liste von Memories nach dem pinned-Dropdown-Wert.
|
||||
* 'all' = alles durchlassen, 'pinned' = nur pinned, 'cold' = nur cold. */
|
||||
function applyPinnedFilter(items) {
|
||||
const v = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||
if (v === 'pinned') return items.filter(m => m.pinned);
|
||||
if (v === 'cold') return items.filter(m => !m.pinned);
|
||||
return items;
|
||||
}
|
||||
|
||||
async function runBrainSearch() {
|
||||
@@ -3457,27 +3725,43 @@
|
||||
return;
|
||||
}
|
||||
const typeFilter = document.getElementById('brain-filter-type').value;
|
||||
// k=10 + Score-Threshold im Backend (0.30) → nur relevante Treffer.
|
||||
// Frueher k=20 ohne Threshold: bei kleiner DB landete fast alles
|
||||
// als "Treffer", egal wie unaehnlich.
|
||||
const params = new URLSearchParams({ q, k: '10', include_pinned: 'true', score_threshold: '0.30' });
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||
const mode = (document.getElementById('brain-search-mode')?.value) || 'text';
|
||||
let url, modeLabel;
|
||||
if (mode === 'semantic') {
|
||||
// Embedder-basiert, mit Score-Threshold gegen Rauschen
|
||||
const params = new URLSearchParams({ q, k: '20', include_pinned: 'true', score_threshold: '0.30' });
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
url = '/api/brain/memory/search?' + params.toString();
|
||||
modeLabel = '🧠 semantisch (Score ≥ 0.30)';
|
||||
} else {
|
||||
// Volltext-Substring (case-insensitive) — findet exakte Begriffe
|
||||
const params = new URLSearchParams({ q, k: '100', include_pinned: 'true' });
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
url = '/api/brain/memory/search-text?' + params.toString();
|
||||
modeLabel = '📝 wortlich (Substring)';
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/brain/memory/search?' + params.toString());
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const hits = await r.json();
|
||||
let hits = await r.json();
|
||||
hits.forEach(m => { brainMemoryCache[m.id] = m; });
|
||||
// Pinned-Filter clientseitig anwenden — Backend kennt nur include_pinned
|
||||
// (all-or-none), wir brauchen aber feiner "nur pinned" / "nur cold".
|
||||
const totalHits = hits.length;
|
||||
hits = applyPinnedFilter(hits);
|
||||
brainSearchIds = hits.map(m => m.id);
|
||||
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
|
||||
: pinnedFilter === 'cold' ? ' · nur cold'
|
||||
: '';
|
||||
if (info) {
|
||||
info.style.display = 'block';
|
||||
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
|
||||
if (hits.length === 0) {
|
||||
info.innerHTML = `🔍 Keine relevanten Treffer für "${escapeHtml(q)}"` +
|
||||
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
|
||||
` (Score < 0.30). Versuche andere Begriffe oder klicke das ✕ rechts um die Suche zu schliessen.`;
|
||||
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
|
||||
info.innerHTML = `🔍 Keine Treffer für "${escapeHtml(q)}"${filterDesc}${extra} · ${modeLabel}.`;
|
||||
} else {
|
||||
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"` +
|
||||
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
|
||||
` · sortiert nach Aehnlichkeit (Score ≥ 0.30)`;
|
||||
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"${filterDesc} · ${modeLabel}`;
|
||||
}
|
||||
}
|
||||
renderBrainList(hits, true);
|
||||
@@ -3585,9 +3869,11 @@
|
||||
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
|
||||
const score = withScore && typeof m.score === 'number' ? `<span style="color:#FFD60A;font-size:10px;margin-left:6px;">${m.score.toFixed(2)}</span>` : '';
|
||||
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
||||
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
|
||||
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}
|
||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
|
||||
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||
</div>
|
||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||
@@ -3780,6 +4066,13 @@
|
||||
const errEl = document.getElementById('memory-modal-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const attBlock = document.getElementById('memory-attachments-block');
|
||||
const attHint = document.getElementById('memory-attachments-hint');
|
||||
const attStatus = document.getElementById('memory-attachment-status');
|
||||
if (attStatus) attStatus.textContent = '';
|
||||
const attInput = document.getElementById('memory-attachment-input');
|
||||
if (attInput) attInput.value = '';
|
||||
|
||||
if (id && brainMemoryCache[id]) {
|
||||
const m = brainMemoryCache[id];
|
||||
titleEl.textContent = 'Memory bearbeiten';
|
||||
@@ -3790,6 +4083,10 @@
|
||||
document.getElementById('memory-category').value = m.category || '';
|
||||
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
||||
document.getElementById('memory-pinned').checked = !!m.pinned;
|
||||
// Anhang-Block sichtbar — Liste rendern
|
||||
if (attBlock) attBlock.style.display = 'block';
|
||||
if (attHint) attHint.style.display = 'none';
|
||||
renderMemoryAttachmentsList(m.attachments || []);
|
||||
} else {
|
||||
titleEl.textContent = 'Neue Memory';
|
||||
idEl.value = '';
|
||||
@@ -3799,10 +4096,96 @@
|
||||
document.getElementById('memory-category').value = '';
|
||||
document.getElementById('memory-tags').value = '';
|
||||
document.getElementById('memory-pinned').checked = false;
|
||||
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
||||
if (attBlock) attBlock.style.display = 'none';
|
||||
if (attHint) attHint.style.display = 'block';
|
||||
}
|
||||
modal.classList.add('open');
|
||||
}
|
||||
|
||||
function renderMemoryAttachmentsList(atts) {
|
||||
const el = document.getElementById('memory-attachments-list');
|
||||
if (!el) return;
|
||||
const id = document.getElementById('memory-edit-id').value;
|
||||
if (!Array.isArray(atts) || atts.length === 0) {
|
||||
el.innerHTML = '<div style="color:#555570;font-size:11px;font-style:italic;">(noch keine Anhaenge)</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = atts.map(a => {
|
||||
const name = escapeHtml(a.name || '?');
|
||||
const mime = a.mime || 'application/octet-stream';
|
||||
const size = a.size ? `${(a.size / 1024).toFixed(0)} KB` : '';
|
||||
const isImage = mime.startsWith('image/');
|
||||
const url = `/api/brain/memory/${encodeURIComponent(id)}/attachments/${encodeURIComponent(a.name)}`;
|
||||
const preview = isImage
|
||||
? `<img src="${url}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;cursor:pointer;" onclick="openLightbox('image','${url}')">`
|
||||
: `<span style="display:inline-block;width:32px;text-align:center;font-size:18px;">📄</span>`;
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 6px;background:#0D0D1A;border-radius:4px;">
|
||||
${preview}
|
||||
<a href="${url}" target="_blank" style="flex:1;min-width:0;color:#E0E0F0;text-decoration:none;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${name}">${name}</a>
|
||||
<span style="color:#555570;font-size:10px;flex-shrink:0;">${escapeHtml(mime)}, ${size}</span>
|
||||
<button class="btn secondary" onclick="deleteMemoryAttachment('${encodeURIComponent(a.name)}')" title="Anhang loeschen" style="padding:2px 6px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function uploadMemoryAttachments(files) {
|
||||
if (!files || !files.length) return;
|
||||
const id = document.getElementById('memory-edit-id').value;
|
||||
if (!id) return;
|
||||
const status = document.getElementById('memory-attachment-status');
|
||||
let lastResult = null;
|
||||
let n = 0;
|
||||
for (const file of files) {
|
||||
if (status) status.textContent = `⏳ Lade ${file.name} (${(file.size/1024).toFixed(0)} KB)...`;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
|
||||
}
|
||||
lastResult = await r.json();
|
||||
n += 1;
|
||||
} catch (e) {
|
||||
if (status) status.textContent = `🔴 ${file.name}: ${e.message}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastResult) {
|
||||
brainMemoryCache[id] = lastResult;
|
||||
renderMemoryAttachmentsList(lastResult.attachments || []);
|
||||
if (status) status.textContent = `✓ ${n} Anhang${n === 1 ? '' : '/Anhaenge'} hochgeladen`;
|
||||
// Eingabe-File-List reset damit erneutes Anwaehlen derselben Datei feuert
|
||||
const inp = document.getElementById('memory-attachment-input');
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMemoryAttachment(filenameEncoded) {
|
||||
const id = document.getElementById('memory-edit-id').value;
|
||||
if (!id) return;
|
||||
const name = decodeURIComponent(filenameEncoded);
|
||||
if (!confirm(`Anhang "${name}" wirklich loeschen?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/${filenameEncoded}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const updated = await r.json();
|
||||
brainMemoryCache[id] = updated;
|
||||
renderMemoryAttachmentsList(updated.attachments || []);
|
||||
const status = document.getElementById('memory-attachment-status');
|
||||
if (status) status.textContent = `✓ "${name}" geloescht`;
|
||||
} catch (e) {
|
||||
alert('Loeschen fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMemoryModal() {
|
||||
document.getElementById('memory-modal').classList.remove('open');
|
||||
}
|
||||
@@ -3856,7 +4239,18 @@
|
||||
try {
|
||||
const r = await fetch('/api/brain/memory/delete/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
loadBrainMemoryList();
|
||||
// Lokalen Cache + Such-State bereinigen damit die Liste nicht den Geist
|
||||
// des geloeschten Eintrags weiterzeigt.
|
||||
delete brainMemoryCache[id];
|
||||
if (Array.isArray(brainSearchIds)) {
|
||||
brainSearchIds = brainSearchIds.filter(x => x !== id);
|
||||
}
|
||||
// Re-Render: bei aktiver Suche neu suchen (Filter respektieren),
|
||||
// sonst die Vollliste neu vom Server holen.
|
||||
const which = (typeof brainSearchActive === 'function') ? brainSearchActive() : null;
|
||||
if (which === 'single') await runBrainSearch();
|
||||
else if (which === 'advanced') await runAdvancedSearch();
|
||||
else await loadBrainMemoryList();
|
||||
loadBrainStatus();
|
||||
} catch (e) {
|
||||
alert('Löschen fehlgeschlagen: ' + e.message);
|
||||
|
||||
+26
-1
@@ -617,6 +617,26 @@ function connectRVS(forcePlain) {
|
||||
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
||||
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
||||
broadcast({ type: "mode", payload: msg.payload });
|
||||
} else if (msg.type === "agent_activity") {
|
||||
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||
// Wenn gerade ein chat:final vorbei ist, unterdruecken wir trailing
|
||||
// 'thinking'-Events (gleiches Schema wie alter OpenClaw-Pfad).
|
||||
const activity = msg.payload?.activity || msg.activity || "idle";
|
||||
if (activity !== "idle" && Date.now() - lastChatFinalAt < SETTLED_WINDOW_MS) {
|
||||
// chat:final ist gerade durch — verstaubende thinking-Events ignorieren
|
||||
} else {
|
||||
broadcast({
|
||||
type: "agent_activity",
|
||||
activity,
|
||||
tool: msg.payload?.tool || msg.tool || "",
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "memory_saved") {
|
||||
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||
const m = msg.payload || {};
|
||||
log("info", "rvs", `ARIA-Memory gespeichert: "${m.title}" (type=${m.type}, pinned=${m.pinned})`);
|
||||
broadcast({ type: "memory_saved", payload: m });
|
||||
} else if (msg.type === "chat_message_deleted") {
|
||||
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
||||
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
||||
@@ -1624,13 +1644,18 @@ const server = http.createServer((req, res) => {
|
||||
// Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd).
|
||||
// Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health
|
||||
const targetPath = req.url.replace(/^\/api\/brain/, "");
|
||||
// Uploads brauchen laenger als die 30s default — Memory-Anhang-Endpoints
|
||||
// koennen bis zu 20 MB tragen, plus chat/distill-Calls dauern manchmal
|
||||
// mehr als eine Minute.
|
||||
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
|
||||
const timeout = isUpload ? 120000 : 60000;
|
||||
const proxyReq = http.request({
|
||||
host: "aria-brain",
|
||||
port: 8080,
|
||||
path: targetPath,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
timeout: 30000,
|
||||
timeout,
|
||||
}, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
|
||||
@@ -20,6 +20,14 @@ services:
|
||||
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
||||
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
|
||||
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
|
||||
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
|
||||
# Qdrant-DB ein File-Memory aufbaut (war Auslöser fuer doppelte Truth-Source).
|
||||
# Tmpfs ist beim Container-Start leer und wird beim Container-Recreate
|
||||
# weggeworfen — Claude Code sieht keine alten Files mehr und das was sie
|
||||
# ggf. neu schreibt landet nicht auf dem VM-Host.
|
||||
tmpfs:
|
||||
- /root/.claude/projects
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
||||
|
||||
@@ -55,6 +55,13 @@ Wichtige Mechanismen:
|
||||
|
||||
### Bugs / Fixes
|
||||
|
||||
- [x] **Cold Memory Crosstalk** durch Score-Threshold im Brain-Agent: Bei kleiner DB lieferte Cold-Search ungefiltert Top-5, auch wenn alle Scores < 0.2 lagen — ARIA hat das als „relevante" Info in den System-Prompt bekommen und in die Antwort eingewoben. Beispiel: Frage „hab ich ein flugzeug?" → Cold-Top war „Firmenadresse" (Score 0.094, Embedder-Noise) → ARIA antwortete „Die Adresse aus meinem Gedaechtnis ist..." ohne dass User danach gefragt hatte. Fix: Konstante `COLD_SCORE_THRESHOLD=0.30` in `agent.py` an `store.search()` durchgereicht. Konsistent mit dem `/memory/search`-HTTP-Threshold und der Diagnostic-Suche
|
||||
- [x] **Diagnostic: Pinned-/Type-Filter wirkt jetzt auch bei aktiver Suche**: Vorher ignorierten `runBrainSearch`/`runAdvancedSearch` die Filter-Dropdowns komplett; Dropdown-onchange rief `loadBrainMemoryList` und brach die Suche damit ab. Fix: `applyPinnedFilter` clientseitig nach Backend-Hit, `onBrainFiltersChanged` re-search bei aktiver Suche
|
||||
- [x] **Diagnostic: Memory-Liste refresht nach Delete sofort**: vorher rendere `loadBrainMemoryList` bei aktiver Such-Ansicht aus `brainMemoryCache` → der gerade geloeschte Eintrag tauchte wieder auf. Fix: Cache + brainSearchIds nach Delete bereinigen + re-search statt list
|
||||
- [x] **Diagnostic: „ARIA denkt..."-Indikator wieder im Chat-Fenster**: `agent_activity`-Events von RVS wurden vom Diagnostic-Server nicht an Browser durchgereicht. Fix: Relay analog zu `mode`/`voice_ready`, mit `SETTLED_WINDOW_MS`-Schutz gegen Trailing-Events nach `chat:final`
|
||||
- [x] **Memory-Suche filtert Rauschen** (score_threshold im HTTP-Endpoint + kleineres k): Vorher k=20 ohne Threshold lieferte bei kleiner DB fast alles als Treffer, auch komplettes Rauschen (z.B. „banane" → 10 false positives mit Score 0.10-0.22). Fix: `score_threshold=0.30` als Query-Param am `/memory/search`-Endpoint + Diagnostic schickt jetzt `k=10` + Threshold, „Keine Treffer"-Box wenn alle unter Score
|
||||
- [x] **Cessna-Beispiel aus System-Prompt raus**: in der `memory_save`-Tool-Description stand „z.B. 'Stefan hat eine Cessna'" als fact-Beispiel. ARIA hat das (korrekt!) korrekt eingeordnet als Beispiel-Text, aber Phantom-Wissen im Prompt ist suboptimal. Fix: durch generische Aufzaehlung (Vorlieben/Besitz/Orte/Termine/Personen) ersetzt
|
||||
- [x] **Claude-Code-Auto-Memory abklemmen**: Claude Code CLI hat ein eingebautes Auto-Memory das Markdown-Files in `~/.claude/projects/<project>/memory/` schreibt. Weil das CLI als ARIAs LLM lief, hat sie da ueber Wochen ihre eigene Schatten-Wissensbasis aufgebaut (cessna, persoenlichkeit, projects) — komplett parallel zur Qdrant-DB. Fix: `tmpfs`-Mount ueber `/root/.claude/projects` im Proxy-Container. Claude Code sieht beim Spawn leeres `projects/`, schreibt sie was rein landet's nur im RAM, beim Container-Recreate weg. Stefans persoenliches `~/.claude/projects/` auf der VM bleibt unangetastet
|
||||
- [x] **Trigger-Antworten landen jetzt im Chat** (App + Diagnostic + TTS): Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft er `agent.chat()` direkt im eigenen Prozess. Die Antwort wurde nur ins Trigger-Log geschrieben — kein RVS-Broadcast, nichts sichtbar. Fix: Bridge hat jetzt einen kleinen asyncio HTTP-Listener auf Port 8090 (intern, nicht exposed). Brain pusht nach jedem Trigger-Feuer per `urllib.request.urlopen` an `http://aria-bridge:8090/internal/trigger-fired` mit `{reply, trigger_name, type, events}`. Bridge ruft `_handle_trigger_fired` → Side-Channel-Events (skill_created/trigger_created/location_tracking) + `_process_core_response` — exakt derselbe Pfad wie normale Chat-Antworten (Bubble + TTS + chat_backup)
|
||||
- [x] **Tool-Use im Proxy durchgereicht** (claude-max-api-proxy): Der Proxy nahm das OpenAI-`tools`-Feld an, ignorierte es aber komplett — `openai-to-cli.js` wandelte nur `messages` zu einem String, `manager.js` rief `claude --print` ohne Tools. Claude Code nutzte ihre internen Tools (Bash, Read, ...) und „simulierte" Aktionen wie `sleep 120` statt `trigger_timer` zu rufen. Fix: zwei eigene Adapter-Files unter `proxy-patches/`, die zur Container-Startzeit ueber die npm-Version kopiert werden. `openai-to-cli.js` injiziert die `tools` als `<system>`-Block mit Schema-Beschreibungen und der Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat zu verwenden; weiterhin verarbeitet sie `role=tool`-Messages als `<tool_result>`-Bloecke fuer den Loop-Replay. `cli-to-openai.js` parsed die `<tool_call>`-Bloecke aus dem Result-Text zurueck zu OpenAI `tool_calls` mit `finish_reason=tool_calls`. Mehrere Tool-Calls + Pre-Tool-Text werden korrekt aufgeteilt
|
||||
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
|
||||
@@ -280,6 +287,32 @@ Skills mit Tool-Use.
|
||||
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
|
||||
- [x] **Aktuelle-Zeit-Block im System-Prompt**: UTC + lokale Europa/Berlin-Zeit (Sommer/Winter-Heuristik) wird bei jedem Chat-Turn oben mit-injiziert, damit Timer-fires_at und Watcher mit `hour_of_day` ueberhaupt sinnvoll sind. `trigger_timer` akzeptiert zusaetzlich `in_seconds` (Server rechnet) — ARIA muss bei relativen Angaben ('in 2 Minuten') nicht selbst ISO-rechnen
|
||||
|
||||
### Memory-System (Phase B Punkt 5+ Bonus)
|
||||
|
||||
- [x] **`memory_save`-Tool fuer ARIA**: ARIA kann selber neue Memories in die Qdrant-DB schreiben (vorher hat sie auf File-Memory ausweichen muessen weil kein Tool da war). Schema: `title`, `content`, `type` (identity/rule/preference/tool/skill/fact/conversation/reminder), optional `category`, `tags`, `pinned`. Tool-Description erklaert die Type-Wahl + sagt explizit „Du hast KEIN File-Memory mehr, schreibe nicht in `~/.claude/projects/...`". Side-Channel-Event `memory_saved` broadcastet via Bridge an App + Diagnostic — gelbe „🧠 ARIA hat etwas gemerkt"-Bubble, Auto-Refresh des Gehirn-Tabs falls offen
|
||||
- [x] **Volltext-Suche im Gehirn** (`/memory/search-text`): Substring-Match (case-insensitive) ueber Title + Content + Category + Tags. Default in der Diagnostic-Suche, weil bei kleiner DB Semantic Search False-Positives ueberproduziert. Toggle „🧠 Semantisch" wechselt zu Embedder-Modus
|
||||
- [x] **Advanced Search im Diagnostic-Gehirn-Tab**: aufklappbares Panel mit dynamisch erweiterbaren Suchfeldern (+ Feld Button) und UND/ODER-Operatoren zwischen ihnen. Backend-side bleibt simpel — pro Begriff einmal `/memory/search-text`, dann clientseitig per Set-Logik kombiniert. Pinned-/Type-Filter werden mit angewandt
|
||||
- [x] **Mülltonne pro Chat-Bubble**: einzelne Nachrichten loeschbar (mit Confirm). Entfernt aus chat_backup.jsonl, Brain conversation.jsonl (rolling window) und allen Clients per RVS-Broadcast `chat_message_deleted`. Wichtig fuer ARIA: geloeschte Turns sind im naechsten Prompt nicht mehr im Window
|
||||
- [x] **Druckansicht fuer Memories**: 📄-Button im Gehirn-Tab oeffnet eine fuer A4-Print optimierte Ansicht in neuem Tab — Strg+P → Als PDF speichern. Filter (Typ + Pinned) werden respektiert
|
||||
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
||||
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
||||
|
||||
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
||||
|
||||
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
||||
- [x] **Backend-Endpoints**: GET/POST/DELETE `/memory/{id}/attachments[/...]`, plus Multipart-Upload-Variante `/upload` fuer Browser-FormData (Base64-Upload sprengt bei grossen Files Bash's ARG_MAX, multipart ist sauberer). Diagnostic-Proxy mit dynamischem Timeout (120s fuer /attachments, 60s sonst)
|
||||
- [x] **Diagnostic-UI**: Memory-Modal hat Upload-Block (multiple File-Picker), Thumbnail-Vorschau bei Bildern + 📄-Icon bei Files, Klick auf Bild → Lightbox, 🗑 pro Anhang. Memory-Liste zeigt 📎N-Badge wenn N > 0 Anhaenge
|
||||
- [x] **App-UI**: `memory_saved`-Bubble zeigt Anhaenge als Tap-Reihen. Tap → `file_request` ueber RVS → Bridge laedt + bei Bildern Vollbild-Modal, bei anderen Intent-Picker. `file_response`-Handler matched zusaetzlich `memorySaved.attachments[].path`
|
||||
- [x] **System-Prompt-Integration**: `_attachments_line` in `prompts.py` haengt nach Hot/Cold-Memory-Eintraegen eine `📎 Anhaenge: foo.jpg (...) — Pfad: ...`-Zeile an. Bei `image/*` zusaetzlich Hinweis „Bilder kannst du via `Read <pfad>` direkt ansehen — Claude Code Read ist multi-modal-faehig"
|
||||
- [x] **ARIA sieht Bilder echt** — Stufe E ohne Proxy-Patch: Claude Code's `Read`-Tool ist bereits multi-modal. ARIA ruft `Read /shared/memory-attachments/<id>/foto.jpg` → Vision-Modell beschreibt das Bild, ARIA antwortet mit den extrahierten Infos. End-to-End getestet mit Cessna-Foto: ARIA hat D-ECSW-Kennung aus dem Bild gelesen, F172-Variante erkannt (Reims-Aviation), EDWM-ICAO fuer Mariensiel selbst dazu kombiniert. **Persistent**: Bild bleibt am Memory, bei spaeteren Detail-Fragen („wie viele Fenster?") kann ARIA das Bild nochmal lesen ohne dass User es re-uploaden muss
|
||||
- [x] **`memory_save` mit `attach_paths`** — ARIA kann beim Speichern selber Bilder anhaengen. Pfade aus `/shared/uploads/` (z.B. ein User-Foto aus dem Chat) werden serverseitig nach `/shared/memory-attachments/<id>/` kopiert. Pfadschutz auf Whitelist-Prefixes (kein Root-FS-Zugriff). Tool-Description weist explizit an: erst `Read <pfad>` (Vision-Beschreibung), dann `memory_save(content=<extrahierte Infos>, attach_paths=[<pfad>])` — End-to-End-Workflow in einer Tool-Call-Sequenz
|
||||
|
||||
### DB als Single Source of Truth
|
||||
|
||||
- [x] **`brain-import/` als Drop-Folder** statt aktive Saat: Inhalt komplett gitignored, nur `.gitkeep` + README im Repo. Stefan kippt MDs rein wenn er was migrieren will, klickt im Diagnostic „Migration aus brain-import/", fertig. Alte AGENT.md/BOOTSTRAP.md aus dem Repo geworfen (waren teils OpenClaw-Altlasten)
|
||||
- [x] **DB-Aufraeumung**: 60 → 31 Eintraege durch Loeschen von 24 Dubletten (gleicher Title+Content unter verschiedenen IDs aus der initialen Migration) + 6 obsoleten facts (OpenClaw-Geschichte, Home-Partition-Snapshots etc.). Firmenadresse als einzige aktive `fact` behalten
|
||||
- [x] **`.claude/aria-vm.env` Setup** fuer die Dev-Maschine: Claude Code auf Stefans Workstation erreicht das Brain-API ueber Diagnostic-Port 3001 via `ARIA_BRAIN_URL`. `.example` im Repo, echte Datei mit IP der VM gitignored. Damit kann Claude direkt curl gegen die DB machen ohne SSH-Tunnel
|
||||
|
||||
### Diagnostic / App Features (drumherum)
|
||||
|
||||
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
|
||||
|
||||
@@ -26,6 +26,7 @@ const ALLOWED_TYPES = new Set([
|
||||
"xtts_import_voice", "xtts_voice_imported",
|
||||
"skill_created",
|
||||
"trigger_created",
|
||||
"memory_saved",
|
||||
"location_update", "location_tracking",
|
||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||
"delete_message_request", "chat_message_deleted",
|
||||
|
||||
Reference in New Issue
Block a user