Compare commits
23
Commits
819010de1c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e95c2631 | ||
|
|
85a42f28a6 | ||
|
|
f49ac5d3c0 | ||
|
|
566932e0ae | ||
|
|
6d00225cc4 | ||
|
|
0b946ffaa4 | ||
|
|
2c50a35795 | ||
|
|
373abf586d | ||
|
|
da06485d64 | ||
|
|
4039dec03c | ||
|
|
d7c74e88ba | ||
|
|
e748132541 | ||
|
|
72e1d399a7 | ||
|
|
5fa2827ed6 | ||
|
|
9a45bde25a | ||
|
|
9f03c6c748 | ||
|
|
f3f6a3ebaf | ||
|
|
8600835436 | ||
|
|
d712fb3494 | ||
|
|
20898d5d21 | ||
|
|
cecb655a4b | ||
|
|
cde6979f2d | ||
|
|
93f3bec975 |
@@ -34,6 +34,8 @@ Telegram **手机遥控器** for DeepSeek Harness:附着本机正在跑的 Web
|
|||||||
2. 手机 Bot:`/sessions` → **工作区** → **会话** → 附着
|
2. 手机 Bot:`/sessions` → **工作区** → **会话** → 附着
|
||||||
3. 之后手机 ↔ Web 走**同一条**轨迹;可用 `/model` 切换模型(下一回合生效)
|
3. 之后手机 ↔ Web 走**同一条**轨迹;可用 `/model` 切换模型(下一回合生效)
|
||||||
|
|
||||||
|
支持的消息:文本、图片(照片或以文件形式发送的 png/jpeg/webp/gif,可带文字说明;多张相册照片合并为一条消息进入会话)。语音/视频/其他文件暂不支持(会收到提示)。图片经宿主附件服务持久化,与 Web 端上传走同一通道;当前模型不支持图像输入时会收到明确提示。
|
||||||
|
|
||||||
### 效果截图
|
### 效果截图
|
||||||
|
|
||||||
手机选择会话并发问:
|
手机选择会话并发问:
|
||||||
@@ -120,14 +122,15 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master
|
|||||||
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
||||||
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
|
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
|
||||||
|
|
||||||
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `unbind` `help`。
|
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `compact` `unbind` `help`。
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
||||||
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
|
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
|
||||||
| `/model` | 切换当前绑定会话的模型 |
|
| `/model` | 切换当前绑定会话的模型 |
|
||||||
| `/status` | 当前绑定 |
|
| `/status` | 通用状态显示:绑定会话/会话 ID/工作区/当前模型/思考强度/上下文长度/首 token 平均/输出速率/输入输出 tokens(与 Web 底部统计条同源) |
|
||||||
|
| `/compact` | 手动压缩当前绑定会话的历史(缩短上下文;会话需空闲,压缩期间新消息排队) |
|
||||||
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
||||||
| `/help` | 帮助 |
|
| `/help` | 帮助 |
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel
|
|||||||
| `allowAllUsers` | `true` 仅调试 |
|
| `allowAllUsers` | `true` 仅调试 |
|
||||||
| `maxMessageLength` | 默认 4096 |
|
| `maxMessageLength` | 默认 4096 |
|
||||||
| `pollingTimeoutSec` | 默认 30 |
|
| `pollingTimeoutSec` | 默认 30 |
|
||||||
|
| `rendering` | `html`(默认,兼容所有客户端)或 `rich`(原生 Rich Message) |
|
||||||
|
|
||||||
若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。
|
若本机用了 HTTP(S)_PROXY 访问 Telegram,插件会自动走代理(无需再设 `NODE_USE_ENV_PROXY`)。
|
||||||
|
|
||||||
|
|||||||
Vendored
+13
@@ -17,6 +17,19 @@ export type ApiProxySessions = {
|
|||||||
rpcId: string;
|
rpcId: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
/** Create a session (used by /new; payload: workspaceId | cwd). */
|
||||||
|
create?: (req: {
|
||||||
|
rpcId: string;
|
||||||
|
payload: unknown;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* Prompt dispatch (Web 端同通道):文本 + 图片字节(base64)经宿主附件服务持久化后
|
||||||
|
* 进入会话;含图片时会校验当前模型是否支持图像输入。
|
||||||
|
*/
|
||||||
|
prompt?: (req: {
|
||||||
|
rpcId: string;
|
||||||
|
payload: unknown;
|
||||||
|
}) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
export type ApiProxyLike = {
|
export type ApiProxyLike = {
|
||||||
workspace?: {
|
workspace?: {
|
||||||
|
|||||||
Vendored
+110
@@ -8,6 +8,7 @@ export interface TelegramBridgeOptions {
|
|||||||
sleep?: (ms: number) => Promise<void>;
|
sleep?: (ms: number) => Promise<void>;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
|
rendering?: 'rich' | 'html';
|
||||||
}
|
}
|
||||||
export declare class TelegramBridge {
|
export declare class TelegramBridge {
|
||||||
private readonly ctx;
|
private readonly ctx;
|
||||||
@@ -17,6 +18,12 @@ export declare class TelegramBridge {
|
|||||||
private readonly client;
|
private readonly client;
|
||||||
private readonly sleep;
|
private readonly sleep;
|
||||||
private readonly maxMessageLength;
|
private readonly maxMessageLength;
|
||||||
|
/** Global rendering mode: follows config; flipped to 'html' permanently when the
|
||||||
|
* Rich Message API itself is unavailable (a server-wide fact, not per-chat). */
|
||||||
|
private renderingMode;
|
||||||
|
/** Per-chat rendering override (persisted): rich messages need a recent Telegram
|
||||||
|
* client — old ones (e.g. 10.x) cannot render them and show “unsupported”. */
|
||||||
|
private readonly renderPrefs;
|
||||||
private readonly bindings;
|
private readonly bindings;
|
||||||
private readonly pickers;
|
private readonly pickers;
|
||||||
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
/** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */
|
||||||
@@ -26,7 +33,34 @@ export declare class TelegramBridge {
|
|||||||
private pollPromise;
|
private pollPromise;
|
||||||
private pollAbort;
|
private pollAbort;
|
||||||
private disposeSessionListener;
|
private disposeSessionListener;
|
||||||
|
/** sessionIds mid-turn (busy feedback, /stop state) */
|
||||||
|
private readonly busySessions;
|
||||||
|
/** chatId → typing heartbeat interval handle */
|
||||||
|
private readonly heartbeats;
|
||||||
|
/** chatId → serialized notice chain (prevents 429 storms) */
|
||||||
|
private readonly noticeQueue;
|
||||||
|
private readonly pendingAsks;
|
||||||
|
private hookTimer;
|
||||||
|
private readonly pendingApprovalsTG;
|
||||||
|
/** Prototype-ask interceptor state (see installAskInterceptor). */
|
||||||
|
private askInstalled;
|
||||||
|
private askRestore;
|
||||||
|
/** Prototype-approval interceptor state (see installApprovalInterceptor). */
|
||||||
|
private approvalInstalled;
|
||||||
|
private approvalRestore;
|
||||||
|
/** callId → tool name (tool/result failure notices) */
|
||||||
|
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;
|
||||||
|
/** Album accumulation: chatId:media_group_id → parts buffered into one prompt. */
|
||||||
|
private readonly mediaGroups;
|
||||||
constructor(ctx: Context, options: TelegramBridgeOptions);
|
constructor(ctx: Context, options: TelegramBridgeOptions);
|
||||||
|
/** Effective rendering mode for a chat: per-chat override, else the global mode. */
|
||||||
|
private modeFor;
|
||||||
start(): void;
|
start(): void;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
processUpdate(update: TelegramUpdate): Promise<void>;
|
processUpdate(update: TelegramUpdate): Promise<void>;
|
||||||
@@ -44,11 +78,87 @@ export declare class TelegramBridge {
|
|||||||
/** Resume cold sessions when needed; never dispose the returned handle. */
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
||||||
private ensureLiveAgent;
|
private ensureLiveAgent;
|
||||||
private sendStatus;
|
private sendStatus;
|
||||||
|
/** 按 sessionId 解析工作区:apiProxy 目录优先,冷/热会话均可用;退化为 agent cwd。 */
|
||||||
|
private workspaceOf;
|
||||||
|
/** 从 sessionProjections 快照读 Web 底部同款统计(sessionStats/tokenUsage/contextPressure)。 */
|
||||||
|
private readRuntimeValues;
|
||||||
|
/** 按名称解析宿主服务(Cordis 需 ctx.get;mock/plain ctx 走自有属性兜底)。 */
|
||||||
|
private serviceOf;
|
||||||
|
private requestCompact;
|
||||||
|
/**
|
||||||
|
* TG /goal —— 走宿主命令运行时(commands.execute),与 Web 端 /goal 完全同一条
|
||||||
|
* 通道:command-goal 在宿主侧操作 session-log 支撑的 goals 域,不依赖 GUI 连接。
|
||||||
|
*/
|
||||||
|
private requestGoal;
|
||||||
|
/** 等待压缩落定并汇报结果(不阻塞轮询循环)。 */
|
||||||
|
private runCompaction;
|
||||||
private followupBound;
|
private followupBound;
|
||||||
|
/**
|
||||||
|
* Extract wire prompt parts from a media message.
|
||||||
|
* Returns undefined when the message carries no media at all; returns [] for
|
||||||
|
* an unsupported media type (the user was already notified); otherwise the
|
||||||
|
* caption text part (when present) followed by the image part.
|
||||||
|
*/
|
||||||
|
private collectMediaParts;
|
||||||
|
/** Download one TG file and encode it as a canonical-base64 wire image part. */
|
||||||
|
private downloadImagePart;
|
||||||
|
/**
|
||||||
|
* Album photos arrive as separate updates sharing a media_group_id — debounce
|
||||||
|
* them into ONE prompt so an album does not spawn N sequential turns.
|
||||||
|
*/
|
||||||
|
private accumulateMediaGroup;
|
||||||
|
/**
|
||||||
|
* Dispatch an image-bearing message to the bound session through the host
|
||||||
|
* prompt RPC — the exact channel the Web composer uses. The host checks
|
||||||
|
* whether the current model accepts image input, admits the bytes into the
|
||||||
|
* durable attachment store, then queues the message on the agent (mode
|
||||||
|
* 'queue' — same semantics as the text path's agent.followup).
|
||||||
|
*/
|
||||||
|
private sendImageFollowup;
|
||||||
private pollLoop;
|
private pollLoop;
|
||||||
private interruptibleDelay;
|
private interruptibleDelay;
|
||||||
private interruptibleSleep;
|
private interruptibleSleep;
|
||||||
private onSessionEvent;
|
private onSessionEvent;
|
||||||
|
private handleRichCommand;
|
||||||
private deliver;
|
private deliver;
|
||||||
|
private deliverHtml;
|
||||||
|
/** Retry an outbound call with capped linear backoff (500ms, 1s, 2s… max 4s). */
|
||||||
|
private withRetry;
|
||||||
|
/**
|
||||||
|
* Send a notice; a leading `> ` marks the body for quote rendering as a real
|
||||||
|
* `<blockquote>` (parse_mode HTML) — every current Telegram client draws the
|
||||||
|
* accent-colored left bar. Old-client compatibility is no longer required,
|
||||||
|
* but a failed HTML send still falls back to plain text — a notice is never lost.
|
||||||
|
*/
|
||||||
|
private deliverNotice;
|
||||||
|
/** Serialized per-chat notice chain — bursts can't race into Telegram 429s. */
|
||||||
|
private enqueueNotice;
|
||||||
|
private startTypingHeartbeat;
|
||||||
|
private stopTypingHeartbeat;
|
||||||
|
private stopAllHeartbeats;
|
||||||
|
private installAskInterceptor;
|
||||||
|
private aroundUserQuestionAsk;
|
||||||
|
private registerTgAsk;
|
||||||
|
/** Announce the incoming question, then one message per question (options as native inline-keyboard buttons). */
|
||||||
|
private deliverAskPrompt;
|
||||||
|
/** Resolve the pending ask once EVERY question has an answer (keyboard and/or text). */
|
||||||
|
private finalizeAskIfComplete;
|
||||||
|
private settleGuiSide;
|
||||||
|
private handleTgAnswer;
|
||||||
|
/** Inline-keyboard tap on an ask message: toggle/record the choice, resolve when every question is answered. */
|
||||||
|
private handleAskCallback;
|
||||||
|
private installApprovalInterceptor;
|
||||||
|
private aroundApprovalRequest;
|
||||||
|
/** Approval prompt as native inline-keyboard buttons; falls back to a plain-text prompt. */
|
||||||
|
private deliverApprovalPrompt;
|
||||||
|
private handleApprovalCallback;
|
||||||
|
private handleTgApproval;
|
||||||
|
private stopBound;
|
||||||
|
private sendMission;
|
||||||
|
/** /new: create a session in the bound session's workspace and attach to it. */
|
||||||
|
private newSessionHere;
|
||||||
|
private bindingsPath;
|
||||||
|
private saveBindings;
|
||||||
|
private loadBindings;
|
||||||
private redact;
|
private redact;
|
||||||
}
|
}
|
||||||
|
|||||||
+1427
-17
File diff suppressed because it is too large
Load Diff
Vendored
+38
@@ -10,12 +10,38 @@ export interface TelegramChat {
|
|||||||
title?: string;
|
title?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
export interface TelegramPhotoSize {
|
||||||
|
file_id: string;
|
||||||
|
file_unique_id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
file_size?: number;
|
||||||
|
}
|
||||||
|
export interface TelegramDocument {
|
||||||
|
file_id: string;
|
||||||
|
file_unique_id: string;
|
||||||
|
file_name?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
file_size?: number;
|
||||||
|
}
|
||||||
export interface TelegramMessage {
|
export interface TelegramMessage {
|
||||||
message_id: number;
|
message_id: number;
|
||||||
date: number;
|
date: number;
|
||||||
chat: TelegramChat;
|
chat: TelegramChat;
|
||||||
from?: TelegramUser;
|
from?: TelegramUser;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Media caption (photos/documents); TG puts it here, not in `text`. */
|
||||||
|
caption?: string;
|
||||||
|
photo?: TelegramPhotoSize[];
|
||||||
|
document?: TelegramDocument;
|
||||||
|
/** Album grouping id: several photo updates share one media_group_id. */
|
||||||
|
media_group_id?: string;
|
||||||
|
}
|
||||||
|
export interface TelegramFile {
|
||||||
|
file_id: string;
|
||||||
|
file_unique_id: string;
|
||||||
|
file_size?: number;
|
||||||
|
file_path?: string;
|
||||||
}
|
}
|
||||||
export interface TelegramCallbackQuery {
|
export interface TelegramCallbackQuery {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +65,10 @@ export interface TelegramBotCommand {
|
|||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
export interface TelegramInputRichMessage {
|
||||||
|
markdown: string;
|
||||||
|
skip_entity_detection?: boolean;
|
||||||
|
}
|
||||||
export interface TelegramClientOptions {
|
export interface TelegramClientOptions {
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -48,9 +78,14 @@ export interface TelegramClientLike {
|
|||||||
getMe(): Promise<TelegramUser>;
|
getMe(): Promise<TelegramUser>;
|
||||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||||
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
||||||
|
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
|
||||||
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
||||||
|
/** Resolve a file_id to a file_path (downloadable URL suffix). */
|
||||||
|
getFile(fileId: string): Promise<TelegramFile>;
|
||||||
|
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
|
||||||
|
downloadFile(filePath: string): Promise<Uint8Array>;
|
||||||
}
|
}
|
||||||
export declare class TelegramClient implements TelegramClientLike {
|
export declare class TelegramClient implements TelegramClientLike {
|
||||||
private readonly token;
|
private readonly token;
|
||||||
@@ -63,7 +98,10 @@ export declare class TelegramClient implements TelegramClientLike {
|
|||||||
getMe(): Promise<TelegramUser>;
|
getMe(): Promise<TelegramUser>;
|
||||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||||
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise<TelegramMessage>;
|
||||||
|
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>;
|
||||||
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
sendChatAction(chatId: number, action: string): Promise<boolean>;
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>;
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>;
|
||||||
|
getFile(fileId: string): Promise<TelegramFile>;
|
||||||
|
downloadFile(filePath: string): Promise<Uint8Array>;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-2
@@ -37,12 +37,17 @@ export class TelegramClient {
|
|||||||
redact(message) {
|
redact(message) {
|
||||||
return message.split(this.token).join('***');
|
return message.split(this.token).join('***');
|
||||||
}
|
}
|
||||||
async call(method, body) {
|
async call(method, body, timeoutMs = 15_000) {
|
||||||
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
||||||
try {
|
try {
|
||||||
const init = {
|
const init = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Without a client-side timeout a stalled proxy connection hangs the
|
||||||
|
// request forever — and the serialized notice queue wedges behind it,
|
||||||
|
// silently swallowing every later tool/ask notice. TimeoutError is
|
||||||
|
// caught below and surfaces as a normal (retryable) failure.
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
@@ -70,7 +75,8 @@ export class TelegramClient {
|
|||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
body.offset = offset;
|
body.offset = offset;
|
||||||
}
|
}
|
||||||
return this.call('getUpdates', body);
|
// Long-poll: client-side abort must outlive the server-side hold.
|
||||||
|
return this.call('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000);
|
||||||
}
|
}
|
||||||
async sendMessage(chatId, text, parseMode, replyMarkup) {
|
async sendMessage(chatId, text, parseMode, replyMarkup) {
|
||||||
const body = { chat_id: chatId, text };
|
const body = { chat_id: chatId, text };
|
||||||
@@ -82,6 +88,16 @@ export class TelegramClient {
|
|||||||
}
|
}
|
||||||
return this.call('sendMessage', body);
|
return this.call('sendMessage', body);
|
||||||
}
|
}
|
||||||
|
async sendRichMessage(chatId, markdown) {
|
||||||
|
const body = {
|
||||||
|
chat_id: chatId,
|
||||||
|
rich_message: {
|
||||||
|
markdown,
|
||||||
|
skip_entity_detection: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.call('sendRichMessage', body);
|
||||||
|
}
|
||||||
async sendChatAction(chatId, action) {
|
async sendChatAction(chatId, action) {
|
||||||
return this.call('sendChatAction', { chat_id: chatId, action });
|
return this.call('sendChatAction', { chat_id: chatId, action });
|
||||||
}
|
}
|
||||||
@@ -94,4 +110,21 @@ export class TelegramClient {
|
|||||||
async setMyCommands(commands) {
|
async setMyCommands(commands) {
|
||||||
return this.call('setMyCommands', { commands });
|
return this.call('setMyCommands', { commands });
|
||||||
}
|
}
|
||||||
|
async getFile(fileId) {
|
||||||
|
return this.call('getFile', { file_id: fileId });
|
||||||
|
}
|
||||||
|
async downloadFile(filePath) {
|
||||||
|
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`;
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(this.redact(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+42
@@ -14,12 +14,31 @@ export declare const MSG: {
|
|||||||
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
||||||
readonly STATUS_BOUND: (label: string) => string;
|
readonly STATUS_BOUND: (label: string) => string;
|
||||||
readonly STATUS_BOUND_COLD: (label: string) => string;
|
readonly STATUS_BOUND_COLD: (label: string) => string;
|
||||||
|
readonly COMPACT_USAGE: "用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)";
|
||||||
|
readonly COMPACT_UNAVAILABLE: "当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。";
|
||||||
|
readonly COMPACT_STARTED: "已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。";
|
||||||
|
readonly COMPACT_BUSY: "当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。";
|
||||||
|
readonly COMPACT_INFLIGHT: "该会话已在进行压缩,请稍候(完成后会报告结果)。";
|
||||||
|
readonly COMPACT_NOTHING: "压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。";
|
||||||
|
readonly COMPACT_DONE: (n: number, tokens: number) => string;
|
||||||
|
readonly COMPACT_CANCELLED: "压缩已取消(会话未改变)。";
|
||||||
|
readonly COMPACT_CHANGED: "压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。";
|
||||||
|
readonly COMPACT_SUMMARY_FAILED: "压缩失败:未能生成有效的摘要(会话未改变),请重试。";
|
||||||
|
readonly COMPACT_COMMIT_FAILED: "压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。";
|
||||||
|
readonly COMPACT_PERSIST_FAILED: "压缩完成但保存失败。请检查本机存储与权限后重试。";
|
||||||
|
readonly COMPACT_FAILED: (detail?: string) => string;
|
||||||
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
||||||
|
readonly MEDIA_UNSUPPORTED: "暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。";
|
||||||
|
readonly IMAGE_MODEL_UNSUPPORTED: "当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。";
|
||||||
|
readonly IMAGE_FAILED: (detail?: string) => string;
|
||||||
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
|
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
|
||||||
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
|
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
|
||||||
readonly MODEL_UNROUTABLE: (current: string) => string;
|
readonly MODEL_UNROUTABLE: (current: string) => string;
|
||||||
readonly MODEL_EMPTY: (current: string) => string;
|
readonly MODEL_EMPTY: (current: string) => string;
|
||||||
readonly MODEL_SET: (selected: string) => string;
|
readonly MODEL_SET: (selected: string) => string;
|
||||||
|
readonly RICH_USAGE: "用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)";
|
||||||
|
readonly RICH_STATE: (mode: "rich" | "html") => string;
|
||||||
|
readonly RICH_SET: (mode: "rich" | "html") => string;
|
||||||
readonly MODEL_FAILED: (detail?: string) => string;
|
readonly MODEL_FAILED: (detail?: string) => string;
|
||||||
readonly unknown: (command: string) => string;
|
readonly unknown: (command: string) => string;
|
||||||
};
|
};
|
||||||
@@ -41,9 +60,32 @@ export type ParsedCommand = {
|
|||||||
} | {
|
} | {
|
||||||
type: 'status';
|
type: 'status';
|
||||||
text: string;
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'compact';
|
||||||
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'rich';
|
||||||
|
text: string;
|
||||||
|
arg?: string;
|
||||||
|
} | {
|
||||||
|
type: 'goal';
|
||||||
|
text: string;
|
||||||
|
arg?: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'unbind';
|
type: 'unbind';
|
||||||
text: string;
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'stop';
|
||||||
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'mission';
|
||||||
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'new';
|
||||||
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'cancel';
|
||||||
|
text: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'unknown';
|
type: 'unknown';
|
||||||
command: string;
|
command: string;
|
||||||
|
|||||||
+66
-1
@@ -9,11 +9,14 @@ export const MSG = {
|
|||||||
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
'/last — 查看绑定会话的上次问答(便于续接)',
|
'/last — 查看绑定会话的上次问答(便于续接)',
|
||||||
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
|
||||||
|
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
|
||||||
|
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||||
|
'直接发送图片(或带文字说明的图片)也会进入该会话,说明文字会一并作为消息文本。',
|
||||||
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||||
@@ -36,7 +39,35 @@ export const MSG = {
|
|||||||
STATUS_BOUND_COLD(label) {
|
STATUS_BOUND_COLD(label) {
|
||||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
||||||
},
|
},
|
||||||
|
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
|
||||||
|
COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。',
|
||||||
|
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
|
||||||
|
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
|
||||||
|
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
|
||||||
|
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
|
||||||
|
COMPACT_DONE(n, tokens) {
|
||||||
|
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`;
|
||||||
|
},
|
||||||
|
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
|
||||||
|
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
|
||||||
|
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
|
||||||
|
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
|
||||||
|
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
|
||||||
|
COMPACT_FAILED(detail) {
|
||||||
|
const tip = '压缩失败。';
|
||||||
|
if (!detail)
|
||||||
|
return tip;
|
||||||
|
return `${tip}\n详情:${detail}`;
|
||||||
|
},
|
||||||
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
|
MEDIA_UNSUPPORTED: '暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。',
|
||||||
|
IMAGE_MODEL_UNSUPPORTED: '当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。',
|
||||||
|
IMAGE_FAILED(detail) {
|
||||||
|
const tip = '图片发送失败。';
|
||||||
|
if (!detail)
|
||||||
|
return tip;
|
||||||
|
return `${tip}\n详情:${detail}`;
|
||||||
|
},
|
||||||
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
||||||
MODEL_UNAVAILABLE(detail) {
|
MODEL_UNAVAILABLE(detail) {
|
||||||
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。';
|
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。';
|
||||||
@@ -53,6 +84,17 @@ export const MSG = {
|
|||||||
MODEL_SET(selected) {
|
MODEL_SET(selected) {
|
||||||
return `已切换模型:${selected}\n下一回合生效。`;
|
return `已切换模型:${selected}\n下一回合生效。`;
|
||||||
},
|
},
|
||||||
|
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
|
||||||
|
RICH_STATE(mode) {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
|
||||||
|
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。';
|
||||||
|
},
|
||||||
|
RICH_SET(mode) {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
|
||||||
|
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。';
|
||||||
|
},
|
||||||
MODEL_FAILED(detail) {
|
MODEL_FAILED(detail) {
|
||||||
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
|
const tip = '切换模型失败。请稍后重试或在 Web 中切换。';
|
||||||
if (!detail)
|
if (!detail)
|
||||||
@@ -83,9 +125,32 @@ export function parseCommand(text) {
|
|||||||
return { type: 'model', text };
|
return { type: 'model', text };
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text };
|
return { type: 'status', text };
|
||||||
|
case '/compact':
|
||||||
|
return { type: 'compact', text };
|
||||||
|
case '/goal':
|
||||||
|
case '/目标':
|
||||||
|
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
|
||||||
|
return { type: 'goal', text, arg: text.slice(command.length).trim() };
|
||||||
|
case '/rich':
|
||||||
|
case '/render':
|
||||||
|
case '/setting': {
|
||||||
|
const arg = text.split(/\s+/)[1]?.toLowerCase();
|
||||||
|
return { type: 'rich', text, arg };
|
||||||
|
}
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
case '/disconnect':
|
case '/disconnect':
|
||||||
return { type: 'unbind', text };
|
return { type: 'unbind', text };
|
||||||
|
case '/stop':
|
||||||
|
case '/halt':
|
||||||
|
return { type: 'stop', text };
|
||||||
|
case '/mission':
|
||||||
|
case '/todos':
|
||||||
|
return { type: 'mission', text };
|
||||||
|
case '/new':
|
||||||
|
case '/create':
|
||||||
|
return { type: 'new', text };
|
||||||
|
case '/cancel':
|
||||||
|
return { type: 'cancel', text };
|
||||||
default:
|
default:
|
||||||
return { type: 'unknown', command, text };
|
return { type: 'unknown', command, text };
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -12,6 +12,30 @@ function inlineToHtml(text) {
|
|||||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
|
||||||
|
function blocksToHtml(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const out = [];
|
||||||
|
let quote = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (quote.length === 0)
|
||||||
|
return;
|
||||||
|
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`);
|
||||||
|
quote = [];
|
||||||
|
};
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('> '))
|
||||||
|
quote.push(line.slice(2));
|
||||||
|
else if (line === '>')
|
||||||
|
quote.push('');
|
||||||
|
else {
|
||||||
|
flush();
|
||||||
|
out.push(inlineToHtml(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
export function markdownToHtml(text) {
|
export function markdownToHtml(text) {
|
||||||
const parts = text.split(/```[\w-]*/);
|
const parts = text.split(/```[\w-]*/);
|
||||||
if (parts.length % 2 === 0)
|
if (parts.length % 2 === 0)
|
||||||
@@ -23,7 +47,7 @@ export function markdownToHtml(text) {
|
|||||||
html += `<pre>${escapeHtml(code)}</pre>`;
|
html += `<pre>${escapeHtml(code)}</pre>`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
html += inlineToHtml(parts[i]);
|
html += blocksToHtml(parts[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Context } from '@deepseek-ai/cordis';
|
import type { Context } from '@deepseek-ai/cordis';
|
||||||
import Schema from '@deepseek-ai/schemastery';
|
import Schema from '@deepseek-ai/schemastery';
|
||||||
export declare const name = "dsh-telegram-channel";
|
export declare const name = "dsh-telegram-channel";
|
||||||
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
|
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
|
||||||
export declare const inject: string[];
|
export declare const inject: string[];
|
||||||
export interface TelegramChannelConfig {
|
export interface TelegramChannelConfig {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -9,6 +9,9 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean;
|
allowAllUsers?: boolean;
|
||||||
maxMessageLength?: number;
|
maxMessageLength?: number;
|
||||||
pollingTimeoutSec?: number;
|
pollingTimeoutSec?: number;
|
||||||
|
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
|
||||||
|
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
|
||||||
|
rendering?: string;
|
||||||
}
|
}
|
||||||
export declare const Config: Schema<TelegramChannelConfig>;
|
export declare const Config: Schema<TelegramChannelConfig>;
|
||||||
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
||||||
|
|||||||
+4
-2
@@ -1,14 +1,15 @@
|
|||||||
import Schema from '@deepseek-ai/schemastery';
|
import Schema from '@deepseek-ai/schemastery';
|
||||||
import { TelegramBridge } from './bridge.js';
|
import { TelegramBridge } from './bridge.js';
|
||||||
export const name = 'dsh-telegram-channel';
|
export const name = 'dsh-telegram-channel';
|
||||||
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
|
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
|
||||||
export const inject = ['agents', 'apiProxy'];
|
export const inject = ['agents'];
|
||||||
export const Config = Schema.object({
|
export const Config = Schema.object({
|
||||||
token: Schema.string().default(''),
|
token: Schema.string().default(''),
|
||||||
allowedUserIds: Schema.array(Schema.number()).default([]),
|
allowedUserIds: Schema.array(Schema.number()).default([]),
|
||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
|
rendering: Schema.string().default('rich'),
|
||||||
});
|
});
|
||||||
function resolveAllowedUserIds(config) {
|
function resolveAllowedUserIds(config) {
|
||||||
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
if (config.allowedUserIds && config.allowedUserIds.length > 0) {
|
||||||
@@ -36,6 +37,7 @@ export function apply(ctx, config) {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
|
rendering: config.rendering === 'rich' ? 'rich' : 'html',
|
||||||
});
|
});
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start();
|
bridge.start();
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export declare const RICH_MESSAGE_MAX_CHARS = 32768;
|
||||||
|
export declare const RICH_MESSAGE_MAX_BLOCKS = 500;
|
||||||
|
export declare function splitRichMarkdown(markdown: string): string[];
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
export const RICH_MESSAGE_MAX_CHARS = 32768;
|
||||||
|
export const RICH_MESSAGE_MAX_BLOCKS = 500;
|
||||||
|
function normalizeTelegramNativeMarkdown(markdown) {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
let fence;
|
||||||
|
let displayMath = false;
|
||||||
|
const hasClosingDisplayMathDelimiter = (source, index) => {
|
||||||
|
for (let i = index + 1; i < source.length; i++) {
|
||||||
|
if (source[i].trim() === '$$')
|
||||||
|
return true;
|
||||||
|
if (/^ {0,3}(`{3,}|~{3,})/.test(source[i]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
||||||
|
const inFence = fence !== undefined;
|
||||||
|
if (!inFence && line.trim() === '$$') {
|
||||||
|
if (displayMath) {
|
||||||
|
displayMath = false;
|
||||||
|
return '```';
|
||||||
|
}
|
||||||
|
if (hasClosingDisplayMathDelimiter(lines, index)) {
|
||||||
|
displayMath = true;
|
||||||
|
return '```math';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (displayMath)
|
||||||
|
return line;
|
||||||
|
if (!inFence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1];
|
||||||
|
fence = {
|
||||||
|
marker: markerText[0],
|
||||||
|
length: markerText.length,
|
||||||
|
};
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (inFence
|
||||||
|
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
|
||||||
|
fence = undefined;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
function countTelegramNativeMarkdownBlocks(block) {
|
||||||
|
if (/^ {0,3}(`{3,}|~{3,})/.test(block))
|
||||||
|
return 1;
|
||||||
|
const lines = block.split('\n').filter((line) => line.trim().length > 0);
|
||||||
|
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
|
||||||
|
return Math.max(1, lines.length);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownBlocks(markdown) {
|
||||||
|
const blocks = [];
|
||||||
|
const current = [];
|
||||||
|
let fence;
|
||||||
|
const flush = () => {
|
||||||
|
if (current.length === 0)
|
||||||
|
return;
|
||||||
|
blocks.push(current.join('\n'));
|
||||||
|
current.length = 0;
|
||||||
|
};
|
||||||
|
for (const line of markdown.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
||||||
|
if (!fence && line.trim().length === 0) {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.push(line);
|
||||||
|
if (!fence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1];
|
||||||
|
fence = { marker: markerText[0], length: markerText.length };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence
|
||||||
|
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
|
||||||
|
fence = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownCountedBlocks(block) {
|
||||||
|
if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
return [block];
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let current = [];
|
||||||
|
let fence;
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
||||||
|
if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
chunks.push(current.join('\n'));
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
current.push(line);
|
||||||
|
if (!fence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1];
|
||||||
|
fence = { marker: markerText[0], length: markerText.length };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence
|
||||||
|
&& new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)) {
|
||||||
|
fence = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.length > 0)
|
||||||
|
chunks.push(current.join('\n'));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
function findTelegramNativeMarkdownSplitIndex(text, hardLimit = RICH_MESSAGE_MAX_CHARS) {
|
||||||
|
const paragraphIndex = text.lastIndexOf('\n\n', hardLimit);
|
||||||
|
if (paragraphIndex > 0)
|
||||||
|
return paragraphIndex + 2;
|
||||||
|
const lineIndex = text.lastIndexOf('\n', hardLimit);
|
||||||
|
if (lineIndex > 0)
|
||||||
|
return lineIndex + 1;
|
||||||
|
const spaceIndex = text.lastIndexOf(' ', hardLimit);
|
||||||
|
if (spaceIndex > 0)
|
||||||
|
return spaceIndex + 1;
|
||||||
|
return hardLimit;
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, wrap) {
|
||||||
|
const chunks = [];
|
||||||
|
let remaining = content;
|
||||||
|
while (remaining.length > maxContentLength) {
|
||||||
|
const window = remaining.slice(0, maxContentLength + 1);
|
||||||
|
const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength);
|
||||||
|
chunks.push(wrap(remaining.slice(0, splitIndex)));
|
||||||
|
remaining = remaining.slice(splitIndex);
|
||||||
|
}
|
||||||
|
if (remaining.length > 0)
|
||||||
|
chunks.push(wrap(remaining));
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownLongFenceBlock(block) {
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const opening = lines[0] ?? '';
|
||||||
|
const closing = lines[lines.length - 1] ?? '';
|
||||||
|
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/);
|
||||||
|
if (!openingMatch || !closing || lines.length < 2)
|
||||||
|
return undefined;
|
||||||
|
const markerText = openingMatch[1] ?? '```';
|
||||||
|
const marker = markerText[0];
|
||||||
|
if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2;
|
||||||
|
if (maxContentLength <= 0)
|
||||||
|
return undefined;
|
||||||
|
const content = lines.slice(1, -1).join('\n');
|
||||||
|
return splitTelegramNativeMarkdownWrappedContent(content, maxContentLength, (chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`);
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownLongWrappedInlineBlock(block) {
|
||||||
|
const delimiter = ['**', '__', '~~', '`', '*', '_'].find((candidate) => block.startsWith(candidate)
|
||||||
|
&& block.endsWith(candidate)
|
||||||
|
&& block.length > candidate.length * 2);
|
||||||
|
if (!delimiter)
|
||||||
|
return undefined;
|
||||||
|
const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2;
|
||||||
|
if (maxContentLength <= 0)
|
||||||
|
return undefined;
|
||||||
|
return splitTelegramNativeMarkdownWrappedContent(block.slice(delimiter.length, -delimiter.length), maxContentLength, (chunk) => `${delimiter}${chunk}${delimiter}`);
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownLongPlainBlock(block) {
|
||||||
|
const chunks = [];
|
||||||
|
let remaining = block;
|
||||||
|
while (remaining.length > RICH_MESSAGE_MAX_CHARS) {
|
||||||
|
const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1);
|
||||||
|
const splitIndex = findTelegramNativeMarkdownSplitIndex(window);
|
||||||
|
chunks.push(remaining.slice(0, splitIndex).trimEnd());
|
||||||
|
remaining = remaining.slice(splitIndex).trimStart();
|
||||||
|
}
|
||||||
|
if (remaining.length > 0)
|
||||||
|
chunks.push(remaining);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
function splitTelegramNativeMarkdownLongBlock(block) {
|
||||||
|
return (splitTelegramNativeMarkdownLongFenceBlock(block)
|
||||||
|
?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block)
|
||||||
|
?? splitTelegramNativeMarkdownLongPlainBlock(block));
|
||||||
|
}
|
||||||
|
export function splitRichMarkdown(markdown) {
|
||||||
|
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
|
||||||
|
if (normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS
|
||||||
|
&& countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
return [normalizedMarkdown];
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let current = '';
|
||||||
|
let currentBlockCount = 0;
|
||||||
|
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
|
||||||
|
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
|
||||||
|
const blockCount = countTelegramNativeMarkdownBlocks(block);
|
||||||
|
const candidate = current ? `${current}\n\n${block}` : block;
|
||||||
|
const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS;
|
||||||
|
const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS;
|
||||||
|
if (!exceedsChars && !exceedsBlocks) {
|
||||||
|
current = candidate;
|
||||||
|
currentBlockCount += blockCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current)
|
||||||
|
chunks.push(current.trimEnd());
|
||||||
|
if (block.length <= RICH_MESSAGE_MAX_CHARS
|
||||||
|
&& blockCount <= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
current = block;
|
||||||
|
currentBlockCount = blockCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.push(...splitTelegramNativeMarkdownLongBlock(block));
|
||||||
|
current = '';
|
||||||
|
currentBlockCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current)
|
||||||
|
chunks.push(current.trimEnd());
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
export interface StatusWorkspace {
|
||||||
|
/** 工作区名(basename 或 Web 工作区标题)。 */
|
||||||
|
title?: string;
|
||||||
|
/** 完整目录/路径。 */
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
export interface StatusModel {
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
|
||||||
|
reasoningEffort?: string;
|
||||||
|
}
|
||||||
|
export interface StatusRuntime {
|
||||||
|
turns?: number;
|
||||||
|
steps?: number;
|
||||||
|
llmMs?: number;
|
||||||
|
toolMs?: number;
|
||||||
|
ttftMs?: number;
|
||||||
|
ttftSteps?: number;
|
||||||
|
decodeMs?: number;
|
||||||
|
decodeTokens?: number;
|
||||||
|
uncachedInputTokens?: number;
|
||||||
|
cacheReadTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
contextUsed?: number;
|
||||||
|
contextWindow?: number;
|
||||||
|
}
|
||||||
|
export interface StatusInfo {
|
||||||
|
/** 绑定时保存的会话展示名。 */
|
||||||
|
label: string;
|
||||||
|
/** 完整 SessionId 字符串。 */
|
||||||
|
sessionId: string;
|
||||||
|
workspace?: StatusWorkspace;
|
||||||
|
model?: StatusModel;
|
||||||
|
runtime?: StatusRuntime;
|
||||||
|
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
|
||||||
|
live?: boolean;
|
||||||
|
}
|
||||||
|
export declare function effortLabel(effort?: string): string | undefined;
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export declare function formatTokens(n: number): string;
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export declare function formatDuration(ms: number): string;
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export declare function formatTokensPerSecond(tps: number): string;
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export declare function billedInputTokens(runtime: StatusRuntime): number;
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export declare function cacheHitPercent(runtime: StatusRuntime): string | null;
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export declare function formatStatusText(info: StatusInfo): string;
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
const EFFORT_NAMES = {
|
||||||
|
off: '关闭(off)',
|
||||||
|
low: '低(low)',
|
||||||
|
medium: '中(medium)',
|
||||||
|
high: '高(high)',
|
||||||
|
max: '最高(max)',
|
||||||
|
};
|
||||||
|
export function effortLabel(effort) {
|
||||||
|
if (!effort)
|
||||||
|
return undefined;
|
||||||
|
return EFFORT_NAMES[effort] ?? String(effort);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatTokens(n) {
|
||||||
|
const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
|
||||||
|
if (n < 1_000)
|
||||||
|
return String(n);
|
||||||
|
if (n < 1_000_000)
|
||||||
|
return `${scaled(n / 1_000)}K`;
|
||||||
|
return `${scaled(n / 1_000_000)}M`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms) {
|
||||||
|
const s = ms / 1_000;
|
||||||
|
if (s < 60)
|
||||||
|
return `${Math.round(s * 10) / 10}s`;
|
||||||
|
const whole = Math.round(s);
|
||||||
|
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
||||||
|
}
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export function formatTokensPerSecond(tps) {
|
||||||
|
const clamped = Math.max(0, tps);
|
||||||
|
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
|
||||||
|
}
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export function billedInputTokens(runtime) {
|
||||||
|
return (runtime.uncachedInputTokens ?? 0)
|
||||||
|
+ (runtime.cacheReadTokens ?? 0)
|
||||||
|
+ (runtime.cacheWriteTokens ?? 0);
|
||||||
|
}
|
||||||
|
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
|
||||||
|
function roundedIntegerPercent(cacheReadTokens, denominator) {
|
||||||
|
const denominatorQuotient = Math.floor(denominator / 200);
|
||||||
|
const denominatorRemainder = denominator % 200;
|
||||||
|
let lower = 0;
|
||||||
|
let upper = 100;
|
||||||
|
while (lower < upper) {
|
||||||
|
const candidate = Math.floor((lower + upper + 1) / 2);
|
||||||
|
const factor = candidate * 2 - 1;
|
||||||
|
const threshold = factor * denominatorQuotient
|
||||||
|
+ Math.ceil(factor * denominatorRemainder / 200);
|
||||||
|
if (cacheReadTokens >= threshold) {
|
||||||
|
lower = candidate;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
upper = candidate - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export function cacheHitPercent(runtime) {
|
||||||
|
const denominator = billedInputTokens(runtime);
|
||||||
|
if (denominator === 0)
|
||||||
|
return null;
|
||||||
|
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0);
|
||||||
|
const read = runtime.cacheReadTokens ?? 0;
|
||||||
|
if (missed === 0)
|
||||||
|
return '100';
|
||||||
|
const integerPercent = roundedIntegerPercent(read, denominator);
|
||||||
|
if (integerPercent < 100)
|
||||||
|
return String(integerPercent);
|
||||||
|
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
|
||||||
|
let decimalPlaces = 1;
|
||||||
|
let scaledDoubleGap = missed * 200;
|
||||||
|
const denominatorTens = Math.floor(denominator / 10);
|
||||||
|
while (scaledDoubleGap <= denominatorTens) {
|
||||||
|
scaledDoubleGap *= 10;
|
||||||
|
decimalPlaces += 1;
|
||||||
|
}
|
||||||
|
const denominatorOnes = denominator % 10;
|
||||||
|
let roundedLoss = 5;
|
||||||
|
for (let loss = 1; loss < 5; loss += 1) {
|
||||||
|
const factor = loss * 2 + 1;
|
||||||
|
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10);
|
||||||
|
if (scaledDoubleGap <= threshold) {
|
||||||
|
roundedLoss = loss;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export function formatStatusText(info) {
|
||||||
|
const lines = ['会话状态'];
|
||||||
|
lines.push(`会话:${info.label || info.sessionId}`);
|
||||||
|
lines.push(`会话 ID:${info.sessionId}`);
|
||||||
|
const ws = info.workspace;
|
||||||
|
if (ws?.title || ws?.path) {
|
||||||
|
const primary = ws.title && ws.path
|
||||||
|
? `${ws.title}(${ws.path})`
|
||||||
|
: (ws.title ?? ws.path ?? '');
|
||||||
|
lines.push(`工作区:${primary}`);
|
||||||
|
}
|
||||||
|
const model = info.model;
|
||||||
|
if (model?.provider && model?.model) {
|
||||||
|
lines.push(`模型:${model.provider}/${model.model}`);
|
||||||
|
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`);
|
||||||
|
}
|
||||||
|
const runtime = info.runtime;
|
||||||
|
if (runtime !== undefined) {
|
||||||
|
const contextUsed = runtime.contextUsed;
|
||||||
|
const window = runtime.contextWindow;
|
||||||
|
if (contextUsed !== undefined && window !== undefined && window > 0) {
|
||||||
|
const percent = Math.min(100, Math.round(contextUsed / window * 100));
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`);
|
||||||
|
}
|
||||||
|
else if (contextUsed !== undefined) {
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`);
|
||||||
|
}
|
||||||
|
const stats = [];
|
||||||
|
if ((runtime.steps ?? 0) > 0) {
|
||||||
|
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`);
|
||||||
|
const durations = [];
|
||||||
|
if ((runtime.llmMs ?? 0) > 0)
|
||||||
|
durations.push(`LLM ${formatDuration(runtime.llmMs)}`);
|
||||||
|
if ((runtime.toolMs ?? 0) > 0)
|
||||||
|
durations.push(`工具调用 ${formatDuration(runtime.toolMs)}`);
|
||||||
|
if (durations.length > 0)
|
||||||
|
stats.push(durations.join(' · '));
|
||||||
|
const speeds = [];
|
||||||
|
if ((runtime.ttftSteps ?? 0) > 0) {
|
||||||
|
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps)}`);
|
||||||
|
}
|
||||||
|
if ((runtime.decodeMs ?? 0) > 0) {
|
||||||
|
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs / 1_000))} tok/s`);
|
||||||
|
}
|
||||||
|
if (speeds.length > 0)
|
||||||
|
stats.push(speeds.join(' · '));
|
||||||
|
}
|
||||||
|
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
|
||||||
|
const hit = cacheHitPercent(runtime);
|
||||||
|
const billing = [
|
||||||
|
hit === null ? '' : `缓存命中 ${hit}%`,
|
||||||
|
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
stats.push(billing);
|
||||||
|
}
|
||||||
|
if (stats.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('统计(与 Web 底部一致)');
|
||||||
|
lines.push(...stats.map((s) => ` ${s}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.live === false) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ export type ApiProxySessions = {
|
|||||||
history?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
history?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
models?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
models?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
selectModel?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
selectModel?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
|
/** Create a session (used by /new; payload: workspaceId | cwd). */
|
||||||
|
create?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
|
/**
|
||||||
|
* Prompt dispatch (Web 端同通道):文本 + 图片字节(base64)经宿主附件服务持久化后
|
||||||
|
* 进入会话;含图片时会校验当前模型是否支持图像输入。
|
||||||
|
*/
|
||||||
|
prompt?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiProxyLike = {
|
export type ApiProxyLike = {
|
||||||
|
|||||||
+1529
-18
File diff suppressed because it is too large
Load Diff
+76
-2
@@ -14,12 +14,41 @@ export interface TelegramChat {
|
|||||||
username?: string
|
username?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelegramPhotoSize {
|
||||||
|
file_id: string
|
||||||
|
file_unique_id: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
file_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramDocument {
|
||||||
|
file_id: string
|
||||||
|
file_unique_id: string
|
||||||
|
file_name?: string
|
||||||
|
mime_type?: string
|
||||||
|
file_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramMessage {
|
export interface TelegramMessage {
|
||||||
message_id: number
|
message_id: number
|
||||||
date: number
|
date: number
|
||||||
chat: TelegramChat
|
chat: TelegramChat
|
||||||
from?: TelegramUser
|
from?: TelegramUser
|
||||||
text?: string
|
text?: string
|
||||||
|
/** Media caption (photos/documents); TG puts it here, not in `text`. */
|
||||||
|
caption?: string
|
||||||
|
photo?: TelegramPhotoSize[]
|
||||||
|
document?: TelegramDocument
|
||||||
|
/** Album grouping id: several photo updates share one media_group_id. */
|
||||||
|
media_group_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramFile {
|
||||||
|
file_id: string
|
||||||
|
file_unique_id: string
|
||||||
|
file_size?: number
|
||||||
|
file_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramCallbackQuery {
|
export interface TelegramCallbackQuery {
|
||||||
@@ -49,6 +78,11 @@ export interface TelegramBotCommand {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelegramInputRichMessage {
|
||||||
|
markdown: string
|
||||||
|
skip_entity_detection?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramClientOptions {
|
export interface TelegramClientOptions {
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
@@ -64,9 +98,14 @@ export interface TelegramClientLike {
|
|||||||
parseMode?: string,
|
parseMode?: string,
|
||||||
replyMarkup?: InlineKeyboardMarkup,
|
replyMarkup?: InlineKeyboardMarkup,
|
||||||
): Promise<TelegramMessage>
|
): Promise<TelegramMessage>
|
||||||
|
sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage>
|
||||||
sendChatAction(chatId: number, action: string): Promise<boolean>
|
sendChatAction(chatId: number, action: string): Promise<boolean>
|
||||||
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
|
||||||
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
|
setMyCommands(commands: TelegramBotCommand[]): Promise<boolean>
|
||||||
|
/** Resolve a file_id to a file_path (downloadable URL suffix). */
|
||||||
|
getFile(fileId: string): Promise<TelegramFile>
|
||||||
|
/** Download file bytes from the Bot API file endpoint (GET, proxy-aware). */
|
||||||
|
downloadFile(filePath: string): Promise<Uint8Array>
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProxyUrl(): string | undefined {
|
function resolveProxyUrl(): string | undefined {
|
||||||
@@ -114,12 +153,17 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
return message.split(this.token).join('***')
|
return message.split(this.token).join('***')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async call<T>(method: string, body?: Record<string, unknown>): Promise<T> {
|
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 15_000): Promise<T> {
|
||||||
const url = `${this.baseUrl}/bot${this.token}/${method}`
|
const url = `${this.baseUrl}/bot${this.token}/${method}`
|
||||||
try {
|
try {
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Without a client-side timeout a stalled proxy connection hangs the
|
||||||
|
// request forever — and the serialized notice queue wedges behind it,
|
||||||
|
// silently swallowing every later tool/ask notice. TimeoutError is
|
||||||
|
// caught below and surfaces as a normal (retryable) failure.
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
}
|
}
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.body = JSON.stringify(body)
|
init.body = JSON.stringify(body)
|
||||||
@@ -148,7 +192,8 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
body.offset = offset
|
body.offset = offset
|
||||||
}
|
}
|
||||||
return this.call<TelegramUpdate[]>('getUpdates', body)
|
// Long-poll: client-side abort must outlive the server-side hold.
|
||||||
|
return this.call<TelegramUpdate[]>('getUpdates', body, this.pollingTimeoutSec * 1000 + 10_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
@@ -167,6 +212,17 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
return this.call<TelegramMessage>('sendMessage', body)
|
return this.call<TelegramMessage>('sendMessage', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendRichMessage(chatId: number, markdown: string): Promise<TelegramMessage> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
chat_id: chatId,
|
||||||
|
rich_message: {
|
||||||
|
markdown,
|
||||||
|
skip_entity_detection: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return this.call<TelegramMessage>('sendRichMessage', body)
|
||||||
|
}
|
||||||
|
|
||||||
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
async sendChatAction(chatId: number, action: string): Promise<boolean> {
|
||||||
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
|
||||||
}
|
}
|
||||||
@@ -180,4 +236,22 @@ export class TelegramClient implements TelegramClientLike {
|
|||||||
async setMyCommands(commands: TelegramBotCommand[]): Promise<boolean> {
|
async setMyCommands(commands: TelegramBotCommand[]): Promise<boolean> {
|
||||||
return this.call<boolean>('setMyCommands', { commands })
|
return this.call<boolean>('setMyCommands', { commands })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFile(fileId: string): Promise<TelegramFile> {
|
||||||
|
return this.call<TelegramFile>('getFile', { file_id: fileId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(filePath: string): Promise<Uint8Array> {
|
||||||
|
const url = `${this.baseUrl}/file/bot${this.token}/${filePath}`
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Telegram file download failed: HTTP ${String(response.status)}`)
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer())
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
throw new Error(this.redact(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-1
@@ -9,11 +9,14 @@ export const MSG = {
|
|||||||
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
'/last — 查看绑定会话的上次问答(便于续接)',
|
'/last — 查看绑定会话的上次问答(便于续接)',
|
||||||
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 会话状态:绑定/会话ID/工作区/模型/思考强度/上下文/首token/速率/输入输出tokens',
|
||||||
|
'/compact — 压缩当前会话历史(缩短上下文,会话空闲时执行)',
|
||||||
|
'/rich — 渲染模式:on=富文本(需新版客户端) / off=HTML兼容(默认);本聊天永久生效',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||||
|
'直接发送图片(或带文字说明的图片)也会进入该会话,说明文字会一并作为消息文本。',
|
||||||
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||||
@@ -36,7 +39,33 @@ export const MSG = {
|
|||||||
STATUS_BOUND_COLD(label: string): string {
|
STATUS_BOUND_COLD(label: string): string {
|
||||||
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
||||||
},
|
},
|
||||||
|
COMPACT_USAGE: '用法:/compact(无参数,压缩当前绑定会话的历史,缩短上下文)',
|
||||||
|
COMPACT_UNAVAILABLE: '当前宿主不可用压缩:/compact 命令未注册(需宿主启用压缩插件 command-compact / compaction-basic 后重启 dsh web)。',
|
||||||
|
COMPACT_STARTED: '已开始压缩会话历史…\n压缩会占用一个模型回合,期间新消息排队;完成或失败后会在这里报告结果。',
|
||||||
|
COMPACT_BUSY: '当前会话有任务正在运行(或压缩锁被占用),无法压缩。请等本轮结束(或 /stop)后再试。',
|
||||||
|
COMPACT_INFLIGHT: '该会话已在进行压缩,请稍候(完成后会报告结果)。',
|
||||||
|
COMPACT_NOTHING: '压缩完成:暂无可压缩的有效历史(历史过短,或没有可安全汇总的区间)。',
|
||||||
|
COMPACT_DONE(n: number, tokens: number): string {
|
||||||
|
return `压缩完成:合并 ${n} 条历史记录(约 ${tokens} tokens)。上下文已缩短,会话可继续。`
|
||||||
|
},
|
||||||
|
COMPACT_CANCELLED: '压缩已取消(会话未改变)。',
|
||||||
|
COMPACT_CHANGED: '压缩失败:待压缩的历史在过程中发生了变化(会话未改变),请重试。',
|
||||||
|
COMPACT_SUMMARY_FAILED: '压缩失败:未能生成有效的摘要(会话未改变),请重试。',
|
||||||
|
COMPACT_COMMIT_FAILED: '压缩未完整结束:部分历史可能已变化。请先检查会话状态再重试。',
|
||||||
|
COMPACT_PERSIST_FAILED: '压缩完成但保存失败。请检查本机存储与权限后重试。',
|
||||||
|
COMPACT_FAILED(detail?: string): string {
|
||||||
|
const tip = '压缩失败。'
|
||||||
|
if (!detail) return tip
|
||||||
|
return `${tip}\n详情:${detail}`
|
||||||
|
},
|
||||||
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
|
MEDIA_UNSUPPORTED: '暂不支持该消息类型(支持文本与 png/jpeg/webp/gif 图片;语音/视频/其他文件请在 Web 端处理)。',
|
||||||
|
IMAGE_MODEL_UNSUPPORTED: '当前模型不支持图片输入,请在 Web 端或 /model 切换到多模态模型后重试。',
|
||||||
|
IMAGE_FAILED(detail?: string): string {
|
||||||
|
const tip = '图片发送失败。'
|
||||||
|
if (!detail) return tip
|
||||||
|
return `${tip}\n详情:${detail}`
|
||||||
|
},
|
||||||
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
|
||||||
MODEL_UNAVAILABLE(detail?: string): string {
|
MODEL_UNAVAILABLE(detail?: string): string {
|
||||||
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'
|
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'
|
||||||
@@ -52,6 +81,17 @@ export const MSG = {
|
|||||||
MODEL_SET(selected: string): string {
|
MODEL_SET(selected: string): string {
|
||||||
return `已切换模型:${selected}\n下一回合生效。`
|
return `已切换模型:${selected}\n下一回合生效。`
|
||||||
},
|
},
|
||||||
|
RICH_USAGE: '用法:/rich on(富文本渲染,需新版客户端)|/rich off(HTML 兼容,默认)|/rich(查看当前状态)',
|
||||||
|
RICH_STATE(mode: 'rich' | 'html'): string {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '当前渲染模式:富文本(on)。若你的客户端把回复显示为“不支持”,请发 /rich off 切回兼容。'
|
||||||
|
: '当前渲染模式:HTML 兼容(off)。新版客户端可用 /rich on 开启富文本渲染。'
|
||||||
|
},
|
||||||
|
RICH_SET(mode: 'rich' | 'html'): string {
|
||||||
|
return mode === 'rich'
|
||||||
|
? '已切换为富文本渲染(本聊天永久生效)。注意:需新版 Telegram 客户端才能显示,旧客户端会显示“不支持”。'
|
||||||
|
: '已切换为 HTML 兼容渲染(本聊天永久生效),所有客户端版本均可正常显示。'
|
||||||
|
},
|
||||||
MODEL_FAILED(detail?: string): string {
|
MODEL_FAILED(detail?: string): string {
|
||||||
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
|
const tip = '切换模型失败。请稍后重试或在 Web 中切换。'
|
||||||
if (!detail) return tip
|
if (!detail) return tip
|
||||||
@@ -69,7 +109,14 @@ export type ParsedCommand =
|
|||||||
| { type: 'last'; text: string }
|
| { type: 'last'; text: string }
|
||||||
| { type: 'model'; text: string }
|
| { type: 'model'; text: string }
|
||||||
| { type: 'status'; text: string }
|
| { type: 'status'; text: string }
|
||||||
|
| { type: 'compact'; text: string }
|
||||||
|
| { type: 'rich'; text: string; arg?: string }
|
||||||
|
| { type: 'goal'; text: string; arg?: string }
|
||||||
| { type: 'unbind'; text: string }
|
| { type: 'unbind'; text: string }
|
||||||
|
| { type: 'stop'; text: string }
|
||||||
|
| { type: 'mission'; text: string }
|
||||||
|
| { type: 'new'; text: string }
|
||||||
|
| { type: 'cancel'; text: string }
|
||||||
| { type: 'unknown'; command: string; text: string }
|
| { type: 'unknown'; command: string; text: string }
|
||||||
| { type: 'plain'; text: string }
|
| { type: 'plain'; text: string }
|
||||||
|
|
||||||
@@ -92,9 +139,32 @@ export function parseCommand(text: string): ParsedCommand {
|
|||||||
return { type: 'model', text }
|
return { type: 'model', text }
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text }
|
return { type: 'status', text }
|
||||||
|
case '/compact':
|
||||||
|
return { type: 'compact', text }
|
||||||
|
case '/goal':
|
||||||
|
case '/目标':
|
||||||
|
// 目标语句是多词文本,保留完整参数(去掉命令名本身)
|
||||||
|
return { type: 'goal', text, arg: text.slice(command.length).trim() }
|
||||||
|
case '/rich':
|
||||||
|
case '/render':
|
||||||
|
case '/setting': {
|
||||||
|
const arg = text.split(/\s+/)[1]?.toLowerCase()
|
||||||
|
return { type: 'rich', text, arg }
|
||||||
|
}
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
case '/disconnect':
|
case '/disconnect':
|
||||||
return { type: 'unbind', text }
|
return { type: 'unbind', text }
|
||||||
|
case '/stop':
|
||||||
|
case '/halt':
|
||||||
|
return { type: 'stop', text }
|
||||||
|
case '/mission':
|
||||||
|
case '/todos':
|
||||||
|
return { type: 'mission', text }
|
||||||
|
case '/new':
|
||||||
|
case '/create':
|
||||||
|
return { type: 'new', text }
|
||||||
|
case '/cancel':
|
||||||
|
return { type: 'cancel', text }
|
||||||
default:
|
default:
|
||||||
return { type: 'unknown', command, text }
|
return { type: 'unknown', command, text }
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -14,6 +14,28 @@ function inlineToHtml(text: string): string {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert `> …` line groups into `<blockquote>` (Telegram renders it with the accent-colored left bar). */
|
||||||
|
function blocksToHtml(text: string): string {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const out: string[] = []
|
||||||
|
let quote: string[] = []
|
||||||
|
const flush = (): void => {
|
||||||
|
if (quote.length === 0) return
|
||||||
|
out.push(`<blockquote>${inlineToHtml(quote.join('\n'))}</blockquote>`)
|
||||||
|
quote = []
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('> ')) quote.push(line.slice(2))
|
||||||
|
else if (line === '>') quote.push('')
|
||||||
|
else {
|
||||||
|
flush()
|
||||||
|
out.push(inlineToHtml(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
export function markdownToHtml(text: string): string {
|
export function markdownToHtml(text: string): string {
|
||||||
const parts = text.split(/```[\w-]*/)
|
const parts = text.split(/```[\w-]*/)
|
||||||
if (parts.length % 2 === 0) return inlineToHtml(text)
|
if (parts.length % 2 === 0) return inlineToHtml(text)
|
||||||
@@ -23,7 +45,7 @@ export function markdownToHtml(text: string): string {
|
|||||||
const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
|
const code = parts[i]!.replace(/^\n/, '').replace(/\n$/, '')
|
||||||
html += `<pre>${escapeHtml(code)}</pre>`
|
html += `<pre>${escapeHtml(code)}</pre>`
|
||||||
} else {
|
} else {
|
||||||
html += inlineToHtml(parts[i]!)
|
html += blocksToHtml(parts[i]!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html
|
return html
|
||||||
|
|||||||
+7
-2
@@ -3,8 +3,8 @@ import Schema from '@deepseek-ai/schemastery'
|
|||||||
import { TelegramBridge } from './bridge.js'
|
import { TelegramBridge } from './bridge.js'
|
||||||
|
|
||||||
export const name = 'dsh-telegram-channel'
|
export const name = 'dsh-telegram-channel'
|
||||||
/** agents: followup; apiProxy: workspace/session catalog + /model (Cordis requires inject to read ctx.apiProxy). */
|
/** agents: followup; apiProxy and userQuestions are optional runtime services. */
|
||||||
export const inject = ['agents', 'apiProxy']
|
export const inject = ['agents']
|
||||||
|
|
||||||
export interface TelegramChannelConfig {
|
export interface TelegramChannelConfig {
|
||||||
token?: string
|
token?: string
|
||||||
@@ -12,6 +12,9 @@ export interface TelegramChannelConfig {
|
|||||||
allowAllUsers?: boolean
|
allowAllUsers?: boolean
|
||||||
maxMessageLength?: number
|
maxMessageLength?: number
|
||||||
pollingTimeoutSec?: number
|
pollingTimeoutSec?: number
|
||||||
|
/** 默认 'rich' = Telegram native Rich Messages(需较新客户端);
|
||||||
|
* 显式 'html'/'compat' 等其它值 = 兼容模式,所有客户端可显示。 */
|
||||||
|
rendering?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
||||||
@@ -20,6 +23,7 @@ export const Config: Schema<TelegramChannelConfig> = Schema.object({
|
|||||||
allowAllUsers: Schema.boolean().default(false),
|
allowAllUsers: Schema.boolean().default(false),
|
||||||
maxMessageLength: Schema.number().default(4096),
|
maxMessageLength: Schema.number().default(4096),
|
||||||
pollingTimeoutSec: Schema.number().default(30),
|
pollingTimeoutSec: Schema.number().default(30),
|
||||||
|
rendering: Schema.string().default('rich'),
|
||||||
})
|
})
|
||||||
|
|
||||||
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
function resolveAllowedUserIds(config: TelegramChannelConfig): number[] {
|
||||||
@@ -51,6 +55,7 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
|
|||||||
allowAllUsers: config.allowAllUsers ?? false,
|
allowAllUsers: config.allowAllUsers ?? false,
|
||||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||||
|
rendering: config.rendering === 'rich' ? 'rich' : 'html',
|
||||||
})
|
})
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
bridge.start()
|
bridge.start()
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
export const RICH_MESSAGE_MAX_CHARS = 32768
|
||||||
|
export const RICH_MESSAGE_MAX_BLOCKS = 500
|
||||||
|
|
||||||
|
function normalizeTelegramNativeMarkdown(markdown: string): string {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||||
|
let fence: { marker: '`' | '~'; length: number } | undefined
|
||||||
|
let displayMath = false
|
||||||
|
|
||||||
|
const hasClosingDisplayMathDelimiter = (source: string[], index: number): boolean => {
|
||||||
|
for (let i = index + 1; i < source.length; i++) {
|
||||||
|
if (source[i]!.trim() === '$$') return true
|
||||||
|
if (/^ {0,3}(`{3,}|~{3,})/.test(source[i]!)) return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||||
|
const inFence = fence !== undefined
|
||||||
|
|
||||||
|
if (!inFence && line.trim() === '$$') {
|
||||||
|
if (displayMath) {
|
||||||
|
displayMath = false
|
||||||
|
return '```'
|
||||||
|
}
|
||||||
|
if (hasClosingDisplayMathDelimiter(lines, index)) {
|
||||||
|
displayMath = true
|
||||||
|
return '```math'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayMath) return line
|
||||||
|
|
||||||
|
if (!inFence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1]!
|
||||||
|
fence = {
|
||||||
|
marker: markerText[0] as '`' | '~',
|
||||||
|
length: markerText.length,
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
inFence
|
||||||
|
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
|
||||||
|
) {
|
||||||
|
fence = undefined
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTelegramNativeMarkdownBlocks(block: string): number {
|
||||||
|
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1
|
||||||
|
const lines = block.split('\n').filter((line) => line.trim().length > 0)
|
||||||
|
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
|
||||||
|
return Math.max(1, lines.length)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
|
||||||
|
const blocks: string[] = []
|
||||||
|
const current: string[] = []
|
||||||
|
let fence: { marker: '`' | '~'; length: number } | undefined
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
if (current.length === 0) return
|
||||||
|
blocks.push(current.join('\n'))
|
||||||
|
current.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of markdown.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||||
|
|
||||||
|
if (!fence && line.trim().length === 0) {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.push(line)
|
||||||
|
|
||||||
|
if (!fence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1]!
|
||||||
|
fence = { marker: markerText[0] as '`' | '~', length: markerText.length }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fence
|
||||||
|
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
|
||||||
|
) {
|
||||||
|
fence = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
|
||||||
|
if (countTelegramNativeMarkdownBlocks(block) <= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
return [block]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current: string[] = []
|
||||||
|
let fence: { marker: '`' | '~'; length: number } | undefined
|
||||||
|
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||||
|
|
||||||
|
if (!fence && current.length >= RICH_MESSAGE_MAX_BLOCKS) {
|
||||||
|
chunks.push(current.join('\n'))
|
||||||
|
current = []
|
||||||
|
}
|
||||||
|
|
||||||
|
current.push(line)
|
||||||
|
|
||||||
|
if (!fence && fenceMatch) {
|
||||||
|
const markerText = fenceMatch[1]!
|
||||||
|
fence = { marker: markerText[0] as '`' | '~', length: markerText.length }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fence
|
||||||
|
&& new RegExp(`^ {0,3}${fence!.marker}{${fence!.length},}\\s*$`).test(line)
|
||||||
|
) {
|
||||||
|
fence = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) chunks.push(current.join('\n'))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTelegramNativeMarkdownSplitIndex(
|
||||||
|
text: string,
|
||||||
|
hardLimit = RICH_MESSAGE_MAX_CHARS,
|
||||||
|
): number {
|
||||||
|
const paragraphIndex = text.lastIndexOf('\n\n', hardLimit)
|
||||||
|
if (paragraphIndex > 0) return paragraphIndex + 2
|
||||||
|
const lineIndex = text.lastIndexOf('\n', hardLimit)
|
||||||
|
if (lineIndex > 0) return lineIndex + 1
|
||||||
|
const spaceIndex = text.lastIndexOf(' ', hardLimit)
|
||||||
|
if (spaceIndex > 0) return spaceIndex + 1
|
||||||
|
return hardLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownWrappedContent(
|
||||||
|
content: string,
|
||||||
|
maxContentLength: number,
|
||||||
|
wrap: (chunk: string) => string,
|
||||||
|
): string[] {
|
||||||
|
const chunks: string[] = []
|
||||||
|
let remaining = content
|
||||||
|
|
||||||
|
while (remaining.length > maxContentLength) {
|
||||||
|
const window = remaining.slice(0, maxContentLength + 1)
|
||||||
|
const splitIndex = findTelegramNativeMarkdownSplitIndex(window, maxContentLength)
|
||||||
|
chunks.push(wrap(remaining.slice(0, splitIndex)))
|
||||||
|
remaining = remaining.slice(splitIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length > 0) chunks.push(wrap(remaining))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownLongFenceBlock(block: string): string[] | undefined {
|
||||||
|
const lines = block.split('\n')
|
||||||
|
const opening = lines[0] ?? ''
|
||||||
|
const closing = lines[lines.length - 1] ?? ''
|
||||||
|
const openingMatch = opening?.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||||
|
|
||||||
|
if (!openingMatch || !closing || lines.length < 2) return undefined
|
||||||
|
|
||||||
|
const markerText = openingMatch[1] ?? '```'
|
||||||
|
const marker = markerText[0] as '`' | '~'
|
||||||
|
if (!new RegExp(`^ {0,3}${marker}{${markerText.length},}\\s*$`).test(closing)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxContentLength = RICH_MESSAGE_MAX_CHARS - opening.length - closing.length - 2
|
||||||
|
if (maxContentLength <= 0) return undefined
|
||||||
|
|
||||||
|
const content = lines.slice(1, -1).join('\n')
|
||||||
|
return splitTelegramNativeMarkdownWrappedContent(
|
||||||
|
content,
|
||||||
|
maxContentLength,
|
||||||
|
(chunk) => `${opening}\n${chunk}${chunk.endsWith('\n') ? '' : '\n'}${closing}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownLongWrappedInlineBlock(block: string): string[] | undefined {
|
||||||
|
const delimiter = ['**', '__', '~~', '`', '*', '_'].find(
|
||||||
|
(candidate) =>
|
||||||
|
block.startsWith(candidate)
|
||||||
|
&& block.endsWith(candidate)
|
||||||
|
&& block.length > candidate.length * 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!delimiter) return undefined
|
||||||
|
|
||||||
|
const maxContentLength = RICH_MESSAGE_MAX_CHARS - delimiter.length * 2
|
||||||
|
if (maxContentLength <= 0) return undefined
|
||||||
|
|
||||||
|
return splitTelegramNativeMarkdownWrappedContent(
|
||||||
|
block.slice(delimiter.length, -delimiter.length),
|
||||||
|
maxContentLength,
|
||||||
|
(chunk) => `${delimiter}${chunk}${delimiter}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownLongPlainBlock(block: string): string[] {
|
||||||
|
const chunks: string[] = []
|
||||||
|
let remaining = block
|
||||||
|
|
||||||
|
while (remaining.length > RICH_MESSAGE_MAX_CHARS) {
|
||||||
|
const window = remaining.slice(0, RICH_MESSAGE_MAX_CHARS + 1)
|
||||||
|
const splitIndex = findTelegramNativeMarkdownSplitIndex(window)
|
||||||
|
chunks.push(remaining.slice(0, splitIndex).trimEnd())
|
||||||
|
remaining = remaining.slice(splitIndex).trimStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length > 0) chunks.push(remaining)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
|
||||||
|
return (
|
||||||
|
splitTelegramNativeMarkdownLongFenceBlock(block)
|
||||||
|
?? splitTelegramNativeMarkdownLongWrappedInlineBlock(block)
|
||||||
|
?? splitTelegramNativeMarkdownLongPlainBlock(block)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitRichMarkdown(markdown: string): string[] {
|
||||||
|
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown)
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedMarkdown.length <= RICH_MESSAGE_MAX_CHARS
|
||||||
|
&& countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= RICH_MESSAGE_MAX_BLOCKS
|
||||||
|
) {
|
||||||
|
return [normalizedMarkdown]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current = ''
|
||||||
|
let currentBlockCount = 0
|
||||||
|
|
||||||
|
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
|
||||||
|
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
|
||||||
|
const blockCount = countTelegramNativeMarkdownBlocks(block)
|
||||||
|
const candidate = current ? `${current}\n\n${block}` : block
|
||||||
|
const exceedsChars = candidate.length > RICH_MESSAGE_MAX_CHARS
|
||||||
|
const exceedsBlocks = currentBlockCount + blockCount > RICH_MESSAGE_MAX_BLOCKS
|
||||||
|
|
||||||
|
if (!exceedsChars && !exceedsBlocks) {
|
||||||
|
current = candidate
|
||||||
|
currentBlockCount += blockCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) chunks.push(current.trimEnd())
|
||||||
|
|
||||||
|
if (
|
||||||
|
block.length <= RICH_MESSAGE_MAX_CHARS
|
||||||
|
&& blockCount <= RICH_MESSAGE_MAX_BLOCKS
|
||||||
|
) {
|
||||||
|
current = block
|
||||||
|
currentBlockCount = blockCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(...splitTelegramNativeMarkdownLongBlock(block))
|
||||||
|
current = ''
|
||||||
|
currentBlockCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) chunks.push(current.trimEnd())
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* /status 的纯文本装配:把 /status 变成“通用状态显示”。
|
||||||
|
*
|
||||||
|
* 数据语义与 Web 底部统计条(dsh-client-ui-conversation 的 StatsLine)逐项对齐:
|
||||||
|
* - sessionStats 投影:轮次/步数、LLM 与工具耗时、首 token 平均、输出速率(解码 tokens/解码墙钟)
|
||||||
|
* - tokenUsage 投影:输入(uncached + cacheRead + cacheWrite)与输出 tokens、缓存命中率
|
||||||
|
* - contextPressure 投影:上下文占用(projectedTokens ?? pressureTokens / contextWindow)
|
||||||
|
* 数字取整/缩写规则与前端同款(formatTokens / formatDuration / formatTokensPerSecond /
|
||||||
|
* cacheHitPercent 与 roundedIntegerPercent 同算法),保证 /status 与 Web 底部看到的完全一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StatusWorkspace {
|
||||||
|
/** 工作区名(basename 或 Web 工作区标题)。 */
|
||||||
|
title?: string
|
||||||
|
/** 完整目录/路径。 */
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusModel {
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
/** reasoning effort id(如 off/low/high/max);缺省表示 provider 默认行为。 */
|
||||||
|
reasoningEffort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusRuntime {
|
||||||
|
// sessionStats 投影(全量日志)
|
||||||
|
turns?: number
|
||||||
|
steps?: number
|
||||||
|
llmMs?: number
|
||||||
|
toolMs?: number
|
||||||
|
ttftMs?: number
|
||||||
|
ttftSteps?: number
|
||||||
|
decodeMs?: number
|
||||||
|
decodeTokens?: number
|
||||||
|
// tokenUsage 投影(累计计费桶)
|
||||||
|
uncachedInputTokens?: number
|
||||||
|
cacheReadTokens?: number
|
||||||
|
cacheWriteTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
// contextPressure 投影(近次请求)
|
||||||
|
contextUsed?: number
|
||||||
|
contextWindow?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusInfo {
|
||||||
|
/** 绑定时保存的会话展示名。 */
|
||||||
|
label: string
|
||||||
|
/** 完整 SessionId 字符串。 */
|
||||||
|
sessionId: string
|
||||||
|
workspace?: StatusWorkspace
|
||||||
|
model?: StatusModel
|
||||||
|
runtime?: StatusRuntime
|
||||||
|
/** false = 会话不在内存中(冷),仅能显示绑定与工作区信息。 */
|
||||||
|
live?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFORT_NAMES: Record<string, string> = {
|
||||||
|
off: '关闭(off)',
|
||||||
|
low: '低(low)',
|
||||||
|
medium: '中(medium)',
|
||||||
|
high: '高(high)',
|
||||||
|
max: '最高(max)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effortLabel(effort?: string): string | undefined {
|
||||||
|
if (!effort) return undefined
|
||||||
|
return EFFORT_NAMES[effort] ?? String(effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧凑 token 数:517 / 12.2K / 517K / 1.2M(三位以内保留一位小数)——与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatTokens(n: number): string {
|
||||||
|
const scaled = (v: number): string =>
|
||||||
|
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
||||||
|
if (n < 1_000) return String(n)
|
||||||
|
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
|
||||||
|
return `${scaled(n / 1_000_000)}M`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧凑时长:45.2s 以内秒级一位小数,以上 m+s —— 与前端同款。
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const s = ms / 1_000
|
||||||
|
if (s < 60) return `${Math.round(s * 10) / 10}s`
|
||||||
|
const whole = Math.round(s)
|
||||||
|
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出速率:>=10 取整,否则保留一位小数 —— 与前端同款。 */
|
||||||
|
export function formatTokensPerSecond(tps: number): string {
|
||||||
|
const clamped = Math.max(0, tps)
|
||||||
|
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计费口径的输入侧总量:uncached + cacheRead + cacheWrite(同 StatsLine.billedInputTokens)。 */
|
||||||
|
export function billedInputTokens(runtime: StatusRuntime): number {
|
||||||
|
return (runtime.uncachedInputTokens ?? 0)
|
||||||
|
+ (runtime.cacheReadTokens ?? 0)
|
||||||
|
+ (runtime.cacheWriteTokens ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向下取整到 <100 的整数百分比(前端 roundedIntegerPercent 同算法)。 */
|
||||||
|
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
|
||||||
|
const denominatorQuotient = Math.floor(denominator / 200)
|
||||||
|
const denominatorRemainder = denominator % 200
|
||||||
|
let lower = 0
|
||||||
|
let upper = 100
|
||||||
|
while (lower < upper) {
|
||||||
|
const candidate = Math.floor((lower + upper + 1) / 2)
|
||||||
|
const factor = candidate * 2 - 1
|
||||||
|
const threshold = factor * denominatorQuotient
|
||||||
|
+ Math.ceil(factor * denominatorRemainder / 200)
|
||||||
|
if (cacheReadTokens >= threshold) {
|
||||||
|
lower = candidate
|
||||||
|
} else {
|
||||||
|
upper = candidate - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存命中占比(计费输入中 cacheRead 的份额);无计费输入返回 null。前端 cacheHitPercent 同算法。
|
||||||
|
*/
|
||||||
|
export function cacheHitPercent(runtime: StatusRuntime): string | null {
|
||||||
|
const denominator = billedInputTokens(runtime)
|
||||||
|
if (denominator === 0) return null
|
||||||
|
const missed = (runtime.uncachedInputTokens ?? 0) + (runtime.cacheWriteTokens ?? 0)
|
||||||
|
const read = runtime.cacheReadTokens ?? 0
|
||||||
|
if (missed === 0) return '100'
|
||||||
|
|
||||||
|
const integerPercent = roundedIntegerPercent(read, denominator)
|
||||||
|
if (integerPercent < 100) return String(integerPercent)
|
||||||
|
|
||||||
|
// 命中率贴近 100% 时,逐位提高精度直到能区分出 <100 的小数。
|
||||||
|
let decimalPlaces = 1
|
||||||
|
let scaledDoubleGap = missed * 200
|
||||||
|
const denominatorTens = Math.floor(denominator / 10)
|
||||||
|
while (scaledDoubleGap <= denominatorTens) {
|
||||||
|
scaledDoubleGap *= 10
|
||||||
|
decimalPlaces += 1
|
||||||
|
}
|
||||||
|
const denominatorOnes = denominator % 10
|
||||||
|
let roundedLoss = 5
|
||||||
|
for (let loss = 1; loss < 5; loss += 1) {
|
||||||
|
const factor = loss * 2 + 1
|
||||||
|
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
|
||||||
|
if (scaledDoubleGap <= threshold) {
|
||||||
|
roundedLoss = loss
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装配 /status 的完整回复文本。仅当某个数据组存在时才输出对应行;
|
||||||
|
* 一组内同 Web 底部:无数据的子项整组省略。
|
||||||
|
*/
|
||||||
|
export function formatStatusText(info: StatusInfo): string {
|
||||||
|
const lines: string[] = ['会话状态']
|
||||||
|
|
||||||
|
lines.push(`会话:${info.label || info.sessionId}`)
|
||||||
|
lines.push(`会话 ID:${info.sessionId}`)
|
||||||
|
|
||||||
|
const ws = info.workspace
|
||||||
|
if (ws?.title || ws?.path) {
|
||||||
|
const primary = ws.title && ws.path
|
||||||
|
? `${ws.title}(${ws.path})`
|
||||||
|
: (ws.title ?? ws.path ?? '')
|
||||||
|
lines.push(`工作区:${primary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = info.model
|
||||||
|
if (model?.provider && model?.model) {
|
||||||
|
lines.push(`模型:${model.provider}/${model.model}`)
|
||||||
|
lines.push(`思考强度:${effortLabel(model.reasoningEffort) ?? '默认(未指定)'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = info.runtime
|
||||||
|
if (runtime !== undefined) {
|
||||||
|
const contextUsed = runtime.contextUsed
|
||||||
|
const window = runtime.contextWindow
|
||||||
|
if (contextUsed !== undefined && window !== undefined && window > 0) {
|
||||||
|
const percent = Math.min(100, Math.round(contextUsed / window * 100))
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} / ${formatTokens(window)} tokens(${percent}%)`)
|
||||||
|
} else if (contextUsed !== undefined) {
|
||||||
|
lines.push(`上下文:${formatTokens(contextUsed)} tokens(窗口大小未知)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: string[] = []
|
||||||
|
if ((runtime.steps ?? 0) > 0) {
|
||||||
|
stats.push(`${runtime.turns ?? 0} 轮 · ${runtime.steps ?? 0} 步`)
|
||||||
|
const durations: string[] = []
|
||||||
|
if ((runtime.llmMs ?? 0) > 0) durations.push(`LLM ${formatDuration(runtime.llmMs!)}`)
|
||||||
|
if ((runtime.toolMs ?? 0) > 0) durations.push(`工具调用 ${formatDuration(runtime.toolMs!)}`)
|
||||||
|
if (durations.length > 0) stats.push(durations.join(' · '))
|
||||||
|
const speeds: string[] = []
|
||||||
|
if ((runtime.ttftSteps ?? 0) > 0) {
|
||||||
|
speeds.push(`首 token 平均 ${formatDuration((runtime.ttftMs ?? 0) / runtime.ttftSteps!)}`)
|
||||||
|
}
|
||||||
|
if ((runtime.decodeMs ?? 0) > 0) {
|
||||||
|
speeds.push(`${formatTokensPerSecond((runtime.decodeTokens ?? 0) / (runtime.decodeMs! / 1_000))} tok/s`)
|
||||||
|
}
|
||||||
|
if (speeds.length > 0) stats.push(speeds.join(' · '))
|
||||||
|
}
|
||||||
|
if (billedInputTokens(runtime) > 0 || (runtime.outputTokens ?? 0) > 0) {
|
||||||
|
const hit = cacheHitPercent(runtime)
|
||||||
|
const billing = [
|
||||||
|
hit === null ? '' : `缓存命中 ${hit}%`,
|
||||||
|
`输入 ${formatTokens(billedInputTokens(runtime))} tok · 输出 ${formatTokens(runtime.outputTokens ?? 0)} tok`,
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
stats.push(billing)
|
||||||
|
}
|
||||||
|
if (stats.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('统计(与 Web 底部一致)')
|
||||||
|
lines.push(...stats.map((s) => ` ${s}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.live === false) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('(会话当前不在内存中;直接发消息会自动 resume。)')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
+5
-1
@@ -1,6 +1,10 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { apply } from '../src/index.ts'
|
import { apply, inject } from '../src/index.ts'
|
||||||
|
|
||||||
|
test('optional host services are not hard Cordis dependencies', () => {
|
||||||
|
assert.deepEqual(inject, ['agents'])
|
||||||
|
})
|
||||||
|
|
||||||
test('apply without token does not throw and does not start effect', () => {
|
test('apply without token does not throw and does not start effect', () => {
|
||||||
const effects: string[] = []
|
const effects: string[] = []
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { rmSync } from 'node:fs'
|
||||||
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
import { TelegramBridge } from '../src/bridge.ts'
|
import { TelegramBridge } from '../src/bridge.ts'
|
||||||
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
|
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
|
||||||
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
|
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
|
||||||
|
|
||||||
|
// 防呆(2026-09-14 事故):binding 落盘路径 = DSH_HOME/telegram-channel-bindings.json。
|
||||||
|
// 少数用例直接构造 bridge 并 bind,未设 DSH_TELEGRAM_BINDINGS_FILE,就把 fixture
|
||||||
|
// (chatId 10 / live-img-dl)写进了真实文件、覆盖掉用户真实绑定。
|
||||||
|
// 这里把 DSH_HOME 指向临时目录兜底:显式 override 的用例仍走自己的临时文件,
|
||||||
|
// 漏设的用例只会写进临时 HOME。
|
||||||
|
process.env.DSH_HOME = '/tmp/dsh-tg-test-home'
|
||||||
|
|
||||||
type SentMessage = {
|
type SentMessage = {
|
||||||
chatId: number
|
chatId: number
|
||||||
text: string
|
text: string
|
||||||
@@ -27,6 +35,8 @@ function fakeClient(
|
|||||||
sendChatAction: async () => true,
|
sendChatAction: async () => true,
|
||||||
answerCallbackQuery: async () => true,
|
answerCallbackQuery: async () => true,
|
||||||
setMyCommands: async () => true,
|
setMyCommands: async () => true,
|
||||||
|
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `path/${fileId}` }),
|
||||||
|
downloadFile: async () => new Uint8Array([1, 2, 3]),
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +308,10 @@ test('callback bind then plain text followups live agent; mirror assistant to ch
|
|||||||
data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } },
|
data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// Delivery is intentionally resilient now (per-chunk retry + rich→HTML
|
||||||
|
// fallback), which lengthens the async microtask chain past this listener's
|
||||||
|
// void return — flush the event loop before asserting.
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply')))
|
assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply')))
|
||||||
|
|
||||||
await bridge.stop()
|
await bridge.stop()
|
||||||
@@ -645,3 +659,660 @@ test('/unbind clears binding without needing create/dispose', async () => {
|
|||||||
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
|
assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
|
||||||
assert.equal(followups.length, 0)
|
assert.equal(followups.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── /rich: per-chat rendering mode (rich messages need a recent client) ──
|
||||||
|
|
||||||
|
function renderHarness(
|
||||||
|
id: string,
|
||||||
|
sent: SentMessage[],
|
||||||
|
rich: string[],
|
||||||
|
followups: UserMessage[],
|
||||||
|
): {
|
||||||
|
bridge: TelegramBridge
|
||||||
|
emitAssistant: (text: string) => Promise<void>
|
||||||
|
} {
|
||||||
|
const agent = makeAgent(id, followups)
|
||||||
|
let sessionListener: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (sid: ReturnType<typeof SessionId>) => (String(sid) === id ? agent : undefined),
|
||||||
|
},
|
||||||
|
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
|
||||||
|
if (event === 'session/event') sessionListener = listener
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent, {
|
||||||
|
sendRichMessage: async (chatId, markdown) => {
|
||||||
|
rich.push(markdown)
|
||||||
|
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text: markdown }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
bridge.start()
|
||||||
|
const emitAssistant = async (text: string): Promise<void> => {
|
||||||
|
await sessionListener?.(
|
||||||
|
{ id: SessionId(id) },
|
||||||
|
{ type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } },
|
||||||
|
)
|
||||||
|
// deliver() chains async work past the listener's void return — flush.
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
}
|
||||||
|
return { bridge, emitAssistant }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('default rendering is HTML compat (no rich messages unless /rich on)', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const rich: string[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const bindingsFile = `/tmp/dsh-tg-test-bindings-default.json`
|
||||||
|
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
|
||||||
|
try {
|
||||||
|
const { bridge, emitAssistant } = renderHarness('live-render-1', sent, rich, followups)
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}live-render-1`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await emitAssistant('default-mode reply')
|
||||||
|
assert.equal(rich.length, 0, 'no rich message without /rich on')
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('default-mode reply')))
|
||||||
|
await bridge.stop()
|
||||||
|
} finally {
|
||||||
|
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
|
||||||
|
rmSync(bindingsFile, { force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/rich on routes replies to sendRichMessage; /rich off reverts to HTML; status reports state', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const rich: string[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const bindingsFile = `/tmp/dsh-tg-test-bindings-rich.json`
|
||||||
|
process.env.DSH_TELEGRAM_BINDINGS_FILE = bindingsFile
|
||||||
|
try {
|
||||||
|
const { bridge, emitAssistant } = renderHarness('live-render-2', sent, rich, followups)
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}live-render-2`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Default: HTML compat → no rich send yet.
|
||||||
|
await emitAssistant('before toggle')
|
||||||
|
assert.equal(rich.length, 0)
|
||||||
|
|
||||||
|
// /rich (no arg) reports current state.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich', 2))
|
||||||
|
assert.match(sent.at(-1)!.text, /HTML 兼容/)
|
||||||
|
|
||||||
|
// /rich on → assistant replies go through sendRichMessage.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 3))
|
||||||
|
assert.match(sent.at(-1)!.text, /已切换为富文本/)
|
||||||
|
await emitAssistant('rich-mode reply')
|
||||||
|
assert.equal(rich.length, 1)
|
||||||
|
assert.match(rich[0]!, /rich-mode reply/)
|
||||||
|
|
||||||
|
// /rich on again is idempotent (still one preference, no dupes).
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich on', 4))
|
||||||
|
assert.ok(!sent.at(-1)!.text.includes('已切换') || rich.length === 1)
|
||||||
|
|
||||||
|
// /rich off → back to HTML compat path.
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/rich off', 5))
|
||||||
|
assert.match(sent.at(-1)!.text, /已切换为 HTML/)
|
||||||
|
await emitAssistant('compat-mode reply')
|
||||||
|
assert.equal(rich.length, 1, 'no new rich send after /rich off')
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('compat-mode reply')))
|
||||||
|
|
||||||
|
await bridge.stop()
|
||||||
|
} finally {
|
||||||
|
delete process.env.DSH_TELEGRAM_BINDINGS_FILE
|
||||||
|
rmSync(bindingsFile, { force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── /status 通用状态显示(对齐 Web 底部统计条)──
|
||||||
|
|
||||||
|
test('/status shows bind/session/workspace/model/effort/context and web-style stats', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-st'
|
||||||
|
const agent = makeAgent(sessionId, followups, { cwd: '/work/proj-a', title: '演示任务' })
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
apiProxy: {
|
||||||
|
workspace: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{
|
||||||
|
workspaceId: 'w1',
|
||||||
|
path: '/work/proj-a',
|
||||||
|
title: 'Proj A',
|
||||||
|
sessionIds: [sessionId],
|
||||||
|
}],
|
||||||
|
archivedSessionIds: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{
|
||||||
|
sessionId,
|
||||||
|
updatedAt: 1,
|
||||||
|
running: true,
|
||||||
|
blank: false,
|
||||||
|
cwd: '/work/proj-a',
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
models: async () => rpcOk({
|
||||||
|
current: { provider: 'deepseek', model: 'reasoner', reasoningEffort: 'high' },
|
||||||
|
routable: true,
|
||||||
|
groups: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionProjections: {
|
||||||
|
snapshot: () => ({
|
||||||
|
asOfSeq: 0,
|
||||||
|
values: {
|
||||||
|
sessionStats: {
|
||||||
|
turns: 3,
|
||||||
|
steps: 5,
|
||||||
|
llmMs: 4200,
|
||||||
|
toolMs: 2100,
|
||||||
|
ttftMs: 1500,
|
||||||
|
ttftSteps: 5,
|
||||||
|
decodeMs: 6000,
|
||||||
|
decodeTokens: 3000,
|
||||||
|
},
|
||||||
|
tokenUsage: {
|
||||||
|
uncachedInputTokens: 3000,
|
||||||
|
cacheReadTokens: 21000,
|
||||||
|
cacheWriteTokens: 500,
|
||||||
|
outputTokens: 9000,
|
||||||
|
},
|
||||||
|
contextPressure: {
|
||||||
|
contextWindow: 131072,
|
||||||
|
projectedTokens: 64536,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
// bind via legacy callback, then ask for status
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/status', 2))
|
||||||
|
const text = sent.at(-1)!.text
|
||||||
|
assert.ok(text.includes('会话 ID:live-st'), text)
|
||||||
|
assert.ok(text.includes('工作区:Proj A(/work/proj-a)'), text)
|
||||||
|
assert.ok(text.includes('模型:deepseek/reasoner'), text)
|
||||||
|
assert.ok(text.includes('思考强度:高(high)'), text)
|
||||||
|
assert.ok(text.includes('上下文:64.5K / 131K tokens(49%)'), text)
|
||||||
|
assert.ok(text.includes('3 轮 · 5 步'), text)
|
||||||
|
assert.ok(text.includes('首 token 平均 0.3s'), text)
|
||||||
|
assert.ok(text.includes('500 tok/s'), text)
|
||||||
|
assert.ok(text.includes('缓存命中'), text)
|
||||||
|
assert.ok(text.includes('输入 24.5K tok · 输出 9K tok'), text)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/status without bind prompts NEED_BIND', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: { list: () => [], roots: () => [], get: () => undefined },
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/status'))
|
||||||
|
assert.equal(sent[0]?.text, MSG.STATUS_NONE)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── /compact 手动压缩 ──
|
||||||
|
|
||||||
|
test('/compact compacts bound session and reports result', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-cc'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
find: () => true,
|
||||||
|
execute: async () => ({
|
||||||
|
commandId: 'cmd-1',
|
||||||
|
result: { kind: 'success', text: 'Compacted 4 history items (~12345 tokens).' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/compact', 2))
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
|
||||||
|
// runCompaction runs detached; wait for its result notice
|
||||||
|
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes('压缩完成')); i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
assert.ok(sent.some((m) => m.text.includes('压缩完成')), 'compaction result notice arrives')
|
||||||
|
assert.ok(
|
||||||
|
sent.some((m) => m.text.includes('4 条历史记录(约 12345 tokens)')),
|
||||||
|
'result mentions shadowed range',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/compact busy agent replies COMPACT_BUSY without starting', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-busy'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
find: () => true,
|
||||||
|
execute: async () => ({
|
||||||
|
commandId: 'cmd-busy',
|
||||||
|
result: {
|
||||||
|
kind: 'error',
|
||||||
|
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
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.ok(sent.some((m) => m.text.includes('已开始压缩')), 'ack sent')
|
||||||
|
for (let i = 0; i < 100 && !sent.some((m) => m.text.includes(MSG.COMPACT_BUSY)); i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
assert.ok(sent.some((m) => m.text === MSG.COMPACT_BUSY), 'busy notice arrives')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/compact without registered /compact command reports unavailable', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const sessionId = 'live-noeng'
|
||||||
|
const agent = makeAgent(sessionId, followups)
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
find: () => undefined, // command not registered on this host
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 图片消息 ──
|
||||||
|
|
||||||
|
type PromptPayload = { sessionId: string; mode: string; content: Array<{ type: string; text?: string; mediaType?: string; data?: string; name?: string }> }
|
||||||
|
|
||||||
|
function photoUpdate(chatId: number, userId: number, fileId: string, opts?: {
|
||||||
|
caption?: string
|
||||||
|
mediaGroupId?: string
|
||||||
|
updateId?: number
|
||||||
|
}): TelegramUpdate {
|
||||||
|
const updateId = opts?.updateId ?? 1
|
||||||
|
return {
|
||||||
|
update_id: updateId,
|
||||||
|
message: {
|
||||||
|
message_id: updateId,
|
||||||
|
date: 0,
|
||||||
|
chat: { id: chatId, type: 'private' },
|
||||||
|
from: { id: userId },
|
||||||
|
caption: opts?.caption,
|
||||||
|
media_group_id: opts?.mediaGroupId,
|
||||||
|
photo: [
|
||||||
|
{ file_id: `${fileId}-small`, file_unique_id: `${fileId}-small`, width: 90, height: 90 },
|
||||||
|
{ file_id: fileId, file_unique_id: fileId, width: 1280, height: 960, file_size: 300000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUpdate(chatId: number, userId: number, sessionId: string): TelegramUpdate {
|
||||||
|
return {
|
||||||
|
update_id: 1,
|
||||||
|
callback_query: {
|
||||||
|
id: 'bind',
|
||||||
|
from: { id: userId },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: chatId, type: 'private' } },
|
||||||
|
data: `${BIND_CB_PREFIX}${sessionId}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageBridgeCtx(sessionId: string, prompts: PromptPayload[], promptResult?: unknown) {
|
||||||
|
const agent = makeAgent(sessionId, [])
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
ctx: {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [agent],
|
||||||
|
roots: () => [agent],
|
||||||
|
get: (id: ReturnType<typeof SessionId>) => (String(id) === sessionId ? agent : undefined),
|
||||||
|
},
|
||||||
|
apiProxy: {
|
||||||
|
sessions: {
|
||||||
|
prompt: async (req: { payload: unknown }) => {
|
||||||
|
prompts.push(req.payload as PromptPayload)
|
||||||
|
return promptResult ?? rpcOk({ accepted: true })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('photo with caption is admitted via sessions.prompt (largest size, jpeg, base64)', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts)
|
||||||
|
const jpegBytes = new Uint8Array([0xff, 0xd8, 1, 2, 3])
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent, {
|
||||||
|
getFile: async (fileId: string) => ({ file_id: fileId, file_unique_id: fileId, file_path: `dir/${fileId}` }),
|
||||||
|
downloadFile: async (filePath: string) => {
|
||||||
|
assert.ok(filePath.includes('big-photo'), 'downloads the largest photo size')
|
||||||
|
return jpegBytes
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'big-photo', { caption: '看看这张图', updateId: 2 }))
|
||||||
|
assert.equal(prompts.length, 1, 'one prompt RPC')
|
||||||
|
const content = prompts[0]!.content
|
||||||
|
assert.deepEqual(content[0], { type: 'text', text: '看看这张图' })
|
||||||
|
assert.equal(content[1]?.type, 'image')
|
||||||
|
assert.equal(content[1]?.mediaType, 'image/jpeg')
|
||||||
|
assert.equal(content[1]?.data, Buffer.from(jpegBytes).toString('base64'))
|
||||||
|
assert.ok(!sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'no unsupported notice')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('image document (png) is admitted with its file name', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img-doc'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts)
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 2,
|
||||||
|
message: {
|
||||||
|
message_id: 2,
|
||||||
|
date: 0,
|
||||||
|
chat: { id: 10, type: 'private' },
|
||||||
|
from: { id: 1 },
|
||||||
|
document: {
|
||||||
|
file_id: 'doc-png',
|
||||||
|
file_unique_id: 'doc-png',
|
||||||
|
file_name: 'screenshot.png',
|
||||||
|
mime_type: 'image/png',
|
||||||
|
file_size: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(prompts.length, 1)
|
||||||
|
const image = prompts[0]!.content.find((p) => p.type === 'image')
|
||||||
|
assert.equal(image?.mediaType, 'image/png')
|
||||||
|
assert.equal(image?.name, 'screenshot.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-image document gets MEDIA_UNSUPPORTED and no prompt', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img-pdf'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts)
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
sent.length = 0
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 2,
|
||||||
|
message: {
|
||||||
|
message_id: 2,
|
||||||
|
date: 0,
|
||||||
|
chat: { id: 10, type: 'private' },
|
||||||
|
from: { id: 1 },
|
||||||
|
document: { file_id: 'doc-pdf', file_unique_id: 'doc-pdf', file_name: 'report.pdf', mime_type: 'application/pdf' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(prompts.length, 0, 'no prompt dispatched')
|
||||||
|
assert.ok(sent.some((m) => m.text === MSG.MEDIA_UNSUPPORTED), 'unsupported notice sent')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('album photos collapse into a single prompt with all images', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img-album'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts)
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'album-1', { mediaGroupId: 'g1', updateId: 2 }))
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'album-2', { mediaGroupId: 'g1', updateId: 3 }))
|
||||||
|
for (let i = 0; i < 500 && prompts.length === 0; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
}
|
||||||
|
assert.equal(prompts.length, 1, 'album debounced into one prompt')
|
||||||
|
assert.equal(prompts[0]!.content.filter((p) => p.type === 'image').length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model without image support replies IMAGE_MODEL_UNSUPPORTED', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img-nv'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts, {
|
||||||
|
rpcId: 'x',
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'attachment-error',
|
||||||
|
message: 'Model "deepseek-chat" does not support image input.',
|
||||||
|
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
sent.length = 0
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'nv-photo', { updateId: 2 }))
|
||||||
|
for (let i = 0; i < 100 && sent.length === 0; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
assert.ok(sent.some((m) => m.text === MSG.IMAGE_MODEL_UNSUPPORTED), 'model unsupported notice')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('photo download failure replies IMAGE_FAILED', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const sessionId = 'live-img-dl'
|
||||||
|
const { ctx } = imageBridgeCtx(sessionId, prompts)
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent, {
|
||||||
|
getFile: async () => { throw new Error('telegram timeout') },
|
||||||
|
}),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(bindUpdate(10, 1, sessionId))
|
||||||
|
sent.length = 0
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'dl-photo', { updateId: 2 }))
|
||||||
|
for (let i = 0; i < 100 && sent.length === 0; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
assert.equal(prompts.length, 0, 'no prompt dispatched')
|
||||||
|
assert.ok(sent.some((m) => m.text.startsWith('图片发送失败。')), 'failure notice')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('photo without bind prompts NEED_BIND and no prompt', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const prompts: PromptPayload[] = []
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: { list: () => [], roots: () => [], get: () => undefined },
|
||||||
|
apiProxy: { sessions: { prompt: async (req: { payload: unknown }) => { prompts.push(req.payload as PromptPayload); return rpcOk({ accepted: true }) } } },
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(photoUpdate(10, 1, 'unbound-photo', { updateId: 1 }))
|
||||||
|
assert.equal(prompts.length, 0)
|
||||||
|
assert.ok(sent.some((m) => m.text === MSG.NEED_BIND), 'NEED_BIND sent')
|
||||||
|
})
|
||||||
|
|||||||
@@ -51,3 +51,24 @@ test('sendMessage can include reply_markup', async () => {
|
|||||||
inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]],
|
inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('sendRichMessage posts native rich markdown body', async () => {
|
||||||
|
const calls: Array<{ url: string; body: Record<string, unknown> }> = []
|
||||||
|
const fetchImpl: typeof fetch = async (input, init) => {
|
||||||
|
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) })
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { message_id: 2, date: 0, chat: { id: 1, type: 'private' }, text: '# hi' },
|
||||||
|
}), { status: 200 })
|
||||||
|
}
|
||||||
|
const client = new TelegramClient('TOK', { fetch: fetchImpl })
|
||||||
|
await client.sendRichMessage(1, '# hi\n\n- a\n- b')
|
||||||
|
assert.match(calls[0]!.url, /\/botTOK\/sendRichMessage$/)
|
||||||
|
assert.deepEqual(calls[0]!.body, {
|
||||||
|
chat_id: 1,
|
||||||
|
rich_message: {
|
||||||
|
markdown: '# hi\n\n- a\n- b',
|
||||||
|
skip_entity_detection: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ test('parse slash commands for remote control', () => {
|
|||||||
assert.equal(parseCommand('hello').type, 'plain')
|
assert.equal(parseCommand('hello').type, 'plain')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parse /rich with optional mode argument', () => {
|
||||||
|
const bare = parseCommand('/rich')
|
||||||
|
assert.equal(bare.type, 'rich')
|
||||||
|
assert.equal(bare.type === 'rich' ? bare.arg : 'x', undefined)
|
||||||
|
const on = parseCommand('/rich on')
|
||||||
|
assert.equal(on.type, 'rich')
|
||||||
|
assert.equal(on.type === 'rich' ? on.arg : 'x', 'on')
|
||||||
|
assert.equal(parseCommand('/rich off').type === 'rich' && parseCommand('/rich off').arg, 'off')
|
||||||
|
assert.equal(parseCommand('/rich ON').type === 'rich' && parseCommand('/rich ON').arg, 'on')
|
||||||
|
assert.equal(parseCommand('/render auto').type === 'rich' && parseCommand('/render auto').arg, 'auto')
|
||||||
|
assert.equal(parseCommand('/setting').type, 'rich')
|
||||||
|
assert.equal(parseCommand('/setting html').type === 'rich' && parseCommand('/setting html').arg, 'html')
|
||||||
|
})
|
||||||
|
|
||||||
test('Chinese copy mentions sessions and bind', () => {
|
test('Chinese copy mentions sessions and bind', () => {
|
||||||
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
||||||
assert.ok(MSG.HELP.includes('/sessions'))
|
assert.ok(MSG.HELP.includes('/sessions'))
|
||||||
@@ -26,3 +40,9 @@ test('Chinese copy mentions sessions and bind', () => {
|
|||||||
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
||||||
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parse /compact', () => {
|
||||||
|
assert.equal(parseCommand('/compact').type, 'compact')
|
||||||
|
assert.equal(parseCommand('/compact@MyBot').type, 'compact')
|
||||||
|
assert.equal(parseCommand('/compact now').type, 'compact')
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
RICH_MESSAGE_MAX_CHARS,
|
||||||
|
splitRichMarkdown,
|
||||||
|
} from '../src/rich-format.ts'
|
||||||
|
|
||||||
|
test('splitRichMarkdown keeps small markdown as one chunk', () => {
|
||||||
|
const markdown = '# 标题\n\n- a\n- b\n\n```js\nx\n```\n'
|
||||||
|
assert.deepEqual(splitRichMarkdown(markdown), [markdown])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('splitRichMarkdown splits long plain text without losing content', () => {
|
||||||
|
const markdown = 'a'.repeat(RICH_MESSAGE_MAX_CHARS + 100)
|
||||||
|
const chunks = splitRichMarkdown(markdown)
|
||||||
|
assert.ok(chunks.length > 1)
|
||||||
|
assert.equal(chunks.join(''), markdown)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('splitRichMarkdown keeps long fenced code blocks balanced', () => {
|
||||||
|
const opening = '```js\n'
|
||||||
|
const closing = '\n```'
|
||||||
|
const markdown = `${opening}${'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000)}${closing}`
|
||||||
|
const chunks = splitRichMarkdown(markdown)
|
||||||
|
assert.ok(chunks.length > 1)
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
assert.ok(chunk.trimStart().startsWith('```'), 'chunk should start with a fence')
|
||||||
|
assert.ok(chunk.trimEnd().endsWith('```'), 'chunk should end with a fence')
|
||||||
|
}
|
||||||
|
assert.equal(chunks.join('').replace(/```js\n/g, '').replace(/\n```/g, ''), 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1000))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user