@moguiyu/dsh-tavily 0.3.2 → 0.3.3
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/lib/client.js +205 -26
- package/package.json +1 -1
- package/src/client.js +205 -26
- package/src/index.js +2 -1
- package/src/tools.js +35 -5
package/lib/client.js
CHANGED
|
@@ -32,6 +32,113 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function formatClock(iso) {
|
|
36
|
+
if (!iso) return null
|
|
37
|
+
const d = new Date(iso)
|
|
38
|
+
if (isNaN(d.getTime())) return null
|
|
39
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
40
|
+
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `/api/tavily-usage` asks Tavily for every key's counters, so it can take a
|
|
44
|
+
// while. Two rules follow: the cell must never look *empty* while that is in
|
|
45
|
+
// flight, and the last values already shown must survive a page reload. Only
|
|
46
|
+
// masked names and counters are stored — never key material.
|
|
47
|
+
const USAGE_SNAPSHOT_KEY = 'dsh-tavily:usage-snapshot:v1'
|
|
48
|
+
const RING_GROWTH_MS = 650
|
|
49
|
+
|
|
50
|
+
function usageStorage() {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof window === 'undefined' || window === null || !window.localStorage) return null
|
|
53
|
+
return window.localStorage
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readUsageSnapshot() {
|
|
60
|
+
const storage = usageStorage()
|
|
61
|
+
if (storage === null) return null
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(storage.getItem(USAGE_SNAPSHOT_KEY))
|
|
64
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
65
|
+
if (parsed.version !== 1) return null
|
|
66
|
+
if (parsed.rows === null || typeof parsed.rows !== 'object') return null
|
|
67
|
+
return parsed
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeUsageSnapshot(data) {
|
|
74
|
+
const storage = usageStorage()
|
|
75
|
+
if (storage === null) return
|
|
76
|
+
try {
|
|
77
|
+
const perKey = data !== null && data !== undefined && Array.isArray(data.perKey) ? data.perKey : []
|
|
78
|
+
const rows = {}
|
|
79
|
+
for (const row of perKey) {
|
|
80
|
+
if (row === null || typeof row !== 'object' || typeof row.masked !== 'string') continue
|
|
81
|
+
rows[row.masked] = {
|
|
82
|
+
ok: row.ok === true,
|
|
83
|
+
usage: typeof row.usage === 'number' ? row.usage : null,
|
|
84
|
+
planUsage: typeof row.planUsage === 'number' ? row.planUsage : null,
|
|
85
|
+
planLimit: typeof row.planLimit === 'number' ? row.planLimit : null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(rows).length === 0) return
|
|
89
|
+
storage.setItem(USAGE_SNAPSHOT_KEY, JSON.stringify({ version: 1, at: new Date().toISOString(), rows: rows }))
|
|
90
|
+
} catch (error) {
|
|
91
|
+
/* storage is best-effort; the card still works without it */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function usagePercent(row) {
|
|
96
|
+
if (row === null || row === undefined || row.ok !== true) return null
|
|
97
|
+
if (row.planLimit === null || row.planLimit === undefined || row.planLimit <= 0) return null
|
|
98
|
+
if (row.planUsage === null || row.planUsage === undefined) return null
|
|
99
|
+
return Math.min(100, Math.round((row.planUsage / row.planLimit) * 100))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Count a number up to `target` instead of swapping it. Returns the value to
|
|
104
|
+
* paint now. A first-ever value (or a non-number) is set instantly: there is
|
|
105
|
+
* nothing to grow from.
|
|
106
|
+
*/
|
|
107
|
+
function useAnimatedNumber(target, durationMs) {
|
|
108
|
+
const [display, setDisplay] = react.useState(target)
|
|
109
|
+
const displayRef = react.useRef(target)
|
|
110
|
+
const frameRef = react.useRef(0)
|
|
111
|
+
react.useEffect(() => {
|
|
112
|
+
const from = typeof displayRef.current === 'number' ? displayRef.current : null
|
|
113
|
+
const settle = (value) => { displayRef.current = value; setDisplay(value) }
|
|
114
|
+
if (typeof target !== 'number' || from === null || from === target) {
|
|
115
|
+
settle(typeof target === 'number' ? target : null)
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
119
|
+
settle(target)
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
let started = null
|
|
123
|
+
const tick = (now) => {
|
|
124
|
+
const stamp = typeof now === 'number' ? now : Date.now()
|
|
125
|
+
if (started === null) started = stamp
|
|
126
|
+
const elapsed = durationMs > 0 ? (stamp - started) / durationMs : 1
|
|
127
|
+
const t = Math.min(1, Math.max(0, elapsed))
|
|
128
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
129
|
+
const next = t >= 1 ? target : Math.round(from + (target - from) * eased)
|
|
130
|
+
displayRef.current = next
|
|
131
|
+
setDisplay(next)
|
|
132
|
+
if (t < 1) frameRef.current = requestAnimationFrame(tick)
|
|
133
|
+
}
|
|
134
|
+
frameRef.current = requestAnimationFrame(tick)
|
|
135
|
+
return () => {
|
|
136
|
+
if (typeof cancelAnimationFrame === 'function' && frameRef.current) cancelAnimationFrame(frameRef.current)
|
|
137
|
+
}
|
|
138
|
+
}, [target, durationMs])
|
|
139
|
+
return display
|
|
140
|
+
}
|
|
141
|
+
|
|
35
142
|
function SvgIcon({ name, size, style }) {
|
|
36
143
|
const paths = ICONS[name] || []
|
|
37
144
|
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 },
|
|
@@ -51,17 +158,54 @@ window.__ModuleLoader__.load({
|
|
|
51
158
|
}, react.createElement(SvgIcon, { name: icon, size: 13 }))
|
|
52
159
|
}
|
|
53
160
|
|
|
54
|
-
function UsageCircle({ percent,
|
|
161
|
+
function UsageCircle({ percent, number, loading, stale, staleAt, onClick }) {
|
|
55
162
|
const radius = 15
|
|
56
163
|
const circumference = 2 * Math.PI * radius
|
|
57
164
|
const pct = percent != null ? Math.min(100, Math.max(0, percent)) : 0
|
|
58
|
-
const dim =
|
|
59
|
-
|
|
60
|
-
|
|
165
|
+
const dim = 44
|
|
166
|
+
const animated = useAnimatedNumber(percent != null ? percent : (number != null ? number : null), RING_GROWTH_MS)
|
|
167
|
+
const shown = animated != null ? animated : (number != null ? number : null)
|
|
168
|
+
const offset = circumference - (pct / 100) * circumference
|
|
169
|
+
const label = percent != null
|
|
170
|
+
? (shown != null ? shown + '%' : '—')
|
|
171
|
+
: (shown != null ? String(shown) : '—')
|
|
172
|
+
const clock = formatClock(staleAt)
|
|
173
|
+
const title = loading
|
|
174
|
+
? (stale && clock !== null ? 'Updating usage… showing the values loaded at ' + clock : 'Updating usage…')
|
|
175
|
+
: (stale && clock !== null ? 'Showing the values loaded at ' + clock + ' — click to refresh' : 'Reload usage')
|
|
176
|
+
return react.createElement('button', {
|
|
177
|
+
type: 'button',
|
|
178
|
+
title: title,
|
|
179
|
+
'aria-label': 'Reload usage',
|
|
180
|
+
'aria-busy': loading ? 'true' : 'false',
|
|
181
|
+
'data-loading': loading ? 'true' : 'false',
|
|
182
|
+
'data-stale': stale ? 'true' : 'false',
|
|
183
|
+
onClick: onClick,
|
|
184
|
+
style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }
|
|
185
|
+
},
|
|
186
|
+
react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40', className: loading ? 'dts-ring dts-ring-loading' : 'dts-ring' },
|
|
61
187
|
react.createElement('circle', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 4 }),
|
|
62
|
-
react.createElement('circle', {
|
|
188
|
+
react.createElement('circle', {
|
|
189
|
+
className: 'dts-ring-progress',
|
|
190
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
191
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
192
|
+
strokeDasharray: circumference,
|
|
193
|
+
strokeDashoffset: offset,
|
|
194
|
+
transform: 'rotate(-90 20 20)',
|
|
195
|
+
// The offset is carried as a style value as well as an attribute:
|
|
196
|
+
// presentation attributes alone do not reliably transition.
|
|
197
|
+
style: { strokeDashoffset: offset, transition: 'stroke-dashoffset ' + RING_GROWTH_MS + 'ms cubic-bezier(.22,.61,.36,1)' }
|
|
198
|
+
}),
|
|
199
|
+
loading && react.createElement('g', { className: 'dts-ring-sweep', style: { transformOrigin: '20px 20px' } },
|
|
200
|
+
react.createElement('circle', {
|
|
201
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
202
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
203
|
+
strokeDasharray: (circumference * 0.22) + ' ' + (circumference * 0.78),
|
|
204
|
+
opacity: 0.45
|
|
205
|
+
})
|
|
206
|
+
)
|
|
63
207
|
),
|
|
64
|
-
react.createElement('span', { style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label
|
|
208
|
+
react.createElement('span', { className: 'dts-ring-label', style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label)
|
|
65
209
|
)
|
|
66
210
|
}
|
|
67
211
|
|
|
@@ -79,23 +223,39 @@ window.__ModuleLoader__.load({
|
|
|
79
223
|
const [notice, setNotice] = react.useState(null)
|
|
80
224
|
const [usage, setUsage] = react.useState(null)
|
|
81
225
|
const [usageError, setUsageError] = react.useState(null)
|
|
226
|
+
const [usageLoading, setUsageLoading] = react.useState(true)
|
|
227
|
+
const [snapshot, setSnapshot] = react.useState(() => readUsageSnapshot())
|
|
82
228
|
|
|
83
229
|
const refresh = react.useCallback(async () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
230
|
+
setUsageLoading(true)
|
|
231
|
+
// The two endpoints are independent and the usage one is the slow half,
|
|
232
|
+
// so they run together: the keys must not wait for Tavily's counters.
|
|
233
|
+
const keys = (async () => {
|
|
234
|
+
try {
|
|
235
|
+
const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
|
|
236
|
+
const data = await response.json()
|
|
237
|
+
if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
|
|
238
|
+
} catch (error) {
|
|
239
|
+
setLoadError(String(error && error.message ? error.message : error))
|
|
240
|
+
}
|
|
241
|
+
})()
|
|
242
|
+
const counters = (async () => {
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
|
|
245
|
+
const data = await response.json()
|
|
246
|
+
setUsage(data)
|
|
247
|
+
setUsageError(null)
|
|
248
|
+
if (data.ok) {
|
|
249
|
+
writeUsageSnapshot(data)
|
|
250
|
+
setSnapshot(readUsageSnapshot())
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
setUsageError(String(error && error.message ? error.message : error))
|
|
254
|
+
} finally {
|
|
255
|
+
setUsageLoading(false)
|
|
256
|
+
}
|
|
257
|
+
})()
|
|
258
|
+
await Promise.all([keys, counters])
|
|
99
259
|
}, [])
|
|
100
260
|
|
|
101
261
|
react.useEffect(() => { refresh() }, [refresh])
|
|
@@ -239,12 +399,16 @@ window.__ModuleLoader__.load({
|
|
|
239
399
|
|
|
240
400
|
const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
|
|
241
401
|
const cellStyle = { padding: '8px', verticalAlign: 'top' }
|
|
402
|
+
const snapshotRows = snapshot !== null && typeof snapshot === 'object' && snapshot.rows !== null && typeof snapshot.rows === 'object'
|
|
403
|
+
? snapshot.rows
|
|
404
|
+
: null
|
|
405
|
+
const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
|
|
242
406
|
|
|
243
407
|
// The Plugins page draws the title, the icon, and the crumb — the
|
|
244
408
|
// `plugins.bundle.config` contract puts the form alone in the entry — so
|
|
245
409
|
// this renders no card chrome, no second heading, and no collapse.
|
|
246
410
|
return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
|
|
247
|
-
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)}'),
|
|
411
|
+
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}}'),
|
|
248
412
|
react.createElement(react.Fragment, null,
|
|
249
413
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
250
414
|
'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.'
|
|
@@ -269,12 +433,20 @@ window.__ModuleLoader__.load({
|
|
|
269
433
|
const isRemoved = removing[masked] === true
|
|
270
434
|
const isReplacing = replacing[masked] === true
|
|
271
435
|
const isRevealed = typeof revealed[masked] === 'string'
|
|
272
|
-
|
|
436
|
+
// Fresh counters win; until they arrive the last stored row is
|
|
437
|
+
// shown so the cell reads as "updating", never as "empty".
|
|
438
|
+
const freshRow = usage !== null && usage.ok === true
|
|
273
439
|
? (usage.perKey.find((row) => row.masked === masked) || usage.perKey[index])
|
|
274
440
|
: null
|
|
275
|
-
const
|
|
276
|
-
?
|
|
441
|
+
const storedRow = snapshotRows !== null && Object.prototype.hasOwnProperty.call(snapshotRows, masked)
|
|
442
|
+
? snapshotRows[masked]
|
|
443
|
+
: null
|
|
444
|
+
const usageRow = freshRow !== null && freshRow !== undefined ? freshRow : storedRow
|
|
445
|
+
const pct = usagePercent(usageRow)
|
|
446
|
+
const rawNumber = pct === null && usageRow !== null && usageRow !== undefined && usageRow.ok === true && usageRow.planUsage != null
|
|
447
|
+
? usageRow.planUsage
|
|
277
448
|
: null
|
|
449
|
+
const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
|
|
278
450
|
return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
|
|
279
451
|
react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
|
|
280
452
|
isReplacing
|
|
@@ -294,7 +466,14 @@ window.__ModuleLoader__.load({
|
|
|
294
466
|
)
|
|
295
467
|
),
|
|
296
468
|
react.createElement('td', { style: cellStyle },
|
|
297
|
-
react.createElement(UsageCircle, {
|
|
469
|
+
react.createElement(UsageCircle, {
|
|
470
|
+
percent: pct,
|
|
471
|
+
number: rawNumber,
|
|
472
|
+
loading: usageLoading,
|
|
473
|
+
stale: isStale,
|
|
474
|
+
staleAt: snapshotAt,
|
|
475
|
+
onClick: refresh
|
|
476
|
+
})
|
|
298
477
|
),
|
|
299
478
|
react.createElement('td', { style: Object.assign({}, cellStyle, { color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' }) }, formatDate(key.savedAt)),
|
|
300
479
|
react.createElement('td', { style: cellStyle },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moguiyu/dsh-tavily",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with a key and usage management card on the Plugins page. The built-in web_search is never replaced.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/client.js
CHANGED
|
@@ -37,6 +37,113 @@ export function factory(require) {
|
|
|
37
37
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function formatClock(iso) {
|
|
41
|
+
if (!iso) return null
|
|
42
|
+
const d = new Date(iso)
|
|
43
|
+
if (isNaN(d.getTime())) return null
|
|
44
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
45
|
+
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `/api/tavily-usage` asks Tavily for every key's counters, so it can take a
|
|
49
|
+
// while. Two rules follow: the cell must never look *empty* while that is in
|
|
50
|
+
// flight, and the last values already shown must survive a page reload. Only
|
|
51
|
+
// masked names and counters are stored — never key material.
|
|
52
|
+
const USAGE_SNAPSHOT_KEY = 'dsh-tavily:usage-snapshot:v1'
|
|
53
|
+
const RING_GROWTH_MS = 650
|
|
54
|
+
|
|
55
|
+
function usageStorage() {
|
|
56
|
+
try {
|
|
57
|
+
if (typeof window === 'undefined' || window === null || !window.localStorage) return null
|
|
58
|
+
return window.localStorage
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readUsageSnapshot() {
|
|
65
|
+
const storage = usageStorage()
|
|
66
|
+
if (storage === null) return null
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(storage.getItem(USAGE_SNAPSHOT_KEY))
|
|
69
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
70
|
+
if (parsed.version !== 1) return null
|
|
71
|
+
if (parsed.rows === null || typeof parsed.rows !== 'object') return null
|
|
72
|
+
return parsed
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeUsageSnapshot(data) {
|
|
79
|
+
const storage = usageStorage()
|
|
80
|
+
if (storage === null) return
|
|
81
|
+
try {
|
|
82
|
+
const perKey = data !== null && data !== undefined && Array.isArray(data.perKey) ? data.perKey : []
|
|
83
|
+
const rows = {}
|
|
84
|
+
for (const row of perKey) {
|
|
85
|
+
if (row === null || typeof row !== 'object' || typeof row.masked !== 'string') continue
|
|
86
|
+
rows[row.masked] = {
|
|
87
|
+
ok: row.ok === true,
|
|
88
|
+
usage: typeof row.usage === 'number' ? row.usage : null,
|
|
89
|
+
planUsage: typeof row.planUsage === 'number' ? row.planUsage : null,
|
|
90
|
+
planLimit: typeof row.planLimit === 'number' ? row.planLimit : null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(rows).length === 0) return
|
|
94
|
+
storage.setItem(USAGE_SNAPSHOT_KEY, JSON.stringify({ version: 1, at: new Date().toISOString(), rows: rows }))
|
|
95
|
+
} catch (error) {
|
|
96
|
+
/* storage is best-effort; the card still works without it */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function usagePercent(row) {
|
|
101
|
+
if (row === null || row === undefined || row.ok !== true) return null
|
|
102
|
+
if (row.planLimit === null || row.planLimit === undefined || row.planLimit <= 0) return null
|
|
103
|
+
if (row.planUsage === null || row.planUsage === undefined) return null
|
|
104
|
+
return Math.min(100, Math.round((row.planUsage / row.planLimit) * 100))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Count a number up to `target` instead of swapping it. Returns the value to
|
|
109
|
+
* paint now. A first-ever value (or a non-number) is set instantly: there is
|
|
110
|
+
* nothing to grow from.
|
|
111
|
+
*/
|
|
112
|
+
function useAnimatedNumber(target, durationMs) {
|
|
113
|
+
const [display, setDisplay] = react.useState(target)
|
|
114
|
+
const displayRef = react.useRef(target)
|
|
115
|
+
const frameRef = react.useRef(0)
|
|
116
|
+
react.useEffect(() => {
|
|
117
|
+
const from = typeof displayRef.current === 'number' ? displayRef.current : null
|
|
118
|
+
const settle = (value) => { displayRef.current = value; setDisplay(value) }
|
|
119
|
+
if (typeof target !== 'number' || from === null || from === target) {
|
|
120
|
+
settle(typeof target === 'number' ? target : null)
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
124
|
+
settle(target)
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
let started = null
|
|
128
|
+
const tick = (now) => {
|
|
129
|
+
const stamp = typeof now === 'number' ? now : Date.now()
|
|
130
|
+
if (started === null) started = stamp
|
|
131
|
+
const elapsed = durationMs > 0 ? (stamp - started) / durationMs : 1
|
|
132
|
+
const t = Math.min(1, Math.max(0, elapsed))
|
|
133
|
+
const eased = 1 - Math.pow(1 - t, 3)
|
|
134
|
+
const next = t >= 1 ? target : Math.round(from + (target - from) * eased)
|
|
135
|
+
displayRef.current = next
|
|
136
|
+
setDisplay(next)
|
|
137
|
+
if (t < 1) frameRef.current = requestAnimationFrame(tick)
|
|
138
|
+
}
|
|
139
|
+
frameRef.current = requestAnimationFrame(tick)
|
|
140
|
+
return () => {
|
|
141
|
+
if (typeof cancelAnimationFrame === 'function' && frameRef.current) cancelAnimationFrame(frameRef.current)
|
|
142
|
+
}
|
|
143
|
+
}, [target, durationMs])
|
|
144
|
+
return display
|
|
145
|
+
}
|
|
146
|
+
|
|
40
147
|
function SvgIcon({ name, size, style }) {
|
|
41
148
|
const paths = ICONS[name] || []
|
|
42
149
|
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 +163,54 @@ export function factory(require) {
|
|
|
56
163
|
}, react.createElement(SvgIcon, { name: icon, size: 13 }))
|
|
57
164
|
}
|
|
58
165
|
|
|
59
|
-
function UsageCircle({ percent,
|
|
166
|
+
function UsageCircle({ percent, number, loading, stale, staleAt, onClick }) {
|
|
60
167
|
const radius = 15
|
|
61
168
|
const circumference = 2 * Math.PI * radius
|
|
62
169
|
const pct = percent != null ? Math.min(100, Math.max(0, percent)) : 0
|
|
63
|
-
const dim =
|
|
64
|
-
|
|
65
|
-
|
|
170
|
+
const dim = 44
|
|
171
|
+
const animated = useAnimatedNumber(percent != null ? percent : (number != null ? number : null), RING_GROWTH_MS)
|
|
172
|
+
const shown = animated != null ? animated : (number != null ? number : null)
|
|
173
|
+
const offset = circumference - (pct / 100) * circumference
|
|
174
|
+
const label = percent != null
|
|
175
|
+
? (shown != null ? shown + '%' : '—')
|
|
176
|
+
: (shown != null ? String(shown) : '—')
|
|
177
|
+
const clock = formatClock(staleAt)
|
|
178
|
+
const title = loading
|
|
179
|
+
? (stale && clock !== null ? 'Updating usage… showing the values loaded at ' + clock : 'Updating usage…')
|
|
180
|
+
: (stale && clock !== null ? 'Showing the values loaded at ' + clock + ' — click to refresh' : 'Reload usage')
|
|
181
|
+
return react.createElement('button', {
|
|
182
|
+
type: 'button',
|
|
183
|
+
title: title,
|
|
184
|
+
'aria-label': 'Reload usage',
|
|
185
|
+
'aria-busy': loading ? 'true' : 'false',
|
|
186
|
+
'data-loading': loading ? 'true' : 'false',
|
|
187
|
+
'data-stale': stale ? 'true' : 'false',
|
|
188
|
+
onClick: onClick,
|
|
189
|
+
style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }
|
|
190
|
+
},
|
|
191
|
+
react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40', className: loading ? 'dts-ring dts-ring-loading' : 'dts-ring' },
|
|
66
192
|
react.createElement('circle', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 4 }),
|
|
67
|
-
react.createElement('circle', {
|
|
193
|
+
react.createElement('circle', {
|
|
194
|
+
className: 'dts-ring-progress',
|
|
195
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
196
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
197
|
+
strokeDasharray: circumference,
|
|
198
|
+
strokeDashoffset: offset,
|
|
199
|
+
transform: 'rotate(-90 20 20)',
|
|
200
|
+
// The offset is carried as a style value as well as an attribute:
|
|
201
|
+
// presentation attributes alone do not reliably transition.
|
|
202
|
+
style: { strokeDashoffset: offset, transition: 'stroke-dashoffset ' + RING_GROWTH_MS + 'ms cubic-bezier(.22,.61,.36,1)' }
|
|
203
|
+
}),
|
|
204
|
+
loading && react.createElement('g', { className: 'dts-ring-sweep', style: { transformOrigin: '20px 20px' } },
|
|
205
|
+
react.createElement('circle', {
|
|
206
|
+
cx: 20, cy: 20, r: radius, fill: 'none',
|
|
207
|
+
stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
|
|
208
|
+
strokeDasharray: (circumference * 0.22) + ' ' + (circumference * 0.78),
|
|
209
|
+
opacity: 0.45
|
|
210
|
+
})
|
|
211
|
+
)
|
|
68
212
|
),
|
|
69
|
-
react.createElement('span', { style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label
|
|
213
|
+
react.createElement('span', { className: 'dts-ring-label', style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label)
|
|
70
214
|
)
|
|
71
215
|
}
|
|
72
216
|
|
|
@@ -84,23 +228,39 @@ export function factory(require) {
|
|
|
84
228
|
const [notice, setNotice] = react.useState(null)
|
|
85
229
|
const [usage, setUsage] = react.useState(null)
|
|
86
230
|
const [usageError, setUsageError] = react.useState(null)
|
|
231
|
+
const [usageLoading, setUsageLoading] = react.useState(true)
|
|
232
|
+
const [snapshot, setSnapshot] = react.useState(() => readUsageSnapshot())
|
|
87
233
|
|
|
88
234
|
const refresh = react.useCallback(async () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
235
|
+
setUsageLoading(true)
|
|
236
|
+
// The two endpoints are independent and the usage one is the slow half,
|
|
237
|
+
// so they run together: the keys must not wait for Tavily's counters.
|
|
238
|
+
const keys = (async () => {
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
|
|
241
|
+
const data = await response.json()
|
|
242
|
+
if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
|
|
243
|
+
} catch (error) {
|
|
244
|
+
setLoadError(String(error && error.message ? error.message : error))
|
|
245
|
+
}
|
|
246
|
+
})()
|
|
247
|
+
const counters = (async () => {
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
|
|
250
|
+
const data = await response.json()
|
|
251
|
+
setUsage(data)
|
|
252
|
+
setUsageError(null)
|
|
253
|
+
if (data.ok) {
|
|
254
|
+
writeUsageSnapshot(data)
|
|
255
|
+
setSnapshot(readUsageSnapshot())
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
setUsageError(String(error && error.message ? error.message : error))
|
|
259
|
+
} finally {
|
|
260
|
+
setUsageLoading(false)
|
|
261
|
+
}
|
|
262
|
+
})()
|
|
263
|
+
await Promise.all([keys, counters])
|
|
104
264
|
}, [])
|
|
105
265
|
|
|
106
266
|
react.useEffect(() => { refresh() }, [refresh])
|
|
@@ -244,12 +404,16 @@ export function factory(require) {
|
|
|
244
404
|
|
|
245
405
|
const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
|
|
246
406
|
const cellStyle = { padding: '8px', verticalAlign: 'top' }
|
|
407
|
+
const snapshotRows = snapshot !== null && typeof snapshot === 'object' && snapshot.rows !== null && typeof snapshot.rows === 'object'
|
|
408
|
+
? snapshot.rows
|
|
409
|
+
: null
|
|
410
|
+
const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
|
|
247
411
|
|
|
248
412
|
// The Plugins page draws the title, the icon, and the crumb — the
|
|
249
413
|
// `plugins.bundle.config` contract puts the form alone in the entry — so
|
|
250
414
|
// this renders no card chrome, no second heading, and no collapse.
|
|
251
415
|
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)}'),
|
|
416
|
+
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
417
|
react.createElement(react.Fragment, null,
|
|
254
418
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
255
419
|
'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.'
|
|
@@ -274,12 +438,20 @@ export function factory(require) {
|
|
|
274
438
|
const isRemoved = removing[masked] === true
|
|
275
439
|
const isReplacing = replacing[masked] === true
|
|
276
440
|
const isRevealed = typeof revealed[masked] === 'string'
|
|
277
|
-
|
|
441
|
+
// Fresh counters win; until they arrive the last stored row is
|
|
442
|
+
// shown so the cell reads as "updating", never as "empty".
|
|
443
|
+
const freshRow = usage !== null && usage.ok === true
|
|
278
444
|
? (usage.perKey.find((row) => row.masked === masked) || usage.perKey[index])
|
|
279
445
|
: null
|
|
280
|
-
const
|
|
281
|
-
?
|
|
446
|
+
const storedRow = snapshotRows !== null && Object.prototype.hasOwnProperty.call(snapshotRows, masked)
|
|
447
|
+
? snapshotRows[masked]
|
|
448
|
+
: null
|
|
449
|
+
const usageRow = freshRow !== null && freshRow !== undefined ? freshRow : storedRow
|
|
450
|
+
const pct = usagePercent(usageRow)
|
|
451
|
+
const rawNumber = pct === null && usageRow !== null && usageRow !== undefined && usageRow.ok === true && usageRow.planUsage != null
|
|
452
|
+
? usageRow.planUsage
|
|
282
453
|
: null
|
|
454
|
+
const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
|
|
283
455
|
return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
|
|
284
456
|
react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
|
|
285
457
|
isReplacing
|
|
@@ -299,7 +471,14 @@ export function factory(require) {
|
|
|
299
471
|
)
|
|
300
472
|
),
|
|
301
473
|
react.createElement('td', { style: cellStyle },
|
|
302
|
-
react.createElement(UsageCircle, {
|
|
474
|
+
react.createElement(UsageCircle, {
|
|
475
|
+
percent: pct,
|
|
476
|
+
number: rawNumber,
|
|
477
|
+
loading: usageLoading,
|
|
478
|
+
stale: isStale,
|
|
479
|
+
staleAt: snapshotAt,
|
|
480
|
+
onClick: refresh
|
|
481
|
+
})
|
|
303
482
|
),
|
|
304
483
|
react.createElement('td', { style: Object.assign({}, cellStyle, { color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' }) }, formatDate(key.savedAt)),
|
|
305
484
|
react.createElement('td', { style: cellStyle },
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Both halves live here, so there is no cross-package contract left to skew:
|
|
5
5
|
*
|
|
6
6
|
* - the model tools (`tools.js`) — `tavily_search`, `tavily_extract`,
|
|
7
|
-
* `tavily_map`, `tavily_crawl`, with key rotation and failover on 401/429
|
|
7
|
+
* `tavily_map`, `tavily_crawl`, with key rotation and failover on 401/429
|
|
8
|
+
* and on Tavily's per-key 432/433 usage-limit refusals;
|
|
8
9
|
* - key and usage management (`backend.js`) — `/api/tavily-usage` and
|
|
9
10
|
* `/api/tavily-manager`;
|
|
10
11
|
* - the browser card (`client.js`) — keys, usage, and strategy.
|
package/src/tools.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Tool half of `@moguiyu/dsh-tavily`: the advanced `tavily_search` model tool
|
|
3
3
|
* plus the extra Tavily direct tools (`tavily_extract`, `tavily_map`,
|
|
4
4
|
* `tavily_crawl`). Keys resolve from the `TAVILY_API_KEYS` credential per
|
|
5
|
-
* call; rotation is round-robin with failover on
|
|
5
|
+
* call; rotation is round-robin with failover on every per-key refusal
|
|
6
|
+
* (401/429, plus Tavily's 432 plan-limit and 433 pay-as-you-go-limit codes).
|
|
6
7
|
*
|
|
7
8
|
* The tools are an OPTION for search, never a replacement: they are
|
|
8
9
|
* independent of the built-in `web_search` provider, and registering them
|
|
@@ -19,6 +20,23 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
19
20
|
|
|
20
21
|
const TAVILY_BASE_URL = 'https://api.tavily.com'
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Statuses that mean "this key cannot serve the request — try another one".
|
|
25
|
+
* Tavily documents 432 (plan limit exceeded) and 433 (pay-as-you-go limit
|
|
26
|
+
* exceeded) as conditions of the key's account, so in a multi-key pool they
|
|
27
|
+
* are failover cases exactly like 401/429. Treating them as fatal made a pool
|
|
28
|
+
* with one exhausted account fail calls that a healthy key could have served.
|
|
29
|
+
*/
|
|
30
|
+
const RETRYABLE_STATUS = new Set([401, 429, 432, 433])
|
|
31
|
+
|
|
32
|
+
/** Why a retryable status refused the key; the pool's exhaustion report uses it. */
|
|
33
|
+
const RETRYABLE_REASON = new Map([
|
|
34
|
+
[401, 'invalid or missing key'],
|
|
35
|
+
[429, 'rate limit'],
|
|
36
|
+
[432, 'Tavily plan usage limit exceeded'],
|
|
37
|
+
[433, 'Tavily pay-as-you-go limit exceeded'],
|
|
38
|
+
])
|
|
39
|
+
|
|
22
40
|
/** Clamp a number into [min, max]; `fallback` when not finite. */
|
|
23
41
|
export function clampInt(value, min, max, fallback) {
|
|
24
42
|
const n = Number(value)
|
|
@@ -103,7 +121,7 @@ async function callOperation(operation, body, key, exec, timeoutMs) {
|
|
|
103
121
|
body: JSON.stringify(body),
|
|
104
122
|
signal: combined.signal,
|
|
105
123
|
})
|
|
106
|
-
if (
|
|
124
|
+
if (RETRYABLE_STATUS.has(response.status)) {
|
|
107
125
|
return { kind: 'retryable', statusCode: response.status }
|
|
108
126
|
}
|
|
109
127
|
if (!response.ok) {
|
|
@@ -113,6 +131,8 @@ async function callOperation(operation, body, key, exec, timeoutMs) {
|
|
|
113
131
|
if (parsed !== null && typeof parsed === 'object') {
|
|
114
132
|
if (typeof parsed.error === 'string') message = parsed.error
|
|
115
133
|
else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
|
|
134
|
+
// 432/433 and other API failures arrive as {"detail":{"error":…}}.
|
|
135
|
+
else if (parsed.detail !== undefined && parsed.detail !== null && typeof parsed.detail === 'object' && typeof parsed.detail.error === 'string') message = parsed.detail.error
|
|
116
136
|
}
|
|
117
137
|
} catch {
|
|
118
138
|
/* keep the status message */
|
|
@@ -455,16 +475,25 @@ export function installTavilyTool(ctx) {
|
|
|
455
475
|
return { keys: [], source: 'unconfigured' }
|
|
456
476
|
}
|
|
457
477
|
|
|
458
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* Run `body` through the rotation/failover loop; returns the response JSON.
|
|
480
|
+
*
|
|
481
|
+
* The first key is picked from the shared round-robin cursor, and each
|
|
482
|
+
* attempt advances by exactly one slot, so one call tries every configured
|
|
483
|
+
* key at most once. Folding the attempt offset into the cursor instead
|
|
484
|
+
* (the earlier shape) skipped keys and repeated others whenever the pool
|
|
485
|
+
* size was even.
|
|
486
|
+
*/
|
|
459
487
|
async function requestWithRotation(operation, body, timeoutMs, exec, detail) {
|
|
460
488
|
const { keys, source } = await resolveKeys()
|
|
461
489
|
if (keys.length === 0) {
|
|
462
490
|
throw new Error(`tavily_${operation}: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)`)
|
|
463
491
|
}
|
|
464
492
|
const attempts = keys.length
|
|
493
|
+
const first = rotation
|
|
465
494
|
let lastRetryable = 0
|
|
466
495
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
467
|
-
const index = (
|
|
496
|
+
const index = (first + attempt) % keys.length
|
|
468
497
|
const key = keys[index]
|
|
469
498
|
const started = Date.now()
|
|
470
499
|
const outcome = await callOperation(operation, body, key, exec, timeoutMs)
|
|
@@ -478,7 +507,8 @@ export function installTavilyTool(ctx) {
|
|
|
478
507
|
rotation = (index + 1) % keys.length
|
|
479
508
|
ctx.logger.info(`tavily_${operation}: key %d/%d returned HTTP %d, rotating`, index + 1, keys.length, outcome.statusCode)
|
|
480
509
|
}
|
|
481
|
-
|
|
510
|
+
const reason = RETRYABLE_REASON.get(lastRetryable) ?? 'invalid key or rate limit'
|
|
511
|
+
throw new Error(`tavily_${operation}: all ${attempts} configured key(s) failed with HTTP ${lastRetryable} (${reason})`)
|
|
482
512
|
}
|
|
483
513
|
|
|
484
514
|
tools.register(defineTool({
|