levix-bot 2.0.0 → 2.1.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.
@@ -15,10 +15,10 @@
15
15
 
16
16
  const fs = require("fs");
17
17
  const path = require("path");
18
- const { GoogleGenerativeAI } = require("@google/generative-ai");
18
+ const { GoogleGenAI, FunctionCallingConfigMode } = require("@google/genai");
19
19
 
20
20
  const logger = require("../utils/logger.cjs");
21
- const brand = require("../config/brand.cjs");
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 GoogleGenerativeAI(key) };
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 — the real persona is config/ai-persona.md (editable from the
61
- // dashboard). No name in here: identity comes from brand.cjs and is prepended
62
- // separately so it survives any edit to the persona.
63
- const DEFAULT_PERSONA = `بوت واتساب ذكي وخفيف الظل.
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, mtime: 0 };
74
+ let personaCache = { text: null, stamp: null };
67
75
 
68
- /** The persona file, re-read whenever it changes on disk. */
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 { mtimeMs } = fs.statSync(PERSONA_FILE);
72
- if (personaCache.text && personaCache.mtime === mtimeMs) {
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, mtime: mtimeMs };
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
- * frozen identity + persona + who/where/when + long-term memory.
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: the
102
- * operator can rewrite the persona from the dashboard, but not the bot's name
103
- * or who wrote it.
104
- * Deliberately short: the old prompt was a page of rules, and the operator
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
- `- المتكلم: ${senderName || "غير معروف"}${senderId ? ` (${senderId})` : ""}`,
121
- `- صلاحيته: ${isOwner ? "مالك البوت" : isAdmin ? "أدمن" : "مستخدم عادي"}`,
122
- `- المكان: ${isGroup ? `جروب${chatName ? ` اسمه "${chatName}"` : ""}` : "محادثة خاصة"}`,
123
- `- الوقت دلوقتي: ${nowLabel()}`,
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 [brand.identityPrompt, loadPersona(), runtime, memoryBlock]
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
- /** Gemini file URIs live ~48h; after that every call fails on the old parts. */
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 model = genAI.getGenerativeModel({
232
- model: settings.get("gemini_model"),
233
- systemInstruction: buildSystemInstruction(context),
234
- ...(useTools ? { tools: toolDeclarations() } : {}),
235
- });
236
-
237
- const chat = model.startChat({ history: trimHistory(history) });
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
- let result = await chat.sendMessage(parts);
440
+ sources.push(...extractSources(response));
241
441
  let steps = 0;
242
442
 
243
443
  while (steps < stepBudget) {
244
- const calls =
245
- (typeof result.response?.functionCalls === "function"
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
- let response;
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
- response = await withTimeout(
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
- response = { error: err?.message || String(err) };
475
+ toolResult = { error: err?.message || String(err) };
276
476
  }
277
477
  responses.push({
278
- functionResponse: { name: call.name, response },
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
- result = await chat.sendMessage(responses);
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 = result.response.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
- newHistory = trimHistory(await chat.getHistory());
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 { text, history: newHistory, toolCalls, steps };
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,
@@ -26,21 +26,23 @@ const {
26
26
  listRoles,
27
27
  } = require("../utils/permissions.cjs");
28
28
 
29
- // The SDK exports a SchemaType enum; fall back to the wire values if a future
30
- // version drops it, so a rename can't take the whole agent down.
31
- let SchemaType = null;
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
- ({ SchemaType } = require("@google/generative-ai"));
35
+ ({ Type } = require("@google/genai"));
34
36
  } catch {
35
- SchemaType = null;
37
+ Type = null;
36
38
  }
37
- const T = SchemaType || {
38
- STRING: "string",
39
- NUMBER: "number",
40
- INTEGER: "integer",
41
- BOOLEAN: "boolean",
42
- ARRAY: "array",
43
- OBJECT: "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 =
@@ -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;
@@ -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>Link a WhatsApp account, or unlink the one in use.</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>Account</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="qr-panel">
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/socket.io.js"></script>
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
- socket.on("status_update", (data) => {
100
- if (data.status === "Connected") {
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>