8a9162b948
The "Synka bank nu" row in the transactions Importera split button fired syncAll() with zero visual feedback. SplitButton now takes busy/busyLabel per option: the primary face and the menu row swap to a spinning Loader2, show the busy label and go inert until the action resolves. useBankSync holds isBusy across the whole syncAll loop so the spinner does not flicker between per-connection syncs. Sweep of the rest of the system for async buttons missing the same feedback (convention: disabled + Loader2 animate-spin + label swap): - bokslut DigitalInlamning "Uppdatera status" (Bolagsverket poll): had no feedback at all; now disabled + spinner + "Uppdaterar ..." while polling - Stripe settings "Synka nu": had disabled + label swap but a static icon - Skatteverket "Verifiera": had disabled + label swap but no spinner - AgentMemoryPanel "Dolj"/"Aterstall" row actions: static icons on async patch; now swap to spinner for the busy row Checked and intentionally unchanged: Arcim migration "Synka igen" and "Ateranslut" (the whole step flips to a spinner view synchronously on click), skattekonto "Forsok igen" (page flips to loading view), AgentChat "Generera om" (streaming indicator is the feedback). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
|
import { formatDateLong } from '@/lib/utils'
|
|
|
|
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
|
|
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
|
|
|
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<Kind, string> = {
|
|
fact: 'Fakta',
|
|
preference: 'Preferens',
|
|
pattern: 'Mönster',
|
|
correction: 'Korrigering',
|
|
}
|
|
|
|
const SOURCE_LABEL: Record<Source, string> = {
|
|
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' },
|
|
]
|
|
|
|
// The API returns errors either as a plain string (legacy/validation) or as
|
|
// the canonical { code, message } envelope — extract something renderable.
|
|
function apiErrorText(error: unknown): string | undefined {
|
|
if (typeof error === 'string') return error
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
const m = (error as { message?: unknown }).message
|
|
return typeof m === 'string' ? m : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function AgentMemoryPanel() {
|
|
const { toast } = useToast()
|
|
const { canWrite } = useCanWrite()
|
|
|
|
const [rows, setRows] = useState<AgentMemoryRow[] | null>(null)
|
|
const [includeDismissed, setIncludeDismissed] = useState(false)
|
|
const [kindFilter, setKindFilter] = useState<'all' | Kind>('all')
|
|
const [busyId, setBusyId] = useState<string | null>(null)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [editDraft, setEditDraft] = useState('')
|
|
const [showAdd, setShowAdd] = useState(false)
|
|
const [newContent, setNewContent] = useState('')
|
|
const [newKind, setNewKind] = useState<Kind>('fact')
|
|
const [adding, setAdding] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
const params = new URLSearchParams()
|
|
if (includeDismissed) params.set('include_dismissed', 'true')
|
|
if (kindFilter !== 'all') params.set('kind', kindFilter)
|
|
const res = await fetch(`/api/agent/memory?${params.toString()}`)
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte hämta minne', description: apiErrorText(json.error), variant: 'destructive' })
|
|
setRows([])
|
|
return
|
|
}
|
|
setRows(json.data as AgentMemoryRow[])
|
|
}, [includeDismissed, kindFilter, toast])
|
|
|
|
useEffect(() => { void load() }, [load])
|
|
|
|
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<Pick<AgentMemoryRow, 'content' | 'is_pinned' | 'is_active'>>) {
|
|
setBusyId(id)
|
|
try {
|
|
const res = await fetch(`/api/agent/memory/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte uppdatera', description: apiErrorText(json.error), variant: 'destructive' })
|
|
return
|
|
}
|
|
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
|
|
} 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 }),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte spara minne', description: apiErrorText(json.error), variant: 'destructive' })
|
|
return
|
|
}
|
|
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
|
|
setNewContent('')
|
|
setNewKind('fact')
|
|
setShowAdd(false)
|
|
toast({ title: 'Minne sparat' })
|
|
} 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)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-base">Vad min assistent kommer ihåg</CardTitle>
|
|
<CardDescription>
|
|
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.
|
|
</CardDescription>
|
|
</div>
|
|
{canWrite && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowAdd((v) => !v)}
|
|
disabled={adding}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Lägg till minne
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-6">
|
|
{showAdd && canWrite && (
|
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
<Textarea
|
|
value={newContent}
|
|
onChange={(e) => 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}
|
|
/>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">Typ</span>
|
|
<Select value={newKind} onValueChange={(v) => setNewKind(v as Kind)}>
|
|
<SelectTrigger className="h-8 w-[160px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
|
|
<SelectItem key={k} value={k}>{KIND_LABEL[k]}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
|
|
Avbryt
|
|
</Button>
|
|
<Button size="sm" onClick={addMemory} disabled={adding || newContent.trim().length < 2}>
|
|
{adding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
Spara
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-wrap gap-1">
|
|
{KIND_FILTER.map((f) => {
|
|
const active = kindFilter === f.value
|
|
return (
|
|
<button
|
|
key={f.value}
|
|
onClick={() => setKindFilter(f.value)}
|
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
|
active
|
|
? 'bg-secondary text-foreground'
|
|
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeDismissed}
|
|
onChange={(e) => setIncludeDismissed(e.target.checked)}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
Visa dolda
|
|
</label>
|
|
</div>
|
|
|
|
{rows && (
|
|
<div className="text-xs text-muted-foreground tabular-nums">
|
|
{counts.active} aktiva · {counts.pinned} fästa
|
|
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
|
|
<span className="ml-1">(upp till 30 ingår i samtal per tur)</span>
|
|
</div>
|
|
)}
|
|
|
|
{rows === null && (
|
|
<div className="space-y-3">
|
|
{[0, 1, 2].map((i) => (
|
|
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{rows && rows.length === 0 && (
|
|
<EmptyState
|
|
icon={Brain}
|
|
title="Inga minnen ännu"
|
|
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
|
|
/>
|
|
)}
|
|
|
|
{rows && rows.length > 0 && (
|
|
<ul className="space-y-3">
|
|
{rows.map((row) => {
|
|
const isEditing = editingId === row.id
|
|
const isBusy = busyId === row.id
|
|
const dimmed = !row.is_active
|
|
return (
|
|
<li
|
|
key={row.id}
|
|
className={`rounded-lg border border-border p-4 transition-colors ${
|
|
dimmed ? 'bg-muted/30 opacity-70' : 'bg-card'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{canWrite && row.is_active ? (
|
|
<button
|
|
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
|
|
disabled={isBusy}
|
|
className={`mt-0.5 shrink-0 rounded-md p-1.5 transition-colors ${
|
|
row.is_pinned
|
|
? 'text-foreground bg-secondary'
|
|
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
|
}`}
|
|
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
|
|
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
|
|
>
|
|
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
|
|
</button>
|
|
) : (
|
|
<div className="mt-0.5 shrink-0 p-1.5">
|
|
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
|
|
</span>
|
|
{dimmed && <Badge variant="secondary">Dold</Badge>}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<Textarea
|
|
value={editDraft}
|
|
onChange={(e) => setEditDraft(e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-foreground whitespace-pre-wrap break-words">{row.content}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
|
<p className="text-[11px] text-muted-foreground tabular-nums">
|
|
Skapad {formatDateLong(row.created_at)}
|
|
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
|
|
</p>
|
|
|
|
{canWrite && (
|
|
<div className="flex items-center gap-1">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingId(null)}
|
|
disabled={isBusy}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Avbryt</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => saveEdit(row)}
|
|
disabled={isBusy || editDraft.trim().length < 2}
|
|
>
|
|
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
|
|
</Button>
|
|
</>
|
|
) : row.is_active ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => startEdit(row)}
|
|
disabled={isBusy}
|
|
>
|
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
|
Redigera
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => patch(row.id, { is_active: false })}
|
|
disabled={isBusy}
|
|
>
|
|
{isBusy ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
Dölj
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => patch(row.id, { is_active: true })}
|
|
disabled={isBusy}
|
|
>
|
|
{isBusy ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
Återställ
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|