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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,3 +105,4 @@ temp/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.project-config.json
|
||||
|
||||
@@ -677,12 +677,14 @@ export default function Settings() {
|
||||
const [sftpAutoExport, setSftpAutoExport] = useState(false);
|
||||
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
|
||||
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 [manusForgeApiKey, setManusForgeApiKey] = useState("");
|
||||
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const [showMistralKey, setShowMistralKey] = useState(false);
|
||||
const [showManusKey, setShowManusKey] = useState(false);
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -706,6 +708,7 @@ export default function Settings() {
|
||||
setMistralApiKey((settings as any).mistralApiKey || "");
|
||||
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
|
||||
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
|
||||
setGeminiApiKey((settings as any).geminiApiKey || "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -754,6 +757,7 @@ export default function Settings() {
|
||||
mistralApiKey,
|
||||
manusForgeApiKey,
|
||||
manusForgeApiUrl,
|
||||
geminiApiKey,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -853,7 +857,7 @@ export default function Settings() {
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
|
||||
{/* Provider selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiProvider("mistral")}
|
||||
@@ -880,6 +884,36 @@ export default function Settings() {
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setAiProvider("manus")}
|
||||
@@ -938,6 +972,46 @@ export default function Settings() {
|
||||
</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 */}
|
||||
{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">
|
||||
@@ -993,14 +1067,14 @@ export default function Settings() {
|
||||
<div>
|
||||
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Paramètres du modèle d'extraction Mistral AI
|
||||
Paramètres avancés du modèle d'extraction IA
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<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
|
||||
id="llmModel"
|
||||
value={llmModel}
|
||||
|
||||
4
drizzle/0034_black_shadowcat.sql
Normal file
4
drizzle/0034_black_shadowcat.sql
Normal 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;
|
||||
2169
drizzle/meta/0034_snapshot.json
Normal file
2169
drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
||||
"when": 1783419609559,
|
||||
"tag": "0033_strange_manta",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "5",
|
||||
"when": 1785167359371,
|
||||
"tag": "0034_black_shadowcat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -149,10 +149,11 @@ export const userSettings = mysqlTable("userSettings", {
|
||||
// Seuil de confiance pour les apprentissages IA
|
||||
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
|
||||
// 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)
|
||||
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)
|
||||
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
|
||||
createdAt: timestamp("createdAt").defaultNow().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
|
||||
|
||||
// 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)
|
||||
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)
|
||||
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
|
||||
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
|
||||
@@ -8,4 +8,5 @@ export const ENV = {
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
|
||||
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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";
|
||||
if (provider === "mistral") {
|
||||
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
|
||||
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
|
||||
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";
|
||||
if (provider === "mistral") {
|
||||
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return settings?.geminiApiKey || ENV.geminiApiKey || "";
|
||||
}
|
||||
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
|
||||
* pour choisir dynamiquement le moteur IA (Mistral ou Manus).
|
||||
* pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
|
||||
*/
|
||||
export async function invokeLLMWithUserSettings(
|
||||
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> {
|
||||
const apiKey = getApiKeyWithSettings(userSettings);
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
@@ -328,7 +342,7 @@ export async function invokeLLMWithUserSettings(
|
||||
response_format,
|
||||
} = params;
|
||||
|
||||
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash";
|
||||
const model = getModelForProvider(provider);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -342,7 +356,8 @@ export async function invokeLLMWithUserSettings(
|
||||
|
||||
payload.max_tokens = 32768;
|
||||
|
||||
if (!isMistral) {
|
||||
// thinking uniquement pour Manus Forge (proxy Gemini)
|
||||
if (provider === "manus") {
|
||||
payload.thinking = { budget_tokens: 128 };
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ async function processEmailAttachment(
|
||||
mistralApiKey: settings.mistralApiKey,
|
||||
manusForgeApiKey: settings.manusForgeApiKey,
|
||||
manusForgeApiUrl: settings.manusForgeApiUrl,
|
||||
geminiApiKey: (settings as any).geminiApiKey || null,
|
||||
} : undefined;
|
||||
|
||||
// Extract invoices
|
||||
|
||||
@@ -218,6 +218,7 @@ export const appRouter = router({
|
||||
mistralApiKey: (settings as any).mistralApiKey || null,
|
||||
manusForgeApiKey: (settings as any).manusForgeApiKey || null,
|
||||
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
|
||||
geminiApiKey: (settings as any).geminiApiKey || null,
|
||||
} : undefined;
|
||||
|
||||
// Extract invoices
|
||||
@@ -1171,10 +1172,11 @@ export const appRouter = router({
|
||||
sftpAutoExport: z.number().optional(),
|
||||
llmLogsRetentionMonths: z.number().optional(),
|
||||
learningConfidenceThreshold: z.number().min(1).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus"]).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
|
||||
mistralApiKey: z.string().optional(),
|
||||
manusForgeApiKey: z.string().optional(),
|
||||
manusForgeApiUrl: z.string().optional(),
|
||||
geminiApiKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await upsertUserSettings({
|
||||
|
||||
Reference in New Issue
Block a user