felo-ai 0.2.6 → 0.2.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/.github/workflows/publish-npm.yml +39 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +346 -346
- package/README.en.md +129 -129
- package/README.md +435 -408
- package/docs/EXAMPLES.md +632 -632
- package/docs/FAQ.md +479 -479
- package/felo-search/LICENSE +21 -21
- package/felo-search/README.md +440 -440
- package/felo-search/SKILL.md +291 -291
- package/felo-slides/LICENSE +21 -21
- package/felo-slides/README.md +87 -87
- package/felo-slides/SKILL.md +166 -166
- package/felo-slides/scripts/run_ppt_task.mjs +251 -251
- package/felo-superAgent/LICENSE +21 -0
- package/felo-superAgent/README.md +125 -0
- package/felo-superAgent/SKILL.md +165 -0
- package/felo-web-fetch/README.md +127 -0
- package/felo-web-fetch/SKILL.md +204 -0
- package/felo-web-fetch/scripts/run_web_fetch.mjs +316 -0
- package/felo-x-search/SKILL.md +204 -0
- package/felo-x-search/scripts/run_x_search.mjs +385 -0
- package/felo-youtube-subtitling/README.md +59 -59
- package/felo-youtube-subtitling/SKILL.md +161 -161
- package/felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs +239 -239
- package/package.json +37 -35
- package/src/cli.js +370 -252
- package/src/config.js +66 -66
- package/src/search.js +142 -142
- package/src/slides.js +332 -332
- package/src/superAgent.js +609 -0
- package/src/{webExtract.js → webFetch.js} +148 -148
- package/src/xSearch.js +366 -0
- package/src/youtubeSubtitling.js +179 -179
- package/tests/config.test.js +78 -78
- package/tests/search.test.js +100 -100
- package/felo-web-extract/README.md +0 -78
- package/felo-web-extract/SKILL.md +0 -200
- package/felo-web-extract/scripts/run_web_extract.mjs +0 -232
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
const DEFAULT_API_BASE = 'https://openapi.felo.ai';
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
3
|
+
/** 流式读取空闲超时:连续这么久未收到任何数据则断开,默认 5 分钟(生图等长任务需较久) */
|
|
4
|
+
const STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
5
|
+
/** Tools whose params and results should be silently ignored. */
|
|
6
|
+
const HIDDEN_TOOLS = new Set(['manage_outline']);
|
|
7
|
+
const RECONNECT_DELAY_MS = 2000;
|
|
8
|
+
|
|
9
|
+
const NO_KEY_MESSAGE = `
|
|
10
|
+
❌ Felo API Key not configured
|
|
11
|
+
|
|
12
|
+
To use SuperAgent, set FELO_API_KEY (env or: felo config set FELO_API_KEY <key>).
|
|
13
|
+
Get your API key from https://felo.ai (Settings → API Keys).
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
async function getApiKey() {
|
|
17
|
+
if (process.env.FELO_API_KEY?.trim()) {
|
|
18
|
+
return process.env.FELO_API_KEY.trim();
|
|
19
|
+
}
|
|
20
|
+
const { getConfigValue } = await import('./config.js');
|
|
21
|
+
const fromConfig = await getConfigValue('FELO_API_KEY');
|
|
22
|
+
return typeof fromConfig === 'string' ? fromConfig.trim() : '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getMessage(payload) {
|
|
26
|
+
return (
|
|
27
|
+
payload?.message ||
|
|
28
|
+
payload?.error ||
|
|
29
|
+
payload?.msg ||
|
|
30
|
+
payload?.code ||
|
|
31
|
+
'Unknown error'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isApiError(payload) {
|
|
36
|
+
const status = payload?.status;
|
|
37
|
+
const code = payload?.code;
|
|
38
|
+
if (typeof status === 'string' && status.toLowerCase() === 'error') return true;
|
|
39
|
+
if (typeof code === 'string' && code && code.toUpperCase() !== 'OK') return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createConversation(apiKey, apiBase, body, timeoutMs, threadId) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
const url = threadId
|
|
48
|
+
? `${apiBase}/v2/conversations/${encodeURIComponent(threadId)}/follow_up`
|
|
49
|
+
: `${apiBase}/v2/conversations`;
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
Accept: 'application/json',
|
|
54
|
+
Authorization: `Bearer ${apiKey}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let data = {};
|
|
62
|
+
try {
|
|
63
|
+
data = await res.json();
|
|
64
|
+
} catch {
|
|
65
|
+
data = {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
|
|
70
|
+
}
|
|
71
|
+
if (isApiError(data)) {
|
|
72
|
+
throw new Error(getMessage(data));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const payload = data?.data ?? {};
|
|
76
|
+
if (!payload.stream_key) {
|
|
77
|
+
throw new Error('Unexpected response: missing stream_key');
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sleep(ms) {
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a single SSE connection until it ends or encounters an error event.
|
|
91
|
+
* Returns { maxOffset, streamDone, streamError } so the caller can decide
|
|
92
|
+
* whether to reconnect.
|
|
93
|
+
*/
|
|
94
|
+
async function readSSE(url, apiKey, startOffset, callbacks) {
|
|
95
|
+
const { onMessage, onError, onDone, onEvent, onToolCall, onToolResult, onStatusMessage } = callbacks;
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
let idleTimer = null;
|
|
98
|
+
const resetIdleTimer = () => {
|
|
99
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
100
|
+
idleTimer = setTimeout(() => controller.abort(), STREAM_IDLE_TIMEOUT_MS);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const connectUrl = startOffset >= 0 ? `${url}?offset=${startOffset}` : url;
|
|
104
|
+
let maxOffset = startOffset;
|
|
105
|
+
let streamDone = false;
|
|
106
|
+
let streamError = null;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(connectUrl, {
|
|
110
|
+
method: 'GET',
|
|
111
|
+
headers: {
|
|
112
|
+
Accept: 'text/event-stream',
|
|
113
|
+
Authorization: `Bearer ${apiKey}`,
|
|
114
|
+
},
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const text = await res.text();
|
|
120
|
+
let msg = text;
|
|
121
|
+
try {
|
|
122
|
+
const j = JSON.parse(text);
|
|
123
|
+
msg = getMessage(j) || text;
|
|
124
|
+
} catch {}
|
|
125
|
+
throw new Error(`HTTP ${res.status}: ${msg}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const reader = res.body?.getReader();
|
|
129
|
+
if (!reader) throw new Error('No response body');
|
|
130
|
+
|
|
131
|
+
const decoder = new TextDecoder();
|
|
132
|
+
let buffer = '';
|
|
133
|
+
let currentEvent = '';
|
|
134
|
+
let currentData = undefined;
|
|
135
|
+
|
|
136
|
+
const processEvent = (evt, data) => {
|
|
137
|
+
if (!evt || data === undefined) return;
|
|
138
|
+
// Track offset and skip already-processed events on reconnect
|
|
139
|
+
let eventOffset = -1;
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(data);
|
|
142
|
+
if (typeof parsed?.offset === 'number') {
|
|
143
|
+
eventOffset = parsed.offset;
|
|
144
|
+
if (parsed.offset > maxOffset) {
|
|
145
|
+
maxOffset = parsed.offset;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
|
|
150
|
+
// Skip events we've already seen (replay after reconnect)
|
|
151
|
+
if (eventOffset >= 0 && eventOffset <= startOffset) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (onEvent) onEvent(evt, data);
|
|
156
|
+
|
|
157
|
+
if (evt === 'error') {
|
|
158
|
+
// Server sends event:error as a "not ready yet" signal during long tasks
|
|
159
|
+
// (e.g. image generation). This does NOT mean the connection is broken.
|
|
160
|
+
// Just ignore it and keep reading.
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (evt === 'done' || evt === 'completed' || evt === 'complete') {
|
|
164
|
+
streamDone = true;
|
|
165
|
+
onDone();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Delegate other events (message, stream, heartbeat, etc.) to dispatch
|
|
169
|
+
dispatch(evt, data, onMessage, onError, onDone, onEvent, onToolCall, onToolResult, onStatusMessage);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
resetIdleTimer();
|
|
173
|
+
while (true) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
resetIdleTimer();
|
|
176
|
+
if (done) break;
|
|
177
|
+
|
|
178
|
+
buffer += decoder.decode(value, { stream: true });
|
|
179
|
+
const lines = buffer.split(/\r?\n/);
|
|
180
|
+
buffer = lines.pop() ?? '';
|
|
181
|
+
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
if (line.startsWith('event:')) {
|
|
184
|
+
processEvent(currentEvent, currentData);
|
|
185
|
+
currentEvent = line.slice(6).trim();
|
|
186
|
+
currentData = undefined;
|
|
187
|
+
} else if (line.startsWith('data:')) {
|
|
188
|
+
currentData = line.slice(5).trim();
|
|
189
|
+
} else if (line === '') {
|
|
190
|
+
processEvent(currentEvent, currentData);
|
|
191
|
+
currentEvent = '';
|
|
192
|
+
currentData = undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
198
|
+
processEvent(currentEvent, currentData);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
201
|
+
if (err?.name === 'AbortError') {
|
|
202
|
+
streamError = `Stream idle timeout (no data for ${STREAM_IDLE_TIMEOUT_MS / 1000}s)`;
|
|
203
|
+
} else {
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { maxOffset, streamDone, streamError };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Consume SSE stream with automatic reconnection.
|
|
213
|
+
* Server may push event:error and close the connection when waiting for
|
|
214
|
+
* long-running tasks (e.g. image generation). We reconnect with ?offset=N
|
|
215
|
+
* to resume from where we left off, and keep retrying until we receive
|
|
216
|
+
* a done/complete event or hit the 2-hour total timeout.
|
|
217
|
+
*/
|
|
218
|
+
async function consumeStream(apiKey, apiBase, streamKey, onMessage, onError, onDone, onEvent, toolCallbacks, onStatusMessage) {
|
|
219
|
+
const url = `${apiBase}/v2/conversations/stream/${encodeURIComponent(streamKey)}`;
|
|
220
|
+
const { onToolCall, onToolResult } = toolCallbacks;
|
|
221
|
+
const callbacks = { onMessage, onError, onDone, onEvent, onToolCall, onToolResult, onStatusMessage };
|
|
222
|
+
|
|
223
|
+
let lastOffset = -1;
|
|
224
|
+
const startTime = Date.now();
|
|
225
|
+
|
|
226
|
+
while (true) {
|
|
227
|
+
if (Date.now() - startTime > STREAM_IDLE_TIMEOUT_MS) {
|
|
228
|
+
onError('Stream timeout: no completion after ' + (STREAM_IDLE_TIMEOUT_MS / 1000) + 's');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = await readSSE(url, apiKey, lastOffset, callbacks);
|
|
233
|
+
|
|
234
|
+
if (result.streamDone) return;
|
|
235
|
+
|
|
236
|
+
if (result.maxOffset > lastOffset) {
|
|
237
|
+
lastOffset = result.maxOffset;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Connection closed (error or unexpected) — reconnect
|
|
241
|
+
await sleep(RECONNECT_DELAY_MS);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extractToolResults(data) {
|
|
246
|
+
const out = [];
|
|
247
|
+
const tools = data?.tools;
|
|
248
|
+
if (!Array.isArray(tools)) return out;
|
|
249
|
+
for (const t of tools) {
|
|
250
|
+
if (HIDDEN_TOOLS.has(t?.name) || HIDDEN_TOOLS.has(t?.tool_name)) continue;
|
|
251
|
+
const callResult = t?.call_result;
|
|
252
|
+
// Image tool results
|
|
253
|
+
if (t?.tool_name === 'generate_images' || t?.name === 'generate_images') {
|
|
254
|
+
if (!callResult) continue;
|
|
255
|
+
if (Array.isArray(callResult)) {
|
|
256
|
+
for (const item of callResult) {
|
|
257
|
+
if (item?.image_url) out.push({ type: 'image', title: item?.title || '', image_url: item.image_url });
|
|
258
|
+
}
|
|
259
|
+
} else if (callResult?.images && Array.isArray(callResult.images)) {
|
|
260
|
+
for (const img of callResult.images) {
|
|
261
|
+
if (img?.image_url) out.push({ type: 'image', title: img?.title || '', image_url: img.image_url });
|
|
262
|
+
}
|
|
263
|
+
} else if (callResult?.image_url) {
|
|
264
|
+
out.push({ type: 'image', title: callResult?.title || '', image_url: callResult.image_url });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Discovery (research report) tool results
|
|
268
|
+
if (t?.name === 'generate_discovery' && callResult?.status === 'success') {
|
|
269
|
+
out.push({ type: 'discovery', title: callResult?.title || t?.params?.title || '研究报告' });
|
|
270
|
+
}
|
|
271
|
+
// Document generation tool results
|
|
272
|
+
if (t?.name === 'generate_document' && callResult?.status === 'success') {
|
|
273
|
+
out.push({ type: 'document', title: callResult?.title || t?.params?.title || '文档' });
|
|
274
|
+
}
|
|
275
|
+
// PPT generation tool results
|
|
276
|
+
if (t?.name === 'generate_ppt' && callResult?.status === 'success') {
|
|
277
|
+
out.push({ type: 'ppt', title: callResult?.title || t?.params?.title || 'PPT' });
|
|
278
|
+
}
|
|
279
|
+
// HTML generation tool results
|
|
280
|
+
if (t?.name === 'generate_html' && callResult?.status === 'success') {
|
|
281
|
+
out.push({ type: 'html', title: callResult?.title || t?.params?.title || 'HTML' });
|
|
282
|
+
}
|
|
283
|
+
// Twitter search tool results
|
|
284
|
+
if (t?.name === 'search_x' && callResult?.tweets && Array.isArray(callResult.tweets)) {
|
|
285
|
+
out.push({ type: 'search_x', status: callResult.status, tweets: callResult.tweets });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Extract tool invocation params from type=tools events for immediate display.
|
|
293
|
+
*/
|
|
294
|
+
function extractToolParams(data) {
|
|
295
|
+
const out = [];
|
|
296
|
+
const tools = data?.tools;
|
|
297
|
+
if (!Array.isArray(tools)) return out;
|
|
298
|
+
for (const t of tools) {
|
|
299
|
+
if (HIDDEN_TOOLS.has(t?.name) || HIDDEN_TOOLS.has(t?.tool_name)) continue;
|
|
300
|
+
if (t?.name && t?.params) {
|
|
301
|
+
out.push({ name: t.name, params: t.params });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Format a single tweet for CLI output.
|
|
309
|
+
*/
|
|
310
|
+
function formatTweet(tweet) {
|
|
311
|
+
const info = tweet?.other_info || {};
|
|
312
|
+
const author = info.author || {};
|
|
313
|
+
const metrics = info.metrics || {};
|
|
314
|
+
const name = author.display_name || author.username || 'Unknown';
|
|
315
|
+
const handle = author.username ? `@${author.username}` : '';
|
|
316
|
+
const text = tweet?.snippet || tweet?.title || '';
|
|
317
|
+
const link = tweet?.link || info.url || '';
|
|
318
|
+
const stats = [];
|
|
319
|
+
if (metrics.favorite_count) stats.push(`❤ ${metrics.favorite_count}`);
|
|
320
|
+
if (metrics.retweet_count) stats.push(`🔁 ${metrics.retweet_count}`);
|
|
321
|
+
if (metrics.view_count) stats.push(`👁 ${metrics.view_count}`);
|
|
322
|
+
const statsStr = stats.length > 0 ? ` [${stats.join(' | ')}]` : '';
|
|
323
|
+
return ` ${name} (${handle})${statsStr}\n ${text}\n ${link}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function dispatch(eventType, dataStr, onMessage, onError, onDone, onEvent, onToolCall, onToolResult, onStatusMessage) {
|
|
327
|
+
let payload = {};
|
|
328
|
+
if (dataStr) {
|
|
329
|
+
try {
|
|
330
|
+
payload = JSON.parse(dataStr);
|
|
331
|
+
} catch {
|
|
332
|
+
payload = { content: dataStr };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
switch (eventType) {
|
|
337
|
+
case 'message':
|
|
338
|
+
if (typeof payload.content === 'string') {
|
|
339
|
+
onMessage(payload.content);
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
case 'stream': {
|
|
343
|
+
const content = payload?.content;
|
|
344
|
+
if (typeof content === 'string') {
|
|
345
|
+
try {
|
|
346
|
+
const inner = JSON.parse(content);
|
|
347
|
+
const type = inner?.type;
|
|
348
|
+
const data = inner?.data;
|
|
349
|
+
if (type === 'content' || type === 'text' || type === 'delta' || type === 'answer') {
|
|
350
|
+
const text = data?.content ?? data?.text ?? data?.delta;
|
|
351
|
+
if (typeof text === 'string') onMessage(text);
|
|
352
|
+
} else if (type === 'message' && onStatusMessage && data?.query) {
|
|
353
|
+
onStatusMessage(`已收到: ${data.query}`);
|
|
354
|
+
} else if (type === 'processing' && onStatusMessage && data?.message) {
|
|
355
|
+
onStatusMessage(data.message);
|
|
356
|
+
} else if (type === 'tools' && onToolCall) {
|
|
357
|
+
const params = extractToolParams(data);
|
|
358
|
+
for (const item of params) onToolCall(item);
|
|
359
|
+
} else if (
|
|
360
|
+
(type === 'tools_result_stream' || type === 'tools_result') &&
|
|
361
|
+
onToolResult
|
|
362
|
+
) {
|
|
363
|
+
const results = extractToolResults(data);
|
|
364
|
+
for (const item of results) onToolResult(item);
|
|
365
|
+
} else if (type !== 'processing' && type !== 'tools' && data?.message && typeof data.message === 'string') {
|
|
366
|
+
onMessage(data.message);
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
onMessage(content);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case 'heartbeat':
|
|
375
|
+
case 'connected':
|
|
376
|
+
break;
|
|
377
|
+
default:
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run SuperAgent: create conversation, consume SSE stream, output answer.
|
|
384
|
+
* @param {string} query - User query (1–2000 chars).
|
|
385
|
+
* @param {Object} [options]
|
|
386
|
+
* @param {boolean} [options.json] - Output JSON with answer, thread_short_id, live_doc_short_id.
|
|
387
|
+
* @param {boolean} [options.verbose] - Log stream key / thread / livedoc to stderr.
|
|
388
|
+
* @param {number} [options.timeoutMs] - Request/stream timeout in ms.
|
|
389
|
+
* @param {string} [options.liveDocId] - Reuse existing LiveDoc short_id.
|
|
390
|
+
* @param {string} [options.threadId] - Existing thread/conversation ID for follow-up.
|
|
391
|
+
* @param {string} [options.acceptLanguage] - e.g. zh, en.
|
|
392
|
+
* @returns {Promise<number>} Exit code 0 or 1.
|
|
393
|
+
*/
|
|
394
|
+
export async function superAgent(query, options = {}) {
|
|
395
|
+
const apiKey = await getApiKey();
|
|
396
|
+
if (!apiKey) {
|
|
397
|
+
process.stderr.write(NO_KEY_MESSAGE.trim() + '\n');
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const apiBase = (process.env.FELO_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
402
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
403
|
+
const body = {
|
|
404
|
+
query: String(query).trim().slice(0, 2000),
|
|
405
|
+
};
|
|
406
|
+
if (options.liveDocId) body.live_doc_short_id = options.liveDocId;
|
|
407
|
+
if (options.acceptLanguage) body.accept_language = options.acceptLanguage;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const threadId = options.threadId;
|
|
411
|
+
process.stderr.write(threadId ? 'SuperAgent: following up...\n' : 'SuperAgent: creating conversation...\n');
|
|
412
|
+
|
|
413
|
+
const createData = await createConversation(apiKey, apiBase, body, timeoutMs, threadId);
|
|
414
|
+
const { stream_key, thread_short_id, live_doc_short_id } = createData;
|
|
415
|
+
|
|
416
|
+
if (options.verbose) {
|
|
417
|
+
process.stderr.write(`Stream key: ${stream_key}\n`);
|
|
418
|
+
process.stderr.write(`Thread ID: ${thread_short_id}\n`);
|
|
419
|
+
process.stderr.write(`LiveDoc ID: ${live_doc_short_id}\n`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const feloBase = (process.env.FELO_WEB_BASE?.trim() || apiBase.replace(/\/\/openapi-/, '//').replace(/\/\/openapi\./, '//')).replace(/\/$/, '');
|
|
423
|
+
const liveDocUrl = live_doc_short_id ? `${feloBase}/zh-Hans/livedoc/${live_doc_short_id}` : '';
|
|
424
|
+
|
|
425
|
+
const chunks = [];
|
|
426
|
+
const toolResults = [];
|
|
427
|
+
const seenKeys = new Set();
|
|
428
|
+
const isJson = options.json;
|
|
429
|
+
|
|
430
|
+
// Immediate output for tool invocations (params)
|
|
431
|
+
const onToolCall = (item) => {
|
|
432
|
+
if (isJson) return;
|
|
433
|
+
const { name, params } = item;
|
|
434
|
+
console.log(`\n[Tool: ${name}]`);
|
|
435
|
+
if (name === 'search_x') {
|
|
436
|
+
console.log(` Query: ${params.query || ''}`);
|
|
437
|
+
if (params.query_type) console.log(` Type: ${params.query_type}`);
|
|
438
|
+
if (params.limit) console.log(` Limit: ${params.limit}`);
|
|
439
|
+
} else if (name === 'generate_images') {
|
|
440
|
+
const images = params?.images;
|
|
441
|
+
if (Array.isArray(images)) {
|
|
442
|
+
for (const img of images) {
|
|
443
|
+
console.log(` Image: ${img.title || '(untitled)'}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} else if (name === 'generate_discovery') {
|
|
447
|
+
console.log(` Title: ${params.title || params.query || ''}`);
|
|
448
|
+
} else if (name === 'generate_document') {
|
|
449
|
+
console.log(` Title: ${params.title || ''}`);
|
|
450
|
+
} else if (name === 'generate_ppt') {
|
|
451
|
+
console.log(` Title: ${params.title || ''}`);
|
|
452
|
+
} else if (name === 'generate_html') {
|
|
453
|
+
console.log(` Title: ${params.title || ''}`);
|
|
454
|
+
} else {
|
|
455
|
+
console.log(` Params: ${JSON.stringify(params)}`);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// Immediate output for tool results
|
|
460
|
+
const onToolResult = (item) => {
|
|
461
|
+
// For types that link to the same livedoc URL, deduplicate by type only
|
|
462
|
+
const LIVEDOC_TYPES = new Set(['document', 'ppt', 'html', 'discovery']);
|
|
463
|
+
const key = item?.image_url || (LIVEDOC_TYPES.has(item?.type) ? item.type : `${item?.type}:${item?.title}`);
|
|
464
|
+
if (seenKeys.has(key)) return;
|
|
465
|
+
seenKeys.add(key);
|
|
466
|
+
toolResults.push(item);
|
|
467
|
+
|
|
468
|
+
if (isJson) return;
|
|
469
|
+
if (item.type === 'image') {
|
|
470
|
+
if (liveDocUrl) {
|
|
471
|
+
console.log(`[${item.title || '图片'}](${liveDocUrl})`);
|
|
472
|
+
} else {
|
|
473
|
+
console.log(item.image_url);
|
|
474
|
+
}
|
|
475
|
+
} else if (item.type === 'discovery') {
|
|
476
|
+
if (liveDocUrl) {
|
|
477
|
+
console.log(`[${item.title}](${liveDocUrl})`);
|
|
478
|
+
} else {
|
|
479
|
+
console.log(item.title);
|
|
480
|
+
}
|
|
481
|
+
} else if (item.type === 'document') {
|
|
482
|
+
if (liveDocUrl) {
|
|
483
|
+
console.log(`[${item.title || '文档'}](${liveDocUrl})`);
|
|
484
|
+
} else {
|
|
485
|
+
console.log(item.title || '文档');
|
|
486
|
+
}
|
|
487
|
+
} else if (item.type === 'ppt') {
|
|
488
|
+
if (liveDocUrl) {
|
|
489
|
+
console.log(`[${item.title || 'PPT'}](${liveDocUrl})`);
|
|
490
|
+
} else {
|
|
491
|
+
console.log(item.title || 'PPT');
|
|
492
|
+
}
|
|
493
|
+
} else if (item.type === 'html') {
|
|
494
|
+
if (liveDocUrl) {
|
|
495
|
+
console.log(`[${item.title || 'HTML'}](${liveDocUrl})`);
|
|
496
|
+
} else {
|
|
497
|
+
console.log(item.title || 'HTML');
|
|
498
|
+
}
|
|
499
|
+
} else if (item.type === 'search_x') {
|
|
500
|
+
console.log(`\n[Twitter Search Results] (${item.tweets.length} tweets)`);
|
|
501
|
+
for (const tweet of item.tweets) {
|
|
502
|
+
console.log(formatTweet(tweet));
|
|
503
|
+
console.log('');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
let streamError = null;
|
|
509
|
+
const onEvent = options.verbose
|
|
510
|
+
? (eventType, dataStr) => {
|
|
511
|
+
process.stderr.write(`[stream] event=${eventType}\n`);
|
|
512
|
+
process.stderr.write(`[stream] data=${dataStr || ''}\n`);
|
|
513
|
+
}
|
|
514
|
+
: undefined;
|
|
515
|
+
|
|
516
|
+
const onStatusMessage = (msg) => {
|
|
517
|
+
process.stderr.write(msg + '\n');
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
await consumeStream(
|
|
521
|
+
apiKey,
|
|
522
|
+
apiBase,
|
|
523
|
+
stream_key,
|
|
524
|
+
(content) => {
|
|
525
|
+
chunks.push(content);
|
|
526
|
+
if (!isJson) process.stdout.write(content);
|
|
527
|
+
},
|
|
528
|
+
(err) => {
|
|
529
|
+
streamError = err;
|
|
530
|
+
},
|
|
531
|
+
() => {},
|
|
532
|
+
onEvent,
|
|
533
|
+
{ onToolCall, onToolResult },
|
|
534
|
+
onStatusMessage
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
if (streamError) {
|
|
538
|
+
throw new Error(streamError);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const answer = chunks.join('').trim();
|
|
542
|
+
|
|
543
|
+
if (options.json) {
|
|
544
|
+
const images = toolResults.filter((r) => r.type === 'image');
|
|
545
|
+
const discoveries = toolResults.filter((r) => r.type === 'discovery');
|
|
546
|
+
const documents = toolResults.filter((r) => r.type === 'document');
|
|
547
|
+
const ppts = toolResults.filter((r) => r.type === 'ppt');
|
|
548
|
+
const htmls = toolResults.filter((r) => r.type === 'html');
|
|
549
|
+
const searches = toolResults.filter((r) => r.type === 'search_x');
|
|
550
|
+
console.log(
|
|
551
|
+
JSON.stringify(
|
|
552
|
+
{
|
|
553
|
+
status: 'ok',
|
|
554
|
+
data: {
|
|
555
|
+
answer: answer || null,
|
|
556
|
+
thread_short_id: thread_short_id ?? null,
|
|
557
|
+
live_doc_short_id: live_doc_short_id ?? null,
|
|
558
|
+
image_urls:
|
|
559
|
+
images.length > 0
|
|
560
|
+
? images.map((r) => ({ url: r.image_url, title: r.title }))
|
|
561
|
+
: undefined,
|
|
562
|
+
discoveries:
|
|
563
|
+
discoveries.length > 0
|
|
564
|
+
? discoveries.map((r) => ({ title: r.title }))
|
|
565
|
+
: undefined,
|
|
566
|
+
documents:
|
|
567
|
+
documents.length > 0
|
|
568
|
+
? documents.map((r) => ({ title: r.title }))
|
|
569
|
+
: undefined,
|
|
570
|
+
ppts:
|
|
571
|
+
ppts.length > 0
|
|
572
|
+
? ppts.map((r) => ({ title: r.title }))
|
|
573
|
+
: undefined,
|
|
574
|
+
htmls:
|
|
575
|
+
htmls.length > 0
|
|
576
|
+
? htmls.map((r) => ({ title: r.title }))
|
|
577
|
+
: undefined,
|
|
578
|
+
search_x:
|
|
579
|
+
searches.length > 0
|
|
580
|
+
? searches.map((r) => ({ tweets: r.tweets }))
|
|
581
|
+
: undefined,
|
|
582
|
+
live_doc_url: liveDocUrl || undefined,
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
null,
|
|
586
|
+
2
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
} else {
|
|
590
|
+
// Text and tool results already printed in real-time via process.stdout.write / onToolResult
|
|
591
|
+
// Just add a trailing newline if there was streaming text
|
|
592
|
+
if (answer) console.log('');
|
|
593
|
+
if (!answer && toolResults.length === 0) {
|
|
594
|
+
console.log('(No content in stream)');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return 0;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
const msg = err?.message || err;
|
|
601
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
602
|
+
if (String(msg).toLowerCase().includes('stream error')) {
|
|
603
|
+
process.stderr.write(
|
|
604
|
+
'(流式无客户端超时;若内部接口能拿到完整流,多为代理/防火墙在等待生图等长任务时空闲断连,可直连或调大代理空闲超时后重试)\n'
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return 1;
|
|
608
|
+
}
|
|
609
|
+
}
|