feat(ai): implement AI models management and configuration in admin panel
This commit is contained in:
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
|
import { AiController } from './ai.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AiController],
|
||||||
providers: [AiService],
|
providers: [AiService],
|
||||||
exports: [AiService],
|
exports: [AiService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'
|
|||||||
import { FlatCategory } from '../categories/categories.service';
|
import { FlatCategory } from '../categories/categories.service';
|
||||||
|
|
||||||
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
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 = {
|
export type CategorySuggestion = {
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AiService } from '../ai/ai.service';
|
|||||||
import { CategoriesService } from '../categories/categories.service';
|
import { CategoriesService } from '../categories/categories.service';
|
||||||
|
|
||||||
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
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.
|
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:
|
Varje vara ska ha följande fält:
|
||||||
@@ -78,7 +79,7 @@ export class ReceiptImportService {
|
|||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'pixtral-12b-2409',
|
model: RECEIPT_IMPORT_MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -126,7 +127,7 @@ export class ReceiptImportService {
|
|||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: RECEIPT_IMPORT_MODEL,
|
||||||
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
|
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
|
||||||
max_tokens: 2000,
|
max_tokens: 2000,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
NEXT_PUBLIC_API_URL_INTERNAL: "http://recipe-api:8080"
|
NEXT_PUBLIC_API_URL_INTERNAL: "http://recipe-api:8080"
|
||||||
AUTH_SECRET: "${AUTH_SECRET}"
|
AUTH_SECRET: "${AUTH_SECRET}"
|
||||||
AUTH_URL: "${NEXT_PUBLIC_APP_URL}"
|
AUTH_URL: "${NEXT_PUBLIC_APP_URL}"
|
||||||
|
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- recipe_images:/app/public/images
|
- recipe_images:/app/public/images
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default async function Navigation() {
|
|||||||
<>
|
<>
|
||||||
<Link href="/admin/products" style={linkStyle}>⚙️ Admin</Link>
|
<Link href="/admin/products" style={linkStyle}>⚙️ Admin</Link>
|
||||||
<Link href="/admin/products/pending" style={linkStyle}>⏳ Förslag</Link>
|
<Link href="/admin/products/pending" style={linkStyle}>⏳ Förslag</Link>
|
||||||
|
<Link href="/admin/ai" style={linkStyle}>🤖 AI</Link>
|
||||||
<Link href="/profil?tab=anvandare" style={linkStyle}>👥 Användare</Link>
|
<Link href="/profil?tab=anvandare" style={linkStyle}>👥 Användare</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<KeyMeta>({ createdAt: '', validityMonths: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) setMeta(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveMeta = (patch: Partial<KeyMeta>) => {
|
||||||
|
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 (
|
||||||
|
<span style={{ fontSize: '0.78rem', background: color, color: '#fff', borderRadius: '4px', padding: '2px 7px', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||||||
|
{model}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessChip = (access: string) => {
|
||||||
|
const isAdmin = access === 'Admin';
|
||||||
|
const isPremium = access.includes('Premium');
|
||||||
|
const bg = isAdmin ? '#7c3aed' : isPremium ? '#f59e0b' : '#10b981';
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: '0.75rem', background: bg, color: '#fff', borderRadius: '4px', padding: '2px 7px', whiteSpace: 'nowrap' }}>
|
||||||
|
{access}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* API-nyckel */}
|
||||||
|
<section style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: '10px', padding: '1.5rem', marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
🔑 Mistral API-nyckel
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1.5rem', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
|
<span style={{ color: '#555', fontSize: '0.9rem' }}>Status</span>
|
||||||
|
<span>
|
||||||
|
{hasKey
|
||||||
|
? <span style={{ color: '#10b981', fontWeight: 600 }}>✓ Konfigurerad</span>
|
||||||
|
: <span style={{ color: '#ef4444', fontWeight: 600 }}>✗ Saknas (MISTRAL_API_KEY ej satt)</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#555', fontSize: '0.9rem' }}>Nyckel (sista 4)</span>
|
||||||
|
<code style={{ fontFamily: 'monospace', fontSize: '1rem', letterSpacing: '0.15em', background: '#f3f4f6', padding: '2px 8px', borderRadius: '4px' }}>
|
||||||
|
****{keyHint}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', alignItems: 'end' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
Skapad datum
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={meta.createdAt}
|
||||||
|
onChange={(e) => saveMeta({ createdAt: e.target.value })}
|
||||||
|
style={{ padding: '0.4rem 0.6rem', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '0.9rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', fontSize: '0.85rem', color: '#374151' }}>
|
||||||
|
Giltighet (månader)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="120"
|
||||||
|
value={meta.validityMonths}
|
||||||
|
onChange={(e) => saveMeta({ validityMonths: e.target.value })}
|
||||||
|
placeholder="t.ex. 12"
|
||||||
|
style={{ padding: '0.4rem 0.6rem', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '0.9rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ paddingBottom: '2px' }}>
|
||||||
|
{expiryDate && daysLeft !== null ? (
|
||||||
|
<div style={{ background: daysLeft <= 14 ? '#fef2f2' : daysLeft <= 30 ? '#fffbeb' : '#f0fdf4', border: `1px solid ${daysLeft <= 14 ? '#fecaca' : daysLeft <= 30 ? '#fde68a' : '#bbf7d0'}`, borderRadius: '8px', padding: '0.6rem 0.9rem' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#6b7280', marginBottom: '2px' }}>Förfaller {expiryDate}</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '1.1rem', color: daysLeft <= 14 ? '#dc2626' : daysLeft <= 30 ? '#d97706' : '#16a34a' }}>
|
||||||
|
{daysLeft <= 0 ? '⚠️ Nyckel har förfallit!' : `${daysLeft} dagar kvar`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '0.85rem', padding: '0.6rem 0' }}>
|
||||||
|
Fyll i datum och giltighet för att se återstående tid
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI-funktioner */}
|
||||||
|
<section style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: '10px', padding: '1.5rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem' }}>✨ Implementerade AI-funktioner</h2>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Funktion</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Modell</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Åtkomst</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Utlösare</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Sida</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aiFunctions.map((fn, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid #f3f4f6', verticalAlign: 'top' }}>
|
||||||
|
<td style={{ padding: '0.65rem 0.75rem' }}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '0.2rem' }}>{fn.name}</div>
|
||||||
|
<div style={{ color: '#6b7280', fontSize: '0.8rem', lineHeight: 1.4 }}>{fn.description}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.65rem 0.75rem' }}>{modelChip(fn.model)}</td>
|
||||||
|
<td style={{ padding: '0.65rem 0.75rem' }}>{accessChip(fn.access)}</td>
|
||||||
|
<td style={{ padding: '0.65rem 0.75rem', color: '#4b5563', fontSize: '0.82rem' }}>{fn.trigger}</td>
|
||||||
|
<td style={{ padding: '0.65rem 0.75rem' }}>
|
||||||
|
<a href={fn.path} style={{ color: '#0070f3', textDecoration: 'none', fontFamily: 'monospace', fontSize: '0.82rem' }}>{fn.path}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: '#6b7280' }}>
|
||||||
|
<span style={{ background: '#6366f1', color: '#fff', borderRadius: '4px', padding: '1px 6px', fontFamily: 'monospace', fontSize: '0.75rem' }}>tiny</span>
|
||||||
|
Snabb och kostnadseffektiv — text- och bildtolkning
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: '#6b7280' }}>
|
||||||
|
<span style={{ background: '#0ea5e9', color: '#fff', borderRadius: '4px', padding: '1px 6px', fontFamily: 'monospace', fontSize: '0.75rem' }}>small</span>
|
||||||
|
Bättre resoneringsförmåga — kategorisering och matchning
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Navigation />
|
||||||
|
<main style={{ maxWidth: '900px', margin: '0 auto', padding: '0 1rem 2rem' }}>
|
||||||
|
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h1>
|
||||||
|
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '0.9rem' }}>
|
||||||
|
Översikt över implementerade AI-funktioner och API-nyckelstatus.
|
||||||
|
</p>
|
||||||
|
<AiAdminClient keyHint={keyHint} hasKey={hasKey} aiFunctions={aiFunctions} />
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user