Add AI receipt parsing with provider API key settings

- Settings → AI: configure Anthropic or OpenAI provider with encrypted API key
- Sparkle button on each attachment in transaction drawer sends image/PDF to AI
- AI extracts merchant, amount, date, description, category hint
- "Apply to transaction" button patches the transaction with parsed fields
- Anthropic supports images and PDFs; OpenAI supports images only
- API key stored AES-256-GCM encrypted in users table (migration 0003)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 17:07:24 +00:00
parent fe4e69b9ad
commit 22fc1ce2f1
8 changed files with 507 additions and 31 deletions

View file

@ -8,13 +8,14 @@ import {
changePassword, updateProfile, exportData, getMe,
} from "@/api/auth";
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
import { getAiSettings, saveAiSettings, clearAiSettings } from "@/api/settings";
import type { BackupFile } from "@/api/admin";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, Sparkles,
} from "lucide-react";
const SECTIONS = [
@ -23,6 +24,7 @@ const SECTIONS = [
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download },
{ id: "backups", label: "Backups", icon: HardDrive },
{ id: "ai", label: "AI", icon: Sparkles },
] as const;
type Section = (typeof SECTIONS)[number]["id"];
@ -64,6 +66,7 @@ export default function SettingsPage() {
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
{section === "ai" && <AiSection />}
</div>
</div>
</div>
@ -648,6 +651,130 @@ function BackupsSection() {
);
}
// ─── AI ───────────────────────────────────────────────────────────────────────
function AiSection() {
const qc = useQueryClient();
const [provider, setProvider] = useState("anthropic");
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [success, setSuccess] = useState("");
const { data: current, isLoading } = useQuery({
queryKey: ["ai-settings"],
queryFn: getAiSettings,
onSuccess: (d: any) => { if (d.provider) setProvider(d.provider); },
} as any);
const saveMutation = useMutation({
mutationFn: () => saveAiSettings(provider, apiKey),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-settings"] });
setApiKey("");
setSuccess("API key saved");
setTimeout(() => setSuccess(""), 3000);
},
});
const clearMutation = useMutation({
mutationFn: clearAiSettings,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-settings"] });
setSuccess("API key removed");
setTimeout(() => setSuccess(""), 3000);
},
});
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-muted-foreground" />
<SectionTitle>AI Receipt Parsing</SectionTitle>
{!isLoading && current?.has_api_key && (
<span className="ml-auto text-xs px-2 py-0.5 rounded-full font-medium bg-success/15 text-success">
Configured
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
Upload a receipt photo to any transaction and use AI to automatically extract the merchant, amount, date, and description.
Your API key is stored encrypted on your server and never shared.
</p>
{success && <SuccessBanner message={success} />}
{(saveMutation.isError || clearMutation.isError) && (
<ErrorBanner message={
(saveMutation.error as any)?.response?.data?.detail ??
(clearMutation.error as any)?.response?.data?.detail ??
"Failed"
} />
)}
<div>
<label className="text-sm font-medium block mb-1.5">Provider</label>
<select
value={provider}
onChange={e => setProvider(e.target.value)}
className={inputCls}
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT-4o mini)</option>
</select>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">
API Key {current?.has_api_key && <span className="text-muted-foreground font-normal">(leave blank to keep existing)</span>}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className={cn(inputCls, "pr-10")}
placeholder={current?.has_api_key ? "••••••••••••••••" : provider === "anthropic" ? "sk-ant-..." : "sk-..."}
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{provider === "anthropic"
? "Get your key at console.anthropic.com → API Keys"
: "Get your key at platform.openai.com → API Keys"}
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || (!apiKey && !current?.has_api_key)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{current?.has_api_key && !apiKey ? "Update provider" : "Save API key"}
</button>
{current?.has_api_key && (
<button
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
className="flex items-center gap-2 border border-destructive/40 text-destructive px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/10 disabled:opacity-50 transition-colors"
>
{clearMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Remove key
</button>
)}
</div>
</div>
);
}
// ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() {

View file

@ -3,12 +3,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { formatCurrency } from "@/utils/currency";
import type { Transaction, AttachmentRef } from "@/api/transactions";
import { uploadAttachment, deleteAttachment, getAttachmentUrl } from "@/api/transactions";
import { uploadAttachment, deleteAttachment, getAttachmentUrl, updateTransaction } from "@/api/transactions";
import { parseReceipt } from "@/api/settings";
import type { ParsedReceipt } from "@/api/settings";
const TYPE_COLORS = {
income: "text-success",
@ -54,6 +56,9 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
const [attachments, setAttachments] = useState<AttachmentRef[]>(transaction.attachment_refs ?? []);
const [dragging, setDragging] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle;
@ -74,10 +79,37 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
mutationFn: (attachmentId: string) => deleteAttachment(transaction.id, attachmentId),
onSuccess: (_data, attachmentId) => {
setAttachments((prev) => prev.filter((a) => a.id !== attachmentId));
if (parseResult?.attId === attachmentId) setParseResult(null);
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
const parseMutation = useMutation({
mutationFn: (attachmentId: string) => parseReceipt(transaction.id, attachmentId),
onSuccess: (data, attachmentId) => {
setParseResult({ attId: attachmentId, data });
setParseError(null);
},
onError: (err: any) => {
setParseError(err?.response?.data?.detail ?? "Parse failed");
},
});
const applyMutation = useMutation({
mutationFn: (d: ParsedReceipt) => updateTransaction(transaction.id, {
...(d.merchant ? { merchant: d.merchant } : {}),
...(d.amount ? { amount: d.amount } : {}),
...(d.date ? { date: d.date } : {}),
...(d.description ? { description: d.description } : {}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
setParseResult(null);
setApplySuccess(true);
setTimeout(() => setApplySuccess(false), 3000);
},
});
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
setUploadError(null);
@ -176,34 +208,77 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
{attachments.length > 0 && (
<div className="space-y-2 mb-3">
{attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-3 bg-secondary/50 rounded-lg px-3 py-2 group"
>
<FileIcon mimeType={att.mime_type} />
<div className="flex-1 min-w-0">
<a
href={getAttachmentUrl(transaction.id, att.id)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate hover:text-primary transition-colors block"
download={att.filename}
<div key={att.id} className="space-y-2">
<div className="flex items-center gap-3 bg-secondary/50 rounded-lg px-3 py-2 group">
<FileIcon mimeType={att.mime_type} />
<div className="flex-1 min-w-0">
<a
href={getAttachmentUrl(transaction.id, att.id)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate hover:text-primary transition-colors block"
download={att.filename}
>
{att.filename}
</a>
<p className="text-xs text-muted-foreground">{humanFileSize(att.size)}</p>
</div>
<button
onClick={() => { setParseError(null); parseMutation.mutate(att.id); }}
disabled={parseMutation.isPending}
title="Parse with AI"
className="shrink-0 text-muted-foreground hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
>
{att.filename}
</a>
<p className="text-xs text-muted-foreground">{humanFileSize(att.size)}</p>
{parseMutation.isPending && parseMutation.variables === att.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => deleteMutation.mutate(att.id)}
disabled={deleteMutation.isPending}
className="shrink-0 text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
<button
onClick={() => deleteMutation.mutate(att.id)}
disabled={deleteMutation.isPending}
className="shrink-0 text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
{/* Parse result card */}
{parseResult?.attId === att.id && (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3 space-y-2">
<p className="text-xs font-semibold text-primary flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5" /> AI parsed result
</p>
<div className="space-y-1 text-xs">
{parseResult.data.merchant && <div className="flex justify-between"><span className="text-muted-foreground">Merchant</span><span className="font-medium">{parseResult.data.merchant}</span></div>}
{parseResult.data.amount && <div className="flex justify-between"><span className="text-muted-foreground">Amount</span><span className="font-medium">{parseResult.data.amount} {parseResult.data.currency ?? ""}</span></div>}
{parseResult.data.date && <div className="flex justify-between"><span className="text-muted-foreground">Date</span><span className="font-medium">{parseResult.data.date}</span></div>}
{parseResult.data.description && <div className="flex justify-between gap-4"><span className="text-muted-foreground shrink-0">Description</span><span className="font-medium text-right">{parseResult.data.description}</span></div>}
{parseResult.data.category && <div className="flex justify-between"><span className="text-muted-foreground">Category hint</span><span className="font-medium">{parseResult.data.category}</span></div>}
</div>
<div className="flex gap-2 pt-1">
<button
onClick={() => applyMutation.mutate(parseResult.data)}
disabled={applyMutation.isPending}
className="flex items-center gap-1.5 bg-primary text-primary-foreground text-xs px-3 py-1.5 rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{applyMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
Apply to transaction
</button>
<button
onClick={() => setParseResult(null)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded-lg hover:bg-secondary transition-colors"
>
Dismiss
</button>
</div>
</div>
)}
</div>
))}
</div>
@ -250,6 +325,16 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
{uploadError && (
<p className="text-destructive text-xs mt-2">{uploadError}</p>
)}
{parseError && (
<p className="text-destructive text-xs mt-2">{parseError}</p>
)}
{applySuccess && (
<div className="flex items-center gap-1.5 text-success text-xs mt-2">
<CheckCircle className="w-3.5 h-3.5" /> Transaction updated from receipt
</div>
)}
</div>
</div>
</div>