'use client' import { useEffect, useMemo, useState } from 'react' import { useLocale } from 'next-intl' import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react' import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { SettingsGroup, SettingsReveal, SettingsRow, SettingsRowEnd, SettingsRowNote, SettingsSeg, SettingsSelect, SettingsTextarea, } from '@/components/settings/SettingsRows' import { useCanWrite } from '@/lib/hooks/use-can-write' import { cn, formatDateLong } from '@/lib/utils' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import type { FactKind as Kind, FactSource as Source } from '@/lib/agent-context/agent-competence' interface AgentMemoryRow { id: string kind: Kind content: string source: Source source_ref: string | null relevance_score: number is_pinned: boolean is_active: boolean last_accessed_at: string | null created_at: string updated_at: string } const KIND_LABEL: Record = { fact: 'Fakta', preference: 'Preferens', pattern: 'Mönster', correction: 'Korrigering', } const SOURCE_LABEL: Record = { composer: 'Inläst vid uppstart', user_taught: 'Du lärde mig', agent_learned: 'Jag noterade', derived: 'Härlett', } const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [ { value: 'all', label: 'Alla' }, { value: 'fact', label: 'Fakta' }, { value: 'preference', label: 'Preferenser' }, { value: 'pattern', label: 'Mönster' }, { value: 'correction', label: 'Korrigeringar' }, ] export function AgentMemoryPanel() { const { toast } = useToast() const { canWrite } = useCanWrite() const errorLocale = useLocale() as ErrorLocale // null = the memory list is not known: still loading, or the read failed // (loadError). A failed read must never render the "Inga minnen ännu" // EmptyState: that is a claim about the assistant's memory, and it is only // true after a confirmed empty read. const [rows, setRows] = useState(null) // detail === null: transient, so the line carries a retry. A detail sentence // means the user has to act (an expired session) and a retry cannot help. const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null) const [reloadKey, setReloadKey] = useState(0) const [includeDismissed, setIncludeDismissed] = useState(false) const [kindFilter, setKindFilter] = useState<'all' | Kind>('all') const [busyId, setBusyId] = useState(null) const [editingId, setEditingId] = useState(null) const [editDraft, setEditDraft] = useState('') const [showAdd, setShowAdd] = useState(false) const [newContent, setNewContent] = useState('') const [newKind, setNewKind] = useState('fact') const [adding, setAdding] = useState(false) // The cancelled closure is the same idiom every sibling panel uses // (TeamPanel, AccountDangerZone): a response that lands after unmount, or // after a filter change superseded this load, must not setState. Without it // a slow "Alla" response could overwrite a newer filtered list. useEffect(() => { let cancelled = false async function load() { setLoadError(null) const params = new URLSearchParams() if (includeDismissed) params.set('include_dismissed', 'true') if (kindFilter !== 'all') params.set('kind', kindFilter) try { const res = await fetch(`/api/agent/memory?${params.toString()}`) if (!res.ok) { // Not-JSON bodies (an HTML error page, an empty 502) leave null, and // getErrorMessage falls back to the status map. const json = await res.json().catch(() => null) if (cancelled) return const sessionGone = res.status === 401 || res.status === 403 setRows(null) setLoadError({ detail: sessionGone ? getErrorMessage(json, { statusCode: res.status, locale: errorLocale }) : null, }) return } // A 200 whose body will not parse throws into the catch below; a 200 // without the list is a failed read too. Neither may become a // fabricated "Inga minnen ännu". const json = await res.json() if (cancelled) return if (!Array.isArray(json?.data)) { setRows(null) setLoadError({ detail: null }) return } setRows(json.data as AgentMemoryRow[]) } catch { if (!cancelled) { setRows(null) setLoadError({ detail: null }) } } } void load() return () => { cancelled = true } }, [includeDismissed, kindFilter, errorLocale, reloadKey]) const counts = useMemo(() => { const active = rows?.filter((r) => r.is_active).length ?? 0 const pinned = rows?.filter((r) => r.is_active && r.is_pinned).length ?? 0 const dismissed = rows?.filter((r) => !r.is_active).length ?? 0 return { active, pinned, dismissed } }, [rows]) async function patch(id: string, body: Partial>) { setBusyId(id) try { const res = await fetch(`/api/agent/memory/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { // Not-JSON bodies (an HTML error page, an empty 502) leave null, and // getErrorMessage falls back to the status map. const json = await res.json().catch(() => null) toast({ title: 'Kunde inte uppdatera', description: getErrorMessage(json, { statusCode: res.status, locale: errorLocale }), variant: 'destructive', }) return } const json = await res.json() setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null) } catch (err) { // A rejected fetch (offline, DNS failure) or a 200 whose body will not // parse never reaches the !res.ok arm above: without this toast the // click looks like a dead control rather than a save that did not land. // One toast per failed click, never two: TOAST_LIMIT is 1 // (components/ui/use-toast.tsx) and a second would evict the first. toast({ title: 'Kunde inte uppdatera', description: getErrorMessage(err, { locale: errorLocale }), variant: 'destructive', }) } finally { setBusyId(null) } } async function addMemory() { if (newContent.trim().length < 2) return setAdding(true) try { const res = await fetch('/api/agent/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: newContent.trim(), kind: newKind }), }) if (!res.ok) { // Not-JSON bodies (an HTML error page, an empty 502) leave null, and // getErrorMessage falls back to the status map. const json = await res.json().catch(() => null) toast({ title: 'Kunde inte spara minne', description: getErrorMessage(json, { statusCode: res.status, locale: errorLocale }), variant: 'destructive', }) return } const json = await res.json() setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])]) setNewContent('') setNewKind('fact') setShowAdd(false) toast({ title: 'Minne sparat' }) } catch (err) { // A rejected fetch or a 200 whose body will not parse never reaches the // !res.ok arm above: the draft stays in the form and one toast says the // save did not land. One toast per outcome, never two: TOAST_LIMIT is 1 // (components/ui/use-toast.tsx) and a second would evict the first. toast({ title: 'Kunde inte spara minne', description: getErrorMessage(err, { locale: errorLocale }), variant: 'destructive', }) } finally { setAdding(false) } } function startEdit(row: AgentMemoryRow) { setEditingId(row.id) setEditDraft(row.content) } async function saveEdit(row: AgentMemoryRow) { const next = editDraft.trim() if (next.length < 2 || next === row.content) { setEditingId(null) return } await patch(row.id, { content: next }) setEditingId(null) } // The view wrapper in AssistantSettingsContent already provides the gap // under the segmented control, so the group starts flush (pt-0). return ( Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30 minnen ingår i samtal per tur. } className="pt-0 first:pt-0" > {/* Toolbar row: kind filter + the add-memory entry point. */}
{canWrite && ( )}
{canWrite && (
setNewContent(e.target.value)} placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag." rows={3} maxLength={2000} aria-label="Nytt minne" className="w-full border-border" />
Typ setNewKind(e.target.value as Kind)} aria-label="Typ" > {(Object.keys(KIND_LABEL) as Kind[]).map((k) => ( ))}
)} {/* Dynamic status stays visible; the static "how it's used" copy lives in the group help above. */} {rows && (

{counts.active} aktiva · {counts.pinned} fästa {includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}

)} {/* Live region always mounted so the failure is announced when it appears, not merely inserted. */}
{loadError && ( setReloadKey((k) => k + 1) } } > {loadError.detail ? `Minnena kunde inte läsas in just nu. ${loadError.detail}` : 'Minnena kunde inte läsas in just nu.'} )}
{rows === null && !loadError && (
{[0, 1, 2].map((i) => ( ))}
)} {rows && rows.length === 0 && ( )} {rows && rows.length > 0 && (
    {rows.map((row) => { const isEditing = editingId === row.id const isBusy = busyId === row.id const dimmed = !row.is_active return (
  • {canWrite && row.is_active ? ( ) : (
    {row.is_pinned && }
    )}
    {KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]} {dimmed && Dold}
    {isEditing ? ( setEditDraft(e.target.value)} rows={3} maxLength={2000} autoFocus aria-label="Redigera minne" className="w-full border-border" /> ) : (

    {row.content}

    )}

    Skapad {formatDateLong(row.created_at)} {row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}

    {canWrite && (
    {isEditing ? ( <> ) : row.is_active ? ( <> ) : ( )}
    )}
  • ) })}
)}
) }