e74e1eaf70
Inbox-Crash gefunden via App-Crash-Reporter (commit21a315c): "URLSearchParams.set is not implemented" at MemoryBrowser → brainApi.listMemories React Native's Hermes-Polyfill kennt zwar new URLSearchParams() aber nicht die .set()-Methode darauf. Pickup-Bug — auf iOS / aelteren Versionen geht's, Stefan's Android-Build crasht. Fix: kleine _qs()-Helper im brainApi.ts der einen Query-String aus einem flachen Object baut, ohne URLSearchParams: _qs({q:'cessna', k:5, type:'fact'}) → "?q=cessna&k=5&type=fact" Plus: undefined/null/empty Werte werden ausgelassen — saubererer als URLSearchParams.set wo man manuell prefilten muss. ErrorBoundary aus21a315chat den Crash sauber abgefangen, statt der App-Tot war ne Error-Box im Inbox-Modal mit der vollen Stack-Trace. Stefan konnte den Log via tools/fetch-app-logs.sh holen ohne ADB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.0 KiB
TypeScript
221 lines
7.0 KiB
TypeScript
/**
|
|
* 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}`;
|
|
}
|
|
|
|
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
|
|
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
|
|
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
|
|
function _qs(params: Record<string, unknown>): string {
|
|
const parts: string[] = [];
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === undefined || v === null || v === '') continue;
|
|
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
}
|
|
return parts.length ? `?${parts.join('&')}` : '';
|
|
}
|
|
|
|
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 = _qs({ type: opts.type, limit: opts.limit || 500 });
|
|
return _send(`/memory/list${qs}`);
|
|
},
|
|
|
|
/** Volltext-Substring-Suche. */
|
|
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
|
const qs = _qs({
|
|
q,
|
|
type: opts.type,
|
|
include_pinned: opts.includePinned !== false,
|
|
k: opts.k || 50,
|
|
});
|
|
return _send(`/memory/search-text${qs}`);
|
|
},
|
|
|
|
/** Semantische Suche (Embedder). */
|
|
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
|
const qs = _qs({
|
|
q,
|
|
type: opts.type,
|
|
include_pinned: opts.includePinned !== false,
|
|
k: opts.k || 10,
|
|
score_threshold: opts.threshold ?? 0.30,
|
|
});
|
|
return _send(`/memory/search${qs}`);
|
|
},
|
|
|
|
/** 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;
|