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:
@@ -89,11 +89,19 @@ interface ChatMessage {
|
|||||||
};
|
};
|
||||||
/** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
|
/** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
|
||||||
memorySaved?: {
|
memorySaved?: {
|
||||||
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
preview?: string;
|
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
|
/** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
|
||||||
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
* 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).
|
// gepackt — eigene Bubble (gelb wie trigger/skill).
|
||||||
if (message.type === 'memory_saved') {
|
if (message.type === 'memory_saved') {
|
||||||
const p = (message.payload || {}) as any;
|
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 = {
|
const memoryMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'aria',
|
sender: 'aria',
|
||||||
text: '',
|
text: '',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
memorySaved: {
|
memorySaved: {
|
||||||
|
id: p.id ? String(p.id) : undefined,
|
||||||
title: String(p.title || '(ohne Titel)'),
|
title: String(p.title || '(ohne Titel)'),
|
||||||
type: String(p.type || 'fact'),
|
type: String(p.type || 'fact'),
|
||||||
category: p.category ? String(p.category) : undefined,
|
category: p.category ? String(p.category) : undefined,
|
||||||
pinned: !!p.pinned,
|
pinned: !!p.pinned,
|
||||||
preview: p.content_preview ? String(p.content_preview) : undefined,
|
preview: p.content_preview ? String(p.content_preview) : undefined,
|
||||||
|
attachments: atts.length ? atts : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, memoryMsg]));
|
setMessages(prev => capMessages([...prev, memoryMsg]));
|
||||||
@@ -595,16 +611,38 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (b64 && reqId) {
|
if (b64 && reqId) {
|
||||||
const fileName = (message.payload.name as string) || 'download';
|
const fileName = (message.payload.name as string) || 'download';
|
||||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||||
setMessages(prev => prev.map(m => ({
|
setMessages(prev => prev.map(m => {
|
||||||
...m,
|
// Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble)
|
||||||
attachments: m.attachments?.map(a =>
|
const updatedAtts = m.attachments?.map(a =>
|
||||||
a.serverPath === serverPath ? { ...a, uri: filePath } : 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
|
// 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)) {
|
if (serverPath && autoOpenPaths.current.has(serverPath)) {
|
||||||
autoOpenPaths.current.delete(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(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -1287,6 +1325,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (item.memorySaved) {
|
if (item.memorySaved) {
|
||||||
const m = item.memorySaved;
|
const m = item.memorySaved;
|
||||||
const catPart = m.category ? ` · [${m.category}]` : '';
|
const catPart = m.category ? ` · [${m.category}]` : '';
|
||||||
|
const atts = m.attachments || [];
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
||||||
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||||
@@ -1299,6 +1338,35 @@ const ChatScreen: React.FC = () => {
|
|||||||
{m.preview ? (
|
{m.preview ? (
|
||||||
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
|
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
|
||||||
) : null}
|
) : 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>
|
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -2065,6 +2133,28 @@ const styles = StyleSheet.create({
|
|||||||
playButtonText: {
|
playButtonText: {
|
||||||
fontSize: 16,
|
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: {
|
bubbleTrash: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ class Agent:
|
|||||||
"id": saved.id, "type": saved.type, "title": saved.title,
|
"id": saved.id, "type": saved.type, "title": saved.title,
|
||||||
"content_preview": (saved.content or "")[:140],
|
"content_preview": (saved.content or "")[:140],
|
||||||
"category": saved.category, "pinned": saved.pinned,
|
"category": saved.category, "pinned": saved.pinned,
|
||||||
|
"attachments": saved.attachments or [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return (f"OK — Memory '{title}' gespeichert "
|
return (f"OK — Memory '{title}' gespeichert "
|
||||||
|
|||||||
@@ -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:
|
def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||||
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
|
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
|
||||||
grouped: dict[str, List[MemoryPoint]] = {}
|
grouped: dict[str, List[MemoryPoint]] = {}
|
||||||
@@ -69,6 +92,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
|||||||
for p in items:
|
for p in items:
|
||||||
parts.append(f"### {p.title}")
|
parts.append(f"### {p.title}")
|
||||||
parts.append(p.content.strip())
|
parts.append(p.content.strip())
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
parts.append(att_line)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
# uebrige Types (falls jemand was anderes als pinned markiert)
|
# 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:
|
for p in items:
|
||||||
parts.append(f"### {p.title}")
|
parts.append(f"### {p.title}")
|
||||||
parts.append(p.content.strip())
|
parts.append(p.content.strip())
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
parts.append(att_line)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
return "\n".join(parts).strip()
|
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 ""
|
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
||||||
lines.append(f"- **{p.title}**{score}")
|
lines.append(f"- **{p.title}**{score}")
|
||||||
lines.append(f" {p.content.strip()}")
|
lines.append(f" {p.content.strip()}")
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
lines.append(f" {att_line}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user