@smart-tinker/kayla 0.1.3 → 0.1.4
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/dist/bot/bot.js +1 -1
- package/dist/core/config.js +36 -0
- package/dist/core/kayla.js +2 -24
- package/dist/core/limiter.js +27 -0
- package/dist/core/sessions.js +18 -9
- package/dist/core/setup.js +19 -2
- package/dist/service.js +9 -1
- package/dist/web/server.js +834 -0
- package/package.json +1 -1
package/dist/bot/bot.js
CHANGED
|
@@ -6,7 +6,7 @@ const handlers_1 = require("./handlers");
|
|
|
6
6
|
const kayla_1 = require("../core/kayla");
|
|
7
7
|
function buildBot(deps) {
|
|
8
8
|
const bot = new grammy_1.Bot(deps.config.telegram.token);
|
|
9
|
-
const service = new kayla_1.KaylaService({ cfg: deps.config, logger: deps.logger, storage: deps.storage, api: bot.api });
|
|
9
|
+
const service = new kayla_1.KaylaService({ cfg: deps.config, logger: deps.logger, storage: deps.storage, api: bot.api, semaphore: deps.semaphore });
|
|
10
10
|
(0, handlers_1.registerHandlers)(bot, { ...deps, service });
|
|
11
11
|
return bot;
|
|
12
12
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -22,6 +22,12 @@ function defaultDataDir(env) {
|
|
|
22
22
|
return ".kayla-data";
|
|
23
23
|
}
|
|
24
24
|
const DEFAULT_CONFIG = {
|
|
25
|
+
web: {
|
|
26
|
+
enabled: false,
|
|
27
|
+
bind: "0.0.0.0",
|
|
28
|
+
port: 17800,
|
|
29
|
+
admin_password: ""
|
|
30
|
+
},
|
|
25
31
|
telegram: {
|
|
26
32
|
token: "",
|
|
27
33
|
mode: "polling",
|
|
@@ -109,6 +115,19 @@ function readYamlIfExists(p) {
|
|
|
109
115
|
}
|
|
110
116
|
function applyEnv(cfg, env) {
|
|
111
117
|
const get = (k) => env[k];
|
|
118
|
+
// Web
|
|
119
|
+
const webEnabled = get("KAYLA_WEB_ENABLED");
|
|
120
|
+
if (webEnabled && webEnabled.trim().length)
|
|
121
|
+
cfg.web.enabled = parseBool(webEnabled);
|
|
122
|
+
const webBind = get("KAYLA_WEB_BIND");
|
|
123
|
+
if (webBind && webBind.trim().length)
|
|
124
|
+
cfg.web.bind = webBind.trim();
|
|
125
|
+
const webPort = get("KAYLA_WEB_PORT");
|
|
126
|
+
if (webPort && webPort.trim().length)
|
|
127
|
+
cfg.web.port = parseNumber(webPort);
|
|
128
|
+
const webPass = get("KAYLA_WEB_ADMIN_PASSWORD");
|
|
129
|
+
if (webPass && webPass.trim().length)
|
|
130
|
+
cfg.web.admin_password = webPass.trim();
|
|
112
131
|
// Telegram
|
|
113
132
|
const t = get("KAYLA_TELEGRAM_TOKEN");
|
|
114
133
|
if (t && t.trim().length)
|
|
@@ -201,6 +220,18 @@ function applyEnv(cfg, env) {
|
|
|
201
220
|
function applyYaml(cfg, doc) {
|
|
202
221
|
if (!isRecord(doc))
|
|
203
222
|
return;
|
|
223
|
+
const w = isRecord(doc.web) ? doc.web : undefined;
|
|
224
|
+
if (w) {
|
|
225
|
+
if (typeof w.enabled === "boolean")
|
|
226
|
+
cfg.web.enabled = w.enabled;
|
|
227
|
+
const bind = optionalString(w.bind);
|
|
228
|
+
if (bind)
|
|
229
|
+
cfg.web.bind = bind;
|
|
230
|
+
if (typeof w.port === "number")
|
|
231
|
+
cfg.web.port = w.port;
|
|
232
|
+
if (typeof w.admin_password === "string")
|
|
233
|
+
cfg.web.admin_password = String(w.admin_password);
|
|
234
|
+
}
|
|
204
235
|
const t = isRecord(doc.telegram) ? doc.telegram : undefined;
|
|
205
236
|
if (t) {
|
|
206
237
|
const token = optionalString(t.token);
|
|
@@ -287,6 +318,11 @@ function validate(cfg) {
|
|
|
287
318
|
throw new Error("Invalid config: jobs.global_concurrency must be >= 1");
|
|
288
319
|
// Derive output_format from streaming by default (kept for compatibility).
|
|
289
320
|
cfg.claude.output_format = cfg.claude.streaming ? "stream-json" : "json";
|
|
321
|
+
if (cfg.web.enabled) {
|
|
322
|
+
if (!cfg.web.admin_password || cfg.web.admin_password.trim().length === 0) {
|
|
323
|
+
throw new Error("Missing web admin password: set web.admin_password in config.yaml or KAYLA_WEB_ADMIN_PASSWORD in env");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
290
326
|
if (!cfg.telegram.token || cfg.telegram.token.trim().length === 0) {
|
|
291
327
|
throw new Error("Missing telegram token: set telegram.token in config.yaml or KAYLA_TELEGRAM_TOKEN in env");
|
|
292
328
|
}
|
package/dist/core/kayla.js
CHANGED
|
@@ -4,34 +4,12 @@ exports.KaylaService = void 0;
|
|
|
4
4
|
const sessions_1 = require("./sessions");
|
|
5
5
|
const runner_1 = require("./runner");
|
|
6
6
|
const telegram_1 = require("./telegram");
|
|
7
|
-
|
|
8
|
-
constructor(size) {
|
|
9
|
-
this.waiters = [];
|
|
10
|
-
this.available = size;
|
|
11
|
-
}
|
|
12
|
-
async acquire() {
|
|
13
|
-
if (this.available > 0) {
|
|
14
|
-
this.available -= 1;
|
|
15
|
-
return () => this.release();
|
|
16
|
-
}
|
|
17
|
-
return new Promise((resolve) => {
|
|
18
|
-
this.waiters.push(resolve);
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
release() {
|
|
22
|
-
this.available += 1;
|
|
23
|
-
const next = this.waiters.shift();
|
|
24
|
-
if (next && this.available > 0) {
|
|
25
|
-
this.available -= 1;
|
|
26
|
-
next(() => this.release());
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
7
|
+
const limiter_1 = require("./limiter");
|
|
30
8
|
class KaylaService {
|
|
31
9
|
constructor(deps) {
|
|
32
10
|
this.deps = deps;
|
|
33
11
|
this.chats = new Map();
|
|
34
|
-
this.semaphore = new Semaphore(this.deps.cfg.jobs.global_concurrency);
|
|
12
|
+
this.semaphore = this.deps.semaphore ?? new limiter_1.Semaphore(this.deps.cfg.jobs.global_concurrency);
|
|
35
13
|
this.runClaudeImpl = this.deps.runClaudeImpl ?? runner_1.runClaude;
|
|
36
14
|
}
|
|
37
15
|
isAllowedTelegramUser(userId) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Semaphore = void 0;
|
|
4
|
+
class Semaphore {
|
|
5
|
+
constructor(size) {
|
|
6
|
+
this.waiters = [];
|
|
7
|
+
this.available = size;
|
|
8
|
+
}
|
|
9
|
+
async acquire() {
|
|
10
|
+
if (this.available > 0) {
|
|
11
|
+
this.available -= 1;
|
|
12
|
+
return () => this.release();
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
this.waiters.push(resolve);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
release() {
|
|
19
|
+
this.available += 1;
|
|
20
|
+
const next = this.waiters.shift();
|
|
21
|
+
if (next && this.available > 0) {
|
|
22
|
+
this.available -= 1;
|
|
23
|
+
next(() => this.release());
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.Semaphore = Semaphore;
|
package/dist/core/sessions.js
CHANGED
|
@@ -3,6 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getOrCreateSessionByIds = getOrCreateSessionByIds;
|
|
7
|
+
exports.createNewSessionByIds = createNewSessionByIds;
|
|
8
|
+
exports.resetSessionByIds = resetSessionByIds;
|
|
6
9
|
exports.getOrCreateSession = getOrCreateSession;
|
|
7
10
|
exports.createNewSession = createNewSession;
|
|
8
11
|
exports.resetSession = resetSession;
|
|
@@ -21,9 +24,7 @@ function defaultWorkspacePath(cfg, chatId) {
|
|
|
21
24
|
function newWorkspacePath(cfg, chatId) {
|
|
22
25
|
return node_path_1.default.join(cfg.runtime.workspaces_dir, `${safeWorkspaceName(chatId)}-${Date.now()}`);
|
|
23
26
|
}
|
|
24
|
-
function
|
|
25
|
-
const chatId = String(chatIdRaw);
|
|
26
|
-
const userId = String(userIdRaw);
|
|
27
|
+
function getOrCreateSessionByIds(storage, cfg, chatId, userId) {
|
|
27
28
|
const existing = storage.getChat(chatId);
|
|
28
29
|
if (existing) {
|
|
29
30
|
ensureDir(existing.workspace_path);
|
|
@@ -34,12 +35,11 @@ function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
|
|
|
34
35
|
const inserted = storage.upsertChat({ chatId, userId, workspacePath, settings: {} });
|
|
35
36
|
return { chatId, userId: inserted.user_id, workspacePath: inserted.workspace_path };
|
|
36
37
|
}
|
|
37
|
-
function
|
|
38
|
-
const chatId = String(chatIdRaw);
|
|
38
|
+
function createNewSessionByIds(storage, cfg, chatId, userId) {
|
|
39
39
|
const existing = storage.getChat(chatId);
|
|
40
40
|
if (!existing) {
|
|
41
41
|
// If no chat yet, "new" is equivalent to create.
|
|
42
|
-
return
|
|
42
|
+
return getOrCreateSessionByIds(storage, cfg, chatId, userId);
|
|
43
43
|
}
|
|
44
44
|
let workspacePath = newWorkspacePath(cfg, chatId);
|
|
45
45
|
// Extremely unlikely, but avoid collisions in fast loops.
|
|
@@ -50,10 +50,19 @@ function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
|
|
|
50
50
|
const updated = storage.updateChatWorkspace(chatId, workspacePath);
|
|
51
51
|
return { chatId, userId: updated.user_id, workspacePath: updated.workspace_path };
|
|
52
52
|
}
|
|
53
|
-
function
|
|
54
|
-
const session =
|
|
53
|
+
function resetSessionByIds(storage, cfg, chatId, userId) {
|
|
54
|
+
const session = getOrCreateSessionByIds(storage, cfg, chatId, userId);
|
|
55
55
|
node_fs_1.default.rmSync(session.workspacePath, { recursive: true, force: true });
|
|
56
56
|
ensureDir(session.workspacePath);
|
|
57
57
|
// Workspace path remains the same; just return current mapping.
|
|
58
|
-
return
|
|
58
|
+
return getOrCreateSessionByIds(storage, cfg, chatId, userId);
|
|
59
|
+
}
|
|
60
|
+
function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
|
|
61
|
+
return getOrCreateSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
|
|
62
|
+
}
|
|
63
|
+
function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
|
|
64
|
+
return createNewSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
|
|
65
|
+
}
|
|
66
|
+
function resetSession(storage, cfg, chatIdRaw, userIdRaw) {
|
|
67
|
+
return resetSessionByIds(storage, cfg, String(chatIdRaw), String(userIdRaw));
|
|
59
68
|
}
|
package/dist/core/setup.js
CHANGED
|
@@ -68,7 +68,14 @@ function buildConfigFromBootstrapInputs(paths, inputs) {
|
|
|
68
68
|
if (adminIds.length === 0)
|
|
69
69
|
throw new Error("Missing telegram admin user ids");
|
|
70
70
|
const allowIds = inputs.telegramAllowlistUserIds;
|
|
71
|
+
const webPass = inputs.webAdminPassword.trim();
|
|
71
72
|
return {
|
|
73
|
+
web: {
|
|
74
|
+
enabled: webPass.length > 0,
|
|
75
|
+
bind: "0.0.0.0",
|
|
76
|
+
port: 17800,
|
|
77
|
+
admin_password: webPass
|
|
78
|
+
},
|
|
72
79
|
telegram: {
|
|
73
80
|
token,
|
|
74
81
|
mode: "polling",
|
|
@@ -115,7 +122,16 @@ function parseBootstrapInputsFromEnv(env) {
|
|
|
115
122
|
throw new Error("Invalid KAYLA_TELEGRAM_ADMIN_USER_IDS: empty list");
|
|
116
123
|
const allowCsv = (env.KAYLA_TELEGRAM_ALLOWLIST_USER_IDS ?? "").trim();
|
|
117
124
|
const allowIds = allowCsv ? parseCsvNumbersStrict(allowCsv) : [];
|
|
118
|
-
|
|
125
|
+
const webEnabledRaw = (env.KAYLA_WEB_ENABLED ?? "").trim();
|
|
126
|
+
const webEnabled = webEnabledRaw ? ["1", "true", "yes", "on"].includes(webEnabledRaw.toLowerCase()) : undefined;
|
|
127
|
+
const webDisabled = webEnabledRaw ? ["0", "false", "no", "off"].includes(webEnabledRaw.toLowerCase()) : undefined;
|
|
128
|
+
if (webEnabledRaw && webEnabled === undefined && webDisabled === undefined)
|
|
129
|
+
throw new Error(`Invalid KAYLA_WEB_ENABLED: ${webEnabledRaw}`);
|
|
130
|
+
const webPass = (env.KAYLA_WEB_ADMIN_PASSWORD ?? "").trim();
|
|
131
|
+
if (webEnabled && !webPass)
|
|
132
|
+
throw new Error("Missing KAYLA_WEB_ADMIN_PASSWORD (required when KAYLA_WEB_ENABLED=true)");
|
|
133
|
+
const webAdminPassword = webDisabled ? "" : webPass;
|
|
134
|
+
return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds, webAdminPassword };
|
|
119
135
|
}
|
|
120
136
|
function buildConfigFromEnv(paths, env) {
|
|
121
137
|
const inputs = parseBootstrapInputsFromEnv(env);
|
|
@@ -152,7 +168,8 @@ async function promptBootstrapInputs(prompt) {
|
|
|
152
168
|
}
|
|
153
169
|
}
|
|
154
170
|
})();
|
|
155
|
-
|
|
171
|
+
const webAdminPassword = (await prompt({ kind: "secret", message: "Web admin password (blank = disable web UI): " })).trim();
|
|
172
|
+
return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds, webAdminPassword };
|
|
156
173
|
}
|
|
157
174
|
function nonInteractiveSetupError(configPath) {
|
|
158
175
|
return [
|
package/dist/service.js
CHANGED
|
@@ -3,8 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.runService = runService;
|
|
4
4
|
const bot_1 = require("./bot/bot");
|
|
5
5
|
const config_1 = require("./core/config");
|
|
6
|
+
const doctor_1 = require("./core/doctor");
|
|
7
|
+
const limiter_1 = require("./core/limiter");
|
|
6
8
|
const logging_1 = require("./core/logging");
|
|
7
9
|
const storage_1 = require("./core/storage");
|
|
10
|
+
const server_1 = require("./web/server");
|
|
8
11
|
async function runService() {
|
|
9
12
|
const config = (0, config_1.loadConfig)();
|
|
10
13
|
const logger = (0, logging_1.createLogger)(config.logging);
|
|
@@ -12,7 +15,12 @@ async function runService() {
|
|
|
12
15
|
const storage = (0, storage_1.openStorage)(config.runtime.data_dir);
|
|
13
16
|
// Bootstrap allowlist into DB (required for first admin).
|
|
14
17
|
storage.bootstrapAllowedUsers("telegram", [...config.telegram.admin_user_ids, ...config.telegram.allowlist.user_ids]);
|
|
15
|
-
const
|
|
18
|
+
const semaphore = new limiter_1.Semaphore(config.jobs.global_concurrency);
|
|
19
|
+
const bot = (0, bot_1.buildBot)({ config, logger, storage, semaphore });
|
|
20
|
+
if (config.web.enabled) {
|
|
21
|
+
const configPath = (0, doctor_1.resolveConfigPath)(process.env);
|
|
22
|
+
await (0, server_1.startWebServer)({ config, configPath, logger, storage, semaphore });
|
|
23
|
+
}
|
|
16
24
|
// MVP: polling only.
|
|
17
25
|
if (config.telegram.mode !== "polling") {
|
|
18
26
|
throw new Error(`telegram.mode=${config.telegram.mode} is not supported yet (MVP supports polling only)`);
|
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startWebServer = startWebServer;
|
|
7
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
11
|
+
const doctor_1 = require("../core/doctor");
|
|
12
|
+
const runner_1 = require("../core/runner");
|
|
13
|
+
const sessions_1 = require("../core/sessions");
|
|
14
|
+
function unauthorized(res) {
|
|
15
|
+
res.statusCode = 401;
|
|
16
|
+
res.setHeader("WWW-Authenticate", 'Basic realm="Kayla"');
|
|
17
|
+
res.end("Unauthorized");
|
|
18
|
+
}
|
|
19
|
+
function parseBasicAuthHeader(v) {
|
|
20
|
+
if (typeof v !== "string")
|
|
21
|
+
return null;
|
|
22
|
+
const [kind, token] = v.split(" ");
|
|
23
|
+
if (kind !== "Basic" || !token)
|
|
24
|
+
return null;
|
|
25
|
+
let raw = "";
|
|
26
|
+
try {
|
|
27
|
+
raw = Buffer.from(token, "base64").toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const idx = raw.indexOf(":");
|
|
33
|
+
if (idx < 0)
|
|
34
|
+
return null;
|
|
35
|
+
const username = raw.slice(0, idx);
|
|
36
|
+
const password = raw.slice(idx + 1);
|
|
37
|
+
return { username, password };
|
|
38
|
+
}
|
|
39
|
+
function isAuthorized(req, adminPassword) {
|
|
40
|
+
const auth = parseBasicAuthHeader(req.headers.authorization);
|
|
41
|
+
if (!auth)
|
|
42
|
+
return false;
|
|
43
|
+
// Username is ignored for now (single-admin mode).
|
|
44
|
+
return auth.password === adminPassword;
|
|
45
|
+
}
|
|
46
|
+
function sendJson(res, status, body) {
|
|
47
|
+
res.statusCode = status;
|
|
48
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
49
|
+
res.end(JSON.stringify(body));
|
|
50
|
+
}
|
|
51
|
+
function sendHtml(res, status, html) {
|
|
52
|
+
res.statusCode = status;
|
|
53
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
54
|
+
res.end(html);
|
|
55
|
+
}
|
|
56
|
+
function readBody(req, maxBytes) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
req.on("data", (c) => {
|
|
61
|
+
size += c.length;
|
|
62
|
+
if (size > maxBytes) {
|
|
63
|
+
reject(new Error("Request body too large"));
|
|
64
|
+
req.destroy();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(c);
|
|
68
|
+
});
|
|
69
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async function readJsonBody(req, maxBytes) {
|
|
74
|
+
const buf = await readBody(req, maxBytes);
|
|
75
|
+
if (buf.length === 0)
|
|
76
|
+
return {};
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(buf.toString("utf8"));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error("Invalid JSON");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function parseCsvNumbersBestEffort(v) {
|
|
85
|
+
if (typeof v !== "string")
|
|
86
|
+
return [];
|
|
87
|
+
const items = v
|
|
88
|
+
.split(",")
|
|
89
|
+
.map((x) => x.trim())
|
|
90
|
+
.filter((x) => x.length > 0);
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const it of items) {
|
|
93
|
+
const n = Number(it);
|
|
94
|
+
if (!Number.isFinite(n))
|
|
95
|
+
continue;
|
|
96
|
+
out.push(n);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function maskSecret(v) {
|
|
101
|
+
if (!v.trim())
|
|
102
|
+
return "";
|
|
103
|
+
// Fixed-length mask to avoid leaking secret length in UI.
|
|
104
|
+
return "********";
|
|
105
|
+
}
|
|
106
|
+
function renderIndexHtml() {
|
|
107
|
+
// Keep it dependency-free; minimal JS to fetch/save settings.
|
|
108
|
+
return `<!doctype html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8" />
|
|
112
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
113
|
+
<title>Kayla Admin</title>
|
|
114
|
+
<style>
|
|
115
|
+
:root { --bg:#0e1116; --panel:#151a22; --text:#e7eefc; --muted:#9bb0d1; --accent:#f6c945; --danger:#ff6673; --ok:#7bf1a8; }
|
|
116
|
+
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: radial-gradient(1200px 600px at 15% 0%, #1a2440, var(--bg)); color: var(--text); }
|
|
117
|
+
.wrap { max-width: 900px; margin: 0 auto; padding: 28px 16px 48px; }
|
|
118
|
+
h1 { margin: 0 0 6px; font-size: 28px; letter-spacing: 0.2px; }
|
|
119
|
+
.sub { margin: 0 0 22px; color: var(--muted); }
|
|
120
|
+
.panel { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 18px; backdrop-filter: blur(8px); }
|
|
121
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
122
|
+
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
|
123
|
+
label { display:block; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; }
|
|
124
|
+
input, select { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.10); background: rgba(10,12,16,0.6); color: var(--text); outline: none; }
|
|
125
|
+
input:focus, select:focus { border-color: rgba(246,201,69,0.65); box-shadow: 0 0 0 3px rgba(246,201,69,0.18); }
|
|
126
|
+
.row { display:flex; gap: 10px; align-items: center; }
|
|
127
|
+
.row > * { flex: 1; }
|
|
128
|
+
.btn { cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(246,201,69,0.12); color: var(--text); font-weight: 600; }
|
|
129
|
+
.btn:hover { border-color: rgba(246,201,69,0.45); background: rgba(246,201,69,0.18); }
|
|
130
|
+
.btn.secondary { background: rgba(255,255,255,0.06); }
|
|
131
|
+
.btn.danger { background: rgba(255,102,115,0.12); }
|
|
132
|
+
.btn.danger:hover { border-color: rgba(255,102,115,0.45); background: rgba(255,102,115,0.18); }
|
|
133
|
+
.hint { margin-top: 6px; color: var(--muted); font-size: 13px; line-height: 1.35; }
|
|
134
|
+
.status { margin-top: 14px; font-size: 14px; color: var(--muted); }
|
|
135
|
+
.status.ok { color: var(--ok); }
|
|
136
|
+
.status.err { color: var(--danger); }
|
|
137
|
+
.hr { height: 1px; background: rgba(255,255,255,0.08); margin: 16px 0; }
|
|
138
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace; }
|
|
139
|
+
</style>
|
|
140
|
+
</head>
|
|
141
|
+
<body>
|
|
142
|
+
<div class="wrap">
|
|
143
|
+
<h1>Kayla Admin</h1>
|
|
144
|
+
<p class="sub">LAN-only HTTP UI. Changes are written to <code>~/.config/kayla/config.yaml</code>.</p>
|
|
145
|
+
<div class="panel">
|
|
146
|
+
<div class="grid">
|
|
147
|
+
<div>
|
|
148
|
+
<label for="telegramToken">Telegram bot token</label>
|
|
149
|
+
<div class="row">
|
|
150
|
+
<input id="telegramToken" type="password" placeholder="(empty)" autocomplete="off" />
|
|
151
|
+
<button class="btn secondary" id="btnShowToken" type="button">Show</button>
|
|
152
|
+
<button class="btn secondary" id="btnToggleMask" type="button">Mask</button>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="hint">Token is masked by default. Use Show to fetch the secret explicitly.</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div>
|
|
157
|
+
<label for="claudeBinary">Claude command/binary</label>
|
|
158
|
+
<input id="claudeBinary" type="text" placeholder="claude" />
|
|
159
|
+
<div class="hint">This is the command Kayla runs (must be in PATH for the service user).</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="grid">
|
|
164
|
+
<div>
|
|
165
|
+
<label for="adminIds">Telegram admin user ids (CSV)</label>
|
|
166
|
+
<input id="adminIds" type="text" placeholder="123456789" />
|
|
167
|
+
</div>
|
|
168
|
+
<div>
|
|
169
|
+
<label for="allowIds">Allowlist user ids (CSV, optional)</label>
|
|
170
|
+
<input id="allowIds" type="text" placeholder="(blank = none)" />
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div class="grid">
|
|
175
|
+
<div>
|
|
176
|
+
<label for="loggingLevel">Logging level</label>
|
|
177
|
+
<select id="loggingLevel">
|
|
178
|
+
<option value="fatal">fatal</option>
|
|
179
|
+
<option value="error">error</option>
|
|
180
|
+
<option value="warn">warn</option>
|
|
181
|
+
<option value="info">info</option>
|
|
182
|
+
<option value="debug">debug</option>
|
|
183
|
+
<option value="trace">trace</option>
|
|
184
|
+
</select>
|
|
185
|
+
</div>
|
|
186
|
+
<div>
|
|
187
|
+
<label for="loggingJson">Logging JSON</label>
|
|
188
|
+
<select id="loggingJson">
|
|
189
|
+
<option value="true">true</option>
|
|
190
|
+
<option value="false">false</option>
|
|
191
|
+
</select>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="hr"></div>
|
|
196
|
+
<div class="row">
|
|
197
|
+
<button class="btn secondary" id="btnDoctor" type="button">Doctor</button>
|
|
198
|
+
<button class="btn secondary" id="btnCheckToken" type="button">Check Telegram token</button>
|
|
199
|
+
<button class="btn danger" id="btnRestart" type="button">Restart service</button>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="hint">Restart exits the process; systemd should bring it back (Restart=always).</div>
|
|
202
|
+
<pre id="actionsOut" style="margin-top:12px; padding:12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background: rgba(10,12,16,0.55); overflow:auto; white-space:pre-wrap;"></pre>
|
|
203
|
+
<div class="hr"></div>
|
|
204
|
+
<div class="row">
|
|
205
|
+
<button class="btn" id="btnSave" type="button">Save settings</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="status" id="status"></div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<script>
|
|
211
|
+
const $ = (id) => document.getElementById(id);
|
|
212
|
+
const status = (kind, msg) => {
|
|
213
|
+
const el = $("status");
|
|
214
|
+
el.className = "status " + (kind || "");
|
|
215
|
+
el.textContent = msg || "";
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
async function loadSettings() {
|
|
219
|
+
status("", "Loading...");
|
|
220
|
+
const res = await fetch("/api/settings", { credentials: "same-origin" });
|
|
221
|
+
if (!res.ok) throw new Error("Failed to load settings (" + res.status + ")");
|
|
222
|
+
const s = await res.json();
|
|
223
|
+
$("telegramToken").value = s.telegramTokenMasked || "";
|
|
224
|
+
$("adminIds").value = s.telegramAdminUserIdsCsv || "";
|
|
225
|
+
$("allowIds").value = s.telegramAllowlistUserIdsCsv || "";
|
|
226
|
+
$("claudeBinary").value = s.claudeBinary || "claude";
|
|
227
|
+
$("loggingLevel").value = s.loggingLevel || "info";
|
|
228
|
+
$("loggingJson").value = String(Boolean(s.loggingJson));
|
|
229
|
+
status("ok", "Loaded.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function showToken() {
|
|
233
|
+
status("", "Fetching token...");
|
|
234
|
+
const res = await fetch("/api/secrets/telegram-token", { credentials: "same-origin" });
|
|
235
|
+
if (!res.ok) throw new Error("Failed to fetch token (" + res.status + ")");
|
|
236
|
+
const out = await res.json();
|
|
237
|
+
$("telegramToken").value = out.token || "";
|
|
238
|
+
$("telegramToken").type = "text";
|
|
239
|
+
status("ok", "Token loaded (visible).");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function toggleMask() {
|
|
243
|
+
const el = $("telegramToken");
|
|
244
|
+
el.type = (el.type === "password") ? "text" : "password";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function saveSettings() {
|
|
248
|
+
status("", "Saving...");
|
|
249
|
+
const payload = {
|
|
250
|
+
telegramToken: $("telegramToken").value || "",
|
|
251
|
+
telegramAdminUserIdsCsv: $("adminIds").value || "",
|
|
252
|
+
telegramAllowlistUserIdsCsv: $("allowIds").value || "",
|
|
253
|
+
claudeBinary: $("claudeBinary").value || "",
|
|
254
|
+
loggingLevel: $("loggingLevel").value || "info",
|
|
255
|
+
loggingJson: $("loggingJson").value === "true"
|
|
256
|
+
};
|
|
257
|
+
const res = await fetch("/api/settings", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "Content-Type": "application/json" },
|
|
260
|
+
body: JSON.stringify(payload),
|
|
261
|
+
credentials: "same-origin"
|
|
262
|
+
});
|
|
263
|
+
const out = await res.json().catch(() => ({}));
|
|
264
|
+
if (!res.ok) throw new Error(out.error || ("Save failed (" + res.status + ")"));
|
|
265
|
+
status("ok", out.message || "Saved. Restart the service to apply changes.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function runDoctor() {
|
|
269
|
+
status("", "Running doctor...");
|
|
270
|
+
const res = await fetch("/api/doctor", { method: "POST", credentials: "same-origin" });
|
|
271
|
+
const out = await res.json().catch(() => ({}));
|
|
272
|
+
if (!res.ok) throw new Error(out.error || ("Doctor failed (" + res.status + ")"));
|
|
273
|
+
$("actionsOut").textContent = JSON.stringify(out, null, 2);
|
|
274
|
+
status(out.ok ? "ok" : "err", out.ok ? "Doctor: OK" : "Doctor: FAIL");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function checkToken() {
|
|
278
|
+
status("", "Checking Telegram token...");
|
|
279
|
+
const res = await fetch("/api/telegram/check-token", { method: "POST", credentials: "same-origin" });
|
|
280
|
+
const out = await res.json().catch(() => ({}));
|
|
281
|
+
if (!res.ok) throw new Error(out.error || ("Token check failed (" + res.status + ")"));
|
|
282
|
+
$("actionsOut").textContent = JSON.stringify(out, null, 2);
|
|
283
|
+
status(out.ok ? "ok" : "err", out.ok ? "Token: OK" : "Token: FAIL");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function restartService() {
|
|
287
|
+
status("", "Restarting...");
|
|
288
|
+
const res = await fetch("/api/restart", { method: "POST", credentials: "same-origin" });
|
|
289
|
+
const out = await res.json().catch(() => ({}));
|
|
290
|
+
$("actionsOut").textContent = JSON.stringify(out, null, 2);
|
|
291
|
+
status("ok", out.message || "Restarting...");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
$("btnShowToken").addEventListener("click", () => showToken().catch(e => status("err", String(e && e.message ? e.message : e))));
|
|
295
|
+
$("btnToggleMask").addEventListener("click", () => toggleMask());
|
|
296
|
+
$("btnSave").addEventListener("click", () => saveSettings().catch(e => status("err", String(e && e.message ? e.message : e))));
|
|
297
|
+
$("btnDoctor").addEventListener("click", () => runDoctor().catch(e => status("err", String(e && e.message ? e.message : e))));
|
|
298
|
+
$("btnCheckToken").addEventListener("click", () => checkToken().catch(e => status("err", String(e && e.message ? e.message : e))));
|
|
299
|
+
$("btnRestart").addEventListener("click", () => restartService().catch(e => status("err", String(e && e.message ? e.message : e))));
|
|
300
|
+
|
|
301
|
+
loadSettings().catch(e => status("err", String(e && e.message ? e.message : e)));
|
|
302
|
+
</script>
|
|
303
|
+
</body>
|
|
304
|
+
</html>`;
|
|
305
|
+
}
|
|
306
|
+
function writeYamlAtomic(targetPath, rawYaml) {
|
|
307
|
+
const dir = node_path_1.default.dirname(targetPath);
|
|
308
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
309
|
+
// Temp dir must be on the same filesystem as the target so rename is atomic.
|
|
310
|
+
const tmpDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(dir, ".kayla-web-"));
|
|
311
|
+
const tmpPath = node_path_1.default.join(tmpDir, "config.yaml");
|
|
312
|
+
try {
|
|
313
|
+
// 0600: contains secrets (Telegram token and web password).
|
|
314
|
+
node_fs_1.default.writeFileSync(tmpPath, rawYaml, { mode: 0o600 });
|
|
315
|
+
node_fs_1.default.renameSync(tmpPath, targetPath);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
try {
|
|
319
|
+
node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// ignore cleanup errors
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function renderChatHtml() {
|
|
327
|
+
return `<!doctype html>
|
|
328
|
+
<html lang="en">
|
|
329
|
+
<head>
|
|
330
|
+
<meta charset="utf-8" />
|
|
331
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
332
|
+
<title>Kayla Chat</title>
|
|
333
|
+
<style>
|
|
334
|
+
:root { --bg:#0e1116; --panel:#151a22; --text:#e7eefc; --muted:#9bb0d1; --accent:#f6c945; --danger:#ff6673; --ok:#7bf1a8; }
|
|
335
|
+
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: radial-gradient(1200px 600px at 15% 0%, #1a2440, var(--bg)); color: var(--text); }
|
|
336
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 18px 16px 40px; }
|
|
337
|
+
h1 { margin: 0 0 10px; font-size: 26px; }
|
|
338
|
+
.panel { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; backdrop-filter: blur(8px); }
|
|
339
|
+
.row { display:flex; gap: 10px; align-items: center; }
|
|
340
|
+
.btn { cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text); font-weight: 600; }
|
|
341
|
+
.btn:hover { border-color: rgba(246,201,69,0.45); background: rgba(246,201,69,0.10); }
|
|
342
|
+
.btn.danger { background: rgba(255,102,115,0.12); }
|
|
343
|
+
.btn.danger:hover { border-color: rgba(255,102,115,0.45); background: rgba(255,102,115,0.18); }
|
|
344
|
+
.btn.primary { background: rgba(246,201,69,0.14); }
|
|
345
|
+
.hint { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
|
346
|
+
#log { margin-top: 12px; padding: 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.08); background: rgba(10,12,16,0.55); min-height: 320px; overflow:auto; white-space: pre-wrap; }
|
|
347
|
+
#input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.10); background: rgba(10,12,16,0.6); color: var(--text); outline: none; }
|
|
348
|
+
#input:focus { border-color: rgba(246,201,69,0.65); box-shadow: 0 0 0 3px rgba(246,201,69,0.18); }
|
|
349
|
+
.status { margin-top: 10px; font-size: 14px; color: var(--muted); }
|
|
350
|
+
.status.ok { color: var(--ok); }
|
|
351
|
+
.status.err { color: var(--danger); }
|
|
352
|
+
</style>
|
|
353
|
+
</head>
|
|
354
|
+
<body>
|
|
355
|
+
<div class="wrap">
|
|
356
|
+
<h1>Kayla Chat</h1>
|
|
357
|
+
<div class="panel">
|
|
358
|
+
<div class="row">
|
|
359
|
+
<button class="btn" id="btnNew" type="button">New session</button>
|
|
360
|
+
<button class="btn danger" id="btnReset" type="button">Reset session</button>
|
|
361
|
+
<button class="btn danger" id="btnCancel" type="button">Cancel</button>
|
|
362
|
+
<button class="btn" id="btnStatus" type="button">Status</button>
|
|
363
|
+
<a class="btn" href="/" style="text-decoration:none;">Admin</a>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="hint">Streaming is via SSE. Reset is destructive and asks for confirmation.</div>
|
|
366
|
+
<div id="log"></div>
|
|
367
|
+
<div class="row" style="margin-top: 12px;">
|
|
368
|
+
<input id="input" type="text" placeholder="Type a message..." autocomplete="off" />
|
|
369
|
+
<button class="btn primary" id="btnSend" type="button">Send</button>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="status" id="status"></div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
<script>
|
|
375
|
+
const $ = (id) => document.getElementById(id);
|
|
376
|
+
const logEl = $("log");
|
|
377
|
+
const statusEl = $("status");
|
|
378
|
+
|
|
379
|
+
const setStatus = (kind, msg) => {
|
|
380
|
+
statusEl.className = "status " + (kind || "");
|
|
381
|
+
statusEl.textContent = msg || "";
|
|
382
|
+
};
|
|
383
|
+
const append = (text) => {
|
|
384
|
+
logEl.textContent += text;
|
|
385
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
let currentJobId = null;
|
|
389
|
+
|
|
390
|
+
const es = new EventSource("/api/chat/stream");
|
|
391
|
+
es.addEventListener("hello", () => setStatus("ok", "Connected."));
|
|
392
|
+
es.addEventListener("status", (e) => {
|
|
393
|
+
try {
|
|
394
|
+
const d = JSON.parse(e.data);
|
|
395
|
+
append("[status] " + JSON.stringify(d) + "\\n");
|
|
396
|
+
} catch {}
|
|
397
|
+
});
|
|
398
|
+
es.addEventListener("start", (e) => {
|
|
399
|
+
const d = JSON.parse(e.data);
|
|
400
|
+
currentJobId = d.jobId || null;
|
|
401
|
+
append("\\nYou: " + (d.prompt || "") + "\\n");
|
|
402
|
+
append("Kayla: ");
|
|
403
|
+
});
|
|
404
|
+
es.addEventListener("delta", (e) => {
|
|
405
|
+
const d = JSON.parse(e.data);
|
|
406
|
+
if (!currentJobId || d.jobId !== currentJobId) return;
|
|
407
|
+
append(d.delta || "");
|
|
408
|
+
});
|
|
409
|
+
es.addEventListener("done", (e) => {
|
|
410
|
+
const d = JSON.parse(e.data);
|
|
411
|
+
if (d.jobId === currentJobId) currentJobId = null;
|
|
412
|
+
append("\\n\\n[done] " + d.status + (d.error ? (" " + d.error) : "") + "\\n");
|
|
413
|
+
setStatus(d.status === "succeeded" ? "ok" : "err", "Done: " + d.status);
|
|
414
|
+
});
|
|
415
|
+
es.onerror = () => setStatus("err", "Stream disconnected.");
|
|
416
|
+
|
|
417
|
+
async function postJson(url, body) {
|
|
418
|
+
const res = await fetch(url, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "Content-Type": "application/json" },
|
|
421
|
+
body: JSON.stringify(body || {}),
|
|
422
|
+
credentials: "same-origin"
|
|
423
|
+
});
|
|
424
|
+
const out = await res.json().catch(() => ({}));
|
|
425
|
+
if (!res.ok) throw new Error(out.error || ("HTTP " + res.status));
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function send() {
|
|
430
|
+
const input = $("input");
|
|
431
|
+
const text = (input.value || "").trim();
|
|
432
|
+
if (!text) return;
|
|
433
|
+
input.value = "";
|
|
434
|
+
setStatus("", "Sending...");
|
|
435
|
+
await postJson("/api/chat/send", { text });
|
|
436
|
+
setStatus("ok", "Sent.");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function newSession() {
|
|
440
|
+
setStatus("", "Starting new session...");
|
|
441
|
+
const out = await postJson("/api/chat/new", {});
|
|
442
|
+
append("[new] workspace=" + (out.workspacePath || "") + "\\n");
|
|
443
|
+
setStatus("ok", "New session started.");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function resetSession() {
|
|
447
|
+
const ok = confirm("Reset session? This deletes the workspace contents.");
|
|
448
|
+
if (!ok) return;
|
|
449
|
+
setStatus("", "Resetting...");
|
|
450
|
+
const out = await postJson("/api/chat/reset", { confirm: true });
|
|
451
|
+
append("[reset] workspace=" + (out.workspacePath || "") + "\\n");
|
|
452
|
+
setStatus("ok", "Session reset.");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function cancel() {
|
|
456
|
+
setStatus("", "Canceling...");
|
|
457
|
+
const out = await postJson("/api/chat/cancel", {});
|
|
458
|
+
append("[cancel] " + (out.message || "") + "\\n");
|
|
459
|
+
setStatus("ok", out.message || "Cancel requested.");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function statusCmd() {
|
|
463
|
+
setStatus("", "Fetching status...");
|
|
464
|
+
const out = await fetch("/api/chat/status", { credentials: "same-origin" }).then((r) => r.json());
|
|
465
|
+
append("[status] " + JSON.stringify(out) + "\\n");
|
|
466
|
+
setStatus("ok", "OK");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
$("btnSend").addEventListener("click", () => send().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
|
|
470
|
+
$("input").addEventListener("keydown", (e) => {
|
|
471
|
+
if (e.key === "Enter") send().catch(() => {});
|
|
472
|
+
});
|
|
473
|
+
$("btnNew").addEventListener("click", () => newSession().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
|
|
474
|
+
$("btnReset").addEventListener("click", () => resetSession().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
|
|
475
|
+
$("btnCancel").addEventListener("click", () => cancel().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
|
|
476
|
+
$("btnStatus").addEventListener("click", () => statusCmd().catch((e) => setStatus("err", String(e && e.message ? e.message : e))));
|
|
477
|
+
</script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>`;
|
|
480
|
+
}
|
|
481
|
+
class WebChat {
|
|
482
|
+
constructor(deps) {
|
|
483
|
+
this.deps = deps;
|
|
484
|
+
this.chatId = "web:default";
|
|
485
|
+
this.userId = "web:default";
|
|
486
|
+
this.clients = new Set();
|
|
487
|
+
this.queue = [];
|
|
488
|
+
this.processing = false;
|
|
489
|
+
}
|
|
490
|
+
broadcast(event, data) {
|
|
491
|
+
const payload = JSON.stringify(data);
|
|
492
|
+
for (const res of this.clients) {
|
|
493
|
+
try {
|
|
494
|
+
res.write(`event: ${event}\n`);
|
|
495
|
+
res.write(`data: ${payload}\n\n`);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// ignore broken connections; cleanup happens on close
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
sendStatus() {
|
|
503
|
+
const running = this.deps.storage.getRunningJob(this.chatId);
|
|
504
|
+
const queued = this.deps.storage.countQueuedJobs(this.chatId);
|
|
505
|
+
const chat = this.deps.storage.getChat(this.chatId);
|
|
506
|
+
this.broadcast("status", {
|
|
507
|
+
running: running ? running.job_id : null,
|
|
508
|
+
queued,
|
|
509
|
+
workspace: chat?.workspace_path ?? null
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
handleChatPage(res) {
|
|
513
|
+
sendHtml(res, 200, renderChatHtml());
|
|
514
|
+
}
|
|
515
|
+
handleStream(req, res) {
|
|
516
|
+
res.statusCode = 200;
|
|
517
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
518
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
519
|
+
res.setHeader("Connection", "keep-alive");
|
|
520
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
521
|
+
// Make sure headers are sent immediately for streaming clients.
|
|
522
|
+
res.flushHeaders?.();
|
|
523
|
+
res.write("event: hello\n");
|
|
524
|
+
res.write(`data: ${JSON.stringify({ ok: true })}\n\n`);
|
|
525
|
+
this.clients.add(res);
|
|
526
|
+
this.sendStatus();
|
|
527
|
+
const keep = setInterval(() => {
|
|
528
|
+
try {
|
|
529
|
+
res.write(":keep-alive\n\n");
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// ignore
|
|
533
|
+
}
|
|
534
|
+
}, 15000);
|
|
535
|
+
req.on("close", () => {
|
|
536
|
+
clearInterval(keep);
|
|
537
|
+
this.clients.delete(res);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async handleSend(req, res) {
|
|
541
|
+
const body = await readJsonBody(req, 256 * 1024);
|
|
542
|
+
const b = typeof body === "object" && body !== null ? body : {};
|
|
543
|
+
const text = typeof b.text === "string" ? b.text.trim() : "";
|
|
544
|
+
if (!text)
|
|
545
|
+
return sendJson(res, 400, { ok: false, error: "text is required" });
|
|
546
|
+
const stateCount = this.queue.length + (this.active ? 1 : 0);
|
|
547
|
+
const cfg = this.deps.getConfig();
|
|
548
|
+
if (stateCount >= cfg.jobs.queue_size_per_chat)
|
|
549
|
+
return sendJson(res, 429, { ok: false, error: "Queue is full" });
|
|
550
|
+
// Ensure chat/workspace exists.
|
|
551
|
+
(0, sessions_1.getOrCreateSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
|
|
552
|
+
const job = this.deps.storage.insertJob({ chatId: this.chatId, requestText: text });
|
|
553
|
+
this.queue.push({ jobId: job.job_id, requestText: text });
|
|
554
|
+
this.broadcast("queued", { jobId: job.job_id });
|
|
555
|
+
this.kick().catch((err) => this.deps.logger.error({ msg: "web chat kick failed", err }));
|
|
556
|
+
return sendJson(res, 200, { ok: true, jobId: job.job_id });
|
|
557
|
+
}
|
|
558
|
+
async handleStatus(_req, res) {
|
|
559
|
+
const running = this.deps.storage.getRunningJob(this.chatId);
|
|
560
|
+
const queued = this.deps.storage.countQueuedJobs(this.chatId);
|
|
561
|
+
const chat = this.deps.storage.getChat(this.chatId);
|
|
562
|
+
return sendJson(res, 200, {
|
|
563
|
+
ok: true,
|
|
564
|
+
chatId: this.chatId,
|
|
565
|
+
running: running ? running.job_id : null,
|
|
566
|
+
queued,
|
|
567
|
+
workspace: chat?.workspace_path ?? null
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async handleCancel(_req, res) {
|
|
571
|
+
if (!this.active)
|
|
572
|
+
return sendJson(res, 200, { ok: true, message: "Nothing to cancel." });
|
|
573
|
+
this.active.cancel();
|
|
574
|
+
return sendJson(res, 200, { ok: true, message: `Cancel requested: ${this.active.jobId}` });
|
|
575
|
+
}
|
|
576
|
+
isBusy() {
|
|
577
|
+
return !!this.active || this.queue.length > 0 || this.deps.storage.getRunningJob(this.chatId) !== undefined;
|
|
578
|
+
}
|
|
579
|
+
async handleNew(_req, res) {
|
|
580
|
+
if (this.isBusy())
|
|
581
|
+
return sendJson(res, 409, { ok: false, error: "Busy: cancel the running job first." });
|
|
582
|
+
const cfg = this.deps.getConfig();
|
|
583
|
+
const s = (0, sessions_1.createNewSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
|
|
584
|
+
this.broadcast("info", { msg: "new session", workspace: s.workspacePath });
|
|
585
|
+
return sendJson(res, 200, { ok: true, workspacePath: s.workspacePath });
|
|
586
|
+
}
|
|
587
|
+
async handleReset(req, res) {
|
|
588
|
+
const body = await readJsonBody(req, 32 * 1024);
|
|
589
|
+
const b = typeof body === "object" && body !== null ? body : {};
|
|
590
|
+
const confirm = Boolean(b.confirm);
|
|
591
|
+
if (!confirm)
|
|
592
|
+
return sendJson(res, 400, { ok: false, error: "Confirm reset by sending {confirm:true}" });
|
|
593
|
+
if (this.isBusy())
|
|
594
|
+
return sendJson(res, 409, { ok: false, error: "Busy: cancel the running job first." });
|
|
595
|
+
const cfg = this.deps.getConfig();
|
|
596
|
+
const s = (0, sessions_1.resetSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
|
|
597
|
+
this.broadcast("info", { msg: "session reset", workspace: s.workspacePath });
|
|
598
|
+
return sendJson(res, 200, { ok: true, workspacePath: s.workspacePath });
|
|
599
|
+
}
|
|
600
|
+
async kick() {
|
|
601
|
+
if (this.processing)
|
|
602
|
+
return;
|
|
603
|
+
this.processing = true;
|
|
604
|
+
try {
|
|
605
|
+
while (this.queue.length > 0) {
|
|
606
|
+
const item = this.queue.shift();
|
|
607
|
+
const release = await this.deps.semaphore.acquire();
|
|
608
|
+
try {
|
|
609
|
+
await this.runOne(item);
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
release();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
this.processing = false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async runOne(item) {
|
|
621
|
+
const cfg = this.deps.getConfig();
|
|
622
|
+
const session = (0, sessions_1.getOrCreateSessionByIds)(this.deps.storage, cfg, this.chatId, this.userId);
|
|
623
|
+
const logger = this.deps.logger.child({ channel: "web", jobId: item.jobId });
|
|
624
|
+
this.deps.storage.setJobRunning(item.jobId);
|
|
625
|
+
this.broadcast("start", { jobId: item.jobId, prompt: item.requestText });
|
|
626
|
+
let assistantSoFar = "";
|
|
627
|
+
const running = this.deps.runClaudeImpl({
|
|
628
|
+
cfg,
|
|
629
|
+
cwd: session.workspacePath,
|
|
630
|
+
prompt: item.requestText,
|
|
631
|
+
logger,
|
|
632
|
+
onTextDelta: (delta) => {
|
|
633
|
+
assistantSoFar += delta;
|
|
634
|
+
this.broadcast("delta", { jobId: item.jobId, delta });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
this.active = { jobId: item.jobId, cancel: running.cancel };
|
|
638
|
+
let status = "succeeded";
|
|
639
|
+
let error = null;
|
|
640
|
+
let exitCode = null;
|
|
641
|
+
let stderr = "";
|
|
642
|
+
try {
|
|
643
|
+
const res = await running.promise;
|
|
644
|
+
exitCode = res.exitCode;
|
|
645
|
+
stderr = res.stderr;
|
|
646
|
+
if (res.timedOut) {
|
|
647
|
+
status = "timeout";
|
|
648
|
+
error = "timeout";
|
|
649
|
+
}
|
|
650
|
+
else if (res.canceled) {
|
|
651
|
+
status = "canceled";
|
|
652
|
+
error = "canceled";
|
|
653
|
+
}
|
|
654
|
+
else if (res.exitCode !== 0) {
|
|
655
|
+
status = "failed";
|
|
656
|
+
error = `exit_code=${res.exitCode}`;
|
|
657
|
+
}
|
|
658
|
+
if (!assistantSoFar)
|
|
659
|
+
assistantSoFar = res.assistantText;
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
status = "failed";
|
|
663
|
+
error = err instanceof Error ? err.message : "unknown error";
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
this.active = undefined;
|
|
667
|
+
}
|
|
668
|
+
logger.info({ msg: "web chat job finished", status, exitCode });
|
|
669
|
+
if (stderr)
|
|
670
|
+
logger.debug({ msg: "claude stderr", stderr });
|
|
671
|
+
this.deps.storage.setJobFinished({ jobId: item.jobId, status, responseText: assistantSoFar, error, exitCode });
|
|
672
|
+
this.broadcast("done", { jobId: item.jobId, status, error });
|
|
673
|
+
this.sendStatus();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
async function startWebServer(opts) {
|
|
677
|
+
// Local mutable config; we don't hot-reload, but UI should reflect last saved values.
|
|
678
|
+
let cfg = opts.config;
|
|
679
|
+
const deps = {
|
|
680
|
+
exit: opts.deps?.exit ?? ((code) => process.exit(code)),
|
|
681
|
+
telegramGetMe: opts.deps?.telegramGetMe ?? (async (token) => {
|
|
682
|
+
try {
|
|
683
|
+
const url = `https://api.telegram.org/bot${token}/getMe`;
|
|
684
|
+
const res = await fetch(url, { method: "GET" });
|
|
685
|
+
const json = (await res.json().catch(() => null));
|
|
686
|
+
if (!res.ok)
|
|
687
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
688
|
+
if (!json || typeof json !== "object")
|
|
689
|
+
return { ok: false, error: "Invalid response" };
|
|
690
|
+
if (json.ok !== true)
|
|
691
|
+
return { ok: false, error: json.description ? String(json.description) : "Telegram API error" };
|
|
692
|
+
return { ok: true, result: json.result };
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
return { ok: false, error: err instanceof Error ? err.message : "Network error" };
|
|
696
|
+
}
|
|
697
|
+
}),
|
|
698
|
+
runClaude: opts.deps?.runClaude ?? runner_1.runClaude
|
|
699
|
+
};
|
|
700
|
+
const adminPassword = (cfg.web?.admin_password ?? "").trim();
|
|
701
|
+
if (!adminPassword)
|
|
702
|
+
throw new Error("web.admin_password is required to start web UI");
|
|
703
|
+
const bind = (cfg.web?.bind ?? "0.0.0.0").trim() || "0.0.0.0";
|
|
704
|
+
const port = typeof cfg.web?.port === "number" && Number.isFinite(cfg.web.port) ? cfg.web.port : 17800;
|
|
705
|
+
const chat = new WebChat({
|
|
706
|
+
getConfig: () => cfg,
|
|
707
|
+
logger: opts.logger,
|
|
708
|
+
storage: opts.storage,
|
|
709
|
+
semaphore: opts.semaphore,
|
|
710
|
+
runClaudeImpl: deps.runClaude
|
|
711
|
+
});
|
|
712
|
+
const server = node_http_1.default.createServer(async (req, res) => {
|
|
713
|
+
try {
|
|
714
|
+
if (!isAuthorized(req, adminPassword))
|
|
715
|
+
return unauthorized(res);
|
|
716
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
717
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
718
|
+
if (method === "GET" && url.pathname === "/") {
|
|
719
|
+
return sendHtml(res, 200, renderIndexHtml());
|
|
720
|
+
}
|
|
721
|
+
if (method === "GET" && url.pathname === "/chat") {
|
|
722
|
+
return chat.handleChatPage(res);
|
|
723
|
+
}
|
|
724
|
+
if (method === "GET" && url.pathname === "/api/chat/stream") {
|
|
725
|
+
chat.handleStream(req, res);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (method === "POST" && url.pathname === "/api/chat/send") {
|
|
729
|
+
return await chat.handleSend(req, res);
|
|
730
|
+
}
|
|
731
|
+
if (method === "GET" && url.pathname === "/api/chat/status") {
|
|
732
|
+
return await chat.handleStatus(req, res);
|
|
733
|
+
}
|
|
734
|
+
if (method === "POST" && url.pathname === "/api/chat/cancel") {
|
|
735
|
+
return await chat.handleCancel(req, res);
|
|
736
|
+
}
|
|
737
|
+
if (method === "POST" && url.pathname === "/api/chat/new") {
|
|
738
|
+
return await chat.handleNew(req, res);
|
|
739
|
+
}
|
|
740
|
+
if (method === "POST" && url.pathname === "/api/chat/reset") {
|
|
741
|
+
return await chat.handleReset(req, res);
|
|
742
|
+
}
|
|
743
|
+
if (method === "GET" && url.pathname === "/api/settings") {
|
|
744
|
+
return sendJson(res, 200, {
|
|
745
|
+
telegramTokenPresent: Boolean(cfg.telegram.token && cfg.telegram.token.trim()),
|
|
746
|
+
telegramTokenMasked: maskSecret(cfg.telegram.token ?? ""),
|
|
747
|
+
telegramAdminUserIdsCsv: (cfg.telegram.admin_user_ids ?? []).join(","),
|
|
748
|
+
telegramAllowlistUserIdsCsv: (cfg.telegram.allowlist?.user_ids ?? []).join(","),
|
|
749
|
+
claudeBinary: cfg.claude.binary ?? "claude",
|
|
750
|
+
loggingLevel: cfg.logging.level ?? "info",
|
|
751
|
+
loggingJson: Boolean(cfg.logging.json)
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
if (method === "GET" && url.pathname === "/api/secrets/telegram-token") {
|
|
755
|
+
return sendJson(res, 200, { token: cfg.telegram.token ?? "" });
|
|
756
|
+
}
|
|
757
|
+
if (method === "POST" && url.pathname === "/api/settings") {
|
|
758
|
+
const body = await readJsonBody(req, 256 * 1024);
|
|
759
|
+
const b = typeof body === "object" && body !== null ? body : {};
|
|
760
|
+
const token = typeof b.telegramToken === "string" ? b.telegramToken.trim() : "";
|
|
761
|
+
if (!token)
|
|
762
|
+
return sendJson(res, 400, { error: "telegramToken is required" });
|
|
763
|
+
const adminIds = parseCsvNumbersBestEffort(b.telegramAdminUserIdsCsv);
|
|
764
|
+
const allowIds = parseCsvNumbersBestEffort(b.telegramAllowlistUserIdsCsv);
|
|
765
|
+
if (adminIds.length === 0 && allowIds.length === 0) {
|
|
766
|
+
return sendJson(res, 400, { error: "At least one admin user id (or allowlist user id) is required" });
|
|
767
|
+
}
|
|
768
|
+
const claudeBinary = typeof b.claudeBinary === "string" ? b.claudeBinary.trim() : "";
|
|
769
|
+
if (!claudeBinary)
|
|
770
|
+
return sendJson(res, 400, { error: "claudeBinary is required" });
|
|
771
|
+
const loggingLevel = typeof b.loggingLevel === "string" ? b.loggingLevel.trim() : "info";
|
|
772
|
+
const allowedLevels = new Set(["fatal", "error", "warn", "info", "debug", "trace"]);
|
|
773
|
+
if (!allowedLevels.has(loggingLevel))
|
|
774
|
+
return sendJson(res, 400, { error: "Invalid loggingLevel" });
|
|
775
|
+
const loggingJson = Boolean(b.loggingJson);
|
|
776
|
+
cfg = {
|
|
777
|
+
...cfg,
|
|
778
|
+
telegram: { ...cfg.telegram, token, admin_user_ids: adminIds, allowlist: { user_ids: allowIds } },
|
|
779
|
+
claude: { ...cfg.claude, binary: claudeBinary },
|
|
780
|
+
logging: { ...cfg.logging, level: loggingLevel, json: loggingJson }
|
|
781
|
+
};
|
|
782
|
+
// Generate full YAML (overwrite; no comment preservation).
|
|
783
|
+
const raw = yaml_1.default.stringify(cfg);
|
|
784
|
+
writeYamlAtomic(opts.configPath, raw);
|
|
785
|
+
return sendJson(res, 200, { ok: true, message: "Saved. Restart the service to apply changes." });
|
|
786
|
+
}
|
|
787
|
+
if (method === "POST" && url.pathname === "/api/doctor") {
|
|
788
|
+
const checks = [
|
|
789
|
+
...(0, doctor_1.checkConfig)(opts.configPath),
|
|
790
|
+
...(0, doctor_1.checkDirs)({ dataDir: cfg.runtime.data_dir, workspacesDir: cfg.runtime.workspaces_dir, uploadsDir: cfg.runtime.uploads_dir }),
|
|
791
|
+
(0, doctor_1.checkSqlite)(cfg.runtime.data_dir),
|
|
792
|
+
(0, doctor_1.checkClaude)(cfg.claude.binary ?? "claude", process.env)
|
|
793
|
+
];
|
|
794
|
+
const ok = checks.every((c) => c.ok);
|
|
795
|
+
return sendJson(res, 200, { ok, checks });
|
|
796
|
+
}
|
|
797
|
+
if (method === "POST" && url.pathname === "/api/telegram/check-token") {
|
|
798
|
+
const token = (cfg.telegram.token ?? "").trim();
|
|
799
|
+
if (!token)
|
|
800
|
+
return sendJson(res, 400, { ok: false, error: "telegram.token is empty" });
|
|
801
|
+
const out = await deps.telegramGetMe(token);
|
|
802
|
+
if (!out.ok)
|
|
803
|
+
return sendJson(res, 502, { ok: false, error: out.error ?? "Telegram API error" });
|
|
804
|
+
return sendJson(res, 200, { ok: true, result: out.result });
|
|
805
|
+
}
|
|
806
|
+
if (method === "POST" && url.pathname === "/api/restart") {
|
|
807
|
+
sendJson(res, 200, { ok: true, message: "Restarting..." });
|
|
808
|
+
// Allow the response to flush before exiting.
|
|
809
|
+
setTimeout(() => deps.exit(0), 250);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
sendJson(res, 404, { error: "Not found" });
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
// Never include secrets in errors; keep generic.
|
|
816
|
+
const msg = err instanceof Error ? err.message : "Internal error";
|
|
817
|
+
opts.logger.error({ msg: "web request failed", err: msg });
|
|
818
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
await new Promise((resolve, reject) => {
|
|
822
|
+
server.once("error", reject);
|
|
823
|
+
server.listen(port, bind, () => resolve());
|
|
824
|
+
});
|
|
825
|
+
const addr = server.address();
|
|
826
|
+
const baseUrl = typeof addr === "object" && addr ? `http://${bind}:${addr.port}` : `http://${bind}:${port}`;
|
|
827
|
+
opts.logger.info({ msg: "web ui started", bind, port });
|
|
828
|
+
return {
|
|
829
|
+
baseUrl,
|
|
830
|
+
close: async () => {
|
|
831
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|