Files
dsh-telegram-channel/lib/status.js
T
RRRosmontis d712fb3494 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 全绿)
2026-09-05 17:24:41 +08:00

180 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /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');
}