alvin-bot 4.4.7 → 4.5.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/CHANGELOG.md +88 -0
- package/dist/handlers/message.js +40 -0
- package/dist/services/broadcast.js +52 -0
- package/dist/tui/index.js +199 -45
- package/dist/web/server.js +108 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.5.1] — 2026-04-09
|
|
6
|
+
|
|
7
|
+
### 🐛 TUI Header Rendering Hotfix
|
|
8
|
+
|
|
9
|
+
**The header was appearing inline in the middle of the conversation after scrolling** — a follow-up bug to the 4.5.0 TUI fix. Reported from a live 4.5.0 Test MacBook session where the header popped up right after a long bot response.
|
|
10
|
+
|
|
11
|
+
**Root cause**: `redrawHeader()` in 4.5.0 used `\x1b[H` (move to top-left) + `\x1b[s`/`\x1b[u` (save/restore cursor) to update the header in place when cost/model/target changed. But `\x1b[H` resolves to the **current viewport top**, not the document top — and once the terminal has scrolled past the original header, the "viewport top" is somewhere in the middle of the conversation. So the header got re-rendered inline in the middle of the bot's output.
|
|
12
|
+
|
|
13
|
+
**Fix**: removed all `redrawHeader()` calls from mid-session code paths:
|
|
14
|
+
- `ws.on("open")` (connect): no redraw, header was already drawn at startup
|
|
15
|
+
- `ws.on("close")` (disconnect): no redraw, just the error message
|
|
16
|
+
- `case "done"` (after each bot response): no redraw (this was the primary bug site — it fired after every message)
|
|
17
|
+
- `case "model"` (model switch): no redraw, just a success info line
|
|
18
|
+
- `case "target tui|telegram"` (target switch): no redraw, just an info line
|
|
19
|
+
- `process.stdout.on("resize")`: no redraw, just re-renders the prompt line
|
|
20
|
+
|
|
21
|
+
The only remaining `redrawHeader()` call is inside `/clear`, which calls `console.clear()` first to wipe the whole buffer — the only context where an in-place redraw is safe.
|
|
22
|
+
|
|
23
|
+
The trade-off: the header no longer reflects live cost/model/target updates mid-session. You'll see the up-to-date values after the next `/clear` or on the next TUI start. In exchange, the conversation flow stays clean. A future release could add a proper status-line region using terminal scrolling regions if this becomes annoying.
|
|
24
|
+
|
|
25
|
+
## [4.5.0] — 2026-04-09
|
|
26
|
+
|
|
27
|
+
### 🐛 TUI Bug Fixes (critical — the old TUI was effectively unusable)
|
|
28
|
+
|
|
29
|
+
**Double-character echo fixed** — Every keystroke in `alvin-bot tui` appeared twice (typing `hello` showed as `hheelllloo`). Root cause: `src/tui/index.ts` called `process.stdin.setRawMode(false)` alongside `readline.createInterface({ terminal: true })`. readline with `terminal: true` already controls the tty mode for its own line editor; forcing cooked mode on top of that makes both the terminal AND readline echo every keystroke. Removed the explicit `setRawMode(false)` call and let readline manage the tty state itself.
|
|
30
|
+
|
|
31
|
+
**Cursor chaos fixed** — The old `redrawHeader()` function wrote `\x1b7` / `\x1b8` (save/restore cursor) escape sequences that raced with readline's internal cursor tracking, producing garbled output mid-stream. The header redraw is now a no-op during active streaming and uses readline's own `cursorTo`/`clearLine` helpers otherwise — cooperating with readline instead of fighting it.
|
|
32
|
+
|
|
33
|
+
**Prompt state machine consolidated** — `showPrompt()` was called at ~7 different places, each re-rendering the prompt at potentially racy moments. It is now the single source of truth and no-ops during streaming. Every helper (`printUser`, `printAssistantStart`, `printInfo`, `printError`, `printSuccess`, `printTool`) calls `clearCurrentLine()` first, so the input line is always cleanly wiped before output is written above it.
|
|
34
|
+
|
|
35
|
+
**Terminal resize handling** — Added `process.stdout.on("resize", …)` so the header redraws correctly when the user resizes the window (when safe).
|
|
36
|
+
|
|
37
|
+
### ✨ New Feature: Parallel Observation + Session Routing
|
|
38
|
+
|
|
39
|
+
**The big one.** Before 4.5.0, the TUI/Web-UI shared the exact same session as the Telegram bot (both keyed to `config.allowedUsers[0]`). That meant `/new` in the TUI wiped the Telegram history, and the TUI had no visibility into live Telegram activity. This release cleanly separates the two and adds live mirroring in both directions.
|
|
40
|
+
|
|
41
|
+
#### New in-process broadcast bus — `src/services/broadcast.ts`
|
|
42
|
+
|
|
43
|
+
A tiny typed `EventEmitter` with four event types:
|
|
44
|
+
- `user_msg` — a user sent a message on a platform (Telegram, WhatsApp, etc.)
|
|
45
|
+
- `response_start` — the bot started generating a response
|
|
46
|
+
- `response_delta` — a streaming text chunk
|
|
47
|
+
- `response_done` — the response is complete
|
|
48
|
+
|
|
49
|
+
Fire-and-forget, zero backpressure, no history retention. The Telegram handler (`src/handlers/message.ts`) emits these events around its normal processing. The web server subscribes once at module load and fan-outs to every connected WebSocket client as `mirror:*` messages. Platform-agnostic signature so WhatsApp/Signal can plug in later without architectural changes.
|
|
50
|
+
|
|
51
|
+
#### TUI session isolation
|
|
52
|
+
|
|
53
|
+
The TUI now owns its own session key, completely separate from the Telegram user's session:
|
|
54
|
+
|
|
55
|
+
- **Default**: `alvin-bot tui` → fresh ephemeral session `tui:ephemeral:<timestamp>`. Every TUI start is a clean slate.
|
|
56
|
+
- **Persistent**: `alvin-bot tui --resume` → resumes `tui:local`, a long-lived session that survives TUI restarts.
|
|
57
|
+
|
|
58
|
+
Your Telegram conversation and your TUI conversation no longer overwrite each other's history. `/new` in the TUI only resets the TUI session.
|
|
59
|
+
|
|
60
|
+
#### `/target tui|telegram` — remote-control the Telegram session from TUI
|
|
61
|
+
|
|
62
|
+
New TUI command to switch where your typed messages go:
|
|
63
|
+
|
|
64
|
+
- **`/target tui`** (default) — Your messages go into the TUI's isolated session. Responses are rendered in the TUI only.
|
|
65
|
+
- **`/target telegram`** — Your messages enter the Telegram session (shared memory with whoever messages your bot on Telegram). The bot responds **both** in the TUI (via the open WebSocket) **and** in the actual Telegram chat (via the existing delivery queue). The active target is shown in the header as `→ Telegram` or `TUI session`.
|
|
66
|
+
|
|
67
|
+
Note: Telegram bot API does not allow bots to forge user messages, so your original prompt stays in the TUI — only the bot's response lands in Telegram. This is the closest possible equivalent to "remote typing into Telegram".
|
|
68
|
+
|
|
69
|
+
#### `/observe on|off` — mirror Telegram activity into the TUI
|
|
70
|
+
|
|
71
|
+
When observer mode is on (default), every Telegram user message and streaming bot response is mirrored into the TUI with distinct dim + `📱 Tel` styling. You can watch a live conversation happen from the TUI while running your own independent session in parallel. Toggle off with `/observe off` if the mirror noise gets in the way.
|
|
72
|
+
|
|
73
|
+
### 🧠 Architecture / Design Note
|
|
74
|
+
|
|
75
|
+
This feature deliberately does **not** go through the Claude Agent SDK or touch the `pathToClaudeCodeExecutable` flow. The broadcast bus is a pure observation layer in alvin-bot's own process, and session routing is just a different `sessionKey` lookup in the existing `getSession()` map. The bot's 1st-party auth behavior (CLI-backed session routing) is preserved exactly as before.
|
|
76
|
+
|
|
77
|
+
### 📦 Compatibility
|
|
78
|
+
|
|
79
|
+
This is a minor release (new feature), not a patch. No breaking changes to existing commands, existing behavior, or existing API endpoints. Old clients that don't send a `target` field continue to work exactly as before (falling back to the primary Telegram user's session).
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm update -g alvin-bot
|
|
83
|
+
alvin-bot tui # fresh TUI session, observer on by default
|
|
84
|
+
alvin-bot tui --resume # resume persistent tui:local session
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Once inside TUI:
|
|
88
|
+
- `/target telegram` — route your messages into the Telegram session (responses land in both TUI and Telegram chat)
|
|
89
|
+
- `/target tui` — switch back to isolated TUI session (default)
|
|
90
|
+
- `/observe off` — stop mirroring Telegram activity
|
|
91
|
+
- `/observe on` — resume mirroring
|
|
92
|
+
|
|
5
93
|
## [4.4.7] — 2026-04-09
|
|
6
94
|
|
|
7
95
|
### 🔐 Security / Dependencies
|
package/dist/handlers/message.js
CHANGED
|
@@ -12,6 +12,7 @@ import { trackAndAdapt } from "../services/language-detect.js";
|
|
|
12
12
|
import { shouldCompact, compactSession } from "../services/compaction.js";
|
|
13
13
|
import { emit } from "../services/hooks.js";
|
|
14
14
|
import { trackUsage } from "../services/usage-tracker.js";
|
|
15
|
+
import { emitUserMessage as broadcastUserMessage, emitResponseStart as broadcastResponseStart, emitResponseDelta as broadcastResponseDelta, emitResponseDone as broadcastResponseDone, } from "../services/broadcast.js";
|
|
15
16
|
/** React to a message with an emoji. Silently fails if reactions aren't supported. */
|
|
16
17
|
async function react(ctx, emoji) {
|
|
17
18
|
try {
|
|
@@ -85,6 +86,22 @@ export async function handleMessage(ctx) {
|
|
|
85
86
|
await ctx.api.sendChatAction(ctx.chat.id, "typing");
|
|
86
87
|
session.messageCount++;
|
|
87
88
|
emit("message:received", { userId, text, platform: "telegram" });
|
|
89
|
+
// v4.5.0: broadcast the user message so TUI/WebUI observers can mirror it.
|
|
90
|
+
// The broadcast bus is fire-and-forget — never affects the Telegram flow.
|
|
91
|
+
broadcastUserMessage({
|
|
92
|
+
platform: "telegram",
|
|
93
|
+
userId,
|
|
94
|
+
userName: ctx.from?.first_name || ctx.from?.username,
|
|
95
|
+
chatId: ctx.chat.id,
|
|
96
|
+
text,
|
|
97
|
+
ts: Date.now(),
|
|
98
|
+
});
|
|
99
|
+
broadcastResponseStart({
|
|
100
|
+
platform: "telegram",
|
|
101
|
+
userId,
|
|
102
|
+
chatId: ctx.chat.id,
|
|
103
|
+
ts: Date.now(),
|
|
104
|
+
});
|
|
88
105
|
// Determine provider type early for compaction check
|
|
89
106
|
const registry = getRegistry();
|
|
90
107
|
const activeProvider = registry.getActive();
|
|
@@ -130,11 +147,25 @@ export async function handleMessage(ctx) {
|
|
|
130
147
|
addToHistory(userId, { role: "user", content: text });
|
|
131
148
|
}
|
|
132
149
|
// Stream response from provider (with fallback)
|
|
150
|
+
let lastBroadcastLen = 0;
|
|
133
151
|
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
134
152
|
switch (chunk.type) {
|
|
135
153
|
case "text":
|
|
136
154
|
finalText = chunk.text || "";
|
|
137
155
|
await streamer.update(finalText);
|
|
156
|
+
// Emit the new delta for observers — accumulated text minus what
|
|
157
|
+
// we already broadcast.
|
|
158
|
+
if (finalText.length > lastBroadcastLen) {
|
|
159
|
+
const delta = finalText.slice(lastBroadcastLen);
|
|
160
|
+
broadcastResponseDelta({
|
|
161
|
+
platform: "telegram",
|
|
162
|
+
userId,
|
|
163
|
+
chatId: ctx.chat.id,
|
|
164
|
+
delta,
|
|
165
|
+
ts: Date.now(),
|
|
166
|
+
});
|
|
167
|
+
lastBroadcastLen = finalText.length;
|
|
168
|
+
}
|
|
138
169
|
break;
|
|
139
170
|
case "tool_use":
|
|
140
171
|
// Could show tool activity indicator
|
|
@@ -161,6 +192,15 @@ export async function handleMessage(ctx) {
|
|
|
161
192
|
}
|
|
162
193
|
await streamer.finalize(finalText);
|
|
163
194
|
emit("message:sent", { userId, text: finalText, platform: "telegram" });
|
|
195
|
+
// v4.5.0: tell observers the response is complete.
|
|
196
|
+
broadcastResponseDone({
|
|
197
|
+
platform: "telegram",
|
|
198
|
+
userId,
|
|
199
|
+
chatId: ctx.chat.id,
|
|
200
|
+
finalText,
|
|
201
|
+
cost: session.costByProvider[registry.getActiveKey()],
|
|
202
|
+
ts: Date.now(),
|
|
203
|
+
});
|
|
164
204
|
// Clear thinking reaction (replace with nothing — message was answered)
|
|
165
205
|
await react(ctx, "👍");
|
|
166
206
|
// Add assistant response to history (for non-SDK providers)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process Broadcast Bus — Telegram Activity Mirror (v4.5.0+)
|
|
3
|
+
*
|
|
4
|
+
* A tiny typed EventEmitter that lets the Telegram handler announce every
|
|
5
|
+
* user message, every streaming response delta, and every "response done"
|
|
6
|
+
* event. The web server subscribes to the same bus and forwards each event
|
|
7
|
+
* to all connected WebSocket clients as `mirror:*` messages.
|
|
8
|
+
*
|
|
9
|
+
* The TUI (and Web UI) can then show the full Telegram conversation in
|
|
10
|
+
* real time, side-by-side with its own isolated chat session.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints:
|
|
13
|
+
* - Zero backpressure: events are fire-and-forget, listeners must be fast
|
|
14
|
+
* - No memory retention: no history is buffered here, just live pub/sub
|
|
15
|
+
* - Platform-agnostic signature so we can later mirror WhatsApp/Signal too
|
|
16
|
+
* - Does not touch the Claude Agent SDK or any provider internals — this
|
|
17
|
+
* is a pure observation layer
|
|
18
|
+
*/
|
|
19
|
+
import { EventEmitter } from "events";
|
|
20
|
+
class TypedBus extends EventEmitter {
|
|
21
|
+
emit(event, ...args) {
|
|
22
|
+
return super.emit(event, ...args);
|
|
23
|
+
}
|
|
24
|
+
on(event, listener) {
|
|
25
|
+
return super.on(event, listener);
|
|
26
|
+
}
|
|
27
|
+
off(event, listener) {
|
|
28
|
+
return super.off(event, listener);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Singleton bus. Import and call methods directly — no need for a factory.
|
|
33
|
+
*
|
|
34
|
+
* EventEmitter default maxListeners is 10; we bump it because a single
|
|
35
|
+
* web server connection may subscribe many listeners (one per connected
|
|
36
|
+
* WS client on a busy day).
|
|
37
|
+
*/
|
|
38
|
+
export const broadcast = new TypedBus();
|
|
39
|
+
broadcast.setMaxListeners(100);
|
|
40
|
+
// ── Convenience Emitters ───────────────────────────────────────────────────
|
|
41
|
+
export function emitUserMessage(payload) {
|
|
42
|
+
broadcast.emit("user_msg", payload);
|
|
43
|
+
}
|
|
44
|
+
export function emitResponseStart(payload) {
|
|
45
|
+
broadcast.emit("response_start", payload);
|
|
46
|
+
}
|
|
47
|
+
export function emitResponseDelta(payload) {
|
|
48
|
+
broadcast.emit("response_delta", payload);
|
|
49
|
+
}
|
|
50
|
+
export function emitResponseDone(payload) {
|
|
51
|
+
broadcast.emit("response_done", payload);
|
|
52
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Usage: alvin-bot tui [--port 3100] [--host localhost] [--lang en|de]
|
|
18
18
|
*/
|
|
19
|
-
import { createInterface } from "readline";
|
|
19
|
+
import { createInterface, cursorTo, clearLine as rlClearLine } from "readline";
|
|
20
20
|
import WebSocket from "ws";
|
|
21
21
|
import http from "http";
|
|
22
22
|
import { initI18n, t } from "../i18n.js";
|
|
@@ -57,11 +57,17 @@ let connected = false;
|
|
|
57
57
|
let currentModel = "loading...";
|
|
58
58
|
let totalCost = 0;
|
|
59
59
|
let isStreaming = false;
|
|
60
|
+
let isMirrorStreaming = false;
|
|
60
61
|
let currentResponse = "";
|
|
61
62
|
let currentToolName = "";
|
|
62
63
|
let toolCount = 0;
|
|
63
64
|
const inputHistory = [];
|
|
64
65
|
let historyIndex = -1;
|
|
66
|
+
let activeTarget = "tui";
|
|
67
|
+
let observerEnabled = true;
|
|
68
|
+
// TUI's own session key — either ephemeral (new every start) or persistent
|
|
69
|
+
// ("tui:local") if --resume is passed. Set once in startTUI().
|
|
70
|
+
let tuiSessionKey = `tui:ephemeral:${Date.now()}`;
|
|
65
71
|
const host = process.argv.includes("--host")
|
|
66
72
|
? process.argv[process.argv.indexOf("--host") + 1] || "localhost"
|
|
67
73
|
: "localhost";
|
|
@@ -76,8 +82,14 @@ const HEADER_LINES = 3;
|
|
|
76
82
|
function getWidth() {
|
|
77
83
|
return process.stdout.columns || 80;
|
|
78
84
|
}
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Clear the current readline input line so we can write content "above" the
|
|
87
|
+
* prompt cleanly. Uses readline's own cursor API instead of raw escape
|
|
88
|
+
* sequences — this cooperates with readline's internal cursor tracking.
|
|
89
|
+
*/
|
|
90
|
+
function clearCurrentLine() {
|
|
91
|
+
cursorTo(process.stdout, 0);
|
|
92
|
+
rlClearLine(process.stdout, 0);
|
|
81
93
|
}
|
|
82
94
|
function drawHeader() {
|
|
83
95
|
const w = getWidth();
|
|
@@ -85,61 +97,76 @@ function drawHeader() {
|
|
|
85
97
|
const status = connected ? t("tui.connected") : t("tui.disconnected");
|
|
86
98
|
const modelStr = `${C.brightMagenta}${currentModel}${C.reset}`;
|
|
87
99
|
const costStr = totalCost > 0 ? ` ${C.gray}· $${totalCost.toFixed(4)}${C.reset}` : "";
|
|
100
|
+
const targetStr = ` ${C.gray}│${C.reset} ${C.brightYellow}${activeTarget === "telegram" ? "→ Telegram" : "TUI session"}${C.reset}`;
|
|
88
101
|
const title = `${C.bold}${C.brightCyan}${t("tui.title")}${C.reset}`;
|
|
89
|
-
const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}`;
|
|
102
|
+
const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}${targetStr}`;
|
|
90
103
|
console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
|
|
91
104
|
console.log(` ${title}${"".padEnd(10)}${right}`);
|
|
92
105
|
console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
|
|
93
106
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Redraw the header. The old "in-place" implementation used cursor save/
|
|
109
|
+
* restore escape sequences and jumped to \x1b[H — but once the terminal
|
|
110
|
+
* has scrolled past the original header, \x1b[H resolves to the current
|
|
111
|
+
* viewport top (not the document top), which means the header gets
|
|
112
|
+
* re-rendered inline in the middle of the content. That's what produced
|
|
113
|
+
* the "header appears in the middle of the bot response" bug in 4.5.0.
|
|
114
|
+
*
|
|
115
|
+
* The only safe way to redraw the header in a scrolling terminal is to
|
|
116
|
+
* clear the whole screen and redraw from scratch. Do that only in
|
|
117
|
+
* explicit reset contexts (/clear, SIGWINCH resize, initial connect).
|
|
118
|
+
* For mid-session cost/status updates, use inline info messages instead.
|
|
119
|
+
*/
|
|
120
|
+
function redrawHeader(opts = {}) {
|
|
121
|
+
if (isStreaming)
|
|
122
|
+
return;
|
|
123
|
+
if (opts.clearScreen) {
|
|
124
|
+
console.clear();
|
|
103
125
|
}
|
|
104
|
-
process.stdout.write("\x1b[H"); // Back to top
|
|
105
126
|
drawHeader();
|
|
106
|
-
|
|
127
|
+
if (rl && !isStreaming)
|
|
128
|
+
rl.prompt(true);
|
|
107
129
|
}
|
|
108
130
|
function drawHelp() {
|
|
109
131
|
console.log(`
|
|
110
132
|
${C.bold}${t("help.title")}${C.reset}
|
|
111
|
-
${C.cyan}/model${C.reset}
|
|
112
|
-
${C.cyan}/status${C.reset}
|
|
113
|
-
${C.cyan}/clear${C.reset}
|
|
114
|
-
${C.cyan}/cron${C.reset}
|
|
115
|
-
${C.cyan}/doctor${C.reset}
|
|
116
|
-
${C.cyan}/backup${C.reset}
|
|
117
|
-
${C.cyan}/restart${C.reset}
|
|
118
|
-
${C.cyan}/
|
|
119
|
-
${C.cyan}/
|
|
133
|
+
${C.cyan}/model${C.reset} ${t("help.model")}
|
|
134
|
+
${C.cyan}/status${C.reset} ${t("help.status")}
|
|
135
|
+
${C.cyan}/clear${C.reset} ${t("help.clear")}
|
|
136
|
+
${C.cyan}/cron${C.reset} ${t("help.cron")}
|
|
137
|
+
${C.cyan}/doctor${C.reset} ${t("help.doctor")}
|
|
138
|
+
${C.cyan}/backup${C.reset} ${t("help.backup")}
|
|
139
|
+
${C.cyan}/restart${C.reset} ${t("help.restart")}
|
|
140
|
+
${C.cyan}/target tui${C.reset}|${C.cyan}telegram${C.reset} Switch where your messages go
|
|
141
|
+
${C.cyan}/observe on${C.reset}|${C.cyan}off${C.reset} Mirror Telegram activity (default: on)
|
|
142
|
+
${C.cyan}/help${C.reset} ${t("help.help")}
|
|
143
|
+
${C.cyan}/quit${C.reset} ${t("help.quit")}
|
|
120
144
|
|
|
121
145
|
${C.dim}${t("help.footer")}${C.reset}
|
|
122
146
|
`);
|
|
123
147
|
}
|
|
124
148
|
function printUser(text) {
|
|
149
|
+
clearCurrentLine();
|
|
125
150
|
console.log(`\n${C.bold}${C.brightGreen}${t("tui.you")}:${C.reset} ${text}`);
|
|
126
151
|
}
|
|
127
152
|
function printAssistantStart() {
|
|
128
|
-
|
|
153
|
+
clearCurrentLine();
|
|
154
|
+
const targetTag = activeTarget === "telegram" ? ` ${C.dim}[→ Tel]${C.reset}` : "";
|
|
155
|
+
process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot${targetTag}:${C.reset} `);
|
|
129
156
|
}
|
|
130
157
|
function printAssistantDelta(text) {
|
|
131
158
|
process.stdout.write(text);
|
|
132
159
|
}
|
|
133
160
|
function printAssistantEnd(cost) {
|
|
134
161
|
const costStr = cost && cost > 0 ? ` ${C.dim}($${cost.toFixed(4)})${C.reset}` : "";
|
|
135
|
-
|
|
162
|
+
process.stdout.write(costStr + "\n");
|
|
136
163
|
}
|
|
137
164
|
function printTool(name) {
|
|
138
|
-
|
|
139
|
-
process.stdout.write(
|
|
165
|
+
clearCurrentLine();
|
|
166
|
+
process.stdout.write(` ${C.yellow}⚙ ${name}...${C.reset}`);
|
|
140
167
|
}
|
|
141
168
|
function printToolDone() {
|
|
142
|
-
|
|
169
|
+
clearCurrentLine();
|
|
143
170
|
if (toolCount > 0) {
|
|
144
171
|
const label = toolCount > 1 ? t("tui.toolsUsed") : t("tui.toolUsed");
|
|
145
172
|
console.log(` ${C.dim}${C.yellow}⚙ ${toolCount} ${label}${C.reset}`);
|
|
@@ -147,26 +174,54 @@ function printToolDone() {
|
|
|
147
174
|
toolCount = 0;
|
|
148
175
|
}
|
|
149
176
|
function printError(msg) {
|
|
177
|
+
clearCurrentLine();
|
|
150
178
|
console.log(`\n${C.red}✖ ${msg}${C.reset}`);
|
|
151
179
|
}
|
|
152
180
|
function printInfo(msg) {
|
|
181
|
+
clearCurrentLine();
|
|
153
182
|
console.log(`${C.cyan}ℹ ${msg}${C.reset}`);
|
|
154
183
|
}
|
|
155
184
|
function printSuccess(msg) {
|
|
185
|
+
clearCurrentLine();
|
|
156
186
|
console.log(`${C.green}✔ ${msg}${C.reset}`);
|
|
157
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Render the mirror of a Telegram event (user message or bot response).
|
|
190
|
+
* Distinct styling: dim, phone prefix, grayed color.
|
|
191
|
+
*/
|
|
192
|
+
function printMirrorUser(text) {
|
|
193
|
+
clearCurrentLine();
|
|
194
|
+
console.log(`\n${C.dim}${C.gray}📱 Tel User: ${text}${C.reset}`);
|
|
195
|
+
}
|
|
196
|
+
function printMirrorAssistantStart() {
|
|
197
|
+
clearCurrentLine();
|
|
198
|
+
process.stdout.write(`\n${C.dim}${C.gray}📱 Tel Bot: ${C.reset}`);
|
|
199
|
+
}
|
|
200
|
+
function printMirrorAssistantDelta(text) {
|
|
201
|
+
// Dim styling while streaming the mirrored response
|
|
202
|
+
process.stdout.write(`${C.dim}${C.gray}${text}${C.reset}`);
|
|
203
|
+
}
|
|
204
|
+
function printMirrorAssistantEnd() {
|
|
205
|
+
process.stdout.write("\n");
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The single source of truth for rendering the input prompt. Only ever
|
|
209
|
+
* called at state-transition points (connect, done, error, command result)
|
|
210
|
+
* and no-ops during streaming so the prompt never races with delta writes.
|
|
211
|
+
*/
|
|
158
212
|
function showPrompt() {
|
|
159
|
-
if (!
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
213
|
+
if (isStreaming || !rl)
|
|
214
|
+
return;
|
|
215
|
+
rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
|
|
216
|
+
rl.prompt(true);
|
|
163
217
|
}
|
|
164
218
|
// ── WebSocket Connection ────────────────────────────────
|
|
165
219
|
function connectWebSocket() {
|
|
166
220
|
ws = new WebSocket(wsUrl);
|
|
167
221
|
ws.on("open", () => {
|
|
168
222
|
connected = true;
|
|
169
|
-
|
|
223
|
+
// No header redraw here — the header was already drawn at startTUI().
|
|
224
|
+
// Calling redrawHeader() in a scrolled terminal re-renders it inline.
|
|
170
225
|
printInfo(t("tui.connectedTo"));
|
|
171
226
|
showPrompt();
|
|
172
227
|
});
|
|
@@ -180,7 +235,7 @@ function connectWebSocket() {
|
|
|
180
235
|
ws.on("close", () => {
|
|
181
236
|
connected = false;
|
|
182
237
|
isStreaming = false;
|
|
183
|
-
|
|
238
|
+
// No header redraw — it would appear inline mid-chat.
|
|
184
239
|
printError(t("tui.connectionLost"));
|
|
185
240
|
setTimeout(connectWebSocket, 3000);
|
|
186
241
|
});
|
|
@@ -223,7 +278,10 @@ function handleMessage(msg) {
|
|
|
223
278
|
isStreaming = false;
|
|
224
279
|
currentResponse = "";
|
|
225
280
|
currentToolName = "";
|
|
226
|
-
redrawHeader()
|
|
281
|
+
// NOTE: do NOT call redrawHeader() here. On a scrolled terminal it
|
|
282
|
+
// renders the header inline at the viewport top, which looks like
|
|
283
|
+
// the header appeared in the middle of the conversation. The total
|
|
284
|
+
// cost is already shown inline at the end of each response.
|
|
227
285
|
showPrompt();
|
|
228
286
|
break;
|
|
229
287
|
case "error":
|
|
@@ -235,6 +293,41 @@ function handleMessage(msg) {
|
|
|
235
293
|
printInfo(t("tui.sessionReset"));
|
|
236
294
|
showPrompt();
|
|
237
295
|
break;
|
|
296
|
+
// ── v4.5.0: Telegram activity mirror events ────────────────────────
|
|
297
|
+
// These arrive whenever someone interacts with the bot via Telegram,
|
|
298
|
+
// regardless of what the TUI is currently doing. We render them
|
|
299
|
+
// distinctly (dim + 📱 prefix) so they don't confuse themselves with
|
|
300
|
+
// the user's own session.
|
|
301
|
+
case "mirror:user_msg":
|
|
302
|
+
if (!observerEnabled)
|
|
303
|
+
break;
|
|
304
|
+
printMirrorUser(msg.text || "");
|
|
305
|
+
break;
|
|
306
|
+
case "mirror:response_start":
|
|
307
|
+
if (!observerEnabled)
|
|
308
|
+
break;
|
|
309
|
+
isMirrorStreaming = true;
|
|
310
|
+
printMirrorAssistantStart();
|
|
311
|
+
break;
|
|
312
|
+
case "mirror:response_delta":
|
|
313
|
+
if (!observerEnabled)
|
|
314
|
+
break;
|
|
315
|
+
if (!isMirrorStreaming) {
|
|
316
|
+
isMirrorStreaming = true;
|
|
317
|
+
printMirrorAssistantStart();
|
|
318
|
+
}
|
|
319
|
+
printMirrorAssistantDelta(msg.delta || "");
|
|
320
|
+
break;
|
|
321
|
+
case "mirror:response_done":
|
|
322
|
+
if (!observerEnabled)
|
|
323
|
+
break;
|
|
324
|
+
if (isMirrorStreaming) {
|
|
325
|
+
printMirrorAssistantEnd();
|
|
326
|
+
isMirrorStreaming = false;
|
|
327
|
+
}
|
|
328
|
+
// Don't call showPrompt here — the user's own prompt state is
|
|
329
|
+
// independent of mirror activity.
|
|
330
|
+
break;
|
|
238
331
|
}
|
|
239
332
|
}
|
|
240
333
|
// ── API Calls ───────────────────────────────────────────
|
|
@@ -304,7 +397,8 @@ async function handleCommand(cmd) {
|
|
|
304
397
|
if (res.ok) {
|
|
305
398
|
currentModel = res.active || parts[1];
|
|
306
399
|
printSuccess(`${t("tui.switchedTo")}: ${currentModel}`);
|
|
307
|
-
|
|
400
|
+
// Header stays as-is (would appear inline otherwise) —
|
|
401
|
+
// next /clear redraws it with the new model.
|
|
308
402
|
}
|
|
309
403
|
else {
|
|
310
404
|
printError(res.error || t("tui.switchError"));
|
|
@@ -417,12 +511,49 @@ async function handleCommand(cmd) {
|
|
|
417
511
|
}
|
|
418
512
|
case "clear":
|
|
419
513
|
case "c":
|
|
420
|
-
|
|
421
|
-
|
|
514
|
+
// /clear is the ONLY command that safely redraws the header, because
|
|
515
|
+
// it wipes the entire screen first.
|
|
516
|
+
redrawHeader({ clearScreen: true });
|
|
422
517
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
423
|
-
ws.send(JSON.stringify({
|
|
518
|
+
ws.send(JSON.stringify({
|
|
519
|
+
type: "reset",
|
|
520
|
+
target: activeTarget,
|
|
521
|
+
sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
|
|
522
|
+
}));
|
|
424
523
|
}
|
|
425
524
|
break;
|
|
525
|
+
case "target":
|
|
526
|
+
case "t": {
|
|
527
|
+
const val = (parts[1] || "").toLowerCase();
|
|
528
|
+
if (val === "tui") {
|
|
529
|
+
activeTarget = "tui";
|
|
530
|
+
printSuccess("Target: TUI (your own isolated session)");
|
|
531
|
+
}
|
|
532
|
+
else if (val === "telegram" || val === "tel") {
|
|
533
|
+
activeTarget = "telegram";
|
|
534
|
+
printSuccess("Target: Telegram (your messages now go into the Telegram session — the bot replies in Telegram AND here)");
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
printInfo(`Current target: ${activeTarget}. Use /target tui or /target telegram.`);
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case "observe":
|
|
542
|
+
case "o": {
|
|
543
|
+
const val = (parts[1] || "").toLowerCase();
|
|
544
|
+
if (val === "on" || val === "1" || val === "true") {
|
|
545
|
+
observerEnabled = true;
|
|
546
|
+
printSuccess("Observer mode: ON — Telegram activity will be mirrored here (dim)");
|
|
547
|
+
}
|
|
548
|
+
else if (val === "off" || val === "0" || val === "false") {
|
|
549
|
+
observerEnabled = false;
|
|
550
|
+
printSuccess("Observer mode: OFF — Telegram activity will NOT be shown here");
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
printInfo(`Observer: ${observerEnabled ? "on" : "off"}. Use /observe on or /observe off.`);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
426
557
|
case "quit":
|
|
427
558
|
case "q":
|
|
428
559
|
case "exit":
|
|
@@ -442,7 +573,16 @@ function sendChat(text) {
|
|
|
442
573
|
return;
|
|
443
574
|
}
|
|
444
575
|
printUser(text);
|
|
445
|
-
|
|
576
|
+
// v4.5.0: include target + sessionKey so the web server routes the
|
|
577
|
+
// message to the right session. For target=tui, sessionKey is the
|
|
578
|
+
// TUI's own ephemeral (or persistent) key; for target=telegram,
|
|
579
|
+
// the server resolves it to the primary Telegram user's key.
|
|
580
|
+
ws.send(JSON.stringify({
|
|
581
|
+
type: "chat",
|
|
582
|
+
text,
|
|
583
|
+
target: activeTarget,
|
|
584
|
+
sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
|
|
585
|
+
}));
|
|
446
586
|
if (inputHistory[0] !== text) {
|
|
447
587
|
inputHistory.unshift(text);
|
|
448
588
|
if (inputHistory.length > 100)
|
|
@@ -464,9 +604,14 @@ async function fetchInitialModel() {
|
|
|
464
604
|
catch { /* will get it on connect */ }
|
|
465
605
|
}
|
|
466
606
|
export async function startTUI() {
|
|
607
|
+
// --resume: use persistent TUI session (survives restarts).
|
|
608
|
+
// Default: ephemeral session, fresh every TUI start.
|
|
609
|
+
const wantResume = process.argv.includes("--resume");
|
|
610
|
+
tuiSessionKey = wantResume ? "tui:local" : `tui:ephemeral:${Date.now()}`;
|
|
467
611
|
console.clear();
|
|
468
612
|
drawHeader();
|
|
469
|
-
console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}
|
|
613
|
+
console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}`);
|
|
614
|
+
console.log(`${C.dim}Session: ${wantResume ? "resuming tui:local (persistent)" : "new ephemeral session"}${C.reset}\n`);
|
|
470
615
|
drawHelp();
|
|
471
616
|
rl = createInterface({
|
|
472
617
|
input: process.stdin,
|
|
@@ -495,9 +640,18 @@ export async function startTUI() {
|
|
|
495
640
|
console.log(`\n${C.dim}${t("tui.bye")}${C.reset}\n`);
|
|
496
641
|
process.exit(0);
|
|
497
642
|
});
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
643
|
+
// NOTE: Do NOT call process.stdin.setRawMode(false) here. readline with
|
|
644
|
+
// `terminal: true` already controls the terminal mode, and forcing cooked
|
|
645
|
+
// mode on top of that causes every keystroke to be echoed TWICE (once by
|
|
646
|
+
// the terminal, once by readline's line editor) — producing the classic
|
|
647
|
+
// "hheelllloo" double-echo bug. Let readline manage the tty mode itself.
|
|
648
|
+
// Handle terminal resize — we can't safely redraw the header in place
|
|
649
|
+
// on a scrolled buffer. Just re-render the prompt so readline picks up
|
|
650
|
+
// the new width for its line editor.
|
|
651
|
+
process.stdout.on("resize", () => {
|
|
652
|
+
if (!isStreaming)
|
|
653
|
+
showPrompt();
|
|
654
|
+
});
|
|
501
655
|
await fetchInitialModel();
|
|
502
656
|
connectWebSocket();
|
|
503
657
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { handleDoctorAPI } from "./doctor-api.js";
|
|
|
28
28
|
import { handleOpenAICompat } from "./openai-compat.js";
|
|
29
29
|
import { addCanvasClient } from "./canvas.js";
|
|
30
30
|
import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
|
|
31
|
+
import { broadcast } from "../services/broadcast.js";
|
|
31
32
|
const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
|
|
32
33
|
const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
|
|
33
34
|
/** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
|
|
@@ -1144,6 +1145,57 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
1144
1145
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
1145
1146
|
}
|
|
1146
1147
|
// ── WebSocket Chat ──────────────────────────────────────
|
|
1148
|
+
// Set of all currently connected chat WebSocket clients (excluding canvas).
|
|
1149
|
+
// Populated on connect, cleaned up on close. Used to forward Telegram
|
|
1150
|
+
// activity to every observer.
|
|
1151
|
+
const chatClients = new Set();
|
|
1152
|
+
/**
|
|
1153
|
+
* Wire the broadcast bus once at module load. The bus is singleton, so
|
|
1154
|
+
* subscribing here means every Telegram message fan-outs to every connected
|
|
1155
|
+
* chat client — without any per-connection re-subscription.
|
|
1156
|
+
*/
|
|
1157
|
+
broadcast.on("user_msg", (payload) => {
|
|
1158
|
+
if (payload.platform !== "telegram")
|
|
1159
|
+
return; // v4.5.0: telegram only for now
|
|
1160
|
+
const json = JSON.stringify({
|
|
1161
|
+
type: "mirror:user_msg",
|
|
1162
|
+
text: payload.text,
|
|
1163
|
+
platform: payload.platform,
|
|
1164
|
+
userName: payload.userName,
|
|
1165
|
+
ts: payload.ts,
|
|
1166
|
+
});
|
|
1167
|
+
for (const client of chatClients) {
|
|
1168
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1169
|
+
client.send(json);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
broadcast.on("response_start", (payload) => {
|
|
1173
|
+
if (payload.platform !== "telegram")
|
|
1174
|
+
return;
|
|
1175
|
+
const json = JSON.stringify({ type: "mirror:response_start", platform: payload.platform, ts: payload.ts });
|
|
1176
|
+
for (const client of chatClients) {
|
|
1177
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1178
|
+
client.send(json);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
broadcast.on("response_delta", (payload) => {
|
|
1182
|
+
if (payload.platform !== "telegram")
|
|
1183
|
+
return;
|
|
1184
|
+
const json = JSON.stringify({ type: "mirror:response_delta", delta: payload.delta, platform: payload.platform, ts: payload.ts });
|
|
1185
|
+
for (const client of chatClients) {
|
|
1186
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1187
|
+
client.send(json);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
broadcast.on("response_done", (payload) => {
|
|
1191
|
+
if (payload.platform !== "telegram")
|
|
1192
|
+
return;
|
|
1193
|
+
const json = JSON.stringify({ type: "mirror:response_done", cost: payload.cost, platform: payload.platform, ts: payload.ts });
|
|
1194
|
+
for (const client of chatClients) {
|
|
1195
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1196
|
+
client.send(json);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1147
1199
|
function handleWebSocket(wss) {
|
|
1148
1200
|
wss.on("connection", (ws, req) => {
|
|
1149
1201
|
// Auth check
|
|
@@ -1158,12 +1210,33 @@ function handleWebSocket(wss) {
|
|
|
1158
1210
|
return;
|
|
1159
1211
|
}
|
|
1160
1212
|
console.log("WebUI: client connected");
|
|
1213
|
+
chatClients.add(ws);
|
|
1161
1214
|
ws.on("message", async (data) => {
|
|
1162
1215
|
try {
|
|
1163
1216
|
const msg = JSON.parse(data.toString());
|
|
1164
1217
|
if (msg.type === "chat") {
|
|
1165
1218
|
let { text, effort, file } = msg;
|
|
1166
|
-
|
|
1219
|
+
// v4.5.0: session routing. The client (TUI/WebUI) tells us which
|
|
1220
|
+
// session it wants its message to go into. Supported targets:
|
|
1221
|
+
// - "tui" → use msg.sessionKey (e.g. "tui:local" or
|
|
1222
|
+
// "tui:ephemeral:…"). Isolated from Telegram.
|
|
1223
|
+
// - "telegram" → route into the primary Telegram user's session.
|
|
1224
|
+
// Responses go back to the client AND to the
|
|
1225
|
+
// actual Telegram chat via the broadcast bus.
|
|
1226
|
+
// - undefined → backwards-compatible: default to the primary
|
|
1227
|
+
// allowed user's session (old behavior).
|
|
1228
|
+
const target = msg.target;
|
|
1229
|
+
const telegramUserId = config.allowedUsers[0] || 0;
|
|
1230
|
+
let sessionKey;
|
|
1231
|
+
if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
|
|
1232
|
+
sessionKey = msg.sessionKey;
|
|
1233
|
+
}
|
|
1234
|
+
else if (target === "telegram") {
|
|
1235
|
+
sessionKey = telegramUserId;
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
sessionKey = telegramUserId; // backwards compat
|
|
1239
|
+
}
|
|
1167
1240
|
// Handle file upload — save to temp and reference in prompt
|
|
1168
1241
|
if (file?.dataUrl && file?.name) {
|
|
1169
1242
|
try {
|
|
@@ -1184,16 +1257,17 @@ function handleWebSocket(wss) {
|
|
|
1184
1257
|
const registry = getRegistry();
|
|
1185
1258
|
const activeProvider = registry.getActive();
|
|
1186
1259
|
const isSDK = activeProvider.config.type === "claude-sdk";
|
|
1187
|
-
const session = getSession(
|
|
1260
|
+
const session = getSession(sessionKey);
|
|
1188
1261
|
const queryOpts = {
|
|
1189
1262
|
prompt: text,
|
|
1190
|
-
systemPrompt: buildSystemPrompt(isSDK, session.language, "web-dashboard"),
|
|
1263
|
+
systemPrompt: buildSystemPrompt(isSDK, session.language, target === "telegram" ? "telegram" : "web-dashboard"),
|
|
1191
1264
|
workingDir: session.workingDir,
|
|
1192
1265
|
effort: effort || session.effort,
|
|
1193
1266
|
sessionId: isSDK ? session.sessionId : null,
|
|
1194
1267
|
history: !isSDK ? session.history : undefined,
|
|
1195
1268
|
};
|
|
1196
1269
|
let gotDone = false;
|
|
1270
|
+
let finalText = ""; // v4.5.0: capture the final response for target=telegram relay
|
|
1197
1271
|
try {
|
|
1198
1272
|
// Stream response
|
|
1199
1273
|
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
@@ -1201,6 +1275,8 @@ function handleWebSocket(wss) {
|
|
|
1201
1275
|
break;
|
|
1202
1276
|
switch (chunk.type) {
|
|
1203
1277
|
case "text":
|
|
1278
|
+
if (chunk.text)
|
|
1279
|
+
finalText = chunk.text;
|
|
1204
1280
|
ws.send(JSON.stringify({ type: "text", text: chunk.text, delta: chunk.delta }));
|
|
1205
1281
|
break;
|
|
1206
1282
|
case "tool_use":
|
|
@@ -1208,6 +1284,8 @@ function handleWebSocket(wss) {
|
|
|
1208
1284
|
break;
|
|
1209
1285
|
case "done":
|
|
1210
1286
|
gotDone = true;
|
|
1287
|
+
if (chunk.text)
|
|
1288
|
+
finalText = chunk.text;
|
|
1211
1289
|
if (chunk.sessionId)
|
|
1212
1290
|
session.sessionId = chunk.sessionId;
|
|
1213
1291
|
if (chunk.costUsd)
|
|
@@ -1235,6 +1313,21 @@ function handleWebSocket(wss) {
|
|
|
1235
1313
|
if (!gotDone && ws.readyState === WebSocket.OPEN) {
|
|
1236
1314
|
ws.send(JSON.stringify({ type: "done", cost: 0 }));
|
|
1237
1315
|
}
|
|
1316
|
+
// v4.5.0: if the user typed in the TUI with target=telegram, we
|
|
1317
|
+
// must also post the bot's final response to the actual Telegram
|
|
1318
|
+
// chat so the continuity is preserved from the Telegram side.
|
|
1319
|
+
// (Telegram bots cannot forge user messages, so only the
|
|
1320
|
+
// response lands in the chat — the user prompt itself stays
|
|
1321
|
+
// in the TUI.)
|
|
1322
|
+
if (target === "telegram" && finalText.trim()) {
|
|
1323
|
+
try {
|
|
1324
|
+
const dq = await import("../services/delivery-queue.js");
|
|
1325
|
+
dq.enqueue("telegram", String(telegramUserId), finalText);
|
|
1326
|
+
}
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
console.error("WebUI → Telegram relay failed:", err);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1238
1331
|
}
|
|
1239
1332
|
catch (streamErr) {
|
|
1240
1333
|
const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
@@ -1248,8 +1341,17 @@ function handleWebSocket(wss) {
|
|
|
1248
1341
|
}
|
|
1249
1342
|
}
|
|
1250
1343
|
if (msg.type === "reset") {
|
|
1251
|
-
|
|
1252
|
-
|
|
1344
|
+
// v4.5.0: reset the target session, not a hardcoded one.
|
|
1345
|
+
const target = msg.target;
|
|
1346
|
+
const telegramUserId = config.allowedUsers[0] || 0;
|
|
1347
|
+
let resetKey;
|
|
1348
|
+
if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
|
|
1349
|
+
resetKey = msg.sessionKey;
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
resetKey = telegramUserId;
|
|
1353
|
+
}
|
|
1354
|
+
resetSession(resetKey);
|
|
1253
1355
|
ws.send(JSON.stringify({ type: "reset", ok: true }));
|
|
1254
1356
|
}
|
|
1255
1357
|
}
|
|
@@ -1260,6 +1362,7 @@ function handleWebSocket(wss) {
|
|
|
1260
1362
|
});
|
|
1261
1363
|
ws.on("close", () => {
|
|
1262
1364
|
console.log("WebUI: client disconnected");
|
|
1365
|
+
chatClients.delete(ws);
|
|
1263
1366
|
});
|
|
1264
1367
|
});
|
|
1265
1368
|
}
|