@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 CHANGED
@@ -29,4 +29,5 @@ which is exactly how the old tool-level switch broke. Both old packages are depr
29
29
  There is no tool-level on/off switch. Composing the plugin registers the tools, and the Plugins
30
30
  page's own bundle toggle is the only on/off.
31
31
 
32
- See the [workspace README](../../README.md) for install and configuration.
32
+ See the [workspace README](https://github.com/moguiyu/dsh-tavily#readme) for install and
33
+ configuration.
package/lib/client.js CHANGED
@@ -6,11 +6,18 @@ window.__ModuleLoader__.load({
6
6
  const inject = ['slots']
7
7
 
8
8
  const STRATEGIES = [
9
- { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429 the next key is tried automatically.' },
9
+ { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429/432/433 the next key is tried automatically.' },
10
10
  { id: 'low-usage-first', label: 'Lowest usage first', hint: 'Re-orders keys by current Tavily usage, least-used first. The first key becomes primary.' },
11
- { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' }
11
+ { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' },
12
+ {
13
+ id: 'load-balance',
14
+ label: 'Load balance',
15
+ 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.'
16
+ }
12
17
  ]
13
18
 
19
+ const ACCOUNT_CAPS = [1, 2, 3, 4, 5, 6, 7, 8]
20
+
14
21
  const ICONS = {
15
22
  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'],
16
23
  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'],
@@ -32,6 +39,113 @@ window.__ModuleLoader__.load({
32
39
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
33
40
  }
34
41
 
42
+ function formatClock(iso) {
43
+ if (!iso) return null
44
+ const d = new Date(iso)
45
+ if (isNaN(d.getTime())) return null
46
+ const pad = (n) => String(n).padStart(2, '0')
47
+ return pad(d.getHours()) + ':' + pad(d.getMinutes())
48
+ }
49
+
50
+ // `/api/tavily-usage` asks Tavily for every key's counters, so it can take a
51
+ // while. Two rules follow: the cell must never look *empty* while that is in
52
+ // flight, and the last values already shown must survive a page reload. Only
53
+ // masked names and counters are stored — never key material.
54
+ const USAGE_SNAPSHOT_KEY = 'dsh-tavily:usage-snapshot:v1'
55
+ const RING_GROWTH_MS = 650
56
+
57
+ function usageStorage() {
58
+ try {
59
+ if (typeof window === 'undefined' || window === null || !window.localStorage) return null
60
+ return window.localStorage
61
+ } catch (error) {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function readUsageSnapshot() {
67
+ const storage = usageStorage()
68
+ if (storage === null) return null
69
+ try {
70
+ const parsed = JSON.parse(storage.getItem(USAGE_SNAPSHOT_KEY))
71
+ if (parsed === null || typeof parsed !== 'object') return null
72
+ if (parsed.version !== 1) return null
73
+ if (parsed.rows === null || typeof parsed.rows !== 'object') return null
74
+ return parsed
75
+ } catch (error) {
76
+ return null
77
+ }
78
+ }
79
+
80
+ function writeUsageSnapshot(data) {
81
+ const storage = usageStorage()
82
+ if (storage === null) return
83
+ try {
84
+ const perKey = data !== null && data !== undefined && Array.isArray(data.perKey) ? data.perKey : []
85
+ const rows = {}
86
+ for (const row of perKey) {
87
+ if (row === null || typeof row !== 'object' || typeof row.masked !== 'string') continue
88
+ rows[row.masked] = {
89
+ ok: row.ok === true,
90
+ usage: typeof row.usage === 'number' ? row.usage : null,
91
+ planUsage: typeof row.planUsage === 'number' ? row.planUsage : null,
92
+ planLimit: typeof row.planLimit === 'number' ? row.planLimit : null
93
+ }
94
+ }
95
+ if (Object.keys(rows).length === 0) return
96
+ storage.setItem(USAGE_SNAPSHOT_KEY, JSON.stringify({ version: 1, at: new Date().toISOString(), rows: rows }))
97
+ } catch (error) {
98
+ /* storage is best-effort; the card still works without it */
99
+ }
100
+ }
101
+
102
+ function usagePercent(row) {
103
+ if (row === null || row === undefined || row.ok !== true) return null
104
+ if (row.planLimit === null || row.planLimit === undefined || row.planLimit <= 0) return null
105
+ if (row.planUsage === null || row.planUsage === undefined) return null
106
+ return Math.min(100, Math.round((row.planUsage / row.planLimit) * 100))
107
+ }
108
+
109
+ /**
110
+ * Count a number up to `target` instead of swapping it. Returns the value to
111
+ * paint now. A first-ever value (or a non-number) is set instantly: there is
112
+ * nothing to grow from.
113
+ */
114
+ function useAnimatedNumber(target, durationMs) {
115
+ const [display, setDisplay] = react.useState(target)
116
+ const displayRef = react.useRef(target)
117
+ const frameRef = react.useRef(0)
118
+ react.useEffect(() => {
119
+ const from = typeof displayRef.current === 'number' ? displayRef.current : null
120
+ const settle = (value) => { displayRef.current = value; setDisplay(value) }
121
+ if (typeof target !== 'number' || from === null || from === target) {
122
+ settle(typeof target === 'number' ? target : null)
123
+ return undefined
124
+ }
125
+ if (typeof requestAnimationFrame !== 'function') {
126
+ settle(target)
127
+ return undefined
128
+ }
129
+ let started = null
130
+ const tick = (now) => {
131
+ const stamp = typeof now === 'number' ? now : Date.now()
132
+ if (started === null) started = stamp
133
+ const elapsed = durationMs > 0 ? (stamp - started) / durationMs : 1
134
+ const t = Math.min(1, Math.max(0, elapsed))
135
+ const eased = 1 - Math.pow(1 - t, 3)
136
+ const next = t >= 1 ? target : Math.round(from + (target - from) * eased)
137
+ displayRef.current = next
138
+ setDisplay(next)
139
+ if (t < 1) frameRef.current = requestAnimationFrame(tick)
140
+ }
141
+ frameRef.current = requestAnimationFrame(tick)
142
+ return () => {
143
+ if (typeof cancelAnimationFrame === 'function' && frameRef.current) cancelAnimationFrame(frameRef.current)
144
+ }
145
+ }, [target, durationMs])
146
+ return display
147
+ }
148
+
35
149
  function SvgIcon({ name, size, style }) {
36
150
  const paths = ICONS[name] || []
37
151
  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 +165,54 @@ window.__ModuleLoader__.load({
51
165
  }, react.createElement(SvgIcon, { name: icon, size: 13 }))
52
166
  }
53
167
 
54
- function UsageCircle({ percent, size, label, onClick }) {
168
+ function UsageCircle({ percent, number, loading, stale, staleAt, onClick }) {
55
169
  const radius = 15
56
170
  const circumference = 2 * Math.PI * radius
57
171
  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' },
172
+ const dim = 44
173
+ const animated = useAnimatedNumber(percent != null ? percent : (number != null ? number : null), RING_GROWTH_MS)
174
+ const shown = animated != null ? animated : (number != null ? number : null)
175
+ const offset = circumference - (pct / 100) * circumference
176
+ const label = percent != null
177
+ ? (shown != null ? shown + '%' : '—')
178
+ : (shown != null ? String(shown) : '—')
179
+ const clock = formatClock(staleAt)
180
+ const title = loading
181
+ ? (stale && clock !== null ? 'Updating usage… showing the values loaded at ' + clock : 'Updating usage…')
182
+ : (stale && clock !== null ? 'Showing the values loaded at ' + clock + ' — click to refresh' : 'Reload usage')
183
+ return react.createElement('button', {
184
+ type: 'button',
185
+ title: title,
186
+ 'aria-label': 'Reload usage',
187
+ 'aria-busy': loading ? 'true' : 'false',
188
+ 'data-loading': loading ? 'true' : 'false',
189
+ 'data-stale': stale ? 'true' : 'false',
190
+ onClick: onClick,
191
+ style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }
192
+ },
193
+ react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40', className: loading ? 'dts-ring dts-ring-loading' : 'dts-ring' },
61
194
  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)' })
195
+ react.createElement('circle', {
196
+ className: 'dts-ring-progress',
197
+ cx: 20, cy: 20, r: radius, fill: 'none',
198
+ stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
199
+ strokeDasharray: circumference,
200
+ strokeDashoffset: offset,
201
+ transform: 'rotate(-90 20 20)',
202
+ // The offset is carried as a style value as well as an attribute:
203
+ // presentation attributes alone do not reliably transition.
204
+ style: { strokeDashoffset: offset, transition: 'stroke-dashoffset ' + RING_GROWTH_MS + 'ms cubic-bezier(.22,.61,.36,1)' }
205
+ }),
206
+ loading && react.createElement('g', { className: 'dts-ring-sweep', style: { transformOrigin: '20px 20px' } },
207
+ react.createElement('circle', {
208
+ cx: 20, cy: 20, r: radius, fill: 'none',
209
+ stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
210
+ strokeDasharray: (circumference * 0.22) + ' ' + (circumference * 0.78),
211
+ opacity: 0.45
212
+ })
213
+ )
63
214
  ),
64
- react.createElement('span', { style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label != null ? label : (percent != null ? percent + '%' : '—'))
215
+ react.createElement('span', { className: 'dts-ring-label', style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label)
65
216
  )
66
217
  }
67
218
 
@@ -79,39 +230,98 @@ window.__ModuleLoader__.load({
79
230
  const [notice, setNotice] = react.useState(null)
80
231
  const [usage, setUsage] = react.useState(null)
81
232
  const [usageError, setUsageError] = react.useState(null)
233
+ const [usageLoading, setUsageLoading] = react.useState(true)
234
+ const [snapshot, setSnapshot] = react.useState(() => readUsageSnapshot())
82
235
 
83
236
  const refresh = react.useCallback(async () => {
237
+ setUsageLoading(true)
238
+ // The two endpoints are independent and the usage one is the slow half,
239
+ // so they run together: the keys must not wait for Tavily's counters.
240
+ const keys = (async () => {
241
+ try {
242
+ const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
243
+ const data = await response.json()
244
+ if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
245
+ } catch (error) {
246
+ setLoadError(String(error && error.message ? error.message : error))
247
+ }
248
+ })()
249
+ const counters = (async () => {
250
+ try {
251
+ const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
252
+ const data = await response.json()
253
+ setUsage(data)
254
+ setUsageError(null)
255
+ if (data.ok) {
256
+ writeUsageSnapshot(data)
257
+ setSnapshot(readUsageSnapshot())
258
+ }
259
+ } catch (error) {
260
+ setUsageError(String(error && error.message ? error.message : error))
261
+ } finally {
262
+ setUsageLoading(false)
263
+ }
264
+ })()
265
+ await Promise.all([keys, counters])
266
+ }, [])
267
+
268
+ react.useEffect(() => { refresh() }, [refresh])
269
+
270
+ const saveStrategy = async (next) => {
271
+ setBusy(true)
272
+ setNotice(null)
84
273
  try {
85
- const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
274
+ const response = await fetch('/api/tavily-manager', {
275
+ method: 'POST',
276
+ headers: { 'content-type': 'application/json' },
277
+ body: JSON.stringify({ add: [], remove: [], strategy: next })
278
+ })
86
279
  const data = await response.json()
87
- if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
280
+ if (!data.ok) { setNotice({ error: data.error || 'Strategy save failed' }); return }
281
+ setNotice({ ok: 'Strategy saved — effective on the next search.' })
282
+ await refresh()
88
283
  } catch (error) {
89
- setLoadError(String(error && error.message ? error.message : error))
284
+ setNotice({ error: String(error && error.message ? error.message : error) })
285
+ } finally {
286
+ setBusy(false)
90
287
  }
288
+ }
289
+
290
+ // The account grouping is inferred from Tavily's usage counters, which is
291
+ // the only account-level signal the API offers; these two handlers are the
292
+ // human override for when the inference is wrong or the cap needs raising.
293
+ const saveAccount = async (masked, choice) => {
294
+ setBusy(true)
295
+ setNotice(null)
91
296
  try {
92
- const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
297
+ const response = await fetch('/api/tavily-manager', {
298
+ method: 'POST',
299
+ headers: { 'content-type': 'application/json' },
300
+ body: JSON.stringify({ add: [], remove: [], strategy, accounts: { [masked]: choice } })
301
+ })
93
302
  const data = await response.json()
94
- setUsage(data)
95
- setUsageError(null)
303
+ if (!data.ok) { setNotice({ error: data.error || 'Account change failed' }); return }
304
+ setNotice({ ok: choice === 'auto' ? 'Account detected automatically again.' : 'Account pinned for this key.' })
305
+ await refresh()
96
306
  } catch (error) {
97
- setUsageError(String(error && error.message ? error.message : error))
307
+ setNotice({ error: String(error && error.message ? error.message : error) })
308
+ } finally {
309
+ setBusy(false)
98
310
  }
99
- }, [])
100
-
101
- react.useEffect(() => { refresh() }, [refresh])
311
+ }
102
312
 
103
- const saveStrategy = async (next) => {
313
+ const saveCap = async (value) => {
104
314
  setBusy(true)
105
315
  setNotice(null)
106
316
  try {
107
317
  const response = await fetch('/api/tavily-manager', {
108
318
  method: 'POST',
109
319
  headers: { 'content-type': 'application/json' },
110
- body: JSON.stringify({ add: [], remove: [], strategy: next })
320
+ body: JSON.stringify({ add: [], remove: [], strategy, accountCap: Number(value) })
111
321
  })
112
322
  const data = await response.json()
113
- if (!data.ok) { setNotice({ error: data.error || 'Strategy save failed' }); return }
114
- setNotice({ ok: 'Strategy saved — effective on the next search.' })
323
+ if (!data.ok) { setNotice({ error: data.error || 'Cap save failed' }); return }
324
+ setNotice({ ok: 'Concurrency cap saved — effective on the next search.' })
115
325
  await refresh()
116
326
  } catch (error) {
117
327
  setNotice({ error: String(error && error.message ? error.message : error) })
@@ -239,12 +449,45 @@ window.__ModuleLoader__.load({
239
449
 
240
450
  const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
241
451
  const cellStyle = { padding: '8px', verticalAlign: 'top' }
452
+ const snapshotRows = snapshot !== null && typeof snapshot === 'object' && snapshot.rows !== null && typeof snapshot.rows === 'object'
453
+ ? snapshot.rows
454
+ : null
455
+ const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
456
+
457
+ // The usage response carries the freshest inference (it is the route that
458
+ // runs it); the manager payload is the fallback, and the only source of the
459
+ // policy before the first usage answer arrives.
460
+ const accountsMap = usage !== null && usage.ok === true && usage.accounts !== null && typeof usage.accounts === 'object'
461
+ ? usage.accounts
462
+ : (server !== null && server.accounts !== null && server.accounts !== undefined && typeof server.accounts === 'object' ? server.accounts : {})
463
+ const accountCap = usage !== null && usage.ok === true && typeof usage.accountCap === 'number'
464
+ ? usage.accountCap
465
+ : (server !== null && typeof server.accountCap === 'number' ? server.accountCap : 1)
466
+ const accountOf = (masked) => {
467
+ const entry = accountsMap[masked]
468
+ const group = entry !== null && entry !== undefined && typeof entry === 'object' && typeof entry.group === 'string' ? entry.group : null
469
+ return {
470
+ group,
471
+ own: group !== null && group.indexOf('own:') === 0,
472
+ manual: entry !== null && entry !== undefined && entry.manual === true,
473
+ }
474
+ }
475
+ const accountLabel = (group) => {
476
+ const name = /^acct-(\d+)$/.exec(group)
477
+ return name === null ? group : 'Account ' + name[1]
478
+ }
479
+ const groupChoices = []
480
+ for (const key of (server !== null ? server.keys : [])) {
481
+ const { group, own } = accountOf(key.masked)
482
+ if (group === null || own) continue
483
+ if (!groupChoices.includes(group)) groupChoices.push(group)
484
+ }
242
485
 
243
486
  // The Plugins page draws the title, the icon, and the crumb — the
244
487
  // `plugins.bundle.config` contract puts the form alone in the entry — so
245
488
  // this renders no card chrome, no second heading, and no collapse.
246
489
  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)}'),
490
+ 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
491
  react.createElement(react.Fragment, null,
249
492
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
250
493
  '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.'
@@ -258,6 +501,7 @@ window.__ModuleLoader__.load({
258
501
  react.createElement('thead', null,
259
502
  react.createElement('tr', null,
260
503
  react.createElement('th', { style: headStyle }, 'Key'),
504
+ react.createElement('th', { style: headStyle }, 'Account'),
261
505
  react.createElement('th', { style: headStyle }, 'Usage'),
262
506
  react.createElement('th', { style: headStyle }, 'Saved'),
263
507
  react.createElement('th', { style: headStyle }, 'Actions')
@@ -269,12 +513,27 @@ window.__ModuleLoader__.load({
269
513
  const isRemoved = removing[masked] === true
270
514
  const isReplacing = replacing[masked] === true
271
515
  const isRevealed = typeof revealed[masked] === 'string'
272
- const usageRow = usage !== null && usage.ok === true
516
+ // Fresh counters win; until they arrive the last stored row is
517
+ // shown so the cell reads as "updating", never as "empty".
518
+ const freshRow = usage !== null && usage.ok === true
273
519
  ? (usage.perKey.find((row) => row.masked === masked) || usage.perKey[index])
274
520
  : 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))
521
+ const storedRow = snapshotRows !== null && Object.prototype.hasOwnProperty.call(snapshotRows, masked)
522
+ ? snapshotRows[masked]
523
+ : null
524
+ const usageRow = freshRow !== null && freshRow !== undefined ? freshRow : storedRow
525
+ const pct = usagePercent(usageRow)
526
+ const rawNumber = pct === null && usageRow !== null && usageRow !== undefined && usageRow.ok === true && usageRow.planUsage != null
527
+ ? usageRow.planUsage
277
528
  : null
529
+ const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
530
+ // Which Tavily account this key belongs to, and the override.
531
+ const account = accountOf(masked)
532
+ const currentChoice = account.manual ? (account.own ? 'own' : account.group) : 'auto'
533
+ const accountChoices = ['auto'].concat(groupChoices).concat(['own'])
534
+ if (currentChoice !== 'auto' && !accountChoices.includes(currentChoice)) {
535
+ accountChoices.splice(1, 0, currentChoice)
536
+ }
278
537
  return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
279
538
  react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
280
539
  isReplacing
@@ -294,7 +553,34 @@ window.__ModuleLoader__.load({
294
553
  )
295
554
  ),
296
555
  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 })
556
+ react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
557
+ react.createElement('span', {
558
+ className: 'dts-account-chip',
559
+ style: { fontSize: 11, color: account.group === null ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)' }
560
+ }, account.group === null ? 'Ungrouped' : (account.own ? 'Separate' : accountLabel(account.group))),
561
+ react.createElement('select', {
562
+ className: 'dts-account',
563
+ 'data-masked': masked,
564
+ 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.',
565
+ value: currentChoice,
566
+ disabled: busy,
567
+ onChange: (event) => saveAccount(masked, event.target.value),
568
+ 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 }
569
+ },
570
+ accountChoices.map((choice) => react.createElement('option', { key: choice, value: choice },
571
+ choice === 'auto' ? 'Auto' : (choice === 'own' ? 'Separate' : accountLabel(choice))))
572
+ )
573
+ )
574
+ ),
575
+ react.createElement('td', { style: cellStyle },
576
+ react.createElement(UsageCircle, {
577
+ percent: pct,
578
+ number: rawNumber,
579
+ loading: usageLoading,
580
+ stale: isStale,
581
+ staleAt: snapshotAt,
582
+ onClick: refresh
583
+ })
298
584
  ),
299
585
  react.createElement('td', { style: Object.assign({}, cellStyle, { color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' }) }, formatDate(key.savedAt)),
300
586
  react.createElement('td', { style: cellStyle },
@@ -335,6 +621,7 @@ window.__ModuleLoader__.load({
335
621
  ),
336
622
  react.createElement('td', { style: cellStyle }, '—'),
337
623
  react.createElement('td', { style: cellStyle }, '—'),
624
+ react.createElement('td', { style: cellStyle }, '—'),
338
625
  react.createElement('td', { style: cellStyle },
339
626
  react.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
340
627
  react.createElement(IconButton, { icon: 'check', title: 'Save key', onClick: () => saveAdd(item), disabled: busy || item.value.trim().length === 0 }),
@@ -351,11 +638,21 @@ window.__ModuleLoader__.load({
351
638
  react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
352
639
  react.createElement('label', { style: { fontWeight: 500, fontSize: 13 } }, 'Key usage strategy'),
353
640
  react.createElement('select', {
641
+ className: 'dts-strategy',
354
642
  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 },
355
643
  value: strategy,
356
644
  onChange: (event) => saveStrategy(event.target.value),
357
645
  disabled: busy
358
646
  }, STRATEGIES.map((option) => react.createElement('option', { key: option.id, value: option.id }, option.label))),
647
+ strategy === 'load-balance' && react.createElement('label', { style: { fontWeight: 500, fontSize: 13, marginLeft: 8 } }, 'Per account'),
648
+ strategy === 'load-balance' && react.createElement('select', {
649
+ className: 'dts-account-cap',
650
+ title: 'How many requests may be in flight for one Tavily account at a time. One suits a free key.',
651
+ 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 },
652
+ value: String(accountCap),
653
+ onChange: (event) => saveCap(event.target.value),
654
+ disabled: busy
655
+ }, ACCOUNT_CAPS.map((n) => react.createElement('option', { key: n, value: String(n) }, String(n) + (n === 1 ? ' request' : ' requests')))),
359
656
  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))
360
657
  ),
361
658
  react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moguiyu/dsh-tavily",
3
- "version": "0.3.2",
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.",
3
+ "version": "0.3.4",
4
+ "description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with multi-key rotation, failover and a per-account in-flight cap, plus a key and usage management card on the Plugins page. The built-in web_search is never replaced. | DSH Tavily 可选高级搜索工具:多 Key 轮换、故障转移与按账号并发上限,插件配置页含密钥与用量管理卡片;内置 web_search 永不替换。",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -64,7 +64,8 @@
64
64
  ],
65
65
  "repository": {
66
66
  "type": "git",
67
- "url": "https://github.com/moguiyu/dsh-tavily.git"
67
+ "url": "git+https://github.com/moguiyu/dsh-tavily.git",
68
+ "directory": "packages/dsh-tavily"
68
69
  },
69
70
  "peerDependencies": {
70
71
  "@deepseek-ai/cordis": "^4.0.1",