@ian2018cs/agenthub 0.1.30 → 0.1.32
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.
- package/dist/assets/index-BjRZpEx5.css +32 -0
- package/dist/assets/{index-C0my6OWo.js → index-jWEew-uM.js} +37 -37
- package/dist/assets/{vendor-icons-_JvlqdUe.js → vendor-icons-CosU8VI8.js} +69 -64
- package/dist/feishu-logo.svg +6 -0
- package/dist/index.html +3 -3
- package/package.json +5 -3
- package/server/claude-sdk.js +8 -5
- package/server/database/db.js +109 -1
- package/server/index.js +10 -0
- package/server/projects.js +13 -10
- package/server/routes/auth.js +18 -1
- package/server/routes/skills.js +10 -3
- package/server/services/feishu/card-builder.js +937 -0
- package/server/services/feishu/command-handler.js +492 -0
- package/server/services/feishu/feishu-db.js +7 -0
- package/server/services/feishu/feishu-engine.js +884 -0
- package/server/services/feishu/index.js +76 -0
- package/server/services/feishu/lark-client.js +398 -0
- package/server/services/feishu/sdk-bridge.js +475 -0
- package/server/services/feishu/speech.js +117 -0
- package/dist/assets/index-Bawp3dBD.css +0 -32
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdk-bridge.js — FakeSendWriter + queryClaudeSDK 调用适配
|
|
3
|
+
*
|
|
4
|
+
* FakeSendWriter 实现与 WebSocketWriter 相同的 .send(data) / .setSessionId(id) 接口,
|
|
5
|
+
* 使得 queryClaudeSDK 可以直接复用,无需修改 claude-sdk.js。
|
|
6
|
+
*
|
|
7
|
+
* 消息路由策略:
|
|
8
|
+
* - claude-response(text) → 累积到 textBuffer,完成时统一发送
|
|
9
|
+
* - claude-response(other) → 工具调用等,发简短通知
|
|
10
|
+
* - claude-permission-request → 发工具审批卡片,挂起 Promise
|
|
11
|
+
* - claude-permission-cancelled → 更新卡片为已取消
|
|
12
|
+
* - session-created → 保存 sessionId
|
|
13
|
+
* - claude-complete → 发送聚合响应,保存 sessionId 到 DB
|
|
14
|
+
* - token-budget → 记录日志(不推送,除非超 90% 警告)
|
|
15
|
+
* - claude-error → 发送错误提示
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { queryClaudeSDK } from '../../claude-sdk.js';
|
|
19
|
+
import { usageDb } from '../../database/db.js';
|
|
20
|
+
import { feishuDb } from './feishu-db.js';
|
|
21
|
+
import fs from 'fs/promises';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import {
|
|
24
|
+
buildToolApprovalCardDirect,
|
|
25
|
+
buildToolApprovalResultCardDirect,
|
|
26
|
+
buildAskUserQuestionCard,
|
|
27
|
+
splitMessage,
|
|
28
|
+
truncate,
|
|
29
|
+
} from './card-builder.js';
|
|
30
|
+
|
|
31
|
+
// ─── 飞书模式系统提示词 ─────────────────────────────────────────────────────────
|
|
32
|
+
// 追加到 claude_code preset 之后,让 Claude 感知飞书环境和文件发送能力
|
|
33
|
+
const FEISHU_SYSTEM_PROMPT = `
|
|
34
|
+
你正在通过飞书(Lark)消息与用户进行对话(飞书 IM 模式)。
|
|
35
|
+
|
|
36
|
+
文件发送能力:
|
|
37
|
+
- 你通过 Write 工具创建的图片(png/jpg/jpeg/gif/webp/bmp)和文档文件(pdf/docx/xlsx/pptx/zip/tar.gz/svg 等),会在回复结束后自动作为飞书附件发送给用户,无需声称自己无法发送文件。
|
|
38
|
+
- 如需将项目中已存在的文件发送给用户,在回复中添加 [[FEISHU_SEND_FILE::<文件绝对路径>]] 标签(支持多个),系统会自动读取并发送该文件,该标签不会显示给用户。
|
|
39
|
+
|
|
40
|
+
示例(发送已有文件):
|
|
41
|
+
[[FEISHU_SEND_FILE::/Users/xxx/project/output.zip]]
|
|
42
|
+
好的,已将 output.zip 发送给你。
|
|
43
|
+
`.trim();
|
|
44
|
+
|
|
45
|
+
class FakeSendWriter {
|
|
46
|
+
/**
|
|
47
|
+
* @param {Object} opts
|
|
48
|
+
* @param {string} opts.feishuOpenId
|
|
49
|
+
* @param {string} opts.chatId
|
|
50
|
+
* @param {string} opts.replyMessageId 原始用户消息 ID(用于回复线程化)
|
|
51
|
+
* @param {Object} opts.larkClient LarkClient 实例
|
|
52
|
+
* @param {Map} opts.pendingApprovals engine 的挂起审批 Map
|
|
53
|
+
*/
|
|
54
|
+
constructor({ feishuOpenId, chatId, replyMessageId, larkClient, pendingApprovals, cwd }) {
|
|
55
|
+
this.feishuOpenId = feishuOpenId;
|
|
56
|
+
this.chatId = chatId;
|
|
57
|
+
this.replyMessageId = replyMessageId;
|
|
58
|
+
this.larkClient = larkClient;
|
|
59
|
+
this.pendingApprovals = pendingApprovals;
|
|
60
|
+
this.cwd = cwd;
|
|
61
|
+
|
|
62
|
+
this.textBuffer = []; // 累积 claude-response text 内容
|
|
63
|
+
this.sessionId = null; // 从 session-created 或 claude-response.session_id 获取
|
|
64
|
+
this.toolNotices = []; // 工具调用通知(可选展示)
|
|
65
|
+
this.sessionCorrupted = false; // 会话历史包含空 text block,需重置
|
|
66
|
+
this.writtenFiles = []; // Write 工具写入的文件路径(用于完成后发送)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── WebSocketWriter 接口 ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
send(data) {
|
|
72
|
+
console.log('[Feishu:sdk-bridge] send() type:', data?.type, data?.type === 'claude-response' ? `msg.type=${data?.data?.type}` : '');
|
|
73
|
+
try {
|
|
74
|
+
this._handle(data);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('[Feishu:sdk-bridge] FakeSendWriter.send error:', err.message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setSessionId(id) {
|
|
81
|
+
this.sessionId = id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getSessionId() {
|
|
85
|
+
return this.sessionId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── 消息路由 ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
_handle(data) {
|
|
91
|
+
switch (data.type) {
|
|
92
|
+
case 'claude-response':
|
|
93
|
+
this._handleResponse(data.data);
|
|
94
|
+
break;
|
|
95
|
+
|
|
96
|
+
case 'claude-permission-request':
|
|
97
|
+
this._handlePermissionRequest(data).catch(err =>
|
|
98
|
+
console.error('[Feishu:sdk-bridge] permission request send error:', err.message)
|
|
99
|
+
);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case 'claude-permission-cancelled':
|
|
103
|
+
this._handlePermissionCancelled(data).catch(() => {});
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case 'session-created':
|
|
107
|
+
if (data.sessionId && !this.sessionId) {
|
|
108
|
+
this.sessionId = data.sessionId;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case 'claude-complete':
|
|
113
|
+
this._handleComplete(data).catch(err =>
|
|
114
|
+
console.error('[Feishu:sdk-bridge] complete handler error:', err.message)
|
|
115
|
+
);
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'token-budget':
|
|
119
|
+
this._handleTokenBudget(data.data);
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case 'claude-error':
|
|
123
|
+
this._handleError(data.error).catch(() => {});
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
default:
|
|
127
|
+
// 其他类型(session-aborted 等)忽略
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_handleResponse(msg) {
|
|
133
|
+
if (!msg) return;
|
|
134
|
+
|
|
135
|
+
// 捕获 session_id
|
|
136
|
+
if (msg.session_id && !this.sessionId) {
|
|
137
|
+
this.sessionId = msg.session_id;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (msg.type) {
|
|
141
|
+
// SDK 标准格式:完整 assistant 消息,content 数组包含 text/tool_use 块
|
|
142
|
+
case 'assistant': {
|
|
143
|
+
const blocks = msg.message?.content || [];
|
|
144
|
+
for (const block of blocks) {
|
|
145
|
+
if (block.type === 'text' && block.text) {
|
|
146
|
+
this.textBuffer.push(block.text);
|
|
147
|
+
} else if (block.type === 'tool_use' && block.name) {
|
|
148
|
+
this.toolNotices.push(`🔧 调用 \`${block.name}\``);
|
|
149
|
+
this.larkClient.sendText(this.chatId, `🔧 正在调用 \`${block.name}\`…`).catch(() => {});
|
|
150
|
+
// 追踪 Write 工具写入的文件路径,完成后按类型发送给用户
|
|
151
|
+
if (block.name === 'Write' && block.input?.file_path) {
|
|
152
|
+
this.writtenFiles.push(block.input.file_path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 兼容:流式增量格式
|
|
160
|
+
case 'content_block_delta':
|
|
161
|
+
if (msg.delta?.text) this.textBuffer.push(msg.delta.text);
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
// 兼容:流式块开始(工具调用)
|
|
165
|
+
case 'content_block_start': {
|
|
166
|
+
const block = msg.content_block;
|
|
167
|
+
if (block?.type === 'tool_use' && block.name) {
|
|
168
|
+
this.toolNotices.push(`🔧 调用 \`${block.name}\``);
|
|
169
|
+
this.larkClient.sendText(this.chatId, `🔧 正在调用 \`${block.name}\`…`).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 兼容:旧版 text 格式
|
|
175
|
+
case 'text':
|
|
176
|
+
if (msg.text) this.textBuffer.push(msg.text);
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
// 兼容:旧版 tool_use 格式
|
|
180
|
+
case 'tool_use':
|
|
181
|
+
if (msg.name) {
|
|
182
|
+
this.toolNotices.push(`🔧 调用 \`${msg.name}\``);
|
|
183
|
+
this.larkClient.sendText(this.chatId, `🔧 正在调用 \`${msg.name}\`…`).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'system':
|
|
188
|
+
case 'thinking':
|
|
189
|
+
case 'content_block_stop':
|
|
190
|
+
case 'message_start':
|
|
191
|
+
case 'message_delta':
|
|
192
|
+
case 'message_stop':
|
|
193
|
+
// 不推送
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'result':
|
|
197
|
+
// 检测会话历史损坏(空 text block 导致 Anthropic API 400 错误)
|
|
198
|
+
if (msg.is_error && typeof msg.result === 'string' &&
|
|
199
|
+
msg.result.includes('text content blocks must be non-empty')) {
|
|
200
|
+
this.sessionCorrupted = true;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
default:
|
|
205
|
+
console.log('[Feishu:sdk-bridge] _handleResponse unknown msg.type:', msg.type);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async _handlePermissionRequest(data) {
|
|
211
|
+
const { requestId, toolName, input } = data;
|
|
212
|
+
if (!requestId) return;
|
|
213
|
+
|
|
214
|
+
// AskUserQuestion:展示问卷,等待用户回复选项
|
|
215
|
+
if (toolName === 'AskUserQuestion') {
|
|
216
|
+
await this._handleAskUserQuestion(requestId, input);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 直接发送交互卡片(JSON 构建,无需模板 ID)
|
|
221
|
+
const cardContent = buildToolApprovalCardDirect(toolName, input, requestId);
|
|
222
|
+
const cardMsgId = await this.larkClient.sendInteractiveAndGetMsgId(this.chatId, cardContent).catch(async () => {
|
|
223
|
+
// 兜底:降级为文本消息
|
|
224
|
+
const inputSummary = truncate(
|
|
225
|
+
typeof input === 'object' ? JSON.stringify(input, null, 2) : String(input || ''),
|
|
226
|
+
300
|
|
227
|
+
);
|
|
228
|
+
const text = `🔔 **工具审批请求**\n工具: \`${toolName}\`\n参数:\n\`\`\`\n${inputSummary}\n\`\`\`\n\n回复 **允许** 或 **拒绝**(或 **允许所有** 跳过同类工具询问)`;
|
|
229
|
+
await this.larkClient.sendText(this.chatId, text).catch(() => {});
|
|
230
|
+
return null;
|
|
231
|
+
});
|
|
232
|
+
this.pendingApprovals.set(this.feishuOpenId, { requestId, cardMessageId: cardMsgId, toolName });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async _handleAskUserQuestion(requestId, input) {
|
|
236
|
+
const questions = input?.questions || [];
|
|
237
|
+
|
|
238
|
+
// 发送交互卡片(动态 JSON,无需模板 ID)
|
|
239
|
+
const cardContent = buildAskUserQuestionCard(questions, requestId);
|
|
240
|
+
const cardMsgId = await this.larkClient.sendInteractiveAndGetMsgId(this.chatId, cardContent)
|
|
241
|
+
.catch(async (err) => {
|
|
242
|
+
console.error('[Feishu:sdk-bridge] AskUserQuestion card send error:', err.message);
|
|
243
|
+
// 降级:发送文字说明,用户通过文字回复选项字母
|
|
244
|
+
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
245
|
+
const lines = ['🤔 **Claude 需要您的回答**\n'];
|
|
246
|
+
questions.forEach((q, qi) => {
|
|
247
|
+
const multiNote = q.multiSelect ? ' 〔可多选〕' : '';
|
|
248
|
+
lines.push(`**${qi + 1}. ${q.question}**${multiNote}`);
|
|
249
|
+
(q.options || []).forEach((opt, oi) => {
|
|
250
|
+
const letter = LETTERS[oi] || String(oi + 1);
|
|
251
|
+
lines.push(` ${letter}. ${opt.label}${opt.description ? ` — ${opt.description}` : ''}`);
|
|
252
|
+
});
|
|
253
|
+
lines.push('');
|
|
254
|
+
});
|
|
255
|
+
lines.push(questions.length > 1
|
|
256
|
+
? '回复格式:各题选项字母用逗号分隔,多选用 + 连接(如 A,A+B,B)'
|
|
257
|
+
: '回复选项字母(多选用 + 连接,如 A+B)'
|
|
258
|
+
);
|
|
259
|
+
lines.push('回复"跳过"可跳过此问卷。');
|
|
260
|
+
await this.larkClient.sendText(this.chatId, lines.join('\n')).catch(() => {});
|
|
261
|
+
return null;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this.pendingApprovals.set(this.feishuOpenId, {
|
|
265
|
+
requestId,
|
|
266
|
+
cardMessageId: cardMsgId,
|
|
267
|
+
toolName: 'AskUserQuestion',
|
|
268
|
+
questions,
|
|
269
|
+
originalInput: input,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async _handlePermissionCancelled(data) {
|
|
274
|
+
const pending = this.pendingApprovals.get(this.feishuOpenId);
|
|
275
|
+
if (!pending) return;
|
|
276
|
+
|
|
277
|
+
if (pending.cardMessageId) {
|
|
278
|
+
const updatedCard = buildToolApprovalResultCardDirect('timeout');
|
|
279
|
+
await this.larkClient.updateCard(pending.cardMessageId, updatedCard).catch(() => {});
|
|
280
|
+
}
|
|
281
|
+
this.pendingApprovals.delete(this.feishuOpenId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async _handleComplete(data) {
|
|
285
|
+
// 持久化 session ID
|
|
286
|
+
if (this.sessionId) {
|
|
287
|
+
feishuDb.updateSessionState(this.feishuOpenId, { claude_session_id: this.sessionId });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 提取并移除文本中的 [[FEISHU_SEND_FILE::...]] 标签,收集待发送的已有文件路径
|
|
291
|
+
const taggedFiles = [];
|
|
292
|
+
const rawText = this.textBuffer.join('');
|
|
293
|
+
const cleanedText = rawText
|
|
294
|
+
.replace(/\[\[FEISHU_SEND_FILE::([^\]]+)\]\]/g, (_, filePath) => {
|
|
295
|
+
taggedFiles.push(filePath.trim());
|
|
296
|
+
return '';
|
|
297
|
+
})
|
|
298
|
+
.trim();
|
|
299
|
+
|
|
300
|
+
// 发送聚合后的文本响应
|
|
301
|
+
console.log(`[Feishu:sdk-bridge] _handleComplete: textBuffer.length=${this.textBuffer.length}, cleanedText.length=${cleanedText.length}, chatId=${this.chatId}`);
|
|
302
|
+
if (cleanedText) {
|
|
303
|
+
const chunks = splitMessage(cleanedText, 4000);
|
|
304
|
+
for (const chunk of chunks) {
|
|
305
|
+
try {
|
|
306
|
+
await this.larkClient.sendText(this.chatId, chunk);
|
|
307
|
+
console.log('[Feishu:sdk-bridge] sendText OK, chunk.length=', chunk.length);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error('[Feishu:sdk-bridge] sendText FAILED:', err.message, err.stack);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
console.warn('[Feishu:sdk-bridge] _handleComplete: cleanedText is empty, nothing sent to Feishu');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 发送 Write 工具写入的图片 / 文档类文件
|
|
317
|
+
await this._sendWrittenFiles();
|
|
318
|
+
// 发送 [[FEISHU_SEND_FILE::...]] 标签指定的已有文件
|
|
319
|
+
await this._sendTaggedFiles(taggedFiles);
|
|
320
|
+
|
|
321
|
+
this.textBuffer = [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 图片扩展名 → 用 sendImageBuffer 发送
|
|
325
|
+
static IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
|
326
|
+
// 文档/归档扩展名 → 用 sendFileBuffer 发送
|
|
327
|
+
static FILE_EXTS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'tar', 'gz', 'svg']);
|
|
328
|
+
|
|
329
|
+
async _sendWrittenFiles() {
|
|
330
|
+
if (!this.writtenFiles.length) return;
|
|
331
|
+
const uniquePaths = [...new Set(this.writtenFiles)];
|
|
332
|
+
this.writtenFiles = [];
|
|
333
|
+
|
|
334
|
+
for (const filePath of uniquePaths) {
|
|
335
|
+
const resolved = path.isAbsolute(filePath)
|
|
336
|
+
? filePath
|
|
337
|
+
: path.join(this.cwd || process.cwd(), filePath);
|
|
338
|
+
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
339
|
+
const filename = path.basename(resolved);
|
|
340
|
+
|
|
341
|
+
if (!FakeSendWriter.IMAGE_EXTS.has(ext) && !FakeSendWriter.FILE_EXTS.has(ext)) continue;
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const buf = await fs.readFile(resolved);
|
|
345
|
+
if (FakeSendWriter.IMAGE_EXTS.has(ext)) {
|
|
346
|
+
await this.larkClient.sendImageBuffer(this.chatId, buf);
|
|
347
|
+
console.log(`[Feishu:sdk-bridge] Sent image: ${filename}`);
|
|
348
|
+
} else {
|
|
349
|
+
await this.larkClient.sendFileBuffer(this.chatId, buf, filename);
|
|
350
|
+
console.log(`[Feishu:sdk-bridge] Sent file: ${filename}`);
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.warn(`[Feishu:sdk-bridge] Failed to send written file ${resolved}:`, err.message);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async _sendTaggedFiles(filePaths) {
|
|
359
|
+
if (!filePaths.length) return;
|
|
360
|
+
for (const filePath of filePaths) {
|
|
361
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
362
|
+
const filename = path.basename(filePath);
|
|
363
|
+
try {
|
|
364
|
+
const buf = await fs.readFile(filePath);
|
|
365
|
+
if (FakeSendWriter.IMAGE_EXTS.has(ext)) {
|
|
366
|
+
await this.larkClient.sendImageBuffer(this.chatId, buf);
|
|
367
|
+
console.log(`[Feishu:sdk-bridge] Sent tagged image: ${filename}`);
|
|
368
|
+
} else {
|
|
369
|
+
await this.larkClient.sendFileBuffer(this.chatId, buf, filename);
|
|
370
|
+
console.log(`[Feishu:sdk-bridge] Sent tagged file: ${filename}`);
|
|
371
|
+
}
|
|
372
|
+
} catch (err) {
|
|
373
|
+
console.warn(`[Feishu:sdk-bridge] Failed to send tagged file ${filePath}:`, err.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_handleTokenBudget(budget) {
|
|
379
|
+
if (!budget) return;
|
|
380
|
+
const { used, total } = budget;
|
|
381
|
+
// 超过 90% 时发提醒
|
|
382
|
+
if (total > 0 && used / total >= 0.9) {
|
|
383
|
+
this.larkClient.sendText(
|
|
384
|
+
this.chatId,
|
|
385
|
+
`⚠️ 上下文已使用 ${Math.round(used / total * 100)}%(${used}/${total} tokens),建议开启新会话。`
|
|
386
|
+
).catch(() => {});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async _handleError(errorMsg) {
|
|
391
|
+
// 会话损坏时不发通用错误(由 runQuery 统一处理并重置会话)
|
|
392
|
+
if (this.sessionCorrupted) return;
|
|
393
|
+
await this.larkClient.sendText(this.chatId, `❌ 出错了:${errorMsg || '未知错误'}`).catch(() => {});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── runQuery — 统一入口 ────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 调用 Claude SDK 并将结果通过 FakeSendWriter 发送到飞书
|
|
401
|
+
*
|
|
402
|
+
* @param {Object} opts
|
|
403
|
+
* @param {string} opts.feishuOpenId
|
|
404
|
+
* @param {string} opts.chatId
|
|
405
|
+
* @param {string} opts.messageId 原始消息 ID
|
|
406
|
+
* @param {string} opts.content 用户输入文字
|
|
407
|
+
* @param {Array|null} opts.images 图片附件(已转换为 data URI 格式)
|
|
408
|
+
* @param {string} opts.userUuid claudecodeui 用户 UUID
|
|
409
|
+
* @param {Object} opts.state feishu_session_state 行
|
|
410
|
+
* @param {Object} opts.larkClient LarkClient 实例
|
|
411
|
+
* @param {Map} opts.pendingApprovals engine 共享的 pendingApprovals Map
|
|
412
|
+
*/
|
|
413
|
+
async function runQuery({
|
|
414
|
+
feishuOpenId,
|
|
415
|
+
chatId,
|
|
416
|
+
messageId,
|
|
417
|
+
content,
|
|
418
|
+
images,
|
|
419
|
+
userUuid,
|
|
420
|
+
state,
|
|
421
|
+
larkClient,
|
|
422
|
+
pendingApprovals,
|
|
423
|
+
}) {
|
|
424
|
+
// 限额检查
|
|
425
|
+
const limitStatus = usageDb.checkUserLimits(userUuid);
|
|
426
|
+
if (!limitStatus.allowed) {
|
|
427
|
+
const reason = limitStatus.reason === 'daily_limit_exceeded'
|
|
428
|
+
? `每日用量已达上限 ($${limitStatus.limit.toFixed(2)})`
|
|
429
|
+
: `总用量已达上限 ($${limitStatus.limit.toFixed(2)})`;
|
|
430
|
+
await larkClient.sendText(chatId, `⚠️ ${reason},请联系管理员。`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const { claude_session_id, cwd, permission_mode } = state || {};
|
|
435
|
+
|
|
436
|
+
const writer = new FakeSendWriter({
|
|
437
|
+
feishuOpenId,
|
|
438
|
+
chatId,
|
|
439
|
+
replyMessageId: messageId,
|
|
440
|
+
larkClient,
|
|
441
|
+
pendingApprovals,
|
|
442
|
+
cwd: cwd || undefined,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// 图片:LarkClient 已将二进制转为 {data: Buffer, mimeType} 格式
|
|
446
|
+
// queryClaudeSDK 的 handleImages 期望 data URI 格式
|
|
447
|
+
const imageOptions = images?.map(img => ({
|
|
448
|
+
data: `data:${img.mimeType};base64,${img.data.toString('base64')}`,
|
|
449
|
+
})) || undefined;
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
await queryClaudeSDK(content, {
|
|
453
|
+
sessionId: claude_session_id || undefined,
|
|
454
|
+
userUuid,
|
|
455
|
+
cwd: cwd || undefined,
|
|
456
|
+
permissionMode: permission_mode || 'default',
|
|
457
|
+
images: imageOptions,
|
|
458
|
+
toolsSettings: { allowedTools: [], disallowedTools: [], skipPermissions: false },
|
|
459
|
+
appendSystemPrompt: FEISHU_SYSTEM_PROMPT,
|
|
460
|
+
}, writer);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// queryClaudeSDK 内部已经通过 writer 发送错误,这里仅记录日志
|
|
463
|
+
console.error('[Feishu:sdk-bridge] queryClaudeSDK error:', err.message);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 会话历史损坏(空 text block):自动清除坏会话,提示用户重发
|
|
467
|
+
if (writer.sessionCorrupted) {
|
|
468
|
+
feishuDb.updateSessionState(feishuOpenId, { claude_session_id: null });
|
|
469
|
+
await larkClient.sendText(chatId,
|
|
470
|
+
'⚠️ 检测到历史会话数据异常,已自动重置会话。请重新发送消息,将以新会话继续。'
|
|
471
|
+
).catch(() => {});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export { FakeSendWriter, runQuery };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speech.js — 语音消息转文字
|
|
3
|
+
*
|
|
4
|
+
* 流程:飞书音频(ogg/amr/m4a)→ ffmpeg 转 mp3 → Whisper API → 文字
|
|
5
|
+
*
|
|
6
|
+
* 环境变量:
|
|
7
|
+
* WHISPER_API_KEY API Key(未设时使用 OPENAI_API_KEY)
|
|
8
|
+
* WHISPER_BASE_URL API 地址(默认 https://api.openai.com/v1)
|
|
9
|
+
* WHISPER_MODEL 模型名称(默认 whisper-1)
|
|
10
|
+
* WHISPER_LANGUAGE 语言提示,如 "zh"(默认空,自动检测)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import fetch from 'node-fetch';
|
|
15
|
+
import FormData from 'form-data';
|
|
16
|
+
|
|
17
|
+
// 需要 ffmpeg 转码的格式
|
|
18
|
+
const NEEDS_CONVERSION = new Set(['ogg', 'opus', 'amr', 'silk', 'm4a', 'aac']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 转写音频为文字
|
|
22
|
+
* @param {Buffer} audioBuffer 音频二进制数据
|
|
23
|
+
* @param {string} format 格式(ogg/amr/mp3/m4a 等)
|
|
24
|
+
* @returns {Promise<string>} 转写文字;空字符串表示未识别到语音内容;出错时抛出异常
|
|
25
|
+
*/
|
|
26
|
+
async function transcribeAudio(audioBuffer, format) {
|
|
27
|
+
const srcFormat = (format || 'ogg').toLowerCase();
|
|
28
|
+
let mp3Buffer = audioBuffer;
|
|
29
|
+
if (NEEDS_CONVERSION.has(srcFormat)) {
|
|
30
|
+
mp3Buffer = await convertToMp3(audioBuffer, srcFormat);
|
|
31
|
+
}
|
|
32
|
+
return await whisperTranscribe(mp3Buffer, 'mp3');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 使用 ffmpeg 将音频转换为 mp3(16kHz 单声道)
|
|
37
|
+
*/
|
|
38
|
+
function convertToMp3(inputBuffer, srcFormat) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
// AMR 需要显式指定输入格式,OGG 可自动识别
|
|
41
|
+
const args = srcFormat === 'amr'
|
|
42
|
+
? ['-f', 'amr', '-i', 'pipe:0', '-f', 'mp3', '-ac', '1', '-ar', '16000', '-y', 'pipe:1']
|
|
43
|
+
: ['-i', 'pipe:0', '-f', 'mp3', '-ac', '1', '-ar', '16000', '-y', 'pipe:1'];
|
|
44
|
+
|
|
45
|
+
const proc = spawn('ffmpeg', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
46
|
+
const chunks = [];
|
|
47
|
+
const errChunks = [];
|
|
48
|
+
|
|
49
|
+
proc.stdout.on('data', chunk => chunks.push(chunk));
|
|
50
|
+
proc.stderr.on('data', chunk => errChunks.push(chunk));
|
|
51
|
+
|
|
52
|
+
proc.on('close', code => {
|
|
53
|
+
if (code !== 0) {
|
|
54
|
+
const errMsg = Buffer.concat(errChunks).toString().slice(-500);
|
|
55
|
+
return reject(new Error(`ffmpeg exited ${code}: ${errMsg}`));
|
|
56
|
+
}
|
|
57
|
+
resolve(Buffer.concat(chunks));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
proc.on('error', err => reject(new Error(`ffmpeg spawn failed: ${err.message}`)));
|
|
61
|
+
|
|
62
|
+
proc.stdin.write(inputBuffer);
|
|
63
|
+
proc.stdin.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 调用 Whisper API 转写 mp3 音频
|
|
69
|
+
*/
|
|
70
|
+
async function whisperTranscribe(audioBuffer, format) {
|
|
71
|
+
const baseUrl = process.env.WHISPER_BASE_URL || 'https://api.openai.com/v1';
|
|
72
|
+
const apiKey = process.env.WHISPER_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
73
|
+
const model = process.env.WHISPER_MODEL || 'whisper-1';
|
|
74
|
+
const lang = process.env.WHISPER_LANGUAGE || '';
|
|
75
|
+
|
|
76
|
+
if (!apiKey) {
|
|
77
|
+
throw new Error('Whisper API key not configured (set WHISPER_API_KEY or OPENAI_API_KEY)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
form.append('file', audioBuffer, {
|
|
82
|
+
filename: `audio.${format}`,
|
|
83
|
+
contentType: `audio/${format}`,
|
|
84
|
+
});
|
|
85
|
+
form.append('model', model);
|
|
86
|
+
form.append('response_format', 'text');
|
|
87
|
+
if (lang) form.append('language', lang);
|
|
88
|
+
|
|
89
|
+
const resp = await fetch(`${baseUrl}/audio/transcriptions`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${apiKey}`,
|
|
93
|
+
...form.getHeaders(),
|
|
94
|
+
},
|
|
95
|
+
body: form,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!resp.ok) {
|
|
99
|
+
const body = await resp.text().catch(() => '');
|
|
100
|
+
throw new Error(`Whisper API ${resp.status}: ${body.slice(0, 200)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (await resp.text()).trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 检查 ffmpeg 是否可用
|
|
108
|
+
*/
|
|
109
|
+
async function checkFfmpeg() {
|
|
110
|
+
return new Promise(resolve => {
|
|
111
|
+
const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
112
|
+
proc.on('error', () => resolve(false));
|
|
113
|
+
proc.on('close', code => resolve(code === 0));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { transcribeAudio, checkFfmpeg };
|