levix-bot 3.3.0-rc.2 → 4.0.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/app.cjs +42 -45
- package/package.json +9 -1
- package/public/dashboard/assets/index-C1b7ijfu.js +391 -0
- package/public/dashboard/assets/index-pYaivHON.css +1 -0
- package/public/dashboard/index.html +19 -0
- package/public/dashboard.css +391 -66
- package/public/dashboard.js +593 -42
- package/scheduler.cjs +9 -14
- package/src/auth/use-database-auth-state.js +2 -4
- package/src/bootstrap/core.js +2 -5
- package/src/bootstrap/panel.js +10 -5
- package/src/cli.js +3 -5
- package/src/commands/autoschedule.cjs +1 -4
- package/src/commands/blacklist.cjs +22 -20
- package/src/commands/block.cjs +2 -4
- package/src/commands/calc.cjs +9 -16
- package/src/commands/digit.cjs +97 -0
- package/src/commands/gemini.cjs +147 -161
- package/src/commands/group/add.cjs +7 -16
- package/src/commands/group/antiSpam.cjs +5 -11
- package/src/commands/group/antilink.cjs +34 -28
- package/src/commands/group/approveall.cjs +3 -11
- package/src/commands/group/clearwarns.cjs +2 -5
- package/src/commands/group/demote.cjs +3 -8
- package/src/commands/group/kick.cjs +12 -15
- package/src/commands/group/media.cjs +17 -24
- package/src/commands/group/members.cjs +1 -2
- package/src/commands/group/promote.cjs +4 -12
- package/src/commands/group/removeall.cjs +2 -5
- package/src/commands/group/setpp.cjs +1 -1
- package/src/commands/group/setrules.cjs +2 -8
- package/src/commands/group/setwarn.cjs +4 -2
- package/src/commands/group/tagadmins.cjs +2 -5
- package/src/commands/group/warn.cjs +26 -24
- package/src/commands/group/warns.cjs +1 -2
- package/src/commands/group/welcome.cjs +13 -17
- package/src/commands/group.cjs +7 -10
- package/src/commands/help.cjs +6 -12
- package/src/commands/lang.cjs +104 -0
- package/src/commands/listschedules.cjs +2 -6
- package/src/commands/loop.cjs +6 -13
- package/src/commands/memory.cjs +19 -32
- package/src/commands/mod.cjs +97 -35
- package/src/commands/perm.cjs +18 -56
- package/src/commands/ping.cjs +1 -5
- package/src/commands/poll.cjs +1 -1
- package/src/commands/rand.cjs +2 -5
- package/src/commands/schedule.cjs +4 -8
- package/src/commands/score.cjs +8 -2
- package/src/commands/setprefix.cjs +2 -2
- package/src/commands/shortlink.cjs +7 -15
- package/src/commands/shutdown.cjs +1 -1
- package/src/commands/sticker.cjs +222 -0
- package/src/commands/stt.cjs +136 -68
- package/src/commands/todo.cjs +9 -15
- package/src/commands/tts.cjs +10 -16
- package/src/commands/unblock.cjs +2 -6
- package/src/commands/weather.cjs +5 -12
- package/src/config/ai-persona.md +5 -3
- package/src/config/available-models.json +1156 -0
- package/src/config/baileys.config.js +2 -1
- package/src/config/brand.esm.js +1 -10
- package/src/config/constants.js +1 -1
- package/src/config/defaults.cjs +66 -66
- package/src/config/lock.cjs +2 -2
- package/src/config/modelCapabilities.cjs +172 -0
- package/src/config/runtime-config.cjs +8 -13
- package/src/config/secrets.cjs +18 -10
- package/src/config/settings.cjs +94 -10
- package/src/config/vault.cjs +107 -0
- package/src/core/connection.js +13 -12
- package/src/core/events.js +41 -23
- package/src/core/proxy.js +7 -7
- package/src/core/session.js +91 -48
- package/src/core/socket.js +12 -15
- package/src/db/db.cjs +14 -6
- package/src/db/store.cjs +221 -87
- package/src/domain/caddy.js +1 -1
- package/src/domain/command.js +40 -19
- package/src/domain/detect.js +1 -1
- package/src/domain/system.js +2 -5
- package/src/handlers/command.handler.js +22 -43
- package/src/handlers/group.handler.js +13 -13
- package/src/handlers/message.handler.js +11 -21
- package/src/index.js +8 -4
- package/src/middleware/antispam.middleware.js +46 -23
- package/src/middleware/blacklist.middleware.js +14 -9
- package/src/middleware/forward-tracking.middleware.js +3 -2
- package/src/middleware/permissions.middleware.js +7 -10
- package/src/panel/login-throttle.cjs +43 -0
- package/src/panel/session-auth.cjs +17 -0
- package/src/routes/dashboard.api.esm.js +394 -72
- package/src/services/aiAgent.cjs +136 -49
- package/src/services/aiProviders.cjs +193 -47
- package/src/services/aiRouter.cjs +137 -0
- package/src/services/aiTools.cjs +305 -125
- package/src/services/llmDiscovery.cjs +481 -0
- package/src/services/modelRegistry.cjs +412 -0
- package/src/utils/datetime.cjs +2 -2
- package/src/utils/lid-helper.esm.js +7 -7
- package/src/utils/logger.cjs +1 -1
- package/src/utils/markdownParser.cjs +159 -0
- package/src/utils/memory.cjs +43 -13
- package/src/utils/messageContent.cjs +40 -0
- package/src/utils/normalizeJid.esm.js +7 -7
- package/src/utils/numerals.cjs +75 -0
- package/src/utils/permissions.esm.js +10 -15
- package/src/utils/providerUrl.cjs +281 -0
- package/src/utils/recurrence.cjs +7 -11
- package/src/utils/sendBotMessage.esm.js +4 -14
- package/src/utils/statusMessage.cjs +5 -13
- package/src/utils/storage.cjs +10 -0
- package/src/utils/storage.esm.js +11 -0
- package/src/utils/textDecode.cjs +4 -9
- package/src/utils/thumbnail.cjs +8 -14
- package/src/utils/typing.esm.js +12 -12
- package/views/dashboard.ejs +134 -106
package/src/services/aiAgent.cjs
CHANGED
|
@@ -21,11 +21,18 @@ const logger = require("../utils/logger.cjs");
|
|
|
21
21
|
const aiIdentity = require("../config/ai-identity.cjs");
|
|
22
22
|
const settings = require("../config/settings.cjs");
|
|
23
23
|
const memory = require("../utils/memory.cjs");
|
|
24
|
+
const {
|
|
25
|
+
assertProviderBaseUrl,
|
|
26
|
+
assertProviderRequestUrl,
|
|
27
|
+
} = require("../utils/providerUrl.cjs");
|
|
24
28
|
const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
25
29
|
const {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
BaseAIProvider,
|
|
31
|
+
registerProvider,
|
|
32
|
+
getProvider,
|
|
33
|
+
detectModelCapabilities,
|
|
34
|
+
} = require("./aiRouter.cjs");
|
|
35
|
+
const { runProviderAgent, activeProviderKeySetting } = require("./aiProviders.cjs");
|
|
29
36
|
|
|
30
37
|
// Model, key, tool budget and history length all come from config/settings.cjs
|
|
31
38
|
// (what the dashboard saved, else the default) and are read per call, so changing
|
|
@@ -45,7 +52,9 @@ let clientCache = { key: null, baseUrl: null, client: null };
|
|
|
45
52
|
function geminiClient() {
|
|
46
53
|
const key = settings.get("gemini_api_key");
|
|
47
54
|
if (!key) return null;
|
|
48
|
-
const baseUrl =
|
|
55
|
+
const baseUrl = assertProviderBaseUrl(settings.get("gemini_base_url"), {
|
|
56
|
+
allowLoopback: true,
|
|
57
|
+
});
|
|
49
58
|
if (clientCache.key !== key || clientCache.baseUrl !== baseUrl) {
|
|
50
59
|
clientCache = {
|
|
51
60
|
key,
|
|
@@ -100,7 +109,12 @@ function loadPersona() {
|
|
|
100
109
|
const raw = fs.readFileSync(PERSONA_FILE, "utf8").trim();
|
|
101
110
|
// Everything above the `---` separator is a note to the human editing the
|
|
102
111
|
// file, not part of the prompt.
|
|
103
|
-
const body = raw.includes("\n---")
|
|
112
|
+
const body = raw.includes("\n---")
|
|
113
|
+
? raw
|
|
114
|
+
.split(/\n---\s*\n/)
|
|
115
|
+
.slice(1)
|
|
116
|
+
.join("\n---\n")
|
|
117
|
+
: raw;
|
|
104
118
|
personaCache = { text: body.trim() || DEFAULT_PERSONA, stamp };
|
|
105
119
|
return personaCache.text;
|
|
106
120
|
} catch {
|
|
@@ -120,6 +134,17 @@ function nowLabel() {
|
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
function botLangDirective() {
|
|
138
|
+
const botLang = settings.get("bot_language") || "auto";
|
|
139
|
+
if (botLang === "ar") {
|
|
140
|
+
return "- Language instruction: Always reply in Modern Standard Arabic (العربية الفصحى), clear, professional, and well-phrased.";
|
|
141
|
+
}
|
|
142
|
+
if (botLang === "en") {
|
|
143
|
+
return "- Language instruction: Always reply in English, clear, professional, natural, and direct.";
|
|
144
|
+
}
|
|
145
|
+
return "- Language instruction: Automatically reply in the language the user writes in (Modern Standard Arabic if Arabic, English if English).";
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
/**
|
|
124
149
|
* product identity + persona + who/where/when + long-term memory.
|
|
125
150
|
*
|
|
@@ -129,15 +154,7 @@ function nowLabel() {
|
|
|
129
154
|
* src/config/ai-identity.cjs) and is never rendered by the panel.
|
|
130
155
|
*/
|
|
131
156
|
function buildSystemInstruction(context = {}) {
|
|
132
|
-
const {
|
|
133
|
-
chatId,
|
|
134
|
-
senderName,
|
|
135
|
-
senderId,
|
|
136
|
-
isOwner,
|
|
137
|
-
isAdmin,
|
|
138
|
-
isGroup,
|
|
139
|
-
chatName,
|
|
140
|
-
} = context;
|
|
157
|
+
const { chatId, senderName, senderId, isOwner, isAdmin, isGroup, chatName } = context;
|
|
141
158
|
|
|
142
159
|
const runtime = [
|
|
143
160
|
"# Current context",
|
|
@@ -148,13 +165,12 @@ function buildSystemInstruction(context = {}) {
|
|
|
148
165
|
isGroup
|
|
149
166
|
? "- This is a group: every participant sees your reply. Say nothing here that belongs to one person."
|
|
150
167
|
: "- This is a private chat: only this person sees your reply.",
|
|
168
|
+
botLangDirective(),
|
|
151
169
|
].join("\n");
|
|
152
170
|
|
|
153
171
|
const memoryBlock = memory.buildMemoryContext(chatId);
|
|
154
172
|
|
|
155
|
-
return [aiIdentity.systemBlock, loadPersona(), runtime, memoryBlock]
|
|
156
|
-
.filter(Boolean)
|
|
157
|
-
.join("\n\n");
|
|
173
|
+
return [aiIdentity.systemBlock, loadPersona(), runtime, memoryBlock].filter(Boolean).join("\n\n");
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
/** A turn the conversation can legally start from. */
|
|
@@ -188,11 +204,7 @@ function trimHistory(history) {
|
|
|
188
204
|
function isFileReferenceError(error) {
|
|
189
205
|
if (!error) return false;
|
|
190
206
|
const status =
|
|
191
|
-
error.status ||
|
|
192
|
-
error.statusCode ||
|
|
193
|
-
error.code ||
|
|
194
|
-
error.response?.status ||
|
|
195
|
-
error.error?.code;
|
|
207
|
+
error.status || error.statusCode || error.code || error.response?.status || error.error?.code;
|
|
196
208
|
const message = `${error.message || ""} ${error.details || ""}`.toLowerCase();
|
|
197
209
|
|
|
198
210
|
if ([403, 404, 410].includes(Number(status))) return true;
|
|
@@ -202,7 +214,7 @@ function isFileReferenceError(error) {
|
|
|
202
214
|
return (
|
|
203
215
|
message.includes("file") &&
|
|
204
216
|
/not found|not exist|expired|not in an active|not active|permission to access|forbidden|file_?uri/.test(
|
|
205
|
-
message
|
|
217
|
+
message,
|
|
206
218
|
)
|
|
207
219
|
);
|
|
208
220
|
}
|
|
@@ -227,7 +239,7 @@ function withTimeout(promise, ms, label) {
|
|
|
227
239
|
new Promise((_, reject) => {
|
|
228
240
|
timer = setTimeout(
|
|
229
241
|
() => reject(new Error(`${label} تأخرت أكتر من ${Math.round(ms / 1000)} ثانية`)),
|
|
230
|
-
ms
|
|
242
|
+
ms,
|
|
231
243
|
);
|
|
232
244
|
}),
|
|
233
245
|
]).finally(() => clearTimeout(timer));
|
|
@@ -391,33 +403,109 @@ function formatSources(sources) {
|
|
|
391
403
|
* @param {boolean} [options.useTools=true]
|
|
392
404
|
* @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number}>}
|
|
393
405
|
*/
|
|
394
|
-
|
|
406
|
+
class GeminiProvider extends BaseAIProvider {
|
|
407
|
+
constructor() {
|
|
408
|
+
super({
|
|
409
|
+
id: "gemini",
|
|
410
|
+
label: "Google Gemini",
|
|
411
|
+
keySetting: "gemini_api_key",
|
|
412
|
+
modelSetting: "gemini_model",
|
|
413
|
+
baseUrlSetting: "gemini_base_url",
|
|
414
|
+
defaultBaseUrl: "",
|
|
415
|
+
supportsFiles: true,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async prepareMedia(
|
|
420
|
+
parts,
|
|
421
|
+
mediaMessage,
|
|
422
|
+
mimeOverride,
|
|
423
|
+
{ downloadContentFromMessage, genAI, tempDir } = {},
|
|
424
|
+
) {
|
|
425
|
+
if (!genAI) throw new Error("Gemini client unavailable");
|
|
426
|
+
const mime = mimeOverride || mediaMessage?.mimetype || "";
|
|
427
|
+
const caps = detectModelCapabilities("gemini");
|
|
428
|
+
if (mime.startsWith("image/") && (!settings.get("ai_vision_enabled") || !caps.supportsVision)) {
|
|
429
|
+
parts.push({
|
|
430
|
+
text: "[تم إرفاق صورة — الرؤية غير مفعّلة للنموذج المحدد / Vision is disabled for the selected model]",
|
|
431
|
+
});
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
if (mime.startsWith("audio/") && (!settings.get("ai_stt_enabled") || !caps.supportsAudioStt)) {
|
|
435
|
+
parts.push({
|
|
436
|
+
text: "[تم إرفاق صوت — تحويل الكلام غير مفعّل للنموذج المحدد / Speech-to-text is disabled for the selected model]",
|
|
437
|
+
});
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const tempFilePath = path.join(tempDir || __dirname, `temp_media_${Date.now()}`);
|
|
441
|
+
const stream = await downloadContentFromMessage(
|
|
442
|
+
mediaMessage,
|
|
443
|
+
mediaMessage.mimetype?.startsWith("image/")
|
|
444
|
+
? "image"
|
|
445
|
+
: mediaMessage.mimetype?.startsWith("video/")
|
|
446
|
+
? "video"
|
|
447
|
+
: mediaMessage.mimetype?.startsWith("audio/")
|
|
448
|
+
? "audio"
|
|
449
|
+
: "document",
|
|
450
|
+
);
|
|
451
|
+
let buffer = Buffer.from([]);
|
|
452
|
+
for await (const chunk of stream) buffer = Buffer.concat([buffer, chunk]);
|
|
453
|
+
if (!buffer.length) throw new Error("Empty media buffer");
|
|
454
|
+
await fs.promises.writeFile(tempFilePath, buffer);
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const uploadResult = await genAI.files.upload({
|
|
458
|
+
file: tempFilePath,
|
|
459
|
+
mimeType: mediaMessage.mimetype,
|
|
460
|
+
});
|
|
461
|
+
parts.push({
|
|
462
|
+
fileData: {
|
|
463
|
+
fileUri: uploadResult.uri,
|
|
464
|
+
mimeType: uploadResult.mimeType,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
return uploadResult.uri;
|
|
468
|
+
} finally {
|
|
469
|
+
await fs.promises.unlink(tempFilePath).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async runTurn(options) {
|
|
474
|
+
return runGeminiTurn(options);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
registerProvider("gemini", new GeminiProvider());
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Run one agent turn.
|
|
482
|
+
* Modular dispatch through getProvider().
|
|
483
|
+
*/
|
|
484
|
+
async function runAgent(options = {}) {
|
|
485
|
+
const provider = getProvider();
|
|
486
|
+
const result = await provider.runTurn({
|
|
487
|
+
...options,
|
|
488
|
+
history: trimHistory(options.history),
|
|
489
|
+
systemInstruction: buildSystemInstruction(options.context),
|
|
490
|
+
});
|
|
491
|
+
return { ...result, history: trimHistory(result.history) };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function runGeminiTurn({
|
|
395
495
|
parts,
|
|
396
496
|
history = [],
|
|
397
497
|
status = null,
|
|
398
498
|
context = {},
|
|
399
499
|
useTools = true,
|
|
400
500
|
maxSteps = null,
|
|
501
|
+
systemInstruction = null,
|
|
401
502
|
} = {}) {
|
|
402
|
-
const provider = settings.get("ai_provider");
|
|
403
|
-
if (provider !== "gemini") {
|
|
404
|
-
const result = await runProviderAgent(provider, {
|
|
405
|
-
parts,
|
|
406
|
-
history: trimHistory(history),
|
|
407
|
-
systemInstruction: buildSystemInstruction(context),
|
|
408
|
-
status,
|
|
409
|
-
context,
|
|
410
|
-
useTools,
|
|
411
|
-
maxSteps,
|
|
412
|
-
});
|
|
413
|
-
return { ...result, history: trimHistory(result.history) };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
503
|
const genAI = geminiClient();
|
|
417
504
|
if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
|
|
505
|
+
await assertProviderRequestUrl(settings.get("gemini_base_url"), { allowLoopback: true });
|
|
418
506
|
|
|
419
507
|
const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
|
|
420
|
-
const
|
|
508
|
+
const instruction = systemInstruction || buildSystemInstruction(context);
|
|
421
509
|
const trimmed = trimHistory(history);
|
|
422
510
|
const model = settings.get("gemini_model");
|
|
423
511
|
|
|
@@ -435,7 +523,7 @@ async function runAgent({
|
|
|
435
523
|
model,
|
|
436
524
|
history: trimmed,
|
|
437
525
|
config: {
|
|
438
|
-
systemInstruction,
|
|
526
|
+
systemInstruction: instruction,
|
|
439
527
|
...(tools.length ? { tools } : {}),
|
|
440
528
|
...(toolConfig ? { toolConfig } : {}),
|
|
441
529
|
},
|
|
@@ -459,7 +547,7 @@ async function runAgent({
|
|
|
459
547
|
if (!searchOffered || !isToolCombinationError(error)) throw error;
|
|
460
548
|
logger.warn(
|
|
461
549
|
{ err: error?.message, model },
|
|
462
|
-
"[aiAgent] this model refused Google Search together with function tools — retrying with functions only"
|
|
550
|
+
"[aiAgent] this model refused Google Search together with function tools — retrying with functions only",
|
|
463
551
|
);
|
|
464
552
|
searchOffered = false;
|
|
465
553
|
chat = openChat(false);
|
|
@@ -486,10 +574,7 @@ async function runAgent({
|
|
|
486
574
|
const responses = [];
|
|
487
575
|
for (const call of calls) {
|
|
488
576
|
toolCalls.push({ name: call.name, args: call.args });
|
|
489
|
-
logger.info(
|
|
490
|
-
{ tool: call.name, chatId: context.chatId },
|
|
491
|
-
"[aiAgent] tool call"
|
|
492
|
-
);
|
|
577
|
+
logger.info({ tool: call.name, chatId: context.chatId }, "[aiAgent] tool call");
|
|
493
578
|
|
|
494
579
|
// Named apart from the outer `response` on purpose: that one is the
|
|
495
580
|
// model's reply and is reassigned at the bottom of this loop.
|
|
@@ -498,7 +583,7 @@ async function runAgent({
|
|
|
498
583
|
toolResult = await withTimeout(
|
|
499
584
|
runTool(call.name, call.args, context),
|
|
500
585
|
settings.get("ai_tool_timeout_ms"),
|
|
501
|
-
call.name
|
|
586
|
+
call.name,
|
|
502
587
|
);
|
|
503
588
|
} catch (err) {
|
|
504
589
|
toolResult = { error: err?.message || String(err) };
|
|
@@ -532,8 +617,7 @@ async function runAgent({
|
|
|
532
617
|
}
|
|
533
618
|
|
|
534
619
|
if (!text && steps >= stepBudget) {
|
|
535
|
-
text =
|
|
536
|
-
"شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.";
|
|
620
|
+
text = "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.";
|
|
537
621
|
}
|
|
538
622
|
|
|
539
623
|
let newHistory = [];
|
|
@@ -574,4 +658,7 @@ module.exports = {
|
|
|
574
658
|
sanitizeHistoryForFiles,
|
|
575
659
|
trimHistory,
|
|
576
660
|
PERSONA_FILE,
|
|
661
|
+
detectModelCapabilities,
|
|
662
|
+
getProvider,
|
|
663
|
+
GeminiProvider,
|
|
577
664
|
};
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
const logger = require("../utils/logger.cjs");
|
|
32
32
|
const settings = require("../config/settings.cjs");
|
|
33
33
|
const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
34
|
+
const { assertProviderBaseUrl, safeProviderFetch } = require("../utils/providerUrl.cjs");
|
|
34
35
|
|
|
35
36
|
// Shown in place of a media part, on the wire and in the canonical history.
|
|
36
37
|
const MEDIA_NOTE = "[تم إرفاق ملف/وسائط في الرسالة]";
|
|
@@ -49,9 +50,7 @@ const ADAPTERS = {
|
|
|
49
50
|
baseUrlSetting: "openai_base_url",
|
|
50
51
|
defaultBaseUrl: "https://api.openai.com/v1",
|
|
51
52
|
tools: openaiTools,
|
|
52
|
-
systemMessage: (systemInstruction) => [
|
|
53
|
-
{ role: "system", content: systemInstruction },
|
|
54
|
-
],
|
|
53
|
+
systemMessage: (systemInstruction) => [{ role: "system", content: systemInstruction }],
|
|
55
54
|
userMessage: (text) => ({ role: "user", content: text }),
|
|
56
55
|
/** Canonical history -> chat-completions messages. */
|
|
57
56
|
historyToMessages: historyToOpenAI,
|
|
@@ -97,6 +96,110 @@ const ADAPTERS = {
|
|
|
97
96
|
},
|
|
98
97
|
};
|
|
99
98
|
|
|
99
|
+
const {
|
|
100
|
+
BaseAIProvider,
|
|
101
|
+
registerProvider,
|
|
102
|
+
getProvider,
|
|
103
|
+
detectModelCapabilities,
|
|
104
|
+
} = require("./aiRouter.cjs");
|
|
105
|
+
|
|
106
|
+
function visionAllowed(providerId) {
|
|
107
|
+
const id = providerId || settings.get("ai_provider");
|
|
108
|
+
return Boolean(settings.get("ai_vision_enabled") && detectModelCapabilities(id).supportsVision);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function prepareInlineImageMedia(
|
|
112
|
+
providerId,
|
|
113
|
+
parts,
|
|
114
|
+
mediaMessage,
|
|
115
|
+
mimeOverride,
|
|
116
|
+
downloadContentFromMessage,
|
|
117
|
+
) {
|
|
118
|
+
const isVisionOn = visionAllowed(providerId);
|
|
119
|
+
const mime = mimeOverride || mediaMessage?.mimetype || "ملف";
|
|
120
|
+
if (isVisionOn && mime.startsWith("image/") && typeof downloadContentFromMessage === "function") {
|
|
121
|
+
try {
|
|
122
|
+
const stream = await downloadContentFromMessage(mediaMessage, "image");
|
|
123
|
+
let buffer = Buffer.from([]);
|
|
124
|
+
for await (const chunk of stream) buffer = Buffer.concat([buffer, chunk]);
|
|
125
|
+
if (buffer.length) {
|
|
126
|
+
parts.push({
|
|
127
|
+
inlineData: {
|
|
128
|
+
mimeType: mime,
|
|
129
|
+
data: buffer.toString("base64"),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return "inline:image";
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger.warn({ err }, `[${providerId}] failed to extract inline image for vision`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
parts.push({
|
|
139
|
+
text: `[تم إرفاق وسائط (${mime}) — المزود الحالي لا يستطيع قراءة هذا النوع من الوسائط أو تم تعطيل ميزة تحليل الصور]`,
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class OpenAIProvider extends BaseAIProvider {
|
|
145
|
+
constructor() {
|
|
146
|
+
super({
|
|
147
|
+
id: "openai",
|
|
148
|
+
label: "OpenAI-compatible",
|
|
149
|
+
keySetting: "openai_api_key",
|
|
150
|
+
modelSetting: "openai_model",
|
|
151
|
+
baseUrlSetting: "openai_base_url",
|
|
152
|
+
defaultBaseUrl: "https://api.openai.com/v1",
|
|
153
|
+
supportsFiles: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async prepareMedia(parts, mediaMessage, mimeOverride, { downloadContentFromMessage } = {}) {
|
|
158
|
+
return prepareInlineImageMedia(
|
|
159
|
+
"openai",
|
|
160
|
+
parts,
|
|
161
|
+
mediaMessage,
|
|
162
|
+
mimeOverride,
|
|
163
|
+
downloadContentFromMessage,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async runTurn(options) {
|
|
168
|
+
return runProviderAgent("openai", options);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
class AnthropicProvider extends BaseAIProvider {
|
|
173
|
+
constructor() {
|
|
174
|
+
super({
|
|
175
|
+
id: "anthropic",
|
|
176
|
+
label: "Anthropic",
|
|
177
|
+
keySetting: "anthropic_api_key",
|
|
178
|
+
modelSetting: "anthropic_model",
|
|
179
|
+
baseUrlSetting: "anthropic_base_url",
|
|
180
|
+
defaultBaseUrl: "https://api.anthropic.com",
|
|
181
|
+
supportsFiles: false,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async prepareMedia(parts, mediaMessage, mimeOverride, { downloadContentFromMessage } = {}) {
|
|
186
|
+
return prepareInlineImageMedia(
|
|
187
|
+
"anthropic",
|
|
188
|
+
parts,
|
|
189
|
+
mediaMessage,
|
|
190
|
+
mimeOverride,
|
|
191
|
+
downloadContentFromMessage,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async runTurn(options) {
|
|
196
|
+
return runProviderAgent("anthropic", options);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
registerProvider("openai", new OpenAIProvider());
|
|
201
|
+
registerProvider("anthropic", new AnthropicProvider());
|
|
202
|
+
|
|
100
203
|
function adapterFor(provider) {
|
|
101
204
|
const adapter = ADAPTERS[provider];
|
|
102
205
|
if (!adapter) throw new Error(`مزود غير معروف: ${provider}`);
|
|
@@ -160,15 +263,7 @@ function toJsonSchema(schema) {
|
|
|
160
263
|
} else if (key === "anyOf") {
|
|
161
264
|
out.anyOf = toJsonSchema(value);
|
|
162
265
|
} else if (
|
|
163
|
-
[
|
|
164
|
-
"description",
|
|
165
|
-
"enum",
|
|
166
|
-
"required",
|
|
167
|
-
"format",
|
|
168
|
-
"minimum",
|
|
169
|
-
"maximum",
|
|
170
|
-
"default",
|
|
171
|
-
].includes(key)
|
|
266
|
+
["description", "enum", "required", "format", "minimum", "maximum", "default"].includes(key)
|
|
172
267
|
) {
|
|
173
268
|
out[key] = value;
|
|
174
269
|
}
|
|
@@ -204,11 +299,17 @@ function anthropicTools() {
|
|
|
204
299
|
// ===========================================================================
|
|
205
300
|
|
|
206
301
|
function partsText(parts) {
|
|
302
|
+
const isVisionOn = visionAllowed();
|
|
207
303
|
return (parts || [])
|
|
208
304
|
.map((part) => {
|
|
209
305
|
if (part?.text) return part.text;
|
|
210
|
-
|
|
211
|
-
|
|
306
|
+
if (
|
|
307
|
+
isVisionOn &&
|
|
308
|
+
part?.inlineData?.mimeType?.startsWith("image/") &&
|
|
309
|
+
part?.inlineData?.data
|
|
310
|
+
) {
|
|
311
|
+
return "";
|
|
312
|
+
}
|
|
212
313
|
if (part?.fileData || part?.inlineData) return MEDIA_NOTE;
|
|
213
314
|
return "";
|
|
214
315
|
})
|
|
@@ -242,9 +343,7 @@ function historyToOpenAI(history) {
|
|
|
242
343
|
(history || []).forEach((turn, turnIndex) => {
|
|
243
344
|
if (!turn || !Array.isArray(turn.parts)) return;
|
|
244
345
|
const role = turn.role === "model" ? "assistant" : "user";
|
|
245
|
-
const calls = turn.parts
|
|
246
|
-
.filter((part) => part?.functionCall)
|
|
247
|
-
.map((part) => part.functionCall);
|
|
346
|
+
const calls = turn.parts.filter((part) => part?.functionCall).map((part) => part.functionCall);
|
|
248
347
|
const responses = turn.parts
|
|
249
348
|
.filter((part) => part?.functionResponse)
|
|
250
349
|
.map((part) => part.functionResponse);
|
|
@@ -268,6 +367,29 @@ function historyToOpenAI(history) {
|
|
|
268
367
|
return;
|
|
269
368
|
}
|
|
270
369
|
|
|
370
|
+
const isVisionOn = visionAllowed("openai");
|
|
371
|
+
const imageParts =
|
|
372
|
+
isVisionOn && role === "user"
|
|
373
|
+
? turn.parts.filter(
|
|
374
|
+
(p) => p?.inlineData?.mimeType?.startsWith("image/") && p?.inlineData?.data,
|
|
375
|
+
)
|
|
376
|
+
: [];
|
|
377
|
+
|
|
378
|
+
let userContent = text;
|
|
379
|
+
if (imageParts.length) {
|
|
380
|
+
const items = [];
|
|
381
|
+
if (text) items.push({ type: "text", text });
|
|
382
|
+
for (const img of imageParts) {
|
|
383
|
+
items.push({
|
|
384
|
+
type: "image_url",
|
|
385
|
+
image_url: {
|
|
386
|
+
url: `data:${img.inlineData.mimeType};base64,${img.inlineData.data}`,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
userContent = items;
|
|
391
|
+
}
|
|
392
|
+
|
|
271
393
|
if (responses.length) {
|
|
272
394
|
for (const response of responses) {
|
|
273
395
|
messages.push({
|
|
@@ -276,11 +398,11 @@ function historyToOpenAI(history) {
|
|
|
276
398
|
content: JSON.stringify(response.response ?? {}),
|
|
277
399
|
});
|
|
278
400
|
}
|
|
279
|
-
if (
|
|
401
|
+
if (userContent) messages.push({ role: "user", content: userContent });
|
|
280
402
|
return;
|
|
281
403
|
}
|
|
282
404
|
|
|
283
|
-
if (
|
|
405
|
+
if (userContent) messages.push({ role, content: userContent });
|
|
284
406
|
});
|
|
285
407
|
|
|
286
408
|
return messages;
|
|
@@ -330,7 +452,23 @@ function historyToAnthropic(history) {
|
|
|
330
452
|
content: JSON.stringify(part.functionResponse.response ?? {}),
|
|
331
453
|
});
|
|
332
454
|
} else if (part?.fileData || part?.inlineData) {
|
|
333
|
-
|
|
455
|
+
const isVisionOn = visionAllowed("anthropic");
|
|
456
|
+
if (
|
|
457
|
+
isVisionOn &&
|
|
458
|
+
part?.inlineData?.mimeType?.startsWith("image/") &&
|
|
459
|
+
part?.inlineData?.data
|
|
460
|
+
) {
|
|
461
|
+
blocks.push({
|
|
462
|
+
type: "image",
|
|
463
|
+
source: {
|
|
464
|
+
type: "base64",
|
|
465
|
+
media_type: part.inlineData.mimeType,
|
|
466
|
+
data: part.inlineData.data,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
blocks.push({ type: "text", text: MEDIA_NOTE });
|
|
471
|
+
}
|
|
334
472
|
}
|
|
335
473
|
}
|
|
336
474
|
|
|
@@ -352,8 +490,8 @@ function parseOpenAIResponse(payload) {
|
|
|
352
490
|
const content = Array.isArray(message.content)
|
|
353
491
|
? message.content.map((block) => block?.text || "").join("\n")
|
|
354
492
|
: typeof message.content === "string"
|
|
355
|
-
|
|
356
|
-
|
|
493
|
+
? message.content
|
|
494
|
+
: "";
|
|
357
495
|
|
|
358
496
|
const calls = (message.tool_calls || [])
|
|
359
497
|
.map((toolCall, i) => ({
|
|
@@ -377,7 +515,7 @@ function parseOpenAIResponse(payload) {
|
|
|
377
515
|
|
|
378
516
|
function parseAnthropicResponse(payload) {
|
|
379
517
|
const blocks = (payload?.content || []).filter(
|
|
380
|
-
(block) => block?.type === "text" || block?.type === "tool_use"
|
|
518
|
+
(block) => block?.type === "text" || block?.type === "tool_use",
|
|
381
519
|
);
|
|
382
520
|
|
|
383
521
|
const calls = blocks
|
|
@@ -411,17 +549,17 @@ async function callApi({ url, headers, body, label }) {
|
|
|
411
549
|
|
|
412
550
|
let response;
|
|
413
551
|
try {
|
|
414
|
-
response = await
|
|
552
|
+
response = await safeProviderFetch(url, {
|
|
415
553
|
method: "POST",
|
|
416
554
|
headers: { "Content-Type": "application/json", ...headers },
|
|
417
555
|
body: JSON.stringify(body),
|
|
418
556
|
signal: controller.signal,
|
|
419
|
-
});
|
|
557
|
+
}, { allowLoopback: true });
|
|
420
558
|
} catch (err) {
|
|
421
559
|
throw new Error(
|
|
422
560
|
err?.name === "AbortError"
|
|
423
561
|
? `${label} API request timed out after ${API_TIMEOUT_MS / 1000}s`
|
|
424
|
-
: `${label} API request failed: ${err?.message || err}
|
|
562
|
+
: `${label} API request failed: ${err?.message || err}`,
|
|
425
563
|
);
|
|
426
564
|
} finally {
|
|
427
565
|
clearTimeout(timer);
|
|
@@ -445,7 +583,11 @@ async function callApi({ url, headers, body, label }) {
|
|
|
445
583
|
return payload;
|
|
446
584
|
}
|
|
447
585
|
|
|
448
|
-
function requestFor(
|
|
586
|
+
function requestFor(
|
|
587
|
+
adapter,
|
|
588
|
+
provider,
|
|
589
|
+
{ baseUrl, apiKey, model, systemInstruction, messages, tools },
|
|
590
|
+
) {
|
|
449
591
|
if (provider === "anthropic") {
|
|
450
592
|
return {
|
|
451
593
|
url: `${baseUrl}/v1/messages`,
|
|
@@ -481,8 +623,8 @@ function withTimeout(promise, ms, label) {
|
|
|
481
623
|
promise,
|
|
482
624
|
new Promise((_, reject) => {
|
|
483
625
|
timer = setTimeout(
|
|
484
|
-
() => reject(new Error(`${label}
|
|
485
|
-
ms
|
|
626
|
+
() => reject(new Error(`${label} استغرقت العملية أكثر من ${Math.round(ms / 1000)} ثانية`)),
|
|
627
|
+
ms,
|
|
486
628
|
);
|
|
487
629
|
}),
|
|
488
630
|
]).finally(() => clearTimeout(timer));
|
|
@@ -505,15 +647,18 @@ function withTimeout(promise, ms, label) {
|
|
|
505
647
|
* @param {boolean} [options.useTools=true]
|
|
506
648
|
* @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number, sources: Array, searchOffered: boolean}>}
|
|
507
649
|
*/
|
|
508
|
-
async function runProviderAgent(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
650
|
+
async function runProviderAgent(
|
|
651
|
+
provider,
|
|
652
|
+
{
|
|
653
|
+
parts,
|
|
654
|
+
systemInstruction,
|
|
655
|
+
history = [],
|
|
656
|
+
status = null,
|
|
657
|
+
context = {},
|
|
658
|
+
useTools = true,
|
|
659
|
+
maxSteps = null,
|
|
660
|
+
} = {},
|
|
661
|
+
) {
|
|
517
662
|
const adapter = adapterFor(provider);
|
|
518
663
|
|
|
519
664
|
const apiKey = settings.get(adapter.keySetting);
|
|
@@ -521,9 +666,10 @@ async function runProviderAgent(provider, {
|
|
|
521
666
|
|
|
522
667
|
const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
|
|
523
668
|
const model = settings.get(adapter.modelSetting);
|
|
524
|
-
const baseUrl =
|
|
525
|
-
settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl
|
|
526
|
-
|
|
669
|
+
const baseUrl = assertProviderBaseUrl(
|
|
670
|
+
settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl,
|
|
671
|
+
{ allowLoopback: true },
|
|
672
|
+
);
|
|
527
673
|
|
|
528
674
|
const turnText = partsText(parts);
|
|
529
675
|
|
|
@@ -585,8 +731,7 @@ async function runProviderAgent(provider, {
|
|
|
585
731
|
// Same contract as the Gemini loop: the budget answer, not a lie.
|
|
586
732
|
return finish({
|
|
587
733
|
text:
|
|
588
|
-
parsed.text ||
|
|
589
|
-
"شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
|
|
734
|
+
parsed.text || "شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
|
|
590
735
|
canonical,
|
|
591
736
|
toolCalls,
|
|
592
737
|
steps,
|
|
@@ -596,9 +741,7 @@ async function runProviderAgent(provider, {
|
|
|
596
741
|
steps += 1;
|
|
597
742
|
|
|
598
743
|
if (status) {
|
|
599
|
-
const line = parsed.calls
|
|
600
|
-
.map((call) => describeCall(call.name, call.args))
|
|
601
|
-
.join("\n");
|
|
744
|
+
const line = parsed.calls.map((call) => describeCall(call.name, call.args)).join("\n");
|
|
602
745
|
await status.update(line);
|
|
603
746
|
}
|
|
604
747
|
|
|
@@ -612,7 +755,7 @@ async function runProviderAgent(provider, {
|
|
|
612
755
|
toolResult = await withTimeout(
|
|
613
756
|
runTool(call.name, call.args, context),
|
|
614
757
|
settings.get("ai_tool_timeout_ms"),
|
|
615
|
-
call.name
|
|
758
|
+
call.name,
|
|
616
759
|
);
|
|
617
760
|
} catch (err) {
|
|
618
761
|
toolResult = { error: err?.message || String(err) };
|
|
@@ -663,4 +806,7 @@ module.exports = {
|
|
|
663
806
|
parseOpenAIResponse,
|
|
664
807
|
parseAnthropicResponse,
|
|
665
808
|
ADAPTERS,
|
|
809
|
+
getProvider,
|
|
810
|
+
OpenAIProvider,
|
|
811
|
+
AnthropicProvider,
|
|
666
812
|
};
|