feat(memory): Anhaenge in App-Bubble + System-Prompt (Stufe C + D)

Stufe C — App:
- ChatMessage.memorySaved.attachments [{name, mime, size, path, localUri}]
- memory_saved-Listener uebernimmt payload.attachments
- renderMessage memorySaved-Bubble zeigt Anhaenge als Tap-Reihen
  (Icon 🖼/📄 + Filename + Hint). Tap → file_request via Bridge,
  beim ersten Mal "(tippen zum Laden)" → nach file_response cached
  + bei Bildern setFullscreenImage, bei anderen openFileWithIntent
- file_response-Handler updated zusaetzlich memorySaved.attachments
  per serverPath-Match
- Styles fuer memoryAttachmentRow/Icon/Name/Meta

Stufe D — System-Prompt:
- prompts._attachments_line: pro Memory eine Zeile
  "📎 Anhaenge: foo.jpg (image/jpeg, 109 KB) — Pfad: /shared/memory-attachments/<id>/"
- Wird in build_hot_memory_section + build_cold_memory_section
  nach dem Content angehangen
- ARIA "weiss" damit dass Anhaenge da sind und kann via Bash darauf
  zugreifen (file, head, base64 …). Echt sehen kann sie sie erst mit
  Multi-Modal-Pipeline (Stufe E)
- memory_save Dispatcher: attachments-Liste auch im memory_saved-Event
  (vermutlich [] beim Save, aber konsistent fuer spaeteres
  Speichern-mit-Anhaengen-Pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 02:45:51 +02:00
parent de9b7b46f9
commit d5531521fa
3 changed files with 129 additions and 6 deletions
+96 -6
View File
@@ -89,11 +89,19 @@ interface ChatMessage {
};
/** 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
@@ -534,17 +542,25 @@ const ChatScreen: React.FC = () => {
// 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]));
@@ -595,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(() => {});
}
@@ -1287,6 +1325,7 @@ const ChatScreen: React.FC = () => {
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}}>
@@ -1299,6 +1338,35 @@ const ChatScreen: React.FC = () => {
{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>
);
@@ -2065,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,
+1
View File
@@ -546,6 +546,7 @@ class Agent:
"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 '{title}' gespeichert "
+32
View File
@@ -52,6 +52,29 @@ 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>
— ARIA kann sie via Bash anschauen (file, head, base64...) wenn relevant.
Sehen kann sie sie erst mit der Multi-Modal-Pipeline (Stufe E)."""
atts = getattr(p, "attachments", None) or []
if not atts:
return ""
items = []
for a in atts:
if not isinstance(a, dict):
continue
name = a.get("name", "?")
mime = a.get("mime", "")
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 ""
base_dir = f"/shared/memory-attachments/{p.id}/" if p.id else ""
return f"📎 Anhaenge: {', '.join(items)}" + (f" — Pfad: {base_dir}" if base_dir else "")
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 +92,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 +103,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 +120,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)