Checkpoint: Ajout de Google Gemini (gemini-2.0-flash) comme 3ème moteur IA : schéma DB mis à jour (enum aiProvider + colonne geminiApiKey dans userSettings et importSettings), llm.ts mis à jour avec routing vers l'API Google AI Studio (compatible OpenAI), fenêtre de configuration IA mise à jour avec 3 cartes (Mistral / Gemini / Manus) et champ clé API Gemini.

This commit is contained in:
Manus
2026-07-27 15:55:07 +00:00
parent 46a61fca0e
commit 827c9ec41e
10 changed files with 2289 additions and 13 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ temp/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.project-config.json

View File

@@ -677,12 +677,14 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false); const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState(""); const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3); const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus">("mistral"); const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [mistralApiKey, setMistralApiKey] = useState(""); const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState(""); const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState(""); const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [showMistralKey, setShowMistralKey] = useState(false); const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false); const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@@ -706,6 +708,7 @@ export default function Settings() {
setMistralApiKey((settings as any).mistralApiKey || ""); setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || ""); setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || ""); setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
} }
}, [settings]); }, [settings]);
@@ -754,6 +757,7 @@ export default function Settings() {
mistralApiKey, mistralApiKey,
manusForgeApiKey, manusForgeApiKey,
manusForgeApiUrl, manusForgeApiUrl,
geminiApiKey,
}); });
}; };
@@ -853,7 +857,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
{/* Provider selector */} {/* Provider selector */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<button <button
type="button" type="button"
onClick={() => setAiProvider("mistral")} onClick={() => setAiProvider("mistral")}
@@ -880,6 +884,36 @@ export default function Settings() {
)} )}
</button> </button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button <button
type="button" type="button"
onClick={() => setAiProvider("manus")} onClick={() => setAiProvider("manus")}
@@ -938,6 +972,46 @@ export default function Settings() {
</div> </div>
)} )}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */} {/* Manus config */}
{aiProvider === "manus" && ( {aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800"> <div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
@@ -993,14 +1067,14 @@ export default function Settings() {
<div> <div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle> <CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI Paramètres avancés du modèle d'extraction IA
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</Label> <Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input <Input
id="llmModel" id="llmModel"
value={llmModel} value={llmModel}

View File

@@ -0,0 +1,4 @@
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `importSettings` ADD `geminiApiKey` text;--> statement-breakpoint
ALTER TABLE `userSettings` ADD `geminiApiKey` text;

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1783419609559, "when": 1783419609559,
"tag": "0033_strange_manta", "tag": "0033_strange_manta",
"breakpoints": true "breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1785167359371,
"tag": "0034_black_shadowcat",
"breakpoints": true
} }
] ]
} }

View File

@@ -149,10 +149,11 @@ export const userSettings = mysqlTable("userSettings", {
// Seuil de confiance pour les apprentissages IA // Seuil de confiance pour les apprentissages IA
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
// AI Engine configuration // AI Engine configuration
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider: 'mistral' or 'manus' aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -196,10 +197,11 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings // AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY) mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY) manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL) manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),

View File

@@ -8,4 +8,5 @@ export const ENV = {
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "", mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
}; };

View File

@@ -283,31 +283,45 @@ const normalizeResponseFormat = ({
/** /**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement. * Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/ */
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }): string { function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions"; return "https://api.mistral.ai/v1/chat/completions";
} }
if (provider === "gemini") {
// Google AI Studio — interface compatible OpenAI
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
}
// Manus provider // Manus provider
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai"; const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`; return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
} }
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string { function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral"; const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") { if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || ""; return settings?.mistralApiKey || ENV.mistralApiKey || "";
} }
if (provider === "gemini") {
return settings?.geminiApiKey || ENV.geminiApiKey || "";
}
return settings?.manusForgeApiKey || ENV.forgeApiKey || ""; return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
} }
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
}
/** /**
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB * Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
* pour choisir dynamiquement le moteur IA (Mistral ou Manus). * pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
*/ */
export async function invokeLLMWithUserSettings( export async function invokeLLMWithUserSettings(
params: InvokeParams, params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null } userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> { ): Promise<InvokeResult> {
const apiKey = getApiKeyWithSettings(userSettings); const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) { if (!apiKey || apiKey.trim().length === 0) {
@@ -328,7 +342,7 @@ export async function invokeLLMWithUserSettings(
response_format, response_format,
} = params; } = params;
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash"; const model = getModelForProvider(provider);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
model, model,
@@ -342,7 +356,8 @@ export async function invokeLLMWithUserSettings(
payload.max_tokens = 32768; payload.max_tokens = 32768;
if (!isMistral) { // thinking uniquement pour Manus Forge (proxy Gemini)
if (provider === "manus") {
payload.thinking = { budget_tokens: 128 }; payload.thinking = { budget_tokens: 128 };
} }

View File

@@ -92,6 +92,7 @@ async function processEmailAttachment(
mistralApiKey: settings.mistralApiKey, mistralApiKey: settings.mistralApiKey,
manusForgeApiKey: settings.manusForgeApiKey, manusForgeApiKey: settings.manusForgeApiKey,
manusForgeApiUrl: settings.manusForgeApiUrl, manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices

View File

@@ -218,6 +218,7 @@ export const appRouter = router({
mistralApiKey: (settings as any).mistralApiKey || null, mistralApiKey: (settings as any).mistralApiKey || null,
manusForgeApiKey: (settings as any).manusForgeApiKey || null, manusForgeApiKey: (settings as any).manusForgeApiKey || null,
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null, manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined; } : undefined;
// Extract invoices // Extract invoices
@@ -1171,10 +1172,11 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(), sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(), llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(), learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus"]).optional(), aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
mistralApiKey: z.string().optional(), mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(), manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(), manusForgeApiUrl: z.string().optional(),
geminiApiKey: z.string().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await upsertUserSettings({ await upsertUserSettings({