39b91d8c87
feat(ai): enhance AI admin client with status messages for API key configuration refactor(api): remove authorization check from products route
220 lines
11 KiB
TypeScript
220 lines
11 KiB
TypeScript
'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>
|
||
{!hasKey && (
|
||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem', color: '#991b1b' }}>
|
||
⚠️ <strong>MISTRAL_API_KEY är inte konfigurerad</strong> — alla AI-funktioner är inaktiva tills nyckeln sätts i miljövariablerna.
|
||
</div>
|
||
)}
|
||
{hasKey && (
|
||
<div style={{ background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.85rem', color: '#92400e' }}>
|
||
ℹ️ Status <strong>Konfigurerad</strong> innebär att API-nyckeln är satt. Om Mistral svarar med 503 är det ett tillfälligt serverfel hos Mistral — inte ett konfigurationsproblem.
|
||
</div>
|
||
)}
|
||
<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 }}>Status</th>
|
||
<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', opacity: hasKey ? 1 : 0.55 }}>
|
||
<td style={{ padding: '0.65rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||
{hasKey ? (
|
||
<span title="API-nyckel konfigurerad" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.78rem', background: '#dcfce7', color: '#166534', border: '1px solid #bbf7d0', borderRadius: '4px', padding: '2px 7px' }}>
|
||
✓ Konfigurerad
|
||
</span>
|
||
) : (
|
||
<span title="MISTRAL_API_KEY saknas" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.78rem', background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: '4px', padding: '2px 7px' }}>
|
||
✗ Inaktiv
|
||
</span>
|
||
)}
|
||
</td>
|
||
<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 };
|
||
}
|