@xl0/pi-lovely-web 0.1.4 → 0.2.1
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/README.md +60 -32
- package/extensions/lovely-web/command.ts +29 -163
- package/extensions/lovely-web/config.ts +117 -54
- package/extensions/lovely-web/constants.ts +1 -0
- package/extensions/lovely-web/format.ts +15 -4
- package/extensions/lovely-web/image.ts +49 -59
- package/extensions/lovely-web/index.ts +13 -4
- package/extensions/lovely-web/providers/brave.ts +29 -14
- package/extensions/lovely-web/providers/exa.ts +20 -10
- package/extensions/lovely-web/providers/firecrawl.ts +37 -6
- package/extensions/lovely-web/providers/tavily.ts +44 -10
- package/extensions/lovely-web/providers/types.ts +28 -14
- package/extensions/lovely-web/tools.ts +182 -52
- package/extensions/lovely-web/types.ts +7 -0
- package/package.json +11 -4
- package/extensions/lovely-web/tool-impl.ts +0 -95
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatDimensionNote, type ResizedImage, resizeImage } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { getImageDimensions } from "@earendil-works/pi-tui"
|
|
3
|
-
import { DEFAULT_TIMEOUT_MS } from "./
|
|
4
|
-
import type { ToolResult } from "./
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "./constants.js"
|
|
4
|
+
import type { ToolResult } from "./types.js"
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_MAX_IMAGE_BYTES = 5_000_000
|
|
7
7
|
export const MAX_IMAGE_BYTES = 20_000_000
|
|
@@ -63,6 +63,25 @@ async function fetchImageContent(
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function textOnlyResult(text: string, details: Record<string, unknown>): ToolResult {
|
|
67
|
+
return { content: [{ type: "text" as const, text }], details }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function imageResult(text: string, image: { data: string; mimeType: string }, details: Record<string, unknown>): ToolResult {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{ type: "text" as const, text },
|
|
74
|
+
{ type: "image" as const, data: image.data, mimeType: image.mimeType }
|
|
75
|
+
],
|
|
76
|
+
details
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function emitResult(result: ToolResult, onUpdate?: (result: ToolResult) => void): ToolResult {
|
|
81
|
+
onUpdate?.(result)
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
export async function imageImpl(
|
|
67
86
|
params: {
|
|
68
87
|
url: string
|
|
@@ -81,87 +100,58 @@ export async function imageImpl(
|
|
|
81
100
|
if (signal?.aborted) throw new Error("Image fetch cancelled")
|
|
82
101
|
|
|
83
102
|
const originalDimensions = getImageDimensions(image.data, image.mimeType) ?? undefined
|
|
103
|
+
const downloadDetails = {
|
|
104
|
+
url: params.url,
|
|
105
|
+
mimeType: image.mimeType,
|
|
106
|
+
bytes: image.bytes,
|
|
107
|
+
contentLength: image.contentLength
|
|
108
|
+
}
|
|
84
109
|
|
|
85
110
|
if (params.resize === false) {
|
|
86
111
|
// Re-encode to sanitize, but don't downscale.
|
|
87
|
-
const validated = (await resizeImage(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)) as ResizedImage | null
|
|
112
|
+
const validated = (await resizeImage(Buffer.from(image.data, "base64"), image.mimeType, {
|
|
113
|
+
maxWidth: Infinity,
|
|
114
|
+
maxHeight: Infinity
|
|
115
|
+
})) as ResizedImage | null
|
|
91
116
|
if (!validated) {
|
|
92
117
|
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded]`
|
|
93
|
-
|
|
94
|
-
content: [{ type: "text" as const, text: note }],
|
|
95
|
-
details: {
|
|
96
|
-
url: params.url,
|
|
97
|
-
mimeType: image.mimeType,
|
|
98
|
-
bytes: image.bytes,
|
|
99
|
-
contentLength: image.contentLength
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
onUpdate?.(result)
|
|
103
|
-
return result
|
|
118
|
+
return emitResult(textOnlyResult(note, downloadDetails), onUpdate)
|
|
104
119
|
}
|
|
105
120
|
const dimensions = { widthPx: validated.width, heightPx: validated.height }
|
|
106
121
|
const note = `Fetched image [${validated.mimeType}]`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{ type: "image" as const, data: validated.data, mimeType: validated.mimeType }
|
|
111
|
-
],
|
|
112
|
-
details: {
|
|
113
|
-
url: params.url,
|
|
122
|
+
return emitResult(
|
|
123
|
+
imageResult(note, validated, {
|
|
124
|
+
...downloadDetails,
|
|
114
125
|
mimeType: validated.mimeType,
|
|
115
|
-
bytes: image.bytes,
|
|
116
|
-
contentLength: image.contentLength,
|
|
117
126
|
dimensions,
|
|
118
127
|
originalDimensions,
|
|
119
128
|
wasResized: validated.wasResized
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return result
|
|
129
|
+
}),
|
|
130
|
+
onUpdate
|
|
131
|
+
)
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
const resized = (await resizeImage(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)) as ResizedImage | null
|
|
134
|
+
const resized = (await resizeImage(Buffer.from(image.data, "base64"), image.mimeType, {
|
|
135
|
+
maxWidth: params.maxSize ?? 2000,
|
|
136
|
+
maxHeight: params.maxSize ?? 2000
|
|
137
|
+
})) as ResizedImage | null
|
|
130
138
|
|
|
131
139
|
if (!resized) {
|
|
132
140
|
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded or resized below the inline image size limit.]`
|
|
133
|
-
|
|
134
|
-
content: [{ type: "text" as const, text: note }],
|
|
135
|
-
details: {
|
|
136
|
-
url: params.url,
|
|
137
|
-
mimeType: image.mimeType,
|
|
138
|
-
bytes: image.bytes,
|
|
139
|
-
contentLength: image.contentLength,
|
|
140
|
-
dimensions: originalDimensions
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
onUpdate?.(result)
|
|
144
|
-
return result
|
|
141
|
+
return emitResult(textOnlyResult(note, { ...downloadDetails, dimensions: originalDimensions }), onUpdate)
|
|
145
142
|
}
|
|
146
143
|
|
|
147
144
|
const dimensionNote = formatDimensionNote(resized)
|
|
148
145
|
const note = `Fetched image [${resized.mimeType}]${dimensionNote ? `\n${dimensionNote}` : ""}`
|
|
149
146
|
const dimensions = { widthPx: resized.width, heightPx: resized.height }
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{ type: "image" as const, data: resized.data, mimeType: resized.mimeType }
|
|
154
|
-
],
|
|
155
|
-
details: {
|
|
156
|
-
url: params.url,
|
|
147
|
+
return emitResult(
|
|
148
|
+
imageResult(note, resized, {
|
|
149
|
+
...downloadDetails,
|
|
157
150
|
mimeType: resized.mimeType,
|
|
158
|
-
bytes: image.bytes,
|
|
159
|
-
contentLength: image.contentLength,
|
|
160
151
|
dimensions,
|
|
161
152
|
originalDimensions,
|
|
162
153
|
wasResized: resized.wasResized
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return result
|
|
154
|
+
}),
|
|
155
|
+
onUpdate
|
|
156
|
+
)
|
|
167
157
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { registerLovelyWebCommand } from "./command.js"
|
|
3
|
-
import { applyToolConfig,
|
|
4
|
-
import {
|
|
3
|
+
import { applyToolConfig, loadScopedConfig } from "./config.js"
|
|
4
|
+
import { asErrorMessage } from "./format.js"
|
|
5
|
+
import { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
|
|
5
6
|
|
|
6
7
|
export default function (pi: ExtensionAPI) {
|
|
8
|
+
registerLovelyWebStaticTools(pi)
|
|
7
9
|
pi.on("session_start", async (_event, ctx) => {
|
|
8
|
-
|
|
10
|
+
try {
|
|
11
|
+
const loaded = loadScopedConfig(ctx.cwd)
|
|
12
|
+
if (loaded.warnings.length > 0) ctx.ui.notify(loaded.warnings.map(w => `${w.path}: ${w.message}`).join("\n"), "warning")
|
|
13
|
+
registerLovelyWebSearchTool(pi, loaded.value)
|
|
14
|
+
registerLovelyWebStaticTools(pi, loaded.value)
|
|
15
|
+
applyToolConfig(pi, loaded.value)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
ctx.ui.notify(`Lovely Web config error: ${asErrorMessage(error)}`, "error")
|
|
18
|
+
}
|
|
9
19
|
})
|
|
10
|
-
registerLovelyWebTools(pi)
|
|
11
20
|
registerLovelyWebCommand(pi)
|
|
12
21
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai"
|
|
2
|
+
import { Type } from "typebox"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "../constants.js"
|
|
1
4
|
import { requestJson } from "./http.js"
|
|
2
5
|
import type { Provider, SearchResult } from "./types.js"
|
|
3
6
|
|
|
4
7
|
const BASE_URL = "https://api.search.brave.com/res/v1"
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
8
|
|
|
7
9
|
interface BraveWebResult {
|
|
8
10
|
title: string
|
|
@@ -28,6 +30,8 @@ interface BraveImageResult {
|
|
|
28
30
|
url: string
|
|
29
31
|
title?: string
|
|
30
32
|
description?: string
|
|
33
|
+
properties?: { url?: string }
|
|
34
|
+
thumbnail?: { src?: string }
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
interface BraveImageResponse {
|
|
@@ -53,24 +57,35 @@ function fetchJson(url: string, apiKey: string, timeout: number, signal?: AbortS
|
|
|
53
57
|
)
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
function buildSearchUrl(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
60
|
+
function buildSearchUrl(
|
|
61
|
+
source: string | undefined,
|
|
62
|
+
query: string,
|
|
63
|
+
count: number,
|
|
64
|
+
opts: { country?: string; searchLang?: string; freshness?: string }
|
|
65
|
+
): string {
|
|
66
|
+
const endpoint = source === "news" ? "news" : source === "images" ? "images" : "web"
|
|
67
|
+
const url = new URL(`${BASE_URL}/${endpoint}/search`)
|
|
68
|
+
url.searchParams.set("q", query)
|
|
69
|
+
url.searchParams.set("count", String(count))
|
|
70
|
+
if (opts.country) url.searchParams.set("country", opts.country.toUpperCase())
|
|
71
|
+
if (opts.searchLang) url.searchParams.set("search_lang", opts.searchLang)
|
|
72
|
+
if (opts.freshness && source !== "images") url.searchParams.set("freshness", opts.freshness)
|
|
73
|
+
return url.toString()
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
export const braveProvider: Provider = {
|
|
68
77
|
id: "brave",
|
|
69
78
|
label: "Brave Search",
|
|
70
79
|
envApiKey: "BRAVE_API_KEY",
|
|
80
|
+
searchParameters: {
|
|
81
|
+
source: Type.Optional(StringEnum(["web", "news", "images"])),
|
|
82
|
+
country: Type.Optional(Type.String({ description: "Two-letter country code, e.g. US, DE, CO, or ALL." })),
|
|
83
|
+
searchLang: Type.Optional(Type.String({ description: "Search language code, e.g. en, es, de." })),
|
|
84
|
+
freshness: Type.Optional(Type.String({ description: "Freshness filter for web/news: pd, pw, pm, py, or YYYY-MM-DDtoYYYY-MM-DD." }))
|
|
85
|
+
},
|
|
71
86
|
|
|
72
87
|
async search(apiKey, query, opts, signal) {
|
|
73
|
-
const url = buildSearchUrl(opts.source, query, opts.limit)
|
|
88
|
+
const url = buildSearchUrl(opts.source, query, opts.limit, opts)
|
|
74
89
|
const raw = await fetchJson(url, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
75
90
|
|
|
76
91
|
let items: SearchResult[] = []
|
|
@@ -85,9 +100,9 @@ export const braveProvider: Provider = {
|
|
|
85
100
|
} else if (opts.source === "images") {
|
|
86
101
|
const data = raw as BraveImageResponse
|
|
87
102
|
items = (data.results ?? []).map(item => ({
|
|
88
|
-
title: item.title || item.url,
|
|
89
|
-
url: item.url,
|
|
90
|
-
description: item.description || ""
|
|
103
|
+
title: item.title || item.properties?.url || item.url,
|
|
104
|
+
url: item.properties?.url || item.thumbnail?.src || item.url,
|
|
105
|
+
description: item.description || item.url || ""
|
|
91
106
|
}))
|
|
92
107
|
} else {
|
|
93
108
|
const data = raw as BraveWebResponse
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai"
|
|
2
|
+
import { Type } from "typebox"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "../constants.js"
|
|
1
4
|
import { requestJson } from "./http.js"
|
|
2
5
|
import type { Provider, SearchResult } from "./types.js"
|
|
3
6
|
|
|
4
7
|
const BASE_URL = "https://api.exa.ai"
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
8
|
|
|
7
9
|
interface SearchBody {
|
|
8
10
|
query: string
|
|
@@ -10,11 +12,13 @@ interface SearchBody {
|
|
|
10
12
|
type: string
|
|
11
13
|
contents: { summary: boolean }
|
|
12
14
|
category?: string
|
|
15
|
+
userLocation?: string
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface ContentsBody {
|
|
16
|
-
|
|
19
|
+
urls: string[]
|
|
17
20
|
text: boolean
|
|
21
|
+
maxAgeHours?: number
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
interface ExaSearchResult {
|
|
@@ -30,11 +34,6 @@ interface ExaContentsResult {
|
|
|
30
34
|
text?: string
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
function sourceToCategory(source?: string): string | undefined {
|
|
34
|
-
if (source === "news") return "news"
|
|
35
|
-
return undefined
|
|
36
|
-
}
|
|
37
|
-
|
|
38
37
|
function stripSummaryLabel(text: string): string {
|
|
39
38
|
return text.replace(/^Summary:\s*/i, "")
|
|
40
39
|
}
|
|
@@ -60,6 +59,15 @@ export const exaProvider: Provider = {
|
|
|
60
59
|
id: "exa",
|
|
61
60
|
label: "Exa",
|
|
62
61
|
envApiKey: "EXA_API_KEY",
|
|
62
|
+
searchParameters: {
|
|
63
|
+
category: Type.Optional(StringEnum(["company", "people", "research paper", "news", "personal site", "financial report"])),
|
|
64
|
+
country: Type.Optional(Type.String({ description: "Two-letter ISO user location for result localization, e.g. US, DE, CO." }))
|
|
65
|
+
},
|
|
66
|
+
fetchParameters: {
|
|
67
|
+
maxAgeHours: Type.Optional(
|
|
68
|
+
Type.Integer({ description: "Maximum cache age in hours; 0 fetches fresh content, -1 uses cache only.", minimum: -1, maximum: 720 })
|
|
69
|
+
)
|
|
70
|
+
},
|
|
63
71
|
|
|
64
72
|
async search(apiKey, query, opts, signal) {
|
|
65
73
|
const body: SearchBody = {
|
|
@@ -68,8 +76,9 @@ export const exaProvider: Provider = {
|
|
|
68
76
|
type: "auto",
|
|
69
77
|
contents: { summary: true }
|
|
70
78
|
}
|
|
71
|
-
|
|
72
|
-
if (category) body.category = category
|
|
79
|
+
if (opts.source) throw new Error("Exa search uses category, not source. Omit category for general web search, or set category: news.")
|
|
80
|
+
if (opts.category) body.category = opts.category
|
|
81
|
+
if (opts.country) body.userLocation = opts.country
|
|
73
82
|
|
|
74
83
|
const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
75
84
|
const data = (raw as { results?: ExaSearchResult[] }).results ?? []
|
|
@@ -88,9 +97,10 @@ export const exaProvider: Provider = {
|
|
|
88
97
|
|
|
89
98
|
async fetch(apiKey, url, opts, signal) {
|
|
90
99
|
const body: ContentsBody = {
|
|
91
|
-
|
|
100
|
+
urls: [url],
|
|
92
101
|
text: true
|
|
93
102
|
}
|
|
103
|
+
if (opts.maxAgeHours !== undefined) body.maxAgeHours = opts.maxAgeHours
|
|
94
104
|
|
|
95
105
|
const raw = await fetchJson(`${BASE_URL}/contents`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
96
106
|
const results = (raw as { results?: ExaContentsResult[] }).results
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai"
|
|
2
|
+
import { Type } from "typebox"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "../constants.js"
|
|
1
4
|
import { requestJson } from "./http.js"
|
|
2
5
|
import type { Provider, SearchResult } from "./types.js"
|
|
3
6
|
|
|
4
|
-
const BASE_URL = "https://api.firecrawl.dev/
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000
|
|
7
|
+
const BASE_URL = "https://api.firecrawl.dev/v2"
|
|
6
8
|
|
|
7
9
|
interface SearchBody {
|
|
8
10
|
query: string
|
|
9
11
|
limit: number
|
|
10
12
|
sources?: string[]
|
|
13
|
+
categories?: string[]
|
|
14
|
+
location?: string
|
|
15
|
+
country?: string
|
|
16
|
+
tbs?: string
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
interface ScrapeBody {
|
|
@@ -52,18 +58,43 @@ export const firecrawlProvider: Provider = {
|
|
|
52
58
|
id: "firecrawl",
|
|
53
59
|
label: "Firecrawl",
|
|
54
60
|
envApiKey: "FIRECRAWL_API_KEY",
|
|
61
|
+
searchParameters: {
|
|
62
|
+
source: Type.Optional(StringEnum(["web", "news", "images"])),
|
|
63
|
+
category: Type.Optional(StringEnum(["github", "research", "pdf"])),
|
|
64
|
+
location: Type.Optional(Type.String({ description: "Geo-target results, e.g. Germany or San Francisco,California,United States." })),
|
|
65
|
+
country: Type.Optional(Type.String({ description: "ISO country code for geo-targeting, e.g. US, DE, CO." })),
|
|
66
|
+
tbs: Type.Optional(Type.String({ description: "Google-style time filter, e.g. qdr:d, qdr:w, qdr:m, qdr:y." }))
|
|
67
|
+
},
|
|
68
|
+
fetchParameters: {
|
|
69
|
+
waitFor: Type.Optional(Type.Integer({ description: "Extra delay in milliseconds before capturing page content.", minimum: 0 }))
|
|
70
|
+
},
|
|
55
71
|
|
|
56
72
|
async search(apiKey, query, opts, signal) {
|
|
57
73
|
const body: SearchBody = { query, limit: opts.limit }
|
|
58
74
|
if (opts.source) body.sources = [opts.source]
|
|
75
|
+
if (opts.category) body.categories = [opts.category]
|
|
76
|
+
if (opts.location) body.location = opts.location
|
|
77
|
+
if (opts.country) body.country = opts.country
|
|
78
|
+
if (opts.tbs) body.tbs = opts.tbs
|
|
59
79
|
|
|
60
80
|
const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
61
81
|
throwIfError(raw)
|
|
62
|
-
const data = raw.data as
|
|
82
|
+
const data = raw.data as {
|
|
83
|
+
web?: Array<{ title: string; url: string; description?: string; markdown?: string }>
|
|
84
|
+
news?: Array<{ title: string; url: string; snippet?: string; markdown?: string }>
|
|
85
|
+
images?: Array<{ title?: string; url: string; imageUrl?: string }>
|
|
86
|
+
}
|
|
87
|
+
const items = opts.source === "news" ? data.news || [] : opts.source === "images" ? data.images || [] : data.web || []
|
|
63
88
|
|
|
64
|
-
const results: SearchResult[] =
|
|
65
|
-
const
|
|
66
|
-
|
|
89
|
+
const results: SearchResult[] = items.map(item => {
|
|
90
|
+
const isImage = opts.source === "images"
|
|
91
|
+
const r: SearchResult = {
|
|
92
|
+
title: item.title || item.url,
|
|
93
|
+
url: isImage && "imageUrl" in item && item.imageUrl ? item.imageUrl : item.url
|
|
94
|
+
}
|
|
95
|
+
const description = "description" in item ? item.description : "snippet" in item ? item.snippet : undefined
|
|
96
|
+
if (description) r.description = description
|
|
97
|
+
if ("markdown" in item && item.markdown) r.markdown = item.markdown
|
|
67
98
|
return r
|
|
68
99
|
})
|
|
69
100
|
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai"
|
|
2
|
+
import { Type } from "typebox"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "../constants.js"
|
|
1
4
|
import { requestJson } from "./http.js"
|
|
2
5
|
import type { Provider, SearchResult } from "./types.js"
|
|
3
6
|
|
|
4
7
|
const BASE_URL = "https://api.tavily.com"
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
8
|
|
|
7
9
|
interface SearchBody {
|
|
8
10
|
query: string
|
|
9
11
|
max_results: number
|
|
10
12
|
search_depth: string
|
|
11
13
|
topic?: string
|
|
14
|
+
time_range?: string
|
|
15
|
+
country?: string
|
|
16
|
+
include_images?: boolean
|
|
17
|
+
include_image_descriptions?: boolean
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
interface ExtractBody {
|
|
@@ -25,11 +31,16 @@ interface TavilySearchResult {
|
|
|
25
31
|
score: number
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
interface TavilyImageResult {
|
|
35
|
+
url: string
|
|
36
|
+
description?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
interface TavilySearchResponse {
|
|
29
40
|
query: string
|
|
30
41
|
answer?: string
|
|
31
42
|
results?: TavilySearchResult[]
|
|
32
|
-
images?:
|
|
43
|
+
images?: TavilyImageResult[]
|
|
33
44
|
response_time: number
|
|
34
45
|
}
|
|
35
46
|
|
|
@@ -46,11 +57,6 @@ interface TavilyExtractResponse {
|
|
|
46
57
|
response_time: number
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
function sourceToTopic(source?: string): string | undefined {
|
|
50
|
-
if (source === "news") return "news"
|
|
51
|
-
return undefined
|
|
52
|
-
}
|
|
53
|
-
|
|
54
60
|
function postJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
|
|
55
61
|
return requestJson(
|
|
56
62
|
"Tavily",
|
|
@@ -72,6 +78,17 @@ export const tavilyProvider: Provider = {
|
|
|
72
78
|
id: "tavily",
|
|
73
79
|
label: "Tavily",
|
|
74
80
|
envApiKey: "TAVILY_API_KEY",
|
|
81
|
+
searchParameters: {
|
|
82
|
+
topic: Type.Optional(StringEnum(["general", "news", "finance"])),
|
|
83
|
+
includeImages: Type.Optional(Type.Boolean({ description: "Return query-related image URLs instead of page results." })),
|
|
84
|
+
country: Type.Optional(Type.String({ description: "Country name to boost general-topic results, e.g. colombia." })),
|
|
85
|
+
timeRange: Type.Optional(StringEnum(["day", "week", "month", "year", "d", "w", "m", "y"]))
|
|
86
|
+
},
|
|
87
|
+
fetchParameters: {
|
|
88
|
+
extractDepth: Type.Optional(
|
|
89
|
+
StringEnum(["basic", "advanced"], { description: "Advanced extraction retrieves more data such as tables; costs more." })
|
|
90
|
+
)
|
|
91
|
+
},
|
|
75
92
|
|
|
76
93
|
async search(apiKey, query, opts, signal) {
|
|
77
94
|
const body: SearchBody = {
|
|
@@ -79,11 +96,28 @@ export const tavilyProvider: Provider = {
|
|
|
79
96
|
max_results: opts.limit,
|
|
80
97
|
search_depth: "basic"
|
|
81
98
|
}
|
|
82
|
-
|
|
83
|
-
|
|
99
|
+
if (opts.source) throw new Error("Tavily search uses topic/includeImages, not source.")
|
|
100
|
+
const topic = opts.topic
|
|
101
|
+
if (topic && topic !== "general") body.topic = topic
|
|
102
|
+
if (opts.timeRange) body.time_range = opts.timeRange
|
|
103
|
+
if (opts.country && topic !== "news" && topic !== "finance") body.country = opts.country.toLowerCase()
|
|
104
|
+
if (opts.includeImages) {
|
|
105
|
+
body.include_images = true
|
|
106
|
+
body.include_image_descriptions = true
|
|
107
|
+
}
|
|
84
108
|
|
|
85
109
|
const raw = await postJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
86
110
|
const data = raw as TavilySearchResponse
|
|
111
|
+
|
|
112
|
+
if (opts.includeImages) {
|
|
113
|
+
const results: SearchResult[] = (data.images ?? []).map(item => ({
|
|
114
|
+
title: item.description || item.url,
|
|
115
|
+
url: item.url,
|
|
116
|
+
description: item.description || ""
|
|
117
|
+
}))
|
|
118
|
+
return { results, raw }
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
const items = data.results ?? []
|
|
88
122
|
|
|
89
123
|
const results: SearchResult[] = items.map(item => {
|
|
@@ -101,7 +135,7 @@ export const tavilyProvider: Provider = {
|
|
|
101
135
|
async fetch(apiKey, url, opts, signal) {
|
|
102
136
|
const body: ExtractBody = {
|
|
103
137
|
urls: [url],
|
|
104
|
-
extract_depth: "basic",
|
|
138
|
+
extract_depth: opts.extractDepth ?? "basic",
|
|
105
139
|
format: "markdown"
|
|
106
140
|
}
|
|
107
141
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TSchema } from "typebox"
|
|
2
|
+
|
|
1
3
|
export interface SearchResult {
|
|
2
4
|
title: string
|
|
3
5
|
url: string
|
|
@@ -5,27 +7,39 @@ export interface SearchResult {
|
|
|
5
7
|
markdown?: string // populated for first result when fetchResult=true
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
export interface SearchOptions {
|
|
11
|
+
limit: number
|
|
12
|
+
source?: string
|
|
13
|
+
timeout?: number
|
|
14
|
+
category?: string
|
|
15
|
+
location?: string
|
|
16
|
+
country?: string
|
|
17
|
+
tbs?: string
|
|
18
|
+
timeRange?: string
|
|
19
|
+
topic?: string
|
|
20
|
+
includeImages?: boolean
|
|
21
|
+
searchLang?: string
|
|
22
|
+
freshness?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FetchOptions {
|
|
26
|
+
timeout?: number
|
|
27
|
+
waitFor?: number
|
|
28
|
+
maxAgeHours?: number
|
|
29
|
+
extractDepth?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export interface Provider {
|
|
9
33
|
readonly id: string
|
|
10
34
|
readonly label: string
|
|
11
35
|
readonly envApiKey: string
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
opts: { limit: number; source?: string; timeout?: number },
|
|
16
|
-
signal?: AbortSignal
|
|
17
|
-
): Promise<{ results: SearchResult[]; raw: unknown }>
|
|
36
|
+
readonly searchParameters?: Record<string, TSchema>
|
|
37
|
+
readonly fetchParameters?: Record<string, TSchema>
|
|
38
|
+
search(apiKey: string, query: string, opts: SearchOptions, signal?: AbortSignal): Promise<{ results: SearchResult[]; raw: unknown }>
|
|
18
39
|
fetch?(
|
|
19
40
|
apiKey: string,
|
|
20
41
|
url: string,
|
|
21
|
-
opts:
|
|
42
|
+
opts: FetchOptions,
|
|
22
43
|
signal?: AbortSignal
|
|
23
44
|
): Promise<{ markdown: string; metadata?: unknown; raw: unknown }>
|
|
24
45
|
}
|
|
25
|
-
|
|
26
|
-
export interface WebToolsConfig {
|
|
27
|
-
webSearch?: { provider?: string | null }
|
|
28
|
-
webFetch?: { provider?: string | null }
|
|
29
|
-
webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
|
|
30
|
-
webApiKeys?: Record<string, string>
|
|
31
|
-
}
|