@yuuz12/dsh-tavily 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +42 -0
- package/README.md +106 -0
- package/cordis.patch.yml +8 -0
- package/index.js +1136 -0
- package/lib/client.js +587 -0
- package/package.json +59 -0
package/index.js
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tavily — persistent Tavily search plugin for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the official web search (`web_search` tool backend, `ctx.web`) with
|
|
5
|
+
* a Tavily-backed search provider. Zero-dependency by design: no imports, so
|
|
6
|
+
* installation is a plain folder plus one cordis.patch.yml row and module
|
|
7
|
+
* resolution can never break against a moving @deepseek-ai/* tree.
|
|
8
|
+
*
|
|
9
|
+
* How replacement works (see README for the full story):
|
|
10
|
+
* - A search provider `{ id: 'dsh-tavily', available(), search() }` registers
|
|
11
|
+
* through the public seam API `ctx.web.registerSearchProvider()`.
|
|
12
|
+
* - `WebRuntime` resolves the provider at call time from its `searchProviderId`
|
|
13
|
+
* field (configured id wins; otherwise the single usable provider runs). We
|
|
14
|
+
* redefine that field as an accessor on the service instance: while the user
|
|
15
|
+
* switch is ON and at least one key is usable, reads yield 'dsh-tavily';
|
|
16
|
+
* otherwise they fall through to the untouched baseline (undefined or an
|
|
17
|
+
* explicit `$DSH_WEB_SEARCH_PROVIDER` value). The override is reversible and
|
|
18
|
+
* removed when the plugin unloads.
|
|
19
|
+
* - `available()` mirrors the same condition, so even if a future runtime stops
|
|
20
|
+
* consulting the field, auto-selection degrades gracefully to the official
|
|
21
|
+
* provider instead of reporting ambiguity.
|
|
22
|
+
*
|
|
23
|
+
* Configuration surfaces:
|
|
24
|
+
* - Settings namespace `dsh-tavily` (schema hand-rolled to stay import-free;
|
|
25
|
+
* callable + toJSON is all `SettingsProvider` consumes). The Plugins →
|
|
26
|
+
* Plugin configuration tab dispatches our browser card by this namespace.
|
|
27
|
+
* - Same-origin HTTP endpoints under `/dsh-tavily/*` serve the card: key pool,
|
|
28
|
+
* per-key usage stats, ordering, tests, and live balance refreshes.
|
|
29
|
+
*
|
|
30
|
+
* Data: keys / stats / balance cache persist to `<profile>/dsh-tavily.json`
|
|
31
|
+
* (user-owned, survives upgrades). Secrets never ride HTTP responses — the UI
|
|
32
|
+
* only ever sees masked keys.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export const name = 'dsh-tavily'
|
|
36
|
+
|
|
37
|
+
export const inject = ['web', 'webServer', 'fs', 'settings']
|
|
38
|
+
|
|
39
|
+
//#region constants -----------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Provider id inside `ctx.web`. Unique against 'deepseek-official' etc. */
|
|
42
|
+
const PROVIDER_ID = 'dsh-tavily'
|
|
43
|
+
|
|
44
|
+
/** Settings namespace served to the Plugins → Plugin configuration tab. */
|
|
45
|
+
const SETTINGS_NAMESPACE = 'dsh-tavily'
|
|
46
|
+
|
|
47
|
+
/** HTTP mount (prefix match: `/dsh-tavily` and `/dsh-tavily/<sub>`). */
|
|
48
|
+
const HTTP_PREFIX = '/dsh-tavily'
|
|
49
|
+
|
|
50
|
+
const TAVILY_SEARCH_URL = 'https://api.tavily.com/search'
|
|
51
|
+
const TAVILY_USAGE_URL = 'https://api.tavily.com/usage'
|
|
52
|
+
const USER_AGENT = 'dsh-tavily/0.1.0 (deepseek-harness plugin)'
|
|
53
|
+
|
|
54
|
+
/** Hard cap on one provider attempt; the cooperative tool budget still applies. */
|
|
55
|
+
const ATTEMPT_TIMEOUT_MS = 25_000
|
|
56
|
+
/** Hard cap on one usage (balance) fetch. */
|
|
57
|
+
const USAGE_TIMEOUT_MS = 12_000
|
|
58
|
+
/** Auth/quota failures park a key at the tail of the rotation for this long. */
|
|
59
|
+
const KEY_COOLDOWN_MS = 5 * 60_000
|
|
60
|
+
/** Rate-limit (429) cooldowns are much shorter. */
|
|
61
|
+
const RATE_COOLDOWN_MS = 30_000
|
|
62
|
+
/** Balance cache TTL used to decide whether the UI shows stale data. */
|
|
63
|
+
const USAGE_STALE_MS = 60_000
|
|
64
|
+
|
|
65
|
+
const SEARCH_DEPTHS = ['basic', 'advanced', 'fast', 'ultra-fast']
|
|
66
|
+
const TOPICS = ['general', 'news', 'finance']
|
|
67
|
+
const STRATEGIES = ['balance', 'manual']
|
|
68
|
+
|
|
69
|
+
/** Unknown balance ranks below every known value: known budgets lead, unrefreshed keys fall back. */
|
|
70
|
+
const UNKNOWN_BALANCE_RANK = -Infinity
|
|
71
|
+
|
|
72
|
+
const CONFIG_DEFAULTS = Object.freeze({
|
|
73
|
+
enabled: false,
|
|
74
|
+
strategy: 'balance',
|
|
75
|
+
searchDepth: 'basic',
|
|
76
|
+
maxResults: 8,
|
|
77
|
+
topic: 'general',
|
|
78
|
+
includeAnswer: false,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
|
|
83
|
+
//#region tiny utils -----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function nowIso() {
|
|
86
|
+
return new Date().toISOString()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function genId(prefix) {
|
|
90
|
+
try {
|
|
91
|
+
return prefix + '_' + crypto.randomUUID().slice(0, 8)
|
|
92
|
+
} catch {
|
|
93
|
+
return prefix + '_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Mask an API key for display: keep the scheme prefix and the last 4 chars. */
|
|
98
|
+
function maskKey(key) {
|
|
99
|
+
const k = String(key ?? '')
|
|
100
|
+
if (k.length <= 10) return '••••'
|
|
101
|
+
const dash = k.indexOf('-')
|
|
102
|
+
const head = dash > 0 ? k.slice(0, dash + 1) : k.slice(0, 3)
|
|
103
|
+
return `${head}…${k.slice(-4)}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The module's own directory, derived from import.meta.url (pure string ops).
|
|
108
|
+
*/
|
|
109
|
+
function pluginDir() {
|
|
110
|
+
try {
|
|
111
|
+
let url = import.meta.url
|
|
112
|
+
const q = url.indexOf('?')
|
|
113
|
+
if (q !== -1) url = url.slice(0, q)
|
|
114
|
+
const h = url.indexOf('#')
|
|
115
|
+
if (h !== -1) url = url.slice(0, h)
|
|
116
|
+
if (url.startsWith('file://')) {
|
|
117
|
+
let p = url.slice('file://'.length)
|
|
118
|
+
if (/^\/[A-Za-z]:\//.test(p)) p = p.slice(1) // Windows: '/C:/...' -> 'C:/...'
|
|
119
|
+
p = decodeURIComponent(p)
|
|
120
|
+
const slash = p.lastIndexOf('/')
|
|
121
|
+
if (slash > 0) return p.slice(0, slash)
|
|
122
|
+
}
|
|
123
|
+
} catch (e) { /* fall through */ }
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The hosting profile directory ($DSH_HOME/profiles/<name>). User data lives
|
|
129
|
+
* HERE rather than in the package folder: content-addressed installs replace
|
|
130
|
+
* package files on upgrade, while the profile directory is user-owned.
|
|
131
|
+
*/
|
|
132
|
+
function profileDirOf() {
|
|
133
|
+
const dir = pluginDir()
|
|
134
|
+
if (!dir) return null
|
|
135
|
+
const marker = '/profiles/'
|
|
136
|
+
const idx = dir.replaceAll('\\', '/').indexOf(marker)
|
|
137
|
+
if (idx === -1) return null
|
|
138
|
+
const rest = dir.replaceAll('\\', '/').slice(idx + marker.length)
|
|
139
|
+
const slash = rest.indexOf('/')
|
|
140
|
+
return slash === -1 ? dir : dir.slice(0, idx + marker.length + slash)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
//#endregion
|
|
144
|
+
|
|
145
|
+
//#region config normalization + hand-rolled schema ----------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Total-function normalization of one raw config section. Doubles as the
|
|
149
|
+
* schemastery-style resolver: unknown shapes collapse to defaults, invalid
|
|
150
|
+
* fields snap back instead of throwing (a stored section can never brick the
|
|
151
|
+
* settings service).
|
|
152
|
+
*/
|
|
153
|
+
function normalizeConfig(raw) {
|
|
154
|
+
const v = (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {}
|
|
155
|
+
return {
|
|
156
|
+
enabled: typeof v.enabled === 'boolean' ? v.enabled : CONFIG_DEFAULTS.enabled,
|
|
157
|
+
strategy: STRATEGIES.includes(v.strategy) ? v.strategy : CONFIG_DEFAULTS.strategy,
|
|
158
|
+
searchDepth: SEARCH_DEPTHS.includes(v.searchDepth) ? v.searchDepth : CONFIG_DEFAULTS.searchDepth,
|
|
159
|
+
maxResults: Number.isInteger(v.maxResults) && v.maxResults >= 1 && v.maxResults <= 20 ? v.maxResults : CONFIG_DEFAULTS.maxResults,
|
|
160
|
+
topic: TOPICS.includes(v.topic) ? v.topic : CONFIG_DEFAULTS.topic,
|
|
161
|
+
includeAnswer: typeof v.includeAnswer === 'boolean' ? v.includeAnswer : CONFIG_DEFAULTS.includeAnswer,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Minimal schemastery-compatible schema. `SettingsProvider` only calls the
|
|
167
|
+
* schema as a function (resolve) and `schema.toJSON()` (describe); secret
|
|
168
|
+
* redaction walks `type`/`dict` and passes unknown nodes through untouched.
|
|
169
|
+
*/
|
|
170
|
+
function buildSchema() {
|
|
171
|
+
const schema = (value) => normalizeConfig(value)
|
|
172
|
+
schema.toJSON = () => ({
|
|
173
|
+
type: 'object',
|
|
174
|
+
dict: {
|
|
175
|
+
enabled: { type: 'boolean', default: CONFIG_DEFAULTS.enabled },
|
|
176
|
+
strategy: { type: 'string', default: CONFIG_DEFAULTS.strategy },
|
|
177
|
+
searchDepth: { type: 'string', default: CONFIG_DEFAULTS.searchDepth },
|
|
178
|
+
maxResults: { type: 'number', default: CONFIG_DEFAULTS.maxResults },
|
|
179
|
+
topic: { type: 'string', default: CONFIG_DEFAULTS.topic },
|
|
180
|
+
includeAnswer: { type: 'boolean', default: CONFIG_DEFAULTS.includeAnswer },
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
return schema
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
//#endregion
|
|
187
|
+
|
|
188
|
+
//#region persisted state (keys / stats / balance cache) ------------------------------
|
|
189
|
+
|
|
190
|
+
function emptyStats() {
|
|
191
|
+
return { requests: 0, success: 0, failed: 0, creditsUsed: 0, lastUsedAt: null, lastError: null }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sanitizeData(raw) {
|
|
195
|
+
const src = (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {}
|
|
196
|
+
const out = { keys: [], order: [], stats: {}, usageCache: {} }
|
|
197
|
+
const seen = new Set()
|
|
198
|
+
if (Array.isArray(src.keys)) {
|
|
199
|
+
for (const entry of src.keys) {
|
|
200
|
+
if (!entry || typeof entry !== 'object') continue
|
|
201
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0) continue
|
|
202
|
+
if (typeof entry.key !== 'string' || entry.key.trim().length === 0) continue
|
|
203
|
+
if (seen.has(entry.id)) continue
|
|
204
|
+
seen.add(entry.id)
|
|
205
|
+
out.keys.push({
|
|
206
|
+
id: entry.id,
|
|
207
|
+
key: entry.key,
|
|
208
|
+
label: typeof entry.label === 'string' ? entry.label : '',
|
|
209
|
+
addedAt: typeof entry.addedAt === 'string' ? entry.addedAt : nowIso(),
|
|
210
|
+
disabled: entry.disabled === true,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(src.order)) {
|
|
215
|
+
for (const id of src.order) if (typeof id === 'string' && seen.has(id) && !out.order.includes(id)) out.order.push(id)
|
|
216
|
+
}
|
|
217
|
+
for (const k of out.keys) if (!out.order.includes(k.id)) out.order.push(k.id)
|
|
218
|
+
|
|
219
|
+
const grabStats = (v) => {
|
|
220
|
+
const s = emptyStats()
|
|
221
|
+
if (!v || typeof v !== 'object') return s
|
|
222
|
+
s.requests = Number.isInteger(v.requests) && v.requests >= 0 ? v.requests : 0
|
|
223
|
+
s.success = Number.isInteger(v.success) && v.success >= 0 ? v.success : 0
|
|
224
|
+
s.failed = Number.isInteger(v.failed) && v.failed >= 0 ? v.failed : 0
|
|
225
|
+
s.creditsUsed = Number.isFinite(v.creditsUsed) && v.creditsUsed >= 0 ? v.creditsUsed : 0
|
|
226
|
+
s.lastUsedAt = typeof v.lastUsedAt === 'string' ? v.lastUsedAt : null
|
|
227
|
+
s.lastError = typeof v.lastError === 'string' ? v.lastError : null
|
|
228
|
+
return s
|
|
229
|
+
}
|
|
230
|
+
if (src.stats && typeof src.stats === 'object') {
|
|
231
|
+
for (const k of out.keys) if (src.stats[k.id]) out.stats[k.id] = grabStats(src.stats[k.id])
|
|
232
|
+
}
|
|
233
|
+
if (src.usageCache && typeof src.usageCache === 'object') {
|
|
234
|
+
for (const k of out.keys) {
|
|
235
|
+
const u = src.usageCache[k.id]
|
|
236
|
+
if (!u || typeof u !== 'object') continue
|
|
237
|
+
out.usageCache[k.id] = {
|
|
238
|
+
fetchedAt: typeof u.fetchedAt === 'string' ? u.fetchedAt : null,
|
|
239
|
+
plan: typeof u.plan === 'string' ? u.plan : null,
|
|
240
|
+
usage: Number.isFinite(u.usage) && u.usage >= 0 ? u.usage : null,
|
|
241
|
+
limit: u.limit === null || u.limit === undefined || u.limit === Infinity ? null : (Number.isFinite(u.limit) && u.limit >= 0 ? u.limit : null),
|
|
242
|
+
searchUsage: Number.isFinite(u.searchUsage) && u.searchUsage >= 0 ? u.searchUsage : null,
|
|
243
|
+
extractUsage: Number.isFinite(u.extractUsage) && u.extractUsage >= 0 ? u.extractUsage : null,
|
|
244
|
+
planUsage: Number.isFinite(u.planUsage) && u.planUsage >= 0 ? u.planUsage : null,
|
|
245
|
+
planLimit: u.planLimit === null || u.planLimit === undefined || u.planLimit === Infinity ? null : (Number.isFinite(u.planLimit) && u.planLimit >= 0 ? u.planLimit : null),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function readData(ctx, dataPath) {
|
|
253
|
+
let raw = null
|
|
254
|
+
try {
|
|
255
|
+
const target = await ctx.fs.resolve(dataPath)
|
|
256
|
+
raw = await ctx.fs.readText(target)
|
|
257
|
+
} catch (e) { raw = null }
|
|
258
|
+
if (!raw) return sanitizeData(null)
|
|
259
|
+
try { return sanitizeData(JSON.parse(raw)) } catch { return sanitizeData(null) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeData(ctx, dataPath, data) {
|
|
263
|
+
const target = await ctx.fs.resolve(dataPath)
|
|
264
|
+
await ctx.fs.writeText(target, JSON.stringify(data, null, 2), undefined, undefined, { mode: 'danger-full-access' })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
//#endregion
|
|
268
|
+
|
|
269
|
+
//#region key ordering engine ---------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** Keys eligible for searching: present, non-empty, not manually disabled. */
|
|
272
|
+
function usableKeys(data) {
|
|
273
|
+
return data.keys.filter((k) => k.disabled !== true && typeof k.key === 'string' && k.key.trim().length > 0)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function cooldownActive(data, keyId, nowMs) {
|
|
277
|
+
const s = data.stats[keyId]
|
|
278
|
+
if (!s) return false
|
|
279
|
+
const until = s.cooldownUntil
|
|
280
|
+
return typeof until === 'number' && nowMs < until
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function remainingRank(data, keyId) {
|
|
284
|
+
const cached = data.usageCache[keyId]
|
|
285
|
+
if (!cached || cached.usage === null || cached.usage === undefined) return UNKNOWN_BALANCE_RANK
|
|
286
|
+
if (cached.limit === null) return Infinity // unlimited plan always leads
|
|
287
|
+
return Math.max(0, cached.limit - cached.usage)
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Order candidate keys for the next attempt.
|
|
291
|
+
*
|
|
292
|
+
* Both strategies share the same scaffolding: cooling keys sink to the tail,
|
|
293
|
+
* everything else follows the strategy order, and the final sequence is what
|
|
294
|
+
* `search()` walks with failover.
|
|
295
|
+
*
|
|
296
|
+
* - `balance`: highest remaining credit first (unlimited plans lead; unknown
|
|
297
|
+
* balances sit below known ones). Ties at the HEAD rotate round-robin via
|
|
298
|
+
* `rrCounter`, so equally-funded keys take turns.
|
|
299
|
+
* - `manual`: the user's `order` array, verbatim.
|
|
300
|
+
*
|
|
301
|
+
* Pure: returns a new array; `rrCounter` is consumed, not mutated.
|
|
302
|
+
*/
|
|
303
|
+
function orderKeys(candidates, strategy, data, rrCounter, nowMs = Date.now()) {
|
|
304
|
+
const displayIndex = new Map(data.order.map((id, i) => [id, i]))
|
|
305
|
+
const decorated = candidates.map((entry, i) => {
|
|
306
|
+
const cooling = cooldownActive(data, entry.id, nowMs)
|
|
307
|
+
const manual = displayIndex.has(entry.id) ? displayIndex.get(entry.id) : data.order.length + i
|
|
308
|
+
const remaining = remainingRank(data, entry.id)
|
|
309
|
+
return { entry, i, cooling, manual, remaining }
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const byStrategy = (a, b) => {
|
|
313
|
+
if (a.cooling !== b.cooling) return a.cooling ? 1 : -1
|
|
314
|
+
if (strategy === 'manual') return a.manual - b.manual || a.i - b.i
|
|
315
|
+
// balance: descending remaining; Infinity-safe numeric compare
|
|
316
|
+
if (a.remaining !== b.remaining) return a.remaining > b.remaining ? -1 : 1
|
|
317
|
+
return a.manual - b.manual || a.i - b.i
|
|
318
|
+
}
|
|
319
|
+
decorated.sort(byStrategy)
|
|
320
|
+
|
|
321
|
+
// Round-robin the head tie-group (only meaningful in balance mode, and only
|
|
322
|
+
// when the leaders are not parked in cooldown).
|
|
323
|
+
if (strategy === 'balance' && decorated.length > 1) {
|
|
324
|
+
const head = decorated[0]
|
|
325
|
+
let groupEnd = 1
|
|
326
|
+
while (groupEnd < decorated.length) {
|
|
327
|
+
const cur = decorated[groupEnd]
|
|
328
|
+
if (cur.cooling !== head.cooling) break
|
|
329
|
+
if (cur.remaining !== head.remaining) break
|
|
330
|
+
groupEnd += 1
|
|
331
|
+
}
|
|
332
|
+
if (groupEnd > 1) {
|
|
333
|
+
const shift = ((rrCounter % groupEnd) + groupEnd) % groupEnd
|
|
334
|
+
const group = decorated.slice(0, groupEnd)
|
|
335
|
+
const rotated = group.slice(shift).concat(group.slice(0, shift))
|
|
336
|
+
decorated.splice(0, groupEnd, ...rotated)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return decorated.map((d) => d.entry)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
//#endregion
|
|
344
|
+
|
|
345
|
+
//#region Tavily HTTP client -----------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
async function requestJson(url, init, timeoutMs, signal) {
|
|
348
|
+
const signals = []
|
|
349
|
+
if (signal) signals.push(signal)
|
|
350
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
351
|
+
signals.push(AbortSignal.timeout(timeoutMs))
|
|
352
|
+
}
|
|
353
|
+
let abortSignal
|
|
354
|
+
if (signals.length === 1) abortSignal = signals[0]
|
|
355
|
+
else if (signals.length > 1 && typeof AbortSignal.any === 'function') abortSignal = AbortSignal.any(signals)
|
|
356
|
+
else if (signals.length > 0) abortSignal = signals[0]
|
|
357
|
+
|
|
358
|
+
const response = await fetch(url, { ...init, ...(abortSignal ? { signal: abortSignal } : {}) })
|
|
359
|
+
const text = await response.text()
|
|
360
|
+
let body = null
|
|
361
|
+
try { body = text ? JSON.parse(text) : null } catch { body = null }
|
|
362
|
+
return { ok: response.ok, status: response.status, body, text }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function describeApiError(res, fallbackLabel) {
|
|
366
|
+
const detail = res.body && res.body.detail
|
|
367
|
+
const message = typeof detail === 'string' ? detail
|
|
368
|
+
: detail && typeof detail === 'object' && typeof detail.error === 'string' ? detail.error
|
|
369
|
+
: res.body && typeof res.body.error === 'string' ? res.body.error
|
|
370
|
+
: `HTTP ${res.status}`
|
|
371
|
+
const err = new Error(`${fallbackLabel}: ${message}`)
|
|
372
|
+
err.status = res.status
|
|
373
|
+
return err
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One Tavily search attempt. Resolves `{ data, credits }`; rejects with
|
|
378
|
+
* `.status` attached for HTTP failures.
|
|
379
|
+
*/
|
|
380
|
+
async function tavilySearch({ apiKey, query, maxResults, config, signal }) {
|
|
381
|
+
const body = {
|
|
382
|
+
query,
|
|
383
|
+
max_results: Math.max(1, Math.min(20, maxResults)),
|
|
384
|
+
search_depth: config.searchDepth,
|
|
385
|
+
topic: config.topic,
|
|
386
|
+
include_answer: config.includeAnswer === true ? 'basic' : false,
|
|
387
|
+
include_usage: true,
|
|
388
|
+
}
|
|
389
|
+
const res = await requestJson(TAVILY_SEARCH_URL, {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: {
|
|
392
|
+
authorization: `Bearer ${apiKey}`,
|
|
393
|
+
'content-type': 'application/json',
|
|
394
|
+
accept: 'application/json',
|
|
395
|
+
'user-agent': USER_AGENT,
|
|
396
|
+
},
|
|
397
|
+
body: JSON.stringify(body),
|
|
398
|
+
}, ATTEMPT_TIMEOUT_MS, signal)
|
|
399
|
+
if (!res.ok) throw describeApiError(res, 'Tavily 搜索请求失败')
|
|
400
|
+
const data = res.body && typeof res.body === 'object' ? res.body : {}
|
|
401
|
+
const credits = data.usage && Number.isFinite(data.usage.credits) ? data.usage.credits : 0
|
|
402
|
+
return { data, credits }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Fetch one key's authoritative balance from the Usage endpoint. */
|
|
406
|
+
async function tavilyUsage(apiKey, signal) {
|
|
407
|
+
const res = await requestJson(TAVILY_USAGE_URL, {
|
|
408
|
+
method: 'GET',
|
|
409
|
+
headers: {
|
|
410
|
+
authorization: `Bearer ${apiKey}`,
|
|
411
|
+
accept: 'application/json',
|
|
412
|
+
'user-agent': USER_AGENT,
|
|
413
|
+
},
|
|
414
|
+
}, USAGE_TIMEOUT_MS, signal)
|
|
415
|
+
if (!res.ok) throw describeApiError(res, 'Tavily 用量查询失败')
|
|
416
|
+
const body = res.body && typeof res.body === 'object' ? res.body : {}
|
|
417
|
+
const keyInfo = body.key && typeof body.key === 'object' ? body.key : {}
|
|
418
|
+
const account = body.account && typeof body.account === 'object' ? body.account : {}
|
|
419
|
+
return {
|
|
420
|
+
fetchedAt: nowIso(),
|
|
421
|
+
plan: typeof account.current_plan === 'string' ? account.current_plan : null,
|
|
422
|
+
usage: Number.isFinite(keyInfo.usage) ? keyInfo.usage : null,
|
|
423
|
+
// 密钥级上限:普通账号常为 null(额度记在账户计划上)
|
|
424
|
+
limit: keyInfo.limit === null || keyInfo.limit === undefined ? null : (Number.isFinite(keyInfo.limit) ? keyInfo.limit : null),
|
|
425
|
+
searchUsage: Number.isFinite(keyInfo.search_usage) ? keyInfo.search_usage : null,
|
|
426
|
+
extractUsage: Number.isFinite(keyInfo.extract_usage) ? keyInfo.extract_usage : null,
|
|
427
|
+
// 账户级计划额度(多密钥共享),作为密钥级上限缺失时的显示回退
|
|
428
|
+
planUsage: Number.isFinite(account.plan_usage) ? account.plan_usage : null,
|
|
429
|
+
planLimit: account.plan_limit === null || account.plan_limit === undefined ? null : (Number.isFinite(account.plan_limit) ? account.plan_limit : null),
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Project a Tavily search response onto the `ctx.web` seam vocabulary. */
|
|
434
|
+
function mapSearchResponse(data) {
|
|
435
|
+
const results = Array.isArray(data.results) ? data.results : []
|
|
436
|
+
const sources = []
|
|
437
|
+
for (const item of results) {
|
|
438
|
+
if (!item || typeof item.url !== 'string' || item.url.length === 0) continue
|
|
439
|
+
sources.push({
|
|
440
|
+
url: item.url,
|
|
441
|
+
...(typeof item.title === 'string' && item.title.length > 0 ? { title: item.title } : {}),
|
|
442
|
+
...(typeof item.content === 'string' && item.content.length > 0 ? { snippet: item.content } : {}),
|
|
443
|
+
...(typeof item.published_date === 'string' && item.published_date.length > 0 ? { publishedAt: item.published_date } : {}),
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
...(typeof data.answer === 'string' && data.answer.length > 0 ? { content: data.answer } : {}),
|
|
448
|
+
sources,
|
|
449
|
+
truncated: false,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isAbortLike(error, signal) {
|
|
454
|
+
if (signal && signal.aborted) return true
|
|
455
|
+
return error instanceof DOMException && error.name === 'AbortError'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
//#endregion
|
|
459
|
+
|
|
460
|
+
//#region optional session guard (dsh-webui-auth compatible) ----------------------------
|
|
461
|
+
//
|
|
462
|
+
// The webServer dispatches by longest-prefix, so any plugin route escapes a
|
|
463
|
+
// blanket auth prefix (dsh-webui-auth wraps `/api` and `/plugins`, but a longer
|
|
464
|
+
// custom prefix wins). Deployments that expose the GUI through a reverse proxy
|
|
465
|
+
// therefore need per-plugin enforcement. When a dsh-webui-auth session store is
|
|
466
|
+
// found on disk we validate its `dsh_wua_session` cookie ourselves — same JSONL
|
|
467
|
+
// replay semantics (add/remove/remove-many/clear + expiry pruning), so logout
|
|
468
|
+
// and password-change revocation apply immediately. Without such a store the
|
|
469
|
+
// endpoints stay open (plain loopback deployments).
|
|
470
|
+
|
|
471
|
+
function parseCookies(header) {
|
|
472
|
+
const out = {}
|
|
473
|
+
for (const part of String(header ?? '').split(';')) {
|
|
474
|
+
const eq = part.indexOf('=')
|
|
475
|
+
if (eq === -1) continue
|
|
476
|
+
const k = part.slice(0, eq).trim()
|
|
477
|
+
if (k.length > 0) out[k] = part.slice(eq + 1).trim()
|
|
478
|
+
}
|
|
479
|
+
return out
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Candidate locations of dsh-webui-auth's sessions.jsonl (first existing wins). */
|
|
483
|
+
function sessionStoreCandidates() {
|
|
484
|
+
const candidates = []
|
|
485
|
+
if (process.env.DSH_WEBUI_AUTH_DATA_DIR) {
|
|
486
|
+
candidates.push(process.env.DSH_WEBUI_AUTH_DATA_DIR.replace(/\\/g, '/').replace(/\/+$/, '') + '/sessions.jsonl')
|
|
487
|
+
}
|
|
488
|
+
const profile = profileDirOf()
|
|
489
|
+
if (profile) candidates.push(profile.replace(/\\/g, '/').replace(/\/+$/, '') + '/node_modules/dsh-webui-auth/sessions.jsonl')
|
|
490
|
+
const home = (process.env.DSH_HOME || ((process.env.USERPROFILE || process.env.HOME || '.') + '/.dsh')).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
491
|
+
candidates.push(home + '/dsh-webui-auth/sessions.jsonl')
|
|
492
|
+
// Sibling of the package directory (store installs put both under node_modules/).
|
|
493
|
+
const pdir = pluginDir()
|
|
494
|
+
if (pdir) candidates.push(pdir.replace(/\\/g, '/').replace(/\/+$/, '') + '/../dsh-webui-auth/sessions.jsonl')
|
|
495
|
+
return candidates
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function findWebuiAuthSessionsFile(ctx) {
|
|
499
|
+
for (const candidate of sessionStoreCandidates()) {
|
|
500
|
+
try {
|
|
501
|
+
const target = await ctx.fs.resolve(candidate)
|
|
502
|
+
const text = await ctx.fs.readText(target)
|
|
503
|
+
if (typeof text === 'string') return target
|
|
504
|
+
} catch (e) { /* try next candidate */ }
|
|
505
|
+
}
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Build a verifier against one sessions.jsonl path. The store disappearing
|
|
511
|
+
* mid-run degrades verification to open rather than bricking the card.
|
|
512
|
+
*/
|
|
513
|
+
function createSessionVerifier(ctx, sessionsFile) {
|
|
514
|
+
const COOKIE_NAME = 'dsh_wua_session'
|
|
515
|
+
function replay(text) {
|
|
516
|
+
const live = new Map()
|
|
517
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
518
|
+
if (!line.trim()) continue
|
|
519
|
+
let ev = null
|
|
520
|
+
try { ev = JSON.parse(line) } catch { continue }
|
|
521
|
+
if (!ev || typeof ev.op !== 'string') continue
|
|
522
|
+
// 注意:clear 不携带 token、remove-many 携带 tokens 数组,
|
|
523
|
+
// 因此不能在循环头部统一要求 token 字段。
|
|
524
|
+
if (ev.op === 'add' && typeof ev.token === 'string' && ev.sess && typeof ev.sess === 'object') {
|
|
525
|
+
const expiresAt = Number(ev.sess.expiresAt) || 0
|
|
526
|
+
if (expiresAt > Date.now()) live.set(ev.token, expiresAt)
|
|
527
|
+
} else if (ev.op === 'remove' && typeof ev.token === 'string') {
|
|
528
|
+
live.delete(ev.token)
|
|
529
|
+
} else if (ev.op === 'remove-many' && Array.isArray(ev.tokens)) {
|
|
530
|
+
for (const t of ev.tokens) live.delete(t)
|
|
531
|
+
} else if (ev.op === 'clear') {
|
|
532
|
+
live.clear()
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return live
|
|
536
|
+
}
|
|
537
|
+
let cacheText = null
|
|
538
|
+
let cacheTokens = null
|
|
539
|
+
async function liveTokens() {
|
|
540
|
+
try {
|
|
541
|
+
const target = await ctx.fs.resolve(sessionsFile)
|
|
542
|
+
const text = await ctx.fs.readText(target)
|
|
543
|
+
if (text !== cacheText) { cacheText = text; cacheTokens = replay(text) }
|
|
544
|
+
return cacheTokens || new Map()
|
|
545
|
+
} catch (e) {
|
|
546
|
+
cacheText = null; cacheTokens = null
|
|
547
|
+
return null
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
enabled: true,
|
|
552
|
+
async verify(req) {
|
|
553
|
+
const cookies = parseCookies(req.headers && req.headers.cookie)
|
|
554
|
+
const token = cookies[COOKIE_NAME]
|
|
555
|
+
if (!token) return false
|
|
556
|
+
const tokens = await liveTokens()
|
|
557
|
+
if (tokens === null) return true
|
|
558
|
+
const expiresAt = tokens.get(token)
|
|
559
|
+
return typeof expiresAt === 'number' && expiresAt > Date.now()
|
|
560
|
+
},
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
//#endregion
|
|
565
|
+
|
|
566
|
+
//#region structured provider error ----------------------------------------------------
|
|
567
|
+
|
|
568
|
+
/** Mirrors the seam's WebError shape (code field) without importing dsh-web. */
|
|
569
|
+
class TavilyProviderError extends Error {
|
|
570
|
+
constructor(message, code, cause) {
|
|
571
|
+
super(message)
|
|
572
|
+
this.name = 'WebError'
|
|
573
|
+
this.code = code
|
|
574
|
+
if (cause !== undefined) this.cause = cause
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
//#endregion
|
|
579
|
+
|
|
580
|
+
//#region route table ------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Pure router so the URL→handler mapping is testable without a socket.
|
|
584
|
+
* `sub` is whatever follows the mount prefix (leading separator optional).
|
|
585
|
+
*/
|
|
586
|
+
function pickRoute(method, sub) {
|
|
587
|
+
const clean = String(sub ?? '').replace(/^\/+/, '')
|
|
588
|
+
const parts = clean === '' ? [] : clean.split('/').filter((p) => p.length > 0)
|
|
589
|
+
if (parts.length === 0 && (method === 'GET' || method === 'HEAD')) return { action: 'state' }
|
|
590
|
+
if (parts.length === 1 && parts[0] === 'state' && method === 'GET') return { action: 'state' }
|
|
591
|
+
if (parts.length === 1 && parts[0] === 'config' && method === 'POST') return { action: 'config' }
|
|
592
|
+
if (parts.length === 1 && parts[0] === 'keys' && method === 'POST') return { action: 'keys.add' }
|
|
593
|
+
if (parts.length === 2 && parts[0] === 'keys' && parts[1] === 'update' && method === 'POST') return { action: 'keys.update' }
|
|
594
|
+
if (parts.length === 2 && parts[0] === 'keys' && parts[1] === 'move' && method === 'POST') return { action: 'keys.move' }
|
|
595
|
+
if (parts.length === 2 && parts[0] === 'keys' && parts[1] === 'test' && method === 'POST') return { action: 'keys.test' }
|
|
596
|
+
if (parts.length === 2 && parts[0] === 'keys' && parts[1] === 'remove' && method === 'POST') return { action: 'keys.remove' }
|
|
597
|
+
if (parts.length === 2 && parts[0] === 'usage' && parts[1] === 'refresh' && method === 'POST') return { action: 'usage.refresh' }
|
|
598
|
+
return { action: 'notfound' }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
//#endregion
|
|
602
|
+
|
|
603
|
+
//#region provider ---------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The Tavily-backed search provider. All mutable coordination state arrives
|
|
607
|
+
* through the `host` facade built in `apply()` — the class itself stays
|
|
608
|
+
* stateless so HMR reloads cannot leak counters across fibers.
|
|
609
|
+
*/
|
|
610
|
+
class TavilySearchProvider {
|
|
611
|
+
constructor(host) {
|
|
612
|
+
this.host = host
|
|
613
|
+
this.id = PROVIDER_ID
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Cheap local check for execution-time selection. Never touches network. */
|
|
617
|
+
available() {
|
|
618
|
+
return this.host.isRouteEligible()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Run one seam search: walk ordered candidates with failover, account
|
|
623
|
+
* credits locally, and project the winning response onto the seam shape.
|
|
624
|
+
*/
|
|
625
|
+
async search(request, signal) {
|
|
626
|
+
const host = this.host
|
|
627
|
+
if (!host.isRouteEligible()) {
|
|
628
|
+
throw new TavilyProviderError('Tavily 搜索提供方不可用:替换开关未开启或没有可用密钥', 'WEB_PROVIDER_UNAVAILABLE')
|
|
629
|
+
}
|
|
630
|
+
const config = host.getConfig()
|
|
631
|
+
const query = String(request.query ?? '').trim()
|
|
632
|
+
if (query.length === 0) throw new TavilyProviderError('搜索词不能为空', 'WEB_PROVIDER_ERROR')
|
|
633
|
+
const requested = Number.isInteger(request.maxResults) && request.maxResults >= 1 ? request.maxResults : config.maxResults
|
|
634
|
+
|
|
635
|
+
const snapshot = host.getDataSnapshot()
|
|
636
|
+
const candidates = orderKeys(usableKeys(snapshot), config.strategy, snapshot, host.nextRotation(), Date.now())
|
|
637
|
+
|
|
638
|
+
let lastError = null
|
|
639
|
+
for (const entry of candidates) {
|
|
640
|
+
if (signal && signal.aborted) break
|
|
641
|
+
try {
|
|
642
|
+
const started = Date.now()
|
|
643
|
+
const { data, credits } = await tavilySearch({
|
|
644
|
+
apiKey: entry.key,
|
|
645
|
+
query,
|
|
646
|
+
maxResults: Math.min(requested, config.maxResults),
|
|
647
|
+
config,
|
|
648
|
+
signal,
|
|
649
|
+
})
|
|
650
|
+
host.recordSuccess(entry.id, credits, Date.now() - started)
|
|
651
|
+
return mapSearchResponse(data)
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (isAbortLike(error, signal)) {
|
|
654
|
+
throw new TavilyProviderError('Tavily 搜索已取消', 'WEB_ABORTED', error)
|
|
655
|
+
}
|
|
656
|
+
lastError = error
|
|
657
|
+
host.recordFailure(entry.id, error)
|
|
658
|
+
if (typeof error.status === 'number') {
|
|
659
|
+
if (error.status === 401 || error.status === 403) host.setCooldown(entry.id, KEY_COOLDOWN_MS)
|
|
660
|
+
else if (error.status === 432 || error.status === 433) host.setCooldown(entry.id, KEY_COOLDOWN_MS)
|
|
661
|
+
else if (error.status === 429) host.setCooldown(entry.id, RATE_COOLDOWN_MS)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (signal && signal.aborted) throw new TavilyProviderError('Tavily 搜索已取消', 'WEB_ABORTED')
|
|
666
|
+
throw new TavilyProviderError(
|
|
667
|
+
`Tavily 搜索失败(已尝试 ${candidates.length} 个密钥):${lastError ? lastError.message : '无可用密钥'}`,
|
|
668
|
+
'WEB_PROVIDER_ERROR',
|
|
669
|
+
lastError,
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
//#endregion
|
|
675
|
+
|
|
676
|
+
//#region apply --------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
export async function apply(ctx, config) {
|
|
679
|
+
const log = (fn, msg) => { try { ctx.logger[fn](msg) } catch { /* logger optional */ } }
|
|
680
|
+
|
|
681
|
+
//# persistence location ----
|
|
682
|
+
const dir = profileDirOf() || pluginDir()
|
|
683
|
+
const dataPath = dir
|
|
684
|
+
? dir.replace(/\\/g, '/').replace(/\/+$/, '') + '/dsh-tavily.json'
|
|
685
|
+
: ((process.env.DSH_HOME || ((process.env.USERPROFILE || process.env.HOME || '.') + '/.dsh')).replace(/\\/g, '/').replace(/\/+$/, '') + '/dsh-tavily.json')
|
|
686
|
+
|
|
687
|
+
let data = await readData(ctx, dataPath)
|
|
688
|
+
const persist = async () => { await writeData(ctx, dataPath, data) }
|
|
689
|
+
// Best-effort flush of a freshly sanitized document; failures must not block startup.
|
|
690
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 初始数据落盘失败(将继续以内存态运行): ${e && e.message}`) }
|
|
691
|
+
|
|
692
|
+
//# settings section ----
|
|
693
|
+
let currentConfig = () => ({ ...CONFIG_DEFAULTS })
|
|
694
|
+
const configEntry = normalizeConfig(config)
|
|
695
|
+
let settingsBound = false
|
|
696
|
+
let unregisterSettingsWatch = null
|
|
697
|
+
try {
|
|
698
|
+
if (ctx.settings && typeof ctx.settings.register === 'function') {
|
|
699
|
+
const scope = ctx.settings.register(SETTINGS_NAMESPACE, buildSchema(), { base: configEntry })
|
|
700
|
+
currentConfig = () => normalizeConfig(scope.get())
|
|
701
|
+
unregisterSettingsWatch = scope.watch(() => { recompute(); })
|
|
702
|
+
settingsBound = true
|
|
703
|
+
} else {
|
|
704
|
+
log('warn', '[dsh-tavily] settings 服务不可用,配置将保持默认值(开关无法持久化)')
|
|
705
|
+
}
|
|
706
|
+
} catch (e) {
|
|
707
|
+
log('warn', `[dsh-tavily] 注册设置命名空间失败: ${e && e.message}`)
|
|
708
|
+
}
|
|
709
|
+
ctx.effect(() => () => {
|
|
710
|
+
if (typeof unregisterSettingsWatch === 'function') { try { unregisterSettingsWatch() } catch { /* noop */ } }
|
|
711
|
+
currentConfig = () => ({ ...CONFIG_DEFAULTS })
|
|
712
|
+
recompute()
|
|
713
|
+
}, 'dsh-tavily: settings teardown')
|
|
714
|
+
|
|
715
|
+
//# routing override over WebRuntime.searchProviderId ----
|
|
716
|
+
const ROUTING_MARKER = '__dshTavilyRouting__'
|
|
717
|
+
const web = ctx.web
|
|
718
|
+
const existing = Object.getOwnPropertyDescriptor(web, 'searchProviderId')
|
|
719
|
+
const alreadyOurs = existing && existing.get && existing.get[ROUTING_MARKER] === true
|
|
720
|
+
let baseline = web.searchProviderId
|
|
721
|
+
let overrideInstalled = false
|
|
722
|
+
let routeActive = false
|
|
723
|
+
|
|
724
|
+
if (!alreadyOurs && existing && existing.configurable) {
|
|
725
|
+
const originalDescriptor = existing
|
|
726
|
+
const accessor = function () { return routeActive ? PROVIDER_ID : baseline }
|
|
727
|
+
accessor[ROUTING_MARKER] = true
|
|
728
|
+
Object.defineProperty(web, 'searchProviderId', {
|
|
729
|
+
configurable: true,
|
|
730
|
+
enumerable: true,
|
|
731
|
+
get: accessor,
|
|
732
|
+
set(value) { baseline = value }, // external writers (env wiring, other plugins) become the new baseline
|
|
733
|
+
})
|
|
734
|
+
overrideInstalled = true
|
|
735
|
+
ctx.effect(() => () => {
|
|
736
|
+
routeActive = false
|
|
737
|
+
try {
|
|
738
|
+
delete web.searchProviderId
|
|
739
|
+
Object.defineProperty(web, 'searchProviderId', { ...originalDescriptor, value: baseline })
|
|
740
|
+
} catch (e) { log('warn', `[dsh-tavily] 还原 searchProviderId 失败: ${e && e.message}`) }
|
|
741
|
+
}, 'dsh-tavily: routing override teardown')
|
|
742
|
+
} else if (alreadyOurs) {
|
|
743
|
+
overrideInstalled = true
|
|
744
|
+
} else {
|
|
745
|
+
log('warn', '[dsh-tavily] 无法接管 web.searchProviderId(属性不可配置);替换功能将依赖自动选择,可能与官方搜索产生歧义冲突')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
//# provider registration ----
|
|
749
|
+
let providerRegistered = false
|
|
750
|
+
ctx.web.registerSearchProvider(new TavilySearchProvider({
|
|
751
|
+
isRouteEligible: () => routeActive && providerRegistered,
|
|
752
|
+
getConfig: () => currentConfig(),
|
|
753
|
+
getDataSnapshot: () => data,
|
|
754
|
+
nextRotation: (() => {
|
|
755
|
+
let counter = 0
|
|
756
|
+
return () => counter++
|
|
757
|
+
})(),
|
|
758
|
+
recordSuccess: (keyId, credits, ms) => {
|
|
759
|
+
const stats = data.stats[keyId] || (data.stats[keyId] = emptyStats())
|
|
760
|
+
stats.requests += 1
|
|
761
|
+
stats.success += 1
|
|
762
|
+
stats.creditsUsed += credits
|
|
763
|
+
stats.lastUsedAt = nowIso()
|
|
764
|
+
stats.lastError = null
|
|
765
|
+
stats.lastLatencyMs = ms
|
|
766
|
+
const cached = data.usageCache[keyId]
|
|
767
|
+
if (cached) {
|
|
768
|
+
// 密钥级已用与账户级计划已用各自前推,保证两种口径的显示都保持新鲜
|
|
769
|
+
if (cached.usage !== null && cached.usage !== undefined) cached.usage += credits
|
|
770
|
+
if (cached.planUsage !== null && cached.planUsage !== undefined) cached.planUsage += credits
|
|
771
|
+
cached.localAdjustedAt = nowIso()
|
|
772
|
+
}
|
|
773
|
+
scheduleFlush()
|
|
774
|
+
},
|
|
775
|
+
recordFailure: (keyId, error) => {
|
|
776
|
+
const stats = data.stats[keyId] || (data.stats[keyId] = emptyStats())
|
|
777
|
+
stats.requests += 1
|
|
778
|
+
stats.failed += 1
|
|
779
|
+
stats.lastUsedAt = nowIso()
|
|
780
|
+
stats.lastError = error && error.message ? String(error.message) : String(error)
|
|
781
|
+
scheduleFlush()
|
|
782
|
+
},
|
|
783
|
+
setCooldown: (keyId, ms) => {
|
|
784
|
+
const stats = data.stats[keyId] || (data.stats[keyId] = emptyStats())
|
|
785
|
+
stats.cooldownUntil = Date.now() + ms
|
|
786
|
+
scheduleFlush()
|
|
787
|
+
},
|
|
788
|
+
}))
|
|
789
|
+
providerRegistered = true
|
|
790
|
+
|
|
791
|
+
function recompute() {
|
|
792
|
+
const cfg = currentConfig()
|
|
793
|
+
routeActive = cfg.enabled === true && usableKeys(data).length > 0
|
|
794
|
+
}
|
|
795
|
+
recompute()
|
|
796
|
+
|
|
797
|
+
//# debounced persistence for hot-path stat writes ----
|
|
798
|
+
let flushTimer = null
|
|
799
|
+
function scheduleFlush() {
|
|
800
|
+
if (flushTimer !== null) return
|
|
801
|
+
flushTimer = setTimeout(() => {
|
|
802
|
+
flushTimer = null
|
|
803
|
+
persist().catch((e) => log('warn', `[dsh-tavily] 统计数据落盘失败: ${e && e.message}`))
|
|
804
|
+
}, 500)
|
|
805
|
+
if (typeof flushTimer === 'object' && flushTimer !== null && typeof flushTimer.unref === 'function') flushTimer.unref()
|
|
806
|
+
}
|
|
807
|
+
ctx.effect(() => () => {
|
|
808
|
+
if (flushTimer !== null) { clearTimeout(flushTimer); flushTimer = null }
|
|
809
|
+
}, 'dsh-tavily: flush timer')
|
|
810
|
+
|
|
811
|
+
//# HTTP surface ----
|
|
812
|
+
function sendJson(res, status, body) {
|
|
813
|
+
res.writeHead(status, {
|
|
814
|
+
'content-type': 'application/json; charset=utf-8',
|
|
815
|
+
'x-content-type-options': 'nosniff',
|
|
816
|
+
'cache-control': 'no-store',
|
|
817
|
+
})
|
|
818
|
+
res.end(JSON.stringify(body))
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function readBody(req) {
|
|
822
|
+
return new Promise((resolve, reject) => {
|
|
823
|
+
const chunks = []
|
|
824
|
+
req.on('data', (c) => chunks.push(c))
|
|
825
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
826
|
+
req.on('error', reject)
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function parseJsonBody(req) {
|
|
831
|
+
const raw = await readBody(req)
|
|
832
|
+
if (!raw || !raw.trim()) return {}
|
|
833
|
+
return JSON.parse(raw) // throws SyntaxError -> 400 at the dispatch site
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function publicState() {
|
|
837
|
+
const cfg = currentConfig()
|
|
838
|
+
const keysView = data.keys.map((k, index) => {
|
|
839
|
+
const cached = data.usageCache[k.id] || null
|
|
840
|
+
const fetchedMs = cached && typeof cached.fetchedAt === 'string' ? Date.parse(cached.fetchedAt) : NaN
|
|
841
|
+
const stale = !Number.isFinite(fetchedMs) || (Date.now() - fetchedMs) > USAGE_STALE_MS
|
|
842
|
+
return {
|
|
843
|
+
id: k.id,
|
|
844
|
+
label: k.label,
|
|
845
|
+
masked: maskKey(k.key),
|
|
846
|
+
disabled: k.disabled === true,
|
|
847
|
+
addedAt: k.addedAt,
|
|
848
|
+
hasWarningPrefix: !/^tvly-/i.test(k.key),
|
|
849
|
+
usage: cached,
|
|
850
|
+
stale,
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
const statsView = {}
|
|
854
|
+
for (const k of data.keys) {
|
|
855
|
+
const s = data.stats[k.id]
|
|
856
|
+
statsView[k.id] = {
|
|
857
|
+
requests: s ? s.requests : 0,
|
|
858
|
+
success: s ? s.success : 0,
|
|
859
|
+
failed: s ? s.failed : 0,
|
|
860
|
+
creditsUsed: s ? s.creditsUsed : 0,
|
|
861
|
+
lastUsedAt: s ? s.lastUsedAt : null,
|
|
862
|
+
lastError: s ? s.lastError : null,
|
|
863
|
+
lastLatencyMs: s && Number.isFinite(s.lastLatencyMs) ? s.lastLatencyMs : null,
|
|
864
|
+
cooling: cooldownActive(data, k.id, Date.now()),
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
version: '0.1.0',
|
|
869
|
+
config: cfg,
|
|
870
|
+
settingsBound,
|
|
871
|
+
dataPath,
|
|
872
|
+
routing: {
|
|
873
|
+
active: routeActive && providerRegistered,
|
|
874
|
+
providerRegistered,
|
|
875
|
+
overrideInstalled,
|
|
876
|
+
baselineId: baseline === undefined ? null : baseline,
|
|
877
|
+
registeredProviders: Array.from(web.searchProviders ? web.searchProviders.keys() : []),
|
|
878
|
+
officialDetected: Boolean(web.searchProviders && web.searchProviders.has && web.searchProviders.has('deepseek-official')),
|
|
879
|
+
},
|
|
880
|
+
keys: keysView,
|
|
881
|
+
order: [...data.order],
|
|
882
|
+
stats: statsView,
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function findKey(id) {
|
|
887
|
+
return data.keys.find((k) => k.id === id) || null
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const handlers = {
|
|
891
|
+
async state(req, res) {
|
|
892
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
async config(req, res) {
|
|
896
|
+
const body = await parseJsonBody(req)
|
|
897
|
+
const patch = {}
|
|
898
|
+
if (body.enabled !== undefined) {
|
|
899
|
+
if (typeof body.enabled !== 'boolean') { sendJson(res, 400, { ok: false, error: 'enabled 必须是布尔值' }); return }
|
|
900
|
+
patch.enabled = body.enabled
|
|
901
|
+
}
|
|
902
|
+
if (body.strategy !== undefined) {
|
|
903
|
+
if (!STRATEGIES.includes(body.strategy)) { sendJson(res, 400, { ok: false, error: 'strategy 必须是 balance 或 manual' }); return }
|
|
904
|
+
patch.strategy = body.strategy
|
|
905
|
+
}
|
|
906
|
+
if (body.searchDepth !== undefined) {
|
|
907
|
+
if (!SEARCH_DEPTHS.includes(body.searchDepth)) { sendJson(res, 400, { ok: false, error: 'searchDepth 必须是 basic / advanced / fast / ultra-fast' }); return }
|
|
908
|
+
patch.searchDepth = body.searchDepth
|
|
909
|
+
}
|
|
910
|
+
if (body.maxResults !== undefined) {
|
|
911
|
+
const n = Number(body.maxResults)
|
|
912
|
+
if (!Number.isInteger(n) || n < 1 || n > 20) { sendJson(res, 400, { ok: false, error: 'maxResults 必须是 1-20 的整数' }); return }
|
|
913
|
+
patch.maxResults = n
|
|
914
|
+
}
|
|
915
|
+
if (body.topic !== undefined) {
|
|
916
|
+
if (!TOPICS.includes(body.topic)) { sendJson(res, 400, { ok: false, error: 'topic 必须是 general / news / finance' }); return }
|
|
917
|
+
patch.topic = body.topic
|
|
918
|
+
}
|
|
919
|
+
if (body.includeAnswer !== undefined) {
|
|
920
|
+
if (typeof body.includeAnswer !== 'boolean') { sendJson(res, 400, { ok: false, error: 'includeAnswer 必须是布尔值' }); return }
|
|
921
|
+
patch.includeAnswer = body.includeAnswer
|
|
922
|
+
}
|
|
923
|
+
if (settingsBound) {
|
|
924
|
+
try { await ctx.settings.update(SETTINGS_NAMESPACE, patch) } catch (e) {
|
|
925
|
+
sendJson(res, 500, { ok: false, error: `写入设置失败: ${e && e.message}` }); return
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
sendJson(res, 500, { ok: false, error: 'settings 服务不可用,无法保存配置' }); return
|
|
929
|
+
}
|
|
930
|
+
recompute()
|
|
931
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
async 'keys.add'(req, res) {
|
|
935
|
+
const body = await parseJsonBody(req)
|
|
936
|
+
const key = typeof body.key === 'string' ? body.key.trim() : ''
|
|
937
|
+
const label = typeof body.label === 'string' ? body.label.trim() : ''
|
|
938
|
+
if (key.length < 8) { sendJson(res, 400, { ok: false, error: '请输入有效的 Tavily API Key' }); return }
|
|
939
|
+
if (data.keys.some((k) => k.key === key)) { sendJson(res, 400, { ok: false, error: '该密钥已存在' }); return }
|
|
940
|
+
const entry = { id: genId('tvly'), key, label: label || maskKey(key), addedAt: nowIso(), disabled: false }
|
|
941
|
+
data.keys.push(entry)
|
|
942
|
+
data.order.push(entry.id)
|
|
943
|
+
data.stats[entry.id] = emptyStats()
|
|
944
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 密钥落盘失败: ${e && e.message}`) }
|
|
945
|
+
recompute()
|
|
946
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
947
|
+
},
|
|
948
|
+
|
|
949
|
+
async 'keys.update'(req, res) {
|
|
950
|
+
const body = await parseJsonBody(req)
|
|
951
|
+
const entry = findKey(typeof body.id === 'string' ? body.id : '')
|
|
952
|
+
if (!entry) { sendJson(res, 404, { ok: false, error: '密钥不存在' }); return }
|
|
953
|
+
if (body.label !== undefined) {
|
|
954
|
+
if (typeof body.label !== 'string') { sendJson(res, 400, { ok: false, error: 'label 必须是字符串' }); return }
|
|
955
|
+
entry.label = body.label.trim() || maskKey(entry.key)
|
|
956
|
+
}
|
|
957
|
+
if (body.disabled !== undefined) {
|
|
958
|
+
if (typeof body.disabled !== 'boolean') { sendJson(res, 400, { ok: false, error: 'disabled 必须是布尔值' }); return }
|
|
959
|
+
entry.disabled = body.disabled
|
|
960
|
+
}
|
|
961
|
+
if (body.removeCooldown === true) {
|
|
962
|
+
const s = data.stats[entry.id]
|
|
963
|
+
if (s) delete s.cooldownUntil
|
|
964
|
+
}
|
|
965
|
+
if (body.resetStats === true) data.stats[entry.id] = emptyStats()
|
|
966
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 更新落盘失败: ${e && e.message}`) }
|
|
967
|
+
recompute()
|
|
968
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
async 'keys.move'(req, res) {
|
|
972
|
+
const body = await parseJsonBody(req)
|
|
973
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
974
|
+
const dirStep = body.dir === 1 || body.dir === '1' ? 1 : body.dir === -1 || body.dir === '-1' ? -1 : 0
|
|
975
|
+
if (dirStep === 0) { sendJson(res, 400, { ok: false, error: 'dir 必须是 1 或 -1' }); return }
|
|
976
|
+
// Move within the FULL key list so ↑↓ matches what the user sees.
|
|
977
|
+
const ids = data.keys.map((k) => k.id)
|
|
978
|
+
const at = ids.indexOf(id)
|
|
979
|
+
const to = at + dirStep
|
|
980
|
+
if (at === -1) { sendJson(res, 404, { ok: false, error: '密钥不存在' }); return }
|
|
981
|
+
if (to >= 0 && to < ids.length) {
|
|
982
|
+
ids.splice(to, 0, ids.splice(at, 1)[0])
|
|
983
|
+
data.order = ids
|
|
984
|
+
}
|
|
985
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 排序落盘失败: ${e && e.message}`) }
|
|
986
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
async 'keys.remove'(req, res) {
|
|
990
|
+
const body = await parseJsonBody(req)
|
|
991
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
992
|
+
const at = data.keys.findIndex((k) => k.id === id)
|
|
993
|
+
if (at === -1) { sendJson(res, 404, { ok: false, error: '密钥不存在' }); return }
|
|
994
|
+
data.keys.splice(at, 1)
|
|
995
|
+
data.order = data.order.filter((x) => x !== id)
|
|
996
|
+
delete data.stats[id]
|
|
997
|
+
delete data.usageCache[id]
|
|
998
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 删除落盘失败: ${e && e.message}`) }
|
|
999
|
+
recompute()
|
|
1000
|
+
sendJson(res, 200, { ok: true, state: publicState() })
|
|
1001
|
+
},
|
|
1002
|
+
|
|
1003
|
+
async 'keys.test'(req, res) {
|
|
1004
|
+
const body = await parseJsonBody(req)
|
|
1005
|
+
const entry = findKey(typeof body.id === 'string' ? body.id : '')
|
|
1006
|
+
if (!entry) { sendJson(res, 404, { ok: false, error: '密钥不存在' }); return }
|
|
1007
|
+
const cfg = currentConfig()
|
|
1008
|
+
const started = Date.now()
|
|
1009
|
+
try {
|
|
1010
|
+
const { credits } = await tavilySearch({
|
|
1011
|
+
apiKey: entry.key,
|
|
1012
|
+
query: 'tavily connectivity check',
|
|
1013
|
+
maxResults: 1,
|
|
1014
|
+
config: { ...cfg, searchDepth: 'basic', includeAnswer: false },
|
|
1015
|
+
signal: undefined,
|
|
1016
|
+
})
|
|
1017
|
+
hostlessRecordSuccess(entry.id, credits, Date.now() - started)
|
|
1018
|
+
const s = data.stats[entry.id]
|
|
1019
|
+
if (s) delete s.cooldownUntil
|
|
1020
|
+
try { await persist() } catch { /* best effort */ }
|
|
1021
|
+
sendJson(res, 200, {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
result: { latencyMs: Date.now() - started, credits },
|
|
1024
|
+
state: publicState(),
|
|
1025
|
+
})
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
hostlessRecordFailure(entry.id, error)
|
|
1028
|
+
if (typeof error.status === 'number' && (error.status === 401 || error.status === 403 || error.status === 432 || error.status === 433)) {
|
|
1029
|
+
const s = data.stats[entry.id] || (data.stats[entry.id] = emptyStats())
|
|
1030
|
+
s.cooldownUntil = Date.now() + KEY_COOLDOWN_MS
|
|
1031
|
+
}
|
|
1032
|
+
try { await persist() } catch { /* best effort */ }
|
|
1033
|
+
sendJson(res, 200, { ok: false, error: error && error.message ? error.message : String(error), state: publicState() })
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
|
|
1037
|
+
async 'usage.refresh'(req, res) {
|
|
1038
|
+
const results = {}
|
|
1039
|
+
for (const entry of data.keys) {
|
|
1040
|
+
try {
|
|
1041
|
+
const usage = await tavilyUsage(entry.key, undefined)
|
|
1042
|
+
data.usageCache[entry.id] = usage
|
|
1043
|
+
results[entry.id] = { ok: true }
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
results[entry.id] = { ok: false, error: error && error.message ? error.message : String(error) }
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
try { await persist() } catch (e) { log('warn', `[dsh-tavily] 用量缓存落盘失败: ${e && e.message}`) }
|
|
1049
|
+
sendJson(res, 200, { ok: true, results, state: publicState() })
|
|
1050
|
+
},
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Test handler shares the success/failure bookkeeping minus rotation side effects.
|
|
1054
|
+
function hostlessRecordSuccess(keyId, credits, ms) {
|
|
1055
|
+
const stats = data.stats[keyId] || (data.stats[keyId] = emptyStats())
|
|
1056
|
+
stats.requests += 1
|
|
1057
|
+
stats.success += 1
|
|
1058
|
+
stats.creditsUsed += credits
|
|
1059
|
+
stats.lastUsedAt = nowIso()
|
|
1060
|
+
stats.lastError = null
|
|
1061
|
+
stats.lastLatencyMs = ms
|
|
1062
|
+
const cached = data.usageCache[keyId]
|
|
1063
|
+
if (cached && cached.usage !== null && cached.usage !== undefined) {
|
|
1064
|
+
cached.usage += credits
|
|
1065
|
+
cached.localAdjustedAt = nowIso()
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
function hostlessRecordFailure(keyId, error) {
|
|
1069
|
+
const stats = data.stats[keyId] || (data.stats[keyId] = emptyStats())
|
|
1070
|
+
stats.requests += 1
|
|
1071
|
+
stats.failed += 1
|
|
1072
|
+
stats.lastUsedAt = nowIso()
|
|
1073
|
+
stats.lastError = error && error.message ? String(error.message) : String(error)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
//# optional webui-auth session guard ----
|
|
1077
|
+
const sessionStoreFile = await findWebuiAuthSessionsFile(ctx)
|
|
1078
|
+
const sessionVerifier = sessionStoreFile ? createSessionVerifier(ctx, sessionStoreFile) : null
|
|
1079
|
+
if (sessionVerifier) log('info', `[dsh-tavily] 已启用 dsh-webui-auth 会话校验(${sessionStoreFile})`)
|
|
1080
|
+
|
|
1081
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1082
|
+
kind: 'prefix',
|
|
1083
|
+
path: HTTP_PREFIX,
|
|
1084
|
+
handler: async (req, res) => {
|
|
1085
|
+
try {
|
|
1086
|
+
if (sessionVerifier && !(await sessionVerifier.verify(req))) {
|
|
1087
|
+
sendJson(res, 401, { ok: false, error: 'unauthorized: 请先登录 Web UI' })
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
let pathname = String(req.url ?? '/')
|
|
1091
|
+
const q = pathname.indexOf('?')
|
|
1092
|
+
if (q !== -1) pathname = pathname.slice(0, q)
|
|
1093
|
+
const sub = pathname.startsWith(HTTP_PREFIX) ? pathname.slice(HTTP_PREFIX.length) : pathname
|
|
1094
|
+
const route = pickRoute(req.method, sub)
|
|
1095
|
+
const handler = handlers[route.action]
|
|
1096
|
+
if (!handler) { sendJson(res, 404, { ok: false, error: `未知端点: ${req.method} ${pathname}` }); return }
|
|
1097
|
+
await handler(req, res)
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
const syntax = error instanceof SyntaxError
|
|
1100
|
+
sendJson(res, syntax ? 400 : 500, { ok: false, error: error && error.message ? error.message : String(error) })
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
}), 'dsh-tavily: http endpoints')
|
|
1104
|
+
|
|
1105
|
+
log('info', `[dsh-tavily] 已加载。数据文件: ${dataPath};路由状态: ${routeActive ? '已接管网页搜索' : '未接管'};官方搜索提供方${web.searchProviders && web.searchProviders.has('deepseek-official') ? '已检测到' : '未检测到'}。`)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
//#endregion
|
|
1109
|
+
|
|
1110
|
+
//#region exported internals (unit-test surface) -----------------------------------------
|
|
1111
|
+
|
|
1112
|
+
export const __internals = {
|
|
1113
|
+
CONFIG_DEFAULTS,
|
|
1114
|
+
PROVIDER_ID,
|
|
1115
|
+
SETTINGS_NAMESPACE,
|
|
1116
|
+
HTTP_PREFIX,
|
|
1117
|
+
normalizeConfig,
|
|
1118
|
+
buildSchema,
|
|
1119
|
+
sanitizeData,
|
|
1120
|
+
maskKey,
|
|
1121
|
+
usableKeys,
|
|
1122
|
+
orderKeys,
|
|
1123
|
+
remainingRank,
|
|
1124
|
+
cooldownActive,
|
|
1125
|
+
mapSearchResponse,
|
|
1126
|
+
pickRoute,
|
|
1127
|
+
tavilySearch,
|
|
1128
|
+
tavilyUsage,
|
|
1129
|
+
parseCookies,
|
|
1130
|
+
findWebuiAuthSessionsFile,
|
|
1131
|
+
createSessionVerifier,
|
|
1132
|
+
TavilySearchProvider,
|
|
1133
|
+
TavilyProviderError,
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
//#endregion
|