levix-bot 2.0.1 → 2.1.1
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 +23 -5
- package/SETUP.md +24 -4
- package/deploy/install.sh +106 -11
- package/deploy/levix.service +3 -0
- package/package.json +11 -6
- package/public/dashboard.css +9 -0
- package/public/dashboard.js +233 -43
- package/scheduler.cjs +15 -0
- package/src/bootstrap/core.js +36 -32
- package/src/bootstrap/events.cjs +4 -2
- package/src/bootstrap/panel.js +9 -7
- package/src/commands/gemini.cjs +45 -28
- package/src/commands/restart.cjs +6 -2
- package/src/commands/stt.cjs +43 -26
- package/src/config/ai-identity.cjs +61 -0
- package/src/config/ai-persona.md +92 -17
- package/src/config/brand.cjs +8 -18
- package/src/config/constants.js +11 -2
- package/src/config/secrets.cjs +19 -0
- package/src/config/settings.cjs +96 -2
- package/src/core/connection.js +140 -134
- package/src/core/events.js +64 -22
- package/src/core/proxy.js +266 -0
- package/src/core/session.js +828 -0
- package/src/core/socket.js +39 -5
- package/src/index.js +136 -29
- package/src/routes/dashboard.api.esm.js +127 -30
- package/src/services/aiAgent.cjs +278 -48
- package/src/services/aiTools.cjs +14 -12
- package/src/utils/storage.esm.js +5 -0
- package/views/dashboard.ejs +20 -11
- package/views/qr.ejs +23 -3
package/src/services/aiAgent.cjs
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require("fs");
|
|
17
17
|
const path = require("path");
|
|
18
|
-
const {
|
|
18
|
+
const { GoogleGenAI, FunctionCallingConfigMode } = require("@google/genai");
|
|
19
19
|
|
|
20
20
|
const logger = require("../utils/logger.cjs");
|
|
21
|
-
const
|
|
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
24
|
const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
@@ -30,13 +30,19 @@ const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
|
30
30
|
|
|
31
31
|
// One client per key. The dashboard can set a key on a bot that started
|
|
32
32
|
// without one, so the client is built lazily and rebuilt when the key changes.
|
|
33
|
+
//
|
|
34
|
+
// @google/genai replaced the old `new GoogleGenerativeAI(key)` +
|
|
35
|
+
// `getGenerativeModel({model, tools, systemInstruction})` pair with a single
|
|
36
|
+
// client whose model and per-request configuration are passed at call time —
|
|
37
|
+
// which suits Levix, because the model and the tool set are settings that may
|
|
38
|
+
// change between one message and the next.
|
|
33
39
|
let clientCache = { key: null, client: null };
|
|
34
40
|
|
|
35
41
|
function geminiClient() {
|
|
36
42
|
const key = settings.get("gemini_api_key");
|
|
37
43
|
if (!key) return null;
|
|
38
44
|
if (clientCache.key !== key) {
|
|
39
|
-
clientCache = { key, client: new
|
|
45
|
+
clientCache = { key, client: new GoogleGenAI({ apiKey: key }) };
|
|
40
46
|
}
|
|
41
47
|
return clientCache.client;
|
|
42
48
|
}
|
|
@@ -57,26 +63,36 @@ try {
|
|
|
57
63
|
// Not fatal: loadPersona() falls back to DEFAULT_PERSONA below.
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
// Fallback only
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// Fallback only, for the moment between "the file is missing" and "the template
|
|
67
|
+
// has been copied". The real persona is the operator's copy of ai-persona.md.
|
|
68
|
+
// No product identity in here: that comes from src/config/ai-identity.cjs and is
|
|
69
|
+
// prepended separately, so it survives any edit to the persona.
|
|
70
|
+
const DEFAULT_PERSONA = `You are a capable personal WhatsApp assistant.
|
|
71
|
+
Be concise and direct by default, reply in the language the user writes in, and
|
|
72
|
+
use your tools instead of guessing when something needs to be looked up.`;
|
|
65
73
|
|
|
66
|
-
let personaCache = { text: null,
|
|
74
|
+
let personaCache = { text: null, stamp: null };
|
|
67
75
|
|
|
68
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* The persona file, re-read whenever it changes on disk.
|
|
78
|
+
*
|
|
79
|
+
* Keyed on the nanosecond mtime AND the size, not `mtimeMs`: that is a
|
|
80
|
+
* millisecond float, and two saves inside the same millisecond — the dashboard
|
|
81
|
+
* writing twice, or a test — produced the same key, so the second edit was
|
|
82
|
+
* served from the cache and the operator's change silently did not apply.
|
|
83
|
+
*/
|
|
69
84
|
function loadPersona() {
|
|
70
85
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
86
|
+
const stat = fs.statSync(PERSONA_FILE, { bigint: true });
|
|
87
|
+
const stamp = `${stat.mtimeNs}:${stat.size}`;
|
|
88
|
+
if (personaCache.text && personaCache.stamp === stamp) {
|
|
73
89
|
return personaCache.text;
|
|
74
90
|
}
|
|
75
91
|
const raw = fs.readFileSync(PERSONA_FILE, "utf8").trim();
|
|
76
92
|
// Everything above the `---` separator is a note to the human editing the
|
|
77
93
|
// file, not part of the prompt.
|
|
78
94
|
const body = raw.includes("\n---") ? raw.split(/\n---\s*\n/).slice(1).join("\n---\n") : raw;
|
|
79
|
-
personaCache = { text: body.trim() || DEFAULT_PERSONA,
|
|
95
|
+
personaCache = { text: body.trim() || DEFAULT_PERSONA, stamp };
|
|
80
96
|
return personaCache.text;
|
|
81
97
|
} catch {
|
|
82
98
|
return DEFAULT_PERSONA;
|
|
@@ -96,13 +112,12 @@ function nowLabel() {
|
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
/**
|
|
99
|
-
*
|
|
115
|
+
* product identity + persona + who/where/when + long-term memory.
|
|
100
116
|
*
|
|
101
|
-
* The identity block goes FIRST and does not come from the persona file
|
|
102
|
-
* operator can rewrite
|
|
103
|
-
* or who wrote it
|
|
104
|
-
*
|
|
105
|
-
* asked for it to go away.
|
|
117
|
+
* The identity block goes FIRST and does not come from the persona file. The
|
|
118
|
+
* operator owns the persona and can rewrite it from the dashboard; they do not
|
|
119
|
+
* own what the product is called or who wrote it, so that part is code (see
|
|
120
|
+
* src/config/ai-identity.cjs) and is never rendered by the panel.
|
|
106
121
|
*/
|
|
107
122
|
function buildSystemInstruction(context = {}) {
|
|
108
123
|
const {
|
|
@@ -116,17 +131,19 @@ function buildSystemInstruction(context = {}) {
|
|
|
116
131
|
} = context;
|
|
117
132
|
|
|
118
133
|
const runtime = [
|
|
119
|
-
"
|
|
120
|
-
`-
|
|
121
|
-
`-
|
|
122
|
-
`-
|
|
123
|
-
`-
|
|
124
|
-
|
|
134
|
+
"# Current context",
|
|
135
|
+
`- Speaking to you: ${senderName || "unknown"}${senderId ? ` (${senderId})` : ""}`,
|
|
136
|
+
`- Their role: ${isOwner ? "bot owner" : isAdmin ? "bot admin" : "ordinary user"}`,
|
|
137
|
+
`- Where: ${isGroup ? `a group chat${chatName ? ` called "${chatName}"` : ""}` : "a private one-to-one chat"}`,
|
|
138
|
+
`- Local time now: ${nowLabel()}`,
|
|
139
|
+
isGroup
|
|
140
|
+
? "- This is a group: every participant sees your reply. Say nothing here that belongs to one person."
|
|
141
|
+
: "- This is a private chat: only this person sees your reply.",
|
|
125
142
|
].join("\n");
|
|
126
143
|
|
|
127
144
|
const memoryBlock = memory.buildMemoryContext(chatId);
|
|
128
145
|
|
|
129
|
-
return [
|
|
146
|
+
return [aiIdentity.systemBlock, loadPersona(), runtime, memoryBlock]
|
|
130
147
|
.filter(Boolean)
|
|
131
148
|
.join("\n\n");
|
|
132
149
|
}
|
|
@@ -152,12 +169,19 @@ function trimHistory(history) {
|
|
|
152
169
|
return out;
|
|
153
170
|
}
|
|
154
171
|
|
|
155
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Gemini file URIs live ~48h; after that every call fails on the old parts.
|
|
174
|
+
*
|
|
175
|
+
* @google/genai throws its own `ApiError` with a numeric `status`, where the
|
|
176
|
+
* old SDK put the code in a handful of different places. Both are still read:
|
|
177
|
+
* the shapes are cheap to check and the stored history can outlive an upgrade.
|
|
178
|
+
*/
|
|
156
179
|
function isFileReferenceError(error) {
|
|
157
180
|
if (!error) return false;
|
|
158
181
|
const status =
|
|
159
182
|
error.status ||
|
|
160
183
|
error.statusCode ||
|
|
184
|
+
error.code ||
|
|
161
185
|
error.response?.status ||
|
|
162
186
|
error.error?.code;
|
|
163
187
|
const message = `${error.message || ""} ${error.details || ""}`.toLowerCase();
|
|
@@ -204,6 +228,143 @@ function isAgentEnabled() {
|
|
|
204
228
|
return Boolean(geminiClient()) && settings.get("ai_agent");
|
|
205
229
|
}
|
|
206
230
|
|
|
231
|
+
// ===========================================================================
|
|
232
|
+
// Google Search — Gemini's own, not ours
|
|
233
|
+
// ===========================================================================
|
|
234
|
+
//
|
|
235
|
+
// `{ googleSearch: {} }` is a BUILT-IN tool: the model decides when a question
|
|
236
|
+
// needs the web, Google runs the search inside the same request, and the answer
|
|
237
|
+
// comes back already grounded with the sources it used. There is no
|
|
238
|
+
// google_search function declaration anywhere in Levix, nothing to execute in
|
|
239
|
+
// the tool loop, and no search API key — deliberately. A hand-rolled search
|
|
240
|
+
// tool would be a second, worse implementation of something the model already
|
|
241
|
+
// does better.
|
|
242
|
+
//
|
|
243
|
+
// The shape is the Gemini 2.x one. The older `googleSearchRetrieval` is what
|
|
244
|
+
// 1.5 models take; this SDK's TypeScript types only know that one, but the SDK
|
|
245
|
+
// passes `tools` through to the REST API untouched (dist/index.js:1377), so the
|
|
246
|
+
// 2.x shape reaches the model exactly as written.
|
|
247
|
+
|
|
248
|
+
const GOOGLE_SEARCH_TOOL = Object.freeze({ googleSearch: {} });
|
|
249
|
+
|
|
250
|
+
/** Gemini-only. Nothing here is offered to Groq or any other provider. */
|
|
251
|
+
function googleSearchEnabled() {
|
|
252
|
+
return settings.get("ai_google_search") === true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The `tools` array for one request.
|
|
257
|
+
*
|
|
258
|
+
* Custom function declarations and the built-in search go in the same array;
|
|
259
|
+
* Gemini 2.x accepts both together. When it does not (see runAgent), the search
|
|
260
|
+
* half is dropped and Levix's own tools are kept — never the other way round.
|
|
261
|
+
*/
|
|
262
|
+
function buildTools({ useTools = true, search = googleSearchEnabled() } = {}) {
|
|
263
|
+
const tools = [];
|
|
264
|
+
if (useTools) tools.push(...toolDeclarations());
|
|
265
|
+
if (search) tools.push(GOOGLE_SEARCH_TOOL);
|
|
266
|
+
return tools;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The tool configuration that lets a built-in tool and custom functions run in
|
|
271
|
+
* the same request.
|
|
272
|
+
*
|
|
273
|
+
* Gemini 3 calls this "tool context circulation": the server runs Google Search
|
|
274
|
+
* itself, and `includeServerSideToolInvocations` is what puts those server-side
|
|
275
|
+
* calls into the conversation so the custom functions can see them. Without the
|
|
276
|
+
* flag the combination is refused outright.
|
|
277
|
+
*
|
|
278
|
+
* Turning it on also constrains function calling to VALIDATED — AUTO is not
|
|
279
|
+
* supported alongside it — so the two are set together or not at all.
|
|
280
|
+
*
|
|
281
|
+
* Returns undefined when there is nothing to combine, which leaves the ordinary
|
|
282
|
+
* AUTO behaviour exactly as it was.
|
|
283
|
+
*/
|
|
284
|
+
function buildToolConfig({ useTools = true, search = googleSearchEnabled() } = {}) {
|
|
285
|
+
if (!useTools || !search) return undefined;
|
|
286
|
+
return {
|
|
287
|
+
includeServerSideToolInvocations: true,
|
|
288
|
+
functionCallingConfig: { mode: FunctionCallingConfigMode.VALIDATED },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Does this error mean "this model will not take search and functions at once"?
|
|
294
|
+
*
|
|
295
|
+
* Some model/API combinations refuse the mix with a 400. Rather than quietly
|
|
296
|
+
* shipping fewer tools forever, runAgent() retries once without the built-in
|
|
297
|
+
* search and says so in the log — Levix's own tools are the ones that must
|
|
298
|
+
* survive, because commands depend on them.
|
|
299
|
+
*/
|
|
300
|
+
function isToolCombinationError(error) {
|
|
301
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.error?.code ?? 0);
|
|
302
|
+
const message = `${error?.message || ""} ${error?.statusText || ""}`.toLowerCase();
|
|
303
|
+
if (status && status !== 400) return false;
|
|
304
|
+
return (
|
|
305
|
+
message.includes("tool") &&
|
|
306
|
+
(message.includes("not supported") ||
|
|
307
|
+
message.includes("unsupported") ||
|
|
308
|
+
message.includes("only one tool") ||
|
|
309
|
+
message.includes("cannot be used") ||
|
|
310
|
+
message.includes("at most one"))
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Pull the sources Gemini actually grounded on out of one response.
|
|
316
|
+
*
|
|
317
|
+
* Reads only real grounding metadata — `candidates[].groundingMetadata` — so a
|
|
318
|
+
* turn where the model answered from its own knowledge yields nothing and no
|
|
319
|
+
* Sources block is ever invented.
|
|
320
|
+
*/
|
|
321
|
+
function extractSources(response) {
|
|
322
|
+
const out = [];
|
|
323
|
+
// The path is unchanged from the old SDK: candidates[].groundingMetadata
|
|
324
|
+
// .groundingChunks[].web.{uri,title}. Verified against @google/genai's
|
|
325
|
+
// GroundingMetadata / GroundingChunkWeb, which also adds a Vertex-only
|
|
326
|
+
// `domain` field that the Gemini Developer API does not populate.
|
|
327
|
+
for (const candidate of response?.candidates || []) {
|
|
328
|
+
const grounding = candidate?.groundingMetadata;
|
|
329
|
+
for (const chunk of grounding?.groundingChunks || []) {
|
|
330
|
+
const uri = chunk?.web?.uri;
|
|
331
|
+
if (!uri) continue;
|
|
332
|
+
out.push({ uri, title: chunk.web.title || hostOf(uri) || "source" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function hostOf(uri) {
|
|
339
|
+
try {
|
|
340
|
+
return new URL(uri).hostname.replace(/^www\./, "");
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** How many sources are worth putting under a WhatsApp answer. */
|
|
347
|
+
const MAX_SOURCES = 5;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A compact Sources block, or "" when there is nothing to cite.
|
|
351
|
+
*
|
|
352
|
+
* Deduplicated by URL, capped, and written in the plain style the rest of the
|
|
353
|
+
* bot uses — no Markdown links, which WhatsApp renders literally.
|
|
354
|
+
*/
|
|
355
|
+
function formatSources(sources) {
|
|
356
|
+
const seen = new Set();
|
|
357
|
+
const lines = [];
|
|
358
|
+
for (const source of sources || []) {
|
|
359
|
+
if (!source?.uri || seen.has(source.uri)) continue;
|
|
360
|
+
seen.add(source.uri);
|
|
361
|
+
const label = String(source.title || hostOf(source.uri) || "source").trim();
|
|
362
|
+
lines.push(`• ${label} — ${source.uri}`);
|
|
363
|
+
if (lines.length >= MAX_SOURCES) break;
|
|
364
|
+
}
|
|
365
|
+
return lines.length ? `\n\n*Sources:*\n${lines.join("\n")}` : "";
|
|
366
|
+
}
|
|
367
|
+
|
|
207
368
|
/**
|
|
208
369
|
* Run one agent turn.
|
|
209
370
|
*
|
|
@@ -227,24 +388,61 @@ async function runAgent({
|
|
|
227
388
|
if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
|
|
228
389
|
|
|
229
390
|
const stepBudget = maxSteps ?? settings.get("ai_max_tool_steps");
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
391
|
+
const systemInstruction = buildSystemInstruction(context);
|
|
392
|
+
const trimmed = trimHistory(history);
|
|
393
|
+
const model = settings.get("gemini_model");
|
|
394
|
+
|
|
395
|
+
// `ai.chats` rather than a hand-rolled contents array on purpose. Gemini 3
|
|
396
|
+
// returns a `thoughtSignature` on the parts it produces and expects it back
|
|
397
|
+
// on the following turns of a multi-step tool run; the Chat class records
|
|
398
|
+
// `candidates[0].content` verbatim into its history, signature included, so
|
|
399
|
+
// circulating it is not something this file has to remember to do. Levix
|
|
400
|
+
// persists that same history, and a signature is a plain string, so it
|
|
401
|
+
// survives the round trip through SQLite too.
|
|
402
|
+
const openChat = (search) => {
|
|
403
|
+
const tools = buildTools({ useTools, search });
|
|
404
|
+
const toolConfig = buildToolConfig({ useTools, search });
|
|
405
|
+
return genAI.chats.create({
|
|
406
|
+
model,
|
|
407
|
+
history: trimmed,
|
|
408
|
+
config: {
|
|
409
|
+
systemInstruction,
|
|
410
|
+
...(tools.length ? { tools } : {}),
|
|
411
|
+
...(toolConfig ? { toolConfig } : {}),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
let searchOffered = googleSearchEnabled();
|
|
417
|
+
let chat = openChat(searchOffered);
|
|
238
418
|
const toolCalls = [];
|
|
419
|
+
// Only ever filled from real grounding metadata; see extractSources().
|
|
420
|
+
const sources = [];
|
|
421
|
+
|
|
422
|
+
let response;
|
|
423
|
+
try {
|
|
424
|
+
response = await chat.sendMessage({ message: parts });
|
|
425
|
+
} catch (error) {
|
|
426
|
+
// Defensive only. Gemini 3 takes the built-in search and custom functions
|
|
427
|
+
// in one request, so this is not the expected path — but a model that
|
|
428
|
+
// refuses the combination must not cost Levix its own tools, which the
|
|
429
|
+
// commands depend on. Search is what gets dropped, never the functions.
|
|
430
|
+
if (!searchOffered || !isToolCombinationError(error)) throw error;
|
|
431
|
+
logger.warn(
|
|
432
|
+
{ err: error?.message, model },
|
|
433
|
+
"[aiAgent] this model refused Google Search together with function tools — retrying with functions only"
|
|
434
|
+
);
|
|
435
|
+
searchOffered = false;
|
|
436
|
+
chat = openChat(false);
|
|
437
|
+
response = await chat.sendMessage({ message: parts });
|
|
438
|
+
}
|
|
239
439
|
|
|
240
|
-
|
|
440
|
+
sources.push(...extractSources(response));
|
|
241
441
|
let steps = 0;
|
|
242
442
|
|
|
243
443
|
while (steps < stepBudget) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
? result.response.functionCalls()
|
|
247
|
-
: null) || [];
|
|
444
|
+
// `functionCalls` is a getter on the new response object, not a method.
|
|
445
|
+
const calls = response.functionCalls || [];
|
|
248
446
|
if (!calls.length) break;
|
|
249
447
|
|
|
250
448
|
steps += 1;
|
|
@@ -264,28 +462,42 @@ async function runAgent({
|
|
|
264
462
|
"[aiAgent] tool call"
|
|
265
463
|
);
|
|
266
464
|
|
|
267
|
-
|
|
465
|
+
// Named apart from the outer `response` on purpose: that one is the
|
|
466
|
+
// model's reply and is reassigned at the bottom of this loop.
|
|
467
|
+
let toolResult;
|
|
268
468
|
try {
|
|
269
|
-
|
|
469
|
+
toolResult = await withTimeout(
|
|
270
470
|
runTool(call.name, call.args, context),
|
|
271
471
|
settings.get("ai_tool_timeout_ms"),
|
|
272
472
|
call.name
|
|
273
473
|
);
|
|
274
474
|
} catch (err) {
|
|
275
|
-
|
|
475
|
+
toolResult = { error: err?.message || String(err) };
|
|
276
476
|
}
|
|
277
477
|
responses.push({
|
|
278
|
-
functionResponse: {
|
|
478
|
+
functionResponse: {
|
|
479
|
+
// Echoed back when the model supplied one: with several calls in a
|
|
480
|
+
// single turn the id is how the API pairs each result with its call.
|
|
481
|
+
...(call.id ? { id: call.id } : {}),
|
|
482
|
+
name: call.name,
|
|
483
|
+
response: toolResult,
|
|
484
|
+
},
|
|
279
485
|
});
|
|
280
486
|
}
|
|
281
487
|
|
|
282
488
|
if (status) await status.update("🤖 بجهّز الرد...");
|
|
283
|
-
|
|
489
|
+
// One message carrying every functionResponse part, which is what the API
|
|
490
|
+
// expects when the model asked for several calls in one turn.
|
|
491
|
+
response = await chat.sendMessage({ message: responses });
|
|
492
|
+
// Grounding can happen on any turn, not just the first.
|
|
493
|
+
sources.push(...extractSources(response));
|
|
284
494
|
}
|
|
285
495
|
|
|
496
|
+
// `text` is a getter now, and it returns undefined rather than throwing when
|
|
497
|
+
// the candidate has no text part.
|
|
286
498
|
let text = "";
|
|
287
499
|
try {
|
|
288
|
-
text =
|
|
500
|
+
text = response.text ?? "";
|
|
289
501
|
} catch (err) {
|
|
290
502
|
logger.warn({ err: err?.message }, "[aiAgent] response had no text part");
|
|
291
503
|
}
|
|
@@ -297,16 +509,34 @@ async function runAgent({
|
|
|
297
509
|
|
|
298
510
|
let newHistory = [];
|
|
299
511
|
try {
|
|
300
|
-
|
|
512
|
+
// Synchronous in this SDK, and `true` asks for the curated history — the
|
|
513
|
+
// turns the API would actually accept back, with empty or invalid model
|
|
514
|
+
// outputs left out. That is exactly what Levix wants to persist.
|
|
515
|
+
newHistory = trimHistory(chat.getHistory(true));
|
|
301
516
|
} catch (err) {
|
|
302
517
|
logger.warn({ err: err?.message }, "[aiAgent] could not read chat history");
|
|
303
518
|
}
|
|
304
519
|
|
|
305
|
-
return {
|
|
520
|
+
return {
|
|
521
|
+
text,
|
|
522
|
+
history: newHistory,
|
|
523
|
+
toolCalls,
|
|
524
|
+
steps,
|
|
525
|
+
// Empty unless Gemini actually grounded this answer on a search.
|
|
526
|
+
sources,
|
|
527
|
+
searchOffered,
|
|
528
|
+
};
|
|
306
529
|
}
|
|
307
530
|
|
|
308
531
|
module.exports = {
|
|
309
532
|
runAgent,
|
|
533
|
+
buildTools,
|
|
534
|
+
buildToolConfig,
|
|
535
|
+
googleSearchEnabled,
|
|
536
|
+
extractSources,
|
|
537
|
+
formatSources,
|
|
538
|
+
isToolCombinationError,
|
|
539
|
+
GOOGLE_SEARCH_TOOL,
|
|
310
540
|
buildSystemInstruction,
|
|
311
541
|
loadPersona,
|
|
312
542
|
isAgentEnabled,
|
package/src/services/aiTools.cjs
CHANGED
|
@@ -26,21 +26,23 @@ const {
|
|
|
26
26
|
listRoles,
|
|
27
27
|
} = require("../utils/permissions.cjs");
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
29
|
+
// @google/genai exports a `Type` enum (the old SDK called it `SchemaType`).
|
|
30
|
+
// Its members are the uppercase wire values — Type.STRING === "STRING" — so the
|
|
31
|
+
// fallback below is the same thing spelled out, and a future rename can't take
|
|
32
|
+
// the whole agent down with it.
|
|
33
|
+
let Type = null;
|
|
32
34
|
try {
|
|
33
|
-
({
|
|
35
|
+
({ Type } = require("@google/genai"));
|
|
34
36
|
} catch {
|
|
35
|
-
|
|
37
|
+
Type = null;
|
|
36
38
|
}
|
|
37
|
-
const T =
|
|
38
|
-
STRING: "
|
|
39
|
-
NUMBER: "
|
|
40
|
-
INTEGER: "
|
|
41
|
-
BOOLEAN: "
|
|
42
|
-
ARRAY: "
|
|
43
|
-
OBJECT: "
|
|
39
|
+
const T = Type || {
|
|
40
|
+
STRING: "STRING",
|
|
41
|
+
NUMBER: "NUMBER",
|
|
42
|
+
INTEGER: "INTEGER",
|
|
43
|
+
BOOLEAN: "BOOLEAN",
|
|
44
|
+
ARRAY: "ARRAY",
|
|
45
|
+
OBJECT: "OBJECT",
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
const UA =
|
package/src/utils/storage.esm.js
CHANGED
|
@@ -34,6 +34,11 @@ export const getAllNotes = store.getAllNotes;
|
|
|
34
34
|
export const deleteNote = store.deleteNote;
|
|
35
35
|
export const getAllNotesFlat = store.getAllNotesFlat;
|
|
36
36
|
|
|
37
|
+
// ===== WhatsApp auth =====
|
|
38
|
+
// Only the wipe. Reads and writes go through src/auth/, which owns the
|
|
39
|
+
// serialisation; unlinking has to work with no socket alive to ask.
|
|
40
|
+
export const clearAuthState = store.authClearAll;
|
|
41
|
+
|
|
37
42
|
// ===== Pairing QR =====
|
|
38
43
|
export const saveQrCode = store.saveQrCode;
|
|
39
44
|
export const getQrCode = store.getQrCode;
|
package/views/dashboard.ejs
CHANGED
|
@@ -100,17 +100,34 @@
|
|
|
100
100
|
<section class="view" id="view-connection">
|
|
101
101
|
<div class="section-head">
|
|
102
102
|
<h2>Connection</h2>
|
|
103
|
-
<p>
|
|
103
|
+
<p>Start or stop the WhatsApp session, and link or unlink an account.</p>
|
|
104
104
|
</div>
|
|
105
105
|
|
|
106
106
|
<div class="card">
|
|
107
107
|
<div class="card-head">
|
|
108
|
-
<h2>
|
|
108
|
+
<h2>Session</h2>
|
|
109
109
|
<div class="spacer"></div>
|
|
110
110
|
<span id="conn-state"></span>
|
|
111
111
|
</div>
|
|
112
112
|
|
|
113
|
-
<div id="
|
|
113
|
+
<div class="hint" id="conn-detail"></div>
|
|
114
|
+
|
|
115
|
+
<div class="hint" id="conn-proxy"></div>
|
|
116
|
+
|
|
117
|
+
<div class="inline" style="margin-top:12px">
|
|
118
|
+
<button class="btn btn-primary" id="start-btn">Start session</button>
|
|
119
|
+
<button class="btn" id="reconnect-btn">Reconnect to apply</button>
|
|
120
|
+
<button class="btn" id="stop-btn">Stop session</button>
|
|
121
|
+
<button class="btn btn-danger" id="unlink-btn">Unlink account</button>
|
|
122
|
+
<button class="btn" id="restart-btn">Restart bot</button>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="hint" style="margin-top:8px">
|
|
125
|
+
Levix runs whether or not WhatsApp is connected. Starting the session is
|
|
126
|
+
the only thing that opens a connection; restart only comes back on its own
|
|
127
|
+
under a supervisor (pm2, systemd, Docker).
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div id="qr-panel" style="display:none">
|
|
114
131
|
<div class="qr-box">
|
|
115
132
|
<div class="qr-frame" id="qr-frame"></div>
|
|
116
133
|
<div class="hint" id="qr-note">Waiting for a QR code…</div>
|
|
@@ -121,14 +138,6 @@
|
|
|
121
138
|
</ol>
|
|
122
139
|
</div>
|
|
123
140
|
</div>
|
|
124
|
-
|
|
125
|
-
<div class="inline" style="margin-top:12px">
|
|
126
|
-
<button class="btn btn-danger" id="unlink-btn">Unlink account</button>
|
|
127
|
-
<button class="btn" id="restart-btn">Restart bot</button>
|
|
128
|
-
</div>
|
|
129
|
-
<div class="hint" style="margin-top:8px">
|
|
130
|
-
Restart only comes back on its own under a supervisor (pm2, systemd, Docker).
|
|
131
|
-
</div>
|
|
132
141
|
</div>
|
|
133
142
|
</section>
|
|
134
143
|
|
package/views/qr.ejs
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
<li>Settings → Linked devices → Link a device.</li>
|
|
56
56
|
<li>Point the camera at the code above.</li>
|
|
57
57
|
</ol>
|
|
58
|
+
<div class="hint">
|
|
59
|
+
No code yet? Levix only connects when you ask it to — press
|
|
60
|
+
<strong>Start session</strong> on the dashboard's Connection screen.
|
|
61
|
+
</div>
|
|
58
62
|
</div>
|
|
59
63
|
|
|
60
64
|
<a class="btn" href="/">Back to the dashboard</a>
|
|
@@ -63,7 +67,7 @@
|
|
|
63
67
|
<div class="foot"><%= brand.name %> · by <%= brand.developer %></div>
|
|
64
68
|
</div>
|
|
65
69
|
|
|
66
|
-
<script src="/socket.io
|
|
70
|
+
<script src="/socket.io.min.js"></script>
|
|
67
71
|
<script src="/qrcode.min.js"></script>
|
|
68
72
|
<script>
|
|
69
73
|
(() => {
|
|
@@ -94,13 +98,29 @@
|
|
|
94
98
|
if (initial) render(initial);
|
|
95
99
|
else setState("Waiting for a code…", "");
|
|
96
100
|
|
|
101
|
+
function clear(text, kind) {
|
|
102
|
+
painted = null;
|
|
103
|
+
frame.innerHTML = "";
|
|
104
|
+
setState(text, kind || "");
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
const socket = io();
|
|
98
108
|
socket.on("qr", render);
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
|
|
110
|
+
// The backend drops a code the moment it stops being scannable. Without
|
|
111
|
+
// this the page sat on an expired QR forever, and somebody would keep
|
|
112
|
+
// pointing a phone at it.
|
|
113
|
+
socket.on("qr_cleared", () => clear("Waiting for a code…", ""));
|
|
114
|
+
|
|
115
|
+
socket.on("session", (state) => {
|
|
116
|
+
if (state.state === "connected") {
|
|
101
117
|
frame.innerHTML = '<div style="padding:60px 30px;color:#0d1730">Linked ✓</div>';
|
|
118
|
+
painted = null;
|
|
102
119
|
setState("Connected", "on");
|
|
120
|
+
return;
|
|
103
121
|
}
|
|
122
|
+
if (state.state === "waiting_for_qr") return; // the qr event paints it
|
|
123
|
+
clear(state.canStart ? "Not started — start it from the dashboard" : "Connecting…", "");
|
|
104
124
|
});
|
|
105
125
|
})();
|
|
106
126
|
</script>
|