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:
1 parent
6dfe23a9a9
commit
b1a043a995
11 files changed
+313
-23
No files matched your search
@@ -11,10 +11,10 @@ src/lib/bridge/
|
||||
├── types.ts # 共享类型(ChannelBinding, BridgeStatus, InboundMessage 等)
|
||||
├── channel-adapter.ts # 抽象基类 + adapter 注册表(registerAdapterFactory/createAdapter)
|
||||
├── channel-router.ts # (channel, user, thread) → session 映射,自动创建/绑定会话
|
||||
├── conversation-engine.ts # 服务端消费 streamClaude() SSE 流,保存消息到 DB
|
||||
├── conversation-engine.ts # 服务端消费 streamClaude() SSE 流,保存消息到 DB,onPartialText 流式回调
|
||||
├── permission-broker.ts # 权限请求转发到 IM 内联按钮,处理回调审批
|
||||
├── delivery-layer.ts # 出站消息分片、限流、重试退避、HTML 降级
|
||||
├── bridge-manager.ts # 生命周期编排,adapter 事件循环,/stop abort,命令路由
|
||||
├── bridge-manager.ts # 生命周期编排,adapter 事件循环,流式预览状态机,deliverResponse 渲染分发
|
||||
├── markdown/
|
||||
│ ├── ir.ts # Markdown → IR 中间表示解析器(基于 markdown-it)
|
||||
│ ├── render.ts # IR → 格式化输出的通用标记渲染器
|
||||
@@ -23,7 +23,7 @@ src/lib/bridge/
|
||||
│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter)
|
||||
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
|
||||
│ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换
|
||||
│ └── telegram-utils.ts # callTelegramApi / escapeHtml / splitMessage
|
||||
│ └── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
|
||||
└── security/
|
||||
├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟)
|
||||
└── validators.ts # 路径/SessionID/危险输入校验
|
||||
@@ -39,16 +39,18 @@ Telegram 消息 → TelegramAdapter.pollLoop()
|
||||
→ BridgeManager.runAdapterLoop() → handleMessage()
|
||||
→ 命令? → handleCommand() 处理 /new /bind /cwd /mode /stop 等
|
||||
→ 普通消息/图片? → ChannelRouter.resolve() 获取 ChannelBinding
|
||||
→ ConversationEngine.processMessage(binding, text, ..., files?)
|
||||
→ ConversationEngine.processMessage(binding, text, ..., files?, onPartialText?)
|
||||
→ 有图片时:写入 .codepilot-uploads/ + <!--files:JSON-->text 格式存 DB(桌面 UI 可渲染)
|
||||
→ streamClaude({ prompt, files }) → Claude vision API
|
||||
→ consumeStream() 服务端消费
|
||||
→ permission_request → 立即回调 → PermissionBroker 转发到 IM
|
||||
→ text/tool_use/tool_result → 累积内容块
|
||||
→ text → 累积 currentText + previewText → onPartialText(previewText) 回调
|
||||
→ tool_use/tool_result → 累积内容块(currentText 清零,previewText 不清零)
|
||||
→ result → 捕获 tokenUsage + sdkSessionId
|
||||
→ addMessage() 保存到 DB
|
||||
→ markdownToTelegramChunks() → Markdown→IR→HTML render-first 分片
|
||||
→ DeliveryLayer.deliverRendered() → 限流 + HTML/plain 双通道发送到 Telegram
|
||||
→ deliverResponse() 按 channelType 分发渲染:
|
||||
→ Telegram: markdownToTelegramChunks() → deliverRendered() → 限流 + HTML/plain 双通道
|
||||
→ 其他 IM: deliver() → 纯文本分块发送
|
||||
→ finally: adapter.acknowledgeUpdate(updateId) → 推进 committedOffset 并持久化
|
||||
```
|
||||
|
||||
@@ -105,7 +107,15 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
|
||||
- **渲染层**(`markdown/render.ts`):通用标记渲染器 `renderMarkdownWithMarkers(ir, options)`,接受样式→标签映射表 + escapeText + buildLink 回调,输出格式化文本。使用 boundary tracking + LIFO stack 处理嵌套。
|
||||
- **Telegram 层**(`markdown/telegram.ts`):组合 IR+渲染器,映射样式到 Telegram HTML 标签。`wrapFileReferencesInHtml()` 防止 `README.md`、`main.go` 等文件名被 Telegram linkify 误识别为 URL(用 `<code>` 包裹)。`markdownToTelegramChunks(text, limit)` 实现 render-first 分片:先按 IR text 长度分块,再渲染每块为 HTML,若 HTML 超出 4096 限制则按比例重新分割。
|
||||
|
||||
`bridge-manager.ts` 对 Claude 回复调用 `markdownToTelegramChunks()`,通过 `deliverRendered()` 发送预渲染 chunks(每个 chunk 包含 html + text 双通道,HTML 解析失败自动降级纯文本)。命令响应和错误消息仍使用 `escapeHtml()` + `deliver()`。
|
||||
`bridge-manager.ts` 通过 `deliverResponse()` 按 `adapter.channelType` 分发渲染:Telegram 走 `markdownToTelegramChunks()` + `deliverRendered()`(HTML/plain 双通道),其他 IM 走 `deliver()` 纯文本。`deliverRendered()` 在分块部分失败时继续投递剩余 chunk 并追发截断提示,最终返回 `ok: false` 标识不完整投递。命令响应和错误消息仍使用 `escapeHtml()` + `deliver()`。
|
||||
|
||||
**12. Telegram 流式预览(sendMessageDraft)**
|
||||
利用 Telegram Bot API 9.5 的 `sendMessageDraft` 方法,在 Claude 生成过程中以草稿形式实时展示文本预览。架构上抽象为通道级可选能力(`BaseChannelAdapter` 的 `getPreviewCapabilities`/`sendPreview`/`endPreview` 三个可选方法),未实现这些方法的 adapter 自动跳过。
|
||||
|
||||
- **引擎层**:`consumeStream()` 维护独立的 `previewText` 变量(只累积、不因 `tool_use` 清零),通过 `onPartialText` 回调同步传递完整预览文本。
|
||||
- **编排层**:`bridge-manager.handleMessage()` 检查 adapter 能力 → 分配 `draftId` → 构建节流闭包(间隔 700ms + 最小增量 20 字符 + trailing-edge timer)→ `flushPreview()` fire-and-forget 发送 → finally 清理 timer + `endPreview()`。
|
||||
- **降级**:`sendPreview` 返回 `'sent'|'skip'|'degrade'` 三态。400/404(API 不支持)→ 永久降级该 chatId;429/网络错误 → 仅跳过本次。`previewDegraded` Set 在 adapter `stop()` 时清空。
|
||||
- **线程安全**:`processWithSessionLock` 保证同 session 串行 → 同时刻只有一个 `previewState`。多个 in-flight `sendMessageDraft` 安全:Telegram 对同 `draft_id` last-write-wins。
|
||||
|
||||
## 设置项(settings 表)
|
||||
|
||||
@@ -120,6 +130,11 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
|
||||
| telegram_bridge_allowed_users | 白名单用户 ID(逗号分隔) |
|
||||
| bridge_telegram_image_enabled | Telegram 图片接收开关(默认 true,设为 false 关闭) |
|
||||
| bridge_telegram_max_image_size | 图片大小上限(字节,默认 20MB) |
|
||||
| bridge_telegram_stream_enabled | 流式预览总开关(默认启用,设为 `false` 关闭) |
|
||||
| bridge_telegram_stream_interval_ms | 预览节流间隔(默认 700ms) |
|
||||
| bridge_telegram_stream_min_delta_chars | 最小增量字符数(默认 20) |
|
||||
| bridge_telegram_stream_max_chars | 草稿截断阈值(默认 3900) |
|
||||
| bridge_telegram_stream_private_only | 仅私聊启用预览(默认 true,群聊自动跳过) |
|
||||
|
||||
## API 路由
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.23.1",
|
||||
"version": "0.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codepilot",
|
||||
"version": "0.23.1",
|
||||
"version": "0.24.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.23.1",
|
||||
"version": "0.24.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user