feat: Telegram 流式预览(sendMessageDraft)+ 出站渲染解耦 + 投递容错

Bridge 新增"边生成边显示"能力,利用 Telegram Bot API 9.5 的
sendMessageDraft 在私聊中以草稿形式流式展示 Claude 生成进度。
架构上抽象为通道级可选能力,便于后续扩展到其他 IM。

types.ts
- 新增 PreviewCapabilities、StreamingPreviewState 接口

channel-adapter.ts
- BaseChannelAdapter 新增三个可选方法:
  getPreviewCapabilities / sendPreview / endPreview
- sendPreview 返回 'sent'|'skip'|'degrade' 三态,
  区分瞬时失败(跳过)和永久失败(降级)

telegram-utils.ts
- 新增 sendMessageDraft() API 封装(plain text,截断到 4096)

telegram-adapter.ts
- 实现 getPreviewCapabilities: 开关检查 + 私聊/群聊检测 + 降级状态
- 实现 sendPreview: 调 sendMessageDraft,400/404 永久降级,429 仅跳过
- 实现 endPreview: 空操作
- stop() 清空 previewDegraded Set

conversation-engine.ts
- 新增 OnPartialText 回调类型
- processMessage / consumeStream 透传 onPartialText 参数
- 使用独立 previewText 变量(只累积、不因 tool_use 清零),
  避免预览文本在工具调用后回退/抖动

bridge-manager.ts
- 流式预览状态机:generateDraftId / getStreamConfig / flushPreview
  节流闭包(间隔+最小增量+trailing-edge timer),finally 清理
- flushPreview 仅在 'degrade' 时降级,'skip'/网络错误不降级
- 新增 deliverResponse() 按 channelType 分发渲染:
  Telegram → markdownToTelegramChunks + deliverRendered
  其他 IM → deliver() 纯文本(内部自动分块)
  解除 handleMessage 对 Telegram 渲染的硬编码依赖

delivery-layer.ts
- deliverRendered: chunk 失败后继续投递剩余 chunk(不中断),
  追发截断提示通知用户,返回 ok:false 标识不完整投递
- deliver: 保持原有 fail-fast 行为(短消息无需跳过)

route.ts (bridge/settings)
- BRIDGE_SETTING_KEYS 白名单追加 5 个流式配置项

docs/handover/bridge-system.md
- 更新目录结构、数据流、设计决策(#12 流式预览)、设置项表

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-03 23:20:47 +08:00
1 parent 6dfe23a9a9
commit b1a043a995
11 files changed
+313 -23

No files matched your search

+5
View File
@@ -9,6 +9,11 @@ const BRIDGE_SETTING_KEYS = [
'bridge_default_work_dir',
'bridge_default_model',
'bridge_default_provider_id',
'bridge_telegram_stream_enabled',
'bridge_telegram_stream_interval_ms',
'bridge_telegram_stream_min_delta_chars',
'bridge_telegram_stream_max_chars',
'bridge_telegram_stream_private_only',
] as const;
export async function GET() {
+45 -1
View File
@@ -10,11 +10,12 @@ import type {
ChannelType,
InboundMessage,
OutboundMessage,
PreviewCapabilities,
SendResult,
} from '../types';
import type { FileAttachment } from '@/types';
import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter';
import { callTelegramApi } from './telegram-utils';
import { callTelegramApi, sendMessageDraft } from './telegram-utils';
import {
isImageEnabled,
downloadPhoto,
@@ -79,6 +80,8 @@ export class TelegramAdapter extends BaseChannelAdapter {
private waiters: Array<(msg: InboundMessage | null) => void> = [];
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
private mediaGroupBuffers = new Map<string, MediaGroupBufferEntry>();
/** Chat IDs where sendMessageDraft has permanently failed (method not found / 400 / 404). */
private previewDegraded = new Set<string>();
/** Committed offset — the highest update_id that has been safely enqueued or skipped. */
private committedOffset = 0;
@@ -145,6 +148,9 @@ export class TelegramAdapter extends BaseChannelAdapter {
}
this.mediaGroupBuffers.clear();
// Reset preview degradation state
this.previewDegraded.clear();
console.log('[telegram-adapter] Stopped');
}
@@ -280,6 +286,44 @@ export class TelegramAdapter extends BaseChannelAdapter {
this.persistCommittedOffset();
}
// ── Streaming preview ────────────────────────────────────────
getPreviewCapabilities(chatId: string): PreviewCapabilities | null {
// Global kill switch
if (getSetting('bridge_telegram_stream_enabled') === 'false') return null;
// Private-only check: positive chatId = private, negative = group/channel
const privateOnly = getSetting('bridge_telegram_stream_private_only') !== 'false';
if (privateOnly && parseInt(chatId, 10) < 0) return null;
// Already degraded for this chat
if (this.previewDegraded.has(chatId)) return null;
return { supported: true, privateOnly };
}
async sendPreview(chatId: string, text: string, draftId: number): Promise<'sent' | 'skip' | 'degrade'> {
const token = this.botToken;
if (!token) return 'skip';
const result = await sendMessageDraft(token, chatId, text, draftId);
if (result.ok) return 'sent';
// Classify failure
const status = result.httpStatus;
if (status === 400 || status === 404) {
// Method not found or bad request — permanent degradation
this.previewDegraded.add(chatId);
return 'degrade';
}
// 429 (rate limit) or transient — skip this update but don't degrade
return 'skip';
}
endPreview(_chatId: string, _draftId: number): void {
// No-op: the final sendMessage naturally replaces the draft
}
// ── Lifecycle hooks (called generically by bridge-manager) ───
onMessageStart(chatId: string): void {
+18
View File
@@ -66,6 +66,24 @@ export async function callTelegramApi(
}
}
/**
* Send a draft message preview via Telegram Bot API 9.5 sendMessageDraft.
* Plain text only (no parse_mode) — used for streaming preview.
*/
export async function sendMessageDraft(
botToken: string,
chatId: string,
text: string,
draftId: number,
): Promise<TelegramSendResult> {
const truncated = text.length > 4096 ? text.slice(0, 4096) : text;
return callTelegramApi(botToken, 'sendMessageDraft', {
chat_id: chatId,
text: truncated,
draft_id: draftId,
});
}
/**
* Escape special HTML characters for Telegram HTML mode.
*/
+147 -9
View File
@@ -7,7 +7,7 @@
* Uses globalThis to survive Next.js HMR in development.
*/
import type { BridgeStatus, InboundMessage, OutboundMessage } from './types';
import type { BridgeStatus, InboundMessage, OutboundMessage, StreamingPreviewState } from './types';
import { createAdapter, getRegisteredTypes } from './channel-adapter';
import type { BaseChannelAdapter } from './channel-adapter';
// Side-effect import: triggers self-registration of all adapter factories
@@ -30,6 +30,79 @@ import {
const GLOBAL_KEY = '__bridge_manager__';
// ── Streaming preview helpers ──────────────────────────────────
/** Generate a non-zero random 31-bit integer for use as draft_id. */
function generateDraftId(): number {
return (Math.floor(Math.random() * 0x7FFFFFFE) + 1); // 1 .. 2^31-1
}
interface StreamConfig {
intervalMs: number;
minDeltaChars: number;
maxChars: number;
}
function getStreamConfig(): StreamConfig {
const intervalMs = parseInt(getSetting('bridge_telegram_stream_interval_ms') || '', 10) || 700;
const minDeltaChars = parseInt(getSetting('bridge_telegram_stream_min_delta_chars') || '', 10) || 20;
const maxChars = parseInt(getSetting('bridge_telegram_stream_max_chars') || '', 10) || 3900;
return { intervalMs, minDeltaChars, maxChars };
}
/** Fire-and-forget: send a preview draft. Only degrades on permanent failure. */
function flushPreview(
adapter: BaseChannelAdapter,
state: StreamingPreviewState,
config: StreamConfig,
): void {
if (state.degraded || !adapter.sendPreview) return;
const text = state.pendingText.length > config.maxChars
? state.pendingText.slice(0, config.maxChars) + '...'
: state.pendingText;
state.lastSentText = text;
state.lastSentAt = Date.now();
adapter.sendPreview(state.chatId, text, state.draftId).then(result => {
if (result === 'degrade') state.degraded = true;
// 'skip' — transient failure, next flush will retry naturally
}).catch(() => {
// Network error — transient, don't degrade
});
}
// ── Channel-aware rendering dispatch ──────────────────────────
import type { ChannelAddress, SendResult } from './types';
/**
* Render response text and deliver via the appropriate channel format.
* Telegram: Markdown → HTML chunks via deliverRendered.
* Other channels: plain text via deliver (no HTML).
*/
async function deliverResponse(
adapter: BaseChannelAdapter,
address: ChannelAddress,
responseText: string,
sessionId: string,
): Promise<SendResult> {
if (adapter.channelType === 'telegram') {
const chunks = markdownToTelegramChunks(responseText, 4096);
if (chunks.length > 0) {
return deliverRendered(adapter, address, chunks, { sessionId });
}
return { ok: true };
}
// Generic fallback: deliver as plain text (deliver() handles chunking internally)
return deliver(adapter, {
address,
text: responseText,
parseMode: 'plain',
}, { sessionId });
}
interface AdapterMeta {
lastMessageAt: string | null;
lastError: string | null;
@@ -365,6 +438,67 @@ async function handleMessage(
const state = getState();
state.activeTasks.set(binding.codepilotSessionId, taskAbort);
// ── Streaming preview setup ──────────────────────────────────
let previewState: StreamingPreviewState | null = null;
const caps = adapter.getPreviewCapabilities?.(msg.address.chatId) ?? null;
if (caps?.supported) {
previewState = {
draftId: generateDraftId(),
chatId: msg.address.chatId,
lastSentText: '',
lastSentAt: 0,
degraded: false,
throttleTimer: null,
pendingText: '',
};
}
const streamCfg = previewState ? getStreamConfig() : null;
// Build the onPartialText callback (or undefined if preview not supported)
const onPartialText = (previewState && streamCfg) ? (fullText: string) => {
const ps = previewState!;
const cfg = streamCfg!;
if (ps.degraded) return;
// Truncate to maxChars + ellipsis
ps.pendingText = fullText.length > cfg.maxChars
? fullText.slice(0, cfg.maxChars) + '...'
: fullText;
const delta = ps.pendingText.length - ps.lastSentText.length;
const elapsed = Date.now() - ps.lastSentAt;
if (delta < cfg.minDeltaChars && ps.lastSentAt > 0) {
// Not enough new content — schedule trailing-edge timer if not already set
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs);
}
return;
}
if (elapsed < cfg.intervalMs && ps.lastSentAt > 0) {
// Too soon — schedule trailing-edge timer to ensure latest text is sent
if (!ps.throttleTimer) {
ps.throttleTimer = setTimeout(() => {
ps.throttleTimer = null;
if (!ps.degraded) flushPreview(adapter, ps, cfg);
}, cfg.intervalMs - elapsed);
}
return;
}
// Clear any pending trailing-edge timer and flush immediately
if (ps.throttleTimer) {
clearTimeout(ps.throttleTimer);
ps.throttleTimer = null;
}
flushPreview(adapter, ps, cfg);
} : undefined;
try {
// Pass permission callback so requests are forwarded to IM immediately
// during streaming (the stream blocks until permission is resolved).
@@ -381,16 +515,11 @@ async function handleMessage(
binding.codepilotSessionId,
perm.suggestions,
);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText);
// Send response text — render Markdown to Telegram HTML
// Send response text — render via channel-appropriate format
if (result.responseText) {
const chunks = markdownToTelegramChunks(result.responseText, 4096);
if (chunks.length > 0) {
await deliverRendered(adapter, msg.address, chunks, {
sessionId: binding.codepilotSessionId,
});
}
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId);
} else if (result.hasError) {
const errorResponse: OutboundMessage = {
address: msg.address,
@@ -413,6 +542,15 @@ async function handleMessage(
} catch { /* best effort */ }
}
} finally {
// Clean up preview state
if (previewState) {
if (previewState.throttleTimer) {
clearTimeout(previewState.throttleTimer);
previewState.throttleTimer = null;
}
adapter.endPreview?.(msg.address.chatId, previewState.draftId);
}
state.activeTasks.delete(binding.codepilotSessionId);
// Notify adapter that message processing ended
adapter.onMessageEnd?.(msg.address.chatId);
+20
View File
@@ -9,6 +9,7 @@ import type {
ChannelType,
InboundMessage,
OutboundMessage,
PreviewCapabilities,
SendResult,
} from './types';
@@ -76,6 +77,25 @@ export abstract class BaseChannelAdapter {
* Default is a no-op; override in adapters that need deferred offset tracking.
*/
acknowledgeUpdate?(_updateId: number): void;
/**
* Return preview capabilities for a given chat.
* Returning null means streaming preview is not available for this chat.
*/
getPreviewCapabilities?(_chatId: string): PreviewCapabilities | null;
/**
* Send (or update) a streaming preview draft.
* Returns 'sent' on success, 'skip' for transient failures (caller should
* retry later), or 'degrade' for permanent failures (caller should stop).
*/
sendPreview?(_chatId: string, _text: string, _draftId: number): Promise<'sent' | 'skip' | 'degrade'>;
/**
* Signal the end of a preview cycle. The final message is sent via the
* normal delivery path, so this is typically a no-op.
*/
endPreview?(_chatId: string, _draftId: number): void;
}
// ── Adapter Registry ────────────────────────────────────────────
+15 -1
View File
@@ -43,6 +43,12 @@ export interface PermissionRequestInfo {
*/
export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise<void>;
/**
* Callback invoked on each `text` SSE event with the full accumulated text so far.
* Must return synchronously — the bridge-manager handles throttling and fire-and-forget.
*/
export type OnPartialText = (fullText: string) => void;
export interface ConversationResult {
responseText: string;
tokenUsage: TokenUsage | null;
@@ -64,6 +70,7 @@ export async function processMessage(
onPermissionRequest?: OnPermissionRequest,
abortSignal?: AbortSignal,
files?: FileAttachment[],
onPartialText?: OnPartialText,
): Promise<ConversationResult> {
const sessionId = binding.codepilotSessionId;
@@ -179,7 +186,7 @@ export async function processMessage(
// Consume the stream server-side (replicate collectStreamResponse pattern).
// Permission requests are forwarded immediately via the callback during streaming
// because the stream blocks until permission is resolved — we can't wait until after.
return await consumeStream(stream, sessionId, onPermissionRequest);
return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText);
} finally {
clearInterval(renewalInterval);
releaseSessionLock(sessionId, lockId);
@@ -195,10 +202,13 @@ async function consumeStream(
stream: ReadableStream<string>,
sessionId: string,
onPermissionRequest?: OnPermissionRequest,
onPartialText?: OnPartialText,
): Promise<ConversationResult> {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
let currentText = '';
/** Monotonically accumulated text for streaming preview — never resets on tool_use. */
let previewText = '';
let tokenUsage: TokenUsage | null = null;
let hasError = false;
let errorMessage = '';
@@ -225,6 +235,10 @@ async function consumeStream(
switch (event.type) {
case 'text':
currentText += event.data;
if (onPartialText) {
previewText += event.data;
try { onPartialText(previewText); } catch { /* non-critical */ }
}
break;
case 'tool_use': {
+18 -1
View File
@@ -287,6 +287,7 @@ export async function deliverRendered(
}
let lastMessageId: string | undefined;
let failedCount = 0;
for (let i = 0; i < chunks.length; i++) {
await rateLimiter.acquire(address.chatId);
@@ -303,7 +304,14 @@ export async function deliverRendered(
// Try HTML first, fall back to plain text on parse error
const result = await sendWithRetry(adapter, htmlMessage, chunk.text);
if (!result.ok) return result;
if (!result.ok) {
console.warn(
`[delivery-layer] Chunk ${i + 1}/${chunks.length} failed for chat ${address.chatId}: ${result.error}`,
);
failedCount++;
// Continue delivering remaining chunks instead of aborting
continue;
}
lastMessageId = result.messageId;
if (result.messageId && opts?.sessionId) {
@@ -319,6 +327,12 @@ export async function deliverRendered(
}
}
// Notify user about incomplete delivery
if (failedCount > 0 && lastMessageId) {
const notice = `[${failedCount}/${chunks.length} part(s) failed to send — response may be incomplete]`;
await adapter.send({ address, text: notice, parseMode: 'plain' }).catch(() => {});
}
if (opts?.dedupKey) {
try { insertDedup(opts.dedupKey); } catch { /* best effort */ }
}
@@ -333,5 +347,8 @@ export async function deliverRendered(
});
} catch { /* best effort */ }
if (failedCount > 0) {
return { ok: false, error: `${failedCount}/${chunks.length} chunk(s) failed`, messageId: lastMessageId };
}
return { ok: true, messageId: lastMessageId };
}
+19
View File
@@ -145,6 +145,25 @@ export interface PermissionLink {
createdAt: string;
}
// ── Streaming Preview ─────────────────────────────────────────
/** Capabilities of a channel adapter's streaming preview support */
export interface PreviewCapabilities {
supported: boolean;
privateOnly: boolean;
}
/** Mutable state for an in-flight streaming preview */
export interface StreamingPreviewState {
draftId: number; // non-zero 31-bit random integer, reused within one answer cycle
chatId: string;
lastSentText: string; // last text actually sent as draft
lastSentAt: number; // timestamp (ms) of last sent draft
degraded: boolean; // set true after API failure → skip further previews
throttleTimer: ReturnType<typeof setTimeout> | null;
pendingText: string; // latest accumulated text (may not yet be sent due to throttle)
}
// ── Config ─────────────────────────────────────────────────────
/** Platform-specific message length limits */