levix-bot 2.2.0 → 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 -85
- package/SETUP.md +11 -5
- package/app.cjs +6 -0
- package/package.json +5 -2
- 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/panel/session-store.cjs +100 -0
- 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
package/src/config/settings.cjs
CHANGED
|
@@ -48,13 +48,40 @@ const SETTINGS = [
|
|
|
48
48
|
},
|
|
49
49
|
|
|
50
50
|
// --- AI ----------------------------------------------------------------
|
|
51
|
+
//
|
|
52
|
+
// One provider answers chats, chosen here and configured right below: the
|
|
53
|
+
// dashboard renders every one of these fields (the provider as a dropdown,
|
|
54
|
+
// keys as password fields), so a provider switch is a settings change, not a
|
|
55
|
+
// code change or an env file.
|
|
56
|
+
{
|
|
57
|
+
key: "ai_provider",
|
|
58
|
+
type: "string",
|
|
59
|
+
default: "gemini",
|
|
60
|
+
choices: ["gemini", "openai", "anthropic"],
|
|
61
|
+
choiceLabels: {
|
|
62
|
+
gemini: "Google Gemini",
|
|
63
|
+
openai: "OpenAI / compatible",
|
|
64
|
+
anthropic: "Anthropic Claude",
|
|
65
|
+
},
|
|
66
|
+
group: "ai",
|
|
67
|
+
label: "AI provider",
|
|
68
|
+
hint: "Which API answers chats. gemini = Google Gemini (the only one that reads media and has built-in Google Search). openai = any OpenAI-compatible chat-completions server. anthropic = Claude.",
|
|
69
|
+
},
|
|
51
70
|
{
|
|
52
71
|
key: "gemini_api_key",
|
|
53
72
|
type: "secret",
|
|
54
73
|
default: "",
|
|
55
74
|
group: "ai",
|
|
56
75
|
label: "Gemini API key",
|
|
57
|
-
hint: "Free key from aistudio.google.com/apikey
|
|
76
|
+
hint: "Free key from aistudio.google.com/apikey. Needed when the provider is gemini, and by !stt / !generate in every case.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "gemini_base_url",
|
|
80
|
+
type: "string",
|
|
81
|
+
default: "https://generativelanguage.googleapis.com",
|
|
82
|
+
group: "ai",
|
|
83
|
+
label: "Google Gemini base URL",
|
|
84
|
+
hint: "Google Generative Language API root. Change it for a proxy or compatible gateway; do not append /v1beta. Applies live to chat, media uploads, !stt and !generate.",
|
|
58
85
|
},
|
|
59
86
|
{
|
|
60
87
|
key: "gemini_model",
|
|
@@ -131,22 +158,54 @@ const SETTINGS = [
|
|
|
131
158
|
default: true,
|
|
132
159
|
group: "ai",
|
|
133
160
|
label: "Gemini Google Search",
|
|
134
|
-
hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the
|
|
161
|
+
hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the openai and anthropic providers never see it.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
key: "openai_api_key",
|
|
165
|
+
type: "secret",
|
|
166
|
+
default: "",
|
|
167
|
+
group: "ai",
|
|
168
|
+
label: "OpenAI-compatible API key",
|
|
169
|
+
hint: "Used when the provider is openai. Works with OpenAI itself or any server that speaks the same format: OpenRouter, Together, LM Studio, ...",
|
|
135
170
|
},
|
|
136
171
|
{
|
|
137
|
-
key: "
|
|
172
|
+
key: "openai_base_url",
|
|
173
|
+
type: "string",
|
|
174
|
+
default: "https://api.openai.com/v1",
|
|
175
|
+
group: "ai",
|
|
176
|
+
label: "OpenAI-compatible base URL",
|
|
177
|
+
hint: "The /v1 root of a chat-completions server. Examples: https://openrouter.ai/api/v1, https://api.together.xyz/v1, http://127.0.0.1:11434/v1 (Ollama).",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
key: "openai_model",
|
|
181
|
+
type: "string",
|
|
182
|
+
default: "gpt-4o-mini",
|
|
183
|
+
group: "ai",
|
|
184
|
+
label: "OpenAI-compatible model",
|
|
185
|
+
hint: "Must exist on the server the base URL points at (e.g. gpt-4o-mini on OpenAI, a llama model on a self-hosted server).",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: "anthropic_api_key",
|
|
138
189
|
type: "secret",
|
|
139
190
|
default: "",
|
|
140
191
|
group: "ai",
|
|
141
|
-
label: "
|
|
142
|
-
hint: "Used
|
|
192
|
+
label: "Anthropic API key",
|
|
193
|
+
hint: "Used when the provider is anthropic. Create one at console.anthropic.com.",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
key: "anthropic_base_url",
|
|
197
|
+
type: "string",
|
|
198
|
+
default: "https://api.anthropic.com",
|
|
199
|
+
group: "ai",
|
|
200
|
+
label: "Anthropic base URL",
|
|
201
|
+
hint: "The host root — /v1/messages is appended. Change it only for a proxy or a compatible gateway.",
|
|
143
202
|
},
|
|
144
203
|
{
|
|
145
|
-
key: "
|
|
204
|
+
key: "anthropic_model",
|
|
146
205
|
type: "string",
|
|
147
|
-
default: "
|
|
206
|
+
default: "claude-sonnet-4-5",
|
|
148
207
|
group: "ai",
|
|
149
|
-
label: "
|
|
208
|
+
label: "Anthropic model",
|
|
150
209
|
},
|
|
151
210
|
{
|
|
152
211
|
key: "google_search_api_key",
|
|
@@ -466,6 +525,7 @@ function describe() {
|
|
|
466
525
|
min: definition.min ?? null,
|
|
467
526
|
max: definition.max ?? null,
|
|
468
527
|
choices: definition.choices ?? null,
|
|
528
|
+
choiceLabels: definition.choiceLabels ?? null,
|
|
469
529
|
restart: definition.restart === true,
|
|
470
530
|
source: sourceOf(definition.key),
|
|
471
531
|
};
|
package/src/core/proxy.js
CHANGED
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
// WHAT IS NOT PROXIED
|
|
26
26
|
// -------------------
|
|
27
27
|
// Nothing global is patched. The agents are handed to one `makeWASocket()`
|
|
28
|
-
// call by the session manager, so the dashboard's own HTTP server,
|
|
29
|
-
//
|
|
30
|
-
// use today.
|
|
28
|
+
// call by the session manager, so the dashboard's own HTTP server, the AI
|
|
29
|
+
// providers and every other outbound request keep using the direct connection
|
|
30
|
+
// they use today.
|
|
31
31
|
//
|
|
32
32
|
// SECRETS
|
|
33
33
|
// -------
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const session = require("express-session");
|
|
2
|
+
|
|
3
|
+
function clone(value) {
|
|
4
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
class PanelSessionStore extends session.Store {
|
|
8
|
+
constructor({ ttlMs = 12 * 60 * 60 * 1000, maxSessions = 128 } = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this.ttlMs = ttlMs;
|
|
11
|
+
this.maxSessions = maxSessions;
|
|
12
|
+
this.sessions = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_expiresAt(sess) {
|
|
16
|
+
const expires = Date.parse(sess?.cookie?.expires || "");
|
|
17
|
+
if (Number.isFinite(expires)) return expires;
|
|
18
|
+
|
|
19
|
+
const maxAge = Number(sess?.cookie?.maxAge);
|
|
20
|
+
if (Number.isFinite(maxAge) && maxAge > 0) return Date.now() + maxAge;
|
|
21
|
+
|
|
22
|
+
return Date.now() + this.ttlMs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_prune() {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
for (const [sid, entry] of this.sessions) {
|
|
28
|
+
if (entry.expiresAt <= now) this.sessions.delete(sid);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_makeRoom(sid) {
|
|
33
|
+
if (this.sessions.has(sid) || this.sessions.size < this.maxSessions) return;
|
|
34
|
+
|
|
35
|
+
let oldestSid = null;
|
|
36
|
+
let oldestExpiry = Infinity;
|
|
37
|
+
for (const [candidateSid, entry] of this.sessions) {
|
|
38
|
+
if (entry.expiresAt < oldestExpiry) {
|
|
39
|
+
oldestSid = candidateSid;
|
|
40
|
+
oldestExpiry = entry.expiresAt;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (oldestSid) this.sessions.delete(oldestSid);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(sid, callback) {
|
|
47
|
+
try {
|
|
48
|
+
this._prune();
|
|
49
|
+
const entry = this.sessions.get(sid);
|
|
50
|
+
callback(null, entry ? clone(entry.session) : null);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
callback(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
set(sid, sess, callback = () => {}) {
|
|
57
|
+
try {
|
|
58
|
+
this._prune();
|
|
59
|
+
this._makeRoom(sid);
|
|
60
|
+
this.sessions.set(sid, {
|
|
61
|
+
session: clone(sess),
|
|
62
|
+
expiresAt: this._expiresAt(sess),
|
|
63
|
+
});
|
|
64
|
+
callback(null);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
callback(error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
touch(sid, sess, callback = () => {}) {
|
|
71
|
+
try {
|
|
72
|
+
this._prune();
|
|
73
|
+
const entry = this.sessions.get(sid);
|
|
74
|
+
if (entry) {
|
|
75
|
+
entry.expiresAt = this._expiresAt(sess);
|
|
76
|
+
if (sess?.cookie) entry.session.cookie = clone(sess.cookie);
|
|
77
|
+
}
|
|
78
|
+
callback(null);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
callback(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
destroy(sid, callback = () => {}) {
|
|
85
|
+
this.sessions.delete(sid);
|
|
86
|
+
callback(null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
clear(callback = () => {}) {
|
|
90
|
+
this.sessions.clear();
|
|
91
|
+
callback(null);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
length(callback) {
|
|
95
|
+
this._prune();
|
|
96
|
+
callback(null, this.sessions.size);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { PanelSessionStore };
|
|
@@ -48,7 +48,10 @@ const settings = require("../config/settings.cjs");
|
|
|
48
48
|
const memory = require("../utils/memory.cjs");
|
|
49
49
|
const secrets = require("../config/secrets.cjs");
|
|
50
50
|
const { DATA_DIR } = require("../config/paths.cjs");
|
|
51
|
-
const {
|
|
51
|
+
const {
|
|
52
|
+
PERSONA_FILE,
|
|
53
|
+
activeProviderKeySetting,
|
|
54
|
+
} = require("../services/aiAgent.cjs");
|
|
52
55
|
const { deleteScheduledJob, retryScheduledJob } = require("../../scheduler.cjs");
|
|
53
56
|
const { describeScheduledJob } = require("../utils/recurrence.cjs");
|
|
54
57
|
|
|
@@ -140,7 +143,9 @@ router.get("/stats", (req, res) => {
|
|
|
140
143
|
|
|
141
144
|
prefix: runtimeConfig.getPrefix(),
|
|
142
145
|
commandCount: getCommandCatalog().length,
|
|
143
|
-
|
|
146
|
+
// The active provider's key — "AI key: Not set" must mean what the
|
|
147
|
+
// bot will actually use, not whatever gemini_api_key happens to hold.
|
|
148
|
+
aiConfigured: !!settings.get(activeProviderKeySetting()),
|
|
144
149
|
|
|
145
150
|
uptime: process.uptime(),
|
|
146
151
|
memory: process.memoryUsage(),
|
package/src/services/aiAgent.cjs
CHANGED
|
@@ -22,6 +22,10 @@ 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");
|
|
25
|
+
const {
|
|
26
|
+
runProviderAgent,
|
|
27
|
+
activeProviderKeySetting,
|
|
28
|
+
} = require("./aiProviders.cjs");
|
|
25
29
|
|
|
26
30
|
// Model, key, tool budget and history length all come from config/settings.cjs
|
|
27
31
|
// (what the dashboard saved, else the default) and are read per call, so changing
|
|
@@ -36,13 +40,18 @@ const { toolDeclarations, describeCall, runTool } = require("./aiTools.cjs");
|
|
|
36
40
|
// client whose model and per-request configuration are passed at call time —
|
|
37
41
|
// which suits Levix, because the model and the tool set are settings that may
|
|
38
42
|
// change between one message and the next.
|
|
39
|
-
let clientCache = { key: null, client: null };
|
|
43
|
+
let clientCache = { key: null, baseUrl: null, client: null };
|
|
40
44
|
|
|
41
45
|
function geminiClient() {
|
|
42
46
|
const key = settings.get("gemini_api_key");
|
|
43
47
|
if (!key) return null;
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const baseUrl = String(settings.get("gemini_base_url")).replace(/\/+$/, "");
|
|
49
|
+
if (clientCache.key !== key || clientCache.baseUrl !== baseUrl) {
|
|
50
|
+
clientCache = {
|
|
51
|
+
key,
|
|
52
|
+
baseUrl,
|
|
53
|
+
client: new GoogleGenAI({ apiKey: key, httpOptions: { baseUrl } }),
|
|
54
|
+
};
|
|
46
55
|
}
|
|
47
56
|
return clientCache.client;
|
|
48
57
|
}
|
|
@@ -225,7 +234,9 @@ function withTimeout(promise, ms, label) {
|
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
function isAgentEnabled() {
|
|
228
|
-
|
|
237
|
+
// The key that matters is the active provider's: a panel switch from gemini
|
|
238
|
+
// to openai must not keep the agent gated on (or running on) a Gemini key.
|
|
239
|
+
return Boolean(settings.get(activeProviderKeySetting())) && settings.get("ai_agent");
|
|
229
240
|
}
|
|
230
241
|
|
|
231
242
|
// ===========================================================================
|
|
@@ -247,7 +258,7 @@ function isAgentEnabled() {
|
|
|
247
258
|
|
|
248
259
|
const GOOGLE_SEARCH_TOOL = Object.freeze({ googleSearch: {} });
|
|
249
260
|
|
|
250
|
-
/** Gemini-only.
|
|
261
|
+
/** Gemini-only. The openai and anthropic providers are never offered it. */
|
|
251
262
|
function googleSearchEnabled() {
|
|
252
263
|
return settings.get("ai_google_search") === true;
|
|
253
264
|
}
|
|
@@ -368,6 +379,10 @@ function formatSources(sources) {
|
|
|
368
379
|
/**
|
|
369
380
|
* Run one agent turn.
|
|
370
381
|
*
|
|
382
|
+
* Dispatches on the `ai_provider` setting: gemini runs the native loop below
|
|
383
|
+
* (built-in Google Search, Files uploads); openai and anthropic run the same
|
|
384
|
+
* loop over their own wire formats in aiProviders.cjs.
|
|
385
|
+
*
|
|
371
386
|
* @param {object} options
|
|
372
387
|
* @param {Array} options.parts - the Gemini parts for this message
|
|
373
388
|
* @param {Array} [options.history]- prior conversation
|
|
@@ -384,6 +399,20 @@ async function runAgent({
|
|
|
384
399
|
useTools = true,
|
|
385
400
|
maxSteps = null,
|
|
386
401
|
} = {}) {
|
|
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
|
+
|
|
387
416
|
const genAI = geminiClient();
|
|
388
417
|
if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
|
|
389
418
|
|
|
@@ -540,6 +569,7 @@ module.exports = {
|
|
|
540
569
|
buildSystemInstruction,
|
|
541
570
|
loadPersona,
|
|
542
571
|
isAgentEnabled,
|
|
572
|
+
activeProviderKeySetting,
|
|
543
573
|
isFileReferenceError,
|
|
544
574
|
sanitizeHistoryForFiles,
|
|
545
575
|
trimHistory,
|