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:
Manus
2026-04-15 08:04:54 -04:00
parent 7fbb3d5f2c
commit 62b7a61bf5
10 changed files with 3807 additions and 13 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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({