alvin-bot 4.4.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/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- package/web/public/login.html +48 -0
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Server — Local dashboard for Alvin Bot.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Static file serving (web/public/)
|
|
6
|
+
* - WebSocket for real-time chat + streaming
|
|
7
|
+
* - REST API for settings, memory, sessions, etc.
|
|
8
|
+
* - Simple password auth (WEB_PASSWORD env var)
|
|
9
|
+
*/
|
|
10
|
+
import http from "http";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
16
|
+
import { getRegistry } from "../engine.js";
|
|
17
|
+
import { getSession, resetSession, getAllSessions } from "../services/session.js";
|
|
18
|
+
import { getMemoryStats, loadLongTermMemory, loadDailyLog } from "../services/memory.js";
|
|
19
|
+
import { getIndexStats } from "../services/embeddings.js";
|
|
20
|
+
import { getLoadedPlugins } from "../services/plugins.js";
|
|
21
|
+
import { getMCPStatus } from "../services/mcp.js";
|
|
22
|
+
import { listProfiles } from "../services/users.js";
|
|
23
|
+
import { listCustomTools, getCustomTools, executeCustomTool } from "../services/custom-tools.js";
|
|
24
|
+
import { buildSystemPrompt, reloadSoul, getSoulContent } from "../services/personality.js";
|
|
25
|
+
import { config } from "../config.js";
|
|
26
|
+
import { handleSetupAPI } from "./setup-api.js";
|
|
27
|
+
import { handleDoctorAPI } from "./doctor-api.js";
|
|
28
|
+
import { handleOpenAICompat } from "./openai-compat.js";
|
|
29
|
+
import { addCanvasClient } from "./canvas.js";
|
|
30
|
+
import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
|
|
31
|
+
const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
|
|
32
|
+
const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
|
|
33
|
+
/** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
|
|
34
|
+
let actualWebPort = WEB_PORT;
|
|
35
|
+
// ── MIME Types ──────────────────────────────────────────
|
|
36
|
+
const MIME = {
|
|
37
|
+
".html": "text/html",
|
|
38
|
+
".css": "text/css",
|
|
39
|
+
".js": "application/javascript",
|
|
40
|
+
".json": "application/json",
|
|
41
|
+
".png": "image/png",
|
|
42
|
+
".jpg": "image/jpeg",
|
|
43
|
+
".svg": "image/svg+xml",
|
|
44
|
+
".ico": "image/x-icon",
|
|
45
|
+
};
|
|
46
|
+
// ── Auth ────────────────────────────────────────────────
|
|
47
|
+
const activeSessions = new Set();
|
|
48
|
+
function generateToken() {
|
|
49
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
50
|
+
.map(b => b.toString(16).padStart(2, "0")).join("");
|
|
51
|
+
}
|
|
52
|
+
function checkAuth(req) {
|
|
53
|
+
if (!WEB_PASSWORD)
|
|
54
|
+
return true; // No password = open access
|
|
55
|
+
const cookie = req.headers.cookie || "";
|
|
56
|
+
const token = cookie.match(/alvinbot_token=([a-f0-9]+)/)?.[1];
|
|
57
|
+
return token ? activeSessions.has(token) : false;
|
|
58
|
+
}
|
|
59
|
+
// ── REST API ────────────────────────────────────────────
|
|
60
|
+
async function handleAPI(req, res, urlPath, body) {
|
|
61
|
+
res.setHeader("Content-Type", "application/json");
|
|
62
|
+
// POST /api/login
|
|
63
|
+
if (urlPath === "/api/login" && req.method === "POST") {
|
|
64
|
+
try {
|
|
65
|
+
const { password } = JSON.parse(body);
|
|
66
|
+
if (!WEB_PASSWORD || password === WEB_PASSWORD) {
|
|
67
|
+
const token = generateToken();
|
|
68
|
+
activeSessions.add(token);
|
|
69
|
+
res.setHeader("Set-Cookie", `alvinbot_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
|
70
|
+
res.end(JSON.stringify({ ok: true }));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
res.statusCode = 401;
|
|
74
|
+
res.end(JSON.stringify({ error: "Wrong password" }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
res.statusCode = 400;
|
|
79
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// POST /api/webhook — external trigger endpoint with bearer auth (no cookie auth needed)
|
|
84
|
+
if (urlPath === "/api/webhook" && req.method === "POST") {
|
|
85
|
+
if (!config.webhookEnabled) {
|
|
86
|
+
res.writeHead(404);
|
|
87
|
+
res.end(JSON.stringify({ error: "Webhooks disabled" }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const authHeader = req.headers.authorization || "";
|
|
91
|
+
if (authHeader !== `Bearer ${config.webhookToken}`) {
|
|
92
|
+
res.writeHead(401);
|
|
93
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const payload = JSON.parse(body);
|
|
98
|
+
if (!payload.message) {
|
|
99
|
+
res.writeHead(400);
|
|
100
|
+
res.end(JSON.stringify({ error: "Missing message field" }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const channel = payload.channel || "telegram";
|
|
104
|
+
const chatId = payload.chatId || String(config.allowedUsers[0] || "");
|
|
105
|
+
const { enqueue } = await import("../services/delivery-queue.js");
|
|
106
|
+
const id = enqueue(channel, chatId, `[Webhook: ${payload.event || "unknown"}] ${payload.message}`);
|
|
107
|
+
res.writeHead(200);
|
|
108
|
+
res.end(JSON.stringify({ ok: true, queued: id }));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
res.writeHead(400);
|
|
112
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Auth check for all other API routes
|
|
117
|
+
if (!checkAuth(req)) {
|
|
118
|
+
res.statusCode = 401;
|
|
119
|
+
res.end(JSON.stringify({ error: "Not authenticated" }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// ── Setup APIs (platforms + models) ─────────────────
|
|
123
|
+
const handled = await handleSetupAPI(req, res, urlPath, body);
|
|
124
|
+
if (handled)
|
|
125
|
+
return;
|
|
126
|
+
// ── Doctor & Backup APIs ──────────────────────────
|
|
127
|
+
const doctorHandled = await handleDoctorAPI(req, res, urlPath, body);
|
|
128
|
+
if (doctorHandled)
|
|
129
|
+
return;
|
|
130
|
+
// GET /api/setup-check — is the bot fully configured?
|
|
131
|
+
if (urlPath === "/api/setup-check") {
|
|
132
|
+
const envPath = ENV_FILE;
|
|
133
|
+
let env = {};
|
|
134
|
+
try {
|
|
135
|
+
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
if (line.startsWith("#") || !line.includes("="))
|
|
138
|
+
continue;
|
|
139
|
+
const idx = line.indexOf("=");
|
|
140
|
+
env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch { }
|
|
144
|
+
const hasBotToken = !!(env.BOT_TOKEN || process.env.BOT_TOKEN);
|
|
145
|
+
const hasAllowedUsers = !!(env.ALLOWED_USERS || process.env.ALLOWED_USERS);
|
|
146
|
+
const hasPrimaryProvider = !!(env.PRIMARY_PROVIDER || process.env.PRIMARY_PROVIDER);
|
|
147
|
+
// Check which providers have keys
|
|
148
|
+
const providerKeys = {
|
|
149
|
+
groq: !!(env.GROQ_API_KEY || process.env.GROQ_API_KEY),
|
|
150
|
+
openai: !!(env.OPENAI_API_KEY || process.env.OPENAI_API_KEY),
|
|
151
|
+
google: !!(env.GOOGLE_API_KEY || process.env.GOOGLE_API_KEY),
|
|
152
|
+
nvidia: !!(env.NVIDIA_API_KEY || process.env.NVIDIA_API_KEY),
|
|
153
|
+
anthropic: !!(env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY),
|
|
154
|
+
openrouter: !!(env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY),
|
|
155
|
+
};
|
|
156
|
+
const hasAnyProvider = hasPrimaryProvider || Object.values(providerKeys).some(Boolean);
|
|
157
|
+
// Check Claude CLI
|
|
158
|
+
let claudeCliInstalled = false;
|
|
159
|
+
try {
|
|
160
|
+
const { execSync } = await import("child_process");
|
|
161
|
+
execSync("claude --version", { timeout: 5000, stdio: "pipe" });
|
|
162
|
+
claudeCliInstalled = true;
|
|
163
|
+
}
|
|
164
|
+
catch { }
|
|
165
|
+
const isComplete = hasBotToken && hasAllowedUsers && hasAnyProvider;
|
|
166
|
+
res.end(JSON.stringify({
|
|
167
|
+
isComplete,
|
|
168
|
+
steps: {
|
|
169
|
+
telegram: { done: hasBotToken && hasAllowedUsers, botToken: hasBotToken, allowedUsers: hasAllowedUsers },
|
|
170
|
+
provider: { done: hasAnyProvider, primary: env.PRIMARY_PROVIDER || process.env.PRIMARY_PROVIDER || "", keys: providerKeys, claudeCli: claudeCliInstalled },
|
|
171
|
+
},
|
|
172
|
+
}));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// POST /api/setup-wizard — save all setup data at once (first-run wizard)
|
|
176
|
+
if (urlPath === "/api/setup-wizard" && req.method === "POST") {
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(body);
|
|
179
|
+
const envPath = ENV_FILE;
|
|
180
|
+
let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
|
|
181
|
+
const setEnv = (key, value) => {
|
|
182
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
183
|
+
if (regex.test(content)) {
|
|
184
|
+
content = content.replace(regex, `${key}=${value}`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
content = content.trimEnd() + `\n${key}=${value}\n`;
|
|
188
|
+
}
|
|
189
|
+
process.env[key] = value;
|
|
190
|
+
};
|
|
191
|
+
// Step 1: Telegram
|
|
192
|
+
if (data.botToken)
|
|
193
|
+
setEnv("BOT_TOKEN", data.botToken);
|
|
194
|
+
if (data.allowedUsers)
|
|
195
|
+
setEnv("ALLOWED_USERS", data.allowedUsers);
|
|
196
|
+
// Step 2: Provider
|
|
197
|
+
if (data.primaryProvider)
|
|
198
|
+
setEnv("PRIMARY_PROVIDER", data.primaryProvider);
|
|
199
|
+
if (data.apiKey && data.apiKeyEnv)
|
|
200
|
+
setEnv(data.apiKeyEnv, data.apiKey);
|
|
201
|
+
// Step 3: Optional
|
|
202
|
+
if (data.webPassword)
|
|
203
|
+
setEnv("WEB_PASSWORD", data.webPassword);
|
|
204
|
+
fs.writeFileSync(envPath, content);
|
|
205
|
+
res.end(JSON.stringify({ ok: true, note: "Setup complete! Restart needed." }));
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
res.statusCode = 400;
|
|
209
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// POST /api/validate-bot-token — validate a Telegram bot token
|
|
214
|
+
if (urlPath === "/api/validate-bot-token" && req.method === "POST") {
|
|
215
|
+
try {
|
|
216
|
+
const { token } = JSON.parse(body);
|
|
217
|
+
if (!token || !token.includes(":")) {
|
|
218
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid token format" }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const tgRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
|
222
|
+
const tgData = await tgRes.json();
|
|
223
|
+
if (tgData.ok) {
|
|
224
|
+
res.end(JSON.stringify({ ok: true, bot: { username: tgData.result.username, firstName: tgData.result.first_name, id: tgData.result.id } }));
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
res.end(JSON.stringify({ ok: false, error: tgData.description || "Invalid token" }));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// GET /api/status
|
|
236
|
+
if (urlPath === "/api/status") {
|
|
237
|
+
let modelInfo = { name: "Not configured", model: "none", status: "unconfigured" };
|
|
238
|
+
try {
|
|
239
|
+
const registry = getRegistry();
|
|
240
|
+
const active = registry.getActive().getInfo();
|
|
241
|
+
modelInfo = { name: active.name, model: active.model, status: active.status };
|
|
242
|
+
}
|
|
243
|
+
catch { /* engine not initialized — no provider configured */ }
|
|
244
|
+
const memory = getMemoryStats();
|
|
245
|
+
const index = getIndexStats();
|
|
246
|
+
const plugins = getLoadedPlugins();
|
|
247
|
+
const mcp = getMCPStatus();
|
|
248
|
+
const users = listProfiles();
|
|
249
|
+
const tools = listCustomTools();
|
|
250
|
+
// Aggregate token usage across all sessions
|
|
251
|
+
const { getAllSessions } = await import("../services/session.js");
|
|
252
|
+
const allSessions = getAllSessions();
|
|
253
|
+
let totalInputTokens = 0, totalOutputTokens = 0, totalCost = 0;
|
|
254
|
+
for (const s of allSessions.values()) {
|
|
255
|
+
totalInputTokens += s.totalInputTokens || 0;
|
|
256
|
+
totalOutputTokens += s.totalOutputTokens || 0;
|
|
257
|
+
totalCost += s.totalCost || 0;
|
|
258
|
+
}
|
|
259
|
+
const { config: appConfig } = await import("../config.js");
|
|
260
|
+
res.end(JSON.stringify({
|
|
261
|
+
bot: { version: "3.0.0", uptime: process.uptime() },
|
|
262
|
+
model: modelInfo,
|
|
263
|
+
memory: { ...memory, vectors: index.entries, indexSize: index.sizeBytes },
|
|
264
|
+
plugins: plugins.length,
|
|
265
|
+
mcp: mcp.length,
|
|
266
|
+
users: users.length,
|
|
267
|
+
tools: tools.length,
|
|
268
|
+
tokens: {
|
|
269
|
+
totalInput: totalInputTokens,
|
|
270
|
+
totalOutput: totalOutputTokens,
|
|
271
|
+
total: totalInputTokens + totalOutputTokens,
|
|
272
|
+
totalCost,
|
|
273
|
+
},
|
|
274
|
+
setup: {
|
|
275
|
+
telegram: !!appConfig.botToken,
|
|
276
|
+
provider: modelInfo.status !== "unconfigured",
|
|
277
|
+
},
|
|
278
|
+
}));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// GET /api/models
|
|
282
|
+
if (urlPath === "/api/models") {
|
|
283
|
+
const registry = getRegistry();
|
|
284
|
+
registry.listAll().then(models => {
|
|
285
|
+
res.end(JSON.stringify({ models, active: registry.getActiveKey() }));
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// POST /api/models/switch
|
|
290
|
+
if (urlPath === "/api/models/switch" && req.method === "POST") {
|
|
291
|
+
try {
|
|
292
|
+
const { key } = JSON.parse(body);
|
|
293
|
+
const registry = getRegistry();
|
|
294
|
+
const ok = registry.switchTo(key);
|
|
295
|
+
res.end(JSON.stringify({ ok, active: registry.getActiveKey() }));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
res.statusCode = 400;
|
|
299
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// GET /api/fallback — Get fallback order + health
|
|
304
|
+
if (urlPath === "/api/fallback" && req.method === "GET") {
|
|
305
|
+
try {
|
|
306
|
+
const { getFallbackOrder } = await import("../services/fallback-order.js");
|
|
307
|
+
const { getHealthStatus, isFailedOver } = await import("../services/heartbeat.js");
|
|
308
|
+
const registry = getRegistry();
|
|
309
|
+
const providers = await registry.listAll();
|
|
310
|
+
res.end(JSON.stringify({
|
|
311
|
+
order: getFallbackOrder(),
|
|
312
|
+
health: getHealthStatus(),
|
|
313
|
+
failedOver: isFailedOver(),
|
|
314
|
+
activeProvider: registry.getActiveKey(),
|
|
315
|
+
availableProviders: providers.map(p => ({ key: p.key, name: p.name, status: p.status })),
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// POST /api/fallback — Set fallback order
|
|
324
|
+
if (urlPath === "/api/fallback" && req.method === "POST") {
|
|
325
|
+
try {
|
|
326
|
+
const { primary, fallbacks } = JSON.parse(body);
|
|
327
|
+
const { setFallbackOrder } = await import("../services/fallback-order.js");
|
|
328
|
+
const result = setFallbackOrder(primary, fallbacks, "webui");
|
|
329
|
+
res.end(JSON.stringify({ ok: true, order: result }));
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
res.statusCode = 400;
|
|
333
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// POST /api/fallback/move — Move provider up/down
|
|
338
|
+
if (urlPath === "/api/fallback/move" && req.method === "POST") {
|
|
339
|
+
try {
|
|
340
|
+
const { key, direction } = JSON.parse(body);
|
|
341
|
+
const fb = await import("../services/fallback-order.js");
|
|
342
|
+
const result = direction === "up" ? fb.moveUp(key, "webui") : fb.moveDown(key, "webui");
|
|
343
|
+
res.end(JSON.stringify({ ok: true, order: result }));
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
res.statusCode = 400;
|
|
347
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// GET /api/heartbeat — Health status
|
|
352
|
+
if (urlPath === "/api/heartbeat") {
|
|
353
|
+
try {
|
|
354
|
+
const { getHealthStatus, isFailedOver } = await import("../services/heartbeat.js");
|
|
355
|
+
res.end(JSON.stringify({
|
|
356
|
+
health: getHealthStatus(),
|
|
357
|
+
failedOver: isFailedOver(),
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
res.end(JSON.stringify({ health: [], failedOver: false }));
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// GET /api/memory
|
|
366
|
+
if (urlPath === "/api/memory") {
|
|
367
|
+
const ltm = loadLongTermMemory();
|
|
368
|
+
const todayLog = loadDailyLog();
|
|
369
|
+
const stats = getMemoryStats();
|
|
370
|
+
const index = getIndexStats();
|
|
371
|
+
// List daily log files
|
|
372
|
+
let dailyFiles = [];
|
|
373
|
+
try {
|
|
374
|
+
dailyFiles = fs.readdirSync(MEMORY_DIR)
|
|
375
|
+
.filter(f => f.endsWith(".md") && !f.startsWith("."))
|
|
376
|
+
.sort()
|
|
377
|
+
.reverse();
|
|
378
|
+
}
|
|
379
|
+
catch { /* empty */ }
|
|
380
|
+
res.end(JSON.stringify({
|
|
381
|
+
longTermMemory: ltm,
|
|
382
|
+
todayLog,
|
|
383
|
+
dailyFiles,
|
|
384
|
+
stats,
|
|
385
|
+
index: { entries: index.entries, files: index.files, sizeBytes: index.sizeBytes },
|
|
386
|
+
}));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// GET /api/memory/:file
|
|
390
|
+
if (urlPath.startsWith("/api/memory/")) {
|
|
391
|
+
const file = urlPath.slice(12);
|
|
392
|
+
if (file.includes("..") || !file.endsWith(".md")) {
|
|
393
|
+
res.statusCode = 400;
|
|
394
|
+
res.end(JSON.stringify({ error: "Invalid file" }));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const content = fs.readFileSync(resolve(MEMORY_DIR, file), "utf-8");
|
|
399
|
+
res.end(JSON.stringify({ file, content }));
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
res.statusCode = 404;
|
|
403
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// POST /api/memory/save
|
|
408
|
+
if (urlPath === "/api/memory/save" && req.method === "POST") {
|
|
409
|
+
try {
|
|
410
|
+
const { file, content } = JSON.parse(body);
|
|
411
|
+
if (file === "MEMORY.md") {
|
|
412
|
+
fs.writeFileSync(MEMORY_FILE, content);
|
|
413
|
+
}
|
|
414
|
+
else if (file.endsWith(".md") && !file.includes("..")) {
|
|
415
|
+
fs.writeFileSync(resolve(MEMORY_DIR, file), content);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
res.statusCode = 400;
|
|
419
|
+
res.end(JSON.stringify({ error: "Invalid file" }));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
res.end(JSON.stringify({ ok: true }));
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
res.statusCode = 400;
|
|
426
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// GET /api/plugins
|
|
431
|
+
if (urlPath === "/api/plugins") {
|
|
432
|
+
res.end(JSON.stringify({ plugins: getLoadedPlugins() }));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// GET /api/users — Enhanced with session data
|
|
436
|
+
if (urlPath === "/api/users" && req.method === "GET") {
|
|
437
|
+
const { getAllSessions } = await import("../services/session.js");
|
|
438
|
+
const profiles = listProfiles();
|
|
439
|
+
const sessions = getAllSessions();
|
|
440
|
+
const sessionMap = new Map(Array.from(sessions.entries()).map(([k, s]) => [Number(k), s]));
|
|
441
|
+
const enriched = profiles.map(p => {
|
|
442
|
+
const session = sessionMap.get(p.userId);
|
|
443
|
+
return {
|
|
444
|
+
...p,
|
|
445
|
+
session: session ? {
|
|
446
|
+
isProcessing: session.isProcessing,
|
|
447
|
+
totalCost: session.totalCost,
|
|
448
|
+
historyLength: session.history.length,
|
|
449
|
+
effort: session.effort,
|
|
450
|
+
voiceReply: session.voiceReply,
|
|
451
|
+
startedAt: session.startedAt,
|
|
452
|
+
messageCount: session.messageCount,
|
|
453
|
+
toolUseCount: session.toolUseCount,
|
|
454
|
+
workingDir: session.workingDir,
|
|
455
|
+
hasActiveQuery: !!session.abortController,
|
|
456
|
+
queuedMessages: session.messageQueue.length,
|
|
457
|
+
} : null,
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
res.end(JSON.stringify({ users: enriched }));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// DELETE /api/users/:id — Kill session + delete user data
|
|
464
|
+
if (urlPath.startsWith("/api/users/") && req.method === "DELETE") {
|
|
465
|
+
const userId = parseInt(urlPath.split("/").pop() || "0");
|
|
466
|
+
if (!userId) {
|
|
467
|
+
res.statusCode = 400;
|
|
468
|
+
res.end(JSON.stringify({ error: "Invalid user ID" }));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const { deleteUser } = await import("../services/users.js");
|
|
472
|
+
const result = deleteUser(userId);
|
|
473
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// GET /api/tools
|
|
477
|
+
if (urlPath === "/api/tools") {
|
|
478
|
+
const tools = getCustomTools();
|
|
479
|
+
res.end(JSON.stringify({ tools }));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// POST /api/tools/execute — run a tool by name
|
|
483
|
+
if (urlPath === "/api/tools/execute" && req.method === "POST") {
|
|
484
|
+
try {
|
|
485
|
+
const { name, params } = JSON.parse(body);
|
|
486
|
+
if (!name) {
|
|
487
|
+
res.statusCode = 400;
|
|
488
|
+
res.end(JSON.stringify({ error: "No tool name" }));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const output = await executeCustomTool(name, params || {});
|
|
492
|
+
res.end(JSON.stringify({ ok: true, output }));
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
496
|
+
res.end(JSON.stringify({ error }));
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// ── MCP Management ─────────────────────────────────────
|
|
501
|
+
// GET /api/mcp — list MCP servers + tools
|
|
502
|
+
if (urlPath === "/api/mcp") {
|
|
503
|
+
const { getMCPStatus, getMCPTools, hasMCPConfig } = await import("../services/mcp.js");
|
|
504
|
+
const servers = getMCPStatus();
|
|
505
|
+
const tools = getMCPTools();
|
|
506
|
+
// Read raw config for editing
|
|
507
|
+
const configPath = MCP_CONFIG;
|
|
508
|
+
let rawConfig = { servers: {} };
|
|
509
|
+
try {
|
|
510
|
+
rawConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
res.end(JSON.stringify({ servers, tools, config: rawConfig, hasConfig: hasMCPConfig() }));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// POST /api/mcp/add — add a new MCP server
|
|
517
|
+
if (urlPath === "/api/mcp/add" && req.method === "POST") {
|
|
518
|
+
try {
|
|
519
|
+
const { name, command, args, url: serverUrl, env, headers } = JSON.parse(body);
|
|
520
|
+
if (!name) {
|
|
521
|
+
res.statusCode = 400;
|
|
522
|
+
res.end(JSON.stringify({ error: "Name required" }));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const configPath = MCP_CONFIG;
|
|
526
|
+
let config = { servers: {} };
|
|
527
|
+
try {
|
|
528
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
529
|
+
}
|
|
530
|
+
catch { }
|
|
531
|
+
const entry = {};
|
|
532
|
+
if (command) {
|
|
533
|
+
entry.command = command;
|
|
534
|
+
entry.args = args || [];
|
|
535
|
+
if (env)
|
|
536
|
+
entry.env = env;
|
|
537
|
+
}
|
|
538
|
+
else if (serverUrl) {
|
|
539
|
+
entry.url = serverUrl;
|
|
540
|
+
if (headers)
|
|
541
|
+
entry.headers = headers;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
res.statusCode = 400;
|
|
545
|
+
res.end(JSON.stringify({ error: "command or url required" }));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
config.servers[name] = entry;
|
|
549
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
550
|
+
res.end(JSON.stringify({ ok: true, note: "Restart needed to connect." }));
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
res.statusCode = 400;
|
|
554
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// POST /api/mcp/remove — remove an MCP server
|
|
559
|
+
if (urlPath === "/api/mcp/remove" && req.method === "POST") {
|
|
560
|
+
try {
|
|
561
|
+
const { name } = JSON.parse(body);
|
|
562
|
+
const configPath = MCP_CONFIG;
|
|
563
|
+
let config = { servers: {} };
|
|
564
|
+
try {
|
|
565
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
566
|
+
}
|
|
567
|
+
catch { }
|
|
568
|
+
delete config.servers[name];
|
|
569
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
570
|
+
res.end(JSON.stringify({ ok: true }));
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
res.statusCode = 400;
|
|
574
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// GET /api/mcp/discover — auto-discover MCP servers on the system
|
|
579
|
+
if (urlPath === "/api/mcp/discover") {
|
|
580
|
+
const discovered = [];
|
|
581
|
+
const { execSync } = await import("child_process");
|
|
582
|
+
// Check for common MCP server npm packages
|
|
583
|
+
const knownServers = [
|
|
584
|
+
{ pkg: "@modelcontextprotocol/server-filesystem", name: "filesystem", args: ["/tmp"] },
|
|
585
|
+
{ pkg: "@modelcontextprotocol/server-brave-search", name: "brave-search", args: [] },
|
|
586
|
+
{ pkg: "@modelcontextprotocol/server-github", name: "github", args: [] },
|
|
587
|
+
{ pkg: "@modelcontextprotocol/server-postgres", name: "postgres", args: [] },
|
|
588
|
+
{ pkg: "@modelcontextprotocol/server-sqlite", name: "sqlite", args: [] },
|
|
589
|
+
{ pkg: "@modelcontextprotocol/server-slack", name: "slack", args: [] },
|
|
590
|
+
{ pkg: "@modelcontextprotocol/server-memory", name: "memory", args: [] },
|
|
591
|
+
{ pkg: "@modelcontextprotocol/server-puppeteer", name: "puppeteer", args: [] },
|
|
592
|
+
{ pkg: "@modelcontextprotocol/server-fetch", name: "web-fetch", args: [] },
|
|
593
|
+
{ pkg: "@anthropic/mcp-server-sequential-thinking", name: "sequential-thinking", args: [] },
|
|
594
|
+
];
|
|
595
|
+
for (const s of knownServers) {
|
|
596
|
+
try {
|
|
597
|
+
execSync(`npx --yes ${s.pkg} --help`, { timeout: 5000, stdio: "pipe", env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" } });
|
|
598
|
+
discovered.push({ name: s.name, command: "npx", args: ["-y", s.pkg, ...s.args], source: "npm" });
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// Not installed — try checking if globally available
|
|
602
|
+
try {
|
|
603
|
+
execSync(`npm list -g ${s.pkg} --depth=0`, { timeout: 5000, stdio: "pipe" });
|
|
604
|
+
discovered.push({ name: s.name, command: "npx", args: ["-y", s.pkg, ...s.args], source: "npm-global" });
|
|
605
|
+
}
|
|
606
|
+
catch { /* not installed */ }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Check for Claude Desktop MCP config
|
|
610
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
611
|
+
const claudeConfigPaths = [
|
|
612
|
+
resolve(homeDir, ".config/claude/claude_desktop_config.json"),
|
|
613
|
+
resolve(homeDir, "Library/Application Support/Claude/claude_desktop_config.json"),
|
|
614
|
+
resolve(homeDir, "AppData/Roaming/Claude/claude_desktop_config.json"),
|
|
615
|
+
];
|
|
616
|
+
for (const cfgPath of claudeConfigPaths) {
|
|
617
|
+
try {
|
|
618
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
619
|
+
if (cfg.mcpServers) {
|
|
620
|
+
for (const [name, srv] of Object.entries(cfg.mcpServers)) {
|
|
621
|
+
if (srv.command) {
|
|
622
|
+
discovered.push({ name: `claude-${name}`, command: srv.command, args: srv.args || [], source: "claude-desktop" });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch { /* not found */ }
|
|
628
|
+
}
|
|
629
|
+
res.end(JSON.stringify({ discovered }));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
// ── Skills Management ─────────────────────────────────
|
|
633
|
+
// GET /api/skills — already in setup-api.ts, but add full CRUD here
|
|
634
|
+
// GET /api/skills/detail/:id — get full skill content
|
|
635
|
+
if (urlPath?.match(/^\/api\/skills\/detail\//) && req.method === "GET") {
|
|
636
|
+
const skillId = urlPath.split("/").pop();
|
|
637
|
+
const { getSkills } = await import("../services/skills.js");
|
|
638
|
+
const skill = getSkills().find(s => s.id === skillId);
|
|
639
|
+
if (skill) {
|
|
640
|
+
res.end(JSON.stringify({ ok: true, skill }));
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
res.statusCode = 404;
|
|
644
|
+
res.end(JSON.stringify({ error: "Skill not found" }));
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// POST /api/skills/create — create a new skill
|
|
649
|
+
if (urlPath === "/api/skills/create" && req.method === "POST") {
|
|
650
|
+
try {
|
|
651
|
+
const { id, name, description, triggers, category, content, priority } = JSON.parse(body);
|
|
652
|
+
if (!id || !name) {
|
|
653
|
+
res.statusCode = 400;
|
|
654
|
+
res.end(JSON.stringify({ error: "id and name required" }));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const skillsDir = SKILLS_DIR;
|
|
658
|
+
const skillDir = resolve(skillsDir, id);
|
|
659
|
+
if (!fs.existsSync(skillDir))
|
|
660
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
661
|
+
const frontmatter = [
|
|
662
|
+
"---",
|
|
663
|
+
`name: ${name}`,
|
|
664
|
+
description ? `description: ${description}` : "",
|
|
665
|
+
triggers ? `triggers: ${Array.isArray(triggers) ? triggers.join(", ") : triggers}` : "",
|
|
666
|
+
`priority: ${priority || 3}`,
|
|
667
|
+
`category: ${category || "custom"}`,
|
|
668
|
+
"---",
|
|
669
|
+
].filter(Boolean).join("\n");
|
|
670
|
+
fs.writeFileSync(resolve(skillDir, "SKILL.md"), `${frontmatter}\n\n${content || ""}`);
|
|
671
|
+
// Force reload
|
|
672
|
+
const { loadSkills } = await import("../services/skills.js");
|
|
673
|
+
loadSkills();
|
|
674
|
+
res.end(JSON.stringify({ ok: true }));
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
res.statusCode = 400;
|
|
678
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// POST /api/skills/update — update an existing skill
|
|
683
|
+
if (urlPath === "/api/skills/update" && req.method === "POST") {
|
|
684
|
+
try {
|
|
685
|
+
const { id, content } = JSON.parse(body);
|
|
686
|
+
const skillPath = resolve(SKILLS_DIR, id, "SKILL.md");
|
|
687
|
+
if (!fs.existsSync(skillPath)) {
|
|
688
|
+
// Try flat file
|
|
689
|
+
const flatPath = resolve(SKILLS_DIR, id + ".md");
|
|
690
|
+
if (fs.existsSync(flatPath)) {
|
|
691
|
+
fs.writeFileSync(flatPath, content);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
res.statusCode = 404;
|
|
695
|
+
res.end(JSON.stringify({ error: "Skill not found" }));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
fs.writeFileSync(skillPath, content);
|
|
701
|
+
}
|
|
702
|
+
const { loadSkills } = await import("../services/skills.js");
|
|
703
|
+
loadSkills();
|
|
704
|
+
res.end(JSON.stringify({ ok: true }));
|
|
705
|
+
}
|
|
706
|
+
catch (e) {
|
|
707
|
+
res.statusCode = 400;
|
|
708
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// POST /api/skills/delete — delete a skill
|
|
713
|
+
if (urlPath === "/api/skills/delete" && req.method === "POST") {
|
|
714
|
+
try {
|
|
715
|
+
const { id } = JSON.parse(body);
|
|
716
|
+
const skillDir = resolve(SKILLS_DIR, id);
|
|
717
|
+
const flatFile = resolve(SKILLS_DIR, id + ".md");
|
|
718
|
+
if (fs.existsSync(skillDir)) {
|
|
719
|
+
fs.rmSync(skillDir, { recursive: true });
|
|
720
|
+
}
|
|
721
|
+
else if (fs.existsSync(flatFile)) {
|
|
722
|
+
fs.unlinkSync(flatFile);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
res.statusCode = 404;
|
|
726
|
+
res.end(JSON.stringify({ error: "Skill not found" }));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const { loadSkills } = await import("../services/skills.js");
|
|
730
|
+
loadSkills();
|
|
731
|
+
res.end(JSON.stringify({ ok: true }));
|
|
732
|
+
}
|
|
733
|
+
catch (e) {
|
|
734
|
+
res.statusCode = 400;
|
|
735
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// GET /api/config
|
|
740
|
+
if (urlPath === "/api/config") {
|
|
741
|
+
res.end(JSON.stringify({
|
|
742
|
+
providers: config.fallbackProviders,
|
|
743
|
+
primaryProvider: config.primaryProvider,
|
|
744
|
+
allowedUsers: config.allowedUsers,
|
|
745
|
+
hasKeys: {
|
|
746
|
+
groq: !!config.apiKeys.groq,
|
|
747
|
+
openai: !!config.apiKeys.openai,
|
|
748
|
+
google: !!config.apiKeys.google,
|
|
749
|
+
nvidia: !!config.apiKeys.nvidia,
|
|
750
|
+
openrouter: !!config.apiKeys.openrouter,
|
|
751
|
+
},
|
|
752
|
+
}));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// GET /api/sessions
|
|
756
|
+
if (urlPath === "/api/sessions") {
|
|
757
|
+
const sessions = getAllSessions();
|
|
758
|
+
const profiles = listProfiles();
|
|
759
|
+
const data = Array.from(sessions.entries()).map(([key, session]) => {
|
|
760
|
+
const userId = Number(key.split(":").pop());
|
|
761
|
+
const profile = profiles.find(p => p.userId === userId);
|
|
762
|
+
return {
|
|
763
|
+
userId: key,
|
|
764
|
+
name: profile?.name || `User ${key}`,
|
|
765
|
+
username: profile?.username,
|
|
766
|
+
messageCount: session.messageCount,
|
|
767
|
+
toolUseCount: session.toolUseCount,
|
|
768
|
+
totalCost: session.totalCost,
|
|
769
|
+
totalInputTokens: session.totalInputTokens || 0,
|
|
770
|
+
totalOutputTokens: session.totalOutputTokens || 0,
|
|
771
|
+
effort: session.effort,
|
|
772
|
+
startedAt: session.startedAt,
|
|
773
|
+
lastActivity: session.lastActivity,
|
|
774
|
+
historyLength: session.history.length,
|
|
775
|
+
isProcessing: session.isProcessing,
|
|
776
|
+
provider: Object.keys(session.queriesByProvider).join(", ") || "none",
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
res.end(JSON.stringify({ sessions: data }));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// GET /api/sessions/:userId/history
|
|
783
|
+
if (urlPath.match(/^\/api\/sessions\/\d+\/history$/)) {
|
|
784
|
+
const userId = parseInt(urlPath.split("/")[3]);
|
|
785
|
+
const session = getSession(userId);
|
|
786
|
+
res.end(JSON.stringify({
|
|
787
|
+
userId,
|
|
788
|
+
history: session.history.map(h => ({ role: h.role, content: h.content.slice(0, 2000) })),
|
|
789
|
+
}));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// GET /api/files?path=...
|
|
793
|
+
if (urlPath === "/api/files") {
|
|
794
|
+
const params = new URLSearchParams((req.url || "").split("?")[1] || "");
|
|
795
|
+
const reqPath = params.get("path") || "";
|
|
796
|
+
const basePath = resolve(BOT_ROOT, reqPath || ".");
|
|
797
|
+
// Security: must be within BOT_ROOT
|
|
798
|
+
if (!basePath.startsWith(BOT_ROOT)) {
|
|
799
|
+
res.statusCode = 403;
|
|
800
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
const stat = fs.statSync(basePath);
|
|
805
|
+
if (stat.isDirectory()) {
|
|
806
|
+
const entries = fs.readdirSync(basePath, { withFileTypes: true })
|
|
807
|
+
.filter(e => !e.name.startsWith(".") && e.name !== "node_modules")
|
|
808
|
+
.map(e => ({
|
|
809
|
+
name: e.name,
|
|
810
|
+
type: e.isDirectory() ? "dir" : "file",
|
|
811
|
+
size: e.isFile() ? fs.statSync(resolve(basePath, e.name)).size : 0,
|
|
812
|
+
modified: fs.statSync(resolve(basePath, e.name)).mtimeMs,
|
|
813
|
+
}))
|
|
814
|
+
.sort((a, b) => {
|
|
815
|
+
if (a.type !== b.type)
|
|
816
|
+
return a.type === "dir" ? -1 : 1;
|
|
817
|
+
return a.name.localeCompare(b.name);
|
|
818
|
+
});
|
|
819
|
+
res.end(JSON.stringify({ path: reqPath || ".", entries }));
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
// Read file content — text files up to 500KB
|
|
823
|
+
const ext = path.extname(basePath).toLowerCase();
|
|
824
|
+
const textExts = new Set([
|
|
825
|
+
".md", ".txt", ".json", ".js", ".ts", ".jsx", ".tsx", ".css", ".html", ".htm",
|
|
826
|
+
".xml", ".svg", ".yml", ".yaml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
|
827
|
+
".sh", ".bash", ".zsh", ".fish", ".py", ".rb", ".go", ".rs", ".java", ".kt",
|
|
828
|
+
".c", ".cpp", ".h", ".hpp", ".cs", ".php", ".sql", ".graphql", ".prisma",
|
|
829
|
+
".dockerfile", ".gitignore", ".gitattributes", ".editorconfig", ".prettierrc",
|
|
830
|
+
".eslintrc", ".babelrc", ".npmrc", ".nvmrc", ".lock", ".log", ".csv", ".tsv",
|
|
831
|
+
".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro",
|
|
832
|
+
]);
|
|
833
|
+
// Files without extension that match known names are always text
|
|
834
|
+
const textNames = new Set([
|
|
835
|
+
"dockerfile", "makefile", "procfile", "gemfile", "rakefile",
|
|
836
|
+
"vagrantfile", "brewfile", "justfile", "taskfile", "cakefile",
|
|
837
|
+
"license", "licence", "readme", "changelog", "authors", "contributors",
|
|
838
|
+
]);
|
|
839
|
+
const baseName = path.basename(basePath).toLowerCase();
|
|
840
|
+
const isKnownTextName = textNames.has(baseName);
|
|
841
|
+
const isText = textExts.has(ext) || isKnownTextName || (!ext && stat.size < 100_000);
|
|
842
|
+
if (stat.size > 500_000) {
|
|
843
|
+
res.end(JSON.stringify({ path: reqPath, content: `[File too large: ${(stat.size / 1024).toFixed(1)} KB — max 500 KB]`, size: stat.size }));
|
|
844
|
+
}
|
|
845
|
+
else if (isText) {
|
|
846
|
+
try {
|
|
847
|
+
const content = fs.readFileSync(basePath, "utf-8");
|
|
848
|
+
// Quick binary check: if >10% null bytes, it's binary
|
|
849
|
+
const nullCount = [...content.slice(0, 1000)].filter(c => c === "\0").length;
|
|
850
|
+
if (nullCount > 100) {
|
|
851
|
+
res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
res.end(JSON.stringify({ path: reqPath, content, size: stat.size }));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
res.statusCode = 404;
|
|
868
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// POST /api/files/save
|
|
873
|
+
if (urlPath === "/api/files/save" && req.method === "POST") {
|
|
874
|
+
try {
|
|
875
|
+
const { path: filePath, content } = JSON.parse(body);
|
|
876
|
+
const absPath = resolve(BOT_ROOT, filePath);
|
|
877
|
+
if (!absPath.startsWith(BOT_ROOT)) {
|
|
878
|
+
res.statusCode = 403;
|
|
879
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
fs.writeFileSync(absPath, content);
|
|
883
|
+
res.end(JSON.stringify({ ok: true }));
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
res.statusCode = 400;
|
|
887
|
+
const error = err instanceof Error ? err.message : "Invalid request";
|
|
888
|
+
res.end(JSON.stringify({ error }));
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// POST /api/files/delete
|
|
893
|
+
if (urlPath === "/api/files/delete" && req.method === "POST") {
|
|
894
|
+
try {
|
|
895
|
+
const { path: filePath } = JSON.parse(body);
|
|
896
|
+
const absPath = resolve(BOT_ROOT, filePath);
|
|
897
|
+
if (!absPath.startsWith(BOT_ROOT)) {
|
|
898
|
+
res.statusCode = 403;
|
|
899
|
+
res.end(JSON.stringify({ error: "Access denied" }));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// Safety: don't allow deleting critical files
|
|
903
|
+
const critical = [".env", "package.json", "tsconfig.json", "ecosystem.config.cjs"];
|
|
904
|
+
const baseName = path.basename(absPath);
|
|
905
|
+
if (critical.includes(baseName)) {
|
|
906
|
+
res.statusCode = 403;
|
|
907
|
+
res.end(JSON.stringify({ error: `${baseName} cannot be deleted (protected)` }));
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!fs.existsSync(absPath)) {
|
|
911
|
+
res.statusCode = 404;
|
|
912
|
+
res.end(JSON.stringify({ error: "File not found" }));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const stat = fs.statSync(absPath);
|
|
916
|
+
if (stat.isDirectory()) {
|
|
917
|
+
res.statusCode = 400;
|
|
918
|
+
res.end(JSON.stringify({ error: "Directories cannot be deleted" }));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
fs.unlinkSync(absPath);
|
|
922
|
+
res.end(JSON.stringify({ ok: true }));
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
res.statusCode = 400;
|
|
926
|
+
const error = err instanceof Error ? err.message : "Invalid request";
|
|
927
|
+
res.end(JSON.stringify({ error }));
|
|
928
|
+
}
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
// POST /api/terminal
|
|
932
|
+
if (urlPath === "/api/terminal" && req.method === "POST") {
|
|
933
|
+
try {
|
|
934
|
+
const { command } = JSON.parse(body);
|
|
935
|
+
if (!command) {
|
|
936
|
+
res.statusCode = 400;
|
|
937
|
+
res.end(JSON.stringify({ error: "No command" }));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// Security: limit command length
|
|
941
|
+
if (command.length > 10000) {
|
|
942
|
+
res.statusCode = 400;
|
|
943
|
+
res.end(JSON.stringify({ error: "Command too long (max 10000 chars)" }));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const cwd = typeof (JSON.parse(body)).cwd === "string" ? resolve(JSON.parse(body).cwd) : BOT_ROOT;
|
|
947
|
+
const output = execSync(command, {
|
|
948
|
+
cwd,
|
|
949
|
+
stdio: "pipe",
|
|
950
|
+
timeout: 120000,
|
|
951
|
+
env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
|
|
952
|
+
}).toString();
|
|
953
|
+
res.end(JSON.stringify({ output: output.slice(0, 100000) }));
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
const error = err;
|
|
957
|
+
const stderr = error.stderr?.toString()?.trim() || "";
|
|
958
|
+
res.end(JSON.stringify({ output: stderr || error.message, exitCode: 1 }));
|
|
959
|
+
}
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// GET /api/env — read .env keys (names only, values masked)
|
|
963
|
+
if (urlPath === "/api/env") {
|
|
964
|
+
try {
|
|
965
|
+
const envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
|
|
966
|
+
const lines = envContent.split("\n").filter(l => l.includes("=") && !l.startsWith("#"));
|
|
967
|
+
const vars = lines.map(l => {
|
|
968
|
+
const [key, ...rest] = l.split("=");
|
|
969
|
+
const value = rest.join("=").trim();
|
|
970
|
+
// Mask sensitive values
|
|
971
|
+
const masked = key.includes("KEY") || key.includes("TOKEN") || key.includes("PASSWORD") || key.includes("SECRET")
|
|
972
|
+
? (value.length > 4 ? value.slice(0, 4) + "..." + value.slice(-4) : "****")
|
|
973
|
+
: value;
|
|
974
|
+
return { key: key.trim(), value: masked, hasValue: value.length > 0 };
|
|
975
|
+
});
|
|
976
|
+
res.end(JSON.stringify({ vars }));
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
res.end(JSON.stringify({ vars: [] }));
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
// POST /api/env/set — update an env var
|
|
984
|
+
if (urlPath === "/api/env/set" && req.method === "POST") {
|
|
985
|
+
try {
|
|
986
|
+
const { key, value } = JSON.parse(body);
|
|
987
|
+
if (!key || typeof key !== "string" || !key.match(/^[A-Z_][A-Z0-9_]*$/)) {
|
|
988
|
+
res.statusCode = 400;
|
|
989
|
+
res.end(JSON.stringify({ error: "Invalid key name" }));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
let envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
|
|
993
|
+
const regex = new RegExp(`^${key}=.*$`, "m");
|
|
994
|
+
if (regex.test(envContent)) {
|
|
995
|
+
envContent = envContent.replace(regex, `${key}=${value}`);
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
envContent = envContent.trimEnd() + `\n${key}=${value}\n`;
|
|
999
|
+
}
|
|
1000
|
+
fs.writeFileSync(ENV_FILE, envContent);
|
|
1001
|
+
res.end(JSON.stringify({ ok: true, note: "Restart required for changes to take effect" }));
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
res.statusCode = 400;
|
|
1005
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
// GET /api/soul — read SOUL.md
|
|
1010
|
+
if (urlPath === "/api/soul") {
|
|
1011
|
+
const content = getSoulContent();
|
|
1012
|
+
res.end(JSON.stringify({ content }));
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
// POST /api/soul/save — update SOUL.md
|
|
1016
|
+
if (urlPath === "/api/soul/save" && req.method === "POST") {
|
|
1017
|
+
try {
|
|
1018
|
+
const { content } = JSON.parse(body);
|
|
1019
|
+
const soulPath = SOUL_FILE;
|
|
1020
|
+
fs.writeFileSync(soulPath, content);
|
|
1021
|
+
reloadSoul();
|
|
1022
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
res.statusCode = 400;
|
|
1026
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// GET /api/platforms — platform adapter status
|
|
1031
|
+
if (urlPath === "/api/platforms") {
|
|
1032
|
+
const platforms = [
|
|
1033
|
+
{ name: "Telegram", key: "BOT_TOKEN", icon: "📱", configured: !!process.env.BOT_TOKEN },
|
|
1034
|
+
{ name: "Discord", key: "DISCORD_TOKEN", icon: "🎮", configured: !!process.env.DISCORD_TOKEN },
|
|
1035
|
+
{ name: "WhatsApp", key: "WHATSAPP_ENABLED", icon: "💬", configured: process.env.WHATSAPP_ENABLED === "true" },
|
|
1036
|
+
{ name: "Signal", key: "SIGNAL_API_URL", icon: "🔒", configured: !!process.env.SIGNAL_API_URL },
|
|
1037
|
+
{ name: "Web UI", key: "WEB_PORT", icon: "🌐", configured: true },
|
|
1038
|
+
];
|
|
1039
|
+
res.end(JSON.stringify({ platforms }));
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
// POST /api/restart — restart the bot process
|
|
1043
|
+
if (urlPath === "/api/restart" && req.method === "POST") {
|
|
1044
|
+
const { scheduleGracefulRestart } = await import("../services/restart.js");
|
|
1045
|
+
res.end(JSON.stringify({ ok: true, note: "Restarting..." }));
|
|
1046
|
+
scheduleGracefulRestart(500);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
// POST /api/chat/export — export chat history
|
|
1050
|
+
if (urlPath === "/api/chat/export" && req.method === "POST") {
|
|
1051
|
+
try {
|
|
1052
|
+
const { messages, format } = JSON.parse(body);
|
|
1053
|
+
if (format === "json") {
|
|
1054
|
+
res.setHeader("Content-Type", "application/json");
|
|
1055
|
+
res.end(JSON.stringify({ export: messages }, null, 2));
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
// Markdown
|
|
1059
|
+
const md = messages.map((m) => {
|
|
1060
|
+
const prefix = m.role === "user" ? "**Du:**" : m.role === "assistant" ? "**Alvin Bot:**" : "*System:*";
|
|
1061
|
+
const time = m.time ? ` _(${m.time})_` : "";
|
|
1062
|
+
return `${prefix}${time}\n${m.text}\n`;
|
|
1063
|
+
}).join("\n---\n\n");
|
|
1064
|
+
res.setHeader("Content-Type", "text/markdown");
|
|
1065
|
+
res.end(`# Chat Export — Alvin Bot\n_${new Date().toLocaleString("de-DE")}_\n\n---\n\n${md}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
res.statusCode = 400;
|
|
1070
|
+
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// ── WhatsApp Group Management API ────────────────────────────────────
|
|
1075
|
+
// GET /api/whatsapp/groups — list all WhatsApp groups (live from WA)
|
|
1076
|
+
if (urlPath === "/api/whatsapp/groups" && req.method === "GET") {
|
|
1077
|
+
try {
|
|
1078
|
+
const { getWhatsAppAdapter } = await import("../platforms/whatsapp.js");
|
|
1079
|
+
const adapter = getWhatsAppAdapter();
|
|
1080
|
+
if (!adapter) {
|
|
1081
|
+
res.end(JSON.stringify({ groups: [], error: "WhatsApp nicht verbunden" }));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const groups = await adapter.getGroups();
|
|
1085
|
+
res.end(JSON.stringify({ groups }));
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
res.end(JSON.stringify({ groups: [], error: String(err) }));
|
|
1089
|
+
}
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
// GET /api/whatsapp/groups/:id/participants — fetch group participants
|
|
1093
|
+
if (urlPath.match(/^\/api\/whatsapp\/groups\/[^/]+\/participants$/)) {
|
|
1094
|
+
try {
|
|
1095
|
+
const groupId = decodeURIComponent(urlPath.split("/")[4]);
|
|
1096
|
+
const { getWhatsAppAdapter } = await import("../platforms/whatsapp.js");
|
|
1097
|
+
const adapter = getWhatsAppAdapter();
|
|
1098
|
+
if (!adapter) {
|
|
1099
|
+
res.end(JSON.stringify({ participants: [], error: "WhatsApp nicht verbunden" }));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const participants = await adapter.getGroupParticipants(groupId);
|
|
1103
|
+
res.end(JSON.stringify({ participants }));
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
res.end(JSON.stringify({ participants: [], error: String(err) }));
|
|
1107
|
+
}
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
// GET /api/whatsapp/group-rules — get all configured group rules
|
|
1111
|
+
if (urlPath === "/api/whatsapp/group-rules" && req.method === "GET") {
|
|
1112
|
+
const { getGroupRules } = await import("../platforms/whatsapp.js");
|
|
1113
|
+
res.end(JSON.stringify({ rules: getGroupRules() }));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
// POST /api/whatsapp/group-rules — create or update a group rule
|
|
1117
|
+
if (urlPath === "/api/whatsapp/group-rules" && req.method === "POST") {
|
|
1118
|
+
try {
|
|
1119
|
+
const rule = JSON.parse(body);
|
|
1120
|
+
if (!rule.groupId) {
|
|
1121
|
+
res.statusCode = 400;
|
|
1122
|
+
res.end(JSON.stringify({ error: "groupId ist erforderlich" }));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const { upsertGroupRule } = await import("../platforms/whatsapp.js");
|
|
1126
|
+
const saved = upsertGroupRule(rule);
|
|
1127
|
+
res.end(JSON.stringify({ ok: true, rule: saved }));
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
res.statusCode = 400;
|
|
1131
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
1132
|
+
}
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// DELETE /api/whatsapp/group-rules/:id — delete a group rule
|
|
1136
|
+
if (urlPath.match(/^\/api\/whatsapp\/group-rules\//) && req.method === "DELETE") {
|
|
1137
|
+
const groupId = decodeURIComponent(urlPath.split("/").slice(4).join("/"));
|
|
1138
|
+
const { deleteGroupRule } = await import("../platforms/whatsapp.js");
|
|
1139
|
+
const ok = deleteGroupRule(groupId);
|
|
1140
|
+
res.end(JSON.stringify({ ok }));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
res.statusCode = 404;
|
|
1144
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
1145
|
+
}
|
|
1146
|
+
// ── WebSocket Chat ──────────────────────────────────────
|
|
1147
|
+
function handleWebSocket(wss) {
|
|
1148
|
+
wss.on("connection", (ws, req) => {
|
|
1149
|
+
// Auth check
|
|
1150
|
+
if (WEB_PASSWORD && !checkAuth(req)) {
|
|
1151
|
+
ws.close(4001, "Not authenticated");
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
// Canvas WebSocket — separate handler
|
|
1155
|
+
const wsUrl = req.url || "/";
|
|
1156
|
+
if (wsUrl === "/canvas/ws") {
|
|
1157
|
+
addCanvasClient(ws);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
console.log("WebUI: client connected");
|
|
1161
|
+
ws.on("message", async (data) => {
|
|
1162
|
+
try {
|
|
1163
|
+
const msg = JSON.parse(data.toString());
|
|
1164
|
+
if (msg.type === "chat") {
|
|
1165
|
+
let { text, effort, file } = msg;
|
|
1166
|
+
const userId = config.allowedUsers[0] || 0;
|
|
1167
|
+
// Handle file upload — save to temp and reference in prompt
|
|
1168
|
+
if (file?.dataUrl && file?.name) {
|
|
1169
|
+
try {
|
|
1170
|
+
const dataDir = resolve(DATA_DIR, "web-uploads");
|
|
1171
|
+
if (!fs.existsSync(dataDir))
|
|
1172
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
1173
|
+
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
1174
|
+
const filePath = resolve(dataDir, `${Date.now()}_${safeName}`);
|
|
1175
|
+
const base64Data = file.dataUrl.split(",")[1] || file.dataUrl;
|
|
1176
|
+
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
|
1177
|
+
// Replace placeholder with actual file path
|
|
1178
|
+
text = text.replace(/\[File attached:.*?\]/, `[File saved: ${filePath}]`);
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
console.error("WebUI file upload error:", err);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const registry = getRegistry();
|
|
1185
|
+
const activeProvider = registry.getActive();
|
|
1186
|
+
const isSDK = activeProvider.config.type === "claude-sdk";
|
|
1187
|
+
const session = getSession(userId);
|
|
1188
|
+
const queryOpts = {
|
|
1189
|
+
prompt: text,
|
|
1190
|
+
systemPrompt: buildSystemPrompt(isSDK, session.language, "web-dashboard"),
|
|
1191
|
+
workingDir: session.workingDir,
|
|
1192
|
+
effort: effort || session.effort,
|
|
1193
|
+
sessionId: isSDK ? session.sessionId : null,
|
|
1194
|
+
history: !isSDK ? session.history : undefined,
|
|
1195
|
+
};
|
|
1196
|
+
let gotDone = false;
|
|
1197
|
+
try {
|
|
1198
|
+
// Stream response
|
|
1199
|
+
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
1200
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1201
|
+
break;
|
|
1202
|
+
switch (chunk.type) {
|
|
1203
|
+
case "text":
|
|
1204
|
+
ws.send(JSON.stringify({ type: "text", text: chunk.text, delta: chunk.delta }));
|
|
1205
|
+
break;
|
|
1206
|
+
case "tool_use":
|
|
1207
|
+
ws.send(JSON.stringify({ type: "tool", name: chunk.toolName, input: chunk.toolInput }));
|
|
1208
|
+
break;
|
|
1209
|
+
case "done":
|
|
1210
|
+
gotDone = true;
|
|
1211
|
+
if (chunk.sessionId)
|
|
1212
|
+
session.sessionId = chunk.sessionId;
|
|
1213
|
+
if (chunk.costUsd)
|
|
1214
|
+
session.totalCost += chunk.costUsd;
|
|
1215
|
+
if (chunk.inputTokens)
|
|
1216
|
+
session.totalInputTokens = (session.totalInputTokens || 0) + chunk.inputTokens;
|
|
1217
|
+
if (chunk.outputTokens)
|
|
1218
|
+
session.totalOutputTokens = (session.totalOutputTokens || 0) + chunk.outputTokens;
|
|
1219
|
+
ws.send(JSON.stringify({
|
|
1220
|
+
type: "done", cost: chunk.costUsd, sessionId: chunk.sessionId,
|
|
1221
|
+
inputTokens: chunk.inputTokens, outputTokens: chunk.outputTokens,
|
|
1222
|
+
sessionTokens: { input: session.totalInputTokens || 0, output: session.totalOutputTokens || 0 },
|
|
1223
|
+
}));
|
|
1224
|
+
break;
|
|
1225
|
+
case "error":
|
|
1226
|
+
ws.send(JSON.stringify({ type: "error", error: chunk.error }));
|
|
1227
|
+
gotDone = true; // error counts as done
|
|
1228
|
+
break;
|
|
1229
|
+
case "fallback":
|
|
1230
|
+
ws.send(JSON.stringify({ type: "fallback", from: chunk.failedProvider, to: chunk.providerName }));
|
|
1231
|
+
break;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// Ensure we always send done (in case stream ended without done/error chunk)
|
|
1235
|
+
if (!gotDone && ws.readyState === WebSocket.OPEN) {
|
|
1236
|
+
ws.send(JSON.stringify({ type: "done", cost: 0 }));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch (streamErr) {
|
|
1240
|
+
const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
1241
|
+
console.error("WebUI stream error:", errMsg);
|
|
1242
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1243
|
+
ws.send(JSON.stringify({ type: "error", error: errMsg }));
|
|
1244
|
+
if (!gotDone) {
|
|
1245
|
+
ws.send(JSON.stringify({ type: "done", cost: 0 }));
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (msg.type === "reset") {
|
|
1251
|
+
const userId = config.allowedUsers[0] || 0;
|
|
1252
|
+
resetSession(userId);
|
|
1253
|
+
ws.send(JSON.stringify({ type: "reset", ok: true }));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (err) {
|
|
1257
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1258
|
+
ws.send(JSON.stringify({ type: "error", error }));
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
ws.on("close", () => {
|
|
1262
|
+
console.log("WebUI: client disconnected");
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
// ── Start Server ────────────────────────────────────────
|
|
1267
|
+
export function startWebServer() {
|
|
1268
|
+
const server = http.createServer((req, res) => {
|
|
1269
|
+
let body = "";
|
|
1270
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1271
|
+
req.on("end", () => {
|
|
1272
|
+
const urlPath = (req.url || "/").split("?")[0];
|
|
1273
|
+
// OpenAI-compatible API (/v1/chat/completions, /v1/models)
|
|
1274
|
+
if (urlPath.startsWith("/v1/")) {
|
|
1275
|
+
handleOpenAICompat(req, res, urlPath, body);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
// API routes
|
|
1279
|
+
if (urlPath.startsWith("/api/")) {
|
|
1280
|
+
handleAPI(req, res, urlPath, body);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// Auth page (if password set and not authenticated)
|
|
1284
|
+
if (WEB_PASSWORD && !checkAuth(req) && urlPath !== "/login.html") {
|
|
1285
|
+
res.writeHead(302, { Location: "/login.html" });
|
|
1286
|
+
res.end();
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
// Canvas UI
|
|
1290
|
+
if (urlPath === "/canvas") {
|
|
1291
|
+
const canvasFile = resolve(PUBLIC_DIR, "canvas.html");
|
|
1292
|
+
try {
|
|
1293
|
+
const content = fs.readFileSync(canvasFile);
|
|
1294
|
+
res.setHeader("Content-Type", "text/html");
|
|
1295
|
+
res.end(content);
|
|
1296
|
+
}
|
|
1297
|
+
catch {
|
|
1298
|
+
res.statusCode = 404;
|
|
1299
|
+
res.end("Not found");
|
|
1300
|
+
}
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
// Static files
|
|
1304
|
+
let filePath = urlPath === "/" ? "/index.html" : urlPath;
|
|
1305
|
+
filePath = resolve(PUBLIC_DIR, filePath.slice(1));
|
|
1306
|
+
// Security: prevent path traversal
|
|
1307
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
1308
|
+
res.statusCode = 403;
|
|
1309
|
+
res.end("Forbidden");
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
const content = fs.readFileSync(filePath);
|
|
1314
|
+
const ext = path.extname(filePath);
|
|
1315
|
+
res.setHeader("Content-Type", MIME[ext] || "application/octet-stream");
|
|
1316
|
+
res.end(content);
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
res.statusCode = 404;
|
|
1320
|
+
res.end("Not found");
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
const wss = new WebSocketServer({ server });
|
|
1325
|
+
handleWebSocket(wss);
|
|
1326
|
+
// Smart port: try WEB_PORT, increment if busy (up to +20)
|
|
1327
|
+
const MAX_TRIES = 20;
|
|
1328
|
+
function tryListen(port, attempt = 0) {
|
|
1329
|
+
server.once("error", (err) => {
|
|
1330
|
+
if (err.code === "EADDRINUSE" && attempt < MAX_TRIES) {
|
|
1331
|
+
tryListen(port + 1, attempt + 1);
|
|
1332
|
+
}
|
|
1333
|
+
else {
|
|
1334
|
+
console.error(`❌ Web UI failed to start: ${err.message}`);
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
server.listen(port, () => {
|
|
1338
|
+
actualWebPort = port;
|
|
1339
|
+
console.log(`🌐 Web UI: http://localhost:${actualWebPort}`);
|
|
1340
|
+
if (actualWebPort !== WEB_PORT) {
|
|
1341
|
+
console.log(` (Port ${WEB_PORT} was busy, using ${actualWebPort} instead)`);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
tryListen(WEB_PORT);
|
|
1346
|
+
return server;
|
|
1347
|
+
}
|
|
1348
|
+
/** Get the actual port the Web UI is running on. */
|
|
1349
|
+
export function getWebPort() {
|
|
1350
|
+
return actualWebPort;
|
|
1351
|
+
}
|