@mzzsfy/dsh-usage-dash 0.4.0 → 0.6.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/src/client.js CHANGED
@@ -77,9 +77,76 @@ function resolveMinuteRange(presetId, now = new Date()) {
77
77
  return { from, to: minuteBucket(now) }
78
78
  }
79
79
 
80
+ // 自定义挡 id 与时/分自定义跨度上限(与数据保留期一致:小时桶固定 15 天,分钟桶上限 7 天)
81
+ const CUSTOM_RANGE_ID = 'custom'
82
+ const HOUR_CUSTOM_MAX_HOURS = 15 * 24
83
+ const MINUTE_CUSTOM_MAX_MINUTES = 7 * 24 * 60
84
+ // 选中自定义挡且无历史输入时的预填窗口
85
+ const POINT_CUSTOM_DEFAULT_HOURS = 24
86
+
87
+ const DATETIME_INPUT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/
88
+
89
+ // datetime-local 输入值解析为本地时刻,形态或日历非法(13 月/2 月 30 日/回卷)返回 null
90
+ function parseLocalDateTime(value) {
91
+ if (typeof value !== 'string') return null
92
+ const match = DATETIME_INPUT_PATTERN.exec(value)
93
+ if (!match) return null
94
+ const [year, month, day, hour, minute, second] = match.slice(1)
95
+ const parts = [Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0)]
96
+ const date = new Date(...parts)
97
+ const components = [date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes()]
98
+ // 数字分量构造对超界值静默回卷,逐分量回读比对拦截
99
+ const legal = parts.slice(0, 5).every((part, index) => components[index] === part)
100
+ return legal ? date : null
101
+ }
102
+
103
+ const formatDateTimeInput = (date) => `${formatDate(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
104
+
105
+ // 小时自定义范围:两端归一到所在小时桶(闭区间),跨度超保留期钳起点
106
+ function resolveHourCustomRange(fromValue, toValue) {
107
+ const from = parseLocalDateTime(fromValue)
108
+ const to = parseLocalDateTime(toValue)
109
+ if (!from || !to || from > to) return null
110
+ const clampedFrom = new Date(Math.max(from.getTime(), to.getTime() - HOUR_CUSTOM_MAX_HOURS * MS_PER_HOUR))
111
+ return { from: hourBucket(clampedFrom), to: hourBucket(to) }
112
+ }
113
+
114
+ // 分钟自定义范围:from 对齐 10 分钟桶边界,to 保持原始分钟(闭区间上界),跨度超保留期钳起点
115
+ function resolveMinuteCustomRange(fromValue, toValue) {
116
+ const from = parseLocalDateTime(fromValue)
117
+ const to = parseLocalDateTime(toValue)
118
+ if (!from || !to || from > to) return null
119
+ const clampedFrom = Math.max(
120
+ minuteBucketFloor(from.getTime()),
121
+ minuteBucketFloor(to.getTime()) - MINUTE_CUSTOM_MAX_MINUTES * MS_PER_MINUTE,
122
+ )
123
+ return { from: minuteBucket(new Date(clampedFrom)), to: minuteBucket(to) }
124
+ }
125
+
126
+ // 时/分查询请求与缓存键:预设挡键为挡位 id,自定义挡键含归一后桶串(范围变化即换键防误命中)
127
+ function resolvePointQuery(view, preset, customFrom, customTo, now = new Date()) {
128
+ if (view === 'hour') {
129
+ if (preset === CUSTOM_RANGE_ID) {
130
+ const range = resolveHourCustomRange(customFrom, customTo)
131
+ return range ? { request: range, key: `${CUSTOM_RANGE_ID}:${range.from}:${range.to}` } : null
132
+ }
133
+ return { request: resolveHourRange(preset, now), key: preset }
134
+ }
135
+ if (view === 'minute') {
136
+ if (preset === CUSTOM_RANGE_ID) {
137
+ const range = resolveMinuteCustomRange(customFrom, customTo)
138
+ return range ? { request: range, key: `${CUSTOM_RANGE_ID}:${range.from}:${range.to}` } : null
139
+ }
140
+ return { request: resolveMinuteRange(preset, now), key: preset }
141
+ }
142
+ return null
143
+ }
144
+
80
145
  function maxSlotsFor(view, presetId) {
81
- if (view === 'hour') return hourValueOf(presetId) + 1
82
- if (view === 'minute') return minuteValueOf(presetId) / MINUTE_BUCKET_SPAN_MINUTES + 1
146
+ if (view === 'hour') return (presetId === CUSTOM_RANGE_ID ? HOUR_CUSTOM_MAX_HOURS : hourValueOf(presetId)) + 1
147
+ if (view === 'minute') {
148
+ return (presetId === CUSTOM_RANGE_ID ? MINUTE_CUSTOM_MAX_MINUTES : minuteValueOf(presetId)) / MINUTE_BUCKET_SPAN_MINUTES + 1
149
+ }
83
150
  return DAY_MAX_SLOTS
84
151
  }
85
152
 
@@ -113,8 +180,8 @@ const MESSAGES_ZH = {
113
180
  'rangePreset.30': '30 天',
114
181
  'rangePreset.90': '90 天',
115
182
  rangeCustom: '自定义',
116
- from: '开始日期',
117
- to: '结束日期',
183
+ from: '开始',
184
+ to: '结束',
118
185
  refresh: '刷新',
119
186
  loading: '正在扫描历史会话。安装插件后首次会全量回扫,数据量大时耗时较久,期间尽量减少操作以免服务变卡',
120
187
  tokens: 'Tokens 用量',
@@ -128,6 +195,7 @@ const MESSAGES_ZH = {
128
195
  hitRateLegend: '缓存命中率',
129
196
  avgSpeed: '平均生成速度',
130
197
  speedLegend: '平均生成速度',
198
+ ttftLegend: '首 token 延迟',
131
199
  topModel: '最常用模型',
132
200
  topModelHint: '按 token 用量排序,非调用次数',
133
201
  heatmap: '活跃热力图',
@@ -267,6 +335,7 @@ const MESSAGES_EN = {
267
335
  hitRateLegend: 'Cache-hit rate',
268
336
  avgSpeed: 'Avg speed',
269
337
  speedLegend: 'Avg speed',
338
+ ttftLegend: 'First-token latency',
270
339
  topModel: 'Top model',
271
340
  topModelHint: 'Ranked by token usage, not call count',
272
341
  heatmap: 'Activity heatmap',
@@ -529,11 +598,14 @@ const BAR_MAX_WIDTH = 30
529
598
  const AXIS_TICK_COUNT = 4
530
599
  // 速度刻度上限钳底:全零或无速度防除零(速度不设轴,读数走 tooltip)
531
600
  const SPEED_SCALE_FLOOR = 1
601
+ // 首 token 延迟刻度上限钳底(毫秒):全零或无数据防除零(不设轴,读数走 tooltip)
602
+ const TTFT_SCALE_FLOOR = 1
532
603
  // 轴上限留白系数:数据峰不顶满绘图区,顶部留出标注空间
533
604
  const AXIS_SCALE_HEADROOM = 1.1
534
605
  // 图例键:折线项与模型项共处同一显隐集合
535
606
  const LEGEND_KEY_RATE = 'rate'
536
607
  const LEGEND_KEY_SPEED = 'speed'
608
+ const LEGEND_KEY_TTFT = 'ttft'
537
609
 
538
610
  function niceTicks(max, count) {
539
611
  if (max <= 0 || count <= 0) return []
@@ -614,6 +686,7 @@ function trendLayout(slots, modelOrder, avail, labelMinPitch) {
614
686
  const PERCENT_SCALE = 100
615
687
  const RATE_AXIS_STEPS = 4
616
688
  const TREND_LINE_WIDTH = 2
689
+ const TREND_LINE_GAP_SLOTS = 1.5
617
690
  const TREND_DOT_RADIUS = 4
618
691
  const TREND_DOT_RING = 2
619
692
  const AXIS_RATE_GAP = 8
@@ -650,8 +723,22 @@ function trendSpeedPoints(slots, bars, plotHeight, scaleMax) {
650
723
  return points
651
724
  }
652
725
 
653
- // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返
654
- function smoothPath(points) {
726
+ // token 延迟曲线点:仅带 ttft 槽产出,高度按刻度上限归一(毫秒域独立归一)
727
+ function trendTtftPoints(slots, bars, plotHeight, scaleMax) {
728
+ const points = []
729
+ slots.forEach((slot, index) => {
730
+ if (slot.ttft === undefined) return
731
+ points.push({
732
+ day: slot.day,
733
+ x: bars[index].x,
734
+ y: CHART_PAD.top + plotHeight - (slot.ttft / scaleMax) * plotHeight,
735
+ })
736
+ })
737
+ return points
738
+ }
739
+
740
+ // Catmull-Rom 转三次贝塞尔:控制点取邻点差六分之一,端点折返;相邻点间距超 maxGap 折线断开成新段(稀疏槽防长弧跨越)
741
+ function smoothPath(points, maxGap = Number.POSITIVE_INFINITY) {
655
742
  if (points.length === 0) return ''
656
743
  if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
657
744
  let d = `M ${points[0].x} ${points[0].y}`
@@ -660,6 +747,10 @@ function smoothPath(points) {
660
747
  const p1 = points[i]
661
748
  const p2 = points[i + 1]
662
749
  const p3 = points[i + 2] ?? p2
750
+ if (p2.x - p1.x > maxGap) {
751
+ d += ` M ${p2.x} ${p2.y}`
752
+ continue
753
+ }
663
754
  const c1x = p1.x + (p2.x - p0.x) / 6
664
755
  const c1y = p1.y + (p2.y - p0.y) / 6
665
756
  const c2x = p2.x - (p3.x - p1.x) / 6
@@ -719,6 +810,30 @@ function speedScaleMax(slots) {
719
810
  return Math.max(SPEED_SCALE_FLOOR, ...slots.map((slot) => slot.speed ?? 0))
720
811
  }
721
812
 
813
+ // 首 token 延迟刻度上限(毫秒):全零或无数据钳底防除零
814
+ function ttftScaleMax(slots) {
815
+ return Math.max(TTFT_SCALE_FLOOR, ...slots.map((slot) => slot.ttft ?? 0))
816
+ }
817
+
818
+ // tooltip 首 token 延迟行文本:无 ttft 与命中率同款占位符,有则为官方时长口径
819
+ function ttftTipText(ttft, t) {
820
+ return ttft === undefined ? TOOLTIP_MISSING : formatDuration(ttft, t)
821
+ }
822
+
823
+ // 语言中立短时长:60 秒内一位小数秒,以上整秒折分秒(模型列表第二行用,禁本地化)
824
+ function formatDurationShort(ms) {
825
+ const seconds = ms / MS_PER_SECOND
826
+ if (seconds < DURATION_MINUTE_SECONDS) return `${Math.round(seconds * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL}s`
827
+ const whole = Math.round(seconds)
828
+ return `${Math.floor(whole / SECONDS_PER_MINUTE)}m${whole % SECONDS_PER_MINUTE}s`
829
+ }
830
+
831
+ // 模型首 token 延迟短文本:语言中立;无 ttft(无配对数据)为空串
832
+ function modelTtftText(ttft) {
833
+ if (ttft === undefined) return ''
834
+ return `TTFT ${formatDurationShort(ttft)}`
835
+ }
836
+
722
837
  // 热力图:窗口固定 26 周,与所选范围无关
723
838
  const HEAT_WEEKS = 26
724
839
  const HEAT_ROW_COUNT = 7
@@ -776,29 +891,33 @@ function heatLevel(tokens, max) {
776
891
  return tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * HEAT_LEVEL_BANDS)
777
892
  }
778
893
 
779
- // ChartTip 定位:锚定区装得下贴内顶(趋势图悬停区即绘图区,悬浮窗留在图表内),装不下上翻、再下翻、末了钳边界顶
894
+ // ChartTip 定位:进入命中区时锚定指针,先放指针右下,右侧或下方越界翻对侧,仍越界钳进边界
780
895
  const TIP_GAP_PX = 8
781
896
  const TIP_MARGIN_PX = 8
782
897
 
898
+ // 悬停锚点:指针事件取指针坐标;焦点事件无坐标,回退目标矩形中心保键盘可访问
899
+ const pointerAt = (event) => {
900
+ if (event.clientX != null && event.clientY != null) return { x: event.clientX, y: event.clientY }
901
+ const rect = event.currentTarget.getBoundingClientRect()
902
+ return { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }
903
+ }
904
+
783
905
  // 回合费用芯片与前置官方芯片(结束时钟)的间距
784
906
  const TURN_COST_GAP_PX = 8
785
907
 
786
908
  function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX) {
787
909
  if (!tip || tip.width <= 0 || tip.height <= 0) return null
788
- if (!anchor || (anchor.left === 0 && anchor.top === 0 && anchor.right === 0 && anchor.bottom === 0)) return null
910
+ if (!anchor || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) return null
789
911
  const minX = bounds.left + margin
790
912
  const minY = bounds.top + margin
791
- const maxX = bounds.right - margin
792
- const maxY = bounds.bottom - margin
793
- const left = Math.max(minX, Math.min((anchor.left + anchor.right) / 2 - tip.width / 2, maxX - tip.width))
794
- const inside = anchor.top + gap
795
- const above = anchor.top - gap - tip.height
796
- const below = anchor.bottom + gap
797
- let top
798
- if (inside + tip.height <= anchor.bottom) top = inside
799
- else if (above >= minY) top = above
800
- else if (below + tip.height <= maxY) top = below
801
- else top = minY
913
+ const maxLeft = bounds.right - margin - tip.width
914
+ const maxTop = bounds.bottom - margin - tip.height
915
+ const left = anchor.x + gap + tip.width <= bounds.right - margin
916
+ ? anchor.x + gap
917
+ : Math.max(minX, Math.min(anchor.x - gap - tip.width, maxLeft))
918
+ const top = anchor.y + gap + tip.height <= bounds.bottom - margin
919
+ ? anchor.y + gap
920
+ : Math.max(minY, Math.min(anchor.y - gap - tip.height, maxTop))
802
921
  return { left, top }
803
922
  }
804
923
 
@@ -1264,32 +1383,76 @@ const TURN_COST_CHIP_ORDER = 20 + 10
1264
1383
  // messageId 反查:节点表为 chat 节点仓库(values() 可枚举,Map/仓库两态兼容);
1265
1384
  // 回合级用量优先取回合位置数据(location.turn.data.get('turn-tail')).tokenUsage(官方 tokenUsage 聚合,
1266
1385
  // 分页窗口缺 turn/start 时缺席),回退视图节点 data.closing.usage(末步用量采样,输入侧已含缓存,计费口径同源)
1267
- function turnTokenUsageOfMessage(nodes, messageId) {
1386
+ // 索引缓存:每份节点表快照只全量扫描一次建 messageId→turn-tail 索引(WeakMap 随快照释放),
1387
+ // 长会话流式期间多芯片各自全量扫描是 O(消息数×节点数) 放大,索引后单快照 O(节点数)
1388
+ const TURN_USAGE_INDEX_CACHE = new WeakMap()
1389
+
1390
+ function turnUsageIndexOf(nodes) {
1391
+ let index = TURN_USAGE_INDEX_CACHE.get(nodes)
1392
+ if (index !== undefined) return index
1393
+ index = new Map()
1268
1394
  const list = nodes && typeof nodes.values === 'function' ? [...nodes.values()] : nodes
1269
- if (!Array.isArray(list)) return null
1270
- for (const node of list) {
1271
- try {
1272
- if (node?.kind !== 'turn-tail') continue
1273
- if (node.data?.closing?.finalNode?.messageId !== messageId) continue
1274
- const turnData = node.location?.turn?.data
1275
- const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1276
- if (tail?.tokenUsage) return tail.tokenUsage
1277
- if (node.data.tokenUsage) return node.data.tokenUsage
1278
- const sampled = node.data?.closing?.usage
1279
- if (!sampled) return null
1280
- return {
1281
- uncachedInputTokens: sampled.inputTokens,
1282
- outputTokens: sampled.outputTokens,
1283
- totalTokens: sampled.totalTokens,
1284
- ...sampled.cacheReadTokens === undefined ? {} : { cacheReadTokens: sampled.cacheReadTokens },
1285
- ...sampled.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: sampled.cacheWriteTokens },
1286
- ...sampled.reasoningTokens === undefined ? {} : { reasoningTokens: sampled.reasoningTokens },
1287
- }
1288
- } catch { /* 单节点形状残缺跳过,扫描继续 */ }
1395
+ if (Array.isArray(list)) {
1396
+ for (const node of list) {
1397
+ try {
1398
+ if (node?.kind !== 'turn-tail') continue
1399
+ const messageId = node.data?.closing?.finalNode?.messageId
1400
+ if (typeof messageId !== 'string' || index.has(messageId)) continue
1401
+ index.set(messageId, node)
1402
+ } catch { /* 单节点形状残缺跳过 */ }
1403
+ }
1289
1404
  }
1405
+ TURN_USAGE_INDEX_CACHE.set(nodes, index)
1406
+ return index
1407
+ }
1408
+
1409
+ function turnTokenUsageOfMessage(nodes, messageId) {
1410
+ // 非对象(含 null/undefined)直接 null:WeakMap 键要求对象,展开校验收进索引构建,
1411
+ // 每快照只物化一次节点序列
1412
+ if (nodes === null || typeof nodes !== 'object') return null
1413
+ const node = turnUsageIndexOf(nodes).get(messageId)
1414
+ if (node === undefined) return null
1415
+ try {
1416
+ const turnData = node.location?.turn?.data
1417
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1418
+ if (tail?.tokenUsage) return tail.tokenUsage
1419
+ if (node.data.tokenUsage) return node.data.tokenUsage
1420
+ return usageSourceBuckets(node.data?.closing?.usage ?? null)
1421
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1290
1422
  return null
1291
1423
  }
1292
1424
 
1425
+ // messageId 反查用量源:返回仓库内既有引用(官方聚合 tokenUsage → 视图节点 tokenUsage → closing.usage 采样),
1426
+ // 引用恒定直至该回合数据被替换;全缺返回 null。供 useChat selector 使用 ——
1427
+ // selector 必须返回稳定引用(useSyncExternalStore 以 Object.is 比对快照),返回新建对象会造成无限渲染;
1428
+ // 采样形态(字段名差异)由 usageSourceBuckets 在渲染层归一
1429
+ function turnUsageSourceOfMessage(nodes, messageId) {
1430
+ if (nodes === null || typeof nodes !== 'object') return null
1431
+ const node = turnUsageIndexOf(nodes).get(messageId)
1432
+ if (node === undefined) return null
1433
+ try {
1434
+ const turnData = node.location?.turn?.data
1435
+ const tail = turnData && typeof turnData.get === 'function' ? turnData.get('turn-tail') : null
1436
+ return tail?.tokenUsage ?? node.data.tokenUsage ?? node.data?.closing?.usage ?? null
1437
+ } catch { /* 单节点形状残缺跳过,语义同扫描容错 */ }
1438
+ return null
1439
+ }
1440
+
1441
+ // 用量源 → 聚合桶形态:closing.usage 采样字段名不同(inputTokens 即 uncached 口径,实测 total-input=output 恒等),
1442
+ // 聚合形态(已带 uncachedInputTokens)原样直通保持引用
1443
+ function usageSourceBuckets(source) {
1444
+ if (!source || source.uncachedInputTokens !== undefined) return source
1445
+ if (source.inputTokens === undefined) return null
1446
+ return {
1447
+ uncachedInputTokens: source.inputTokens,
1448
+ outputTokens: source.outputTokens,
1449
+ totalTokens: source.totalTokens,
1450
+ ...source.cacheReadTokens === undefined ? {} : { cacheReadTokens: source.cacheReadTokens },
1451
+ ...source.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: source.cacheWriteTokens },
1452
+ ...source.reasoningTokens === undefined ? {} : { reasoningTokens: source.reasoningTokens },
1453
+ }
1454
+ }
1455
+
1293
1456
  // 计价模型键:routes 首条按官方 messageRoute 分离字段拼两段键,与采集器 refOf 双实现同源
1294
1457
  // (官方 source.provider/model 是分离字段,routes[].model 是裸模型名,展示侧才拼 provider),
1295
1458
  // 仅 model 用裸名命中 */model 档,双缺回退全通配键;多 route 取首条(单轮估算口径)
@@ -1693,9 +1856,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1693
1856
  const STYLE_CSS = `
1694
1857
  .ud-panel{display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary);
1695
1858
  --ud-chart-1:color-mix(in srgb,#0576ff 70%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 70%,white);--ud-chart-3:color-mix(in srgb,#c46212 70%,white);--ud-chart-4:color-mix(in srgb,#975bf1 70%,white);--ud-chart-5:color-mix(in srgb,#d34591 70%,white);--ud-chart-other:color-mix(in srgb,#576270 70%,white);
1696
- --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678}
1859
+ --dsw-heat-0:#ebedf0;--dsw-heat-1:#dbe3ff;--dsw-heat-2:#b7c5ff;--dsw-heat-3:#8ea4ff;--dsw-heat-4:#6884ff;--dsw-heat-5:#4d6bfe;--ud-trend-line:#0576ff;--ud-trend-speed:#0ca678;--ud-trend-ttft:#e8590c}
1697
1860
  body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,white);--ud-chart-2:color-mix(in srgb,#2f6f37 65%,white);--ud-chart-3:color-mix(in srgb,#c46212 65%,white);--ud-chart-4:color-mix(in srgb,#975bf1 65%,white);--ud-chart-5:color-mix(in srgb,#d34591 65%,white);--ud-chart-other:color-mix(in srgb,#576270 65%,white);
1698
- --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f}
1861
+ --dsw-heat-0:#21262d;--dsw-heat-1:#2f4bd0;--dsw-heat-2:#4d6bfe;--dsw-heat-3:#6e8bff;--dsw-heat-4:#93aaff;--dsw-heat-5:#c4d0ff;--ud-trend-line:#4d6bfe;--ud-trend-speed:#2fbf8f;--ud-trend-ttft:#ffa94d}
1699
1862
  .ud-toolbar{display:flex;align-items:flex-start;gap:8px}
1700
1863
  .ud-toolbar-main{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
1701
1864
  .ud-group{display:flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
@@ -1781,10 +1944,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1781
1944
  .ud-bar-hit{fill:transparent;pointer-events:all}
1782
1945
  .ud-trend{stroke:var(--ud-trend-line);opacity:.9;fill:none;pointer-events:none}
1783
1946
  .ud-trend--speed{stroke:var(--ud-trend-speed)}
1947
+ .ud-trend--ttft{stroke:var(--ud-trend-ttft)}
1784
1948
  .ud-trend-dot{fill:var(--ud-trend-line);stroke:var(--dsw-alias-bg-layer-1);stroke-width:${TREND_DOT_RING}px;pointer-events:none}
1785
1949
  .ud-trend-dot--speed{fill:var(--ud-trend-speed)}
1950
+ .ud-trend-dot--ttft{fill:var(--ud-trend-ttft)}
1786
1951
  .ud-legend-swatch--line{height:2px;border-radius:1px;background:var(--ud-trend-line)}
1787
1952
  .ud-legend-swatch--line--speed{background:var(--ud-trend-speed)}
1953
+ .ud-legend-swatch--line--ttft{background:var(--ud-trend-ttft)}
1788
1954
  .ud-model-usage{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
1789
1955
  .ud-donut-wrap{flex:0 0 auto}
1790
1956
  .ud-donut-seg{cursor:pointer;outline:none;transition:stroke-width .12s ease}
@@ -1935,7 +2101,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1935
2101
  h(FitText, null, String(stats.activeDays))))
1936
2102
  }
1937
2103
 
1938
- function Legend({ models, colorFor, speedEnabled = false, isVisible, onItem, t = defaultT }) {
2104
+ function Legend({ models, colorFor, speedEnabled = false, ttftEnabled = false, isVisible, onItem, t = defaultT }) {
1939
2105
  const itemProps = (key) => ({
1940
2106
  className: cx('ud-legend-item', isVisible && !isVisible(key) && 'ud-legend-item--off'),
1941
2107
  onClick: onItem ? (event) => onItem(key, event.ctrlKey || event.metaKey) : undefined,
@@ -1950,7 +2116,10 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1950
2116
  h('span', null, t('hitRateLegend'))),
1951
2117
  speedEnabled ? h('span', { key: 'speed', title: t('speedLegend'), ...itemProps(LEGEND_KEY_SPEED) },
1952
2118
  h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--speed' }),
1953
- h('span', null, t('speedLegend'))) : null)
2119
+ h('span', null, t('speedLegend'))) : null,
2120
+ ttftEnabled ? h('span', { key: 'ttft', title: t('ttftLegend'), ...itemProps(LEGEND_KEY_TTFT) },
2121
+ h('i', { className: 'ud-legend-swatch ud-legend-swatch--line ud-legend-swatch--line--ttft' }),
2122
+ h('span', null, t('ttftLegend'))) : null)
1954
2123
  }
1955
2124
 
1956
2125
  const colorForModel = (models) => (model) => {
@@ -1980,23 +2149,34 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
1980
2149
  return () => observer.disconnect()
1981
2150
  }, [])
1982
2151
  const hasSpeed = slots.some((slot) => slot.speed !== undefined)
1983
- const visibleSet = visibleKeys ?? new Set([...modelOrder, LEGEND_KEY_RATE, ...(hasSpeed ? [LEGEND_KEY_SPEED] : [])])
2152
+ const hasTtft = slots.some((slot) => slot.ttft !== undefined)
2153
+ const visibleSet = visibleKeys ?? new Set([
2154
+ ...modelOrder, LEGEND_KEY_RATE,
2155
+ ...(hasSpeed ? [LEGEND_KEY_SPEED] : []),
2156
+ ...(hasTtft ? [LEGEND_KEY_TTFT] : []),
2157
+ ])
1984
2158
  const visibleModels = modelOrder.filter((model) => visibleSet.has(model))
1985
2159
  const showRate = visibleSet.has(LEGEND_KEY_RATE)
1986
2160
  const showSpeed = hasSpeed && visibleSet.has(LEGEND_KEY_SPEED)
2161
+ const showTtft = hasTtft && visibleSet.has(LEGEND_KEY_TTFT)
1987
2162
  const layout = trendLayout(slots, visibleModels, avail, labelMinPitch)
1988
2163
  const plotRight = CHART_PAD.left + (slots.length - 1) * layout.step + layout.barWidth
1989
2164
  const ratePoints = trendRatePoints(slots, layout.bars, layout.plotHeight)
1990
2165
  const speedMax = speedScaleMax(slots)
1991
2166
  const speedAxisMax = speedMax * AXIS_SCALE_HEADROOM
1992
2167
  const speedPoints = showSpeed ? trendSpeedPoints(slots, layout.bars, layout.plotHeight, speedAxisMax) : []
2168
+ const ttftMax = ttftScaleMax(slots)
2169
+ const ttftAxisMax = ttftMax * AXIS_SCALE_HEADROOM
2170
+ const ttftPoints = showTtft ? trendTtftPoints(slots, layout.bars, layout.plotHeight, ttftAxisMax) : []
1993
2171
  // 左轴标定:可见柱有数据标 token;无柱数据且速度线可见标速度(tok/s);否则空
2172
+ // (ttft 不设轴,读数走 tooltip,毫秒域与速度轴不同单位不混轴)
1994
2173
  const speedAxisTicks = layout.ticks.length === 0 && showSpeed ? niceTicks(speedMax, AXIS_TICK_COUNT) : []
1995
2174
  const yTicks = leftAxisTicks(layout.ticks, layout.scaleMax, speedAxisTicks, speedAxisMax)
1996
2175
  const hoverSlot = hover ? slots[hover.index] : null
1997
2176
  const hoverRatePoint = showRate && hoverSlot ? ratePoints.find((point) => point.day === hoverSlot.day) : null
1998
2177
  const hoverSpeedPoint = hoverSlot ? speedPoints.find((point) => point.day === hoverSlot.day) : null
1999
- const pick = (index) => (event) => setHover({ index, anchor: event.currentTarget })
2178
+ const hoverTtftPoint = hoverSlot ? ttftPoints.find((point) => point.day === hoverSlot.day) : null
2179
+ const pick = (index) => (event) => setHover({ index, anchor: pointerAt(event) })
2000
2180
  const clear = () => setHover(null)
2001
2181
  const otherEntries = hoverSlot
2002
2182
  ? Object.entries(hoverSlot.otherByModel ?? {}).sort((a, b) => b[1] - a[1])
@@ -2038,11 +2218,15 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2038
2218
  ? h('text', { key: slot.day, className: 'ud-axis', x: layout.bars[index].x, y: CHART_HEIGHT - X_LABEL_OFFSET, textAnchor: 'middle' }, labelFor(slot.day))
2039
2219
  : null),
2040
2220
  showRate ? h('path', {
2041
- className: 'ud-trend', d: smoothPath(ratePoints),
2221
+ className: 'ud-trend', d: smoothPath(ratePoints, layout.step * TREND_LINE_GAP_SLOTS),
2042
2222
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2043
2223
  }) : null,
2044
2224
  showSpeed ? h('path', {
2045
- className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints),
2225
+ className: cx('ud-trend', 'ud-trend--speed'), d: smoothPath(speedPoints, layout.step * TREND_LINE_GAP_SLOTS),
2226
+ strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2227
+ }) : null,
2228
+ showTtft ? h('path', {
2229
+ className: cx('ud-trend', 'ud-trend--ttft'), d: smoothPath(ttftPoints, layout.step * TREND_LINE_GAP_SLOTS),
2046
2230
  strokeWidth: TREND_LINE_WIDTH, strokeLinejoin: 'round', strokeLinecap: 'round',
2047
2231
  }) : null,
2048
2232
  hoverRatePoint
@@ -2051,13 +2235,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2051
2235
  hoverSpeedPoint
2052
2236
  ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--speed'), cx: hoverSpeedPoint.x, cy: hoverSpeedPoint.y, r: TREND_DOT_RADIUS })
2053
2237
  : null,
2238
+ hoverTtftPoint
2239
+ ? h('circle', { className: cx('ud-trend-dot', 'ud-trend-dot--ttft'), cx: hoverTtftPoint.x, cy: hoverTtftPoint.y, r: TREND_DOT_RADIUS })
2240
+ : null,
2054
2241
  slots.map((slot, index) => h('rect', {
2055
2242
  key: `hit-${slot.day}`, className: 'ud-bar-hit',
2056
2243
  x: layout.bars[index].x - layout.step / 2, y: CHART_PAD.top, width: layout.step, height: layout.plotHeight,
2057
2244
  onMouseEnter: pick(index), onFocus: pick(index), onMouseLeave: clear, onBlur: clear,
2058
2245
  })))),
2059
2246
  h(Legend, {
2060
- models: legendModels, colorFor, speedEnabled: hasSpeed,
2247
+ models: legendModels, colorFor, speedEnabled: hasSpeed, ttftEnabled: hasTtft,
2061
2248
  isVisible: (key) => visibleSet.has(key),
2062
2249
  onItem: (key, ctrl) => setVisibleKeys(legendToggle(visibleKeys, key, ctrl)),
2063
2250
  }),
@@ -2073,6 +2260,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2073
2260
  `${model}: ${formatTokens(tokens)}`)) : []),
2074
2261
  showRate ? h('div', { key: 'rate', className: 'ud-tip-row' }, `${t('cacheHitRate')}: ${cacheRateText(hoverSlot.cacheHit, hoverSlot.cacheMiss)}`) : null,
2075
2262
  showSpeed ? h('div', { key: 'speed', className: 'ud-tip-row' }, `${t('avgSpeed')}: ${speedTipText(hoverSlot.speed)}`) : null,
2263
+ showTtft ? h('div', { key: 'ttft', className: 'ud-tip-row' }, `${t('ttftLegend')}: ${ttftTipText(hoverSlot.ttft, t)}`) : null,
2076
2264
  costEnabled && prefsRef.current.costDisplay && hoverSlot.cost !== undefined
2077
2265
  ? h('div', { key: 'cost', className: 'ud-tip-row' }, `≈ ${formatCost(hoverSlot.cost, costCurrency)}`)
2078
2266
  : null,
@@ -2092,7 +2280,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2092
2280
  ? panel.getBoundingClientRect()
2093
2281
  : { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
2094
2282
  const next = tipPlace(
2095
- anchor ? anchor.getBoundingClientRect() : null,
2283
+ anchor,
2096
2284
  { width: tip.offsetWidth, height: tip.offsetHeight },
2097
2285
  bounds,
2098
2286
  )
@@ -2144,7 +2332,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2144
2332
  const display = geom ? heatDisplayDays(days, geom.cols) : null
2145
2333
  const grid = display ? heatGrid(display, geom.size) : null
2146
2334
  const peak = display ? Math.max(1, ...display.map((slot) => slot.tokens)) : 1
2147
- const pick = (cell) => (event) => setHover({ ...cell, anchor: event.currentTarget })
2335
+ const pick = (cell) => (event) => setHover({ ...cell, anchor: pointerAt(event) })
2148
2336
  const clear = () => setHover(null)
2149
2337
  return h('div', { className: 'ud-section' },
2150
2338
  h('div', { className: 'ud-section-head' },
@@ -2193,7 +2381,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2193
2381
  const displayName = (model) => (model === OTHER_MODEL ? t('other') : model)
2194
2382
  const pick = (model) => (event) => {
2195
2383
  setHover(model)
2196
- setTip({ model, anchor: event.currentTarget })
2384
+ setTip({ model, anchor: pointerAt(event) })
2197
2385
  }
2198
2386
  const clear = () => {
2199
2387
  setHover(null)
@@ -2233,6 +2421,12 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2233
2421
  models.map((item) => {
2234
2422
  const isOther = item.model === OTHER_MODEL
2235
2423
  const speedText = modelSpeedText(item.speed)
2424
+ const ttftText = modelTtftText(item.ttft)
2425
+ const metaParts = [
2426
+ item.cost !== undefined ? `≈ ${formatCost(item.cost, costCurrency)}` : null,
2427
+ ttftText,
2428
+ speedText,
2429
+ ].filter(Boolean)
2236
2430
  return h(React.Fragment, { key: item.model },
2237
2431
  h('div', {
2238
2432
  className: cx('ud-model-row', isOther && 'ud-model-row--expand'),
@@ -2254,11 +2448,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2254
2448
  }, '›')
2255
2449
  : null,
2256
2450
  h('div', { className: 'ud-model-values' },
2257
- h('span', { className: 'ud-model-tokens' }, formatTokens(item.tokens)),
2258
- h('span', { className: 'ud-model-pct' },
2259
- item.cost !== undefined ? h('span', { className: 'ud-model-cost' }, `≈ ${formatCost(item.cost, costCurrency)} · `) : null,
2260
- formatPercent((item.tokens / total) * PERCENT_SCALE),
2261
- speedText ? ` · ${speedText}` : null))),
2451
+ h('span', { className: 'ud-model-tokens' },
2452
+ `${formatTokens(item.tokens)} (${formatPercent((item.tokens / total) * PERCENT_SCALE)})`),
2453
+ h('span', { className: 'ud-model-pct' }, metaParts.join(' · ')))),
2262
2454
  isOther
2263
2455
  ? h('div', { className: cx('ud-model-other', expandedOther && 'ud-model-other--open') },
2264
2456
  h('div', { className: 'ud-model-other-list' },
@@ -2350,9 +2542,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2350
2542
  // 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
2351
2543
  // 受费用显示开关;价格异步首帧未回不渲染,回包后补渲染;显隐节奏随官方 data-actions-reveal
2352
2544
  // 官方槽容器固定在用量/用时芯片之前,末位排布由 SlotTailPortal 移交实现
2545
+ // 响应性:useChat selector 返回用量源稳定引用(null→引用 / 引用→引用),回合结束数据发布即触发重渲染;
2546
+ // 返回仓库本体则引用恒定,快照比对恒等,新回合永不重渲染(刷新才出现的根因)
2353
2547
  const CostChip = React.memo(function CostChip({ messageId, useChat, t = defaultT }) {
2354
2548
  if (typeof useChat !== 'function') return null
2355
- const chatNodes = useChat((state) => (state && typeof state === 'object') ? state.nodes : undefined)
2549
+ const usageSource = typeof messageId === 'string' && messageId !== ''
2550
+ ? useChat((state) => turnUsageSourceOfMessage(state?.nodes, messageId))
2551
+ : null
2356
2552
  const [prefs, setPrefs] = useState(() => statsLineState.get())
2357
2553
  useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2358
2554
  const [pricingRules, setPricingRules] = useState(null)
@@ -2363,9 +2559,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2363
2559
  })
2364
2560
  return () => { alive = false }
2365
2561
  }, [])
2366
- const matched = typeof messageId === 'string' && messageId !== ''
2367
- ? turnTokenUsageOfMessage(chatNodes, messageId)
2368
- : null
2562
+ const matched = usageSourceBuckets(usageSource)
2369
2563
  const price = matched && prefs.costDisplay && pricingRules !== null
2370
2564
  ? matchPrice(pricingRules, turnModelOf(matched), new Date())
2371
2565
  : null
@@ -2829,6 +3023,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2829
3023
  const [error, setError] = useState('')
2830
3024
  const [hourPreset, setHourPreset] = useState(DEFAULT_HOUR_PRESET)
2831
3025
  const [minutePreset, setMinutePreset] = useState(DEFAULT_MINUTE_PRESET)
3026
+ // 自定义挡 datetime 输入,时/分视图共享一对,各视图按桶粒度分别归一
3027
+ const [pointCustomFrom, setPointCustomFrom] = useState('')
3028
+ const [pointCustomTo, setPointCustomTo] = useState('')
2832
3029
  const [pointStats, setPointStats] = useState(null)
2833
3030
  const [pointStatus, setPointStatus] = useState('idle')
2834
3031
  const [fetchTick, setFetchTick] = useState(0)
@@ -2895,17 +3092,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2895
3092
  load()
2896
3093
  }, [load])
2897
3094
 
2898
- const presetId = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
3095
+ const rawPreset = view === 'hour' ? hourPreset : view === 'minute' ? minutePreset : null
3096
+ // 自定义挡输入不完整时 query 为 null:不发请求,已取数据留存缓存,输入恢复即按键命中
3097
+ const activeQuery = view === 'day' ? null : resolvePointQuery(view, rawPreset, pointCustomFrom, pointCustomTo)
2899
3098
 
2900
3099
  useEffect(() => {
2901
- if (view === 'day') return
2902
- if (pointStatsMatches(pointStats, view, presetId)) return
2903
- const request = view === 'hour'
2904
- ? resolveHourRange(presetId, new Date())
2905
- : resolveMinuteRange(presetId, new Date())
3100
+ if (!activeQuery) return
3101
+ if (pointStatsMatches(pointStats, view, activeQuery.key)) return
2906
3102
  const generation = ++pointGenerationRef.current
2907
3103
  setPointStatus('loading')
2908
- requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, request).then((result) => {
3104
+ requestPost(view === 'hour' ? ENDPOINTS.hours : ENDPOINTS.minutes, activeQuery.request).then((result) => {
2909
3105
  if (pointGenerationRef.current !== generation) return
2910
3106
  if (!result.ok) {
2911
3107
  setPointStatus('error')
@@ -2914,9 +3110,9 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2914
3110
  }
2915
3111
  setError('')
2916
3112
  setPointStatus('ok')
2917
- setPointStats({ view, preset: presetId, value: result.value })
3113
+ setPointStats({ view, preset: activeQuery.key, value: result.value })
2918
3114
  })
2919
- }, [view, presetId, fetchTick])
3115
+ }, [view, rawPreset, pointCustomFrom, pointCustomTo, fetchTick])
2920
3116
 
2921
3117
  const refreshPoints = useCallback(() => {
2922
3118
  setPointStats(null)
@@ -2937,6 +3133,16 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2937
3133
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
2938
3134
  }, [])
2939
3135
 
3136
+ // 切换时/分挡位;切到自定义挡且无历史输入时预填最近窗口,即点即用
3137
+ const selectPointPreset = (id) => {
3138
+ if (id === CUSTOM_RANGE_ID && !pointCustomFrom && !pointCustomTo) {
3139
+ setPointCustomFrom(formatDateTimeInput(new Date(Date.now() - POINT_CUSTOM_DEFAULT_HOURS * MS_PER_HOUR)))
3140
+ setPointCustomTo(formatDateTimeInput(new Date()))
3141
+ }
3142
+ if (view === 'hour') setHourPreset(id)
3143
+ else setMinutePreset(id)
3144
+ }
3145
+
2940
3146
  const refresh = () => {
2941
3147
  setError('')
2942
3148
  if (view === 'day') load()
@@ -2982,7 +3188,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2982
3188
  }, [])
2983
3189
 
2984
3190
  const grouped = useMemo(() => (stats ? groupStats(stats) : null), [stats])
2985
- const pointView = pointStatsMatches(pointStats, view, presetId) ? pointStats.value : null
3191
+ const pointView = activeQuery && pointStatsMatches(pointStats, view, activeQuery.key) ? pointStats.value : null
2986
3192
  const pointGrouped = useMemo(() => (pointView ? groupPointSlots(pointView.daily) : null), [pointView])
2987
3193
  const colorFor = useMemo(() => colorForModel(stats ? stats.models : []), [stats])
2988
3194
 
@@ -2990,7 +3196,7 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2990
3196
  const trendSource = pointActive
2991
3197
  ? (pointGrouped ? { slots: pointGrouped.daily, models: pointGrouped.models, value: pointView } : null)
2992
3198
  : (grouped ? { slots: grouped.daily, models: grouped.models, value: stats } : null)
2993
- const maxSlots = pointActive ? maxSlotsFor(view, presetId) : DAY_MAX_SLOTS
3199
+ const maxSlots = pointActive ? maxSlotsFor(view, rawPreset) : DAY_MAX_SLOTS
2994
3200
  const trimmedSlots = trendSource ? trimSlots(trendSource.slots, maxSlots) : null
2995
3201
  const notes = []
2996
3202
  if (trendSource && trendSource.slots.length > trimmedSlots.length) notes.push(trendLimitedText(t, view, trimmedSlots.length))
@@ -3011,13 +3217,33 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3011
3217
  onClick: () => setView(tab.id),
3012
3218
  }, viewLabel(t, tab.id)))),
3013
3219
  pointActive
3014
- ? h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
3015
- (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
3016
- key: id,
3017
- className: cx('ud-seg-item', presetId === id && 'ud-seg-item--on'),
3018
- 'aria-pressed': presetId === id,
3019
- onClick: () => (view === 'hour' ? setHourPreset(id) : setMinutePreset(id)),
3020
- }, presetLabel(t, view, id))))
3220
+ ? h(React.Fragment, null,
3221
+ h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
3222
+ (view === 'hour' ? HOUR_PRESETS : MINUTE_PRESETS).map((id) => h('button', {
3223
+ key: id,
3224
+ className: cx('ud-seg-item', rawPreset === id && 'ud-seg-item--on'),
3225
+ 'aria-pressed': rawPreset === id,
3226
+ onClick: () => selectPointPreset(id),
3227
+ }, presetLabel(t, view, id))),
3228
+ h('button', {
3229
+ className: cx('ud-seg-item', rawPreset === CUSTOM_RANGE_ID && 'ud-seg-item--on'),
3230
+ 'aria-pressed': rawPreset === CUSTOM_RANGE_ID,
3231
+ onClick: () => selectPointPreset(CUSTOM_RANGE_ID),
3232
+ }, t('rangeCustom'))),
3233
+ rawPreset === CUSTOM_RANGE_ID
3234
+ ? h('div', { className: 'ud-custom-range' },
3235
+ h('input', {
3236
+ type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('from'),
3237
+ value: pointCustomFrom, max: pointCustomTo || undefined,
3238
+ onChange: (event) => setPointCustomFrom(event.target.value),
3239
+ }),
3240
+ h('span', { className: 'ud-custom-sep' }, '–'),
3241
+ h('input', {
3242
+ type: 'datetime-local', className: 'ud-date-input', 'aria-label': t('to'),
3243
+ value: pointCustomTo, min: pointCustomFrom || undefined, max: formatDateTimeInput(new Date()),
3244
+ onChange: (event) => setPointCustomTo(event.target.value),
3245
+ }))
3246
+ : null)
3021
3247
  : h(React.Fragment, null,
3022
3248
  h('div', { className: 'ud-group', role: 'group', 'aria-label': t('range') },
3023
3249
  DAY_PRESETS.map((id) => h('button', {