5809ad3adb
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>
223 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|