Checkpoint: Ajout de la section "Moteur IA" dans les paramètres : sélecteur Mistral/Manus, champs clé API avec affichage masqué, persistance en DB, migration VPS appliquée
This commit is contained in:
@@ -23,7 +23,11 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
User
|
||||
User,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Zap,
|
||||
Bot
|
||||
} from "lucide-react";
|
||||
// useRef, useCallback already imported above
|
||||
import { toast } from "sonner";
|
||||
@@ -673,6 +677,12 @@ 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 [mistralApiKey, setMistralApiKey] = useState("");
|
||||
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
|
||||
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
|
||||
const [showMistralKey, setShowMistralKey] = useState(false);
|
||||
const [showManusKey, setShowManusKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -692,6 +702,10 @@ export default function Settings() {
|
||||
setSftpAutoExport(settings.sftpAutoExport === 1);
|
||||
setSftpRecipientFilter((settings as any).sftpRecipientFilter || "");
|
||||
setLlmLogsRetentionMonths(settings.llmLogsRetentionMonths || 3);
|
||||
setAiProvider((settings as any).aiProvider || "mistral");
|
||||
setMistralApiKey((settings as any).mistralApiKey || "");
|
||||
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
|
||||
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -736,6 +750,10 @@ export default function Settings() {
|
||||
sftpAutoExport: sftpAutoExport ? 1 : 0,
|
||||
sftpRecipientFilter,
|
||||
llmLogsRetentionMonths,
|
||||
aiProvider,
|
||||
mistralApiKey,
|
||||
manusForgeApiKey,
|
||||
manusForgeApiUrl,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -816,6 +834,156 @@ export default function Settings() {
|
||||
|
||||
{/* LLM Tab */}
|
||||
<TabsContent value="llm" className="space-y-6 animate-in fade-in-50 duration-300">
|
||||
|
||||
{/* AI Engine Selector */}
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500 rounded-lg">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl">Moteur IA</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Choisissez le fournisseur d'intelligence artificielle pour l'extraction des factures
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
|
||||
{/* Provider selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiProvider("mistral")}
|
||||
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
|
||||
aiProvider === "mistral"
|
||||
? "border-orange-500 bg-orange-50 dark:bg-orange-950/20 shadow-md"
|
||||
: "border-muted hover:border-orange-300 bg-background"
|
||||
}`}
|
||||
>
|
||||
{aiProvider === "mistral" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-xl">
|
||||
<Bot className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-base">Mistral AI</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">API Mistral directe<br/>Clé API requise</p>
|
||||
</div>
|
||||
{aiProvider === "mistral" && (
|
||||
<Badge className="bg-orange-500 text-white text-xs">Actif</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiProvider("manus")}
|
||||
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
|
||||
aiProvider === "manus"
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20 shadow-md"
|
||||
: "border-muted hover:border-blue-300 bg-background"
|
||||
}`}
|
||||
>
|
||||
{aiProvider === "manus" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-xl">
|
||||
<Sparkles className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-base">Manus AI</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">API Manus Forge<br/>Clé Forge requise</p>
|
||||
</div>
|
||||
{aiProvider === "manus" && (
|
||||
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mistral config */}
|
||||
{aiProvider === "mistral" && (
|
||||
<div className="space-y-4 p-4 bg-orange-50 dark:bg-orange-950/10 rounded-xl border border-orange-200 dark:border-orange-800">
|
||||
<p className="text-sm font-semibold text-orange-700 dark:text-orange-400 uppercase tracking-wide">Configuration Mistral AI</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mistralApiKey" className="font-medium">Clé API Mistral</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="mistralApiKey"
|
||||
type={showMistralKey ? "text" : "password"}
|
||||
value={mistralApiKey}
|
||||
onChange={(e) => setMistralApiKey(e.target.value)}
|
||||
placeholder="Votre clé API Mistral (ex: 3rCjWwA2...)"
|
||||
className="h-11 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMistralKey(!showMistralKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showMistralKey ? <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é sur <a href="https://console.mistral.ai" target="_blank" rel="noopener noreferrer" className="text-orange-600 hover:underline">console.mistral.ai</a>. Laissez vide pour utiliser la variable d'environnement MISTRAL_API_KEY du serveur.
|
||||
</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">
|
||||
<p className="text-sm font-semibold text-blue-700 dark:text-blue-400 uppercase tracking-wide">Configuration Manus Forge</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manusForgeApiUrl" className="font-medium">URL de l'API Forge</Label>
|
||||
<Input
|
||||
id="manusForgeApiUrl"
|
||||
type="text"
|
||||
value={manusForgeApiUrl}
|
||||
onChange={(e) => setManusForgeApiUrl(e.target.value)}
|
||||
placeholder="https://forge.manus.im"
|
||||
className="h-11"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Laissez vide pour utiliser la valeur par défaut (https://forge.manus.im)</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manusForgeApiKey" className="font-medium">Clé API Forge</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="manusForgeApiKey"
|
||||
type={showManusKey ? "text" : "password"}
|
||||
value={manusForgeApiKey}
|
||||
onChange={(e) => setManusForgeApiKey(e.target.value)}
|
||||
placeholder="Votre clé API Manus Forge"
|
||||
className="h-11 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManusKey(!showManusKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showManusKey ? <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" />
|
||||
Laissez vide pour utiliser la variable d'environnement BUILT_IN_FORGE_API_KEY du serveur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
4
drizzle/0020_overrated_nekra.sql
Normal file
4
drizzle/0020_overrated_nekra.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `importSettings` ADD `aiProvider` enum('mistral','manus') DEFAULT 'mistral' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `importSettings` ADD `mistralApiKey` text;--> statement-breakpoint
|
||||
ALTER TABLE `importSettings` ADD `manusForgeApiKey` text;--> statement-breakpoint
|
||||
ALTER TABLE `importSettings` ADD `manusForgeApiUrl` text;
|
||||
4
drizzle/0021_unique_speed.sql
Normal file
4
drizzle/0021_unique_speed.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `userSettings` ADD `aiProvider` enum('mistral','manus') DEFAULT 'mistral' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `userSettings` ADD `mistralApiKey` text;--> statement-breakpoint
|
||||
ALTER TABLE `userSettings` ADD `manusForgeApiKey` text;--> statement-breakpoint
|
||||
ALTER TABLE `userSettings` ADD `manusForgeApiUrl` text;
|
||||
1731
drizzle/meta/0020_snapshot.json
Normal file
1731
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1760
drizzle/meta/0021_snapshot.json
Normal file
1760
drizzle/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,20 @@
|
||||
"when": 1776021930426,
|
||||
"tag": "0019_smooth_lord_hawal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "5",
|
||||
"when": 1776253931726,
|
||||
"tag": "0020_overrated_nekra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "5",
|
||||
"when": 1776254066006,
|
||||
"tag": "0021_unique_speed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -148,6 +148,11 @@ export const userSettings = mysqlTable("userSettings", {
|
||||
llmLogsRetentionMonths: int("llmLogsRetentionMonths").default(3).notNull(), // Durée de conservation des logs LLM en mois (défaut: 3 mois)
|
||||
// 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'
|
||||
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)
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
@@ -181,7 +186,13 @@ export const importSettings = mysqlTable("importSettings", {
|
||||
// Export folder settings
|
||||
exportFolder: text("exportFolder"), // Path to folder for exporting invoices
|
||||
bapExportMode: mysqlEnum("bapExportMode", ["browser", "folder"]).default("browser").notNull(), // BAP export mode: open in browser or save to folder
|
||||
|
||||
|
||||
// AI Engine settings
|
||||
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).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)
|
||||
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -280,6 +280,93 @@ 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 {
|
||||
const provider = settings?.aiProvider || "mistral";
|
||||
if (provider === "mistral") {
|
||||
return "https://api.mistral.ai/v1/chat/completions";
|
||||
}
|
||||
// Manus provider
|
||||
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.im";
|
||||
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string {
|
||||
const provider = settings?.aiProvider || "mistral";
|
||||
if (provider === "mistral") {
|
||||
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
||||
}
|
||||
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
|
||||
* pour choisir dynamiquement le moteur IA (Mistral ou Manus).
|
||||
*/
|
||||
export async function invokeLLMWithUserSettings(
|
||||
params: InvokeParams,
|
||||
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
|
||||
): Promise<InvokeResult> {
|
||||
const apiKey = getApiKeyWithSettings(userSettings);
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
|
||||
}
|
||||
|
||||
const provider = userSettings?.aiProvider || "mistral";
|
||||
const isMistral = provider === "mistral";
|
||||
|
||||
const {
|
||||
messages,
|
||||
tools,
|
||||
toolChoice,
|
||||
tool_choice,
|
||||
outputSchema,
|
||||
output_schema,
|
||||
responseFormat,
|
||||
response_format,
|
||||
} = params;
|
||||
|
||||
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash";
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: messages.map(normalizeMessage),
|
||||
};
|
||||
|
||||
if (tools && tools.length > 0) payload.tools = tools;
|
||||
|
||||
const normalizedToolChoice = normalizeToolChoice(toolChoice || tool_choice, tools);
|
||||
if (normalizedToolChoice) payload.tool_choice = normalizedToolChoice;
|
||||
|
||||
payload.max_tokens = 32768;
|
||||
|
||||
if (!isMistral) {
|
||||
payload.thinking = { budget_tokens: 128 };
|
||||
}
|
||||
|
||||
const normalizedResponseFormat = normalizeResponseFormat({ responseFormat, response_format, outputSchema, output_schema });
|
||||
if (normalizedResponseFormat) payload.response_format = normalizedResponseFormat;
|
||||
|
||||
const apiUrl = resolveApiUrlWithSettings(userSettings);
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as InvokeResult;
|
||||
}
|
||||
|
||||
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
assertApiKey();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invokeLLM } from "./_core/llm";
|
||||
import { invokeLLM, invokeLLMWithUserSettings } from "./_core/llm";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { createLlmLog } from "./db";
|
||||
import PDFParser from "pdf2json";
|
||||
@@ -143,7 +143,8 @@ export async function extractInvoicesWithMistral(
|
||||
totalAmount?: string | null;
|
||||
subscription?: string | null;
|
||||
recipient?: string | null;
|
||||
}
|
||||
},
|
||||
aiSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
|
||||
): Promise<MultiInvoiceResult> {
|
||||
// Load user's field configuration
|
||||
const { getLlmFieldsConfigByUser } = await import("./db");
|
||||
@@ -240,14 +241,17 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
||||
|
||||
// Call Mistral LLM with extracted text
|
||||
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: fullPrompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await invokeLLMWithUserSettings(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: fullPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
aiSettings
|
||||
);
|
||||
|
||||
const rawResponse = typeof response.choices[0]?.message?.content === "string"
|
||||
? response.choices[0].message.content
|
||||
|
||||
@@ -200,6 +200,12 @@ export const appRouter = router({
|
||||
} : undefined;
|
||||
|
||||
const model = settings?.llmModel || "mistral-large-latest";
|
||||
const aiSettings = settings ? {
|
||||
aiProvider: (settings as any).aiProvider || "mistral",
|
||||
mistralApiKey: (settings as any).mistralApiKey || null,
|
||||
manusForgeApiKey: (settings as any).manusForgeApiKey || null,
|
||||
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
|
||||
} : undefined;
|
||||
|
||||
// Extract invoices
|
||||
const result = await extractInvoicesWithMistral(
|
||||
@@ -207,7 +213,8 @@ export const appRouter = router({
|
||||
userId,
|
||||
sourceFile.id,
|
||||
model,
|
||||
customKeywords
|
||||
customKeywords,
|
||||
aiSettings
|
||||
);
|
||||
|
||||
// Update source file with total count
|
||||
@@ -932,6 +939,10 @@ export const appRouter = router({
|
||||
sftpAutoExport: z.number().optional(),
|
||||
llmLogsRetentionMonths: z.number().optional(),
|
||||
learningConfidenceThreshold: z.number().min(1).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus"]).optional(),
|
||||
mistralApiKey: z.string().optional(),
|
||||
manusForgeApiKey: z.string().optional(),
|
||||
manusForgeApiUrl: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await upsertUserSettings({
|
||||
|
||||
Reference in New Issue
Block a user