iflow-feishu 1.0.8 → 1.0.9
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/package.json +1 -1
- package/src/core/feishu-client.js +177 -0
- package/src/core/http-server.js +48 -0
- package/src/core/service.js +1 -2
- package/src/core/stream-handler.js +32 -5
package/package.json
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
const http = require('http');
|
|
6
6
|
const https = require('https');
|
|
7
7
|
const { URL } = require('url');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
8
10
|
const { logger } = require('../utils/logger');
|
|
9
11
|
|
|
10
12
|
class FeishuClient {
|
|
@@ -194,6 +196,181 @@ class FeishuClient {
|
|
|
194
196
|
return false;
|
|
195
197
|
}
|
|
196
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 上传文件到飞书
|
|
202
|
+
* @param {string} filePath - 本地文件路径
|
|
203
|
+
* @returns {Promise<string|null>} - 返回 file_key 或 null
|
|
204
|
+
*/
|
|
205
|
+
async uploadFile(filePath) {
|
|
206
|
+
try {
|
|
207
|
+
const token = await this.getToken();
|
|
208
|
+
|
|
209
|
+
// 检查文件是否存在
|
|
210
|
+
if (!fs.existsSync(filePath)) {
|
|
211
|
+
logger.error('❌ 文件不存在:', filePath);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 读取文件
|
|
216
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
217
|
+
const fileName = path.basename(filePath);
|
|
218
|
+
|
|
219
|
+
// 生成 boundary
|
|
220
|
+
const boundary = `----iFlowBoundary${Date.now()}`;
|
|
221
|
+
|
|
222
|
+
// 构建 multipart/form-data body
|
|
223
|
+
const parts = [];
|
|
224
|
+
|
|
225
|
+
// 文件类型字段
|
|
226
|
+
parts.push(`--${boundary}\r\n`);
|
|
227
|
+
parts.push(`Content-Disposition: form-data; name="file_type"\r\n\r\n`);
|
|
228
|
+
parts.push(`stream\r\n`);
|
|
229
|
+
|
|
230
|
+
// 文件名字段
|
|
231
|
+
parts.push(`--${boundary}\r\n`);
|
|
232
|
+
parts.push(`Content-Disposition: form-data; name="file_name"\r\n\r\n`);
|
|
233
|
+
parts.push(`${fileName}\r\n`);
|
|
234
|
+
|
|
235
|
+
// 文件内容
|
|
236
|
+
parts.push(`--${boundary}\r\n`);
|
|
237
|
+
parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`);
|
|
238
|
+
parts.push(`Content-Type: application/octet-stream\r\n\r\n`);
|
|
239
|
+
|
|
240
|
+
const bodyParts = Buffer.concat([
|
|
241
|
+
Buffer.from(parts.join(''), 'utf8'),
|
|
242
|
+
fileBuffer,
|
|
243
|
+
Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
logger.info(`📤 正在上传文件: ${fileName} (${(fileBuffer.length / 1024).toFixed(2)} KB)`);
|
|
247
|
+
|
|
248
|
+
// 发送请求
|
|
249
|
+
const result = await this.httpRequestMultipart(
|
|
250
|
+
'https://open.feishu.cn/open-apis/im/v1/files',
|
|
251
|
+
{
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: {
|
|
254
|
+
'Authorization': `Bearer ${token}`,
|
|
255
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
256
|
+
'Content-Length': bodyParts.length
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
bodyParts
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (result.ok && result.data?.data?.file_key) {
|
|
263
|
+
logger.info('✅ 文件上传成功, file_key:', result.data.data.file_key);
|
|
264
|
+
return result.data.data.file_key;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
logger.error('❌ 文件上传失败:', JSON.stringify(result.data));
|
|
268
|
+
return null;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logger.error('上传文件错误:', err.message);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 发送 multipart/form-data 请求
|
|
277
|
+
*/
|
|
278
|
+
async httpRequestMultipart(url, options, body) {
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const u = new URL(url);
|
|
281
|
+
|
|
282
|
+
const req = https.request({
|
|
283
|
+
hostname: u.hostname,
|
|
284
|
+
port: u.port || 443,
|
|
285
|
+
path: u.pathname + u.search,
|
|
286
|
+
method: options.method || 'POST',
|
|
287
|
+
headers: options.headers || {},
|
|
288
|
+
}, (res) => {
|
|
289
|
+
let data = '';
|
|
290
|
+
res.on('data', chunk => data += chunk);
|
|
291
|
+
res.on('end', () => {
|
|
292
|
+
try {
|
|
293
|
+
resolve({
|
|
294
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
295
|
+
data: JSON.parse(data),
|
|
296
|
+
status: res.statusCode
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
resolve({
|
|
300
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
301
|
+
data,
|
|
302
|
+
status: res.statusCode
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
req.on('error', reject);
|
|
309
|
+
req.setTimeout(60000, () => { // 文件上传超时时间更长
|
|
310
|
+
req.destroy();
|
|
311
|
+
reject(new Error('Upload timeout'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (body) req.write(body);
|
|
315
|
+
req.end();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 发送文件消息
|
|
321
|
+
* @param {string} chatId - 群聊 ID
|
|
322
|
+
* @param {string} fileKey - 文件 key(由 uploadFile 返回)
|
|
323
|
+
* @param {string} fileName - 文件名(可选,用于日志)
|
|
324
|
+
* @returns {Promise<boolean>} - 发送是否成功
|
|
325
|
+
*/
|
|
326
|
+
async sendFileMessage(chatId, fileKey, fileName = '') {
|
|
327
|
+
try {
|
|
328
|
+
const token = await this.getToken();
|
|
329
|
+
|
|
330
|
+
const r = await this.httpRequest(
|
|
331
|
+
'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id',
|
|
332
|
+
{
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: {
|
|
335
|
+
'Authorization': `Bearer ${token}`,
|
|
336
|
+
'Content-Type': 'application/json'
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
receive_id: chatId,
|
|
341
|
+
msg_type: 'file',
|
|
342
|
+
content: JSON.stringify({ file_key: fileKey })
|
|
343
|
+
})
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (r.ok && r.data?.data?.message_id) {
|
|
347
|
+
logger.info(`📤 文件消息已发送: ${fileName || fileKey}`);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
logger.error('❌ 发送文件消息失败:', JSON.stringify(r.data));
|
|
352
|
+
return false;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
logger.error('发送文件消息错误:', err.message);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 上传并发送文件(便捷方法)
|
|
361
|
+
* @param {string} chatId - 群聊 ID
|
|
362
|
+
* @param {string} filePath - 本地文件路径
|
|
363
|
+
* @returns {Promise<boolean>} - 发送是否成功
|
|
364
|
+
*/
|
|
365
|
+
async sendFile(chatId, filePath) {
|
|
366
|
+
const fileKey = await this.uploadFile(filePath);
|
|
367
|
+
if (!fileKey) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const fileName = path.basename(filePath);
|
|
372
|
+
return await this.sendFileMessage(chatId, fileKey, fileName);
|
|
373
|
+
}
|
|
197
374
|
}
|
|
198
375
|
|
|
199
376
|
module.exports = { FeishuClient };
|
package/src/core/http-server.js
CHANGED
|
@@ -46,6 +46,12 @@ class HTTPServer {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// 发送文件 API
|
|
50
|
+
if (url.pathname === '/send-file' && req.method === 'POST') {
|
|
51
|
+
await this.handleSendFile(req, res);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
// 404
|
|
50
56
|
res.writeHead(404);
|
|
51
57
|
res.end('Not Found');
|
|
@@ -101,6 +107,48 @@ class HTTPServer {
|
|
|
101
107
|
});
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 处理发送文件请求
|
|
112
|
+
*/
|
|
113
|
+
async handleSendFile(req, res) {
|
|
114
|
+
let body = '';
|
|
115
|
+
|
|
116
|
+
req.on('data', chunk => body += chunk);
|
|
117
|
+
|
|
118
|
+
req.on('end', async () => {
|
|
119
|
+
try {
|
|
120
|
+
const data = JSON.parse(body);
|
|
121
|
+
const { chatId, filePath, message } = data;
|
|
122
|
+
|
|
123
|
+
// 验证必需参数
|
|
124
|
+
if (!chatId || !filePath) {
|
|
125
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
126
|
+
res.end(JSON.stringify({ code: 400, msg: '缺少必需参数: chatId 或 filePath' }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
logger.info(`发送文件请求: chatId=${chatId}, filePath=${filePath}`);
|
|
131
|
+
|
|
132
|
+
// 如果有消息,先发送文字消息
|
|
133
|
+
if (message) {
|
|
134
|
+
await this.service.feishuClient.sendMessage(chatId, message);
|
|
135
|
+
logger.info(`文字消息已发送: ${message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 上传并发送文件
|
|
139
|
+
const result = await this.service.feishuClient.sendFile(chatId, filePath);
|
|
140
|
+
logger.info(`文件发送成功: ${JSON.stringify(result)}`);
|
|
141
|
+
|
|
142
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end(JSON.stringify({ code: 0, msg: 'success', data: result }));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
logger.error(`发送文件错误: ${err.message}`);
|
|
146
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
147
|
+
res.end(JSON.stringify({ code: 500, msg: err.message }));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
/**
|
|
105
153
|
* 关闭服务器
|
|
106
154
|
*/
|
package/src/core/service.js
CHANGED
|
@@ -287,6 +287,7 @@ class FeishuService {
|
|
|
287
287
|
|
|
288
288
|
// 提取响应
|
|
289
289
|
const extracted = this.streamHandler.extractResponse(stdoutSoFar, chatId, modelName);
|
|
290
|
+
// 内容变化会自动触发卡片更新,无需显式调用 update()
|
|
290
291
|
updater.setReasoning(extracted.reasoning);
|
|
291
292
|
updater.setContent(extracted.content || '');
|
|
292
293
|
|
|
@@ -294,8 +295,6 @@ class FeishuService {
|
|
|
294
295
|
const currentLength = (stdoutSoFar || '').length;
|
|
295
296
|
const percent = this.messageProcessor.calculateContentLeftPercent(chatId, currentLength, modelName);
|
|
296
297
|
updater.setPercent(percent);
|
|
297
|
-
|
|
298
|
-
updater.update();
|
|
299
298
|
},
|
|
300
299
|
{ mode: 'default', thinking: false }
|
|
301
300
|
);
|
|
@@ -155,6 +155,7 @@ class StreamHandler {
|
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* 创建卡片更新器
|
|
158
|
+
* 优化:只在内容变化时才调用API,计时变化不单独触发更新
|
|
158
159
|
*/
|
|
159
160
|
createCardUpdater(cardId, startTime, modelName, initialPercent) {
|
|
160
161
|
let lastUpdate = 0;
|
|
@@ -167,6 +168,9 @@ class StreamHandler {
|
|
|
167
168
|
let isCompleted = false;
|
|
168
169
|
let thinkingStartTime = null;
|
|
169
170
|
let thinkingEndTime = null;
|
|
171
|
+
let lastReasoning = '';
|
|
172
|
+
let lastContent = '';
|
|
173
|
+
let pendingUpdate = false;
|
|
170
174
|
|
|
171
175
|
const update = async (force = false) => {
|
|
172
176
|
if (!cardId || isCompleted) return;
|
|
@@ -175,6 +179,9 @@ class StreamHandler {
|
|
|
175
179
|
if (!force && now - lastUpdate < CARD_UPDATE_INTERVAL) return;
|
|
176
180
|
|
|
177
181
|
lastUpdate = now;
|
|
182
|
+
lastReasoning = currentReasoning;
|
|
183
|
+
lastContent = currentContent;
|
|
184
|
+
pendingUpdate = false;
|
|
178
185
|
|
|
179
186
|
const elapsed = isStreamEnded && streamEndTime
|
|
180
187
|
? streamEndTime - startTime
|
|
@@ -198,10 +205,30 @@ class StreamHandler {
|
|
|
198
205
|
}
|
|
199
206
|
};
|
|
200
207
|
|
|
208
|
+
// 检查内容是否有变化
|
|
209
|
+
const hasContentChanged = () => {
|
|
210
|
+
return currentReasoning !== lastReasoning || currentContent !== lastContent;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// 内容变化时触发更新
|
|
214
|
+
const triggerUpdate = () => {
|
|
215
|
+
if (hasContentChanged() && !pendingUpdate) {
|
|
216
|
+
pendingUpdate = true;
|
|
217
|
+
update();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
201
221
|
return {
|
|
202
222
|
update,
|
|
203
|
-
|
|
204
|
-
|
|
223
|
+
triggerUpdate,
|
|
224
|
+
setReasoning: (r) => {
|
|
225
|
+
currentReasoning = r;
|
|
226
|
+
triggerUpdate();
|
|
227
|
+
},
|
|
228
|
+
setContent: (c) => {
|
|
229
|
+
currentContent = c;
|
|
230
|
+
triggerUpdate();
|
|
231
|
+
},
|
|
205
232
|
setPercent: (p) => { currentPercent = p; },
|
|
206
233
|
setThinking: (isThinking) => { isInThinking = isThinking; },
|
|
207
234
|
setThinkingStart: (t) => { thinkingStartTime = t; },
|
|
@@ -218,12 +245,12 @@ class StreamHandler {
|
|
|
218
245
|
|
|
219
246
|
/**
|
|
220
247
|
* 创建定时器
|
|
248
|
+
* 优化:定时器不再主动更新,只在完成时强制更新一次显示最终耗时
|
|
221
249
|
*/
|
|
222
250
|
createTimer(updater) {
|
|
223
251
|
return setInterval(() => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
252
|
+
// 不再主动更新,只检查是否完成
|
|
253
|
+
// 卡片更新由内容变化触发
|
|
227
254
|
}, TIMER_UPDATE_INTERVAL);
|
|
228
255
|
}
|
|
229
256
|
}
|