feat: /status 通用状态显示 + /compact 手动压缩;HTML 兼容渲染默认化
- /status:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/ 首 token 平均/输出速率/输入输出 tokens —— 与 Web 底部统计条同源 同算法(sessionProjections: sessionStats/tokenUsage/contextPressure), 投影插件缺失时逐组优雅降级;冷会话不强制 resume - /compact:复用 ctx.compaction.compactNow 手动压缩绑定会话历史, 异步执行不阻塞轮询,完成/失败均中文通知,同会话去重, 桥停止/热重载自动中止进行中的压缩 - 全部用户可见文案不使用 emoji - rendering 默认改 html(兼容优先)+ userQuestions/approval 双通道等 既有修改一并合入(此前已上线确认) - tests:+6 覆盖 /status 全字段与 /compact 各分支(51 全绿)
This commit is contained in:
@@ -120,14 +120,15 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master
|
|||||||
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
||||||
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
|
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
|
||||||
|
|
||||||
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `unbind` `help`。
|
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `compact` `unbind` `help`。
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
||||||
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
|
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
|
||||||
| `/model` | 切换当前绑定会话的模型 |
|
| `/model` | 切换当前绑定会话的模型 |
|
||||||
| `/status` | 当前绑定 |
|
| `/status` | 通用状态显示:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/首 token 平均/输出速率/输入输出 tokens(与 Web 底部统计条同源) |
|
||||||
|
| `/compact` | 手动压缩当前绑定会话的历史(缩短上下文;会话需空闲,压缩期间新消息排队) |
|
||||||
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
||||||
| `/help` | 帮助 |
|
| `/help` | 帮助 |
|
||||||
|
|
||||||
|
|||||||
Vendored
+25
-3
@@ -18,7 +18,12 @@ export declare class TelegramBridge {
|
|||||||
private readonly client;
|
private readonly client;
|
||||||
private readonly sleep;
|
private readonly sleep;
|
||||||
private readonly maxMessageLength;
|
private readonly maxMessageLength;
|
||||||
|
/** Global rendering mode: follows config; flipped to 'html' permanently when the
|
||||||
|
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
|
||||||
private renderingMode;
|
private renderingMode;
|
||||||
|
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
|
||||||
|
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
|
||||||
|
private readonly renderPrefs;
|
||||||
private readonly bindings;
|
private readonly bindings;
|
||||||
private readonly pickers;
|
private readonly pickers;
|
||||||
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
||||||
@@ -44,7 +49,13 @@ export declare class TelegramBridge {
|
|||||||
private readonly callNames;
|
private readonly callNames;
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
private readonly lastTodos;
|
private readonly lastTodos;
|
||||||
|
/** sessionId → in-flight manual compaction (dedup /compact) */
|
||||||
|
private readonly compacting;
|
||||||
|
/** sessionId → compaction abort controller (cancelled on bridge stop) */
|
||||||
|
private readonly compactAborts;
|
||||||
constructor(ctx: Context, options: TelegramBridgeOptions);
|
constructor(ctx: Context, options: TelegramBridgeOptions);
|
||||||
|
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
|
||||||
|
private modeFor;
|
||||||
start(): void;
|
start(): void;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
processUpdate(update: TelegramUpdate): Promise<void>;
|
processUpdate(update: TelegramUpdate): Promise<void>;
|
||||||
@@ -62,19 +73,30 @@ export declare class TelegramBridge {
|
|||||||
/** Resume cold sessions when needed; never dispose the returned handle. */
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
||||||
private ensureLiveAgent;
|
private ensureLiveAgent;
|
||||||
private sendStatus;
|
private sendStatus;
|
||||||
|
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
|
||||||
|
private workspaceOf;
|
||||||
|
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
|
||||||
|
private readRuntimeValues;
|
||||||
|
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
||||||
|
private serviceOf;
|
||||||
|
private requestCompact;
|
||||||
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
|
private runCompaction;
|
||||||
private followupBound;
|
private followupBound;
|
||||||
private pollLoop;
|
private pollLoop;
|
||||||
private interruptibleDelay;
|
private interruptibleDelay;
|
||||||
private interruptibleSleep;
|
private interruptibleSleep;
|
||||||
private onSessionEvent;
|
private onSessionEvent;
|
||||||
|
private handleRichCommand;
|
||||||
private deliver;
|
private deliver;
|
||||||
private deliverHtml;
|
private deliverHtml;
|
||||||
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
||||||
private withRetry;
|
private withRetry;
|
||||||
private escHtml;
|
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` renders as a Telegram blockquote (HTML).
|
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
||||||
* Falls back to plain text when the HTML send fails — a notice is never lost.
|
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
||||||
|
* the blockquote entity at all and show the whole message as “not supported”.
|
||||||
|
* Falls back to plain text when the send fails — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
private deliverNotice;
|
private deliverNotice;
|
||||||
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||||
|
|||||||
+309
-29
@@ -11,8 +11,17 @@ import { MSG, LAST_CB, parseCommand } from './commands.js';
|
|||||||
import { markdownToHtml, splitMessage } from './format.js';
|
import { markdownToHtml, splitMessage } from './format.js';
|
||||||
import { splitRichMarkdown } from './rich-format.js';
|
import { splitRichMarkdown } from './rich-format.js';
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js';
|
import { formatLastTurn, loadLastTurn } from './history.js';
|
||||||
import { describeAgent, displayLabel } from './label.js';
|
import { describeAgent, displayLabel, workspaceName } from './label.js';
|
||||||
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
|
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
|
||||||
|
import { formatStatusText, } from './status.js';
|
||||||
|
function compactErrorCode(err) {
|
||||||
|
const code = err?.code;
|
||||||
|
if (code === 'busy' || code === 'cancelled' || code === 'changed'
|
||||||
|
|| code === 'summary' || code === 'commit' || code === 'persistence') {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const WS_CB = 'ws:';
|
const WS_CB = 'ws:';
|
||||||
const SID_CB = 'sid:';
|
const SID_CB = 'sid:';
|
||||||
const BACK_WS_CB = 'wb';
|
const BACK_WS_CB = 'wb';
|
||||||
@@ -45,7 +54,12 @@ export class TelegramBridge {
|
|||||||
client;
|
client;
|
||||||
sleep;
|
sleep;
|
||||||
maxMessageLength;
|
maxMessageLength;
|
||||||
|
/** Global rendering mode: follows config; flipped to 'html' permanently when the
|
||||||
|
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
|
||||||
renderingMode;
|
renderingMode;
|
||||||
|
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
|
||||||
|
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
|
||||||
|
renderPrefs = new Map();
|
||||||
bindings = new Map();
|
bindings = new Map();
|
||||||
pickers = new Map();
|
pickers = new Map();
|
||||||
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
||||||
@@ -72,6 +86,10 @@ export class TelegramBridge {
|
|||||||
callNames = new Map();
|
callNames = new Map();
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
lastTodos = new Map();
|
lastTodos = new Map();
|
||||||
|
/** sessionId → in-flight manual compaction (dedup /compact) */
|
||||||
|
compacting = new Set();
|
||||||
|
/** sessionId → compaction abort controller (cancelled on bridge stop) */
|
||||||
|
compactAborts = new Map();
|
||||||
constructor(ctx, options) {
|
constructor(ctx, options) {
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
this.token = options.token;
|
this.token = options.token;
|
||||||
@@ -82,7 +100,13 @@ export class TelegramBridge {
|
|||||||
});
|
});
|
||||||
this.sleep = options.sleep ?? defaultSleep;
|
this.sleep = options.sleep ?? defaultSleep;
|
||||||
this.maxMessageLength = options.maxMessageLength ?? 4096;
|
this.maxMessageLength = options.maxMessageLength ?? 4096;
|
||||||
this.renderingMode = options.rendering === 'html' ? 'html' : 'rich';
|
// Compat-first default: only an explicit `rendering: 'rich'` opts into rich
|
||||||
|
// messages, because old Telegram clients (10.x) cannot render them at all.
|
||||||
|
this.renderingMode = options.rendering === 'rich' ? 'rich' : 'html';
|
||||||
|
}
|
||||||
|
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
|
||||||
|
modeFor(chatId) {
|
||||||
|
return this.renderPrefs.get(String(chatId)) ?? this.renderingMode;
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
this.disposeSessionListener?.();
|
this.disposeSessionListener?.();
|
||||||
@@ -102,7 +126,9 @@ export class TelegramBridge {
|
|||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
{ command: 'last', description: '查看上次问答(续接上下文)' },
|
{ command: 'last', description: '查看上次问答(续接上下文)' },
|
||||||
{ command: 'model', description: '切换当前绑定会话的模型' },
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '查看当前绑定' },
|
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
||||||
|
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
||||||
|
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'stop', description: '中止当前正在运行的任务' },
|
{ command: 'stop', description: '中止当前正在运行的任务' },
|
||||||
{ command: 'mission', description: '查看任务清单与完成情况' },
|
{ command: 'mission', description: '查看任务清单与完成情况' },
|
||||||
@@ -129,6 +155,7 @@ export class TelegramBridge {
|
|||||||
this.disposeSessionListener = undefined;
|
this.disposeSessionListener = undefined;
|
||||||
// Never dispose host agents — only clear remote bindings.
|
// Never dispose host agents — only clear remote bindings.
|
||||||
this.bindings.clear();
|
this.bindings.clear();
|
||||||
|
this.renderPrefs.clear();
|
||||||
this.pickers.clear();
|
this.pickers.clear();
|
||||||
this.pendingModels.clear();
|
this.pendingModels.clear();
|
||||||
this.stopAllHeartbeats();
|
this.stopAllHeartbeats();
|
||||||
@@ -139,6 +166,10 @@ export class TelegramBridge {
|
|||||||
this.thinkingSessions.clear();
|
this.thinkingSessions.clear();
|
||||||
this.callNames.clear();
|
this.callNames.clear();
|
||||||
this.lastTodos.clear();
|
this.lastTodos.clear();
|
||||||
|
for (const controller of this.compactAborts.values())
|
||||||
|
controller.abort();
|
||||||
|
this.compactAborts.clear();
|
||||||
|
this.compacting.clear();
|
||||||
if (this.hookTimer) {
|
if (this.hookTimer) {
|
||||||
clearTimeout(this.hookTimer);
|
clearTimeout(this.hookTimer);
|
||||||
this.hookTimer = undefined;
|
this.hookTimer = undefined;
|
||||||
@@ -189,6 +220,12 @@ export class TelegramBridge {
|
|||||||
case 'status':
|
case 'status':
|
||||||
await this.sendStatus(chatId);
|
await this.sendStatus(chatId);
|
||||||
return;
|
return;
|
||||||
|
case 'compact':
|
||||||
|
await this.requestCompact(chatId);
|
||||||
|
return;
|
||||||
|
case 'rich':
|
||||||
|
await this.handleRichCommand(chatId, parsed.arg);
|
||||||
|
return;
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
this.bindings.delete(String(chatId));
|
this.bindings.delete(String(chatId));
|
||||||
this.pickers.delete(String(chatId));
|
this.pickers.delete(String(chatId));
|
||||||
@@ -605,12 +642,212 @@ export class TelegramBridge {
|
|||||||
await this.client.sendMessage(chatId, MSG.STATUS_NONE);
|
await this.client.sendMessage(chatId, MSG.STATUS_NONE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stillLive = this.findLiveAgent(binding.sessionId);
|
const info = {
|
||||||
if (!stillLive) {
|
label: binding.label,
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label));
|
sessionId: binding.sessionId,
|
||||||
|
};
|
||||||
|
// 冷会话:不强制 resume(只读查询),给出可用的绑定/工作区信息即可。
|
||||||
|
const agent = this.findLiveAgent(binding.sessionId);
|
||||||
|
if (!agent) {
|
||||||
|
info.live = false;
|
||||||
|
info.workspace = await this.workspaceOf(binding.sessionId, undefined);
|
||||||
|
await this.client.sendMessage(chatId, formatStatusText(info));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label));
|
info.live = true;
|
||||||
|
info.workspace = await this.workspaceOf(binding.sessionId, agent);
|
||||||
|
try {
|
||||||
|
const snap = await loadSessionModels(this.ctx, binding.sessionId);
|
||||||
|
if (snap.current) {
|
||||||
|
info.model = {
|
||||||
|
provider: snap.current.provider,
|
||||||
|
model: snap.current.model,
|
||||||
|
reasoningEffort: snap.current.reasoningEffort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /status model read failed: ${this.redact(err)}`);
|
||||||
|
}
|
||||||
|
info.runtime = this.readRuntimeValues(agent);
|
||||||
|
await this.client.sendMessage(chatId, formatStatusText(info));
|
||||||
|
}
|
||||||
|
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
|
||||||
|
async workspaceOf(sessionId, agent) {
|
||||||
|
try {
|
||||||
|
const catalog = await this.resolveCatalog();
|
||||||
|
const ws = (catalog?.workspaces ?? []).find((w) => (w.sessionIds ?? []).includes(sessionId));
|
||||||
|
if (ws) {
|
||||||
|
return { title: ws.title || undefined, path: ws.path || undefined };
|
||||||
|
}
|
||||||
|
const row = catalog?.sessionsById?.get(sessionId);
|
||||||
|
if (row?.cwd) {
|
||||||
|
return { title: workspaceName(row.cwd) || undefined, path: row.cwd };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 目录不可用 → 继续走 agent 侧兜底
|
||||||
|
}
|
||||||
|
if (agent) {
|
||||||
|
const parts = describeAgent(agent, 0, this.ctx);
|
||||||
|
if (parts.cwd) {
|
||||||
|
return { title: parts.workspace || undefined, path: parts.cwd };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
|
||||||
|
readRuntimeValues(agent) {
|
||||||
|
const registry = this.serviceOf('sessionProjections');
|
||||||
|
let values;
|
||||||
|
if (registry?.snapshot) {
|
||||||
|
try {
|
||||||
|
values = registry.snapshot(agent.session)?.values;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: projection snapshot failed: ${this.redact(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!values)
|
||||||
|
return undefined;
|
||||||
|
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||||
|
const stats = (values['sessionStats'] ?? {});
|
||||||
|
const usage = (values['tokenUsage'] ?? {});
|
||||||
|
const pressure = (values['contextPressure'] ?? {});
|
||||||
|
const runtime = {
|
||||||
|
turns: num(stats.turns),
|
||||||
|
steps: num(stats.steps),
|
||||||
|
llmMs: num(stats.llmMs),
|
||||||
|
toolMs: num(stats.toolMs),
|
||||||
|
ttftMs: num(stats.ttftMs),
|
||||||
|
ttftSteps: num(stats.ttftSteps),
|
||||||
|
decodeMs: num(stats.decodeMs),
|
||||||
|
decodeTokens: num(stats.decodeTokens),
|
||||||
|
uncachedInputTokens: num(usage.uncachedInputTokens),
|
||||||
|
cacheReadTokens: num(usage.cacheReadTokens),
|
||||||
|
cacheWriteTokens: num(usage.cacheWriteTokens),
|
||||||
|
outputTokens: num(usage.outputTokens),
|
||||||
|
contextUsed: num(pressure.projectedTokens) ?? num(pressure.pressureTokens),
|
||||||
|
contextWindow: num(pressure.contextWindow),
|
||||||
|
};
|
||||||
|
// 完全无数据时视为无统计块。
|
||||||
|
const hasAny = Object.values(runtime).some((v) => v !== undefined);
|
||||||
|
return hasAny ? runtime : undefined;
|
||||||
|
}
|
||||||
|
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
||||||
|
serviceOf(name) {
|
||||||
|
const c = this.ctx;
|
||||||
|
if (typeof c.get === 'function') {
|
||||||
|
try {
|
||||||
|
const via = c.get(name, false);
|
||||||
|
if (via)
|
||||||
|
return via;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 服务未装配或当前 ctx 不可解析
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.ctx, name)) {
|
||||||
|
return this.ctx[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// proxy ctx:无 inject 直接读属性会抛错,忽略
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// ── /compact:手动压缩当前绑定会话(需会话空闲)──
|
||||||
|
async requestCompact(chatId) {
|
||||||
|
const binding = this.bindings.get(String(chatId));
|
||||||
|
if (!binding) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.NEED_BIND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const engine = this.serviceOf('compaction');
|
||||||
|
if (!engine?.compactNow) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.compacting.has(binding.sessionId)) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agent = await this.ensureLiveAgent(binding.sessionId);
|
||||||
|
if (!agent) {
|
||||||
|
this.bindings.delete(String(chatId));
|
||||||
|
await this.client.sendMessage(chatId, MSG.GONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
let operation;
|
||||||
|
try {
|
||||||
|
// compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。
|
||||||
|
operation = engine.compactNow(agent, controller.signal);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const code = compactErrorCode(err);
|
||||||
|
if (code === 'busy') {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`);
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.compacting.add(binding.sessionId);
|
||||||
|
this.compactAborts.set(binding.sessionId, controller);
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_STARTED);
|
||||||
|
void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => {
|
||||||
|
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
|
async runCompaction(chatId, sessionId, operation, controller) {
|
||||||
|
try {
|
||||||
|
const result = await operation;
|
||||||
|
if (result === null) {
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
// 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。
|
||||||
|
this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = compactErrorCode(err);
|
||||||
|
switch (code) {
|
||||||
|
case 'cancelled':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED);
|
||||||
|
return;
|
||||||
|
case 'changed':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED);
|
||||||
|
return;
|
||||||
|
case 'summary':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED);
|
||||||
|
return;
|
||||||
|
case 'commit':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED);
|
||||||
|
return;
|
||||||
|
case 'persistence':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED);
|
||||||
|
return;
|
||||||
|
case 'busy':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_BUSY);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`);
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.compacting.delete(sessionId);
|
||||||
|
this.compactAborts.delete(sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async followupBound(chatId, text) {
|
async followupBound(chatId, text) {
|
||||||
const binding = this.bindings.get(String(chatId));
|
const binding = this.bindings.get(String(chatId));
|
||||||
@@ -795,8 +1032,45 @@ export class TelegramBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── /rich: per-chat rendering mode (persisted) ──
|
||||||
|
//
|
||||||
|
// Rich messages (Bot API sendRichMessage) only render on recent Telegram
|
||||||
|
// clients; old ones (10.x) show the message as “not supported”. The bot cannot
|
||||||
|
// detect the recipient's client version, so the choice is per-chat and manual,
|
||||||
|
// remembered forever. HTML mode is the compat default and works everywhere.
|
||||||
|
async handleRichCommand(chatId, arg) {
|
||||||
|
const key = String(chatId);
|
||||||
|
const setAndReply = async (mode) => {
|
||||||
|
this.renderPrefs.set(key, mode);
|
||||||
|
this.saveBindings();
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_SET(mode));
|
||||||
|
};
|
||||||
|
switch (arg) {
|
||||||
|
case 'on':
|
||||||
|
case 'rich':
|
||||||
|
await setAndReply('rich');
|
||||||
|
return;
|
||||||
|
case 'off':
|
||||||
|
case 'html':
|
||||||
|
case 'compat':
|
||||||
|
await setAndReply('html');
|
||||||
|
return;
|
||||||
|
case 'auto':
|
||||||
|
case 'default':
|
||||||
|
case 'reset':
|
||||||
|
this.renderPrefs.delete(key);
|
||||||
|
this.saveBindings();
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.renderingMode));
|
||||||
|
return;
|
||||||
|
case undefined:
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.modeFor(chatId)));
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_USAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
async deliver(chatId, markdown) {
|
async deliver(chatId, markdown) {
|
||||||
if (this.renderingMode !== 'html') {
|
if (this.modeFor(chatId) !== 'html') {
|
||||||
let sentAny = false;
|
let sentAny = false;
|
||||||
try {
|
try {
|
||||||
const chunks = splitRichMarkdown(markdown);
|
const chunks = splitRichMarkdown(markdown);
|
||||||
@@ -810,6 +1084,13 @@ export class TelegramBridge {
|
|||||||
if (!sentAny && isRichUnsupportedError(err)) {
|
if (!sentAny && isRichUnsupportedError(err)) {
|
||||||
this.ctx.logger.warn('dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering');
|
this.ctx.logger.warn('dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering');
|
||||||
this.renderingMode = 'html';
|
this.renderingMode = 'html';
|
||||||
|
// Rich API being gone is a server-wide fact: per-chat rich overrides
|
||||||
|
// must not bypass the global downgrade (modeFor prefers the override).
|
||||||
|
for (const [key, mode] of this.renderPrefs) {
|
||||||
|
if (mode === 'rich')
|
||||||
|
this.renderPrefs.delete(key);
|
||||||
|
}
|
||||||
|
this.saveBindings();
|
||||||
await this.deliverHtml(chatId, markdown);
|
await this.deliverHtml(chatId, markdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -864,36 +1145,23 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr;
|
||||||
}
|
}
|
||||||
escHtml(s) {
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` renders as a Telegram blockquote (HTML).
|
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
||||||
* Falls back to plain text when the HTML send fails — a notice is never lost.
|
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
||||||
|
* the blockquote entity at all and show the whole message as “not supported”.
|
||||||
|
* Falls back to plain text when the send fails — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
async deliverNotice(chatId, text) {
|
async deliverNotice(chatId, text) {
|
||||||
const quoted = text.startsWith('> ');
|
const quoted = text.startsWith('> ');
|
||||||
const body = quoted ? text.slice(2) : text;
|
const body = quoted ? text.slice(2) : text;
|
||||||
try {
|
try {
|
||||||
await this.withRetry(() => {
|
await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body));
|
||||||
if (!quoted)
|
|
||||||
return this.client.sendMessage(chatId, body);
|
|
||||||
return this.client.sendMessage(chatId, `<blockquote>${this.escHtml(body)}</blockquote>`, 'HTML');
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
try {
|
|
||||||
await this.client.sendMessage(chatId, quoted ? `> ${body}` : body);
|
|
||||||
}
|
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.ctx.logger.error(this.redact(err));
|
this.ctx.logger.error(this.redact(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||||
enqueueNotice(chatId, text) {
|
enqueueNotice(chatId, text) {
|
||||||
const key = String(chatId);
|
const key = String(chatId);
|
||||||
@@ -1288,7 +1556,10 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
saveBindings() {
|
saveBindings() {
|
||||||
try {
|
try {
|
||||||
const obj = Object.fromEntries(this.bindings);
|
const obj = {
|
||||||
|
bindings: Object.fromEntries(this.bindings),
|
||||||
|
renderPrefs: Object.fromEntries(this.renderPrefs),
|
||||||
|
};
|
||||||
mkdirSync(dirname(this.bindingsPath()), { recursive: true });
|
mkdirSync(dirname(this.bindingsPath()), { recursive: true });
|
||||||
writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2));
|
writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2));
|
||||||
}
|
}
|
||||||
@@ -1299,9 +1570,13 @@ export class TelegramBridge {
|
|||||||
loadBindings() {
|
loadBindings() {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(this.bindingsPath(), 'utf8');
|
const raw = readFileSync(this.bindingsPath(), 'utf8');
|
||||||
const obj = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
|
const data = parsed;
|
||||||
|
// Legacy files (pre-renderPrefs) stored the bindings flat at the top level.
|
||||||
|
const flat = parsed;
|
||||||
|
const bindingEntries = data.bindings ?? flat;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const [chatId, b] of Object.entries(obj ?? {})) {
|
for (const [chatId, b] of Object.entries(bindingEntries ?? {})) {
|
||||||
if (b && b.sessionId) {
|
if (b && b.sessionId) {
|
||||||
this.bindings.set(String(chatId), {
|
this.bindings.set(String(chatId), {
|
||||||
chatId: b.chatId ?? Number(chatId),
|
chatId: b.chatId ?? Number(chatId),
|
||||||
@@ -1311,6 +1586,11 @@ export class TelegramBridge {
|
|||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [chatId, mode] of Object.entries(data.renderPrefs ?? {})) {
|
||||||
|
if (mode === 'rich' || mode === 'html') {
|
||||||
|
this.renderPrefs.set(String(chatId), mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (n > 0)
|
if (n > 0)
|
||||||
this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`);
|
this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+23
@@ -14,12 +14,28 @@ export declare const MSG: {
|
|||||||
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
||||||
readonly STATUS_BOUND: (label: string) => string;
|
readonly STATUS_BOUND: (label: string) => string;
|
||||||
readonly STATUS_BOUND_COLD: (label: string) => string;
|
readonly STATUS_BOUND_COLD: (label: string) => string;
|
||||||
|
readonly COMPACT_USAGE: "用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)";
|
||||||
|
readonly COMPACT_UNAVAILABLE: "当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。";
|
||||||
|
readonly COMPACT_STARTED: "已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。";
|
||||||
|
readonly COMPACT_BUSY: "当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。";
|
||||||
|
readonly COMPACT_INFLIGHT: "该会话已在进行压缩,请稍候(完成后会报告结果)。";
|
||||||
|
readonly COMPACT_NOTHING: "压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。";
|
||||||
|
readonly COMPACT_DONE: (n: number, tokens: number) => string;
|
||||||
|
readonly COMPACT_CANCELLED: "压缩已取消(会话未改变)。";
|
||||||
|
readonly COMPACT_CHANGED: "压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。";
|
||||||
|
readonly COMPACT_SUMMARY_FAILED: "压缩失败:未能生成有效的摘要(会话未改变),请重试。";
|
||||||
|
readonly COMPACT_COMMIT_FAILED: "压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。";
|
||||||
|
readonly COMPACT_PERSIST_FAILED: "压缩完成但保存失败。请检查本机存储与权限后重试。";
|
||||||
|
readonly COMPACT_FAILED: (detail?: string) => string;
|
||||||
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
||||||
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
|
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
|
||||||
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
|
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
|
||||||
readonly MODEL_UNROUTABLE: (current: string) => string;
|
readonly MODEL_UNROUTABLE: (current: string) => string;
|
||||||
readonly MODEL_EMPTY: (current: string) => string;
|
readonly MODEL_EMPTY: (current: string) => string;
|
||||||
readonly MODEL_SET: (selected: string) => string;
|
readonly MODEL_SET: (selected: string) => string;
|
||||||
|
readonly RICH_USAGE: "用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)";
|
||||||
|
readonly RICH_STATE: (mode: "rich" | "html") => string;
|
||||||
|
readonly RICH_SET: (mode: "rich" | "html") => string;
|
||||||
readonly MODEL_FAILED: (detail?: string) => string;
|
readonly MODEL_FAILED: (detail?: string) => string;
|
||||||
readonly unknown: (command: string) => string;
|
readonly unknown: (command: string) => string;
|
||||||
};
|
};
|
||||||
@@ -41,6 +57,13 @@ export type ParsedCommand = {
|
|||||||
} | {
|
} | {
|
||||||
type: 'status';
|
type: 'status';
|
||||||
text: string;
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'compact';
|
||||||
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'rich';
|
||||||
|
text: string;
|
||||||
|
arg?: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'unbind';
|
type: 'unbind';
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
+42
-1
@@ -9,7 +9,9 @@ export const MSG = {
|
|||||||
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
'/last — 查看绑定会话的上次问答(便于续接)',
|
'/last — 查看绑定会话的上次问答(便于续接)',
|
||||||
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
|
||||||
|
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
|
||||||
|
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
@@ -36,6 +38,26 @@ export const MSG = {
|
|||||||
STATUS_BOUND_COLD(label) {
|
STATUS_BOUND_COLD(label) {
|
||||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
||||||
},
|
},
|
||||||
|
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
|
||||||
|
COMPACT_UNAVAILABLE: '当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。',
|
||||||
|
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
|
||||||
|
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
|
||||||
|
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
|
||||||
|
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
|
||||||
|
COMPACT_DONE(n, tokens) {
|
||||||
|
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`;
|
||||||
|
},
|
||||||
|
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
|
||||||
|
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
|
||||||
|
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
|
||||||
|
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
|
||||||
|
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
|
||||||
|
COMPACT_FAILED(detail) {
|
||||||
|
const tip = '压缩失败。';
|
||||||
|
if (!detail)
|
||||||
|
return tip;
|
||||||
|
return `${tip}\n详情:${detail}`;
|
||||||
|
},
|
||||||
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
||||||
MODEL_UNAVAILABLE(detail) {
|
MODEL_UNAVAILABLE(detail) {
|
||||||
@@ -53,6 +75,17 @@ export const MSG = {
|
|||||||
MODEL_SET(selected) {
|
MODEL_SET(selected) {
|
||||||
return `已切换模型:${selected}\n下一回合生效。`;
|
return `已切换模型:${selected}\n下一回合生效。`;
|
||||||
},
|
},
|
||||||
|
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
|
||||||
|
RICH_STATE(mode) {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
|
||||||
|
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。';
|
||||||
|
},
|
||||||
|
RICH_SET(mode) {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
|
||||||
|
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。';
|
||||||
|
},
|
||||||
MODEL_FAILED(detail) {
|
MODEL_FAILED(detail) {
|
||||||
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
|
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
|
||||||
if (!detail)
|
if (!detail)
|
||||||
@@ -83,6 +116,14 @@ export function parseCommand(text) {
|
|||||||
return { type: 'model', text };
|
return { type: 'model', text };
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text };
|
return { type: 'status', text };
|
||||||
|
case '/compact':
|
||||||
|
return { type: 'compact', text };
|
||||||
|
case '/rich':
|
||||||
|
case '/render':
|
||||||
|
case '/setting': {
|
||||||
|
const arg = text.split(/\s+/)[1]?.toLowerCase();
|
||||||
|
return { type: 'rich', text, arg };
|
||||||
|
}
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
case '/disconnect':
|
case '/disconnect':
|
||||||
return { type: 'unbind', text };
|
return { type: 'unbind', text };
|
||||||
|
|||||||
Vendored
+2
@@ -9,6 +9,8 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean;
|
allowAllUsers?: boolean;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
|
/** 'rich' = Telegram native Rich Messages (needs a recent client);
|
||||||
|
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
||||||
rendering?: string;
|
rendering?: string;
|
||||||
}
|
}
|
||||||
export declare const Config: Schema<TelegramChannelConfig>;
|
export declare const Config: Schema<TelegramChannelConfig>;
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ export const Config = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
rendering: Schema.string().default('rich'),
|
rendering: Schema.string().default('html'),
|
||||||
});
|
});
|
||||||
function resolveAllowedUserIds(config) {
|
function resolveAllowedUserIds(config) {
|
||||||
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
||||||
@@ -37,7 +37,7 @@ export function apply(ctx, config) {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
rendering: config.rendering === 'html' ? 'html' : 'rich',
|
rendering: config.rendering === 'rich' ? 'rich' : 'html',
|
||||||
});
|
});
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start();
|
bridge.start();
|
||||||
|
|||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
export interface StatusWorkspace {
|
||||||
|
/** 工作区名(basename 或 Web 工作区标题)。 */
|
||||||
|
title?: string;
|
||||||
|
/** 完整目录/路径。 */
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
export interface StatusModel {
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
|
||||||
|
reasoningEffort?: string;
|
||||||
|
}
|
||||||
|
export interface StatusRuntime {
|
||||||
|
turns?: number;
|
||||||
|
steps?: number;
|
||||||
|
llmMs?: number;
|
||||||
|
toolMs?: number;
|
||||||
|
ttftMs?: number;
|
||||||
|
ttftSteps?: number;
|
||||||
|
decodeMs?: number;
|
||||||
|
decodeTokens?: number;
|
||||||
|
uncachedInputTokens?: number;
|
||||||
|
cacheReadTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
contextUsed?: number;
|
||||||
|
contextWindow?: number;
|
||||||
|
}
|
||||||
|
export interface StatusInfo {
|
||||||
|
/** 绑定时保存的会话展示名。 */
|
||||||
|
label: string;
|
||||||
|
/** 完整 SessionId 字符串。 */
|
||||||
|
sessionId: string;
|
||||||
|
workspace?: StatusWorkspace;
|
||||||
|
model?: StatusModel;
|
||||||
|
runtime?: StatusRuntime;
|
||||||
|
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
|
||||||
|
live?: boolean;
|
||||||
|
}
|
||||||
|
export declare function effortLabel(effort?: string): string | undefined;
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export declare function formatTokens(n: number): string;
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export declare function formatDuration(ms: number): string;
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export declare function formatTokensPerSecond(tps: number): string;
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export declare function billedInputTokens(runtime: StatusRuntime): number;
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export declare function cacheHitPercent(runtime: StatusRuntime): string | null;
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export declare function formatStatusText(info: StatusInfo): string;
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
const EFFORT_NAMES = {
|
||||||
|
off: '关闭(off)',
|
||||||
|
low: '低(low)',
|
||||||
|
medium: '中(medium)',
|
||||||
|
high: '高(high)',
|
||||||
|
max: '最高(max)',
|
||||||
|
};
|
||||||
|
export function effortLabel(effort) {
|
||||||
|
if (!effort)
|
||||||
|
return undefined;
|
||||||
|
return EFFORT_NAMES[effort] ?? String(effort);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatTokens(n) {
|
||||||
|
const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
|
||||||
|
if (n < 1_000)
|
||||||
|
return String(n);
|
||||||
|
if (n < 1_000_000)
|
||||||
|
return `${scaled(n / 1_000)}K`;
|
||||||
|
return `${scaled(n / 1_000_000)}M`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms) {
|
||||||
|
const s = ms / 1_000;
|
||||||
|
if (s < 60)
|
||||||
|
return `${Math.round(s * 10) / 10}s`;
|
||||||
|
const whole = Math.round(s);
|
||||||
|
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
||||||
|
}
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export function formatTokensPerSecond(tps) {
|
||||||
|
const clamped = Math.max(0, tps);
|
||||||
|
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
|
||||||
|
}
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export function billedInputTokens(runtime) {
|
||||||
|
return (runtime.uncachedInputTokens ?? 0)
|
||||||
|
+ (runtime.cacheReadTokens ?? 0)
|
||||||
|
+ (runtime.cacheWriteTokens ?? 0);
|
||||||
|
}
|
||||||
|
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
|
||||||
|
function roundedIntegerPercent(cacheReadTokens, denominator) {
|
||||||
|
const denominatorQuotient = Math.floor(denominator / 200);
|
||||||
|
const denominatorRemainder = denominator % 200;
|
||||||
|
let lower = 0;
|
||||||
|
let upper = 100;
|
||||||
|
while (lower < upper) {
|
||||||
|
const candidate = Math.floor((lower + upper + 1) / 2);
|
||||||
|
const factor = candidate * 2 - 1;
|
||||||
|
const threshold = factor * denominatorQuotient
|
||||||
|
+ Math.ceil(factor * denominatorRemainder / 200);
|
||||||
|
if (cacheReadTokens >= threshold) {
|
||||||
|
lower = candidate;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
upper = candidate - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export function cacheHitPercent(runtime) {
|
||||||
|
const denominator = billedInputTokens(runtime);
|
||||||
|
if (denominator === 0)
|
||||||
|
return null;
|
||||||
|
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0);
|
||||||
|
const read = runtime.cacheReadTokens ?? 0;
|
||||||
|
if (missed === 0)
|
||||||
|
return '100';
|
||||||
|
const integerPercent = roundedIntegerPercent(read, denominator);
|
||||||
|
if (integerPercent < 100)
|
||||||
|
return String(integerPercent);
|
||||||
|
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
|
||||||
|
let decimalPlaces = 1;
|
||||||
|
let scaledDoubleGap = missed * 200;
|
||||||
|
const denominatorTens = Math.floor(denominator / 10);
|
||||||
|
while (scaledDoubleGap <= denominatorTens) {
|
||||||
|
scaledDoubleGap *= 10;
|
||||||
|
decimalPlaces += 1;
|
||||||
|
}
|
||||||
|
const denominatorOnes = denominator % 10;
|
||||||
|
let roundedLoss = 5;
|
||||||
|
for (let loss = 1; loss < 5; loss += 1) {
|
||||||
|
const factor = loss * 2 + 1;
|
||||||
|
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10);
|
||||||
|
if (scaledDoubleGap <= threshold) {
|
||||||
|
roundedLoss = loss;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export function formatStatusText(info) {
|
||||||
|
const lines = ['会话状态'];
|
||||||
|
lines.push(`会话:${info.label || info.sessionId}`);
|
||||||
|
lines.push(`会话 ID:${info.sessionId}`);
|
||||||
|
const ws = info.workspace;
|
||||||
|
if (ws?.title || ws?.path) {
|
||||||
|
const primary = ws.title && ws.path
|
||||||
|
? `${ws.title}(${ws.path})`
|
||||||
|
: (ws.title ?? ws.path ?? '');
|
||||||
|
lines.push(`工作区:${primary}`);
|
||||||
|
}
|
||||||
|
const model = info.model;
|
||||||
|
if (model?.provider && model?.model) {
|
||||||
|
lines.push(`模型:${model.provider}/${model.model}`);
|
||||||
|
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`);
|
||||||
|
}
|
||||||
|
const runtime = info.runtime;
|
||||||
|
if (runtime !== undefined) {
|
||||||
|
const contextUsed = runtime.contextUsed;
|
||||||
|
const window = runtime.contextWindow;
|
||||||
|
if (contextUsed !== undefined && window !== undefined && window > 0) {
|
||||||
|
const percent = Math.min(100, Math.round(contextUsed / window * 100));
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`);
|
||||||
|
}
|
||||||
|
else if (contextUsed !== undefined) {
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`);
|
||||||
|
}
|
||||||
|
const stats = [];
|
||||||
|
if ((runtime.steps ?? 0) > 0) {
|
||||||
|
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`);
|
||||||
|
const durations = [];
|
||||||
|
if ((runtime.llmMs ?? 0) > 0)
|
||||||
|
durations.push(`LLM ${formatDuration(runtime.llmMs)}`);
|
||||||
|
if ((runtime.toolMs ?? 0) > 0)
|
||||||
|
durations.push(`工具调用 ${formatDuration(runtime.toolMs)}`);
|
||||||
|
if (durations.length > 0)
|
||||||
|
stats.push(durations.join(' · '));
|
||||||
|
const speeds = [];
|
||||||
|
if ((runtime.ttftSteps ?? 0) > 0) {
|
||||||
|
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps)}`);
|
||||||
|
}
|
||||||
|
if ((runtime.decodeMs ?? 0) > 0) {
|
||||||
|
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs / 1_000))} tok/s`);
|
||||||
|
}
|
||||||
|
if (speeds.length > 0)
|
||||||
|
stats.push(speeds.join(' · '));
|
||||||
|
}
|
||||||
|
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
|
||||||
|
const hit = cacheHitPercent(runtime);
|
||||||
|
const billing = [
|
||||||
|
hit === null ? '' : `缓存命中 ${hit}%`,
|
||||||
|
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
stats.push(billing);
|
||||||
|
}
|
||||||
|
if (stats.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('统计(与 Web 底部一致)');
|
||||||
|
lines.push(...stats.map((s) => ` ${s}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.live === false) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
+345
-28
@@ -28,13 +28,19 @@ import { MSG, LAST_CB, parseCommand } from './commands.js'
|
|||||||
import { markdownToHtml, splitMessage } from './format.js'
|
import { markdownToHtml, splitMessage } from './format.js'
|
||||||
import { splitRichMarkdown } from './rich-format.js'
|
import { splitRichMarkdown } from './rich-format.js'
|
||||||
import { formatLastTurn, loadLastTurn } from './history.js'
|
import { formatLastTurn, loadLastTurn } from './history.js'
|
||||||
import { describeAgent, displayLabel } from './label.js'
|
import { describeAgent, displayLabel, workspaceName } from './label.js'
|
||||||
import {
|
import {
|
||||||
formatModel,
|
formatModel,
|
||||||
loadSessionModels,
|
loadSessionModels,
|
||||||
selectSessionModel,
|
selectSessionModel,
|
||||||
type ModelOption,
|
type ModelOption,
|
||||||
} from './models.js'
|
} from './models.js'
|
||||||
|
import {
|
||||||
|
formatStatusText,
|
||||||
|
type StatusInfo,
|
||||||
|
type StatusRuntime,
|
||||||
|
type StatusWorkspace,
|
||||||
|
} from './status.js'
|
||||||
|
|
||||||
export interface TelegramBridgeOptions {
|
export interface TelegramBridgeOptions {
|
||||||
token: string
|
token: string
|
||||||
@@ -113,6 +119,38 @@ interface ApprovalRequestLike {
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ── /status 与 /compact 需要的宿主缝隙(结构镜像,避免引入额外依赖)── */
|
||||||
|
|
||||||
|
/** 会话投影注册表(只读使用 snapshot):对齐 Web 底部统计条的数据源。 */
|
||||||
|
interface ProjectionRegistryLike {
|
||||||
|
snapshot?: (session: unknown) => { asOfSeq: number; values: Record<string, unknown> } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动压缩引擎(结构镜像 ctx.compaction.compactNow)。 */
|
||||||
|
interface CompactionEngineLike {
|
||||||
|
compactNow?: (
|
||||||
|
agent: unknown,
|
||||||
|
signal: AbortSignal,
|
||||||
|
sourceCommandId?: string,
|
||||||
|
) => Promise<{
|
||||||
|
shadowedSeqs: number[]
|
||||||
|
shadowedTokenCount: number
|
||||||
|
summarySeq: number
|
||||||
|
} | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动压缩的预期失败类(ManualCompactionError.code 的运行时字符串)。 */
|
||||||
|
type CompactFailureCode = 'busy' | 'cancelled' | 'changed' | 'summary' | 'commit' | 'persistence'
|
||||||
|
|
||||||
|
function compactErrorCode(err: unknown): CompactFailureCode | undefined {
|
||||||
|
const code = (err as { code?: unknown } | undefined)?.code
|
||||||
|
if (code === 'busy' || code === 'cancelled' || code === 'changed'
|
||||||
|
|| code === 'summary' || code === 'commit' || code === 'persistence') {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const WS_CB = 'ws:'
|
const WS_CB = 'ws:'
|
||||||
const SID_CB = 'sid:'
|
const SID_CB = 'sid:'
|
||||||
const BACK_WS_CB = 'wb'
|
const BACK_WS_CB = 'wb'
|
||||||
@@ -150,7 +188,12 @@ export class TelegramBridge {
|
|||||||
private readonly client: TelegramClientLike
|
private readonly client: TelegramClientLike
|
||||||
private readonly sleep: (ms: number) => Promise<void>
|
private readonly sleep: (ms: number) => Promise<void>
|
||||||
private readonly maxMessageLength: number
|
private readonly maxMessageLength: number
|
||||||
|
/** Global rendering mode: follows config; flipped to 'html' permanently when the
|
||||||
|
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
|
||||||
private renderingMode: 'rich' | 'html'
|
private renderingMode: 'rich' | 'html'
|
||||||
|
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
|
||||||
|
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
|
||||||
|
private readonly renderPrefs = new Map<string, 'rich' | 'html'>()
|
||||||
|
|
||||||
private readonly bindings = new Map<string, Binding>()
|
private readonly bindings = new Map<string, Binding>()
|
||||||
private readonly pickers = new Map<string, PickerState>()
|
private readonly pickers = new Map<string, PickerState>()
|
||||||
@@ -179,6 +222,10 @@ export class TelegramBridge {
|
|||||||
private readonly callNames = new Map<string, string>()
|
private readonly callNames = new Map<string, string>()
|
||||||
/** sessionId → latest todo snapshot (/mission) */
|
/** sessionId → latest todo snapshot (/mission) */
|
||||||
private readonly lastTodos = new Map<string, Array<{ content?: string; status?: string }>>()
|
private readonly lastTodos = new Map<string, Array<{ content?: string; status?: string }>>()
|
||||||
|
/** sessionId → in-flight manual compaction (dedup /compact) */
|
||||||
|
private readonly compacting = new Set<string>()
|
||||||
|
/** sessionId → compaction abort controller (cancelled on bridge stop) */
|
||||||
|
private readonly compactAborts = new Map<string, AbortController>()
|
||||||
|
|
||||||
constructor(ctx: Context, options: TelegramBridgeOptions) {
|
constructor(ctx: Context, options: TelegramBridgeOptions) {
|
||||||
this.ctx = ctx
|
this.ctx = ctx
|
||||||
@@ -190,7 +237,14 @@ export class TelegramBridge {
|
|||||||
})
|
})
|
||||||
this.sleep = options.sleep ?? defaultSleep
|
this.sleep = options.sleep ?? defaultSleep
|
||||||
this.maxMessageLength = options.maxMessageLength ?? 4096
|
this.maxMessageLength = options.maxMessageLength ?? 4096
|
||||||
this.renderingMode = options.rendering === 'html' ? 'html' : 'rich'
|
// Compat-first default: only an explicit `rendering: 'rich'` opts into rich
|
||||||
|
// messages, because old Telegram clients (10.x) cannot render them at all.
|
||||||
|
this.renderingMode = options.rendering === 'rich' ? 'rich' : 'html'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
|
||||||
|
private modeFor(chatId: number): 'rich' | 'html' {
|
||||||
|
return this.renderPrefs.get(String(chatId)) ?? this.renderingMode
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -213,7 +267,9 @@ export class TelegramBridge {
|
|||||||
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
{ command: 'last', description: '查看上次问答(续接上下文)' },
|
{ command: 'last', description: '查看上次问答(续接上下文)' },
|
||||||
{ command: 'model', description: '切换当前绑定会话的模型' },
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '查看当前绑定' },
|
{ command: 'status', description: '会话状态:模型/上下文/统计(首token/速率/tokens)' },
|
||||||
|
{ command: 'compact', description: '压缩当前会话历史(缩短上下文,空闲时执行)' },
|
||||||
|
{ command: 'rich', description: '渲染:on 富文本 / off HTML 兼容(本聊天,需新版客户端)' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'stop', description: '中止当前正在运行的任务' },
|
{ command: 'stop', description: '中止当前正在运行的任务' },
|
||||||
{ command: 'mission', description: '查看任务清单与完成情况' },
|
{ command: 'mission', description: '查看任务清单与完成情况' },
|
||||||
@@ -241,6 +297,7 @@ export class TelegramBridge {
|
|||||||
this.disposeSessionListener = undefined
|
this.disposeSessionListener = undefined
|
||||||
// Never dispose host agents — only clear remote bindings.
|
// Never dispose host agents — only clear remote bindings.
|
||||||
this.bindings.clear()
|
this.bindings.clear()
|
||||||
|
this.renderPrefs.clear()
|
||||||
this.pickers.clear()
|
this.pickers.clear()
|
||||||
this.pendingModels.clear()
|
this.pendingModels.clear()
|
||||||
this.stopAllHeartbeats()
|
this.stopAllHeartbeats()
|
||||||
@@ -251,6 +308,9 @@ export class TelegramBridge {
|
|||||||
this.thinkingSessions.clear()
|
this.thinkingSessions.clear()
|
||||||
this.callNames.clear()
|
this.callNames.clear()
|
||||||
this.lastTodos.clear()
|
this.lastTodos.clear()
|
||||||
|
for (const controller of this.compactAborts.values()) controller.abort()
|
||||||
|
this.compactAborts.clear()
|
||||||
|
this.compacting.clear()
|
||||||
if (this.hookTimer) {
|
if (this.hookTimer) {
|
||||||
clearTimeout(this.hookTimer)
|
clearTimeout(this.hookTimer)
|
||||||
this.hookTimer = undefined
|
this.hookTimer = undefined
|
||||||
@@ -305,6 +365,12 @@ export class TelegramBridge {
|
|||||||
case 'status':
|
case 'status':
|
||||||
await this.sendStatus(chatId)
|
await this.sendStatus(chatId)
|
||||||
return
|
return
|
||||||
|
case 'compact':
|
||||||
|
await this.requestCompact(chatId)
|
||||||
|
return
|
||||||
|
case 'rich':
|
||||||
|
await this.handleRichCommand(chatId, parsed.arg)
|
||||||
|
return
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
this.bindings.delete(String(chatId))
|
this.bindings.delete(String(chatId))
|
||||||
this.pickers.delete(String(chatId))
|
this.pickers.delete(String(chatId))
|
||||||
@@ -732,12 +798,215 @@ export class TelegramBridge {
|
|||||||
await this.client.sendMessage(chatId, MSG.STATUS_NONE)
|
await this.client.sendMessage(chatId, MSG.STATUS_NONE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const stillLive = this.findLiveAgent(binding.sessionId)
|
const info: StatusInfo = {
|
||||||
if (!stillLive) {
|
label: binding.label,
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label))
|
sessionId: binding.sessionId,
|
||||||
|
}
|
||||||
|
// 冷会话:不强制 resume(只读查询),给出可用的绑定/工作区信息即可。
|
||||||
|
const agent = this.findLiveAgent(binding.sessionId)
|
||||||
|
if (!agent) {
|
||||||
|
info.live = false
|
||||||
|
info.workspace = await this.workspaceOf(binding.sessionId, undefined)
|
||||||
|
await this.client.sendMessage(chatId, formatStatusText(info))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label))
|
info.live = true
|
||||||
|
info.workspace = await this.workspaceOf(binding.sessionId, agent)
|
||||||
|
try {
|
||||||
|
const snap = await loadSessionModels(this.ctx, binding.sessionId)
|
||||||
|
if (snap.current) {
|
||||||
|
info.model = {
|
||||||
|
provider: snap.current.provider,
|
||||||
|
model: snap.current.model,
|
||||||
|
reasoningEffort: snap.current.reasoningEffort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /status model read failed: ${this.redact(err)}`)
|
||||||
|
}
|
||||||
|
info.runtime = this.readRuntimeValues(agent)
|
||||||
|
await this.client.sendMessage(chatId, formatStatusText(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
|
||||||
|
private async workspaceOf(sessionId: string, agent: Agent | undefined): Promise<StatusWorkspace | undefined> {
|
||||||
|
try {
|
||||||
|
const catalog = await this.resolveCatalog()
|
||||||
|
const ws = (catalog?.workspaces ?? []).find((w) => (w.sessionIds ?? []).includes(sessionId))
|
||||||
|
if (ws) {
|
||||||
|
return { title: ws.title || undefined, path: ws.path || undefined }
|
||||||
|
}
|
||||||
|
const row = catalog?.sessionsById?.get(sessionId)
|
||||||
|
if (row?.cwd) {
|
||||||
|
return { title: workspaceName(row.cwd) || undefined, path: row.cwd }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 目录不可用 → 继续走 agent 侧兜底
|
||||||
|
}
|
||||||
|
if (agent) {
|
||||||
|
const parts = describeAgent(agent, 0, this.ctx)
|
||||||
|
if (parts.cwd) {
|
||||||
|
return { title: parts.workspace || undefined, path: parts.cwd }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
|
||||||
|
private readRuntimeValues(agent: Agent): StatusRuntime | undefined {
|
||||||
|
const registry = this.serviceOf<ProjectionRegistryLike>('sessionProjections')
|
||||||
|
let values: Record<string, unknown> | undefined
|
||||||
|
if (registry?.snapshot) {
|
||||||
|
try {
|
||||||
|
values = registry.snapshot(agent.session)?.values
|
||||||
|
} catch (err) {
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: projection snapshot failed: ${this.redact(err)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!values) return undefined
|
||||||
|
|
||||||
|
const num = (v: unknown): number | undefined =>
|
||||||
|
typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||||
|
const stats = (values['sessionStats'] ?? {}) as Record<string, unknown>
|
||||||
|
const usage = (values['tokenUsage'] ?? {}) as Record<string, unknown>
|
||||||
|
const pressure = (values['contextPressure'] ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
const runtime: StatusRuntime = {
|
||||||
|
turns: num(stats.turns),
|
||||||
|
steps: num(stats.steps),
|
||||||
|
llmMs: num(stats.llmMs),
|
||||||
|
toolMs: num(stats.toolMs),
|
||||||
|
ttftMs: num(stats.ttftMs),
|
||||||
|
ttftSteps: num(stats.ttftSteps),
|
||||||
|
decodeMs: num(stats.decodeMs),
|
||||||
|
decodeTokens: num(stats.decodeTokens),
|
||||||
|
uncachedInputTokens: num(usage.uncachedInputTokens),
|
||||||
|
cacheReadTokens: num(usage.cacheReadTokens),
|
||||||
|
cacheWriteTokens: num(usage.cacheWriteTokens),
|
||||||
|
outputTokens: num(usage.outputTokens),
|
||||||
|
contextUsed: num(pressure.projectedTokens) ?? num(pressure.pressureTokens),
|
||||||
|
contextWindow: num(pressure.contextWindow),
|
||||||
|
}
|
||||||
|
// 完全无数据时视为无统计块。
|
||||||
|
const hasAny = Object.values(runtime).some((v) => v !== undefined)
|
||||||
|
return hasAny ? runtime : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
||||||
|
private serviceOf<T>(name: string): T | undefined {
|
||||||
|
const c = this.ctx as { get?: (name: string, strict?: boolean) => unknown }
|
||||||
|
if (typeof c.get === 'function') {
|
||||||
|
try {
|
||||||
|
const via = c.get(name, false)
|
||||||
|
if (via) return via as T
|
||||||
|
} catch {
|
||||||
|
// 服务未装配或当前 ctx 不可解析
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(this.ctx, name)) {
|
||||||
|
return (this.ctx as unknown as Record<string, unknown>)[name] as T
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// proxy ctx:无 inject 直接读属性会抛错,忽略
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /compact:手动压缩当前绑定会话(需会话空闲)──
|
||||||
|
|
||||||
|
private async requestCompact(chatId: number): Promise<void> {
|
||||||
|
const binding = this.bindings.get(String(chatId))
|
||||||
|
if (!binding) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const engine = this.serviceOf<CompactionEngineLike>('compaction')
|
||||||
|
if (!engine?.compactNow) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_UNAVAILABLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.compacting.has(binding.sessionId)) {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_INFLIGHT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const agent = await this.ensureLiveAgent(binding.sessionId)
|
||||||
|
if (!agent) {
|
||||||
|
this.bindings.delete(String(chatId))
|
||||||
|
await this.client.sendMessage(chatId, MSG.GONE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
let operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>
|
||||||
|
try {
|
||||||
|
// compactNow 在会话忙时同步抛 busy(runMaintenance 同步判定)。
|
||||||
|
operation = engine.compactNow(agent as never, controller.signal)
|
||||||
|
} catch (err) {
|
||||||
|
const code = compactErrorCode(err)
|
||||||
|
if (code === 'busy') {
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_BUSY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /compact rejected: ${this.redact(err)}`)
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_FAILED(this.redact(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.compacting.add(binding.sessionId)
|
||||||
|
this.compactAborts.set(binding.sessionId, controller)
|
||||||
|
await this.client.sendMessage(chatId, MSG.COMPACT_STARTED)
|
||||||
|
void this.runCompaction(chatId, binding.sessionId, operation, controller).catch((err) => {
|
||||||
|
this.ctx.logger.error(`dsh-telegram-channel: /compact runner crashed: ${this.redact(err)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
|
private async runCompaction(
|
||||||
|
chatId: number,
|
||||||
|
sessionId: string,
|
||||||
|
operation: Promise<{ shadowedSeqs: number[]; shadowedTokenCount: number; summarySeq: number } | null>,
|
||||||
|
controller: AbortController,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await operation
|
||||||
|
if (result === null) {
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_NOTHING)
|
||||||
|
} else {
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_DONE(result.shadowedSeqs.length, result.shadowedTokenCount))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
// 桥停止/热重载导致的取消:静默,不打扰(本实例正在退出)。
|
||||||
|
this.ctx.logger.info('dsh-telegram-channel: compaction aborted by bridge stop')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = compactErrorCode(err)
|
||||||
|
switch (code) {
|
||||||
|
case 'cancelled':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_CANCELLED)
|
||||||
|
return
|
||||||
|
case 'changed':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_CHANGED)
|
||||||
|
return
|
||||||
|
case 'summary':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_SUMMARY_FAILED)
|
||||||
|
return
|
||||||
|
case 'commit':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_COMMIT_FAILED)
|
||||||
|
return
|
||||||
|
case 'persistence':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_PERSIST_FAILED)
|
||||||
|
return
|
||||||
|
case 'busy':
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_BUSY)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
this.ctx.logger.warn(`dsh-telegram-channel: /compact failed: ${this.redact(err)}`)
|
||||||
|
await this.enqueueNotice(chatId, MSG.COMPACT_FAILED(this.redact(err)))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.compacting.delete(sessionId)
|
||||||
|
this.compactAborts.delete(sessionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async followupBound(chatId: number, text: string): Promise<void> {
|
private async followupBound(chatId: number, text: string): Promise<void> {
|
||||||
@@ -920,8 +1189,47 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /rich: per-chat rendering mode (persisted) ──
|
||||||
|
//
|
||||||
|
// Rich messages (Bot API sendRichMessage) only render on recent Telegram
|
||||||
|
// clients; old ones (10.x) show the message as “not supported”. The bot cannot
|
||||||
|
// detect the recipient's client version, so the choice is per-chat and manual,
|
||||||
|
// remembered forever. HTML mode is the compat default and works everywhere.
|
||||||
|
|
||||||
|
private async handleRichCommand(chatId: number, arg?: string): Promise<void> {
|
||||||
|
const key = String(chatId)
|
||||||
|
const setAndReply = async (mode: 'rich' | 'html'): Promise<void> => {
|
||||||
|
this.renderPrefs.set(key, mode)
|
||||||
|
this.saveBindings()
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_SET(mode))
|
||||||
|
}
|
||||||
|
switch (arg) {
|
||||||
|
case 'on':
|
||||||
|
case 'rich':
|
||||||
|
await setAndReply('rich')
|
||||||
|
return
|
||||||
|
case 'off':
|
||||||
|
case 'html':
|
||||||
|
case 'compat':
|
||||||
|
await setAndReply('html')
|
||||||
|
return
|
||||||
|
case 'auto':
|
||||||
|
case 'default':
|
||||||
|
case 'reset':
|
||||||
|
this.renderPrefs.delete(key)
|
||||||
|
this.saveBindings()
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.renderingMode))
|
||||||
|
return
|
||||||
|
case undefined:
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_STATE(this.modeFor(chatId)))
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
await this.client.sendMessage(chatId, MSG.RICH_USAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async deliver(chatId: number, markdown: string): Promise<void> {
|
private async deliver(chatId: number, markdown: string): Promise<void> {
|
||||||
if (this.renderingMode !== 'html') {
|
if (this.modeFor(chatId) !== 'html') {
|
||||||
let sentAny = false
|
let sentAny = false
|
||||||
try {
|
try {
|
||||||
const chunks = splitRichMarkdown(markdown)
|
const chunks = splitRichMarkdown(markdown)
|
||||||
@@ -936,6 +1244,12 @@ export class TelegramBridge {
|
|||||||
'dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering',
|
'dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering',
|
||||||
)
|
)
|
||||||
this.renderingMode = 'html'
|
this.renderingMode = 'html'
|
||||||
|
// Rich API being gone is a server-wide fact: per-chat rich overrides
|
||||||
|
// must not bypass the global downgrade (modeFor prefers the override).
|
||||||
|
for (const [key, mode] of this.renderPrefs) {
|
||||||
|
if (mode === 'rich') this.renderPrefs.delete(key)
|
||||||
|
}
|
||||||
|
this.saveBindings()
|
||||||
await this.deliverHtml(chatId, markdown)
|
await this.deliverHtml(chatId, markdown)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -988,34 +1302,22 @@ export class TelegramBridge {
|
|||||||
throw lastErr
|
throw lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
private escHtml(s: string): string {
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notice; a leading `> ` renders as a Telegram blockquote (HTML).
|
* Send a notice; a leading `> ` keeps the quote look via a plain-text `> `
|
||||||
* Falls back to plain text when the HTML send fails — a notice is never lost.
|
* prefix. NB: NOT `<blockquote>` HTML — older Telegram clients cannot render
|
||||||
|
* the blockquote entity at all and show the whole message as “not supported”.
|
||||||
|
* Falls back to plain text when the send fails — a notice is never lost.
|
||||||
*/
|
*/
|
||||||
private async deliverNotice(chatId: number, text: string): Promise<void> {
|
private async deliverNotice(chatId: number, text: string): Promise<void> {
|
||||||
const quoted = text.startsWith('> ')
|
const quoted = text.startsWith('> ')
|
||||||
const body = quoted ? text.slice(2) : text
|
const body = quoted ? text.slice(2) : text
|
||||||
try {
|
try {
|
||||||
await this.withRetry(() => {
|
await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body))
|
||||||
if (!quoted) return this.client.sendMessage(chatId, body)
|
|
||||||
return this.client.sendMessage(chatId, `<blockquote>${this.escHtml(body)}</blockquote>`, 'HTML')
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await this.client.sendMessage(chatId, quoted ? `> ${body}` : body)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.ctx.logger.error(this.redact(err))
|
this.ctx.logger.error(this.redact(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||||
private enqueueNotice(chatId: number, text: string): Promise<void> {
|
private enqueueNotice(chatId: number, text: string): Promise<void> {
|
||||||
@@ -1408,7 +1710,10 @@ export class TelegramBridge {
|
|||||||
|
|
||||||
private saveBindings(): void {
|
private saveBindings(): void {
|
||||||
try {
|
try {
|
||||||
const obj = Object.fromEntries(this.bindings)
|
const obj = {
|
||||||
|
bindings: Object.fromEntries(this.bindings),
|
||||||
|
renderPrefs: Object.fromEntries(this.renderPrefs),
|
||||||
|
}
|
||||||
mkdirSync(dirname(this.bindingsPath()), { recursive: true })
|
mkdirSync(dirname(this.bindingsPath()), { recursive: true })
|
||||||
writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2))
|
writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1419,9 +1724,16 @@ export class TelegramBridge {
|
|||||||
private loadBindings(): void {
|
private loadBindings(): void {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(this.bindingsPath(), 'utf8')
|
const raw = readFileSync(this.bindingsPath(), 'utf8')
|
||||||
const obj = JSON.parse(raw) as Record<string, { chatId?: number; sessionId?: string; label?: string }>
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
const data = parsed as {
|
||||||
|
bindings?: Record<string, { chatId?: number; sessionId?: string; label?: string }>
|
||||||
|
renderPrefs?: Record<string, string>
|
||||||
|
}
|
||||||
|
// Legacy files (pre-renderPrefs) stored the bindings flat at the top level.
|
||||||
|
const flat = parsed as Record<string, { chatId?: number; sessionId?: string; label?: string }>
|
||||||
|
const bindingEntries = data.bindings ?? flat
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const [chatId, b] of Object.entries(obj ?? {})) {
|
for (const [chatId, b] of Object.entries(bindingEntries ?? {})) {
|
||||||
if (b && b.sessionId) {
|
if (b && b.sessionId) {
|
||||||
this.bindings.set(String(chatId), {
|
this.bindings.set(String(chatId), {
|
||||||
chatId: b.chatId ?? Number(chatId),
|
chatId: b.chatId ?? Number(chatId),
|
||||||
@@ -1431,6 +1743,11 @@ export class TelegramBridge {
|
|||||||
n += 1
|
n += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [chatId, mode] of Object.entries(data.renderPrefs ?? {})) {
|
||||||
|
if (mode === 'rich' || mode === 'html') {
|
||||||
|
this.renderPrefs.set(String(chatId), mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (n > 0)
|
if (n > 0)
|
||||||
this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`)
|
this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+43
-1
@@ -9,7 +9,9 @@ export const MSG = {
|
|||||||
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
'/last — 查看绑定会话的上次问答(便于续接)',
|
'/last — 查看绑定会话的上次问答(便于续接)',
|
||||||
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
|
||||||
|
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
|
||||||
|
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
@@ -36,6 +38,25 @@ export const MSG = {
|
|||||||
STATUS_BOUND_COLD(label: string): string {
|
STATUS_BOUND_COLD(label: string): string {
|
||||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
||||||
},
|
},
|
||||||
|
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
|
||||||
|
COMPACT_UNAVAILABLE: '当前宿主未启用压缩服务(compaction 未装配),无法执行 /compact。',
|
||||||
|
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
|
||||||
|
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
|
||||||
|
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
|
||||||
|
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
|
||||||
|
COMPACT_DONE(n: number, tokens: number): string {
|
||||||
|
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`
|
||||||
|
},
|
||||||
|
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
|
||||||
|
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
|
||||||
|
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
|
||||||
|
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
|
||||||
|
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
|
||||||
|
COMPACT_FAILED(detail?: string): string {
|
||||||
|
const tip = '压缩失败。'
|
||||||
|
if (!detail) return tip
|
||||||
|
return `${tip}\n详情:${detail}`
|
||||||
|
},
|
||||||
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
||||||
MODEL_UNAVAILABLE(detail?: string): string {
|
MODEL_UNAVAILABLE(detail?: string): string {
|
||||||
@@ -52,6 +73,17 @@ export const MSG = {
|
|||||||
MODEL_SET(selected: string): string {
|
MODEL_SET(selected: string): string {
|
||||||
return `已切换模型:${selected}\n下一回合生效。`
|
return `已切换模型:${selected}\n下一回合生效。`
|
||||||
},
|
},
|
||||||
|
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
|
||||||
|
RICH_STATE(mode: 'rich' | 'html'): string {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
|
||||||
|
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。'
|
||||||
|
},
|
||||||
|
RICH_SET(mode: 'rich' | 'html'): string {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
|
||||||
|
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。'
|
||||||
|
},
|
||||||
MODEL_FAILED(detail?: string): string {
|
MODEL_FAILED(detail?: string): string {
|
||||||
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
|
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
|
||||||
if (!detail) return tip
|
if (!detail) return tip
|
||||||
@@ -69,6 +101,8 @@ export type ParsedCommand =
|
|||||||
| { type: 'last'; text: string }
|
| { type: 'last'; text: string }
|
||||||
| { type: 'model'; text: string }
|
| { type: 'model'; text: string }
|
||||||
| { type: 'status'; text: string }
|
| { type: 'status'; text: string }
|
||||||
|
| { type: 'compact'; text: string }
|
||||||
|
| { type: 'rich'; text: string; arg?: string }
|
||||||
| { type: 'unbind'; text: string }
|
| { type: 'unbind'; text: string }
|
||||||
| { type: 'stop'; text: string }
|
| { type: 'stop'; text: string }
|
||||||
| { type: 'mission'; text: string }
|
| { type: 'mission'; text: string }
|
||||||
@@ -96,6 +130,14 @@ export function parseCommand(text: string): ParsedCommand {
|
|||||||
return { type: 'model', text }
|
return { type: 'model', text }
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text }
|
return { type: 'status', text }
|
||||||
|
case '/compact':
|
||||||
|
return { type: 'compact', text }
|
||||||
|
case '/rich':
|
||||||
|
case '/render':
|
||||||
|
case '/setting': {
|
||||||
|
const arg = text.split(/\s+/)[1]?.toLowerCase()
|
||||||
|
return { type: 'rich', text, arg }
|
||||||
|
}
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
case '/disconnect':
|
case '/disconnect':
|
||||||
return { type: 'unbind', text }
|
return { type: 'unbind', text }
|
||||||
|
|||||||
+4
-2
@@ -12,6 +12,8 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean
|
allowAllUsers?: boolean
|
||||||
maxMessageLength?: number
|
maxMessageLength?: number
|
||||||
pollingTimeoutSec?: number
|
pollingTimeoutSec?: number
|
||||||
|
/** 'rich' = Telegram native Rich Messages (needs a recent client);
|
||||||
|
* anything else (default) = HTML compatibility mode that renders on all clients. */
|
||||||
rendering?: string
|
rendering?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
rendering: Schema.string().default('rich'),
|
rendering: Schema.string().default('html'),
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
||||||
@@ -53,7 +55,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
rendering: config.rendering === 'html' ? 'html' : 'rich',
|
rendering: config.rendering === 'rich' ? 'rich' : 'html',
|
||||||
})
|
})
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start()
|
bridge.start()
|
||||||
|
|||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StatusWorkspace {
|
||||||
|
/** 工作区名(basename 或 Web 工作区标题)。 */
|
||||||
|
title?: string
|
||||||
|
/** 完整目录/路径。 */
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModel {
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
|
||||||
|
reasoningEffort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusRuntime {
|
||||||
|
// sessionStats 投影(全量日志)
|
||||||
|
turns?: number
|
||||||
|
steps?: number
|
||||||
|
llmMs?: number
|
||||||
|
toolMs?: number
|
||||||
|
ttftMs?: number
|
||||||
|
ttftSteps?: number
|
||||||
|
decodeMs?: number
|
||||||
|
decodeTokens?: number
|
||||||
|
// tokenUsage 投影(累计计费桶)
|
||||||
|
uncachedInputTokens?: number
|
||||||
|
cacheReadTokens?: number
|
||||||
|
cacheWriteTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
// contextPressure 投影(近次请求)
|
||||||
|
contextUsed?: number
|
||||||
|
contextWindow?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusInfo {
|
||||||
|
/** 绑定时保存的会话展示名。 */
|
||||||
|
label: string
|
||||||
|
/** 完整 SessionId 字符串。 */
|
||||||
|
sessionId: string
|
||||||
|
workspace?: StatusWorkspace
|
||||||
|
model?: StatusModel
|
||||||
|
runtime?: StatusRuntime
|
||||||
|
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
|
||||||
|
live?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFORT_NAMES: Record<string, string> = {
|
||||||
|
off: '关闭(off)',
|
||||||
|
low: '低(low)',
|
||||||
|
medium: '中(medium)',
|
||||||
|
high: '高(high)',
|
||||||
|
max: '最高(max)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effortLabel(effort?: string): string | undefined {
|
||||||
|
if (!effort) return undefined
|
||||||
|
return EFFORT_NAMES[effort] ?? String(effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatTokens(n: number): string {
|
||||||
|
const scaled = (v: number): string =>
|
||||||
|
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
||||||
|
if (n < 1_000) return String(n)
|
||||||
|
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
|
||||||
|
return `${scaled(n / 1_000_000)}M`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const s = ms / 1_000
|
||||||
|
if (s < 60) return `${Math.round(s * 10) / 10}s`
|
||||||
|
const whole = Math.round(s)
|
||||||
|
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export function formatTokensPerSecond(tps: number): string {
|
||||||
|
const clamped = Math.max(0, tps)
|
||||||
|
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export function billedInputTokens(runtime: StatusRuntime): number {
|
||||||
|
return (runtime.uncachedInputTokens ?? 0)
|
||||||
|
+ (runtime.cacheReadTokens ?? 0)
|
||||||
|
+ (runtime.cacheWriteTokens ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
|
||||||
|
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
|
||||||
|
const denominatorQuotient = Math.floor(denominator / 200)
|
||||||
|
const denominatorRemainder = denominator % 200
|
||||||
|
let lower = 0
|
||||||
|
let upper = 100
|
||||||
|
while (lower < upper) {
|
||||||
|
const candidate = Math.floor((lower + upper + 1) / 2)
|
||||||
|
const factor = candidate * 2 - 1
|
||||||
|
const threshold = factor * denominatorQuotient
|
||||||
|
+ Math.ceil(factor * denominatorRemainder / 200)
|
||||||
|
if (cacheReadTokens >= threshold) {
|
||||||
|
lower = candidate
|
||||||
|
} else {
|
||||||
|
upper = candidate - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export function cacheHitPercent(runtime: StatusRuntime): string | null {
|
||||||
|
const denominator = billedInputTokens(runtime)
|
||||||
|
if (denominator === 0) return null
|
||||||
|
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0)
|
||||||
|
const read = runtime.cacheReadTokens ?? 0
|
||||||
|
if (missed === 0) return '100'
|
||||||
|
|
||||||
|
const integerPercent = roundedIntegerPercent(read, denominator)
|
||||||
|
if (integerPercent < 100) return String(integerPercent)
|
||||||
|
|
||||||
|
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
|
||||||
|
let decimalPlaces = 1
|
||||||
|
let scaledDoubleGap = missed * 200
|
||||||
|
const denominatorTens = Math.floor(denominator / 10)
|
||||||
|
while (scaledDoubleGap <= denominatorTens) {
|
||||||
|
scaledDoubleGap *= 10
|
||||||
|
decimalPlaces += 1
|
||||||
|
}
|
||||||
|
const denominatorOnes = denominator % 10
|
||||||
|
let roundedLoss = 5
|
||||||
|
for (let loss = 1; loss < 5; loss += 1) {
|
||||||
|
const factor = loss * 2 + 1
|
||||||
|
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
|
||||||
|
if (scaledDoubleGap <= threshold) {
|
||||||
|
roundedLoss = loss
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export function formatStatusText(info: StatusInfo): string {
|
||||||
|
const lines: string[] = ['会话状态']
|
||||||
|
|
||||||
|
lines.push(`会话:${info.label || info.sessionId}`)
|
||||||
|
lines.push(`会话 ID:${info.sessionId}`)
|
||||||
|
|
||||||
|
const ws = info.workspace
|
||||||
|
if (ws?.title || ws?.path) {
|
||||||
|
const primary = ws.title && ws.path
|
||||||
|
? `${ws.title}(${ws.path})`
|
||||||
|
: (ws.title ?? ws.path ?? '')
|
||||||
|
lines.push(`工作区:${primary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = info.model
|
||||||
|
if (model?.provider && model?.model) {
|
||||||
|
lines.push(`模型:${model.provider}/${model.model}`)
|
||||||
|
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = info.runtime
|
||||||
|
if (runtime !== undefined) {
|
||||||
|
const contextUsed = runtime.contextUsed
|
||||||
|
const window = runtime.contextWindow
|
||||||
|
if (contextUsed !== undefined && window !== undefined && window > 0) {
|
||||||
|
const percent = Math.min(100, Math.round(contextUsed / window * 100))
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`)
|
||||||
|
} else if (contextUsed !== undefined) {
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: string[] = []
|
||||||
|
if ((runtime.steps ?? 0) > 0) {
|
||||||
|
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`)
|
||||||
|
const durations: string[] = []
|
||||||
|
if ((runtime.llmMs ?? 0) > 0) durations.push(`LLM ${formatDuration(runtime.llmMs!)}`)
|
||||||
|
if ((runtime.toolMs ?? 0) > 0) durations.push(`工具调用 ${formatDuration(runtime.toolMs!)}`)
|
||||||
|
if (durations.length > 0) stats.push(durations.join(' · '))
|
||||||
|
const speeds: string[] = []
|
||||||
|
if ((runtime.ttftSteps ?? 0) > 0) {
|
||||||
|
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps!)}`)
|
||||||
|
}
|
||||||
|
if ((runtime.decodeMs ?? 0) > 0) {
|
||||||
|
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs! / 1_000))} tok/s`)
|
||||||
|
}
|
||||||
|
if (speeds.length > 0) stats.push(speeds.join(' · '))
|
||||||
|
}
|
||||||
|
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
|
||||||
|
const hit = cacheHitPercent(runtime)
|
||||||
|
const billing = [
|
||||||
|
hit === null ? '' : `缓存命中 ${hit}%`,
|
||||||
|
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
stats.push(billing)
|
||||||
|
}
|
||||||
|
if (stats.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('统计(与 Web 底部一致)')
|
||||||
|
lines.push(...stats.map((s) => ` ${s}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.live === false) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { rmSync } from 'node:fs'
|
||||||
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
import { TelegramBridge } from '../src/bridge.ts'
|
import { TelegramBridge } from '../src/bridge.ts'
|
||||||
@@ -649,3 +650,395 @@ test('/unbind clears binding without needing create/dispose', async () => {
|
|||||||
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
|
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
|
||||||
assert.equal(followups.length, 0)
|
assert.equal(followups.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── /rich: per-chat rendering mode (rich messages need a recent client) ──
|
||||||
|
|
||||||
|
function renderHarness(
|
||||||
|
id: string,
|
||||||
|
sent: SentMessage[],
|
||||||
|
rich: string[],
|
||||||
|
followups: UserMessage[],
|
||||||
|
): {
|
||||||
|
bridge: TelegramBridge
|
||||||
|
emitAssistant: (text: string) => Promise<void>
|
||||||
|
} {
|
||||||
|
const agent = makeAgent(id, followups)
|
||||||
|
let sessionListener: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (sid: ReturnType<typeof SessionId>) => (String(sid) === id ? agent : undefined),
|
||||||
|
},
|
||||||
|
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
|
||||||
|
if (event === 'session/event') sessionListener = listener
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent, {
|
||||||
|
sendRichMessage: async (chatId, markdown) => {
|
||||||
|
rich.push(markdown)
|
||||||
|
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text: markdown }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
bridge.start()
|
||||||
|
const emitAssistant = async (text: string): Promise<void> => {
|
||||||
|
await sessionListener?.(
|
||||||
|
{ id: SessionId(id) },
|
||||||
|
{ type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } },
|
||||||
|
)
|
||||||
|
// deliver() chains async work past the listener's void return — flush.
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
}
|
||||||
|
return { bridge, emitAssistant }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('default rendering is HTML compat (no rich messages unless /rich on)', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const rich: string[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const bindingsFile = `/tmp/dsh-tg-test-bindings-default.json`
|
||||||
|
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
|
||||||
|
try {
|
||||||
|
const { bridge, emitAssistant } = renderHarness('live-render-1', sent, rich, followups)
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}live-render-1`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await emitAssistant('default-mode reply')
|
||||||
|
assert.equal(rich.length, 0, 'no rich message without /rich on')
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('default-mode reply')))
|
||||||
|
await bridge.stop()
|
||||||
|
} finally {
|
||||||
|
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
|
||||||
|
rmSync(bindingsFile, { force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/rich on routes replies to sendRichMessage; /rich off reverts to HTML; status reports state', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const rich: string[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const bindingsFile = `/tmp/dsh-tg-test-bindings-rich.json`
|
||||||
|
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
|
||||||
|
try {
|
||||||
|
const { bridge, emitAssistant } = renderHarness('live-render-2', sent, rich, followups)
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}live-render-2`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Default: HTML compat → no rich send yet.
|
||||||
|
await emitAssistant('before toggle')
|
||||||
|
assert.equal(rich.length, 0)
|
||||||
|
|
||||||
|
// /rich (no arg) reports current state.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich', 2))
|
||||||
|
assert.match(sent.at(-1)!.text, /HTML 兼容/)
|
||||||
|
|
||||||
|
// /rich on → assistant replies go through sendRichMessage.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 3))
|
||||||
|
assert.match(sent.at(-1)!.text, /已切换为富文本/)
|
||||||
|
await emitAssistant('rich-mode reply')
|
||||||
|
assert.equal(rich.length, 1)
|
||||||
|
assert.match(rich[0]!, /rich-mode reply/)
|
||||||
|
|
||||||
|
// /rich on again is idempotent (still one preference, no dupes).
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 4))
|
||||||
|
assert.ok(!sent.at(-1)!.text.includes('已切换') || rich.length === 1)
|
||||||
|
|
||||||
|
// /rich off → back to HTML compat path.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich off', 5))
|
||||||
|
assert.match(sent.at(-1)!.text, /已切换为 HTML/)
|
||||||
|
await emitAssistant('compat-mode reply')
|
||||||
|
assert.equal(rich.length, 1, 'no new rich send after /rich off')
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('compat-mode reply')))
|
||||||
|
|
||||||
|
await bridge.stop()
|
||||||
|
} finally {
|
||||||
|
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
|
||||||
|
rmSync(bindingsFile, { force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── /status 通用状态显示(对齐 Web 底部统计条)──
|
||||||
|
|
||||||
|
test('/status shows bind/session/workspace/model/effort/context and web-style stats', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-st'
|
||||||
|
const agent = makeAgent(sessionId, followups, { cwd: '/work/proj-a', title: '演示任务' })
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
apiProxy: {
|
||||||
|
workspace: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{
|
||||||
|
workspaceId: 'w1',
|
||||||
|
path: '/work/proj-a',
|
||||||
|
title: 'Proj A',
|
||||||
|
sessionIds: [sessionId],
|
||||||
|
}],
|
||||||
|
archivedSessionIds: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{
|
||||||
|
sessionId,
|
||||||
|
updatedAt: 1,
|
||||||
|
running: true,
|
||||||
|
blank: false,
|
||||||
|
cwd: '/work/proj-a',
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
models: async () => rpcOk({
|
||||||
|
current: { provider: 'deepseek', model: 'reasoner', reasoningEffort: 'high' },
|
||||||
|
routable: true,
|
||||||
|
groups: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionProjections: {
|
||||||
|
snapshot: () => ({
|
||||||
|
asOfSeq: 0,
|
||||||
|
values: {
|
||||||
|
sessionStats: {
|
||||||
|
turns: 3,
|
||||||
|
steps: 5,
|
||||||
|
llmMs: 4200,
|
||||||
|
toolMs: 2100,
|
||||||
|
ttftMs: 1500,
|
||||||
|
ttftSteps: 5,
|
||||||
|
decodeMs: 6000,
|
||||||
|
decodeTokens: 3000,
|
||||||
|
},
|
||||||
|
tokenUsage: {
|
||||||
|
uncachedInputTokens: 3000,
|
||||||
|
cacheReadTokens: 21000,
|
||||||
|
cacheWriteTokens: 500,
|
||||||
|
outputTokens: 9000,
|
||||||
|
},
|
||||||
|
contextPressure: {
|
||||||
|
contextWindow: 131072,
|
||||||
|
projectedTokens: 64536,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
// bind via legacy callback, then ask for status
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/status', 2))
|
||||||
|
const text = sent.at(-1)!.text
|
||||||
|
assert.ok(text.includes('会话 ID:live-st'), text)
|
||||||
|
assert.ok(text.includes('工作区:Proj A(/work/proj-a)'), text)
|
||||||
|
assert.ok(text.includes('模型:deepseek/reasoner'), text)
|
||||||
|
assert.ok(text.includes('思考强度:高(high)'), text)
|
||||||
|
assert.ok(text.includes('上下文:64.5K / 131K tokens(49%)'), text)
|
||||||
|
assert.ok(text.includes('3 轮 · 5 步'), text)
|
||||||
|
assert.ok(text.includes('首 token 平均 0.3s'), text)
|
||||||
|
assert.ok(text.includes('500 tok/s'), text)
|
||||||
|
assert.ok(text.includes('缓存命中'), text)
|
||||||
|
assert.ok(text.includes('输入 24.5K tok · 输出 9K tok'), text)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/status without bind prompts NEED_BIND', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: { list: () => [], roots: () => [], get: () => undefined },
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/status'))
|
||||||
|
assert.equal(sent[0]?.text, MSG.STATUS_NONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── /compact 手动压缩 ──
|
||||||
|
|
||||||
|
test('/compact compacts bound session and reports result', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-cc'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
compaction: {
|
||||||
|
compactNow: async () => ({
|
||||||
|
shadowedSeqs: [1, 2, 3, 4],
|
||||||
|
shadowedTokenCount: 12_345,
|
||||||
|
summarySeq: 9,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
|
||||||
|
// runCompaction runs detached; wait for its result notice
|
||||||
|
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes('压缩完成')); i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('压缩完成')), 'compaction result notice arrives')
|
||||||
|
assert.ok(
|
||||||
|
sent.some((m) => m.text.includes('4 条历史记录(约 12345 tokens)')),
|
||||||
|
'result mentions shadowed range',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/compact busy agent replies COMPACT_BUSY without starting', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-busy'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
compaction: {
|
||||||
|
compactNow: () => {
|
||||||
|
throw Object.assign(new Error('busy'), { code: 'busy' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
sent.length = 0 // drop the BOUND message; keep only /compact replies
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
|
||||||
|
assert.equal(sent.length, 1)
|
||||||
|
assert.equal(sent[0]!.text, MSG.COMPACT_BUSY)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/compact without compaction engine reports unavailable', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-noeng'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
|
||||||
|
assert.equal(sent.at(-1)!.text, MSG.COMPACT_UNAVAILABLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/compact without bind prompts NEED_BIND', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: { list: () => [], roots: () => [], get: () => undefined },
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/compact'))
|
||||||
|
assert.equal(sent[0]?.text, MSG.NEED_BIND)
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ test('parse slash commands for remote control', () => {
|
|||||||
assert.equal(parseCommand('hello').type, 'plain')
|
assert.equal(parseCommand('hello').type, 'plain')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parse /rich with optional mode argument', () => {
|
||||||
|
const bare = parseCommand('/rich')
|
||||||
|
assert.equal(bare.type, 'rich')
|
||||||
|
assert.equal(bare.type === 'rich' ? bare.arg : 'x', undefined)
|
||||||
|
const on = parseCommand('/rich on')
|
||||||
|
assert.equal(on.type, 'rich')
|
||||||
|
assert.equal(on.type === 'rich' ? on.arg : 'x', 'on')
|
||||||
|
assert.equal(parseCommand('/rich off').type === 'rich' && parseCommand('/rich off').arg, 'off')
|
||||||
|
assert.equal(parseCommand('/rich ON').type === 'rich' && parseCommand('/rich ON').arg, 'on')
|
||||||
|
assert.equal(parseCommand('/render auto').type === 'rich' && parseCommand('/render auto').arg, 'auto')
|
||||||
|
assert.equal(parseCommand('/setting').type, 'rich')
|
||||||
|
assert.equal(parseCommand('/setting html').type === 'rich' && parseCommand('/setting html').arg, 'html')
|
||||||
|
})
|
||||||
|
|
||||||
test('Chinese copy mentions sessions and bind', () => {
|
test('Chinese copy mentions sessions and bind', () => {
|
||||||
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
||||||
assert.ok(MSG.HELP.includes('/sessions'))
|
assert.ok(MSG.HELP.includes('/sessions'))
|
||||||
@@ -26,3 +40,9 @@ test('Chinese copy mentions sessions and bind', () => {
|
|||||||
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
||||||
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parse /compact', () => {
|
||||||
|
assert.equal(parseCommand('/compact').type, 'compact')
|
||||||
|
assert.equal(parseCommand('/compact@MyBot').type, 'compact')
|
||||||
|
assert.equal(parseCommand('/compact now').type, 'compact')
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user