levix-bot 2.2.1 → 2.3.0
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 +156 -87
- package/package.json +4 -1
- package/public/dashboard.js +1 -1
- package/src/commands/gemini.cjs +40 -88
- package/src/commands/stt.cjs +8 -3
- package/src/config/settings.cjs +68 -8
- package/src/core/proxy.js +3 -3
- package/src/routes/dashboard.api.esm.js +7 -2
- package/src/services/aiAgent.cjs +35 -5
- package/src/services/aiProviders.cjs +666 -0
- package/src/utils/memory.cjs +1 -1
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// The openai and anthropic providers: the same agent aiAgent.cjs runs on
|
|
2
|
+
// Gemini, walked over two plain HTTP APIs.
|
|
3
|
+
//
|
|
4
|
+
// Why hand-rolled and not an SDK: the OpenAI side has to be OPENAI-COMPATIBLE,
|
|
5
|
+
// not OpenAI — the whole point of the provider is that the operator points
|
|
6
|
+
// `openai_base_url` at whichever server they have (OpenAI, OpenRouter, Groq,
|
|
7
|
+
// Ollama, LM Studio), and every SDK along that path hides or hard-codes the
|
|
8
|
+
// base URL in its own way. Two `fetch` calls with no dependencies between them
|
|
9
|
+
// are easier to keep honest than a dependency that fights the feature.
|
|
10
|
+
//
|
|
11
|
+
// What is deliberately NOT here:
|
|
12
|
+
// * Google Search grounding — that is a Gemini built-in tool; these APIs
|
|
13
|
+
// have no such concept. (Levix's own web_search tool still travels, so the
|
|
14
|
+
// model can still look things up.)
|
|
15
|
+
// * Media. There is no Files API to upload to on this path, so a message's
|
|
16
|
+
// media parts become a one-line note and the text is what gets answered.
|
|
17
|
+
// `!stt` and `!generate` stay on Gemini for the same reason.
|
|
18
|
+
//
|
|
19
|
+
// Storage does not change: chat history stays in the Gemini parts format on
|
|
20
|
+
// disk (single canonical shape, so the operator can switch providers in the
|
|
21
|
+
// panel without invalidating a single conversation), and each adapter here
|
|
22
|
+
// translates on the way out and back. Tool calls follow the same round trip:
|
|
23
|
+
// a provider's native tool_call / tool_use id is echoed into the canonical
|
|
24
|
+
// functionCall/functionResponse pair, so a conversation survives being moved
|
|
25
|
+
// from one provider to another mid-flight.
|
|
26
|
+
//
|
|
27
|
+
// The loop, narration, budget and history trimming are the aiAgent.cjs ones —
|
|
28
|
+
// runAgent() dispatches here and hands over the already-built system
|
|
29
|
+
// instruction and already-trimmed history.
|
|
30
|
+
|
|
31
|
+
const logger = require("../utils/logger.cjs");
|
|
32
|
+
const settings = require("../config/settings.cjs");
|
|
33
|
+
const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
34
|
+
|
|
35
|
+
// Shown in place of a media part, on the wire and in the canonical history.
|
|
36
|
+
const MEDIA_NOTE = "[تم إرفاق ملف/وسائط في الرسالة]";
|
|
37
|
+
|
|
38
|
+
const API_TIMEOUT_MS = 120000;
|
|
39
|
+
|
|
40
|
+
// ===========================================================================
|
|
41
|
+
// adapters: everything that differs between the two wire formats
|
|
42
|
+
// ===========================================================================
|
|
43
|
+
|
|
44
|
+
const ADAPTERS = {
|
|
45
|
+
openai: {
|
|
46
|
+
label: "OpenAI-compatible",
|
|
47
|
+
keySetting: "openai_api_key",
|
|
48
|
+
modelSetting: "openai_model",
|
|
49
|
+
baseUrlSetting: "openai_base_url",
|
|
50
|
+
defaultBaseUrl: "https://api.openai.com/v1",
|
|
51
|
+
tools: openaiTools,
|
|
52
|
+
systemMessage: (systemInstruction) => [
|
|
53
|
+
{ role: "system", content: systemInstruction },
|
|
54
|
+
],
|
|
55
|
+
userMessage: (text) => ({ role: "user", content: text }),
|
|
56
|
+
/** Canonical history -> chat-completions messages. */
|
|
57
|
+
historyToMessages: historyToOpenAI,
|
|
58
|
+
/** Raw parsed response body -> { text, calls, assistantMessage }. */
|
|
59
|
+
parseResponse: parseOpenAIResponse,
|
|
60
|
+
/** One tool result as a message the API pairs with the call by id. */
|
|
61
|
+
toolResultMessages: (calls, results) =>
|
|
62
|
+
calls.map((call, i) => ({
|
|
63
|
+
role: "tool",
|
|
64
|
+
tool_call_id: call.id,
|
|
65
|
+
content: JSON.stringify(results[i] ?? {}),
|
|
66
|
+
})),
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
anthropic: {
|
|
70
|
+
label: "Anthropic",
|
|
71
|
+
keySetting: "anthropic_api_key",
|
|
72
|
+
modelSetting: "anthropic_model",
|
|
73
|
+
baseUrlSetting: "anthropic_base_url",
|
|
74
|
+
defaultBaseUrl: "https://api.anthropic.com",
|
|
75
|
+
tools: anthropicTools,
|
|
76
|
+
// Anthropic takes the system prompt as a top-level field, not a message.
|
|
77
|
+
systemMessage: () => [],
|
|
78
|
+
userMessage: (text) => ({
|
|
79
|
+
role: "user",
|
|
80
|
+
content: [{ type: "text", text }],
|
|
81
|
+
}),
|
|
82
|
+
historyToMessages: historyToAnthropic,
|
|
83
|
+
parseResponse: parseAnthropicResponse,
|
|
84
|
+
// Tool results ride in a user message as tool_result blocks, which is why
|
|
85
|
+
// this also has to say which role carries them.
|
|
86
|
+
toolResultRole: "user",
|
|
87
|
+
toolResultMessages: (calls, results) => [
|
|
88
|
+
{
|
|
89
|
+
role: "user",
|
|
90
|
+
content: calls.map((call, i) => ({
|
|
91
|
+
type: "tool_result",
|
|
92
|
+
tool_use_id: call.id,
|
|
93
|
+
content: JSON.stringify(results[i] ?? {}),
|
|
94
|
+
})),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function adapterFor(provider) {
|
|
101
|
+
const adapter = ADAPTERS[provider];
|
|
102
|
+
if (!adapter) throw new Error(`مزود غير معروف: ${provider}`);
|
|
103
|
+
return adapter;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The settings key holding the API key of whichever provider is active.
|
|
108
|
+
*
|
|
109
|
+
* Gemini is deliberately not in ADAPTERS — its loop lives in aiAgent.cjs and
|
|
110
|
+
* needs no wire adapter — so the key lookup is its own total map over all
|
|
111
|
+
* three providers, default included. This runs inside the /stats handler and
|
|
112
|
+
* in front of every AI command, so a provider the map does not know must be
|
|
113
|
+
* the only thing that throws here.
|
|
114
|
+
*/
|
|
115
|
+
const PROVIDER_KEY_SETTINGS = Object.freeze({
|
|
116
|
+
gemini: "gemini_api_key",
|
|
117
|
+
openai: "openai_api_key",
|
|
118
|
+
anthropic: "anthropic_api_key",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function activeProviderKeySetting(provider = settings.get("ai_provider")) {
|
|
122
|
+
const keySetting = PROVIDER_KEY_SETTINGS[provider];
|
|
123
|
+
if (!keySetting) throw new Error(`مزود غير معروف: ${provider}`);
|
|
124
|
+
return keySetting;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ===========================================================================
|
|
128
|
+
// schema translation — Gemini Schema (uppercase `Type`) -> JSON Schema
|
|
129
|
+
// ===========================================================================
|
|
130
|
+
|
|
131
|
+
// aiTools.cjs declares its parameters in Gemini's dialect because that is what
|
|
132
|
+
// the Gemini path consumes natively. Both other formats take ordinary JSON
|
|
133
|
+
// Schema, whose only real difference is lowercase type names and a few fields
|
|
134
|
+
// Gemini invented that other servers reject.
|
|
135
|
+
const TYPE_MAP = {
|
|
136
|
+
string: "string",
|
|
137
|
+
number: "number",
|
|
138
|
+
integer: "integer",
|
|
139
|
+
boolean: "boolean",
|
|
140
|
+
array: "array",
|
|
141
|
+
object: "object",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function toJsonSchema(schema) {
|
|
145
|
+
if (Array.isArray(schema)) return schema.map(toJsonSchema);
|
|
146
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
147
|
+
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
150
|
+
if (key === "type") {
|
|
151
|
+
const name = String(value).toLowerCase();
|
|
152
|
+
out.type = TYPE_MAP[name] || name;
|
|
153
|
+
} else if (key === "properties" && value && typeof value === "object") {
|
|
154
|
+
out.properties = {};
|
|
155
|
+
for (const [name, sub] of Object.entries(value)) {
|
|
156
|
+
out.properties[name] = toJsonSchema(sub);
|
|
157
|
+
}
|
|
158
|
+
} else if (key === "items") {
|
|
159
|
+
out.items = toJsonSchema(value);
|
|
160
|
+
} else if (key === "anyOf") {
|
|
161
|
+
out.anyOf = toJsonSchema(value);
|
|
162
|
+
} else if (
|
|
163
|
+
[
|
|
164
|
+
"description",
|
|
165
|
+
"enum",
|
|
166
|
+
"required",
|
|
167
|
+
"format",
|
|
168
|
+
"minimum",
|
|
169
|
+
"maximum",
|
|
170
|
+
"default",
|
|
171
|
+
].includes(key)
|
|
172
|
+
) {
|
|
173
|
+
out[key] = value;
|
|
174
|
+
}
|
|
175
|
+
// Everything else (nullable, ...) is Gemini-specific and dropped rather
|
|
176
|
+
// than sent to a server that might 400 on an unknown field.
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Levix's tool declarations in chat-completions `tools` shape. */
|
|
182
|
+
function openaiTools() {
|
|
183
|
+
return toolDeclarations()[0].functionDeclarations.map((declaration) => ({
|
|
184
|
+
type: "function",
|
|
185
|
+
function: {
|
|
186
|
+
name: declaration.name,
|
|
187
|
+
description: declaration.description,
|
|
188
|
+
parameters: toJsonSchema(declaration.parameters),
|
|
189
|
+
},
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Levix's tool declarations in Messages-API `tools` shape. */
|
|
194
|
+
function anthropicTools() {
|
|
195
|
+
return toolDeclarations()[0].functionDeclarations.map((declaration) => ({
|
|
196
|
+
name: declaration.name,
|
|
197
|
+
description: declaration.description,
|
|
198
|
+
input_schema: toJsonSchema(declaration.parameters),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ===========================================================================
|
|
203
|
+
// history translation — canonical Gemini parts -> provider messages
|
|
204
|
+
// ===========================================================================
|
|
205
|
+
|
|
206
|
+
function partsText(parts) {
|
|
207
|
+
return (parts || [])
|
|
208
|
+
.map((part) => {
|
|
209
|
+
if (part?.text) return part.text;
|
|
210
|
+
// Media on this path is a note, never bytes: there is no upload API to
|
|
211
|
+
// send it through (see the header comment).
|
|
212
|
+
if (part?.fileData || part?.inlineData) return MEDIA_NOTE;
|
|
213
|
+
return "";
|
|
214
|
+
})
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function safeJsonArgs(raw) {
|
|
220
|
+
if (raw == null) return {};
|
|
221
|
+
if (typeof raw === "object") return raw;
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(raw);
|
|
224
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
225
|
+
} catch {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Canonical -> chat-completions.
|
|
232
|
+
*
|
|
233
|
+
* `callIds` closes the loop for history written by the GEMINI path: Gemini
|
|
234
|
+
* function calls carry no id, but a `tool` message is required to name the
|
|
235
|
+
* `tool_call_id` it answers, so the assistant turn mints one and the map hands
|
|
236
|
+
* the same value to the matching functionResponse.
|
|
237
|
+
*/
|
|
238
|
+
function historyToOpenAI(history) {
|
|
239
|
+
const messages = [];
|
|
240
|
+
const callIds = new Map();
|
|
241
|
+
|
|
242
|
+
(history || []).forEach((turn, turnIndex) => {
|
|
243
|
+
if (!turn || !Array.isArray(turn.parts)) return;
|
|
244
|
+
const role = turn.role === "model" ? "assistant" : "user";
|
|
245
|
+
const calls = turn.parts
|
|
246
|
+
.filter((part) => part?.functionCall)
|
|
247
|
+
.map((part) => part.functionCall);
|
|
248
|
+
const responses = turn.parts
|
|
249
|
+
.filter((part) => part?.functionResponse)
|
|
250
|
+
.map((part) => part.functionResponse);
|
|
251
|
+
const text = partsText(turn.parts);
|
|
252
|
+
|
|
253
|
+
if (calls.length) {
|
|
254
|
+
const toolCalls = calls.map((call, i) => {
|
|
255
|
+
const id = call.id || `call_${turnIndex}_${i}`;
|
|
256
|
+
if (call.name) callIds.set(call.name, id);
|
|
257
|
+
return {
|
|
258
|
+
id,
|
|
259
|
+
type: "function",
|
|
260
|
+
function: { name: call.name, arguments: JSON.stringify(call.args || {}) },
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
messages.push({
|
|
264
|
+
role: "assistant",
|
|
265
|
+
content: text || null,
|
|
266
|
+
tool_calls: toolCalls,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (responses.length) {
|
|
272
|
+
for (const response of responses) {
|
|
273
|
+
messages.push({
|
|
274
|
+
role: "tool",
|
|
275
|
+
tool_call_id: response.id || callIds.get(response.name) || `call_${turnIndex}`,
|
|
276
|
+
content: JSON.stringify(response.response ?? {}),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (text) messages.push({ role: "user", content: text });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (text) messages.push({ role, content: text });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return messages;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Canonical -> Messages API. Content is always a block array, consecutive
|
|
291
|
+
* same-role turns are merged (the API requires strict alternation), and a
|
|
292
|
+
* tool_result block must land in a user turn — which is exactly where the
|
|
293
|
+
* canonical format already keeps functionResponse parts.
|
|
294
|
+
*/
|
|
295
|
+
function historyToAnthropic(history) {
|
|
296
|
+
const messages = [];
|
|
297
|
+
const callIds = new Map();
|
|
298
|
+
|
|
299
|
+
const push = (role, blocks) => {
|
|
300
|
+
if (!blocks.length) return;
|
|
301
|
+
const last = messages[messages.length - 1];
|
|
302
|
+
if (last && last.role === role) last.content.push(...blocks);
|
|
303
|
+
else messages.push({ role, content: blocks });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
(history || []).forEach((turn, turnIndex) => {
|
|
307
|
+
if (!turn || !Array.isArray(turn.parts)) return;
|
|
308
|
+
const role = turn.role === "model" ? "assistant" : "user";
|
|
309
|
+
const blocks = [];
|
|
310
|
+
|
|
311
|
+
for (const part of turn.parts) {
|
|
312
|
+
if (part?.text) {
|
|
313
|
+
blocks.push({ type: "text", text: part.text });
|
|
314
|
+
} else if (part?.functionCall) {
|
|
315
|
+
const id = part.functionCall.id || `call_${turnIndex}_${blocks.length}`;
|
|
316
|
+
if (part.functionCall.name) callIds.set(part.functionCall.name, id);
|
|
317
|
+
blocks.push({
|
|
318
|
+
type: "tool_use",
|
|
319
|
+
id,
|
|
320
|
+
name: part.functionCall.name,
|
|
321
|
+
input: part.functionCall.args || {},
|
|
322
|
+
});
|
|
323
|
+
} else if (part?.functionResponse) {
|
|
324
|
+
blocks.push({
|
|
325
|
+
type: "tool_result",
|
|
326
|
+
tool_use_id:
|
|
327
|
+
part.functionResponse.id ||
|
|
328
|
+
callIds.get(part.functionResponse.name) ||
|
|
329
|
+
`call_${turnIndex}`,
|
|
330
|
+
content: JSON.stringify(part.functionResponse.response ?? {}),
|
|
331
|
+
});
|
|
332
|
+
} else if (part?.fileData || part?.inlineData) {
|
|
333
|
+
blocks.push({ type: "text", text: MEDIA_NOTE });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
push(role, blocks);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// The API refuses a conversation that opens on an assistant turn.
|
|
341
|
+
while (messages.length && messages[0].role !== "user") messages.shift();
|
|
342
|
+
return messages;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ===========================================================================
|
|
346
|
+
// response parsing
|
|
347
|
+
// ===========================================================================
|
|
348
|
+
|
|
349
|
+
function parseOpenAIResponse(payload) {
|
|
350
|
+
const message = payload?.choices?.[0]?.message || {};
|
|
351
|
+
|
|
352
|
+
const content = Array.isArray(message.content)
|
|
353
|
+
? message.content.map((block) => block?.text || "").join("\n")
|
|
354
|
+
: typeof message.content === "string"
|
|
355
|
+
? message.content
|
|
356
|
+
: "";
|
|
357
|
+
|
|
358
|
+
const calls = (message.tool_calls || [])
|
|
359
|
+
.map((toolCall, i) => ({
|
|
360
|
+
id: toolCall.id || `call_${i}`,
|
|
361
|
+
name: toolCall.function?.name,
|
|
362
|
+
args: safeJsonArgs(toolCall.function?.arguments),
|
|
363
|
+
}))
|
|
364
|
+
.filter((call) => call.name);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
text: content.trim(),
|
|
368
|
+
calls,
|
|
369
|
+
// Echoed verbatim so the tool messages can pair on the same ids.
|
|
370
|
+
assistantMessage: {
|
|
371
|
+
role: "assistant",
|
|
372
|
+
content: message.content ?? null,
|
|
373
|
+
...(calls.length ? { tool_calls: message.tool_calls } : {}),
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function parseAnthropicResponse(payload) {
|
|
379
|
+
const blocks = (payload?.content || []).filter(
|
|
380
|
+
(block) => block?.type === "text" || block?.type === "tool_use"
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const calls = blocks
|
|
384
|
+
.filter((block) => block.type === "tool_use")
|
|
385
|
+
.map((block) => ({
|
|
386
|
+
id: block.id || `call_${blocks.indexOf(block)}`,
|
|
387
|
+
name: block.name,
|
|
388
|
+
args: block.input && typeof block.input === "object" ? block.input : {},
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
text: blocks
|
|
393
|
+
.filter((block) => block.type === "text")
|
|
394
|
+
.map((block) => block.text || "")
|
|
395
|
+
.join("\n")
|
|
396
|
+
.trim(),
|
|
397
|
+
calls,
|
|
398
|
+
// tool_use blocks must be echoed with their ids for the tool_result
|
|
399
|
+
// pairing; text goes back too, exactly as the API expects.
|
|
400
|
+
assistantMessage: { role: "assistant", content: blocks },
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ===========================================================================
|
|
405
|
+
// the wire
|
|
406
|
+
// ===========================================================================
|
|
407
|
+
|
|
408
|
+
async function callApi({ url, headers, body, label }) {
|
|
409
|
+
const controller = new AbortController();
|
|
410
|
+
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
411
|
+
|
|
412
|
+
let response;
|
|
413
|
+
try {
|
|
414
|
+
response = await fetch(url, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
417
|
+
body: JSON.stringify(body),
|
|
418
|
+
signal: controller.signal,
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
err?.name === "AbortError"
|
|
423
|
+
? `${label} API request timed out after ${API_TIMEOUT_MS / 1000}s`
|
|
424
|
+
: `${label} API request failed: ${err?.message || err}`
|
|
425
|
+
);
|
|
426
|
+
} finally {
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let payload = {};
|
|
431
|
+
try {
|
|
432
|
+
payload = await response.json();
|
|
433
|
+
} catch {
|
|
434
|
+
// A non-JSON body (a proxy error page, say) still gets the status below.
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
const message =
|
|
439
|
+
payload?.error?.message || payload?.error || response.statusText || "unknown error";
|
|
440
|
+
const error = new Error(`${label} API request failed (${response.status}): ${message}`);
|
|
441
|
+
error.status = response.status;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return payload;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function requestFor(adapter, provider, { baseUrl, apiKey, model, systemInstruction, messages, tools }) {
|
|
449
|
+
if (provider === "anthropic") {
|
|
450
|
+
return {
|
|
451
|
+
url: `${baseUrl}/v1/messages`,
|
|
452
|
+
headers: {
|
|
453
|
+
"x-api-key": apiKey,
|
|
454
|
+
"anthropic-version": "2023-06-01",
|
|
455
|
+
},
|
|
456
|
+
body: {
|
|
457
|
+
model,
|
|
458
|
+
max_tokens: 2048,
|
|
459
|
+
...(systemInstruction ? { system: systemInstruction } : {}),
|
|
460
|
+
messages,
|
|
461
|
+
...(tools.length ? { tools, tool_choice: { type: "auto" } } : {}),
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
url: `${baseUrl}/chat/completions`,
|
|
468
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
469
|
+
body: {
|
|
470
|
+
model,
|
|
471
|
+
messages: [...adapter.systemMessage(systemInstruction), ...messages],
|
|
472
|
+
...(tools.length ? { tools, tool_choice: "auto" } : {}),
|
|
473
|
+
temperature: 0.7,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function withTimeout(promise, ms, label) {
|
|
479
|
+
let timer;
|
|
480
|
+
return Promise.race([
|
|
481
|
+
promise,
|
|
482
|
+
new Promise((_, reject) => {
|
|
483
|
+
timer = setTimeout(
|
|
484
|
+
() => reject(new Error(`${label} تأخرت أكتر من ${Math.round(ms / 1000)} ثانية`)),
|
|
485
|
+
ms
|
|
486
|
+
);
|
|
487
|
+
}),
|
|
488
|
+
]).finally(() => clearTimeout(timer));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ===========================================================================
|
|
492
|
+
// the loop — same shape as aiAgent.runAgent, different wire format
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Run one agent turn on a non-Gemini provider.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} provider - "openai" | "anthropic"
|
|
499
|
+
* @param {object} options
|
|
500
|
+
* @param {Array} options.parts - the current message (text parts; media arrives as notes)
|
|
501
|
+
* @param {string} options.systemInstruction - built by aiAgent.buildSystemInstruction
|
|
502
|
+
* @param {Array} [options.history] - canonical history, already trimmed
|
|
503
|
+
* @param {object} [options.status] - live status message for narration
|
|
504
|
+
* @param {object} [options.context] - handed to the tools
|
|
505
|
+
* @param {boolean} [options.useTools=true]
|
|
506
|
+
* @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number, sources: Array, searchOffered: boolean}>}
|
|
507
|
+
*/
|
|
508
|
+
async function runProviderAgent(provider, {
|
|
509
|
+
parts,
|
|
510
|
+
systemInstruction,
|
|
511
|
+
history = [],
|
|
512
|
+
status = null,
|
|
513
|
+
context = {},
|
|
514
|
+
useTools = true,
|
|
515
|
+
maxSteps = null,
|
|
516
|
+
} = {}) {
|
|
517
|
+
const adapter = adapterFor(provider);
|
|
518
|
+
|
|
519
|
+
const apiKey = settings.get(adapter.keySetting);
|
|
520
|
+
if (!apiKey) throw new Error(`${adapter.label} API key is not configured`);
|
|
521
|
+
|
|
522
|
+
const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
|
|
523
|
+
const model = settings.get(adapter.modelSetting);
|
|
524
|
+
const baseUrl = String(
|
|
525
|
+
settings.get(adapter.baseUrlSetting) || adapter.defaultBaseUrl
|
|
526
|
+
).replace(/\/+$/, "");
|
|
527
|
+
|
|
528
|
+
const turnText = partsText(parts);
|
|
529
|
+
|
|
530
|
+
// The provider-native conversation, and the canonical one that comes back
|
|
531
|
+
// for storage. Both start from the trimmed history, then gain the current
|
|
532
|
+
// user turn — exactly what the Gemini path's Chat class does internally.
|
|
533
|
+
const messages = adapter.historyToMessages(history);
|
|
534
|
+
messages.push(adapter.userMessage(turnText));
|
|
535
|
+
|
|
536
|
+
const canonical = [...(Array.isArray(history) ? history : [])];
|
|
537
|
+
canonical.push({
|
|
538
|
+
role: "user",
|
|
539
|
+
parts: (parts || []).filter((part) => part?.text || part?.fileData || part?.inlineData),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const tools = useTools ? adapter.tools() : [];
|
|
543
|
+
const toolCalls = [];
|
|
544
|
+
let steps = 0;
|
|
545
|
+
|
|
546
|
+
const send = () => {
|
|
547
|
+
const request = requestFor(adapter, provider, {
|
|
548
|
+
baseUrl,
|
|
549
|
+
apiKey,
|
|
550
|
+
model,
|
|
551
|
+
systemInstruction,
|
|
552
|
+
messages,
|
|
553
|
+
tools,
|
|
554
|
+
});
|
|
555
|
+
return callApi({ ...request, label: adapter.label });
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
let response = await send();
|
|
559
|
+
while (true) {
|
|
560
|
+
const parsed = adapter.parseResponse(response);
|
|
561
|
+
|
|
562
|
+
if (parsed.text || parsed.calls.length) {
|
|
563
|
+
canonical.push({
|
|
564
|
+
role: "model",
|
|
565
|
+
parts: [
|
|
566
|
+
...(parsed.text ? [{ text: parsed.text }] : []),
|
|
567
|
+
...parsed.calls.map((call) => ({
|
|
568
|
+
functionCall: {
|
|
569
|
+
// The provider's own id, so a mid-conversation provider switch
|
|
570
|
+
// keeps tool call/result pairs intact.
|
|
571
|
+
...(call.id ? { id: call.id } : {}),
|
|
572
|
+
name: call.name,
|
|
573
|
+
args: call.args,
|
|
574
|
+
},
|
|
575
|
+
})),
|
|
576
|
+
],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
messages.push(parsed.assistantMessage);
|
|
580
|
+
|
|
581
|
+
if (!parsed.calls.length) {
|
|
582
|
+
return finish({ text: parsed.text, canonical, toolCalls, steps });
|
|
583
|
+
}
|
|
584
|
+
if (steps >= stepBudget) {
|
|
585
|
+
// Same contract as the Gemini loop: the budget answer, not a lie.
|
|
586
|
+
return finish({
|
|
587
|
+
text:
|
|
588
|
+
parsed.text ||
|
|
589
|
+
"شغّلت الأدوات المتاحة بس مقدرتش أوصل لإجابة نهائية. جرّب تسأل بصيغة أوضح.",
|
|
590
|
+
canonical,
|
|
591
|
+
toolCalls,
|
|
592
|
+
steps,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
steps += 1;
|
|
597
|
+
|
|
598
|
+
if (status) {
|
|
599
|
+
const line = parsed.calls
|
|
600
|
+
.map((call) => describeCall(call.name, call.args))
|
|
601
|
+
.join("\n");
|
|
602
|
+
await status.update(line);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const results = [];
|
|
606
|
+
for (const call of parsed.calls) {
|
|
607
|
+
toolCalls.push({ name: call.name, args: call.args });
|
|
608
|
+
logger.info({ tool: call.name, chatId: context.chatId }, "[aiAgent] tool call");
|
|
609
|
+
|
|
610
|
+
let toolResult;
|
|
611
|
+
try {
|
|
612
|
+
toolResult = await withTimeout(
|
|
613
|
+
runTool(call.name, call.args, context),
|
|
614
|
+
settings.get("ai_tool_timeout_ms"),
|
|
615
|
+
call.name
|
|
616
|
+
);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
toolResult = { error: err?.message || String(err) };
|
|
619
|
+
}
|
|
620
|
+
results.push(toolResult);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const resultMessages = adapter.toolResultMessages(parsed.calls, results);
|
|
624
|
+
messages.push(...resultMessages);
|
|
625
|
+
// The canonical shape keeps one user turn carrying every functionResponse,
|
|
626
|
+
// which is also what adapterFor("openai") expects to translate back.
|
|
627
|
+
canonical.push({
|
|
628
|
+
role: "user",
|
|
629
|
+
parts: parsed.calls.map((call, i) => ({
|
|
630
|
+
functionResponse: {
|
|
631
|
+
...(call.id ? { id: call.id } : {}),
|
|
632
|
+
name: call.name,
|
|
633
|
+
response: results[i],
|
|
634
|
+
},
|
|
635
|
+
})),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (status) await status.update("🤖 بجهّز الرد...");
|
|
639
|
+
response = await send();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function finish({ text, canonical, toolCalls, steps }) {
|
|
644
|
+
return {
|
|
645
|
+
text,
|
|
646
|
+
history: canonical,
|
|
647
|
+
toolCalls,
|
|
648
|
+
steps,
|
|
649
|
+
// Grounding is a Gemini feature; these providers never have sources.
|
|
650
|
+
sources: [],
|
|
651
|
+
searchOffered: false,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
module.exports = {
|
|
656
|
+
runProviderAgent,
|
|
657
|
+
activeProviderKeySetting,
|
|
658
|
+
toJsonSchema,
|
|
659
|
+
openaiTools,
|
|
660
|
+
anthropicTools,
|
|
661
|
+
historyToOpenAI,
|
|
662
|
+
historyToAnthropic,
|
|
663
|
+
parseOpenAIResponse,
|
|
664
|
+
parseAnthropicResponse,
|
|
665
|
+
ADAPTERS,
|
|
666
|
+
};
|
package/src/utils/memory.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Why files and not a table
|
|
4
4
|
// -------------------------
|
|
5
5
|
// The operator asked for "save it to your memory" to end up in a `.md` file
|
|
6
|
-
// per chat (plus one global file) — the same idea as an agent's
|
|
6
|
+
// per chat (plus one global file) — the same idea as an agent's AGENTS.md.
|
|
7
7
|
// Markdown keeps the memory:
|
|
8
8
|
// * human readable and hand-editable (open the file, fix a line, done),
|
|
9
9
|
// * diffable / backup-able without a DB dump,
|