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:
RRRosmontis
2026-09-05 17:24:41 +08:00
parent 20898d5d21
commit d712fb3494
15 changed files with 1695 additions and 72 deletions
+25 -3
View File
@@ -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<void>;
processUpdate(update: TelegramUpdate): Promise<void>;
@@ -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 `<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;
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
+311 -31
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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 `<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) {
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, `<blockquote>${this.escHtml(body)}</blockquote>`, '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`);
}
+23
View File
@@ -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;
+42 -1
View File
@@ -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 };
+2
View File
@@ -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<TelegramChannelConfig>;
+2 -2
View File
@@ -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();
+71
View File
@@ -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
View File
@@ -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');
}