dsh-plugin-show-me-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,1877 @@
1
+ /**
2
+ * Panel components, written with 'React.createElement' (docs/05 §2).
3
+ *
4
+ * The browser half is a classic script with no transform step, so JSX and hooks
5
+ * beyond 'useState'/'useEffect'/'useMemo'/'useRef' are off the table; the tests
6
+ * assert structure ("label, value, unit and source badge all reach the DOM")
7
+ * rather than pixels.
8
+ *
9
+ * @module client/components
10
+ */
11
+
12
+ /** How often the panel asks a discussion session for its answer. */
13
+ const DISCUSSION_POLL_INTERVAL_MS = 1500
14
+ /** How long it keeps asking before giving up (a model turn can be slow). */
15
+ const DISCUSSION_POLL_MS = 180_000
16
+
17
+ /** CSS scoped under the panel root, using only theme tokens with fallbacks. */
18
+ export const PANEL_CSS = `
19
+ .smd-root{position:fixed;right:18px;bottom:18px;z-index:60;pointer-events:none;font-size:13px}
20
+ .smd-root *{box-sizing:border-box}
21
+ .smd-trigger{pointer-events:auto;display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:10px;
22
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-overlay, #fff);
23
+ color:var(--dsw-alias-label-primary, #111);cursor:pointer;box-shadow:var(--dsw-shadow-lv3, 0 6px 24px rgba(0,0,0,.18))}
24
+ .smd-trigger:hover{color:var(--dsw-alias-brand-primary, #3b6cff)}
25
+ .smd-badge{background:var(--dsw-alias-button-primary-fill, #3b6cff);color:var(--dsw-alias-label-primary-foreground, #fff);border-radius:9px;padding:0 6px;font-size:11px;line-height:16px}
26
+ .smd-panel{pointer-events:auto;position:fixed;right:18px;bottom:66px;width:min(960px, calc(100vw - 36px));
27
+ max-height:min(78vh, 900px);display:flex;flex-direction:column;border-radius:14px;overflow:hidden;
28
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-overlay, #fff);
29
+ color:var(--dsw-alias-label-primary, #111);box-shadow:var(--dsw-shadow-lv3, 0 10px 40px rgba(0,0,0,.22))}
30
+ .smd-header{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.2))}
31
+ .smd-title{font-weight:600}
32
+ .smd-sub{color:var(--dsw-alias-label-secondary, #666);font-size:11px}
33
+ .smd-spacer{flex:1}
34
+ .smd-btn{background:transparent;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.25));color:inherit;
35
+ border-radius:8px;padding:3px 8px;cursor:pointer;font-size:12px}
36
+ .smd-btn:hover{border-color:var(--dsw-alias-brand-primary, #3b6cff);color:var(--dsw-alias-brand-primary, #3b6cff)}
37
+ .smd-btn[aria-pressed="true"]{background:var(--dsw-alias-button-primary-fill, #3b6cff);color:var(--dsw-alias-label-primary-foreground, #fff);border-color:transparent;font-weight:600}
38
+ .smd-btn[aria-pressed="true"]:hover{background:var(--dsw-alias-button-primary-hover, #2f5ae0);color:var(--dsw-alias-label-primary-foreground, #fff)}
39
+ .smd-tabs{display:flex;gap:6px;flex-wrap:wrap}
40
+ .smd-body{overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
41
+ .smd-banner{border-radius:10px;padding:8px 10px;background:var(--dsw-alias-state-warn-primary, #b8860b1a);border:1px solid var(--dsw-alias-state-warn-primary, #b8860b)}
42
+ .smd-bannerError{background:var(--dsw-alias-state-error-primary, #b000201a);border-color:var(--dsw-alias-state-error-primary, #b00020)}
43
+ .smd-sectionTitle{font-weight:600;margin:2px 0 6px}
44
+ .smd-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:8px}
45
+ .smd-iterate{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));padding-top:8px}
46
+ .smd-selectBar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 8px;border-radius:10px;
47
+ border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06));font-size:12px}
48
+ .smd-section{display:flex;flex-direction:column;gap:8px}
49
+ .smd-groupTitle{font-weight:600;font-size:12px;letter-spacing:.02em}
50
+ .smd-cardOn{border-color:var(--dsw-alias-button-primary-fill, #3b6cff)}
51
+ .smd-pick{display:flex;align-items:center;padding:0 2px 0 0}
52
+ .smd-cardBody{display:flex;flex-direction:column;gap:4px;background:transparent;border:0;color:inherit;text-align:left;cursor:pointer;width:100%;padding:0}
53
+ .smd-card{border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));border-radius:10px;padding:8px 10px;
54
+ background:var(--dsw-alias-bg-layer-1, #fafafa);display:flex;flex-direction:row;gap:6px;align-items:flex-start;text-align:left}
55
+ .smd-card:has(.smd-pick:hover){border-color:var(--dsw-alias-brand-primary, #3b6cff)}
56
+ .smd-card:hover{border-color:var(--dsw-alias-brand-primary, #3b6cff)}
57
+ .smd-cardHead{display:flex;align-items:center;gap:6px}
58
+ .smd-label{font-weight:600;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
59
+ .smd-dot{width:7px;height:7px;border-radius:50%;flex:none}
60
+ .smd-dotUp{background:var(--dsw-alias-state-success-primary, #12805c)}
61
+ .smd-dotWarn{background:var(--dsw-alias-state-warn-primary, #b8860b)}
62
+ .smd-dotError{background:var(--dsw-alias-state-error-primary, #b00020)}
63
+ .smd-dotNeutral{background:var(--dsw-alias-label-secondary, #888)}
64
+ .smd-value{font-size:18px;font-variant-numeric:tabular-nums}
65
+ .smd-unit{font-size:11px;color:var(--dsw-alias-label-secondary, #666);margin-left:3px}
66
+ .smd-change{font-size:11px;font-variant-numeric:tabular-nums}
67
+ .smd-up{color:var(--dsw-alias-state-success-primary, #12805c)}
68
+ .smd-down{color:var(--dsw-alias-state-error-primary, #b00020)}
69
+ .smd-neutral{color:var(--dsw-alias-label-secondary, #666)}
70
+ .smd-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
71
+ .smd-src{font-size:10px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.25));border-radius:6px;
72
+ padding:0 5px;color:var(--dsw-alias-label-secondary, #666);text-decoration:none}
73
+ .smd-src:hover{color:var(--dsw-alias-brand-primary, #3b6cff)}
74
+ .smd-spark{display:block}
75
+ .smd-note{font-size:11px;color:var(--dsw-alias-label-secondary, #666)}
76
+ .smd-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
77
+ .smd-noteItem{border-left:3px solid var(--dsw-alias-brand-primary, #3b6cff);padding-left:8px}
78
+ .smd-detail{display:flex;flex-direction:column;gap:10px}
79
+ .smd-chart{display:flex;flex-direction:column;gap:6px}
80
+ .smd-chartBar{display:flex;align-items:center;gap:8px;min-height:22px}
81
+ .smd-chartHint{font-size:11px;color:var(--dsw-alias-label-secondary, #888)}
82
+ .smd-chartReadout{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary, #111);font-weight:600}
83
+ .smd-chipBtn{font-size:10px;line-height:16px;padding:1px 7px;border-radius:999px;cursor:pointer;color:inherit;
84
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));background:transparent}
85
+ .smd-chipBtnOn{background:var(--dsw-alias-interactive-bg-active, #eef1f6);border-color:var(--dsw-alias-border-l2, rgba(127,127,127,.45));font-weight:600}
86
+ .smd-btnInline{margin-left:6px;font-size:11px;padding:1px 6px}
87
+ .smd-chartPlot{position:relative;width:100%}
88
+ .smd-svg{display:block;width:100%;height:auto;color:var(--dsw-alias-brand-primary, #3b6cff);touch-action:none}
89
+ .smd-gridLine{stroke:currentColor;stroke-opacity:.10}
90
+ .smd-zero{stroke:currentColor;stroke-opacity:.35}
91
+ .smd-ref{stroke:currentColor;stroke-opacity:.28}
92
+ .smd-refLabel,.smd-axis{font-size:9px;fill:var(--dsw-alias-label-secondary, #777)}
93
+ .smd-hitArea{cursor:crosshair}
94
+ .smd-series{color:var(--dsw-alias-brand-primary, #3b6cff)}
95
+ .smd-line{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round}
96
+ .smd-area{fill:url(#smd-area);stroke:none}
97
+ .smd-bar{fill:currentColor;fill-opacity:.75}
98
+ .smd-candle line{stroke:currentColor;stroke-width:1}
99
+ .smd-candle rect{fill:currentColor}
100
+ .smd-candleHollow rect{fill:var(--dsw-alias-bg-base, #fff);stroke:currentColor;stroke-width:1}
101
+ .smd-cross line{stroke:currentColor;stroke-opacity:.45;stroke-dasharray:3 3}
102
+ .smd-cross circle{fill:currentColor;stroke:var(--dsw-alias-bg-base, #fff);stroke-width:1.4}
103
+ .smd-tip{position:absolute;top:4px;transform:translateX(8px);pointer-events:none;background:var(--dsw-alias-bg-overlay, #fff);
104
+ border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));border-radius:8px;padding:4px 7px;font-size:11px;
105
+ box-shadow:var(--dsw-shadow-lv2, 0 2px 10px rgba(0,0,0,.12));color:var(--dsw-alias-label-primary, #111);min-width:96px}
106
+ .smd-tipLeft{transform:translateX(calc(-100% - 8px))}
107
+ .smd-tipDate{color:var(--dsw-alias-label-secondary, #666);font-size:10px}
108
+ .smd-tipValue{font-weight:600;font-variant-numeric:tabular-nums}
109
+ .smd-tipRow{display:grid;grid-template-columns:auto auto auto auto auto auto;gap:0 5px;color:var(--dsw-alias-label-secondary, #666);font-variant-numeric:tabular-nums}
110
+ .smd-stats{display:grid;grid-template-columns:repeat(auto-fill, minmax(120px, 1fr));gap:6px;font-size:12px}
111
+ .smd-statKey{color:var(--dsw-alias-label-secondary, #666);font-size:11px}
112
+ .smd-table{width:100%;border-collapse:collapse;font-size:12px}
113
+ .smd-table th,.smd-table td{text-align:left;padding:3px 6px;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.18))}
114
+ .smd-ai{border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));border-radius:10px;padding:8px 10px;display:flex;flex-direction:column;gap:6px}
115
+ .smd-aiText{white-space:pre-wrap;font-size:12px;line-height:1.6}
116
+ .smd-discussAnswer{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.22));padding-top:6px}
117
+ .smd-setRow{display:grid;grid-template-columns:minmax(96px, 130px) minmax(80px, 1fr) minmax(120px, 1.4fr);gap:4px 10px;align-items:baseline;
118
+ padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.14));font-size:12px}
119
+ .smd-setLabel{color:var(--dsw-alias-label-secondary, #666)}
120
+ .smd-setValue{font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
121
+ .smd-setHint{color:var(--dsw-alias-label-tertiary, #888);font-size:11px}
122
+ .smd-discuss{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.35));
123
+ border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))}
124
+ .smd-chips{display:flex;gap:6px;flex-wrap:wrap}
125
+ .smd-chip{font-size:10px;border-radius:6px;padding:1px 5px;background:var(--dsw-alias-bg-layer-2, #eee);color:var(--dsw-alias-label-secondary, #555)}
126
+ .smd-input{width:100%;padding:5px 7px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
127
+ background:var(--dsw-alias-bg-base, #fff);color:inherit;font-size:12px}
128
+ .smd-foot{font-size:11px;color:var(--dsw-alias-label-secondary, #666);border-top:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.2));padding:8px 12px}
129
+ .smd-row{display:flex;align-items:center;gap:8px}
130
+ `
131
+
132
+ /**
133
+ * Inject the panel's stylesheet once.
134
+ *
135
+ * @param {Document} doc - host document.
136
+ * @param {string} [css] - stylesheet text.
137
+ * @returns {() => void} disposer removing the tag.
138
+ */
139
+ export function insertStyles(doc, css = PANEL_CSS) {
140
+ const tagId = 'show-me-data/panel.css'
141
+ if (doc.querySelector(`style[data-plugin-css="${tagId}"]`) !== null) return () => {}
142
+ const tag = doc.createElement('style')
143
+ tag.dataset.plugin = 'show-me-data'
144
+ tag.dataset.pluginCss = tagId
145
+ tag.textContent = css
146
+ doc.head.appendChild(tag)
147
+ return () => {
148
+ if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Create the component set for one React instance.
154
+ *
155
+ * @param {object} React - React (or the test stub).
156
+ * @param {object} deps - dependencies.
157
+ * @param {object} deps.store - panel store.
158
+ * @param {object} deps.api - API client.
159
+ * @param {object} deps.copy - copy table.
160
+ * @param {object} deps.format - formatting helpers ('core/format' plus the chart builders).
161
+ * @returns {Record<string, Function>} components.
162
+ */
163
+ export function createComponents(React, { store, api, copy, format, sessions, applyIndicatorChange = () => ({}), pollIntervalMs, pollTimeoutMs }) {
164
+ const { createElement: h, useState, useEffect, useMemo, useRef } = React
165
+ const {
166
+ buildLinePath,
167
+ buildBarRects,
168
+ buildSparklineShape,
169
+ buildXAxis,
170
+ buildYAxis,
171
+ buildZeroLine,
172
+ buildTimeAxis,
173
+ buildCandles,
174
+ buildCrosshair,
175
+ buildReferenceLines,
176
+ nearestIndex,
177
+ valueDomain,
178
+ hasOhlc,
179
+ linearScale,
180
+ niceTicks,
181
+ formatValue,
182
+ formatChange,
183
+ formatAge,
184
+ changeColor,
185
+ statusDot,
186
+ metricTooltip,
187
+ truncate,
188
+ } = format
189
+
190
+ /**
191
+ * A small inline sparkline.
192
+ *
193
+ * @param {{ values: number[], width?: number, height?: number }} props - props.
194
+ * @returns {any} element.
195
+ */
196
+ function Sparkline({ values, width = 88, height = 22 }) {
197
+ const { path, dot } = buildSparklineShape(values ?? [], { width, height, padding: 1 })
198
+ if (path === '') return h('span', { className: 'smd-note' }, '—')
199
+ return h(
200
+ 'svg',
201
+ { className: 'smd-spark', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': '走势' },
202
+ h('path', { d: path, fill: 'none', stroke: 'currentColor', strokeWidth: 1.2 }),
203
+ dot === null ? null : h('circle', { cx: dot.cx, cy: dot.cy, r: dot.r, fill: 'currentColor' }),
204
+ )
205
+ }
206
+
207
+ /**
208
+ * One metric card.
209
+ *
210
+ * @param {{ metric: object, onOpen: Function }} props - props.
211
+ * @returns {any} element.
212
+ */
213
+ function MetricCard({ metric, onOpen, selected, onToggleSelect }) {
214
+ const dot = statusDot(metric.status)
215
+ const decimals = metric.display?.decimals ?? 2
216
+ const color = changeColor(metric.changeAbs, metric.display?.polarity)
217
+ const dotClass = { up: 'smd-dotUp', warn: 'smd-dotWarn', error: 'smd-dotError', neutral: 'smd-dotNeutral' }[dot.color]
218
+ return h(
219
+ 'div',
220
+ {
221
+ className: `smd-card${selected === true ? ' smd-cardOn' : ''}`,
222
+ },
223
+ // A nested button cannot hold the checkbox, so selection lives beside the
224
+ // card's own clickable surface rather than inside it.
225
+ onToggleSelect === undefined
226
+ ? null
227
+ : h(
228
+ 'label',
229
+ { className: 'smd-pick', title: copy.selectHint },
230
+ h('input', { type: 'checkbox', checked: selected === true, onChange: () => onToggleSelect(metric.indicatorId) }),
231
+ ),
232
+ h(
233
+ 'button',
234
+ {
235
+ type: 'button',
236
+ className: 'smd-cardBody',
237
+ title: metricTooltip(metric),
238
+ onClick: () => onOpen?.(metric.indicatorId),
239
+ },
240
+ h(
241
+ 'div',
242
+ { className: 'smd-cardHead' },
243
+ h('span', { className: 'smd-label' }, metric.label?.zh ?? metric.indicatorId),
244
+ metric.seasonal !== undefined && metric.seasonal !== 'NA' ? h('span', { className: 'smd-chip' }, metric.seasonal) : null,
245
+ h('span', { className: `smd-dot ${dotClass}`, 'aria-label': dot.label }),
246
+ ),
247
+ metric.status === 'error'
248
+ ? h('div', { className: 'smd-note' }, `${copy.errorHint}(${metric.errorKind ?? 'unknown'})`)
249
+ : metric.status === 'missing'
250
+ ? h('div', { className: 'smd-note' }, copy.missingHint)
251
+ : h(
252
+ 'div',
253
+ { className: 'smd-value' },
254
+ formatValue(metric.latest, { decimals }),
255
+ h('span', { className: 'smd-unit' }, metric.unit),
256
+ ),
257
+ metric.status === 'fresh' || metric.status === 'stale'
258
+ ? h('div', { className: `smd-change smd-${color}` }, formatChange(metric.changeAbs, metric.changePct, '', decimals))
259
+ : null,
260
+ h('div', { className: 'smd-footer' }, h(Sparkline, { values: metric.sparkline })),
261
+ h(
262
+ 'div',
263
+ { className: 'smd-footer' },
264
+ metric.sourceRef?.url === undefined
265
+ ? null
266
+ : h(
267
+ 'a',
268
+ {
269
+ className: 'smd-src',
270
+ href: metric.sourceRef.url,
271
+ target: '_blank',
272
+ rel: 'noreferrer noopener',
273
+ onClick: (event) => event.stopPropagation(),
274
+ title: copy.viewSource,
275
+ },
276
+ `${copy.sourceBadge}: ${metric.sourceRef.label ?? metric.sourceRef.adapterId}`,
277
+ ),
278
+ h('span', { className: 'smd-note' }, metric.latestAt ?? ''),
279
+ metric.status === 'stale' ? h('span', { className: 'smd-chip' }, copy.fromCache) : null,
280
+ ),
281
+ ),
282
+ )
283
+ }
284
+
285
+ /**
286
+ * The noteworthy list.
287
+ *
288
+ * @param {{ items: object[], onOpen: Function }} props - props.
289
+ * @returns {any} element.
290
+ */
291
+ function NoteworthyList({ items, onOpen, onDiscuss, discussingId }) {
292
+ if ((items ?? []).length === 0) return h('div', { className: 'smd-note' }, copy.noteworthyEmpty)
293
+ return h(
294
+ 'ul',
295
+ { className: 'smd-list' },
296
+ items.map((item) =>
297
+ h(
298
+ 'li',
299
+ { className: 'smd-noteItem', key: item.indicatorId },
300
+ h(
301
+ 'button',
302
+ { type: 'button', className: 'smd-btn', onClick: () => onOpen?.(item.indicatorId) },
303
+ `${copy.viewDetail} ${item.label?.zh ?? item.indicatorId}`,
304
+ ),
305
+ h('span', { className: 'smd-note' }, ` 关注分 ${item.score}`),
306
+ // The whole point of this list is "what moved and why", so the row
307
+ // carries the action that answers it: a session seeded with this
308
+ // indicator, the rule that fired, and the panel numbers behind it.
309
+ onDiscuss === undefined
310
+ ? null
311
+ : h(
312
+ 'button',
313
+ {
314
+ type: 'button',
315
+ className: 'smd-btn smd-btnInline',
316
+ title: copy.noteworthyDiscussHint,
317
+ disabled: discussingId === item.indicatorId,
318
+ onClick: () => onDiscuss(item.indicatorId),
319
+ },
320
+ discussingId === item.indicatorId ? copy.discussing : copy.noteworthyDiscuss,
321
+ ),
322
+ h(
323
+ 'div',
324
+ { className: 'smd-note' },
325
+ (item.reasons ?? []).map((reason) => reason.reason?.zh ?? reason.ruleId).join(';'),
326
+ ),
327
+ item.sourceRef?.url === undefined
328
+ ? null
329
+ : h(
330
+ 'a',
331
+ { className: 'smd-src', href: item.sourceRef.url, target: '_blank', rel: 'noreferrer noopener' },
332
+ item.sourceRef.label ?? copy.sourceBadge,
333
+ ),
334
+ ),
335
+ ),
336
+ )
337
+ }
338
+
339
+ /**
340
+ * The main chart: value axis, time axis, candles or a line, crosshair readout.
341
+ *
342
+ * Everything it draws comes from the pure builders in 'core/chart', so the
343
+ * interactive parts are only React state: which index the pointer is over and
344
+ * which view mode is selected. Hovering is index-snapped — the pointer never
345
+ * has to land on a 1px line to read a value — and the readout states OHLC per
346
+ * bar when the source supplies it.
347
+ *
348
+ * @param {{ points: object[], unit: string, decimals?: number, kind?: string, stats?: object, polarity?: string, onDiscuss?: Function, discussing?: boolean, height?: number, mode?: string }} props - props.
349
+ * @returns {any} element.
350
+ */
351
+ function Chart({ points, unit, decimals = 2, kind = 'line', stats, polarity, height = 280, mode }) {
352
+ const [showTable, setShowTable] = useState(false)
353
+ const [hover, setHover] = useState(-1)
354
+ const candlesAvailable = hasOhlc(points)
355
+ const [view, setView] = useState(mode ?? (candlesAvailable && kind !== 'bar' ? 'candle' : kind === 'bar' ? 'bar' : 'area'))
356
+ const svgRef = useRef(null)
357
+ const width = 720
358
+ const geometry = useMemo(() => ({ width, height, padLeft: 14, padRight: 62, padTop: 16, padBottom: 30 }), [height])
359
+
360
+ const chart = useMemo(() => {
361
+ const list = points ?? []
362
+ if (list.length === 0) return undefined
363
+ const useExtremes = view === 'candle'
364
+ const raw = valueDomain(list, { useExtremes })
365
+ if (raw === undefined) return undefined
366
+ const axis = niceTicks(raw.min, raw.max, 5)
367
+ const yDomain = { min: axis.min, max: axis.max }
368
+ const plot = {
369
+ x: geometry.padLeft,
370
+ y: geometry.padTop,
371
+ w: width - geometry.padLeft - geometry.padRight,
372
+ h: height - geometry.padTop - geometry.padBottom,
373
+ }
374
+ const yScale = linearScale({ domain: [yDomain.min, yDomain.max], range: [plot.y + plot.h, plot.y] })
375
+ const span = Math.max(1, list.length - 1)
376
+ const xAt = (index) => plot.x + (index / span) * plot.w
377
+ const pathFor = (entries, close) => {
378
+ let d = ''
379
+ let open = false
380
+ entries.forEach((point, index) => {
381
+ if (!Number.isFinite(point.v)) {
382
+ open = false
383
+ return
384
+ }
385
+ d += `${open ? 'L' : 'M'}${Number(xAt(index).toFixed(2))} ${Number(yScale(point.v).toFixed(2))} `
386
+ open = true
387
+ })
388
+ return close && entries.length > 0 ? `${d}Z` : d.trim()
389
+ }
390
+ return {
391
+ list,
392
+ plot,
393
+ yDomain,
394
+ yTicks: axis.ticks.map((tick) => ({ value: tick, y: Number(yScale(tick).toFixed(2)), label: formatValue(tick, { decimals }) })),
395
+ xTicks: buildTimeAxis(list.map((point) => point.t), { width, height, padX: plot.x, padY: plot.y, padRight: geometry.padRight, padBottom: geometry.padBottom, innerW: plot.w, innerH: plot.h }),
396
+ candles: view === 'candle' ? buildCandles(list, { width, height, padX: plot.x, padY: plot.y, innerW: plot.w, innerH: plot.h, yDomain }) : [],
397
+ line: pathFor(list, false),
398
+ area: `${pathFor(list, false)}L${Number(xAt(list.length - 1).toFixed(2))} ${plot.y + plot.h} L${Number(xAt(0).toFixed(2))} ${plot.y + plot.h} Z`,
399
+ bars: view === 'bar' ? buildBarRects(list, { width, height, padX: plot.x, padY: plot.y, innerW: plot.w, innerH: plot.h, yDomain }) : [],
400
+ refs: buildReferenceLines(stats, yDomain, { plot }),
401
+ zero: yDomain.min < 0 && yDomain.max > 0 ? Number(yScale(0).toFixed(2)) : null,
402
+ }
403
+ }, [points, decimals, view, stats, geometry, height, width])
404
+
405
+ if (chart === undefined) return h('div', { className: 'smd-note' }, copy.empty)
406
+
407
+ const toIndex = (event) => {
408
+ const node = svgRef.current
409
+ if (node === null || typeof node.getBoundingClientRect !== 'function') return -1
410
+ const rect = node.getBoundingClientRect()
411
+ if (rect.width <= 0) return -1
412
+ const x = ((event.clientX - rect.left) / rect.width) * width
413
+ return nearestIndex(x, chart.list.length, { width, padLeft: geometry.padLeft, padRight: geometry.padRight, padX: chart.plot.x, innerW: chart.plot.w })
414
+ }
415
+ const cross = hover < 0 ? undefined : buildCrosshair({
416
+ points: chart.list,
417
+ index: hover,
418
+ geometry: { width, height, yDomain: chart.yDomain, padLeft: geometry.padLeft, padRight: geometry.padRight, padTop: geometry.padTop, padBottom: geometry.padBottom, padX: chart.plot.x, padY: chart.plot.y, innerW: chart.plot.w, innerH: chart.plot.h },
419
+ })
420
+
421
+ const modes = [
422
+ ...(candlesAvailable ? [['candle', copy.chartCandle]] : []),
423
+ ['area', copy.chartArea],
424
+ ['line', copy.chartLine],
425
+ ['bar', copy.chartBar],
426
+ ]
427
+
428
+ return h(
429
+ 'div',
430
+ { className: 'smd-chart' },
431
+ h(
432
+ 'div',
433
+ { className: 'smd-chartBar' },
434
+ h(
435
+ 'span',
436
+ { className: 'smd-chips' },
437
+ modes.map(([id, label]) =>
438
+ h(
439
+ 'button',
440
+ {
441
+ key: id,
442
+ type: 'button',
443
+ className: `smd-chipBtn${view === id ? ' smd-chipBtnOn' : ''}`,
444
+ 'aria-pressed': view === id,
445
+ onClick: () => setView(id),
446
+ },
447
+ label,
448
+ ),
449
+ ),
450
+ ),
451
+ h('span', { className: 'smd-spacer' }),
452
+ cross === undefined
453
+ ? h('span', { className: 'smd-chartHint' }, copy.chartHint)
454
+ : h('span', { className: 'smd-chartReadout' }, readout(cross, { unit, decimals, polarity })),
455
+ ),
456
+ h(
457
+ 'div',
458
+ { className: 'smd-chartPlot' },
459
+ h(
460
+ 'svg',
461
+ {
462
+ ref: svgRef,
463
+ className: 'smd-svg',
464
+ viewBox: `0 0 ${width} ${height}`,
465
+ preserveAspectRatio: 'none',
466
+ role: 'img',
467
+ 'aria-label': `${copy.tabDetail} ${unit}`,
468
+ onPointerMove: (event) => setHover(toIndex(event)),
469
+ onPointerLeave: () => setHover(-1),
470
+ onPointerDown: (event) => setHover(toIndex(event)),
471
+ },
472
+ h(
473
+ 'defs',
474
+ null,
475
+ h(
476
+ 'linearGradient',
477
+ { id: 'smd-area', x1: '0', y1: '0', x2: '0', y2: '1' },
478
+ h('stop', { offset: '0%', stopColor: 'currentColor', stopOpacity: 0.28 }),
479
+ h('stop', { offset: '100%', stopColor: 'currentColor', stopOpacity: 0.02 }),
480
+ ),
481
+ ),
482
+ chart.yTicks.map((tick) =>
483
+ h('line', { key: `g${tick.value}`, className: 'smd-gridLine', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: tick.y, y2: tick.y }),
484
+ ),
485
+ chart.zero === null ? null : h('line', { className: 'smd-zero', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: chart.zero, y2: chart.zero }),
486
+ chart.refs.map((ref) =>
487
+ h(
488
+ 'g',
489
+ { key: `r${ref.kind}` },
490
+ h('line', { className: 'smd-ref', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: ref.y, y2: ref.y, strokeDasharray: '4 4' }),
491
+ h('text', { className: 'smd-refLabel', x: chart.plot.x + chart.plot.w + 4, y: ref.y + 3 }, `${ref.label}${formatValue(ref.value, { decimals })}`),
492
+ ),
493
+ ),
494
+ chart.yTicks.map((tick) => h('text', { key: `y${tick.value}`, className: 'smd-axis', x: chart.plot.x + chart.plot.w + 4, y: tick.y + 3 }, tick.label)),
495
+ chart.xTicks.map((tick) => h('text', { key: `x${tick.t}`, className: 'smd-axis', x: tick.x, y: height - 10, textAnchor: tick.anchor }, tick.label)),
496
+ view === 'candle'
497
+ ? chart.candles.map((candle) =>
498
+ h(
499
+ 'g',
500
+ { key: candle.t, className: `smd-candle ${changeColor(candle.point.v - candle.point.o, polarity)}` },
501
+ h('line', { x1: candle.x, x2: candle.x, y1: candle.yHigh, y2: candle.yLow }),
502
+ h('rect', { x: Number((candle.x - candle.bodyW / 2).toFixed(2)), y: candle.bodyTop, width: candle.bodyW, height: candle.bodyH, className: candle.hollow ? 'smd-candleHollow' : undefined }),
503
+ ),
504
+ )
505
+ : view === 'bar'
506
+ ? chart.bars.map((rect) => h('rect', { key: rect.t, className: 'smd-bar', x: rect.x, y: rect.y, width: Math.max(1, rect.w), height: rect.h }))
507
+ : h(
508
+ 'g',
509
+ { className: 'smd-series' },
510
+ view === 'area' ? h('path', { className: 'smd-area', d: chart.area }) : null,
511
+ h('path', { className: 'smd-line', d: chart.line }),
512
+ ),
513
+ cross === undefined
514
+ ? null
515
+ : h(
516
+ 'g',
517
+ { className: 'smd-cross' },
518
+ h('line', { x1: cross.x, x2: cross.x, y1: chart.plot.y, y2: chart.plot.y + chart.plot.h }),
519
+ h('circle', { cx: cross.x, cy: cross.y, r: 3.2 }),
520
+ chart.yTicks.map((tick) => h('line', { key: `h${tick.value}`, className: 'smd-crossH', x1: chart.plot.x, x2: chart.plot.x + chart.plot.w, y1: tick.y, y2: tick.y, strokeOpacity: 0 })),
521
+ ),
522
+ h('rect', { className: 'smd-hitArea', x: chart.plot.x, y: chart.plot.y, width: chart.plot.w, height: chart.plot.h, fill: 'transparent' }),
523
+ ),
524
+ cross === undefined
525
+ ? null
526
+ : h(
527
+ 'div',
528
+ { className: `smd-tip${cross.anchoredLeft ? ' smd-tipLeft' : ''}`, style: { left: `${(cross.x / width) * 100}%` } },
529
+ h('div', { className: 'smd-tipDate' }, cross.point.t),
530
+ cross.bar === undefined
531
+ ? null
532
+ : h(
533
+ 'div',
534
+ { className: 'smd-tipRow' },
535
+ h('span', null, '开'), h('span', null, formatValue(cross.bar.o, { decimals })),
536
+ h('span', null, '高'), h('span', null, formatValue(cross.bar.h, { decimals })),
537
+ h('span', null, '低'), h('span', null, formatValue(cross.bar.l, { decimals })),
538
+ ),
539
+ h('div', { className: 'smd-tipValue' }, `${formatValue(cross.point.v, { decimals })}${unit ?? ''}`),
540
+ ),
541
+ ),
542
+ h(
543
+ 'div',
544
+ { className: 'smd-chartBar' },
545
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => setShowTable((value) => !value) }, showTable ? copy.hideDataTable : copy.showDataTable),
546
+ h('span', { className: 'smd-spacer' }),
547
+ h('span', { className: 'smd-note' }, `${(points ?? []).length} ${copy.pointsUnit}`),
548
+ ),
549
+ showTable
550
+ ? h(
551
+ 'table',
552
+ { className: 'smd-table' },
553
+ h('thead', null, h('tr', null, h('th', null, copy.date), h('th', null, copy.value))),
554
+ h(
555
+ 'tbody',
556
+ null,
557
+ (points ?? []).slice(-40).reverse().map((point) =>
558
+ h('tr', { key: point.t }, h('td', null, point.t), h('td', null, formatValue(point.v, { decimals }))),
559
+ ),
560
+ ),
561
+ )
562
+ : null,
563
+ )
564
+ }
565
+
566
+ /**
567
+ * The hover readout shown in the chart's header bar.
568
+ *
569
+ * @param {object} cross - crosshair state.
570
+ * @param {{ unit?: string, decimals?: number, polarity?: string }} options - formatting.
571
+ * @returns {string} readout text.
572
+ */
573
+ function readout(cross, { unit, decimals, polarity }) {
574
+ const change = cross.bar === undefined ? undefined : cross.point.v - cross.bar.o
575
+ const parts = [cross.point.t, `${formatValue(cross.point.v, { decimals })}${unit ?? ''}`]
576
+ if (change !== undefined) parts.push(`${change >= 0 ? '+' : ''}${formatValue(change, { decimals })}`)
577
+ return parts.join(' ')
578
+ }
579
+
580
+ /**
581
+ * The statistics strip.
582
+ *
583
+ * @param {{ stats: object, unit: string, decimals?: number }} props - props.
584
+ * @returns {any} element.
585
+ */
586
+ function StatsStrip({ stats, unit, decimals = 2 }) {
587
+ if (stats === undefined || stats === null) return h('div', { className: 'smd-note' }, copy.empty)
588
+ const entries = [
589
+ [copy.statsMean, formatValue(stats.mean, { decimals })],
590
+ [copy.statsMin, formatValue(stats.min, { decimals })],
591
+ [copy.statsMax, formatValue(stats.max, { decimals })],
592
+ [copy.statsStdDev, formatValue(stats.stdDev, { decimals })],
593
+ [copy.statsYoy, stats.yoy === undefined ? '—' : `${formatValue(stats.yoy, { decimals: 1 })}%`],
594
+ [copy.statsMom, stats.mom === undefined ? '—' : `${formatValue(stats.mom, { decimals: 1 })}%`],
595
+ [copy.statsPercentile, stats.percentile === undefined ? '—' : `${formatValue(stats.percentile * 100, { decimals: 0 })}%`],
596
+ [copy.statsMissing, formatValue(stats.missingCount, { decimals: 0 })],
597
+ ]
598
+ return h(
599
+ 'div',
600
+ { className: 'smd-stats' },
601
+ entries.map(([key, value]) =>
602
+ h('div', { key, className: 'smd-stat' }, h('div', { className: 'smd-statKey' }, key), h('div', null, `${value}${unit === '' ? '' : ''}`)),
603
+ ),
604
+ )
605
+ }
606
+
607
+ /**
608
+ * The AI block: streamed text, citations and the mode chip.
609
+ *
610
+ * @param {{ ai: object, onAsk: Function, onSubmitQuestion: Function, question: string, onQuestionChange: Function }} props - props.
611
+ * @returns {any} element.
612
+ */
613
+ function AiPanel({ ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussAnswer, discussPending, discussError, discussSession, onOpenSession }) {
614
+ return h(
615
+ 'div',
616
+ { className: 'smd-ai' },
617
+ h(
618
+ 'div',
619
+ { className: 'smd-row' },
620
+ h('span', { className: 'smd-chips' }, h('span', { className: 'smd-chip' }, ai.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic)),
621
+ ai.result?.cached === true ? h('span', { className: 'smd-chip' }, copy.aiCached) : null,
622
+ h('span', { className: 'smd-spacer' }),
623
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onAsk?.() }, copy.ai),
624
+ onDiscuss === undefined
625
+ ? null
626
+ : h(
627
+ 'button',
628
+ {
629
+ type: 'button',
630
+ className: 'smd-btn',
631
+ title: copy.discussHint,
632
+ disabled: discussing === true,
633
+ onClick: () => onDiscuss?.(),
634
+ },
635
+ discussing === true ? copy.discussing : copy.discuss,
636
+ ),
637
+ ),
638
+ ai.error === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, ai.error.detail ?? String(ai.error)),
639
+ discussError === undefined
640
+ ? null
641
+ : h(
642
+ 'div',
643
+ { className: 'smd-banner smd-bannerError smd-row' },
644
+ h(
645
+ 'span',
646
+ null,
647
+ // A session that exists but whose first turn failed is not "the
648
+ // session is unavailable": the reader can still open it and read the
649
+ // model's own error there.
650
+ typeof discussSession === 'string' && discussSession !== ''
651
+ ? `${copy.discussTurnFailed}:${discussError}`
652
+ : `${copy.discussFailed}:${discussError}`,
653
+ ),
654
+ typeof discussSession === 'string' && discussSession !== ''
655
+ ? h('button', { type: 'button', className: 'smd-btn', onClick: () => onOpenSession?.(discussSession) }, copy.discussOpenSession)
656
+ : null,
657
+ ),
658
+ discussPending === true ? h('div', { className: 'smd-note' }, copy.discussOpened) : null,
659
+ typeof discussAnswer === 'string' && discussAnswer !== ''
660
+ ? h(
661
+ 'div',
662
+ { className: 'smd-discussAnswer' },
663
+ h('div', { className: 'smd-note' }, copy.discussAnswer),
664
+ h('div', { className: 'smd-aiBody' }, discussAnswer),
665
+ )
666
+ : null,
667
+ ai.result?.violations !== undefined && ai.result.violations.length > 0
668
+ ? h(
669
+ 'div',
670
+ { className: 'smd-banner' },
671
+ `${copy.aiDegraded}${ai.result.degradedReason === undefined ? `(${ai.result.violations.length} 项)` : `:${ai.result.degradedReason}`}`,
672
+ )
673
+ : null,
674
+ h(
675
+ 'div',
676
+ { className: 'smd-aiText' },
677
+ ai.text === ''
678
+ ? h(
679
+ 'span',
680
+ { className: 'smd-note' },
681
+ // Only claim "no model configured" when that is actually the case:
682
+ // an empty answer from a configured model is a different problem.
683
+ ai.streaming ? copy.loading : ai.result?.degradedFrom === undefined ? copy.aiOffline : `${copy.aiEmptyAnswer}(${ai.result?.degradedReason ?? '模型未返回文本'})`,
684
+ )
685
+ : ai.text,
686
+ ),
687
+ ai.result?.insufficient === undefined
688
+ ? null
689
+ : h('div', { className: 'smd-note' }, `${copy.aiInsufficient}:${ai.result.insufficient}`),
690
+ (ai.result?.usedPoints ?? []).length === 0
691
+ ? null
692
+ : h(
693
+ 'div',
694
+ { className: 'smd-note' },
695
+ `${copy.aiCitations}(${ai.result.usedPoints.length}):`,
696
+ ai.result.usedPoints
697
+ .slice(0, 8)
698
+ .map((point) => `${point.indicatorId}@${point.t}=${point.value ?? point.v}`)
699
+ .join(';'),
700
+ ),
701
+ h(
702
+ 'div',
703
+ { className: 'smd-row' },
704
+ h('input', {
705
+ className: 'smd-input',
706
+ value: question,
707
+ placeholder: copy.aiAskPlaceholder,
708
+ onChange: (event) => onQuestionChange?.(event.target.value),
709
+ onKeyDown: (event) => {
710
+ if (event.key === 'Enter') onSubmitQuestion?.()
711
+ },
712
+ }),
713
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onSubmitQuestion?.() }, copy.aiAskSubmit),
714
+ ),
715
+ )
716
+ }
717
+
718
+ /**
719
+ * The detail drawer.
720
+ *
721
+ * @param {{ detail: object, loading: boolean, onClose: Function, ai: object, onAsk: Function }} props - props.
722
+ * @returns {any} element.
723
+ */
724
+ function DetailDrawer({ detail, loading, onClose, ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussError, discussAnswer, discussPending, discussSession, onOpenSession, barSize, onBarSize }) {
725
+ if (loading) return h('div', { className: 'smd-note' }, copy.loading)
726
+ if (detail === undefined) return null
727
+ if (detail.error !== undefined) {
728
+ return h('div', { className: 'smd-banner smd-bannerError' }, `${copy.errorHint}(${detail.error.code ?? detail.error.kind})`)
729
+ }
730
+ const decimals = detail.display?.decimals ?? 2
731
+ return h(
732
+ 'div',
733
+ { className: 'smd-detail' },
734
+ h(
735
+ 'div',
736
+ { className: 'smd-row' },
737
+ h('span', { className: 'smd-title' }, `${detail.label?.zh ?? detail.indicatorId}(${detail.indicatorId})`),
738
+ h('span', { className: 'smd-spacer' }),
739
+ h('span', { className: 'smd-chip' }, detail.unitLabel ?? detail.unit),
740
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onClose?.() }, copy.close),
741
+ ),
742
+ // Bar size only matters for sources that publish OHLC; a macro series has
743
+ // nothing to re-sample, so the control is hidden rather than inert.
744
+ detail.barSizes === true
745
+ ? h(
746
+ 'div',
747
+ { className: 'smd-row' },
748
+ h('span', { className: 'smd-note' }, copy.barSize),
749
+ h(
750
+ 'span',
751
+ { className: 'smd-chips' },
752
+ [['day', copy.barDay], ['week', copy.barWeek], ['month', copy.barMonth]].map(([id, label]) =>
753
+ h(
754
+ 'button',
755
+ {
756
+ key: id,
757
+ type: 'button',
758
+ className: `smd-chipBtn${(barSize ?? 'day') === id ? ' smd-chipBtnOn' : ''}`,
759
+ 'aria-pressed': (barSize ?? 'day') === id,
760
+ onClick: () => onBarSize?.(id),
761
+ },
762
+ label,
763
+ ),
764
+ ),
765
+ ),
766
+ )
767
+ : null,
768
+ h(Chart, {
769
+ points: detail.points ?? [],
770
+ unit: detail.unit,
771
+ decimals,
772
+ stats: detail.stats,
773
+ polarity: detail.display?.polarity,
774
+ kind: detail.display?.transform === 'diff' ? 'bar' : 'line',
775
+ }),
776
+ h(StatsStrip, { stats: detail.stats, unit: detail.unit, decimals }),
777
+ discussing === true ? h('div', { className: 'smd-note' }, copy.discussing) : null,
778
+ // Every prop AiPanel reads must be forwarded: a missing callback makes the
779
+ // input throw on its first keystroke and React aborts the render.
780
+ h(AiPanel, { ai, onAsk, question, onQuestionChange, onSubmitQuestion, onDiscuss, discussing, discussAnswer, discussPending, discussError, discussSession, onOpenSession }),
781
+ detail.sourceRef?.url === undefined
782
+ ? null
783
+ : h(
784
+ 'a',
785
+ { className: 'smd-src', href: detail.sourceRef.url, target: '_blank', rel: 'noreferrer noopener' },
786
+ `${copy.viewSource}:${detail.sourceRef.label ?? detail.sourceRef.adapterId ?? ''} ${detail.sourceRef.url}`,
787
+ ),
788
+ )
789
+ }
790
+
791
+ /**
792
+ * Settings tab: mount checklist, AI mode, source switches.
793
+ *
794
+ * @param {{ settings: object, health: object }} props - props.
795
+ * @returns {any} element.
796
+ */
797
+ /**
798
+ * The selection toolbar: how many indicators are picked and what to do with them.
799
+ *
800
+ * Only rendered when something is selected, so the panel stays quiet until the
801
+ * reader actually opts in.
802
+ *
803
+ * @param {{ ids: string[], onClear: Function, onAnalyze: Function, busy: boolean }} props - props.
804
+ * @returns {any} element.
805
+ */
806
+ function SelectionBar({ ids, onClear, onAnalyze, busy }) {
807
+ if ((ids ?? []).length === 0) return null
808
+ return h(
809
+ 'div',
810
+ { className: 'smd-selectBar' },
811
+ h('span', { className: 'smd-chip' }, `${copy.selectedCount} ${ids.length}`),
812
+ h('span', { className: 'smd-note' }, ids.map((id) => id).join('、')),
813
+ h('span', { className: 'smd-spacer' }),
814
+ h('button', { type: 'button', className: 'smd-btn', disabled: busy === true, onClick: () => onAnalyze?.() }, busy === true ? copy.loading : copy.analyzeSelected),
815
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onClear?.() }, copy.clearSelection),
816
+ )
817
+ }
818
+
819
+ /**
820
+ * Split metrics into labelled sections, keeping the catalog's group order.
821
+ *
822
+ * A flat wall of 60 cards is the panel's main legibility problem; grouping by
823
+ * the same taxonomy the catalog already uses (US / CN / global / custom) costs
824
+ * nothing and makes the panel scannable.
825
+ *
826
+ * @param {object[]} metrics - cards.
827
+ * @param {string[]} [order] - group ids in display order.
828
+ * @returns {Array<{ id: string, title: string, metrics: object[] }>} sections.
829
+ */
830
+ function groupBySection(metrics, order) {
831
+ const ids = [...new Set([...(Array.isArray(order) && order.length > 0 ? order : ['US', 'CN', 'GLOBAL', 'CUSTOM']), 'CUSTOM'])]
832
+ const titles = { US: copy.groupUS, CN: copy.groupCN, GLOBAL: copy.groupGlobal, CUSTOM: copy.groupCustom }
833
+ const buckets = new Map(ids.map((id) => [id, []]))
834
+ for (const metric of metrics) {
835
+ // An unknown group has to land somewhere rather than vanish from the panel.
836
+ const key = buckets.has(metric.group) ? metric.group : 'CUSTOM'
837
+ buckets.get(key).push(metric)
838
+ }
839
+ return ids
840
+ .map((id) => ({ id, title: titles[id] ?? id, metrics: buckets.get(id) ?? [] }))
841
+ .filter((section) => section.metrics.length > 0)
842
+ }
843
+
844
+ /**
845
+ * What the last discussion is doing, shown wherever one can be started.
846
+ *
847
+ * A discussion is opened asynchronously and its answer lands in a new session,
848
+ * so without this the button's only effect was a session appearing somewhere
849
+ * else — which reads as "the button does nothing". This states the phase, the
850
+ * answer once it exists, and the cause when it fails.
851
+ *
852
+ * @param {{ state: object, onOpenSession: Function, onDismiss: Function }} props - props.
853
+ * @returns {any} element.
854
+ */
855
+ /**
856
+ * One phrase for what the last discussion is doing.
857
+ *
858
+ * A failure after the session was created is not "会话已就绪": the reader has to
859
+ * be able to tell "the session is there and this turn failed" from "nothing has
860
+ * happened yet", because only the first one is worth opening.
861
+ *
862
+ * @param {object} state - panel state.
863
+ * @param {boolean} running - whether a turn is in flight.
864
+ * @returns {string} phrase.
865
+ */
866
+ function discussPhase(state, running) {
867
+ if (state.discussing === true) return copy.discussOpening
868
+ if (running) return copy.discussRunning
869
+ if (state.discussError !== undefined && typeof state.discussSession === 'string' && state.discussSession !== '') return copy.discussTurnFailed
870
+ return copy.discussReady
871
+ }
872
+
873
+ function DiscussionStatus({ state, onOpenSession, onDismiss }) {
874
+ if (state.discussing !== true && state.discussPending !== true && state.discussError === undefined && typeof state.discussAnswer !== 'string') {
875
+ return null
876
+ }
877
+ const running = state.discussing === true || state.discussPending === true
878
+ return h(
879
+ 'div',
880
+ { className: 'smd-discuss' },
881
+ h(
882
+ 'div',
883
+ { className: 'smd-row' },
884
+ h('span', { className: 'smd-chip' }, discussPhase(state, running)),
885
+ typeof state.discussAnswer === 'string' && state.discussAnswer !== '' ? h('span', { className: 'smd-chip' }, copy.discussAnswer) : null,
886
+ h('span', { className: 'smd-spacer' }),
887
+ state.discussSession === undefined
888
+ ? null
889
+ : h('button', { type: 'button', className: 'smd-btn', onClick: () => onOpenSession?.(state.discussSession) }, copy.discussOpenSession),
890
+ onDismiss === undefined
891
+ ? null
892
+ : h('button', { type: 'button', className: 'smd-btn', onClick: () => onDismiss() }, copy.dismiss),
893
+ ),
894
+ state.discussError === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, state.discussError),
895
+ typeof state.discussQuestion === 'string' && state.discussQuestion !== ''
896
+ ? h(
897
+ 'div',
898
+ { className: 'smd-note' },
899
+ `${state.discussAutoQuestion === true ? copy.discussAutoAsked : copy.discussQuestion}${state.discussQuestion}`,
900
+ )
901
+ : null,
902
+ state.discussSession === undefined
903
+ ? null
904
+ : h('div', { className: 'smd-note' }, `${copy.discussSessionStarted} ${state.discussSession}`),
905
+ typeof state.discussAnswer === 'string' && state.discussAnswer !== ''
906
+ ? h('div', { className: 'smd-aiText' }, state.discussAnswer)
907
+ : running
908
+ ? h('div', { className: 'smd-note' }, copy.discussRunningHint)
909
+ : null,
910
+ )
911
+ }
912
+
913
+ /**
914
+ * Whole-panel analysis: one digest over a scope, plus a session to continue in.
915
+ *
916
+ * The scope chips mirror the panel's own grouping, so "美国" here means exactly
917
+ * the cards the US filter shows. Its result is deliberately kept out of the
918
+ * per-indicator AI state: it is about the panel, not about whatever card the
919
+ * reader opened last.
920
+ *
921
+ * @param {{ range: string, onDiscuss: Function, discussing: boolean }} props - props.
922
+ * @returns {any} element.
923
+ */
924
+ function AnalysisTab({ range, onDiscuss, discussing, discussion, onOpenSession, onDismiss }) {
925
+ const [scope, setScope] = useState('ALL')
926
+ const [state, setState] = useState({ loading: false, result: undefined, error: undefined })
927
+ const scopes = [
928
+ ['ALL', copy.analyzeAll],
929
+ ['US', copy.groupUs],
930
+ ['CN', copy.groupCn],
931
+ ['GLOBAL', copy.groupGlobal],
932
+ ['CUSTOM', copy.tabMine],
933
+ ]
934
+ const run = () => {
935
+ setState({ loading: true, result: undefined, error: undefined })
936
+ api.aiOverview({ range, ...(scope === 'ALL' ? {} : { groups: [scope] }) }).then((response) => {
937
+ if (!response.ok) {
938
+ setState({ loading: false, result: undefined, error: response.error?.detail ?? `请求失败(HTTP ${response.status})` })
939
+ return
940
+ }
941
+ setState({ loading: false, result: response.data, error: undefined })
942
+ })
943
+ }
944
+ const result = state.result
945
+ return h(
946
+ 'div',
947
+ { className: 'smd-detail' },
948
+ h(
949
+ 'div',
950
+ { className: 'smd-row' },
951
+ h('span', { className: 'smd-sectionTitle' }, copy.analyzeScope),
952
+ h(
953
+ 'span',
954
+ { className: 'smd-chips' },
955
+ scopes.map(([id, label]) =>
956
+ h(
957
+ 'button',
958
+ {
959
+ key: id,
960
+ type: 'button',
961
+ className: `smd-chipBtn${scope === id ? ' smd-chipBtnOn' : ''}`,
962
+ 'aria-pressed': scope === id,
963
+ onClick: () => setScope(id),
964
+ },
965
+ label,
966
+ ),
967
+ ),
968
+ ),
969
+ h('span', { className: 'smd-spacer' }),
970
+ h('button', { type: 'button', className: 'smd-btn', disabled: state.loading, onClick: run }, state.loading ? copy.loading : copy.analyzeRun),
971
+ ),
972
+ state.error === undefined ? null : h('div', { className: 'smd-banner smd-bannerError' }, state.error),
973
+ state.loading ? h('div', { className: 'smd-note' }, copy.loading) : null,
974
+ h(DiscussionStatus, { state, onOpenSession, onDismiss }),
975
+ result === undefined
976
+ ? h('div', { className: 'smd-note' }, copy.analyzeIdle)
977
+ : h(
978
+ 'div',
979
+ { className: 'smd-detail' },
980
+ h(
981
+ 'div',
982
+ { className: 'smd-row' },
983
+ h('span', { className: 'smd-chip' }, result.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic),
984
+ h('span', { className: 'smd-chip' }, `${copy.analyzeCount} ${(result.indicators ?? []).length}`),
985
+ (result.noteworthy ?? []).length === 0 ? null : h('span', { className: 'smd-chip' }, `${copy.noteworthy} ${result.noteworthy.length}`),
986
+ h('span', { className: 'smd-spacer' }),
987
+ onDiscuss === undefined
988
+ ? null
989
+ : h(
990
+ 'button',
991
+ {
992
+ type: 'button',
993
+ className: 'smd-btn',
994
+ title: copy.analyzeDiscussHint,
995
+ disabled: discussing === true,
996
+ onClick: () => onDiscuss(result.scope),
997
+ },
998
+ discussing === true ? copy.discussing : copy.analyzeDiscuss,
999
+ ),
1000
+ ),
1001
+ (result.violations ?? []).length > 0
1002
+ ? h('div', { className: 'smd-banner' }, `${copy.aiDegraded}${result.degradedReason === undefined ? `(${result.violations.length} 项)` : `:${result.degradedReason}`}`)
1003
+ : null,
1004
+ h('div', { className: 'smd-aiText' }, result.markdown ?? ''),
1005
+ ),
1006
+ )
1007
+ }
1008
+
1009
+ /**
1010
+ * Settings: what this mount is doing right now, and where each value comes from.
1011
+ *
1012
+ * The screen used to print four labels with no values, so "what can I set here"
1013
+ * had no answer. Every row now states its effective value, the config path that
1014
+ * produced it, and whether it can be changed from the browser at all — most
1015
+ * cannot, because they are row config read once at profile boot.
1016
+ *
1017
+ * @param {{ settings: object, health: object, onReload: Function }} props - props.
1018
+ * @returns {any} element.
1019
+ */
1020
+ function SettingsTab({ settings, health, onReload, onTest, testing, testingId, testResult }) {
1021
+ const row = (label, value, hint, key) =>
1022
+ h(
1023
+ 'div',
1024
+ { className: 'smd-setRow', key: key ?? label },
1025
+ h('span', { className: 'smd-setLabel' }, label),
1026
+ h('span', { className: 'smd-setValue' }, value === undefined || value === '' ? '—' : String(value)),
1027
+ hint === undefined ? null : h('span', { className: 'smd-setHint' }, hint),
1028
+ )
1029
+ const sources = settings?.sources ?? []
1030
+ const offline = (settings?.sourcesOff ?? [])
1031
+ return h(
1032
+ 'div',
1033
+ { className: 'smd-detail' },
1034
+ h(
1035
+ 'div',
1036
+ { className: 'smd-row' },
1037
+ h('span', { className: 'smd-sectionTitle' }, copy.settingsWhatIsThis),
1038
+ h('span', { className: 'smd-spacer' }),
1039
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onReload?.() }, copy.reload),
1040
+ ),
1041
+ h('div', { className: 'smd-note' }, copy.settingsIntro),
1042
+ row(copy.settingsPrefix, settings?.runtime?.prefix, copy.settingsPrefixHint),
1043
+ row(copy.settingsRoutes, settings?.runtime?.routes, copy.settingsReadOnly),
1044
+ row(copy.settingsTools, settings?.runtime?.tools, copy.settingsReadOnly),
1045
+ row(copy.settingsIndicators, settings?.runtime?.indicators, `${copy.settingsUnsupported} ${settings?.runtime?.unsupported ?? 0}`),
1046
+ row(copy.settingsStorage, settings?.storageDir, copy.settingsStorageHint),
1047
+
1048
+ h('div', { className: 'smd-sectionTitle' }, copy.settingsAi),
1049
+ row(copy.settingsAiMode, settings?.ai?.mode, `${copy.settingsCurrentModel}: ${settings?.ai?.provider ?? '—'} / ${settings?.ai?.model ?? '—'}`),
1050
+ row(copy.settingsAiEnabled, settings?.ai?.enabled === true ? copy.yes : copy.no, copy.settingsAiEnabledHint),
1051
+ row(copy.settingsAiChars, settings?.ai?.maxChars, copy.settingsAiCharsHint),
1052
+ row(copy.settingsAiCache, `${settings?.ai?.cacheMinutes ?? '—'} ${copy.minutes}`, copy.settingsAiCacheHint),
1053
+
1054
+ h('div', { className: 'smd-sectionTitle' }, copy.settingsData),
1055
+ row(copy.settingsRefresh, `${settings?.refreshMinutes ?? '—'} ${copy.minutes}`, copy.settingsRefreshHint),
1056
+ row(copy.settingsGroups, (settings?.groups ?? []).join(' / '), copy.settingsGroupsHint),
1057
+ row(copy.settingsNoteworthy, settings?.noteworthyLimit, copy.settingsNoteworthyHint),
1058
+ row(copy.settingsDebug, settings?.debug === true ? copy.yes : copy.no, copy.settingsDebugHint),
1059
+
1060
+ h(
1061
+ 'div',
1062
+ { className: 'smd-row' },
1063
+ h('span', { className: 'smd-sectionTitle' }, copy.settingsSources),
1064
+ h('span', { className: 'smd-spacer' }),
1065
+ h(
1066
+ 'button',
1067
+ { type: 'button', className: 'smd-btn', disabled: testing === true, onClick: () => onTest?.(undefined) },
1068
+ testing === true ? copy.testing : copy.testAll,
1069
+ ),
1070
+ ),
1071
+ h('div', { className: 'smd-note' }, copy.settingsSourcesHint),
1072
+ testResult === undefined ? null : h('div', { className: 'smd-banner' }, testResult),
1073
+ h(
1074
+ 'ul',
1075
+ { className: 'smd-list' },
1076
+ sources.length === 0
1077
+ ? h('li', { className: 'smd-note' }, copy.loading)
1078
+ : sources.map((source) =>
1079
+ h(
1080
+ 'li',
1081
+ { key: source.adapterId, className: 'smd-setRow' },
1082
+ h('span', { className: 'smd-setLabel' }, source.label ?? source.adapterId),
1083
+ h('span', { className: 'smd-setValue' }, source.available === null || source.available === undefined ? copy.notChecked : source.available ? copy.available : copy.unavailable),
1084
+ h(
1085
+ 'span',
1086
+ { className: 'smd-setHint' },
1087
+ `${source.adapterId}${offline.includes(source.adapterId) ? ` · ${copy.settingsSourceOff}` : ''}${source.failed > 0 ? ` · ${copy.failedCount} ${source.failed}` : ''}${source.lastSuccessAt === undefined ? '' : ` · ${copy.settingsLast} ${formatAge(ageMinutes(source.lastSuccessAt))}`}`,
1088
+ ),
1089
+ h(
1090
+ 'button',
1091
+ {
1092
+ type: 'button',
1093
+ className: 'smd-btn smd-btnInline',
1094
+ disabled: testing === true,
1095
+ title: copy.testSourceHint,
1096
+ onClick: () => onTest?.(source.adapterId),
1097
+ },
1098
+ testingId === source.adapterId ? copy.testing : copy.testSource,
1099
+ ),
1100
+ ),
1101
+ ),
1102
+ ),
1103
+ settings?.configPath === undefined
1104
+ ? null
1105
+ : h('div', { className: 'smd-note' }, `${copy.settingsHowToChange} ${settings.configPath}`),
1106
+ )
1107
+ }
1108
+
1109
+ /**
1110
+ * Add-indicator tab: catalog search plus natural-language propose.
1111
+ *
1112
+ * @param {{ onAdd: Function, onPropose: Function, results: object[], proposal: object }} props - props.
1113
+ * @returns {any} element.
1114
+ */
1115
+ function AddIndicatorTab({ onAdd, onPropose, results, proposal, query, onQueryChange, onIterate, iterating }) {
1116
+ const unsupported = proposal?.unsupported ?? []
1117
+ const candidates = proposal?.candidates ?? []
1118
+ return h(
1119
+ 'div',
1120
+ { className: 'smd-detail' },
1121
+ h('div', { className: 'smd-sectionTitle' }, copy.addIndicator),
1122
+ h('input', {
1123
+ className: 'smd-input',
1124
+ value: query,
1125
+ placeholder: copy.addSearchPlaceholder,
1126
+ onChange: (event) => onQueryChange?.(event.target.value),
1127
+ }),
1128
+ h(
1129
+ 'ul',
1130
+ { className: 'smd-list' },
1131
+ (results ?? []).slice(0, 8).map((entry) =>
1132
+ h(
1133
+ 'li',
1134
+ { key: entry.id, className: 'smd-row' },
1135
+ h('span', null, `${entry.label?.zh ?? entry.id}`),
1136
+ h('span', { className: 'smd-note' }, entry.id),
1137
+ h('span', { className: 'smd-spacer' }),
1138
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => onAdd?.(entry.id) }, copy.addConfirm),
1139
+ ),
1140
+ ),
1141
+ ),
1142
+ h('div', { className: 'smd-sectionTitle' }, copy.addByText),
1143
+ h('input', {
1144
+ className: 'smd-input',
1145
+ placeholder: copy.addByTextPlaceholder,
1146
+ onKeyDown: (event) => {
1147
+ if (event.key === 'Enter') onPropose?.(event.target.value)
1148
+ },
1149
+ }),
1150
+ proposal === undefined
1151
+ ? null
1152
+ : h(
1153
+ 'div',
1154
+ { className: 'smd-detail' },
1155
+ candidates.length > 0
1156
+ ? h('div', { className: 'smd-note' }, `候选:${candidates.map((entry) => `${entry.label?.zh ?? entry.id}${entry.conflict ? `(${copy.addExists})` : ''}`).join('、')}`)
1157
+ : h('div', { className: 'smd-note' }, `${copy.addUnsupported}:${unsupported.map((entry) => entry.reason).join(';')}`),
1158
+ // "No data source" is not a dead end: the honest next step is a
1159
+ // session that can actually add the source, and the panel can open it
1160
+ // with the request, the reasons and the plugin's own layout attached.
1161
+ unsupported.length === 0 || onIterate === undefined
1162
+ ? null
1163
+ : h(
1164
+ 'div',
1165
+ { className: 'smd-iterate' },
1166
+ h('div', { className: 'smd-note' }, copy.iterateHint),
1167
+ h(
1168
+ 'button',
1169
+ {
1170
+ type: 'button',
1171
+ className: 'smd-btn',
1172
+ disabled: iterating === true,
1173
+ onClick: () => onIterate(unsupported, query),
1174
+ },
1175
+ iterating === true ? copy.iterateOpening : copy.iterateAction,
1176
+ ),
1177
+ ),
1178
+ ),
1179
+ )
1180
+ }
1181
+
1182
+ /**
1183
+ * The overlay root: trigger badge plus the panel, self-managing open state.
1184
+ *
1185
+ * @param {object} props - slot props (the overlay passes no panel props).
1186
+ * @returns {any} element.
1187
+ */
1188
+ function DataRadarOverlay() {
1189
+ const [state, setLocal] = useState(store.getState())
1190
+ const [query, setQuery] = useState('')
1191
+ const [searchResults, setSearchResults] = useState([])
1192
+ const [proposal, setProposal] = useState(undefined)
1193
+ const [question, setQuestion] = useState('')
1194
+ const [collapsed, setCollapsed] = useState({})
1195
+ const [selectionResult, setSelectionResult] = useState(undefined)
1196
+ const rootRef = useRef(null)
1197
+ const alive = useRef(true)
1198
+ // One pending poll for a discussion answer, cancelled on unmount.
1199
+ const timer = useRef(undefined)
1200
+
1201
+ useEffect(() => {
1202
+ const unsubscribe = store.subscribe((next) => {
1203
+ if (alive.current) setLocal(next)
1204
+ })
1205
+ return () => {
1206
+ alive.current = false
1207
+ if (timer.current !== undefined) clearTimeout(timer.current)
1208
+ unsubscribe()
1209
+ }
1210
+ }, [])
1211
+
1212
+ useEffect(() => {
1213
+ if (!state.open) return undefined
1214
+ const controller = new AbortController()
1215
+ api.overview({ range: state.range, signal: controller.signal }).then((response) => {
1216
+ if (!alive.current) return
1217
+ if (response.ok) store.setState((current) => ({ ...current, ...applyOverviewShape(response.data) }))
1218
+ else if (response.status === 404) store.setState((current) => ({ ...current, unreachable: true, status: 'unreachable' }))
1219
+ else store.setState((current) => ({ ...current, error: response.error, status: 'error' }))
1220
+ })
1221
+ return () => controller.abort()
1222
+ }, [state.open, state.range])
1223
+
1224
+ useEffect(() => {
1225
+ if (!state.open || state.tab !== 'settings') return undefined
1226
+ loadSettings()
1227
+ return undefined
1228
+ }, [state.open, state.tab])
1229
+
1230
+ /**
1231
+ * Load the mount's effective settings (and source health) for the settings tab.
1232
+ *
1233
+ * @returns {void}
1234
+ */
1235
+ const loadSettings = () => {
1236
+ api.settings().then((response) => {
1237
+ if (!alive.current || !response.ok) return
1238
+ store.setState((current) => ({ ...current, settings: { ...current.settings, ...response.data } }))
1239
+ })
1240
+ api.health().then((response) => {
1241
+ if (!alive.current || !response.ok) return
1242
+ store.setState((current) => ({ ...current, health: response.data, settings: { ...current.settings, sources: response.data.sources ?? current.settings.sources } }))
1243
+ })
1244
+ }
1245
+
1246
+ /**
1247
+ * Add or remove one indicator from the multi-select set.
1248
+ *
1249
+ * @param {string} indicatorId - indicator id.
1250
+ * @returns {void}
1251
+ */
1252
+ const toggleSelection = (indicatorId) => {
1253
+ store.setState((current) => {
1254
+ const selected = current.selected ?? []
1255
+ return {
1256
+ ...current,
1257
+ selected: selected.includes(indicatorId) ? selected.filter((id) => id !== indicatorId) : [...selected, indicatorId],
1258
+ }
1259
+ })
1260
+ }
1261
+
1262
+ /**
1263
+ * Analyse every selected indicator in one digest.
1264
+ *
1265
+ * The selection is a *reader's* question ("how do these relate"), so the
1266
+ * request carries exactly the picked ids and the digest is built from them —
1267
+ * no group inference, no silent extra indicators.
1268
+ *
1269
+ * @returns {void}
1270
+ */
1271
+ const runSelectionAnalysis = () => {
1272
+ const ids = store.getState().selected ?? []
1273
+ if (ids.length === 0) return
1274
+ store.setState((current) => ({ ...current, selectionLoading: true }))
1275
+ setSelectionResult(undefined)
1276
+ api.summary({ range: state.range, indicators: ids }).then((response) => {
1277
+ if (!alive.current) return
1278
+ store.setState((current) => ({ ...current, selectionLoading: false }))
1279
+ if (!response.ok) {
1280
+ setSelectionResult({ markdown: `${copy.discussFailed}:${response.error?.detail ?? response.status}`, mode: 'deterministic', violations: [] })
1281
+ return
1282
+ }
1283
+ setSelectionResult({ ...response.data, indicators: response.data?.indicators ?? ids })
1284
+ })
1285
+ }
1286
+
1287
+ /**
1288
+ * Re-fetch the open indicator's bars at another size (day/week/month).
1289
+ *
1290
+ * @param {'day'|'week'|'month'} size - bar size.
1291
+ * @returns {void}
1292
+ */
1293
+ const setBarSize = (size) => {
1294
+ const indicatorId = store.getState().detail?.indicatorId
1295
+ store.setState((current) => ({ ...current, barSize: size }))
1296
+ if (indicatorId === undefined) return
1297
+ api.series({ indicator: indicatorId, range: state.range, freq: size }).then((response) => {
1298
+ if (alive.current && response.ok) store.setState((current) => ({ ...current, detail: response.data }))
1299
+ })
1300
+ }
1301
+
1302
+ /**
1303
+ * Test one data source (or all of them) by re-fetching, ignoring the cache.
1304
+ *
1305
+ * A cached "ok" says nothing about right now: these upstreams rate-limit and
1306
+ * block, so the only honest test is a fresh request. It is narrowed to the
1307
+ * indicators behind the chosen adapter so one source does not cost a full
1308
+ * panel refresh.
1309
+ *
1310
+ * @param {string|undefined} adapterId - source to test; all sources when omitted.
1311
+ * @returns {void}
1312
+ */
1313
+ const testSources = (adapterId) => {
1314
+ const ids = adapterId === undefined
1315
+ ? undefined
1316
+ : (store.getState().overview?.metrics ?? []).filter((metric) => metric.sourceRef?.adapterId === adapterId).map((metric) => metric.indicatorId)
1317
+ store.setState((current) => ({ ...current, testing: true, testingId: adapterId, testResult: undefined }))
1318
+ api.health({ probe: true, ids, range: adapterId === undefined ? 'MAX' : undefined }).then((response) => {
1319
+ if (!alive.current) return
1320
+ if (!response.ok) {
1321
+ store.setState((current) => ({ ...current, testing: false, testingId: undefined, testResult: `${copy.testFailed}:${response.error?.detail ?? response.status}` }))
1322
+ return
1323
+ }
1324
+ const sources = response.data?.sources ?? []
1325
+ const scoped = adapterId === undefined ? sources : sources.filter((source) => source.adapterId === adapterId)
1326
+ const failed = scoped.filter((source) => source.available === false)
1327
+ const probed = ids === undefined ? copy.allSources : ids.length
1328
+ store.setState((current) => ({
1329
+ ...current,
1330
+ testing: false,
1331
+ testingId: undefined,
1332
+ health: response.data,
1333
+ settings: { ...current.settings, sources },
1334
+ testResult: failed.length === 0
1335
+ ? `${copy.testOk}(${copy.probeIndicators} ${probed})`
1336
+ : `${copy.testFailed}:${failed.map((source) => `${source.label ?? source.adapterId}(${source.failed})`).join('、')}`,
1337
+ }))
1338
+ })
1339
+ }
1340
+
1341
+ const openDetail = (indicatorId) => {
1342
+ // Selecting another card starts a new subject: the previous answer, its
1343
+ // question and any discussion belong to the indicator being left behind.
1344
+ store.setState((current) => ({ ...current, ...applyIndicatorChange(current, indicatorId), detailLoading: true, tab: 'detail', detail: undefined }))
1345
+ api.series({ indicator: indicatorId, range: state.range }).then((response) => {
1346
+ if (!alive.current) return
1347
+ if (response.ok) store.setState((current) => ({ ...current, detail: response.data, detailLoading: false }))
1348
+ else store.setState((current) => ({ ...current, detailLoading: false, detail: { error: response.error } }))
1349
+ })
1350
+ }
1351
+
1352
+ const runExplain = () => {
1353
+ const indicatorId = state.detail?.indicatorId
1354
+ if (indicatorId === undefined) return
1355
+ store.setState((current) => ({ ...current, ai: { ...current.ai, text: '', streaming: true, error: undefined } }))
1356
+ api.explain(
1357
+ { indicator: indicatorId, range: state.range },
1358
+ {
1359
+ onText: (chunk) => store.setState((current) => ({ ...current, ai: { ...current.ai, text: `${current.ai.text}${chunk}`, streaming: true } })),
1360
+ onDone: (result) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, result, text: result?.markdown ?? current.ai.text, mode: result?.mode ?? current.ai.mode } })),
1361
+ onError: (error) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, error } })),
1362
+ },
1363
+ )
1364
+ }
1365
+
1366
+ const submitQuestion = () => {
1367
+ if (question.trim() === '') return
1368
+ store.setState((current) => ({ ...current, ai: { ...current.ai, text: '', streaming: true, error: undefined } }))
1369
+ api.ask(
1370
+ { question, range: state.range },
1371
+ {
1372
+ onText: (chunk) => store.setState((current) => ({ ...current, ai: { ...current.ai, text: `${current.ai.text}${chunk}`, streaming: true } })),
1373
+ onDone: (result) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, result, text: result?.markdown ?? current.ai.text, mode: result?.mode ?? current.ai.mode } })),
1374
+ onError: (error) => store.setState((current) => ({ ...current, ai: { ...current.ai, streaming: false, error } })),
1375
+ },
1376
+ )
1377
+ }
1378
+
1379
+ const runSearch = (text) => {
1380
+ setQuery(text)
1381
+ if (text.trim() === '') {
1382
+ setSearchResults([])
1383
+ return
1384
+ }
1385
+ api.search(text).then((response) => {
1386
+ if (alive.current && response.ok) setSearchResults(response.data?.matches ?? [])
1387
+ })
1388
+ }
1389
+
1390
+ const runPropose = (text) => {
1391
+ if (text.trim() === '') return
1392
+ api.propose({ text }).then((response) => {
1393
+ if (alive.current && response.ok) setProposal(response.data)
1394
+ })
1395
+ }
1396
+
1397
+ const addIndicator = (indicatorId) => {
1398
+ api.watchlistAction({ action: 'add', item: { indicatorId }, createdBy: 'user' }).then((response) => {
1399
+ if (alive.current && response.ok) store.setState((current) => ({ ...current, watchlist: response.data.items ?? [] }))
1400
+ })
1401
+ }
1402
+
1403
+ /**
1404
+ * Poll a discussion session until its answer exists, then show it here.
1405
+ *
1406
+ * The panel opens the session without waiting, so this is how the answer also
1407
+ * lands beside the data the question was about. Polling stops on the first
1408
+ * answer or failure, and on unmount, so a closed panel leaves nothing behind.
1409
+ *
1410
+ * @param {string} sessionId - the session to collect from.
1411
+ * @returns {void}
1412
+ */
1413
+ const collectDiscussionAnswer = (sessionId) => {
1414
+ const deadline = Date.now() + (pollTimeoutMs ?? DISCUSSION_POLL_MS)
1415
+ let polls = 0
1416
+ const tick = () => {
1417
+ if (!alive.current) return
1418
+ polls += 1
1419
+ // A session we no longer track (a newer discussion replaced it) must not
1420
+ // keep writing its answer into the panel.
1421
+ if (store.getState().discussSession !== sessionId) return
1422
+ api.discussAnswer(sessionId).then((response) => {
1423
+ if (!alive.current) return
1424
+ const status = response.data?.status
1425
+ if (status === 'idle') {
1426
+ // The session holds context but was never asked anything, so there is
1427
+ // no answer to wait for. Saying "the model failed" here is wrong.
1428
+ store.setState((current) => ({ ...current, discussPending: false, discussAnswer: undefined, discussError: copy.discussIdle }))
1429
+ return
1430
+ }
1431
+ if (status === 'done') {
1432
+ const reply = typeof response.data?.reply === 'string' ? response.data.reply : ''
1433
+ store.setState((current) => ({
1434
+ ...current,
1435
+ discussPending: false,
1436
+ discussAnswer: reply === '' ? undefined : reply,
1437
+ discussError: reply === '' ? copy.discussEmpty : undefined,
1438
+ }))
1439
+ return
1440
+ }
1441
+ if (status === 'failed') {
1442
+ store.setState((current) => ({
1443
+ ...current,
1444
+ discussPending: false,
1445
+ discussError: response.data?.error?.detail ?? copy.discussFailed,
1446
+ }))
1447
+ return
1448
+ }
1449
+ if (Date.now() > deadline) {
1450
+ store.setState((current) => ({ ...current, discussPending: false, discussError: copy.discussTimeout }))
1451
+ return
1452
+ }
1453
+ if (polls === 1) {
1454
+ // First miss: say a turn is running, so the panel is visibly working
1455
+ // rather than inert while the model thinks.
1456
+ store.setState((current) => ({ ...current, discussPending: true }))
1457
+ }
1458
+ timer.current = setTimeout(tick, pollIntervalMs ?? DISCUSSION_POLL_INTERVAL_MS)
1459
+ })
1460
+ }
1461
+ timer.current = setTimeout(tick, pollIntervalMs ?? DISCUSSION_POLL_INTERVAL_MS)
1462
+ }
1463
+
1464
+ /**
1465
+ * Open an independent DSH session about the indicator currently shown.
1466
+ *
1467
+ * The host creates the session and seeds it with the panel digest; we then
1468
+ * ask the GUI to switch to it, so the conversation continues with the full
1469
+ * composer instead of inside the panel.
1470
+ */
1471
+ /**
1472
+ * The session the reader is looking at, when the shell exposes one.
1473
+ *
1474
+ * The host cannot see which session a web request came from, so the panel
1475
+ * names it: a discussion created without it joins no composition and is born
1476
+ * with only this plugin's tools — no filesystem, no shell, no prompt sections.
1477
+ *
1478
+ * @returns {string|undefined} current session id.
1479
+ */
1480
+ const currentSessionId = () => {
1481
+ try {
1482
+ const current = sessions?.list?.getSnapshot?.()?.current
1483
+ return typeof current === 'string' && current !== '' ? current : undefined
1484
+ } catch {
1485
+ return undefined
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Open an independent session about one subject.
1491
+ *
1492
+ * Three callers share it: a card's "讨论" button (subject: that indicator), a
1493
+ * noteworthy row (subject: that indicator *and* the rule that fired), and the
1494
+ * analysis tab (subject: the whole panel or one group). The host builds the
1495
+ * context in each case, so the panel never assembles a digest itself.
1496
+ *
1497
+ * @param {{ noteworthy?: string, scope?: string, question?: string }} [options] - which subject.
1498
+ * @returns {void}
1499
+ */
1500
+ const startDiscussion = (options = {}) => {
1501
+ const noteworthy = options.noteworthy
1502
+ const scope = options.scope
1503
+ const indicatorId = noteworthy === undefined && scope === undefined ? state.detail?.indicatorId : undefined
1504
+ if (noteworthy === undefined && scope === undefined && indicatorId === undefined) return
1505
+ const body = noteworthy !== undefined
1506
+ ? { group: 'ALL', noteworthy: [noteworthy], range: state.range }
1507
+ : scope !== undefined
1508
+ ? { group: scope === 'ALL' ? 'ALL' : scope, range: state.range, limit: 8 }
1509
+ : { indicator: indicatorId, range: state.range }
1510
+ // NOT `const question = … question …`: shadowing the state variable makes
1511
+ // the initializer read the binding being declared, so the whole handler
1512
+ // threw `Cannot access 'question' before initialization` on every click and
1513
+ // the reader saw a button that did nothing at all.
1514
+ const asked = options.question ?? (question === '' ? undefined : question.trim())
1515
+ store.setState((current) => ({
1516
+ ...current,
1517
+ discussing: true,
1518
+ discussingId: noteworthy,
1519
+ discussError: undefined,
1520
+ discussAnswer: undefined,
1521
+ discussPending: false,
1522
+ discussQuestion: asked,
1523
+ discussAutoQuestion: asked === undefined,
1524
+ }))
1525
+ api.discuss({ ...body, question: asked, sessionId: currentSessionId() }).then((response) => {
1526
+ if (!alive.current) return
1527
+ if (!response.ok) {
1528
+ const detail = response.error?.detail ?? `请求失败(HTTP ${response.status})`
1529
+ // A failed first turn still created the session: keep its id so the
1530
+ // reader can open it and see the model's own error, which is the only
1531
+ // place the real cause is written down.
1532
+ const failedSession = response.data?.sessionId
1533
+ store.setState((current) => ({
1534
+ ...current,
1535
+ discussing: false,
1536
+ discussingId: undefined,
1537
+ discussError: detail,
1538
+ discussSession: typeof failedSession === 'string' && failedSession !== '' ? failedSession : current.discussSession,
1539
+ discussQuestion: response.data?.question ?? current.discussQuestion,
1540
+ discussAutoQuestion: response.data?.autoQuestion === true,
1541
+ }))
1542
+ return
1543
+ }
1544
+ const sessionId = response.data?.sessionId
1545
+ store.setState((current) => ({
1546
+ ...current,
1547
+ discussing: false,
1548
+ discussingId: undefined,
1549
+ discussSession: sessionId,
1550
+ discussPending: true,
1551
+ discussQuestion: response.data?.question ?? current.discussQuestion,
1552
+ discussAutoQuestion: response.data?.autoQuestion === true,
1553
+ }))
1554
+ if (typeof sessionId === 'string' && sessionId !== '') collectDiscussionAnswer(sessionId)
1555
+ else store.setState((current) => ({ ...current, discussPending: false }))
1556
+ // Switch the GUI to the new session, so the conversation continues with
1557
+ // the full composer; the panel keeps the answer beside the numbers too.
1558
+ if (sessions !== undefined && typeof sessions.open === 'function') sessions.open(sessionId)
1559
+ })
1560
+ }
1561
+
1562
+ /**
1563
+ * Switch the GUI to a discussion session.
1564
+ *
1565
+ * The client `sessions` service is not present in every shell, so the panel
1566
+ * keeps the id and shows the answer itself rather than depending on this.
1567
+ *
1568
+ * @param {string} sessionId - session to open.
1569
+ * @returns {void}
1570
+ */
1571
+ const openSession = (sessionId) => {
1572
+ if (sessions !== undefined && typeof sessions.open === 'function' && typeof sessionId === 'string') sessions.open(sessionId)
1573
+ }
1574
+
1575
+ /** Clear the discussion card without touching the session itself. */
1576
+ const dismissDiscussion = () => {
1577
+ if (timer.current !== undefined) clearTimeout(timer.current)
1578
+ store.setState((current) => ({
1579
+ ...current,
1580
+ discussing: false,
1581
+ discussingId: undefined,
1582
+ discussPending: false,
1583
+ discussError: undefined,
1584
+ discussAnswer: undefined,
1585
+ discussSession: undefined,
1586
+ }))
1587
+ }
1588
+
1589
+ /**
1590
+ * Open a session that can actually add the missing data source.
1591
+ *
1592
+ * The panel is read-only by design: it cannot register a source at runtime,
1593
+ * so "cannot add" is the correct answer — and an iteration session in the
1594
+ * plugin workspace is the honest next step, carrying the request and the
1595
+ * per-indicator reasons with it.
1596
+ *
1597
+ * @param {object[]} unsupported - rejected proposals with reasons.
1598
+ * @param {string} request - what the reader typed.
1599
+ * @returns {void}
1600
+ */
1601
+ const startIteration = (unsupported, request) => {
1602
+ store.setState((current) => ({ ...current, discussing: true, discussError: undefined, discussAnswer: undefined, discussPending: false }))
1603
+ api.iterate({
1604
+ request: typeof request === 'string' ? request : '',
1605
+ reasons: (unsupported ?? []).map((entry) => `${entry.label?.zh ?? entry.id ?? ''}:${entry.reason ?? ''}`.trim()),
1606
+ sessionId: currentSessionId(),
1607
+ }).then((response) => {
1608
+ if (!alive.current) return
1609
+ if (!response.ok) {
1610
+ store.setState((current) => ({ ...current, discussing: false, discussError: response.error?.detail ?? `请求失败(HTTP ${response.status})` }))
1611
+ return
1612
+ }
1613
+ const sessionId = response.data?.sessionId
1614
+ store.setState((current) => ({ ...current, discussing: false, discussSession: sessionId, discussPending: true }))
1615
+ if (typeof sessionId === 'string' && sessionId !== '') collectDiscussionAnswer(sessionId)
1616
+ if (sessions !== undefined && typeof sessions.open === 'function') sessions.open(sessionId)
1617
+ })
1618
+ }
1619
+
1620
+ /** The analysis tab's "deep dive": a session over the whole scope. */
1621
+ const runScopeDiscussion = (scope) => startDiscussion({ scope })
1622
+
1623
+ const toggle = () => store.setState((current) => ({ ...current, open: !current.open }))
1624
+ const overview = state.overview
1625
+ const metrics = overview?.metrics ?? []
1626
+ const visible = state.tab === 'today'
1627
+ ? metrics.filter((metric) => metric.importance >= 5 || metric.score > 0)
1628
+ : state.tab === 'mine'
1629
+ ? metrics.filter((metric) => metric.group === 'CUSTOM')
1630
+ : metrics
1631
+
1632
+ const trigger = h(
1633
+ 'button',
1634
+ {
1635
+ type: 'button',
1636
+ className: 'smd-trigger',
1637
+ onClick: toggle,
1638
+ 'aria-expanded': state.open,
1639
+ title: copy.open,
1640
+ },
1641
+ h('span', null, copy.panelTitle),
1642
+ overview !== undefined && (overview.noteworthy ?? []).length > 0
1643
+ ? h('span', { className: 'smd-badge' }, String((overview.noteworthy ?? []).length))
1644
+ : null,
1645
+ )
1646
+
1647
+ if (!state.open) return h('div', { className: 'smd-root', ref: rootRef }, trigger)
1648
+
1649
+ return h(
1650
+ 'div',
1651
+ { className: 'smd-root', ref: rootRef },
1652
+ h(
1653
+ 'div',
1654
+ { className: 'smd-panel', role: 'dialog', 'aria-label': copy.panelTitle },
1655
+ h(
1656
+ 'div',
1657
+ { className: 'smd-header' },
1658
+ h('span', { className: 'smd-title' }, copy.panelTitle),
1659
+ h('span', { className: 'smd-sub' }, copy.panelSubtitle),
1660
+ h('span', { className: 'smd-spacer' }),
1661
+ h(
1662
+ 'span',
1663
+ { className: 'smd-tabs' },
1664
+ ['today', 'analysis', 'core', 'mine', 'settings'].map((tab) =>
1665
+ h(
1666
+ 'button',
1667
+ {
1668
+ key: tab,
1669
+ type: 'button',
1670
+ className: 'smd-btn',
1671
+ 'aria-pressed': state.tab === tab,
1672
+ onClick: () => store.setState((current) => ({ ...current, tab })),
1673
+ },
1674
+ { today: copy.tabToday, analysis: copy.analyzeTab, core: copy.tabCore, mine: copy.tabMine, settings: copy.tabSettings }[tab],
1675
+ ),
1676
+ ),
1677
+ ['1M', '3M', '6M', 'YTD', '1Y'].map((range) =>
1678
+ h(
1679
+ 'button',
1680
+ {
1681
+ key: range,
1682
+ type: 'button',
1683
+ className: 'smd-btn',
1684
+ 'aria-pressed': state.range === range,
1685
+ onClick: () => store.setState((current) => ({ ...current, range })),
1686
+ },
1687
+ range,
1688
+ ),
1689
+ ),
1690
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => store.setState((current) => ({ ...current, range: current.range })) }, copy.refresh),
1691
+ h('button', { type: 'button', className: 'smd-btn', onClick: toggle }, copy.close),
1692
+ ),
1693
+ ),
1694
+ h(
1695
+ 'div',
1696
+ { className: 'smd-body' },
1697
+ state.unreachable ? h('div', { className: 'smd-banner smd-bannerError' }, copy.unreachable) : null,
1698
+ state.error !== undefined ? h('div', { className: 'smd-banner smd-bannerError' }, state.error.detail ?? String(state.error)) : null,
1699
+ shouldShowDegraded(state) ? h('div', { className: 'smd-banner' }, copy.degradedBanner) : null,
1700
+ state.status === 'idle' || state.status === 'loading'
1701
+ ? h('div', { className: 'smd-note' }, copy.loading)
1702
+ : null,
1703
+ state.tab === 'settings'
1704
+ ? h(SettingsTab, {
1705
+ settings: state.settings,
1706
+ health: state.health,
1707
+ onReload: loadSettings,
1708
+ onTest: testSources,
1709
+ testing: state.testing === true,
1710
+ testingId: state.testingId,
1711
+ testResult: state.testResult,
1712
+ })
1713
+ : state.tab === 'analysis'
1714
+ ? h(AnalysisTab, {
1715
+ range: state.range,
1716
+ onDiscuss: runScopeDiscussion,
1717
+ discussing: state.discussing,
1718
+ discussion: state,
1719
+ onOpenSession: openSession,
1720
+ onDismiss: dismissDiscussion,
1721
+ })
1722
+ : state.tab === 'mine'
1723
+ ? h(AddIndicatorTab, {
1724
+ onAdd: addIndicator,
1725
+ onPropose: runPropose,
1726
+ results: searchResults,
1727
+ proposal,
1728
+ query,
1729
+ onQueryChange: runSearch,
1730
+ onIterate: startIteration,
1731
+ iterating: state.discussing === true,
1732
+ })
1733
+ : h(
1734
+ 'div',
1735
+ { className: 'smd-detail' },
1736
+ h('div', { className: 'smd-sectionTitle' }, copy.noteworthy),
1737
+ h(NoteworthyList, {
1738
+ items: overview?.noteworthy ?? [],
1739
+ onOpen: openDetail,
1740
+ onDiscuss: (indicatorId) => startDiscussion({ noteworthy: indicatorId }),
1741
+ discussingId: state.discussingId,
1742
+ onOpenSession: openSession,
1743
+ }),
1744
+ h(DiscussionStatus, { state, onOpenSession: openSession, onDismiss: dismissDiscussion }),
1745
+ h(
1746
+ 'div',
1747
+ { className: 'smd-row' },
1748
+ h('span', { className: 'smd-sectionTitle' }, copy.tabCore),
1749
+ h('span', { className: 'smd-spacer' }),
1750
+ h('span', { className: 'smd-note' }, `${copy.lastUpdated} ${formatAge(ageMinutes(state.lastUpdatedAt))}`),
1751
+ ),
1752
+ h(SelectionBar, {
1753
+ ids: state.selected ?? [],
1754
+ onClear: () => store.setState((current) => ({ ...current, selected: [] })),
1755
+ onAnalyze: runSelectionAnalysis,
1756
+ busy: state.selectionLoading === true,
1757
+ }),
1758
+ selectionResult === undefined
1759
+ ? null
1760
+ : h(
1761
+ 'div',
1762
+ { className: 'smd-ai' },
1763
+ h(
1764
+ 'div',
1765
+ { className: 'smd-row' },
1766
+ h('span', { className: 'smd-chip' }, selectionResult.mode === 'llm' ? copy.aiModeLlm : copy.aiModeDeterministic),
1767
+ h('span', { className: 'smd-chip' }, `${copy.selectedCount} ${(selectionResult.indicators ?? state.selected ?? []).length}`),
1768
+ h('span', { className: 'smd-spacer' }),
1769
+ h('button', { type: 'button', className: 'smd-btn', onClick: () => setSelectionResult(undefined) }, copy.dismiss),
1770
+ ),
1771
+ (selectionResult.violations ?? []).length > 0
1772
+ ? h('div', { className: 'smd-banner' }, `${copy.aiDegraded}${selectionResult.degradedReason === undefined ? `(${selectionResult.violations.length} 项)` : `:${selectionResult.degradedReason}`}`)
1773
+ : null,
1774
+ h('div', { className: 'smd-aiText' }, selectionResult.markdown ?? ''),
1775
+ ),
1776
+ visible.length === 0
1777
+ ? h('div', { className: 'smd-note' }, copy.empty)
1778
+ : groupBySection(visible, state.settings?.groups).map((section) =>
1779
+ h(
1780
+ 'div',
1781
+ { className: 'smd-section', key: section.id },
1782
+ h(
1783
+ 'div',
1784
+ { className: 'smd-row' },
1785
+ h('span', { className: 'smd-groupTitle' }, section.title),
1786
+ h('span', { className: 'smd-note' }, `${section.metrics.length}`),
1787
+ h('span', { className: 'smd-spacer' }),
1788
+ h(
1789
+ 'button',
1790
+ {
1791
+ type: 'button',
1792
+ className: 'smd-btn smd-btnInline',
1793
+ onClick: () => setCollapsed((current) => ({ ...current, [section.id]: !current[section.id] })),
1794
+ },
1795
+ collapsed[section.id] === true ? copy.expand : copy.collapse,
1796
+ ),
1797
+ ),
1798
+ collapsed[section.id] === true
1799
+ ? null
1800
+ : h(
1801
+ 'div',
1802
+ { className: 'smd-grid' },
1803
+ section.metrics.map((metric) =>
1804
+ h(MetricCard, {
1805
+ key: metric.indicatorId,
1806
+ metric,
1807
+ onOpen: openDetail,
1808
+ onToggleSelect: toggleSelection,
1809
+ selected: (state.selected ?? []).includes(metric.indicatorId),
1810
+ }),
1811
+ ),
1812
+ ),
1813
+ ),
1814
+ ),
1815
+ ),
1816
+ state.tab === 'detail'
1817
+ ? h(DetailDrawer, {
1818
+ detail: state.detail,
1819
+ barSize: state.barSize,
1820
+ onBarSize: setBarSize,
1821
+ loading: state.detailLoading,
1822
+ onClose: () => store.setState((current) => ({ ...current, tab: 'core' })),
1823
+ ai: state.ai,
1824
+ onAsk: runExplain,
1825
+ question,
1826
+ onQuestionChange: setQuestion,
1827
+ onSubmitQuestion: submitQuestion,
1828
+ onDiscuss: startDiscussion,
1829
+ discussing: state.discussing,
1830
+ discussError: state.discussError,
1831
+ discussAnswer: state.discussAnswer,
1832
+ discussPending: state.discussPending,
1833
+ discussSession: state.discussSession,
1834
+ onOpenSession: openSession,
1835
+ onDiscussNoteworthy: startDiscussion,
1836
+ })
1837
+ : null,
1838
+ ),
1839
+ h('div', { className: 'smd-foot' }, copy.disclaimer),
1840
+ ),
1841
+ )
1842
+ }
1843
+
1844
+ /**
1845
+ * Shape an '/overview' payload into a state patch.
1846
+ *
1847
+ * @param {object} data - payload.
1848
+ * @returns {object} patch.
1849
+ */
1850
+ function applyOverviewShape(data) {
1851
+ return { status: 'ready', overview: data, lastUpdatedAt: data?.generatedAt, error: undefined, unreachable: false }
1852
+ }
1853
+
1854
+ /**
1855
+ * Minutes since an ISO timestamp, for the "updated N minutes ago" label.
1856
+ *
1857
+ * @param {string} iso - timestamp.
1858
+ * @returns {number} minutes.
1859
+ */
1860
+ function ageMinutes(iso) {
1861
+ if (typeof iso !== 'string') return Number.NaN
1862
+ const at = Date.parse(iso)
1863
+ if (!Number.isFinite(at)) return Number.NaN
1864
+ return (Date.now() - at) / 60000
1865
+ }
1866
+
1867
+ /**
1868
+ * @param {object} state - panel state.
1869
+ * @returns {boolean} whether the degraded banner should render.
1870
+ */
1871
+ function shouldShowDegraded(state) {
1872
+ if (state.overview === undefined) return false
1873
+ return state.overview.degraded === true || (state.overview.errors ?? []).length > 0
1874
+ }
1875
+
1876
+ return { DataRadarOverlay, MetricCard, NoteworthyList, Chart, StatsStrip, AiPanel, DetailDrawer, AnalysisTab, SettingsTab, AddIndicatorTab, SelectionBar, DiscussionStatus, Sparkline, applyOverviewShape, groupBySection, ageMinutes, shouldShowDegraded, truncate }
1877
+ }