mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement database and AI provider configuration settings with Google GenAI integration
This commit is contained in:
1 parent
7372885e48
commit
d0ca0c3602
7 files changed
+639
-108
No files matched your search
Generated
+360
-95
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@
|
||||
"docker:build": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.49.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
|
||||
@@ -145,9 +145,18 @@ function MessageBubble({ msg }: { msg: Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
const GOOGLE_MODELS: ModelInfo[] = [
|
||||
{ id: "gemini-2.5-pro-preview-05-06", name: "Gemini 2.5 Pro Preview", free: false, promptPrice: 0, completionPrice: 0 },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", free: false, promptPrice: 0, completionPrice: 0 },
|
||||
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking", free: false, promptPrice: 0, completionPrice: 0 },
|
||||
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", free: false, promptPrice: 0, completionPrice: 0 },
|
||||
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", free: false, promptPrice: 0, completionPrice: 0 },
|
||||
];
|
||||
|
||||
export default function AdminAiChatPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
|
||||
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -161,15 +170,31 @@ export default function AdminAiChatPage() {
|
||||
|
||||
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
|
||||
|
||||
// Fetch models
|
||||
// Fetch config + models
|
||||
useEffect(() => {
|
||||
setLoadingModels(true);
|
||||
fetch("/api/openrouter/models")
|
||||
fetch("/api/admin/ai-config")
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.models) setModels(d.models);
|
||||
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
|
||||
setAiProviderState(provider);
|
||||
if (provider === "google") {
|
||||
setModels(GOOGLE_MODELS);
|
||||
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
|
||||
} else {
|
||||
fetch("/api/openrouter/models")
|
||||
.then(r => r.json())
|
||||
.then(od => { if (od.models) setModels(od.models); })
|
||||
.catch(() => { });
|
||||
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
fetch("/api/openrouter/models")
|
||||
.then(r => r.json())
|
||||
.then(od => { if (od.models) setModels(od.models); })
|
||||
.catch(() => { });
|
||||
})
|
||||
.catch(() => { })
|
||||
.finally(() => setLoadingModels(false));
|
||||
}, []);
|
||||
|
||||
@@ -335,7 +360,14 @@ export default function AdminAiChatPage() {
|
||||
{/* Model selector */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className="text-xs font-medium text-[var(--text-muted)] shrink-0">Modèle</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
|
||||
aiProvider === "google"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "bg-[var(--accent-bg)] text-[var(--accent)]"
|
||||
)}>
|
||||
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
|
||||
</span>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@@ -121,6 +121,12 @@ export default function SettingsPage() {
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
|
||||
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
// AI Provider
|
||||
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
|
||||
const [googleAiKey, setGoogleAiKey] = useState("");
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [googleAiModel, setGoogleAiModel] = useState("gemini-2.5-pro-preview-05-06");
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -171,6 +177,9 @@ export default function SettingsPage() {
|
||||
if (config.openRouterModel) {
|
||||
setSelectedModel(config.openRouterModel);
|
||||
}
|
||||
if (config.aiProvider) setAiProvider(config.aiProvider);
|
||||
if (config.googleAiKey) setGoogleAiKey(config.googleAiKey);
|
||||
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
|
||||
}
|
||||
}, [fetchModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
|
||||
|
||||
@@ -195,7 +204,7 @@ export default function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
const url = getDatabaseUrl();
|
||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel);
|
||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
|
||||
if (res.success) {
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => setSaveStatus("idle"), 2500);
|
||||
@@ -308,6 +317,65 @@ export default function SettingsPage() {
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
{/* AI Provider selector */}
|
||||
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAiProvider("openrouter")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
OpenRouter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider("google")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
Google AI Studio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiProvider === "google" && (
|
||||
<>
|
||||
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showGoogleKey ? "text" : "password"}
|
||||
placeholder="AIzaSy..."
|
||||
value={googleAiKey}
|
||||
onChange={(e) => setGoogleAiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowGoogleKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Modèle Google" hint="ex: gemini-2.5-pro-preview-05-06, gemini-2.0-flash, gemini-1.5-pro">
|
||||
<select
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
<option value="gemini-2.5-pro-preview-05-06">Gemini 2.5 Pro Preview</option>
|
||||
<option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
|
||||
<option value="gemini-2.0-flash-thinking-exp">Gemini 2.0 Flash Thinking</option>
|
||||
<option value="gemini-1.5-pro">Gemini 1.5 Pro</option>
|
||||
<option value="gemini-1.5-flash">Gemini 1.5 Flash</option>
|
||||
</select>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{aiProvider === "openrouter" && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
|
||||
@@ -3,16 +3,31 @@ import { auth } from "@/lib/auth";
|
||||
import { getDb } from "@/db";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
async function getOpenRouterKey(): Promise<string | null> {
|
||||
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
||||
interface AiConfig {
|
||||
provider: "openrouter" | "google";
|
||||
openRouterKey?: string;
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
async function getAiConfig(): Promise<AiConfig> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.openRouterKey ?? null;
|
||||
return {
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
|
||||
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
|
||||
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
return {
|
||||
provider: "openrouter",
|
||||
openRouterKey: process.env.OPENROUTER_API_KEY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +208,116 @@ async function callOpenRouter(
|
||||
});
|
||||
}
|
||||
|
||||
// Google AI Studio handler
|
||||
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
|
||||
if (!config.googleAiKey) {
|
||||
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const model = config.googleAiModel ?? "gemini-2.0-flash";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const googleTools = [{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "execute_sql",
|
||||
description: "Execute a READ-ONLY SQL SELECT query on the CollectFlow PostgreSQL database.",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: { query: { type: Type.STRING, description: "A valid PostgreSQL SELECT query." } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_db_schema",
|
||||
description: "Returns the full database schema with table names, columns, and descriptions.",
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
|
||||
|
||||
// Convert messages to Google format
|
||||
const history = messages.slice(0, -1).map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
const chat = ai.chats.create({
|
||||
model,
|
||||
config: { systemInstruction: SYSTEM_PROMPT, tools: googleTools as any },
|
||||
history,
|
||||
});
|
||||
|
||||
// Agentic loop
|
||||
let currentParts: Array<{ text?: string; functionCall?: { name: string; args: Record<string, unknown> } }> = [{ text: lastMessage.content }];
|
||||
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const response = await chat.sendMessage({ message: currentParts as any });
|
||||
const candidate = response.candidates?.[0];
|
||||
if (!candidate) break;
|
||||
|
||||
const fnCalls = candidate.content?.parts?.filter((p: any) => p.functionCall) ?? [];
|
||||
const textParts = candidate.content?.parts?.filter((p: any) => p.text) ?? [];
|
||||
|
||||
if (fnCalls.length === 0) {
|
||||
// Final answer
|
||||
const text = textParts.map((p: any) => p.text).join("");
|
||||
send({ type: "text", content: text });
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute tools
|
||||
const toolResults: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
|
||||
for (const part of fnCalls) {
|
||||
const fn = (part as any).functionCall as { name: string; args: Record<string, unknown> };
|
||||
send({ type: "tool_start", name: fn.name, args: fn.args });
|
||||
|
||||
let result = "";
|
||||
if (fn.name === "execute_sql") {
|
||||
result = await executeSql((fn.args as any).query as string);
|
||||
} else if (fn.name === "get_db_schema") {
|
||||
result = DB_SCHEMA;
|
||||
} else {
|
||||
result = `Outil inconnu: ${fn.name}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResults.push({ functionResponse: { name: fn.name, response: { output: result } } });
|
||||
}
|
||||
|
||||
currentParts = toolResults as any;
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
const session = await auth();
|
||||
@@ -202,7 +327,13 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { messages, model } = await req.json();
|
||||
|
||||
const apiKey = await getOpenRouterKey();
|
||||
const aiConfig = await getAiConfig();
|
||||
|
||||
if (aiConfig.provider === "google") {
|
||||
return handleGoogleAi(messages, aiConfig);
|
||||
}
|
||||
|
||||
const apiKey = aiConfig.openRouterKey;
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return NextResponse.json({
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterModel: config.openRouterModel,
|
||||
googleAiModel: config.googleAiModel,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ provider: "openrouter" });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
@@ -34,9 +37,16 @@ export async function testDatabaseConnection(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDatabaseSettings(url: string, openRouterKey?: string, openRouterModel?: string) {
|
||||
export async function saveDatabaseSettings(
|
||||
url: string,
|
||||
openRouterKey?: string,
|
||||
openRouterModel?: string,
|
||||
aiProvider?: "openrouter" | "google",
|
||||
googleAiKey?: string,
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
try {
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel };
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
Reference in new issue
Block a user