feat(memory): Tap auf Memory-Bubble oeffnet Detail+Edit-Modal in der App (Etappen 2+3)
Stefans naechste Wunsch-Etappe — komplettes Edit eines Memory-Eintrags
aus der App heraus, inkl. Anhang-Upload, ohne Diagnostic-Browser
auszuklappen.
Backend-Fundament (Phase A):
- Brain bekommt GET /memory/get/{id} fuer Einzel-Lookup mit allen Feldern
- RVS ALLOWED_TYPES um brain_request + brain_response erweitert
- Bridge implementiert generischen RVS-Brain-Proxy:
payload {requestId, method, path, body|bodyBase64, contentType}
→ ruft Brain-HTTP-API → broadcastet brain_response {requestId,
status, json|text|base64+contentType}. Damit kann die App
beliebige Brain-Endpoints ueber RVS adressieren — nicht nur Memory.
App-Service (Phase B):
- services/brainApi.ts: Promise-basierter Client. _send() schickt
brain_request mit requestId, _ensureListener() filtert die passende
brain_response. Methoden: getMemory, listMemories, searchText,
searchSemantic, saveMemory, updateMemory, deleteMemory,
uploadAttachment (Base64), deleteAttachment, getAttachmentBytes.
App-UI (Phasen C+D):
- components/MemoryDetailModal.tsx: Modal mit zwei Modi.
- Read: Titel, Type, Category, Tags, voller Content, Anhang-Liste
(Tap = Bild im Vollbild oder Datei-Info), Stift-Icon → Edit.
- Edit: Titel/Content/Category/Tags/Pinned editierbar, Save via
brainApi.updateMemory.
- DocumentPicker + RNFS.readFile(base64) → uploadAttachment(...).
- Anhang loeschen, kompletter Memory loeschen (mit Alert-confirm).
- ChatScreen: TouchableOpacity-Wrapper um die memorySaved-Bubble,
Tap setzt memoryDetailId → Modal oeffnet. Hint im Footer
"tippen für Details" wenn die Bubble eine ID hat.
Etappen 4 (Notizen-Inbox neben Lupe) + 5 (Memory-Editor in App-
Settings) folgen — diese nutzen die gleiche MemoryDetailModal-
Komponente, sind also schnell aufgesetzt sobald 2+3 verifiziert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Brain-API-Client fuer die App.
|
||||
*
|
||||
* Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir
|
||||
* tunneln alle Memory-Operationen ueber den generischen brain_request /
|
||||
* brain_response RVS-Channel den die Bridge implementiert.
|
||||
*
|
||||
* Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende
|
||||
* brain_response, Promise loest auf / wird abgelehnt bei status>=400.
|
||||
*/
|
||||
|
||||
import rvs from './rvs';
|
||||
|
||||
type AnyJson = any;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (data: AnyJson) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
expectBinary?: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let installed = false;
|
||||
|
||||
function _ensureListener() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
rvs.onMessage((msg: any) => {
|
||||
if (!msg || msg.type !== 'brain_response') return;
|
||||
const p = msg.payload || {};
|
||||
const reqId: string = p.requestId || '';
|
||||
const handler = pending.get(reqId);
|
||||
if (!handler) return;
|
||||
pending.delete(reqId);
|
||||
clearTimeout(handler.timer);
|
||||
const status: number = Number(p.status || 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (handler.expectBinary) {
|
||||
handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' });
|
||||
} else {
|
||||
handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null));
|
||||
}
|
||||
} else {
|
||||
const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`;
|
||||
handler.reject(new Error(`Brain ${status}: ${detail}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
function _newRequestId(): string {
|
||||
_nextId += 1;
|
||||
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
||||
}
|
||||
|
||||
interface SendOpts {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: AnyJson;
|
||||
bodyBase64?: string;
|
||||
contentType?: string;
|
||||
expectBinary?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
||||
_ensureListener();
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = _newRequestId();
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.delete(requestId)) {
|
||||
reject(new Error(`Brain-Timeout fuer ${path}`));
|
||||
}
|
||||
}, opts.timeoutMs || 30000);
|
||||
pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary });
|
||||
rvs.send('brain_request' as any, {
|
||||
requestId,
|
||||
method: opts.method || 'GET',
|
||||
path,
|
||||
...(opts.body !== undefined ? { body: opts.body } : {}),
|
||||
...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}),
|
||||
...(opts.contentType ? { contentType: opts.contentType } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Typen ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemoryAttachment {
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned: boolean;
|
||||
category: string;
|
||||
source: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
conversation_id?: string | null;
|
||||
score?: number | null;
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export const brainApi = {
|
||||
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
|
||||
getMemory(id: string): Promise<Memory> {
|
||||
return _send(`/memory/get/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('limit', String(opts.limit || 500));
|
||||
return _send(`/memory/list?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Volltext-Substring-Suche. */
|
||||
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 50));
|
||||
return _send(`/memory/search-text?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Semantische Suche (Embedder). */
|
||||
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 10));
|
||||
qs.set('score_threshold', String(opts.threshold ?? 0.30));
|
||||
return _send(`/memory/search?${qs.toString()}`);
|
||||
},
|
||||
|
||||
/** Memory anlegen. */
|
||||
saveMemory(body: {
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned?: boolean;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}): Promise<Memory> {
|
||||
return _send('/memory/save', {
|
||||
method: 'POST',
|
||||
body: { source: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */
|
||||
updateMemory(id: string, body: Partial<Pick<Memory, 'title' | 'content' | 'pinned' | 'category' | 'tags'>>): Promise<Memory> {
|
||||
return _send(`/memory/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory loeschen. */
|
||||
deleteMemory(id: string): Promise<{ deleted: string }> {
|
||||
return _send(`/memory/delete/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Anhaenge ────────────────────────────────────────────────────────
|
||||
|
||||
/** Datei als Anhang an die Memory haengen (Base64-Upload). */
|
||||
uploadAttachment(memoryId: string, name: string, base64: string): Promise<Memory> {
|
||||
return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, {
|
||||
method: 'POST',
|
||||
body: { name, data_base64: base64 },
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Anhang loeschen. */
|
||||
deleteAttachment(memoryId: string, filename: string): Promise<Memory> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
|
||||
/** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */
|
||||
getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ expectBinary: true, timeoutMs: 60000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
Reference in New Issue
Block a user