@moguiyu/dsh-tavily 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/client.js +324 -27
- package/package.json +4 -3
- package/src/backend.js +110 -11
- package/src/client.js +324 -27
- package/src/index.js +2 -1
- package/src/lib.js +123 -3
- package/src/tools.js +178 -6
package/src/backend.js
CHANGED
|
@@ -11,11 +11,26 @@
|
|
|
11
11
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
14
|
-
import { STRATEGIES, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
|
|
14
|
+
import { STRATEGIES, accountFingerprint, clampAccountCap, groupAccounts, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Tavily allows only **10 `/usage` requests per 10 minutes** per key, so the
|
|
18
|
+
* card's refreshes must not each become an upstream call: entries are served
|
|
19
|
+
* for two minutes, and a 429 stops asking for as long as `Retry-After` says
|
|
20
|
+
* (ten minutes by default) while the numbers already loaded keep being served.
|
|
21
|
+
*/
|
|
22
|
+
const USAGE_TTL_MS = 120000
|
|
23
|
+
const USAGE_BACKOFF_MS = 600000
|
|
17
24
|
const usageCache = new Map()
|
|
18
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Test seam: age every cache entry so the next read is a real upstream call.
|
|
28
|
+
* The cache is module-scoped and time-based, which no test can otherwise reach.
|
|
29
|
+
*/
|
|
30
|
+
export function ageUsageCache(ms) {
|
|
31
|
+
for (const [key, entry] of usageCache) usageCache.set(key, { ...entry, at: entry.at - ms })
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
const MANAGER_STATE = 'tavily-manager.json'
|
|
20
35
|
const LEGACY_STATE = 'tavily-settings.json'
|
|
21
36
|
const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
|
|
@@ -50,7 +65,11 @@ function readBody(req) {
|
|
|
50
65
|
|
|
51
66
|
async function fetchUsageDetailsFor(key) {
|
|
52
67
|
const cached = usageCache.get(key)
|
|
53
|
-
|
|
68
|
+
const now = Date.now()
|
|
69
|
+
if (cached !== undefined) {
|
|
70
|
+
if (cached.until !== undefined && now < cached.until) return cached.details
|
|
71
|
+
if (now - cached.at < USAGE_TTL_MS) return cached.details
|
|
72
|
+
}
|
|
54
73
|
try {
|
|
55
74
|
const controller = new AbortController()
|
|
56
75
|
const timer = setTimeout(() => controller.abort(), 15000)
|
|
@@ -63,7 +82,18 @@ async function fetchUsageDetailsFor(key) {
|
|
|
63
82
|
} finally {
|
|
64
83
|
clearTimeout(timer)
|
|
65
84
|
}
|
|
66
|
-
if (!response.ok)
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
if (response.status === 429) {
|
|
87
|
+
const header = response.headers !== undefined && typeof response.headers.get === 'function'
|
|
88
|
+
? Number(response.headers.get('retry-after'))
|
|
89
|
+
: NaN
|
|
90
|
+
const waitMs = Number.isFinite(header) && header > 0 ? header * 1000 : USAGE_BACKOFF_MS
|
|
91
|
+
const details = cached !== undefined ? cached.details : null
|
|
92
|
+
usageCache.set(key, { at: cached !== undefined ? cached.at : now, details, until: now + waitMs })
|
|
93
|
+
return details
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
67
97
|
const json = await response.json()
|
|
68
98
|
const account = json !== null && typeof json === 'object' ? json.account : undefined
|
|
69
99
|
const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
|
|
@@ -81,7 +111,7 @@ async function fetchUsageDetailsFor(key) {
|
|
|
81
111
|
? account.plan
|
|
82
112
|
: (keyUsage !== undefined && typeof keyUsage.plan === 'string' ? keyUsage.plan : null)
|
|
83
113
|
if (usage === null && planUsage === null) return null
|
|
84
|
-
const details = { usage, planUsage, planLimit, currentPlan }
|
|
114
|
+
const details = { usage, planUsage, planLimit, currentPlan, accountId: accountFingerprint(account) }
|
|
85
115
|
usageCache.set(key, { at: Date.now(), details })
|
|
86
116
|
return details
|
|
87
117
|
} catch {
|
|
@@ -166,6 +196,35 @@ async function syncPrimary(credentials, value) {
|
|
|
166
196
|
}
|
|
167
197
|
}
|
|
168
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Apply the card's per-key account choices to the stored grouping.
|
|
201
|
+
*
|
|
202
|
+
* `auto` drops the entry so the next usage scan infers it again, `own` pins the
|
|
203
|
+
* key as its own account (the escape hatch when the inference wrongly merges two
|
|
204
|
+
* accounts), and any other string joins that group. Unknown keys are ignored.
|
|
205
|
+
*/
|
|
206
|
+
function applyAccountOverrides(previous, patch, keys) {
|
|
207
|
+
const next = previous !== null && previous !== undefined && typeof previous === 'object' && !Array.isArray(previous)
|
|
208
|
+
? { ...previous }
|
|
209
|
+
: {}
|
|
210
|
+
const known = new Set(keys.map((key) => maskValue(key)))
|
|
211
|
+
for (const [masked, choice] of Object.entries(patch)) {
|
|
212
|
+
if (!known.has(masked)) continue
|
|
213
|
+
if (choice === null || choice === undefined || choice === 'auto') {
|
|
214
|
+
delete next[masked]
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
if (choice === 'own') {
|
|
218
|
+
next[masked] = { group: 'own:' + masked, manual: true }
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (typeof choice === 'string' && choice.length > 0) {
|
|
222
|
+
next[masked] = { group: choice, manual: true }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return next
|
|
226
|
+
}
|
|
227
|
+
|
|
169
228
|
/**
|
|
170
229
|
* Register the Tavily key/usage routes on `ctx.webServer`:
|
|
171
230
|
* `/api/tavily-usage` and `/api/tavily-manager`.
|
|
@@ -229,6 +288,12 @@ export function installBackend(ctx) {
|
|
|
229
288
|
removable,
|
|
230
289
|
})),
|
|
231
290
|
strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
|
|
291
|
+
// The tools half reads these from the state file; the card needs them to
|
|
292
|
+
// show which keys share an account and to offer the per-key override.
|
|
293
|
+
accounts: state.accounts !== null && state.accounts !== undefined && typeof state.accounts === 'object' && !Array.isArray(state.accounts)
|
|
294
|
+
? state.accounts
|
|
295
|
+
: {},
|
|
296
|
+
accountCap: clampAccountCap(state.accountCap),
|
|
232
297
|
source,
|
|
233
298
|
writable: { keys: keysWritable, primary: primaryWritable },
|
|
234
299
|
}
|
|
@@ -253,20 +318,44 @@ export function installBackend(ctx) {
|
|
|
253
318
|
return am < bm ? -1 : 1
|
|
254
319
|
})
|
|
255
320
|
const perKey = []
|
|
321
|
+
const entries = []
|
|
256
322
|
for (const key of orderedKeys) {
|
|
323
|
+
const masked = maskValue(key)
|
|
257
324
|
try {
|
|
258
325
|
const usage = await fetchUsageDetailsFor(key)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
326
|
+
if (usage !== null) {
|
|
327
|
+
perKey.push({
|
|
328
|
+
ok: true,
|
|
329
|
+
masked,
|
|
330
|
+
usage: usage.usage,
|
|
331
|
+
planUsage: usage.planUsage,
|
|
332
|
+
planLimit: usage.planLimit,
|
|
333
|
+
currentPlan: usage.currentPlan,
|
|
334
|
+
})
|
|
335
|
+
entries.push({ masked, fingerprint: typeof usage.accountId === 'string' ? usage.accountId : null })
|
|
336
|
+
} else {
|
|
337
|
+
perKey.push({ ok: false, masked, error: 'usage unavailable' })
|
|
338
|
+
entries.push({ masked, fingerprint: null })
|
|
339
|
+
}
|
|
262
340
|
} catch (error) {
|
|
263
|
-
perKey.push({ ok: false, masked
|
|
341
|
+
perKey.push({ ok: false, masked, error: String(error && error.message ? error.message : error) })
|
|
342
|
+
entries.push({ masked, fingerprint: null })
|
|
264
343
|
}
|
|
265
344
|
}
|
|
345
|
+
// Tavily has no account id, so the account block each key reports is the
|
|
346
|
+
// only grouping signal there is. Recompute it here (the one place that
|
|
347
|
+
// already pays for `/usage`), persist it, and hand it to the card.
|
|
348
|
+
const accounts = groupAccounts(entries, state.accounts)
|
|
349
|
+
const accountCap = clampAccountCap(state.accountCap)
|
|
350
|
+
if (JSON.stringify(state.accounts ?? null) !== JSON.stringify(accounts)) {
|
|
351
|
+
writeState(MANAGER_STATE, { ...state, accounts, accountCap })
|
|
352
|
+
}
|
|
266
353
|
const okRows = perKey.filter((row) => row.ok)
|
|
267
354
|
return send(res, 200, {
|
|
268
355
|
ok: true,
|
|
269
356
|
perKey,
|
|
357
|
+
accounts,
|
|
358
|
+
accountCap,
|
|
270
359
|
totals: {
|
|
271
360
|
keys: keys.length,
|
|
272
361
|
okKeys: okRows.length,
|
|
@@ -343,7 +432,10 @@ export function installBackend(ctx) {
|
|
|
343
432
|
await credentials.unset('TAVILY_API_KEYS')
|
|
344
433
|
await syncPrimary(credentials, null)
|
|
345
434
|
} else {
|
|
346
|
-
|
|
435
|
+
// Only the usage-ordered strategies need Tavily's counters, and
|
|
436
|
+
// `/usage` allows just 10 requests per 10 minutes: never spend one
|
|
437
|
+
// on a strategy that keeps the list order anyway.
|
|
438
|
+
if (strategy === 'low-usage-first' || strategy === 'high-usage-first') {
|
|
347
439
|
const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
|
|
348
440
|
const usageOf = (value) => {
|
|
349
441
|
const row = usageRows.find((r) => r.value === value)
|
|
@@ -362,7 +454,14 @@ export function installBackend(ctx) {
|
|
|
362
454
|
const masked = maskValue(value)
|
|
363
455
|
if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
|
|
364
456
|
}
|
|
365
|
-
|
|
457
|
+
// Spread the previous state: writing only these two keys used to drop
|
|
458
|
+
// every other one — including the account grouping and the cap.
|
|
459
|
+
const nextState = { ...state, keySavedAt, strategy }
|
|
460
|
+
if (body.accountCap !== undefined) nextState.accountCap = clampAccountCap(body.accountCap)
|
|
461
|
+
if (body.accounts !== null && body.accounts !== undefined && typeof body.accounts === 'object' && !Array.isArray(body.accounts)) {
|
|
462
|
+
nextState.accounts = applyAccountOverrides(state.accounts, body.accounts, values)
|
|
463
|
+
}
|
|
464
|
+
writeState(MANAGER_STATE, nextState)
|
|
366
465
|
|
|
367
466
|
return send(res, 200, { ok: true, ...(await buildManagerPayload(values, source)) })
|
|
368
467
|
}
|
package/src/client.js
CHANGED
|
@@ -11,11 +11,18 @@ export function factory(require) {
|
|
|
11
11
|
const inject = ['slots']
|
|
12
12
|
|
|
13
13
|
const STRATEGIES = [
|
|
14
|
-
{ id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429 the next key is tried automatically.' },
|
|
14
|
+
{ id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429/432/433 the next key is tried automatically.' },
|
|
15
15
|
{ id: 'low-usage-first', label: 'Lowest usage first', hint: 'Re-orders keys by current Tavily usage, least-used first. The first key becomes primary.' },
|
|
16
|
-
{ id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' }
|
|
16
|
+
{ id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' },
|
|
17
|
+
{
|
|
18
|
+
id: 'load-balance',
|
|
19
|
+
label: 'Load balance',
|
|
20
|
+
hint: 'Keeps at most the set number of requests in flight per Tavily account and spreads concurrent calls across accounts, so one free account is never hit in parallel. Accounts are inferred from live Tavily usage data — pin a key below if that is wrong. Keys on one account share its 100 requests/minute.'
|
|
21
|
+
}
|
|
17
22
|
]
|
|
18
23
|
|
|
24
|
+
const ACCOUNT_CAPS = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
25
|
+
|
|
19
26
|
const ICONS = {
|
|
20
27
|
eye: ['M1.5 8C1.5 8 4 3.5 8 3.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z', 'M8 5.8a2.2 2.2 0 1 0 0 4.4 2.2 2.2 0 0 0 0-4.4Z'],
|
|
21
28
|
eyeOff: ['M1.5 8C1.5 8 4 3.5 8 3.5c1.6 0 3 .5 4.1 1.2', 'M14.5 8C14.5 8 12 12.5 8 12.5c-1.6 0-3-.5-4.1-1.2', 'M2 2l12 12', 'M8 5.8a2.2 2.2 0 0 1 2.2 2.2'],
|
|
@@ -37,6 +44,113 @@ export function factory(require) {
|
|
|
37
44
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
function formatClock(iso) {
|
|
48
|
+
if (!iso) return null
|
|
49
|
+
const d = new Date(iso)
|
|
50
|
+
if (isNaN(d.getTime())) return null
|
|
51
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
52
|
+
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `/api/tavily-usage` asks Tavily for every key's counters, so it can take a
|
|
56
|
+
// while. Two rules follow: the cell must never look *empty* while that is in
|
|
57
|
+
// flight, and the last values already shown must survive a page reload. Only
|
|
58
|
+
// masked names and counters are stored — never key material.
|
|
59
|
+
const USAGE_SNAPSHOT_KEY = 'dsh-tavily:usage-snapshot:v1'
|
|
60
|
+
const RING_GROWTH_MS = 650
|
|
61
|
+
|
|
62
|
+
function usageStorage() {
|
|
63
|
+
try {
|
|
64
|
+
if (typeof window === 'undefined' || window === null || !window.localStorage) return null
|
|
65
|
+
return window.localStorage
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readUsageSnapshot() {
|
|
72
|
+
const storage = usageStorage()
|
|
73
|
+
if (storage === null) return null
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(storage.getItem(USAGE_SNAPSHOT_KEY))
|
|
76
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
77
|
+
if (parsed.version !== 1) return null
|
|
78
|
+
if (parsed.rows === null || typeof parsed.rows !== 'object') return null
|
|
79
|
+
return parsed
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeUsageSnapshot(data) {
|
|
86
|
+
const storage = usageStorage()
|
|
87
|
+
if (storage === null) return
|
|
88
|
+
try {
|
|
89
|
+
const perKey = data !== null && data !== undefined && Array.isArray(data.perKey) ? data.perKey : []
|
|
90
|
+
const rows = {}
|
|
91
|
+
for (const row of perKey) {
|
|
92
|
+
if (row === null || typeof row !== 'object' || typeof row.masked !== 'string') continue
|
|
93
|
+
rows[row.masked] = {
|
|
94
|
+
ok: row.ok === true,
|
|
95
|
+
usage: typeof row.usage === 'number' ? row.usage : null,
|
|
96
|
+
planUsage: typeof row.planUsage === 'number' ? row.planUsage : null,
|
|
97
|
+
planLimit: typeof row.planLimit === 'number' ? row.planLimit : null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(rows).length === 0) return
|
|
101
|
+
storage.setItem(USAGE_SNAPSHOT_KEY, JSON.stringify({ version: 1, at: new Date().toISOString(), rows: rows }))
|
|
102
|
+
} catch (error) {
|
|
103
|
+
/* storage is best-effort; the card still works without it */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function usagePercent(row) {
|
|
108
|
+
if (row === null || row === undefined || row.ok !== true) return null
|
|
109
|
+
if (row.planLimit === null || row.planLimit === undefined || row.planLimit <= 0) return null
|
|
110
|
+
if (row.planUsage === null || row.planUsage === undefined) return null
|
|
111
|
+
return Math.min(100, Math.round((row.planUsage / row.planLimit) * 100))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Count a number up to `target` instead of swapping it. Returns the value to
|
|
116
|
+
* paint now. A first-ever value (or a non-number) is set instantly: there is
|
|
117
|
+
* nothing to grow from.
|
|
118
|
+
*/
|
|
119
|
+
function useAnimatedNumber(target, durationMs) {
|
|
120
|
+
const [display, setDisplay] = react.useState(target)
|
|
121
|
+
const displayRef = react.useRef(target)
|
|
122
|
+
const frameRef = react.useRef(0)
|
|
123
|
+
react.useEffect(() => {
|
|
124
|
+
const from = typeof displayRef.current === 'number' ? displayRef.current : null
|
|
125
|
+
const settle = (value) => { displayRef.current = value; setDisplay(value) }
|
|
126
|
+
if (typeof target !== 'number' || from === null || from === target) {
|
|
127
|
+
settle(typeof target === 'number' ? target : null)
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
131
|
+
settle(target)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
let started = null
|
|
135
|
+
const tick = (now) => {
|
|
136
|
+
const stamp = typeof now === 'number' ? now : Date.now()
|
|
137
|
+
if (started === null) started = stamp
|
|
138
|
+
const elapsed = durationMs > 0 ? (stamp - started) / durationMs : 1
|
|
139
|
+
const t = Math.min(1, Math.max(0, elapsed))
|
|
140
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
141
|
+
const next = t >= 1 ? target : Math.round(from + (target - from) * eased)
|
|
142
|
+
displayRef.current = next
|
|
143
|
+
setDisplay(next)
|
|
144
|
+
if (t < 1) frameRef.current = requestAnimationFrame(tick)
|
|
145
|
+
}
|
|
146
|
+
frameRef.current = requestAnimationFrame(tick)
|
|
147
|
+
return () => {
|
|
148
|
+
if (typeof cancelAnimationFrame === 'function' && frameRef.current) cancelAnimationFrame(frameRef.current)
|
|
149
|
+
}
|
|
150
|
+
}, [target, durationMs])
|
|
151
|
+
return display
|
|
152
|
+
}
|
|
153
|
+
|
|
40
154
|
function SvgIcon({ name, size, style }) {
|
|
41
155
|
const paths = ICONS[name] || []
|
|
42
156
|
return react.createElement('svg', { width: size || 14, height: size || 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', style: style },
|
|
@@ -56,17 +170,54 @@ export function factory(require) {
|
|
|
56
170
|
}, react.createElement(SvgIcon, { name: icon, size: 13 }))
|
|
57
171
|
}
|
|
58
172
|
|
|
59
|
-
function UsageCircle({ percent,
|
|
173
|
+
function UsageCircle({ percent, number, loading, stale, staleAt, onClick }) {
|
|
60
174
|
const radius = 15
|
|
61
175
|
const circumference = 2 * Math.PI * radius
|
|
62
176
|
const pct = percent != null ? Math.min(100, Math.max(0, percent)) : 0
|
|
63
|
-
const dim =
|
|
64
|
-
|
|
65
|
-
|
|
177
|
+
const dim = 44
|
|
178
|
+
const animated = useAnimatedNumber(percent != null ? percent : (number != null ? number : null), RING_GROWTH_MS)
|
|
179
|
+
const shown = animated != null ? animated : (number != null ? number : null)
|
|
180
|
+
const offset = circumference - (pct / 100) * circumference
|
|
181
|
+
const label = percent != null
|
|
182
|
+
? (shown != null ? shown + '%' : '—')
|
|
183
|
+
: (shown != null ? String(shown) : '—')
|
|
184
|
+
const clock = formatClock(staleAt)
|
|
185
|
+
const title = loading
|
|
186
|
+
? (stale && clock !== null ? 'Updating usage… showing the values loaded at ' + clock : 'Updating usage…')
|
|
187
|
+
: (stale && clock !== null ? 'Showing the values loaded at ' + clock + ' — click to refresh' : 'Reload usage')
|
|
188
|
+
return react.createElement('button', {
|
|
189
|
+
type: 'button',
|
|
190
|
+
title: title,
|
|
191
|
+
'aria-label': 'Reload usage',
|
|
192
|
+
'aria-busy': loading ? 'true' : 'false',
|
|
193
|
+
'data-loading': loading ? 'true' : 'false',
|
|
194
|
+
'data-stale': stale ? 'true' : 'false',
|
|
195
|
+
onClick: onClick,
|
|
196
|
+
style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }
|
|
197
|
+
},
|
|
198
|
+
react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40', className: loading ? 'dts-ring dts-ring-loading' : 'dts-ring' },
|
|
66
199
|
react.createElement('circle', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 4 }),
|
|
67
|
-
react.createElement('circle', {
|
|
200
|
+
react.createElement('circle', {
|
|
201
|
+
className: 'dts-ring-progress',
|
|
202
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
203
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
204
|
+
strokeDasharray: circumference,
|
|
205
|
+
strokeDashoffset: offset,
|
|
206
|
+
transform: 'rotate(-90 20 20)',
|
|
207
|
+
// The offset is carried as a style value as well as an attribute:
|
|
208
|
+
// presentation attributes alone do not reliably transition.
|
|
209
|
+
style: { strokeDashoffset: offset, transition: 'stroke-dashoffset ' + RING_GROWTH_MS + 'ms cubic-bezier(.22,.61,.36,1)' }
|
|
210
|
+
}),
|
|
211
|
+
loading && react.createElement('g', { className: 'dts-ring-sweep', style: { transformOrigin: '20px 20px' } },
|
|
212
|
+
react.createElement('circle', {
|
|
213
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
214
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
215
|
+
strokeDasharray: (circumference * 0.22) + ' ' + (circumference * 0.78),
|
|
216
|
+
opacity: 0.45
|
|
217
|
+
})
|
|
218
|
+
)
|
|
68
219
|
),
|
|
69
|
-
react.createElement('span', { style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label
|
|
220
|
+
react.createElement('span', { className: 'dts-ring-label', style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label)
|
|
70
221
|
)
|
|
71
222
|
}
|
|
72
223
|
|
|
@@ -84,39 +235,98 @@ export function factory(require) {
|
|
|
84
235
|
const [notice, setNotice] = react.useState(null)
|
|
85
236
|
const [usage, setUsage] = react.useState(null)
|
|
86
237
|
const [usageError, setUsageError] = react.useState(null)
|
|
238
|
+
const [usageLoading, setUsageLoading] = react.useState(true)
|
|
239
|
+
const [snapshot, setSnapshot] = react.useState(() => readUsageSnapshot())
|
|
87
240
|
|
|
88
241
|
const refresh = react.useCallback(async () => {
|
|
242
|
+
setUsageLoading(true)
|
|
243
|
+
// The two endpoints are independent and the usage one is the slow half,
|
|
244
|
+
// so they run together: the keys must not wait for Tavily's counters.
|
|
245
|
+
const keys = (async () => {
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
|
|
248
|
+
const data = await response.json()
|
|
249
|
+
if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
|
|
250
|
+
} catch (error) {
|
|
251
|
+
setLoadError(String(error && error.message ? error.message : error))
|
|
252
|
+
}
|
|
253
|
+
})()
|
|
254
|
+
const counters = (async () => {
|
|
255
|
+
try {
|
|
256
|
+
const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
|
|
257
|
+
const data = await response.json()
|
|
258
|
+
setUsage(data)
|
|
259
|
+
setUsageError(null)
|
|
260
|
+
if (data.ok) {
|
|
261
|
+
writeUsageSnapshot(data)
|
|
262
|
+
setSnapshot(readUsageSnapshot())
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
setUsageError(String(error && error.message ? error.message : error))
|
|
266
|
+
} finally {
|
|
267
|
+
setUsageLoading(false)
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
await Promise.all([keys, counters])
|
|
271
|
+
}, [])
|
|
272
|
+
|
|
273
|
+
react.useEffect(() => { refresh() }, [refresh])
|
|
274
|
+
|
|
275
|
+
const saveStrategy = async (next) => {
|
|
276
|
+
setBusy(true)
|
|
277
|
+
setNotice(null)
|
|
89
278
|
try {
|
|
90
|
-
const response = await fetch('/api/tavily-manager', {
|
|
279
|
+
const response = await fetch('/api/tavily-manager', {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: { 'content-type': 'application/json' },
|
|
282
|
+
body: JSON.stringify({ add: [], remove: [], strategy: next })
|
|
283
|
+
})
|
|
91
284
|
const data = await response.json()
|
|
92
|
-
if (data.ok) {
|
|
285
|
+
if (!data.ok) { setNotice({ error: data.error || 'Strategy save failed' }); return }
|
|
286
|
+
setNotice({ ok: 'Strategy saved — effective on the next search.' })
|
|
287
|
+
await refresh()
|
|
93
288
|
} catch (error) {
|
|
94
|
-
|
|
289
|
+
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
290
|
+
} finally {
|
|
291
|
+
setBusy(false)
|
|
95
292
|
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The account grouping is inferred from Tavily's usage counters, which is
|
|
296
|
+
// the only account-level signal the API offers; these two handlers are the
|
|
297
|
+
// human override for when the inference is wrong or the cap needs raising.
|
|
298
|
+
const saveAccount = async (masked, choice) => {
|
|
299
|
+
setBusy(true)
|
|
300
|
+
setNotice(null)
|
|
96
301
|
try {
|
|
97
|
-
const response = await fetch('/api/tavily-
|
|
302
|
+
const response = await fetch('/api/tavily-manager', {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: { 'content-type': 'application/json' },
|
|
305
|
+
body: JSON.stringify({ add: [], remove: [], strategy, accounts: { [masked]: choice } })
|
|
306
|
+
})
|
|
98
307
|
const data = await response.json()
|
|
99
|
-
|
|
100
|
-
|
|
308
|
+
if (!data.ok) { setNotice({ error: data.error || 'Account change failed' }); return }
|
|
309
|
+
setNotice({ ok: choice === 'auto' ? 'Account detected automatically again.' : 'Account pinned for this key.' })
|
|
310
|
+
await refresh()
|
|
101
311
|
} catch (error) {
|
|
102
|
-
|
|
312
|
+
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
313
|
+
} finally {
|
|
314
|
+
setBusy(false)
|
|
103
315
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
react.useEffect(() => { refresh() }, [refresh])
|
|
316
|
+
}
|
|
107
317
|
|
|
108
|
-
const
|
|
318
|
+
const saveCap = async (value) => {
|
|
109
319
|
setBusy(true)
|
|
110
320
|
setNotice(null)
|
|
111
321
|
try {
|
|
112
322
|
const response = await fetch('/api/tavily-manager', {
|
|
113
323
|
method: 'POST',
|
|
114
324
|
headers: { 'content-type': 'application/json' },
|
|
115
|
-
body: JSON.stringify({ add: [], remove: [], strategy:
|
|
325
|
+
body: JSON.stringify({ add: [], remove: [], strategy, accountCap: Number(value) })
|
|
116
326
|
})
|
|
117
327
|
const data = await response.json()
|
|
118
|
-
if (!data.ok) { setNotice({ error: data.error || '
|
|
119
|
-
setNotice({ ok: '
|
|
328
|
+
if (!data.ok) { setNotice({ error: data.error || 'Cap save failed' }); return }
|
|
329
|
+
setNotice({ ok: 'Concurrency cap saved — effective on the next search.' })
|
|
120
330
|
await refresh()
|
|
121
331
|
} catch (error) {
|
|
122
332
|
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
@@ -244,12 +454,45 @@ export function factory(require) {
|
|
|
244
454
|
|
|
245
455
|
const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
|
|
246
456
|
const cellStyle = { padding: '8px', verticalAlign: 'top' }
|
|
457
|
+
const snapshotRows = snapshot !== null && typeof snapshot === 'object' && snapshot.rows !== null && typeof snapshot.rows === 'object'
|
|
458
|
+
? snapshot.rows
|
|
459
|
+
: null
|
|
460
|
+
const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
|
|
461
|
+
|
|
462
|
+
// The usage response carries the freshest inference (it is the route that
|
|
463
|
+
// runs it); the manager payload is the fallback, and the only source of the
|
|
464
|
+
// policy before the first usage answer arrives.
|
|
465
|
+
const accountsMap = usage !== null && usage.ok === true && usage.accounts !== null && typeof usage.accounts === 'object'
|
|
466
|
+
? usage.accounts
|
|
467
|
+
: (server !== null && server.accounts !== null && server.accounts !== undefined && typeof server.accounts === 'object' ? server.accounts : {})
|
|
468
|
+
const accountCap = usage !== null && usage.ok === true && typeof usage.accountCap === 'number'
|
|
469
|
+
? usage.accountCap
|
|
470
|
+
: (server !== null && typeof server.accountCap === 'number' ? server.accountCap : 1)
|
|
471
|
+
const accountOf = (masked) => {
|
|
472
|
+
const entry = accountsMap[masked]
|
|
473
|
+
const group = entry !== null && entry !== undefined && typeof entry === 'object' && typeof entry.group === 'string' ? entry.group : null
|
|
474
|
+
return {
|
|
475
|
+
group,
|
|
476
|
+
own: group !== null && group.indexOf('own:') === 0,
|
|
477
|
+
manual: entry !== null && entry !== undefined && entry.manual === true,
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const accountLabel = (group) => {
|
|
481
|
+
const name = /^acct-(\d+)$/.exec(group)
|
|
482
|
+
return name === null ? group : 'Account ' + name[1]
|
|
483
|
+
}
|
|
484
|
+
const groupChoices = []
|
|
485
|
+
for (const key of (server !== null ? server.keys : [])) {
|
|
486
|
+
const { group, own } = accountOf(key.masked)
|
|
487
|
+
if (group === null || own) continue
|
|
488
|
+
if (!groupChoices.includes(group)) groupChoices.push(group)
|
|
489
|
+
}
|
|
247
490
|
|
|
248
491
|
// The Plugins page draws the title, the icon, and the crumb — the
|
|
249
492
|
// `plugins.bundle.config` contract puts the form alone in the entry — so
|
|
250
493
|
// this renders no card chrome, no second heading, and no collapse.
|
|
251
494
|
return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
|
|
252
|
-
react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
|
|
495
|
+
react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}@keyframes dts-ring-spin{to{transform:rotate(360deg)}}@keyframes dts-ring-pulse{0%,100%{opacity:1}50%{opacity:.45}}.dts-ring-loading{animation:dts-ring-pulse 1.6s ease-in-out infinite}.dts-ring-sweep{animation:dts-ring-spin 1.1s linear infinite}@media (prefers-reduced-motion: reduce){.dts-ring-loading,.dts-ring-sweep{animation:none}.dts-ring-progress{transition:none!important}}'),
|
|
253
496
|
react.createElement(react.Fragment, null,
|
|
254
497
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
255
498
|
'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
|
|
@@ -263,6 +506,7 @@ export function factory(require) {
|
|
|
263
506
|
react.createElement('thead', null,
|
|
264
507
|
react.createElement('tr', null,
|
|
265
508
|
react.createElement('th', { style: headStyle }, 'Key'),
|
|
509
|
+
react.createElement('th', { style: headStyle }, 'Account'),
|
|
266
510
|
react.createElement('th', { style: headStyle }, 'Usage'),
|
|
267
511
|
react.createElement('th', { style: headStyle }, 'Saved'),
|
|
268
512
|
react.createElement('th', { style: headStyle }, 'Actions')
|
|
@@ -274,12 +518,27 @@ export function factory(require) {
|
|
|
274
518
|
const isRemoved = removing[masked] === true
|
|
275
519
|
const isReplacing = replacing[masked] === true
|
|
276
520
|
const isRevealed = typeof revealed[masked] === 'string'
|
|
277
|
-
|
|
521
|
+
// Fresh counters win; until they arrive the last stored row is
|
|
522
|
+
// shown so the cell reads as "updating", never as "empty".
|
|
523
|
+
const freshRow = usage !== null && usage.ok === true
|
|
278
524
|
? (usage.perKey.find((row) => row.masked === masked) || usage.perKey[index])
|
|
279
525
|
: null
|
|
280
|
-
const
|
|
281
|
-
?
|
|
526
|
+
const storedRow = snapshotRows !== null && Object.prototype.hasOwnProperty.call(snapshotRows, masked)
|
|
527
|
+
? snapshotRows[masked]
|
|
528
|
+
: null
|
|
529
|
+
const usageRow = freshRow !== null && freshRow !== undefined ? freshRow : storedRow
|
|
530
|
+
const pct = usagePercent(usageRow)
|
|
531
|
+
const rawNumber = pct === null && usageRow !== null && usageRow !== undefined && usageRow.ok === true && usageRow.planUsage != null
|
|
532
|
+
? usageRow.planUsage
|
|
282
533
|
: null
|
|
534
|
+
const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
|
|
535
|
+
// Which Tavily account this key belongs to, and the override.
|
|
536
|
+
const account = accountOf(masked)
|
|
537
|
+
const currentChoice = account.manual ? (account.own ? 'own' : account.group) : 'auto'
|
|
538
|
+
const accountChoices = ['auto'].concat(groupChoices).concat(['own'])
|
|
539
|
+
if (currentChoice !== 'auto' && !accountChoices.includes(currentChoice)) {
|
|
540
|
+
accountChoices.splice(1, 0, currentChoice)
|
|
541
|
+
}
|
|
283
542
|
return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
|
|
284
543
|
react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
|
|
285
544
|
isReplacing
|
|
@@ -299,7 +558,34 @@ export function factory(require) {
|
|
|
299
558
|
)
|
|
300
559
|
),
|
|
301
560
|
react.createElement('td', { style: cellStyle },
|
|
302
|
-
react.createElement(
|
|
561
|
+
react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
562
|
+
react.createElement('span', {
|
|
563
|
+
className: 'dts-account-chip',
|
|
564
|
+
style: { fontSize: 11, color: account.group === null ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)' }
|
|
565
|
+
}, account.group === null ? 'Ungrouped' : (account.own ? 'Separate' : accountLabel(account.group))),
|
|
566
|
+
react.createElement('select', {
|
|
567
|
+
className: 'dts-account',
|
|
568
|
+
'data-masked': masked,
|
|
569
|
+
title: 'Which Tavily account this key counts against. Auto follows the grouping inferred from usage; Separate gives the key its own account, so it is never held back by another key.',
|
|
570
|
+
value: currentChoice,
|
|
571
|
+
disabled: busy,
|
|
572
|
+
onChange: (event) => saveAccount(masked, event.target.value),
|
|
573
|
+
style: { height: 26, borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', fontSize: 11, maxWidth: 130 }
|
|
574
|
+
},
|
|
575
|
+
accountChoices.map((choice) => react.createElement('option', { key: choice, value: choice },
|
|
576
|
+
choice === 'auto' ? 'Auto' : (choice === 'own' ? 'Separate' : accountLabel(choice))))
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
),
|
|
580
|
+
react.createElement('td', { style: cellStyle },
|
|
581
|
+
react.createElement(UsageCircle, {
|
|
582
|
+
percent: pct,
|
|
583
|
+
number: rawNumber,
|
|
584
|
+
loading: usageLoading,
|
|
585
|
+
stale: isStale,
|
|
586
|
+
staleAt: snapshotAt,
|
|
587
|
+
onClick: refresh
|
|
588
|
+
})
|
|
303
589
|
),
|
|
304
590
|
react.createElement('td', { style: Object.assign({}, cellStyle, { color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' }) }, formatDate(key.savedAt)),
|
|
305
591
|
react.createElement('td', { style: cellStyle },
|
|
@@ -340,6 +626,7 @@ export function factory(require) {
|
|
|
340
626
|
),
|
|
341
627
|
react.createElement('td', { style: cellStyle }, '—'),
|
|
342
628
|
react.createElement('td', { style: cellStyle }, '—'),
|
|
629
|
+
react.createElement('td', { style: cellStyle }, '—'),
|
|
343
630
|
react.createElement('td', { style: cellStyle },
|
|
344
631
|
react.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
|
|
345
632
|
react.createElement(IconButton, { icon: 'check', title: 'Save key', onClick: () => saveAdd(item), disabled: busy || item.value.trim().length === 0 }),
|
|
@@ -356,11 +643,21 @@ export function factory(require) {
|
|
|
356
643
|
react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
357
644
|
react.createElement('label', { style: { fontWeight: 500, fontSize: 13 } }, 'Key usage strategy'),
|
|
358
645
|
react.createElement('select', {
|
|
646
|
+
className: 'dts-strategy',
|
|
359
647
|
style: { maxWidth: 280, height: 32, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', padding: '0 10px', fontSize: 13 },
|
|
360
648
|
value: strategy,
|
|
361
649
|
onChange: (event) => saveStrategy(event.target.value),
|
|
362
650
|
disabled: busy
|
|
363
651
|
}, STRATEGIES.map((option) => react.createElement('option', { key: option.id, value: option.id }, option.label))),
|
|
652
|
+
strategy === 'load-balance' && react.createElement('label', { style: { fontWeight: 500, fontSize: 13, marginLeft: 8 } }, 'Per account'),
|
|
653
|
+
strategy === 'load-balance' && react.createElement('select', {
|
|
654
|
+
className: 'dts-account-cap',
|
|
655
|
+
title: 'How many requests may be in flight for one Tavily account at a time. One suits a free key.',
|
|
656
|
+
style: { height: 32, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', padding: '0 10px', fontSize: 13 },
|
|
657
|
+
value: String(accountCap),
|
|
658
|
+
onChange: (event) => saveCap(event.target.value),
|
|
659
|
+
disabled: busy
|
|
660
|
+
}, ACCOUNT_CAPS.map((n) => react.createElement('option', { key: n, value: String(n) }, String(n) + (n === 1 ? ' request' : ' requests')))),
|
|
364
661
|
notice !== null && react.createElement('span', { style: { fontSize: 13, color: notice.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, notice.ok ? notice.ok : String(notice.error))
|
|
365
662
|
),
|
|
366
663
|
react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
|