@xl0/pi-lovely-web 0.1.2
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 +87 -0
- package/extensions/lovely-web/command.ts +177 -0
- package/extensions/lovely-web/config.ts +134 -0
- package/extensions/lovely-web/format.ts +38 -0
- package/extensions/lovely-web/image.ts +167 -0
- package/extensions/lovely-web/index.ts +12 -0
- package/extensions/lovely-web/providers/brave.ts +103 -0
- package/extensions/lovely-web/providers/exa.ts +112 -0
- package/extensions/lovely-web/providers/firecrawl.ts +91 -0
- package/extensions/lovely-web/providers/http.ts +21 -0
- package/extensions/lovely-web/providers/tavily.ts +131 -0
- package/extensions/lovely-web/providers/types.ts +31 -0
- package/extensions/lovely-web/render.ts +23 -0
- package/extensions/lovely-web/tool-impl.ts +95 -0
- package/extensions/lovely-web/tools.ts +182 -0
- package/package.json +54 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { requestJson } from "./http.js"
|
|
2
|
+
import type { Provider, SearchResult } from "./types.js"
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://api.search.brave.com/res/v1"
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
interface BraveWebResult {
|
|
8
|
+
title: string
|
|
9
|
+
url: string
|
|
10
|
+
description: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BraveWebResponse {
|
|
14
|
+
web?: { results?: BraveWebResult[] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BraveNewsResult {
|
|
18
|
+
title: string
|
|
19
|
+
url: string
|
|
20
|
+
description: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BraveNewsResponse {
|
|
24
|
+
results?: BraveNewsResult[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface BraveImageResult {
|
|
28
|
+
url: string
|
|
29
|
+
title?: string
|
|
30
|
+
description?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BraveImageResponse {
|
|
34
|
+
results?: BraveImageResult[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stripHtmlTags(text: string): string {
|
|
38
|
+
return text.replace(/<[^>]+>/g, "")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fetchJson(url: string, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
42
|
+
return requestJson(
|
|
43
|
+
"Brave",
|
|
44
|
+
url,
|
|
45
|
+
{
|
|
46
|
+
headers: {
|
|
47
|
+
"X-Subscription-Token": apiKey,
|
|
48
|
+
Accept: "application/json"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
timeout,
|
|
52
|
+
signal
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSearchUrl(source: string | undefined, query: string, count: number): string {
|
|
57
|
+
const encodedQuery = encodeURIComponent(query)
|
|
58
|
+
if (source === "news") {
|
|
59
|
+
return `${BASE_URL}/news/search?q=${encodedQuery}&count=${count}`
|
|
60
|
+
}
|
|
61
|
+
if (source === "images") {
|
|
62
|
+
return `${BASE_URL}/images/search?q=${encodedQuery}&count=${count}`
|
|
63
|
+
}
|
|
64
|
+
return `${BASE_URL}/web/search?q=${encodedQuery}&count=${count}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const braveProvider: Provider = {
|
|
68
|
+
id: "brave",
|
|
69
|
+
label: "Brave Search",
|
|
70
|
+
envApiKey: "BRAVE_API_KEY",
|
|
71
|
+
|
|
72
|
+
async search(apiKey, query, opts, signal) {
|
|
73
|
+
const url = buildSearchUrl(opts.source, query, opts.limit)
|
|
74
|
+
const raw = await fetchJson(url, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
75
|
+
|
|
76
|
+
let items: SearchResult[] = []
|
|
77
|
+
|
|
78
|
+
if (opts.source === "news") {
|
|
79
|
+
const data = raw as BraveNewsResponse
|
|
80
|
+
items = (data.results ?? []).map(item => ({
|
|
81
|
+
title: item.title || "Untitled",
|
|
82
|
+
url: item.url,
|
|
83
|
+
description: stripHtmlTags(item.description || "")
|
|
84
|
+
}))
|
|
85
|
+
} else if (opts.source === "images") {
|
|
86
|
+
const data = raw as BraveImageResponse
|
|
87
|
+
items = (data.results ?? []).map(item => ({
|
|
88
|
+
title: item.title || item.url,
|
|
89
|
+
url: item.url,
|
|
90
|
+
description: item.description || ""
|
|
91
|
+
}))
|
|
92
|
+
} else {
|
|
93
|
+
const data = raw as BraveWebResponse
|
|
94
|
+
items = (data.web?.results ?? []).map(item => ({
|
|
95
|
+
title: item.title || "Untitled",
|
|
96
|
+
url: item.url,
|
|
97
|
+
description: stripHtmlTags(item.description || "")
|
|
98
|
+
}))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { results: items, raw }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { requestJson } from "./http.js"
|
|
2
|
+
import type { Provider, SearchResult } from "./types.js"
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://api.exa.ai"
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
interface SearchBody {
|
|
8
|
+
query: string
|
|
9
|
+
numResults: number
|
|
10
|
+
type: string
|
|
11
|
+
contents: { summary: boolean }
|
|
12
|
+
category?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ContentsBody {
|
|
16
|
+
ids: string[]
|
|
17
|
+
text: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ExaSearchResult {
|
|
21
|
+
title: string
|
|
22
|
+
url: string
|
|
23
|
+
summary?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ExaContentsResult {
|
|
27
|
+
id: string
|
|
28
|
+
url: string
|
|
29
|
+
title?: string
|
|
30
|
+
text?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sourceToCategory(source?: string): string | undefined {
|
|
34
|
+
if (source === "news") return "news"
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stripSummaryLabel(text: string): string {
|
|
39
|
+
return text.replace(/^Summary:\s*/i, "")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fetchJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
43
|
+
return requestJson(
|
|
44
|
+
"Exa",
|
|
45
|
+
url,
|
|
46
|
+
{
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"x-api-key": apiKey,
|
|
50
|
+
"Content-Type": "application/json"
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify(body)
|
|
53
|
+
},
|
|
54
|
+
timeout,
|
|
55
|
+
signal
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const exaProvider: Provider = {
|
|
60
|
+
id: "exa",
|
|
61
|
+
label: "Exa",
|
|
62
|
+
envApiKey: "EXA_API_KEY",
|
|
63
|
+
|
|
64
|
+
async search(apiKey, query, opts, signal) {
|
|
65
|
+
const body: SearchBody = {
|
|
66
|
+
query,
|
|
67
|
+
numResults: opts.limit,
|
|
68
|
+
type: "auto",
|
|
69
|
+
contents: { summary: true }
|
|
70
|
+
}
|
|
71
|
+
const category = sourceToCategory(opts.source)
|
|
72
|
+
if (category) body.category = category
|
|
73
|
+
|
|
74
|
+
const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
75
|
+
const data = (raw as { results?: ExaSearchResult[] }).results ?? []
|
|
76
|
+
|
|
77
|
+
const results: SearchResult[] = data.map(item => {
|
|
78
|
+
const r: SearchResult = {
|
|
79
|
+
title: item.title || "Untitled",
|
|
80
|
+
url: item.url
|
|
81
|
+
}
|
|
82
|
+
if (item.summary) r.description = stripSummaryLabel(item.summary)
|
|
83
|
+
return r
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return { results, raw }
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async fetch(apiKey, url, opts, signal) {
|
|
90
|
+
const body: ContentsBody = {
|
|
91
|
+
ids: [url],
|
|
92
|
+
text: true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = await fetchJson(`${BASE_URL}/contents`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
96
|
+
const results = (raw as { results?: ExaContentsResult[] }).results
|
|
97
|
+
const first = results?.[0]
|
|
98
|
+
|
|
99
|
+
if (!first?.text) {
|
|
100
|
+
const statuses = (raw as { statuses?: Array<{ id: string; status: string; error?: { tag: string } }> }).statuses
|
|
101
|
+
const status = statuses?.find(s => s.id === url)
|
|
102
|
+
const detail = status?.error?.tag ?? "no content"
|
|
103
|
+
throw new Error(`Exa could not fetch content for ${url}: ${detail}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
markdown: first.text.trim(),
|
|
108
|
+
metadata: first.title ? { title: first.title } : undefined,
|
|
109
|
+
raw
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { requestJson } from "./http.js"
|
|
2
|
+
import type { Provider, SearchResult } from "./types.js"
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://api.firecrawl.dev/v1"
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
interface SearchBody {
|
|
8
|
+
query: string
|
|
9
|
+
limit: number
|
|
10
|
+
sources?: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ScrapeBody {
|
|
14
|
+
url: string
|
|
15
|
+
formats: string[]
|
|
16
|
+
onlyMainContent: boolean
|
|
17
|
+
waitFor?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FirecrawlResponse {
|
|
21
|
+
success: boolean
|
|
22
|
+
data: unknown
|
|
23
|
+
error?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwIfError(response: unknown): asserts response is FirecrawlResponse {
|
|
27
|
+
if (!response || typeof response !== "object" || !("success" in response)) {
|
|
28
|
+
throw new Error("Unexpected Firecrawl response")
|
|
29
|
+
}
|
|
30
|
+
const r = response as FirecrawlResponse
|
|
31
|
+
if (r.success !== true) throw new Error(r.error || "Firecrawl request failed")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fetchJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
35
|
+
return requestJson(
|
|
36
|
+
"Firecrawl",
|
|
37
|
+
url,
|
|
38
|
+
{
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${apiKey}`,
|
|
42
|
+
"Content-Type": "application/json"
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify(body)
|
|
45
|
+
},
|
|
46
|
+
timeout,
|
|
47
|
+
signal
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const firecrawlProvider: Provider = {
|
|
52
|
+
id: "firecrawl",
|
|
53
|
+
label: "Firecrawl",
|
|
54
|
+
envApiKey: "FIRECRAWL_API_KEY",
|
|
55
|
+
|
|
56
|
+
async search(apiKey, query, opts, signal) {
|
|
57
|
+
const body: SearchBody = { query, limit: opts.limit }
|
|
58
|
+
if (opts.source) body.sources = [opts.source]
|
|
59
|
+
|
|
60
|
+
const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
61
|
+
throwIfError(raw)
|
|
62
|
+
const data = raw.data as Array<{ title: string; url: string; description: string }>
|
|
63
|
+
|
|
64
|
+
const results: SearchResult[] = data.map(item => {
|
|
65
|
+
const r: SearchResult = { title: item.title, url: item.url }
|
|
66
|
+
if (item.description) r.description = item.description
|
|
67
|
+
return r
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return { results, raw }
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async fetch(apiKey, url, opts, signal) {
|
|
74
|
+
const body: ScrapeBody = {
|
|
75
|
+
url,
|
|
76
|
+
formats: ["markdown"],
|
|
77
|
+
onlyMainContent: true
|
|
78
|
+
}
|
|
79
|
+
if (opts.waitFor !== undefined) body.waitFor = opts.waitFor
|
|
80
|
+
|
|
81
|
+
const raw = await fetchJson(`${BASE_URL}/scrape`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
82
|
+
throwIfError(raw)
|
|
83
|
+
const data = raw.data as { markdown: string; metadata?: unknown }
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
markdown: data.markdown?.trim() || "No markdown content returned.",
|
|
87
|
+
metadata: data.metadata,
|
|
88
|
+
raw
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function requestJson(label: string, url: string, init: RequestInit, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
2
|
+
const controller = new AbortController()
|
|
3
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
4
|
+
const abort = () => controller.abort()
|
|
5
|
+
if (signal?.aborted) controller.abort()
|
|
6
|
+
else signal?.addEventListener("abort", abort, { once: true })
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, { ...init, signal: controller.signal })
|
|
10
|
+
|
|
11
|
+
if (!res.ok) {
|
|
12
|
+
const text = await res.text()
|
|
13
|
+
throw new Error(`${label} request failed (${res.status}): ${text}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return res.json()
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
signal?.removeEventListener("abort", abort)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { requestJson } from "./http.js"
|
|
2
|
+
import type { Provider, SearchResult } from "./types.js"
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://api.tavily.com"
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
interface SearchBody {
|
|
8
|
+
query: string
|
|
9
|
+
max_results: number
|
|
10
|
+
search_depth: string
|
|
11
|
+
topic?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ExtractBody {
|
|
15
|
+
urls: string[]
|
|
16
|
+
extract_depth: string
|
|
17
|
+
format: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TavilySearchResult {
|
|
21
|
+
title: string
|
|
22
|
+
url: string
|
|
23
|
+
content: string
|
|
24
|
+
raw_content?: string
|
|
25
|
+
score: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TavilySearchResponse {
|
|
29
|
+
query: string
|
|
30
|
+
answer?: string
|
|
31
|
+
results?: TavilySearchResult[]
|
|
32
|
+
images?: unknown[]
|
|
33
|
+
response_time: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TavilyExtractResult {
|
|
37
|
+
url: string
|
|
38
|
+
raw_content: string
|
|
39
|
+
images?: unknown[]
|
|
40
|
+
favicon?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TavilyExtractResponse {
|
|
44
|
+
results?: TavilyExtractResult[]
|
|
45
|
+
failed_results?: Array<{ url: string; error: string }>
|
|
46
|
+
response_time: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceToTopic(source?: string): string | undefined {
|
|
50
|
+
if (source === "news") return "news"
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function postJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
55
|
+
return requestJson(
|
|
56
|
+
"Tavily",
|
|
57
|
+
url,
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${apiKey}`,
|
|
62
|
+
"Content-Type": "application/json"
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body)
|
|
65
|
+
},
|
|
66
|
+
timeout,
|
|
67
|
+
signal
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const tavilyProvider: Provider = {
|
|
72
|
+
id: "tavily",
|
|
73
|
+
label: "Tavily",
|
|
74
|
+
envApiKey: "TAVILY_API_KEY",
|
|
75
|
+
|
|
76
|
+
async search(apiKey, query, opts, signal) {
|
|
77
|
+
const body: SearchBody = {
|
|
78
|
+
query,
|
|
79
|
+
max_results: opts.limit,
|
|
80
|
+
search_depth: "basic"
|
|
81
|
+
}
|
|
82
|
+
const topic = sourceToTopic(opts.source)
|
|
83
|
+
if (topic) body.topic = topic
|
|
84
|
+
|
|
85
|
+
const raw = await postJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
86
|
+
const data = raw as TavilySearchResponse
|
|
87
|
+
const items = data.results ?? []
|
|
88
|
+
|
|
89
|
+
const results: SearchResult[] = items.map(item => {
|
|
90
|
+
const r: SearchResult = {
|
|
91
|
+
title: item.title || "Untitled",
|
|
92
|
+
url: item.url
|
|
93
|
+
}
|
|
94
|
+
if (item.content) r.description = item.content
|
|
95
|
+
return r
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return { results, raw }
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async fetch(apiKey, url, opts, signal) {
|
|
102
|
+
const body: ExtractBody = {
|
|
103
|
+
urls: [url],
|
|
104
|
+
extract_depth: "basic",
|
|
105
|
+
format: "markdown"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const raw = await postJson(`${BASE_URL}/extract`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
109
|
+
const data = raw as TavilyExtractResponse
|
|
110
|
+
|
|
111
|
+
const failed = data.failed_results?.find(f => f.url === url)
|
|
112
|
+
if (failed) {
|
|
113
|
+
throw new Error(`Tavily could not extract ${url}: ${failed.error}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const result = data.results?.find(r => r.url === url)
|
|
117
|
+
if (!result?.raw_content) {
|
|
118
|
+
throw new Error(`Tavily returned no content for ${url}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const metadata: { images?: unknown[]; favicon?: string } = {}
|
|
122
|
+
if (result.images?.length) metadata.images = result.images
|
|
123
|
+
if (result.favicon) metadata.favicon = result.favicon
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
markdown: result.raw_content.trim(),
|
|
127
|
+
metadata: Object.keys(metadata).length ? metadata : undefined,
|
|
128
|
+
raw
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SearchResult {
|
|
2
|
+
title: string
|
|
3
|
+
url: string
|
|
4
|
+
description?: string
|
|
5
|
+
markdown?: string // populated for first result when fetchResult=true
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Provider {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly label: string
|
|
11
|
+
readonly envApiKey: string
|
|
12
|
+
search(
|
|
13
|
+
apiKey: string,
|
|
14
|
+
query: string,
|
|
15
|
+
opts: { limit: number; source?: string; timeout?: number },
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
): Promise<{ results: SearchResult[]; raw: unknown }>
|
|
18
|
+
fetch?(
|
|
19
|
+
apiKey: string,
|
|
20
|
+
url: string,
|
|
21
|
+
opts: { waitFor?: number; timeout?: number },
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
): Promise<{ markdown: string; metadata?: unknown; raw: unknown }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WebToolsConfig {
|
|
27
|
+
webSearch?: { provider?: string; enabled?: boolean }
|
|
28
|
+
webFetch?: { provider?: string; enabled?: boolean }
|
|
29
|
+
webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
|
|
30
|
+
webApiKeys?: Record<string, string>
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui"
|
|
3
|
+
|
|
4
|
+
const COLLAPSED_RESULT_LINES = 6
|
|
5
|
+
|
|
6
|
+
export function renderTextResult(
|
|
7
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
8
|
+
expanded: boolean,
|
|
9
|
+
theme: Theme,
|
|
10
|
+
partialLabel: string
|
|
11
|
+
) {
|
|
12
|
+
const content = result.content[0]
|
|
13
|
+
if (content?.type !== "text" || content.text === undefined) return new Text(theme.fg("error", "No text output"), 0, 0)
|
|
14
|
+
if (!content.text.trim()) return new Text(theme.fg("dim", partialLabel), 0, 0)
|
|
15
|
+
|
|
16
|
+
const lines = content.text.split("\n")
|
|
17
|
+
const shown = expanded ? lines : lines.slice(0, COLLAPSED_RESULT_LINES)
|
|
18
|
+
let text = shown.map(line => theme.fg("toolOutput", line)).join("\n")
|
|
19
|
+
if (!expanded && lines.length > COLLAPSED_RESULT_LINES) {
|
|
20
|
+
text += `\n${theme.fg("muted", `... ${lines.length - COLLAPSED_RESULT_LINES} more lines (ctrl-o to expand)`)}`
|
|
21
|
+
}
|
|
22
|
+
return new Text(text, 0, 0)
|
|
23
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai"
|
|
2
|
+
import { DEFAULT_TIMEOUT_MS, getProvider, resolveApiKey } from "./config.js"
|
|
3
|
+
import { asErrorMessage, formatSearchOutput, stringify } from "./format.js"
|
|
4
|
+
import type { WebToolsConfig } from "./providers/types.js"
|
|
5
|
+
|
|
6
|
+
export interface ToolResult {
|
|
7
|
+
content: Array<TextContent | ImageContent>
|
|
8
|
+
details: unknown
|
|
9
|
+
isError?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function searchImpl(
|
|
13
|
+
config: WebToolsConfig,
|
|
14
|
+
params: { query: string; limit?: number; source?: string; fetchResult?: boolean },
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
onUpdate?: (result: ToolResult) => void
|
|
17
|
+
): Promise<ToolResult> {
|
|
18
|
+
const searchProvider = getProvider("search", config)
|
|
19
|
+
const apiKey = resolveApiKey(searchProvider, config)
|
|
20
|
+
|
|
21
|
+
const searchResult = await searchProvider.search(
|
|
22
|
+
apiKey,
|
|
23
|
+
params.query,
|
|
24
|
+
{
|
|
25
|
+
limit: params.limit ?? 5,
|
|
26
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
27
|
+
...(params.source !== undefined ? { source: params.source } : {})
|
|
28
|
+
},
|
|
29
|
+
signal
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (signal?.aborted) throw new Error("Search cancelled")
|
|
33
|
+
|
|
34
|
+
const shouldFetch = params.fetchResult ?? true
|
|
35
|
+
const first = searchResult.results[0]
|
|
36
|
+
if (shouldFetch && first?.url) {
|
|
37
|
+
onUpdate?.({
|
|
38
|
+
content: [{ type: "text" as const, text: `Fetching first result: ${first.url}` }],
|
|
39
|
+
details: undefined as unknown
|
|
40
|
+
})
|
|
41
|
+
try {
|
|
42
|
+
const fetchProvider = getProvider("fetch", config)
|
|
43
|
+
const fetchApiKey = resolveApiKey(fetchProvider, config)
|
|
44
|
+
const fetched = await fetchProvider.fetch(fetchApiKey, first.url, { timeout: DEFAULT_TIMEOUT_MS }, signal)
|
|
45
|
+
|
|
46
|
+
if (signal?.aborted) throw new Error("Search cancelled")
|
|
47
|
+
first.markdown = fetched.markdown
|
|
48
|
+
if (fetched.metadata) (first as { metadata?: unknown }).metadata = fetched.metadata
|
|
49
|
+
} catch (err) {
|
|
50
|
+
first.description = first.description || `[Fetch failed: ${asErrorMessage(err)}]`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result: ToolResult = {
|
|
55
|
+
content: [{ type: "text" as const, text: formatSearchOutput(searchResult.results) }],
|
|
56
|
+
details: searchResult.raw
|
|
57
|
+
}
|
|
58
|
+
onUpdate?.(result)
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function fetchImpl(
|
|
63
|
+
config: WebToolsConfig,
|
|
64
|
+
params: { url: string; waitFor?: number; timeout?: number; includeMetadata?: boolean },
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
onUpdate?: (result: ToolResult) => void
|
|
67
|
+
): Promise<ToolResult> {
|
|
68
|
+
const fetchProvider = getProvider("fetch", config)
|
|
69
|
+
const apiKey = resolveApiKey(fetchProvider, config)
|
|
70
|
+
|
|
71
|
+
const result = await fetchProvider.fetch(
|
|
72
|
+
apiKey,
|
|
73
|
+
params.url,
|
|
74
|
+
{
|
|
75
|
+
timeout: params.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
76
|
+
...(params.waitFor !== undefined ? { waitFor: params.waitFor } : {})
|
|
77
|
+
},
|
|
78
|
+
signal
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if (signal?.aborted) throw new Error("Fetch cancelled")
|
|
82
|
+
|
|
83
|
+
const warning =
|
|
84
|
+
["exa", "tavily"].includes(fetchProvider.id) && params.waitFor !== undefined
|
|
85
|
+
? `Warning: ${fetchProvider.label} ignores waitFor; request sent without any extra page-load delay.\n\n`
|
|
86
|
+
: ""
|
|
87
|
+
const metadata = params.includeMetadata && result.metadata ? `\n\nMetadata:\n${stringify(result.metadata)}` : ""
|
|
88
|
+
|
|
89
|
+
const toolResult: ToolResult = {
|
|
90
|
+
content: [{ type: "text" as const, text: `${warning}${result.markdown}${metadata}` }],
|
|
91
|
+
details: result.raw
|
|
92
|
+
}
|
|
93
|
+
onUpdate?.(toolResult)
|
|
94
|
+
return toolResult
|
|
95
|
+
}
|