cc-haha/adapters/common/format.ts
程序员阿江(Relakkes) 05b183158c fix: keep long Telegram streams readable
Telegram Bot API text edits share the same practical message length ceiling as sends, so streaming one growing placeholder can stop exposing new assistant output once the text approaches the limit. The adapter now seals full chunks, opens a fresh editable placeholder, and routes Telegram outbound text through a table-to-bullet formatter for phone readability.

Constraint: Telegram text messages and edits must stay below the platform message limit.
Rejected: Wait until message_complete to split the answer | users still lose streaming visibility during long replies.
Confidence: high
Scope-risk: narrow
Directive: Keep Telegram-specific streaming state in adapters/telegram/format.ts instead of spreading chunk math across message handlers.
Tested: bun test adapters/telegram/__tests__/telegram.test.ts adapters/common/__tests__/format.test.ts
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Tested: cd desktop && bun run build:sidecars
Not-tested: Live Telegram Bot API run with a real bot token.
2026-05-21 17:34:51 +08:00

324 lines
9.4 KiB
TypeScript

/**
* 消息格式化工具
*/
type AdapterChatState =
| 'idle'
| 'thinking'
| 'streaming'
| 'tool_executing'
| 'permission_pending'
type ImStatusSummary = {
sessionId?: string
projectName?: string | null
branch?: string | null
model?: string | null
state?: AdapterChatState | null
verb?: string | null
pendingPermissionCount?: number
taskCounts?: {
total: number
pending: number
inProgress: number
completed: number
}
}
const IM_HELP_LINES = [
'/new [项目] / 新会话 — 新建会话或切换项目',
'/projects / 项目列表 — 查看最近项目',
'/status / 状态 — 查看当前会话状态',
'/clear / 清空 — 清空当前会话上下文',
'/stop / 停止 — 停止当前生成',
'/help / 帮助 — 显示这份帮助',
'权限审批:/allow <id>、/always <id>、/deny <id>',
]
/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
export function splitMessage(text: string, limit: number): string[] {
if (text.length <= limit) return [text]
const chunks: string[] = []
let remaining = text
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining)
break
}
let splitAt = remaining.lastIndexOf('\n\n', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
if (splitAt <= 0) splitAt = limit
// Include the delimiter for paragraph/sentence breaks
if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
chunks.push(remaining.slice(0, splitAt).trimEnd())
remaining = remaining.slice(splitAt).trimStart()
}
return chunks
}
type MarkdownTable = {
headers: string[]
rows: string[][]
}
function splitMarkdownTableRow(line: string): string[] {
const trimmed = line.trim()
const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
const withoutTrailingPipe = inner.endsWith('|') ? inner.slice(0, -1) : inner
return withoutTrailingPipe.split('|').map((cell) => cell.trim())
}
function isMarkdownTableDivider(line: string): boolean {
const cells = splitMarkdownTableRow(line)
if (cells.length < 2) return false
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
}
function isPotentialMarkdownTableRow(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && splitMarkdownTableRow(trimmed).length >= 2
}
function isFenceMarker(line: string): boolean {
return /^\s*(```|~~~)/.test(line)
}
function formatMarkdownTableAsBullets(table: MarkdownTable): string {
const { headers, rows } = table
if (headers.length === 0 || rows.length === 0) return ''
const output: string[] = []
for (const row of rows) {
if (row.every((cell) => !cell)) continue
const label = row[0]
if (label) output.push(label)
for (let i = 1; i < Math.max(headers.length, row.length); i++) {
const value = row[i]
if (!value) continue
const header = headers[i]
output.push(`${header ? `${header}: ` : `Column ${i}: `}${value}`)
}
if (output[output.length - 1] !== '') output.push('')
}
while (output[output.length - 1] === '') output.pop()
return output.join('\n')
}
/** Convert GitHub-flavored Markdown pipe tables into mobile-friendly bullet lists. */
export function convertMarkdownTablesToBullets(markdown: string): string {
const lines = markdown.split('\n')
const output: string[] = []
let inFence = false
let i = 0
while (i < lines.length) {
const headerLine = lines[i] ?? ''
if (isFenceMarker(headerLine)) {
inFence = !inFence
output.push(headerLine)
i += 1
continue
}
const dividerLine = lines[i + 1] ?? ''
if (!inFence && isPotentialMarkdownTableRow(headerLine) && isMarkdownTableDivider(dividerLine)) {
const headers = splitMarkdownTableRow(headerLine)
const rows: string[][] = []
i += 2
while (i < lines.length && isPotentialMarkdownTableRow(lines[i] ?? '')) {
rows.push(splitMarkdownTableRow(lines[i] ?? ''))
i += 1
}
const rendered = formatMarkdownTableAsBullets({ headers, rows })
if (rendered) output.push(rendered)
continue
}
output.push(headerLine)
i += 1
}
return output.join('\n')
}
/** Format tool use info for display in IM. */
export function formatToolUse(toolName: string, input: unknown): string {
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
const summary = formatToolSummary(toolName, inp)
if (summary) return `🔧 ${toolName} ${summary}`
const preview = truncateInput(input, 200)
return `🔧 ${toolName}\n${preview}`
}
/** Generate a concise human-readable summary for common tools. */
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
switch (tool) {
case 'Bash': {
const desc = inp.description as string | undefined
const cmd = inp.command as string | undefined
if (desc) return desc
if (cmd) return truncate(cmd, 120)
return null
}
case 'Read': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Edit': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Write': {
const fp = inp.file_path as string | undefined
if (fp) return shortPath(fp)
return null
}
case 'Grep': {
const pat = inp.pattern as string | undefined
const p = inp.path as string | undefined
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
return null
}
case 'Glob': {
const pat = inp.pattern as string | undefined
return pat ? `"${pat}"` : null
}
case 'Skill': {
const skill = inp.skill as string | undefined
return skill || null
}
case 'Agent': {
const desc = inp.description as string | undefined
return desc || null
}
case 'WebFetch': {
const url = inp.url as string | undefined
return url ? truncate(url, 120) : null
}
case 'WebSearch': {
const q = inp.query as string | undefined
return q ? `"${truncate(q, 80)}"` : null
}
default:
return null
}
}
function shortPath(fp: string): string {
const parts = fp.split('/')
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '…' : s
}
/** Format a permission request for display in IM. */
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
const preview = truncateInput(input, 300)
return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
}
/** Truncate tool input to a preview string. */
export function truncateInput(input: unknown, maxLen: number): string {
try {
const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
} catch {
return '(unserializable)'
}
}
/** Escape special characters for Telegram MarkdownV2. */
export function escapeMarkdownV2(text: string): string {
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
}
export function formatImHelp(): string {
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
}
export function formatImStatus(summary: ImStatusSummary | null): string {
if (!summary?.sessionId) {
return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
}
const lines = ['当前会话状态:']
if (summary.projectName) {
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
} else if (summary.branch) {
lines.push(`分支: ${summary.branch}`)
}
lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
if (summary.model) {
lines.push(`模型: ${summary.model}`)
}
lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
const pendingPermissionCount = summary.pendingPermissionCount ?? 0
if (pendingPermissionCount > 0) {
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
}
const taskCounts = summary.taskCounts
if (taskCounts && taskCounts.total > 0) {
const taskParts = [`总计 ${taskCounts.total}`]
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
lines.push(`任务: ${taskParts.join(' · ')}`)
}
return lines.join('\n')
}
function formatAdapterChatState(
state: AdapterChatState | null | undefined,
verb: string | null | undefined,
): string {
const label = (() => {
switch (state) {
case 'thinking':
return '思考中'
case 'streaming':
return '生成中'
case 'tool_executing':
return '执行工具中'
case 'permission_pending':
return '等待权限确认'
case 'idle':
default:
return '空闲'
}
})()
if (!verb || verb === 'Thinking') return label
return `${label} (${verb})`
}
function shortSessionId(sessionId: string): string {
return sessionId.length > 12 ? `${sessionId.slice(0, 8)}` : sessionId
}