pi-grok-search 2.0.0
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/LICENSE +21 -0
- package/README.md +151 -0
- package/README_EN.md +150 -0
- package/index.ts +1972 -0
- package/package.json +27 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-grok-search Extension (v2)
|
|
3
|
+
*
|
|
4
|
+
* 通过 Grok API + Tavily + Firecrawl 为 pi 提供完整的网络访问能力。
|
|
5
|
+
* 参考: https://github.com/GuDaStudio/GrokSearch
|
|
6
|
+
*
|
|
7
|
+
* 双引擎架构:
|
|
8
|
+
* - Grok: AI 驱动的智能搜索
|
|
9
|
+
* - Tavily: 高保真网页抓取与站点映射
|
|
10
|
+
* - Firecrawl: Tavily 失败时自动托底
|
|
11
|
+
*
|
|
12
|
+
* 功能:
|
|
13
|
+
* - grok_search: AI 网络搜索(带信源缓存)
|
|
14
|
+
* - grok_sources: 获取搜索信源
|
|
15
|
+
* - web_fetch: 网页内容抓取(Tavily → Firecrawl 自动降级)
|
|
16
|
+
* - web_map: 站点结构映射
|
|
17
|
+
* - 搜索规划: 6 阶段结构化搜索规划
|
|
18
|
+
* - 配置诊断: 连接测试 + 模型发现
|
|
19
|
+
* - CLI 命令: /grok-search, /grok-config, /grok-model, /pi-ext-docs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
25
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
26
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { join, dirname } from "node:path";
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
interface GrokConfigFile {
|
|
35
|
+
apiUrl?: string;
|
|
36
|
+
apiKey?: string;
|
|
37
|
+
model?: string;
|
|
38
|
+
tavilyApiKey?: string;
|
|
39
|
+
tavilyApiUrl?: string;
|
|
40
|
+
firecrawlApiKey?: string;
|
|
41
|
+
firecrawlApiUrl?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Source {
|
|
45
|
+
url: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
provider?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SearchResult {
|
|
52
|
+
content: string;
|
|
53
|
+
sources: Source[];
|
|
54
|
+
sourcesCount: number;
|
|
55
|
+
sessionId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PlanningSession {
|
|
59
|
+
sessionId: string;
|
|
60
|
+
phases: Record<string, PhaseRecord>;
|
|
61
|
+
complexityLevel: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PhaseRecord {
|
|
65
|
+
phase: string;
|
|
66
|
+
thought: string;
|
|
67
|
+
data: unknown;
|
|
68
|
+
confidence: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Sources Cache
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
class SourcesCache {
|
|
76
|
+
private maxSize: number;
|
|
77
|
+
private cache: Map<string, Source[]>;
|
|
78
|
+
|
|
79
|
+
constructor(maxSize = 256) {
|
|
80
|
+
this.maxSize = maxSize;
|
|
81
|
+
this.cache = new Map();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
set(sessionId: string, sources: Source[]): void {
|
|
85
|
+
this.cache.set(sessionId, sources);
|
|
86
|
+
// LRU eviction
|
|
87
|
+
if (this.cache.size > this.maxSize) {
|
|
88
|
+
const firstKey = this.cache.keys().next().value;
|
|
89
|
+
if (firstKey) this.cache.delete(firstKey);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get(sessionId: string): Source[] | undefined {
|
|
94
|
+
const sources = this.cache.get(sessionId);
|
|
95
|
+
if (sources) {
|
|
96
|
+
// Move to end (most recently used)
|
|
97
|
+
this.cache.delete(sessionId);
|
|
98
|
+
this.cache.set(sessionId, sources);
|
|
99
|
+
}
|
|
100
|
+
return sources;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sourcesCache = new SourcesCache(256);
|
|
105
|
+
|
|
106
|
+
// =============================================================================
|
|
107
|
+
// Planning Engine
|
|
108
|
+
// =============================================================================
|
|
109
|
+
|
|
110
|
+
const PHASE_NAMES = [
|
|
111
|
+
"intent_analysis",
|
|
112
|
+
"complexity_assessment",
|
|
113
|
+
"query_decomposition",
|
|
114
|
+
"search_strategy",
|
|
115
|
+
"tool_selection",
|
|
116
|
+
"execution_order",
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const REQUIRED_PHASES: Record<number, Set<string>> = {
|
|
120
|
+
1: new Set([
|
|
121
|
+
"intent_analysis",
|
|
122
|
+
"complexity_assessment",
|
|
123
|
+
"query_decomposition",
|
|
124
|
+
]),
|
|
125
|
+
2: new Set([
|
|
126
|
+
"intent_analysis",
|
|
127
|
+
"complexity_assessment",
|
|
128
|
+
"query_decomposition",
|
|
129
|
+
"search_strategy",
|
|
130
|
+
"tool_selection",
|
|
131
|
+
]),
|
|
132
|
+
3: new Set(PHASE_NAMES),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
class PlanningEngine {
|
|
136
|
+
private sessions: Map<string, PlanningSession>;
|
|
137
|
+
|
|
138
|
+
constructor() {
|
|
139
|
+
this.sessions = new Map();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getSession(sessionId: string): PlanningSession | undefined {
|
|
143
|
+
return this.sessions.get(sessionId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
processPhase(params: {
|
|
147
|
+
phase: string;
|
|
148
|
+
thought: string;
|
|
149
|
+
sessionId?: string;
|
|
150
|
+
isRevision?: boolean;
|
|
151
|
+
confidence?: number;
|
|
152
|
+
phaseData?: unknown;
|
|
153
|
+
}): Record<string, unknown> {
|
|
154
|
+
const {
|
|
155
|
+
phase,
|
|
156
|
+
thought,
|
|
157
|
+
sessionId,
|
|
158
|
+
isRevision = false,
|
|
159
|
+
confidence = 1.0,
|
|
160
|
+
phaseData,
|
|
161
|
+
} = params;
|
|
162
|
+
|
|
163
|
+
if (!PHASE_NAMES.includes(phase)) {
|
|
164
|
+
return {
|
|
165
|
+
error: `Unknown phase: ${phase}. Valid: ${PHASE_NAMES.join(", ")}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let session: PlanningSession;
|
|
170
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
171
|
+
session = this.sessions.get(sessionId)!;
|
|
172
|
+
} else {
|
|
173
|
+
const sid = sessionId || this.newSessionId();
|
|
174
|
+
session = { sessionId: sid, phases: {}, complexityLevel: null };
|
|
175
|
+
this.sessions.set(sid, session);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (phase === "query_decomposition" || phase === "tool_selection") {
|
|
179
|
+
if (isRevision) {
|
|
180
|
+
session.phases[phase] = {
|
|
181
|
+
phase,
|
|
182
|
+
thought,
|
|
183
|
+
data: Array.isArray(phaseData) ? phaseData : [phaseData],
|
|
184
|
+
confidence,
|
|
185
|
+
};
|
|
186
|
+
} else if (
|
|
187
|
+
session.phases[phase] &&
|
|
188
|
+
Array.isArray(session.phases[phase].data)
|
|
189
|
+
) {
|
|
190
|
+
(session.phases[phase].data as unknown[]).push(phaseData);
|
|
191
|
+
session.phases[phase].thought = thought;
|
|
192
|
+
session.phases[phase].confidence = confidence;
|
|
193
|
+
} else {
|
|
194
|
+
session.phases[phase] = {
|
|
195
|
+
phase,
|
|
196
|
+
thought,
|
|
197
|
+
data: [phaseData],
|
|
198
|
+
confidence,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
} else if (phase === "search_strategy") {
|
|
202
|
+
if (isRevision || !session.phases[phase]) {
|
|
203
|
+
session.phases[phase] = { phase, thought, data: phaseData, confidence };
|
|
204
|
+
} else {
|
|
205
|
+
const existing = session.phases[phase];
|
|
206
|
+
const existingData = existing.data as Record<string, unknown>;
|
|
207
|
+
const newData = phaseData as Record<string, unknown>;
|
|
208
|
+
if (existingData && newData) {
|
|
209
|
+
const existingTerms = (existingData.search_terms as unknown[]) || [];
|
|
210
|
+
const newTerms = (newData.search_terms as unknown[]) || [];
|
|
211
|
+
existingData.search_terms = [...existingTerms, ...newTerms];
|
|
212
|
+
if (newData.approach) existingData.approach = newData.approach;
|
|
213
|
+
if (newData.fallback_plan)
|
|
214
|
+
existingData.fallback_plan = newData.fallback_plan;
|
|
215
|
+
existing.thought = thought;
|
|
216
|
+
existing.confidence = confidence;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
session.phases[phase] = { phase, thought, data: phaseData, confidence };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
phase === "complexity_assessment" &&
|
|
225
|
+
phaseData &&
|
|
226
|
+
typeof phaseData === "object"
|
|
227
|
+
) {
|
|
228
|
+
const level = (phaseData as Record<string, unknown>).level;
|
|
229
|
+
if (level === 1 || level === 2 || level === 3) {
|
|
230
|
+
session.complexityLevel = level;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const completedPhases = PHASE_NAMES.filter((p) => session.phases[p]);
|
|
235
|
+
const requiredPhases =
|
|
236
|
+
REQUIRED_PHASES[session.complexityLevel || 3] || REQUIRED_PHASES[3];
|
|
237
|
+
const remaining = [...requiredPhases].filter((p) => !session.phases[p]);
|
|
238
|
+
const isComplete =
|
|
239
|
+
session.complexityLevel !== null && remaining.length === 0;
|
|
240
|
+
|
|
241
|
+
const result: Record<string, unknown> = {
|
|
242
|
+
session_id: session.sessionId,
|
|
243
|
+
completed_phases: completedPhases,
|
|
244
|
+
complexity_level: session.complexityLevel,
|
|
245
|
+
plan_complete: isComplete,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (remaining.length > 0) {
|
|
249
|
+
result.phases_remaining = remaining;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isComplete) {
|
|
253
|
+
const plan: Record<string, unknown> = {};
|
|
254
|
+
for (const [name, record] of Object.entries(session.phases)) {
|
|
255
|
+
plan[name] = record.data;
|
|
256
|
+
}
|
|
257
|
+
result.executable_plan = plan;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private newSessionId(): string {
|
|
264
|
+
return Math.random().toString(36).slice(2, 14);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const planningEngine = new PlanningEngine();
|
|
269
|
+
|
|
270
|
+
// =============================================================================
|
|
271
|
+
// Configuration Manager
|
|
272
|
+
// =============================================================================
|
|
273
|
+
|
|
274
|
+
class ConfigManager {
|
|
275
|
+
private configPath: string;
|
|
276
|
+
private configCache: GrokConfigFile | null = null;
|
|
277
|
+
private modelsCache: { key: string; models: string[] } | null = null;
|
|
278
|
+
|
|
279
|
+
constructor() {
|
|
280
|
+
this.configPath = join(
|
|
281
|
+
homedir(),
|
|
282
|
+
".config",
|
|
283
|
+
"pi-grok-search",
|
|
284
|
+
"config.json",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
getConfigPath(): string {
|
|
289
|
+
return this.configPath;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async loadFile(): Promise<GrokConfigFile> {
|
|
293
|
+
try {
|
|
294
|
+
const content = await readFile(this.configPath, "utf-8");
|
|
295
|
+
return JSON.parse(content);
|
|
296
|
+
} catch {
|
|
297
|
+
return {};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async saveFile(config: GrokConfigFile): Promise<void> {
|
|
302
|
+
await mkdir(dirname(this.configPath), { recursive: true });
|
|
303
|
+
await writeFile(this.configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
304
|
+
this.configCache = null;
|
|
305
|
+
this.modelsCache = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getFullConfig(): Promise<{
|
|
309
|
+
grokApiUrl: string;
|
|
310
|
+
grokApiKey: string;
|
|
311
|
+
grokModel: string;
|
|
312
|
+
tavilyApiUrl: string;
|
|
313
|
+
tavilyApiKey: string;
|
|
314
|
+
firecrawlApiUrl: string;
|
|
315
|
+
firecrawlApiKey: string;
|
|
316
|
+
}> {
|
|
317
|
+
const file = await this.loadFile();
|
|
318
|
+
return {
|
|
319
|
+
grokApiUrl: process.env.GROK_API_URL || file.apiUrl || "",
|
|
320
|
+
grokApiKey: process.env.GROK_API_KEY || file.apiKey || "",
|
|
321
|
+
grokModel: process.env.GROK_MODEL || file.model || "grok-4-fast",
|
|
322
|
+
tavilyApiUrl:
|
|
323
|
+
process.env.TAVILY_API_URL ||
|
|
324
|
+
file.tavilyApiUrl ||
|
|
325
|
+
"https://api.tavily.com",
|
|
326
|
+
tavilyApiKey: process.env.TAVILY_API_KEY || file.tavilyApiKey || "",
|
|
327
|
+
firecrawlApiUrl:
|
|
328
|
+
process.env.FIRECRAWL_API_URL ||
|
|
329
|
+
file.firecrawlApiUrl ||
|
|
330
|
+
"https://api.firecrawl.dev/v2",
|
|
331
|
+
firecrawlApiKey:
|
|
332
|
+
process.env.FIRECRAWL_API_KEY || file.firecrawlApiKey || "",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async setModel(model: string): Promise<void> {
|
|
337
|
+
const file = await this.loadFile();
|
|
338
|
+
file.model = model;
|
|
339
|
+
await this.saveFile(file);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async setGrokApi(url: string, key: string): Promise<void> {
|
|
343
|
+
const file = await this.loadFile();
|
|
344
|
+
file.apiUrl = url;
|
|
345
|
+
file.apiKey = key;
|
|
346
|
+
await this.saveFile(file);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async setTavily(key: string, url?: string): Promise<void> {
|
|
350
|
+
const file = await this.loadFile();
|
|
351
|
+
file.tavilyApiKey = key;
|
|
352
|
+
if (url) file.tavilyApiUrl = url;
|
|
353
|
+
await this.saveFile(file);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async setFirecrawl(key: string, url?: string): Promise<void> {
|
|
357
|
+
const file = await this.loadFile();
|
|
358
|
+
file.firecrawlApiKey = key;
|
|
359
|
+
if (url) file.firecrawlApiUrl = url;
|
|
360
|
+
await this.saveFile(file);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
maskKey(key: string): string {
|
|
364
|
+
if (!key || key.length <= 8) return "***";
|
|
365
|
+
return `${key.slice(0, 4)}${"*".repeat(key.length - 8)}${key.slice(-4)}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
getModelsCacheKey(apiUrl: string, apiKey: string): string {
|
|
369
|
+
return `${apiUrl}|${apiKey}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
getCachedModels(apiUrl: string, apiKey: string): string[] | null {
|
|
373
|
+
const key = this.getModelsCacheKey(apiUrl, apiKey);
|
|
374
|
+
if (this.modelsCache && this.modelsCache.key === key) {
|
|
375
|
+
return this.modelsCache.models;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
setCachedModels(apiUrl: string, apiKey: string, models: string[]): void {
|
|
381
|
+
this.modelsCache = { key: this.getModelsCacheKey(apiUrl, apiKey), models };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const configManager = new ConfigManager();
|
|
386
|
+
|
|
387
|
+
// =============================================================================
|
|
388
|
+
// HTTP Utilities
|
|
389
|
+
// =============================================================================
|
|
390
|
+
|
|
391
|
+
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
392
|
+
|
|
393
|
+
async function fetchWithRetry(
|
|
394
|
+
url: string,
|
|
395
|
+
init: RequestInit,
|
|
396
|
+
maxRetries = 3,
|
|
397
|
+
): Promise<Response> {
|
|
398
|
+
let lastError: Error | null = null;
|
|
399
|
+
|
|
400
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
401
|
+
try {
|
|
402
|
+
const response = await fetch(url, init);
|
|
403
|
+
|
|
404
|
+
if (response.status === 429) {
|
|
405
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
406
|
+
let waitMs: number;
|
|
407
|
+
if (retryAfter && /^\d+$/.test(retryAfter.trim())) {
|
|
408
|
+
waitMs = parseInt(retryAfter, 10) * 1000;
|
|
409
|
+
} else {
|
|
410
|
+
waitMs = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 10000);
|
|
411
|
+
}
|
|
412
|
+
await sleep(waitMs);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < maxRetries) {
|
|
417
|
+
await sleep(Math.min(1000 * 2 ** attempt, 10000));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
const text = await response.text().catch(() => "");
|
|
423
|
+
throw new Error(`HTTP ${response.status}: ${text.slice(0, 300)}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return response;
|
|
427
|
+
} catch (e) {
|
|
428
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
429
|
+
if (lastError.name === "AbortError") throw lastError;
|
|
430
|
+
if (attempt < maxRetries) {
|
|
431
|
+
await sleep(Math.min(1000 * 2 ** attempt, 10000));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
throw lastError || new Error("Request failed");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function sleep(ms: number): Promise<void> {
|
|
440
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function newSessionId(): string {
|
|
444
|
+
return Math.random().toString(36).slice(2, 14);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function getLocalTimeInfo(): string {
|
|
448
|
+
const now = new Date();
|
|
449
|
+
const weekdays = [
|
|
450
|
+
"星期日",
|
|
451
|
+
"星期一",
|
|
452
|
+
"星期二",
|
|
453
|
+
"星期三",
|
|
454
|
+
"星期四",
|
|
455
|
+
"星期五",
|
|
456
|
+
"星期六",
|
|
457
|
+
];
|
|
458
|
+
const weekday = weekdays[now.getDay()];
|
|
459
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "Local";
|
|
460
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
`[Current Time Context]\n` +
|
|
464
|
+
`- Date: ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} (${weekday})\n` +
|
|
465
|
+
`- Time: ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}\n` +
|
|
466
|
+
`- Timezone: ${tz}\n\n`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function needsTimeContext(query: string): boolean {
|
|
471
|
+
const cnKeywords = [
|
|
472
|
+
"当前",
|
|
473
|
+
"现在",
|
|
474
|
+
"今天",
|
|
475
|
+
"明天",
|
|
476
|
+
"昨天",
|
|
477
|
+
"本周",
|
|
478
|
+
"上周",
|
|
479
|
+
"下周",
|
|
480
|
+
"本月",
|
|
481
|
+
"上月",
|
|
482
|
+
"下月",
|
|
483
|
+
"今年",
|
|
484
|
+
"去年",
|
|
485
|
+
"明年",
|
|
486
|
+
"最新",
|
|
487
|
+
"最近",
|
|
488
|
+
"近期",
|
|
489
|
+
"刚刚",
|
|
490
|
+
"刚才",
|
|
491
|
+
"实时",
|
|
492
|
+
"目前",
|
|
493
|
+
];
|
|
494
|
+
const enKeywords = [
|
|
495
|
+
"current",
|
|
496
|
+
"now",
|
|
497
|
+
"today",
|
|
498
|
+
"tomorrow",
|
|
499
|
+
"yesterday",
|
|
500
|
+
"this week",
|
|
501
|
+
"last week",
|
|
502
|
+
"next week",
|
|
503
|
+
"this month",
|
|
504
|
+
"last month",
|
|
505
|
+
"latest",
|
|
506
|
+
"recent",
|
|
507
|
+
"recently",
|
|
508
|
+
"just now",
|
|
509
|
+
"real-time",
|
|
510
|
+
"up-to-date",
|
|
511
|
+
];
|
|
512
|
+
const lower = query.toLowerCase();
|
|
513
|
+
return (
|
|
514
|
+
cnKeywords.some((k) => query.includes(k)) ||
|
|
515
|
+
enKeywords.some((k) => lower.includes(k))
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// =============================================================================
|
|
520
|
+
// Source Extraction Utilities
|
|
521
|
+
// =============================================================================
|
|
522
|
+
|
|
523
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"'`,。、;:!?》)】)]+/g;
|
|
524
|
+
const MD_LINK_PATTERN = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
|
|
525
|
+
|
|
526
|
+
function extractUrls(text: string): string[] {
|
|
527
|
+
const seen = new Set<string>();
|
|
528
|
+
const urls: string[] = [];
|
|
529
|
+
for (const m of text.matchAll(URL_PATTERN)) {
|
|
530
|
+
const url = m[0].replace(/[.,;:!?]+$/, "");
|
|
531
|
+
if (!seen.has(url)) {
|
|
532
|
+
seen.add(url);
|
|
533
|
+
urls.push(url);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return urls;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function extractSourcesFromText(text: string): Source[] {
|
|
540
|
+
const sources: Source[] = [];
|
|
541
|
+
const seen = new Set<string>();
|
|
542
|
+
|
|
543
|
+
for (const [, title, url] of text.matchAll(MD_LINK_PATTERN)) {
|
|
544
|
+
const cleanUrl = url.trim();
|
|
545
|
+
if (!cleanUrl || seen.has(cleanUrl)) continue;
|
|
546
|
+
seen.add(cleanUrl);
|
|
547
|
+
sources.push({ url: cleanUrl, title: title.trim() || undefined });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const url of extractUrls(text)) {
|
|
551
|
+
if (!seen.has(url)) {
|
|
552
|
+
seen.add(url);
|
|
553
|
+
sources.push({ url });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return sources;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function splitAnswerAndSources(text: string): {
|
|
561
|
+
answer: string;
|
|
562
|
+
sources: Source[];
|
|
563
|
+
} {
|
|
564
|
+
const trimmed = text.trim();
|
|
565
|
+
if (!trimmed) return { answer: "", sources: [] };
|
|
566
|
+
|
|
567
|
+
// Try to find Sources/References/信源 heading
|
|
568
|
+
const headingPattern =
|
|
569
|
+
/(?:^|\n)(?:#{1,6}\s*)?(?:\*\*)?\s*(?:sources?|references?|citations?|信源|参考资料|参考|引用|来源)\s*(?:\*\*)?\s*[::]?\s*$/im;
|
|
570
|
+
const match = headingPattern.exec(trimmed);
|
|
571
|
+
if (match) {
|
|
572
|
+
const sourcesText = trimmed.slice(match.index);
|
|
573
|
+
const sources = extractSourcesFromText(sourcesText);
|
|
574
|
+
if (sources.length > 0) {
|
|
575
|
+
return { answer: trimmed.slice(0, match.index).trim(), sources };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Try tail link block
|
|
580
|
+
const lines = trimmed.split("\n");
|
|
581
|
+
let tailStart = lines.length;
|
|
582
|
+
let linkCount = 0;
|
|
583
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
584
|
+
const line = lines[i].trim();
|
|
585
|
+
if (!line) continue;
|
|
586
|
+
if (/^https?:\/\//.test(line) || MD_LINK_PATTERN.test(line)) {
|
|
587
|
+
linkCount++;
|
|
588
|
+
tailStart = i;
|
|
589
|
+
} else {
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (linkCount >= 2) {
|
|
594
|
+
const tailText = lines.slice(tailStart).join("\n");
|
|
595
|
+
const sources = extractSourcesFromText(tailText);
|
|
596
|
+
if (sources.length > 0) {
|
|
597
|
+
return { answer: lines.slice(0, tailStart).join("\n").trim(), sources };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { answer: trimmed, sources: [] };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function mergeSources(...lists: Source[][]): Source[] {
|
|
605
|
+
const seen = new Set<string>();
|
|
606
|
+
const merged: Source[] = [];
|
|
607
|
+
for (const list of lists) {
|
|
608
|
+
for (const item of list) {
|
|
609
|
+
if (!item.url || seen.has(item.url)) continue;
|
|
610
|
+
seen.add(item.url);
|
|
611
|
+
merged.push(item);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return merged;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// =============================================================================
|
|
618
|
+
// Grok API Client
|
|
619
|
+
// =============================================================================
|
|
620
|
+
|
|
621
|
+
async function grokSearch(
|
|
622
|
+
query: string,
|
|
623
|
+
platform = "",
|
|
624
|
+
signal?: AbortSignal,
|
|
625
|
+
): Promise<string> {
|
|
626
|
+
const config = await configManager.getFullConfig();
|
|
627
|
+
if (!config.grokApiUrl || !config.grokApiKey) {
|
|
628
|
+
throw new Error("Grok API 未配置。请使用 /grok-config 命令配置。");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const timeContext = needsTimeContext(query) ? getLocalTimeInfo() : "";
|
|
632
|
+
const platformPrompt = platform
|
|
633
|
+
? `\n\nYou should search the web for the information you need, and focus on these platform: ${platform}\n`
|
|
634
|
+
: "";
|
|
635
|
+
|
|
636
|
+
const payload = {
|
|
637
|
+
model: config.grokModel,
|
|
638
|
+
messages: [
|
|
639
|
+
{ role: "system", content: SEARCH_PROMPT },
|
|
640
|
+
{ role: "user", content: timeContext + query + platformPrompt },
|
|
641
|
+
],
|
|
642
|
+
stream: true,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const response = await fetchWithRetry(
|
|
646
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/chat/completions`,
|
|
647
|
+
{
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: {
|
|
650
|
+
Authorization: `Bearer ${config.grokApiKey}`,
|
|
651
|
+
"Content-Type": "application/json",
|
|
652
|
+
},
|
|
653
|
+
body: JSON.stringify(payload),
|
|
654
|
+
signal,
|
|
655
|
+
},
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
return parseStreamResponse(response);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function grokFetch(url: string, signal?: AbortSignal): Promise<string> {
|
|
662
|
+
const config = await configManager.getFullConfig();
|
|
663
|
+
if (!config.grokApiUrl || !config.grokApiKey) {
|
|
664
|
+
throw new Error("Grok API 未配置。");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const payload = {
|
|
668
|
+
model: config.grokModel,
|
|
669
|
+
messages: [
|
|
670
|
+
{ role: "system", content: FETCH_PROMPT },
|
|
671
|
+
{
|
|
672
|
+
role: "user",
|
|
673
|
+
content: `${url}\n获取该网页内容并返回其结构化Markdown格式`,
|
|
674
|
+
},
|
|
675
|
+
],
|
|
676
|
+
stream: true,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const response = await fetchWithRetry(
|
|
680
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/chat/completions`,
|
|
681
|
+
{
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers: {
|
|
684
|
+
Authorization: `Bearer ${config.grokApiKey}`,
|
|
685
|
+
"Content-Type": "application/json",
|
|
686
|
+
},
|
|
687
|
+
body: JSON.stringify(payload),
|
|
688
|
+
signal,
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
return parseStreamResponse(response);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function parseStreamResponse(response: Response): Promise<string> {
|
|
696
|
+
const reader = response.body?.getReader();
|
|
697
|
+
if (!reader) throw new Error("无法读取响应流");
|
|
698
|
+
|
|
699
|
+
const decoder = new TextDecoder();
|
|
700
|
+
let content = "";
|
|
701
|
+
let buffer = "";
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
while (true) {
|
|
705
|
+
const { done, value } = await reader.read();
|
|
706
|
+
if (done) break;
|
|
707
|
+
|
|
708
|
+
buffer += decoder.decode(value, { stream: true });
|
|
709
|
+
const lines = buffer.split("\n");
|
|
710
|
+
buffer = lines.pop() || "";
|
|
711
|
+
|
|
712
|
+
for (const line of lines) {
|
|
713
|
+
const trimmed = line.trim();
|
|
714
|
+
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
|
715
|
+
if (trimmed === "data: [DONE]" || trimmed === "data:[DONE]") continue;
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const data = JSON.parse(trimmed.slice(5).trim());
|
|
719
|
+
const delta = data.choices?.[0]?.delta;
|
|
720
|
+
if (delta?.content) content += delta.content;
|
|
721
|
+
} catch {
|
|
722
|
+
// skip malformed chunks
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} finally {
|
|
727
|
+
reader.releaseLock();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Fallback: non-streaming
|
|
731
|
+
if (!content) {
|
|
732
|
+
try {
|
|
733
|
+
const data = (await response.clone().json()) as Record<string, unknown>;
|
|
734
|
+
const choices = data.choices as
|
|
735
|
+
| Array<{ message?: { content?: string } }>
|
|
736
|
+
| undefined;
|
|
737
|
+
content = choices?.[0]?.message?.content || "";
|
|
738
|
+
} catch {
|
|
739
|
+
// ignore
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return content;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// =============================================================================
|
|
747
|
+
// Tavily API Client
|
|
748
|
+
// =============================================================================
|
|
749
|
+
|
|
750
|
+
async function tavilySearch(
|
|
751
|
+
query: string,
|
|
752
|
+
maxResults = 6,
|
|
753
|
+
signal?: AbortSignal,
|
|
754
|
+
): Promise<Source[]> {
|
|
755
|
+
const config = await configManager.getFullConfig();
|
|
756
|
+
if (!config.tavilyApiKey) return [];
|
|
757
|
+
|
|
758
|
+
const response = await fetchWithRetry(
|
|
759
|
+
`${config.tavilyApiUrl.replace(/\/+$/, "")}/search`,
|
|
760
|
+
{
|
|
761
|
+
method: "POST",
|
|
762
|
+
headers: {
|
|
763
|
+
Authorization: `Bearer ${config.tavilyApiKey}`,
|
|
764
|
+
"Content-Type": "application/json",
|
|
765
|
+
},
|
|
766
|
+
body: JSON.stringify({
|
|
767
|
+
query,
|
|
768
|
+
max_results: maxResults,
|
|
769
|
+
search_depth: "advanced",
|
|
770
|
+
include_raw_content: false,
|
|
771
|
+
include_answer: false,
|
|
772
|
+
}),
|
|
773
|
+
signal,
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const data = (await response.json()) as {
|
|
778
|
+
results?: Array<{
|
|
779
|
+
title?: string;
|
|
780
|
+
url: string;
|
|
781
|
+
content?: string;
|
|
782
|
+
score?: number;
|
|
783
|
+
}>;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
return (data.results || []).map((r) => ({
|
|
787
|
+
url: r.url,
|
|
788
|
+
title: r.title || undefined,
|
|
789
|
+
description: r.content || undefined,
|
|
790
|
+
provider: "tavily",
|
|
791
|
+
}));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async function tavilyExtract(
|
|
795
|
+
url: string,
|
|
796
|
+
signal?: AbortSignal,
|
|
797
|
+
): Promise<string | null> {
|
|
798
|
+
const config = await configManager.getFullConfig();
|
|
799
|
+
if (!config.tavilyApiKey) return null;
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
const response = await fetch(
|
|
803
|
+
`${config.tavilyApiUrl.replace(/\/+$/, "")}/extract`,
|
|
804
|
+
{
|
|
805
|
+
method: "POST",
|
|
806
|
+
headers: {
|
|
807
|
+
Authorization: `Bearer ${config.tavilyApiKey}`,
|
|
808
|
+
"Content-Type": "application/json",
|
|
809
|
+
},
|
|
810
|
+
body: JSON.stringify({ urls: [url], format: "markdown" }),
|
|
811
|
+
signal,
|
|
812
|
+
},
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
if (!response.ok) return null;
|
|
816
|
+
|
|
817
|
+
const data = (await response.json()) as {
|
|
818
|
+
results?: Array<{ raw_content?: string }>;
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const content = data.results?.[0]?.raw_content;
|
|
822
|
+
return content?.trim() || null;
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function tavilyMap(
|
|
829
|
+
url: string,
|
|
830
|
+
options: {
|
|
831
|
+
instructions?: string;
|
|
832
|
+
maxDepth?: number;
|
|
833
|
+
maxBreadth?: number;
|
|
834
|
+
limit?: number;
|
|
835
|
+
timeout?: number;
|
|
836
|
+
},
|
|
837
|
+
signal?: AbortSignal,
|
|
838
|
+
): Promise<string> {
|
|
839
|
+
const config = await configManager.getFullConfig();
|
|
840
|
+
if (!config.tavilyApiKey) {
|
|
841
|
+
return "配置错误: TAVILY_API_KEY 未配置,请使用 /grok-config 设置 Tavily API Key。";
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const timeout = options.timeout || 150;
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
const response = await fetch(
|
|
848
|
+
`${config.tavilyApiUrl.replace(/\/+$/, "")}/map`,
|
|
849
|
+
{
|
|
850
|
+
method: "POST",
|
|
851
|
+
headers: {
|
|
852
|
+
Authorization: `Bearer ${config.tavilyApiKey}`,
|
|
853
|
+
"Content-Type": "application/json",
|
|
854
|
+
},
|
|
855
|
+
body: JSON.stringify({
|
|
856
|
+
url,
|
|
857
|
+
max_depth: options.maxDepth || 1,
|
|
858
|
+
max_breadth: options.maxBreadth || 20,
|
|
859
|
+
limit: options.limit || 50,
|
|
860
|
+
timeout,
|
|
861
|
+
...(options.instructions
|
|
862
|
+
? { instructions: options.instructions }
|
|
863
|
+
: {}),
|
|
864
|
+
}),
|
|
865
|
+
signal: AbortSignal.timeout((timeout + 10) * 1000),
|
|
866
|
+
},
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
if (!response.ok) {
|
|
870
|
+
const text = await response.text().catch(() => "");
|
|
871
|
+
return `映射失败: HTTP ${response.status} - ${text.slice(0, 200)}`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const data = (await response.json()) as {
|
|
875
|
+
base_url?: string;
|
|
876
|
+
results?: string[];
|
|
877
|
+
response_time?: number;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
return JSON.stringify(
|
|
881
|
+
{
|
|
882
|
+
base_url: data.base_url || url,
|
|
883
|
+
results: data.results || [],
|
|
884
|
+
response_time: data.response_time || 0,
|
|
885
|
+
},
|
|
886
|
+
null,
|
|
887
|
+
2,
|
|
888
|
+
);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
return `映射错误: ${e instanceof Error ? e.message : String(e)}`;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// =============================================================================
|
|
895
|
+
// Firecrawl API Client
|
|
896
|
+
// =============================================================================
|
|
897
|
+
|
|
898
|
+
async function firecrawlSearch(
|
|
899
|
+
query: string,
|
|
900
|
+
limit = 14,
|
|
901
|
+
signal?: AbortSignal,
|
|
902
|
+
): Promise<Source[]> {
|
|
903
|
+
const config = await configManager.getFullConfig();
|
|
904
|
+
if (!config.firecrawlApiKey) return [];
|
|
905
|
+
|
|
906
|
+
try {
|
|
907
|
+
const response = await fetch(
|
|
908
|
+
`${config.firecrawlApiUrl.replace(/\/+$/, "")}/search`,
|
|
909
|
+
{
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: {
|
|
912
|
+
Authorization: `Bearer ${config.firecrawlApiKey}`,
|
|
913
|
+
"Content-Type": "application/json",
|
|
914
|
+
},
|
|
915
|
+
body: JSON.stringify({ query, limit }),
|
|
916
|
+
signal,
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
if (!response.ok) return [];
|
|
921
|
+
|
|
922
|
+
const data = (await response.json()) as {
|
|
923
|
+
data?: {
|
|
924
|
+
web?: Array<{ title?: string; url: string; description?: string }>;
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
return (data.data?.web || []).map((r) => ({
|
|
929
|
+
url: r.url,
|
|
930
|
+
title: r.title || undefined,
|
|
931
|
+
description: r.description || undefined,
|
|
932
|
+
provider: "firecrawl",
|
|
933
|
+
}));
|
|
934
|
+
} catch {
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function firecrawlScrape(
|
|
940
|
+
url: string,
|
|
941
|
+
signal?: AbortSignal,
|
|
942
|
+
): Promise<string | null> {
|
|
943
|
+
const config = await configManager.getFullConfig();
|
|
944
|
+
if (!config.firecrawlApiKey) return null;
|
|
945
|
+
|
|
946
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
947
|
+
try {
|
|
948
|
+
const response = await fetch(
|
|
949
|
+
`${config.firecrawlApiUrl.replace(/\/+$/, "")}/scrape`,
|
|
950
|
+
{
|
|
951
|
+
method: "POST",
|
|
952
|
+
headers: {
|
|
953
|
+
Authorization: `Bearer ${config.firecrawlApiKey}`,
|
|
954
|
+
"Content-Type": "application/json",
|
|
955
|
+
},
|
|
956
|
+
body: JSON.stringify({
|
|
957
|
+
url,
|
|
958
|
+
formats: ["markdown"],
|
|
959
|
+
timeout: 60000,
|
|
960
|
+
waitFor: (attempt + 1) * 1500,
|
|
961
|
+
}),
|
|
962
|
+
signal,
|
|
963
|
+
},
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
if (!response.ok) return null;
|
|
967
|
+
|
|
968
|
+
const data = (await response.json()) as {
|
|
969
|
+
data?: { markdown?: string };
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const md = data.data?.markdown;
|
|
973
|
+
if (md?.trim()) return md;
|
|
974
|
+
} catch {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// =============================================================================
|
|
983
|
+
// Prompts
|
|
984
|
+
// =============================================================================
|
|
985
|
+
|
|
986
|
+
const SEARCH_PROMPT = `# Core Instruction
|
|
987
|
+
|
|
988
|
+
1. User needs may be vague. Think divergently, infer intent from multiple angles, and leverage full conversation context to progressively clarify their true needs.
|
|
989
|
+
2. **Breadth-First Search**—Approach problems from multiple dimensions. Brainstorm 5+ perspectives and execute parallel searches for each. Consult as many high-quality sources as possible before responding.
|
|
990
|
+
3. **Depth-First Search**—After broad exploration, select ≥2 most relevant perspectives for deep investigation into specialized knowledge.
|
|
991
|
+
4. **Evidence-Based Reasoning & Traceable Sources**—Every claim must be followed by a citation (URL). More credible sources strengthen arguments. If no references exist, remain silent.
|
|
992
|
+
5. Before responding, ensure full execution of Steps 1–4.
|
|
993
|
+
|
|
994
|
+
# Search Instruction
|
|
995
|
+
|
|
996
|
+
1. Think carefully before responding—anticipate the user's true intent to ensure precision.
|
|
997
|
+
2. Verify every claim rigorously to avoid misinformation.
|
|
998
|
+
3. Follow problem logic—dig deeper until clues are exhaustively clear. If a question seems simple, still infer broader intent and search accordingly. Use multiple parallel tool calls per query and ensure answers are well-sourced.
|
|
999
|
+
4. Search in English first (prioritizing English resources for volume/quality), but switch to Chinese if context demands.
|
|
1000
|
+
5. Prioritize authoritative sources: Wikipedia, academic databases, books, reputable media/journalism.
|
|
1001
|
+
6. Favor sharing in-depth, specialized knowledge over generic or common-sense content.
|
|
1002
|
+
|
|
1003
|
+
# Output Style
|
|
1004
|
+
|
|
1005
|
+
0. **Be direct—no unnecessary follow-ups**.
|
|
1006
|
+
1. Lead with the **most probable solution** before detailed analysis.
|
|
1007
|
+
2. **Define every technical term** in plain language.
|
|
1008
|
+
3. **Every sentence must cite sources** (URLs). Silence if uncited.
|
|
1009
|
+
4. **Strictly format outputs in polished Markdown**.
|
|
1010
|
+
`;
|
|
1011
|
+
|
|
1012
|
+
const FETCH_PROMPT = `You are a professional web content fetcher. Given a URL, fetch its content and return a structured Markdown document.
|
|
1013
|
+
|
|
1014
|
+
Rules:
|
|
1015
|
+
- Preserve the original content structure (headings, lists, tables, code blocks)
|
|
1016
|
+
- Convert HTML to clean Markdown
|
|
1017
|
+
- Do NOT summarize or modify the content
|
|
1018
|
+
- Return the complete content as-is
|
|
1019
|
+
- Use proper Markdown formatting: # for headings, **bold**, *italic*, \`code\`, etc.
|
|
1020
|
+
`;
|
|
1021
|
+
|
|
1022
|
+
// =============================================================================
|
|
1023
|
+
// Extension Entry Point
|
|
1024
|
+
// =============================================================================
|
|
1025
|
+
|
|
1026
|
+
export default function (pi: ExtensionAPI) {
|
|
1027
|
+
// =========================================================================
|
|
1028
|
+
// Tool: grok_search — AI 网络搜索
|
|
1029
|
+
// =========================================================================
|
|
1030
|
+
pi.registerTool({
|
|
1031
|
+
name: "grok_search",
|
|
1032
|
+
label: "Grok Search",
|
|
1033
|
+
description:
|
|
1034
|
+
"通过 Grok API 执行 AI 驱动的深度网络搜索。自动检测时间相关查询并注入时间上下文。\n" +
|
|
1035
|
+
"返回搜索结果正文和 session_id(用于 grok_sources 获取信源)。\n" +
|
|
1036
|
+
"适用:查找技术文档、API 规范、开源项目、pi Extension 开发指南等。",
|
|
1037
|
+
promptSnippet:
|
|
1038
|
+
"通过 Grok API 执行 AI 深度网络搜索(文档、API、开源项目等)",
|
|
1039
|
+
promptGuidelines: [
|
|
1040
|
+
// === Search Trigger Conditions ===
|
|
1041
|
+
"Use grok_search when the user asks to search the web, find documentation, or look up technical information.",
|
|
1042
|
+
"Use grok_search to find pi Extension development docs, API references, and best practices.",
|
|
1043
|
+
"Strictly distinguish internal vs external knowledge. Even if you possess common-sense knowledge about a topic (e.g., a library like FastAPI), you MUST still use grok_search to verify with latest search results or official documentation.",
|
|
1044
|
+
"When uncertain about facts, explicitly inform the user of limitations rather than speculating from internal knowledge.",
|
|
1045
|
+
// === Search Execution ===
|
|
1046
|
+
"Search queries to grok_search MUST be in English for maximum coverage. Final user-facing output MUST be in Chinese.",
|
|
1047
|
+
"Execute independent grok_search calls in PARALLEL. Sequential execution only when one search depends on another's results.",
|
|
1048
|
+
"Prioritize authoritative sources: official docs, Wikipedia, academic databases, GitHub repos, reputable media.",
|
|
1049
|
+
// === Source Quality ===
|
|
1050
|
+
"Key factual claims MUST be supported by ≥2 independent sources. Single-source claims: explicitly state this limitation.",
|
|
1051
|
+
"Conflicting sources: Present evidence from both sides, assess credibility/timeliness, identify stronger evidence, or declare unresolved discrepancies.",
|
|
1052
|
+
"Empirical conclusions MUST include confidence levels (High/Medium/Low).",
|
|
1053
|
+
// === Post-Search Behavior ===
|
|
1054
|
+
"After grok_search, call grok_sources with the returned session_id to retrieve source URLs if needed.",
|
|
1055
|
+
"After grok_search returns results, use web_fetch to get full content from interesting URLs.",
|
|
1056
|
+
// === Output Standards ===
|
|
1057
|
+
"All conclusions MUST specify: applicable conditions, scope boundaries, and known limitations.",
|
|
1058
|
+
"When uncertain: state unknowns and reasons BEFORE presenting confirmed facts.",
|
|
1059
|
+
"Challenge flawed premises: when user logic contains errors, pinpoint specific issues with evidence.",
|
|
1060
|
+
],
|
|
1061
|
+
parameters: Type.Object({
|
|
1062
|
+
query: Type.String({
|
|
1063
|
+
description: "搜索查询,清晰、自包含的自然语言问题",
|
|
1064
|
+
}),
|
|
1065
|
+
platform: Type.Optional(
|
|
1066
|
+
Type.String({
|
|
1067
|
+
description:
|
|
1068
|
+
'聚焦平台,如 "Twitter", "GitHub, Reddit"。留空为全网搜索。',
|
|
1069
|
+
}),
|
|
1070
|
+
),
|
|
1071
|
+
extra_sources: Type.Optional(
|
|
1072
|
+
Type.Number({
|
|
1073
|
+
description:
|
|
1074
|
+
"额外补充信源数量(Tavily/Firecrawl),0 为关闭。默认 0。",
|
|
1075
|
+
}),
|
|
1076
|
+
),
|
|
1077
|
+
}),
|
|
1078
|
+
|
|
1079
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
1080
|
+
onUpdate?.({ content: [{ type: "text", text: "🔍 正在搜索..." }] });
|
|
1081
|
+
|
|
1082
|
+
try {
|
|
1083
|
+
const config = await configManager.getFullConfig();
|
|
1084
|
+
const sessionId = newSessionId();
|
|
1085
|
+
|
|
1086
|
+
// Parallel: Grok search + optional Tavily/Firecrawl
|
|
1087
|
+
const hasTavily = !!config.tavilyApiKey;
|
|
1088
|
+
const hasFirecrawl = !!config.firecrawlApiKey;
|
|
1089
|
+
const extraCount = params.extra_sources || 0;
|
|
1090
|
+
|
|
1091
|
+
const tasks: Promise<unknown>[] = [
|
|
1092
|
+
grokSearch(params.query, params.platform || "", signal),
|
|
1093
|
+
];
|
|
1094
|
+
|
|
1095
|
+
if (extraCount > 0 && hasTavily) {
|
|
1096
|
+
tasks.push(tavilySearch(params.query, extraCount, signal));
|
|
1097
|
+
}
|
|
1098
|
+
if (extraCount > 0 && hasFirecrawl) {
|
|
1099
|
+
tasks.push(
|
|
1100
|
+
firecrawlSearch(params.query, Math.round(extraCount * 0.7), signal),
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const results = await Promise.allSettled(tasks);
|
|
1105
|
+
|
|
1106
|
+
const grokResult =
|
|
1107
|
+
results[0].status === "fulfilled" ? (results[0].value as string) : "";
|
|
1108
|
+
const tavilySources =
|
|
1109
|
+
extraCount > 0 && hasTavily && results[1]?.status === "fulfilled"
|
|
1110
|
+
? (results[1].value as Source[])
|
|
1111
|
+
: [];
|
|
1112
|
+
const firecrawlSources =
|
|
1113
|
+
extraCount > 0 &&
|
|
1114
|
+
hasFirecrawl &&
|
|
1115
|
+
results[results.length - 1]?.status === "fulfilled"
|
|
1116
|
+
? (results[results.length - 1].value as Source[])
|
|
1117
|
+
: [];
|
|
1118
|
+
|
|
1119
|
+
// Parse Grok response
|
|
1120
|
+
const { answer, sources: grokSources } =
|
|
1121
|
+
splitAnswerAndSources(grokResult);
|
|
1122
|
+
const allSources = mergeSources(
|
|
1123
|
+
grokSources,
|
|
1124
|
+
tavilySources,
|
|
1125
|
+
firecrawlSources,
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
// Cache sources
|
|
1129
|
+
sourcesCache.set(sessionId, allSources);
|
|
1130
|
+
|
|
1131
|
+
// Build output
|
|
1132
|
+
let output = answer;
|
|
1133
|
+
if (allSources.length > 0) {
|
|
1134
|
+
output += `\n\n---\n**信源 (${allSources.length})** | session_id: \`${sessionId}\`\n`;
|
|
1135
|
+
for (const s of allSources.slice(0, 10)) {
|
|
1136
|
+
output += s.title ? `- [${s.title}](${s.url})\n` : `- ${s.url}\n`;
|
|
1137
|
+
}
|
|
1138
|
+
if (allSources.length > 10) {
|
|
1139
|
+
output += `- ... 还有 ${allSources.length - 10} 个信源,使用 grok_sources 获取\n`;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
content: [{ type: "text", text: output }],
|
|
1145
|
+
details: {
|
|
1146
|
+
session_id: sessionId,
|
|
1147
|
+
content: answer,
|
|
1148
|
+
sources_count: allSources.length,
|
|
1149
|
+
sources: allSources,
|
|
1150
|
+
},
|
|
1151
|
+
};
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
`搜索失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// =========================================================================
|
|
1161
|
+
// Tool: grok_sources — 获取缓存信源
|
|
1162
|
+
// =========================================================================
|
|
1163
|
+
pi.registerTool({
|
|
1164
|
+
name: "grok_sources",
|
|
1165
|
+
label: "Grok Sources",
|
|
1166
|
+
description:
|
|
1167
|
+
"通过 session_id 获取之前 grok_search 缓存的完整信源列表。\n" +
|
|
1168
|
+
"当对搜索结果感兴趣或需要更多参考链接时使用。",
|
|
1169
|
+
promptSnippet: "通过 session_id 获取搜索信源列表",
|
|
1170
|
+
promptGuidelines: [
|
|
1171
|
+
"Use grok_sources with the session_id from grok_search to retrieve the full source list when you need more reference URLs.",
|
|
1172
|
+
],
|
|
1173
|
+
parameters: Type.Object({
|
|
1174
|
+
session_id: Type.String({ description: "grok_search 返回的 session_id" }),
|
|
1175
|
+
}),
|
|
1176
|
+
|
|
1177
|
+
async execute(_toolCallId, params) {
|
|
1178
|
+
const sources = sourcesCache.get(params.session_id);
|
|
1179
|
+
if (!sources) {
|
|
1180
|
+
return {
|
|
1181
|
+
content: [
|
|
1182
|
+
{
|
|
1183
|
+
type: "text",
|
|
1184
|
+
text: `未找到 session_id: ${params.session_id} 的信源缓存(可能已过期)`,
|
|
1185
|
+
},
|
|
1186
|
+
],
|
|
1187
|
+
details: {
|
|
1188
|
+
session_id: params.session_id,
|
|
1189
|
+
sources: [],
|
|
1190
|
+
sources_count: 0,
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
let output = `## 信源列表 (${sources.length})\n\n`;
|
|
1196
|
+
for (const s of sources) {
|
|
1197
|
+
if (s.title) {
|
|
1198
|
+
output += `- **[${s.title}](${s.url})**`;
|
|
1199
|
+
} else {
|
|
1200
|
+
output += `- ${s.url}`;
|
|
1201
|
+
}
|
|
1202
|
+
if (s.description) output += ` — ${s.description.slice(0, 100)}`;
|
|
1203
|
+
if (s.provider) output += ` [${s.provider}]`;
|
|
1204
|
+
output += "\n";
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return {
|
|
1208
|
+
content: [{ type: "text", text: output }],
|
|
1209
|
+
details: {
|
|
1210
|
+
session_id: params.session_id,
|
|
1211
|
+
sources,
|
|
1212
|
+
sources_count: sources.length,
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
// =========================================================================
|
|
1219
|
+
// Tool: web_fetch — 网页内容抓取(Tavily → Firecrawl 自动降级)
|
|
1220
|
+
// =========================================================================
|
|
1221
|
+
pi.registerTool({
|
|
1222
|
+
name: "web_fetch",
|
|
1223
|
+
label: "Web Fetch",
|
|
1224
|
+
description:
|
|
1225
|
+
"抓取并提取指定 URL 的完整网页内容,返回 Markdown 格式。\n" +
|
|
1226
|
+
"优先使用 Tavily Extract,失败时自动降级到 Firecrawl Scrape。\n" +
|
|
1227
|
+
"100% 内容保真,不做摘要或修改。",
|
|
1228
|
+
promptSnippet: "抓取网页完整内容(Tavily → Firecrawl 自动降级)",
|
|
1229
|
+
promptGuidelines: [
|
|
1230
|
+
"Use web_fetch to get the full content of a specific webpage URL.",
|
|
1231
|
+
"Use web_fetch after grok_search to read detailed content from search result URLs.",
|
|
1232
|
+
],
|
|
1233
|
+
parameters: Type.Object({
|
|
1234
|
+
url: Type.String({ description: "要抓取的网页 URL(HTTP/HTTPS)" }),
|
|
1235
|
+
}),
|
|
1236
|
+
|
|
1237
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
1238
|
+
onUpdate?.({ content: [{ type: "text", text: "📄 正在抓取网页..." }] });
|
|
1239
|
+
|
|
1240
|
+
const config = await configManager.getFullConfig();
|
|
1241
|
+
|
|
1242
|
+
// Try Tavily first
|
|
1243
|
+
if (config.tavilyApiKey) {
|
|
1244
|
+
const result = await tavilyExtract(params.url, signal);
|
|
1245
|
+
if (result) {
|
|
1246
|
+
return {
|
|
1247
|
+
content: [{ type: "text", text: result }],
|
|
1248
|
+
details: { url: params.url, provider: "tavily" },
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Fallback to Firecrawl
|
|
1254
|
+
if (config.firecrawlApiKey) {
|
|
1255
|
+
onUpdate?.({
|
|
1256
|
+
content: [
|
|
1257
|
+
{ type: "text", text: "📄 Tavily 失败,尝试 Firecrawl..." },
|
|
1258
|
+
],
|
|
1259
|
+
});
|
|
1260
|
+
const result = await firecrawlScrape(params.url, signal);
|
|
1261
|
+
if (result) {
|
|
1262
|
+
return {
|
|
1263
|
+
content: [{ type: "text", text: result }],
|
|
1264
|
+
details: { url: params.url, provider: "firecrawl" },
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Both failed or not configured
|
|
1270
|
+
if (!config.tavilyApiKey && !config.firecrawlApiKey) {
|
|
1271
|
+
return {
|
|
1272
|
+
content: [
|
|
1273
|
+
{
|
|
1274
|
+
type: "text",
|
|
1275
|
+
text: "配置错误: TAVILY_API_KEY 和 FIRECRAWL_API_KEY 均未配置。\n请使用 /grok-config 设置至少一个。",
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
details: { url: params.url, error: "not_configured" },
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
content: [
|
|
1284
|
+
{
|
|
1285
|
+
type: "text",
|
|
1286
|
+
text: "提取失败: 所有提取服务均未能获取内容。可尝试用 grok_search 搜索相关内容。",
|
|
1287
|
+
},
|
|
1288
|
+
],
|
|
1289
|
+
details: { url: params.url, error: "all_failed" },
|
|
1290
|
+
};
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// =========================================================================
|
|
1295
|
+
// Tool: web_map — 站点结构映射
|
|
1296
|
+
// =========================================================================
|
|
1297
|
+
pi.registerTool({
|
|
1298
|
+
name: "web_map",
|
|
1299
|
+
label: "Web Map",
|
|
1300
|
+
description:
|
|
1301
|
+
"通过 Tavily Map API 遍历网站结构,发现 URL 并生成站点地图。\n" +
|
|
1302
|
+
"从根 URL 开始图遍历,支持深度/广度控制和自然语言过滤。",
|
|
1303
|
+
promptSnippet: "遍历网站结构,发现 URL 生成站点地图",
|
|
1304
|
+
promptGuidelines: [
|
|
1305
|
+
"Use web_map to discover a website's structure and find specific pages.",
|
|
1306
|
+
"Start with low max_depth (1-2) for initial exploration.",
|
|
1307
|
+
],
|
|
1308
|
+
parameters: Type.Object({
|
|
1309
|
+
url: Type.String({
|
|
1310
|
+
description: "起始 URL(如 'https://docs.example.com')",
|
|
1311
|
+
}),
|
|
1312
|
+
instructions: Type.Optional(
|
|
1313
|
+
Type.String({
|
|
1314
|
+
description: "自然语言过滤指令(如 'only documentation pages')",
|
|
1315
|
+
}),
|
|
1316
|
+
),
|
|
1317
|
+
max_depth: Type.Optional(
|
|
1318
|
+
Type.Number({ description: "最大遍历深度(1-5),默认 1" }),
|
|
1319
|
+
),
|
|
1320
|
+
max_breadth: Type.Optional(
|
|
1321
|
+
Type.Number({ description: "每页最大跟踪链接数(1-500),默认 20" }),
|
|
1322
|
+
),
|
|
1323
|
+
limit: Type.Optional(
|
|
1324
|
+
Type.Number({ description: "总链接处理上限(1-500),默认 50" }),
|
|
1325
|
+
),
|
|
1326
|
+
timeout: Type.Optional(
|
|
1327
|
+
Type.Number({ description: "超时秒数(10-150),默认 150" }),
|
|
1328
|
+
),
|
|
1329
|
+
}),
|
|
1330
|
+
|
|
1331
|
+
async execute(_toolCallId, params, signal) {
|
|
1332
|
+
const result = await tavilyMap(
|
|
1333
|
+
params.url,
|
|
1334
|
+
{
|
|
1335
|
+
instructions: params.instructions,
|
|
1336
|
+
maxDepth: params.max_depth,
|
|
1337
|
+
maxBreadth: params.max_breadth,
|
|
1338
|
+
limit: params.limit,
|
|
1339
|
+
timeout: params.timeout,
|
|
1340
|
+
},
|
|
1341
|
+
signal,
|
|
1342
|
+
);
|
|
1343
|
+
return {
|
|
1344
|
+
content: [{ type: "text", text: result }],
|
|
1345
|
+
details: { url: params.url },
|
|
1346
|
+
};
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// =========================================================================
|
|
1351
|
+
// Tool: grok_config — 配置管理
|
|
1352
|
+
// =========================================================================
|
|
1353
|
+
pi.registerTool({
|
|
1354
|
+
name: "grok_config",
|
|
1355
|
+
label: "Grok Config",
|
|
1356
|
+
description:
|
|
1357
|
+
"查看或修改 Grok Search 的完整配置(Grok/Tavily/Firecrawl API)。",
|
|
1358
|
+
promptSnippet: "查看或修改 Grok Search 配置",
|
|
1359
|
+
parameters: Type.Object({
|
|
1360
|
+
action: StringEnum(["show", "set", "test"] as const),
|
|
1361
|
+
key: Type.Optional(
|
|
1362
|
+
StringEnum([
|
|
1363
|
+
"grokApiUrl",
|
|
1364
|
+
"grokApiKey",
|
|
1365
|
+
"model",
|
|
1366
|
+
"tavilyApiKey",
|
|
1367
|
+
"tavilyApiUrl",
|
|
1368
|
+
"firecrawlApiKey",
|
|
1369
|
+
"firecrawlApiUrl",
|
|
1370
|
+
] as const),
|
|
1371
|
+
),
|
|
1372
|
+
value: Type.Optional(Type.String()),
|
|
1373
|
+
}),
|
|
1374
|
+
|
|
1375
|
+
async execute(_toolCallId, params) {
|
|
1376
|
+
const config = await configManager.getFullConfig();
|
|
1377
|
+
|
|
1378
|
+
if (params.action === "show") {
|
|
1379
|
+
const lines = [
|
|
1380
|
+
"## Grok Search 配置\n",
|
|
1381
|
+
"| 配置项 | 值 |",
|
|
1382
|
+
"|--------|-----|",
|
|
1383
|
+
`| Grok API URL | ${config.grokApiUrl || "❌ 未配置"} |`,
|
|
1384
|
+
`| Grok API Key | ${config.grokApiKey ? configManager.maskKey(config.grokApiKey) : "❌ 未配置"} |`,
|
|
1385
|
+
`| Grok 模型 | ${config.grokModel} |`,
|
|
1386
|
+
`| Tavily API URL | ${config.tavilyApiUrl} |`,
|
|
1387
|
+
`| Tavily API Key | ${config.tavilyApiKey ? configManager.maskKey(config.tavilyApiKey) : "❌ 未配置"} |`,
|
|
1388
|
+
`| Firecrawl API URL | ${config.firecrawlApiUrl} |`,
|
|
1389
|
+
`| Firecrawl API Key | ${config.firecrawlApiKey ? configManager.maskKey(config.firecrawlApiKey) : "❌ 未配置"} |`,
|
|
1390
|
+
`| 配置文件 | ${configManager.getConfigPath()} |`,
|
|
1391
|
+
];
|
|
1392
|
+
|
|
1393
|
+
return {
|
|
1394
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1395
|
+
details: config,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (params.action === "test") {
|
|
1400
|
+
const results: string[] = ["## 连接测试\n"];
|
|
1401
|
+
|
|
1402
|
+
// Test Grok
|
|
1403
|
+
if (config.grokApiUrl && config.grokApiKey) {
|
|
1404
|
+
try {
|
|
1405
|
+
const start = Date.now();
|
|
1406
|
+
const response = await fetch(
|
|
1407
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/models`,
|
|
1408
|
+
{
|
|
1409
|
+
headers: { Authorization: `Bearer ${config.grokApiKey}` },
|
|
1410
|
+
signal: AbortSignal.timeout(10000),
|
|
1411
|
+
},
|
|
1412
|
+
);
|
|
1413
|
+
const elapsed = Date.now() - start;
|
|
1414
|
+
if (response.ok) {
|
|
1415
|
+
const data = (await response.json()) as {
|
|
1416
|
+
data?: Array<{ id: string }>;
|
|
1417
|
+
};
|
|
1418
|
+
const models = (data.data || []).map((m) => m.id);
|
|
1419
|
+
configManager.setCachedModels(
|
|
1420
|
+
config.grokApiUrl,
|
|
1421
|
+
config.grokApiKey,
|
|
1422
|
+
models,
|
|
1423
|
+
);
|
|
1424
|
+
results.push(
|
|
1425
|
+
`✅ **Grok API**: 连接成功 (${elapsed}ms),${models.length} 个模型`,
|
|
1426
|
+
);
|
|
1427
|
+
if (models.length > 0) {
|
|
1428
|
+
results.push(
|
|
1429
|
+
` 模型: ${models.slice(0, 10).join(", ")}${models.length > 10 ? "..." : ""}`,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
results.push(`⚠️ **Grok API**: HTTP ${response.status}`);
|
|
1434
|
+
}
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
results.push(
|
|
1437
|
+
`❌ **Grok API**: ${e instanceof Error ? e.message : String(e)}`,
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
} else {
|
|
1441
|
+
results.push("⏭️ **Grok API**: 未配置");
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Test Tavily
|
|
1445
|
+
if (config.tavilyApiKey) {
|
|
1446
|
+
try {
|
|
1447
|
+
const response = await fetch(
|
|
1448
|
+
`${config.tavilyApiUrl.replace(/\/+$/, "")}/search`,
|
|
1449
|
+
{
|
|
1450
|
+
method: "POST",
|
|
1451
|
+
headers: {
|
|
1452
|
+
Authorization: `Bearer ${config.tavilyApiKey}`,
|
|
1453
|
+
"Content-Type": "application/json",
|
|
1454
|
+
},
|
|
1455
|
+
body: JSON.stringify({ query: "test", max_results: 1 }),
|
|
1456
|
+
signal: AbortSignal.timeout(10000),
|
|
1457
|
+
},
|
|
1458
|
+
);
|
|
1459
|
+
results.push(
|
|
1460
|
+
response.ok
|
|
1461
|
+
? "✅ **Tavily API**: 连接成功"
|
|
1462
|
+
: `⚠️ **Tavily API**: HTTP ${response.status}`,
|
|
1463
|
+
);
|
|
1464
|
+
} catch {
|
|
1465
|
+
results.push("❌ **Tavily API**: 连接失败");
|
|
1466
|
+
}
|
|
1467
|
+
} else {
|
|
1468
|
+
results.push("⏭️ **Tavily API**: 未配置");
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Test Firecrawl
|
|
1472
|
+
if (config.firecrawlApiKey) {
|
|
1473
|
+
try {
|
|
1474
|
+
const response = await fetch(
|
|
1475
|
+
`${config.firecrawlApiUrl.replace(/\/+$/, "")}/scrape`,
|
|
1476
|
+
{
|
|
1477
|
+
method: "POST",
|
|
1478
|
+
headers: {
|
|
1479
|
+
Authorization: `Bearer ${config.firecrawlApiKey}`,
|
|
1480
|
+
"Content-Type": "application/json",
|
|
1481
|
+
},
|
|
1482
|
+
body: JSON.stringify({
|
|
1483
|
+
url: "https://example.com",
|
|
1484
|
+
formats: ["markdown"],
|
|
1485
|
+
}),
|
|
1486
|
+
signal: AbortSignal.timeout(15000),
|
|
1487
|
+
},
|
|
1488
|
+
);
|
|
1489
|
+
results.push(
|
|
1490
|
+
response.ok
|
|
1491
|
+
? "✅ **Firecrawl API**: 连接成功"
|
|
1492
|
+
: `⚠️ **Firecrawl API**: HTTP ${response.status}`,
|
|
1493
|
+
);
|
|
1494
|
+
} catch {
|
|
1495
|
+
results.push("❌ **Firecrawl API**: 连接失败");
|
|
1496
|
+
}
|
|
1497
|
+
} else {
|
|
1498
|
+
results.push("⏭️ **Firecrawl API**: 未配置");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
return {
|
|
1502
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
1503
|
+
details: { tested: true },
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (params.action === "set") {
|
|
1508
|
+
if (!params.key || !params.value) {
|
|
1509
|
+
throw new Error("action=set 时 key 和 value 为必填项");
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const displayValue = params.key.toLowerCase().includes("key")
|
|
1513
|
+
? configManager.maskKey(params.value)
|
|
1514
|
+
: params.value;
|
|
1515
|
+
|
|
1516
|
+
switch (params.key) {
|
|
1517
|
+
case "grokApiUrl": {
|
|
1518
|
+
const file = await configManager.loadFile();
|
|
1519
|
+
await configManager.setGrokApi(
|
|
1520
|
+
params.value,
|
|
1521
|
+
file.apiKey || config.grokApiKey,
|
|
1522
|
+
);
|
|
1523
|
+
break;
|
|
1524
|
+
}
|
|
1525
|
+
case "grokApiKey": {
|
|
1526
|
+
const file = await configManager.loadFile();
|
|
1527
|
+
await configManager.setGrokApi(
|
|
1528
|
+
file.apiUrl || config.grokApiUrl,
|
|
1529
|
+
params.value,
|
|
1530
|
+
);
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
case "model":
|
|
1534
|
+
await configManager.setModel(params.value);
|
|
1535
|
+
break;
|
|
1536
|
+
case "tavilyApiKey":
|
|
1537
|
+
await configManager.setTavily(params.value);
|
|
1538
|
+
break;
|
|
1539
|
+
case "tavilyApiUrl":
|
|
1540
|
+
await configManager.setTavily(config.tavilyApiKey, params.value);
|
|
1541
|
+
break;
|
|
1542
|
+
case "firecrawlApiKey":
|
|
1543
|
+
await configManager.setFirecrawl(params.value);
|
|
1544
|
+
break;
|
|
1545
|
+
case "firecrawlApiUrl":
|
|
1546
|
+
await configManager.setFirecrawl(
|
|
1547
|
+
config.firecrawlApiKey,
|
|
1548
|
+
params.value,
|
|
1549
|
+
);
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
content: [
|
|
1555
|
+
{ type: "text", text: `✅ 已更新 ${params.key} = ${displayValue}` },
|
|
1556
|
+
],
|
|
1557
|
+
details: { key: params.key, updated: true },
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
throw new Error(`未知 action: ${params.action}`);
|
|
1562
|
+
},
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
// =========================================================================
|
|
1566
|
+
// Tool: search_planning — 搜索规划(6 阶段)
|
|
1567
|
+
// =========================================================================
|
|
1568
|
+
pi.registerTool({
|
|
1569
|
+
name: "search_planning",
|
|
1570
|
+
label: "Search Planning",
|
|
1571
|
+
description:
|
|
1572
|
+
"结构化搜索规划工具。在执行复杂搜索前先生成可执行的搜索计划。\n" +
|
|
1573
|
+
"流程: plan_intent → plan_complexity → plan_sub_query(×N) → plan_search_term(×N) → plan_tool_mapping(×N) → plan_execution\n" +
|
|
1574
|
+
"复杂度 Level 1 = 阶段 1-3; Level 2 = 阶段 1-5; Level 3 = 全部 6 阶段。",
|
|
1575
|
+
promptSnippet: "结构化搜索规划(分阶段、多轮)",
|
|
1576
|
+
promptGuidelines: [
|
|
1577
|
+
"Use search_planning before executing complex, multi-faceted searches to create a structured plan.",
|
|
1578
|
+
],
|
|
1579
|
+
parameters: Type.Object({
|
|
1580
|
+
phase: StringEnum([
|
|
1581
|
+
"intent_analysis",
|
|
1582
|
+
"complexity_assessment",
|
|
1583
|
+
"query_decomposition",
|
|
1584
|
+
"search_strategy",
|
|
1585
|
+
"tool_selection",
|
|
1586
|
+
"execution_order",
|
|
1587
|
+
] as const),
|
|
1588
|
+
thought: Type.String({ description: "本阶段的推理过程" }),
|
|
1589
|
+
session_id: Type.Optional(
|
|
1590
|
+
Type.String({ description: "留空创建新会话,或传入已有 ID" }),
|
|
1591
|
+
),
|
|
1592
|
+
is_revision: Type.Optional(
|
|
1593
|
+
Type.Boolean({ description: "是否覆盖已有阶段" }),
|
|
1594
|
+
),
|
|
1595
|
+
confidence: Type.Optional(Type.Number({ description: "置信度 0.0-1.0" })),
|
|
1596
|
+
phase_data: Type.String({ description: "阶段数据,JSON 字符串格式" }),
|
|
1597
|
+
}),
|
|
1598
|
+
|
|
1599
|
+
async execute(_toolCallId, params) {
|
|
1600
|
+
let phaseData: unknown;
|
|
1601
|
+
try {
|
|
1602
|
+
phaseData = JSON.parse(params.phase_data);
|
|
1603
|
+
} catch {
|
|
1604
|
+
phaseData = params.phase_data;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const result = planningEngine.processPhase({
|
|
1608
|
+
phase: params.phase,
|
|
1609
|
+
thought: params.thought,
|
|
1610
|
+
sessionId: params.session_id,
|
|
1611
|
+
isRevision: params.is_revision,
|
|
1612
|
+
confidence: params.confidence,
|
|
1613
|
+
phaseData,
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
return {
|
|
1617
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1618
|
+
details: result,
|
|
1619
|
+
};
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
// =========================================================================
|
|
1624
|
+
// Command: /grok-search
|
|
1625
|
+
// =========================================================================
|
|
1626
|
+
pi.registerCommand("grok-search", {
|
|
1627
|
+
description: "使用 Grok 搜索网络(/grok-search <query>)",
|
|
1628
|
+
handler: async (args, ctx) => {
|
|
1629
|
+
if (!args.trim()) {
|
|
1630
|
+
ctx.ui.notify("用法: /grok-search <搜索内容>", "warning");
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
ctx.ui.setStatus("grok", "🔍 搜索中...");
|
|
1635
|
+
|
|
1636
|
+
try {
|
|
1637
|
+
const raw = await grokSearch(args.trim());
|
|
1638
|
+
const { answer, sources } = splitAnswerAndSources(raw);
|
|
1639
|
+
|
|
1640
|
+
let output = answer;
|
|
1641
|
+
if (sources.length > 0) {
|
|
1642
|
+
const sessionId = newSessionId();
|
|
1643
|
+
sourcesCache.set(sessionId, sources);
|
|
1644
|
+
output += `\n\n---\n**信源 (${sources.length})** | session_id: \`${sessionId}\`\n`;
|
|
1645
|
+
for (const s of sources.slice(0, 10)) {
|
|
1646
|
+
output += s.title ? `- [${s.title}](${s.url})\n` : `- ${s.url}\n`;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
pi.sendMessage(
|
|
1651
|
+
{
|
|
1652
|
+
customType: "grok-search",
|
|
1653
|
+
content: output,
|
|
1654
|
+
display: true,
|
|
1655
|
+
details: { sources },
|
|
1656
|
+
},
|
|
1657
|
+
{ triggerTurn: true },
|
|
1658
|
+
);
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
ctx.ui.notify(
|
|
1661
|
+
`搜索失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
1662
|
+
"error",
|
|
1663
|
+
);
|
|
1664
|
+
} finally {
|
|
1665
|
+
ctx.ui.setStatus("grok", undefined);
|
|
1666
|
+
}
|
|
1667
|
+
},
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
// =========================================================================
|
|
1671
|
+
// Command: /grok-config
|
|
1672
|
+
// =========================================================================
|
|
1673
|
+
pi.registerCommand("grok-config", {
|
|
1674
|
+
description: "配置 Grok Search(Grok / Tavily / Firecrawl API)",
|
|
1675
|
+
handler: async (_args, ctx) => {
|
|
1676
|
+
const choice = await ctx.ui.select("Grok Search 配置:", [
|
|
1677
|
+
"查看当前配置",
|
|
1678
|
+
"设置 Grok API",
|
|
1679
|
+
"设置 Tavily API",
|
|
1680
|
+
"设置 Firecrawl API",
|
|
1681
|
+
"切换模型",
|
|
1682
|
+
"测试所有连接",
|
|
1683
|
+
]);
|
|
1684
|
+
|
|
1685
|
+
if (!choice) return;
|
|
1686
|
+
|
|
1687
|
+
switch (choice) {
|
|
1688
|
+
case "查看当前配置": {
|
|
1689
|
+
const config = await configManager.getFullConfig();
|
|
1690
|
+
const lines = [
|
|
1691
|
+
`Grok: ${config.grokApiUrl || "未配置"} | ${config.grokApiKey ? configManager.maskKey(config.grokApiKey) : "未配置"}`,
|
|
1692
|
+
`模型: ${config.grokModel}`,
|
|
1693
|
+
`Tavily: ${config.tavilyApiKey ? configManager.maskKey(config.tavilyApiKey) : "未配置"}`,
|
|
1694
|
+
`Firecrawl: ${config.firecrawlApiKey ? configManager.maskKey(config.firecrawlApiKey) : "未配置"}`,
|
|
1695
|
+
];
|
|
1696
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
case "设置 Grok API": {
|
|
1701
|
+
const url = await ctx.ui.input(
|
|
1702
|
+
"Grok API URL:",
|
|
1703
|
+
"https://api.x.ai/v1",
|
|
1704
|
+
);
|
|
1705
|
+
if (!url) return;
|
|
1706
|
+
const key = await ctx.ui.input("Grok API Key:", "");
|
|
1707
|
+
if (!key) return;
|
|
1708
|
+
const file = await configManager.loadFile();
|
|
1709
|
+
await configManager.setGrokApi(url, key);
|
|
1710
|
+
ctx.ui.notify(`✅ Grok API 已配置`, "success");
|
|
1711
|
+
break;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
case "设置 Tavily API": {
|
|
1715
|
+
const key = await ctx.ui.input("Tavily API Key:", "");
|
|
1716
|
+
if (!key) return;
|
|
1717
|
+
await configManager.setTavily(key);
|
|
1718
|
+
ctx.ui.notify(`✅ Tavily API 已配置`, "success");
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
case "设置 Firecrawl API": {
|
|
1723
|
+
const key = await ctx.ui.input("Firecrawl API Key:", "");
|
|
1724
|
+
if (!key) return;
|
|
1725
|
+
await configManager.setFirecrawl(key);
|
|
1726
|
+
ctx.ui.notify(`✅ Firecrawl API 已配置`, "success");
|
|
1727
|
+
break;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
case "切换模型": {
|
|
1731
|
+
const config = await configManager.getFullConfig();
|
|
1732
|
+
ctx.ui.notify("正在获取可用模型...", "info");
|
|
1733
|
+
|
|
1734
|
+
// Try cached first
|
|
1735
|
+
let models = configManager.getCachedModels(
|
|
1736
|
+
config.grokApiUrl,
|
|
1737
|
+
config.grokApiKey,
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
if (!models && config.grokApiUrl && config.grokApiKey) {
|
|
1741
|
+
try {
|
|
1742
|
+
const response = await fetch(
|
|
1743
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/models`,
|
|
1744
|
+
{
|
|
1745
|
+
headers: { Authorization: `Bearer ${config.grokApiKey}` },
|
|
1746
|
+
signal: AbortSignal.timeout(10000),
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
if (response.ok) {
|
|
1750
|
+
const data = (await response.json()) as {
|
|
1751
|
+
data?: Array<{ id: string }>;
|
|
1752
|
+
};
|
|
1753
|
+
models = (data.data || []).map((m) => m.id);
|
|
1754
|
+
configManager.setCachedModels(
|
|
1755
|
+
config.grokApiUrl,
|
|
1756
|
+
config.grokApiKey,
|
|
1757
|
+
models,
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
} catch {
|
|
1761
|
+
// ignore
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (models && models.length > 0) {
|
|
1766
|
+
const choice = await ctx.ui.select(
|
|
1767
|
+
`当前: ${config.grokModel}`,
|
|
1768
|
+
models,
|
|
1769
|
+
);
|
|
1770
|
+
if (choice) {
|
|
1771
|
+
await configManager.setModel(choice);
|
|
1772
|
+
ctx.ui.notify(`✅ 模型已切换: ${choice}`, "success");
|
|
1773
|
+
}
|
|
1774
|
+
} else {
|
|
1775
|
+
const model = await ctx.ui.input("输入模型 ID:", config.grokModel);
|
|
1776
|
+
if (model) {
|
|
1777
|
+
await configManager.setModel(model);
|
|
1778
|
+
ctx.ui.notify(`✅ 模型已切换: ${model}`, "success");
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
break;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
case "测试所有连接": {
|
|
1785
|
+
ctx.ui.notify("正在测试连接...", "info");
|
|
1786
|
+
const results: string[] = [];
|
|
1787
|
+
const config = await configManager.getFullConfig();
|
|
1788
|
+
|
|
1789
|
+
if (config.grokApiUrl && config.grokApiKey) {
|
|
1790
|
+
try {
|
|
1791
|
+
const start = Date.now();
|
|
1792
|
+
const response = await fetch(
|
|
1793
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/models`,
|
|
1794
|
+
{
|
|
1795
|
+
headers: { Authorization: `Bearer ${config.grokApiKey}` },
|
|
1796
|
+
signal: AbortSignal.timeout(10000),
|
|
1797
|
+
},
|
|
1798
|
+
);
|
|
1799
|
+
const elapsed = Date.now() - start;
|
|
1800
|
+
if (response.ok) {
|
|
1801
|
+
const data = (await response.json()) as {
|
|
1802
|
+
data?: Array<{ id: string }>;
|
|
1803
|
+
};
|
|
1804
|
+
const count = (data.data || []).length;
|
|
1805
|
+
results.push(`✅ Grok: ${elapsed}ms, ${count} 模型`);
|
|
1806
|
+
} else {
|
|
1807
|
+
results.push(`⚠️ Grok: HTTP ${response.status}`);
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
results.push("❌ Grok: 连接失败");
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
results.push("⏭️ Grok: 未配置");
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
results.push(
|
|
1817
|
+
config.tavilyApiKey ? "✅ Tavily: 已配置" : "⏭️ Tavily: 未配置",
|
|
1818
|
+
);
|
|
1819
|
+
results.push(
|
|
1820
|
+
config.firecrawlApiKey
|
|
1821
|
+
? "✅ Firecrawl: 已配置"
|
|
1822
|
+
: "⏭️ Firecrawl: 未配置",
|
|
1823
|
+
);
|
|
1824
|
+
|
|
1825
|
+
ctx.ui.notify(results.join("\n"), "info");
|
|
1826
|
+
break;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
},
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
// =========================================================================
|
|
1833
|
+
// Command: /grok-model
|
|
1834
|
+
// =========================================================================
|
|
1835
|
+
pi.registerCommand("grok-model", {
|
|
1836
|
+
description: "快速切换 Grok 模型(/grok-model [model-id])",
|
|
1837
|
+
handler: async (args, ctx) => {
|
|
1838
|
+
if (args.trim()) {
|
|
1839
|
+
await configManager.setModel(args.trim());
|
|
1840
|
+
ctx.ui.notify(`✅ 模型已切换: ${args.trim()}`, "success");
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const config = await configManager.getFullConfig();
|
|
1845
|
+
let models = configManager.getCachedModels(
|
|
1846
|
+
config.grokApiUrl,
|
|
1847
|
+
config.grokApiKey,
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
if (!models && config.grokApiUrl && config.grokApiKey) {
|
|
1851
|
+
ctx.ui.notify("正在获取可用模型...", "info");
|
|
1852
|
+
try {
|
|
1853
|
+
const response = await fetch(
|
|
1854
|
+
`${config.grokApiUrl.replace(/\/+$/, "")}/models`,
|
|
1855
|
+
{
|
|
1856
|
+
headers: { Authorization: `Bearer ${config.grokApiKey}` },
|
|
1857
|
+
signal: AbortSignal.timeout(10000),
|
|
1858
|
+
},
|
|
1859
|
+
);
|
|
1860
|
+
if (response.ok) {
|
|
1861
|
+
const data = (await response.json()) as {
|
|
1862
|
+
data?: Array<{ id: string }>;
|
|
1863
|
+
};
|
|
1864
|
+
models = (data.data || []).map((m) => m.id);
|
|
1865
|
+
configManager.setCachedModels(
|
|
1866
|
+
config.grokApiUrl,
|
|
1867
|
+
config.grokApiKey,
|
|
1868
|
+
models,
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
} catch {
|
|
1872
|
+
// ignore
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
if (models && models.length > 0) {
|
|
1877
|
+
const choice = await ctx.ui.select(`当前: ${config.grokModel}`, models);
|
|
1878
|
+
if (choice) {
|
|
1879
|
+
await configManager.setModel(choice);
|
|
1880
|
+
ctx.ui.notify(`✅ 模型已切换: ${choice}`, "success");
|
|
1881
|
+
}
|
|
1882
|
+
} else {
|
|
1883
|
+
const model = await ctx.ui.input("输入模型 ID:", config.grokModel);
|
|
1884
|
+
if (model) {
|
|
1885
|
+
await configManager.setModel(model);
|
|
1886
|
+
ctx.ui.notify(`✅ 模型已切换: ${model}`, "success");
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
},
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
// =========================================================================
|
|
1893
|
+
// Command: /pi-ext-docs
|
|
1894
|
+
// =========================================================================
|
|
1895
|
+
pi.registerCommand("pi-ext-docs", {
|
|
1896
|
+
description: "搜索 pi Extension 开发文档(/pi-ext-docs [topic])",
|
|
1897
|
+
handler: async (args, ctx) => {
|
|
1898
|
+
const topic =
|
|
1899
|
+
args.trim() || "pi Extension API registerTool registerCommand";
|
|
1900
|
+
ctx.ui.setStatus("grok", "📚 搜索 pi 文档...");
|
|
1901
|
+
|
|
1902
|
+
try {
|
|
1903
|
+
const raw = await grokSearch(
|
|
1904
|
+
`site:github.com earendil-works pi coding agent extensions ${topic}`,
|
|
1905
|
+
);
|
|
1906
|
+
const { answer, sources } = splitAnswerAndSources(raw);
|
|
1907
|
+
|
|
1908
|
+
let output = `## pi Extension 文档搜索: ${topic}\n\n${answer}`;
|
|
1909
|
+
if (sources.length > 0) {
|
|
1910
|
+
output += "\n\n### 相关链接\n";
|
|
1911
|
+
for (const s of sources.slice(0, 8)) {
|
|
1912
|
+
output += s.title ? `- [${s.title}](${s.url})\n` : `- ${s.url}\n`;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
pi.sendMessage(
|
|
1917
|
+
{
|
|
1918
|
+
customType: "grok-search",
|
|
1919
|
+
content: output,
|
|
1920
|
+
display: true,
|
|
1921
|
+
details: { sources },
|
|
1922
|
+
},
|
|
1923
|
+
{ triggerTurn: true },
|
|
1924
|
+
);
|
|
1925
|
+
} catch (e) {
|
|
1926
|
+
ctx.ui.notify(
|
|
1927
|
+
`搜索失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
1928
|
+
"error",
|
|
1929
|
+
);
|
|
1930
|
+
} finally {
|
|
1931
|
+
ctx.ui.setStatus("grok", undefined);
|
|
1932
|
+
}
|
|
1933
|
+
},
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// =========================================================================
|
|
1937
|
+
// Message Renderer
|
|
1938
|
+
// =========================================================================
|
|
1939
|
+
pi.registerMessageRenderer("grok-search", (message, options, theme) => {
|
|
1940
|
+
const { expanded } = options;
|
|
1941
|
+
let text = theme.fg("accent", "🔍 Grok Search\n\n");
|
|
1942
|
+
text += message.content;
|
|
1943
|
+
|
|
1944
|
+
if (expanded && message.details?.sources?.length) {
|
|
1945
|
+
text += "\n\n" + theme.fg("muted", "─── 信源 ───\n");
|
|
1946
|
+
for (const s of message.details.sources) {
|
|
1947
|
+
const label = s.title || s.url;
|
|
1948
|
+
const provider = s.provider ? ` [${s.provider}]` : "";
|
|
1949
|
+
text += theme.fg("dim", `• ${label}${provider}\n`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
return new Text(text, 0, 0);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
// =========================================================================
|
|
1957
|
+
// Session Start: Show status
|
|
1958
|
+
// =========================================================================
|
|
1959
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1960
|
+
const config = await configManager.getFullConfig();
|
|
1961
|
+
const services: string[] = [];
|
|
1962
|
+
if (config.grokApiUrl) services.push("Grok");
|
|
1963
|
+
if (config.tavilyApiKey) services.push("Tavily");
|
|
1964
|
+
if (config.firecrawlApiKey) services.push("Firecrawl");
|
|
1965
|
+
|
|
1966
|
+
if (services.length > 0) {
|
|
1967
|
+
ctx.ui.setStatus("grok", `${services.join("+")} | ${config.grokModel}`);
|
|
1968
|
+
} else {
|
|
1969
|
+
ctx.ui.setStatus("grok", "Grok: 未配置 (/grok-config)");
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
}
|