diff --git a/README.md b/README.md index 317a50c..58b7c62 100644 --- a/README.md +++ b/README.md @@ -120,14 +120,15 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master 4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效) 5. 续接上下文:附着后点 **查看上次对话**,或发 `/last` -输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `unbind` `help`。 +输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `compact` `unbind` `help`。 | 命令 | 作用 | |---|---| | `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume | | `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) | | `/model` | 切换当前绑定会话的模型 | -| `/status` | 当前绑定 | +| `/status` | 通用状态显示:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/首 token 平均/输出速率/输入输出 tokens(与 Web 底部统计条同源) | +| `/compact` | 手动压缩当前绑定会话的历史(缩短上下文;会话需空闲,压缩期间新消息排队) | | `/unbind` | 只断开手机,**不关**电脑会话 | | `/help` | 帮助 | diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 6bd9684..7c5d0a8 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -18,7 +18,12 @@ export declare class TelegramBridge { private readonly client; private readonly sleep; 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; + /** 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 pickers; /** 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; /** sessionId → latest todo snapshot (/mission) */ 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); + /** Effective rendering mode for a chat: per-chat override, else the global mode. */ + private modeFor; start(): void; stop(): Promise; processUpdate(update: TelegramUpdate): Promise; @@ -62,19 +73,30 @@ export declare class TelegramBridge { /** Resume cold sessions when needed; never dispose the returned handle. */ private ensureLiveAgent; 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 pollLoop; private interruptibleDelay; private interruptibleSleep; private onSessionEvent; + private handleRichCommand; private deliver; private deliverHtml; /** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */ private withRetry; - private escHtml; /** - * Send a notice; a leading `> ` renders as a Telegram blockquote (HTML). - * Falls back to plain text when the HTML send fails — a notice is never lost. + * Send a notice; a leading `> ` keeps the quote look via a plain-text `> ` + * prefix. NB: NOT `
` 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; /** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */ diff --git a/lib/bridge.js b/lib/bridge.js index 63688bd..d1457a9 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -11,8 +11,17 @@ import { MSG, LAST_CB, parseCommand } from './commands.js'; import { markdownToHtml, splitMessage } from './format.js'; import { splitRichMarkdown } from './rich-format.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 { 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 SID_CB = 'sid:'; const BACK_WS_CB = 'wb'; @@ -45,7 +54,12 @@ export class TelegramBridge { client; sleep; 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; + /** 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(); pickers = new Map(); /** 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(); /** sessionId → latest todo snapshot (/mission) */ 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) { this.ctx = ctx; this.token = options.token; @@ -82,7 +100,13 @@ export class TelegramBridge { }); this.sleep = options.sleep ?? defaultSleep; 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() { this.disposeSessionListener?.(); @@ -102,7 +126,9 @@ export class TelegramBridge { { command: 'sessions', description: '按工作区列出并附着会话' }, { command: 'last', 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: 'stop', description: '中止当前正在运行的任务' }, { command: 'mission', description: '查看任务清单与完成情况' }, @@ -129,6 +155,7 @@ export class TelegramBridge { this.disposeSessionListener = undefined; // Never dispose host agents — only clear remote bindings. this.bindings.clear(); + this.renderPrefs.clear(); this.pickers.clear(); this.pendingModels.clear(); this.stopAllHeartbeats(); @@ -139,6 +166,10 @@ export class TelegramBridge { this.thinkingSessions.clear(); this.callNames.clear(); this.lastTodos.clear(); + for (const controller of this.compactAborts.values()) + controller.abort(); + this.compactAborts.clear(); + this.compacting.clear(); if (this.hookTimer) { clearTimeout(this.hookTimer); this.hookTimer = undefined; @@ -189,6 +220,12 @@ export class TelegramBridge { case 'status': await this.sendStatus(chatId); return; + case 'compact': + await this.requestCompact(chatId); + return; + case 'rich': + await this.handleRichCommand(chatId, parsed.arg); + return; case 'unbind': this.bindings.delete(String(chatId)); this.pickers.delete(String(chatId)); @@ -605,12 +642,212 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.STATUS_NONE); return; } - const stillLive = this.findLiveAgent(binding.sessionId); - if (!stillLive) { - await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label)); + const info = { + label: 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; } - 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) { const binding = this.bindings.get(String(chatId)); @@ -795,8 +1032,45 @@ export class TelegramBridge { 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) { - if (this.renderingMode !== 'html') { + if (this.modeFor(chatId) !== 'html') { let sentAny = false; try { const chunks = splitRichMarkdown(markdown); @@ -810,6 +1084,13 @@ export class TelegramBridge { if (!sentAny && isRichUnsupportedError(err)) { this.ctx.logger.warn('dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering'); 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); return; } @@ -864,34 +1145,21 @@ export class TelegramBridge { } throw lastErr; } - escHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>'); - } /** - * Send a notice; a leading `> ` renders as a Telegram blockquote (HTML). - * Falls back to plain text when the HTML send fails — a notice is never lost. + * Send a notice; a leading `> ` keeps the quote look via a plain-text `> ` + * prefix. NB: NOT `
` 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) { const quoted = text.startsWith('> '); const body = quoted ? text.slice(2) : text; try { - await this.withRetry(() => { - if (!quoted) - return this.client.sendMessage(chatId, body); - return this.client.sendMessage(chatId, `
${this.escHtml(body)}
`, 'HTML'); - }); + await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body)); return; } - catch { - try { - await this.client.sendMessage(chatId, quoted ? `> ${body}` : body); - } - catch (err) { - this.ctx.logger.error(this.redact(err)); - } + catch (err) { + this.ctx.logger.error(this.redact(err)); } } /** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */ @@ -1288,7 +1556,10 @@ export class TelegramBridge { } saveBindings() { 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 }); writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2)); } @@ -1299,9 +1570,13 @@ export class TelegramBridge { loadBindings() { try { 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; - for (const [chatId, b] of Object.entries(obj ?? {})) { + for (const [chatId, b] of Object.entries(bindingEntries ?? {})) { if (b && b.sessionId) { this.bindings.set(String(chatId), { chatId: b.chatId ?? Number(chatId), @@ -1311,6 +1586,11 @@ export class TelegramBridge { 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) this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`); } diff --git a/lib/commands.d.ts b/lib/commands.d.ts index e62e830..40898bc 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -14,12 +14,28 @@ export declare const MSG: { readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。"; readonly STATUS_BOUND: (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 LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。"; readonly MODEL_UNAVAILABLE: (detail?: string) => string; readonly MODEL_UNROUTABLE: (current: string) => string; readonly MODEL_EMPTY: (current: 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 unknown: (command: string) => string; }; @@ -41,6 +57,13 @@ export type ParsedCommand = { } | { type: 'status'; text: string; +} | { + type: 'compact'; + text: string; +} | { + type: 'rich'; + text: string; + arg?: string; } | { type: 'unbind'; text: string; diff --git a/lib/commands.js b/lib/commands.js index 5aa4ea4..f584a79 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -9,7 +9,9 @@ export const MSG = { '/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着', '/last — 查看绑定会话的上次问答(便于续接)', '/model — 切换当前绑定会话的模型(下一回合生效)', - '/status — 查看当前绑定', + '/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens', + '/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)', + '/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效', '/unbind — 断开绑定(不关闭本机会话)', '/help — 显示帮助', '', @@ -36,6 +38,26 @@ export const MSG = { STATUS_BOUND_COLD(label) { 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。', LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。', MODEL_UNAVAILABLE(detail) { @@ -53,6 +75,17 @@ export const MSG = { MODEL_SET(selected) { 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) { const tip = '切换模型失败。请稍后重试或在 Web 中切换。'; if (!detail) @@ -83,6 +116,14 @@ export function parseCommand(text) { return { type: 'model', text }; case '/status': 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 '/disconnect': return { type: 'unbind', text }; diff --git a/lib/index.d.ts b/lib/index.d.ts index 1b77e98..d8b8769 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -9,6 +9,8 @@ export interface TelegramChannelConfig { allowAllUsers?: boolean; maxMessageLength?: 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; } export declare const Config: Schema; diff --git a/lib/index.js b/lib/index.js index 05c9385..cc2d308 100644 --- a/lib/index.js +++ b/lib/index.js @@ -9,7 +9,7 @@ export const Config = Schema.object({ allowAllUsers: Schema.boolean().default(false), maxMessageLength: Schema.number().default(4096), pollingTimeoutSec: Schema.number().default(30), - rendering: Schema.string().default('rich'), + rendering: Schema.string().default('html'), }); function resolveAllowedUserIds(config) { if (config.allowedUserIds && config.allowedUserIds.length > 0) { @@ -37,7 +37,7 @@ export function apply(ctx, config) { allowAllUsers: config.allowAllUsers ?? false, maxMessageLength: config.maxMessageLength ?? 4096, pollingTimeoutSec: config.pollingTimeoutSec ?? 30, - rendering: config.rendering === 'html' ? 'html' : 'rich', + rendering: config.rendering === 'rich' ? 'rich' : 'html', }); ctx.effect(() => { bridge.start(); diff --git a/lib/status.d.ts b/lib/status.d.ts new file mode 100644 index 0000000..d6308ac --- /dev/null +++ b/lib/status.d.ts @@ -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; diff --git a/lib/status.js b/lib/status.js new file mode 100644 index 0000000..c698503 --- /dev/null +++ b/lib/status.js @@ -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'); +} diff --git a/src/bridge.ts b/src/bridge.ts index 42e01d5..3920731 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -28,13 +28,19 @@ import { MSG, LAST_CB, parseCommand } from './commands.js' import { markdownToHtml, splitMessage } from './format.js' import { splitRichMarkdown } from './rich-format.js' import { formatLastTurn, loadLastTurn } from './history.js' -import { describeAgent, displayLabel } from './label.js' +import { describeAgent, displayLabel, workspaceName } from './label.js' import { formatModel, loadSessionModels, selectSessionModel, type ModelOption, } from './models.js' +import { + formatStatusText, + type StatusInfo, + type StatusRuntime, + type StatusWorkspace, +} from './status.js' export interface TelegramBridgeOptions { token: string @@ -113,6 +119,38 @@ interface ApprovalRequestLike { signal?: AbortSignal } +/** ── /status 与 /compact 需要的宿主缝隙(结构镜像,避免引入额外依赖)── */ + +/** 会话投影注册表(只读使用 snapshot):对齐 Web 底部统计条的数据源。 */ +interface ProjectionRegistryLike { + snapshot?: (session: unknown) => { asOfSeq: number; values: Record } | 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 SID_CB = 'sid:' const BACK_WS_CB = 'wb' @@ -150,7 +188,12 @@ export class TelegramBridge { private readonly client: TelegramClientLike private readonly sleep: (ms: number) => Promise 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' + /** 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() private readonly bindings = new Map() private readonly pickers = new Map() @@ -179,6 +222,10 @@ export class TelegramBridge { private readonly callNames = new Map() /** sessionId → latest todo snapshot (/mission) */ private readonly lastTodos = new Map>() + /** sessionId → in-flight manual compaction (dedup /compact) */ + private readonly compacting = new Set() + /** sessionId → compaction abort controller (cancelled on bridge stop) */ + private readonly compactAborts = new Map() constructor(ctx: Context, options: TelegramBridgeOptions) { this.ctx = ctx @@ -190,7 +237,14 @@ export class TelegramBridge { }) this.sleep = options.sleep ?? defaultSleep 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 { @@ -213,7 +267,9 @@ export class TelegramBridge { { command: 'sessions', description: '按工作区列出并附着会话' }, { command: 'last', 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: 'stop', description: '中止当前正在运行的任务' }, { command: 'mission', description: '查看任务清单与完成情况' }, @@ -241,6 +297,7 @@ export class TelegramBridge { this.disposeSessionListener = undefined // Never dispose host agents — only clear remote bindings. this.bindings.clear() + this.renderPrefs.clear() this.pickers.clear() this.pendingModels.clear() this.stopAllHeartbeats() @@ -251,6 +308,9 @@ export class TelegramBridge { this.thinkingSessions.clear() this.callNames.clear() this.lastTodos.clear() + for (const controller of this.compactAborts.values()) controller.abort() + this.compactAborts.clear() + this.compacting.clear() if (this.hookTimer) { clearTimeout(this.hookTimer) this.hookTimer = undefined @@ -305,6 +365,12 @@ export class TelegramBridge { case 'status': await this.sendStatus(chatId) return + case 'compact': + await this.requestCompact(chatId) + return + case 'rich': + await this.handleRichCommand(chatId, parsed.arg) + return case 'unbind': this.bindings.delete(String(chatId)) this.pickers.delete(String(chatId)) @@ -732,12 +798,215 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.STATUS_NONE) return } - const stillLive = this.findLiveAgent(binding.sessionId) - if (!stillLive) { - await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label)) + const info: StatusInfo = { + label: 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 } - 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 { + 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('sessionProjections') + let values: Record | 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 + const usage = (values['tokenUsage'] ?? {}) as Record + const pressure = (values['contextPressure'] ?? {}) as Record + + 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(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)[name] as T + } + } catch { + // proxy ctx:无 inject 直接读属性会抛错,忽略 + } + return undefined + } + + // ── /compact:手动压缩当前绑定会话(需会话空闲)── + + private async requestCompact(chatId: number): Promise { + 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: 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 { + 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 { @@ -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 { + const key = String(chatId) + const setAndReply = async (mode: 'rich' | 'html'): Promise => { + 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 { - if (this.renderingMode !== 'html') { + if (this.modeFor(chatId) !== 'html') { let sentAny = false try { const chunks = splitRichMarkdown(markdown) @@ -936,6 +1244,12 @@ export class TelegramBridge { 'dsh-telegram-channel: Rich Message API unavailable, falling back to HTML rendering', ) 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) return } @@ -988,32 +1302,20 @@ export class TelegramBridge { throw lastErr } - private escHtml(s: string): string { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - } - /** - * Send a notice; a leading `> ` renders as a Telegram blockquote (HTML). - * Falls back to plain text when the HTML send fails — a notice is never lost. + * Send a notice; a leading `> ` keeps the quote look via a plain-text `> ` + * prefix. NB: NOT `
` 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 { const quoted = text.startsWith('> ') const body = quoted ? text.slice(2) : text try { - await this.withRetry(() => { - if (!quoted) return this.client.sendMessage(chatId, body) - return this.client.sendMessage(chatId, `
${this.escHtml(body)}
`, 'HTML') - }) + await this.withRetry(() => this.client.sendMessage(chatId, quoted ? `> ${body}` : body)) return - } catch { - try { - await this.client.sendMessage(chatId, quoted ? `> ${body}` : body) - } catch (err) { - this.ctx.logger.error(this.redact(err)) - } + } catch (err) { + this.ctx.logger.error(this.redact(err)) } } @@ -1408,7 +1710,10 @@ export class TelegramBridge { private saveBindings(): void { 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 }) writeFileSync(this.bindingsPath(), JSON.stringify(obj, null, 2)) } catch (err) { @@ -1419,9 +1724,16 @@ export class TelegramBridge { private loadBindings(): void { try { const raw = readFileSync(this.bindingsPath(), 'utf8') - const obj = JSON.parse(raw) as Record + const parsed = JSON.parse(raw) as unknown + const data = parsed as { + bindings?: Record + renderPrefs?: Record + } + // Legacy files (pre-renderPrefs) stored the bindings flat at the top level. + const flat = parsed as Record + const bindingEntries = data.bindings ?? flat let n = 0 - for (const [chatId, b] of Object.entries(obj ?? {})) { + for (const [chatId, b] of Object.entries(bindingEntries ?? {})) { if (b && b.sessionId) { this.bindings.set(String(chatId), { chatId: b.chatId ?? Number(chatId), @@ -1431,6 +1743,11 @@ export class TelegramBridge { 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) this.ctx.logger.info(`dsh-telegram-channel: restored ${n} binding(s) from disk`) } catch { diff --git a/src/commands.ts b/src/commands.ts index 5cb8b0c..22e45a4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,7 +9,9 @@ export const MSG = { '/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着', '/last — 查看绑定会话的上次问答(便于续接)', '/model — 切换当前绑定会话的模型(下一回合生效)', - '/status — 查看当前绑定', + '/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens', + '/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)', + '/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效', '/unbind — 断开绑定(不关闭本机会话)', '/help — 显示帮助', '', @@ -36,6 +38,25 @@ export const MSG = { STATUS_BOUND_COLD(label: string): string { 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。', LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。', MODEL_UNAVAILABLE(detail?: string): string { @@ -52,6 +73,17 @@ export const MSG = { MODEL_SET(selected: string): string { 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 { const tip = '切换模型失败。请稍后重试或在 Web 中切换。' if (!detail) return tip @@ -69,6 +101,8 @@ export type ParsedCommand = | { type: 'last'; text: string } | { type: 'model'; text: string } | { type: 'status'; text: string } + | { type: 'compact'; text: string } + | { type: 'rich'; text: string; arg?: string } | { type: 'unbind'; text: string } | { type: 'stop'; text: string } | { type: 'mission'; text: string } @@ -96,6 +130,14 @@ export function parseCommand(text: string): ParsedCommand { return { type: 'model', text } case '/status': 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 '/disconnect': return { type: 'unbind', text } diff --git a/src/index.ts b/src/index.ts index 26ff6c9..cb48213 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ export interface TelegramChannelConfig { allowAllUsers?: boolean maxMessageLength?: 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 } @@ -21,7 +23,7 @@ export const Config: Schema = Schema.object({ allowAllUsers: Schema.boolean().default(false), maxMessageLength: Schema.number().default(4096), pollingTimeoutSec: Schema.number().default(30), - rendering: Schema.string().default('rich'), + rendering: Schema.string().default('html'), }) function resolveAllowedUserIds(config: TelegramChannelConfig): number[] { @@ -53,7 +55,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void { allowAllUsers: config.allowAllUsers ?? false, maxMessageLength: config.maxMessageLength ?? 4096, pollingTimeoutSec: config.pollingTimeoutSec ?? 30, - rendering: config.rendering === 'html' ? 'html' : 'rich', + rendering: config.rendering === 'rich' ? 'rich' : 'html', }) ctx.effect(() => { bridge.start() diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..c24f834 --- /dev/null +++ b/src/status.ts @@ -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 = { + 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') +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index cc00239..c03e8ba 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { rmSync } from 'node:fs' import type { UserMessage } from '@deepseek-ai/dsh-llm/types' import { SessionId } from '@deepseek-ai/dsh-session/types' 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(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 +} { + const agent = makeAgent(id, followups) + let sessionListener: ((session: { id: ReturnType }, event: unknown) => void) | undefined + const ctx = { + logger: { info() {}, warn() {}, error() {} }, + agents: { + list: () => [agent], + roots: () => [agent], + get: (sid: ReturnType) => (String(sid) === id ? agent : undefined), + }, + on(event: string, listener: (session: { id: ReturnType }, 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 => { + 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) => (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) => (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) => (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) => (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) +}) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 2d79aa6..8f29555 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -18,6 +18,20 @@ test('parse slash commands for remote control', () => { 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', () => { assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权')) 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.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') +})