From f3db5ba51aefa80b3b5c1fbd83abdfb74e172487 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 19 Apr 2026 11:07:15 +0200 Subject: [PATCH] feat(ai): implement AI models management and configuration in admin panel --- backend/src/ai/ai.controller.ts | 69 ++++++ backend/src/ai/ai.module.ts | 2 + backend/src/ai/ai.service.ts | 3 +- .../receipt-import/receipt-import.service.ts | 5 +- compose.yml | 1 + frontend/app/Navigation.tsx | 1 + frontend/app/admin/ai/AiAdminClient.tsx | 197 ++++++++++++++++++ frontend/app/admin/ai/page.tsx | 39 ++++ frontend/app/api/ai-config/route.ts | 15 ++ frontend/app/api/ai-models/route.ts | 12 ++ 10 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 backend/src/ai/ai.controller.ts create mode 100644 frontend/app/admin/ai/AiAdminClient.tsx create mode 100644 frontend/app/admin/ai/page.tsx create mode 100644 frontend/app/api/ai-config/route.ts create mode 100644 frontend/app/api/ai-models/route.ts diff --git a/backend/src/ai/ai.controller.ts b/backend/src/ai/ai.controller.ts new file mode 100644 index 00000000..b6946b55 --- /dev/null +++ b/backend/src/ai/ai.controller.ts @@ -0,0 +1,69 @@ +import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +import { AI_CATEGORIZATION_MODEL } from './ai.service'; +import { RECEIPT_IMPORT_MODEL } from '../receipt-import/receipt-import.service'; + +export interface AiModelInfo { + id: string; + name: string; + description: string; + model: string; + path: string; + trigger: string; + access: string; +} + +@Controller('ai') +export class AiController { + @Get('models') + @Public() + getModels(): AiModelInfo[] { + return [ + { + id: 'receipt-pdf', + name: 'Kvittoimport — PDF-tolkning', + description: 'Extraherar varunamn, mängd och pris ur PDF-kvitton via textanalys.', + model: RECEIPT_IMPORT_MODEL, + path: '/import', + trigger: 'Vid uppladdning av PDF-kvitto', + access: 'Alla inloggade', + }, + { + id: 'receipt-image', + name: 'Kvittoimport — Bildtolkning', + description: 'Extraherar varunamn, mängd och pris ur kvittofoton via bildanalys.', + model: RECEIPT_IMPORT_MODEL, + path: '/import', + trigger: 'Vid uppladdning av kvittobild (JPEG, PNG, WebP, HEIC)', + access: 'Alla inloggade', + }, + { + id: 'receipt-category', + name: 'Kvittoimport — Kategorisuggestion', + description: 'För varor som inte matchas mot befintliga produkter visas ett AI-förslag på kategori som ledtråd.', + model: AI_CATEGORIZATION_MODEL, + path: '/import', + trigger: 'Automatiskt efter kvittotolkning (om inga träffar hittas)', + access: 'Premium-användare + Admin', + }, + { + id: 'product-suggest', + name: 'AI-kategorisering per produkt', + description: 'Ger ett AI-förslag på kategori för en enskild produkt med säkerhetsindikation (hög/medel/låg).', + model: AI_CATEGORIZATION_MODEL, + path: '/admin/products', + trigger: 'Manuell — klick på "✨ Fråga AI" i produktlistan', + access: 'Admin', + }, + { + id: 'product-bulk', + name: 'AI-bulk-kategorisering', + description: 'Analyserar alla okategoriserade produkter och presenterar förslag i ett bekräftelsemodal.', + model: AI_CATEGORIZATION_MODEL, + path: '/admin/products', + trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"', + access: 'Admin', + }, + ]; + } +} diff --git a/backend/src/ai/ai.module.ts b/backend/src/ai/ai.module.ts index 3bed5a9a..b2da299d 100644 --- a/backend/src/ai/ai.module.ts +++ b/backend/src/ai/ai.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { AiService } from './ai.service'; +import { AiController } from './ai.controller'; @Module({ + controllers: [AiController], providers: [AiService], exports: [AiService], }) diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts index e19cf9ad..4be5dc89 100644 --- a/backend/src/ai/ai.service.ts +++ b/backend/src/ai/ai.service.ts @@ -2,7 +2,8 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common' import { FlatCategory } from '../categories/categories.service'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; -const MODEL = 'mistral-small-2603'; +export const AI_CATEGORIZATION_MODEL = 'mistral-small-2603'; +const MODEL = AI_CATEGORIZATION_MODEL; export type CategorySuggestion = { categoryId: number; diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index b786ec5e..e8c49979 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -11,6 +11,7 @@ import { AiService } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +export const RECEIPT_IMPORT_MODEL = 'mistral-tiny-2603'; const IMAGE_PROMPT = `Du är en kvittoläsare. Analysera detta kvitto och returnera ENDAST en JSON-array med alla köpta varor. Varje vara ska ha följande fält: @@ -78,7 +79,7 @@ export class ReceiptImportService { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: 'pixtral-12b-2409', + model: RECEIPT_IMPORT_MODEL, messages: [ { role: 'user', @@ -126,7 +127,7 @@ export class ReceiptImportService { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: RECEIPT_IMPORT_MODEL, messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], max_tokens: 2000, temperature: 0.1, diff --git a/compose.yml b/compose.yml index 11451824..bb0a6c9c 100644 --- a/compose.yml +++ b/compose.yml @@ -15,6 +15,7 @@ services: NEXT_PUBLIC_API_URL_INTERNAL: "http://recipe-api:8080" AUTH_SECRET: "${AUTH_SECRET}" AUTH_URL: "${NEXT_PUBLIC_APP_URL}" + MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}" volumes: - recipe_images:/app/public/images depends_on: diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index 371bee58..ea3b7658 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -39,6 +39,7 @@ export default async function Navigation() { <> ⚙️ Admin ⏳ Förslag + 🤖 AI 👥 Användare )} diff --git a/frontend/app/admin/ai/AiAdminClient.tsx b/frontend/app/admin/ai/AiAdminClient.tsx new file mode 100644 index 00000000..6d00675c --- /dev/null +++ b/frontend/app/admin/ai/AiAdminClient.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export interface AiModelInfo { + id: string; + name: string; + description: string; + model: string; + path: string; + trigger: string; + access: string; +} + +const STORAGE_KEY = 'mistral_api_key_meta'; + +interface KeyMeta { + createdAt: string; + validityMonths: string; +} + +interface Props { + keyHint: string; + hasKey: boolean; + aiFunctions: AiModelInfo[]; +} + +export default function AiAdminClient({ keyHint, hasKey, aiFunctions }: Props) { + const [meta, setMeta] = useState({ createdAt: '', validityMonths: '' }); + + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) setMeta(JSON.parse(stored)); + } catch { + // ignore + } + }, []); + + const saveMeta = (patch: Partial) => { + const updated = { ...meta, ...patch }; + setMeta(updated); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + // ignore + } + }; + + const { daysLeft, expiryDate } = computeExpiry(meta.createdAt, meta.validityMonths); + + const modelChip = (model: string) => { + const color = model.includes('tiny') ? '#6366f1' : '#0ea5e9'; + return ( + + {model} + + ); + }; + + const accessChip = (access: string) => { + const isAdmin = access === 'Admin'; + const isPremium = access.includes('Premium'); + const bg = isAdmin ? '#7c3aed' : isPremium ? '#f59e0b' : '#10b981'; + return ( + + {access} + + ); + }; + + return ( +
+ {/* API-nyckel */} +
+

+ 🔑 Mistral API-nyckel +

+
+ Status + + {hasKey + ? ✓ Konfigurerad + : ✗ Saknas (MISTRAL_API_KEY ej satt)} + + Nyckel (sista 4) + + ****{keyHint} + +
+ +
+ + +
+ {expiryDate && daysLeft !== null ? ( +
+
Förfaller {expiryDate}
+
+ {daysLeft <= 0 ? '⚠️ Nyckel har förfallit!' : `${daysLeft} dagar kvar`} +
+
+ ) : ( +
+ Fyll i datum och giltighet för att se återstående tid +
+ )} +
+
+
+ + {/* AI-funktioner */} +
+

✨ Implementerade AI-funktioner

+
+ + + + + + + + + + + + {aiFunctions.map((fn, i) => ( + + + + + + + + ))} + +
FunktionModellÅtkomstUtlösareSida
+
{fn.name}
+
{fn.description}
+
{modelChip(fn.model)}{accessChip(fn.access)}{fn.trigger} + {fn.path} +
+
+
+
+ tiny + Snabb och kostnadseffektiv — text- och bildtolkning +
+
+ small + Bättre resoneringsförmåga — kategorisering och matchning +
+
+
+
+ ); +} + +function computeExpiry(createdAt: string, validityMonths: string): { daysLeft: number | null; expiryDate: string | null } { + if (!createdAt || !validityMonths) return { daysLeft: null, expiryDate: null }; + const months = parseInt(validityMonths, 10); + if (isNaN(months) || months <= 0) return { daysLeft: null, expiryDate: null }; + + const created = new Date(createdAt); + if (isNaN(created.getTime())) return { daysLeft: null, expiryDate: null }; + + const expiry = new Date(created); + expiry.setMonth(expiry.getMonth() + months); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + expiry.setHours(0, 0, 0, 0); + + const daysLeft = Math.round((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + const expiryDate = expiry.toLocaleDateString('sv-SE'); + + return { daysLeft, expiryDate }; +} diff --git a/frontend/app/admin/ai/page.tsx b/frontend/app/admin/ai/page.tsx new file mode 100644 index 00000000..352be0ef --- /dev/null +++ b/frontend/app/admin/ai/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from 'next/navigation'; +import { auth } from '../../../auth'; +import Navigation from '../../Navigation'; +import AiAdminClient from './AiAdminClient'; +import type { AiModelInfo } from './AiAdminClient'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export default async function AiAdminPage() { + const session = await auth(); + if ((session?.user as any)?.role !== 'admin') { + redirect('/'); + } + + const key = process.env.MISTRAL_API_KEY ?? ''; + const hasKey = key.length > 0; + const keyHint = key.length >= 4 ? key.slice(-4) : '????'; + + let aiFunctions: AiModelInfo[] = []; + try { + const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' }); + if (res.ok) aiFunctions = await res.json(); + } catch { + // backend ej nåbart — visa tom lista + } + + return ( + <> + +
+

🤖 AI-konfiguration

+

+ Översikt över implementerade AI-funktioner och API-nyckelstatus. +

+ +
+ + ); +} diff --git a/frontend/app/api/ai-config/route.ts b/frontend/app/api/ai-config/route.ts new file mode 100644 index 00000000..2a144eda --- /dev/null +++ b/frontend/app/api/ai-config/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { auth } from '../../../auth'; + +export async function GET() { + const session = await auth(); + if ((session?.user as any)?.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const key = process.env.MISTRAL_API_KEY ?? ''; + const keyHint = key.length >= 4 ? key.slice(-4) : '????'; + const hasKey = key.length > 0; + + return NextResponse.json({ keyHint, hasKey }); +} diff --git a/frontend/app/api/ai-models/route.ts b/frontend/app/api/ai-models/route.ts new file mode 100644 index 00000000..ca6bdeee --- /dev/null +++ b/frontend/app/api/ai-models/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET() { + const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +}