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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- 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
|
+
}
|