bingocode 1.0.41 → 1.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/bingo-win.cjs +2 -1
  2. package/bin/bingocode-win.cjs +2 -1
  3. package/bin/claude-win.cjs +2 -1
  4. package/bun.lock +1716 -0
  5. package/package.json +14 -2
  6. package/src/server/config/providers.yaml +1 -1
  7. package/src/server/proxy/transform/anthropicToOpenaiChat.ts +11 -4
  8. package/adapters/README.md +0 -87
  9. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  10. package/adapters/common/__tests__/format.test.ts +0 -148
  11. package/adapters/common/__tests__/http-client.test.ts +0 -105
  12. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  13. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  14. package/adapters/common/__tests__/session-store.test.ts +0 -62
  15. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  16. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  17. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  18. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  19. package/adapters/common/attachment/attachment-limits.ts +0 -58
  20. package/adapters/common/attachment/attachment-store.ts +0 -121
  21. package/adapters/common/attachment/attachment-types.ts +0 -29
  22. package/adapters/common/attachment/image-block-watcher.ts +0 -94
  23. package/adapters/common/chat-queue.ts +0 -24
  24. package/adapters/common/config.ts +0 -96
  25. package/adapters/common/format.ts +0 -229
  26. package/adapters/common/http-client.ts +0 -107
  27. package/adapters/common/message-buffer.ts +0 -91
  28. package/adapters/common/message-dedup.ts +0 -57
  29. package/adapters/common/pairing.ts +0 -149
  30. package/adapters/common/session-store.ts +0 -60
  31. package/adapters/common/ws-bridge.ts +0 -282
  32. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  33. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  34. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  35. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  36. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  37. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  38. package/adapters/feishu/__tests__/media.test.ts +0 -120
  39. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  40. package/adapters/feishu/card-errors.ts +0 -151
  41. package/adapters/feishu/cardkit.ts +0 -294
  42. package/adapters/feishu/extract-payload.ts +0 -95
  43. package/adapters/feishu/flush-controller.ts +0 -149
  44. package/adapters/feishu/index.ts +0 -1275
  45. package/adapters/feishu/markdown-style.ts +0 -212
  46. package/adapters/feishu/media.ts +0 -176
  47. package/adapters/feishu/streaming-card.ts +0 -612
  48. package/adapters/package.json +0 -23
  49. package/adapters/telegram/__tests__/media.test.ts +0 -86
  50. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  51. package/adapters/telegram/index.ts +0 -754
  52. package/adapters/telegram/media.ts +0 -89
  53. package/adapters/tsconfig.json +0 -18
  54. package/runtime/mac_helper.py +0 -775
  55. package/runtime/requirements-win.txt +0 -7
  56. package/runtime/requirements.txt +0 -6
  57. package/runtime/test_helpers.py +0 -322
  58. package/runtime/win_helper.py +0 -723
  59. package/scripts/count-app-loc.ts +0 -256
  60. package/scripts/release.ts +0 -130
  61. package/start-cli.bat +0 -7
  62. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  63. package/stubs/color-diff-napi.ts +0 -45
@@ -1,94 +0,0 @@
1
- /**
2
- * Pure, stateful extractor that watches a stream of assistant text for
3
- * markdown image references (`![alt](source)`) and emits PendingUpload
4
- * records. Used by IM adapters to know which images to upload to IM.
5
- *
6
- * - Buffers input so an image marker split across multiple feed() calls
7
- * still gets detected.
8
- * - Dedups by fingerprint of the source so the same image is only emitted
9
- * once per watcher lifetime.
10
- */
11
-
12
- import type { PendingUpload } from './attachment-types.js'
13
-
14
- // Matches a complete markdown image: ![alt](target)
15
- // `alt` may be empty; `target` stops at the first closing paren.
16
- const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
17
-
18
- function fingerprint(raw: string): string {
19
- let h = 5381
20
- for (let i = 0; i < raw.length; i++) {
21
- h = ((h << 5) + h) ^ raw.charCodeAt(i)
22
- }
23
- return (h >>> 0).toString(16)
24
- }
25
-
26
- function classify(target: string): PendingUpload['source'] | null {
27
- if (target.startsWith('data:')) {
28
- const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
29
- if (!m) return null
30
- return { kind: 'base64', mime: m[1]!, data: m[2]! }
31
- }
32
- if (target.startsWith('file://')) {
33
- return { kind: 'path', path: target.slice('file://'.length) }
34
- }
35
- if (target.startsWith('http://') || target.startsWith('https://')) {
36
- return { kind: 'url', url: target }
37
- }
38
- if (target.startsWith('/')) {
39
- return { kind: 'path', path: target }
40
- }
41
- return null // relative paths — skip, we can't resolve them safely
42
- }
43
-
44
- export class ImageBlockWatcher {
45
- private buffer = ''
46
- private seen = new Set<string>()
47
- private accumulated: PendingUpload[] = []
48
-
49
- /** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
50
- feed(chunk: string): PendingUpload[] {
51
- this.buffer += chunk
52
- const out: PendingUpload[] = []
53
-
54
- IMAGE_RE.lastIndex = 0
55
- let lastConsumedEnd = 0
56
- let m: RegExpExecArray | null
57
- while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
58
- const [, alt, target] = m
59
- const source = classify(target!)
60
- if (source) {
61
- const id = fingerprint(`${source.kind}:${target}`)
62
- if (!this.seen.has(id)) {
63
- this.seen.add(id)
64
- const pending: PendingUpload = { id, source, alt: alt || undefined }
65
- out.push(pending)
66
- this.accumulated.push(pending)
67
- }
68
- }
69
- lastConsumedEnd = m.index + m[0].length
70
- }
71
-
72
- // Preserve tail that might contain a partially-received marker.
73
- if (lastConsumedEnd > 0) {
74
- this.buffer = this.buffer.slice(lastConsumedEnd)
75
- }
76
- if (this.buffer.length > 4096) {
77
- this.buffer = this.buffer.slice(-2048)
78
- }
79
-
80
- return out
81
- }
82
-
83
- /** Return everything seen so far (for end-of-stream reconciliation). */
84
- drain(): PendingUpload[] {
85
- return [...this.accumulated]
86
- }
87
-
88
- /** Reset watcher state (use at /clear or new session). */
89
- reset(): void {
90
- this.buffer = ''
91
- this.seen.clear()
92
- this.accumulated = []
93
- }
94
- }
@@ -1,24 +0,0 @@
1
- /**
2
- * 会话串行队列
3
- *
4
- * 同一 chatId 的消息串行处理,防并发冲突。
5
- * 不同 chatId 之间互不影响。
6
- * 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
7
- */
8
-
9
- const queues = new Map<string, Promise<void>>()
10
-
11
- export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
12
- const prev = queues.get(chatId) ?? Promise.resolve()
13
- const next = prev.then(fn, () => fn()).catch((err) => {
14
- console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
15
- })
16
- queues.set(chatId, next)
17
- // Clean up after completion to avoid memory leak for one-off chats
18
- next.finally(() => {
19
- if (queues.get(chatId) === next) {
20
- queues.delete(chatId)
21
- }
22
- })
23
- return next
24
- }
@@ -1,96 +0,0 @@
1
- /**
2
- * Adapter 配置加载
3
- *
4
- * 优先级:环境变量 > ~/.claude/adapters.json > 默认值
5
- */
6
-
7
- import * as fs from 'node:fs'
8
- import * as os from 'node:os'
9
- import * as path from 'node:path'
10
-
11
- export type PairedUser = {
12
- userId: string | number
13
- displayName: string
14
- pairedAt: number
15
- }
16
-
17
- export type PairingState = {
18
- code: string | null
19
- expiresAt: number | null
20
- createdAt: number | null
21
- }
22
-
23
- export type TelegramConfig = {
24
- botToken: string
25
- allowedUsers: number[]
26
- pairedUsers: PairedUser[]
27
- defaultWorkDir: string
28
- }
29
-
30
- export type FeishuConfig = {
31
- appId: string
32
- appSecret: string
33
- encryptKey: string
34
- verificationToken: string
35
- allowedUsers: string[]
36
- pairedUsers: PairedUser[]
37
- defaultWorkDir: string
38
- streamingCard: boolean
39
- }
40
-
41
- export type AdapterConfig = {
42
- serverUrl: string
43
- defaultProjectDir: string
44
- pairing: PairingState
45
- telegram: TelegramConfig
46
- feishu: FeishuConfig
47
- }
48
-
49
- function getConfigPath(): string {
50
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
51
- return path.join(configDir, 'adapters.json')
52
- }
53
-
54
- function loadFile(): Record<string, any> {
55
- try {
56
- return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
57
- } catch (err: any) {
58
- if (err?.code !== 'ENOENT') {
59
- console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
60
- }
61
- return {}
62
- }
63
- }
64
-
65
- export function loadConfig(): AdapterConfig {
66
- const file = loadFile()
67
- const tg = file.telegram ?? {}
68
- const fs_ = file.feishu ?? {}
69
- const pairing = file.pairing ?? {}
70
-
71
- return {
72
- serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
73
- defaultProjectDir: file.defaultProjectDir || '',
74
- pairing: {
75
- code: pairing.code ?? null,
76
- expiresAt: pairing.expiresAt ?? null,
77
- createdAt: pairing.createdAt ?? null,
78
- },
79
- telegram: {
80
- botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
81
- allowedUsers: tg.allowedUsers ?? [],
82
- pairedUsers: tg.pairedUsers ?? [],
83
- defaultWorkDir: tg.defaultWorkDir || process.cwd(),
84
- },
85
- feishu: {
86
- appId: process.env.FEISHU_APP_ID || fs_.appId || '',
87
- appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
88
- encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
89
- verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
90
- allowedUsers: fs_.allowedUsers ?? [],
91
- pairedUsers: fs_.pairedUsers ?? [],
92
- defaultWorkDir: fs_.defaultWorkDir || process.cwd(),
93
- streamingCard: fs_.streamingCard ?? false,
94
- },
95
- }
96
- }
@@ -1,229 +0,0 @@
1
- /**
2
- * 消息格式化工具
3
- */
4
-
5
- type AdapterChatState =
6
- | 'idle'
7
- | 'thinking'
8
- | 'streaming'
9
- | 'tool_executing'
10
- | 'permission_pending'
11
-
12
- type ImStatusSummary = {
13
- sessionId?: string
14
- projectName?: string | null
15
- branch?: string | null
16
- model?: string | null
17
- state?: AdapterChatState | null
18
- verb?: string | null
19
- pendingPermissionCount?: number
20
- taskCounts?: {
21
- total: number
22
- pending: number
23
- inProgress: number
24
- completed: number
25
- }
26
- }
27
-
28
- const IM_HELP_LINES = [
29
- '/new [项目] — 新建会话或切换项目',
30
- '/projects — 查看最近项目',
31
- '/status — 查看当前会话状态',
32
- '/clear — 清空当前会话上下文',
33
- '/stop — 停止当前生成',
34
- '/help — 显示这份帮助',
35
- ]
36
-
37
- /** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
38
- export function splitMessage(text: string, limit: number): string[] {
39
- if (text.length <= limit) return [text]
40
-
41
- const chunks: string[] = []
42
- let remaining = text
43
-
44
- while (remaining.length > 0) {
45
- if (remaining.length <= limit) {
46
- chunks.push(remaining)
47
- break
48
- }
49
-
50
- let splitAt = remaining.lastIndexOf('\n\n', limit)
51
- if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
52
- if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
53
- if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
54
- if (splitAt <= 0) splitAt = limit
55
-
56
- // Include the delimiter for paragraph/sentence breaks
57
- if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
58
-
59
- chunks.push(remaining.slice(0, splitAt).trimEnd())
60
- remaining = remaining.slice(splitAt).trimStart()
61
- }
62
-
63
- return chunks
64
- }
65
-
66
- /** Format tool use info for display in IM. */
67
- export function formatToolUse(toolName: string, input: unknown): string {
68
- const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
69
- const summary = formatToolSummary(toolName, inp)
70
- if (summary) return `🔧 ${toolName} ${summary}`
71
- const preview = truncateInput(input, 200)
72
- return `🔧 ${toolName}\n${preview}`
73
- }
74
-
75
- /** Generate a concise human-readable summary for common tools. */
76
- function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
77
- switch (tool) {
78
- case 'Bash': {
79
- const desc = inp.description as string | undefined
80
- const cmd = inp.command as string | undefined
81
- if (desc) return desc
82
- if (cmd) return truncate(cmd, 120)
83
- return null
84
- }
85
- case 'Read': {
86
- const fp = inp.file_path as string | undefined
87
- if (fp) return shortPath(fp)
88
- return null
89
- }
90
- case 'Edit': {
91
- const fp = inp.file_path as string | undefined
92
- if (fp) return shortPath(fp)
93
- return null
94
- }
95
- case 'Write': {
96
- const fp = inp.file_path as string | undefined
97
- if (fp) return shortPath(fp)
98
- return null
99
- }
100
- case 'Grep': {
101
- const pat = inp.pattern as string | undefined
102
- const p = inp.path as string | undefined
103
- if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
104
- return null
105
- }
106
- case 'Glob': {
107
- const pat = inp.pattern as string | undefined
108
- return pat ? `"${pat}"` : null
109
- }
110
- case 'Skill': {
111
- const skill = inp.skill as string | undefined
112
- return skill || null
113
- }
114
- case 'Agent': {
115
- const desc = inp.description as string | undefined
116
- return desc || null
117
- }
118
- case 'WebFetch': {
119
- const url = inp.url as string | undefined
120
- return url ? truncate(url, 120) : null
121
- }
122
- case 'WebSearch': {
123
- const q = inp.query as string | undefined
124
- return q ? `"${truncate(q, 80)}"` : null
125
- }
126
- default:
127
- return null
128
- }
129
- }
130
-
131
- function shortPath(fp: string): string {
132
- const parts = fp.split('/')
133
- return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
134
- }
135
-
136
- function truncate(s: string, max: number): string {
137
- return s.length > max ? s.slice(0, max) + '…' : s
138
- }
139
-
140
- /** Format a permission request for display in IM. */
141
- export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
142
- const preview = truncateInput(input, 300)
143
- return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
144
- }
145
-
146
- /** Truncate tool input to a preview string. */
147
- export function truncateInput(input: unknown, maxLen: number): string {
148
- try {
149
- const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
150
- return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
151
- } catch {
152
- return '(unserializable)'
153
- }
154
- }
155
-
156
- /** Escape special characters for Telegram MarkdownV2. */
157
- export function escapeMarkdownV2(text: string): string {
158
- return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
159
- }
160
-
161
- export function formatImHelp(): string {
162
- return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
163
- }
164
-
165
- export function formatImStatus(summary: ImStatusSummary | null): string {
166
- if (!summary?.sessionId) {
167
- return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
168
- }
169
-
170
- const lines = ['当前会话状态:']
171
-
172
- if (summary.projectName) {
173
- lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
174
- } else if (summary.branch) {
175
- lines.push(`分支: ${summary.branch}`)
176
- }
177
-
178
- lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
179
-
180
- if (summary.model) {
181
- lines.push(`模型: ${summary.model}`)
182
- }
183
-
184
- lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
185
-
186
- const pendingPermissionCount = summary.pendingPermissionCount ?? 0
187
- if (pendingPermissionCount > 0) {
188
- lines.push(`审批: ${pendingPermissionCount} 个待确认`)
189
- }
190
-
191
- const taskCounts = summary.taskCounts
192
- if (taskCounts && taskCounts.total > 0) {
193
- const taskParts = [`总计 ${taskCounts.total}`]
194
- if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
195
- if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
196
- if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
197
- lines.push(`任务: ${taskParts.join(' · ')}`)
198
- }
199
-
200
- return lines.join('\n')
201
- }
202
-
203
- function formatAdapterChatState(
204
- state: AdapterChatState | null | undefined,
205
- verb: string | null | undefined,
206
- ): string {
207
- const label = (() => {
208
- switch (state) {
209
- case 'thinking':
210
- return '思考中'
211
- case 'streaming':
212
- return '生成中'
213
- case 'tool_executing':
214
- return '执行工具中'
215
- case 'permission_pending':
216
- return '等待权限确认'
217
- case 'idle':
218
- default:
219
- return '空闲'
220
- }
221
- })()
222
-
223
- if (!verb || verb === 'Thinking') return label
224
- return `${label} (${verb})`
225
- }
226
-
227
- function shortSessionId(sessionId: string): string {
228
- return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
229
- }
@@ -1,107 +0,0 @@
1
- export type RecentProject = {
2
- projectPath: string
3
- realPath: string
4
- projectName: string
5
- isGit: boolean
6
- repoName: string | null
7
- branch: string | null
8
- modifiedAt: string
9
- sessionCount: number
10
- }
11
-
12
- export type GitInfo = {
13
- branch: string | null
14
- repoName: string | null
15
- workDir: string
16
- changedFiles: number
17
- }
18
-
19
- export type SessionTask = {
20
- id: string
21
- subject: string
22
- status: 'pending' | 'in_progress' | 'completed'
23
- }
24
-
25
- export class AdapterHttpClient {
26
- readonly httpBaseUrl: string
27
-
28
- constructor(wsUrl: string) {
29
- this.httpBaseUrl = wsUrl
30
- .replace(/^ws:/, 'http:')
31
- .replace(/^wss:/, 'https:')
32
- .replace(/\/$/, '')
33
- }
34
-
35
- async createSession(workDir: string): Promise<string> {
36
- const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
37
- method: 'POST',
38
- headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify({ workDir }),
40
- })
41
- if (!res.ok) {
42
- const err = await res.json().catch(() => ({ message: res.statusText }))
43
- throw new Error(`Failed to create session: ${(err as any).message}`)
44
- }
45
- const data = (await res.json()) as { sessionId: string }
46
- return data.sessionId
47
- }
48
-
49
- async listRecentProjects(): Promise<RecentProject[]> {
50
- const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`)
51
- if (!res.ok) {
52
- throw new Error(`Failed to list projects: ${res.statusText}`)
53
- }
54
- const data = (await res.json()) as { projects: RecentProject[] }
55
- return data.projects
56
- }
57
-
58
- /**
59
- * Match a project by index (1-based) or fuzzy name from recent projects.
60
- * Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
61
- */
62
- async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
63
- const projects = await this.listRecentProjects()
64
-
65
- // Try as 1-based index
66
- const num = parseInt(query, 10)
67
- if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
68
- return { project: projects[num - 1] }
69
- }
70
-
71
- const q = query.toLowerCase()
72
-
73
- // Exact project name match
74
- const exact = projects.find(p => p.projectName.toLowerCase() === q)
75
- if (exact) return { project: exact }
76
-
77
- // Fuzzy: name or path contains query
78
- const matches = projects.filter(p =>
79
- p.projectName.toLowerCase().includes(q) ||
80
- p.realPath.toLowerCase().includes(q)
81
- )
82
- if (matches.length === 1) return { project: matches[0] }
83
- if (matches.length > 1) return { ambiguous: matches }
84
-
85
- return {}
86
- }
87
-
88
- async getGitInfo(sessionId: string): Promise<GitInfo> {
89
- const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`)
90
- if (!res.ok) {
91
- const err = await res.json().catch(() => ({ message: res.statusText }))
92
- throw new Error(`Failed to load git info: ${(err as any).message}`)
93
- }
94
- return (await res.json()) as GitInfo
95
- }
96
-
97
- async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
98
- const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`)
99
- if (!res.ok) {
100
- if (res.status === 404) return []
101
- const err = await res.json().catch(() => ({ message: res.statusText }))
102
- throw new Error(`Failed to load tasks: ${(err as any).message}`)
103
- }
104
- const data = (await res.json()) as { tasks?: SessionTask[] }
105
- return Array.isArray(data.tasks) ? data.tasks : []
106
- }
107
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * 流式消息缓冲
3
- *
4
- * 将 content_delta 累积后按时间窗口或字符数批量 flush。
5
- * 用于 Telegram editMessage / 飞书流式卡片更新。
6
- */
7
-
8
- export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
9
-
10
- const DEFAULT_INTERVAL_MS = 500
11
- const DEFAULT_CHAR_THRESHOLD = 200
12
-
13
- export class MessageBuffer {
14
- private buffer = ''
15
- private timer: ReturnType<typeof setTimeout> | null = null
16
- private flushing = false
17
- private pendingComplete = false
18
-
19
- constructor(
20
- private onFlush: FlushCallback,
21
- private intervalMs = DEFAULT_INTERVAL_MS,
22
- private charThreshold = DEFAULT_CHAR_THRESHOLD,
23
- ) {}
24
-
25
- /** Append text delta. Triggers flush if threshold reached. */
26
- append(text: string): void {
27
- this.buffer += text
28
- if (this.buffer.length >= this.charThreshold) {
29
- this.scheduleFlush()
30
- } else if (!this.timer) {
31
- this.timer = setTimeout(() => this.flush(false), this.intervalMs)
32
- }
33
- }
34
-
35
- /** Immediately flush all remaining content (called on message_complete). */
36
- async complete(): Promise<void> {
37
- if (this.timer) {
38
- clearTimeout(this.timer)
39
- this.timer = null
40
- }
41
- if (this.flushing) {
42
- // A flush is in-flight; mark pending so it fires after current flush finishes
43
- this.pendingComplete = true
44
- return
45
- }
46
- await this.flush(true)
47
- }
48
-
49
- /** Reset the buffer for a new message. */
50
- reset(): void {
51
- this.buffer = ''
52
- this.pendingComplete = false
53
- if (this.timer) {
54
- clearTimeout(this.timer)
55
- this.timer = null
56
- }
57
- }
58
-
59
- private scheduleFlush(): void {
60
- if (this.timer) {
61
- clearTimeout(this.timer)
62
- this.timer = null
63
- }
64
- queueMicrotask(() => this.flush(false))
65
- }
66
-
67
- private async flush(isComplete: boolean): Promise<void> {
68
- if (this.timer) {
69
- clearTimeout(this.timer)
70
- this.timer = null
71
- }
72
- if (this.flushing) return
73
- if (this.buffer.length === 0) return
74
-
75
- this.flushing = true
76
- const text = this.buffer
77
- this.buffer = ''
78
- try {
79
- await this.onFlush(text, isComplete)
80
- } catch (err) {
81
- console.error('[MessageBuffer] Flush error:', err)
82
- } finally {
83
- this.flushing = false
84
- // If complete() was called while we were flushing, do the final flush now
85
- if (this.pendingComplete) {
86
- this.pendingComplete = false
87
- await this.flush(true)
88
- }
89
- }
90
- }
91
- }
@@ -1,57 +0,0 @@
1
- /**
2
- * 消息去重
3
- *
4
- * 防止 WebSocket 重连等场景下消息重复处理。
5
- * 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
6
- */
7
-
8
- const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
9
- const DEFAULT_MAX_ENTRIES = 5000
10
- const SWEEP_INTERVAL_MS = 60_000 // 1 minute
11
-
12
- export class MessageDedup {
13
- private store = new Map<string, number>()
14
- private sweepTimer: ReturnType<typeof setInterval>
15
-
16
- constructor(
17
- private ttlMs = DEFAULT_TTL_MS,
18
- private maxEntries = DEFAULT_MAX_ENTRIES,
19
- ) {
20
- this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
21
- }
22
-
23
- /** Returns true if this is a NEW message, false if duplicate. */
24
- tryRecord(id: string): boolean {
25
- const now = Date.now()
26
- const existing = this.store.get(id)
27
-
28
- if (existing !== undefined && now - existing < this.ttlMs) {
29
- return false // duplicate
30
- }
31
-
32
- // Evict oldest if at capacity
33
- if (this.store.size >= this.maxEntries) {
34
- const oldest = this.store.keys().next().value
35
- if (oldest !== undefined) this.store.delete(oldest)
36
- }
37
-
38
- this.store.set(id, now)
39
- return true
40
- }
41
-
42
- private sweep(): void {
43
- const now = Date.now()
44
- for (const [key, ts] of this.store) {
45
- if (now - ts >= this.ttlMs) {
46
- this.store.delete(key)
47
- } else {
48
- break // Map preserves insertion order; once fresh, rest is fresh
49
- }
50
- }
51
- }
52
-
53
- destroy(): void {
54
- clearInterval(this.sweepTimer)
55
- this.store.clear()
56
- }
57
- }