@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/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