@sligqoer/dsh-usage-stats 0.1.2

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,4074 @@
1
+ /**
2
+ * dsh-usage-stats — desktop renderer half. Loaded by DSH's client-modules
3
+ * bundle route at /plugins/<package-name>/client.js inside the Electron app.
4
+ *
5
+ * Registers a settings page (用量统计) under settings.section: a usage
6
+ * heatmap (GitHub-contribution-style calendar, one cell per day, colour =
7
+ * billed tokens; paged to fit the settings pane without horizontal scrolling), a
8
+ * 24-hour token bar chart (usage by hour of the current day), a per-model
9
+ * stacked bar chart, and whole-window totals. Time-range (segmented preset
10
+ * group 7天/30天/1年/全部 — same pill toggle style as the token 用量 view
11
+ * switch) and model filters are selectable; data comes from the host route
12
+ * /api/dsh-usage-stats/stats.
13
+ *
14
+ * Styling uses the DSH theme alias tokens (--dsw-alias-*), so the page stays
15
+ * legible in light AND dark themes. DOM failure policy: mounting problems are
16
+ * logged, never thrown — an external plugin must not take the GUI down.
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: "@sligqoer/dsh-usage-stats",
20
+ factory: (require) => {
21
+ 'use strict'
22
+ var module = { exports: {} }
23
+ var exports = module.exports
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
25
+
26
+ var React = require('react')
27
+
28
+ /** Stable data attribute identifying this settings section. */
29
+ var SECTION_ID = 'usage-stats'
30
+ var STYLE_ID = 'dsh-usage-stats-styles'
31
+ // Replaced from package.json by the build and npm prepack steps.
32
+ var PLUGIN_VERSION = "0.1.2"
33
+ var localeService = null
34
+ var translate = null
35
+
36
+ // Chinese is also the fallback for standalone checks without the host service.
37
+ // Each entry supplies the English and Japanese dictionaries for DSH's locale registry.
38
+ var messages = {
39
+ '用量统计': ['Usage statistics', '利用状況'],
40
+ '用量统计页面': ['Usage statistics tabs', '利用状況のタブ'],
41
+ '用量': ['Usage', '使用量'],
42
+ '配额': ['Quota', '利用枠'],
43
+ '控制': ['Controls', '設定'],
44
+ '刷新用量统计': ['Refresh usage statistics', '利用状況を更新'],
45
+ '刷新全部配额': ['Refresh all quotas', 'すべての利用枠を更新'],
46
+ '部分配额未能更新,请稍后刷新重试。': ['Some quotas could not be updated. Try refreshing again later.', '一部の利用枠を更新できませんでした。しばらくしてから再試行してください。'],
47
+ '部分会话读取失败': ['Some sessions could not be read', '一部のセッションを読み込めませんでした'],
48
+ '在 GitHub 查看 dsh-usage-stats': ['View dsh-usage-stats on GitHub', 'GitHub で dsh-usage-stats を表示'],
49
+ '概览指标': ['Overview', '概要'],
50
+ '今日tokens数': ["Today's tokens", '今日のトークン数'],
51
+ '使用天数': ['Active days', '利用日数'],
52
+ '累计tokens数': ['Total tokens', '累計トークン数'],
53
+ '暂无数据': ['No data yet', 'データはまだありません'],
54
+ '今日Token用量': ["Today's token usage", '今日のトークン使用量'],
55
+ '今日Token用量统计模式': ["Today's token grouping", '今日のトークン使用量の集計方法'],
56
+ '供应商·模型': ['Provider · Model', 'プロバイダー・モデル'],
57
+ '按模型': ['By model', 'モデル別'],
58
+ '使用量热力图': ['Usage heatmap', '使用量ヒートマップ'],
59
+ '热力图时长': ['Heatmap interval', 'ヒートマップの間隔'],
60
+ '每日': ['Daily', '日別'],
61
+ '每周': ['Weekly', '週別'],
62
+ '每日Token趋势图': ['Daily token trend', '日別トークン使用量の推移'],
63
+ '每日Token趋势时长': ['Token trend period', 'トークン推移の期間'],
64
+ '7天': ['7 days', '7日間'],
65
+ '30天': ['30 days', '30日間'],
66
+ '模型明细与分布': ['Model details and distribution', 'モデル別の詳細と分布'],
67
+ '每个供应商 · 模型组合单独统计': ['Count each provider and model combination separately', 'プロバイダーとモデルの組み合わせごとに集計'],
68
+ '合并同名模型,不区分供应商': ['Combine matching model names across providers', '同名モデルをプロバイダーを問わず合算'],
69
+ '模型用量清单 ({count})': ['Model usage ({count})', 'モデル使用量一覧({count})'],
70
+ '搜索模型或供应商名称…': ['Search models or providers…', 'モデル名・プロバイダー名で検索…'],
71
+ '模型名称': ['Model', 'モデル名'],
72
+ '用量 Tokens': ['Tokens used', 'トークン使用量'],
73
+ '占比': ['Share', '割合'],
74
+ '分布条': ['Distribution', '分布'],
75
+ '最后活跃时间': ['Last active', '最終利用日時'],
76
+ '没有找到匹配的模型': ['No matching models', '一致するモデルがありません'],
77
+ '收起清单': ['Collapse list', '一覧を折りたたむ'],
78
+ '展开其余 {count} 个模型': ['Show {count} more models', '残り{count}個のモデルを表示'],
79
+ '近期调用': ['Recent activity', '最近の利用'],
80
+ '全部': ['All', 'すべて'],
81
+ '其他': ['Other', 'その他'],
82
+ '没有按模型聚合的数据': ['No usage data by model', 'モデル別の集計データがありません'],
83
+ '窗口内没有记录': ['No records in this period', 'この期間に記録がありません'],
84
+ '无记录': ['No records', '記録なし'],
85
+ '不在所选时间范围内': ['Outside the selected period', '選択した期間の範囲外'],
86
+ ',不在所选时间范围内': [', outside the selected period', '、選択した期間の範囲外'],
87
+ '正在读取模型明细…': ['Loading model details…', 'モデルの詳細を読み込み中…'],
88
+ '模型明细暂不可用': ['Model details are temporarily unavailable', 'モデルの詳細を現在取得できません'],
89
+ '总调用次数': ['Total calls', '総呼び出し回数'],
90
+ '{count} 次': ['{count} calls', '{count}回'],
91
+ '{count} 次调用': ['{count} calls', '{count}回の呼び出し'],
92
+ '{date} 当周': ['Week of {date}', '{date}の週'],
93
+ '{year}年{month}月{day}日起的一周': ['Week starting {year}-{month}-{day}', '{year}年{month}月{day}日からの週'],
94
+ '{year}年{month}月{day}日': ['{year}-{month}-{day}', '{year}年{month}月{day}日'],
95
+ '{hour}时': ['{hour}:00', '{hour}時'],
96
+ '{time},{count} 次调用,{tokens} tokens': ['{time}, {count} calls, {tokens} tokens', '{time}、{count}回の呼び出し、{tokens}トークン'],
97
+ '{time}: {tokens} tokens,{count} 次调用': ['{time}: {tokens} tokens, {count} calls', '{time}:{tokens}トークン、{count}回の呼び出し'],
98
+ ' · 全天': [' · All day', '・終日'],
99
+ 'Token 用量': ['Token usage', 'トークン使用量'],
100
+ '每日Token用量与调用次数': ['Daily token usage and calls', '日別のトークン使用量と呼び出し回数'],
101
+ '今日每小时用量与调用次数': ["Today's hourly usage and calls", '今日の時間別使用量と呼び出し回数'],
102
+ '调用次数': ['Calls', '呼び出し回数'],
103
+ '少': ['Less', '少'],
104
+ '多': ['More', '多'],
105
+ '热力图翻页': ['Heatmap pagination', 'ヒートマップのページ切り替え'],
106
+ '当前显示的日期范围': ['Visible date range', '表示中の日付範囲'],
107
+ '查看更早日期': ['Show earlier dates', '以前の日付を表示'],
108
+ '查看较新日期': ['Show later dates', '以降の日付を表示'],
109
+ '连接模型服务后,可读取的配额会显示在这里': ['Connect a model provider to see available quotas here', 'モデルプロバイダーに接続すると、取得可能な利用枠が表示されます'],
110
+ '上次获取时间:{time}': ['Last fetched: {time}', '最終取得日時:{time}'],
111
+ '未查询': ['Not queried', '未照会'],
112
+ '未获取': ['Not available', '未取得'],
113
+ '未登录': ['Signed out', '未ログイン'],
114
+ '刷新 {name} 配额': ['Refresh {name} quota', '{name}の利用枠を更新'],
115
+ '{name} 配额{status}': ['{name} quota: {status}', '{name}の利用枠:{status}'],
116
+ '已在模型设置中配置,尚未查询配额。': ['Configured in model settings; quota has not been queried yet.', 'モデル設定に登録済みです。利用枠はまだ照会されていません。'],
117
+ '尚未获取到有效配额,可刷新重试。': ['No valid quota data yet. Refresh to try again.', '有効な利用枠を取得できていません。更新して再試行してください。'],
118
+ '上限 {amount}': ['Limit {amount}', '上限 {amount}'],
119
+ '剩余 {value}': ['{value} remaining', '残り{value}'],
120
+ '重置时间:{time}': ['Resets at: {time}', 'リセット日時:{time}'],
121
+ '即将重置': ['Resetting soon', 'まもなくリセット'],
122
+ '{minutes} 分钟后重置': ['Resets in {minutes} min', '{minutes}分後にリセット'],
123
+ '{hours} 小时{minutes}后重置': ['Resets in {hours}h{minutes}', '{hours}時間{minutes}後にリセット'],
124
+ ' {minutes} 分': [' {minutes}m', '{minutes}分'],
125
+ '{days} 天{hours}后重置': ['Resets in {days}d{hours}', '{days}日{hours}後にリセット'],
126
+ ' {hours} 小时': [' {hours}h', '{hours}時間'],
127
+ '刷新 {name} 账号与积分': ['Refresh {name} account and credits', '{name}のアカウントとクレジットを更新'],
128
+ '剩余积分合计': ['Total remaining credits', '残りクレジット合計'],
129
+ '有效套餐 {active} / {total} 个': ['Active plans {active} / {total}', '有効なプラン {active} / {total}'],
130
+ '套餐': ['Plan', 'プラン'],
131
+ '{name} 剩余 {percent}%': ['{name}: {percent}% remaining', '{name}:残り{percent}%'],
132
+ '计费套餐': ['Billing plan', '課金プラン'],
133
+ '使用中': ['In use', '利用中'],
134
+ '{value} 积分': ['{value} credits', '{value}クレジット'],
135
+ '收起其余套餐': ['Collapse other plans', '他のプランを折りたたむ'],
136
+ '展开其余 {count} 个套餐': ['Show {count} more plans', '残り{count}件のプランを表示'],
137
+ '免费': ['Free', '無料'],
138
+ '{value} 积分/次': ['{value} credits/call', '1回あたり{value}クレジット'],
139
+ '模型优惠': ['Model offers', 'モデルの特典'],
140
+ '自动获取剩余余额': ['Fetch remaining balance automatically', '残高を自動取得'],
141
+ '自动查询费用提醒': ['Automatic query cost notice', '自動照会の料金に関する確認'],
142
+ '自动查询可能会消耗少量余额,具体取决于供应商的计费规则。确认开启后,将立即获取一次,之后按所选间隔更新。': ['Automatic queries may incur a small charge, depending on the provider. Enabling this fetches once immediately, then updates at the selected interval.', '自動照会では、プロバイダーの料金設定によって少額の残高が消費される場合があります。有効にすると直ちに1回取得し、以後は選択した間隔で更新します。'],
143
+ '确认开启': ['Enable', '有効にする'],
144
+ '取消': ['Cancel', 'キャンセル'],
145
+ '自动获取间隔': ['Automatic refresh interval', '自動取得の間隔'],
146
+ '10 分钟': ['10 minutes', '10分'],
147
+ '1 小时': ['1 hour', '1時間'],
148
+ '5 小时': ['5 hours', '5時間'],
149
+ '每天': ['Daily', '毎日'],
150
+ '下次获取:{time} · 当前设置:{interval}': ['Next fetch: {time} · Interval: {interval}', '次回取得:{time}・現在の間隔:{interval}'],
151
+ '当前设置:{interval}': ['Current interval: {interval}', '現在の間隔:{interval}'],
152
+ '高级模型选择器': ['Advanced model selector', '詳細モデル選択'],
153
+ '已关闭,已恢复官方模型选择器': ['Disabled; the default model selector is restored', '無効にしました。標準のモデル選択に戻りました'],
154
+ '已开启高级模型选择器': ['Advanced model selector enabled', '詳細モデル選択を有効にしました'],
155
+ '配额查询': ['Quota queries', '利用枠の照会'],
156
+ '刷新供应商列表': ['Refresh provider list', 'プロバイダー一覧を更新'],
157
+ '查询来源': ['Query source', '照会元'],
158
+ '正在加载供应商…': ['Loading providers…', 'プロバイダーを読み込み中…'],
159
+ '选择已有供应商': ['Select a provider', 'プロバイダーを選択'],
160
+ '已保存自定义查询': ['Saved custom query', '保存済みのカスタム照会'],
161
+ '内置扩展查询': ['Built-in extension query', '拡張機能の標準照会'],
162
+ '内置供应商查询': ['Built-in provider query', 'プロバイダーの標準照会'],
163
+ '尚无可选来源,请先添加模型供应商或启用支持的扩展。': ['No sources available. Add a model provider or enable a supported extension.', '照会元がありません。モデルプロバイダーを追加するか、対応する拡張機能を有効にしてください。'],
164
+ '正在查询…': ['Querying…', '照会中…'],
165
+ '查询配额': ['Query quota', '利用枠を照会'],
166
+ '查询模板(JSON)': ['Query template (JSON)', '照会テンプレート(JSON)'],
167
+ '重置模板': ['Reset template', 'テンプレートをリセット'],
168
+ '用 AI 生成模板': ['Generate a template with AI', 'AIでテンプレートを作成'],
169
+ '复制提示词': ['Copy prompt', 'プロンプトをコピー'],
170
+ '可复制的模板生成提示词': ['Template generation prompt', 'テンプレート作成用プロンプト'],
171
+ '已复制,粘贴到任意 AI 对话即可': ['Copied. Paste into an AI chat.', 'コピーしました。AIのチャットに貼り付けてください'],
172
+ '复制失败,请手动选中下方提示词': ['Copy failed. Select the prompt below to copy it manually.', 'コピーできませんでした。下のプロンプトを選択して手動でコピーしてください'],
173
+ '正在测试…': ['Testing…', 'テスト中…'],
174
+ '测试查询': ['Test query', '照会をテスト'],
175
+ '正在保存…': ['Saving…', '保存中…'],
176
+ '确认并显示': ['Confirm and display', '確定して表示'],
177
+ '测试会发起一次请求,可能消耗少量余额。': ['Testing sends one request and may incur a small charge.', 'テストでは1回リクエストを送信します。少額の残高が消費される場合があります。'],
178
+ '测试成功 · {name}': ['Test successful · {name}', 'テスト成功・{name}'],
179
+ '已启用的自定义查询': ['Enabled custom queries', '有効なカスタム照会'],
180
+ '编辑': ['Edit', '編集'],
181
+ '移除': ['Remove', '削除'],
182
+ '正在读取设置…': ['Loading settings…', '設定を読み込み中…'],
183
+ '设置已保存': ['Settings saved', '設定を保存しました'],
184
+ '已保存,可在配额页查看此供应商。': ['Saved. This provider is now available on the quota tab.', '保存しました。利用枠タブでこのプロバイダーを確認できます。'],
185
+ '已移除自定义查询,恢复内置配额查询。': ['Custom query removed; the built-in quota query is restored.', 'カスタム照会を削除し、標準の利用枠照会に戻しました。'],
186
+ '查询已更新,可在配额页查看。': ['Query updated. View it on the quota tab.', '照会を更新しました。利用枠タブで確認できます。'],
187
+ '请先在 WorkBuddy 中登录。': ['Sign in to WorkBuddy first.', '先にWorkBuddyにログインしてください。'],
188
+ '尚未获取到配额,请检查扩展是否启用后重试。': ['Quota is not available yet. Check that the extension is enabled and try again.', '利用枠をまだ取得できていません。拡張機能が有効か確認して再試行してください。'],
189
+ '模板不是有效 JSON,请检查引号、逗号和括号。': ['Invalid JSON template. Check quotes, commas and brackets.', 'テンプレートのJSONが無効です。引用符、カンマ、括弧を確認してください。'],
190
+ '重试': ['Retry', '再試行'],
191
+ '自动': ['Auto', '自動'],
192
+ '默认': ['Default', '既定'],
193
+ '关': ['Off', 'オフ'],
194
+ '极简': ['Minimal', '最小'],
195
+ '低': ['Low', '低'],
196
+ '中': ['Medium', '中'],
197
+ '高': ['High', '高'],
198
+ '极高': ['Extra high', '非常に高い'],
199
+ '极致': ['Maximum', '最大'],
200
+ '推理强度': ['Reasoning effort', '推論の強度'],
201
+ '‹ 模型': ['‹ Models', '‹ モデル'],
202
+ '模型目录加载失败': ['Failed to load model catalog', 'モデル一覧を読み込めませんでした'],
203
+ '加载中…': ['Loading…', '読み込み中…'],
204
+ '选择模型': ['Select a model', 'モデルを選択'],
205
+ '账户可用余额': ['Available balance', '利用可能な残高'],
206
+ '账户可用额度': ['Available credit', '利用可能なクレジット'],
207
+ '充值余额': ['Paid balance', 'チャージ残高'],
208
+ '赠送余额': ['Gift balance', '付与残高'],
209
+ '预付费余额': ['Prepaid balance', '前払い残高'],
210
+ '密钥额度': ['API key allowance', 'APIキーの利用枠'],
211
+ '余额': ['Balance', '残高'],
212
+ '{hours} 小时剩余': ['{hours}-hour remaining', '{hours}時間枠の残り'],
213
+ '周剩余': ['Weekly remaining', '週間枠の残り'],
214
+ '月剩余': ['Monthly remaining', '月間枠の残り'],
215
+ '窗口剩余': ['Window remaining', '期間枠の残り'],
216
+ '积分剩余': ['Credits remaining', '残りクレジット'],
217
+ 'Token 配额剩余': ['Tokens remaining', '残りトークン枠'],
218
+ '高级请求剩余': ['Premium requests remaining', '残りプレミアムリクエスト'],
219
+ '聊天请求剩余': ['Chat requests remaining', '残りチャットリクエスト'],
220
+ '补全请求剩余': ['Completion requests remaining', '残り補完リクエスト'],
221
+ 'Claude 订阅': ['Claude subscription', 'Claudeサブスクリプション'],
222
+ 'OpenAI Codex 订阅': ['OpenAI Codex subscription', 'OpenAI Codexサブスクリプション'],
223
+ 'StepFun 阶跃星辰': ['StepFun', 'StepFun'],
224
+ 'Moonshot AI 国内': ['Moonshot AI China', 'Moonshot AI 中国'],
225
+ 'MiniMax 国际': ['MiniMax Global', 'MiniMax グローバル'],
226
+ 'MiniMax 国内': ['MiniMax China', 'MiniMax 中国'],
227
+ '智谱 GLM Coding': ['Zhipu GLM Coding', 'Zhipu GLM Coding'],
228
+ '阿里云 Token Plan 国内': ['Alibaba Cloud Token Plan China', 'Alibaba Cloud Token Plan 中国'],
229
+ '配额窗口 {index}': ['Quota window {index}', '利用枠の期間 {index}'],
230
+ '其他模型': ['Other models', 'その他のモデル'],
231
+ '查询模板应为 JSON 对象,且不超过 16 KB。': ['The query template must be a JSON object of at most 16 KB.', '照会テンプレートは16 KB以下のJSONオブジェクトにしてください。'],
232
+ '请填写配额查询 URL。': ['Enter a quota query URL.', '利用枠照会のURLを入力してください。'],
233
+ '查询 URL 格式无效。': ['Invalid query URL.', '照会URLの形式が無効です。'],
234
+ '查询 URL 需使用 HTTPS;本机供应商可使用 HTTP。': ['Use HTTPS for the query URL. Local providers may use HTTP.', '照会URLにはHTTPSを使用してください。ローカルのプロバイダーはHTTPも使用できます。'],
235
+ '请使用供应商凭据认证,不要把密钥放入 URL。': ['Use provider credentials; do not put API keys in the URL.', 'プロバイダーの認証情報を使用し、URLにキーを含めないでください。'],
236
+ '查询方法仅支持 GET 或 POST。': ['Only GET and POST queries are supported.', '照会方法はGETまたはPOSTのみ対応しています。'],
237
+ 'auth 仅支持 provider 或 none。': ['auth must be provider or none.', 'authはproviderまたはnoneにしてください。'],
238
+ 'headers 应为 JSON 对象,最多 20 项。': ['headers must be a JSON object with at most 20 entries.', 'headersは20項目以下のJSONオブジェクトにしてください。'],
239
+ '认证由供应商凭据提供,请移除模板中的密钥或认证请求头。': ['Authentication uses provider credentials. Remove keys and authentication headers from the template.', '認証にはプロバイダーの認証情報を使用します。テンプレート内のキーや認証ヘッダーを削除してください。'],
240
+ '请求头值应为单行文本。': ['Header values must be single-line text.', 'ヘッダーの値は1行のテキストにしてください。'],
241
+ 'GET 查询不能包含 body。': ['GET queries cannot include a body.', 'GET照会にbodyは指定できません。'],
242
+ 'POST body 应为 JSON 对象或数组。': ['The POST body must be a JSON object or array.', 'POSTのbodyはJSONオブジェクトまたは配列にしてください。'],
243
+ '请使用供应商凭据认证,不要在 body 中填写密钥。': ['Use provider credentials; do not put API keys in the body.', 'プロバイダーの認証情報を使用し、bodyにキーを含めないでください。'],
244
+ '请填写 response 字段映射。': ['Enter response field mappings.', 'responseのフィールドマッピングを入力してください。'],
245
+ '此供应商没有内置解析器,请使用 response.metrics 映射。': ['This provider has no built-in parser. Use response.metrics mappings.', 'このプロバイダーには標準の解析処理がありません。response.metricsのマッピングを使用してください。'],
246
+ 'response.metrics 需包含 1–20 个指标。': ['response.metrics must contain 1–20 metrics.', 'response.metricsには1~20個の指標を指定してください。'],
247
+ 'response.rows 应为数组字段路径。': ['response.rows must be an array field path.', 'response.rowsには配列のフィールドパスを指定してください。'],
248
+ '每个指标需填写 label 和 kind(amount 或 window)。': ['Each metric needs label and kind (amount or window).', '各指標にlabelとkind(amountまたはwindow)を指定してください。'],
249
+ '指标字段路径应为不超过 200 字符的文本。': ['Metric field paths must be text of at most 200 characters.', '指標のフィールドパスは200文字以下にしてください。'],
250
+ '余额指标需提供 remaining,或 total 与 used 字段路径。': ['Balance metrics need remaining, or both total and used field paths.', '残高指標にはremaining、またはtotalとusedのフィールドパスを指定してください。'],
251
+ '配额指标需提供百分比或总量与剩余量字段路径。': ['Quota metrics need a percentage, or total and remaining field paths.', '利用枠指標には割合、または総量と残量のフィールドパスを指定してください。'],
252
+ '查询响应过大(上限 1 MB)。': ['Query response exceeds the 1 MB limit.', '照会の応答が上限の1 MBを超えています。'],
253
+ '查询未返回 JSON 数据。': ['The query did not return JSON.', '照会結果がJSONではありません。'],
254
+ '查询响应不是有效 JSON,或读取已超时。': ['The response is invalid JSON or timed out.', '応答のJSONが無効か、読み取りがタイムアウトしました。'],
255
+ '所选供应商已不存在,请重新选择。': ['The selected provider no longer exists. Select another provider.', '選択したプロバイダーが見つかりません。選択し直してください。'],
256
+ 'HTTP 本机查询地址需与供应商配置地址同源。': ['Local HTTP queries must use the configured provider origin.', 'ローカルHTTP照会にはプロバイダー設定と同じオリジンを使用してください。'],
257
+ '认证查询地址需与所选供应商的地址或官方配额地址同源。': ['Authenticated queries must use the provider or official quota origin.', '認証付き照会にはプロバイダーまたは公式の利用枠URLと同じオリジンを使用してください。'],
258
+ '未找到可用的供应商凭据,请先在模型设置中配置或登录。': ['No provider credentials found. Configure them or sign in in model settings.', 'プロバイダーの認証情報がありません。モデル設定で登録するか、ログインしてください。'],
259
+ '未配置 xAI 管理凭据和团队 ID。': ['xAI management credentials and team ID are not configured.', 'xAIの管理用認証情報とチームIDが設定されていません。'],
260
+ '查询失败,请检查地址和网络(8 秒超时,不跟随重定向)。': ['Query failed. Check the URL and network (8-second timeout; redirects disabled).', '照会に失敗しました。URLとネットワークを確認してください(8秒でタイムアウト、リダイレクトなし)。'],
261
+ '查询返回 HTTP {status},请检查地址和供应商凭据。': ['Query returned HTTP {status}. Check the URL and provider credentials.', '照会結果はHTTP {status}でした。URLとプロバイダーの認証情報を確認してください。'],
262
+ '响应中未找到有效配额,请检查 response 的字段路径。': ['No valid quota found. Check the response field paths.', '有効な利用枠が見つかりません。responseのフィールドパスを確認してください。'],
263
+ '未知配额来源。': ['Unknown quota source.', '利用枠の照会元が不明です。'],
264
+ '此来源使用内置适配器,无需填写 HTTP 查询模板。': ['This source uses a built-in adapter; no HTTP template is needed.', 'この照会元は標準のアダプターを使用します。HTTPテンプレートは不要です。'],
265
+ '设置应为 JSON 对象。': ['Settings must be a JSON object.', '設定はJSONオブジェクトにしてください。'],
266
+ '模板已修改或测试已过期,请重新测试后确认。': ['The template changed or the test expired. Test again before confirming.', 'テンプレートが変更されたか、テスト結果の期限が切れました。再テストしてから確定してください。'],
267
+ '自定义查询最多支持 50 个供应商。': ['Custom queries support up to 50 providers.', 'カスタム照会は最大50プロバイダーまで対応しています。'],
268
+ '自动查询开关值无效。': ['Invalid automatic query switch value.', '自動照会のスイッチ値が無効です。'],
269
+ '请先确认自动查询可能会消耗少量余额。': ['Confirm that automatic queries may incur a small charge.', '自動照会で少額の残高が消費される場合があることを確認してください。'],
270
+ '仅支持 10 分钟、1 小时、5 小时或每天。': ['Supported intervals: 10 minutes, 1 hour, 5 hours or daily.', '間隔は10分、1時間、5時間、または毎日のみ対応しています。'],
271
+ '模型明细开关值无效。': ['Invalid model details switch value.', 'モデル詳細のスイッチ値が無効です。'],
272
+ '高级模型选择器开关值无效。': ['Invalid advanced model selector switch value.', '詳細モデル選択のスイッチ値が無効です。'],
273
+ '未知设置操作。': ['Unknown settings action.', '設定の操作が不明です。'],
274
+ '设置保存失败,请检查存储目录权限。': ['Failed to save settings. Check storage directory permissions.', '設定を保存できませんでした。保存先フォルダーの権限を確認してください。'],
275
+ '未知 WorkBuddy 积分来源。': ['Unknown WorkBuddy credit source.', 'WorkBuddyのクレジット取得元が不明です。'],
276
+ '用量统计暂时不可用,请稍后重试。': ['Usage statistics are temporarily unavailable. Try again later.', '利用状況を現在取得できません。しばらくしてから再試行してください。'],
277
+ '请求来源不受信任。': ['The request origin is not trusted.', 'リクエストの送信元が信頼されていません。'],
278
+ '操作失败,请稍后重试。': ['The operation failed. Try again later.', '操作に失敗しました。しばらくしてから再試行してください。'],
279
+ '该供应商': ['this provider', 'このプロバイダー'],
280
+ '请帮我为「{name}」写一份 DSH 用量统计插件的余额查询模板(JSON)。': ['Write a balance query template (JSON) for "{name}" for the DSH usage statistics plugin.', 'DSH利用状況プラグイン用に「{name}」の残高照会テンプレート(JSON)を作成してください。'],
281
+ '【输出要求】': ['[Output requirements]', '【出力要件】'],
282
+ '只输出一个 JSON 对象,不要解释、不要 markdown 代码块围栏。': ['Output one JSON object only, without explanations or Markdown code fences.', 'JSONオブジェクトを1つだけ出力し、説明やMarkdownのコードフェンスは付けないでください。'],
283
+ '【获取接口信息的方式】': ['[Finding API details]', '【API情報の調べ方】'],
284
+ '请先自己联网搜索「{name}」的余额 / 用量查询接口文档,不要一上来就找我要资料。': ['First search online for the balance / usage API documentation for "{name}" before asking me for information.', 'まず「{name}」の残高・使用量照会APIのドキュメントを検索してください。最初から資料を求めないでください。'],
285
+ '优先按下面的顺序查:官方 API 文档 → 余额或 billing / usage 接口说明 → 社区示例或 SDK 源码。': ['Search in this order: official API docs → balance or billing / usage API docs → community examples or SDK source.', '公式APIドキュメント → 残高・billing / usage APIの説明 → コミュニティの例やSDKソースの順に調べてください。'],
286
+ '如果能查到明确的接口地址与返回字段,直接据此写出模板。': ['If the endpoint and response fields are documented, use them to write the template.', 'APIのURLと応答フィールドを確認できた場合は、それに基づいてテンプレートを作成してください。'],
287
+ '只有在确实查不到、或查到多个互相冲突的结果时,才向我提问;提问要具体,一次只问最关键的一点。': ['Ask me only if information is unavailable or contradictory. Ask one specific, essential question at a time.', '情報が見つからないか矛盾する場合にだけ質問してください。一度に最も重要な点を1つ、具体的に尋ねてください。'],
288
+ '如果你搜索到的是猜测而非确定的字段路径,请在回答里简要注明依据,方便我核对。': ['If a field path is inferred rather than confirmed, briefly state the evidence so I can verify it.', 'フィールドパスが確認済みではなく推測の場合は、確認できるよう根拠を簡潔に示してください。'],
289
+ '【字段说明】': ['[Fields]', '【フィールドの説明】'],
290
+ 'url:查询地址,必须与下面 auth 所用凭据同源。': ['url: query URL, on the same origin as the credentials used by auth.', 'url:照会URL。authで使用する認証情報と同じオリジンにしてください。'],
291
+ 'method:GET 或 POST。': ['method: GET or POST.', 'method:GETまたはPOST。'],
292
+ 'auth:"provider" 表示复用该供应商已保存的凭据;"none" 表示不需要认证。': ['auth: "provider" reuses saved provider credentials; "none" means no authentication.', 'auth:"provider"は保存済みの認証情報を使用し、"none"は認証なしを意味します。'],
293
+ 'headers:可选,附加请求头对象。': ['headers: optional additional request headers object.', 'headers:任意。追加のリクエストヘッダーのオブジェクト。'],
294
+ 'body:可选,POST 时发送的请求体对象。': ['body: optional request body object for POST.', 'body:任意。POSTで送信するリクエスト本文のオブジェクト。'],
295
+ 'response.rows:可选。当余额在数组里时填写该数组的字段路径,例如 "data.list"。': ['response.rows: optional array field path when balances are in an array, e.g. "data.list".', 'response.rows:任意。残高が配列にある場合、そのフィールドパスを指定します。例:"data.list"。'],
296
+ 'response.metrics:指标数组,每项包含:': ['response.metrics: an array of metrics, each containing:', 'response.metrics:指標の配列。各項目には次のフィールドを含めます:'],
297
+ ' label:界面显示的名称,例如 "账户可用余额"。': [' label: display name, e.g. "Available balance".', ' label:画面に表示する名称。例:"利用可能な残高"。'],
298
+ ' kind:"amount" 表示金额,"window" 表示带重置时间的额度窗口。': [' kind: "amount" for balances, "window" for quota windows with reset times.', ' kind:"amount"は金額、"window"はリセット日時のある利用枠。'],
299
+ ' remaining:金额型指标在响应中的字段路径,例如 "data.balance"。': [' remaining: response field path for an amount metric, e.g. "data.balance".', ' remaining:金額指標の応答フィールドパス。例:"data.balance"。'],
300
+ ' usedPercent / remainingPercent:额度窗口型指标使用,取值为百分比字段路径。': [' usedPercent / remainingPercent: percentage field paths for quota window metrics.', ' usedPercent / remainingPercent:期間枠指標の割合を示すフィールドパス。'],
301
+ ' currency:可选,金额单位,如 "CNY" 或 "USD"。': [' currency: optional currency, e.g. "CNY" or "USD".', ' currency:任意。通貨単位。例:"CNY"または"USD"。'],
302
+ '【取值规则】': ['[Value rules]', '【値の指定方法】'],
303
+ '字段路径用点号表示层级,例如 data.balance_infos.0.total_balance。': ['Use dot-separated field paths, e.g. data.balance_infos.0.total_balance.', 'フィールドパスの階層はドットで区切ります。例:data.balance_infos.0.total_balance。'],
304
+ '数字字符串也要能直接使用,不要额外包装。': ['Numeric strings should work directly without extra wrappers.', '数値文字列も追加のラッパーなしでそのまま使用できるようにしてください。'],
305
+ '【参考格式】': ['[Example format]', '【参考形式】'],
306
+ '【当前情况】': ['[Current context]', '【現在の状況】'],
307
+ '供应商 ID:{provider}': ['Provider ID: {provider}', 'プロバイダーID:{provider}'],
308
+ '(未指定)': ['(not specified)', '(未指定)'],
309
+ '现有模板(可在此基础上修正):\n': ['Existing template (you may revise it):\n', '現在のテンプレート(修正の参考にしてください):\n'],
310
+ '该供应商目前没有模板,请从零编写。': ['This provider has no template yet. Write one from scratch.', 'このプロバイダーにはまだテンプレートがありません。新規に作成してください。'],
311
+ }
312
+
313
+ function t(text, params) {
314
+ if (translate) return translate(text, params)
315
+ return String(text || '').replace(/\{(\w+)\}/g, function (match, key) { return params && key in params ? String(params[key]) : match })
316
+ }
317
+
318
+ function currentLanguage() {
319
+ return localeService ? String(localeService.getSnapshot().active).toLowerCase().split('-')[0] : 'zh'
320
+ }
321
+
322
+ function useLocaleRevision() {
323
+ if (localeService && React.useSyncExternalStore) React.useSyncExternalStore(function (listener) { return localeService.subscribe(listener) }, function () { return localeService.getSnapshot() })
324
+ }
325
+
326
+ function quotaLabel(label, custom) {
327
+ if (custom) return label
328
+ var hours = /^(.*?)(\d+(?:\.\d+)?) 小时剩余$/.exec(label)
329
+ if (hours) return hours[1] + t('{hours} 小时剩余', { hours: hours[2] })
330
+ var period = /^(.*?)(周剩余|月剩余|窗口剩余)$/.exec(label)
331
+ var window = /^配额窗口 (\d+)$/.exec(label)
332
+ return period ? period[1] + t(period[2]) : window ? t('配额窗口 {index}', { index: window[1] }) : t(label)
333
+ }
334
+
335
+ function uiError(error) {
336
+ var http = /^查询返回 HTTP (\d+),请检查地址和供应商凭据。$/.exec(error)
337
+ return http ? t('查询返回 HTTP {status},请检查地址和供应商凭据。', { status: http[1] }) : t(error)
338
+ }
339
+
340
+ /* ------------------------------------------------------------------ */
341
+ /* Formatting helpers */
342
+ /* ------------------------------------------------------------------ */
343
+
344
+ function fmt(n) {
345
+ if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
346
+ if (currentLanguage() === 'en' && Math.abs(n) >= 1e9) return Number((n / 1e9).toFixed(2)) + 'B'
347
+ if (currentLanguage() !== 'en' && Math.abs(n) >= 1e8) return Number((n / 1e8).toFixed(2)) + (currentLanguage() === 'ja' ? '億' : '亿')
348
+ if (Math.abs(n) >= 1e6) return Number((n / 1e6).toFixed(2)) + 'M'
349
+ if (Math.abs(n) >= 1e3) return Number((n / 1e3).toFixed(2)) + 'k'
350
+ return String(Math.round(n))
351
+ }
352
+
353
+ function fmtExact(n) {
354
+ return Number(n || 0).toLocaleString(currentLanguage() === 'zh' ? 'zh-CN' : currentLanguage() === 'ja' ? 'ja-JP' : 'en-US')
355
+ }
356
+
357
+ /** Human countdown to a quota reset, e.g. "3小时21分后" / "2天后". */
358
+ function fmtReset(iso) {
359
+ if (!iso) return ''
360
+ var ms = new Date(iso).getTime() - Date.now()
361
+ if (!(ms > 0)) return t('即将重置')
362
+ var totalMin = Math.ceil(ms / 60000)
363
+ if (totalMin < 60) return t('{minutes} 分钟后重置', { minutes: totalMin })
364
+ var h = Math.floor(totalMin / 60)
365
+ var m = totalMin % 60
366
+ if (h < 24) return t('{hours} 小时{minutes}后重置', { hours: h, minutes: m > 0 ? t(' {minutes} 分', { minutes: m }) : '' })
367
+ var d = Math.floor(h / 24)
368
+ var hr = h % 24
369
+ return t('{days} 天{hours}后重置', { days: d, hours: hr > 0 ? t(' {hours} 小时', { hours: hr }) : '' })
370
+ }
371
+
372
+ /** Format a timestamp at a fixed UTC offset (UTC - local minutes). */
373
+ function fmtInTz(dateOrMs, tzMin) {
374
+ if (!dateOrMs) return '—'
375
+ var d = new Date(dateOrMs)
376
+ if (Number.isNaN(d.getTime())) return '—'
377
+ var shifted = new Date(d.getTime() - tzMin * 60000)
378
+ var y = shifted.getUTCFullYear()
379
+ var mo = String(shifted.getUTCMonth() + 1).padStart(2, '0')
380
+ var day = String(shifted.getUTCDate()).padStart(2, '0')
381
+ var h = String(shifted.getUTCHours()).padStart(2, '0')
382
+ var min = String(shifted.getUTCMinutes()).padStart(2, '0')
383
+ var s = String(shifted.getUTCSeconds()).padStart(2, '0')
384
+ return y + '-' + mo + '-' + day + ' ' + h + ':' + min + ':' + s
385
+ }
386
+
387
+ /** Desktop-local tz offset in "UTC - local" minutes (API convention). */
388
+ var LOCAL_TZ = (function () {
389
+ try { return new Date().getTimezoneOffset() } catch (e) { return 0 }
390
+ })()
391
+
392
+ /**
393
+ * "上次获取" line shown under each quota card. Accepts a millisecond
394
+ * timestamp or an ISO string; anything missing or unparseable yields '—'
395
+ * so the row keeps its height and never shows "Invalid Date".
396
+ */
397
+ function fmtFetched(value) {
398
+ if (value === null || value === undefined || value === '') return '—'
399
+ var ms = typeof value === 'number' ? value : new Date(value).getTime()
400
+ if (!Number.isFinite(ms) || ms <= 0) return '—'
401
+ var d = new Date(ms)
402
+ if (Number.isNaN(d.getTime())) return '—'
403
+ var p = function (n) { return String(n).padStart(2, '0') }
404
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
405
+ }
406
+
407
+ // Keep tiny positive shares visible instead of rounding them to 0%.
408
+ function formatPct(value) {
409
+ var pct = Number(value) || 0
410
+ if (pct === 0) return '0%'
411
+ var digits = 1
412
+ while (digits < 6 && Number(pct.toFixed(digits)) === 0) digits += 1
413
+ return pct.toFixed(digits).replace(/\.?(0+)$/, '') + '%'
414
+ }
415
+
416
+ function monthShort(dateKey) {
417
+ var parts = String(dateKey).split('-')
418
+ if (parts.length !== 3) return ''
419
+ var month = Number(parts[1])
420
+ if (!(month >= 1 && month <= 12)) return ''
421
+ return new Intl.DateTimeFormat(currentLanguage() === 'en' ? 'en-US' : 'ja-JP', { month: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2000, month - 1, 1)))
422
+ }
423
+
424
+ function readJson(response) {
425
+ return response.json().then(function (body) {
426
+ if (!response.ok || (body && body.ok === false)) throw new Error((body && body.error) || ('HTTP ' + response.status))
427
+ return body
428
+ })
429
+ }
430
+
431
+ /** Desktop API client, forwarded to the local host by the dsh-app protocol. */
432
+ var UsageApi = (function () {
433
+ function UsageApi() {}
434
+ UsageApi.prototype.stats = function (opts) {
435
+ var search = new URLSearchParams()
436
+ if (opts.days > 0) search.set('days', String(opts.days))
437
+ if (opts.model) search.set('model', opts.model)
438
+ search.set('tz', String(LOCAL_TZ))
439
+ if (opts.fresh) search.set('fresh', '1')
440
+ var q = search.toString()
441
+ return fetch('/api/dsh-usage-stats/stats' + (q ? '?' + q : ''), { headers: { accept: 'application/json' } }).then(readJson)
442
+ }
443
+ UsageApi.prototype.providerQuotas = function (opts) {
444
+ var search = new URLSearchParams()
445
+ if (opts && opts.fresh) search.set('fresh', '1')
446
+ var q = search.toString()
447
+ return fetch('/api/dsh-usage-stats/provider-quotas' + (q ? '?' + q : ''), { headers: { accept: 'application/json' } }).then(readJson)
448
+ }
449
+ /**
450
+ * WorkBuddy's display-only document through our host cache. Only an
451
+ * explicit refresh or the enabled host timer calls the plugin upstream.
452
+ * Shape:
453
+ * { status: 'signed-in'|'signed-out', nickname?, expiresAt?, models?,
454
+ * credits?: { total, accounts: [{ packageName, remain, size }] },
455
+ * creditsError? }.
456
+ */
457
+ UsageApi.prototype.workbuddyStatus = function (opts) {
458
+ var source = opts && opts.source === 'workbuddy-ai' ? 'workbuddy-ai' : 'workbuddy'
459
+ return fetch('/api/dsh-usage-stats/' + source + (opts && opts.fresh ? '?fresh=1' : ''), {
460
+ headers: { accept: 'application/json' },
461
+ credentials: 'same-origin',
462
+ }).then(readJson)
463
+ }
464
+ UsageApi.prototype.quotaSource = function (source, opts) {
465
+ return fetch('/api/dsh-usage-stats/' + encodeURIComponent(source) + (opts && opts.fresh ? '?fresh=1' : ''), {
466
+ headers: { accept: 'application/json' },
467
+ }).then(readJson)
468
+ }
469
+ UsageApi.prototype.controls = function (patch) {
470
+ return fetch('/api/dsh-usage-stats/controls', patch === undefined ? { headers: { accept: 'application/json' } } : {
471
+ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(patch),
472
+ }).then(readJson)
473
+ }
474
+ UsageApi.prototype.quotaProviders = function () {
475
+ return fetch('/api/dsh-usage-stats/quota-providers', { headers: { accept: 'application/json' } }).then(readJson)
476
+ }
477
+ UsageApi.prototype.testQuota = function (provider, template) {
478
+ return fetch('/api/dsh-usage-stats/quota-test', {
479
+ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ provider: provider, template: template }),
480
+ }).then(readJson)
481
+ }
482
+ return UsageApi
483
+ })()
484
+
485
+ /* ------------------------------------------------------------------ */
486
+ /* Styles — DSH alias tokens (legible in light and dark themes) */
487
+ /* ------------------------------------------------------------------ */
488
+
489
+ var navIconMask = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="black" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 13.5h11M4 11.5v-3M8 11.5v-5M12 11.5v-8"/></svg>')
490
+ // Doubles as the dshus-select chevron: the stroke is painted with
491
+ // currentColor, so one data URI follows the field colour in either theme.
492
+ var selectChevronMask = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6.5 8 10.5 12 6.5"/></svg>')
493
+ var styles = [
494
+ '[data-dshus-nav-icon] > svg { display: none; }',
495
+ '[data-dshus-nav-icon]::before { content: ""; width: 16px; height: 16px; flex: none; background: currentColor; -webkit-mask: url("' + navIconMask + '") center / contain no-repeat; mask: url("' + navIconMask + '") center / contain no-repeat; }',
496
+ // Keep the toolbar outside the panels' size-containment context.
497
+ '.dshus-page { font-family: var(--dsw-font-family, ui-sans-serif, system-ui, sans-serif); color: var(--dsw-alias-label-primary, #1f2430); box-sizing: border-box; min-height: 100%; overflow-anchor: none; --dshus-heat: #22c55e; }',
498
+ '.dshus-page * { box-sizing: border-box; }',
499
+ '.dshus-panels { container-type: inline-size; }',
500
+ '.dshus-setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--dsw-alias-border-l2, #414144); }',
501
+ '.dshus-setting-row:last-child { border-bottom: 0; padding-bottom: 0; }',
502
+ '.dshus-setting-label { display: block; font-size: 14px; font-weight: 500; line-height: 22px; }',
503
+ '.dshus-setting-help { margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #6b7280); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }',
504
+ // The switch owns its own on/off palette: brand-primary is near-white in the
505
+ // dark theme, so using it as the "on" track made the two states look alike.
506
+ // Both track states contrast with the near-white knob, so the knob stays
507
+ // visible whether the switch is on or off, in either theme.
508
+ '.dshus-switch { flex: none; position: relative; width: 38px; height: 22px; padding: 0; border: 1px solid transparent; border-radius: 11px; background: var(--dshus-switch-off, #b8bcc4); cursor: pointer; transition: background-color .15s ease; }',
509
+ '.dshus-switch.on, .dshus-switch[aria-checked="true"] { background: var(--dshus-switch-on, #2563eb); }',
510
+ '.dshus-switch::after { content: ""; position: absolute; top: 50%; left: 2px; width: 16px; height: 16px; margin-top: -8px; border-radius: 50%; background: var(--dshus-switch-knob, #f9fafb); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }',
511
+ '.dshus-switch.on::after, .dshus-switch[aria-checked="true"]::after { transform: translateX(16px); }',
512
+ '.dshus-switch:disabled { opacity: .55; cursor: wait; }',
513
+ '.dshus-switch:focus-visible, .dshus-field:focus-visible { outline: 2px solid var(--dsh-alias-brand-primary, #3b82f6); outline-offset: 3px; }',
514
+ '.dshus-field { min-width: 0; max-width: 100%; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l2, #414144); border-radius: 8px; background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #1f2430); font: inherit; font-size: 13px; color-scheme: inherit; }',
515
+ '.dshus-field:disabled { opacity: .6; }',
516
+ '.dshus-field option { background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #1f2430); }',
517
+ '.dshus-field::placeholder { color: var(--dsw-alias-label-tertiary, #9ca3af); }',
518
+ // A native <select> renders its own control inside the box we style, so the
519
+ // list arrow lands wherever the platform puts it and the height/typography
520
+ // drift from the rest of the form. This one is appearance:none with a
521
+ // hand-placed arrow, sized to the same 34px rhythm as the picker trigger.
522
+ '.dshus-select { appearance: none; -webkit-appearance: none; width: 148px; min-height: 34px; padding: 0 32px 0 12px; font-size: 13px; line-height: 20px; cursor: pointer; background-image: url("' + selectChevronMask + '"); background-repeat: no-repeat; background-position: right 11px center; background-size: 14px 14px; }',
523
+ '.dshus-select:hover:not(:disabled) { border-color: var(--dsw-alias-label-tertiary, #9ca3af); }',
524
+ '.dshus-select:disabled { cursor: default; background-image: none; }',
525
+ '.dshus-select::-ms-expand { display: none; }',
526
+ '.dshus-provider-picker { position: relative; margin-top: 8px; }',
527
+ '.dshus-query-select { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px; text-align: left; cursor: pointer; }',
528
+ '.dshus-query-select:disabled { cursor: default; }',
529
+ '.dshus-provider-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
530
+ '.dshus-provider-dot { display: inline-block; flex: none; width: 6px; height: 6px; border-radius: 50%; background: #16a34a; }',
531
+ '.dshus-provider-chevron { flex: none; width: 14px; height: 14px; }',
532
+ '.dshus-provider-list { position: absolute; z-index: 5; top: calc(100% + 4px); left: 0; right: 0; padding: 4px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; border: 1px solid var(--dsw-alias-border-l2, #414144); border-radius: 8px; background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #1f2430); box-shadow: 0 4px 12px rgba(0,0,0,.16); }',
533
+ '.dshus-provider-list.above { top: auto; bottom: calc(100% + 4px); }',
534
+ '.dshus-provider-option { display: flex; align-items: center; gap: 12px; min-height: 34px; padding: 6px 8px; border-radius: 4px; font-size: 13px; line-height: 22px; cursor: pointer; }',
535
+ '.dshus-provider-option.active { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }',
536
+ '.dshus-provider-option[aria-selected="true"] { font-weight: 600; }',
537
+ '.dshus-query-editor { display: block; width: 100%; min-height: 250px; margin-top: 8px; resize: vertical; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.6; tab-size: 2; }',
538
+ '.dshus-prompt { margin-top: 12px; border: 1px solid var(--dsw-alias-border-l2, #414144); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f2f3f6); overflow: hidden; }',
539
+ '.dshus-prompt-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #414144); }',
540
+ '.dshus-prompt-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-secondary, #4b5563); }',
541
+ '.dshus-prompt-copy { height: 26px; font-size: 12px; }',
542
+ // The prompt is meant to be selected and copied, so it scrolls rather than
543
+ // truncates and keeps the monospace rhythm of the JSON editor above it.
544
+ '.dshus-prompt-body { margin: 0; padding: 10px; max-height: 190px; overflow: auto; overscroll-behavior: contain; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary, #1f2430); user-select: text; }',
545
+ '.dshus-prompt-body:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: -3px; }',
546
+ '.dshus-prompt-note { margin: 0; padding: 7px 10px; border-top: 1px solid var(--dsw-alias-border-l2, #414144); font-size: 12px; color: var(--dsw-alias-brand-primary, #3b82f6); }',
547
+ '.dshus-cost-note { margin: 10px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #6b7280); }',
548
+ '.dshus-control-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }',
549
+ '.dshus-confirm { margin-top: 12px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #414144); background: var(--dsw-alias-bg-layer-3, #f3f4f6); border-radius: 8px; }',
550
+ '.dshus-confirm p { margin: 0; font-size: 13px; line-height: 1.6; }',
551
+ '.dshus-query-preview { margin-top: 12px; padding: 10px 0; border-top: 1px solid var(--dsw-alias-border-l2, #414144); font-size: 13px; line-height: 1.8; }',
552
+ '.dshus-control-status { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b7280); line-height: 1.6; }',
553
+ '.dshus-saved-query { display: flex; align-items: center; gap: 8px; padding-top: 10px; font-size: 13px; }',
554
+ '.dshus-saved-query span { flex: 1; min-width: 0; overflow-wrap: anywhere; }',
555
+ '@container (max-width: 420px) { .dshus-setting-row { gap: 10px; } .dshus-select { width: 120px; } }',
556
+ // Title and tabs share one sticky box: no guessed offset or independent
557
+ // pinning. Reserve the refresh button's height even on the control tab.
558
+ '.dshus-toolbar { position: sticky; top: 0; z-index: 3; margin-bottom: 16px; background: var(--dsw-alias-bg-layer-2, #fff); }',
559
+ '.dshus-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; height: 46px; padding: 4px 4px 12px 0; }',
560
+ '.dshus-brand { display: flex; flex: 1; align-items: center; gap: 8px; flex-wrap: nowrap; min-width: 0; }',
561
+ '.dshus-brand-icon { display: inline-block; width: 17px; height: 17px; flex: none; background: currentColor; -webkit-mask: url("' + navIconMask + '") center / contain no-repeat; mask: url("' + navIconMask + '") center / contain no-repeat; }',
562
+ '.dshus-title { flex: none; font-size: 15px; font-weight: 400; letter-spacing: 0.01em; line-height: 22px; margin: 0; white-space: nowrap; }',
563
+ '.dshus-brand-meta { color: var(--dsw-alias-label-secondary, #6b7280); font-size: 13px; line-height: 20px; white-space: nowrap; }',
564
+ '.dshus-brand-link { min-width: 0; overflow: hidden; text-overflow: ellipsis; text-decoration: none; }',
565
+ '.dshus-brand-link:visited { color: var(--dsw-alias-label-secondary, #6b7280); }',
566
+ '.dshus-brand-link:hover { color: var(--dsw-alias-brand-primary, #3b82f6); text-decoration: underline; }',
567
+ '.dshus-brand-link:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: 2px; border-radius: 2px; }',
568
+ '.dshus-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l2, #414144); }',
569
+ '.dshus-tab { flex: none; margin-bottom: -1px; padding: 9px 18px 11px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--dsw-alias-label-secondary, #9ca3af); font: inherit; font-size: 14px; font-weight: 500; line-height: 20px; cursor: pointer; }',
570
+ '.dshus-tab:hover { color: var(--dsw-alias-label-primary, #1f2430); }',
571
+ '.dshus-tab.on { color: var(--dsw-alias-label-primary, #1f2430); border-bottom-color: currentColor; font-weight: 600; }',
572
+ '.dshus-tab:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: -3px; }',
573
+ '.dshus-quota-panel[hidden] { display: none !important; }',
574
+ '.dshus-quota-panel:empty::before { content: attr(data-empty-label); display: block; padding: 32px 16px; border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); border-radius: 16px; color: var(--dsw-alias-label-secondary, #6b7280); font-size: 13px; text-align: center; }',
575
+
576
+ '.dshus-tools { display: flex; align-items: center; flex: none; width: 30px; height: 30px; }',
577
+ '.dshus-range { display: flex; align-items: center; gap: 2px; background: var(--dsw-alias-bg-layer-2, #f2f3f6); border-radius: 10px; padding: 3px; }',
578
+ '.dshus-range-btn { height: 24px; padding: 0 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary, #4b5563); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }',
579
+ '.dshus-range-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1)); }',
580
+ '.dshus-range-btn.on { background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-brand-primary, #3b82f6); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.12); }',
581
+ '.dshus-btn { height: 30px; padding: 0 12px; border: 1px solid var(--dsw-alias-border-l2, #d0d5dd); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1f2430); font: inherit; font-size: 12.5px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }',
582
+ '.dshus-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1)); }',
583
+ '.dshus-btn:disabled { opacity: 0.55; cursor: default; }',
584
+ '.dshus-icon-btn { width: 30px; min-width: 30px; padding: 0; justify-content: center; border-radius: 9px; }',
585
+ '.dshus-icon-btn svg { display: block; width: 16px; height: 16px; }',
586
+ '.dshus-icon-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: 2px; }',
587
+ '.dshus-heading-refresh { margin-left: auto; }',
588
+ '.dshus-spin { width: 13px; height: 13px; border: 2px solid var(--dsw-alias-border-l2, #d0d5dd); border-top-color: var(--dsw-alias-brand-primary, #3b82f6); border-radius: 50%; animation: dshus-spin 0.7s linear infinite; display: inline-block; }',
589
+ '@keyframes dshus-spin { to { transform: rotate(360deg); } }',
590
+
591
+ /* metric cards */
592
+ '.dshus-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }',
593
+ '@container (max-width: 480px) { .dshus-card { padding: 10px 8px; } .dshus-card .v { font-size: 16px; } }',
594
+ '.dshus-card { background: var(--dsw-alias-bg-layer-1, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); border-radius: 14px; padding: 14px 14px 12px; min-width: 0; text-align: center; }',
595
+ '.dshus-card .v { font-size: 20px; font-weight: 650; letter-spacing: -0.01em; line-height: 24px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #1f2430); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
596
+ '.dshus-card .k { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #6b7280); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
597
+ '.dshus-card .sub { font-size: 11px; color: var(--dsw-alias-label-caption, #9ca3af); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }',
598
+
599
+ /* 概览指标 / 配额横向条:同一行内以竖线分割,不再使用独立小卡片 */
600
+ '.dshus-statbar { display: flex; align-items: stretch; flex-wrap: wrap; background: var(--dsw-alias-bg-layer-1, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); border-radius: 14px; padding: 14px 6px; }',
601
+ '.dshus-statbar .dshus-stat { position: relative; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0 10px; text-align: center; }',
602
+ // 竖线用绝对定位画满整行高度:直接给分栏加 border-left 时,分栏自身高度
603
+ // 可能小于行高(内容只有两行文字),竖线就会断掉。
604
+ '.dshus-statbar .dshus-stat + .dshus-stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 1px; background: var(--dsw-alias-border-l2, #e5e7eb); }',
605
+ '.dshus-statbar .dshus-stat .v { font-size: 20px; font-weight: 650; letter-spacing: -0.01em; line-height: 24px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #1f2430); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }',
606
+ '.dshus-statbar .dshus-stat .k { font-size: 11.5px; line-height: 16px; color: var(--dsw-alias-label-tertiary, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }',
607
+ '.dshus-statbar .dshus-stat .sub { font-size: 11px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }',
608
+ '@container (max-width: 480px) { .dshus-statbar { padding: 12px 2px; } .dshus-statbar .dshus-stat { padding: 0 6px; } .dshus-statbar .dshus-stat .v { font-size: 16px; line-height: 20px; } }',
609
+
610
+ /* 多值余额沿用概览分栏;周期额度逐行对齐名称、进度、百分比与重置时间。 */
611
+ '.dshus-quota-bar { padding: 14px 6px 12px; }',
612
+ '.dshus-quota-bar .dshus-stat { flex: 1 1 160px; }',
613
+ '.dshus-quota-bar .dshus-stat .v { font-size: 22px; line-height: 26px; }',
614
+ '.dshus-quota-bar .dshus-stat .k { margin-top: 2px; }',
615
+ // 余额分栏中零宽的轨道会留下空白;数值与上限直接相邻。
616
+ '.dshus-quota-bar .dshus-stat .dshus-go-track { display: none; }',
617
+ '.dshus-quota-bar .dshus-stat .dshus-go-foot { margin-top: 2px; }',
618
+ // 窄面板中的余额分栏可能换行,统一隐藏竖线,避免出现孤立的分隔线。
619
+ '@container (max-width: 640px) { .dshus-quota-bar .dshus-stat + .dshus-stat::before { display: none; } }',
620
+ '.dshus-fetched { margin-top: 10px; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
621
+ '.dshus-quota-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 12px; }',
622
+ '.dshus-module .dshus-quota-heading h3 { flex: 1; min-width: 0; margin: 0; overflow-wrap: anywhere; }',
623
+ '.dshus-quota-heading .dshus-fetched { margin-top: 0; }',
624
+ '.dshus-quota-rows { display: grid; gap: 12px; }',
625
+ '.dshus-quota-bar + .dshus-quota-rows { margin-top: 16px; }',
626
+ '.dshus-quota-row { display: grid; grid-template-columns: minmax(128px, 1.2fr) minmax(0, 2fr) 48px minmax(136px, .9fr); align-items: center; gap: 16px; min-height: 24px; }',
627
+ '.dshus-quota-name { min-width: 0; }',
628
+ '.dshus-quota-label { display: block; font-size: 14px; font-weight: 500; line-height: 22px; color: var(--dsw-alias-label-secondary, #4b5563); overflow-wrap: anywhere; }',
629
+ '.dshus-quota-detail { display: block; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }',
630
+ '.dshus-quota-row .dshus-go-track { width: 100%; min-width: 0; height: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18)); }',
631
+ '.dshus-quota-pct { font-size: 15px; font-weight: 600; line-height: 22px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }',
632
+ '.dshus-quota-reset { min-width: 0; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }',
633
+ '@container (max-width: 640px) { .dshus-quota-row { grid-template-columns: minmax(96px, 1fr) minmax(0, 2fr) 42px; gap: 4px 12px; } .dshus-quota-name { grid-row: span 2; } .dshus-quota-reset { grid-column: 2 / -1; } .dshus-quota-heading .dshus-fetched { order: 1; flex-basis: 100%; } }',
634
+ '@container (max-width: 360px) { .dshus-quota-row { grid-template-columns: minmax(80px, 1fr) minmax(0, 1.5fr) 38px; column-gap: 8px; } .dshus-quota-label { font-size: 13px; } .dshus-quota-pct { font-size: 14px; } }',
635
+
636
+ /* official OpenCode Go quota progress-bar cards (opencode.ai style) */
637
+ '.dshus-go-cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }',
638
+ '.dshus-go-cards .dshus-go-last { grid-column: 1 / -1; }',
639
+ '.dshus-go-card { padding-bottom: 13px; }',
640
+ '.dshus-go-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }',
641
+ '.dshus-go-head .k { margin-top: 0; font-size: 13px; }',
642
+ '.dshus-go-pct { font-size: 25px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; line-height: 1; }',
643
+ '.dshus-go-track { height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #f2f3f6); overflow: hidden; }',
644
+ '.dshus-go-fill { height: 100%; border-radius: 999px; transition: width 0.35s ease; }',
645
+ '.dshus-go-foot { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-top: 9px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #6b7280); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; }',
646
+ '.dshus-go-reset { color: var(--dsw-alias-label-caption, #9ca3af); font-size: 12px; }',
647
+
648
+
649
+ /* WorkBuddy (dsh-workbuddy-connect) quota board extras */
650
+ '.dshus-wb-total-value { font-size: 25px; }',
651
+ '@container (max-width: 480px) { .dshus-wb-module .dshus-go-cards { grid-template-columns: minmax(0, 1fr); } }',
652
+ '.dshus-wb-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }',
653
+ '.dshus-wb-chips-label { font-size: 11.5px; color: var(--dsw-alias-label-caption, #9ca3af); }',
654
+ '.dshus-wb-chip { display: inline-flex; align-items: center; flex-wrap: wrap; max-width: 100%; gap: 6px; font-size: 11.5px; padding: 2px 9px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #f2f3f6); border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); color: var(--dsw-alias-label-secondary, #4b5563); }',
655
+ '.dshus-wb-chip > span { min-width: 0; overflow-wrap: anywhere; }',
656
+ '.dshus-wb-chip .rate { color: var(--dsw-alias-label-caption, #9ca3af); }',
657
+ '.dshus-wb-chip .tag { color: #16a34a; font-weight: 600; }',
658
+ '.dshus-wb-name { flex: 1 1 auto; min-width: 0; }',
659
+ '.dshus-wb-inuse { flex: 0 0 auto; padding: 0 6px; border-radius: 999px; font-size: 10px; font-weight: 600; line-height: 16px; background: rgba(34, 197, 94, 0.12); color: #16a34a; border: 1px solid rgba(34, 197, 94, 0.25); }',
660
+
661
+ /* modules — every usage module framed in one large card */
662
+ '.dshus-module { background: var(--dsw-alias-bg-layer-1, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); border-radius: 16px; padding: 16px 18px 18px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }',
663
+ '.dshus-module h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; color: var(--dsw-alias-label-secondary, #4b5563); display: flex; align-items: center; gap: 8px; }',
664
+ '#dshus-usage-panel .dshus-module h3 { font-weight: 400; }',
665
+ '@container (max-width: 480px) { #dshus-usage-panel .dshus-module h3 { flex-wrap: wrap; } #dshus-usage-panel .dshus-head-tools { max-width: 100%; flex-wrap: wrap; gap: 8px; } }',
666
+ '.dshus-balance-module { display: flex; align-items: center; gap: 12px; min-height: 62px; padding: 14px 18px; }',
667
+ '.dshus-balance-title { display: flex; align-items: center; gap: 6px; min-width: 0; }',
668
+ '.dshus-balance-module h3 { margin: 0; white-space: nowrap; }',
669
+ '.dshus-balance-value { margin-left: auto; color: var(--dsw-alias-label-primary, #1f2430); font-size: 22px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }',
670
+ // 余额卡片:数值右对齐,下面一行小字显示上次获取时间。
671
+ '.dshus-balance-side { margin-left: auto; min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }',
672
+ '.dshus-balance-side .dshus-balance-value { margin-left: 0; }',
673
+ '.dshus-balance-side .dshus-fetched { margin-top: 0; }',
674
+ '@container (max-width: 420px) { .dshus-balance-module { padding: 12px; flex-wrap: wrap; gap: 8px; } .dshus-balance-title { width: 100%; justify-content: space-between; } .dshus-balance-module h3 { font-size: 13px; white-space: normal; overflow-wrap: anywhere; } .dshus-balance-title .dshus-icon-btn { flex: none; } .dshus-balance-side { width: 100%; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; } .dshus-balance-side .dshus-fetched { order: -1; min-width: 0; } .dshus-balance-value { font-size: 18px; } }',
675
+ '.dshus-balance-status { color: var(--dsw-alias-label-caption, #6b7280); font-size: 14px; font-weight: 400; }',
676
+
677
+ /* Compact calendar; weekly totals fill seven-cell columns from below.
678
+ The strip is a bare grid — paging moved to the card header and the
679
+ scale legend to a footer row, so the calendar never gets a side rail
680
+ that shifts it when paging becomes unavailable. */
681
+ '.dshus-heat-wrap { width: 100%; display: flex; align-items: center; }',
682
+ '.dshus-heat-viewport { min-width: 0; flex: 1 1 auto; box-sizing: border-box; padding: 4px; overflow: hidden; display: flex; flex-direction: column; align-items: center; }',
683
+ '.dshus-heat-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; min-height: 28px; }',
684
+ '.dshus-heat-legend { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--dsw-alias-label-caption, #9ca3af); }',
685
+ '.dshus-heat-legend-cell { width: 11px; height: 11px; border-radius: 3px; background: var(--dsw-alias-border-l2, #e5e7eb); }',
686
+ '.dshus-heat-pager { margin-left: auto; }',
687
+ '.dshus-heat-nav-group { display: inline-flex; align-items: center; gap: 2px; }',
688
+ '.dshus-heat-nav-label { min-width: 68px; text-align: right; white-space: nowrap; font-size: 12px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; }',
689
+ '.dshus-heat-nav-static .dshus-heat-nav { display: none; }',
690
+ '.dshus-heat-nav { flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; background: transparent; color: var(--dsw-alias-label-secondary, #4b5563); cursor: pointer; }',
691
+ '.dshus-heat-nav:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1)); }',
692
+ '.dshus-heat-nav:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: 2px; }',
693
+ '.dshus-heat-nav:disabled { opacity: .28; cursor: default; }',
694
+ '.dshus-heat-label-month { font-size: 12px; line-height: 16px; color: var(--dsw-alias-label-caption, #9ca3af); margin: 8px 0 0; height: 16px; }',
695
+ '.dshus-heat { display: inline-flex; gap: 4px; animation: dshus-heat-in .5s ease-out; }',
696
+ '@keyframes dshus-heat-in { from { opacity: 0; } to { opacity: 1; } }',
697
+ '.dshus-heat-col { display: flex; flex-direction: column; gap: 4px; }',
698
+ '.dshus-heat-weekly { --dshus-week-fill: #0089ff; }',
699
+ '.dshus-heat-weekly .dshus-heat-col.weekly-active { --dshus-week-fill: #67b6ff; }',
700
+ '.dshus-cell { width: var(--dshus-cell-size, 16px); height: var(--dshus-cell-size, 16px); border-radius: 4px; background: var(--dsw-alias-border-l2, #e5e7eb); cursor: pointer; transition: background-color .16s ease; }',
701
+ '.dshus-cell:hover { filter: brightness(1.2); }',
702
+ '.dshus-cell:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b82f6); outline-offset: 1px; }',
703
+ '.dshus-heat-weekly .dshus-cell { background: color-mix(in srgb, var(--dsw-alias-border-l2, #e5e7eb) 28%, transparent); }',
704
+ '.dshus-heat-weekly .dshus-cell:hover { filter: none; }',
705
+ '.dshus-heat-modes { flex-shrink: 0; gap: 16px; padding: 0; background: transparent; }',
706
+ '.dshus-heat-modes .dshus-range-btn { height: 24px; padding: 0; font-weight: 400; }',
707
+ '.dshus-heat-modes .dshus-range-btn.on { background: transparent; box-shadow: none; font-weight: 400; }',
708
+ '.dshus-head-tools { margin-left: auto; display: inline-flex; align-items: center; gap: 18px; }',
709
+ '.dshus-heat-total-label { font-size: 12px; color: var(--dsw-alias-label-caption, #9ca3af); }',
710
+ '@media (prefers-reduced-motion: reduce) { .dshus-heat { animation: none; } .dshus-cell { transition: none; } }',
711
+
712
+ /* bars */
713
+ '.dshus-bars { width: 100%; position: relative; z-index: 1; }',
714
+ '.dshus-bars-content { display: flex; align-items: stretch; gap: 2px; min-width: 0; width: 100%; position: relative; }',
715
+ '.dshus-hour { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }',
716
+ '.dshus-hour-bar { flex: 0 0 150px; display: flex; align-items: flex-end; overflow: hidden; }',
717
+ '.dshus-bar { width: 100%; border-radius: 3px 3px 0 0; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; min-height: 2px; cursor: pointer; }',
718
+ '.dshus-bar { transition: height 1s cubic-bezier(0.22, 0.61, 0.36, 1); }',
719
+ '.dshus-bar-seg { width: 100%; }',
720
+ '.dshus-bar-label { text-align: center; font-size: 9px; line-height: 12px; min-height: 12px; margin-top: 4px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; white-space: nowrap; }',
721
+ '.dshus-call-line { position: absolute; left: 0; top: 0; width: 100%; height: 150px; z-index: 2; pointer-events: none; overflow: visible; }',
722
+ '.dshus-call-path { transform-box: view-box; transition: transform 1s cubic-bezier(0.22, 0.61, 0.36, 1); }',
723
+ '.dshus-call-dot { position: absolute; width: 7px; height: 7px; border-radius: 50%; border: 1px solid var(--dsw-alias-bg-layer-1, #fff); background: #3b82f6; transform: translate(-50%, -50%); transition: transform 1s cubic-bezier(0.22, 0.61, 0.36, 1); z-index: 3; pointer-events: none; }',
724
+ '@media (prefers-reduced-motion: reduce) { .dshus-bar, .dshus-call-path, .dshus-call-dot { transition: none; } }',
725
+ '.dshus-call-axis { position: relative; width: 34px; flex: 0 0 34px; height: 150px; color: #3b82f6; font-size: 10px; font-variant-numeric: tabular-nums; }',
726
+ '.dshus-call-axis span { position: absolute; left: 2px; white-space: nowrap; transform: translateY(-50%); }',
727
+ '.dshus-call-axis-label { color: #3b82f6; font-size: 11px; text-align: right; margin-top: 4px; }',
728
+ '.dshus-hour-hit-area { position: absolute; inset: 0 0 auto; height: 150px; z-index: 4; display: flex; }',
729
+ '.dshus-hour-hit { flex: 1 1 0; min-width: 0; cursor: crosshair; }',
730
+ '.dshus-hour-hit:hover { background: rgba(59, 130, 246, 0.06); }',
731
+ '.dshus-hour-hit:focus-visible { outline: 2px solid #3b82f6; outline-offset: -2px; }',
732
+ '.dshus-hover-line { position: absolute; top: 0; height: 150px; border-left: 1px solid var(--dsw-alias-label-caption, #9ca3af); z-index: 3; pointer-events: none; }',
733
+ /* 24h chart — variable Y scale + dashed grid lines (enlarged & left-shifted) */
734
+ '.dshus-bars-wrap { display: flex; gap: 4px; align-items: flex-start; margin-left: -4px; }',
735
+ '.dshus-yaxis { position: relative; width: 34px; flex: 0 0 34px; height: 150px; flex-shrink: 0; }',
736
+ '.dshus-yaxis-tick { position: absolute; right: 2px; font-size: 10px; line-height: 10px; color: var(--dsw-alias-label-caption, #9ca3af); font-variant-numeric: tabular-nums; transform: translateY(-50%); white-space: nowrap; }',
737
+ '.dshus-chart-area { flex: 1 1 auto; min-width: 0; position: relative; }',
738
+ '.dshus-grid { position: absolute; left: 0; right: 0; top: 0; height: 150px; pointer-events: none; z-index: 0; }',
739
+ '.dshus-grid-line { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l2, #d0d5dd); opacity: 0.95; }',
740
+
741
+ /* donut (per-model token share) */
742
+ '.dshus-donut { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }',
743
+ '.dshus-donut-wrap { position: relative; flex: 0 0 auto; }',
744
+ '.dshus-donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }',
745
+ '.dshus-donut-center .v { font-size: 17px; font-weight: 650; letter-spacing: -0.01em; line-height: 22px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, #1f2430); }',
746
+ '.dshus-donut-center .k { font-size: 10.5px; color: var(--dsw-alias-label-caption, #9ca3af); margin-top: 2px; }',
747
+ '.dshus-donut-legend { flex: 1 1 260px; min-width: 230px; display: flex; flex-direction: column; gap: 7px; }',
748
+ '.dshus-donut-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-primary, #1f2430); cursor: pointer; padding: 2px 4px; border-radius: 4px; }',
749
+ '.dshus-donut-row:hover, .dshus-donut-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.08)); outline: none; }',
750
+ '.dshus-tip { position: fixed; z-index: 9999; pointer-events: none; background: var(--dsw-alias-tooltip-bg, #2c2c2e); color: #ffffff; border-radius: 8px; padding: 6px 10px; font-size: 12px; line-height: 16px; box-shadow: 0 4px 14px rgba(0,0,0,0.28); max-width: 280px; white-space: normal; }',
751
+ '.dshus-tip-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
752
+ '.dshus-tip-val { opacity: 0.88; margin-top: 2px; font-variant-numeric: tabular-nums; white-space: pre-line; }',
753
+ '.dshus-chart-tip { width: 260px; max-width: min(260px, calc(100vw - 24px)); padding: 10px 12px; }',
754
+ '.dshus-chart-tip .dshus-tip-name { font-size: 13px; color: #60a5fa; }',
755
+ '.dshus-tip-time { color: rgba(255,255,255,0.62); margin: 4px 0 8px; }',
756
+ '.dshus-tip-row { display: flex; align-items: center; gap: 7px; padding: 3px 0; border-top: 1px solid rgba(255,255,255,0.1); }',
757
+ '.dshus-tip-row span:nth-child(2) { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
758
+ '.dshus-tip-row strong { font-variant-numeric: tabular-nums; }',
759
+ '.dshus-tip-swatch { flex: 0 0 8px; height: 8px; border-radius: 50%; }',
760
+ '.dshus-heat-tip { width: 320px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px); padding: 10px 12px; border-radius: 12px; overflow-y: auto; pointer-events: auto; }',
761
+ '.dshus-heat-tip .dshus-tip-name { font-size: 13px; font-weight: 400; white-space: normal; }',
762
+ '.dshus-heat-tip .dshus-tip-val { margin-bottom: 8px; }',
763
+ '.dshus-heat-tip .dshus-tip-row span:nth-child(2) { white-space: normal; overflow-wrap: anywhere; }',
764
+ '.dshus-heat-tip .dshus-tip-row strong { white-space: nowrap; }',
765
+ '.dshus-heat-tip .dshus-tip-row:last-child span:first-child { flex: 1 1 auto; }',
766
+ '.dshus-donut-dot { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }',
767
+ '.dshus-donut-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }',
768
+ '.dshus-donut-pct { flex: 0 0 46px; text-align: right; color: var(--dsw-alias-label-secondary, #4b5563); font-variant-numeric: tabular-nums; }',
769
+ '.dshus-donut-val { flex: 0 0 70px; text-align: right; color: var(--dsw-alias-label-secondary, #4b5563); font-variant-numeric: tabular-nums; }',
770
+ '.dshus-card-model .v { font-size: 15px; line-height: 20px; }',
771
+ '.dshus-card-model .sub { font-size: 11px; color: var(--dsw-alias-label-caption, #9ca3af); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
772
+
773
+ /* Model breakdown table and search */
774
+ '.dshus-model-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 18px; margin-bottom: 10px; flex-wrap: wrap; }',
775
+ '.dshus-search-input { height: 30px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #d0d5dd); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1f2430); font: inherit; font-size: 12px; width: 220px; outline: none; }',
776
+ '.dshus-search-input:focus { border-color: var(--dsw-alias-brand-primary, #3b82f6); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); }',
777
+ '.dshus-table-wrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--dsw-alias-border-l2, #e5e7eb); border-radius: 10px; }',
778
+ '.dshus-table { width: 100%; border-collapse: collapse; font-size: 12.5px; text-align: left; }',
779
+ '.dshus-table th { padding: 9px 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #4b5563); background: var(--dsw-alias-bg-layer-2, #f9fafb); border-bottom: 1px solid var(--dsw-alias-border-l2, #e5e7eb); font-size: 12px; white-space: nowrap; }',
780
+ '.dshus-table td { padding: 9px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2, #f2f3f6); color: var(--dsw-alias-label-primary, #1f2430); font-variant-numeric: tabular-nums; }',
781
+ '.dshus-table tr:last-child td { border-bottom: none; }',
782
+ '.dshus-table tr:hover td { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.04)); }',
783
+ '.dshus-table-model-cell { display: flex; align-items: center; gap: 8px; font-weight: 500; }',
784
+ '.dshus-table-bar-cell { width: 140px; }',
785
+ '.dshus-table-bar-bg { height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #e5e7eb); overflow: hidden; }',
786
+ '.dshus-table-bar-fill { height: 100%; border-radius: 999px; }',
787
+
788
+ '.dshus-muted { color: var(--dsw-alias-label-tertiary, #6b7280); font-size: 12.5px; }',
789
+ '.dshus-error { color: var(--dsw-alias-state-error-primary, #dc2626); font-size: 13px; margin: 8px 0; white-space: pre-wrap; }',
790
+
791
+ ].join('\n')
792
+
793
+ function injectStyles() {
794
+ // Always refresh the stylesheet content, even when the element already
795
+ // exists: a previous in-page load may have injected an older version
796
+ // (e.g. stretched cells) whose rules would otherwise keep applying.
797
+ var style = document.getElementById(STYLE_ID)
798
+ if (!style) {
799
+ style = document.createElement('style')
800
+ style.id = STYLE_ID
801
+ document.head.appendChild(style)
802
+ }
803
+ style.textContent = styles
804
+ }
805
+
806
+ // The host chooses sidebar icons by hard-coded section id and ignores slot
807
+ // icon metadata. Mark only our settings row so CSS can show a chart glyph.
808
+ function markUsageNavIcon() {
809
+ var buttons = document.querySelectorAll('[data-shortcut-modal="settings"] nav button')
810
+ for (var i = 0; i < buttons.length; i++) {
811
+ var label = buttons[i].querySelector('span')
812
+ if (label && label.textContent.trim() === t('用量统计')) {
813
+ buttons[i].setAttribute('data-dshus-nav-icon', '')
814
+ }
815
+ }
816
+ }
817
+
818
+ function watchUsageNavIcon() {
819
+ if (typeof MutationObserver === 'undefined' || !document.body) return function () {}
820
+ markUsageNavIcon()
821
+ var observer = new MutationObserver(markUsageNavIcon)
822
+ observer.observe(document.body, { childList: true, subtree: true })
823
+ return function () { observer.disconnect() }
824
+ }
825
+
826
+ /* ------------------------------------------------------------------ */
827
+ /* Heatmap (GitHub-style calendar) */
828
+ /* ------------------------------------------------------------------ */
829
+
830
+ /** Keep one full strip visible; extend it into older pages only as needed. */
831
+ function buildHeatmap(dayMap, days, tzMin, pageWeeks) {
832
+ var todayKey = new Date(Date.now() - tzMin * 60000).toISOString().slice(0, 10)
833
+ var todayOrdinal = Date.parse(todayKey + 'T00:00:00Z') / 86400000
834
+ var firstOrdinal = days > 0 ? todayOrdinal - days + 1 : todayOrdinal - 364
835
+ if (!(days > 0)) {
836
+ dayMap.forEach(function (_bucket, date) {
837
+ var ordinal = Date.parse(date + 'T00:00:00Z') / 86400000
838
+ if (Number.isFinite(ordinal) && ordinal < firstOrdinal) firstOrdinal = ordinal
839
+ })
840
+ }
841
+ // Monday is always the first cell in a column. This keeps every
842
+ // calendar week's seven days together, including the incomplete week
843
+ // at either end of the available history.
844
+ var todayWeekStart = Date.parse(weekStart(todayKey) + 'T00:00:00Z') / 86400000
845
+ var firstWeekStart = Date.parse(weekStart(new Date(firstOrdinal * 86400000).toISOString().slice(0, 10)) + 'T00:00:00Z') / 86400000
846
+ var colCount = Math.max(pageWeeks, Math.round((todayWeekStart - firstWeekStart) / 7) + 1)
847
+ var firstColOrdinal = todayWeekStart - (colCount - 1) * 7
848
+ function key(ordinal) { return new Date(ordinal * 86400000).toISOString().slice(0, 10) }
849
+ var weeks = []
850
+ var max = 0
851
+ for (var col = 0; col < colCount; col++) {
852
+ var colStart = firstColOrdinal + col * 7
853
+ var cells = []
854
+ for (var i = 0; i < 7; i++) {
855
+ var ordinal = colStart + i
856
+ var k = key(ordinal)
857
+ var inWindow = ordinal >= firstOrdinal && ordinal <= todayOrdinal
858
+ var bucket = dayMap.get(k) || null
859
+ var v = bucket ? bucket.billed : 0
860
+ if (v > max && inWindow) max = v
861
+ cells.push({ date: k, value: v, bucket: bucket, inWindow: inWindow })
862
+ }
863
+ weeks.push({ start: key(colStart), cells: cells })
864
+ }
865
+ return { weeks: weeks, max: max, today: todayKey }
866
+ }
867
+
868
+ function weekStart(date) {
869
+ var ordinal = Date.parse(date + 'T00:00:00Z') / 86400000
870
+ var day = (new Date(ordinal * 86400000).getUTCDay() + 6) % 7
871
+ return new Date((ordinal - day) * 86400000).toISOString().slice(0, 10)
872
+ }
873
+
874
+ function weeklyBuckets(dayMap, dayModelMap) {
875
+ var buckets = new Map()
876
+ var models = new Map()
877
+ dayMap.forEach(function (day, date) {
878
+ var key = weekStart(date)
879
+ var bucket = buckets.get(key)
880
+ if (!bucket) { bucket = { date: key, billed: 0, calls: 0 }; buckets.set(key, bucket) }
881
+ bucket.billed += day.billed || 0
882
+ bucket.calls += day.calls || 0
883
+ var dailyModels = dayModelMap.get(date) || []
884
+ var modelBuckets = models.get(key)
885
+ if (!modelBuckets) { modelBuckets = new Map(); models.set(key, modelBuckets) }
886
+ dailyModels.forEach(function (model) {
887
+ var current = modelBuckets.get(model.key)
888
+ if (!current) {
889
+ current = { key: model.key, provider: model.provider, model: model.model, billed: 0, calls: 0 }
890
+ modelBuckets.set(model.key, current)
891
+ }
892
+ current.billed += model.billed || 0
893
+ current.calls += model.calls || 0
894
+ })
895
+ })
896
+ var modelLists = new Map()
897
+ models.forEach(function (items, key) {
898
+ modelLists.set(key, Array.from(items.values()).sort(function (a, b) { return b.billed - a.billed }))
899
+ })
900
+ return { buckets: buckets, models: modelLists }
901
+ }
902
+
903
+ function heatLevel(value, max) {
904
+ if (!(value > 0) || !(max > 0)) return 0
905
+ var r = value / max
906
+ if (r > 0.75) return 4
907
+ if (r > 0.5) return 3
908
+ if (r > 0.25) return 2
909
+ return 1
910
+ }
911
+
912
+ /** Colour for a heat level — heat green blended to transparent via color-mix. */
913
+ function cellColor(level) {
914
+ switch (level) {
915
+ case 1: return 'color-mix(in srgb, var(--dshus-heat, #22c55e) 22%, transparent)'
916
+ case 2: return 'color-mix(in srgb, var(--dshus-heat, #22c55e) 45%, transparent)'
917
+ case 3: return 'color-mix(in srgb, var(--dshus-heat, #22c55e) 72%, transparent)'
918
+ case 4: return 'var(--dshus-heat, #22c55e)'
919
+ default: return ''
920
+ }
921
+ }
922
+
923
+ function Heatmap(props) {
924
+ var weekly = props.mode === 'weekly'
925
+ var weeklyData = weekly ? weeklyBuckets(props.dayMap, props.dayModelMap) : null
926
+ var weeklyMax = 0
927
+ if (weekly) weeklyData.buckets.forEach(function (bucket) { weeklyMax = Math.max(weeklyMax, bucket.billed || 0) })
928
+ var dayMap = props.dayMap
929
+ var dayModelMap = props.dayModelMap
930
+ var days = props.days
931
+ var MIN_CELL = 15
932
+ var GAP = 5
933
+ // Hover only brightens the cell (filter), so the gutter exists purely so a
934
+ // forced 25px track cannot push the grid past the pane: it now also owns
935
+ // the gap between the strip and its pager.
936
+ var EDGE_GUTTER = 4
937
+ var viewportRef = React.useRef(null)
938
+ var widthState = React.useState(0)
939
+ var width = widthState[0]
940
+ var setWidth = widthState[1]
941
+ // Paging is owned by the usage page, which renders the arrows in the card
942
+ // header. The index arrives as props.pageIndex and the geometry this
943
+ // component computes goes back out through props.onPageInfo, so pageCount
944
+ // and the visible window stay in one place.
945
+ var reportPageInfo = props.onPageInfo
946
+ var tipState = React.useState(null)
947
+ var tip = tipState[0]
948
+ var setTip = tipState[1]
949
+ var selectedWeekState = React.useState(null)
950
+ var selectedWeek = selectedWeekState[0]
951
+ var setSelectedWeek = selectedWeekState[1]
952
+ var hideTimerRef = React.useRef(null)
953
+ var modelCacheRef = React.useRef(new Map())
954
+ var modelVersionState = React.useState(0)
955
+ var modelVersion = modelVersionState[0]
956
+ var setModelVersion = modelVersionState[1]
957
+ var tipRef = React.useRef(null)
958
+
959
+ var modelCacheKey = function (date) { return String(props.syncedAt || 0) + '|' + (props.modelKey || '') + '|' + date }
960
+ var modelResult = function (date) {
961
+ var result
962
+ if (weekly) result = { status: 'ready', models: weeklyData.models.get(weekStart(date)) || [] }
963
+ else if (dayModelMap.has(date)) result = { status: 'ready', models: dayModelMap.get(date) || [] }
964
+ else result = modelCacheRef.current.get(modelCacheKey(date)) || { status: props.loadDayModels ? 'idle' : 'unavailable', models: [] }
965
+ return { status: result.status, models: groupModelRows(result.models) }
966
+ }
967
+ var requestModels = function (cell) {
968
+ if (weekly || !cell.inWindow || !cell.bucket || !props.loadDayModels || dayModelMap.has(cell.date)) return
969
+ var key = modelCacheKey(cell.date)
970
+ if (modelCacheRef.current.has(key)) return
971
+ modelCacheRef.current.set(key, { status: 'loading', models: [] })
972
+ Promise.resolve().then(function () { return props.loadDayModels(cell.date) }).then(function (models) {
973
+ modelCacheRef.current.set(key, { status: 'ready', models: models })
974
+ setModelVersion(function (v) { return v + 1 })
975
+ }).catch(function () {
976
+ modelCacheRef.current.set(key, { status: 'error', models: [] })
977
+ setModelVersion(function (v) { return v + 1 })
978
+ })
979
+ }
980
+
981
+ // useLayoutEffect measures before the first renderer paint, so changing
982
+ // range or resizing the pane does not briefly show a one-week strip.
983
+ var useMeasureEffect = React.useLayoutEffect || React.useEffect
984
+ useMeasureEffect(function () {
985
+ var node = viewportRef.current
986
+ if (!node) return
987
+ var measure = function () {
988
+ var nextWidth = Math.max(0, node.clientWidth)
989
+ setWidth(nextWidth)
990
+ }
991
+ measure()
992
+ var observer = window.ResizeObserver ? new window.ResizeObserver(measure) : null
993
+ if (observer) observer.observe(node)
994
+ else if (window.addEventListener) window.addEventListener('resize', measure)
995
+ return function () {
996
+ if (observer) observer.disconnect()
997
+ else if (window.removeEventListener) window.removeEventListener('resize', measure)
998
+ }
999
+ }, [])
1000
+
1001
+ // Fit complete weeks inside the viewport padding, then let their cells use
1002
+ // the remaining width. MAX_CELL keeps the calendar from turning into a
1003
+ // sparse wall of squares when the settings pane is dragged wide.
1004
+ var heatAreaWidth = Math.max(0, width - EDGE_GUTTER * 2)
1005
+ var visibleCount = Math.min(53, Math.max(1, Math.floor((heatAreaWidth + GAP) / (MIN_CELL + GAP))))
1006
+ var CELL = heatAreaWidth > 0 ? Math.min(25, Math.max(MIN_CELL, (heatAreaWidth - (visibleCount - 1) * GAP) / visibleCount)) : MIN_CELL
1007
+ var STEP = CELL + GAP
1008
+ var heat = buildHeatmap(dayMap, days, props.tz, visibleCount)
1009
+ var pageCount = Math.max(1, Math.ceil(heat.weeks.length / visibleCount))
1010
+ var page = props.pageIndex == null ? 0 : Math.min(Math.max(0, props.pageIndex), pageCount - 1)
1011
+ var end = heat.weeks.length - page * visibleCount
1012
+ var weeks = heat.weeks.slice(Math.max(0, end - visibleCount), end)
1013
+
1014
+ // Publish the paging geometry to the card header. Report the column start
1015
+ // (Monday, the same key the grid renders as data-week-start) so the label
1016
+ // and the pager state always describe the window actually on screen.
1017
+ React.useEffect(function () {
1018
+ if (!reportPageInfo) return
1019
+ var first = weeks.length > 0 ? weeks[0].start : null
1020
+ var lastKey = weeks.length > 0 ? weeks[weeks.length - 1].start : null
1021
+ var last = lastKey !== null && lastKey > heat.today ? heat.today : lastKey
1022
+ reportPageInfo({
1023
+ page: page,
1024
+ pageCount: pageCount,
1025
+ first: first,
1026
+ last: last,
1027
+ firstMonth: first === null ? '' : monthShort(first),
1028
+ lastMonth: last === null ? '' : monthShort(last),
1029
+ })
1030
+ }, [reportPageInfo, page, pageCount, visibleCount, props.mode, days, dayMap, heat.today])
1031
+
1032
+ var clearHideTimer = function () {
1033
+ if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current)
1034
+ hideTimerRef.current = null
1035
+ }
1036
+ var hideTip = function () { clearHideTimer(); setTip(null) }
1037
+ var hideTipSoon = function () {
1038
+ clearHideTimer()
1039
+ hideTimerRef.current = setTimeout(function () { setTip(null) }, 120)
1040
+ }
1041
+ React.useEffect(function () {
1042
+ if (window.addEventListener) window.addEventListener('resize', hideTip)
1043
+ return function () {
1044
+ clearHideTimer()
1045
+ if (window.removeEventListener) window.removeEventListener('resize', hideTip)
1046
+ }
1047
+ }, [])
1048
+ var TIP_GAP = 8
1049
+ var TIP_MARGIN = 10
1050
+ /**
1051
+ * Anchor the tooltip to the BOTTOM-RIGHT of the hovered cell so it never
1052
+ * covers the cell or the days around it. Only when that would leave the
1053
+ * window do we fall back: align to the right edge, then flip above.
1054
+ * Kept as a pure function of the anchor + size so the post-render measure
1055
+ * pass can re-run it once the real tooltip height is known.
1056
+ */
1057
+ var placeTip = function (anchor, tipWidth, tipHeight) {
1058
+ var x = anchor.right + TIP_GAP
1059
+ if (x + tipWidth > window.innerWidth - TIP_MARGIN) {
1060
+ // Not enough room on the right: hang it off the left of the cell
1061
+ // instead of letting it run past the window edge.
1062
+ x = anchor.left - TIP_GAP - tipWidth
1063
+ }
1064
+ if (x < TIP_MARGIN) x = Math.max(TIP_MARGIN, Math.min(anchor.right + TIP_GAP, window.innerWidth - tipWidth - TIP_MARGIN))
1065
+
1066
+ var y = anchor.bottom + TIP_GAP
1067
+ if (y + tipHeight > window.innerHeight - TIP_MARGIN) {
1068
+ // No room below: flip above the cell.
1069
+ y = anchor.top - TIP_GAP - tipHeight
1070
+ }
1071
+
1072
+ return {
1073
+ x: Math.max(TIP_MARGIN, Math.min(window.innerWidth - tipWidth - TIP_MARGIN, x)),
1074
+ y: Math.max(TIP_MARGIN, Math.min(window.innerHeight - tipHeight - TIP_MARGIN, y)),
1075
+ }
1076
+ }
1077
+ // Must mirror .dshus-heat-tip's own width/max-width so the first, pre-measure
1078
+ // placement already picks the right side; the measure effect then corrects
1079
+ // both axes from the real box.
1080
+ var tipWidthFor = function () { return Math.min(320, window.innerWidth - 20) }
1081
+ var showTip = function (e, cell) {
1082
+ clearHideTimer()
1083
+ requestModels(cell)
1084
+ var models = modelResult(cell.date).models
1085
+ var bounds = e.currentTarget && e.currentTarget.getBoundingClientRect ? e.currentTarget.getBoundingClientRect() : null
1086
+ var anchor = bounds ? { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom } : { left: e.clientX, right: e.clientX, top: e.clientY, bottom: e.clientY }
1087
+ var position = placeTip(anchor, tipWidthFor(), Math.min(window.innerHeight - 20, 80 + models.length * 25))
1088
+ setTip(function (previous) {
1089
+ if (previous && previous.anchor && previous.cell.date === cell.date && previous.cell.bucket === cell.bucket && previous.cell.inWindow === cell.inWindow && previous.days === days && previous.mode === props.mode && previous.anchor.left === anchor.left && previous.anchor.top === anchor.top && previous.anchor.right === anchor.right && previous.anchor.bottom === anchor.bottom) return previous
1090
+ return { x: position.x, y: position.y, anchor: anchor, cell: cell, days: days, mode: props.mode }
1091
+ })
1092
+ }
1093
+ var tipEl = null
1094
+ if (tip !== null && tip.days === days && tip.mode === props.mode) {
1095
+ var weekDate = weekly ? weekStart(tip.cell.date) : tip.cell.date
1096
+ var bucket = weekly ? weeklyData.buckets.get(weekDate) : tip.cell.bucket
1097
+ var tipInWindow = weekly || tip.cell.inWindow
1098
+ var tipValue = bucket ? bucket.billed || 0 : 0
1099
+ var detail = modelResult(tip.cell.date)
1100
+ var modelRows = detail.models.map(function (m) {
1101
+ return React.createElement('div', { className: 'dshus-tip-row', key: m.key },
1102
+ React.createElement('span', { className: 'dshus-tip-swatch', style: { background: sharedModelColor(m.model) } }),
1103
+ React.createElement('span', { title: m.model }, m.model),
1104
+ React.createElement('strong', { title: fmtExact(m.billed) + ' tokens' }, fmt(m.billed)),
1105
+ )
1106
+ })
1107
+ tipEl = React.createElement('div', {
1108
+ className: 'dshus-tip dshus-heat-tip',
1109
+ ref: tipRef,
1110
+ style: { left: tip.x, top: tip.y },
1111
+ onMouseEnter: clearHideTimer,
1112
+ onMouseLeave: hideTip,
1113
+ },
1114
+ React.createElement('div', { className: 'dshus-tip-name', title: bucket ? fmtExact(tipValue) + ' tokens' : undefined }, weekly ? weekDate.replace(/^(\d{4})-(\d{2})-(\d{2})$/, function (_match, year, month, day) { return t('{year}年{month}月{day}日起的一周', { year: year, month: month, day: day }) }) : tip.cell.date),
1115
+ tipInWindow && React.createElement('div', { className: 'dshus-tip-val', title: bucket ? fmtExact(tipValue) + ' tokens' : undefined }, bucket ? fmt(tipValue) + ' tokens' : t('无记录')),
1116
+ !tipInWindow && React.createElement('div', { className: 'dshus-tip-val' }, t('不在所选时间范围内')),
1117
+ tipInWindow && modelRows,
1118
+ tipInWindow && bucket && detail.models.length === 0 && !weekly && React.createElement('div', { className: 'dshus-tip-val' },
1119
+ detail.status === 'loading' || detail.status === 'idle' ? t('正在读取模型明细…') : t('模型明细暂不可用'),
1120
+ ),
1121
+ tipInWindow && React.createElement('div', { className: 'dshus-tip-row', style: { color: '#60a5fa' } },
1122
+ React.createElement('span', null, t('总调用次数')),
1123
+ React.createElement('strong', null, t('{count} 次', { count: fmtExact(bucket && bucket.calls) })),
1124
+ ),
1125
+ )
1126
+ }
1127
+ useMeasureEffect(function () {
1128
+ if (!tip || !tipRef.current) return
1129
+ var bounds = tipRef.current.getBoundingClientRect()
1130
+ var position = placeTip(tip.anchor, bounds.width, bounds.height)
1131
+ if (Math.abs(position.x - tip.x) > .5 || Math.abs(position.y - tip.y) > .5) {
1132
+ setTip(function (current) { return current === tip ? Object.assign({}, tip, position) : current })
1133
+ }
1134
+ }, [tip, modelVersion, width, props.mode, days])
1135
+
1136
+ var segs = []
1137
+ var prevMonth = ''
1138
+ for (var wi = 0; wi < weeks.length; wi++) {
1139
+ var monthDate = weeks[wi].cells[6].date
1140
+ var m = monthShort(monthDate > heat.today ? heat.today : monthDate)
1141
+ if (m !== prevMonth) {
1142
+ if (prevMonth !== '') segs[segs.length - 1].end = wi - 1
1143
+ segs.push({ label: m, start: wi, end: wi })
1144
+ prevMonth = m
1145
+ }
1146
+ }
1147
+ if (segs.length > 0) segs[segs.length - 1].end = weeks.length - 1
1148
+ var heatWidth = weeks.length * STEP - GAP
1149
+ var labelWidth = Math.max(36, heatWidth)
1150
+ var monthLabels = segs.map(function (seg, i) {
1151
+ var cx = ((seg.start + seg.end) / 2) * STEP + CELL / 2 + (labelWidth - heatWidth) / 2
1152
+ // Keep the label fully inside the strip even for a 1-week edge month
1153
+ // (wider labels like 12月 need extra room on both sides).
1154
+ cx = Math.min(Math.max(cx, 16), labelWidth - 16)
1155
+ return React.createElement('span', {
1156
+ key: 'ml' + i,
1157
+ style: { position: 'absolute', left: cx, transform: 'translateX(-50%)', whiteSpace: 'nowrap' },
1158
+ }, seg.label)
1159
+ })
1160
+ var monthRow = React.createElement('div', {
1161
+ className: 'dshus-heat-label-month',
1162
+ style: { position: 'relative', width: labelWidth, height: 16 },
1163
+ }, monthLabels)
1164
+
1165
+ var activeWeek = weekly && tip && tip.mode === props.mode ? weekStart(tip.cell.date) : selectedWeek
1166
+ var weekCols = weeks.map(function (week, w) {
1167
+ var weekBucket = weekly && weeklyData.buckets.get(week.start)
1168
+ var weekValue = weekBucket ? weekBucket.billed || 0 : 0
1169
+ var filledCells = weekly && weekValue > 0 && weeklyMax > 0 ? Math.max(1, Math.round(weekValue / weeklyMax * 7)) : 0
1170
+ return React.createElement('div', { className: 'dshus-heat-col' + (weekly && activeWeek === week.start ? ' weekly-active' : ''), key: week.start, 'data-week-start': week.start },
1171
+ week.cells.map(function (cell, c) {
1172
+ if (!cell.inWindow && !weekly) {
1173
+ return React.createElement('div', {
1174
+ className: 'dshus-cell',
1175
+ key: w + '-' + c,
1176
+ role: 'gridcell',
1177
+ 'aria-label': cell.date + t(',不在所选时间范围内'),
1178
+ onMouseMove: function (e) { showTip(e, cell) },
1179
+ onClick: function (e) { if (weekly) setSelectedWeek(week.start); if (e) showTip(e, cell) },
1180
+ onMouseLeave: hideTipSoon,
1181
+ })
1182
+ }
1183
+ var level = heatLevel(cell.value, heat.max)
1184
+ var models = modelResult(cell.date).models
1185
+ var filled = weekly && c >= 7 - filledCells
1186
+ var ariaLabel = t('{time}: {tokens} tokens,{count} 次调用', { time: weekly ? t('{date} 当周', { date: cell.date }) : cell.date, count: fmtExact(weekly ? weekBucket && weekBucket.calls : cell.bucket && cell.bucket.calls), tokens: fmtExact(weekly ? weekBucket && weekBucket.billed : cell.value) })
1187
+ if (models.length > 0) ariaLabel += ';' + models.map(function (m) { return m.model + ' ' + fmtExact(m.billed) }).join(';')
1188
+ return React.createElement('div', {
1189
+ className: 'dshus-cell' + (filled ? ' weekly-filled' : ''),
1190
+ key: w + '-' + c,
1191
+ tabIndex: 0,
1192
+ role: 'gridcell',
1193
+ 'aria-label': ariaLabel,
1194
+ onMouseMove: function (e) { showTip(e, cell) },
1195
+ onClick: function (e) { if (weekly) setSelectedWeek(week.start); if (e) showTip(e, cell) },
1196
+ onMouseLeave: hideTipSoon,
1197
+ onFocus: function (e) {
1198
+ var rect = e.currentTarget.getBoundingClientRect()
1199
+ showTip({ currentTarget: e.currentTarget, clientX: rect.left, clientY: rect.bottom }, cell)
1200
+ },
1201
+ onBlur: hideTip,
1202
+ style: weekly ? (filled ? { background: 'var(--dshus-week-fill, #0089ff)' } : undefined) : (level > 0 ? { background: cellColor(level) } : undefined),
1203
+ })
1204
+ }),
1205
+ )
1206
+ })
1207
+
1208
+ return React.createElement('div', { className: weekly ? 'dshus-heat-weekly' : undefined, style: { '--dshus-heat': '#0089ff' } },
1209
+ React.createElement('div', { className: 'dshus-heat-wrap' },
1210
+ React.createElement('div', {
1211
+ className: 'dshus-heat-viewport',
1212
+ ref: viewportRef,
1213
+ 'data-page': page,
1214
+ 'data-page-count': pageCount,
1215
+ style: { '--dshus-cell-size': CELL + 'px' },
1216
+ },
1217
+ React.createElement('div', { className: 'dshus-heat', key: props.mode + '-' + page + '-' + visibleCount, role: 'grid', 'aria-label': t('使用量热力图') }, weekCols),
1218
+ monthRow,
1219
+ ),
1220
+ ),
1221
+ // Colour scale replaces the old 累计总量 caption (which only restated the
1222
+ // 每周 switch next to it) with the one legend the calendar actually
1223
+ // needs: what the shades of blue mean.
1224
+ React.createElement('div', { className: 'dshus-heat-legend', 'aria-hidden': 'true' },
1225
+ React.createElement('span', null, t('少')),
1226
+ [1, 2, 3, 4].map(function (level) {
1227
+ return React.createElement('span', { className: 'dshus-heat-legend-cell', key: 'lg' + level, style: { background: cellColor(level) } })
1228
+ }),
1229
+ React.createElement('span', null, t('多')),
1230
+ ),
1231
+ tipEl,
1232
+ )
1233
+ }
1234
+
1235
+ /**
1236
+ * Pager for the heatmap, rendered into the card header so the calendar
1237
+ * itself stays a bare grid. `info` is published by Heatmap via onPageInfo;
1238
+ * `onStep` receives +1 (older) / -1 (newer).
1239
+ */
1240
+ function heatArrow(left) {
1241
+ return React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true },
1242
+ React.createElement('path', { d: left ? 'M11 3 4 8l7 5Z' : 'M5 3l7 5-7 5Z', fill: 'currentColor' }),
1243
+ )
1244
+ }
1245
+
1246
+ function HeatPager(props) {
1247
+ var info = props.info
1248
+ var range = ''
1249
+ if (info && info.firstMonth) range = info.firstMonth === info.lastMonth ? info.firstMonth : info.firstMonth + ' – ' + info.lastMonth
1250
+ var canOlder = Boolean(info) && info.page < info.pageCount - 1
1251
+ var canNewer = Boolean(info) && info.page > 0
1252
+ return React.createElement('div', {
1253
+ className: 'dshus-heat-nav-group' + (info && info.pageCount > 1 ? '' : ' dshus-heat-nav-static'),
1254
+ role: 'group',
1255
+ 'aria-label': t('热力图翻页'),
1256
+ },
1257
+ React.createElement('span', { className: 'dshus-heat-nav-label', title: t('当前显示的日期范围') }, range),
1258
+ React.createElement('button', {
1259
+ type: 'button',
1260
+ className: 'dshus-heat-nav',
1261
+ 'aria-label': t('查看更早日期'),
1262
+ disabled: !canOlder,
1263
+ onClick: function () { props.onStep(1) },
1264
+ }, heatArrow(true)),
1265
+ React.createElement('button', {
1266
+ type: 'button',
1267
+ className: 'dshus-heat-nav',
1268
+ 'aria-label': t('查看较新日期'),
1269
+ disabled: !canNewer,
1270
+ onClick: function () { props.onStep(-1) },
1271
+ }, heatArrow(false)),
1272
+ )
1273
+ }
1274
+
1275
+ /* ------------------------------------------------------------------ */
1276
+ /* 24-hour token bar chart (usage by hour of day over the window) */
1277
+ /* ------------------------------------------------------------------ */
1278
+
1279
+ /* ------------------------------------------------------------------ */
1280
+ /* Charts */
1281
+ /* ------------------------------------------------------------------ */
1282
+
1283
+ /**
1284
+ * useGrowIn(active): returns [grown, ref]. While `active`, the chart
1285
+ * element (ref) stays in its hidden "start" state until it first becomes
1286
+ * visible in the viewport (IntersectionObserver); only then does `grown`
1287
+ * flip to true — deferred by two animation frames so the hidden state is
1288
+ * guaranteed to be painted before the CSS transition starts (otherwise
1289
+ * Chromium skips the transition entirely). Charts below the fold begin
1290
+ * drawing when the user scrolls to them, not at page open.
1291
+ */
1292
+ function useGrowIn(active) {
1293
+ var grownState = React.useState(false)
1294
+ var grown = grownState[0]
1295
+ var ref = React.useRef(null)
1296
+ React.useEffect(function () {
1297
+ if (grown) return
1298
+ var el = ref.current
1299
+ if (!el || !active) return
1300
+ var started = false
1301
+ var io = null
1302
+ function start() {
1303
+ if (started) return
1304
+ started = true
1305
+ window.requestAnimationFrame(function () {
1306
+ window.requestAnimationFrame(function () { grownState[1](true) })
1307
+ })
1308
+ }
1309
+ if (typeof IntersectionObserver === 'undefined') { start(); return undefined }
1310
+ io = new IntersectionObserver(function (entries) {
1311
+ for (var ei = 0; ei < entries.length; ei++) {
1312
+ if (entries[ei].isIntersecting) {
1313
+ start()
1314
+ io.disconnect()
1315
+ break
1316
+ }
1317
+ }
1318
+ }, { threshold: 0.15 })
1319
+ io.observe(el)
1320
+ return function () {
1321
+ if (io !== null) io.disconnect()
1322
+ }
1323
+ }, [active])
1324
+ return [grown, ref]
1325
+ }
1326
+
1327
+ /**
1328
+ * Merge key for one model: case-insensitive, provider prefix and tag suffix
1329
+ * dropped, so "GLM-4.7" vs "glm-4.7" and "stealth/ox-alpha" vs "OX-ALPHA"
1330
+ * land in one bucket. The ox / x-preview family are earlier gateway names of
1331
+ * what is now served as glm-5.3-flash, so they fold into that bucket too.
1332
+ * Shared by the trend tooltip, the donut and the hourly chart.
1333
+ */
1334
+ function sharedNormModelKey(name) {
1335
+ var s = String(name || '').trim()
1336
+ var low = s.toLowerCase()
1337
+ var slash = low.lastIndexOf('/')
1338
+ if (slash !== -1) low = low.slice(slash + 1)
1339
+ var colon = low.indexOf(':')
1340
+ if (colon !== -1) low = low.slice(0, colon)
1341
+ if (low.endsWith('-free')) low = low.slice(0, -5)
1342
+ if (low === 'x-preview-f' || low === 'x-preview' || low === 'x-privew' || low === 'x-privew-f' || low === 'ox' || low === 'ox-alpha') low = 'glm-5.3-flash'
1343
+ return low
1344
+ }
1345
+
1346
+ /**
1347
+ * Display name for a merged group: the basename of the winning variant (text
1348
+ * after the last '/', original casing). OpenRouter-style ids carry a namespace
1349
+ * prefix ("z-ai/glm-5.3-flash"); showing the full id would make the merged
1350
+ * group look like a provider leaked in, so the prefix is always dropped.
1351
+ */
1352
+ function sharedModelDisplayName(name) {
1353
+ var s = String(name || '').trim()
1354
+ var slash = s.lastIndexOf('/')
1355
+ return slash !== -1 ? s.slice(slash + 1) : s
1356
+ }
1357
+
1358
+ /** Model-only breakdown shared by heatmap hovers and daily trends. */
1359
+ function groupModelRows(rows) {
1360
+ var merged = []
1361
+ var index = Object.create(null)
1362
+ for (var i = 0; i < rows.length; i++) {
1363
+ var entry = rows[i]
1364
+ if (entry.key === '__all__') { merged.push(entry); continue }
1365
+ var modelId = sharedNormModelKey(entry.model || entry.key)
1366
+ var existing = index[modelId]
1367
+ if (existing === undefined) {
1368
+ index[modelId] = merged.length
1369
+ merged.push({ key: 'by-model:' + modelId, provider: '', model: sharedModelDisplayName(entry.model), billed: entry.billed || 0 })
1370
+ } else {
1371
+ merged[existing].billed += entry.billed || 0
1372
+ }
1373
+ }
1374
+ return merged.sort(function (a, b) { return (b.billed || 0) - (a.billed || 0) })
1375
+ }
1376
+
1377
+ /** Distinct, theme-agnostic hues for the model segments. */
1378
+ var sharedModelColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6']
1379
+
1380
+ /**
1381
+ * Stable colour for a merged model name. Hashing the name (not the position)
1382
+ * keeps one model on the same hue in every chart and on every day, so the
1383
+ * daily trend bars and its tooltip swatches agree.
1384
+ */
1385
+ function sharedModelColor(name) {
1386
+ var key = sharedNormModelKey(name)
1387
+ var hash = 0
1388
+ for (var i = 0; i < key.length; i++) hash = ((hash * 31) + key.charCodeAt(i)) >>> 0
1389
+ return sharedModelColors[hash % sharedModelColors.length]
1390
+ }
1391
+
1392
+ function Bars(props) {
1393
+ var hourModels = props.hourModels // [{hour, models:[{key,provider,model,billed}]}]
1394
+ var byHour = props.byHour || []
1395
+ var colorOf = props.colorOf
1396
+ var daily = props.variant === 'daily'
1397
+
1398
+ // ALL hooks run unconditionally, before any early return, so the hook
1399
+ // count stays identical whether the window has records or not.
1400
+ // Grow-in: bars stay at 0% height until the chart scrolls into view,
1401
+ // then transition up to their real height once (visibility-triggered,
1402
+ // see useGrowIn).
1403
+ var grow = useGrowIn(!!(hourModels && hourModels.length > 0))
1404
+ var grown = grow[0]
1405
+ var barsRef = grow[1]
1406
+ // One hover region per hour, including the empty space above the bar.
1407
+ var tipState = React.useState(null)
1408
+ var tip = tipState[0]
1409
+ var setTip = tipState[1]
1410
+
1411
+ if (!hourModels || hourModels.length === 0) return React.createElement('div', { className: 'dshus-muted' }, t('窗口内没有记录'))
1412
+
1413
+ var showTip = function (e, index) {
1414
+ var x = e.clientX + 14
1415
+ var y = e.clientY + 14
1416
+ if (x + 270 > window.innerWidth) x = Math.max(8, e.clientX - 270)
1417
+ var estimatedHeight = 70 + ((hourModels[index].models || []).length + 1) * 23
1418
+ if (y + estimatedHeight > window.innerHeight) y = Math.max(8, window.innerHeight - estimatedHeight - 8)
1419
+ setTip({ x: x, y: y, index: index })
1420
+ }
1421
+ var hideTip = function () { setTip(null) }
1422
+ var tipEl = null
1423
+ if (tip !== null && hourModels[tip.index]) {
1424
+ var tipItem = hourModels[tip.index]
1425
+ var tipHour = tipItem.hour
1426
+ var tipBucket = byHour[tipHour] || {}
1427
+ var tipCalls = Number(tipBucket.calls) || 0
1428
+ var tipTokens = Number(tipBucket.billed) || 0
1429
+ var tipModels = tipItem.models || []
1430
+ var tipDate = daily ? new Date(tipItem.date + 'T00:00:00Z') : new Date(Date.now() - LOCAL_TZ * 60000)
1431
+ var tipDayLabel = t('{year}年{month}月{day}日', { year: tipDate.getUTCFullYear(), month: tipDate.getUTCMonth() + 1, day: tipDate.getUTCDate() })
1432
+ tipEl = React.createElement('div', { className: 'dshus-tip dshus-chart-tip', style: { left: tip.x, top: tip.y } },
1433
+ React.createElement('div', { className: 'dshus-tip-name' }, t('{count} 次调用', { count: fmtExact(tipCalls) })),
1434
+ React.createElement('div', { className: 'dshus-tip-time' }, tipDayLabel + (daily ? t(' · 全天') : ' · ' + String(tipHour).padStart(2, '0') + ':00–' + String(tipHour).padStart(2, '0') + ':59')),
1435
+ React.createElement('div', { className: 'dshus-tip-row' },
1436
+ React.createElement('span', { className: 'dshus-tip-swatch', style: { background: '#94a3b8' } }),
1437
+ React.createElement('span', null, t('Token 用量')),
1438
+ React.createElement('strong', null, fmt(tipTokens)),
1439
+ ),
1440
+ tipModels.filter(function (mm) { return !daily || mm.key !== '__all__' }).map(function (mm) {
1441
+ // The daily trend lists models only: repeating the provider on every
1442
+ // row made the breakdown harder to scan than the model names.
1443
+ var label = daily ? mm.model : (mm.provider ? mm.provider + ' · ' + mm.model : mm.model)
1444
+ return React.createElement('div', { className: 'dshus-tip-row', key: mm.key },
1445
+ React.createElement('span', { className: 'dshus-tip-swatch', style: { background: colorOf ? colorOf(mm.key) : '#94a3b8' } }),
1446
+ React.createElement('span', null, label),
1447
+ React.createElement('strong', null, fmt(mm.billed || 0)),
1448
+ )
1449
+ }),
1450
+ )
1451
+ }
1452
+
1453
+ var max = 0
1454
+ for (var i = 0; i < hourModels.length; i++) {
1455
+ var tot = 0
1456
+ var ms = hourModels[i].models || []
1457
+ for (var j = 0; j < ms.length; j++) tot += ms[j].billed
1458
+ if (tot > max) max = tot
1459
+ }
1460
+ // ---- Y轴可变刻度 + 虚线网格(nice 数算法:1/2/5 * 10^n,目标4段)----
1461
+ function getYScale(m) {
1462
+ if (!(m > 0)) return { yMax: 1, ticks: [0, 1], step: 1 }
1463
+ var target = 4
1464
+ var rough = m / target
1465
+ var mag = Math.pow(10, Math.floor(Math.log10(rough)))
1466
+ if (!isFinite(mag) || mag === 0) mag = 1
1467
+ var residual = rough / mag
1468
+ var nice
1469
+ if (residual <= 1) nice = 1 * mag
1470
+ else if (residual <= 2) nice = 2 * mag
1471
+ else if (residual <= 5) nice = 5 * mag
1472
+ else nice = 10 * mag
1473
+ var step = nice
1474
+ if (step < 1) step = 1
1475
+ var yMax = Math.ceil(m / step) * step
1476
+ var ticks = []
1477
+ for (var v = 0; v <= yMax + 1e-9; v += step) {
1478
+ var vv = Math.round(v / step) * step
1479
+ if (Math.abs(vv) < 1e-9) vv = 0
1480
+ ticks.push(vv)
1481
+ }
1482
+ if (ticks.length < 2) ticks = [0, yMax]
1483
+ if (ticks.length > 6) {
1484
+ step *= 2
1485
+ yMax = Math.ceil(m / step) * step
1486
+ ticks = []
1487
+ for (var v2 = 0; v2 <= yMax + 1e-9; v2 += step) {
1488
+ var vv2 = Math.round(v2 / step) * step
1489
+ if (Math.abs(vv2) < 1e-9) vv2 = 0
1490
+ ticks.push(vv2)
1491
+ }
1492
+ }
1493
+ return { yMax: yMax, ticks: ticks, step: step }
1494
+ }
1495
+ var scale = getYScale(max)
1496
+ var yMax = scale.yMax
1497
+ var ticks = scale.ticks
1498
+ var maxCalls = 0
1499
+ for (var ci = 0; ci < hourModels.length; ci++) {
1500
+ var hourCallCount = byHour[hourModels[ci].hour] ? Number(byHour[hourModels[ci].hour].calls) || 0 : 0
1501
+ if (hourCallCount > maxCalls) maxCalls = hourCallCount
1502
+ }
1503
+ var callYMax = getYScale(maxCalls).yMax
1504
+ var callY = function (calls) { return 6 + (1 - calls / callYMax) * 138 }
1505
+ var callBaseline = callY(0)
1506
+ var callPoints = hourModels.map(function (item, i) {
1507
+ var calls = byHour[item.hour] ? Number(byHour[item.hour].calls) || 0 : 0
1508
+ return ((i + 0.5) * 10) + ',' + callY(calls)
1509
+ }).join(' ')
1510
+ var yAxis = React.createElement('div', { className: 'dshus-yaxis' },
1511
+ ticks.map(function (v) {
1512
+ var top = yMax > 0 ? (1 - v / yMax) * 100 : 100
1513
+ return React.createElement('div', { className: 'dshus-yaxis-tick', key: 'y' + v, style: { top: top + '%' } }, v === 0 ? '0' : fmt(v))
1514
+ })
1515
+ )
1516
+ var grid = React.createElement('div', { className: 'dshus-grid' },
1517
+ ticks.map(function (v) {
1518
+ if (v === 0) return null
1519
+ var top = yMax > 0 ? (1 - v / yMax) * 100 : 100
1520
+ return React.createElement('div', { className: 'dshus-grid-line', key: 'g' + v, style: { top: top + '%' } })
1521
+ })
1522
+ )
1523
+ // One column per hour: the bar sits on top of its own label inside the
1524
+ // same flex column, so a bar can never be positioned under a different
1525
+ // hour's label (see the CSS comment for the old misalignment).
1526
+ var columns = hourModels.map(function (item, index) {
1527
+ var ms = item.models || []
1528
+ var tot = 0
1529
+ for (var j = 0; j < ms.length; j++) tot += ms[j].billed
1530
+ var empty = !(tot > 0)
1531
+ var h = yMax > 0 ? Math.round((tot / yMax) * 100) : 0
1532
+ var segs = ms.map(function (mm) {
1533
+ var sh = tot > 0 ? Math.round((mm.billed / tot) * 100) : 0
1534
+ return React.createElement('div', {
1535
+ className: 'dshus-bar-seg',
1536
+ key: mm.key,
1537
+ style: { height: sh + '%', background: colorOf ? colorOf(mm.key) : undefined },
1538
+ })
1539
+ })
1540
+ var bar = React.createElement('div', {
1541
+ className: 'dshus-bar',
1542
+ // Zero-usage hours keep the grid slot but render no visible bar.
1543
+ style: empty
1544
+ ? { height: 0, minHeight: 0, background: 'transparent' }
1545
+ : { height: grown ? Math.max(4, h) + '%' : '0%' },
1546
+ }, segs)
1547
+ return React.createElement('div', { className: 'dshus-hour', key: item.hour },
1548
+ React.createElement('div', { className: 'dshus-hour-bar' }, bar),
1549
+ React.createElement('div', { className: 'dshus-bar-label' }, daily
1550
+ ? ((hourModels.length <= 7 || index % 5 === 0 || index === hourModels.length - 1) ? item.date.slice(5).replace('-', '/') : '')
1551
+ : (item.hour % 3 === 0 || item.hour === 23 ? item.hour : '')),
1552
+ )
1553
+ })
1554
+ var callDots = hourModels.map(function (item, i) {
1555
+ var calls = byHour[item.hour] ? Number(byHour[item.hour].calls) || 0 : 0
1556
+ if (calls === 0) return null
1557
+ return React.createElement('div', {
1558
+ key: 'call-' + item.hour,
1559
+ className: 'dshus-call-dot',
1560
+ style: { left: ((i + 0.5) / hourModels.length * 100) + '%', top: callY(calls) + 'px', transform: 'translate(-50%, -50%) translateY(' + (grown ? 0 : callBaseline - callY(calls)) + 'px)' },
1561
+ })
1562
+ })
1563
+ var hitAreas = hourModels.map(function (item, i) {
1564
+ var bucket = byHour[item.hour] || {}
1565
+ return React.createElement('div', {
1566
+ key: 'hit-' + item.hour,
1567
+ className: 'dshus-hour-hit',
1568
+ role: 'gridcell',
1569
+ tabIndex: 0,
1570
+ 'aria-label': t('{time},{count} 次调用,{tokens} tokens', { time: daily ? item.date : t('{hour}时', { hour: item.hour }), count: bucket.calls || 0, tokens: fmt(bucket.billed || 0) }),
1571
+ onMouseMove: function (e) { showTip(e, i) },
1572
+ onFocus: function (e) {
1573
+ var rect = e.currentTarget.getBoundingClientRect()
1574
+ showTip({ clientX: rect.left, clientY: rect.bottom }, i)
1575
+ },
1576
+ onBlur: hideTip,
1577
+ })
1578
+ })
1579
+ return React.createElement('div', null,
1580
+ React.createElement('div', { className: 'dshus-bars-wrap' },
1581
+ yAxis,
1582
+ React.createElement('div', { className: 'dshus-chart-area' },
1583
+ grid,
1584
+ React.createElement('div', { className: 'dshus-bars' },
1585
+ React.createElement('div', { className: 'dshus-bars-content', ref: barsRef },
1586
+ columns,
1587
+ React.createElement('svg', { className: 'dshus-call-line', viewBox: '0 0 ' + hourModels.length * 10 + ' 150', preserveAspectRatio: 'none', 'aria-hidden': true },
1588
+ React.createElement('g', { className: 'dshus-call-path', style: { transformOrigin: '0 ' + callBaseline + 'px', transform: grown ? 'scaleY(1)' : 'scaleY(0)' } },
1589
+ React.createElement('polyline', { points: callPoints, fill: 'none', stroke: '#3b82f6', strokeWidth: 2, vectorEffect: 'non-scaling-stroke' }),
1590
+ ),
1591
+ ),
1592
+ callDots,
1593
+ tip !== null && React.createElement('div', { className: 'dshus-hover-line', style: { left: ((tip.index + 0.5) / hourModels.length * 100) + '%' } }),
1594
+ React.createElement('div', { className: 'dshus-hour-hit-area', role: 'grid', 'aria-label': daily ? t('每日Token用量与调用次数') : t('今日每小时用量与调用次数'), onMouseLeave: hideTip }, hitAreas),
1595
+ ),
1596
+ ),
1597
+ ),
1598
+ React.createElement('div', { className: 'dshus-call-axis', 'aria-label': t('调用次数') },
1599
+ React.createElement('span', { style: { top: '0%' } }, callYMax),
1600
+ React.createElement('span', { style: { top: '100%' } }, '0'),
1601
+ ),
1602
+ ),
1603
+ React.createElement('div', { className: 'dshus-call-axis-label' }, t('调用次数')),
1604
+ tipEl,
1605
+ )
1606
+ }
1607
+
1608
+ /* ------------------------------------------------------------------ */
1609
+ /* Daily token trend */
1610
+ /* ------------------------------------------------------------------ */
1611
+
1612
+ function DailyTrend(props) {
1613
+ var count = props.period === 'month' ? 30 : 7
1614
+ var today = Date.parse(new Date(Date.now() - props.tz * 60000).toISOString().slice(0, 10) + 'T00:00:00Z') / 86400000
1615
+ var dayModels = []
1616
+ var dayTotals = []
1617
+ for (var i = 0; i < count; i++) {
1618
+ var date = new Date((today - count + 1 + i) * 86400000).toISOString().slice(0, 10)
1619
+ var bucket = props.dayMap.get(date)
1620
+ var value = bucket ? bucket.billed || 0 : 0
1621
+ var models = props.dayModelMap.get(date) || []
1622
+ if (!models.length && value > 0) models = [{ key: '__all__', provider: '', model: t('全部'), billed: value }]
1623
+ // The daily breakdown is keyed by model alone, so the same model served by
1624
+ // two providers folds into one row instead of repeating under each provider.
1625
+ var merged = groupModelRows(models)
1626
+ dayModels.push({ hour: i, date: date, models: merged.length ? merged : models })
1627
+ dayTotals.push({ hour: i, date: date, billed: value, calls: bucket ? bucket.calls || 0 : 0 })
1628
+ }
1629
+ // Merged rows are keyed "by-model:<name>", which the provider-keyed colour
1630
+ // lookup does not know, so resolve those from the shared model palette.
1631
+ // The key never varies across days, so a model keeps one colour chart-wide.
1632
+ var trendColorOf = function (key) {
1633
+ if (key.indexOf('by-model:') !== 0) return props.colorOf(key)
1634
+ return sharedModelColor(key.slice('by-model:'.length))
1635
+ }
1636
+ // A fresh chart instance restarts the grow-in animation for every day
1637
+ // when switching between seven and thirty days.
1638
+ return React.createElement(Bars, { key: props.period, hourModels: dayModels, byHour: dayTotals, colorOf: trendColorOf, variant: 'daily' })
1639
+ }
1640
+
1641
+ /* ------------------------------------------------------------------ */
1642
+ /* Per-model donut chart */
1643
+ /* ------------------------------------------------------------------ */
1644
+
1645
+ /** Distinct, theme-agnostic hues for the model segments. */
1646
+ var DONUT_COLORS = sharedModelColors
1647
+
1648
+ function Donut(props) {
1649
+ var byModel = props.byModel || []
1650
+ var onSelect = props.onSelect
1651
+ var colorOf = props.colorOf
1652
+
1653
+ // ALL hooks run unconditionally, before any early return, so the hook
1654
+ // count stays identical across empty/non-empty renders.
1655
+ // Custom hover tooltip (replaces the ugly native one). Position follows
1656
+ // the cursor; content = model + tokens + share.
1657
+ var tipState = React.useState(null)
1658
+ var tip = tipState[0]
1659
+ var setTip = tipState[1]
1660
+ var tipKeyState = React.useState('')
1661
+ var tipKey = tipKeyState[0]
1662
+ var setTipKey = tipKeyState[1]
1663
+
1664
+ if (byModel.length === 0) return React.createElement('div', { className: 'dshus-muted' }, t('没有按模型聚合的数据'))
1665
+ var total = 0
1666
+ for (var i = 0; i < byModel.length; i++) total += byModel[i].billed
1667
+ if (!(total > 0)) return React.createElement('div', { className: 'dshus-muted' }, t('没有按模型聚合的数据'))
1668
+
1669
+ // Keep the chart readable: top 8 models + "其他" for the rest.
1670
+ var show = byModel.slice(0, 8)
1671
+ var restBilled = 0
1672
+ for (var r = 8; r < byModel.length; r++) restBilled += byModel[r].billed
1673
+ if (restBilled > 0) show.push({ key: '__rest__', provider: '', model: t('其他'), billed: restBilled })
1674
+
1675
+ var showTip = function (e, key) {
1676
+ setTipKey(key)
1677
+ var x = e.clientX + 14
1678
+ var y = e.clientY + 14
1679
+ if (x + 220 > window.innerWidth) x = e.clientX - 220
1680
+ setTip({ x: x, y: y })
1681
+ window.addEventListener('scroll', hideTip, { passive: true, once: true })
1682
+ }
1683
+ var hideTip = function () {
1684
+ setTip(null)
1685
+ setTipKey('')
1686
+ }
1687
+ var tipData = null
1688
+ for (var ti = 0; ti < show.length; ti++) {
1689
+ if (show[ti].key === tipKey) {
1690
+ tipData = show[ti]
1691
+ break
1692
+ }
1693
+ }
1694
+ var tipEl = null
1695
+ if (tip !== null && tipData) {
1696
+ var tipLabel = tipData.provider ? tipData.provider + ' · ' + tipData.model : tipData.model
1697
+ var tipPct = (tipData.billed / total) * 100
1698
+ tipEl = React.createElement('div', {
1699
+ className: 'dshus-tip',
1700
+ style: { left: tip.x, top: tip.y },
1701
+ },
1702
+ React.createElement('div', { className: 'dshus-tip-name' }, tipLabel),
1703
+ React.createElement('div', { className: 'dshus-tip-val' },
1704
+ fmt(tipData.billed) + ' tokens · ' + formatPct(tipPct)),
1705
+ )
1706
+ }
1707
+
1708
+ var SIZE = 168
1709
+ var R = 62
1710
+ var STROKE = 26
1711
+ var C = 2 * Math.PI * R
1712
+ var cumAngle = -90 // start at 12 o'clock
1713
+ var arcs = show.map(function (m, idx) {
1714
+ var frac = m.billed / total
1715
+ var arcLen = Math.max(frac * C - 2, 0.5)
1716
+ var el = React.createElement('circle', {
1717
+ key: m.key,
1718
+ cx: SIZE / 2,
1719
+ cy: SIZE / 2,
1720
+ r: R,
1721
+ fill: 'none',
1722
+ stroke: colorOf ? colorOf(m.key) : DONUT_COLORS[idx % DONUT_COLORS.length],
1723
+ strokeWidth: STROKE,
1724
+ strokeDasharray: arcLen + ' ' + (C - arcLen),
1725
+ transform: 'rotate(' + cumAngle + ' ' + (SIZE / 2) + ' ' + (SIZE / 2) + ')',
1726
+ style: { cursor: m.key === '__rest__' ? 'default' : 'pointer' },
1727
+ onMouseMove: function (e) { showTip(e, m.key) },
1728
+ onMouseLeave: hideTip,
1729
+ })
1730
+ cumAngle += frac * 360
1731
+ return el
1732
+ })
1733
+
1734
+ var legend = show.map(function (m, idx) {
1735
+ var pct = (m.billed / total) * 100
1736
+ var label = m.provider ? m.provider + ' · ' + m.model : m.model
1737
+ return React.createElement('div', {
1738
+ className: 'dshus-donut-row',
1739
+ key: m.key,
1740
+ onMouseMove: function (e) { showTip(e, m.key) },
1741
+ onMouseLeave: hideTip,
1742
+ onClick: m.key !== '__rest__' && onSelect ? function () { onSelect(m.key) } : undefined,
1743
+ },
1744
+ React.createElement('span', { className: 'dshus-donut-dot', style: { background: colorOf ? colorOf(m.key) : DONUT_COLORS[idx % DONUT_COLORS.length] } }),
1745
+ React.createElement('span', { className: 'dshus-donut-name' }, label),
1746
+ React.createElement('span', { className: 'dshus-donut-pct' }, formatPct(pct)),
1747
+ React.createElement('span', { className: 'dshus-donut-val' }, fmt(m.billed)),
1748
+ )
1749
+ })
1750
+
1751
+ return React.createElement('div', { className: 'dshus-donut' },
1752
+ React.createElement('div', { className: 'dshus-donut-wrap', style: { width: SIZE, height: SIZE } },
1753
+ React.createElement('svg', { width: SIZE, height: SIZE, viewBox: '0 0 ' + SIZE + ' ' + SIZE }, arcs),
1754
+ React.createElement('div', { className: 'dshus-donut-center' },
1755
+ React.createElement('div', { className: 'v' }, fmt(total)),
1756
+ React.createElement('div', { className: 'k' }, 'tokens'),
1757
+ ),
1758
+ ),
1759
+ React.createElement('div', { className: 'dshus-donut-legend' }, legend),
1760
+ tipEl,
1761
+ )
1762
+ }
1763
+
1764
+ function refreshIconButton(onClick, loading, label, alignEnd) {
1765
+ return React.createElement('button', {
1766
+ type: 'button',
1767
+ className: 'dshus-btn dshus-icon-btn' + (alignEnd ? ' dshus-heading-refresh' : ''),
1768
+ onClick: onClick,
1769
+ disabled: loading,
1770
+ title: label,
1771
+ 'aria-label': label,
1772
+ }, loading
1773
+ ? React.createElement('span', { className: 'dshus-spin', 'aria-hidden': 'true' })
1774
+ : React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
1775
+ React.createElement('path', { d: 'M20 7v5h-5M4 17v-5h5M5.5 9A7 7 0 0 1 18 7M18.5 15A7 7 0 0 1 6 17' }),
1776
+ ))
1777
+ }
1778
+
1779
+ // Keep the last successful response during refreshes and transient failures.
1780
+ // Request generations prevent a late background read from replacing a newer snapshot.
1781
+ function quotaFailed(body) { return !body || body.ok === false || body.status === 'error' }
1782
+
1783
+ function useQuotaData(loader, props) {
1784
+ var state = React.useState(null)
1785
+ var data = state[0]
1786
+ var setData = state[1]
1787
+ var loadingState = React.useState(false)
1788
+ var loading = loadingState[0]
1789
+ var setLoading = loadingState[1]
1790
+ var errorState = React.useState('')
1791
+ var error = errorState[0], setError = errorState[1]
1792
+ var requestRef = React.useRef(0)
1793
+ var busyRef = React.useRef(false)
1794
+ var loaderRef = React.useRef(loader)
1795
+ var pausedRef = React.useRef(false)
1796
+ loaderRef.current = loader
1797
+ pausedRef.current = props.active === false || props.refreshing === true
1798
+ function load(fresh) {
1799
+ if (busyRef.current || pausedRef.current) return
1800
+ var request = ++requestRef.current
1801
+ busyRef.current = true
1802
+ setLoading(true)
1803
+ setError('')
1804
+ return loaderRef.current(fresh).then(function (body) {
1805
+ if (request !== requestRef.current) return
1806
+ if (quotaFailed(body)) throw new Error(body && body.error || '操作失败,请稍后重试。')
1807
+ setData(body)
1808
+ }).catch(function (err) {
1809
+ if (request === requestRef.current) setError(String(err && err.message || err))
1810
+ }).finally(function () {
1811
+ if (request !== requestRef.current) return
1812
+ busyRef.current = false
1813
+ setLoading(false)
1814
+ })
1815
+ }
1816
+ React.useEffect(function () {
1817
+ if (props.active === false) return
1818
+ load(false)
1819
+ var timer = typeof setInterval === 'function' ? setInterval(function () { load(false) }, 30000) : null
1820
+ return function () {
1821
+ requestRef.current++
1822
+ busyRef.current = false
1823
+ if (timer !== null) clearInterval(timer)
1824
+ }
1825
+ }, [props.active, props.revision])
1826
+ React.useEffect(function () {
1827
+ if (props.snapshot === undefined) return
1828
+ requestRef.current++
1829
+ busyRef.current = false
1830
+ if (quotaFailed(props.snapshot)) {
1831
+ setError(String(props.snapshot && props.snapshot.error || '操作失败,请稍后重试。'))
1832
+ setLoading(false)
1833
+ return
1834
+ }
1835
+ setData(props.snapshot)
1836
+ setLoading(false)
1837
+ setError('')
1838
+ }, [props.snapshot])
1839
+ return { data: data, loading: loading || props.refreshing === true, error: error, load: load }
1840
+ }
1841
+
1842
+ function ProviderQuotasPanel(props) {
1843
+ var resource = useQuotaData(function (fresh) { return props.api.providerQuotas({ fresh: fresh }) }, props)
1844
+ var data = resource.data, loading = resource.loading, load = resource.load
1845
+ var quotas = data && data.ok === true && Array.isArray(data.quotas) ? data.quotas : []
1846
+ if (!quotas.length) return resource.error ? React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(resource.error)) : null
1847
+
1848
+ function isBalanceOnly(quota) {
1849
+ var metrics = Array.isArray(quota.metrics) ? quota.metrics : []
1850
+ return metrics.length ? metrics.every(function (metric) { return metric.kind === 'amount' }) : quota.kind === 'balance'
1851
+ }
1852
+ // Partition a copy so refreshing keeps each group's original provider order.
1853
+ quotas = quotas.filter(isBalanceOnly).concat(quotas.filter(function (quota) { return !isBalanceOnly(quota) }))
1854
+ return React.createElement('div', { className: 'dshus-provider-panels' }, resource.error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(resource.error)), quotas.map(function (quota) {
1855
+ var metrics = Array.isArray(quota.metrics) ? quota.metrics : []
1856
+ // 上次获取时间:同一张配额卡片内,所有指标共享同一个获取时刻。
1857
+ var fetchedLine = React.createElement('div', { className: 'dshus-fetched' },
1858
+ t('上次获取时间:{time}', { time: fmtFetched(quota.syncedAt) }))
1859
+ if (!metrics.length) {
1860
+ if (quota.status !== 'unqueried' && quota.status !== 'unavailable') return null
1861
+ var statusLabel = quota.status === 'unqueried' ? t('未查询') : t('未获取')
1862
+ return React.createElement('div', { key: quota.provider, className: 'dshus-module dshus-balance-module' },
1863
+ React.createElement('div', { className: 'dshus-balance-title' },
1864
+ React.createElement('h3', null, t(quota.name)),
1865
+ refreshIconButton(function () { load(true) }, loading, t('刷新 {name} 配额', { name: t(quota.name) }), false),
1866
+ ),
1867
+ React.createElement('div', { className: 'dshus-balance-side' },
1868
+ React.createElement('div', {
1869
+ className: 'dshus-balance-value dshus-balance-status',
1870
+ 'aria-label': t('{name} 配额{status}', { name: t(quota.name), status: statusLabel }),
1871
+ title: quota.status === 'unqueried' ? t('已在模型设置中配置,尚未查询配额。') : t('尚未获取到有效配额,可刷新重试。'),
1872
+ }, statusLabel),
1873
+ fetchedLine,
1874
+ ),
1875
+ )
1876
+ }
1877
+ var cards = []
1878
+ var windowRows = []
1879
+ metrics.forEach(function (metric, index) {
1880
+ var isWindow = metric.kind === 'window'
1881
+ var pct = isWindow ? Math.max(0, Math.min(100, Number(metric.remainingPercent))) : null
1882
+ var total = Number(metric.total)
1883
+ var remaining = Number(metric.remaining)
1884
+ var hasRatio = isWindow || (Number.isFinite(total) && total > 0 && Number.isFinite(remaining))
1885
+ if (!isWindow && hasRatio) pct = Math.max(0, Math.min(100, remaining / total * 100))
1886
+ var color = pct === null || pct >= 30 ? '#22c55e' : pct >= 10 ? '#f59e0b' : '#ef4444'
1887
+ var symbol = metric.currency === 'CNY' ? '¥' : metric.currency === 'USD' ? '$' : ''
1888
+ var value = isWindow ? Math.round(pct) + '%' : symbol + remaining.toFixed(2)
1889
+ var detail = isWindow && Number.isFinite(total) && total > 0 && Number.isFinite(remaining)
1890
+ ? fmtExact(remaining) + ' / ' + fmtExact(total)
1891
+ : !isWindow && Number.isFinite(total) && total > 0 ? t('上限 {amount}', { amount: symbol + total.toFixed(2) }) : ''
1892
+ if (isWindow) {
1893
+ var reset = metric.resetAt ? fmtReset(metric.resetAt) : ''
1894
+ windowRows.push(React.createElement('div', { key: metric.label + '-' + index, className: 'dshus-quota-row' },
1895
+ React.createElement('div', { className: 'dshus-quota-name' },
1896
+ React.createElement('span', { className: 'dshus-quota-label' }, quotaLabel(metric.label, quota.custom)),
1897
+ detail && React.createElement('span', { className: 'dshus-quota-detail' }, detail),
1898
+ ),
1899
+ React.createElement('div', {
1900
+ className: 'dshus-go-track',
1901
+ role: 'progressbar',
1902
+ 'aria-label': quotaLabel(metric.label, quota.custom),
1903
+ 'aria-valuemin': 0,
1904
+ 'aria-valuemax': 100,
1905
+ 'aria-valuenow': pct,
1906
+ 'aria-valuetext': t('剩余 {value}', { value: value }) + (detail ? ',' + detail : '') + (reset ? ',' + reset : ''),
1907
+ }, React.createElement('div', { className: 'dshus-go-fill', style: { width: pct + '%', background: color } })),
1908
+ React.createElement('span', { className: 'dshus-quota-pct', style: { color: color }, title: t('剩余 {value}', { value: formatPct(pct) }) }, value),
1909
+ React.createElement('span', { className: 'dshus-quota-reset', title: metric.resetAt ? t('重置时间:{time}', { time: fmtFetched(metric.resetAt) }) : undefined }, reset),
1910
+ ))
1911
+ return
1912
+ }
1913
+ // 余额的多值分栏保留数值、上限和比例。
1914
+ cards.push(React.createElement('div', { key: metric.label + '-' + index, className: 'dshus-stat' },
1915
+ React.createElement('div', { className: 'v', style: { color: isWindow ? color : undefined } }, value),
1916
+ React.createElement('div', { className: 'k' }, quotaLabel(metric.label, quota.custom)),
1917
+ hasRatio && React.createElement('div', { className: 'dshus-go-track' },
1918
+ React.createElement('div', { className: 'dshus-go-fill', style: { width: pct + '%', background: color } }),
1919
+ ),
1920
+ (detail || metric.resetAt) && React.createElement('div', { className: 'dshus-go-foot' },
1921
+ React.createElement('span', null, detail),
1922
+ React.createElement('span', { className: 'dshus-go-reset' }, metric.resetAt ? fmtReset(metric.resetAt) : ''),
1923
+ ),
1924
+ ))
1925
+ })
1926
+ var balance = metrics.length === 1 && metrics[0].kind === 'amount' && metrics[0].total == null
1927
+ ? metrics[0] : null
1928
+ if (balance) {
1929
+ var refreshBtn = refreshIconButton(function () { load(true) }, loading, t('刷新 {name} 配额', { name: t(quota.name) }), false)
1930
+ var balanceSymbol = balance.currency === 'CNY' ? '¥' : balance.currency === 'USD' ? '$' : ''
1931
+ return React.createElement('div', { key: quota.provider, className: 'dshus-module dshus-balance-module' },
1932
+ React.createElement('div', { className: 'dshus-balance-title' },
1933
+ React.createElement('h3', null, t(quota.name)),
1934
+ refreshBtn,
1935
+ ),
1936
+ React.createElement('div', { className: 'dshus-balance-side' },
1937
+ React.createElement('div', { className: 'dshus-balance-value', 'aria-label': quotaLabel(balance.label, quota.custom) + ' ' + balanceSymbol + Number(balance.remaining).toFixed(2) },
1938
+ balanceSymbol + Number(balance.remaining).toFixed(2)),
1939
+ fetchedLine,
1940
+ ),
1941
+ )
1942
+ }
1943
+ return React.createElement('div', { key: quota.provider, className: 'dshus-module' },
1944
+ React.createElement('div', { className: 'dshus-quota-heading' },
1945
+ React.createElement('h3', null, t(quota.name)),
1946
+ fetchedLine,
1947
+ refreshIconButton(function () { load(true) }, loading, t('刷新 {name} 配额', { name: t(quota.name) }), false),
1948
+ ),
1949
+ cards.length > 0 && React.createElement('div', { className: 'dshus-statbar dshus-quota-bar' }, cards),
1950
+ windowRows.length > 0 && React.createElement('div', { className: 'dshus-quota-rows' }, windowRows),
1951
+ )
1952
+ }))
1953
+ }
1954
+
1955
+ /* ------------------------------------------------------------------ */
1956
+ /* WorkBuddy quota panel (data from the dsh-workbuddy-connect plugin) */
1957
+ /* ------------------------------------------------------------------ */
1958
+
1959
+ /**
1960
+ * WorkBuddy and WorkBuddy AI account and credit boards. All data
1961
+ * comes from the dsh-workbuddy-connect plugin's own status routes,
1962
+ * which read the matching WorkBuddy
1963
+ * desktop sign-in and the live billing packages. Credit bars follow the
1964
+ * official quota-card convention: the lit part of the bar is the REMAINING
1965
+ * quota. Only a successful signed-in credit response is rendered.
1966
+ */
1967
+ function WorkBuddyPanel(props) {
1968
+ var api = props.api
1969
+ var source = props.source || 'workbuddy'
1970
+ var name = source === 'workbuddy-ai' ? 'WorkBuddy AI' : 'WorkBuddy'
1971
+
1972
+ // All hooks are declared unconditionally at the top (before any early
1973
+ // return below) so React's hook order never breaks.
1974
+ var resource = useQuotaData(function (fresh) { return api.workbuddyStatus({ fresh: fresh === true, source: source }) }, props)
1975
+ var wbData = resource.data, wbLoading = resource.loading, load = resource.load
1976
+ // Collapsed by default: only the in-use package (+ the next richest one)
1977
+ // is shown; the rest hide behind a toggle so a dozen small packages
1978
+ // don't stack the board.
1979
+ var wbExpandedState = React.useState(false)
1980
+ var wbExpanded = wbExpandedState[0]
1981
+ var setWbExpanded = wbExpandedState[1]
1982
+
1983
+ var connected = wbData !== null && wbData.ok !== false && wbData.status === 'signed-in' &&
1984
+ wbData.credits && Number.isFinite(Number(wbData.credits.total))
1985
+ if (!connected) {
1986
+ if (!wbData || wbData.available !== true) return resource.error ? React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(resource.error)) : null
1987
+ var statusLabel = wbData.status === 'unqueried' ? t('未查询') : wbData.status === 'signed-out' ? t('未登录') : t('未获取')
1988
+ return React.createElement('div', { className: 'dshus-module dshus-balance-module' },
1989
+ resource.error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(resource.error)),
1990
+ React.createElement('div', { className: 'dshus-balance-title' },
1991
+ React.createElement('h3', null, name),
1992
+ refreshIconButton(function () { load(true) }, wbLoading, t('刷新 {name} 账号与积分', { name: name }), false)),
1993
+ React.createElement('div', { className: 'dshus-balance-side' },
1994
+ React.createElement('div', { className: 'dshus-balance-value dshus-balance-status' }, statusLabel),
1995
+ React.createElement('div', { className: 'dshus-fetched' }, t('上次获取时间:{time}', { time: fmtFetched(wbData.syncedAt) }))))
1996
+ }
1997
+
1998
+ var refreshBtn = refreshIconButton(function () { load(true) }, wbLoading, t('刷新 {name} 账号与积分', { name: name }), false)
1999
+
2000
+ var contentEl
2001
+ {
2002
+ var cards = []
2003
+
2004
+ // Card 1: aggregated remaining credit over all billing packages.
2005
+ var credits = wbData.credits || null
2006
+ var accounts = credits && Array.isArray(credits.accounts) ? credits.accounts : []
2007
+ var active = accounts.filter(function (a) { return a && a.remain > 0 })
2008
+ if (credits) {
2009
+ cards.push(React.createElement('div', {
2010
+ key: '__total__',
2011
+ className: 'dshus-card dshus-go-card dshus-go-last',
2012
+ },
2013
+ React.createElement('div', { className: 'dshus-go-head' },
2014
+ React.createElement('div', { className: 'k' }, t('剩余积分合计')),
2015
+ React.createElement('div', { className: 'dshus-go-pct dshus-wb-total-value', style: { color: 'var(--dsw-alias-label-primary, #1f2430)' } },
2016
+ fmtExact(credits.total)),
2017
+ ),
2018
+ React.createElement('div', { className: 'dshus-go-foot' },
2019
+ React.createElement('span', null, t('有效套餐 {active} / {total} 个', { active: active.length, total: accounts.length })),
2020
+ ),
2021
+ ))
2022
+ }
2023
+ // One card per funded billing package: the lit part of the bar is the
2024
+ // REMAINING quota (official quota-card convention). Ordering: a
2025
+ // partially-consumed package (the one being used right now — billing
2026
+ // draws it down first) leads, then the richest untouched package;
2027
+ // duplicate package names stay separate. Only the first two are
2028
+ // rendered until the user expands the rest.
2029
+ function isPartlyUsed(a) {
2030
+ var s = Number(a.size) || 0
2031
+ var r = Number(a.remain) || 0
2032
+ return s > 0 && r > 0 && r < s
2033
+ }
2034
+ var sorted = active.slice().sort(function (a, b) {
2035
+ var au = isPartlyUsed(a) ? 0 : 1
2036
+ var bu = isPartlyUsed(b) ? 0 : 1
2037
+ if (au !== bu) return au - bu
2038
+ if (au === 0) return (Number(a.remain) || 0) - (Number(b.remain) || 0)
2039
+ return (Number(b.remain) || 0) - (Number(a.remain) || 0)
2040
+ })
2041
+ var visible = wbExpanded ? sorted : sorted.slice(0, 2)
2042
+ for (var ai = 0; ai < visible.length; ai++) {
2043
+ var acc = visible[ai]
2044
+ var size = Number(acc.size) || 0
2045
+ var remain = Number(acc.remain) || 0
2046
+ var remPct = size > 0 ? Math.max(0, Math.min(100, Math.round(remain / size * 100))) : 100
2047
+ var barColor = remPct >= 30 ? '#22c55e' : remPct >= 10 ? '#f59e0b' : '#ef4444'
2048
+ cards.push(React.createElement('div', {
2049
+ key: (acc.packageName || 'package') + '-' + ai,
2050
+ role: 'graphics-symbol',
2051
+ 'aria-label': t('{name} 剩余 {percent}%', { name: acc.packageName || t('套餐'), percent: remPct }),
2052
+ className: 'dshus-card dshus-go-card',
2053
+ },
2054
+ React.createElement('div', { className: 'dshus-go-head' },
2055
+ React.createElement('div', { className: 'k dshus-wb-name' }, acc.packageName || t('计费套餐')),
2056
+ isPartlyUsed(acc) ? React.createElement('span', { className: 'dshus-wb-inuse' }, t('使用中')) : null,
2057
+ React.createElement('div', { className: 'dshus-go-pct', style: { color: barColor } }, remPct + '%'),
2058
+ ),
2059
+ React.createElement('div', { className: 'dshus-go-track' },
2060
+ React.createElement('div', { className: 'dshus-go-fill', style: { width: remPct + '%', background: barColor } }),
2061
+ ),
2062
+ React.createElement('div', { className: 'dshus-go-foot' },
2063
+ React.createElement('span', null, size > 0
2064
+ ? t('{value} 积分', { value: fmtExact(remain) + ' / ' + fmtExact(size) })
2065
+ : t('{value} 积分', { value: fmtExact(remain) })),
2066
+ ),
2067
+ ))
2068
+ }
2069
+
2070
+ var body = []
2071
+ if (cards.length > 0) {
2072
+ body.push(React.createElement('div', { className: 'dshus-go-cards', key: '__cards__' }, cards))
2073
+ if (sorted.length > 2) {
2074
+ body.push(React.createElement('div', { style: { textAlign: 'center', marginTop: 10 }, key: '__wb-toggle__' },
2075
+ React.createElement('button', {
2076
+ type: 'button',
2077
+ className: 'dshus-btn',
2078
+ onClick: function () { setWbExpanded(!wbExpanded) },
2079
+ style: { fontSize: 12, height: 28, padding: '0 16px', margin: '0 auto' },
2080
+ }, wbExpanded ? t('收起其余套餐') : t('展开其余 {count} 个套餐', { count: sorted.length - 2 })),
2081
+ ))
2082
+ }
2083
+ }
2084
+ // Model offers (free / promo-priced), same catalog the plugin card shows.
2085
+ var offers = Array.isArray(wbData.models) ? wbData.models.filter(function (m) {
2086
+ return m && (m.free === true || (Array.isArray(m.badges) && m.badges.length > 0))
2087
+ }) : []
2088
+ if (offers.length > 0) {
2089
+ var chips = offers.map(function (m) {
2090
+ var tags = []
2091
+ if (m.free === true) tags.push(t('免费'))
2092
+ if (Array.isArray(m.badges)) tags = tags.concat(m.badges.map(function (badge) { return t(badge) }))
2093
+ return React.createElement('span', { className: 'dshus-wb-chip', key: m.id || m.name },
2094
+ React.createElement('span', null, m.name || m.id),
2095
+ m.credits !== undefined ? React.createElement('span', { className: 'rate' }, t('{value} 积分/次', { value: m.credits })) : null,
2096
+ tags.map(function (tg) { return React.createElement('span', { className: 'tag', key: tg }, tg) }),
2097
+ )
2098
+ })
2099
+ body.push(React.createElement('div', { className: 'dshus-wb-chips', key: '__models__' },
2100
+ React.createElement('span', { className: 'dshus-wb-chips-label' }, t('模型优惠')),
2101
+ chips,
2102
+ ))
2103
+ }
2104
+ contentEl = React.createElement(React.Fragment, null, body)
2105
+ }
2106
+
2107
+ return React.createElement('div', { className: 'dshus-module dshus-wb-module' },
2108
+ resource.error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(resource.error)),
2109
+ React.createElement('div', { className: 'dshus-quota-heading' },
2110
+ React.createElement('h3', null, name),
2111
+ React.createElement('div', { className: 'dshus-fetched' }, t('上次获取时间:{time}', { time: fmtFetched(wbData.syncedAt) })),
2112
+ refreshBtn,
2113
+ ),
2114
+ contentEl,
2115
+ )
2116
+ }
2117
+
2118
+ /** Select-only combobox: real status dots, with focus kept on the trigger. */
2119
+ function ProviderPicker(props) {
2120
+ var openState = React.useState(false)
2121
+ var open = openState[0], setOpen = openState[1]
2122
+ var activeState = React.useState(0)
2123
+ var active = activeState[0], setActive = activeState[1]
2124
+ var placementState = React.useState({ above: false, height: 260 })
2125
+ var placement = placementState[0], setPlacement = placementState[1]
2126
+ var rootRef = React.useRef(null)
2127
+ var listRef = React.useRef(null)
2128
+ var searchRef = React.useRef({ text: '', at: 0 })
2129
+ var options = props.options
2130
+ var selected = options.find(function (item) { return item.value === props.value }) || options[0]
2131
+ var activeIndex = Math.max(0, Math.min(active, options.length - 1))
2132
+ var listId = props.id + '-list'
2133
+
2134
+ function show(index) {
2135
+ if (props.disabled) return
2136
+ var root = rootRef.current
2137
+ if (root) {
2138
+ var rect = root.getBoundingClientRect()
2139
+ var top = 0, bottom = window.innerHeight
2140
+ for (var parent = root.parentElement; parent; parent = parent.parentElement) {
2141
+ if (/(auto|scroll|hidden|clip)/.test(window.getComputedStyle(parent).overflowY)) {
2142
+ var bounds = parent.getBoundingClientRect()
2143
+ top = Math.max(top, bounds.top)
2144
+ bottom = Math.min(bottom, bounds.bottom)
2145
+ }
2146
+ }
2147
+ var page = root.closest('.dshus-page')
2148
+ var toolbar = page && page.querySelector('.dshus-toolbar')
2149
+ if (toolbar) top = Math.max(top, toolbar.getBoundingClientRect().bottom)
2150
+ var below = bottom - rect.bottom - 8
2151
+ var above = rect.top - top - 8
2152
+ var flip = below < 200 && above > below
2153
+ setPlacement({ above: flip, height: Math.max(34, Math.min(260, flip ? above : below)) })
2154
+ }
2155
+ setActive(index == null ? Math.max(0, options.indexOf(selected)) : index)
2156
+ searchRef.current = { text: '', at: 0 }
2157
+ setOpen(true)
2158
+ }
2159
+ function choose(index) {
2160
+ if (props.disabled) return
2161
+ setOpen(false)
2162
+ props.onChange(options[index].value)
2163
+ }
2164
+ function onKeyDown(event) {
2165
+ if (props.disabled) return
2166
+ var key = event.key
2167
+ if (key === 'Escape' && open) {
2168
+ event.preventDefault()
2169
+ event.stopPropagation()
2170
+ setOpen(false)
2171
+ } else if (key === 'Tab') setOpen(false)
2172
+ else if (key === 'Enter' || key === ' ') {
2173
+ event.preventDefault()
2174
+ if (open) choose(activeIndex)
2175
+ else show()
2176
+ } else if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
2177
+ event.preventDefault()
2178
+ var index = key === 'Home' ? 0 : key === 'End' ? options.length - 1 : Math.max(0, Math.min(options.length - 1, activeIndex + (key === 'ArrowDown' ? 1 : -1)))
2179
+ if (open) setActive(index)
2180
+ else show(key === 'Home' || key === 'End' ? index : null)
2181
+ } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
2182
+ event.preventDefault()
2183
+ var now = Date.now()
2184
+ var search = open && now - searchRef.current.at < 700 ? searchRef.current.text + key.toLowerCase() : key.toLowerCase()
2185
+ if (search.split('').every(function (char) { return char === search[0] })) search = search[0]
2186
+ searchRef.current = { text: search, at: now }
2187
+ var start = search.length > 1 ? activeIndex : activeIndex + 1
2188
+ for (var offset = 0; offset < options.length; offset++) {
2189
+ var match = (start + offset) % options.length
2190
+ if (options[match].name.toLowerCase().startsWith(search)) {
2191
+ if (open) setActive(match)
2192
+ else { show(match); searchRef.current = { text: search, at: now } }
2193
+ break
2194
+ }
2195
+ }
2196
+ }
2197
+ }
2198
+ React.useEffect(function () {
2199
+ if (!open) return
2200
+ function outside(event) { if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false) }
2201
+ function scroll(event) { if (listRef.current && !listRef.current.contains(event.target)) setOpen(false) }
2202
+ function close() { setOpen(false) }
2203
+ document.addEventListener('pointerdown', outside)
2204
+ document.addEventListener('scroll', scroll, true)
2205
+ window.addEventListener('resize', close)
2206
+ return function () {
2207
+ document.removeEventListener('pointerdown', outside)
2208
+ document.removeEventListener('scroll', scroll, true)
2209
+ window.removeEventListener('resize', close)
2210
+ }
2211
+ }, [open])
2212
+ React.useEffect(function () {
2213
+ // Scroll only the menu. scrollIntoView() can also move the settings pane.
2214
+ var list = listRef.current
2215
+ var item = list && list.children[activeIndex]
2216
+ if (!open || !item) return
2217
+ if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop
2218
+ else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight
2219
+ }, [open, activeIndex])
2220
+ React.useEffect(function () { if (props.disabled) setOpen(false) }, [props.disabled])
2221
+
2222
+ function dot(source) { return source ? React.createElement('span', { className: 'dshus-provider-dot', 'aria-hidden': 'true' }) : null }
2223
+ return React.createElement('div', { className: 'dshus-provider-picker', ref: rootRef },
2224
+ React.createElement('button', { id: props.id, type: 'button', role: 'combobox', className: 'dshus-field dshus-query-select', disabled: props.disabled, 'aria-labelledby': props.labelId + ' ' + props.id + '-value', 'aria-haspopup': 'listbox', 'aria-expanded': open, 'aria-controls': open ? listId : undefined, 'aria-activedescendant': open ? listId + '-' + activeIndex : undefined, onClick: function () { if (open) setOpen(false); else show() }, onKeyDown: onKeyDown },
2225
+ React.createElement('span', { id: props.id + '-value', className: 'dshus-provider-name' }, selected.name),
2226
+ dot(selected.source),
2227
+ React.createElement('svg', { className: 'dshus-provider-chevron', viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': 'true' }, React.createElement('path', { d: 'm4 6 4 4 4-4' }))),
2228
+ open && React.createElement('div', { id: listId, role: 'listbox', 'aria-labelledby': props.labelId, className: 'dshus-provider-list' + (placement.above ? ' above' : ''), style: { maxHeight: placement.height }, ref: listRef },
2229
+ options.map(function (item, index) {
2230
+ // A stationary cursor must not override keyboard navigation as rows scroll past it.
2231
+ return React.createElement('div', { key: item.value, id: listId + '-' + index, role: 'option', 'data-value': item.value, 'aria-selected': item.value === props.value, 'aria-label': item.name + (item.source ? ',' + item.source : ''), title: item.source || undefined, className: 'dshus-provider-option' + (index === activeIndex ? ' active' : ''), onMouseMove: function () { setActive(index) }, onMouseDown: function (event) { event.preventDefault() }, onClick: function () { choose(index) } },
2232
+ React.createElement('span', { className: 'dshus-provider-name' }, item.name), dot(item.source))
2233
+ })),
2234
+ )
2235
+ }
2236
+
2237
+ /**
2238
+ * The template schema, written as an instruction an assistant can act on.
2239
+ * Kept as a plain string so the whole thing can go to the clipboard verbatim:
2240
+ * pasting it into any chat assistant should produce a paste-ready template
2241
+ * without the user having to explain the schema themselves.
2242
+ */
2243
+ function buildTemplatePrompt(context) {
2244
+ var name = (context && context.name) || t('该供应商')
2245
+ var key = (context && context.provider) || ''
2246
+ var existing = (context && context.template) || null
2247
+ return [
2248
+ t('请帮我为「{name}」写一份 DSH 用量统计插件的余额查询模板(JSON)。', { name: name }),
2249
+ '',
2250
+ t('【输出要求】'),
2251
+ t('只输出一个 JSON 对象,不要解释、不要 markdown 代码块围栏。'),
2252
+ '',
2253
+ t('【获取接口信息的方式】'),
2254
+ t('请先自己联网搜索「{name}」的余额 / 用量查询接口文档,不要一上来就找我要资料。', { name: name }),
2255
+ t('优先按下面的顺序查:官方 API 文档 → 余额或 billing / usage 接口说明 → 社区示例或 SDK 源码。'),
2256
+ t('如果能查到明确的接口地址与返回字段,直接据此写出模板。'),
2257
+ t('只有在确实查不到、或查到多个互相冲突的结果时,才向我提问;提问要具体,一次只问最关键的一点。'),
2258
+ t('如果你搜索到的是猜测而非确定的字段路径,请在回答里简要注明依据,方便我核对。'),
2259
+ '',
2260
+ t('【字段说明】'),
2261
+ t('url:查询地址,必须与下面 auth 所用凭据同源。'),
2262
+ t('method:GET 或 POST。'),
2263
+ t('auth:"provider" 表示复用该供应商已保存的凭据;"none" 表示不需要认证。'),
2264
+ t('headers:可选,附加请求头对象。'),
2265
+ t('body:可选,POST 时发送的请求体对象。'),
2266
+ t('response.rows:可选。当余额在数组里时填写该数组的字段路径,例如 "data.list"。'),
2267
+ t('response.metrics:指标数组,每项包含:'),
2268
+ t(' label:界面显示的名称,例如 "账户可用余额"。'),
2269
+ t(' kind:"amount" 表示金额,"window" 表示带重置时间的额度窗口。'),
2270
+ t(' remaining:金额型指标在响应中的字段路径,例如 "data.balance"。'),
2271
+ t(' usedPercent / remainingPercent:额度窗口型指标使用,取值为百分比字段路径。'),
2272
+ t(' currency:可选,金额单位,如 "CNY" 或 "USD"。'),
2273
+ '',
2274
+ t('【取值规则】'),
2275
+ t('字段路径用点号表示层级,例如 data.balance_infos.0.total_balance。'),
2276
+ t('数字字符串也要能直接使用,不要额外包装。'),
2277
+ '',
2278
+ t('【参考格式】'),
2279
+ '{',
2280
+ ' "url": "https://api.example.com/user/balance",',
2281
+ ' "method": "GET",',
2282
+ ' "auth": "provider",',
2283
+ ' "response": {',
2284
+ ' "metrics": [',
2285
+ ' { "label": ' + JSON.stringify(t('账户可用余额')) + ', "kind": "amount", "remaining": "data.balance", "currency": "CNY" }',
2286
+ ' ]',
2287
+ ' }',
2288
+ '}',
2289
+ '',
2290
+ t('【当前情况】'),
2291
+ t('供应商 ID:{provider}', { provider: key || t('(未指定)') }),
2292
+ existing
2293
+ ? t('现有模板(可在此基础上修正):\n') + JSON.stringify(existing, null, 2)
2294
+ : t('该供应商目前没有模板,请从零编写。'),
2295
+ ].join('\n')
2296
+ }
2297
+
2298
+ /** Settings use the host store so timers also work while this page is closed. */
2299
+ function ControlsPanel(props) {
2300
+ var control = props.control
2301
+ var settings = control.settings
2302
+ // Opt-in: a host that predates the setting has no key at all.
2303
+ var advancedModelSelect = settings.advancedModelSelect === true
2304
+ var providersState = React.useState([])
2305
+ var providers = providersState[0], setProviders = providersState[1]
2306
+ var providerState = React.useState('')
2307
+ var provider = providerState[0], setProvider = providerState[1]
2308
+ var templateState = React.useState('')
2309
+ var templateText = templateState[0], setTemplateText = templateState[1]
2310
+ var confirmState = React.useState(false)
2311
+ var confirmAuto = confirmState[0], setConfirmAuto = confirmState[1]
2312
+ var savingState = React.useState(false)
2313
+ var saving = savingState[0], setSaving = savingState[1]
2314
+ var testingState = React.useState(false)
2315
+ var testing = testingState[0], setTesting = testingState[1]
2316
+ var previewState = React.useState(null)
2317
+ var preview = previewState[0], setPreview = previewState[1]
2318
+ var errorState = React.useState('')
2319
+ var error = errorState[0], setError = errorState[1]
2320
+ var statusState = React.useState('')
2321
+ var status = statusState[0], setStatus = statusState[1]
2322
+ var providerLoadingState = React.useState(true)
2323
+ var providerLoading = providerLoadingState[0], setProviderLoading = providerLoadingState[1]
2324
+ var copiedState = React.useState('')
2325
+ var copied = copiedState[0], setCopied = copiedState[1]
2326
+ var testRef = React.useRef(0)
2327
+
2328
+ function loadProviders() {
2329
+ setProviderLoading(true)
2330
+ props.api.quotaProviders().then(function (body) {
2331
+ setProviders(body.providers || [])
2332
+ setProviderLoading(false)
2333
+ }).catch(function (err) { setError(String(err.message || err)); setProviderLoading(false) })
2334
+ }
2335
+ React.useEffect(function () { loadProviders(); return function () { testRef.current++ } }, [])
2336
+
2337
+ function save(patch, message) {
2338
+ setSaving(true)
2339
+ setError('')
2340
+ setStatus('')
2341
+ return props.onSave(patch).then(function () {
2342
+ setSaving(false)
2343
+ setStatus(message || '设置已保存')
2344
+ if (patch.autoQuota !== undefined) setConfirmAuto(false)
2345
+ }).catch(function (err) { setSaving(false); setError(String(err.message || err)) })
2346
+ }
2347
+ function editTemplate(text) {
2348
+ testRef.current++
2349
+ setTemplateText(text)
2350
+ setPreview(null)
2351
+ setTesting(false)
2352
+ setStatus('')
2353
+ setError('')
2354
+ }
2355
+ function selectProvider(id) {
2356
+ setProvider(id)
2357
+ var saved = settings.customQueries.find(function (query) { return query.provider === id })
2358
+ var item = providers.find(function (entry) { return entry.provider === id })
2359
+ editTemplate(item && item.queryType === 'adapter' ? '' : saved || item ? JSON.stringify(saved ? saved.template : item.template, null, 2) : '')
2360
+ }
2361
+ /**
2362
+ * Copy the ready-made prompt. The clipboard API needs a secure context and
2363
+ * permission, so a rejection falls back to a hidden textarea + execCommand
2364
+ * and, if that also fails, tells the user to select the text by hand rather
2365
+ * than silently doing nothing.
2366
+ */
2367
+ function copyPrompt() {
2368
+ var text = buildTemplatePrompt({ provider: provider, name: promptProviderName, template: promptTemplate })
2369
+ function done() { setCopied('已复制,粘贴到任意 AI 对话即可'); setTimeout(function () { setCopied('') }, 4000) }
2370
+ function failed() { setCopied('复制失败,请手动选中下方提示词'); setTimeout(function () { setCopied('') }, 4000) }
2371
+ function fallback() {
2372
+ try {
2373
+ var area = document.createElement('textarea')
2374
+ area.value = text
2375
+ area.setAttribute('readonly', '')
2376
+ area.style.position = 'fixed'
2377
+ area.style.top = '-1000px'
2378
+ document.body.appendChild(area)
2379
+ area.select()
2380
+ var ok = document.execCommand('copy')
2381
+ document.body.removeChild(area)
2382
+ if (ok) done(); else failed()
2383
+ } catch (e) { failed() }
2384
+ }
2385
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2386
+ navigator.clipboard.writeText(text).then(done).catch(fallback)
2387
+ } else fallback()
2388
+ }
2389
+ function refreshAdapter() {
2390
+ var item = providers.find(function (entry) { return entry.provider === provider })
2391
+ if (!item || item.queryType !== 'adapter') return
2392
+ var current = ++testRef.current
2393
+ setTesting(true)
2394
+ setError('')
2395
+ setStatus('')
2396
+ props.api.quotaSource(item.source, { fresh: true }).then(function (body) {
2397
+ if (current !== testRef.current) return
2398
+ setTesting(false)
2399
+ setStatus(body.status === 'signed-in' ? '查询已更新,可在配额页查看。' : body.status === 'signed-out' ? '请先在 WorkBuddy 中登录。' : '尚未获取到配额,请检查扩展是否启用后重试。')
2400
+ }).catch(function (err) {
2401
+ if (current !== testRef.current) return
2402
+ setTesting(false)
2403
+ setError(String(err.message || err))
2404
+ })
2405
+ }
2406
+ function testTemplate() {
2407
+ var template
2408
+ try { template = JSON.parse(templateText) } catch (e) { setError('模板不是有效 JSON,请检查引号、逗号和括号。'); return }
2409
+ var current = ++testRef.current
2410
+ setTesting(true)
2411
+ setPreview(null)
2412
+ setError('')
2413
+ setStatus('')
2414
+ props.api.testQuota(provider, template).then(function (body) {
2415
+ if (current !== testRef.current) return
2416
+ setTesting(false)
2417
+ setPreview({ result: body, text: templateText, provider: provider })
2418
+ }).catch(function (err) {
2419
+ if (current !== testRef.current) return
2420
+ setTesting(false)
2421
+ setError(String(err.message || err))
2422
+ })
2423
+ }
2424
+ function switchButton(label, checked, callback) {
2425
+ return React.createElement('button', { type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': checked, className: 'dshus-switch' + (checked ? ' on' : ''), disabled: saving || !props.ready, onClick: callback })
2426
+ }
2427
+ // Kept next to the option list it describes: the status line below the
2428
+ // select reads the same value the selected <option> shows.
2429
+ function intervalLabel(minutes) {
2430
+ switch (Number(minutes)) {
2431
+ case 10: return t('10 分钟')
2432
+ case 300: return t('5 小时')
2433
+ case 1440: return t('每天')
2434
+ default: return t('1 小时')
2435
+ }
2436
+ }
2437
+ var validPreview = preview && preview.text === templateText && preview.provider === provider
2438
+ var selected = providers.find(function (entry) { return entry.provider === provider })
2439
+ // The prompt describes the provider the user is looking at, so it borrows
2440
+ // the live template when one is loaded and stays generic otherwise.
2441
+ var promptProviderName = selected ? selected.name : provider
2442
+ var promptTemplate = null
2443
+ if (templateText.trim()) {
2444
+ try { promptTemplate = JSON.parse(templateText) } catch (e) { promptTemplate = null }
2445
+ }
2446
+ return React.createElement('div', null,
2447
+ // The settings-load failure is not about one query, so it stays above both
2448
+ // modules; a query failure is reported inside the query module instead.
2449
+ props.error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(props.error),
2450
+ React.createElement('button', { type: 'button', className: 'dshus-btn', onClick: props.onReload }, t('重试'))),
2451
+ React.createElement('div', { className: 'dshus-module' },
2452
+ React.createElement('div', { className: 'dshus-setting-row' },
2453
+ React.createElement('div', null,
2454
+ React.createElement('span', { className: 'dshus-setting-label' }, t('自动获取剩余余额'))),
2455
+ switchButton(t('自动获取剩余余额'), settings.autoQuota, function () { if (settings.autoQuota) save({ autoQuota: false }); else setConfirmAuto(true) }),
2456
+ ),
2457
+ confirmAuto && React.createElement('div', { className: 'dshus-confirm', role: 'alert', 'aria-label': t('自动查询费用提醒') },
2458
+ React.createElement('p', null, t('自动查询可能会消耗少量余额,具体取决于供应商的计费规则。确认开启后,将立即获取一次,之后按所选间隔更新。')),
2459
+ React.createElement('div', { className: 'dshus-control-actions' },
2460
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving, onClick: function () { save({ autoQuota: true, acknowledgeCost: true }) } }, t('确认开启')),
2461
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving, onClick: function () { setConfirmAuto(false) } }, t('取消'))),
2462
+ ),
2463
+ React.createElement('div', { className: 'dshus-setting-row' },
2464
+ React.createElement('label', { id: 'dshus-auto-interval-label', htmlFor: 'dshus-auto-interval' },
2465
+ React.createElement('span', { className: 'dshus-setting-label' }, t('自动获取间隔'))),
2466
+ React.createElement('select', { id: 'dshus-auto-interval', className: 'dshus-field dshus-select', value: settings.intervalMinutes, disabled: saving || !props.ready, 'aria-labelledby': 'dshus-auto-interval-label', onChange: function (event) { save({ intervalMinutes: Number(event.target.value) }) } },
2467
+ [[10, t('10 分钟')], [60, t('1 小时')], [300, t('5 小时')], [1440, t('每天')]].map(function (option) { return React.createElement('option', { key: option[0], value: option[0] }, option[1]) })),
2468
+ ),
2469
+ // The select no longer carries a help line, so the row is followed by a
2470
+ // single status line: it names the next run once automatic fetching is
2471
+ // armed, and reads back the chosen interval otherwise.
2472
+ control.nextRefreshAt
2473
+ ? React.createElement('p', { className: 'dshus-control-status', role: 'status' }, t('下次获取:{time} · 当前设置:{interval}', { time: fmtInTz(control.nextRefreshAt, LOCAL_TZ), interval: intervalLabel(settings.intervalMinutes) }))
2474
+ : React.createElement('p', { className: 'dshus-control-status', role: 'status' }, t('当前设置:{interval}', { interval: intervalLabel(settings.intervalMinutes) })),
2475
+ React.createElement('div', { className: 'dshus-setting-row' },
2476
+ React.createElement('div', null,
2477
+ React.createElement('span', { className: 'dshus-setting-label' }, t('模型明细与分布'))),
2478
+ switchButton(t('模型明细与分布'), settings.showModelDetails, function () { save({ showModelDetails: !settings.showModelDetails }) }),
2479
+ ),
2480
+ React.createElement('div', { className: 'dshus-setting-row' },
2481
+ React.createElement('div', null,
2482
+ React.createElement('span', { className: 'dshus-setting-label' }, t('高级模型选择器'))),
2483
+ switchButton(t('高级模型选择器'), advancedModelSelect, function () {
2484
+ save({ advancedModelSelect: !advancedModelSelect }, advancedModelSelect ? '已关闭,已恢复官方模型选择器' : '已开启高级模型选择器')
2485
+ }),
2486
+ ),
2487
+ ),
2488
+ React.createElement('div', { className: 'dshus-module' },
2489
+ React.createElement('h3', null, t('配额查询'), refreshIconButton(loadProviders, providerLoading, t('刷新供应商列表'), true)),
2490
+ React.createElement('label', { id: 'dshus-query-provider-label', className: 'dshus-setting-label', htmlFor: 'dshus-query-provider' }, t('查询来源')),
2491
+ React.createElement(ProviderPicker, { id: 'dshus-query-provider', labelId: 'dshus-query-provider-label', value: provider, disabled: saving || providerLoading || !props.ready, onChange: selectProvider, options: [{ value: '', name: providerLoading ? t('正在加载供应商…') : t('选择已有供应商') }].concat(providers.map(function (item) {
2492
+ var custom = settings.customQueries.some(function (query) { return query.provider === item.provider })
2493
+ var source = custom ? t('已保存自定义查询') : item.hasBuiltinQuery === true ? item.queryType === 'adapter' ? t('内置扩展查询') : t('内置供应商查询') : ''
2494
+ return { value: item.provider, name: t(item.name), source: source }
2495
+ })) }),
2496
+ !providerLoading && !providers.length && React.createElement('p', { className: 'dshus-setting-help' }, t('尚无可选来源,请先添加模型供应商或启用支持的扩展。')),
2497
+ // With no provider chosen there is still a failure worth showing here,
2498
+ // e.g. loading the provider list itself.
2499
+ !provider && error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(error)),
2500
+ selected && selected.queryType === 'adapter' && React.createElement('div', { className: 'dshus-control-actions' },
2501
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving || testing, onClick: refreshAdapter }, testing ? t('正在查询…') : t('查询配额')),
2502
+ error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(error))),
2503
+ provider && (!selected || selected.queryType !== 'adapter') && React.createElement(React.Fragment, null,
2504
+ React.createElement('div', { className: 'dshus-control-actions' },
2505
+ React.createElement('label', { className: 'dshus-setting-label', htmlFor: 'dshus-query-template' }, t('查询模板(JSON)')),
2506
+ selected && React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving, onClick: function () { editTemplate(JSON.stringify(selected.template, null, 2)) } }, t('重置模板'))),
2507
+ React.createElement('textarea', { id: 'dshus-query-template', className: 'dshus-field dshus-query-editor', value: templateText, spellCheck: false, disabled: saving, onChange: function (event) { editTemplate(event.target.value) } }),
2508
+ // The old one-line schema description told the user what to write but
2509
+ // still left them to write it. This hands them a prompt to paste into
2510
+ // an AI instead, with the provider and current template already filled
2511
+ // in, and the text is selectable when the clipboard is unavailable.
2512
+ React.createElement('div', { className: 'dshus-prompt' },
2513
+ React.createElement('div', { className: 'dshus-prompt-head' },
2514
+ React.createElement('span', { className: 'dshus-prompt-title' }, t('用 AI 生成模板')),
2515
+ React.createElement('button', { type: 'button', className: 'dshus-btn dshus-prompt-copy', onClick: copyPrompt }, t('复制提示词'))),
2516
+ React.createElement('pre', { className: 'dshus-prompt-body', tabIndex: 0, 'aria-label': t('可复制的模板生成提示词') }, buildTemplatePrompt({ provider: provider, name: promptProviderName, template: promptTemplate })),
2517
+ copied && React.createElement('p', { className: 'dshus-prompt-note', role: 'status' }, t(copied))),
2518
+ React.createElement('div', { className: 'dshus-control-actions' },
2519
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving || testing || !templateText.trim(), onClick: testTemplate }, testing ? t('正在测试…') : t('测试查询')),
2520
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving || testing || !validPreview, onClick: function () { save({ action: 'save-query', provider: provider, template: JSON.parse(templateText), testId: preview.result.testId }, '已保存,可在配额页查看此供应商。') } }, saving ? t('正在保存…') : t('确认并显示'))),
2521
+ // The cost warning stays last, directly under the buttons that can
2522
+ // spend balance, so it is read right before the user acts.
2523
+ React.createElement('p', { className: 'dshus-cost-note' }, t('测试会发起一次请求,可能消耗少量余额。')),
2524
+ // A failed query belongs with the controls that produced it, so the
2525
+ // message sits inside this module rather than at the top of the page.
2526
+ error && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(error)),
2527
+ validPreview && React.createElement('div', { className: 'dshus-query-preview', role: 'status' },
2528
+ React.createElement('strong', null, t('测试成功 · {name}', { name: t(preview.result.quota.name) })),
2529
+ preview.result.quota.metrics.map(function (metric, index) { return React.createElement('div', { key: index }, metric.label + ':' + (metric.kind === 'window' ? Math.round(metric.remainingPercent * 100) / 100 + '%' : (metric.currency === 'CNY' ? '¥' : metric.currency === 'USD' ? '$' : '') + fmtExact(metric.remaining))) })),
2530
+ ),
2531
+ settings.customQueries.length > 0 && React.createElement('div', { className: 'dshus-query-preview' },
2532
+ React.createElement('strong', null, t('已启用的自定义查询')),
2533
+ settings.customQueries.map(function (query) {
2534
+ var item = providers.find(function (entry) { return entry.provider === query.provider })
2535
+ return React.createElement('div', { key: query.provider, className: 'dshus-saved-query' },
2536
+ React.createElement('span', null, item ? item.name : query.provider),
2537
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving, onClick: function () { selectProvider(query.provider) } }, t('编辑')),
2538
+ React.createElement('button', { type: 'button', className: 'dshus-btn', disabled: saving, onClick: function () { save({ action: 'remove-query', provider: query.provider }, '已移除自定义查询,恢复内置配额查询。') } }, t('移除')))
2539
+ })),
2540
+ ),
2541
+ React.createElement('div', { className: 'dshus-control-status', role: 'status' }, !props.ready && !props.error ? t('正在读取设置…') : t(status)),
2542
+ )
2543
+ }
2544
+
2545
+ /* ------------------------------------------------------------------ */
2546
+ /* Settings section page */
2547
+ /* ------------------------------------------------------------------ */
2548
+
2549
+ function UsageStatsSection(props) {
2550
+ useLocaleRevision()
2551
+ var api = props.api
2552
+ var intl = props.intl
2553
+ var modelState = React.useState('')
2554
+ var model = modelState[0]
2555
+ var setModel = modelState[1]
2556
+
2557
+ var heatModeState = React.useState('daily')
2558
+ var heatMode = heatModeState[0]
2559
+ var setHeatMode = heatModeState[1]
2560
+
2561
+ // Heatmap paging is owned here rather than inside Heatmap: the arrows sit
2562
+ // in the card header, so the header needs the page count and the current
2563
+ // range that only the heatmap layout math can compute. Heatmap publishes
2564
+ // them through onPageInfo and consumes the resulting index back.
2565
+ var heatNavState = React.useState({ mode: null, index: 0 })
2566
+ var heatNav = heatNavState[0]
2567
+ var setHeatNav = heatNavState[1]
2568
+ var heatInfoState = React.useState(null)
2569
+ var heatInfo = heatInfoState[0]
2570
+ var setHeatInfo = heatInfoState[1]
2571
+ // Stable identity (ref-backed) so the heatmap's reporting effect does not
2572
+ // re-fire on every render; the setter itself is already stable.
2573
+ var reportHeatInfoRef = React.useRef(null)
2574
+ if (reportHeatInfoRef.current === null) {
2575
+ reportHeatInfoRef.current = function (next) {
2576
+ setHeatInfo(function (previous) {
2577
+ if (previous && previous.page === next.page && previous.pageCount === next.pageCount &&
2578
+ previous.first === next.first && previous.last === next.last) return previous
2579
+ return next
2580
+ })
2581
+ }
2582
+ }
2583
+ var reportHeatInfo = reportHeatInfoRef.current
2584
+ // Paging resets whenever the mode changes, so a weekly page index can
2585
+ // never leak into the daily grid (their week counts differ).
2586
+ var stepHeatPage = function (delta) {
2587
+ setHeatNav(function (previous) {
2588
+ var base = previous.mode === heatMode ? previous.index : 0
2589
+ var next = base + delta
2590
+ if (heatInfo && (next < 0 || next > heatInfo.pageCount - 1)) return previous
2591
+ return { mode: heatMode, index: next }
2592
+ })
2593
+ }
2594
+ var heatPageIndex = heatNav.mode === heatMode ? heatNav.index : 0
2595
+
2596
+ var trendPeriodState = React.useState('week')
2597
+ var trendPeriod = trendPeriodState[0]
2598
+ var setTrendPeriod = trendPeriodState[1]
2599
+
2600
+ var tableExpandedState = React.useState(false)
2601
+ var tableExpanded = tableExpandedState[0]
2602
+ var setTableExpanded = tableExpandedState[1]
2603
+
2604
+ // "按模型" view: merge the same model name across providers.
2605
+ var groupModelState = React.useState(true)
2606
+ var groupByModel = groupModelState[0]
2607
+ var setGroupByModel = groupModelState[1]
2608
+
2609
+ var chartGroupState = React.useState(true)
2610
+ var chartGroupByModel = chartGroupState[0]
2611
+ var setChartGroupByModel = chartGroupState[1]
2612
+
2613
+ var dataState = React.useState(null)
2614
+ var data = dataState[0]
2615
+ var setData = dataState[1]
2616
+
2617
+ var loadingState = React.useState(false)
2618
+ var loading = loadingState[0]
2619
+ var setLoading = loadingState[1]
2620
+
2621
+ var errorState = React.useState(null)
2622
+ var error = errorState[0]
2623
+ var setError = errorState[1]
2624
+
2625
+ var staleState = React.useState(false)
2626
+ var stale = staleState[0]
2627
+ var setStale = staleState[1]
2628
+
2629
+ var searchState = React.useState('')
2630
+ var searchQuery = searchState[0]
2631
+ var setSearchQuery = searchState[1]
2632
+
2633
+ var tabState = React.useState('usage')
2634
+ var tab = tabState[0]
2635
+ var setTab = tabState[1]
2636
+
2637
+ var controlState = React.useState({ settings: { autoQuota: false, intervalMinutes: 60, showModelDetails: true, advancedModelSelect: false, customQueries: [] } })
2638
+ var control = controlState[0], setControl = controlState[1]
2639
+ var controlReadyState = React.useState(false)
2640
+ var controlReady = controlReadyState[0], setControlReady = controlReadyState[1]
2641
+ var controlErrorState = React.useState('')
2642
+ var controlError = controlErrorState[0], setControlError = controlErrorState[1]
2643
+ var quotaRevisionState = React.useState(0)
2644
+ var quotaRevision = quotaRevisionState[0], setQuotaRevision = quotaRevisionState[1]
2645
+ var quotaVisitedState = React.useState(false)
2646
+ var quotaVisited = quotaVisitedState[0], setQuotaVisited = quotaVisitedState[1]
2647
+ var quotaSnapshotsState = React.useState({})
2648
+ var quotaSnapshots = quotaSnapshotsState[0], setQuotaSnapshots = quotaSnapshotsState[1]
2649
+ var quotaLoadingState = React.useState(false)
2650
+ var quotaLoading = quotaLoadingState[0], setQuotaLoading = quotaLoadingState[1]
2651
+ var quotaErrorState = React.useState('')
2652
+ var quotaError = quotaErrorState[0], setQuotaError = quotaErrorState[1]
2653
+
2654
+ function changeTab(next) {
2655
+ if (next === tab) return
2656
+ // A short tab must not inherit the long usage page's scroll position.
2657
+ if (scrollPortRef.current) scrollPortRef.current.scrollTop = 0
2658
+ if (next === 'quota') setQuotaVisited(true)
2659
+ setTab(next)
2660
+ }
2661
+ function onTabKeyDown(event) {
2662
+ var names = ['usage', 'quota', 'control']
2663
+ var index = names.indexOf(tab)
2664
+ var next = event.key === 'ArrowRight' ? names[(index + 1) % names.length] : event.key === 'ArrowLeft' ? names[(index + names.length - 1) % names.length] : event.key === 'Home' ? 'usage' : event.key === 'End' ? 'control' : null
2665
+ if (next === null) return
2666
+ event.preventDefault()
2667
+ changeTab(next)
2668
+ var tabs = event.currentTarget.parentElement.querySelectorAll('[role="tab"]')
2669
+ tabs[names.indexOf(next)].focus({ preventScroll: true })
2670
+ }
2671
+
2672
+ var tz = LOCAL_TZ
2673
+ var requestRef = React.useRef(0)
2674
+ var retryTimerRef = React.useRef(null)
2675
+ var successfulQueryRef = React.useRef({ model: '' })
2676
+ var pageRef = React.useRef(null)
2677
+ var scrollPortRef = React.useRef(null)
2678
+
2679
+ function load(opts) {
2680
+ var optModel = opts && opts.model !== undefined ? opts.model : model
2681
+ var fresh = opts && opts.fresh === true
2682
+ var request = ++requestRef.current
2683
+ var attempt = 0
2684
+ if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current)
2685
+ retryTimerRef.current = null
2686
+ function pull(isFresh) {
2687
+ attempt++
2688
+ setLoading(true)
2689
+ setError(null)
2690
+ api.stats({ days: 0, model: optModel, fresh: isFresh })
2691
+ .then(function (body) {
2692
+ if (request !== requestRef.current) return
2693
+ setData(body)
2694
+ successfulQueryRef.current = { model: optModel }
2695
+ setLoading(false)
2696
+ setStale(!!body.stale)
2697
+ if (body.stale && attempt < 40) retryTimerRef.current = setTimeout(function () { pull(false) }, 2500)
2698
+ })
2699
+ .catch(function (err) {
2700
+ if (request !== requestRef.current) return
2701
+ setError(String(err && err.message || err))
2702
+ setLoading(false)
2703
+ setStale(true)
2704
+ setModel(successfulQueryRef.current.model)
2705
+ })
2706
+ }
2707
+ pull(fresh)
2708
+ }
2709
+
2710
+ React.useEffect(function () {
2711
+ load({ model: model })
2712
+ return function () {
2713
+ requestRef.current++
2714
+ if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current)
2715
+ }
2716
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2717
+ }, [])
2718
+
2719
+ function loadControls() {
2720
+ setControlError('')
2721
+ api.controls().then(function (body) { setControl(body); syncModelSeatFromControls(body); setControlReady(true) }).catch(function (err) { setControlError(String(err.message || err)); setControlReady(false) })
2722
+ }
2723
+ React.useEffect(function () { loadControls() }, [])
2724
+ React.useEffect(function () {
2725
+ var page = pageRef.current
2726
+ if (!page) return
2727
+ var port = page.parentElement
2728
+ while (port && !/(auto|scroll)/.test(window.getComputedStyle(port).overflowY)) port = port.parentElement
2729
+ if (!port) return
2730
+ scrollPortRef.current = port
2731
+ var previous = port.style.scrollbarGutter
2732
+ port.style.scrollbarGutter = 'stable'
2733
+ return function () {
2734
+ if (port.style.scrollbarGutter === 'stable') port.style.scrollbarGutter = previous
2735
+ scrollPortRef.current = null
2736
+ }
2737
+ }, [])
2738
+ React.useEffect(function () {
2739
+ if (tab === 'usage' || typeof setInterval !== 'function') return
2740
+ var timer = setInterval(loadControls, 30000)
2741
+ return function () { clearInterval(timer) }
2742
+ }, [tab])
2743
+ function saveControls(patch) {
2744
+ return api.controls(patch).then(function (body) {
2745
+ setControl(body); syncModelSeatFromControls(body); setControlReady(true)
2746
+ if (patch.action === 'save-query' || patch.action === 'remove-query') setQuotaRevision(function (value) { return value + 1 })
2747
+ return body
2748
+ })
2749
+ }
2750
+ function refreshQuotas() {
2751
+ setQuotaLoading(true)
2752
+ setQuotaError('')
2753
+ Promise.allSettled([api.providerQuotas({ fresh: true }), api.workbuddyStatus({ fresh: true }), api.workbuddyStatus({ fresh: true, source: 'workbuddy-ai' })]).then(function (results) {
2754
+ if (results.some(function (result) { return result.status === 'rejected' || quotaFailed(result.value) })) setQuotaError('部分配额未能更新,请稍后刷新重试。')
2755
+ setQuotaSnapshots(function (previous) {
2756
+ var next = Object.assign({}, previous)
2757
+ ;['providers', 'workbuddy', 'workbuddy-ai'].forEach(function (key, index) { if (results[index].status === 'fulfilled' && !quotaFailed(results[index].value)) next[key] = results[index].value })
2758
+ return next
2759
+ })
2760
+ setQuotaLoading(false)
2761
+ }).catch(function (err) { setQuotaLoading(false); setQuotaError(String(err.message || err)) })
2762
+ }
2763
+
2764
+ var changeModel = function (m) {
2765
+ setModel(m)
2766
+ load({ model: m })
2767
+ }
2768
+ var refresh = function () {
2769
+ load({ model: model, fresh: true })
2770
+ }
2771
+
2772
+ var totals = data && data.totals
2773
+ var byModel = data ? data.byModel : []
2774
+ var displayedModel = data && Object.prototype.hasOwnProperty.call(data, 'model') ? (data.model || '') : model
2775
+ var dayMap = new Map()
2776
+ var dayModelMap = new Map()
2777
+ if (data) {
2778
+ for (var i = 0; i < data.byDay.length; i++) dayMap.set(data.byDay[i].date, data.byDay[i])
2779
+ for (var j = 0; j < (data.byDayModels || []).length; j++) dayModelMap.set(data.byDayModels[j].date, data.byDayModels[j].models)
2780
+ }
2781
+ // A running host can still serve the previous response shape until it
2782
+ // reloads. For that version, subtract adjacent cumulative model ranges
2783
+ // on hover to recover the exact per-day breakdown without a full rescan.
2784
+ var loadDayModels = data && !Array.isArray(data.byDayModels) ? function (date) {
2785
+ var today = new Date(Date.now() - tz * 60000).toISOString().slice(0, 10)
2786
+ var span = Math.round((Date.parse(today + 'T00:00:00Z') - Date.parse(date + 'T00:00:00Z')) / 86400000) + 1
2787
+ if (!(span >= 1 && span <= 3650)) return Promise.resolve([])
2788
+ return Promise.all([
2789
+ api.stats({ days: span, model: displayedModel }),
2790
+ span > 1 ? api.stats({ days: span - 1, model: displayedModel }) : Promise.resolve({ byModel: [] }),
2791
+ ]).then(function (results) {
2792
+ var later = new Map()
2793
+ var models = []
2794
+ for (var li = 0; li < (results[1].byModel || []).length; li++) later.set(results[1].byModel[li].key, results[1].byModel[li])
2795
+ for (var mi = 0; mi < (results[0].byModel || []).length; mi++) {
2796
+ var current = results[0].byModel[mi]
2797
+ var following = later.get(current.key)
2798
+ var billed = Math.max(0, (current.billed || 0) - (following && following.billed || 0))
2799
+ var calls = Math.max(0, (current.calls || 0) - (following && following.calls || 0))
2800
+ if (billed > 0 || calls > 0) models.push({ key: current.key, provider: current.provider, model: current.model, billed: billed, calls: calls })
2801
+ }
2802
+ models.sort(function (a, b) { return b.billed - a.billed })
2803
+ return models
2804
+ })
2805
+ } : null
2806
+ // Fixed colour per model, persisted in localStorage: the first time a
2807
+ // model is seen it is assigned the next unused colour from a
2808
+ // golden-angle palette and keeps it forever — never tied to the usage
2809
+ // ranking, so the most-used model is not always the same colour. The
2810
+ // donut and the 24-hour bars agree because they share this map.
2811
+ var STORAGE_KEY = 'dshus.modelColors.v1'
2812
+ var modelColor = {}
2813
+ try {
2814
+ var storedColors = window.localStorage.getItem(STORAGE_KEY)
2815
+ if (storedColors) {
2816
+ var parsedColors = JSON.parse(storedColors)
2817
+ if (parsedColors && typeof parsedColors === 'object') {
2818
+ for (var pk in parsedColors) modelColor[pk] = parsedColors[pk]
2819
+ }
2820
+ }
2821
+ } catch (e) { /* storage unavailable — fall back to ephemeral colours */ }
2822
+ var usedColors = {}
2823
+ for (var uk in modelColor) usedColors[modelColor[uk]] = true
2824
+ for (var ci = 0; ci < byModel.length; ci++) {
2825
+ var key = byModel[ci].key
2826
+ if (modelColor[key]) continue
2827
+ var hue = 0
2828
+ for (var hc = 0; hc < 360; hc++) {
2829
+ hue = Math.round((hc * 137.508) % 360)
2830
+ if (!usedColors['hsl(' + hue + ', 70%, 55%)']) break
2831
+ }
2832
+ modelColor[key] = 'hsl(' + hue + ', 70%, 55%)'
2833
+ usedColors[modelColor[key]] = true
2834
+ }
2835
+ try {
2836
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(modelColor))
2837
+ } catch (e) { /* ignore quota / private-mode errors */ }
2838
+ modelColor['__rest__'] = '#94a3b8'
2839
+ modelColor['__all__'] = 'var(--dsw-alias-brand-primary, #3b82f6)'
2840
+ var colorOf = function (key) { return modelColor[key] || '#94a3b8' }
2841
+ // "按模型" view: fold the provider/model entries into one entry per
2842
+ // model name (provider ignored), sorted by usage. The merge key is the
2843
+ // model display name's basename (after last '/') lowercased, so
2844
+ // "GLM-4.7" vs "glm-4.7" and "stealth/ox-alpha" vs "OX-ALPHA" land in
2845
+ // one bucket; the displayed name keeps the casing of the variant that
2846
+ // carries the most usage (ties keep the first-seen spelling).
2847
+ var normModelKey = sharedNormModelKey
2848
+ // Display name for a merged group: the basename of the winning
2849
+ // variant (text after the last '/', original casing). Model ids on
2850
+ // OpenRouter-style gateways carry a namespace prefix ("z-ai/glm-
2851
+ // 5.3-flash" on cline-gw); showing the full id in the merged view
2852
+ // makes the group look like a provider leaked in ("z-ai/…"), so the
2853
+ // merged label always drops the prefix — the same rule the merge key
2854
+ // (normModelKey) already uses, only case-preserving.
2855
+ var modelDisplayName = sharedModelDisplayName
2856
+ var donutModels = byModel
2857
+ if (groupByModel && byModel.length > 0) {
2858
+ var mergedMap = {}
2859
+ var mergedOrder = []
2860
+ for (var gmI = 0; gmI < byModel.length; gmI++) {
2861
+ var gmItem = byModel[gmI]
2862
+ var gmName = gmItem.model || gmItem.key
2863
+ var gmKey = normModelKey(gmName)
2864
+ var gmCur = mergedMap[gmKey]
2865
+ var canonical = gmKey === 'glm-5.3-flash' ? 'glm-5.3-flash' : null
2866
+ if (gmCur === undefined) {
2867
+ gmCur = { key: 'by-model:' + gmKey, provider: '', model: canonical || modelDisplayName(gmName), lastTime: gmItem.lastTime || 0, billed: gmItem.billed || 0, _topBilled: gmItem.billed || 0 }
2868
+ mergedMap[gmKey] = gmCur
2869
+ mergedOrder.push(gmCur)
2870
+ } else {
2871
+ gmCur.billed += gmItem.billed || 0
2872
+ if ((gmItem.lastTime || 0) > gmCur.lastTime) gmCur.lastTime = gmItem.lastTime || 0
2873
+ if (canonical === null && (gmItem.billed || 0) > (gmCur._topBilled || 0)) {
2874
+ gmCur.model = modelDisplayName(gmName)
2875
+ gmCur._topBilled = gmItem.billed || 0
2876
+ } else if ((gmItem.billed || 0) > (gmCur._topBilled || 0)) {
2877
+ gmCur._topBilled = gmItem.billed || 0
2878
+ }
2879
+ }
2880
+ }
2881
+ mergedOrder.sort(function (a, b) { return b.billed - a.billed })
2882
+ donutModels = mergedOrder
2883
+ }
2884
+ // Merged "by-model:*" keys have no persisted colour — give them a stable
2885
+ // palette colour by current rank; everything else keeps its own colour.
2886
+ var donutColorFor = function (key) {
2887
+ if (groupByModel && key.indexOf('by-model:') === 0) {
2888
+ for (var di = 0; di < donutModels.length; di++) {
2889
+ if (donutModels[di].key === key) return DONUT_COLORS[di % DONUT_COLORS.length]
2890
+ }
2891
+ }
2892
+ return colorOf(key)
2893
+ }
2894
+ // Per-model hour breakdown for the stacked 24h bars. Older hosts (before
2895
+ // this feature) do not serve byHourModels: fall back to the aggregated
2896
+ // byHour series as a single "全部" segment so the chart still renders.
2897
+ var hourModels = []
2898
+ if (data) {
2899
+ if (data.byHourModels && data.byHourModels.length > 0) {
2900
+ hourModels = data.byHourModels
2901
+ } else {
2902
+ hourModels = data.byHour.map(function (h) {
2903
+ return { hour: h.hour, models: [{ key: '__all__', provider: '', model: t('全部'), billed: h.billed }] }
2904
+ })
2905
+ }
2906
+ // The chart has its own grouping control, independent of the donut.
2907
+ // When "按模型" is on, fold hourModels by the same basename
2908
+ // lowercased key so case variants like "stealth/ox-alpha" vs
2909
+ // "STEALTH/OX-ALPHA" share one stack segment per hour, consistent
2910
+ // with the donut.
2911
+ if (chartGroupByModel && hourModels.length > 0) {
2912
+ var mergedHourModels = []
2913
+ for (var hi = 0; hi < hourModels.length; hi++) {
2914
+ var hm = hourModels[hi]
2915
+ var ms = hm.models || []
2916
+ // Do not reuse a single raw provider/model segment here. Even a
2917
+ // one-segment hour must pass through the merged-model projection
2918
+ // so its provider is cleared and namespaced model ids are reduced
2919
+ // to the same display name used by the donut.
2920
+ if (ms.length === 0) { mergedHourModels.push(hm); continue }
2921
+ var hMap = {}
2922
+ var hOrder = []
2923
+ for (var mi = 0; mi < ms.length; mi++) {
2924
+ var mm = ms[mi]
2925
+ var mmName = mm.model || mm.key
2926
+ var mmKey = normModelKey(mmName)
2927
+ var canonicalH = mmKey === 'glm-5.3-flash' ? 'glm-5.3-flash' : null
2928
+ var cur = hMap[mmKey]
2929
+ if (cur === undefined) {
2930
+ cur = { key: 'by-model:' + mmKey, provider: '', model: canonicalH || modelDisplayName(mm.model), billed: mm.billed || 0, _topBilled: mm.billed || 0 }
2931
+ hMap[mmKey] = cur
2932
+ hOrder.push(cur)
2933
+ } else {
2934
+ cur.billed += mm.billed || 0
2935
+ if (canonicalH === null && (mm.billed || 0) > (cur._topBilled || 0)) { cur.model = modelDisplayName(mmName); cur._topBilled = mm.billed || 0 }
2936
+ else if ((mm.billed || 0) > (cur._topBilled || 0)) cur._topBilled = mm.billed || 0
2937
+ }
2938
+ }
2939
+ mergedHourModels.push({ hour: hm.hour, models: hOrder })
2940
+ }
2941
+ hourModels = mergedHourModels
2942
+ }
2943
+ }
2944
+ var chartColorFor = function (key) {
2945
+ if (!chartGroupByModel || key.indexOf('by-model:') !== 0) return colorOf(key)
2946
+ return sharedModelColor(key.slice('by-model:'.length))
2947
+ }
2948
+
2949
+ var tools = React.createElement('div', { className: 'dshus-tools' },
2950
+ tab === 'usage' && refreshIconButton(refresh, loading, t('刷新用量统计'), false),
2951
+ tab === 'quota' && refreshIconButton(refreshQuotas, quotaLoading, t('刷新全部配额'), false),
2952
+ )
2953
+
2954
+ var cards
2955
+ if (totals) {
2956
+ var overview = data.overview || {
2957
+ todayBilled: (data.byHour || []).reduce(function (sum, h) { return sum + (h.billed || 0) }, 0),
2958
+ usedDays: (data.byDay || []).filter(function (d) { return d.calls > 0 }).length,
2959
+ totalBilled: totals.billed,
2960
+ }
2961
+ // 概览指标在同一行内展示,相邻两项之间以竖线分割(不再拆成小卡片)。
2962
+ var stat = function (v, k, opts) {
2963
+ opts = opts || {}
2964
+ var display = typeof v === 'number' ? (opts.exact ? fmtExact(v) : fmt(v)) : v
2965
+ return React.createElement('div', { className: 'dshus-stat' + (opts.cls ? ' ' + opts.cls : '') },
2966
+ React.createElement('div', { className: 'v' }, display),
2967
+ React.createElement('div', { className: 'k' }, k),
2968
+ )
2969
+ }
2970
+ cards = React.createElement('div', { className: 'dshus-statbar dshus-overview-bar', role: 'group', 'aria-label': t('概览指标') },
2971
+ stat(overview.todayBilled, t('今日tokens数')),
2972
+ stat(overview.usedDays, t('使用天数'), { exact: true }),
2973
+ stat(overview.totalBilled, t('累计tokens数')),
2974
+ )
2975
+ } else {
2976
+ cards = React.createElement('div', { className: 'dshus-muted' }, t('暂无数据'))
2977
+ }
2978
+
2979
+ // Filter models for the breakdown table
2980
+ var filterText = searchQuery.trim().toLowerCase()
2981
+ var tableModels = donutModels.filter(function (m) {
2982
+ if (!filterText) return true
2983
+ var name = (m.model || m.key || '').toLowerCase()
2984
+ var prov = (m.provider || '').toLowerCase()
2985
+ return name.indexOf(filterText) !== -1 || prov.indexOf(filterText) !== -1
2986
+ })
2987
+
2988
+ // Show only the top model until the list is expanded or searched.
2989
+ var tableVisibleModels = (!filterText && !tableExpanded) ? tableModels.slice(0, 1) : tableModels
2990
+
2991
+ // Breakdown table element
2992
+ var totalDonutBilled = 0
2993
+ for (var tdi = 0; tdi < donutModels.length; tdi++) totalDonutBilled += donutModels[tdi].billed || 0
2994
+
2995
+ var tableRows = tableVisibleModels.map(function (m, idx) {
2996
+ var pct = totalDonutBilled > 0 ? ((m.billed || 0) / totalDonutBilled) * 100 : 0
2997
+ var mColor = donutColorFor(m.key)
2998
+ var label = m.provider ? m.provider + ' · ' + m.model : m.model
2999
+ var isRest = m.key === '__rest__'
3000
+ return React.createElement('tr', { key: m.key || idx },
3001
+ React.createElement('td', null,
3002
+ React.createElement('div', { className: 'dshus-table-model-cell' },
3003
+ React.createElement('span', { className: 'dshus-donut-dot', style: { background: mColor } }),
3004
+ React.createElement('span', null, label),
3005
+ ),
3006
+ ),
3007
+ React.createElement('td', { style: { textAlign: 'right', fontWeight: 600 } }, fmt(m.billed || 0)),
3008
+ React.createElement('td', { style: { textAlign: 'right' } }, formatPct(pct)),
3009
+ React.createElement('td', { className: 'dshus-table-bar-cell' },
3010
+ React.createElement('div', { className: 'dshus-table-bar-bg' },
3011
+ React.createElement('div', { className: 'dshus-table-bar-fill', style: { width: Math.min(100, Math.max(1, pct)) + '%', background: mColor } }),
3012
+ ),
3013
+ ),
3014
+ React.createElement('td', { style: { textAlign: 'right', color: 'var(--dsw-alias-label-caption, #9ca3af)', fontSize: 11.5 } },
3015
+ m.lastTime ? fmtInTz(m.lastTime, tz) : (isRest ? '—' : t('近期调用')),
3016
+ ),
3017
+ )
3018
+ })
3019
+
3020
+ return React.createElement('div', { className: 'dshus-page', ref: pageRef },
3021
+ React.createElement('div', { className: 'dshus-toolbar' },
3022
+ React.createElement('div', { className: 'dshus-head' },
3023
+ React.createElement('div', { className: 'dshus-brand' },
3024
+ React.createElement('span', { className: 'dshus-brand-icon', 'aria-hidden': 'true' }),
3025
+ React.createElement('h2', { className: 'dshus-title' }, t('用量统计')),
3026
+ React.createElement('a', { className: 'dshus-brand-meta dshus-brand-link', href: 'https://github.com/Jockjrop/dsh-usage-stats', target: '_blank', rel: 'noopener noreferrer', title: t('在 GitHub 查看 dsh-usage-stats') }, 'dsh-usage-stats'),
3027
+ PLUGIN_VERSION !== '__DSH_USAGE_STATS_VERSION__' && React.createElement('span', { className: 'dshus-brand-meta' }, 'v' + PLUGIN_VERSION),
3028
+ ),
3029
+ tools,
3030
+ ),
3031
+ React.createElement('div', { className: 'dshus-tabs', role: 'tablist', 'aria-label': t('用量统计页面') },
3032
+ React.createElement('button', { id: 'dshus-tab-usage', type: 'button', role: 'tab', 'aria-selected': tab === 'usage', 'aria-controls': 'dshus-usage-panel', tabIndex: tab === 'usage' ? 0 : -1, className: 'dshus-tab' + (tab === 'usage' ? ' on' : ''), onClick: function () { changeTab('usage') }, onKeyDown: onTabKeyDown }, t('用量')),
3033
+ React.createElement('button', { id: 'dshus-tab-quota', type: 'button', role: 'tab', 'aria-selected': tab === 'quota', 'aria-controls': 'dshus-quota-panel', tabIndex: tab === 'quota' ? 0 : -1, className: 'dshus-tab' + (tab === 'quota' ? ' on' : ''), onClick: function () { changeTab('quota') }, onKeyDown: onTabKeyDown }, t('配额')),
3034
+ React.createElement('button', { id: 'dshus-tab-control', type: 'button', role: 'tab', 'aria-selected': tab === 'control', 'aria-controls': 'dshus-control-panel', tabIndex: tab === 'control' ? 0 : -1, className: 'dshus-tab' + (tab === 'control' ? ' on' : ''), onClick: function () { changeTab('control') }, onKeyDown: onTabKeyDown }, t('控制')),
3035
+ ),
3036
+ ),
3037
+ // The size container wraps only the panels, so the sticky header above it
3038
+ // is not inside a containment context.
3039
+ React.createElement('div', { className: 'dshus-panels' },
3040
+ tab === 'usage' && React.createElement('div', { id: 'dshus-usage-panel', role: 'tabpanel', 'aria-labelledby': 'dshus-tab-usage', tabIndex: 0 },
3041
+ error !== null && React.createElement('div', { className: 'dshus-error' }, uiError(error)),
3042
+ stale && data && data.partial && React.createElement('div', { className: 'dshus-error' }, t('部分会话读取失败')),
3043
+
3044
+ /* Area 1: 概览指标 */
3045
+ React.createElement('div', { className: 'dshus-module' },
3046
+ React.createElement('h3', null, t('概览指标')),
3047
+ cards,
3048
+ ),
3049
+
3050
+ /* Area 2: 今日Token用量 */
3051
+ React.createElement('div', { className: 'dshus-module' },
3052
+ React.createElement('h3', null,
3053
+ t('今日Token用量'),
3054
+ React.createElement('div', { className: 'dshus-range', style: { marginLeft: 'auto' }, 'aria-label': t('今日Token用量统计模式') },
3055
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (!chartGroupByModel ? ' on' : ''), onClick: function () { setChartGroupByModel(false) } }, t('供应商·模型')),
3056
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (chartGroupByModel ? ' on' : ''), onClick: function () { setChartGroupByModel(true) } }, t('按模型')),
3057
+ ),
3058
+ ),
3059
+ React.createElement(Bars, { hourModels: hourModels, byHour: data ? data.byHour : [], colorOf: chartColorFor }),
3060
+ ),
3061
+
3062
+ /* Area 3: 使用量热力图 */
3063
+ React.createElement('div', { className: 'dshus-module' },
3064
+ React.createElement('h3', null,
3065
+ t('使用量热力图'),
3066
+ React.createElement('div', { className: 'dshus-head-tools' },
3067
+ React.createElement(HeatPager, {
3068
+ info: heatInfo,
3069
+ onStep: stepHeatPage,
3070
+ }),
3071
+ React.createElement('div', { className: 'dshus-range dshus-heat-modes', 'aria-label': t('热力图时长') },
3072
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (heatMode === 'daily' ? ' on' : ''), onClick: function () { setHeatNav({ mode: 'daily', index: 0 }); setHeatInfo(null); setHeatMode('daily') } }, t('每日')),
3073
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (heatMode === 'weekly' ? ' on' : ''), onClick: function () { setHeatNav({ mode: 'weekly', index: 0 }); setHeatInfo(null); setHeatMode('weekly') } }, t('每周')),
3074
+ ),
3075
+ ),
3076
+ ),
3077
+ React.createElement(Heatmap, { dayMap: dayMap, dayModelMap: dayModelMap, days: 0, mode: heatMode, tz: tz, colorOf: colorOf, modelKey: displayedModel, syncedAt: data && data.syncedAt, loadDayModels: loadDayModels, pageIndex: heatPageIndex, onPageInfo: reportHeatInfo }),
3078
+ ),
3079
+
3080
+ /* Area 4: 每日Token趋势图 */
3081
+ React.createElement('div', { className: 'dshus-module' },
3082
+ React.createElement('h3', null,
3083
+ t('每日Token趋势图'),
3084
+ React.createElement('div', { className: 'dshus-range', style: { marginLeft: 'auto' }, 'aria-label': t('每日Token趋势时长') },
3085
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (trendPeriod === 'week' ? ' on' : ''), onClick: function () { setTrendPeriod('week') } }, t('7天')),
3086
+ React.createElement('button', { type: 'button', className: 'dshus-range-btn' + (trendPeriod === 'month' ? ' on' : ''), onClick: function () { setTrendPeriod('month') } }, t('30天')),
3087
+ ),
3088
+ ),
3089
+ React.createElement(DailyTrend, { dayMap: dayMap, dayModelMap: dayModelMap, colorOf: colorOf, tz: tz, period: trendPeriod }),
3090
+ ),
3091
+
3092
+ /* Area 5: 模型明细 */
3093
+ control.settings.showModelDetails && React.createElement('div', { className: 'dshus-module' },
3094
+ React.createElement('h3', null,
3095
+ t('模型明细与分布'),
3096
+ React.createElement('div', { className: 'dshus-range', style: { marginLeft: 'auto' } },
3097
+ React.createElement('button', {
3098
+ type: 'button',
3099
+ className: 'dshus-range-btn' + (!groupByModel ? ' on' : ''),
3100
+ onClick: function () { setGroupByModel(false); setTableExpanded(false) },
3101
+ title: t('每个供应商 · 模型组合单独统计'),
3102
+ }, t('供应商·模型')),
3103
+ React.createElement('button', {
3104
+ type: 'button',
3105
+ className: 'dshus-range-btn' + (groupByModel ? ' on' : ''),
3106
+ onClick: function () { setGroupByModel(true); setTableExpanded(false) },
3107
+ title: t('合并同名模型,不区分供应商'),
3108
+ }, t('按模型')),
3109
+ ),
3110
+ ),
3111
+ React.createElement(Donut, {
3112
+ byModel: donutModels,
3113
+ colorOf: donutColorFor,
3114
+ onSelect: groupByModel ? undefined : function (key) { changeModel(key === model ? '' : key) },
3115
+ }),
3116
+ React.createElement('div', { className: 'dshus-model-toolbar' },
3117
+ React.createElement('span', { style: { fontSize: 13, fontWeight: 400, color: 'var(--dsw-alias-label-secondary, #4b5563)' } },
3118
+ t('模型用量清单 ({count})', { count: tableModels.length }),
3119
+ ),
3120
+ React.createElement('input', {
3121
+ type: 'text',
3122
+ className: 'dshus-search-input',
3123
+ placeholder: t('搜索模型或供应商名称…'),
3124
+ value: searchQuery,
3125
+ onChange: function (e) { setSearchQuery(e.target.value) },
3126
+ }),
3127
+ ),
3128
+ React.createElement('div', { className: 'dshus-table-wrap' },
3129
+ React.createElement('table', { className: 'dshus-table' },
3130
+ React.createElement('thead', null,
3131
+ React.createElement('tr', null,
3132
+ React.createElement('th', null, t('模型名称')),
3133
+ React.createElement('th', { style: { textAlign: 'right' } }, t('用量 Tokens')),
3134
+ React.createElement('th', { style: { textAlign: 'right' } }, t('占比')),
3135
+ React.createElement('th', { className: 'dshus-table-bar-cell' }, t('分布条')),
3136
+ React.createElement('th', { style: { textAlign: 'right' } }, t('最后活跃时间')),
3137
+ ),
3138
+ ),
3139
+ React.createElement('tbody', null,
3140
+ tableRows.length > 0 ? tableRows : React.createElement('tr', null,
3141
+ React.createElement('td', { colSpan: 5, style: { textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #6b7280)', padding: '16px 0' } }, t('没有找到匹配的模型')),
3142
+ ),
3143
+ ),
3144
+ ),
3145
+ ),
3146
+ (!filterText && tableModels.length > 1) && React.createElement('div', { style: { textAlign: 'center', marginTop: 10 } },
3147
+ React.createElement('button', {
3148
+ type: 'button',
3149
+ className: 'dshus-btn',
3150
+ onClick: function () { setTableExpanded(!tableExpanded) },
3151
+ style: { fontSize: 12, height: 28, padding: '0 16px', margin: '0 auto' }
3152
+ }, tableExpanded ? t('收起清单') : t('展开其余 {count} 个模型', { count: tableModels.length - 1 }))
3153
+ ),
3154
+ ),
3155
+
3156
+ ),
3157
+ quotaVisited && React.createElement('div', { id: 'dshus-quota-panel', className: 'dshus-quota-panel', role: 'tabpanel', 'aria-labelledby': 'dshus-tab-quota', tabIndex: 0, hidden: tab !== 'quota', 'data-empty-label': t('连接模型服务后,可读取的配额会显示在这里') },
3158
+ quotaError && React.createElement('div', { className: 'dshus-error', role: 'alert' }, uiError(quotaError)),
3159
+ React.createElement(ProviderQuotasPanel, { api: api, key: 'providers', active: tab === 'quota', revision: quotaRevision, refreshing: quotaLoading, snapshot: quotaSnapshots.providers }),
3160
+ React.createElement(WorkBuddyPanel, { api: api, key: 'workbuddy', active: tab === 'quota', revision: quotaRevision, refreshing: quotaLoading, snapshot: quotaSnapshots.workbuddy }),
3161
+ React.createElement(WorkBuddyPanel, { api: api, source: 'workbuddy-ai', key: 'workbuddy-ai', active: tab === 'quota', revision: quotaRevision, refreshing: quotaLoading, snapshot: quotaSnapshots['workbuddy-ai'] }),
3162
+ ),
3163
+ tab === 'control' && React.createElement('div', { id: 'dshus-control-panel', role: 'tabpanel', 'aria-labelledby': 'dshus-tab-control', tabIndex: 0 },
3164
+ React.createElement(ControlsPanel, { api: api, control: control, ready: controlReady, error: controlError, onReload: loadControls, onSave: saveControls }),
3165
+ ),
3166
+ ),
3167
+ )
3168
+ }
3169
+
3170
+ /* ------------------------------------------------------------------ */
3171
+ /* Plugin entry */
3172
+ /* ------------------------------------------------------------------ */
3173
+
3174
+ /**
3175
+ * Required services.
3176
+ *
3177
+ * `slots` registers the settings section (and the optional seat).
3178
+ *
3179
+ * The trailing four exist only for the 高级模型选择器 seat, and every
3180
+ * ctx.<name> the seat touches MUST be declared: the cordis guard denies
3181
+ * undeclared access on this plugin's fiber, and a denial raised inside a
3182
+ * render/effect crashes the slot entry, which then silently ABDICATES to
3183
+ * the official seat — the plugin still reports "running" and only the
3184
+ * console shows 'slot entry crashed'. Two such traps (ported from
3185
+ * dsh-model-selection-seat):
3186
+ * - 'remote' / 'remote.session': the official modelDirectories service
3187
+ * reaches into ctx.remote.session inside directoryFor(), and the slot
3188
+ * definition's inject(sessionId) runs on THIS plugin's fiber;
3189
+ * - 'timer': the effort-preview clear uses ctx.timeout — without
3190
+ * declaring it, the very first slider tap crashed the entry.
3191
+ * Registering the seat is guarded by typeof checks, so a host that
3192
+ * provides none of these still runs the usage page normally.
3193
+ */
3194
+ var inject = ['slots', 'locale', 'modelDirectories', 'sessions', 'remote', 'remote.session', 'timer']
3195
+
3196
+ /* ------------------------------------------------------------------ */
3197
+ /* Advanced model selector — optional rs2 slider seat (高级模型选择器) */
3198
+ /* ------------------------------------------------------------------ */
3199
+
3200
+ /**
3201
+ * Optional seat (ported from dsh-model-selection-seat, itself the
3202
+ * dpnows-model-selection-custom slider seat). Slot resolution picks the
3203
+ * entry with the lowest allocated priority, and bundle-client
3204
+ * registrations get NO automatic shadowing priority — the official
3205
+ * @deepseek-ai/dsh-client-ui-model-selection ModelSelect registers with
3206
+ * priority unset, so the explicit -1 below is what shadows it. The
3207
+ * official plugin stays loaded: its /model command, its
3208
+ * ctx.modelDirectories service and its `model` locale dictionaries are
3209
+ * reused untouched (the registration carries locale: 'model').
3210
+ *
3211
+ * Off (the default) the seat is not registered at all, so the official
3212
+ * selector keeps the input; toggled off at runtime the disposer below
3213
+ * unregisters it and the official selector returns.
3214
+ */
3215
+ var SEAT_STYLE_ID = 'dsh-usage-stats-seat-styles'
3216
+ /** localStorage key of the seat's own "高级" preference (ported as-is). */
3217
+ var SEAT_ADV_KEY = 'dsh-rs2-adv'
3218
+ /** Startup hint; the host controls endpoint remains the source of truth. */
3219
+ var SEAT_ENABLED_KEY = 'dsh-usage-stats-advanced-model-select'
3220
+
3221
+ /**
3222
+ * The seat's stylesheet (rs2 CSS, ported from the seat plugin).
3223
+ *
3224
+ * Color note — do NOT use `--dsw-alias-brand-primary` as an accent here.
3225
+ * In this host that alias is a *monochrome foreground* token:
3226
+ * light: --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000) (#0f1115)
3227
+ * dark: --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50) (#f9fafb)
3228
+ * It is defined in BOTH themes, so a var() with a blue fallback never falls
3229
+ * back — it silently resolves to near-black or near-white. Painting the
3230
+ * slider fill with it produced the reported bug: the filled region rendered
3231
+ * 8/255 lighter than the unfilled chrome (#6e7174 vs #61666b) and read as
3232
+ * "the slider has no progress color / the colors are wrong".
3233
+ *
3234
+ * The real accent is `--dsw-alias-brand-primary-new-colorprimary-new-color`
3235
+ * (#4176e6 light / --dsw-static-deepseek-450 #5686fe dark). It is only
3236
+ * defined by the current host, so every use carries a literal blue fallback
3237
+ * for older builds: an undefined var() would invalidate the whole gradient
3238
+ * and drop the track to transparent.
3239
+ */
3240
+ var SEAT_ACCENT = 'var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6)'
3241
+ var SEAT_BLUE = 'color-mix(in srgb, ' + SEAT_ACCENT + ' 20%, #0068c8)'
3242
+ var SEAT_MAX_ACCENT = '#a46cff'
3243
+ /** Neutral rail in both themes, close to the host's own input chrome. */
3244
+ var SEAT_TRACK_REST = 'color-mix(in srgb, var(--dsw-alias-label-primary, #f9fafb) 12%, var(--dsw-alias-bg-layer-2, #2c2c2e))'
3245
+ /**
3246
+ * Popover surface.
3247
+ *
3248
+ * `--dsw-alias-bg-overlay` reads like the obvious "popover background", but
3249
+ * in this host it is NOT: dark resolves it to --dsw-static-neutral-bluish-700
3250
+ * = #61666b, a MID-GREY, and the host stylesheet spends it on exactly one
3251
+ * 20x20 badge (`.mAtvLq_number`) and never on a menu. Painting the menu with
3252
+ * it produced the reported flat-grey slab (measured #61666b across the whole
3253
+ * panel in the screenshot).
3254
+ *
3255
+ * The layer tokens are the theme-correct elevated surfaces, and they match
3256
+ * what the host's own popovers render as:
3257
+ * dark --dsw-alias-bg-layer-2 = --dsw-static-neutral-bluish-850 = #2c2c2e
3258
+ * light --dsw-alias-bg-layer-2 = --dsw-static-neutral-bluish-00 = #ffffff
3259
+ * #2c2c2e is exactly the surface the official model popover showed, so the
3260
+ * seat now matches the selector it shadows.
3261
+ *
3262
+ * The sticky group header reuses this same token rather than the host's
3263
+ * `--dsw-alias-menu-group-header-fill`: that name only exists inside the
3264
+ * Electron bundle and is NOT declared by the installed
3265
+ * @deepseek-ai/dsh-client-ui-theme, so referencing it would fall back to the
3266
+ * dark literal in the light theme. It must also stay opaque — a translucent
3267
+ * header would let scrolled rows show through it.
3268
+ */
3269
+ var SEAT_SURFACE = 'var(--dsw-alias-bg-layer-2, #2c2c2e)'
3270
+
3271
+ /**
3272
+ * The host's frosted-menu material, copied from how the official popovers
3273
+ * paint themselves (`.wq12jW_menu` + its `._material_` child):
3274
+ *
3275
+ * ._material_ { position:absolute; inset:0; z-index:-1;
3276
+ * border-radius:inherit;
3277
+ * background: var(--dsw-menu-surface-fill);
3278
+ * backdrop-filter: var(--dsw-menu-backdrop-filter) }
3279
+ *
3280
+ * Measured in the screenshot the user supplied: the official panel is a
3281
+ * 45%-alpha fill (#43454a73 dark) over a 40px/saturate(150%) backdrop blur,
3282
+ * so its colour drifts with whatever sits behind it (#3b3c40 over the dark
3283
+ * page, #6a6b6d over light text behind it). That drift IS the translucency.
3284
+ *
3285
+ * The fill is translucent, so it MUST NOT be paired with an opaque surface:
3286
+ * the earlier opaque `--dsw-alias-bg-layer-2` panel is kept only as the
3287
+ * no-backdrop-filter fallback below, where the blur cannot frost anything.
3288
+ */
3289
+ var SEAT_MATERIAL = 'var(--dsw-menu-surface-fill, rgba(67,69,74,.45))'
3290
+ var SEAT_MATERIAL_BLUR = 'var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%))'
3291
+ /** The host's own popover shadow; it also draws the 0.5px hairline stroke. */
3292
+ var SEAT_ELEVATION = 'var(--dsw-elevation-prominent, 0 0 0 .5px rgba(128,128,128,.28), 0 3px 8px 0 rgba(0,0,0,.04), 0 0 20px 0 rgba(0,0,0,.05))'
3293
+
3294
+ /**
3295
+ * Narrow-screen layout: the composer row measures its own children and, when
3296
+ * they cannot share a line, sets `data-model-compact` on the row, which
3297
+ * toggles these two inherited custom properties. The official model seat
3298
+ * consumes exactly this pair, so reading the same variables makes our seat
3299
+ * collapse to the icon at precisely the same moment the official one would —
3300
+ * no viewport breakpoint can guess that, because the row also shrinks when
3301
+ * the sidebar is open.
3302
+ */
3303
+ var SEAT_ICON_DISPLAY = 'var(--dsh-composer-model-icon-display, none)'
3304
+ var SEAT_TEXT_DISPLAY = 'var(--dsh-composer-model-text-display, block)'
3305
+
3306
+ var SEAT_CSS = [
3307
+ '.rs2-root{display:inline-block;width:max-content;min-width:0;position:relative;flex:none}',
3308
+ // min-width keeps a usable hit target when the label collapses away and
3309
+ // only the 16px icon remains.
3310
+ '.rs2-trigger{display:inline-flex;align-items:center;gap:2px;width:max-content;min-width:28px;padding:0 4px 0 8px;height:28px;border:none;border-radius:24px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;white-space:nowrap;cursor:pointer;outline:none}',
3311
+ '.rs2-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
3312
+ '.rs2-trigger-icon{display:' + SEAT_ICON_DISPLAY + ';flex:none}',
3313
+ '.rs2-trigger-icon svg{display:block;width:16px;height:16px}',
3314
+ '.rs2-trigger-name{display:' + SEAT_TEXT_DISPLAY + ';flex:none;white-space:nowrap}',
3315
+ '.rs2-trigger-effort{display:' + SEAT_TEXT_DISPLAY + ';flex:none;color:var(--dsw-alias-label-caption)}',
3316
+ '.rs2-chev{flex:none;display:flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
3317
+ '.rs2-chev-open{transform:rotate(180deg)}',
3318
+ '.rs2-menu{position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:30;box-sizing:border-box;display:flex;flex-direction:column;width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(380px,100vh - 96px);padding:6px;border:0;border-radius:12px;color:var(--dsw-alias-label-primary);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:' + SEAT_ELEVATION + ';overflow:hidden}',
3319
+ // The frosted pane. z-index:-1 is contained because .rs2-menu already
3320
+ // builds a stacking context (position:absolute + z-index:30), so the layer
3321
+ // paints behind the rows but never escapes behind the page. The host's own
3322
+ // MenuSurface also sets isolation:isolate here; it is redundant for us
3323
+ // (verified in a real engine: the 40px blur diffuses a 6px stripe backdrop
3324
+ // identically with and without it), so it is left out.
3325
+ '.rs2-menu::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:' + SEAT_MATERIAL + ';-webkit-backdrop-filter:' + SEAT_MATERIAL_BLUR + ';backdrop-filter:' + SEAT_MATERIAL_BLUR + ';pointer-events:none}',
3326
+ // Without backdrop support a 45%-alpha pane is just washed out, so fall
3327
+ // back to the opaque theme surface.
3328
+ '@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.rs2-menu::before{background:' + SEAT_SURFACE + '}}',
3329
+ '.rs2-cell{display:flex;align-items:center;gap:8px;width:100%;height:40px;padding:0 10px;border:none;border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-align:left;cursor:pointer}',
3330
+ '.rs2-cell:hover{background:var(--dsw-alias-interactive-bg-hover)}',
3331
+ '.rs2-cell-label{flex:none;white-space:nowrap}',
3332
+ '.rs2-cell-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;color:var(--dsw-alias-label-caption)}',
3333
+ '.rs2-back{display:flex;align-items:center;gap:6px;width:100%;height:34px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;cursor:pointer;text-align:left}',
3334
+ '.rs2-back:hover{background:var(--dsw-alias-interactive-bg-hover)}',
3335
+ '.rs2-list{min-height:0;overflow-y:auto}',
3336
+ // The sticky group title must stay legible while rows scroll under it, so
3337
+ // it repeats the surface at ~94% alpha — the same opacity the host's own
3338
+ // menu header uses (#303136f0 dark). Fully opaque would look pasted onto
3339
+ // the frosted pane; fully transparent would let rows bleed through.
3340
+ '.rs2-grouptitle{position:sticky;top:0;z-index:2;padding:5px 8px 3px;background:color-mix(in srgb, ' + SEAT_SURFACE + ' 94%, transparent);color:var(--dsw-alias-label-caption);font-size:12px;font-weight:500;line-height:18px}',
3341
+ '.rs2-option{display:flex;align-items:center;gap:8px;width:auto;min-width:100%;min-height:38px;padding:6px 8px;border:none;border-radius:10px;background:transparent;color:inherit;font-size:14px;text-align:left;cursor:pointer}',
3342
+ '.rs2-option:hover,.rs2-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}',
3343
+ '.rs2-optname{overflow-wrap:anywhere;white-space:normal}',
3344
+ '.rs2-check{display:grid;place-items:center;flex:0 0 18px;color:var(--dsw-alias-label-primary)}',
3345
+ '.rs2-advrow{display:flex;justify-content:flex-end;padding:4px 6px 0}',
3346
+ '.rs2-advtoggle{display:flex;align-items:center;gap:3px;height:24px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-caption);font-size:12px;font-weight:500;cursor:pointer}',
3347
+ '.rs2-advtoggle:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
3348
+ '.rs2-advhead{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:2px 2px 0}',
3349
+ '.rs2-modeljump{display:flex;align-items:center;gap:2px;flex:1;min-width:0;min-height:26px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px;cursor:pointer}',
3350
+ '.rs2-modeljump:hover{background:var(--dsw-alias-interactive-bg-hover)}',
3351
+ '.rs2-modeljump-name{overflow-wrap:anywhere;white-space:normal}',
3352
+ '.rs2-curlevel{flex:none;font-size:12px;font-weight:600;line-height:18px;color:' + SEAT_ACCENT + ';padding-right:4px;pointer-events:none;white-space:nowrap}',
3353
+ '.rs2-curlevel-max{color:color-mix(in srgb, ' + SEAT_MAX_ACCENT + ' 80%, var(--dsw-alias-label-primary))}',
3354
+ '.rs2-sliderbox{padding:8px 4px 2px}',
3355
+ '.rs2-track{position:relative;height:28px;margin:4px 0 0;outline:none;cursor:pointer;touch-action:none}',
3356
+ '.rs2-rail{position:absolute;inset:2px 0;border-radius:999px;background:' + SEAT_TRACK_REST + ';overflow:hidden;pointer-events:none}',
3357
+ '.rs2-fill{position:absolute;inset:0 auto 0 0;width:calc(14px + (100% - 28px) * var(--rs2-p,0));border-radius:999px;background:' + SEAT_BLUE + ';transition:width .18s ease;overflow:hidden}',
3358
+ '.rs2-max .rs2-fill{width:100%;background:linear-gradient(100deg,' + SEAT_BLUE + ' 0%,#6569ef 48%,' + SEAT_MAX_ACCENT + ' 100%)}',
3359
+ '.rs2-dot{position:absolute;top:0;width:28px;height:28px;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.35);pointer-events:none;z-index:3;left:calc((100% - 28px) * var(--rs2-p,0));transition:left .18s ease}',
3360
+ '.rs2-tick-shell{position:absolute;inset:0;z-index:2;pointer-events:none}',
3361
+ '.rs2-tick{position:absolute;top:50%;width:4px;height:4px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-label-primary,#f9fafb) 55%,#fff);opacity:.72;transform:translate(-50%,-50%);left:calc(14px + (100% - 28px) * var(--rs2-p,0))}',
3362
+ '.rs2-tick-on{opacity:.9;box-shadow:0 0 4px 1px rgba(255,255,255,.38)}',
3363
+ '.rs2-max .rs2-tick-shell{animation:rs2-tick-flight 3.6s linear forwards;animation-delay:var(--rs2-delay,0s);will-change:transform}',
3364
+ '.rs2-max .rs2-tick{background:#fff}',
3365
+ '@keyframes rs2-tick-flight{from{transform:translate3d(0,0,0)}to{transform:translate3d(-100%,0,0)}}',
3366
+ '.rs2-stream{position:absolute;inset:0;z-index:1;pointer-events:none;animation:rs2-stream-flight var(--rs2-duration) linear infinite;animation-delay:var(--rs2-delay);will-change:transform}',
3367
+ '.rs2-stream-dot{position:absolute;right:0;top:var(--rs2-y);width:var(--rs2-size);height:var(--rs2-size);border-radius:50%;background:#f4f7ff;opacity:.8}',
3368
+ '@keyframes rs2-stream-flight{from{transform:translate3d(0,0,0)}to{transform:translate3d(-100%,0,0)}}',
3369
+ '.rs2-max .rs2-dot{box-shadow:0 0 5px 1px color-mix(in srgb, ' + SEAT_MAX_ACCENT + ' 25%, transparent),0 1px 4px rgba(0,0,0,.35)}',
3370
+ '@media(prefers-reduced-motion:reduce){.rs2-max .rs2-tick-shell{animation:none;visibility:hidden}.rs2-stream{animation:none;display:none}.rs2-fill,.rs2-dot{transition:none}}',
3371
+ ].join('\n')
3372
+
3373
+ /** Find one provider/model entry in a modelDirectories group list. */
3374
+ function seatFindEntry(groups, provider, model) {
3375
+ if (!groups) return undefined
3376
+ for (var i = 0; i < groups.length; i++) {
3377
+ var group = groups[i]
3378
+ if (group.id !== provider) continue
3379
+ for (var j = 0; j < group.models.length; j++) {
3380
+ if (group.models[j].id === model) return group.models[j]
3381
+ }
3382
+ }
3383
+ return undefined
3384
+ }
3385
+
3386
+ /**
3387
+ * Pure viewport-clamp math for the popover, split out of the effect so it can
3388
+ * be tested without a DOM.
3389
+ *
3390
+ * The menu is centered on the trigger and opens upward. On a narrow
3391
+ * composer it can still extend past a viewport edge; nudge the measured
3392
+ * box inward without changing its default centered anchor.
3393
+ *
3394
+ * @param rect - measured box, in viewport coordinates
3395
+ * @param vw - viewport width; @param vh - viewport height
3396
+ * @param margin - minimum gap to keep from every edge
3397
+ * @returns `{ shift, maxHeight }`; shift is 0 and maxHeight null when the box
3398
+ * already fits, so the caller can leave both styles unset.
3399
+ */
3400
+ function seatClamp(rect, vw, vh, margin) {
3401
+ var gap = typeof margin === 'number' ? margin : 12
3402
+ if (!rect || !(vw > 0) || !(vh > 0) || !(rect.width > 0)) return { shift: 0, maxHeight: null }
3403
+ // Horizontal. When the box is wider than the usable width no shift can
3404
+ // satisfy both edges, so pin the left edge and let CSS clamp the width.
3405
+ var shift = 0
3406
+ var usable = vw - gap * 2
3407
+ if (rect.width > usable) shift = gap - rect.left
3408
+ else if (rect.left < gap) shift = gap - rect.left
3409
+ else if (rect.right > vw - gap) shift = (vw - gap) - rect.right
3410
+ // Vertical. The box grows upward, so only its TOP can escape the window;
3411
+ // cap the height by exactly the overflow so nothing is lost.
3412
+ var maxHeight = null
3413
+ if (rect.top < gap) maxHeight = Math.max(120, Math.round(rect.height - (gap - rect.top)))
3414
+ return { shift: Math.round(shift), maxHeight: maxHeight }
3415
+ }
3416
+
3417
+ /**
3418
+ * The official model seat's compact-mode glyph (IconDataOutlineRegular from
3419
+ * @deepseek-ai/dsh-client-ui-primitives), traced byte-for-byte so our
3420
+ * collapsed trigger is indistinguishable from the host's own. Two paths: a
3421
+ * filled cylinder body plus a stroked "edit" badge, 16x16, strokeWidth 1.
3422
+ */
3423
+ function SeatDataIcon() {
3424
+ return React.createElement('svg', {
3425
+ viewBox: '0 0 16 16', width: '16', height: '16', fill: 'none',
3426
+ xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
3427
+ },
3428
+ React.createElement('path', {
3429
+ d: 'M7.8667 0.349609C8.96906 0.349634 10.0601 0.481272 11.0317 0.735352C11.9973 0.987845 12.8453 1.362 13.4644 1.84766C14.0744 2.32629 14.507 2.95539 14.5161 3.69336H14.5171V8.53516C14.0843 8.32076 13.6108 8.17679 13.1108 8.11816C13.1831 7.96848 13.2162 7.82856 13.2163 7.70312V5.76758C12.6269 6.16618 11.8739 6.47995 11.0317 6.7002C10.0602 6.95423 8.96896 7.08494 7.8667 7.08496C6.76461 7.08493 5.67411 6.95415 4.70264 6.7002C3.85994 6.48006 3.10694 6.1662 2.51709 5.76758V7.70312L2.521 7.78418C2.56374 8.19554 2.93361 8.74414 3.91357 9.23145C4.9281 9.73585 6.35004 10.0371 7.8667 10.0371C8.26373 10.0371 8.6543 10.0141 9.03271 9.97461C8.75596 10.3799 8.54664 10.8349 8.42041 11.3232C8.23666 11.3313 8.0518 11.3369 7.8667 11.3369C6.20108 11.3369 4.57025 11.01 3.33447 10.3955C3.04163 10.2499 2.76658 10.0836 2.51709 9.90039V11.6738C2.51728 12.1379 2.88589 12.7556 3.92236 13.292C4.93457 13.8157 6.35342 14.1289 7.8667 14.1289C8.12318 14.1289 8.37694 14.1161 8.62646 14.0986C8.82021 14.5535 9.08999 14.9682 9.41943 15.3271C8.91285 15.3934 8.39149 15.4287 7.8667 15.4287C6.19761 15.4287 4.56379 15.0869 3.32568 14.4463C2.11244 13.8185 1.21649 12.8562 1.21631 11.6738V3.76367C1.21595 3.74853 1.21438 3.733 1.21436 3.71777C1.21436 2.96917 1.65103 2.33053 2.26807 1.84668C2.88747 1.36112 3.73675 0.987685 4.70264 0.735352C5.67413 0.481376 6.76457 0.349636 7.8667 0.349609ZM7.8667 1.65039C6.86269 1.65042 5.88326 1.77028 5.03076 1.99316C4.17183 2.2176 3.50421 2.52956 3.06982 2.87012C2.65043 3.19909 2.52622 3.48898 2.51709 3.69336V3.74414C2.52719 3.94845 2.65185 4.23772 3.06982 4.56543C3.50425 4.90601 4.17172 5.21795 5.03076 5.44238C5.88326 5.66527 6.8627 5.78513 7.8667 5.78516C8.8707 5.78513 9.85015 5.66525 10.7026 5.44238C11.5611 5.21787 12.2286 4.9049 12.6626 4.56445C13.0982 4.22252 13.2163 3.9231 13.2163 3.71777L13.2104 3.63574C13.1818 3.43623 13.044 3.16941 12.6626 2.87012C12.2286 2.52957 11.5614 2.21773 10.7026 1.99316C9.85009 1.77025 8.8708 1.65041 7.8667 1.65039Z',
3430
+ fill: 'currentColor',
3431
+ }),
3432
+ React.createElement('path', {
3433
+ d: 'M12.8936 10.0361L13.2061 10.5566C13.2296 10.5959 13.2651 10.6562 13.3027 10.707C13.3469 10.7666 13.4148 10.8431 13.5195 10.9023C13.6244 10.9617 13.725 10.9801 13.7988 10.9873C13.8619 10.9934 13.9318 10.9932 13.9775 10.9932H14.6162L14.8896 11.4502L14.5947 11.9443C14.5698 11.9859 14.5312 12.0483 14.5029 12.1084C14.4781 12.1611 14.4514 12.2312 14.4395 12.3164L14.4326 12.4072L14.4395 12.4971C14.4514 12.5825 14.4781 12.6532 14.5029 12.7061C14.5312 12.7661 14.5689 12.8287 14.5938 12.8701L14.8896 13.3633L14.6162 13.8213H13.9775C13.9318 13.8213 13.8619 13.821 13.7988 13.8271C13.7433 13.8326 13.6728 13.8442 13.5967 13.875L13.5195 13.9121C13.4148 13.9714 13.3469 14.0478 13.3027 14.1074C13.265 14.1583 13.2296 14.2186 13.2061 14.2578L12.8936 14.7783H12.3115L11.999 14.2578C11.9755 14.2186 11.9401 14.1583 11.9023 14.1074C11.8693 14.0628 11.823 14.0083 11.7578 13.959L11.6855 13.9121L11.6074 13.875C11.5316 13.8445 11.4615 13.8325 11.4062 13.8271C11.3432 13.821 11.2733 13.8213 11.2275 13.8213H10.5889L10.3135 13.3633L10.6104 12.8701C10.6352 12.8287 10.6739 12.7661 10.7021 12.7061C10.7352 12.6357 10.7724 12.534 10.7725 12.4072C10.7724 12.2804 10.7352 12.1788 10.7021 12.1084C10.6739 12.0483 10.6353 11.9859 10.6104 11.9443L10.3135 11.4502L10.5889 10.9932H11.2275C11.2733 10.9932 11.3432 10.9934 11.4062 10.9873C11.4801 10.9801 11.5808 10.9616 11.6855 10.9023C11.7903 10.843 11.8582 10.7666 11.9023 10.707C11.94 10.6562 11.9755 10.5959 11.999 10.5566L12.3115 10.0361H12.8936Z',
3434
+ stroke: 'currentColor',
3435
+ strokeMiterlimit: '10',
3436
+ }))
3437
+ }
3438
+
3439
+ function SeatChevron(props) {
3440
+ return React.createElement('span', { className: 'rs2-chev' + (props.open ? ' rs2-chev-open' : ''), 'aria-hidden': true },
3441
+ React.createElement('svg', { viewBox: '0 0 16 16', width: '12', height: '12' },
3442
+ React.createElement('path', { d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', fill: 'none' })))
3443
+ }
3444
+
3445
+ function SeatArrow(props) {
3446
+ return React.createElement('span', { className: 'rs2-chev', 'aria-hidden': true, style: props.up ? { transform: 'rotate(180deg)' } : undefined },
3447
+ React.createElement('svg', { viewBox: '0 0 16 16', width: '11', height: '11' },
3448
+ React.createElement('path', { d: 'M4 10l4-4 4 4', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', fill: 'none' })))
3449
+ }
3450
+
3451
+ function SeatCheckIcon() {
3452
+ return React.createElement('svg', { viewBox: '0 0 16 16', width: '14', height: '14', 'aria-hidden': true },
3453
+ React.createElement('path', { d: 'M3.5 8.5l3 3 6-7', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', fill: 'none' }))
3454
+ }
3455
+
3456
+ function seatReadAdvPref() {
3457
+ try { return localStorage.getItem(SEAT_ADV_KEY) === '1' } catch (error) { return false }
3458
+ }
3459
+
3460
+ function seatWriteAdvPref(value) {
3461
+ try { localStorage.setItem(SEAT_ADV_KEY, value ? '1' : '0') } catch (error) { /* preference is best-effort */ }
3462
+ }
3463
+
3464
+ function seatReadEnabledPref() {
3465
+ try { return localStorage.getItem(SEAT_ENABLED_KEY) === '1' } catch (error) { return false }
3466
+ }
3467
+
3468
+ function seatWriteEnabledPref(value) {
3469
+ try { localStorage.setItem(SEAT_ENABLED_KEY, value ? '1' : '0') } catch (error) { /* startup hint is best-effort */ }
3470
+ }
3471
+
3472
+ /** Chinese labels for the reasoning-effort ids the model directory exposes. */
3473
+ var SEAT_EFFORT_ZH = { auto: '自动', default: '默认', none: '关', minimal: '极简', low: '低', medium: '中', high: '高', xhigh: '极高', max: '极致' }
3474
+
3475
+ function seatEffortZh(id, name) {
3476
+ if (id !== undefined) {
3477
+ var key = String(id).toLowerCase()
3478
+ if (SEAT_EFFORT_ZH[key]) return t(SEAT_EFFORT_ZH[key])
3479
+ }
3480
+ if (name) {
3481
+ var key2 = String(name).toLowerCase()
3482
+ if (SEAT_EFFORT_ZH[key2]) return t(SEAT_EFFORT_ZH[key2])
3483
+ }
3484
+ return name ? String(name) : (id === undefined ? t('自动') : String(id))
3485
+ }
3486
+
3487
+ function seatEffortStops(reasoning) {
3488
+ var stops = []
3489
+ // Keep the selectable unspecified effort even when an adapter reports
3490
+ // the current selection as a concrete effort.
3491
+ if (reasoning.defaultEffort === undefined) {
3492
+ stops.push({ id: undefined, label: t('自动') })
3493
+ }
3494
+ for (var i = 0; i < reasoning.efforts.length; i++) {
3495
+ stops.push({ id: reasoning.efforts[i].id, label: seatEffortZh(reasoning.efforts[i].id, reasoning.efforts[i].name) })
3496
+ }
3497
+ return stops
3498
+ }
3499
+
3500
+ /** Deterministic pseudo-random [0,1) — stable particle paths per index. */
3501
+ function seatPrand(i, salt) {
3502
+ var x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453
3503
+ return x - Math.floor(x)
3504
+ }
3505
+
3506
+ /** Rounded reasoning slider with motion only at the highest effort. */
3507
+ function SeatSlider(props) {
3508
+ var count = props.count
3509
+ var value = props.value
3510
+ var trackRef = React.useRef(null)
3511
+ var dragRectRef = React.useRef(null)
3512
+ var draggingRef = React.useRef(false)
3513
+ var valueRef = React.useRef(value)
3514
+ valueRef.current = value
3515
+ var max = Math.max(count - 1, 1)
3516
+ var isMax = count > 1 && value === count - 1
3517
+ function posToIndex(clientX) {
3518
+ var el = trackRef.current
3519
+ if (!el) return valueRef.current
3520
+ var rect = dragRectRef.current || el.getBoundingClientRect()
3521
+ var ratio = Math.min(1, Math.max(0, (clientX - rect.left - 14) / Math.max(rect.width - 28, 1)))
3522
+ return Math.round(ratio * max)
3523
+ }
3524
+ function previewIndex(index) {
3525
+ if (index === valueRef.current) return
3526
+ valueRef.current = index
3527
+ props.onPreview(index)
3528
+ }
3529
+ var tickEls = []
3530
+ for (var i = 0; i < count; i++) {
3531
+ tickEls.push(React.createElement('span', {
3532
+ key: 't' + i,
3533
+ className: 'rs2-tick-shell',
3534
+ style: { '--rs2-p': String(i / max), '--rs2-delay': (i * 0.07).toFixed(2) + 's' },
3535
+ }, React.createElement('span', {
3536
+ className: 'rs2-tick' + (i <= value ? ' rs2-tick-on' : ''),
3537
+ })))
3538
+ }
3539
+ var streamEls = []
3540
+ if (isMax) {
3541
+ for (var s = 0; s < 18; s++) {
3542
+ var duration = 3.6 + seatPrand(s, 6) * 1.8
3543
+ streamEls.push(React.createElement('span', {
3544
+ key: 's' + s,
3545
+ className: 'rs2-stream',
3546
+ style: {
3547
+ '--rs2-y': (15 + seatPrand(s, 2) * 70).toFixed(1) + '%',
3548
+ '--rs2-size': (seatPrand(s, 3) < 0.7 ? 1.5 : 2) + 'px',
3549
+ '--rs2-duration': duration.toFixed(2) + 's',
3550
+ '--rs2-delay': (-seatPrand(s, 7) * duration).toFixed(2) + 's',
3551
+ },
3552
+ }, React.createElement('span', { className: 'rs2-stream-dot' })))
3553
+ }
3554
+ }
3555
+ var p = String(max === 0 ? 0 : value / max)
3556
+ var kids = [React.createElement('div', { key: 'rail', className: 'rs2-rail' },
3557
+ React.createElement('div', { className: 'rs2-fill' }),
3558
+ tickEls,
3559
+ streamEls)]
3560
+ kids.push(React.createElement('div', { key: 'thumb', className: 'rs2-dot' }))
3561
+ return React.createElement('div', {
3562
+ ref: trackRef,
3563
+ className: 'rs2-track' + (isMax ? ' rs2-max' : ''),
3564
+ role: 'slider',
3565
+ 'aria-label': t('推理强度'),
3566
+ 'aria-valuemin': 0,
3567
+ 'aria-valuemax': max,
3568
+ 'aria-valuenow': value,
3569
+ style: { '--rs2-p': p },
3570
+ onPointerDown: function (event) {
3571
+ event.preventDefault()
3572
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { /* capture is best-effort */ }
3573
+ dragRectRef.current = trackRef.current ? trackRef.current.getBoundingClientRect() : null
3574
+ draggingRef.current = true
3575
+ previewIndex(posToIndex(event.clientX))
3576
+ },
3577
+ onPointerMove: function (event) {
3578
+ if (!draggingRef.current) return
3579
+ previewIndex(posToIndex(event.clientX))
3580
+ },
3581
+ onPointerUp: function (event) {
3582
+ if (!draggingRef.current) return
3583
+ draggingRef.current = false
3584
+ try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (error) { /* capture is best-effort */ }
3585
+ var index = posToIndex(event.clientX)
3586
+ dragRectRef.current = null
3587
+ props.onCommit(index)
3588
+ },
3589
+ onLostPointerCapture: function () { draggingRef.current = false; dragRectRef.current = null },
3590
+ }, kids)
3591
+ }
3592
+
3593
+ /**
3594
+ * The seat component. `props` come from the slot definition's inject():
3595
+ * { available, store, load, select }.
3596
+ */
3597
+ function ModelSeat(props) {
3598
+ useLocaleRevision()
3599
+ var store = props.store
3600
+ var state = React.useSyncExternalStore(
3601
+ function (onChange) { return store ? store.subscribe(onChange) : function () {} },
3602
+ function () { return store ? store.getSnapshot() : null },
3603
+ )
3604
+ React.useEffect(function () {
3605
+ if (props.available && store) props.load()
3606
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3607
+ }, [props.available, !!store])
3608
+ var openState = React.useState(false)
3609
+ var open = openState[0]
3610
+ var setOpen = openState[1]
3611
+ var modeState = React.useState('advanced')
3612
+ var mode = modeState[0]
3613
+ var setMode = modeState[1]
3614
+ var optState = React.useState(null)
3615
+ var opt = optState[0]
3616
+ var setOpt = optState[1]
3617
+ var rootRef = React.useRef(null)
3618
+ var selectingRef = React.useRef(false)
3619
+ var listRef = React.useRef(null)
3620
+ React.useEffect(function () {
3621
+ if (!open || mode !== 'models') return
3622
+ var list = listRef.current
3623
+ if (!list || typeof list.querySelector !== 'function') return
3624
+ var selected = list.querySelector('[data-selected="1"]')
3625
+ if (!selected) return
3626
+ try {
3627
+ var listRect = list.getBoundingClientRect()
3628
+ var selectedRect = selected.getBoundingClientRect()
3629
+ list.scrollTop = list.scrollTop + (selectedRect.top - listRect.top) - list.clientHeight / 2 + selected.clientHeight / 2
3630
+ } catch (error) { /* scrolling is cosmetic */ }
3631
+ }, [open, mode])
3632
+ /**
3633
+ * Keep the popover inside the viewport on narrow screens.
3634
+ *
3635
+ * The menu's CSS anchor follows the trigger center. Re-measure when it
3636
+ * opens or the model directory changes, then shift only if that centered
3637
+ * box crosses a viewport edge. Height is capped on short windows.
3638
+ */
3639
+ React.useEffect(function () {
3640
+ if (!open) return undefined
3641
+ var root = rootRef.current
3642
+ if (!root || typeof root.querySelector !== 'function') return undefined
3643
+ var win = (typeof window !== 'undefined' ? window : undefined)
3644
+ if (!win || typeof win.addEventListener !== 'function') return undefined
3645
+ var apply = function () {
3646
+ var menu = root.querySelector('.rs2-menu')
3647
+ if (!menu || typeof menu.getBoundingClientRect !== 'function' || !menu.style) return
3648
+ // Reset first so the measurement reflects the centered CSS anchor,
3649
+ // not a previous clamp (the effect re-runs on resize).
3650
+ menu.style.transform = ''
3651
+ menu.style.maxHeight = ''
3652
+ try {
3653
+ var rect = menu.getBoundingClientRect()
3654
+ var fix = seatClamp(rect, win.innerWidth, win.innerHeight)
3655
+ if (fix.shift) menu.style.transform = 'translateX(calc(-50% + ' + fix.shift + 'px))'
3656
+ if (fix.maxHeight) menu.style.maxHeight = fix.maxHeight + 'px'
3657
+ } catch (error) { /* clamping is cosmetic */ }
3658
+ }
3659
+ apply()
3660
+ win.addEventListener('resize', apply)
3661
+ return function () { win.removeEventListener('resize', apply) }
3662
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3663
+ }, [open, mode, state])
3664
+ React.useEffect(function () {
3665
+ if (!open) return undefined
3666
+ var onDown = function (event) {
3667
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
3668
+ setOpen(false)
3669
+ setMode('advanced')
3670
+ }
3671
+ }
3672
+ if (typeof document === 'undefined') return undefined
3673
+ document.addEventListener('mousedown', onDown)
3674
+ return function () { document.removeEventListener('mousedown', onDown) }
3675
+ }, [open])
3676
+ React.useEffect(function () {
3677
+ if (!opt) return undefined
3678
+ return props.scheduleClear(function () {
3679
+ setOpt(function (current) { return current === opt ? null : current })
3680
+ })
3681
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3682
+ }, [opt])
3683
+ if (!props.available || !store || !state) return null
3684
+ var current = state.current || null
3685
+ var groups = state.groups || []
3686
+ var model = current ? seatFindEntry(groups, current.provider, current.model) : undefined
3687
+ var displayName = current ? ((model && model.name) || current.model) : ''
3688
+ var reasoning = (current && model) ? model.reasoning : undefined
3689
+ var effLabel
3690
+ if (reasoning) {
3691
+ var eff = current.reasoningEffort !== undefined ? current.reasoningEffort : reasoning.defaultEffort
3692
+ effLabel = eff === undefined ? t('自动') : seatEffortZh(eff, (reasoning.efforts.find(function (x) { return x.id === eff }) || {}).name || eff)
3693
+ }
3694
+ function commitSelection(selection) {
3695
+ selectingRef.current = true
3696
+ return Promise.resolve(props.select(selection)).finally(function () {
3697
+ selectingRef.current = false
3698
+ setOpt(null)
3699
+ })
3700
+ }
3701
+ var stops = []
3702
+ var value = 0
3703
+ var committedIdx = 0
3704
+ if (mode === 'advanced' && current && reasoning && reasoning.efforts.length > 0) {
3705
+ stops = seatEffortStops(reasoning)
3706
+ var idx = -1
3707
+ for (var si = 0; si < stops.length; si++) { if (stops[si].id === current.reasoningEffort) { idx = si; break } }
3708
+ if (idx === -1) for (var sj = 0; sj < stops.length; sj++) { if (stops[sj].id === undefined) { idx = sj; break } }
3709
+ if (idx === -1) idx = 0
3710
+ committedIdx = idx
3711
+ value = opt && opt.provider === current.provider && opt.model === current.model && opt.value !== idx ? opt.value : idx
3712
+ }
3713
+ function commitStop(i) {
3714
+ if (!current || selectingRef.current) { setOpt(null); return }
3715
+ var stop = stops[i]
3716
+ if (!stop || i === committedIdx) { setOpt(null); return }
3717
+ var selection = { provider: current.provider, model: current.model }
3718
+ if (stop.id !== undefined) selection.reasoningEffort = stop.id
3719
+ commitSelection(selection)
3720
+ }
3721
+ function pickModel(group, entry) {
3722
+ setOpt(null)
3723
+ var selection = { provider: group.id, model: entry.id }
3724
+ if (entry.reasoning && entry.reasoning.defaultEffort !== undefined) selection.reasoningEffort = entry.reasoning.defaultEffort
3725
+ commitSelection(selection)
3726
+ setMode('advanced')
3727
+ }
3728
+ function pickEffort(id) {
3729
+ if (!current) return
3730
+ setOpt(null)
3731
+ var selection = { provider: current.provider, model: current.model }
3732
+ if (id !== undefined) selection.reasoningEffort = id
3733
+ commitSelection(selection)
3734
+ setOpen(false)
3735
+ setMode('advanced')
3736
+ }
3737
+ var body
3738
+ if (mode === 'models') {
3739
+ body = React.createElement(React.Fragment, null,
3740
+ React.createElement('button', { type: 'button', className: 'rs2-back', onClick: function () { setMode('advanced') } }, t('‹ 模型')),
3741
+ React.createElement('div', { className: 'rs2-list', ref: listRef },
3742
+ groups.length === 0 ? React.createElement('div', { className: 'rs2-grouptitle' }, state.status === 'error' ? t('模型目录加载失败') : t('加载中…')) : null,
3743
+ groups.map(function (group) {
3744
+ return React.createElement('div', { key: group.id },
3745
+ React.createElement('div', { className: 'rs2-grouptitle' }, group.name || group.id),
3746
+ group.models.map(function (entry) {
3747
+ var isSelected = !!current && current.provider === group.id && current.model === entry.id
3748
+ return React.createElement('button', {
3749
+ key: entry.id,
3750
+ type: 'button',
3751
+ className: 'rs2-option',
3752
+ 'data-selected': isSelected ? '1' : '0',
3753
+ onClick: function () { pickModel(group, entry) },
3754
+ },
3755
+ React.createElement('span', { className: 'rs2-optname' }, entry.name || entry.id),
3756
+ React.createElement('span', { style: { flex: 1 } }),
3757
+ isSelected ? React.createElement('span', { className: 'rs2-check' }, React.createElement(SeatCheckIcon, null)) : null)
3758
+ })
3759
+ )
3760
+ })
3761
+ )
3762
+ )
3763
+ } else {
3764
+ body = React.createElement(React.Fragment, null,
3765
+ React.createElement('div', { className: 'rs2-advhead' },
3766
+ React.createElement('button', { type: 'button', className: 'rs2-modeljump', onClick: function () { setMode('models') } },
3767
+ React.createElement('span', { className: 'rs2-modeljump-name' }, current ? displayName : t('选择模型')),
3768
+ React.createElement(SeatChevron, { open: false })),
3769
+ React.createElement('div', { className: 'rs2-curlevel' + (stops.length > 1 && value === stops.length - 1 ? ' rs2-curlevel-max' : '') }, (stops[value] || {}).label || effLabel)),
3770
+ React.createElement('div', { className: 'rs2-sliderbox' },
3771
+ React.createElement(SeatSlider, {
3772
+ count: stops.length,
3773
+ value: value,
3774
+ onPreview: function (i) { if (current) setOpt({ provider: current.provider, model: current.model, value: i }) },
3775
+ onCommit: function (i) { commitStop(i) },
3776
+ })))
3777
+ }
3778
+ return React.createElement('div', { className: 'rs2-root', ref: rootRef },
3779
+ React.createElement('button', {
3780
+ type: 'button',
3781
+ className: 'rs2-trigger',
3782
+ 'aria-expanded': open,
3783
+ disabled: props.locked === true,
3784
+ onClick: function () {
3785
+ setOpt(null)
3786
+ if (props.available) props.load()
3787
+ setOpen(function (v) {
3788
+ var next = !v
3789
+ if (next) setMode('advanced')
3790
+ return next
3791
+ })
3792
+ },
3793
+ },
3794
+ // Compact-mode glyph first, exactly like the official seat: the host
3795
+ // row toggles the two display variables defined above, so this icon
3796
+ // appears only when the composer cannot fit the text.
3797
+ React.createElement('span', { className: 'rs2-trigger-icon', 'aria-hidden': true }, React.createElement(SeatDataIcon, null)),
3798
+ React.createElement('span', { className: 'rs2-trigger-name' }, current ? displayName : t('选择模型')),
3799
+ reasoning ? React.createElement('span', { className: 'rs2-trigger-effort' }, effLabel) : null,
3800
+ React.createElement(SeatChevron, { open: open })),
3801
+ open && React.createElement('div', { className: 'rs2-menu' }, body))
3802
+ }
3803
+
3804
+ /**
3805
+ * Dispose handle of the seat registration synchronously: the returned
3806
+ * disposer first detaches the entry, and only then does the deferred
3807
+ * `slots.inject` callback update the live registration — the two orderings
3808
+ * cover both a live and a not-yet-run slots.inject.
3809
+ */
3810
+ function seatDisposer() {
3811
+ var state = { disposed: false, disposer: undefined, scope: undefined }
3812
+ function dispose() {
3813
+ if (state.disposed) return
3814
+ state.disposed = true
3815
+ if (typeof state.disposer === 'function') state.disposer()
3816
+ if (state.scope && typeof state.scope.dispose === 'function') state.scope.dispose()
3817
+ }
3818
+ return {
3819
+ state: state,
3820
+ dispose: dispose,
3821
+ }
3822
+ }
3823
+
3824
+ /**
3825
+ * Register the seat once the slots service is up. Returns null (with a
3826
+ * warning) when the host has no slots service.
3827
+ */
3828
+ function registerModelSeat(ctx) {
3829
+ var slots = ctx.get('slots')
3830
+ if (slots === undefined) {
3831
+ console.warn('[dsh-usage-stats] 高级模型选择器: slots service unavailable')
3832
+ return null
3833
+ }
3834
+ var models = ctx.get('modelDirectories')
3835
+ var sessions = ctx.get('sessions')
3836
+ if (models === undefined || typeof models.directoryFor !== 'function' || sessions === undefined || typeof sessions.subagentAddress !== 'function') {
3837
+ console.warn('[dsh-usage-stats] 高级模型选择器: modelDirectories/sessions service unavailable')
3838
+ return null
3839
+ }
3840
+ var handle = seatDisposer()
3841
+ // The effort-preview clear cancels its 2s timer when the component
3842
+ // unmounts, so every timer created inside the seat is owned by it.
3843
+ var timers = new Set()
3844
+ function scheduleClear(fn) {
3845
+ var fire = function () {
3846
+ timers.delete(fire)
3847
+ fn()
3848
+ }
3849
+ var stop = ctx.timeout(fire, 2000)
3850
+ timers.add(fire)
3851
+ return function () {
3852
+ if (!timers.has(fire)) return
3853
+ timers.delete(fire)
3854
+ if (typeof stop === 'function') stop()
3855
+ }
3856
+ }
3857
+ handle.state.scope = slots.inject('conversation.input.model', function () {
3858
+ if (handle.state.disposed) return undefined
3859
+ var disposer = slots.register({
3860
+ name: 'conversation.input.model',
3861
+ // Bundle-client registrations get NO automatic shadowing priority:
3862
+ // the official seat registers with priority unset, so an unset tie
3863
+ // falls back to insertion order and the official selector wins.
3864
+ // -1 beats it (same value the old dpnows file patch used).
3865
+ priority: -1,
3866
+ locale: 'model',
3867
+ inject: function (sessionId) {
3868
+ // The official modelDirectories service reaches into other
3869
+ // services here, on THIS plugin's fiber — see the inject list.
3870
+ var directory = models.directoryFor(sessionId)
3871
+ var available = sessions.subagentAddress(sessionId) === undefined
3872
+ if (!available) return { available: false }
3873
+ return {
3874
+ available: true,
3875
+ store: directory.store,
3876
+ load: function () { directory.load().catch(function () {}) },
3877
+ select: function (selection) { return directory.select(selection).then(function () { return true }, function () { return false }) },
3878
+ }
3879
+ },
3880
+ }, function (slotProps) {
3881
+ return React.createElement(ModelSeat, Object.assign({}, slotProps, { scheduleClear: scheduleClear }))
3882
+ })
3883
+ if (handle.state.disposed) {
3884
+ if (typeof disposer === 'function') disposer()
3885
+ return undefined
3886
+ }
3887
+ handle.state.disposer = disposer
3888
+ return disposer
3889
+ })
3890
+ return handle
3891
+ }
3892
+
3893
+ // Seat registration + its stylesheet, held per plugin instance so a
3894
+ // runtime toggle can dispose and re-register.
3895
+ var seatRegistration = null
3896
+
3897
+ // Both startup and the settings page use the same host-controlled switch.
3898
+ var modelSeatSync = null
3899
+ var modelSeatSyncRevision = 0
3900
+
3901
+ function setModelSeatSync(fn) {
3902
+ modelSeatSync = fn
3903
+ }
3904
+
3905
+ /** Re-evaluate the seat from a controls payload; never throws. */
3906
+ function syncModelSeatFromControls(control) {
3907
+ if (typeof modelSeatSync !== 'function') return false
3908
+ try {
3909
+ // Absent (older host) reads as false: the switch is opt-in.
3910
+ var enabled = !!(control && control.settings && control.settings.advancedModelSelect)
3911
+ modelSeatSyncRevision++
3912
+ seatWriteEnabledPref(enabled)
3913
+ return modelSeatSync(enabled)
3914
+ } catch (error) {
3915
+ console.warn('[dsh-usage-stats] 高级模型选择器 sync failed:', error)
3916
+ return false
3917
+ }
3918
+ }
3919
+
3920
+ /** Load the saved host switch independently of the settings page. */
3921
+ function bootModelSeat(ctx, api) {
3922
+ var disposed = false
3923
+ var stopRetry = null
3924
+ var retries = 0
3925
+ function retry() {
3926
+ if (disposed || retries >= 10 || typeof ctx.timeout !== 'function') return
3927
+ var delay = Math.min(400 * Math.pow(2, retries++), 4000)
3928
+ stopRetry = ctx.timeout(load, delay)
3929
+ }
3930
+ function load() {
3931
+ stopRetry = null
3932
+ var revision = modelSeatSyncRevision
3933
+ var request
3934
+ try { request = api.controls() } catch (error) { retry(); return }
3935
+ request.then(function (body) {
3936
+ // A settings-page read/save made after this request takes precedence.
3937
+ if (disposed || revision !== modelSeatSyncRevision) return
3938
+ var enabled = !!(body && body.settings && body.settings.advancedModelSelect)
3939
+ if (!syncModelSeatFromControls(body) && enabled) retry()
3940
+ }).catch(function () {
3941
+ if (!disposed && revision === modelSeatSyncRevision) retry()
3942
+ })
3943
+ }
3944
+ load()
3945
+ return function () {
3946
+ disposed = true
3947
+ if (typeof stopRetry === 'function') stopRetry()
3948
+ }
3949
+ }
3950
+
3951
+ /** Idempotent: syncModelSeat(true) twice registers exactly one entry. */
3952
+ function syncModelSeat(ctx, enabled) {
3953
+ if (enabled !== true) {
3954
+ if (seatRegistration) {
3955
+ var stale = seatRegistration
3956
+ seatRegistration = null
3957
+ try { stale.dispose() } catch (error) {
3958
+ console.warn('[dsh-usage-stats] 高级模型选择器 unmount failed:', error)
3959
+ }
3960
+ var seatStyle = document.getElementById(SEAT_STYLE_ID)
3961
+ if (seatStyle && typeof seatStyle.remove === 'function') seatStyle.remove()
3962
+ }
3963
+ return false
3964
+ }
3965
+ if (seatRegistration) return true
3966
+ var handle
3967
+ try {
3968
+ handle = registerModelSeat(ctx)
3969
+ } catch (error) {
3970
+ console.warn('[dsh-usage-stats] 高级模型选择器 mount failed:', error)
3971
+ return false
3972
+ }
3973
+ if (handle === null) return false
3974
+ seatRegistration = handle
3975
+ try {
3976
+ injectSeatStyles(ctx)
3977
+ } catch (error) {
3978
+ console.warn('[dsh-usage-stats] 高级模型选择器 styles failed:', error)
3979
+ }
3980
+ return true
3981
+ }
3982
+
3983
+ /** The seat stylesheet, removable on dispose and refreshable in place. */
3984
+ function injectSeatStyles(ctx) {
3985
+ var dispose = function () {
3986
+ var style = document.getElementById(SEAT_STYLE_ID)
3987
+ if (style && typeof style.remove === 'function') style.remove()
3988
+ }
3989
+ return ctx.effect(function () {
3990
+ // Always refresh the content, mirroring injectStyles(): an older
3991
+ // in-page load may have injected a stale revision of these rules.
3992
+ var style = document.getElementById(SEAT_STYLE_ID)
3993
+ if (!style) {
3994
+ style = document.createElement('style')
3995
+ style.id = SEAT_STYLE_ID
3996
+ style.dataset.plugin = 'dsh-model-selection-seat'
3997
+ style.dataset.pluginCss = 'dsh-model-selection-seat/seat'
3998
+ document.head.appendChild(style)
3999
+ }
4000
+ style.textContent = SEAT_CSS
4001
+ return dispose
4002
+ }, 'dsh-usage-stats: model seat stylesheet')
4003
+ }
4004
+
4005
+ /**
4006
+ * Mount the 用量统计 settings section.
4007
+ * @param ctx - client root context (slots service).
4008
+ */
4009
+ function apply(ctx) {
4010
+ // The product bridge is exposed only in the desktop app's main renderer.
4011
+ // A browser opened against its local host must not mount desktop features.
4012
+ if (!window.dshDesktop || typeof window.dshDesktop.deviceInfo !== 'function') return
4013
+
4014
+ var slots = ctx.get('slots')
4015
+ if (slots === undefined) {
4016
+ console.warn('[dsh-usage-stats] slots service unavailable')
4017
+ return
4018
+ }
4019
+ var hostLocale = ctx.get('locale')
4020
+ if (hostLocale && typeof hostLocale.register === 'function' && typeof hostLocale.bind === 'function') {
4021
+ var dictionaries = { zh: {}, en: {}, ja: {} }
4022
+ Object.keys(messages).forEach(function (key) {
4023
+ dictionaries.zh[key] = key
4024
+ dictionaries.en[key] = messages[key][0]
4025
+ dictionaries.ja[key] = messages[key][1]
4026
+ })
4027
+ ctx.effect(function () { return hostLocale.register(SECTION_ID, dictionaries) }, 'dsh-usage-stats: translations')
4028
+ localeService = hostLocale
4029
+ translate = hostLocale.bind(SECTION_ID)
4030
+ }
4031
+ var api = new UsageApi()
4032
+ setModelSeatSync(function (enabled) { return syncModelSeat(ctx, enabled) })
4033
+ // A cached true avoids showing the official seat while the controls
4034
+ // request is in flight. The host response always confirms or corrects it.
4035
+ if (seatReadEnabledPref()) syncModelSeat(ctx, true)
4036
+ try {
4037
+ injectStyles()
4038
+ ctx.effect(watchUsageNavIcon, 'dsh-usage-stats: sidebar icon')
4039
+ ctx.effect(function () { return bootModelSeat(ctx, api) }, 'dsh-usage-stats: model seat startup')
4040
+ ctx.effect(function () {
4041
+ return slots.inject('settings.section', function () {
4042
+ return slots.register(
4043
+ {
4044
+ name: 'settings.section',
4045
+ id: SECTION_ID,
4046
+ order: 32,
4047
+ locale: SECTION_ID,
4048
+ label: function () { return t('用量统计') },
4049
+ },
4050
+ function (slotProps) {
4051
+ return React.createElement(UsageStatsSection, { api: api, intl: (slotProps && slotProps.intl !== undefined) ? slotProps.intl : true })
4052
+ },
4053
+ )
4054
+ })
4055
+ }, 'dsh-usage-stats: settings section')
4056
+ } catch (error) {
4057
+ console.warn('[dsh-usage-stats] mount failed:', error)
4058
+ }
4059
+ ctx.effect(function () {
4060
+ return function () {
4061
+ syncModelSeat(ctx, false)
4062
+ setModelSeatSync(null)
4063
+ }
4064
+ }, 'dsh-usage-stats: model seat cleanup')
4065
+ }
4066
+
4067
+ exports.inject = inject
4068
+ exports.apply = apply
4069
+ // Structural hooks for the offline layout check (scripts/render-heatmap-check.mjs).
4070
+ // Not consumed by the host: the bundle's real entry points stay inject/apply.
4071
+ exports.__components = { Heatmap: Heatmap, HeatPager: HeatPager, buildHeatmap: buildHeatmap }
4072
+ return module.exports
4073
+ },
4074
+ })