@2en/clawly-plugins 1.31.0 → 1.32.0-beta.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/clawly-config-defaults.json5 +3 -3
- package/gateway/index.ts +6 -0
- package/gateway/info.ts +18 -0
- package/gateway/issueAccessToken.ts +17 -0
- package/{outbound.ts → http/file/outbound.ts} +3 -26
- package/http/file/upload.ts +98 -0
- package/index.ts +5 -1
- package/lib/httpAuth.ts +21 -0
- package/package.json +2 -2
- package/tools/clawly-search.test.ts +99 -0
- package/tools/clawly-search.ts +3 -0
- package/tools/create-search-tool.ts +44 -20
package/gateway/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {registerClawhub2gateway} from './clawhub2gateway'
|
|
|
7
7
|
import {registerConfigModel} from './config-model'
|
|
8
8
|
import {registerConfigRepair} from './config-repair'
|
|
9
9
|
import {registerConfigTimezone} from './config-timezone'
|
|
10
|
+
import {registerInfo} from './info'
|
|
11
|
+
import {registerIssueAccessToken} from './issueAccessToken'
|
|
10
12
|
import {registerCronDelivery} from './cron-delivery'
|
|
11
13
|
import {registerCronTelemetry} from './cron-telemetry'
|
|
12
14
|
import {registerMessageLog} from './message-log'
|
|
@@ -19,6 +21,7 @@ import {registerPlugins} from './plugins'
|
|
|
19
21
|
import {initPostHog, shutdownPostHog} from './posthog'
|
|
20
22
|
import {registerPresence} from './presence'
|
|
21
23
|
import {registerSessionSanitize} from './session-sanitize'
|
|
24
|
+
import {registerUploadHttpRoute} from '../http/file/upload'
|
|
22
25
|
import {registerVersion} from './version'
|
|
23
26
|
|
|
24
27
|
export function registerGateway(api: PluginApi) {
|
|
@@ -47,6 +50,7 @@ export function registerGateway(api: PluginApi) {
|
|
|
47
50
|
})
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
registerInfo(api)
|
|
50
54
|
registerPresence(api)
|
|
51
55
|
registerNotification(api)
|
|
52
56
|
registerAgentSend(api)
|
|
@@ -64,6 +68,8 @@ export function registerGateway(api: PluginApi) {
|
|
|
64
68
|
registerSessionSanitize(api)
|
|
65
69
|
registerPairing(api)
|
|
66
70
|
registerVersion(api)
|
|
71
|
+
registerIssueAccessToken(api)
|
|
72
|
+
registerUploadHttpRoute(api)
|
|
67
73
|
registerAudit(api)
|
|
68
74
|
registerCalendarNative(api)
|
|
69
75
|
}
|
package/gateway/info.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin info — fast, synchronous metadata from package.json.
|
|
3
|
+
*
|
|
4
|
+
* Only static info that can be resolved without I/O or network calls.
|
|
5
|
+
*
|
|
6
|
+
* Method: clawly.info({}) → { version: string }
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {PluginApi} from '../types'
|
|
10
|
+
|
|
11
|
+
// @ts-expect-error — JSON import
|
|
12
|
+
import pkg from '../package.json'
|
|
13
|
+
|
|
14
|
+
export function registerInfo(api: PluginApi) {
|
|
15
|
+
api.registerGatewayMethod('clawly.info', async ({respond}) => {
|
|
16
|
+
respond(true, {version: pkg.version})
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {createAccessToken, resolveGatewaySecret} from '../lib/httpAuth'
|
|
2
|
+
import type {PluginApi} from '../types'
|
|
3
|
+
|
|
4
|
+
export function registerIssueAccessToken(api: PluginApi) {
|
|
5
|
+
api.registerGatewayMethod('clawly.issueAccessToken', async ({respond}) => {
|
|
6
|
+
const secret = resolveGatewaySecret(api)
|
|
7
|
+
if (!secret) {
|
|
8
|
+
respond(false, undefined, {
|
|
9
|
+
code: 'no_secret',
|
|
10
|
+
message: 'gateway auth token is not configured',
|
|
11
|
+
})
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const {token, expiresAt} = createAccessToken(secret)
|
|
15
|
+
respond(true, {accessToken: token, expiresAt})
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -20,8 +20,8 @@ import os from 'node:os'
|
|
|
20
20
|
import path from 'node:path'
|
|
21
21
|
import mime from 'mime'
|
|
22
22
|
|
|
23
|
-
import type {PluginApi} from '
|
|
24
|
-
import {
|
|
23
|
+
import type {PluginApi} from '../../types'
|
|
24
|
+
import {guardHttpAuth, handleCors, sendJson} from '../../lib/httpAuth'
|
|
25
25
|
|
|
26
26
|
const OUTBOUND_DIR = path.join(os.homedir(), '.openclaw', 'clawly', 'outbound')
|
|
27
27
|
|
|
@@ -116,19 +116,6 @@ export function registerOutboundMethods(api: PluginApi) {
|
|
|
116
116
|
)
|
|
117
117
|
respond(true, {base64: buffer.toString('base64')})
|
|
118
118
|
})
|
|
119
|
-
|
|
120
|
-
api.registerGatewayMethod('clawly.issueAccessToken', async ({respond}) => {
|
|
121
|
-
const secret = resolveGatewaySecret(api)
|
|
122
|
-
if (!secret) {
|
|
123
|
-
respond(false, undefined, {
|
|
124
|
-
code: 'no_secret',
|
|
125
|
-
message: 'gateway auth token is not configured',
|
|
126
|
-
})
|
|
127
|
-
return
|
|
128
|
-
}
|
|
129
|
-
const {token, expiresAt} = createAccessToken(secret)
|
|
130
|
-
respond(true, {accessToken: token, expiresAt})
|
|
131
|
-
})
|
|
132
119
|
}
|
|
133
120
|
|
|
134
121
|
// ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
|
|
@@ -180,17 +167,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
180
167
|
path: '/clawly/file/outbound',
|
|
181
168
|
auth: 'plugin',
|
|
182
169
|
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
183
|
-
res
|
|
184
|
-
|
|
185
|
-
// Handle CORS preflight
|
|
186
|
-
if (_req.method === 'OPTIONS') {
|
|
187
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
|
|
188
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
189
|
-
res.setHeader('Access-Control-Max-Age', '86400')
|
|
190
|
-
res.statusCode = 204
|
|
191
|
-
res.end()
|
|
192
|
-
return
|
|
193
|
-
}
|
|
170
|
+
if (handleCors(_req, res, 'GET, HEAD, OPTIONS')) return
|
|
194
171
|
|
|
195
172
|
const url = new URL(_req.url ?? '/', 'http://localhost')
|
|
196
173
|
if (!guardHttpAuth(api, _req, res, url)) return
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import type {IncomingMessage, ServerResponse} from 'node:http'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import mime from 'mime'
|
|
7
|
+
import {guardHttpAuth, handleCors, sendJson} from '../../lib/httpAuth'
|
|
8
|
+
import type {PluginApi} from '../../types'
|
|
9
|
+
|
|
10
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024 // 20 MB
|
|
11
|
+
const ALLOWED_CONTENT_TYPE_PREFIXES = ['image/', 'audio/']
|
|
12
|
+
|
|
13
|
+
function uploadsDir(api: PluginApi): string {
|
|
14
|
+
return path.join(api.runtime.state.resolveStateDir(), 'media', 'clawly')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function collectBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const chunks: Buffer[] = []
|
|
20
|
+
let size = 0
|
|
21
|
+
let done = false
|
|
22
|
+
req.on('data', (chunk: Buffer) => {
|
|
23
|
+
if (done) return
|
|
24
|
+
size += chunk.length
|
|
25
|
+
if (size > maxBytes) {
|
|
26
|
+
done = true
|
|
27
|
+
req.destroy()
|
|
28
|
+
reject(new Error(`body too large (exceeded ${maxBytes} bytes)`))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
chunks.push(chunk)
|
|
32
|
+
})
|
|
33
|
+
req.on('end', () => {
|
|
34
|
+
if (!done) resolve(Buffer.concat(chunks))
|
|
35
|
+
})
|
|
36
|
+
req.on('error', reject)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerUploadHttpRoute(api: PluginApi) {
|
|
41
|
+
api.registerHttpRoute({
|
|
42
|
+
path: '/clawly/file/upload',
|
|
43
|
+
auth: 'plugin',
|
|
44
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
45
|
+
if (handleCors(req, res, 'POST, OPTIONS')) return
|
|
46
|
+
|
|
47
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
48
|
+
if (!guardHttpAuth(api, req, res, url)) return
|
|
49
|
+
|
|
50
|
+
if (req.method !== 'POST') {
|
|
51
|
+
sendJson(res, 405, {error: 'method not allowed'})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const contentType = req.headers['content-type']?.split(';')[0]?.trim()
|
|
56
|
+
if (!contentType || !ALLOWED_CONTENT_TYPE_PREFIXES.some((p) => contentType.startsWith(p))) {
|
|
57
|
+
sendJson(res, 415, {
|
|
58
|
+
error: `only ${ALLOWED_CONTENT_TYPE_PREFIXES.map((p) => p + '*').join(', ')} content types are accepted`,
|
|
59
|
+
})
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filename = url.searchParams.get('filename')?.trim() ?? ''
|
|
64
|
+
const extFromFilename = filename ? path.extname(filename).toLowerCase() : ''
|
|
65
|
+
const extFromMime = mime.getExtension(contentType)
|
|
66
|
+
const ext = extFromFilename || (extFromMime ? `.${extFromMime}` : '')
|
|
67
|
+
if (!ext) {
|
|
68
|
+
sendJson(res, 400, {error: 'cannot determine file extension from filename or Content-Type'})
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let buffer: Buffer
|
|
73
|
+
try {
|
|
74
|
+
buffer = await collectBody(req, MAX_UPLOAD_BYTES)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
sendJson(res, 413, {error: err instanceof Error ? err.message : 'body too large'})
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (buffer.length === 0) {
|
|
81
|
+
sendJson(res, 400, {error: 'empty body'})
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dir = uploadsDir(api)
|
|
86
|
+
await fsp.mkdir(dir, {recursive: true})
|
|
87
|
+
|
|
88
|
+
const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16)
|
|
89
|
+
const destFilename = `${hash}${ext}`
|
|
90
|
+
const destPath = path.join(dir, destFilename)
|
|
91
|
+
|
|
92
|
+
await fsp.writeFile(destPath, buffer)
|
|
93
|
+
api.logger.info(`upload: saved ${destPath} (${buffer.length} bytes)`)
|
|
94
|
+
|
|
95
|
+
sendJson(res, 200, {path: destPath})
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|
package/index.ts
CHANGED
|
@@ -50,7 +50,11 @@ import {registerCronHook} from './cron-hook'
|
|
|
50
50
|
import {registerEmail} from './email'
|
|
51
51
|
import {registerGateway} from './gateway'
|
|
52
52
|
import {getGatewayConfig} from './gateway-fetch'
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
registerOutboundHook,
|
|
55
|
+
registerOutboundHttpRoute,
|
|
56
|
+
registerOutboundMethods,
|
|
57
|
+
} from './http/file/outbound'
|
|
54
58
|
import {registerSkillCommandRestore} from './skill-command-restore'
|
|
55
59
|
import {registerTools} from './tools'
|
|
56
60
|
import type {PluginApi} from './types'
|
package/lib/httpAuth.ts
CHANGED
|
@@ -94,6 +94,27 @@ export function authenticateHttpRequest(req: IncomingMessage, url: URL, secret:
|
|
|
94
94
|
return false
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Set CORS headers and handle OPTIONS preflight.
|
|
99
|
+
* Returns true if the request was a preflight (response already sent).
|
|
100
|
+
*/
|
|
101
|
+
export function handleCors(
|
|
102
|
+
req: IncomingMessage,
|
|
103
|
+
res: ServerResponse,
|
|
104
|
+
methods: string = 'GET, HEAD, OPTIONS',
|
|
105
|
+
): boolean {
|
|
106
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
107
|
+
if (req.method === 'OPTIONS') {
|
|
108
|
+
res.setHeader('Access-Control-Allow-Methods', methods)
|
|
109
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
110
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
111
|
+
res.statusCode = 204
|
|
112
|
+
res.end()
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
export function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
|
|
98
119
|
res.writeHead(status, {'Content-Type': 'application/json'})
|
|
99
120
|
res.end(JSON.stringify(body))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0-beta.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"cron-hook.ts",
|
|
34
34
|
"email.ts",
|
|
35
35
|
"gateway-fetch.ts",
|
|
36
|
-
"
|
|
36
|
+
"http",
|
|
37
37
|
"model-gateway-setup.ts",
|
|
38
38
|
"resolve-gateway-credentials.ts",
|
|
39
39
|
"skill-command-restore.ts",
|
|
@@ -192,6 +192,105 @@ describe('clawly_search', () => {
|
|
|
192
192
|
expect(call.body?.search_mode).toBeUndefined()
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
+
test('extracts citations from message annotations (OpenRouter format)', async () => {
|
|
196
|
+
mockResponse.body = {
|
|
197
|
+
choices: [
|
|
198
|
+
{
|
|
199
|
+
message: {
|
|
200
|
+
content: 'Answer with annotation citations',
|
|
201
|
+
annotations: [
|
|
202
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/a'}},
|
|
203
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/b'}},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
}
|
|
209
|
+
const {execute} = createMockApi(registerSearchTool)
|
|
210
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
211
|
+
|
|
212
|
+
expect(res.answer).toBe('Answer with annotation citations')
|
|
213
|
+
expect(res.citations).toEqual(['https://example.com/a', 'https://example.com/b'])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('prefers top-level citations over annotations', async () => {
|
|
217
|
+
mockResponse.body = {
|
|
218
|
+
choices: [
|
|
219
|
+
{
|
|
220
|
+
message: {
|
|
221
|
+
content: 'Answer',
|
|
222
|
+
annotations: [
|
|
223
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/annotation'}},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
citations: ['https://example.com/top-level'],
|
|
229
|
+
}
|
|
230
|
+
const {execute} = createMockApi(registerSearchTool)
|
|
231
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
232
|
+
|
|
233
|
+
expect(res.citations).toEqual(['https://example.com/top-level'])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('deduplicates annotation citations', async () => {
|
|
237
|
+
mockResponse.body = {
|
|
238
|
+
choices: [
|
|
239
|
+
{
|
|
240
|
+
message: {
|
|
241
|
+
content: 'Answer',
|
|
242
|
+
annotations: [
|
|
243
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/same'}},
|
|
244
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/same'}},
|
|
245
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/other'}},
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
const {execute} = createMockApi(registerSearchTool)
|
|
252
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
253
|
+
|
|
254
|
+
expect(res.citations).toEqual(['https://example.com/same', 'https://example.com/other'])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('extracts citations from annotation.url fallback', async () => {
|
|
258
|
+
mockResponse.body = {
|
|
259
|
+
choices: [
|
|
260
|
+
{
|
|
261
|
+
message: {
|
|
262
|
+
content: 'Answer',
|
|
263
|
+
annotations: [{type: 'url_citation', url: 'https://example.com/fallback-url'}],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
const {execute} = createMockApi(registerSearchTool)
|
|
269
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
270
|
+
|
|
271
|
+
expect(res.citations).toEqual(['https://example.com/fallback-url'])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('falls back to annotations when top-level citations is empty array', async () => {
|
|
275
|
+
mockResponse.body = {
|
|
276
|
+
choices: [
|
|
277
|
+
{
|
|
278
|
+
message: {
|
|
279
|
+
content: 'Answer',
|
|
280
|
+
annotations: [
|
|
281
|
+
{type: 'url_citation', url_citation: {url: 'https://example.com/fallback'}},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
citations: [],
|
|
287
|
+
}
|
|
288
|
+
const {execute} = createMockApi(registerSearchTool)
|
|
289
|
+
const res = parseResult(await execute('tc-1', {query: 'test'}))
|
|
290
|
+
|
|
291
|
+
expect(res.citations).toEqual(['https://example.com/fallback'])
|
|
292
|
+
})
|
|
293
|
+
|
|
195
294
|
test('returns error on API failure', async () => {
|
|
196
295
|
mockResponse = {ok: false, status: 503, body: {error: 'service unavailable'}}
|
|
197
296
|
const {execute} = createMockApi(registerSearchTool)
|
package/tools/clawly-search.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createSearchToolRegistrar,
|
|
8
8
|
parseGrokResponse,
|
|
9
9
|
parseKimiResponse,
|
|
10
|
+
parsePerplexityResponse,
|
|
10
11
|
} from './create-search-tool'
|
|
11
12
|
|
|
12
13
|
export const registerSearchTool = createSearchToolRegistrar({
|
|
@@ -17,6 +18,7 @@ export const registerSearchTool = createSearchToolRegistrar({
|
|
|
17
18
|
buildUrl: buildPerplexityUrl,
|
|
18
19
|
timeoutMs: 30_000,
|
|
19
20
|
provider: 'perplexity',
|
|
21
|
+
parseResponse: parsePerplexityResponse,
|
|
20
22
|
})
|
|
21
23
|
|
|
22
24
|
export const registerDeepSearchTool = createSearchToolRegistrar({
|
|
@@ -27,6 +29,7 @@ export const registerDeepSearchTool = createSearchToolRegistrar({
|
|
|
27
29
|
buildUrl: buildPerplexityUrl,
|
|
28
30
|
timeoutMs: 120_000,
|
|
29
31
|
provider: 'perplexity',
|
|
32
|
+
parseResponse: parsePerplexityResponse,
|
|
30
33
|
})
|
|
31
34
|
|
|
32
35
|
// model is 'grok-4-fast' (not 'x-ai/grok-4-fast') because this goes through
|
|
@@ -17,9 +17,8 @@ export interface SearchToolConfig {
|
|
|
17
17
|
buildUrl: (baseUrl: string) => string
|
|
18
18
|
timeoutMs: number
|
|
19
19
|
provider: SearchProvider
|
|
20
|
-
extraBody?: Record<string, unknown>
|
|
21
20
|
buildBody?: (model: string, query: string) => Record<string, unknown>
|
|
22
|
-
parseResponse
|
|
21
|
+
parseResponse: (data: unknown) => {answer: string; citations: string[]}
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
function createParameters(): Record<string, unknown> {
|
|
@@ -70,6 +69,47 @@ function extractCitations(text: string): string[] {
|
|
|
70
69
|
return urls
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
export function parsePerplexityResponse(data: unknown): {answer: string; citations: string[]} {
|
|
73
|
+
const d = data as {
|
|
74
|
+
choices?: {
|
|
75
|
+
message?: {
|
|
76
|
+
content?: string
|
|
77
|
+
annotations?: Array<{type?: string; url?: string; url_citation?: {url?: string}}>
|
|
78
|
+
}
|
|
79
|
+
}[]
|
|
80
|
+
citations?: string[]
|
|
81
|
+
}
|
|
82
|
+
const answer = d.choices?.[0]?.message?.content ?? ''
|
|
83
|
+
|
|
84
|
+
const seen = new Set<string>()
|
|
85
|
+
const topLevel = (d.citations ?? []).reduce<string[]>((acc, raw) => {
|
|
86
|
+
if (typeof raw !== 'string') return acc
|
|
87
|
+
const url = raw.trim()
|
|
88
|
+
if (url && !seen.has(url)) {
|
|
89
|
+
seen.add(url)
|
|
90
|
+
acc.push(url)
|
|
91
|
+
}
|
|
92
|
+
return acc
|
|
93
|
+
}, [])
|
|
94
|
+
if (topLevel.length > 0) {
|
|
95
|
+
return {answer, citations: topLevel}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback: extract from message annotations (OpenRouter format)
|
|
99
|
+
const citations: string[] = []
|
|
100
|
+
for (const annotation of d.choices?.[0]?.message?.annotations ?? []) {
|
|
101
|
+
if (annotation.type !== 'url_citation') continue
|
|
102
|
+
const raw = annotation.url_citation?.url ?? annotation.url
|
|
103
|
+
if (typeof raw !== 'string') continue
|
|
104
|
+
const url = raw.trim()
|
|
105
|
+
if (url && !seen.has(url)) {
|
|
106
|
+
seen.add(url)
|
|
107
|
+
citations.push(url)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {answer, citations}
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
export function parseKimiResponse(data: unknown): {answer: string; citations: string[]} {
|
|
74
114
|
const d = data as {choices?: {message?: {content?: string}}[]}
|
|
75
115
|
const answerText = d.choices?.[0]?.message?.content ?? ''
|
|
@@ -120,12 +160,7 @@ export function createSearchToolRegistrar(config: SearchToolConfig) {
|
|
|
120
160
|
body: JSON.stringify(
|
|
121
161
|
config.buildBody
|
|
122
162
|
? config.buildBody(config.model, query)
|
|
123
|
-
: {
|
|
124
|
-
model: config.model,
|
|
125
|
-
stream: false,
|
|
126
|
-
messages: [{role: 'user', content: query}],
|
|
127
|
-
...config.extraBody,
|
|
128
|
-
},
|
|
163
|
+
: {model: config.model, stream: false, messages: [{role: 'user', content: query}]},
|
|
129
164
|
),
|
|
130
165
|
signal: controller.signal,
|
|
131
166
|
})
|
|
@@ -143,18 +178,7 @@ export function createSearchToolRegistrar(config: SearchToolConfig) {
|
|
|
143
178
|
|
|
144
179
|
const data = await res.json()
|
|
145
180
|
|
|
146
|
-
const parsed = config.parseResponse
|
|
147
|
-
? config.parseResponse(data)
|
|
148
|
-
: (() => {
|
|
149
|
-
const d = data as {
|
|
150
|
-
choices?: {message?: {content?: string}}[]
|
|
151
|
-
citations?: string[]
|
|
152
|
-
}
|
|
153
|
-
return {
|
|
154
|
-
answer: d.choices?.[0]?.message?.content ?? '',
|
|
155
|
-
citations: d.citations ?? [],
|
|
156
|
-
}
|
|
157
|
-
})()
|
|
181
|
+
const parsed = config.parseResponse(data)
|
|
158
182
|
|
|
159
183
|
const result: SearchResult = {
|
|
160
184
|
query,
|