dsh-tacit 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/client/client.js +1739 -91
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +114 -30
- package/lib/index.js +3 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +170 -0
- package/lib/service.js +349 -60
- package/lib/store.js +163 -3
- package/lib/usage.js +708 -0
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -64,7 +64,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
64
64
|
'settings.learnGood': '也从混乱之后的顺利提示词中学习',
|
|
65
65
|
'bootstrap.btn': '从我最近 20 轮中学习',
|
|
66
66
|
'bootstrap.running': '学习中… {done}/{total}',
|
|
67
|
-
'bootstrap.
|
|
67
|
+
'bootstrap.runningUsd': '学习中… {done}/{total} · {usd}',
|
|
68
|
+
'bootstrap.hint': '分析你最近完成的轮次:提示词至少 8 个字符、不是单纯的「继续」类消息(「继续」「好的」…)、且还没有报告——最多 20 条。',
|
|
69
|
+
'bootstrap.estimateDoc': '使用 deepseek-v4-flash 约 $0.02–0.05,一次性。',
|
|
70
|
+
'bootstrap.preview': '有 {eligible} 轮符合条件 · 按目录价约 {usd}',
|
|
71
|
+
'bootstrap.previewMeasured': '(根据你最近 {samples} 次分析的实测数据)',
|
|
72
|
+
'bootstrap.previewDoc': '(文档估算——还没有足够的实测分析)',
|
|
68
73
|
'steer.enrich': '发送前补充学到的上下文(实验性,每次发送一次小调用)',
|
|
69
74
|
'steer.title': '智能体被告知的关于你的信息',
|
|
70
75
|
'steer.desc': '这些指令会注入每个新会话的系统提示词,让智能体替你补上你通常没说的内容。',
|
|
@@ -132,6 +137,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
132
137
|
'preview.improved': '改进后',
|
|
133
138
|
'preview.rationale': '改动说明',
|
|
134
139
|
'preview.pending': '正在分析草稿…',
|
|
140
|
+
'preview.unchanged': '草稿已经完整,无需修改。',
|
|
135
141
|
'preview.apply': '应用改进',
|
|
136
142
|
'preview.cancel': '取消',
|
|
137
143
|
'feedback.title': '这次改进有帮助吗?',
|
|
@@ -142,6 +148,131 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
142
148
|
'feedback.noted': '已记录,谢谢!',
|
|
143
149
|
'panel.styleRules': '已学习的风格规则',
|
|
144
150
|
'panel.styleRulesEmpty': '还没有学习到风格规则——应用改进后给出 👎 反馈(附一句话原因),累计 3 次即可提炼出规则。',
|
|
151
|
+
'card.overview': '概览',
|
|
152
|
+
'card.usage': '用量',
|
|
153
|
+
'card.pricing': '价格',
|
|
154
|
+
'card.learning': '学习',
|
|
155
|
+
'card.guidance': '智能体指令',
|
|
156
|
+
'card.improve': '改进与反馈',
|
|
157
|
+
'card.history': '分析历史',
|
|
158
|
+
'card.privacy': '数据与隐私',
|
|
159
|
+
'card.expand': '展开这一节',
|
|
160
|
+
'card.collapse': '收起这一节',
|
|
161
|
+
'overview.model': '模型:{model}',
|
|
162
|
+
'usage.empty': '还没有计费调用——用量从 {since} 开始记录。',
|
|
163
|
+
'usage.today': '今日',
|
|
164
|
+
'usage.month': '本月',
|
|
165
|
+
'usage.last30': '最近 30 天',
|
|
166
|
+
'usage.since': '自 {day} 起',
|
|
167
|
+
'usage.calls': '计费调用(30 天)',
|
|
168
|
+
'usage.avgAnalysis': '单次分析中位成本',
|
|
169
|
+
'usage.cachedRate': '输入缓存命中率(30 天)',
|
|
170
|
+
'usage.unpriced': '无价目调用(30 天)',
|
|
171
|
+
'usage.unpricedShort': '{n} 次无价目',
|
|
172
|
+
'usage.priceUnavailable': '无价目',
|
|
173
|
+
'usage.chartLabel': '每日花费,最近 {n} 天',
|
|
174
|
+
'usage.chartSummary': '最近 {n} 天:合计 {total};最高 {day} {max}',
|
|
175
|
+
'usage.barTitle': '{day} · {usd} · {calls} 次调用',
|
|
176
|
+
'usage.series7': '7 天',
|
|
177
|
+
'usage.series30': '30 天',
|
|
178
|
+
'usage.breakdown': '按操作分类(最近 30 天)',
|
|
179
|
+
'usage.filters': '筛选',
|
|
180
|
+
'usage.loading': '正在载入这次运行的调用明细…',
|
|
181
|
+
'usage.page': '第 {page} / {pages} 页',
|
|
182
|
+
'usage.prev': '上一页',
|
|
183
|
+
'usage.next': '下一页',
|
|
184
|
+
'usage.label': '真实用量 · 按目录价计算',
|
|
185
|
+
'usage.col.time': '时间',
|
|
186
|
+
'usage.col.op': '操作',
|
|
187
|
+
'usage.col.scope': '范围',
|
|
188
|
+
'usage.col.model': '模型',
|
|
189
|
+
'usage.col.status': '状态',
|
|
190
|
+
'usage.col.calls': '计费调用',
|
|
191
|
+
'usage.col.tokens': 'Tokens',
|
|
192
|
+
'usage.col.cost': '花费',
|
|
193
|
+
'filter.range': '时间范围',
|
|
194
|
+
'filter.type': '操作',
|
|
195
|
+
'filter.status': '状态',
|
|
196
|
+
'filter.model': '模型',
|
|
197
|
+
'filter.workspace': '工作区',
|
|
198
|
+
'filter.session': '会话 ID',
|
|
199
|
+
'filter.all': '全部',
|
|
200
|
+
'range.today': '今日',
|
|
201
|
+
'range.7d': '最近 7 天',
|
|
202
|
+
'range.30d': '最近 30 天',
|
|
203
|
+
'range.month': '本月',
|
|
204
|
+
'range.all': '全部保留期',
|
|
205
|
+
'runtype.bootstrap': '引导批量',
|
|
206
|
+
'runtype.analysis': '分析',
|
|
207
|
+
'runtype.analysis-batch': '批量分析',
|
|
208
|
+
'runtype.improve': '改进',
|
|
209
|
+
'runtype.directive-distillation': '指令提炼',
|
|
210
|
+
'runtype.style-distillation': '风格提炼',
|
|
211
|
+
'runtype.prompt-enrichment': '提示词补充',
|
|
212
|
+
'op.analysis': '分析',
|
|
213
|
+
'op.analysis-repair': '分析修复',
|
|
214
|
+
'op.directive-distillation': '指令提炼',
|
|
215
|
+
'op.style-distillation': '风格提炼',
|
|
216
|
+
'op.improve': '改进',
|
|
217
|
+
'op.improve-repair': '改进修复',
|
|
218
|
+
'op.enrichment': '上下文补充',
|
|
219
|
+
'status.success': '成功',
|
|
220
|
+
'status.partial': '部分成功',
|
|
221
|
+
'status.failed': '失败',
|
|
222
|
+
'status.ok': '成功',
|
|
223
|
+
'status.unmetered': '未计量',
|
|
224
|
+
'status.running': '进行中',
|
|
225
|
+
'attempt.effort': '推理强度 {effort}',
|
|
226
|
+
'attempt.finish': '结束 {finish} {code}',
|
|
227
|
+
'attempt.source': '{source} · {tier}',
|
|
228
|
+
'attempt.duration': '{ms} 毫秒',
|
|
229
|
+
'warn.daily': '今日 {spent} / 每日上限 {limit}({pct}%)',
|
|
230
|
+
'warn.monthly': '本月 {spent} / 每月上限 {limit}({pct}%)',
|
|
231
|
+
'improve.explain': '「改进」会在你发送前重写草稿。给某次重写点 👎 并写一句原因,Tacit 会从中提炼出持久的风格规则。',
|
|
232
|
+
'privacy.stored': '分析报告、指令和风格规则只保存在本机的 Tacit 数据目录里。一条用量记录只包含:时间、操作、会话 ID、轮次编号、工作区名称、模型、服务商、token 计数与目录价——绝不包含你的提示词或模型回复的内容。清除操作只会删除 Tacit 自己的文件。',
|
|
233
|
+
'privacy.retention': '详细用量记录保留天数',
|
|
234
|
+
'privacy.warnDaily': '每日花费提醒阈值(美元)',
|
|
235
|
+
'privacy.warnMonthly': '每月花费提醒阈值(美元)',
|
|
236
|
+
'privacy.warnHint': '填 0 表示关闭提醒。达到阈值的 80% 时开始提醒,超过阈值后标记为已超出。',
|
|
237
|
+
'privacy.clearUsage': '清除用量记录',
|
|
238
|
+
'privacy.apply': '应用',
|
|
239
|
+
'confirm.reportsTitle': '清除所有分析报告?',
|
|
240
|
+
'confirm.reportsBody': '这会删除 Tacit 为所有会话写下的全部分析报告。指令、风格规则和用量记录会保留。此操作无法撤销。',
|
|
241
|
+
'confirm.usageTitle': '清除用量记录?',
|
|
242
|
+
'confirm.usageBody': '这会删除 Tacit 的用量账本——每一次记录的调用、它的 token 计数和目录价——并重新开始计量窗口。分析报告和指令会保留。此操作无法撤销。',
|
|
243
|
+
'confirm.cancel': '取消',
|
|
244
|
+
'confirm.clear': '清除',
|
|
245
|
+
'pricing.summary': '{model} {tier} {rates} / 100 万 · {source}({asOf})',
|
|
246
|
+
'pricing.rateTable': '每 100 万 tokens 的目录价',
|
|
247
|
+
'pricing.model': '模型',
|
|
248
|
+
'pricing.tier': '时段',
|
|
249
|
+
'pricing.cacheHit': '缓存命中输入',
|
|
250
|
+
'pricing.cacheMiss': '输入',
|
|
251
|
+
'pricing.output': '输出',
|
|
252
|
+
'pricing.reasoning': '推理',
|
|
253
|
+
'pricing.reasoningSameAsOutput': '按输出计费',
|
|
254
|
+
'pricing.peak': '高峰',
|
|
255
|
+
'pricing.offPeak': '非高峰',
|
|
256
|
+
'pricing.tierNow': '当前时段:{tier}',
|
|
257
|
+
'pricing.schedule': '高峰时段为 UTC 01:00–04:00 与 06:00–10:00,其余时间均为非高峰;时段按调用开始的时刻判定。',
|
|
258
|
+
'pricing.weekendRule': '自 2026-08-22 16:00 UTC 起,北京日历的周末(UTC+8 的周六与周日)全天按非高峰计费。',
|
|
259
|
+
'pricing.source': '价目来源',
|
|
260
|
+
'pricing.sourceBundled': '内置价目表',
|
|
261
|
+
'pricing.sourceCostMeter': 'cost-meter 服务',
|
|
262
|
+
'pricing.refreshedAt': '刷新于 {time}',
|
|
263
|
+
'pricing.never': '从未刷新',
|
|
264
|
+
'pricing.formula': '费用 =(输入 × 输入价 + 缓存命中输入 × 缓存价 + 输出 × 输出价)÷ 1,000,000',
|
|
265
|
+
'pricing.accuracy': '目录价;以服务商账单为准',
|
|
266
|
+
'pricing.refresh': '刷新价目',
|
|
267
|
+
'pricing.refreshing': '刷新中…',
|
|
268
|
+
'pricing.error': '无法读取价目来源:{error}',
|
|
269
|
+
'notice.bootstrap': '引导完成 · 已分析 {analyzed} 条 · 跳过 {skipped} 条 · {calls} 次计费调用 · {tokens} tokens · 目录价 {usd}',
|
|
270
|
+
'notice.analyze': '已分析 #{turn} · {calls} 次调用 · {tokens} tokens · {usd}',
|
|
271
|
+
'notice.improve': '已改进草稿 · {calls} 次调用 · {tokens} tokens · {usd}',
|
|
272
|
+
'notice.batch': '已分析 {analyzed}/{requested} 条 · {calls} 次调用 · {tokens} tokens · {usd}',
|
|
273
|
+
'notice.usageCleared': '用量记录已清除 · 删除 {n} 天的记录 · 计量窗口重新开始',
|
|
274
|
+
'notice.pricingRefreshed': '价目已刷新 · {source}({asOf})',
|
|
275
|
+
'notice.unpriced': ' · {n} 次无价目',
|
|
145
276
|
'err.bad-request': '请求无效。',
|
|
146
277
|
'err.bad-json': '请求体无效。',
|
|
147
278
|
'err.no-session': '当前会话不可用(可能尚未在本进程恢复)。',
|
|
@@ -178,7 +309,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
178
309
|
'settings.learnGood': 'Also learn from a clean prompt right after a messy turn',
|
|
179
310
|
'bootstrap.btn': 'Learn from my last 20 turns',
|
|
180
311
|
'bootstrap.running': 'Learning… {done}/{total}',
|
|
181
|
-
'bootstrap.
|
|
312
|
+
'bootstrap.runningUsd': 'Learning… {done}/{total} · {usd}',
|
|
313
|
+
'bootstrap.hint': 'Analyzes your newest finished turns whose prompt is at least 8 characters, is not a bare continuation (“continue”, “ok”…), and has no report yet — up to 20.',
|
|
314
|
+
'bootstrap.estimateDoc': '≈ $0.02–0.05 with deepseek-v4-flash, one time',
|
|
315
|
+
'bootstrap.preview': '{eligible} turn(s) eligible · ≈{usd} at list price',
|
|
316
|
+
'bootstrap.previewMeasured': '(measured from your last {samples} analyses)',
|
|
317
|
+
'bootstrap.previewDoc': '(documented estimate — not enough measured analyses yet)',
|
|
182
318
|
'steer.enrich': 'Add learned context before each send (experimental — one small call per send)',
|
|
183
319
|
'steer.title': 'What the agent is told about you',
|
|
184
320
|
'steer.desc': 'These directives ride every new session\'s system prompt so the agent fills in what you usually leave unsaid.',
|
|
@@ -246,6 +382,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
246
382
|
'preview.improved': 'Improved',
|
|
247
383
|
'preview.rationale': 'What changed',
|
|
248
384
|
'preview.pending': 'Analyzing your draft…',
|
|
385
|
+
'preview.unchanged': 'Your draft is already complete — nothing to change.',
|
|
249
386
|
'preview.apply': 'Apply improvement',
|
|
250
387
|
'preview.cancel': 'Cancel',
|
|
251
388
|
'feedback.title': 'Was this better?',
|
|
@@ -256,6 +393,131 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
256
393
|
'feedback.noted': 'Noted — thanks!',
|
|
257
394
|
'panel.styleRules': 'Learned style rules',
|
|
258
395
|
'panel.styleRulesEmpty': 'No style rules yet — apply an improvement and rate it 👎 with a one-line reason; after 3, Tacit distills durable rules from them.',
|
|
396
|
+
'card.overview': 'Overview',
|
|
397
|
+
'card.usage': 'Usage',
|
|
398
|
+
'card.pricing': 'Pricing',
|
|
399
|
+
'card.learning': 'Learning',
|
|
400
|
+
'card.guidance': 'Agent guidance',
|
|
401
|
+
'card.improve': 'Improve & feedback',
|
|
402
|
+
'card.history': 'Analysis history',
|
|
403
|
+
'card.privacy': 'Data & privacy',
|
|
404
|
+
'card.expand': 'Expand this section',
|
|
405
|
+
'card.collapse': 'Collapse this section',
|
|
406
|
+
'overview.model': 'Model: {model}',
|
|
407
|
+
'usage.empty': 'No billed calls yet — usage has been tracked since {since}.',
|
|
408
|
+
'usage.today': 'Today',
|
|
409
|
+
'usage.month': 'This month',
|
|
410
|
+
'usage.last30': 'Last 30 days',
|
|
411
|
+
'usage.since': 'Since {day}',
|
|
412
|
+
'usage.calls': 'Billed calls (30 d)',
|
|
413
|
+
'usage.avgAnalysis': 'Median cost per analysis',
|
|
414
|
+
'usage.cachedRate': 'Cached input rate (30 d)',
|
|
415
|
+
'usage.unpriced': 'Unpriced calls (30 d)',
|
|
416
|
+
'usage.unpricedShort': '{n} unpriced',
|
|
417
|
+
'usage.priceUnavailable': 'No price',
|
|
418
|
+
'usage.chartLabel': 'Daily spend, last {n} days',
|
|
419
|
+
'usage.chartSummary': 'Last {n} days: total {total}; highest {day} {max}',
|
|
420
|
+
'usage.barTitle': '{day} · {usd} · {calls} calls',
|
|
421
|
+
'usage.series7': '7 d',
|
|
422
|
+
'usage.series30': '30 d',
|
|
423
|
+
'usage.breakdown': 'By operation (last 30 days)',
|
|
424
|
+
'usage.filters': 'Filters',
|
|
425
|
+
'usage.loading': 'Loading this run\u2019s calls…',
|
|
426
|
+
'usage.page': 'Page {page} of {pages}',
|
|
427
|
+
'usage.prev': 'Previous',
|
|
428
|
+
'usage.next': 'Next',
|
|
429
|
+
'usage.label': 'Measured usage · list-price cost',
|
|
430
|
+
'usage.col.time': 'Time',
|
|
431
|
+
'usage.col.op': 'Operation',
|
|
432
|
+
'usage.col.scope': 'Scope',
|
|
433
|
+
'usage.col.model': 'Model',
|
|
434
|
+
'usage.col.status': 'Status',
|
|
435
|
+
'usage.col.calls': 'Billed calls',
|
|
436
|
+
'usage.col.tokens': 'Tokens',
|
|
437
|
+
'usage.col.cost': 'Cost',
|
|
438
|
+
'filter.range': 'Range',
|
|
439
|
+
'filter.type': 'Operation',
|
|
440
|
+
'filter.status': 'Status',
|
|
441
|
+
'filter.model': 'Model',
|
|
442
|
+
'filter.workspace': 'Workspace',
|
|
443
|
+
'filter.session': 'Session id',
|
|
444
|
+
'filter.all': 'All',
|
|
445
|
+
'range.today': 'Today',
|
|
446
|
+
'range.7d': 'Last 7 days',
|
|
447
|
+
'range.30d': 'Last 30 days',
|
|
448
|
+
'range.month': 'This month',
|
|
449
|
+
'range.all': 'Everything retained',
|
|
450
|
+
'runtype.bootstrap': 'Bootstrap batch',
|
|
451
|
+
'runtype.analysis': 'Analysis',
|
|
452
|
+
'runtype.analysis-batch': 'Analysis batch',
|
|
453
|
+
'runtype.improve': 'Improve',
|
|
454
|
+
'runtype.directive-distillation': 'Directive distillation',
|
|
455
|
+
'runtype.style-distillation': 'Style distillation',
|
|
456
|
+
'runtype.prompt-enrichment': 'Prompt enrichment',
|
|
457
|
+
'op.analysis': 'analysis',
|
|
458
|
+
'op.analysis-repair': 'analysis repair',
|
|
459
|
+
'op.directive-distillation': 'directive distillation',
|
|
460
|
+
'op.style-distillation': 'style distillation',
|
|
461
|
+
'op.improve': 'improve',
|
|
462
|
+
'op.improve-repair': 'improve repair',
|
|
463
|
+
'op.enrichment': 'enrichment',
|
|
464
|
+
'status.success': 'success',
|
|
465
|
+
'status.partial': 'partial',
|
|
466
|
+
'status.failed': 'failed',
|
|
467
|
+
'status.ok': 'ok',
|
|
468
|
+
'status.unmetered': 'unmetered',
|
|
469
|
+
'status.running': 'running',
|
|
470
|
+
'attempt.effort': 'effort {effort}',
|
|
471
|
+
'attempt.finish': 'finish {finish} {code}',
|
|
472
|
+
'attempt.source': '{source} · {tier}',
|
|
473
|
+
'attempt.duration': '{ms} ms',
|
|
474
|
+
'warn.daily': 'Today {spent} of the {limit} daily cap ({pct}%)',
|
|
475
|
+
'warn.monthly': 'This month {spent} of the {limit} monthly cap ({pct}%)',
|
|
476
|
+
'improve.explain': 'Improve rewrites your draft before you send it. Rate a rewrite 👎 with a one-line reason and Tacit distills a durable style rule from it.',
|
|
477
|
+
'privacy.stored': 'Analysis reports, directives and style rules are stored on this machine only, in Tacit’s own data directory. A usage record holds the time, the operation, the session id, the turn number, the workspace name, the model, the provider, token counts and the list price — never your prompt or the response text. Clearing only ever removes Tacit’s own files.',
|
|
478
|
+
'privacy.retention': 'Keep detailed usage history (days)',
|
|
479
|
+
'privacy.warnDaily': 'Warn above this daily spend (USD)',
|
|
480
|
+
'privacy.warnMonthly': 'Warn above this monthly spend (USD)',
|
|
481
|
+
'privacy.warnHint': '0 turns the warning off. Tacit warns at 80 % of the amount and marks it exceeded above it.',
|
|
482
|
+
'privacy.clearUsage': 'Clear usage history',
|
|
483
|
+
'privacy.apply': 'Apply',
|
|
484
|
+
'confirm.reportsTitle': 'Clear all analysis reports?',
|
|
485
|
+
'confirm.reportsBody': 'This deletes every analysis report Tacit has written, for every session. Your directives, style rules and usage history are kept. This cannot be undone.',
|
|
486
|
+
'confirm.usageTitle': 'Clear usage history?',
|
|
487
|
+
'confirm.usageBody': 'This deletes Tacit’s usage ledger — every recorded call, its token counts and its list price — and restarts the tracking window. Your analysis reports and directives are kept. This cannot be undone.',
|
|
488
|
+
'confirm.cancel': 'Cancel',
|
|
489
|
+
'confirm.clear': 'Clear',
|
|
490
|
+
'pricing.summary': '{model} {tier} {rates} per 1M · {source} ({asOf})',
|
|
491
|
+
'pricing.rateTable': 'List price per 1M tokens',
|
|
492
|
+
'pricing.model': 'Model',
|
|
493
|
+
'pricing.tier': 'Tier',
|
|
494
|
+
'pricing.cacheHit': 'Cached input',
|
|
495
|
+
'pricing.cacheMiss': 'Input',
|
|
496
|
+
'pricing.output': 'Output',
|
|
497
|
+
'pricing.reasoning': 'Reasoning',
|
|
498
|
+
'pricing.reasoningSameAsOutput': 'billed as output',
|
|
499
|
+
'pricing.peak': 'peak',
|
|
500
|
+
'pricing.offPeak': 'off-peak',
|
|
501
|
+
'pricing.tierNow': 'Right now: {tier}',
|
|
502
|
+
'pricing.schedule': 'Peak is 01:00–04:00 and 06:00–10:00 UTC; every other hour is off-peak. The tier is decided when a call starts.',
|
|
503
|
+
'pricing.weekendRule': 'Beijing-calendar weekends (Saturday and Sunday in UTC+8) are off-peak all day, from 2026-08-22 16:00 UTC.',
|
|
504
|
+
'pricing.source': 'Price source',
|
|
505
|
+
'pricing.sourceBundled': 'bundled table',
|
|
506
|
+
'pricing.sourceCostMeter': 'cost-meter service',
|
|
507
|
+
'pricing.refreshedAt': 'refreshed {time}',
|
|
508
|
+
'pricing.never': 'never refreshed',
|
|
509
|
+
'pricing.formula': 'Cost = (input × input rate + cached input × cached rate + output × output rate) ÷ 1,000,000',
|
|
510
|
+
'pricing.accuracy': 'List price; your provider invoice is the billing authority',
|
|
511
|
+
'pricing.refresh': 'Refresh prices',
|
|
512
|
+
'pricing.refreshing': 'Refreshing…',
|
|
513
|
+
'pricing.error': 'Could not read the price source: {error}',
|
|
514
|
+
'notice.bootstrap': 'Bootstrap complete · {analyzed} analyzed · {skipped} skipped · {calls} billed calls · {tokens} tokens · {usd} list price',
|
|
515
|
+
'notice.analyze': 'Analyzed #{turn} · {calls} call(s) · {tokens} tokens · {usd}',
|
|
516
|
+
'notice.improve': 'Improved your draft · {calls} call(s) · {tokens} tokens · {usd}',
|
|
517
|
+
'notice.batch': 'Analyzed {analyzed} of {requested} · {calls} calls · {tokens} tokens · {usd}',
|
|
518
|
+
'notice.usageCleared': 'Usage history cleared · {n} day file(s) removed · tracking restarts now',
|
|
519
|
+
'notice.pricingRefreshed': 'Prices refreshed · {source} ({asOf})',
|
|
520
|
+
'notice.unpriced': ' · {n} unpriced',
|
|
259
521
|
'err.bad-request': 'Invalid request.',
|
|
260
522
|
'err.bad-json': 'Invalid request body.',
|
|
261
523
|
'err.no-session': 'The session is not available (it may not be restored in this process).',
|
|
@@ -372,6 +634,30 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
372
634
|
trend: null, // measured early-vs-recent trend
|
|
373
635
|
bootstrap: null, // {running, done, total}
|
|
374
636
|
coached: [], // cross-session coached-prompt entries
|
|
637
|
+
// Which Settings cards are expanded. Open state lives here (not in React
|
|
638
|
+
// state) so it survives re-renders and is seedable from the SSR suite.
|
|
639
|
+
sections: {
|
|
640
|
+
overview: true,
|
|
641
|
+
usage: true,
|
|
642
|
+
pricing: false,
|
|
643
|
+
learning: false,
|
|
644
|
+
guidance: false,
|
|
645
|
+
improve: false,
|
|
646
|
+
history: false,
|
|
647
|
+
privacy: false,
|
|
648
|
+
},
|
|
649
|
+
notice: null, // {text} — a short-lived result line, cleared after 5s
|
|
650
|
+
// A destructive action waiting on its confirmation dialog:
|
|
651
|
+
// null | {kind:'reports'|'usage'}.
|
|
652
|
+
confirm: null,
|
|
653
|
+
preview: null, // the last /bootstrap-preview envelope
|
|
654
|
+
usage: null, // the last /usage envelope
|
|
655
|
+
usageFilters: { range: '30d', type: '', status: '', model: '', workspace: '', sessionId: '', page: 1, pageSize: 20 },
|
|
656
|
+
usageRuns: {}, // runId → the full run (attempts included), fetched on first expand
|
|
657
|
+
usageExpanded: new Set(), // runIds whose attempt rows are open
|
|
658
|
+
usageLoading: false,
|
|
659
|
+
usageSeries: '30', // '7' | '30' — which sparkline the strip shows
|
|
660
|
+
pricingRefreshing: false, // a /pricing-refresh call is in flight
|
|
375
661
|
initStarted: false,
|
|
376
662
|
initDone: false,
|
|
377
663
|
error: null,
|
|
@@ -382,6 +668,217 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
382
668
|
for (const listener of rootStore.listeners) listener()
|
|
383
669
|
}
|
|
384
670
|
|
|
671
|
+
/** Expand/collapse one Settings card; unknown ids are ignored. */
|
|
672
|
+
function toggleSection(id) {
|
|
673
|
+
const key = String(id)
|
|
674
|
+
if (rootStore.sections === null || typeof rootStore.sections !== 'object'
|
|
675
|
+
|| typeof rootStore.sections[key] !== 'boolean') return
|
|
676
|
+
rootStore.sections[key] = !rootStore.sections[key]
|
|
677
|
+
notifyRoot()
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let noticeTimer = null
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Show one result line (e.g. "Bootstrap complete · 7 analyzed"). It clears
|
|
684
|
+
* itself after 5s; the timer is skipped where there is no scheduler (SSR).
|
|
685
|
+
*/
|
|
686
|
+
function setRootNotice(text) {
|
|
687
|
+
rootStore.notice = typeof text === 'string' && text.length > 0 ? { text } : null
|
|
688
|
+
notifyRoot()
|
|
689
|
+
if (typeof setTimeout !== 'function' || rootStore.notice === null) return
|
|
690
|
+
if (noticeTimer !== null && typeof clearTimeout === 'function') clearTimeout(noticeTimer)
|
|
691
|
+
noticeTimer = setTimeout(() => {
|
|
692
|
+
noticeTimer = null
|
|
693
|
+
rootStore.notice = null
|
|
694
|
+
notifyRoot()
|
|
695
|
+
}, 5000)
|
|
696
|
+
unrefTimer(noticeTimer)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Node hands back a Timeout object that keeps the process alive; the
|
|
701
|
+
* browser hands back a number. Unref'ing where it exists means a test that
|
|
702
|
+
* merely touches one of these paths can never hold the runner open.
|
|
703
|
+
*/
|
|
704
|
+
function unrefTimer(handle) {
|
|
705
|
+
if (handle !== null && typeof handle === 'object' && typeof handle.unref === 'function') handle.unref()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The bound translator, captured once when the plugin applies.
|
|
710
|
+
*
|
|
711
|
+
* A notice states what a call actually cost, so it is written where those
|
|
712
|
+
* figures land — inside the store actions. Those run far from any `kit`
|
|
713
|
+
* (a click in the conversation tab, a poll), and there is exactly one
|
|
714
|
+
* translator per plugin instance, so it is held here rather than threaded
|
|
715
|
+
* through every call site.
|
|
716
|
+
*/
|
|
717
|
+
let translate = null
|
|
718
|
+
|
|
719
|
+
function setTranslator(fn) {
|
|
720
|
+
translate = typeof fn === 'function' ? fn : null
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* A result line with the run's measured figures: billed calls, total
|
|
725
|
+
* tokens and list-price cost. A run that priced nothing says so instead of
|
|
726
|
+
* claiming `$0.0000`, and the count of unpriced calls rides as a suffix —
|
|
727
|
+
* an unmetered call has no price, not a price of zero.
|
|
728
|
+
*/
|
|
729
|
+
function runNotice(t, key, vars, run) {
|
|
730
|
+
const source = run !== null && typeof run === 'object' ? run : {}
|
|
731
|
+
const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
|
|
732
|
+
const totals = {
|
|
733
|
+
// `attempts` rides along so a run whose every call went unmetered
|
|
734
|
+
// reads as an unknown cost rather than a free one.
|
|
735
|
+
attempts: count(source.attempts),
|
|
736
|
+
billedCalls: count(source.billedCalls),
|
|
737
|
+
unpricedCalls: count(source.unpricedCalls),
|
|
738
|
+
usdKnown: count(source.usdKnown),
|
|
739
|
+
}
|
|
740
|
+
const text = t(key, {
|
|
741
|
+
...(vars === null || typeof vars !== 'object' ? {} : vars),
|
|
742
|
+
calls: fmtTokens(totals.billedCalls),
|
|
743
|
+
tokens: fmtTokens(tokensTotal(source.tokens)),
|
|
744
|
+
usd: usageSpend({ t }, totals),
|
|
745
|
+
})
|
|
746
|
+
return totals.unpricedCalls > 0 ? text + t('notice.unpriced', { n: String(totals.unpricedCalls) }) : text
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Record a run's result line on a session store, where the conversation
|
|
751
|
+
* tab's live region reads it. It stays on that store: a tab action has no
|
|
752
|
+
* business announcing itself on the Settings page.
|
|
753
|
+
*/
|
|
754
|
+
function setStoreNotice(store, key, vars, run) {
|
|
755
|
+
if (translate === null || store === null || typeof store !== 'object') return
|
|
756
|
+
store.notice = { code: key, text: runNotice(translate, key, vars, run) }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Usage ledger (the Settings → Usage card) ───────────────────────────
|
|
760
|
+
|
|
761
|
+
/** The filter payload for `/usage`: defaults always, empty strings never. */
|
|
762
|
+
function usageQuery() {
|
|
763
|
+
const filters = rootStore.usageFilters !== null && typeof rootStore.usageFilters === 'object' ? rootStore.usageFilters : {}
|
|
764
|
+
const query = {
|
|
765
|
+
range: typeof filters.range === 'string' && filters.range.length > 0 ? filters.range : '30d',
|
|
766
|
+
page: typeof filters.page === 'number' && filters.page > 0 ? Math.floor(filters.page) : 1,
|
|
767
|
+
pageSize: typeof filters.pageSize === 'number' && filters.pageSize > 0 ? Math.floor(filters.pageSize) : 20,
|
|
768
|
+
}
|
|
769
|
+
for (const key of ['type', 'status', 'model', 'workspace', 'sessionId']) {
|
|
770
|
+
const value = filters[key]
|
|
771
|
+
if (typeof value === 'string' && value.length > 0) query[key] = value
|
|
772
|
+
}
|
|
773
|
+
return query
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Read the whole cost panel in one call; a failure keeps the last envelope. */
|
|
777
|
+
async function fetchUsage() {
|
|
778
|
+
rootStore.usageLoading = true
|
|
779
|
+
try {
|
|
780
|
+
const result = await api('/usage', usageQuery())
|
|
781
|
+
if (result !== null && typeof result === 'object' && result.ok === true) rootStore.usage = result
|
|
782
|
+
} catch {
|
|
783
|
+
// A stale panel beats a blank one; the next poll tries again.
|
|
784
|
+
}
|
|
785
|
+
rootStore.usageLoading = false
|
|
786
|
+
notifyRoot()
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Narrow the runs list. Any change but an explicit page jump goes back to page 1. */
|
|
790
|
+
function setUsageFilter(patch) {
|
|
791
|
+
if (patch === null || typeof patch !== 'object') return
|
|
792
|
+
const next = { ...rootStore.usageFilters, ...patch }
|
|
793
|
+
if (patch.page === undefined) next.page = 1
|
|
794
|
+
rootStore.usageFilters = next
|
|
795
|
+
notifyRoot()
|
|
796
|
+
fetchUsage()
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Open/close one run's attempt rows, fetching its detail the first time. */
|
|
800
|
+
async function toggleUsageRun(runId) {
|
|
801
|
+
const key = String(runId)
|
|
802
|
+
if (key.length === 0) return
|
|
803
|
+
if (rootStore.usageExpanded.has(key)) {
|
|
804
|
+
rootStore.usageExpanded.delete(key)
|
|
805
|
+
notifyRoot()
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
rootStore.usageExpanded.add(key)
|
|
809
|
+
notifyRoot()
|
|
810
|
+
if (rootStore.usageRuns[key] !== undefined) return
|
|
811
|
+
try {
|
|
812
|
+
const result = await api('/usage-run', { runId: key })
|
|
813
|
+
if (result !== null && typeof result === 'object' && result.ok === true
|
|
814
|
+
&& result.run !== null && typeof result.run === 'object') {
|
|
815
|
+
rootStore.usageRuns[key] = result.run
|
|
816
|
+
}
|
|
817
|
+
} catch {
|
|
818
|
+
// The row keeps its loading line; closing and reopening retries.
|
|
819
|
+
}
|
|
820
|
+
notifyRoot()
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Re-read the prices from the cost-meter service. The card renders the
|
|
825
|
+
* rates the `/usage` envelope carries, so the refreshed table only shows
|
|
826
|
+
* once that envelope has been re-fetched.
|
|
827
|
+
*/
|
|
828
|
+
async function refreshPricing(t) {
|
|
829
|
+
if (rootStore.pricingRefreshing) return
|
|
830
|
+
rootStore.pricingRefreshing = true
|
|
831
|
+
rootStore.error = null
|
|
832
|
+
notifyRoot()
|
|
833
|
+
try {
|
|
834
|
+
const result = await api('/pricing-refresh', {})
|
|
835
|
+
if (result !== null && typeof result === 'object' && result.ok === true) {
|
|
836
|
+
const pricing = result.pricing !== null && typeof result.pricing === 'object' ? result.pricing : {}
|
|
837
|
+
if (typeof t === 'function') {
|
|
838
|
+
setRootNotice(t('notice.pricingRefreshed', {
|
|
839
|
+
source: t(pricing.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled'),
|
|
840
|
+
asOf: typeof pricing.asOf === 'string' && pricing.asOf.length > 0 ? pricing.asOf : '—',
|
|
841
|
+
}))
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
rootStore.error = errorOf(error)
|
|
848
|
+
} finally {
|
|
849
|
+
rootStore.pricingRefreshing = false
|
|
850
|
+
}
|
|
851
|
+
await fetchUsage()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Which sparkline the bar strip shows; anything unknown means 30 days. */
|
|
855
|
+
function setUsageSeries(value) {
|
|
856
|
+
rootStore.usageSeries = value === '7' ? '7' : '30'
|
|
857
|
+
notifyRoot()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** One shared 10s poll, reference-counted so remounts never stack timers. */
|
|
861
|
+
let usageTimer = null
|
|
862
|
+
let usageMounts = 0
|
|
863
|
+
|
|
864
|
+
function startUsagePolling() {
|
|
865
|
+
usageMounts += 1
|
|
866
|
+
if (usageTimer !== null || typeof setInterval !== 'function') return
|
|
867
|
+
usageTimer = setInterval(() => {
|
|
868
|
+
// A hidden tab costs nothing: the next visible tick catches up.
|
|
869
|
+
if (typeof document !== 'undefined' && document !== null && document.hidden === true) return
|
|
870
|
+
fetchUsage()
|
|
871
|
+
}, 10000)
|
|
872
|
+
unrefTimer(usageTimer)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function stopUsagePolling() {
|
|
876
|
+
usageMounts = usageMounts > 0 ? usageMounts - 1 : 0
|
|
877
|
+
if (usageMounts > 0 || usageTimer === null) return
|
|
878
|
+
if (typeof clearInterval === 'function') clearInterval(usageTimer)
|
|
879
|
+
usageTimer = null
|
|
880
|
+
}
|
|
881
|
+
|
|
385
882
|
function useRootVersion() {
|
|
386
883
|
const [version, setVersion] = useState(0)
|
|
387
884
|
useEffect(() => {
|
|
@@ -420,6 +917,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
420
917
|
rootStore.error = errorOf(error)
|
|
421
918
|
rootStore.initDone = true
|
|
422
919
|
}
|
|
920
|
+
fetchUsage()
|
|
423
921
|
notifyRoot()
|
|
424
922
|
}
|
|
425
923
|
|
|
@@ -438,6 +936,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
438
936
|
} catch {
|
|
439
937
|
// Stale view beats a broken panel.
|
|
440
938
|
}
|
|
939
|
+
fetchUsage()
|
|
441
940
|
notifyRoot()
|
|
442
941
|
}
|
|
443
942
|
|
|
@@ -456,6 +955,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
456
955
|
} catch (error) {
|
|
457
956
|
rootStore.error = errorOf(error)
|
|
458
957
|
}
|
|
958
|
+
// The warning thresholds live in the config, so the bars may have moved.
|
|
959
|
+
fetchUsage()
|
|
459
960
|
notifyRoot()
|
|
460
961
|
}
|
|
461
962
|
|
|
@@ -471,6 +972,78 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
471
972
|
} catch (error) {
|
|
472
973
|
rootStore.error = errorOf(error)
|
|
473
974
|
}
|
|
975
|
+
fetchUsage()
|
|
976
|
+
// Turns without a report are eligible again, so a preview that last read
|
|
977
|
+
// `eligible: 0` would otherwise keep the Bootstrap button disabled.
|
|
978
|
+
await fetchBootstrapPreview()
|
|
979
|
+
notifyRoot()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Delete the usage ledger and restart the tracking window. The runs the
|
|
984
|
+
* panel had expanded are dropped with it — their ids no longer exist.
|
|
985
|
+
*/
|
|
986
|
+
async function clearUsageHistory() {
|
|
987
|
+
rootStore.error = null
|
|
988
|
+
try {
|
|
989
|
+
const result = await api('/usage-clear', {})
|
|
990
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
991
|
+
rootStore.usageRuns = {}
|
|
992
|
+
rootStore.usageExpanded = new Set()
|
|
993
|
+
rootStore.usageFilters = { ...rootStore.usageFilters, page: 1 }
|
|
994
|
+
if (translate !== null) setRootNotice(translate('notice.usageCleared', { n: fmtTokens(usageNum(result.removed)) }))
|
|
995
|
+
} else {
|
|
996
|
+
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
|
|
997
|
+
}
|
|
998
|
+
} catch (error) {
|
|
999
|
+
rootStore.error = errorOf(error)
|
|
1000
|
+
}
|
|
1001
|
+
await fetchUsage()
|
|
1002
|
+
// The measured basis of the bootstrap estimate came from the ledger this
|
|
1003
|
+
// just deleted; re-price it rather than quoting figures that are gone.
|
|
1004
|
+
await fetchBootstrapPreview()
|
|
1005
|
+
notifyRoot()
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Ask before deleting. The two clears are the only irreversible actions in
|
|
1010
|
+
* the panel, so neither fires straight off a click: the kind is parked here
|
|
1011
|
+
* and the dialog does the asking.
|
|
1012
|
+
*/
|
|
1013
|
+
function openConfirm(kind) {
|
|
1014
|
+
if (kind !== 'reports' && kind !== 'usage') return
|
|
1015
|
+
// Captured before the dialog steals focus, so closing it can hand focus
|
|
1016
|
+
// back to the button that opened it.
|
|
1017
|
+
captureConfirmOpener()
|
|
1018
|
+
rootStore.confirm = { kind }
|
|
1019
|
+
notifyRoot()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function closeConfirm() {
|
|
1023
|
+
rootStore.confirm = null
|
|
1024
|
+
notifyRoot()
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Run the parked destructive action, whichever it is, and close the dialog. */
|
|
1028
|
+
async function confirmAction() {
|
|
1029
|
+
const kind = rootStore.confirm !== null && typeof rootStore.confirm === 'object' ? rootStore.confirm.kind : null
|
|
1030
|
+
rootStore.confirm = null
|
|
1031
|
+
notifyRoot()
|
|
1032
|
+
if (kind === 'reports') await clearAllRoot()
|
|
1033
|
+
else if (kind === 'usage') await clearUsageHistory()
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* "What would Bootstrap do, and what would it cost?" — read-only, no model
|
|
1038
|
+
* call. A failure leaves `preview` alone: the documented estimate stands in.
|
|
1039
|
+
*/
|
|
1040
|
+
async function fetchBootstrapPreview() {
|
|
1041
|
+
try {
|
|
1042
|
+
const result = await api('/bootstrap-preview', { limit: 20 })
|
|
1043
|
+
if (result !== null && typeof result === 'object' && result.ok === true) rootStore.preview = result
|
|
1044
|
+
} catch {
|
|
1045
|
+
// One missing hint line; the next mount tries again.
|
|
1046
|
+
}
|
|
474
1047
|
notifyRoot()
|
|
475
1048
|
}
|
|
476
1049
|
|
|
@@ -517,10 +1090,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
517
1090
|
notify(store)
|
|
518
1091
|
}
|
|
519
1092
|
|
|
520
|
-
|
|
1093
|
+
/** `t` is the caller's bound translator, used only for the result notice. */
|
|
1094
|
+
async function bootstrapAll(t) {
|
|
521
1095
|
if (rootStore.bootstrap !== null && typeof rootStore.bootstrap === 'object' && rootStore.bootstrap.running) return
|
|
522
1096
|
rootStore.bootstrap = { running: true, done: 0, total: 0 }
|
|
523
1097
|
rootStore.error = null
|
|
1098
|
+
rootStore.notice = null
|
|
524
1099
|
notifyRoot()
|
|
525
1100
|
pollBootstrap((state) => {
|
|
526
1101
|
if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') rootStore.bootstrap = state.bootstrap
|
|
@@ -528,7 +1103,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
528
1103
|
})
|
|
529
1104
|
try {
|
|
530
1105
|
const result = await api('/bootstrap', { limit: 20 })
|
|
531
|
-
if (
|
|
1106
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
1107
|
+
if (typeof t === 'function') {
|
|
1108
|
+
setRootNotice(runNotice(t, 'notice.bootstrap', {
|
|
1109
|
+
analyzed: String(typeof result.analyzed === 'number' ? result.analyzed : 0),
|
|
1110
|
+
skipped: String(typeof result.skipped === 'number' ? result.skipped : 0),
|
|
1111
|
+
}, result.run))
|
|
1112
|
+
}
|
|
1113
|
+
} else {
|
|
532
1114
|
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
|
|
533
1115
|
}
|
|
534
1116
|
} catch (error) {
|
|
@@ -536,7 +1118,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
536
1118
|
}
|
|
537
1119
|
rootStore.initStarted = false
|
|
538
1120
|
rootStore.initDone = false
|
|
1121
|
+
// `initRootStore` refetches the ledger itself, so there is no `fetchUsage`
|
|
1122
|
+
// here: a second call would only duplicate the one the batch already
|
|
1123
|
+
// makes worth issuing.
|
|
539
1124
|
await initRootStore()
|
|
1125
|
+
// Fewer turns are eligible now, and the ledger has fresh measurements to
|
|
1126
|
+
// price the next batch from.
|
|
1127
|
+
await fetchBootstrapPreview()
|
|
540
1128
|
}
|
|
541
1129
|
|
|
542
1130
|
async function editDirectives(payload) {
|
|
@@ -552,6 +1140,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
552
1140
|
} catch (error) {
|
|
553
1141
|
rootStore.error = errorOf(error)
|
|
554
1142
|
}
|
|
1143
|
+
fetchUsage()
|
|
555
1144
|
notifyRoot()
|
|
556
1145
|
}
|
|
557
1146
|
|
|
@@ -583,12 +1172,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
583
1172
|
store.inFlight[String(turn)] = true
|
|
584
1173
|
store.expanded.add(turn)
|
|
585
1174
|
store.error = null
|
|
1175
|
+
store.notice = null
|
|
586
1176
|
notify(store)
|
|
587
1177
|
api('/analyze', { sessionId: store.sessionId, turn })
|
|
588
1178
|
.then((result) => {
|
|
589
1179
|
if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
|
|
590
1180
|
store.reports[String(turn)] = result.report
|
|
591
1181
|
store.error = null
|
|
1182
|
+
// What that one analysis cost, from the envelope's own run record.
|
|
1183
|
+
setStoreNotice(store, 'notice.analyze', { turn: String(turn) }, result.run)
|
|
592
1184
|
} else {
|
|
593
1185
|
store.error = {
|
|
594
1186
|
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
@@ -608,54 +1200,68 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
608
1200
|
})
|
|
609
1201
|
}
|
|
610
1202
|
|
|
611
|
-
/**
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
|
|
619
|
-
store.reports[String(turn)] = result.report
|
|
620
|
-
store.selection.delete(turn)
|
|
621
|
-
store.expanded.add(turn)
|
|
622
|
-
store.error = null
|
|
623
|
-
} else {
|
|
624
|
-
store.error = {
|
|
625
|
-
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
626
|
-
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
|
|
630
|
-
store.profile = result.profile
|
|
631
|
-
}
|
|
632
|
-
})
|
|
633
|
-
.catch((error) => {
|
|
634
|
-
store.error = errorOf(error)
|
|
635
|
-
})
|
|
636
|
-
.finally(() => {
|
|
637
|
-
delete store.inFlight[String(turn)]
|
|
638
|
-
notify(store)
|
|
639
|
-
resolve()
|
|
640
|
-
})
|
|
641
|
-
})
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/** Coach every ticked prompt sequentially (the user-chosen 20). */
|
|
1203
|
+
/**
|
|
1204
|
+
* Coach every ticked prompt in ONE `/analyze-batch` call.
|
|
1205
|
+
*
|
|
1206
|
+
* The host runs the whole selection under a single ledger run, so the tab
|
|
1207
|
+
* reports one price for the batch instead of N separate analyses — and the
|
|
1208
|
+
* turns settle together rather than one row at a time.
|
|
1209
|
+
*/
|
|
645
1210
|
async function coachSelected(store) {
|
|
646
1211
|
if (store.batchRunning) return
|
|
647
1212
|
const turns = [...store.selection].sort((a, b) => a - b)
|
|
648
1213
|
if (turns.length === 0) return
|
|
649
1214
|
store.batchRunning = true
|
|
650
1215
|
store.error = null
|
|
1216
|
+
store.notice = null
|
|
1217
|
+
// Every ticked row shows its spinner for the whole call: the batch has no
|
|
1218
|
+
// per-turn progress to report.
|
|
1219
|
+
for (const turn of turns) store.inFlight[String(turn)] = true
|
|
651
1220
|
notify(store)
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1221
|
+
try {
|
|
1222
|
+
const result = await api('/analyze-batch', { sessionId: store.sessionId, turns })
|
|
1223
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
1224
|
+
const results = Array.isArray(result.results) ? result.results : []
|
|
1225
|
+
let analyzed = 0
|
|
1226
|
+
let failure = null
|
|
1227
|
+
for (const entry of results) {
|
|
1228
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
1229
|
+
const turn = typeof entry.turn === 'number' ? entry.turn : null
|
|
1230
|
+
if (entry.ok === true && entry.report !== null && typeof entry.report === 'object') {
|
|
1231
|
+
if (turn !== null) {
|
|
1232
|
+
store.reports[String(turn)] = entry.report
|
|
1233
|
+
store.expanded.add(turn)
|
|
1234
|
+
}
|
|
1235
|
+
analyzed += 1
|
|
1236
|
+
continue
|
|
1237
|
+
}
|
|
1238
|
+
// `busy` means an analysis of that turn was already running — a
|
|
1239
|
+
// race with the auto-analyzer, not a failure of this batch.
|
|
1240
|
+
const code = typeof entry.code === 'string' && entry.code.length > 0 ? entry.code : 'call-failed'
|
|
1241
|
+
if (failure === null && code !== 'busy') failure = { code, detail: '' }
|
|
1242
|
+
}
|
|
1243
|
+
if (failure !== null) store.error = failure
|
|
1244
|
+
if (result.profile !== null && typeof result.profile === 'object') store.profile = result.profile
|
|
1245
|
+
// What the whole batch cost, from the run the host closed for it.
|
|
1246
|
+
setStoreNotice(store, 'notice.batch', { analyzed: String(analyzed), requested: String(turns.length) }, result.run)
|
|
1247
|
+
} else {
|
|
1248
|
+
store.error = {
|
|
1249
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
1250
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
store.error = errorOf(error)
|
|
655
1255
|
}
|
|
1256
|
+
for (const turn of turns) delete store.inFlight[String(turn)]
|
|
1257
|
+
store.selection.clear()
|
|
656
1258
|
store.batchRunning = false
|
|
657
1259
|
store.selecting = false
|
|
658
1260
|
notify(store)
|
|
1261
|
+
// The batch just spent money, and analyzed turns stop being eligible for
|
|
1262
|
+
// a bootstrap; the Settings page should say both.
|
|
1263
|
+
fetchUsage()
|
|
1264
|
+
fetchBootstrapPreview()
|
|
659
1265
|
}
|
|
660
1266
|
|
|
661
1267
|
function toggleSelecting(store) {
|
|
@@ -673,11 +1279,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
673
1279
|
function improveDraft(store, draft) {
|
|
674
1280
|
if (store.preview.open || typeof draft !== 'string' || draft.trim().length === 0) return
|
|
675
1281
|
store.preview = { open: true, pending: true, original: draft, data: null, error: null }
|
|
1282
|
+
store.notice = null
|
|
676
1283
|
notify(store)
|
|
677
1284
|
api('/improve', { sessionId: store.sessionId, draft })
|
|
678
1285
|
.then((result) => {
|
|
679
1286
|
if (result !== null && typeof result === 'object' && result.ok && typeof result.improved === 'string' && result.improved.trim().length > 0) {
|
|
680
1287
|
store.preview = { ...store.preview, pending: false, data: result }
|
|
1288
|
+
setStoreNotice(store, 'notice.improve', {}, result.run)
|
|
681
1289
|
} else {
|
|
682
1290
|
store.preview = {
|
|
683
1291
|
...store.preview,
|
|
@@ -887,6 +1495,193 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
887
1495
|
}
|
|
888
1496
|
}
|
|
889
1497
|
|
|
1498
|
+
// ── Usage formatting & narrowing (money is formatted ONLY here) ─────────
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* List-price money, always four decimals — a coach analysis costs a few
|
|
1502
|
+
* hundredths of a cent, and two decimals would round every honest figure
|
|
1503
|
+
* to `$0.00`. A positive amount too small to show at four decimals reads
|
|
1504
|
+
* `< $0.0001` rather than a zero it is not.
|
|
1505
|
+
*/
|
|
1506
|
+
function fmtUsd(n) {
|
|
1507
|
+
const value = Number(n)
|
|
1508
|
+
if (!Number.isFinite(value)) return '—'
|
|
1509
|
+
if (value > 0 && value < 0.0001) return '< $0.0001'
|
|
1510
|
+
if (value < 0) return '-$' + Math.abs(value).toFixed(4)
|
|
1511
|
+
return '$' + value.toFixed(4)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* A published rate, per 1M tokens. Rates are quoted, not measured, so they
|
|
1516
|
+
* read as they are quoted: three decimals at most and no trailing zeros —
|
|
1517
|
+
* `$0.007`, `$0.22`, `$1.32`. `fmtUsd`'s four fixed decimals belong to
|
|
1518
|
+
* spend, where every hundredth of a cent is a real figure.
|
|
1519
|
+
*/
|
|
1520
|
+
function fmtRate(n) {
|
|
1521
|
+
// Strict about the type: `Number(null)` is 0, and a rate nobody quoted
|
|
1522
|
+
// must not print as a free one.
|
|
1523
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '—'
|
|
1524
|
+
const trimmed = n.toFixed(3).replace(/\.?0+$/, '')
|
|
1525
|
+
if (n > 0 && Number(trimmed) === 0) return '< $0.001'
|
|
1526
|
+
return '$' + trimmed
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Spend for one bucket of calls. A bucket that billed calls but priced
|
|
1531
|
+
* none of them says so — `$0.0000` would claim the calls were free, and an
|
|
1532
|
+
* unmetered call is never `$0.00`. Same for a bucket that made calls and
|
|
1533
|
+
* billed none of them: every call came back without usage, so its cost is
|
|
1534
|
+
* unknown, not zero. A genuinely idle bucket (no attempts) stays `$0.0000`.
|
|
1535
|
+
*/
|
|
1536
|
+
function usageSpend(kit, totals) {
|
|
1537
|
+
const attempts = usageNum(totals.attempts)
|
|
1538
|
+
const billed = usageNum(totals.billedCalls)
|
|
1539
|
+
if (billed > 0 && usageNum(totals.unpricedCalls) >= billed) return kit.t('usage.priceUnavailable')
|
|
1540
|
+
if (attempts > 0 && billed === 0) return kit.t('usage.priceUnavailable')
|
|
1541
|
+
return fmtUsd(totals.usdKnown)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** Thousands-separated token counts, grouped here so no locale data is needed. */
|
|
1545
|
+
function fmtTokens(n) {
|
|
1546
|
+
const value = Number(n)
|
|
1547
|
+
if (!Number.isFinite(value)) return '—'
|
|
1548
|
+
const grouped = String(Math.round(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
1549
|
+
return (value < 0 ? '-' : '') + grouped
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** A `YYYY-MM-DD` day, from either a day key (passed through) or an epoch ms. */
|
|
1553
|
+
function fmtDay(day) {
|
|
1554
|
+
if (typeof day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(day)) return day
|
|
1555
|
+
const value = Number(day)
|
|
1556
|
+
if (!Number.isFinite(value) || value <= 0) return '—'
|
|
1557
|
+
const date = new Date(value)
|
|
1558
|
+
if (Number.isNaN(date.getTime())) return '—'
|
|
1559
|
+
const pad = (part) => String(part).padStart(2, '0')
|
|
1560
|
+
return String(date.getFullYear()) + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** A 0..1 ratio as a whole percentage; `null`/non-finite stays an em dash. */
|
|
1564
|
+
function fmtPct(x) {
|
|
1565
|
+
const value = Number(x)
|
|
1566
|
+
if (!Number.isFinite(value)) return '—'
|
|
1567
|
+
return String(Math.round(value * 100)) + '%'
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Billed tokens in one bucket set: uncached input + output + cache reads +
|
|
1572
|
+
* cache writes. `reasoningTokens` is a subset of `outputTokens`, so adding
|
|
1573
|
+
* it would count every reasoning token twice.
|
|
1574
|
+
*/
|
|
1575
|
+
function tokensTotal(buckets) {
|
|
1576
|
+
if (buckets === null || typeof buckets !== 'object') return 0
|
|
1577
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
|
|
1578
|
+
return num(buckets.inputTokens) + num(buckets.outputTokens) + num(buckets.cacheReadTokens) + num(buckets.cacheWriteTokens)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function usageNum(value) {
|
|
1582
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function usageTokens(value) {
|
|
1586
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
1587
|
+
return {
|
|
1588
|
+
inputTokens: usageNum(source.inputTokens),
|
|
1589
|
+
outputTokens: usageNum(source.outputTokens),
|
|
1590
|
+
cacheReadTokens: usageNum(source.cacheReadTokens),
|
|
1591
|
+
cacheWriteTokens: usageNum(source.cacheWriteTokens),
|
|
1592
|
+
reasoningTokens: usageNum(source.reasoningTokens),
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/** One period card, zero-filled; the two derived figures stay nullable. */
|
|
1597
|
+
function usagePeriod(value) {
|
|
1598
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
1599
|
+
return {
|
|
1600
|
+
attempts: usageNum(source.attempts),
|
|
1601
|
+
billedCalls: usageNum(source.billedCalls),
|
|
1602
|
+
unmeteredCalls: usageNum(source.unmeteredCalls),
|
|
1603
|
+
unpricedCalls: usageNum(source.unpricedCalls),
|
|
1604
|
+
tokens: usageTokens(source.tokens),
|
|
1605
|
+
usdKnown: usageNum(source.usdKnown),
|
|
1606
|
+
avgAnalysisUsd: typeof source.avgAnalysisUsd === 'number' && Number.isFinite(source.avgAnalysisUsd) ? source.avgAnalysisUsd : null,
|
|
1607
|
+
cachedInputRate: typeof source.cachedInputRate === 'number' && Number.isFinite(source.cachedInputRate) ? source.cachedInputRate : null,
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function usageBuckets(value) {
|
|
1612
|
+
const out = {}
|
|
1613
|
+
if (value === null || typeof value !== 'object') return out
|
|
1614
|
+
for (const [key, totals] of Object.entries(value)) out[key] = usagePeriod(totals)
|
|
1615
|
+
return out
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function usageSeries(value) {
|
|
1619
|
+
if (!Array.isArray(value)) return []
|
|
1620
|
+
return value
|
|
1621
|
+
.filter((point) => point !== null && typeof point === 'object' && typeof point.day === 'string')
|
|
1622
|
+
.map((point) => ({ day: point.day, usdKnown: usageNum(point.usdKnown), billedCalls: usageNum(point.billedCalls) }))
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function usageWarning(value) {
|
|
1626
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
1627
|
+
const level = source.level === 'warn' || source.level === 'exceeded' ? source.level : 'none'
|
|
1628
|
+
return { limit: usageNum(source.limit), spent: usageNum(source.spent), level }
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function usageRunItem(value) {
|
|
1632
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
1633
|
+
const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
|
|
1634
|
+
return {
|
|
1635
|
+
runId: text('runId'),
|
|
1636
|
+
type: text('type'),
|
|
1637
|
+
status: text('status'),
|
|
1638
|
+
attempts: usageNum(source.attempts),
|
|
1639
|
+
billedCalls: usageNum(source.billedCalls),
|
|
1640
|
+
unmeteredCalls: usageNum(source.unmeteredCalls),
|
|
1641
|
+
unpricedCalls: usageNum(source.unpricedCalls),
|
|
1642
|
+
tokens: usageTokens(source.tokens),
|
|
1643
|
+
usdKnown: usageNum(source.usdKnown),
|
|
1644
|
+
trigger: text('trigger'),
|
|
1645
|
+
startedAt: usageNum(source.startedAt),
|
|
1646
|
+
endedAt: usageNum(source.endedAt),
|
|
1647
|
+
sessionId: text('sessionId'),
|
|
1648
|
+
turn: typeof source.turn === 'number' ? source.turn : null,
|
|
1649
|
+
workspace: text('workspace'),
|
|
1650
|
+
model: text('model'),
|
|
1651
|
+
provider: text('provider'),
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* The `/usage` envelope, narrowed. `null` for anything that is not a
|
|
1657
|
+
* successful report, so the card can render its empty state instead of
|
|
1658
|
+
* reaching into `undefined`.
|
|
1659
|
+
*/
|
|
1660
|
+
function usageOf(value) {
|
|
1661
|
+
if (value === null || typeof value !== 'object' || value.ok !== true) return null
|
|
1662
|
+
const runs = value.runs !== null && typeof value.runs === 'object' ? value.runs : {}
|
|
1663
|
+
const warnings = value.warnings !== null && typeof value.warnings === 'object' ? value.warnings : {}
|
|
1664
|
+
return {
|
|
1665
|
+
trackingSince: usageNum(value.trackingSince),
|
|
1666
|
+
pricing: value.pricing !== null && typeof value.pricing === 'object' ? value.pricing : {},
|
|
1667
|
+
today: usagePeriod(value.today),
|
|
1668
|
+
month: usagePeriod(value.month),
|
|
1669
|
+
last7: usagePeriod(value.last7),
|
|
1670
|
+
last30: usagePeriod(value.last30),
|
|
1671
|
+
lifetime: usagePeriod(value.lifetime),
|
|
1672
|
+
byType: usageBuckets(value.byType),
|
|
1673
|
+
byModel: usageBuckets(value.byModel),
|
|
1674
|
+
series7: usageSeries(value.series7),
|
|
1675
|
+
series30: usageSeries(value.series30),
|
|
1676
|
+
warnings: { daily: usageWarning(warnings.daily), monthly: usageWarning(warnings.monthly) },
|
|
1677
|
+
runs: {
|
|
1678
|
+
items: (Array.isArray(runs.items) ? runs.items : []).map(usageRunItem),
|
|
1679
|
+
page: usageNum(runs.page) > 0 ? Math.floor(usageNum(runs.page)) : 1,
|
|
1680
|
+
pageSize: usageNum(runs.pageSize) > 0 ? Math.floor(usageNum(runs.pageSize)) : 20,
|
|
1681
|
+
total: usageNum(runs.total),
|
|
1682
|
+
},
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
890
1685
|
// ── Components ─────────────────────────────────────────────────────────
|
|
891
1686
|
|
|
892
1687
|
function makeKit(t) {
|
|
@@ -894,20 +1689,46 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
894
1689
|
}
|
|
895
1690
|
|
|
896
1691
|
/** "Learned from N prompts · Auto-learning on · x/y today" status card. */
|
|
897
|
-
/**
|
|
898
|
-
|
|
1692
|
+
/**
|
|
1693
|
+
* "Learn from my last 20 turns" — while a bootstrap runs the label counts
|
|
1694
|
+
* the turns and what the batch has cost so far.
|
|
1695
|
+
*
|
|
1696
|
+
* The service ships `usdKnown: 0` from the first tick, so the money is
|
|
1697
|
+
* gated on a billed call, not on the field being present: before that the
|
|
1698
|
+
* label carries no figure at all, and a batch whose billed calls all came
|
|
1699
|
+
* back unpriced says so through `usageSpend` rather than claiming `$0.0000`.
|
|
1700
|
+
*/
|
|
1701
|
+
function BootstrapButton(kit, { bootstrap, onClick, disabled }) {
|
|
899
1702
|
const { t } = kit
|
|
900
1703
|
const running = bootstrap !== null && bootstrap !== undefined && typeof bootstrap === 'object' && bootstrap.running === true
|
|
1704
|
+
const progress = running
|
|
1705
|
+
? {
|
|
1706
|
+
done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0),
|
|
1707
|
+
total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0),
|
|
1708
|
+
}
|
|
1709
|
+
: null
|
|
1710
|
+
const billed = running ? usageNum(bootstrap.billedCalls) : 0
|
|
1711
|
+
const label = () => {
|
|
1712
|
+
if (!running) return t('bootstrap.btn')
|
|
1713
|
+
if (billed === 0) return t('bootstrap.running', progress)
|
|
1714
|
+
return t('bootstrap.runningUsd', {
|
|
1715
|
+
...progress,
|
|
1716
|
+
usd: usageSpend(kit, {
|
|
1717
|
+
billedCalls: billed,
|
|
1718
|
+
unpricedCalls: usageNum(bootstrap.unpricedCalls),
|
|
1719
|
+
usdKnown: usageNum(bootstrap.usdKnown),
|
|
1720
|
+
}),
|
|
1721
|
+
})
|
|
1722
|
+
}
|
|
901
1723
|
return h('span', { className: 'tacit-bootstrap' },
|
|
902
1724
|
h('button', {
|
|
903
1725
|
type: 'button',
|
|
904
1726
|
className: 'tacit-btn tacit-btn-sm',
|
|
905
|
-
disabled
|
|
1727
|
+
// Also disabled when the caller knows there is nothing eligible left.
|
|
1728
|
+
disabled: running || disabled === true,
|
|
906
1729
|
title: t('bootstrap.hint'),
|
|
907
1730
|
onClick,
|
|
908
|
-
},
|
|
909
|
-
? t('bootstrap.running', { done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0), total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0) })
|
|
910
|
-
: t('bootstrap.btn')))
|
|
1731
|
+
}, label()))
|
|
911
1732
|
}
|
|
912
1733
|
|
|
913
1734
|
function pct(value) {
|
|
@@ -1204,6 +2025,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1204
2025
|
error !== null && typeof error === 'object'
|
|
1205
2026
|
? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
|
|
1206
2027
|
: null,
|
|
2028
|
+
// Analyze and Improve report what they cost here. Always mounted and
|
|
2029
|
+
// empty when idle: a live region that appears together with its text
|
|
2030
|
+
// is missed by screen readers. The settings clear notice carries a
|
|
2031
|
+
// code and a count instead of text, and stays this panel's business.
|
|
2032
|
+
h('div', { className: 'tacit-settings-notice', role: 'status' },
|
|
2033
|
+
store.notice !== null && typeof store.notice === 'object' && typeof store.notice.text === 'string'
|
|
2034
|
+
? store.notice.text
|
|
2035
|
+
: ''),
|
|
1207
2036
|
turns.length === 0
|
|
1208
2037
|
? h('div', { className: 'tacit-empty' }, t('empty'))
|
|
1209
2038
|
: h('div', null,
|
|
@@ -1264,6 +2093,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1264
2093
|
const preview = store.preview
|
|
1265
2094
|
const onApply = () => applyImproved(store, props.inputActions)
|
|
1266
2095
|
const onCancel = () => closePreview(store)
|
|
2096
|
+
const improvedText = preview.data !== null && typeof preview.data === 'object' && typeof preview.data.improved === 'string'
|
|
2097
|
+
? preview.data.improved
|
|
2098
|
+
: ''
|
|
2099
|
+
// The model returns a finished draft verbatim (its fixed point): show
|
|
2100
|
+
// that instead of two identical columns, and offer no Apply so a no-op
|
|
2101
|
+
// rewrite never counts as applied.
|
|
2102
|
+
const unchanged = !preview.pending && preview.error === null && improvedText.trim() === preview.original.trim()
|
|
1267
2103
|
|
|
1268
2104
|
return h('div', { className: 'tacit-modal-backdrop' },
|
|
1269
2105
|
h('div', { className: 'tacit-modal-card' },
|
|
@@ -1276,20 +2112,24 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1276
2112
|
? h('div', { className: 'tacit-error' },
|
|
1277
2113
|
t('err.' + String(preview.error.code), { detail: String(preview.error.detail || '') }))
|
|
1278
2114
|
: h('div', null,
|
|
1279
|
-
|
|
1280
|
-
h('div', { className: 'tacit-modal-
|
|
1281
|
-
|
|
1282
|
-
h('
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
h('
|
|
2115
|
+
unchanged
|
|
2116
|
+
? h('div', { className: 'tacit-modal-unchanged' }, t('preview.unchanged'))
|
|
2117
|
+
: h('div', { className: 'tacit-modal-cols' },
|
|
2118
|
+
h('div', { className: 'tacit-modal-col' },
|
|
2119
|
+
h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
|
|
2120
|
+
h('pre', { className: 'tacit-pre' }, preview.original)),
|
|
2121
|
+
h('div', { className: 'tacit-modal-col' },
|
|
2122
|
+
h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
|
|
2123
|
+
h('pre', { className: 'tacit-pre' }, improvedText))),
|
|
1286
2124
|
preview.data !== null && typeof preview.data === 'object' && typeof preview.data.rationale === 'string' && preview.data.rationale.length > 0
|
|
1287
2125
|
? h('div', { className: 'tacit-modal-rationale' },
|
|
1288
2126
|
h('div', { className: 'tacit-modal-col-title' }, t('preview.rationale')),
|
|
1289
2127
|
h('div', { className: 'tacit-modal-rationale-text' }, preview.data.rationale))
|
|
1290
2128
|
: null,
|
|
1291
2129
|
h('div', { className: 'tacit-modal-actions' },
|
|
1292
|
-
|
|
2130
|
+
unchanged
|
|
2131
|
+
? null
|
|
2132
|
+
: h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
|
|
1293
2133
|
h('button', { type: 'button', className: 'tacit-btn', onClick: onCancel }, t('preview.cancel'))))))
|
|
1294
2134
|
}
|
|
1295
2135
|
}
|
|
@@ -1364,6 +2204,104 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1364
2204
|
}
|
|
1365
2205
|
}
|
|
1366
2206
|
|
|
2207
|
+
// ── Collapsible section card (Settings → Tacit) ────────────────────────
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* One accessible collapsible card.
|
|
2211
|
+
*
|
|
2212
|
+
* A `<details>` would be shorter, but its open state lives in the DOM: the
|
|
2213
|
+
* store could not drive it and the SSR suite could not seed it. So this is
|
|
2214
|
+
* the button/`aria-expanded`/`aria-controls` pattern instead, with the body
|
|
2215
|
+
* always in the DOM and only the `hidden` attribute toggled — the content
|
|
2216
|
+
* stays findable (browser find-in-page aside) and nothing remounts.
|
|
2217
|
+
*/
|
|
2218
|
+
function SectionCard(kit, { id, title, summary, count, open, onToggle, children }) {
|
|
2219
|
+
const { t } = kit
|
|
2220
|
+
const key = String(id)
|
|
2221
|
+
const isOpen = open === true
|
|
2222
|
+
const bodyId = 'tacit-card-' + key + '-body'
|
|
2223
|
+
const label = isOpen ? t('card.collapse') : t('card.expand')
|
|
2224
|
+
const kids = Array.isArray(children) ? children : (children === undefined || children === null ? [] : [children])
|
|
2225
|
+
return h('div', { className: 'tacit-card' },
|
|
2226
|
+
h('button', {
|
|
2227
|
+
type: 'button',
|
|
2228
|
+
className: 'tacit-card-head',
|
|
2229
|
+
id: 'tacit-card-' + key + '-head',
|
|
2230
|
+
'aria-expanded': isOpen,
|
|
2231
|
+
'aria-controls': bodyId,
|
|
2232
|
+
onClick: onToggle,
|
|
2233
|
+
},
|
|
2234
|
+
h('span', { className: 'tacit-card-title' }, title),
|
|
2235
|
+
typeof summary === 'string' && summary.length > 0
|
|
2236
|
+
? h('span', { className: 'tacit-card-summary' }, summary)
|
|
2237
|
+
: null,
|
|
2238
|
+
count !== undefined && count !== null
|
|
2239
|
+
? h('span', { className: 'tacit-card-count' }, String(count))
|
|
2240
|
+
: null,
|
|
2241
|
+
h('span', { className: 'tacit-card-chevron', 'aria-label': label, title: label }, isOpen ? '▾' : '▸')),
|
|
2242
|
+
h('div', { className: 'tacit-card-body', id: bodyId, hidden: !isOpen }, ...kids))
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// ── Confirm dialog (destructive actions) ───────────────────────────────
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* What had focus when a destructive action was requested.
|
|
2249
|
+
*
|
|
2250
|
+
* It is captured by the store action that opens the dialog rather than by
|
|
2251
|
+
* the dialog itself: React focuses the `autoFocus`ed Cancel button while it
|
|
2252
|
+
* commits, so by the time an effect runs the opener is already gone.
|
|
2253
|
+
*/
|
|
2254
|
+
let confirmOpener = null
|
|
2255
|
+
|
|
2256
|
+
function captureConfirmOpener() {
|
|
2257
|
+
confirmOpener = typeof document !== 'undefined' && document !== null ? document.activeElement : null
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* One small modal confirmation, rendered unconditionally by its owner (and
|
|
2262
|
+
* `null` when closed) so React's hook count never changes between renders.
|
|
2263
|
+
* Hook-free but for the focus restore, which is the one thing it cannot do
|
|
2264
|
+
* with markup alone.
|
|
2265
|
+
*
|
|
2266
|
+
* Cancel comes first and takes focus: the safe answer is the one a keyboard
|
|
2267
|
+
* or screen-reader user reaches without looking.
|
|
2268
|
+
*/
|
|
2269
|
+
function ConfirmDialog(kit, { open, title, body, confirmLabel, onConfirm, onCancel }) {
|
|
2270
|
+
const { t } = kit
|
|
2271
|
+
const isOpen = open === true
|
|
2272
|
+
useEffect(() => {
|
|
2273
|
+
if (!isOpen || typeof document === 'undefined') return undefined
|
|
2274
|
+
const opener = confirmOpener
|
|
2275
|
+
return () => {
|
|
2276
|
+
if (opener !== null && typeof opener.focus === 'function') opener.focus()
|
|
2277
|
+
}
|
|
2278
|
+
}, [isOpen])
|
|
2279
|
+
if (!isOpen) return null
|
|
2280
|
+
const cancel = typeof onCancel === 'function' ? onCancel : () => {}
|
|
2281
|
+
return h('div', {
|
|
2282
|
+
className: 'tacit-modal-backdrop',
|
|
2283
|
+
onClick: cancel,
|
|
2284
|
+
// Escape cancels from anywhere inside: keydown from either button
|
|
2285
|
+
// bubbles here.
|
|
2286
|
+
onKeyDown: (event) => {
|
|
2287
|
+
if (event.key === 'Escape') cancel()
|
|
2288
|
+
},
|
|
2289
|
+
},
|
|
2290
|
+
h('div', {
|
|
2291
|
+
className: 'tacit-modal-card',
|
|
2292
|
+
role: 'dialog',
|
|
2293
|
+
'aria-modal': 'true',
|
|
2294
|
+
'aria-labelledby': 'tacit-confirm-title',
|
|
2295
|
+
// A click on the card is not a click on the backdrop.
|
|
2296
|
+
onClick: (event) => event.stopPropagation(),
|
|
2297
|
+
},
|
|
2298
|
+
h('h3', { className: 'tacit-modal-title', id: 'tacit-confirm-title' }, title),
|
|
2299
|
+
h('p', { className: 'tacit-confirm-body' }, body),
|
|
2300
|
+
h('div', { className: 'tacit-confirm-actions' },
|
|
2301
|
+
h('button', { type: 'button', className: 'tacit-btn', autoFocus: true, onClick: cancel }, t('confirm.cancel')),
|
|
2302
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-danger', onClick: onConfirm }, confirmLabel))))
|
|
2303
|
+
}
|
|
2304
|
+
|
|
1367
2305
|
// ── Global panel (Settings → Tacit section) ────────────────────────────
|
|
1368
2306
|
|
|
1369
2307
|
function DirectivesEditor(kit) {
|
|
@@ -1465,6 +2403,26 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1465
2403
|
}
|
|
1466
2404
|
}
|
|
1467
2405
|
|
|
2406
|
+
/** Days offered by the retention select; the host clamps to 7–365 anyway. */
|
|
2407
|
+
const RETENTION_DAYS = [7, 14, 30, 90, 180, 365]
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* The `/bootstrap-preview` envelope, narrowed. `null` for anything that is
|
|
2411
|
+
* not a successful preview, so the Overview card falls back to the
|
|
2412
|
+
* documented estimate rather than pricing an envelope it cannot read.
|
|
2413
|
+
*/
|
|
2414
|
+
function previewOf(value) {
|
|
2415
|
+
if (value === null || typeof value !== 'object' || value.ok !== true) return null
|
|
2416
|
+
if (typeof value.eligible !== 'number' || !Number.isFinite(value.eligible)) return null
|
|
2417
|
+
const estimate = value.estimate !== null && typeof value.estimate === 'object' ? value.estimate : {}
|
|
2418
|
+
return {
|
|
2419
|
+
eligible: Math.max(0, Math.floor(value.eligible)),
|
|
2420
|
+
usd: typeof estimate.usd === 'number' && Number.isFinite(estimate.usd) ? estimate.usd : null,
|
|
2421
|
+
basis: estimate.basis === 'measured' ? 'measured' : 'doc',
|
|
2422
|
+
samples: typeof estimate.samples === 'number' && Number.isFinite(estimate.samples) ? Math.max(0, Math.floor(estimate.samples)) : 0,
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
1468
2426
|
function CoachPanel(kit) {
|
|
1469
2427
|
const { t, fmtTime } = kit
|
|
1470
2428
|
const DirectivesEditorView = DirectivesEditor(kit)
|
|
@@ -1481,6 +2439,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1481
2439
|
const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
|
|
1482
2440
|
const [learnGood, setLearnGood] = useState(config !== null && config.learnFromGood !== false)
|
|
1483
2441
|
const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
|
|
2442
|
+
// The two USD thresholds follow the budget field's text-state + Apply
|
|
2443
|
+
// pattern: money is typed a character at a time, and clamping mid-typing
|
|
2444
|
+
// fights the user. `0` is a real value here (the warning off), so the
|
|
2445
|
+
// fallback is `??`, never `||`.
|
|
2446
|
+
const [dailyText, setDailyText] = useState(config !== null && typeof config.costWarnDailyUsd === 'number' ? String(config.costWarnDailyUsd) : '0')
|
|
2447
|
+
const [monthlyText, setMonthlyText] = useState(config !== null && typeof config.costWarnMonthlyUsd === 'number' ? String(config.costWarnMonthlyUsd) : '0')
|
|
1484
2448
|
|
|
1485
2449
|
useEffect(() => {
|
|
1486
2450
|
if (config !== null) {
|
|
@@ -1489,6 +2453,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1489
2453
|
if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
|
|
1490
2454
|
if (typeof config.learnFromGood === 'boolean') setLearnGood(config.learnFromGood)
|
|
1491
2455
|
if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
|
|
2456
|
+
if (typeof config.costWarnDailyUsd === 'number') setDailyText(String(config.costWarnDailyUsd))
|
|
2457
|
+
if (typeof config.costWarnMonthlyUsd === 'number') setMonthlyText(String(config.costWarnMonthlyUsd))
|
|
1492
2458
|
}
|
|
1493
2459
|
}, [config])
|
|
1494
2460
|
|
|
@@ -1516,6 +2482,17 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1516
2482
|
setLive(next)
|
|
1517
2483
|
updateRootConfig({ liveSuggestions: next })
|
|
1518
2484
|
}
|
|
2485
|
+
/** One USD threshold: never negative, `0` = off, no rounding to whole dollars. */
|
|
2486
|
+
const applyWarn = (key, text, setText) => {
|
|
2487
|
+
const typed = String(text).trim()
|
|
2488
|
+
// An empty field means "off". Anything that is not a number at all is
|
|
2489
|
+
// a typo: silently writing 0 would turn a warning off behind the
|
|
2490
|
+
// user's back, so the entry is left alone instead.
|
|
2491
|
+
if (typed.length > 0 && !Number.isFinite(Number(typed))) return
|
|
2492
|
+
const value = Math.max(0, typed.length === 0 ? 0 : Number(typed))
|
|
2493
|
+
setText(String(value))
|
|
2494
|
+
updateRootConfig({ [key]: value })
|
|
2495
|
+
}
|
|
1519
2496
|
|
|
1520
2497
|
const coached = Array.isArray(rootStore.coached) ? rootStore.coached : []
|
|
1521
2498
|
const error = rootStore.error
|
|
@@ -1526,13 +2503,93 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1526
2503
|
? rootStore.profile.directives.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.text === 'string')
|
|
1527
2504
|
: []
|
|
1528
2505
|
|
|
2506
|
+
const sections = rootStore.sections !== null && typeof rootStore.sections === 'object' ? rootStore.sections : {}
|
|
2507
|
+
const notice = rootStore.notice !== null && typeof rootStore.notice === 'object' && typeof rootStore.notice.text === 'string'
|
|
2508
|
+
? rootStore.notice
|
|
2509
|
+
: null
|
|
2510
|
+
// Rates ride the /usage envelope, so the Pricing card reads them there
|
|
2511
|
+
// rather than calling a route of its own on every render.
|
|
2512
|
+
const usageReport = usageOf(rootStore.usage)
|
|
2513
|
+
const usagePricing = usageReport === null ? null : usageReport.pricing
|
|
2514
|
+
const preview = previewOf(rootStore.preview)
|
|
2515
|
+
const confirm = rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'usage'
|
|
2516
|
+
? 'usage'
|
|
2517
|
+
: (rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'reports' ? 'reports' : null)
|
|
2518
|
+
const retention = config !== null && typeof config.costHistoryDays === 'number' && RETENTION_DAYS.includes(config.costHistoryDays)
|
|
2519
|
+
? config.costHistoryDays
|
|
2520
|
+
: 30
|
|
2521
|
+
/** Every card is titled by `card.<id>` and driven by `rootStore.sections`. */
|
|
2522
|
+
const card = (id, children, extra) => SectionCard(kit, {
|
|
2523
|
+
id,
|
|
2524
|
+
title: t('card.' + id),
|
|
2525
|
+
open: sections[id] === true,
|
|
2526
|
+
onToggle: () => toggleSection(id),
|
|
2527
|
+
children,
|
|
2528
|
+
...(extra !== undefined ? extra : {}),
|
|
2529
|
+
})
|
|
2530
|
+
|
|
1529
2531
|
return h('div', { className: 'tacit-panel' },
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2532
|
+
error !== null && typeof error === 'object'
|
|
2533
|
+
? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
|
|
2534
|
+
: null,
|
|
2535
|
+
// Above the cards, and always mounted: a live region that appears
|
|
2536
|
+
// together with its text is missed by screen readers, and one nested
|
|
2537
|
+
// in the Overview body would be silent while that card is collapsed.
|
|
2538
|
+
h('div', { className: 'tacit-settings-notice', role: 'status' }, notice === null ? '' : notice.text),
|
|
2539
|
+
card('overview', [
|
|
2540
|
+
StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
|
|
2541
|
+
config !== null && typeof config.model === 'string' && config.model.length > 0
|
|
2542
|
+
? h('div', { className: 'tacit-settings-row' },
|
|
2543
|
+
h('span', { className: 'tacit-chip' }, t('overview.model', { model: config.model })))
|
|
2544
|
+
: null,
|
|
2545
|
+
h('div', { className: 'tacit-settings-row' },
|
|
2546
|
+
BootstrapButton(kit, {
|
|
2547
|
+
bootstrap: rootStore.bootstrap,
|
|
2548
|
+
// Only a loaded preview may disable the button: before one
|
|
2549
|
+
// lands, "0 eligible" is an absence of data, not a fact.
|
|
2550
|
+
disabled: preview !== null && preview.eligible === 0,
|
|
2551
|
+
onClick: () => bootstrapAll(t),
|
|
2552
|
+
}),
|
|
2553
|
+
h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
|
|
2554
|
+
// What the run would actually cost, priced from the ledger once it
|
|
2555
|
+
// holds enough analyses; until the preview lands, the documented
|
|
2556
|
+
// figure stands in.
|
|
2557
|
+
preview === null
|
|
2558
|
+
? h('p', { className: 'tacit-panel-hint' }, t('bootstrap.estimateDoc'))
|
|
2559
|
+
: h('p', { className: 'tacit-panel-hint' },
|
|
2560
|
+
// An estimate the host could not price says so; `fmtUsd(null)`
|
|
2561
|
+
// would read `$0.0000` and claim the run is free.
|
|
2562
|
+
t('bootstrap.preview', {
|
|
2563
|
+
eligible: String(preview.eligible),
|
|
2564
|
+
usd: preview.usd === null ? t('usage.priceUnavailable') : fmtUsd(preview.usd),
|
|
2565
|
+
}),
|
|
2566
|
+
' ',
|
|
2567
|
+
preview.basis === 'measured'
|
|
2568
|
+
? t('bootstrap.previewMeasured', { samples: String(preview.samples) })
|
|
2569
|
+
: t('bootstrap.previewDoc')),
|
|
2570
|
+
]),
|
|
2571
|
+
card('usage', [
|
|
2572
|
+
UsageCard(kit, {
|
|
2573
|
+
usage: rootStore.usage,
|
|
2574
|
+
config,
|
|
2575
|
+
filters: rootStore.usageFilters,
|
|
2576
|
+
series: rootStore.usageSeries,
|
|
2577
|
+
expanded: rootStore.usageExpanded,
|
|
2578
|
+
runs: rootStore.usageRuns,
|
|
2579
|
+
onFilter: (patch) => setUsageFilter(patch),
|
|
2580
|
+
onToggleRun: (runId) => toggleUsageRun(runId),
|
|
2581
|
+
onSeries: (value) => setUsageSeries(value),
|
|
2582
|
+
}),
|
|
2583
|
+
]),
|
|
2584
|
+
card('pricing', [
|
|
2585
|
+
PricingCard(kit, {
|
|
2586
|
+
pricing: usagePricing,
|
|
2587
|
+
open: sections.pricing === true,
|
|
2588
|
+
refreshing: rootStore.pricingRefreshing === true,
|
|
2589
|
+
onRefresh: () => refreshPricing(t),
|
|
2590
|
+
}),
|
|
2591
|
+
], { summary: pricingSummary(kit, usagePricing) }),
|
|
2592
|
+
card('learning', [
|
|
1536
2593
|
h('div', { className: 'tacit-settings-row' },
|
|
1537
2594
|
h('label', { className: 'tacit-settings-label' }, t('settings.model')),
|
|
1538
2595
|
h('select', {
|
|
@@ -1558,39 +2615,107 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1558
2615
|
onChange: (event) => setBudgetText(event.target.value),
|
|
1559
2616
|
}),
|
|
1560
2617
|
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
|
|
2618
|
+
]),
|
|
2619
|
+
card('guidance', [
|
|
2620
|
+
h(DirectivesEditorView, {
|
|
2621
|
+
workspaces: rootStore.workspaces, config, directives, steering: rootStore.steering }),
|
|
2622
|
+
]),
|
|
2623
|
+
card('improve', [
|
|
1561
2624
|
h('div', { className: 'tacit-settings-row' },
|
|
1562
2625
|
h('label', { className: 'tacit-settings-label' }, t('settings.live')),
|
|
1563
2626
|
h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
|
|
2627
|
+
h('div', { className: 'tacit-panel-section' },
|
|
2628
|
+
h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
|
|
2629
|
+
styleRules.length === 0
|
|
2630
|
+
? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
|
|
2631
|
+
: h('div', { className: 'tacit-rules-list' },
|
|
2632
|
+
styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
|
|
2633
|
+
h('p', { className: 'tacit-panel-hint' }, t('improve.explain')),
|
|
2634
|
+
]),
|
|
2635
|
+
card('history', [
|
|
2636
|
+
h('div', { className: 'tacit-panel-section' },
|
|
2637
|
+
h('div', { className: 'tacit-report-title' }, t('panel.coached')),
|
|
2638
|
+
coached.length === 0
|
|
2639
|
+
? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
|
|
2640
|
+
: h('div', { className: 'tacit-coached-list' },
|
|
2641
|
+
coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
|
|
2642
|
+
h('div', { className: 'tacit-coached-meta' },
|
|
2643
|
+
h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
|
|
2644
|
+
h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
|
|
2645
|
+
h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
|
|
2646
|
+
typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
|
|
2647
|
+
? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
|
|
2648
|
+
: null,
|
|
2649
|
+
typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
|
|
2650
|
+
? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
|
|
2651
|
+
: null)))),
|
|
2652
|
+
h('p', { className: 'tacit-panel-hint' }, t('panel.hint')),
|
|
2653
|
+
], { count: coached.length }),
|
|
2654
|
+
card('privacy', [
|
|
2655
|
+
h('p', { className: 'tacit-panel-hint' }, t('privacy.stored')),
|
|
1564
2656
|
h('div', { className: 'tacit-settings-row' },
|
|
1565
|
-
h('
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2657
|
+
h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-retention' }, t('privacy.retention')),
|
|
2658
|
+
h('select', {
|
|
2659
|
+
id: 'tacit-retention',
|
|
2660
|
+
className: 'tacit-select',
|
|
2661
|
+
value: String(retention),
|
|
2662
|
+
onChange: (event) => updateRootConfig({ costHistoryDays: Number(event.target.value) }),
|
|
2663
|
+
},
|
|
2664
|
+
...RETENTION_DAYS.map((days) => h('option', { key: days, value: String(days) }, String(days))))),
|
|
2665
|
+
h('div', { className: 'tacit-settings-row' },
|
|
2666
|
+
h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-daily' }, t('privacy.warnDaily')),
|
|
2667
|
+
h('input', {
|
|
2668
|
+
id: 'tacit-warn-daily',
|
|
2669
|
+
className: 'tacit-input',
|
|
2670
|
+
type: 'number',
|
|
2671
|
+
min: 0,
|
|
2672
|
+
step: '0.01',
|
|
2673
|
+
value: dailyText,
|
|
2674
|
+
onChange: (event) => setDailyText(event.target.value),
|
|
2675
|
+
}),
|
|
2676
|
+
h('button', {
|
|
2677
|
+
type: 'button',
|
|
2678
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
2679
|
+
// Two identically labelled buttons in one card: the accessible
|
|
2680
|
+
// name has to say which threshold each one applies.
|
|
2681
|
+
'aria-label': t('privacy.apply') + ': ' + t('privacy.warnDaily'),
|
|
2682
|
+
onClick: () => applyWarn('costWarnDailyUsd', dailyText, setDailyText),
|
|
2683
|
+
}, t('privacy.apply'))),
|
|
2684
|
+
h('div', { className: 'tacit-settings-row' },
|
|
2685
|
+
h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-monthly' }, t('privacy.warnMonthly')),
|
|
2686
|
+
h('input', {
|
|
2687
|
+
id: 'tacit-warn-monthly',
|
|
2688
|
+
className: 'tacit-input',
|
|
2689
|
+
type: 'number',
|
|
2690
|
+
min: 0,
|
|
2691
|
+
step: '0.01',
|
|
2692
|
+
value: monthlyText,
|
|
2693
|
+
onChange: (event) => setMonthlyText(event.target.value),
|
|
2694
|
+
}),
|
|
2695
|
+
h('button', {
|
|
2696
|
+
type: 'button',
|
|
2697
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
2698
|
+
// Two identically labelled buttons in one card: the accessible
|
|
2699
|
+
// name has to say which threshold each one applies.
|
|
2700
|
+
'aria-label': t('privacy.apply') + ': ' + t('privacy.warnMonthly'),
|
|
2701
|
+
onClick: () => applyWarn('costWarnMonthlyUsd', monthlyText, setMonthlyText),
|
|
2702
|
+
}, t('privacy.apply'))),
|
|
2703
|
+
h('p', { className: 'tacit-panel-hint' }, t('privacy.warnHint')),
|
|
2704
|
+
// Both clears are irreversible, so both go through the dialog.
|
|
2705
|
+
h('div', { className: 'tacit-settings-row' },
|
|
2706
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('reports') }, t('settings.clear')),
|
|
2707
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('usage') }, t('privacy.clearUsage'))),
|
|
2708
|
+
]),
|
|
2709
|
+
// Always rendered (null while closed) so its one effect keeps this
|
|
2710
|
+
// component's hook count stable.
|
|
2711
|
+
ConfirmDialog(kit, {
|
|
2712
|
+
open: confirm !== null,
|
|
2713
|
+
title: t(confirm === 'usage' ? 'confirm.usageTitle' : 'confirm.reportsTitle'),
|
|
2714
|
+
body: t(confirm === 'usage' ? 'confirm.usageBody' : 'confirm.reportsBody'),
|
|
2715
|
+
confirmLabel: t('confirm.clear'),
|
|
2716
|
+
onConfirm: () => confirmAction(),
|
|
2717
|
+
onCancel: () => closeConfirm(),
|
|
2718
|
+
}))
|
|
1594
2719
|
}
|
|
1595
2720
|
}
|
|
1596
2721
|
|
|
@@ -1602,6 +2727,11 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1602
2727
|
initRootStore()
|
|
1603
2728
|
// Fresh read so freshly distilled style rules appear on open.
|
|
1604
2729
|
refreshRootState()
|
|
2730
|
+
// What a bootstrap would cost right now, priced from the ledger.
|
|
2731
|
+
fetchBootstrapPreview()
|
|
2732
|
+
// One shared 10s /usage poll while any Tacit settings page is mounted.
|
|
2733
|
+
startUsagePolling()
|
|
2734
|
+
return stopUsagePolling
|
|
1605
2735
|
}, [])
|
|
1606
2736
|
return h('div', { className: 'tacit-root' },
|
|
1607
2737
|
h('div', { className: 'tacit-head' }, h('div', { className: 'tacit-head-title' }, t('panel.title'))),
|
|
@@ -1609,6 +2739,459 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1609
2739
|
}
|
|
1610
2740
|
}
|
|
1611
2741
|
|
|
2742
|
+
// ── Usage dashboard (Settings → Usage card) ────────────────────────────
|
|
2743
|
+
|
|
2744
|
+
/** Every run type and op the ledger can tag, in the order the filters list them. */
|
|
2745
|
+
const USAGE_RUN_TYPES = ['bootstrap', 'analysis', 'analysis-batch', 'improve', 'directive-distillation', 'style-distillation', 'prompt-enrichment']
|
|
2746
|
+
const USAGE_RANGES = ['today', '7d', '30d', 'month', 'all']
|
|
2747
|
+
const USAGE_STATUSES = ['success', 'partial', 'failed']
|
|
2748
|
+
const USAGE_COLUMNS = ['time', 'op', 'scope', 'model', 'status', 'calls', 'tokens', 'cost']
|
|
2749
|
+
|
|
2750
|
+
/** One label/value tile; `note` is the small "n unpriced" line under the value. */
|
|
2751
|
+
function UsageTile(key, label, value, note) {
|
|
2752
|
+
return h('div', { key, className: 'tacit-tile' },
|
|
2753
|
+
h('span', { className: 'tacit-tile-label' }, label),
|
|
2754
|
+
h('span', { className: 'tacit-tile-value' }, value),
|
|
2755
|
+
typeof note === 'string' && note.length > 0 ? h('span', { className: 'tacit-tile-note' }, note) : null)
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** Money tile: the period's spend plus its unpriced-call footnote. */
|
|
2759
|
+
function SpendTile(kit, id, label, totals) {
|
|
2760
|
+
const note = totals.unpricedCalls > 0 ? kit.t('usage.unpricedShort', { n: String(totals.unpricedCalls) }) : ''
|
|
2761
|
+
return UsageTile(id, label, usageSpend(kit, totals), note)
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Daily spend as a bar per day. `role="img"` with one `<title>` per rect
|
|
2766
|
+
* carries the detail to a pointer; the visually hidden paragraph under it
|
|
2767
|
+
* carries the same shape to a screen reader, which cannot read the bars.
|
|
2768
|
+
*/
|
|
2769
|
+
function BarStrip(kit, { series, which, onSeries }) {
|
|
2770
|
+
const { t } = kit
|
|
2771
|
+
const days = Array.isArray(series) ? series : []
|
|
2772
|
+
const count = days.length
|
|
2773
|
+
let max = 0
|
|
2774
|
+
let total = 0
|
|
2775
|
+
let peak = null
|
|
2776
|
+
for (const point of days) {
|
|
2777
|
+
if (point.usdKnown > max) max = point.usdKnown
|
|
2778
|
+
total += point.usdKnown
|
|
2779
|
+
if (peak === null || point.usdKnown > peak.usdKnown) peak = point
|
|
2780
|
+
}
|
|
2781
|
+
const bars = days.map((point, index) => {
|
|
2782
|
+
// A day with any spend at all keeps a visible 1px stub.
|
|
2783
|
+
const height = point.usdKnown > 0 && max > 0 ? Math.max(1, Math.round((point.usdKnown / max) * 46)) : 0
|
|
2784
|
+
return h('rect', {
|
|
2785
|
+
key: point.day,
|
|
2786
|
+
className: 'tacit-bar',
|
|
2787
|
+
x: String(index * 10 + 1),
|
|
2788
|
+
y: String(48 - height),
|
|
2789
|
+
width: '8',
|
|
2790
|
+
height: String(height),
|
|
2791
|
+
}, h('title', null, t('usage.barTitle', {
|
|
2792
|
+
day: fmtDay(point.day),
|
|
2793
|
+
usd: fmtUsd(point.usdKnown),
|
|
2794
|
+
calls: String(point.billedCalls),
|
|
2795
|
+
})))
|
|
2796
|
+
})
|
|
2797
|
+
const summary = t('usage.chartSummary', {
|
|
2798
|
+
n: String(count),
|
|
2799
|
+
total: fmtUsd(total),
|
|
2800
|
+
day: peak === null ? '—' : fmtDay(peak.day),
|
|
2801
|
+
max: peak === null ? '—' : fmtUsd(peak.usdKnown),
|
|
2802
|
+
})
|
|
2803
|
+
const seriesButton = (value, label) => h('button', {
|
|
2804
|
+
key: value,
|
|
2805
|
+
type: 'button',
|
|
2806
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
2807
|
+
'aria-pressed': which === value,
|
|
2808
|
+
onClick: () => onSeries(value),
|
|
2809
|
+
}, label)
|
|
2810
|
+
return h('div', { className: 'tacit-usage-chart' },
|
|
2811
|
+
h('svg', {
|
|
2812
|
+
className: 'tacit-bars',
|
|
2813
|
+
role: 'img',
|
|
2814
|
+
'aria-label': t('usage.chartLabel', { n: String(count) }),
|
|
2815
|
+
viewBox: '0 0 ' + String(count * 10) + ' 48',
|
|
2816
|
+
preserveAspectRatio: 'none',
|
|
2817
|
+
}, bars),
|
|
2818
|
+
h('p', { className: 'tacit-visually-hidden' }, summary),
|
|
2819
|
+
h('div', { className: 'tacit-series-toggle' },
|
|
2820
|
+
seriesButton('7', t('usage.series7')),
|
|
2821
|
+
seriesButton('30', t('usage.series30'))))
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
/** Spend per operation, costliest first, each row ending in a share bar. */
|
|
2825
|
+
function UsageBreakdown(kit, { byType }) {
|
|
2826
|
+
const { t } = kit
|
|
2827
|
+
const rows = Object.entries(byType).sort((a, b) => b[1].usdKnown - a[1].usdKnown)
|
|
2828
|
+
if (rows.length === 0) return null
|
|
2829
|
+
const top = rows[0][1].usdKnown
|
|
2830
|
+
return h('div', { className: 'tacit-usage-breakdown' },
|
|
2831
|
+
h('div', { className: 'tacit-report-title' }, t('usage.breakdown')),
|
|
2832
|
+
rows.map(([type, totals]) => {
|
|
2833
|
+
const share = top > 0 ? Math.round((totals.usdKnown / top) * 100) : 0
|
|
2834
|
+
return h('div', { key: type, className: 'tacit-breakdown-row' },
|
|
2835
|
+
h('span', { className: 'tacit-breakdown-name' }, t('runtype.' + type)),
|
|
2836
|
+
h('span', { className: 'tacit-breakdown-calls' }, fmtTokens(totals.billedCalls)),
|
|
2837
|
+
h('span', { className: 'tacit-breakdown-tokens' }, fmtTokens(tokensTotal(totals.tokens))),
|
|
2838
|
+
h('span', { className: 'tacit-breakdown-usd' }, usageSpend(kit, totals)),
|
|
2839
|
+
h('div', { className: 'tacit-share-track' },
|
|
2840
|
+
h('div', { className: 'tacit-share', style: { width: String(share) + '%' } })))
|
|
2841
|
+
}))
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/** One budget bar; nothing at all when that threshold is switched off (`limit` 0). */
|
|
2845
|
+
function WarnBar(kit, { id, warning }) {
|
|
2846
|
+
const { t } = kit
|
|
2847
|
+
if (!(warning.limit > 0)) return null
|
|
2848
|
+
const pct = Math.round((warning.spent / warning.limit) * 100)
|
|
2849
|
+
const label = t('warn.' + id, { spent: fmtUsd(warning.spent), limit: fmtUsd(warning.limit), pct: String(pct) })
|
|
2850
|
+
return h('div', {
|
|
2851
|
+
key: id,
|
|
2852
|
+
className: 'tacit-warn-bar tacit-warn-' + warning.level,
|
|
2853
|
+
role: 'progressbar',
|
|
2854
|
+
'aria-valuemin': 0,
|
|
2855
|
+
'aria-valuemax': warning.limit,
|
|
2856
|
+
// An overspend is real, but `aria-valuenow` past `aria-valuemax` is not a
|
|
2857
|
+
// valid range: the bar pins at the cap and the true figure rides valuetext.
|
|
2858
|
+
'aria-valuenow': Math.min(warning.spent, warning.limit),
|
|
2859
|
+
'aria-valuetext': fmtUsd(warning.spent),
|
|
2860
|
+
'aria-label': label,
|
|
2861
|
+
},
|
|
2862
|
+
h('span', { className: 'tacit-warn-label' }, label),
|
|
2863
|
+
h('div', { className: 'tacit-warn-track' },
|
|
2864
|
+
h('div', { className: 'tacit-warn-fill', style: { width: String(Math.min(100, pct)) + '%' } })))
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/** Range/type/status/model selects plus the two free-text scopes. */
|
|
2868
|
+
function UsageFilters(kit, { filters, models, onFilter }) {
|
|
2869
|
+
const { t } = kit
|
|
2870
|
+
const select = (name, value, options) => h('label', { className: 'tacit-filter' },
|
|
2871
|
+
h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
|
|
2872
|
+
h('select', {
|
|
2873
|
+
className: 'tacit-select',
|
|
2874
|
+
value,
|
|
2875
|
+
onChange: (event) => onFilter({ [name]: event.target.value }),
|
|
2876
|
+
}, options.map((option) => h('option', { key: option.value, value: option.value }, option.label))))
|
|
2877
|
+
// A text scope applies on Enter or on leaving the field — not per keystroke,
|
|
2878
|
+
// which would fire a /usage call for every character typed. `name` is the
|
|
2879
|
+
// dictionary key, `field` the store/wire key: the session scope is labelled
|
|
2880
|
+
// `filter.session` but filtered on `sessionId`, which is what the server reads.
|
|
2881
|
+
const text = (name, field, value) => h('label', { className: 'tacit-filter' },
|
|
2882
|
+
h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
|
|
2883
|
+
h('input', {
|
|
2884
|
+
className: 'tacit-input',
|
|
2885
|
+
type: 'text',
|
|
2886
|
+
key: field + '-' + value,
|
|
2887
|
+
defaultValue: value,
|
|
2888
|
+
onBlur: (event) => {
|
|
2889
|
+
if (event.target.value !== value) onFilter({ [field]: event.target.value.trim() })
|
|
2890
|
+
},
|
|
2891
|
+
onKeyDown: (event) => {
|
|
2892
|
+
if (event.key === 'Enter') onFilter({ [field]: event.target.value.trim() })
|
|
2893
|
+
},
|
|
2894
|
+
}))
|
|
2895
|
+
const all = { value: '', label: t('filter.all') }
|
|
2896
|
+
return h('div', { className: 'tacit-usage-filters' },
|
|
2897
|
+
h('div', { className: 'tacit-report-title' }, t('usage.filters')),
|
|
2898
|
+
h('div', { className: 'tacit-filter-row' },
|
|
2899
|
+
select('range', filters.range, USAGE_RANGES.map((value) => ({ value, label: t('range.' + value) }))),
|
|
2900
|
+
select('type', filters.type, [all, ...USAGE_RUN_TYPES.map((value) => ({ value, label: t('runtype.' + value) }))]),
|
|
2901
|
+
select('status', filters.status, [all, ...USAGE_STATUSES.map((value) => ({ value, label: t('status.' + value) }))]),
|
|
2902
|
+
select('model', filters.model, [all, ...models.map((value) => ({ value, label: value }))]),
|
|
2903
|
+
text('workspace', 'workspace', filters.workspace),
|
|
2904
|
+
text('session', 'sessionId', filters.sessionId)))
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/** The five token buckets of one attempt, in the order the adapter reports them. */
|
|
2908
|
+
function attemptTokens(usage) {
|
|
2909
|
+
if (usage === null || typeof usage !== 'object') return '—'
|
|
2910
|
+
return 'in ' + fmtTokens(usage.inputTokens)
|
|
2911
|
+
+ ' · out ' + fmtTokens(usage.outputTokens)
|
|
2912
|
+
+ ' · cacheRead ' + fmtTokens(usage.cacheReadTokens)
|
|
2913
|
+
+ ' · cacheWrite ' + fmtTokens(usage.cacheWriteTokens)
|
|
2914
|
+
+ ' · reasoning ' + fmtTokens(usage.reasoningTokens)
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
/** One `run()` of the coach model: what it was, how it ended, what it cost. */
|
|
2918
|
+
function AttemptRow(kit, attempt) {
|
|
2919
|
+
const { t } = kit
|
|
2920
|
+
const priced = attempt.priced !== null && typeof attempt.priced === 'object' ? attempt.priced : null
|
|
2921
|
+
const finish = String(attempt.finish || '')
|
|
2922
|
+
const code = String(attempt.code || '')
|
|
2923
|
+
return h('div', { key: String(attempt.id), className: 'tacit-attempt' },
|
|
2924
|
+
h('span', { className: 'tacit-chip' }, t('op.' + String(attempt.op))),
|
|
2925
|
+
typeof attempt.reasoningEffort === 'string' && attempt.reasoningEffort.length > 0
|
|
2926
|
+
? h('span', { className: 'tacit-attempt-meta' }, t('attempt.effort', { effort: attempt.reasoningEffort }))
|
|
2927
|
+
: null,
|
|
2928
|
+
h('span', { className: 'tacit-chip tacit-status-' + String(attempt.status) }, t('status.' + String(attempt.status))),
|
|
2929
|
+
h('span', { className: 'tacit-attempt-meta' }, t('attempt.finish', { finish, code })),
|
|
2930
|
+
h('span', { className: 'tacit-attempt-meta' }, priced === null
|
|
2931
|
+
? t('usage.priceUnavailable')
|
|
2932
|
+
: t('attempt.source', { source: String(priced.source), tier: String(priced.tier) })),
|
|
2933
|
+
h('span', { className: 'tacit-attempt-tokens' }, attemptTokens(attempt.usage)),
|
|
2934
|
+
h('span', { className: 'tacit-attempt-meta' }, t('attempt.duration', { ms: fmtTokens(attempt.durationMs) })),
|
|
2935
|
+
h('span', { className: 'tacit-attempt-usd' }, priced === null ? t('usage.priceUnavailable') : fmtUsd(priced.usd)))
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/** The expanded detail row of one run: its attempts, or the loading line. */
|
|
2939
|
+
function AttemptsRow(kit, { runId, run }) {
|
|
2940
|
+
const { t } = kit
|
|
2941
|
+
const attempts = run !== null && run !== undefined && typeof run === 'object' && Array.isArray(run.attempts)
|
|
2942
|
+
? run.attempts.filter((attempt) => attempt !== null && typeof attempt === 'object')
|
|
2943
|
+
: null
|
|
2944
|
+
return h('div', { className: 'tacit-usage-attempts', id: 'tacit-run-' + runId, role: 'row' },
|
|
2945
|
+
h('div', { className: 'tacit-usage-cell tacit-usage-attempts-cell', role: 'cell' },
|
|
2946
|
+
attempts === null
|
|
2947
|
+
? h('p', { className: 'tacit-panel-hint' }, t('usage.loading'))
|
|
2948
|
+
: attempts.map((attempt) => AttemptRow(kit, attempt))))
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/** One run row; the first cell is the disclosure button for its attempts. */
|
|
2952
|
+
function RunRow(kit, { item, open, onToggleRun }) {
|
|
2953
|
+
const { t, fmtTime } = kit
|
|
2954
|
+
const scope = item.workspace.length > 0 && item.turn !== null
|
|
2955
|
+
? item.workspace + ' · #' + String(item.turn)
|
|
2956
|
+
: (item.workspace.length > 0 ? item.workspace : (item.turn !== null ? '#' + String(item.turn) : '—'))
|
|
2957
|
+
const cell = (key, child) => h('span', { key, className: 'tacit-usage-cell', role: 'cell' }, child)
|
|
2958
|
+
return h('div', { className: 'tacit-usage-row', role: 'row' },
|
|
2959
|
+
cell('time', h('button', {
|
|
2960
|
+
type: 'button',
|
|
2961
|
+
className: 'tacit-btn tacit-btn-sm tacit-run-toggle',
|
|
2962
|
+
'aria-expanded': open,
|
|
2963
|
+
'aria-controls': 'tacit-run-' + item.runId,
|
|
2964
|
+
onClick: () => onToggleRun(item.runId),
|
|
2965
|
+
}, (open ? '▾ ' : '▸ ') + fmtTime(item.startedAt))),
|
|
2966
|
+
cell('op', t('runtype.' + item.type)),
|
|
2967
|
+
cell('scope', scope),
|
|
2968
|
+
cell('model', item.model.length > 0 ? item.model : '—'),
|
|
2969
|
+
cell('status', h('span', { className: 'tacit-chip tacit-status-' + item.status }, t('status.' + item.status))),
|
|
2970
|
+
cell('calls', fmtTokens(item.billedCalls)),
|
|
2971
|
+
cell('tokens', fmtTokens(tokensTotal(item.tokens))),
|
|
2972
|
+
cell('cost', usageSpend(kit, item)))
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/** The runs table plus its pager. */
|
|
2976
|
+
function UsageRuns(kit, { runs, expanded, runDetails, onToggleRun, onFilter }) {
|
|
2977
|
+
const { t } = kit
|
|
2978
|
+
const pages = Math.max(1, Math.ceil(runs.total / Math.max(1, runs.pageSize)))
|
|
2979
|
+
const page = Math.min(Math.max(1, runs.page), pages)
|
|
2980
|
+
return h('div', { className: 'tacit-usage-runs' },
|
|
2981
|
+
h('div', { className: 'tacit-usage-table', role: 'table', 'aria-label': t('card.usage') },
|
|
2982
|
+
h('div', { className: 'tacit-usage-row tacit-usage-head', role: 'row' },
|
|
2983
|
+
USAGE_COLUMNS.map((column) => h('span', {
|
|
2984
|
+
key: column,
|
|
2985
|
+
className: 'tacit-usage-cell',
|
|
2986
|
+
role: 'columnheader',
|
|
2987
|
+
}, t('usage.col.' + column)))),
|
|
2988
|
+
runs.items.map((item) => {
|
|
2989
|
+
const open = expanded.has(item.runId)
|
|
2990
|
+
return h(React.Fragment, { key: item.runId },
|
|
2991
|
+
RunRow(kit, { item, open, onToggleRun }),
|
|
2992
|
+
open ? AttemptsRow(kit, { runId: item.runId, run: runDetails[item.runId] }) : null)
|
|
2993
|
+
})),
|
|
2994
|
+
h('div', { className: 'tacit-pager' },
|
|
2995
|
+
h('button', {
|
|
2996
|
+
type: 'button',
|
|
2997
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
2998
|
+
disabled: page <= 1,
|
|
2999
|
+
onClick: () => onFilter({ page: page - 1 }),
|
|
3000
|
+
}, t('usage.prev')),
|
|
3001
|
+
h('span', { className: 'tacit-pager-label' }, t('usage.page', { page: String(page), pages: String(pages) })),
|
|
3002
|
+
h('button', {
|
|
3003
|
+
type: 'button',
|
|
3004
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
3005
|
+
disabled: page >= pages,
|
|
3006
|
+
onClick: () => onFilter({ page: page + 1 }),
|
|
3007
|
+
}, t('usage.next'))))
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/**
|
|
3011
|
+
* The whole Usage card. A plain renderer, not a component: it is called
|
|
3012
|
+
* from `CoachPanelView`'s render, so it must never own hooks — every bit
|
|
3013
|
+
* of its state lives in `rootStore` and arrives as a prop.
|
|
3014
|
+
*/
|
|
3015
|
+
function UsageCard(kit, props) {
|
|
3016
|
+
const { t } = kit
|
|
3017
|
+
const usage = usageOf(props.usage)
|
|
3018
|
+
const since = fmtDay(usage === null ? 0 : usage.trackingSince)
|
|
3019
|
+
if (usage === null || usage.lifetime.billedCalls === 0) {
|
|
3020
|
+
return h('div', { className: 'tacit-usage' },
|
|
3021
|
+
h('p', { className: 'tacit-panel-hint' }, t('usage.empty', { since })))
|
|
3022
|
+
}
|
|
3023
|
+
const filters = props.filters !== null && typeof props.filters === 'object' ? props.filters : {}
|
|
3024
|
+
const value = (key) => (typeof filters[key] === 'string' ? filters[key] : '')
|
|
3025
|
+
const expanded = props.expanded instanceof Set ? props.expanded : new Set()
|
|
3026
|
+
const runDetails = props.runs !== null && typeof props.runs === 'object' ? props.runs : {}
|
|
3027
|
+
const which = props.series === '7' ? '7' : '30'
|
|
3028
|
+
const last30 = usage.last30
|
|
3029
|
+
return h('div', { className: 'tacit-usage' },
|
|
3030
|
+
h('p', { className: 'tacit-panel-hint' }, t('usage.label')),
|
|
3031
|
+
h('div', { className: 'tacit-tiles' },
|
|
3032
|
+
SpendTile(kit, 'today', t('usage.today'), usage.today),
|
|
3033
|
+
SpendTile(kit, 'month', t('usage.month'), usage.month),
|
|
3034
|
+
SpendTile(kit, 'last30', t('usage.last30'), last30),
|
|
3035
|
+
SpendTile(kit, 'lifetime', t('usage.since', { day: since }), usage.lifetime)),
|
|
3036
|
+
h('div', { className: 'tacit-tiles' },
|
|
3037
|
+
UsageTile('calls', t('usage.calls'), fmtTokens(last30.billedCalls)),
|
|
3038
|
+
UsageTile('avg', t('usage.avgAnalysis'), last30.avgAnalysisUsd === null
|
|
3039
|
+
? t('usage.priceUnavailable')
|
|
3040
|
+
: fmtUsd(last30.avgAnalysisUsd)),
|
|
3041
|
+
UsageTile('cached', t('usage.cachedRate'), fmtPct(last30.cachedInputRate)),
|
|
3042
|
+
UsageTile('unpriced', t('usage.unpriced'), fmtTokens(last30.unpricedCalls))),
|
|
3043
|
+
BarStrip(kit, { series: which === '7' ? usage.series7 : usage.series30, which, onSeries: props.onSeries }),
|
|
3044
|
+
UsageBreakdown(kit, { byType: usage.byType }),
|
|
3045
|
+
WarnBar(kit, { id: 'daily', warning: usage.warnings.daily }),
|
|
3046
|
+
WarnBar(kit, { id: 'monthly', warning: usage.warnings.monthly }),
|
|
3047
|
+
UsageFilters(kit, {
|
|
3048
|
+
filters: {
|
|
3049
|
+
range: value('range').length > 0 ? value('range') : '30d',
|
|
3050
|
+
type: value('type'),
|
|
3051
|
+
status: value('status'),
|
|
3052
|
+
model: value('model'),
|
|
3053
|
+
workspace: value('workspace'),
|
|
3054
|
+
sessionId: value('sessionId'),
|
|
3055
|
+
},
|
|
3056
|
+
models: Object.keys(usage.byModel).sort(),
|
|
3057
|
+
onFilter: props.onFilter,
|
|
3058
|
+
}),
|
|
3059
|
+
UsageRuns(kit, {
|
|
3060
|
+
runs: usage.runs,
|
|
3061
|
+
expanded,
|
|
3062
|
+
runDetails,
|
|
3063
|
+
onToggleRun: props.onToggleRun,
|
|
3064
|
+
onFilter: props.onFilter,
|
|
3065
|
+
}))
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// ── Pricing card (Settings → Pricing) ──────────────────────────────────
|
|
3069
|
+
|
|
3070
|
+
/** The two tiers and the three quoted rates, in the order the table lists them. */
|
|
3071
|
+
const PRICING_TIERS = ['offPeak', 'peak']
|
|
3072
|
+
const PRICING_COLUMNS = ['model', 'tier', 'cacheHit', 'cacheMiss', 'output', 'reasoning']
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* The `usage.pricing` block, narrowed. Rates are copied per model and tier
|
|
3076
|
+
* so a missing or malformed triple renders an em dash instead of a number
|
|
3077
|
+
* the provider never quoted.
|
|
3078
|
+
*/
|
|
3079
|
+
function pricingOf(value) {
|
|
3080
|
+
const source = value !== null && typeof value === 'object' ? value : {}
|
|
3081
|
+
const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
|
|
3082
|
+
const rates = source.rates !== null && typeof source.rates === 'object' ? source.rates : {}
|
|
3083
|
+
const models = {}
|
|
3084
|
+
for (const [model, tiers] of Object.entries(rates)) {
|
|
3085
|
+
if (tiers === null || typeof tiers !== 'object') continue
|
|
3086
|
+
const entry = {}
|
|
3087
|
+
for (const tier of PRICING_TIERS) {
|
|
3088
|
+
const triple = tiers[tier] !== null && typeof tiers[tier] === 'object' ? tiers[tier] : {}
|
|
3089
|
+
const rate = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined)
|
|
3090
|
+
entry[tier] = { cacheHit: rate(triple.cacheHit), cacheMiss: rate(triple.cacheMiss), output: rate(triple.output) }
|
|
3091
|
+
}
|
|
3092
|
+
models[model] = entry
|
|
3093
|
+
}
|
|
3094
|
+
return {
|
|
3095
|
+
source: source.source === 'costMeter' ? 'costMeter' : 'bundled',
|
|
3096
|
+
asOf: text('asOf'),
|
|
3097
|
+
refreshedAt: typeof source.refreshedAt === 'number' && source.refreshedAt > 0 ? source.refreshedAt : 0,
|
|
3098
|
+
tierNow: source.tierNow === 'peak' ? 'peak' : 'offPeak',
|
|
3099
|
+
error: text('error'),
|
|
3100
|
+
models,
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/** `deepseek-v4-flash` → `flash`, for the one-line header summary. */
|
|
3105
|
+
function shortModel(model) {
|
|
3106
|
+
const parts = String(model).split('-').filter((part) => part.length > 0)
|
|
3107
|
+
return parts.length > 0 ? parts[parts.length - 1] : String(model)
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
/** Which price source this table came from, as a phrase. */
|
|
3111
|
+
function pricingSourceLabel(kit, priced) {
|
|
3112
|
+
return kit.t(priced.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled')
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
/**
|
|
3116
|
+
* The line the collapsed card shows in its header: the flash model — the
|
|
3117
|
+
* default coach model — at the tier in force right now, so the headline
|
|
3118
|
+
* figure is the one a call started this minute would actually be billed at.
|
|
3119
|
+
*/
|
|
3120
|
+
function pricingSummary(kit, pricing) {
|
|
3121
|
+
const { t } = kit
|
|
3122
|
+
const priced = pricingOf(pricing)
|
|
3123
|
+
const models = Object.keys(priced.models).sort()
|
|
3124
|
+
const model = models.find((name) => name.endsWith('flash'))
|
|
3125
|
+
const pick = model !== undefined ? model : models[0]
|
|
3126
|
+
if (pick === undefined) return ''
|
|
3127
|
+
const triple = priced.models[pick][priced.tierNow]
|
|
3128
|
+
return t('pricing.summary', {
|
|
3129
|
+
model: shortModel(pick),
|
|
3130
|
+
tier: t('pricing.' + priced.tierNow),
|
|
3131
|
+
rates: fmtRate(triple.cacheHit) + ' / ' + fmtRate(triple.cacheMiss) + ' / ' + fmtRate(triple.output),
|
|
3132
|
+
source: pricingSourceLabel(kit, priced),
|
|
3133
|
+
asOf: priced.asOf.length > 0 ? priced.asOf : '—',
|
|
3134
|
+
})
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
/** One model at one tier. Reasoning is prose: it is billed as output, never quoted apart. */
|
|
3138
|
+
function PricingRow(kit, model, tier, triple) {
|
|
3139
|
+
const { t } = kit
|
|
3140
|
+
const cell = (key, child) => h('span', { key, className: 'tacit-pricing-cell', role: 'cell' }, child)
|
|
3141
|
+
return h('div', { key: model + '-' + tier, className: 'tacit-pricing-row', role: 'row' },
|
|
3142
|
+
h('span', { className: 'tacit-pricing-cell tacit-pricing-model', role: 'rowheader' }, model),
|
|
3143
|
+
cell('tier', t('pricing.' + tier)),
|
|
3144
|
+
cell('cacheHit', fmtRate(triple.cacheHit)),
|
|
3145
|
+
cell('cacheMiss', fmtRate(triple.cacheMiss)),
|
|
3146
|
+
cell('output', fmtRate(triple.output)),
|
|
3147
|
+
cell('reasoning', t('pricing.reasoningSameAsOutput')))
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/**
|
|
3151
|
+
* The Pricing card body. Like `UsageCard` a plain renderer, not a
|
|
3152
|
+
* component: it owns no hooks and no state. It builds nothing at all while
|
|
3153
|
+
* collapsed — the header summary already carries the headline rate, and
|
|
3154
|
+
* this page re-renders on every 10s usage poll.
|
|
3155
|
+
*/
|
|
3156
|
+
function PricingCard(kit, { pricing, open, onRefresh, refreshing }) {
|
|
3157
|
+
const { t, fmtTime } = kit
|
|
3158
|
+
if (open !== true) return null
|
|
3159
|
+
const priced = pricingOf(pricing)
|
|
3160
|
+
const models = Object.keys(priced.models).sort()
|
|
3161
|
+
const when = priced.refreshedAt > 0
|
|
3162
|
+
? t('pricing.refreshedAt', { time: fmtDay(priced.refreshedAt) + ' ' + fmtTime(priced.refreshedAt) })
|
|
3163
|
+
: t('pricing.never')
|
|
3164
|
+
const asOf = priced.asOf.length > 0 ? priced.asOf : '—'
|
|
3165
|
+
return h('div', { className: 'tacit-pricing' },
|
|
3166
|
+
h('div', { className: 'tacit-report-title', id: 'tacit-pricing-title' }, t('pricing.rateTable')),
|
|
3167
|
+
models.length === 0
|
|
3168
|
+
? h('p', { className: 'tacit-panel-hint' }, t('usage.priceUnavailable'))
|
|
3169
|
+
: h('div', { className: 'tacit-pricing-table', role: 'table', 'aria-labelledby': 'tacit-pricing-title' },
|
|
3170
|
+
h('div', { className: 'tacit-pricing-row tacit-pricing-head', role: 'row' },
|
|
3171
|
+
PRICING_COLUMNS.map((column) => h('span', {
|
|
3172
|
+
key: column,
|
|
3173
|
+
className: 'tacit-pricing-cell',
|
|
3174
|
+
role: 'columnheader',
|
|
3175
|
+
}, t('pricing.' + column)))),
|
|
3176
|
+
models.map((model) => PRICING_TIERS.map((tier) => PricingRow(kit, model, tier, priced.models[model][tier])))),
|
|
3177
|
+
h('p', { className: 'tacit-panel-hint' }, t('pricing.tierNow', { tier: t('pricing.' + priced.tierNow) })),
|
|
3178
|
+
h('p', { className: 'tacit-panel-hint' }, t('pricing.schedule')),
|
|
3179
|
+
h('p', { className: 'tacit-panel-hint' }, t('pricing.weekendRule')),
|
|
3180
|
+
h('p', { className: 'tacit-panel-hint' },
|
|
3181
|
+
t('pricing.source') + ': ' + pricingSourceLabel(kit, priced) + ' (' + asOf + ') · ' + when),
|
|
3182
|
+
priced.error.length > 0
|
|
3183
|
+
? h('p', { className: 'tacit-pricing-error' }, t('pricing.error', { error: priced.error }))
|
|
3184
|
+
: null,
|
|
3185
|
+
h('p', { className: 'tacit-panel-hint' }, t('pricing.formula')),
|
|
3186
|
+
h('p', { className: 'tacit-panel-hint' }, t('pricing.accuracy')),
|
|
3187
|
+
h('div', { className: 'tacit-settings-row' },
|
|
3188
|
+
h('button', {
|
|
3189
|
+
type: 'button',
|
|
3190
|
+
className: 'tacit-btn tacit-btn-sm tacit-pricing-refresh',
|
|
3191
|
+
disabled: refreshing === true,
|
|
3192
|
+
onClick: onRefresh,
|
|
3193
|
+
}, refreshing === true ? t('pricing.refreshing') : t('pricing.refresh'))))
|
|
3194
|
+
}
|
|
1612
3195
|
// ── CSS (injected once) ────────────────────────────────────────────────
|
|
1613
3196
|
|
|
1614
3197
|
const css = '.tacit-root{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);padding:16px 20px 32px;font-size:13px;overflow-y:auto}'
|
|
@@ -1619,6 +3202,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1619
3202
|
+ '.tacit-btn{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;cursor:pointer;padding:4px 10px}.tacit-btn:hover{border-color:var(--dsw-alias-label-primary)}.tacit-btn:disabled{opacity:.55;cursor:default}'
|
|
1620
3203
|
+ '.tacit-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}.tacit-btn-primary:hover{border-color:transparent;filter:brightness(1.1)}'
|
|
1621
3204
|
+ '.tacit-btn-sm{padding:2px 8px;font-size:11px}'
|
|
3205
|
+
+ '.tacit-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)}.tacit-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}'
|
|
1622
3206
|
+ '.tacit-row{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}'
|
|
1623
3207
|
+ '.tacit-row-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.tacit-row-heading{display:flex;align-items:baseline;gap:8px;font-weight:600}.tacit-row-turn{font-size:13px}.tacit-row-time{color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}.tacit-row-live{color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:600}'
|
|
1624
3208
|
+ '.tacit-row-chips{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto}.tacit-chip{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px}.tacit-chip-warn{color:var(--dsw-alias-state-warn-primary)}'
|
|
@@ -1653,12 +3237,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1653
3237
|
+ ''
|
|
1654
3238
|
+ '.tacit-modal-backdrop{z-index:200;background:#00000073;display:flex;justify-content:center;align-items:center;position:fixed;inset:0}'
|
|
1655
3239
|
+ '.tacit-modal-card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:min(760px,100vw - 48px);max-height:min(84vh,800px);box-shadow:var(--dsw-shadow-lv3,0 12px 32px #0006);color:var(--dsw-alias-label-primary);border-radius:12px;padding:16px 18px 18px;font-size:13px;display:flex;flex-direction:column;gap:10px;overflow-y:auto}'
|
|
1656
|
-
+ '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin
|
|
3240
|
+
+ '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin:0 auto 0 0}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
|
|
1657
3241
|
+ '.tacit-modal-pending{color:var(--dsw-alias-label-secondary);padding:16px 0;text-align:center}'
|
|
3242
|
+
+ '.tacit-modal-unchanged{color:var(--dsw-alias-label-primary);padding:16px 0;text-align:center}'
|
|
1658
3243
|
+ '.tacit-modal-cols{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tacit-modal-col{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;min-width:0;max-height:300px;overflow-y:auto}'
|
|
1659
3244
|
+ '.tacit-modal-col-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}'
|
|
1660
3245
|
+ '.tacit-modal-rationale-text{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.6}.tacit-modal-savings{color:var(--dsw-alias-state-success-primary);font-size:12px;font-weight:600}'
|
|
1661
3246
|
+ '.tacit-modal-actions{display:flex;gap:8px;justify-content:flex-end}'
|
|
3247
|
+
+ '.tacit-confirm-body{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary)}'
|
|
3248
|
+
+ '.tacit-confirm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}'
|
|
1662
3249
|
+ '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:0 2px 8px;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
|
|
1663
3250
|
+ '.tacit-feedback-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-feedback-title{color:var(--dsw-alias-label-secondary)}'
|
|
1664
3251
|
+ '.tacit-feedback-vote{font:inherit;font-size:14px;line-height:1;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 8px}.tacit-feedback-vote:hover{border-color:var(--dsw-alias-brand-primary)}.tacit-feedback-vote-active{border-color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}'
|
|
@@ -1668,6 +3255,46 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1668
3255
|
+ '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}.tacit-chip-scope{color:var(--dsw-alias-brand-primary)}.tacit-select{max-width:11rem}'
|
|
1669
3256
|
+ '.tacit-directive{display:flex;align-items:center;gap:8px}.tacit-directive-text{flex:1;min-width:0}.tacit-directive-off .tacit-directive-text{opacity:.5;text-decoration:line-through}.tacit-directive-input{width:min(420px,100%);flex:1}.tacit-preview summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-preview pre{margin-top:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:8px 10px}'
|
|
1670
3257
|
+ '.tacit-rules-list{display:flex;flex-direction:column;gap:6px}.tacit-rule{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}'
|
|
3258
|
+
+ '.tacit-card{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}'
|
|
3259
|
+
+ '.tacit-card-head{width:100%;display:flex;align-items:center;gap:8px;text-align:left;padding:10px 12px;background:none;border:0;cursor:pointer;font:inherit;color:inherit}'
|
|
3260
|
+
+ '.tacit-card-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary)}'
|
|
3261
|
+
+ '.tacit-card-title{font-size:13px;font-weight:600}.tacit-card-summary{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
|
|
3262
|
+
+ '.tacit-card-count{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px;font-variant-numeric:tabular-nums}'
|
|
3263
|
+
+ '.tacit-card-chevron{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:11px}'
|
|
3264
|
+
+ '.tacit-card-body{padding:0 12px 12px}'
|
|
3265
|
+
+ '.tacit-usage{display:flex;flex-direction:column;gap:10px}'
|
|
3266
|
+
+ '.tacit-tiles{display:flex;flex-wrap:wrap;gap:8px}'
|
|
3267
|
+
+ '.tacit-tile{flex:1 1 140px;display:flex;flex-direction:column;gap:2px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;min-width:0}'
|
|
3268
|
+
+ '.tacit-tile-label{font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-tile-value{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.tacit-tile-note{font-size:10px;color:var(--dsw-alias-state-warn-primary)}'
|
|
3269
|
+
+ '.tacit-usage-chart{display:flex;flex-direction:column;gap:6px}.tacit-bars{width:100%;height:48px}.tacit-bar{fill:var(--dsw-alias-brand-primary)}'
|
|
3270
|
+
+ '.tacit-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}'
|
|
3271
|
+
+ '.tacit-series-toggle{display:flex;gap:6px}.tacit-series-toggle button[aria-pressed=true]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}'
|
|
3272
|
+
+ '.tacit-usage-breakdown{display:flex;flex-direction:column;gap:4px}'
|
|
3273
|
+
+ '.tacit-breakdown-row{display:grid;grid-template-columns:minmax(0,1.6fr) 4rem 5rem 5rem minmax(60px,1fr);align-items:center;gap:6px;font-size:12px;font-variant-numeric:tabular-nums}'
|
|
3274
|
+
+ '.tacit-breakdown-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-breakdown-calls,.tacit-breakdown-tokens,.tacit-breakdown-usd{text-align:right;color:var(--dsw-alias-label-secondary)}'
|
|
3275
|
+
+ '.tacit-share-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-share{background:var(--dsw-alias-brand-primary);border-radius:3px;height:6px}'
|
|
3276
|
+
+ '.tacit-warn-bar{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;font-size:11px}'
|
|
3277
|
+
+ '.tacit-warn-label{color:var(--dsw-alias-label-secondary)}.tacit-warn-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-warn-fill{background:var(--dsw-alias-label-secondary);border-radius:3px;height:6px}'
|
|
3278
|
+
+ '.tacit-warn-warn{border-color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-label{color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-fill{background:var(--dsw-alias-state-warn-primary)}'
|
|
3279
|
+
+ '.tacit-warn-exceeded{border-color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-label{color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-fill{background:var(--dsw-alias-state-error-primary)}'
|
|
3280
|
+
+ '.tacit-usage-filters{display:flex;flex-direction:column;gap:6px}.tacit-filter-row{display:flex;flex-wrap:wrap;gap:8px}.tacit-filter{display:flex;flex-direction:column;gap:2px;min-width:0}.tacit-filter-label{font-size:10px;color:var(--dsw-alias-label-secondary)}'
|
|
3281
|
+
+ '.tacit-usage-runs{display:flex;flex-direction:column;gap:6px}'
|
|
3282
|
+
+ '.tacit-usage-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
|
|
3283
|
+
+ '.tacit-usage-row{display:grid;grid-template-columns:minmax(90px,1fr) minmax(90px,1.2fr) minmax(80px,1.2fr) minmax(90px,1.2fr) 5rem 4rem 5rem 5rem;gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
|
|
3284
|
+
+ '.tacit-usage-table>.tacit-usage-row:first-child{border-top:0}.tacit-usage-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
|
|
3285
|
+
+ '.tacit-usage-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
|
|
3286
|
+
+ '.tacit-run-toggle{width:100%;text-align:left}'
|
|
3287
|
+
+ '.tacit-usage-attempts{border-top:1px dashed var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);padding:6px 8px}.tacit-usage-attempts-cell{display:flex;flex-direction:column;gap:4px}'
|
|
3288
|
+
+ '.tacit-attempt{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-primary)}.tacit-attempt-meta,.tacit-attempt-tokens{color:var(--dsw-alias-label-secondary)}.tacit-attempt-usd{margin-left:auto;font-variant-numeric:tabular-nums}'
|
|
3289
|
+
+ '.tacit-status-success,.tacit-status-ok{color:var(--dsw-alias-state-success-primary)}.tacit-status-partial,.tacit-status-unmetered{color:var(--dsw-alias-state-warn-primary)}.tacit-status-failed{color:var(--dsw-alias-state-error-primary)}.tacit-status-running{color:var(--dsw-alias-label-secondary)}'
|
|
3290
|
+
+ '.tacit-pager{display:flex;align-items:center;gap:8px}.tacit-pager-label{font-size:11px;color:var(--dsw-alias-label-secondary)}'
|
|
3291
|
+
+ '.tacit-pricing{display:flex;flex-direction:column;gap:8px}'
|
|
3292
|
+
+ '.tacit-pricing-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
|
|
3293
|
+
+ '.tacit-pricing-row{display:grid;grid-template-columns:minmax(120px,1.6fr) 6rem repeat(3,5rem) minmax(90px,1fr);gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
|
|
3294
|
+
+ '.tacit-pricing-table>.tacit-pricing-row:first-child{border-top:0}.tacit-pricing-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
|
|
3295
|
+
+ '.tacit-pricing-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-pricing-model{font-weight:600}'
|
|
3296
|
+
+ '.tacit-pricing-error{font-size:11px;color:var(--dsw-alias-state-warn-primary);line-height:1.5}'
|
|
3297
|
+
+ '@media (max-width:640px){.tacit-settings-label{min-width:0;flex-basis:100%}.tacit-modal-cols{grid-template-columns:1fr}.tacit-usage-row{display:flex;flex-direction:column}.tacit-usage-cell{white-space:normal}.tacit-pricing-row{display:flex;flex-direction:column}.tacit-pricing-cell{white-space:normal}.tacit-tile{flex-basis:45%}.tacit-breakdown-row{grid-template-columns:minmax(0,1fr) 4rem 5rem}}'
|
|
1671
3298
|
|
|
1672
3299
|
function injectCss() {
|
|
1673
3300
|
const tagId = 'dsh-tacit/styles.css'
|
|
@@ -1685,6 +3312,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1685
3312
|
function apply(ctx) {
|
|
1686
3313
|
ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'dsh-tacit: dictionaries')
|
|
1687
3314
|
const t = ctx.locale.bind(NS)
|
|
3315
|
+
// Store actions write result notices themselves — they hold the measured
|
|
3316
|
+
// figures — so the one bound translator is handed to them here.
|
|
3317
|
+
setTranslator(t)
|
|
1688
3318
|
const kit = makeKit(t)
|
|
1689
3319
|
injectCss()
|
|
1690
3320
|
|
|
@@ -1752,6 +3382,24 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
|
|
|
1752
3382
|
closeFeedback,
|
|
1753
3383
|
fadeFeedback,
|
|
1754
3384
|
rootStore,
|
|
3385
|
+
toggleSection,
|
|
3386
|
+
setRootNotice,
|
|
3387
|
+
fetchUsage,
|
|
3388
|
+
setUsageFilter,
|
|
3389
|
+
toggleUsageRun,
|
|
3390
|
+
startUsagePolling,
|
|
3391
|
+
stopUsagePolling,
|
|
3392
|
+
setUsageSeries,
|
|
3393
|
+
refreshPricing,
|
|
3394
|
+
openConfirm,
|
|
3395
|
+
closeConfirm,
|
|
3396
|
+
fetchBootstrapPreview,
|
|
3397
|
+
clearAllRoot,
|
|
3398
|
+
clearUsageHistory,
|
|
3399
|
+
runNotice,
|
|
3400
|
+
fmtRate,
|
|
3401
|
+
UsageCard,
|
|
3402
|
+
PricingCard,
|
|
1755
3403
|
},
|
|
1756
3404
|
}
|
|
1757
3405
|
},
|