Files
simbarag/raggr-frontend/src/components/MemoryPanel.tsx
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

223 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { historyService, type SavedMemory } from "../api/historyService";
type MemoryPanelProps = { onClose?: () => void };
/** Mount inside a page or dialog. Only the authenticated user's saved memories are loaded. */
export function MemoryPanel({ onClose }: MemoryPanelProps) {
const [memories, setMemories] = useState<SavedMemory[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [content, setContent] = useState("");
const [forgetting, setForgetting] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const offset = useRef(0);
const generation = useRef(0);
const load = useCallback(async (append = false) => {
const requestId = ++generation.current;
setLoading(true);
setError("");
try {
const page = await historyService.memories(append ? offset.current : 0);
if (requestId !== generation.current) return;
offset.current = (append ? offset.current : 0) + page.length;
setMemories((previous) =>
append
? [
...previous,
...page.filter((m) => !previous.some((old) => old.id === m.id)),
]
: page,
);
setHasMore(page.length === 100);
} catch {
if (requestId === generation.current)
setError("Unable to load memories. Please try again.");
} finally {
if (requestId === generation.current) setLoading(false);
}
}, []);
useEffect(() => {
void load();
return () => {
++generation.current;
};
}, [load]);
const save = async (memory: SavedMemory) => {
setBusy(memory.id);
setError("");
try {
await historyService.correctMemory(memory.id, content.trim());
setEditing(null);
setNotice(
"Memory corrected. Future responses will use the saved correction.",
);
await load();
} catch {
setError(
"Unable to correct memory. Your edit is still here; please try again.",
);
} finally {
setBusy(null);
}
};
const forget = async (memory: SavedMemory) => {
setBusy(memory.id);
setError("");
try {
await historyService.forgetMemory(memory.id);
setForgetting(null);
if (editing === memory.id) setEditing(null);
setNotice(
"Memory forgotten. Existing conversation messages are retained.",
);
await load();
} catch {
setError("Unable to forget memory. Please try again.");
} finally {
setBusy(null);
}
};
return (
<section
aria-label="Saved memories"
className="bg-warm-white text-charcoal rounded-2xl p-4 flex flex-col gap-3 max-h-[80dvh] overflow-y-auto"
>
<div className="flex items-center justify-between gap-4">
<h2 className="font-bold text-lg">Saved memories</h2>
{onClose && (
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-xl hover:bg-cream-dark"
>
Close
</button>
)}
</div>
<p className="text-sm text-warm-gray">
Correct facts Simba remembers about you, or forget a saved memory.
</p>
{error && (
<div role="alert">
<p>{error}</p>
<button
type="button"
disabled={loading || !!busy}
onClick={() => load()}
>
Retry loading
</button>
</div>
)}
{notice && (
<p role="status" className="text-sm">
{notice}
</p>
)}
{loading && <p role="status">Loading memories...</p>}
{!loading && !error && memories.length === 0 && (
<p>No saved memories yet.</p>
)}
{memories.map((memory) => (
<div
key={memory.id}
className="border border-sand-light rounded-xl p-3 flex flex-col gap-2"
>
{editing === memory.id ? (
<>
<label htmlFor={`memory-${memory.id}`} className="text-sm">
Correct memory
</label>
<textarea
id={`memory-${memory.id}`}
autoFocus
maxLength={10000}
value={content}
onChange={(e) => setContent(e.target.value)}
className="border border-sand-light rounded-lg p-2 min-h-24"
/>
<div className="flex gap-3">
<button
type="button"
disabled={!!busy || !content.trim()}
onClick={() => save(memory)}
>
Save correction
</button>
<button
type="button"
disabled={!!busy}
onClick={() => setEditing(null)}
>
Cancel
</button>
</div>
</>
) : (
<p className="whitespace-pre-wrap break-words">{memory.content}</p>
)}
<div className="flex gap-3 text-sm">
{editing !== memory.id && (
<button
type="button"
disabled={loading || !!busy}
onClick={() => {
setContent(memory.content);
setEditing(memory.id);
setForgetting(null);
}}
>
Correct
</button>
)}
<button
type="button"
disabled={loading || !!busy}
onClick={() => setForgetting(memory.id)}
>
Forget
</button>
</div>
{forgetting === memory.id && (
<div className="text-sm flex flex-col gap-2">
<p>Forget this saved memory?</p>
<div className="flex gap-3">
<button
type="button"
disabled={!!busy}
onClick={() => forget(memory)}
>
Confirm forget
</button>
<button
type="button"
disabled={!!busy}
onClick={() => setForgetting(null)}
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
{hasMore && (
<button
type="button"
disabled={loading || !!busy}
onClick={() => load(true)}
>
Load more memories
</button>
)}
</section>
);
}