@inetafrica/open-claudia 3.0.12 → 3.0.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.13 — chat with the bot from its own dashboard
4
+
5
+ - **The web dashboard gains a Chat tab — a real channel, not a side door.** A new in-process `WebAdapter` (`channels/web/`) registers as a first-class channel whenever the web UI is up: messages typed in the dashboard flow through the same router, identity, packs, transcripts, and approval pipeline as Telegram/Kazee/voice, and the bot's replies (including inline approval buttons, files, voice notes, live typing) stream back over `/api/chat/stream` (SSE with history replay on connect). New session-gated routes: `GET /api/chat/stream`, `POST /api/chat/send`, `POST /api/chat/action`, `GET /api/chat/media/<id>`. The channel is fixed single-owner (`web-owner`): the dashboard session already gates every call, so `access.js`/`identity.js` authorize it as the owner and — under unified identity — it resolves to the owner's canonical brain like every other owner channel.
6
+ - **Platform SSO: `POST /api/otp/mint` (control-token only).** AgentSpace can mint a one-time magic-link token (`/otp/<token>`, 10-min TTL, single-use — the existing `core/web-otp` machinery behind `/dashboard`) over `BOT_CONTROL_TOKEN`. Dashboard sessions get a 403: they are already logged in and gain nothing, so the surface stays strictly machine-to-machine.
7
+ - **SSE unsubscribe bug fixed before it shipped.** Cleanup was hooked on `req.on("close")`, which in modern Node fires as soon as the GET body completes — every stream subscriber was torn down instantly after the history frame. Cleanup now hooks `res.on("close")` (actual connection teardown), verified by an end-to-end live-frame test.
8
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up only on their next approved image upgrade; new pods get it immediately. `channels/` already ships in `files`, so the adapter rides the existing package layout.
9
+
3
10
  ## v3.0.10 — Kazee-only pods are first-class
4
11
 
5
12
  - **/doctor no longer demands Telegram env on a Kazee-only install.** `health.js` required `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` unconditionally — a pod provisioned with `CHANNELS=kazee` failed its env check despite being perfectly configured. Required keys are now derived from `CHANNELS` (default `telegram`, preserving legacy behaviour): telegram installs require the Telegram pair, kazee installs require `KAZEE_BOT_TOKEN`, and `WORKSPACE` stays universal.
package/bot.js CHANGED
@@ -271,6 +271,10 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
271
271
  if (process.env.WEB_UI === "true") {
272
272
  try {
273
273
  require("./web.js").startWebServer();
274
+ // Dashboard chat rides a real channel adapter so it gets the full
275
+ // pipeline (identity, packs, transcripts, approval buttons).
276
+ const webChat = await registry.addAdapter({ type: "web", id: "web" }, { publicCommands: publicCommands() });
277
+ if (!webChat.ok) console.error("Web chat adapter failed:", webChat.error);
274
278
  } catch (e) {
275
279
  console.error("Web UI start failed:", e.message);
276
280
  }
@@ -0,0 +1,263 @@
1
+ // WebAdapter — the dashboard chat channel.
2
+ //
3
+ // Unlike Telegram/Kazee/Voice this adapter owns no transport: web.js runs
4
+ // in the same process and feeds it directly. Inbound: postText/postAction
5
+ // called by the /api/chat endpoints (session- or control-token-gated).
6
+ // Outbound: send/edit/typing broadcast frames to subscribed SSE clients
7
+ // and into a small in-memory history ring so a page reload can replay the
8
+ // conversation since boot. Everything routes through the same core
9
+ // handlers as the other channels, so web chat has the full agent
10
+ // capability set — including approval buttons.
11
+ //
12
+ // Security: single-owner channel. web.js only calls in after its own auth
13
+ // (dashboard session cookie or BOT_CONTROL_TOKEN), so every envelope
14
+ // carries the fixed owner id and access.js authorizes it as the owner.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const crypto = require("crypto");
19
+ const { canonicalForChannel } = require("../../core/identity");
20
+ const { inlineKeyboardToPortable } = require("../types");
21
+
22
+ const HISTORY_LIMIT = 200;
23
+ const MEDIA_TTL_MS = 60 * 60 * 1000;
24
+
25
+ const WEB_OWNER_ID = "web-owner";
26
+
27
+ class WebAdapter {
28
+ constructor({ id = "web" } = {}) {
29
+ this.id = id;
30
+ this.type = "web";
31
+ this.ownerUserId = WEB_OWNER_ID;
32
+ // Single conversation: the channel id is the owner identity.
33
+ this.channelId = WEB_OWNER_ID;
34
+ this._listeners = { message: new Set(), action: new Set() };
35
+ this._subscribers = new Set();
36
+ this._history = [];
37
+ this._media = new Map(); // id -> { path, mime, fileName, keep, expires }
38
+ this._commands = [];
39
+ this._sweepTimer = null;
40
+ }
41
+
42
+ on(event, fn) {
43
+ if (!this._listeners[event]) return () => {};
44
+ this._listeners[event].add(fn);
45
+ return () => this._listeners[event].delete(fn);
46
+ }
47
+
48
+ _emit(event, envelope) {
49
+ for (const fn of this._listeners[event] || []) {
50
+ try { Promise.resolve(fn(envelope)).catch((e) => console.error(`web ${event} handler:`, e.message)); }
51
+ catch (e) { console.error(`web ${event} handler:`, e.message); }
52
+ }
53
+ }
54
+
55
+ async start() {
56
+ this._sweepTimer = setInterval(() => this._sweepMedia(), 5 * 60 * 1000);
57
+ if (this._sweepTimer.unref) this._sweepTimer.unref();
58
+ }
59
+
60
+ async stop() {
61
+ if (this._sweepTimer) clearInterval(this._sweepTimer);
62
+ this._subscribers.clear();
63
+ }
64
+
65
+ // ── inbound (called by web.js /api/chat routes) ─────────────────
66
+
67
+ postText(text) {
68
+ const body = String(text || "");
69
+ if (!body.trim()) return null;
70
+ const messageId = this._mkId("t");
71
+ // Echo into history/stream so every connected tab sees the user's turn.
72
+ this._pushFrame({ kind: "message", role: "user", messageId, text: body, ts: Date.now() });
73
+ this._emit("message", {
74
+ adapter: this,
75
+ channelId: this.channelId,
76
+ canonicalUserId: canonicalForChannel("web", this.channelId),
77
+ userId: this.ownerUserId,
78
+ type: body.trim().startsWith("/") ? "command" : "text",
79
+ text: body,
80
+ messageId,
81
+ from: { id: this.ownerUserId, name: "Owner", username: "" },
82
+ raw: { source: "web" },
83
+ });
84
+ return messageId;
85
+ }
86
+
87
+ postAction({ id, payload, messageId } = {}) {
88
+ const p = String(payload != null ? payload : (id != null ? id : ""));
89
+ if (!p) return null;
90
+ const callbackId = this._mkId("cb");
91
+ this._emit("action", {
92
+ adapter: this,
93
+ channelId: this.channelId,
94
+ canonicalUserId: canonicalForChannel("web", this.channelId),
95
+ userId: this.ownerUserId,
96
+ type: "action",
97
+ messageId,
98
+ from: { id: this.ownerUserId, name: "Owner", username: "" },
99
+ action: {
100
+ buttonId: String(id != null ? id : p),
101
+ payload: p,
102
+ sourceMessageId: messageId,
103
+ callbackId,
104
+ },
105
+ raw: { source: "web" },
106
+ });
107
+ return callbackId;
108
+ }
109
+
110
+ // ── stream plumbing (web.js SSE endpoint) ───────────────────────
111
+
112
+ subscribe(fn) {
113
+ this._subscribers.add(fn);
114
+ return () => this._subscribers.delete(fn);
115
+ }
116
+
117
+ history() {
118
+ return this._history.slice();
119
+ }
120
+
121
+ getMedia(id) {
122
+ const entry = this._media.get(id);
123
+ if (!entry || !fs.existsSync(entry.path)) return null;
124
+ return entry;
125
+ }
126
+
127
+ _pushFrame(frame) {
128
+ this._history.push(frame);
129
+ if (this._history.length > HISTORY_LIMIT) this._history.splice(0, this._history.length - HISTORY_LIMIT);
130
+ for (const fn of this._subscribers) {
131
+ try { fn(frame); } catch (e) {}
132
+ }
133
+ }
134
+
135
+ // ── outbound contract (called by core/io.js) ────────────────────
136
+
137
+ _normalizeKeyboard(keyboard) {
138
+ if (!keyboard) return null;
139
+ if (keyboard.buttons) return keyboard.buttons;
140
+ if (keyboard.inline_keyboard) return inlineKeyboardToPortable(keyboard.inline_keyboard);
141
+ return null;
142
+ }
143
+
144
+ async send(channelId, text, opts = {}) {
145
+ const messageId = this._mkId("a");
146
+ this._pushFrame({
147
+ kind: "message",
148
+ role: "assistant",
149
+ messageId,
150
+ text: text || "",
151
+ buttons: this._normalizeKeyboard(opts.keyboard),
152
+ replyTo: opts.replyTo || null,
153
+ ts: Date.now(),
154
+ });
155
+ return messageId;
156
+ }
157
+
158
+ async edit(channelId, messageId, text, opts = {}) {
159
+ const frame = {
160
+ kind: "edit",
161
+ messageId,
162
+ text: text || "",
163
+ buttons: this._normalizeKeyboard(opts.keyboard),
164
+ ts: Date.now(),
165
+ };
166
+ // Keep history coherent: apply the edit to the stored message so a
167
+ // reload shows the final text, not the first streamed fragment.
168
+ const original = this._history.find((f) => f.kind === "message" && f.messageId === messageId);
169
+ if (original) { original.text = frame.text; original.buttons = frame.buttons; }
170
+ this._pushFrame(frame);
171
+ }
172
+
173
+ async delete(channelId, messageId) {
174
+ // Prune the message AND its edit frames so a reload replays nothing stale.
175
+ this._history = this._history.filter(
176
+ (f) => !((f.kind === "message" || f.kind === "edit") && f.messageId === messageId)
177
+ );
178
+ this._pushFrame({ kind: "delete", messageId, ts: Date.now() });
179
+ }
180
+
181
+ async sendVoice(channelId, audioPath) {
182
+ try {
183
+ const id = this._registerMedia(audioPath, this._guessMime(audioPath), path.basename(audioPath));
184
+ this._pushFrame({ kind: "voice", messageId: this._mkId("v"), url: `/api/chat/media/${id}`, ts: Date.now() });
185
+ return true;
186
+ } catch (e) {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ async sendPhoto(channelId, filePath, caption) { return this.sendFile(channelId, filePath, caption); }
192
+
193
+ async sendFile(channelId, filePath, caption) {
194
+ try {
195
+ const fileName = path.basename(filePath);
196
+ const id = this._registerMedia(filePath, this._guessMime(fileName), fileName, /* keep */ true);
197
+ this._pushFrame({
198
+ kind: "file",
199
+ messageId: this._mkId("f"),
200
+ url: `/api/chat/media/${id}`,
201
+ fileName,
202
+ caption: caption || "",
203
+ ts: Date.now(),
204
+ });
205
+ return true;
206
+ } catch (e) {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ async typing(channelId) {
212
+ // Ephemeral — streamed to live clients but never recorded in history.
213
+ for (const fn of this._subscribers) {
214
+ try { fn({ kind: "typing", ts: Date.now() }); } catch (e) {}
215
+ }
216
+ }
217
+
218
+ async downloadMedia(media) {
219
+ if (!media) return null;
220
+ return media.fileId || null;
221
+ }
222
+
223
+ async registerCommands(commands) {
224
+ this._commands = (commands || [])
225
+ .filter((c) => c && c.name)
226
+ .map((c) => ({ name: String(c.name).replace(/^\//, ""), description: String(c.description || ""), args: typeof c.args === "string" ? c.args : "" }));
227
+ }
228
+
229
+ // ── media store ─────────────────────────────────────────────────
230
+
231
+ _registerMedia(filePath, mime, fileName, keep = false) {
232
+ const id = crypto.randomBytes(9).toString("hex");
233
+ this._media.set(id, { path: filePath, mime, fileName, keep, expires: Date.now() + MEDIA_TTL_MS });
234
+ return id;
235
+ }
236
+
237
+ _sweepMedia() {
238
+ const now = Date.now();
239
+ for (const [id, entry] of this._media) {
240
+ if (entry.expires <= now) {
241
+ this._media.delete(id);
242
+ if (!entry.keep) { try { fs.unlinkSync(entry.path); } catch (e) {} }
243
+ }
244
+ }
245
+ }
246
+
247
+ // ── helpers ─────────────────────────────────────────────────────
248
+
249
+ _mkId(prefix) { return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; }
250
+
251
+ _guessMime(fileName) {
252
+ const ext = path.extname(fileName).toLowerCase();
253
+ const map = {
254
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
255
+ ".webp": "image/webp", ".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".m4a": "audio/mp4",
256
+ ".wav": "audio/wav", ".mp4": "video/mp4", ".pdf": "application/pdf", ".txt": "text/plain",
257
+ ".json": "application/json", ".csv": "text/csv",
258
+ };
259
+ return map[ext] || "application/octet-stream";
260
+ }
261
+ }
262
+
263
+ module.exports = { WebAdapter, WEB_OWNER_ID };
package/core/access.js CHANGED
@@ -17,6 +17,10 @@ function transportOwnerUserId(transport) {
17
17
  // The voice channel is single-owner: the bearer token gates the connection,
18
18
  // and every envelope carries this fixed owner id, so it authorizes as owner.
19
19
  if (transport === "voice") return config.VOICE_OWNER_USER_ID || "voice-owner";
20
+ // The web channel is single-owner: the dashboard session (password or
21
+ // magic-link) gates every call into the adapter, so its fixed owner id
22
+ // authorizes as owner.
23
+ if (transport === "web") return "web-owner";
20
24
  return "";
21
25
  }
22
26
 
@@ -13,6 +13,7 @@ const { setAdapters } = require("./scheduler");
13
13
  const { TelegramAdapter } = require("../channels/telegram/adapter");
14
14
  const { KazeeAdapter } = require("../channels/kazee/adapter");
15
15
  const { VoiceAdapter } = require("../channels/voice/adapter");
16
+ const { WebAdapter } = require("../channels/web/adapter");
16
17
 
17
18
  const adapters = [];
18
19
  let messageHandler = null;
@@ -22,6 +23,7 @@ function createAdapter(spec) {
22
23
  if (spec.type === "telegram") return new TelegramAdapter({ id: spec.id, ...spec.opts });
23
24
  if (spec.type === "kazee") return new KazeeAdapter({ id: spec.id, ...spec.opts });
24
25
  if (spec.type === "voice") return new VoiceAdapter({ id: spec.id, ...spec.opts });
26
+ if (spec.type === "web") return new WebAdapter({ id: spec.id, ...spec.opts });
25
27
  console.error(`Unknown adapter type: ${spec.type}`);
26
28
  return null;
27
29
  }
package/core/identity.js CHANGED
@@ -47,6 +47,9 @@ function isConfiguredOwnerChannel(transport, channelId) {
47
47
  if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
48
48
  if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
49
49
  if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
50
+ // Web dashboard chat: fixed single-owner channel gated by the dashboard
51
+ // session, so its identity is operator-implied rather than env-declared.
52
+ if (t === "web") return c === "web-owner";
50
53
  return false;
51
54
  }
52
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.12",
3
+ "version": "3.0.13",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,12 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.12 approved by Sumeet 2026-07-13 ("Yes" to the platform unification
36
- // epic): dashboard auth hardening (no forced password change, login
37
- // rate-limiting + lockout, constant-time compares, Secure cookie),
38
- // channel-less boot with the web UI up, and live `channels` in /api/status
39
- // for AgentSpace channel chips.
40
- assert.strictEqual(pkg.version, "3.0.12", "release version must remain unchanged without explicit approval");
35
+ // 3.0.13 approved by Sumeet 2026-07-13 ("ok please proceed... once done
36
+ // please push it out" on the agent-space P0–P3 plan): web chat channel —
37
+ // in-process WebAdapter, /api/chat SSE + send/action/media routes, Chat tab
38
+ // in the dashboard, and a control-token-only /api/otp/mint for AgentSpace
39
+ // magic-link SSO.
40
+ assert.strictEqual(pkg.version, "3.0.13", "release version must remain unchanged without explicit approval");
41
41
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
42
42
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
43
43
  }
package/web.js CHANGED
@@ -664,6 +664,93 @@ async function handleAPI(req, res, body, dependencies = {}) {
664
664
  }));
665
665
  }
666
666
 
667
+ // ── Chat (dashboard ↔ bot, via the in-process web channel adapter) ──
668
+ if (url.startsWith("/api/chat/")) {
669
+ let webChat = null;
670
+ try { webChat = require("./core/adapter-registry").findAdapter("web"); } catch (e) {}
671
+ if (!webChat) {
672
+ res.writeHead(503, { "Content-Type": "application/json" });
673
+ return res.end(JSON.stringify({ error: "Chat unavailable — bot not running in this process" }));
674
+ }
675
+
676
+ // Live event stream. Replays history on connect, then pushes frames as
677
+ // the bot replies (message / edit / delete / typing / file / voice).
678
+ if (url.startsWith("/api/chat/stream") && req.method === "GET") {
679
+ res.writeHead(200, {
680
+ "Content-Type": "text/event-stream",
681
+ "Cache-Control": "no-cache",
682
+ "Connection": "keep-alive",
683
+ "X-Accel-Buffering": "no",
684
+ });
685
+ res.write(`data: ${JSON.stringify({ kind: "history", frames: webChat.history() })}\n\n`);
686
+ const unsubscribe = webChat.subscribe((frame) => {
687
+ try { res.write(`data: ${JSON.stringify(frame)}\n\n`); } catch (e) {}
688
+ });
689
+ const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch (e) {} }, 25000);
690
+ // res "close" = connection torn down. (req "close" fires as soon as the
691
+ // GET body completes in modern Node, which would unsubscribe instantly.)
692
+ res.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
693
+ return;
694
+ }
695
+
696
+ if (url === "/api/chat/send" && req.method === "POST") {
697
+ let text = "";
698
+ try { ({ text } = JSON.parse(body || "{}")); } catch (e) {}
699
+ if (!text || !String(text).trim()) {
700
+ res.writeHead(400, { "Content-Type": "application/json" });
701
+ return res.end(JSON.stringify({ error: "Empty message" }));
702
+ }
703
+ const messageId = webChat.postText(String(text));
704
+ res.writeHead(202, { "Content-Type": "application/json" });
705
+ return res.end(JSON.stringify({ ok: true, messageId }));
706
+ }
707
+
708
+ if (url === "/api/chat/action" && req.method === "POST") {
709
+ let parsed = {};
710
+ try { parsed = JSON.parse(body || "{}"); } catch (e) {}
711
+ const callbackId = webChat.postAction(parsed);
712
+ if (!callbackId) {
713
+ res.writeHead(400, { "Content-Type": "application/json" });
714
+ return res.end(JSON.stringify({ error: "Missing action payload" }));
715
+ }
716
+ res.writeHead(202, { "Content-Type": "application/json" });
717
+ return res.end(JSON.stringify({ ok: true, callbackId }));
718
+ }
719
+
720
+ if (url.startsWith("/api/chat/media/") && req.method === "GET") {
721
+ const id = url.slice("/api/chat/media/".length).split("?")[0];
722
+ const entry = webChat.getMedia(id);
723
+ if (!entry) {
724
+ res.writeHead(404, { "Content-Type": "application/json" });
725
+ return res.end(JSON.stringify({ error: "Expired or unknown media" }));
726
+ }
727
+ const stat = fs.statSync(entry.path);
728
+ res.writeHead(200, {
729
+ "Content-Type": entry.mime || "application/octet-stream",
730
+ "Content-Length": stat.size,
731
+ "Content-Disposition": `inline; filename="${(entry.fileName || "file").replace(/"/g, "")}"`,
732
+ });
733
+ return fs.createReadStream(entry.path).pipe(res);
734
+ }
735
+
736
+ res.writeHead(404, { "Content-Type": "application/json" });
737
+ return res.end(JSON.stringify({ error: "Unknown chat route" }));
738
+ }
739
+
740
+ // Platform SSO: mint a one-time magic-link token. Control-token only —
741
+ // this is a machine-to-machine surface for AgentSpace, never exposed to
742
+ // dashboard sessions (they are already logged in and gain nothing).
743
+ if (url === "/api/otp/mint" && req.method === "POST") {
744
+ if (!checkBearerAuth(req)) {
745
+ res.writeHead(403, { "Content-Type": "application/json" });
746
+ return res.end(JSON.stringify({ error: "Control token required" }));
747
+ }
748
+ const { mint } = require("./core/web-otp");
749
+ const { token, expiresAt } = mint();
750
+ res.writeHead(200, { "Content-Type": "application/json" });
751
+ return res.end(JSON.stringify({ ok: true, token, path: `/otp/${token}`, expiresAt }));
752
+ }
753
+
667
754
  // Change password (voluntary — never forced)
668
755
  if (url === "/api/password" && req.method === "POST") {
669
756
  const { current, newPassword } = JSON.parse(body);
@@ -764,6 +851,19 @@ function getHTML() {
764
851
  table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
765
852
  table.usage-table th { color: #888; font-weight: 500; }
766
853
  table.usage-table td:first-child { color: #e0e0e0; }
854
+ .chat-box { display: flex; flex-direction: column; height: calc(100vh - 210px); min-height: 320px; }
855
+ .chat-log { flex: 1; overflow-y: auto; padding: 4px 2px; display: flex; flex-direction: column; gap: 10px; }
856
+ .chat-bubble { max-width: 85%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
857
+ .chat-bubble.user { align-self: flex-end; background: #6366f1; color: #fff; border-bottom-right-radius: 4px; }
858
+ .chat-bubble.bot { align-self: flex-start; background: #1a1a1a; border: 1px solid #2a2a2a; border-bottom-left-radius: 4px; }
859
+ .chat-bubble .chat-btns { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
860
+ .chat-bubble .chat-btns button { padding: 6px 12px; font-size: 13px; background: #333; }
861
+ .chat-bubble .chat-btns button:hover { background: #444; }
862
+ .chat-bubble a { color: #a5b4fc; }
863
+ .chat-typing { align-self: flex-start; color: #888; font-size: 13px; padding: 4px 8px; }
864
+ .chat-input-row { display: flex; gap: 8px; margin-top: 12px; }
865
+ .chat-input-row textarea { min-height: 44px; max-height: 140px; resize: none; flex: 1; }
866
+ .chat-input-row button { align-self: flex-end; }
767
867
  </style>
768
868
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
769
869
  </head>
@@ -814,6 +914,7 @@ async function doLogin() {
814
914
  function render() {
815
915
  const tabs = [
816
916
  { id: "dashboard", label: "Dashboard" },
917
+ { id: "chat", label: "Chat" },
817
918
  { id: "usage", label: "Usage" },
818
919
  { id: "auth", label: "Users" },
819
920
  { id: "credentials", label: "Credentials" },
@@ -839,10 +940,116 @@ function render() {
839
940
  loadTab();
840
941
  }
841
942
 
842
- function switchTab(tab) { currentTab = tab; render(); }
943
+ function switchTab(tab) {
944
+ if (tab !== "chat" && window.chatES) { try { window.chatES.close(); } catch (e) {} window.chatES = null; }
945
+ currentTab = tab;
946
+ render();
947
+ }
948
+
949
+ // ── Chat tab ──
950
+ function chatEsc(s) {
951
+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
952
+ }
953
+
954
+ function chatFrameHTML(f) {
955
+ if (f.kind === "message") {
956
+ const cls = f.role === "user" ? "user" : "bot";
957
+ let inner = chatEsc(f.text);
958
+ if (f.buttons && f.buttons.length) {
959
+ inner += '<div class="chat-btns">' + f.buttons.map(function (b) {
960
+ return '<button onclick="chatAction(\\'' + chatEsc(b.id) + '\\',\\'' + chatEsc(b.payload || b.id) + '\\',\\'' + chatEsc(f.messageId) + '\\')">' + chatEsc(b.label) + '</button>';
961
+ }).join("") + '</div>';
962
+ }
963
+ return '<div class="chat-bubble ' + cls + '" data-mid="' + chatEsc(f.messageId) + '">' + inner + '</div>';
964
+ }
965
+ if (f.kind === "file") {
966
+ return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '">📎 <a href="' + chatEsc(f.url) + '" target="_blank" rel="noopener">' + chatEsc(f.fileName || "file") + '</a>' + (f.caption ? '<br>' + chatEsc(f.caption) : '') + '</div>';
967
+ }
968
+ if (f.kind === "voice") {
969
+ return '<div class="chat-bubble bot" data-mid="' + chatEsc(f.messageId) + '"><audio controls src="' + chatEsc(f.url) + '"></audio></div>';
970
+ }
971
+ return "";
972
+ }
973
+
974
+ function chatApplyFrame(f) {
975
+ const log = $("#chat-log");
976
+ if (!log) return;
977
+ const typingEl = $("#chat-typing");
978
+ if (f.kind === "typing") {
979
+ if (typingEl) typingEl.classList.remove("hidden");
980
+ clearTimeout(window.chatTypingTimer);
981
+ window.chatTypingTimer = setTimeout(function () { if ($("#chat-typing")) $("#chat-typing").classList.add("hidden"); }, 8000);
982
+ return;
983
+ }
984
+ if (typingEl && (f.kind === "message" || f.kind === "edit")) typingEl.classList.add("hidden");
985
+ if (f.kind === "edit" || f.kind === "delete") {
986
+ const target = log.querySelector('[data-mid="' + (window.CSS && CSS.escape ? CSS.escape(f.messageId) : f.messageId) + '"]');
987
+ if (f.kind === "delete") { if (target) target.remove(); return; }
988
+ if (target) {
989
+ target.outerHTML = chatFrameHTML({ kind: "message", role: "assistant", messageId: f.messageId, text: f.text, buttons: f.buttons });
990
+ }
991
+ log.scrollTop = log.scrollHeight;
992
+ return;
993
+ }
994
+ const html = chatFrameHTML(f);
995
+ if (!html) return;
996
+ typingEl.insertAdjacentHTML("beforebegin", html);
997
+ log.scrollTop = log.scrollHeight;
998
+ }
999
+
1000
+ function chatConnect() {
1001
+ if (window.chatES) { try { window.chatES.close(); } catch (e) {} }
1002
+ const es = new EventSource("/api/chat/stream");
1003
+ window.chatES = es;
1004
+ es.onmessage = function (ev) {
1005
+ let f;
1006
+ try { f = JSON.parse(ev.data); } catch (e) { return; }
1007
+ if (f.kind === "history") {
1008
+ const log = $("#chat-log");
1009
+ if (!log) return;
1010
+ log.innerHTML = f.frames.map(chatFrameHTML).join("") + '<div id="chat-typing" class="chat-typing hidden">thinking…</div>';
1011
+ log.scrollTop = log.scrollHeight;
1012
+ return;
1013
+ }
1014
+ chatApplyFrame(f);
1015
+ };
1016
+ es.onerror = function () {
1017
+ // EventSource auto-reconnects; a 401 (expired session) won't, so bounce
1018
+ // through the login screen on persistent failure.
1019
+ setTimeout(function () { if (window.chatES === es && es.readyState === EventSource.CLOSED) api("/api/status"); }, 3000);
1020
+ };
1021
+ }
1022
+
1023
+ async function chatSend() {
1024
+ const input = $("#chat-input");
1025
+ const text = input.value.trim();
1026
+ if (!text) return;
1027
+ input.value = "";
1028
+ input.style.height = "";
1029
+ await api("/api/chat/send", { method: "POST", body: { text: text } });
1030
+ }
1031
+
1032
+ async function chatAction(id, payload, messageId) {
1033
+ await api("/api/chat/action", { method: "POST", body: { id: id, payload: payload, messageId: messageId } });
1034
+ }
843
1035
 
844
1036
  async function loadTab() {
845
1037
  const el = $("#content");
1038
+ if (currentTab === "chat") {
1039
+ el.innerHTML =
1040
+ '<div class="chat-box">' +
1041
+ '<div id="chat-log" class="chat-log"><div id="chat-typing" class="chat-typing hidden">thinking…</div></div>' +
1042
+ '<div class="chat-input-row">' +
1043
+ '<textarea id="chat-input" placeholder="Message ${botName}…" rows="1"' +
1044
+ ' onkeydown="if(event.key===\\'Enter\\'&&!event.shiftKey){event.preventDefault();chatSend();}"' +
1045
+ ' oninput="this.style.height=\\'\\';this.style.height=Math.min(this.scrollHeight,140)+\\'px\\';"></textarea>' +
1046
+ '<button onclick="chatSend()">Send</button>' +
1047
+ '</div>' +
1048
+ '</div>';
1049
+ chatConnect();
1050
+ setTimeout(function () { $("#chat-input") && $("#chat-input").focus(); }, 100);
1051
+ return;
1052
+ }
846
1053
  if (currentTab === "dashboard") {
847
1054
  el.innerHTML = \`
848
1055
  <div class="card">