@moguiyu/dsh-tavily 0.3.1 → 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 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, size, label, onClick }) {
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 = size || 44
59
- return react.createElement('button', { type: 'button', title: 'Reload usage', 'aria-label': 'Reload usage', onClick: onClick, style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } },
60
- react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40' },
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', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round', strokeDasharray: circumference, strokeDashoffset: circumference - (pct / 100) * circumference, transform: 'rotate(-90 20 20)' })
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 != null ? label : (percent != null ? percent + '%' : '—'))
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
- try {
85
- const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
86
- const data = await response.json()
87
- if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
88
- } catch (error) {
89
- setLoadError(String(error && error.message ? error.message : error))
90
- }
91
- try {
92
- const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
93
- const data = await response.json()
94
- setUsage(data)
95
- setUsageError(null)
96
- } catch (error) {
97
- setUsageError(String(error && error.message ? error.message : error))
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
- const usageRow = usage !== null && usage.ok === true
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 pct = usageRow && usageRow.ok && usageRow.planLimit != null && usageRow.planLimit > 0 && usageRow.planUsage != null
276
- ? Math.min(100, Math.round((usageRow.planUsage / usageRow.planLimit) * 100))
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, { percent: pct, label: pct != null ? pct + '%' : (usageRow && usageRow.ok && usageRow.planUsage != null ? String(usageRow.planUsage) : '—'), onClick: refresh })
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.1",
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",
@@ -37,7 +37,10 @@
37
37
  "0.1.5-rc.1": "compatible",
38
38
  "0.1.5-rc.2": "compatible",
39
39
  "0.1.6-alpha.1": "compatible",
40
- "0.1.6-alpha.2": "compatible"
40
+ "0.1.6-alpha.2": "compatible",
41
+ "0.1.7-rc.2": "compatible",
42
+ "0.2.0-rc.1": "compatible",
43
+ "0.2.0-rc.2": "compatible"
41
44
  }
42
45
  },
43
46
  "client": {
@@ -65,8 +68,8 @@
65
68
  },
66
69
  "peerDependencies": {
67
70
  "@deepseek-ai/cordis": "^4.0.1",
68
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0",
69
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0"
71
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0",
72
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0"
70
73
  },
71
74
  "devDependencies": {
72
75
  "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2"
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, size, label, onClick }) {
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 = size || 44
64
- return react.createElement('button', { type: 'button', title: 'Reload usage', 'aria-label': 'Reload usage', onClick: onClick, style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } },
65
- react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40' },
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', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round', strokeDasharray: circumference, strokeDashoffset: circumference - (pct / 100) * circumference, transform: 'rotate(-90 20 20)' })
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 != null ? label : (percent != null ? percent + '%' : '—'))
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
- try {
90
- const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
91
- const data = await response.json()
92
- if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
93
- } catch (error) {
94
- setLoadError(String(error && error.message ? error.message : error))
95
- }
96
- try {
97
- const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
98
- const data = await response.json()
99
- setUsage(data)
100
- setUsageError(null)
101
- } catch (error) {
102
- setUsageError(String(error && error.message ? error.message : error))
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
- const usageRow = usage !== null && usage.ok === true
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 pct = usageRow && usageRow.ok && usageRow.planLimit != null && usageRow.planLimit > 0 && usageRow.planUsage != null
281
- ? Math.min(100, Math.round((usageRow.planUsage / usageRow.planLimit) * 100))
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, { percent: pct, label: pct != null ? pct + '%' : (usageRow && usageRow.ok && usageRow.planUsage != null ? String(usageRow.planUsage) : '—'), onClick: refresh })
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 401/429.
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 (response.status === 401 || response.status === 429) {
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
- /** Run `body` through the rotation/failover loop; returns the response JSON. */
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 = (rotation + attempt) % keys.length
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
- throw new Error(`tavily_${operation}: all ${attempts} configured key(s) failed with HTTP ${lastRetryable} (invalid key or rate limit)`)
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({