chore: Prepare for Tailwind v4 migration
This commit is contained in:
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -4,15 +4,19 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
let supabaseClient: SupabaseClient | null = null;
|
let supabaseClient: SupabaseClient | null = null;
|
||||||
|
|
||||||
export function createClient() {
|
export function createClient() {
|
||||||
if (supabaseClient) return supabaseClient;
|
if (typeof window === 'undefined') {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
return createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
throw new Error('Supabase URL and Anon Key must be defined');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
|
// Singleton for client-side to prevent multiple instances
|
||||||
return supabaseClient;
|
// Use window object to persist across module reloads in dev
|
||||||
|
if (!(window as any).supabase) {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
(window as any).supabase = createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as any).supabase as SupabaseClient;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export async function middleware(request: NextRequest) {
|
|||||||
path.startsWith("/_next"); // Static assets
|
path.startsWith("/_next"); // Static assets
|
||||||
|
|
||||||
// 2. Specialized Logic for /splits
|
// 2. Specialized Logic for /splits
|
||||||
// - Public: /splits/[slug]
|
|
||||||
// - Protected: /splits/create, /splits/manage
|
|
||||||
const isSplitsPublic =
|
const isSplitsPublic =
|
||||||
path.startsWith("/splits/") &&
|
path.startsWith("/splits/") &&
|
||||||
!path.startsWith("/splits/create") &&
|
!path.startsWith("/splits/create") &&
|
||||||
@@ -75,11 +73,6 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(redirectUrl);
|
return NextResponse.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Admin Protection (Optional Layer in Middleware, but Page also checks)
|
|
||||||
// We rely on Page component for Admin role check to avoid DB hit in middleware if possible,
|
|
||||||
// OR we can trust getUser() + Page logic.
|
|
||||||
// Middleware mainly ensures *Authentication*. Authorization is Page level.
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
src/proxy.ts
53
src/proxy.ts
@@ -1,53 +0,0 @@
|
|||||||
import { createServerClient, type CookieOptions } from '@supabase/ssr';
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
|
||||||
let response = NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const supabase = createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
request.cookies.set(name, value)
|
|
||||||
);
|
|
||||||
response = NextResponse.next({
|
|
||||||
request: {
|
|
||||||
headers: request.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
response.cookies.set(name, value, options)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
|
||||||
|
|
||||||
if (!isStatic) {
|
|
||||||
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
|
|
||||||
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
191
src/services/analyze-bottle-mistral.ts
Normal file
191
src/services/analyze-bottle-mistral.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { Mistral } from '@mistralai/mistralai';
|
||||||
|
import { getSystemPrompt } from '@/lib/ai-prompts';
|
||||||
|
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { trackApiUsage } from './track-api-usage';
|
||||||
|
import { checkCreditBalance, deductCredits } from './credit-service';
|
||||||
|
|
||||||
|
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
|
||||||
|
export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse & { search_string?: string }> {
|
||||||
|
if (!process.env.MISTRAL_API_KEY) {
|
||||||
|
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let supabase;
|
||||||
|
try {
|
||||||
|
// Helper to get value from either FormData or POJO
|
||||||
|
const getValue = (obj: any, key: string): any => {
|
||||||
|
if (obj && typeof obj.get === 'function') return obj.get(key);
|
||||||
|
if (obj && typeof obj[key] !== 'undefined') return obj[key];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Daten extrahieren
|
||||||
|
const file = getValue(input, 'file') as File;
|
||||||
|
const tagsString = getValue(input, 'tags') as string;
|
||||||
|
const locale = getValue(input, 'locale') || 'de';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Kein Bild empfangen.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
|
||||||
|
|
||||||
|
// 2. Auth & Credits
|
||||||
|
supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||||
|
if (!creditCheck.allowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datei in Buffer umwandeln
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// 4. Hash für Cache erstellen
|
||||||
|
const imageHash = createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
|
// Cache Check
|
||||||
|
const { data: cachedResult } = await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.select('result')
|
||||||
|
.eq('hash', imageHash)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedResult.result as any,
|
||||||
|
perf: {
|
||||||
|
apiDuration: 0,
|
||||||
|
parseDuration: 0,
|
||||||
|
uploadSize: buffer.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Für Mistral vorbereiten
|
||||||
|
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
|
||||||
|
const base64Data = buffer.toString('base64');
|
||||||
|
const mimeType = file.type || 'image/webp';
|
||||||
|
const dataUrl = `data:${mimeType};base64,${base64Data}`;
|
||||||
|
const uploadSize = buffer.length;
|
||||||
|
|
||||||
|
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startApi = performance.now();
|
||||||
|
const chatResponse = await client.chat.complete({
|
||||||
|
model: 'mistral-large-latest',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: prompt },
|
||||||
|
{ type: 'image_url', imageUrl: dataUrl }H
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responseFormat: { type: 'json_object' },
|
||||||
|
temperature: 0.1
|
||||||
|
});
|
||||||
|
const endApi = performance.now();
|
||||||
|
|
||||||
|
const startParse = performance.now();
|
||||||
|
const rawContent = chatResponse.choices?.[0].message.content;
|
||||||
|
if (!rawContent) throw new Error("Keine Antwort von Mistral");
|
||||||
|
|
||||||
|
let jsonData;
|
||||||
|
try {
|
||||||
|
jsonData = JSON.parse(rawContent as string);
|
||||||
|
} catch (e) {
|
||||||
|
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
|
||||||
|
jsonData = JSON.parse(cleanedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||||
|
console.log('[Mistral AI] JSON Response:', jsonData);
|
||||||
|
|
||||||
|
const searchString = jsonData.search_string;
|
||||||
|
delete jsonData.search_string;
|
||||||
|
|
||||||
|
if (typeof jsonData.abv === 'string') {
|
||||||
|
// Fix: Global replace to remove all % signs
|
||||||
|
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
|
||||||
|
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
|
||||||
|
|
||||||
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
|
const endParse = performance.now();
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: 'mistral/mistral-large',
|
||||||
|
success: true,
|
||||||
|
provider: 'mistral',
|
||||||
|
model: 'mistral-large-latest',
|
||||||
|
responseText: rawContent as string
|
||||||
|
});
|
||||||
|
|
||||||
|
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('vision_cache')
|
||||||
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: validatedData,
|
||||||
|
search_string: searchString,
|
||||||
|
perf: {
|
||||||
|
apiDuration: endApi - startApi,
|
||||||
|
parseDuration: endParse - startParse,
|
||||||
|
uploadSize: uploadSize
|
||||||
|
},
|
||||||
|
raw: jsonData
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (aiError: any) {
|
||||||
|
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
|
||||||
|
|
||||||
|
await trackApiUsage({
|
||||||
|
userId: userId,
|
||||||
|
apiType: 'gemini_ai',
|
||||||
|
endpoint: 'mistral/mistral-large',
|
||||||
|
success: false,
|
||||||
|
errorMessage: aiError.message,
|
||||||
|
provider: 'mistral',
|
||||||
|
model: 'mistral-large-latest'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isAiError: true,
|
||||||
|
error: aiError.message,
|
||||||
|
imageHash: imageHash
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mistral Analysis Global Error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user