@yuuz12/dsh-tavily 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,587 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-tavily',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
7
+ let react = require('react');
8
+ let _runtime_client = require('@deepseek-ai/dsh-client-store');
9
+
10
+ const inject = ['slots', 'locale', 'connection', 'remote'];
11
+
12
+ const ENDPOINT = '/dsh-tavily';
13
+ const LOCALE_NS = 'dsh-tavily';
14
+
15
+ // ---------------------------------------------------------------------
16
+ // 样式:全部 --dsw-alias-* 主题令牌,明暗自适应。
17
+ // 表单范式对齐内置插件卡片字段:标签在上、控件在下、
18
+ // 字段间 border-top 分隔、成对设置走两列栅格。
19
+ // ---------------------------------------------------------------------
20
+ const CSS = [
21
+ // —— 卡片骨架(对齐内置 PluginCard)——
22
+ '.tvly-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background-color .16s}',
23
+ '.tvly-card:hover{border-color:var(--dsw-alias-label-dimmed)}',
24
+ '.tvly-card[data-open="true"]{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
25
+ '.tvly-head{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}',
26
+ '.tvly-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}',
27
+ '.tvly-head-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}',
28
+ '.tvly-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}',
29
+ '.tvly-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
30
+ '.tvly-chevron{flex:none;display:block;width:14px;height:14px;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
31
+ '.tvly-chevron[data-open="true"]{transform:rotate(180deg)}',
32
+ '.tvly-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:0 0 12px;color:var(--dsw-alias-label-primary);font-size:13px}',
33
+
34
+ // —— 状态行 ——
35
+ '.tvly-status{display:flex;align-items:flex-start;gap:8px;margin:10px 0 4px;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-module-platform);line-height:1.55;font-size:12px;color:var(--dsw-alias-label-secondary)}',
36
+ '.tvly-dot{flex:none;width:8px;height:8px;border-radius:50%;margin-top:4px;background:var(--dsw-alias-label-faint,#9c9c9c)}',
37
+
38
+ // —— 字段范式:分隔线 + 标签行 + 控件 ——
39
+ '.tvly-field{padding:12px 0;border-top:1px solid var(--dsw-alias-border-l2);min-width:0}',
40
+ '.tvly-headrow{display:flex;align-items:center;gap:8px;margin-bottom:6px}',
41
+ '.tvly-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}',
42
+ '.tvly-sub{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
43
+
44
+ // 主开关字段:左文案右开关
45
+ '.tvly-togglefield{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--dsw-alias-border-l2)}',
46
+ '.tvly-texts{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
47
+
48
+ // 两列成对字段
49
+ '.tvly-duo{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;padding:12px 0;border-top:1px solid var(--dsw-alias-border-l2)}',
50
+ '.tvly-minifield{display:flex;flex-direction:column;gap:6px;min-width:0}',
51
+ '.tvly-minihead{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
52
+
53
+ // 控件
54
+ '.tvly-select,.tvly-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;transition:border-color .16s}',
55
+ '.tvly-select:focus-visible,.tvly-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}',
56
+ '.tvly-select:disabled,.tvly-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}',
57
+ '.tvly-input::placeholder{color:var(--dsw-alias-label-faint,#9c9c9c)}',
58
+
59
+ '.tvly-switch{position:relative;display:inline-block;width:36px;height:20px;flex:none;cursor:pointer}',
60
+ '.tvly-switch input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}',
61
+ '.tvly-switch-track{position:absolute;inset:0;border-radius:999px;background:var(--dsw-alias-bg-module-platform);transition:background-color .16s;pointer-events:none}',
62
+ '.tvly-switch-track::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:transform .16s,background-color .16s}',
63
+ '.tvly-switch input:checked + .tvly-switch-track{background:var(--dsw-alias-brand-primary)}',
64
+ // 选中态旋钮用主题底色令牌:暗色下是深色点、浅色下是白点,与品牌轨道恒有反差
65
+ '.tvly-switch input:checked + .tvly-switch-track::after{transform:translateX(16px);background:var(--dsw-alias-bg-layer-3,#fff)}',
66
+ '.tvly-switch input:focus-visible + .tvly-switch-track{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
67
+ '.tvly-switch input:disabled + .tvly-switch-track{opacity:.4}',
68
+
69
+ '.tvly-btn{appearance:none;font:inherit;font-size:12px;line-height:1.5;cursor:pointer;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary);white-space:nowrap;transition:color .16s,border-color .16s,opacity .16s}',
70
+ '.tvly-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
71
+ '.tvly-btn:disabled{opacity:.4;cursor:default}',
72
+ '.tvly-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
73
+ '.tvly-btn-solid{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
74
+ '.tvly-btn-solid:hover:not(:disabled){opacity:.9}',
75
+ '.tvly-btn-danger:hover:not(:disabled){color:var(--dsw-alias-label-error,#d1242f);border-color:var(--dsw-alias-label-error,#d1242f)}',
76
+
77
+ '.tvly-badge{display:inline-flex;align-items:center;white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}',
78
+ '.tvly-badge-warn{color:#b45309}',
79
+ '.tvly-badge-err{color:var(--dsw-alias-label-error,#d1242f)}',
80
+
81
+ // —— 密钥列表 ——
82
+ '.tvly-sechead{display:flex;align-items:center;gap:8px;margin-top:2px;padding:12px 0 8px;border-top:1px solid var(--dsw-alias-border-l2)}',
83
+ '.tvly-sectitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
84
+ '.tvly-spacer{flex:1}',
85
+ '.tvly-keys{display:flex;flex-direction:column;gap:8px;margin:0;padding:0;list-style:none}',
86
+ '.tvly-key{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;transition:border-color .16s}',
87
+ '.tvly-key:hover{border-color:var(--dsw-alias-label-dimmed)}',
88
+ '.tvly-key[data-disabled="true"]{opacity:.55}',
89
+ '.tvly-key-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
90
+ '.tvly-order{display:flex;flex-direction:column;gap:2px;flex:none}',
91
+ '.tvly-arrow{appearance:none;font:inherit;font-size:9px;line-height:1;cursor:pointer;padding:2px 7px;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:4px}',
92
+ '.tvly-arrow:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
93
+ '.tvly-arrow:disabled{opacity:.35;cursor:default}',
94
+ '.tvly-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--dsw-alias-label-primary)}',
95
+ '.tvly-labelinput{width:120px;height:26px;padding:0 8px;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:6px}',
96
+ '.tvly-labelinput:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}',
97
+
98
+ '.tvly-balrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
99
+ '.tvly-bar{position:relative;flex:1 1 140px;height:6px;min-width:110px;border-radius:999px;background:var(--dsw-alias-bg-module-platform);overflow:hidden}',
100
+ '.tvly-bar>i{position:absolute;top:0;bottom:0;left:0;border-radius:999px;transition:width .2s ease}',
101
+ '.tvly-baltext{font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
102
+
103
+ '.tvly-stats{display:flex;gap:14px;flex-wrap:wrap;margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
104
+ '.tvly-stats b{font-weight:600;color:var(--dsw-alias-label-secondary)}',
105
+ '.tvly-lasterr{margin:0;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-error,#d1242f);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
106
+
107
+ '.tvly-msg{margin:6px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-success-primary,#1a7f37)}',
108
+ '.tvly-err{margin:6px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error,#d1242f);white-space:pre-wrap}',
109
+ '.tvly-hint{margin:0;padding:12px 0 0;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px;line-height:1.7;color:var(--dsw-alias-label-tertiary)}',
110
+ '.tvly-hint code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}',
111
+ ].join('\n');
112
+
113
+ // ---------------------------------------------------------------------
114
+ // 文案(zh 主,en 备)。组件优先取 props.t(key),取不到回落中文表。
115
+ // ---------------------------------------------------------------------
116
+ const ZH = {
117
+ title: 'Tavily 网页搜索',
118
+ description: '多密钥池 · 余额优先轮流 · 失败自动切换 · 用量统计',
119
+ statusActive: '已接管网页搜索:模型的 web_search 工具现在通过 Tavily 执行。',
120
+ statusNoKeys: '开关已打开,但还没有可用密钥——当前仍由官方搜索兜底。请先在下方添加 API Key。',
121
+ statusOff: '未接管:网页搜索仍由 DSH 官方提供方执行。',
122
+ baseline: '基线',
123
+ replace: '替换官方网页搜索',
124
+ replaceOn: '开启中——web_search 走 Tavily',
125
+ replaceOff: '关闭——web_search 走官方搜索',
126
+ strategyLabel: '多密钥使用顺序',
127
+ strategyBalance: '余额优先:剩余额度多的先用,同额轮流',
128
+ strategyManual: '手动顺序:按下方列表从上到下',
129
+ depthLabel: '搜索深度',
130
+ maxResultsLabel: '单次结果上限',
131
+ topicLabel: '搜索类别',
132
+ answerLabel: 'AI 摘要回答',
133
+ keysSection: 'API 密钥',
134
+ test: '测试',
135
+ enable: '启用',
136
+ disable: '停用',
137
+ remove: '删除',
138
+ removeConfirm: '确定删除该密钥?其统计与用量缓存将一并清除。',
139
+ noKeys: '还没有添加任何 Tavily API Key。',
140
+ keyPlaceholder: '粘贴 Tavily API Key(tvly-…)',
141
+ labelPlaceholder: '备注名(可选)',
142
+ addKey: '添加密钥',
143
+ refreshUsage: '刷新全部用量',
144
+ keyAdded: '密钥已添加',
145
+ usageRefreshed: '已完成全部密钥的用量刷新',
146
+ unknownBalance: '余额未知——点上方「刷新全部用量」获取',
147
+ staleData: '可能过期',
148
+ cooling: '冷却中',
149
+ disabledBadge: '已停用',
150
+ formatWarn: '格式提醒',
151
+ calls: '调用',
152
+ successN: '成功',
153
+ failedN: '失败',
154
+ credits: '积分',
155
+ lastLatency: '耗时',
156
+ lastUsed: '最近使用',
157
+ lastErrorPrefix: '最近错误:',
158
+ searchHint: '「测试」会真实发起一次 basic 搜索(消耗 1 积分);失败会自动切换下一把密钥,鉴权失败/额度用尽冷却 5 分钟。',
159
+ }
160
+ const EN = {
161
+ title: 'Tavily web search',
162
+ description: 'Multi-key pool · balance-first rotation · failover · usage stats',
163
+ statusActive: 'Active: the model web_search tool now runs through Tavily.',
164
+ statusNoKeys: 'Switch is on but no usable key — official search still serves. Add an API key below.',
165
+ statusOff: 'Inactive: web search is served by the official provider.',
166
+ baseline: 'baseline',
167
+ replace: 'Replace official web search',
168
+ replaceOn: 'On — web_search goes through Tavily',
169
+ replaceOff: 'Off — web_search uses the official provider',
170
+ strategyLabel: 'Multi-key order',
171
+ strategyBalance: 'Balance first: highest remaining leads, ties rotate',
172
+ strategyManual: 'Manual: top to bottom as listed below',
173
+ depthLabel: 'Search depth',
174
+ maxResultsLabel: 'Max results',
175
+ topicLabel: 'Topic',
176
+ answerLabel: 'AI answer',
177
+ keysSection: 'API keys',
178
+ test: 'Test',
179
+ enable: 'Enable',
180
+ disable: 'Disable',
181
+ remove: 'Remove',
182
+ removeConfirm: 'Remove this key? Its stats and cached usage will be cleared.',
183
+ noKeys: 'No Tavily API key yet.',
184
+ keyPlaceholder: 'Paste a Tavily API key (tvly-…)',
185
+ labelPlaceholder: 'Label (optional)',
186
+ addKey: 'Add key',
187
+ refreshUsage: 'Refresh balances',
188
+ keyAdded: 'Key added',
189
+ usageRefreshed: 'Balances refreshed',
190
+ unknownBalance: 'Unknown — click "Refresh balances"',
191
+ staleData: 'stale?',
192
+ cooling: 'cooling',
193
+ disabledBadge: 'disabled',
194
+ formatWarn: 'format',
195
+ calls: 'calls',
196
+ successN: 'ok',
197
+ failedN: 'failed',
198
+ credits: 'credits',
199
+ lastLatency: 'latency',
200
+ lastUsed: 'last used',
201
+ lastErrorPrefix: 'Last error: ',
202
+ searchHint: '"Test" runs one real basic search (1 credit). Failures roll over to the next key; auth/quota failures cool down for 5 minutes.',
203
+ }
204
+
205
+ async function api(path, body) {
206
+ const init = body !== undefined
207
+ ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
208
+ : undefined;
209
+ const res = await fetch(ENDPOINT + path, init);
210
+ let data;
211
+ try { data = await res.json(); } catch (e) { data = { ok: false, error: 'HTTP ' + res.status }; }
212
+ return data;
213
+ }
214
+
215
+ function fmtInt(n) {
216
+ if (n === null || n === undefined || !Number.isFinite(Number(n))) return '—';
217
+ return String(n);
218
+ }
219
+
220
+ /** 共享控制器:服务端状态快照 + 少量 UI 态,经 slots hooks 注入卡片。 */
221
+ class TavilyController {
222
+ constructor() {
223
+ this.data = null;
224
+ this.ui = { busy: false, message: '', error: '' };
225
+ this.snapshot = { ready: false, data: null, busy: false, message: '', error: '' };
226
+ this.store = _runtime_client.createSnapshotStore(this.snapshot);
227
+ }
228
+ async load() {
229
+ try {
230
+ const r = await api('/state');
231
+ if (r && r.ok) this.data = r.state;
232
+ } catch (e) { /* 首帧失败留给错误条:run/refresh 会再取 */ }
233
+ this.publish();
234
+ }
235
+ publish() {
236
+ this.snapshot = {
237
+ ready: this.data !== null,
238
+ data: this.data,
239
+ busy: this.ui.busy,
240
+ message: this.ui.message,
241
+ error: this.ui.error,
242
+ };
243
+ this.store.set(this.snapshot);
244
+ }
245
+ async run(fn, okText) {
246
+ this.ui.busy = true; this.ui.message = ''; this.ui.error = ''; this.publish();
247
+ try {
248
+ const r = await fn();
249
+ if (r && r.state) this.data = r.state;
250
+ if (r && r.ok) { if (typeof okText === 'string') this.ui.message = okText; }
251
+ else this.ui.error = (r && r.error) || '操作失败';
252
+ return r;
253
+ } catch (e) {
254
+ this.ui.error = (e && e.message ? e.message : String(e));
255
+ return null;
256
+ } finally {
257
+ this.ui.busy = false;
258
+ this.publish();
259
+ }
260
+ }
261
+ patchConfig(patch, okText) { return this.run(() => api('/config', patch), okText); }
262
+ addKey(key, label) { return this.run(() => api('/keys', { key: key.trim(), label: label.trim() }), ZH.keyAdded); }
263
+ updateKey(id, patch) { return this.run(() => api('/keys/update', { id, ...patch })); }
264
+ moveKey(id, dir) { return this.run(() => api('/keys/move', { id, dir })); }
265
+ removeKey(id) { return this.run(() => api('/keys/remove', { id })); }
266
+ testKey(id) { return this.run(() => api('/keys/test', { id })); }
267
+ refreshUsage() { return this.run(() => api('/usage/refresh', {}), ZH.usageRefreshed); }
268
+ }
269
+
270
+ function chevron(open) {
271
+ return react.createElement('svg', {
272
+ className: 'tvly-chevron', 'data-open': open ? 'true' : 'false',
273
+ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true,
274
+ }, react.createElement('path', {
275
+ d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z',
276
+ fill: 'currentColor',
277
+ }));
278
+ }
279
+
280
+ function BalanceBar({ usage, stale }) {
281
+ if (!usage) return react.createElement('span', { className: 'tvly-baltext' }, ZH.unknownBalance);
282
+ // 口径选择:密钥级上限存在 → 按密钥口径;否则回退账户计划额度(多密钥共享)
283
+ const hasKeyCap = usage.limit != null && usage.usage != null;
284
+ const hasPlan = usage.planLimit != null;
285
+ if (!hasKeyCap && !hasPlan) {
286
+ return react.createElement('span', { className: 'tvly-baltext' }, ZH.unknownBalance);
287
+ }
288
+ let used; let limit; let prefix = '';
289
+ if (hasKeyCap) { used = usage.usage; limit = usage.limit; }
290
+ else { used = usage.planUsage ?? 0; limit = usage.planLimit; prefix = '计划 '; }
291
+ const pct = limit ? Math.max(0, Math.min(100, (used / limit) * 100)) : 100;
292
+ const color = limit == null
293
+ ? 'var(--dsw-alias-state-success-primary,#1a7f37)'
294
+ : pct >= 80 ? 'var(--dsw-alias-label-error,#d1242f)' : pct >= 50 ? '#b45309' : 'var(--dsw-alias-state-success-primary,#1a7f37)';
295
+ return react.createElement(react.Fragment, null,
296
+ react.createElement('div', { className: 'tvly-bar', title: (prefix + fmtInt(used)) + (limit != null ? ' / ' + fmtInt(limit) : '') },
297
+ react.createElement('i', { style: { width: pct + '%', background: color } })
298
+ ),
299
+ react.createElement('span', { className: 'tvly-baltext' },
300
+ prefix + fmtInt(used) + (limit != null ? ' / ' + fmtInt(limit) : '') +
301
+ ' · 剩余 ' + (limit == null ? '—' : fmtInt(limit - used)) +
302
+ (usage.plan ? ' · ' + usage.plan : '')
303
+ ),
304
+ stale && react.createElement('span', { className: 'tvly-badge' }, ZH.staleData)
305
+ );
306
+ }
307
+
308
+ /** 一个 mini 字段:小标签在上、控件在下(用于两列栅格)。 */
309
+ function MiniField({ label, extra, children }) {
310
+ return react.createElement('div', { className: 'tvly-minifield' },
311
+ react.createElement('span', { className: 'tvly-minihead' }, label, extra || null),
312
+ children
313
+ );
314
+ }
315
+
316
+ function KeyRow({ k, st, index, total, busy, ctrl }) {
317
+ return react.createElement('li', { className: 'tvly-key', 'data-disabled': k.disabled ? 'true' : 'false' },
318
+ react.createElement('div', { className: 'tvly-key-top' },
319
+ react.createElement('span', { className: 'tvly-order' },
320
+ react.createElement('button', {
321
+ className: 'tvly-arrow', disabled: busy || index === 0, title: '上移',
322
+ onClick: () => ctrl.moveKey(k.id, -1),
323
+ }, '▲'),
324
+ react.createElement('button', {
325
+ className: 'tvly-arrow', disabled: busy || index === total - 1, title: '下移',
326
+ onClick: () => ctrl.moveKey(k.id, 1),
327
+ }, '▼')
328
+ ),
329
+ react.createElement('span', { className: 'tvly-mono' }, '#' + (index + 1) + ' ' + k.masked),
330
+ k.hasWarningPrefix && react.createElement('span', { className: 'tvly-badge tvly-badge-warn', title: 'Tavily 密钥通常以 tvly- 开头' }, ZH.formatWarn),
331
+ st.cooling && react.createElement('span', { className: 'tvly-badge tvly-badge-warn' }, ZH.cooling),
332
+ k.disabled && react.createElement('span', { className: 'tvly-badge' }, ZH.disabledBadge),
333
+ react.createElement('span', { className: 'tvly-spacer' }),
334
+ react.createElement('button', { className: 'tvly-btn', disabled: busy, onClick: () => ctrl.testKey(k.id) }, ZH.test),
335
+ react.createElement('button', { className: 'tvly-btn', disabled: busy, onClick: () => ctrl.updateKey(k.id, { disabled: !k.disabled }) }, k.disabled ? ZH.enable : ZH.disable),
336
+ react.createElement('button', {
337
+ className: 'tvly-btn tvly-btn-danger', disabled: busy,
338
+ onClick: () => { if (window.confirm(ZH.removeConfirm)) ctrl.removeKey(k.id); },
339
+ }, ZH.remove)
340
+ ),
341
+ react.createElement('div', { className: 'tvly-key-top' },
342
+ react.createElement(BalanceBar, { usage: k.usage, stale: k.stale }),
343
+ react.createElement('input', {
344
+ className: 'tvly-labelinput',
345
+ defaultValue: k.label || '', placeholder: '备注名', maxLength: 40, disabled: busy,
346
+ key: 'lbl-' + k.id + '-' + (k.label || ''),
347
+ onBlur: (e) => { const v = e.target.value.trim(); if (v !== (k.label || '')) ctrl.updateKey(k.id, { label: v }); },
348
+ onKeyDown: (e) => { if (e.key === 'Enter') e.target.blur(); },
349
+ })
350
+ ),
351
+ react.createElement('div', { className: 'tvly-stats' },
352
+ react.createElement('span', null, ZH.calls, ' ', react.createElement('b', null, fmtInt(st.requests))),
353
+ react.createElement('span', null, ZH.successN, ' ', react.createElement('b', null, fmtInt(st.success))),
354
+ react.createElement('span', null, ZH.failedN, ' ', react.createElement('b', null, fmtInt(st.failed))),
355
+ react.createElement('span', null, ZH.credits, ' ', react.createElement('b', null, fmtInt(st.creditsUsed))),
356
+ st.lastLatencyMs != null && react.createElement('span', null, ZH.lastLatency, ' ', react.createElement('b', null, st.lastLatencyMs + 'ms')),
357
+ st.lastUsedAt && react.createElement('span', null, ZH.lastUsed, ' ', new Date(st.lastUsedAt).toLocaleString())
358
+ ),
359
+ st.lastError && react.createElement('p', { className: 'tvly-lasterr', title: st.lastError }, ZH.lastErrorPrefix + st.lastError)
360
+ );
361
+ }
362
+
363
+ function TavilyCardBody({ state, ctrl, t }) {
364
+ const T = (key) => {
365
+ if (t) { try { const v = t(key); if (typeof v === 'string' && v.length > 0) return v; } catch (e) { /* fallthrough */ } }
366
+ return ZH[key];
367
+ };
368
+ const [newKey, setNewKey] = react.useState('');
369
+ const [newLabel, setNewLabel] = react.useState('');
370
+
371
+ const busy = !!state.busy;
372
+ const data = state.data;
373
+ const cfg = (data && data.config) || {};
374
+ const routing = (data && data.routing) || {};
375
+ const keys = (data && Array.isArray(data.keys)) ? data.keys : [];
376
+ const stats = (data && data.stats) || {};
377
+ const usableCount = keys.filter((k) => !k.disabled).length;
378
+
379
+ let statusColor = 'var(--dsw-alias-label-faint,#9c9c9c)';
380
+ let statusText = T('statusOff');
381
+ if (routing.active) {
382
+ statusColor = 'var(--dsw-alias-state-success-primary,#1a7f37)';
383
+ statusText = T('statusActive');
384
+ } else if (cfg.enabled && usableCount === 0) {
385
+ statusColor = '#b45309';
386
+ statusText = T('statusNoKeys');
387
+ }
388
+ const baselineSuffix = routing.baselineId
389
+ ? `(${T('baseline')}: ${routing.baselineId}${routing.registeredProviders && routing.registeredProviders.length ? ' · ' + routing.registeredProviders.join(', ') : ''})`
390
+ : '';
391
+
392
+ return react.createElement(react.Fragment, null,
393
+ // 状态行
394
+ react.createElement('p', { className: 'tvly-status' },
395
+ react.createElement('span', { className: 'tvly-dot', style: { background: statusColor } }),
396
+ react.createElement('span', null, statusText, baselineSuffix)
397
+ ),
398
+
399
+ // 主开关
400
+ react.createElement('div', { className: 'tvly-togglefield' },
401
+ react.createElement('label', { className: 'tvly-switch' },
402
+ react.createElement('input', {
403
+ type: 'checkbox', role: 'switch', checked: cfg.enabled === true, disabled: busy,
404
+ onChange: (e) => ctrl.patchConfig({ enabled: e.target.checked }),
405
+ }),
406
+ react.createElement('span', { className: 'tvly-switch-track', 'aria-hidden': true })
407
+ ),
408
+ react.createElement('span', { className: 'tvly-texts' },
409
+ react.createElement('span', { className: 'tvly-label' }, T('replace')),
410
+ react.createElement('span', { className: 'tvly-sub' }, cfg.enabled ? T('replaceOn') : T('replaceOff'))
411
+ )
412
+ ),
413
+
414
+ // 多密钥策略(整行)
415
+ react.createElement('div', { className: 'tvly-field' },
416
+ react.createElement('div', { className: 'tvly-headrow' },
417
+ react.createElement('span', { className: 'tvly-label' }, T('strategyLabel'))
418
+ ),
419
+ react.createElement('select', {
420
+ className: 'tvly-select', value: cfg.strategy || 'balance', disabled: busy,
421
+ onChange: (e) => ctrl.patchConfig({ strategy: e.target.value }),
422
+ },
423
+ react.createElement('option', { value: 'balance' }, T('strategyBalance')),
424
+ react.createElement('option', { value: 'manual' }, T('strategyManual'))
425
+ )
426
+ ),
427
+
428
+ // 搜索参数(两列 × 两行)
429
+ react.createElement('div', { className: 'tvly-duo' },
430
+ react.createElement(MiniField, { label: T('depthLabel') },
431
+ react.createElement('select', {
432
+ className: 'tvly-select', value: cfg.searchDepth || 'basic', disabled: busy,
433
+ onChange: (e) => ctrl.patchConfig({ searchDepth: e.target.value }),
434
+ },
435
+ react.createElement('option', { value: 'basic' }, 'basic · 1 积分/次'),
436
+ react.createElement('option', { value: 'advanced' }, 'advanced · 2 积分/次'),
437
+ react.createElement('option', { value: 'fast' }, 'fast · 低延迟'),
438
+ react.createElement('option', { value: 'ultra-fast' }, 'ultra-fast · 极速')
439
+ )
440
+ ),
441
+ react.createElement(MiniField, { label: T('topicLabel') },
442
+ react.createElement('select', {
443
+ className: 'tvly-select', value: cfg.topic || 'general', disabled: busy,
444
+ onChange: (e) => ctrl.patchConfig({ topic: e.target.value }),
445
+ },
446
+ react.createElement('option', { value: 'general' }, 'general · 综合网页'),
447
+ react.createElement('option', { value: 'news' }, 'news · 新闻'),
448
+ react.createElement('option', { value: 'finance' }, 'finance · 财经')
449
+ )
450
+ ),
451
+ react.createElement(MiniField, { label: T('maxResultsLabel') },
452
+ react.createElement('input', {
453
+ className: 'tvly-input', type: 'number', min: 1, max: 20,
454
+ defaultValue: cfg.maxResults != null ? String(cfg.maxResults) : '8',
455
+ key: 'mr-' + cfg.maxResults, disabled: busy,
456
+ onBlur: (e) => {
457
+ const n = Number(e.target.value);
458
+ if (Number.isInteger(n) && n >= 1 && n <= 20 && n !== cfg.maxResults) ctrl.patchConfig({ maxResults: n });
459
+ else e.target.value = String(cfg.maxResults != null ? cfg.maxResults : 8);
460
+ },
461
+ onKeyDown: (e) => { if (e.key === 'Enter') e.target.blur(); },
462
+ })
463
+ ),
464
+ react.createElement(MiniField, { label: T('answerLabel') },
465
+ react.createElement('div', { style: { display: 'flex', alignItems: 'center', height: 32 } },
466
+ react.createElement('label', { className: 'tvly-switch' },
467
+ react.createElement('input', {
468
+ type: 'checkbox', role: 'switch', checked: cfg.includeAnswer === true, disabled: busy,
469
+ onChange: (e) => ctrl.patchConfig({ includeAnswer: e.target.checked }),
470
+ }),
471
+ react.createElement('span', { className: 'tvly-switch-track', 'aria-hidden': true })
472
+ )
473
+ )
474
+ )
475
+ ),
476
+
477
+ // 密钥区
478
+ react.createElement('div', { className: 'tvly-sechead' },
479
+ react.createElement('span', { className: 'tvly-sectitle' }, T('keysSection')),
480
+ react.createElement('span', { className: 'tvly-badge' }, keys.length + (usableCount < keys.length ? ` / 可用 ${usableCount}` : '')),
481
+ react.createElement('span', { className: 'tvly-spacer' }),
482
+ react.createElement('button', { className: 'tvly-btn', disabled: busy || keys.length === 0, onClick: () => ctrl.refreshUsage() }, T('refreshUsage'))
483
+ ),
484
+ react.createElement('ul', { className: 'tvly-keys' },
485
+ keys.map((k, index) => react.createElement(KeyRow, {
486
+ key: k.id, k, st: stats[k.id] || {}, index, total: keys.length, busy, ctrl,
487
+ })),
488
+ keys.length === 0 && react.createElement('li', { className: 'tvly-sub' }, T('noKeys'))
489
+ ),
490
+
491
+ // 添加密钥
492
+ react.createElement('div', { className: 'tvly-field' },
493
+ react.createElement('div', { className: 'tvly-headrow' },
494
+ react.createElement('span', { className: 'tvly-label' }, T('addKey'))
495
+ ),
496
+ react.createElement('div', { className: 'tvly-row', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
497
+ react.createElement('input', {
498
+ className: 'tvly-input', style: { flex: '1 1 220px', width: 'auto' }, type: 'password', autoComplete: 'off',
499
+ placeholder: T('keyPlaceholder'), value: newKey, disabled: busy,
500
+ onChange: (e) => setNewKey(e.target.value),
501
+ }),
502
+ react.createElement('input', {
503
+ className: 'tvly-input', style: { flex: '0 1 150px', width: 'auto' }, type: 'text',
504
+ placeholder: T('labelPlaceholder'), value: newLabel, maxLength: 40, disabled: busy,
505
+ onChange: (e) => setNewLabel(e.target.value),
506
+ }),
507
+ react.createElement('button', {
508
+ className: 'tvly-btn tvly-btn-solid', style: { alignSelf: 'center' },
509
+ disabled: busy || newKey.trim().length < 8,
510
+ onClick: () => { ctrl.addKey(newKey, newLabel).then((r) => { if (r && r.ok) { setNewKey(''); setNewLabel(''); } }); },
511
+ }, T('addKey'))
512
+ )
513
+ ),
514
+
515
+ state.message && react.createElement('p', { className: 'tvly-msg' }, state.message),
516
+ state.error && react.createElement('p', { className: 'tvly-err' }, state.error),
517
+
518
+ data && data.dataPath && react.createElement('p', { className: 'tvly-hint' },
519
+ T('searchHint'),
520
+ ' 密钥仅存本机(',
521
+ react.createElement('code', null, data.dataPath),
522
+ '),界面只显示脱敏形式。'
523
+ )
524
+ );
525
+ }
526
+
527
+ /**
528
+ * 插件配置标签页的 keyed 卡片。结构对齐内置 PluginCard:
529
+ * li 折叠卡;数据经 slots hooks 注入(hooks.{handle:store} → props.use<Handle>)。
530
+ */
531
+ function TavilyCard(props) {
532
+ const t = props.t;
533
+ const state = props.useTavilyCard((s) => s);
534
+ const ctrl = props.tavilyCtrl;
535
+ const [open, setOpen] = react.useState(false);
536
+ return react.createElement('li', { className: 'tvly-card', 'data-open': open ? 'true' : 'false' },
537
+ react.createElement('button', {
538
+ type: 'button', className: 'tvly-head', 'aria-expanded': open,
539
+ onClick: () => setOpen(!open),
540
+ },
541
+ react.createElement('span', { className: 'tvly-head-text' },
542
+ react.createElement('span', { className: 'tvly-name' }, t ? t('title') : ZH.title),
543
+ react.createElement('span', { className: 'tvly-desc' }, t ? t('description') : ZH.description)
544
+ ),
545
+ chevron(open)
546
+ ),
547
+ open ? react.createElement('div', { className: 'tvly-body' },
548
+ !state.ready
549
+ ? react.createElement('p', { className: 'tvly-sub', style: { margin: '10px 0' } }, 'Loading…')
550
+ : react.createElement(TavilyCardBody, { state, ctrl, t })
551
+ ) : null
552
+ );
553
+ }
554
+
555
+ function apply(ctx) {
556
+ ctx.effect(() => {
557
+ const prev = document.querySelector('style[data-plugin-css="dsh-tavily"]');
558
+ if (prev) prev.remove();
559
+ const tag = document.createElement('style');
560
+ tag.setAttribute('data-plugin', 'dsh-tavily');
561
+ tag.setAttribute('data-plugin-css', 'dsh-tavily');
562
+ tag.textContent = CSS;
563
+ document.head.appendChild(tag);
564
+ return () => { if (tag.parentNode) tag.parentNode.removeChild(tag); };
565
+ }, 'dsh-tavily: styles');
566
+
567
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh: ZH, en: EN }), 'dsh-tavily: locale');
568
+
569
+ const ctrl = new TavilyController();
570
+ ctrl.load();
571
+
572
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
573
+ name: 'settings.plugin.item',
574
+ key: 'dsh-tavily',
575
+ locale: LOCALE_NS,
576
+ inject: () => ({
577
+ hooks: { tavilyCard: ctrl.store },
578
+ tavilyCtrl: ctrl,
579
+ }),
580
+ }, TavilyCard));
581
+ }
582
+
583
+ exports.apply = apply;
584
+ exports.inject = inject;
585
+ return module.exports;
586
+ }
587
+ });
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@yuuz12/dsh-tavily",
3
+ "version": "0.1.0",
4
+ "description": "Tavily-backed search provider for the DeepSeek Harness web capability seam (ctx.web) — replaces the official web search with multi-key balance-aware rotation, manageable from the DSH web settings (Plugins → Plugin configuration).",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": {
10
+ "default": "./lib/client.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "lib/client.js",
17
+ "cordis.patch.yml",
18
+ "README.md",
19
+ "README.en.md",
20
+ "LICENSE"
21
+ ],
22
+ "license": "MIT",
23
+ "keywords": [
24
+ "deepseek-harness",
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "tavily",
28
+ "web-search",
29
+ "search-provider",
30
+ "cordis"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Yuuz12/dsh-tavily.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/Yuuz12/dsh-tavily/issues"
41
+ },
42
+ "homepage": "https://github.com/Yuuz12/dsh-tavily#readme",
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ },
47
+ "client": {
48
+ "platform": "web",
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-connection",
51
+ "@deepseek-ai/dsh-client-locale",
52
+ "@deepseek-ai/dsh-client-store",
53
+ "@deepseek-ai/dsh-api-remotes",
54
+ "@deepseek-ai/dsh-client-ui-settings",
55
+ "@deepseek-ai/dsh-client-ui-slots"
56
+ ]
57
+ }
58
+ }
59
+ }