@trim21/personal-pi-extensions 0.0.334 → 0.0.336
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/aft/outline.md +1 -1
- package/src/aft/tools.ts +1 -1
- package/src/web/config.ts +31 -0
- package/src/web/fetch.ts +183 -0
- package/src/web/index.ts +174 -0
- package/src/web/search.ts +145 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.336",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
38
38
|
"@eslint/js": "10.0.1",
|
|
39
39
|
"@types/node": "^24.13.3",
|
|
40
|
+
"@types/turndown": "^5.0.6",
|
|
40
41
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
41
42
|
"@vitest/coverage-v8": "^4.1.10",
|
|
42
43
|
"eslint": "^10.8.1",
|
|
@@ -65,7 +66,8 @@
|
|
|
65
66
|
"src/gh-readonly.ts",
|
|
66
67
|
"src/spawn-agent.ts",
|
|
67
68
|
"src/system-prompt/index.ts",
|
|
68
|
-
"src/talk/index.ts"
|
|
69
|
+
"src/talk/index.ts",
|
|
70
|
+
"src/web/index.ts"
|
|
69
71
|
],
|
|
70
72
|
"skills": [
|
|
71
73
|
"src/talk/skills"
|
|
@@ -86,9 +88,12 @@
|
|
|
86
88
|
},
|
|
87
89
|
"dependencies": {
|
|
88
90
|
"@cortexkit/aft-bridge": "0.51.2",
|
|
91
|
+
"@mozilla/readability": "^0.6.0",
|
|
89
92
|
"@vscode/tree-sitter-wasm": "^0.3.1",
|
|
90
93
|
"jsonc-parser": "^3.3.1",
|
|
94
|
+
"linkedom": "^0.16.0",
|
|
91
95
|
"minimatch": "^10.2.6",
|
|
96
|
+
"turndown": "^7.2.0",
|
|
92
97
|
"vscode-jsonrpc": "^9.0.1",
|
|
93
98
|
"vscode-languageserver-types": "^3.18.0",
|
|
94
99
|
"web-tree-sitter": "^0.26.12"
|
package/src/aft/outline.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### aft_outline tool
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在读取具体内容之前先了解文件或目录的结构。
|
|
4
4
|
|
|
5
5
|
- target 为文件路径时:返回该文件的符号大纲,每个符号带签名与行号(如 `function greet(name: string): void 5:12`);Markdown/HTML 返回标题层级。适合先定位符号名和位置,再决定是否用 aft_zoom 看实现。
|
|
6
6
|
- target 为目录路径时:默认返回扁平文件树(语言、顶层符号数、字节大小),一眼看清目录里有什么、规模多大;传 `files: false` 可切换为符号大纲(目录下每个文件的符号树,无签名,输出上限 30KB,超限截断)。
|
package/src/aft/tools.ts
CHANGED
|
@@ -125,7 +125,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
125
125
|
label: "aft_outline",
|
|
126
126
|
description: [
|
|
127
127
|
"输出代码文件、目录的结构化大纲:函数/类/类型等符号及其行号范围;Markdown/HTML 返回标题层级。",
|
|
128
|
-
"
|
|
128
|
+
"用它在读取具体内容之前先了解文件结构。",
|
|
129
129
|
"深入了解某个符号用 aft_zoom;看跨文件调用关系用 aft_callgraph。",
|
|
130
130
|
"target 支持:文件路径(带签名的符号大纲)、目录路径(递归最多 200 文件)。只接受单个 target。",
|
|
131
131
|
"target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web 扩展的本地配置读取:Search1API key。
|
|
3
|
+
* 路径可注入,便于测试。
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { Value } from "typebox/value";
|
|
11
|
+
|
|
12
|
+
const webSearchSchema = Type.Object({
|
|
13
|
+
search1apiApiKey: Type.Optional(Type.String()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function webSearchConfigPath(): string {
|
|
17
|
+
return join(homedir(), ".pi", "web-search.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ~/.pi/web-search.json 的 search1apiApiKey,或 SEARCH1API_KEY 环境变量。 */
|
|
21
|
+
export async function loadSearch1ApiKey(path = webSearchConfigPath()): Promise<string | undefined> {
|
|
22
|
+
const envKey = process.env.SEARCH1API_KEY;
|
|
23
|
+
if (envKey) return envKey;
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(path, "utf8");
|
|
26
|
+
const parsed = Value.Parse(webSearchSchema, JSON.parse(raw));
|
|
27
|
+
return parsed.search1apiApiKey?.trim() || undefined;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/web/fetch.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web_fetch:抓取 URL 并提取正文为 markdown。
|
|
3
|
+
*
|
|
4
|
+
* SSRF 防护:DNS 预解析 + 拒绝私有/保留地址 + 每跳重定向重新校验,
|
|
5
|
+
* 防止把 agent 变成内网探测口。正文提取用 readability 主内容算法。
|
|
6
|
+
*/
|
|
7
|
+
import { lookup } from "node:dns/promises";
|
|
8
|
+
import { isIP } from "node:net";
|
|
9
|
+
|
|
10
|
+
import { Readability } from "@mozilla/readability";
|
|
11
|
+
import { parseHTML } from "linkedom";
|
|
12
|
+
import TurndownService from "turndown";
|
|
13
|
+
|
|
14
|
+
const MAX_REDIRECTS = 5;
|
|
15
|
+
const TIMEOUT_MS = 30_000;
|
|
16
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
const MIN_USEFUL_CONTENT = 200;
|
|
18
|
+
|
|
19
|
+
function isPrivateIpv4(ip: string): boolean {
|
|
20
|
+
const parts = ip.split(".").map(Number);
|
|
21
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false;
|
|
22
|
+
const [a, b, c] = parts;
|
|
23
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
24
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
25
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
26
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
27
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
28
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
29
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
30
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
|
|
31
|
+
if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24
|
|
32
|
+
return a >= 224; // 224.0.0.0/3 multicast + reserved
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPrivateIpv6(ip: string): boolean {
|
|
36
|
+
const lower = ip.toLowerCase();
|
|
37
|
+
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
|
|
38
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 ULA
|
|
39
|
+
if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
|
|
40
|
+
if (lower.startsWith("::ffff:")) return isPrivateIpv4(lower.slice(7));
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isPrivateAddress(ip: string): boolean {
|
|
45
|
+
const version = isIP(ip);
|
|
46
|
+
if (version === 4) return isPrivateIpv4(ip);
|
|
47
|
+
if (version === 6) return isPrivateIpv6(ip);
|
|
48
|
+
return true; // 非 IP 一律拒绝
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 解析 hostname 的全部地址,任一私有即拒绝,返回解析结果 */
|
|
52
|
+
export async function assertPublicHostname(
|
|
53
|
+
hostname: string,
|
|
54
|
+
lookupFn: (hostname: string) => Promise<{ address: string }[]> = (h) => lookup(h, { all: true }),
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
let addresses: { address: string }[];
|
|
57
|
+
try {
|
|
58
|
+
addresses = await lookupFn(hostname);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`域名解析失败 ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
|
|
62
|
+
{ cause: error },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const blocked = addresses.find(({ address }) => isPrivateAddress(address));
|
|
66
|
+
if (blocked) {
|
|
67
|
+
throw new Error(`拒绝访问内网地址 ${hostname} (${blocked.address})`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
72
|
+
const timeout = AbortSignal.timeout(ms);
|
|
73
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface FetchedPage {
|
|
77
|
+
url: string;
|
|
78
|
+
title: string;
|
|
79
|
+
markdown: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 手动跟随重定向,每跳重新做 SSRF 校验(防 DNS rebinding 简化处理) */
|
|
83
|
+
async function fetchWithRedirects(
|
|
84
|
+
url: URL,
|
|
85
|
+
signal: AbortSignal | undefined,
|
|
86
|
+
fetchFn: typeof fetch = fetch,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
let current = url;
|
|
89
|
+
for (let redirects = 0; ; redirects++) {
|
|
90
|
+
await assertPublicHostname(current.hostname);
|
|
91
|
+
const response = await fetchFn(current, {
|
|
92
|
+
redirect: "manual",
|
|
93
|
+
signal: withTimeout(signal, TIMEOUT_MS),
|
|
94
|
+
headers: { "user-agent": "Mozilla/5.0 (compatible; pi-web-fetch/1.0)" },
|
|
95
|
+
});
|
|
96
|
+
const location = response.headers.get("location");
|
|
97
|
+
if (location && response.status >= 300 && response.status < 400) {
|
|
98
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
99
|
+
throw new Error(`重定向次数超过上限 (${MAX_REDIRECTS})`);
|
|
100
|
+
}
|
|
101
|
+
current = new URL(location, current);
|
|
102
|
+
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
|
103
|
+
throw new Error(`不支持的协议: ${current.protocol}`);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function fetchPage(url: string, signal?: AbortSignal): Promise<FetchedPage> {
|
|
112
|
+
let target: URL;
|
|
113
|
+
try {
|
|
114
|
+
target = new URL(url);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`无效的 URL: ${url}`);
|
|
117
|
+
}
|
|
118
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
119
|
+
throw new Error(`只支持 http/https,收到: ${target.protocol}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const response = await fetchWithRedirects(target, signal);
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
127
|
+
if (!contentType.includes("text/html") && !contentType.includes("text/plain")) {
|
|
128
|
+
throw new Error(`不支持的内容类型: ${contentType || "unknown"}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
132
|
+
if (declaredLength > MAX_BYTES) {
|
|
133
|
+
throw new Error(`页面过大 (${declaredLength} bytes),上限 ${MAX_BYTES}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let html = "";
|
|
137
|
+
if (response.body) {
|
|
138
|
+
const reader = response.body.getReader();
|
|
139
|
+
const decoder = new TextDecoder();
|
|
140
|
+
for (;;) {
|
|
141
|
+
const chunk = (await reader.read()) as { done: boolean; value: Uint8Array };
|
|
142
|
+
if (chunk.done) break;
|
|
143
|
+
html += decoder.decode(chunk.value, { stream: true });
|
|
144
|
+
if (Buffer.byteLength(html, "utf8") > MAX_BYTES) {
|
|
145
|
+
throw new Error(`页面过大,上限 ${MAX_BYTES} bytes`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
html += decoder.decode();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return extractMarkdown(html, response.url);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 提取用到的 document 最小接口(linkedom 类型是 any,显式标注避免 unsafe) */
|
|
155
|
+
interface ParsedDocument {
|
|
156
|
+
title: string | null;
|
|
157
|
+
body: { textContent: string | null } | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 从 HTML 提取标题 + 正文 markdown(readability 主内容 → turndown) */
|
|
161
|
+
export function extractMarkdown(html: string, sourceUrl: string): FetchedPage {
|
|
162
|
+
const parsed = parseHTML(html) as { document: ParsedDocument };
|
|
163
|
+
const document = parsed.document;
|
|
164
|
+
// tsconfig 无 DOM lib;Readability 构造参数声明为 DOM Document,运行时只用到
|
|
165
|
+
// linkedom document 的兼容方法,cast 桥接即可
|
|
166
|
+
const article = new Readability(parsed.document).parse();
|
|
167
|
+
let title = article?.title ?? document.title?.trim() ?? sourceUrl;
|
|
168
|
+
if (typeof title !== "string" || title.length === 0) title = sourceUrl;
|
|
169
|
+
let body = article?.content;
|
|
170
|
+
if (!body || body.length === 0) {
|
|
171
|
+
body = document.body?.textContent ?? "";
|
|
172
|
+
}
|
|
173
|
+
if (typeof body !== "string" || body.trim().length < MIN_USEFUL_CONTENT) {
|
|
174
|
+
throw new Error("页面没有可提取的正文内容");
|
|
175
|
+
}
|
|
176
|
+
const markdown = new TurndownService({
|
|
177
|
+
headingStyle: "atx",
|
|
178
|
+
codeBlockStyle: "fenced",
|
|
179
|
+
})
|
|
180
|
+
.turndown(body)
|
|
181
|
+
.trim();
|
|
182
|
+
return { url: sourceUrl, title, markdown };
|
|
183
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web 扩展:自研 web_search + web_fetch,替代 pi-web-access。
|
|
3
|
+
*
|
|
4
|
+
* - web_search:Search1API 搜索(key 读 ~/.pi/web-search.json 的
|
|
5
|
+
* search1apiApiKey 或 SEARCH1API_KEY),直接返回整理后的结构化结果。
|
|
6
|
+
* - web_fetch:抓取 URL,SSRF 防护 + readability 提取正文为 markdown。
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
import { loadSearch1ApiKey } from "./config.js";
|
|
12
|
+
import { fetchPage } from "./fetch.js";
|
|
13
|
+
import { type SearchHit, searchWeb } from "./search.js";
|
|
14
|
+
|
|
15
|
+
const MAX_MARKDOWN_BYTES = 100 * 1024;
|
|
16
|
+
|
|
17
|
+
function truncateMarkdown(text: string): { text: string; truncated: boolean } {
|
|
18
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_MARKDOWN_BYTES) return { text, truncated: false };
|
|
19
|
+
const bytes = Buffer.from(text, "utf8");
|
|
20
|
+
const sliced = bytes.subarray(0, MAX_MARKDOWN_BYTES).toString("utf8");
|
|
21
|
+
const cut = sliced.lastIndexOf("\n", sliced.length - 1);
|
|
22
|
+
return { text: (cut > 0 ? sliced.slice(0, cut) : sliced) + "\n…(已截断)", truncated: true };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function webTools(pi: ExtensionAPI) {
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "web_search",
|
|
28
|
+
label: "Web Search",
|
|
29
|
+
description:
|
|
30
|
+
"Search the web via Search1API and return structured results " +
|
|
31
|
+
"(title/url/snippet, optionally inline content).",
|
|
32
|
+
promptSnippet: "Search the web",
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
query: Type.Optional(
|
|
35
|
+
Type.String({ description: "Search query (mutually exclusive with queries)" }),
|
|
36
|
+
),
|
|
37
|
+
queries: Type.Optional(
|
|
38
|
+
Type.Array(Type.String(), { description: "Multiple queries searched in sequence" }),
|
|
39
|
+
),
|
|
40
|
+
numResults: Type.Optional(
|
|
41
|
+
Type.Number({ description: "Results per query (default: 5, max: 50)" }),
|
|
42
|
+
),
|
|
43
|
+
recencyFilter: Type.Optional(
|
|
44
|
+
Type.String({ description: "Filter by recency: day, week, month, year" }),
|
|
45
|
+
),
|
|
46
|
+
domainFilter: Type.Optional(
|
|
47
|
+
Type.Array(Type.String(), { description: "Limit to domains; prefix with - to exclude" }),
|
|
48
|
+
),
|
|
49
|
+
searchService: Type.Optional(
|
|
50
|
+
Type.String({
|
|
51
|
+
description:
|
|
52
|
+
"Search engine: google, bing, duckduckgo, github, arxiv, reddit, youtube, etc.",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
includeContent: Type.Optional(
|
|
56
|
+
Type.Boolean({ description: "Inline-fetch content for the top results (up to 5)" }),
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
async execute(_id, params, signal, onUpdate) {
|
|
60
|
+
try {
|
|
61
|
+
const queries = [...(params.query ? [params.query] : []), ...(params.queries ?? [])]
|
|
62
|
+
.map((q) => q.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.slice(0, 4);
|
|
65
|
+
if (queries.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
isError: true,
|
|
68
|
+
content: [{ type: "text", text: "需要提供 query 或 queries(至少一个搜索词)。" }],
|
|
69
|
+
details: { error: "no query" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const apiKey = await loadSearch1ApiKey();
|
|
74
|
+
if (!apiKey) {
|
|
75
|
+
return {
|
|
76
|
+
isError: true,
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: "未找到 Search1API key:请在 ~/.pi/web-search.json 配置 search1apiApiKey,或设置 SEARCH1API_KEY 环境变量。",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
details: { error: "search1api key not configured" },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
onUpdate?.({
|
|
88
|
+
content: [{ type: "text", text: `正在搜索: ${queries.join(" / ")}` }],
|
|
89
|
+
details: {},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const common = {
|
|
93
|
+
numResults: params.numResults,
|
|
94
|
+
recencyFilter: params.recencyFilter,
|
|
95
|
+
domainFilter: params.domainFilter,
|
|
96
|
+
searchService: params.searchService,
|
|
97
|
+
includeContent: params.includeContent,
|
|
98
|
+
signal,
|
|
99
|
+
};
|
|
100
|
+
const results = await Promise.all(queries.map((query) => searchWeb(query, apiKey, common)));
|
|
101
|
+
|
|
102
|
+
// 按 URL 去重合并
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const hits: SearchHit[] = [];
|
|
105
|
+
for (const result of results) {
|
|
106
|
+
for (const hit of result.hits) {
|
|
107
|
+
if (seen.has(hit.url)) continue;
|
|
108
|
+
seen.add(hit.url);
|
|
109
|
+
hits.push(hit);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (hits.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: "没有找到结果。" }],
|
|
115
|
+
details: { query: queries, count: 0 },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
|
|
121
|
+
details: {
|
|
122
|
+
query: queries,
|
|
123
|
+
provider: "search1api",
|
|
124
|
+
count: hits.length,
|
|
125
|
+
results: hits,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return {
|
|
131
|
+
isError: true,
|
|
132
|
+
content: [{ type: "text", text: `搜索失败: ${message}` }],
|
|
133
|
+
details: { error: message },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.registerTool({
|
|
140
|
+
name: "web_fetch",
|
|
141
|
+
label: "Web Fetch",
|
|
142
|
+
description:
|
|
143
|
+
"Fetch a URL and extract the main content as markdown. SSRF-protected: refuses " +
|
|
144
|
+
"private/internal addresses. Only http/https HTML pages are supported.",
|
|
145
|
+
promptSnippet: "Fetch a web page and extract its content",
|
|
146
|
+
parameters: Type.Object({
|
|
147
|
+
url: Type.String({ description: "The URL to fetch" }),
|
|
148
|
+
}),
|
|
149
|
+
async execute(_id, params, signal) {
|
|
150
|
+
try {
|
|
151
|
+
const page = await fetchPage(params.url, signal);
|
|
152
|
+
const { text, truncated } = truncateMarkdown(page.markdown);
|
|
153
|
+
const details: Record<string, unknown> = {
|
|
154
|
+
url: page.url,
|
|
155
|
+
title: page.title,
|
|
156
|
+
bytes: Buffer.byteLength(page.markdown, "utf8"),
|
|
157
|
+
truncated,
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text }],
|
|
161
|
+
details,
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
const details: Record<string, unknown> = { error: message, url: params.url };
|
|
166
|
+
return {
|
|
167
|
+
isError: true,
|
|
168
|
+
content: [{ type: "text", text: `抓取失败: ${message}` }],
|
|
169
|
+
details,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search1API 搜索:请求构造、响应解析与结果整理。
|
|
3
|
+
*
|
|
4
|
+
* 搜索响应不做 AI 预消化,直接返回整理后的结构化结果(title/url/snippet,
|
|
5
|
+
* crawl_results 开启时含内联正文)——模型自己读,零额外模型调用。
|
|
6
|
+
*/
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
9
|
+
|
|
10
|
+
const SEARCH_URL = "https://api.search1api.com/search";
|
|
11
|
+
const TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
const hitSchema = Type.Object({
|
|
14
|
+
title: Type.Union([Type.String(), Type.Null()]),
|
|
15
|
+
link: Type.Union([Type.String(), Type.Null()]),
|
|
16
|
+
snippet: Type.Union([Type.String(), Type.Null()]),
|
|
17
|
+
content: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const searchResponseSchema = Type.Object(
|
|
21
|
+
{ results: Type.Array(hitSchema) },
|
|
22
|
+
{ additionalProperties: true },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export interface SearchHit {
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
snippet: string;
|
|
29
|
+
content?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SearchResult {
|
|
33
|
+
query: string;
|
|
34
|
+
hits: SearchHit[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SearchOptions {
|
|
38
|
+
numResults?: number;
|
|
39
|
+
recencyFilter?: string;
|
|
40
|
+
domainFilter?: string[];
|
|
41
|
+
searchService?: string;
|
|
42
|
+
/** 内联抓取前几个结果的正文(crawl_results),可省去后续 web_fetch */
|
|
43
|
+
includeContent?: boolean;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clampNumResults(value: number | undefined): number {
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 5;
|
|
49
|
+
return Math.max(1, Math.min(Math.floor(value), 50));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeDomain(raw: string): string | undefined {
|
|
53
|
+
const input = raw.trim().toLowerCase().replace(/^-/, "").trim();
|
|
54
|
+
if (!input) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const host = input.includes("://")
|
|
57
|
+
? new URL(input).hostname
|
|
58
|
+
: new URL(`https://${input}`).hostname;
|
|
59
|
+
return host.replaceAll(/^\.+|\.+$/g, "") || undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return input.split("/", 1)[0]?.split(":", 1)[0] || undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function splitDomainFilter(domainFilter: string[] | undefined): {
|
|
66
|
+
includeSites: string[];
|
|
67
|
+
excludeSites: string[];
|
|
68
|
+
} {
|
|
69
|
+
const includeSites: string[] = [];
|
|
70
|
+
const excludeSites: string[] = [];
|
|
71
|
+
for (const raw of domainFilter ?? []) {
|
|
72
|
+
const domain = normalizeDomain(raw);
|
|
73
|
+
if (!domain) continue;
|
|
74
|
+
(raw.trimStart().startsWith("-") ? excludeSites : includeSites).push(domain);
|
|
75
|
+
}
|
|
76
|
+
return { includeSites, excludeSites };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildSearchBody(query: string, options: SearchOptions): Record<string, unknown> {
|
|
80
|
+
const numResults = clampNumResults(options.numResults);
|
|
81
|
+
const { includeSites, excludeSites } = splitDomainFilter(options.domainFilter);
|
|
82
|
+
const body: Record<string, unknown> = {
|
|
83
|
+
query,
|
|
84
|
+
max_results: numResults,
|
|
85
|
+
crawl_results: options.includeContent ? Math.min(numResults, 5) : 0,
|
|
86
|
+
};
|
|
87
|
+
if (options.recencyFilter) body.time_range = options.recencyFilter;
|
|
88
|
+
if (options.searchService) body.search_service = options.searchService;
|
|
89
|
+
if (includeSites.length > 0) body.include_sites = includeSites;
|
|
90
|
+
if (excludeSites.length > 0) body.exclude_sites = excludeSites;
|
|
91
|
+
return body;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mapHits(
|
|
95
|
+
results: {
|
|
96
|
+
title: string | null;
|
|
97
|
+
link: string | null;
|
|
98
|
+
snippet: string | null;
|
|
99
|
+
content?: string | null;
|
|
100
|
+
}[],
|
|
101
|
+
): SearchHit[] {
|
|
102
|
+
const hits: SearchHit[] = [];
|
|
103
|
+
for (const item of results) {
|
|
104
|
+
const url = item.link?.trim();
|
|
105
|
+
if (!url) continue;
|
|
106
|
+
const hit: SearchHit = {
|
|
107
|
+
title: item.title?.trim() || url,
|
|
108
|
+
url,
|
|
109
|
+
snippet: item.snippet?.replaceAll(/\s+/g, " ").trim() || "",
|
|
110
|
+
};
|
|
111
|
+
const content = item.content?.trim();
|
|
112
|
+
if (content) hit.content = content;
|
|
113
|
+
hits.push(hit);
|
|
114
|
+
}
|
|
115
|
+
return hits;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 合并调用方 signal 与超时;调用方未传时仍有超时兜底 */
|
|
119
|
+
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
120
|
+
const timeout = AbortSignal.timeout(ms);
|
|
121
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function searchWeb(
|
|
125
|
+
query: string,
|
|
126
|
+
apiKey: string,
|
|
127
|
+
options: SearchOptions = {},
|
|
128
|
+
): Promise<SearchResult> {
|
|
129
|
+
const response = await fetch(SEARCH_URL, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: `Bearer ${apiKey}`,
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(buildSearchBody(query, options)),
|
|
136
|
+
signal: withTimeout(options.signal, TIMEOUT_MS),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const raw = await response.text();
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(`Search1API error ${response.status}: ${raw.slice(0, 300)}`);
|
|
142
|
+
}
|
|
143
|
+
const parsed = Value.Parse(searchResponseSchema, JSON.parse(raw));
|
|
144
|
+
return { query, hits: mapHits(parsed.results) };
|
|
145
|
+
}
|