@changfenhuang/dsh-genui 0.11.3 → 0.12.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/CHANGELOG.md +44 -0
- package/README.md +21 -6
- package/README.zh-CN.md +21 -6
- package/SKILL.md +18 -6
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +10 -9
- package/lib/client.js +31 -33
- package/lib/index.js +1060 -371
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +55 -12
- package/lib/types/plugin/index.d.ts +7 -3
- package/lib/types/shared/fence-repair.d.ts +19 -7
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +100 -4
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +63 -14
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +11 -8
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +190 -50
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +98 -153
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +28 -10
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +384 -53
- package/src/plugin/index.ts +20 -9
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +345 -128
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
|
@@ -191,7 +191,7 @@ body[data-ds-dark-theme] .panel {
|
|
|
191
191
|
|
|
192
192
|
/* ---------- text ---------- */
|
|
193
193
|
|
|
194
|
-
.text { color: var(--dsw-alias-label-primary); }
|
|
194
|
+
.text { color: var(--dsw-alias-label-primary); white-space: pre-line; }
|
|
195
195
|
.text.h1 {
|
|
196
196
|
font-size: var(--dsl-g-font-h1);
|
|
197
197
|
font-weight: 800;
|
|
@@ -229,6 +229,7 @@ body[data-ds-dark-theme] .panel {
|
|
|
229
229
|
cursor: pointer;
|
|
230
230
|
transition: border-color 0.15s, filter 0.15s, background 0.15s, transform 0.1s;
|
|
231
231
|
text-align: center;
|
|
232
|
+
white-space: pre-line;
|
|
232
233
|
/* A button in a `col` must hug its label; only `full: true` stretches.
|
|
233
234
|
(Default flex `align-items: stretch` used to make every stacked button a
|
|
234
235
|
full-width slab.) */
|
|
@@ -420,6 +421,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
420
421
|
}
|
|
421
422
|
.inlineLink { color: var(--dsl-g-accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
422
423
|
.inlineLink:hover { text-decoration-thickness: 2px; }
|
|
424
|
+
.inlineFileLink { padding: 0; border: 0; background: none; color: var(--dsl-g-accent); font: inherit; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
|
|
425
|
+
.inlineFileLink:hover { text-decoration-thickness: 2px; }
|
|
426
|
+
.inlineFileLink:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 3px; }
|
|
423
427
|
|
|
424
428
|
/* ---------- badges / stat / progress ---------- */
|
|
425
429
|
|
|
@@ -628,6 +632,81 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
628
632
|
.tdDelta { font-weight: 600; }
|
|
629
633
|
.tdDeltaUp { color: var(--dsw-alias-state-success-secondary, #1f9d63); }
|
|
630
634
|
.tdDeltaDown { color: var(--dsw-alias-state-error-primary, #d94848); }
|
|
635
|
+
/* A cell carrying real line breaks opts out of the `nowrap` data voice.
|
|
636
|
+
Prose keeps `pre-line` (newlines paint, spaces collapse as everywhere else);
|
|
637
|
+
a code-shaped cell uses `pre-wrap` so leading indentation survives too.
|
|
638
|
+
Either way the newline characters stay in the DOM, which is what makes the
|
|
639
|
+
cell selectable and copyable as the multi-line snippet the model wrote. */
|
|
640
|
+
.table th.tdMultiline,
|
|
641
|
+
.table td.tdMultiline {
|
|
642
|
+
white-space: pre-line;
|
|
643
|
+
overflow-wrap: anywhere;
|
|
644
|
+
word-break: normal;
|
|
645
|
+
vertical-align: top;
|
|
646
|
+
}
|
|
647
|
+
.table th.tdCode,
|
|
648
|
+
.table td.tdCode {
|
|
649
|
+
white-space: pre-wrap;
|
|
650
|
+
overflow-wrap: anywhere;
|
|
651
|
+
word-break: normal;
|
|
652
|
+
vertical-align: top;
|
|
653
|
+
}
|
|
654
|
+
/* An inline code span keeps its own spacing too (a pasted snippet often lives
|
|
655
|
+
inside backticks), matching the block-level code surface. */
|
|
656
|
+
.inlineCode { white-space: pre-wrap; }
|
|
657
|
+
/* Table cells are single-line by default, so a chip inside them must not
|
|
658
|
+
re-open line breaking. Host shells render message bodies inside `.markdown`,
|
|
659
|
+
which makes every chip an atomic `inline-flex` box and inherits
|
|
660
|
+
`overflow-wrap: anywhere` into the cell; together with `pre-wrap` the chip's
|
|
661
|
+
automatic minimum size collapses, the column can then be sized below its
|
|
662
|
+
content, and the still-unbroken text is painted over the next column (#280).
|
|
663
|
+
Keep the cell's own line-breaking contract for every table section. */
|
|
664
|
+
.table > thead > tr > th:not(.tdCode):not(.tdMultiline) .inlineCode,
|
|
665
|
+
.table > tbody > tr:not(.detailRow) > td:not(.tdCode):not(.tdMultiline) .inlineCode,
|
|
666
|
+
.table > tfoot > tr > td:not(.tdCode):not(.tdMultiline) .inlineCode {
|
|
667
|
+
white-space: pre;
|
|
668
|
+
}
|
|
669
|
+
/* Rich-text owners must paint the real newlines kept by renderInline. Scope
|
|
670
|
+
this to prose/labels: table data keeps nowrap and code keeps pre-wrap. For
|
|
671
|
+
flex controls, target the label span so emphasis and plain text share one
|
|
672
|
+
line-formatting context (rather than becoming separate flex items). */
|
|
673
|
+
.calloutBody,
|
|
674
|
+
.liTitle,
|
|
675
|
+
.liDesc,
|
|
676
|
+
.kvValue,
|
|
677
|
+
.tlDesc,
|
|
678
|
+
.detailBody,
|
|
679
|
+
.accBody,
|
|
680
|
+
.banner,
|
|
681
|
+
.cardTitle,
|
|
682
|
+
.mediaLabel,
|
|
683
|
+
.heroLabel,
|
|
684
|
+
.heroTitle,
|
|
685
|
+
.heroSubtitle,
|
|
686
|
+
.statLabel,
|
|
687
|
+
.ringLabel,
|
|
688
|
+
.ringSub,
|
|
689
|
+
.calloutTitle,
|
|
690
|
+
.stepTitle,
|
|
691
|
+
.stepDesc,
|
|
692
|
+
.kvKey,
|
|
693
|
+
.tlTitle,
|
|
694
|
+
.tlTime,
|
|
695
|
+
.accTitle,
|
|
696
|
+
.field > span,
|
|
697
|
+
.fieldLabel,
|
|
698
|
+
.switchLabel,
|
|
699
|
+
.radio > span,
|
|
700
|
+
.checkbox > span,
|
|
701
|
+
.tab,
|
|
702
|
+
.badgeLabel,
|
|
703
|
+
.gradeQ,
|
|
704
|
+
.gradeAns,
|
|
705
|
+
.gradeRight,
|
|
706
|
+
.gradeExp,
|
|
707
|
+
.quizQuestion,
|
|
708
|
+
.quizFeedback,
|
|
709
|
+
.quizExplanation { white-space: pre-line; }
|
|
631
710
|
|
|
632
711
|
/* sortable header button (v2.9) */
|
|
633
712
|
.thSort {
|
|
@@ -1063,11 +1142,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
1063
1142
|
/* Master-detail rows (table.details): a chevron in the first cell opens a
|
|
1064
1143
|
full-width panel underneath, so a table can drill down without leaving the
|
|
1065
1144
|
page or opening a modal. */
|
|
1066
|
-
.detailCell { padding-left: var(--dsl-g-gap-sm); }
|
|
1145
|
+
.detailCell { position: relative; padding-left: var(--dsl-g-gap-sm); }
|
|
1067
1146
|
.detailToggle {
|
|
1147
|
+
position: relative;
|
|
1148
|
+
z-index: 1;
|
|
1068
1149
|
display: inline-flex;
|
|
1069
1150
|
align-items: center;
|
|
1070
|
-
|
|
1151
|
+
justify-content: center;
|
|
1152
|
+
vertical-align: middle;
|
|
1153
|
+
width: 24px;
|
|
1154
|
+
height: 24px;
|
|
1155
|
+
margin-right: 4px;
|
|
1071
1156
|
padding: 0;
|
|
1072
1157
|
border: none;
|
|
1073
1158
|
background: none;
|
|
@@ -1078,7 +1163,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
1078
1163
|
text-align: left;
|
|
1079
1164
|
}
|
|
1080
1165
|
.detailToggle:hover { color: var(--dsl-g-accent); }
|
|
1166
|
+
.detailCell:hover .detailToggle { color: var(--dsl-g-accent); }
|
|
1081
1167
|
.detailToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
|
|
1168
|
+
.detailContent { position: relative; z-index: 1; vertical-align: middle; cursor: pointer; }
|
|
1082
1169
|
.detailChevron {
|
|
1083
1170
|
flex-shrink: 0;
|
|
1084
1171
|
font-size: 10px;
|
|
@@ -1118,10 +1205,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
1118
1205
|
.table tbody tr.groupRow:first-child td { box-shadow: none; padding-top: 10px; }
|
|
1119
1206
|
.table tbody tr.groupRow:hover { background: none; }
|
|
1120
1207
|
.table tbody tr.groupRow:nth-child(even) { background: none; }
|
|
1208
|
+
.groupRow td { position: relative; }
|
|
1121
1209
|
.groupToggle {
|
|
1210
|
+
position: relative;
|
|
1211
|
+
z-index: 1;
|
|
1122
1212
|
display: inline-flex;
|
|
1123
1213
|
align-items: center;
|
|
1124
|
-
|
|
1214
|
+
justify-content: center;
|
|
1215
|
+
vertical-align: middle;
|
|
1216
|
+
width: 24px;
|
|
1217
|
+
height: 24px;
|
|
1218
|
+
margin-right: 7px;
|
|
1125
1219
|
padding: 0;
|
|
1126
1220
|
border: none;
|
|
1127
1221
|
background: none;
|
|
@@ -1133,7 +1227,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
|
|
|
1133
1227
|
cursor: pointer;
|
|
1134
1228
|
}
|
|
1135
1229
|
.groupToggle:hover { color: var(--dsl-g-accent); }
|
|
1230
|
+
.groupLabel:hover { color: var(--dsl-g-accent); }
|
|
1136
1231
|
.groupToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
|
|
1232
|
+
.groupLabel { position: relative; z-index: 1; display: inline-flex; vertical-align: middle; align-items: center; gap: 7px; cursor: pointer; }
|
|
1137
1233
|
.groupChevron { color: var(--dsw-alias-label-tertiary); font-size: 11px; width: 10px; }
|
|
1138
1234
|
.groupCount {
|
|
1139
1235
|
padding: 0 7px;
|
|
@@ -13,8 +13,9 @@ import css from './GenuiBlock.module.css'
|
|
|
13
13
|
import { loadBlockState, saveBlockState } from './interaction-store.ts'
|
|
14
14
|
import { recordFence, recordInteraction } from './achievement-store.ts'
|
|
15
15
|
import { renderNode } from './blocks/render-node.tsx'
|
|
16
|
-
import type { AnswersState, GenuiBlockProps
|
|
16
|
+
import type { AnswersState, GenuiBlockProps } from './blocks/state.ts'
|
|
17
17
|
import type { GenuiSpec } from './spec.ts'
|
|
18
|
+
import { compileSubmissionRegistry } from './submission-registry.ts'
|
|
18
19
|
|
|
19
20
|
export const GENUI_ACTION_DEBOUNCE_MS = 300
|
|
20
21
|
|
|
@@ -96,7 +97,7 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
|
|
|
96
97
|
const [answers, setAnswers] = useState<Record<string, string>>(persisted?.answers ?? {})
|
|
97
98
|
const [multiAnswers, setMultiAnswers] = useState<Record<string, string[]>>(persisted?.multiAnswers ?? {})
|
|
98
99
|
const [fields, setFields] = useState<Record<string, string>>(persisted?.fields ?? {})
|
|
99
|
-
const
|
|
100
|
+
const submissionRegistry = useMemo(() => compileSubmissionRegistry(spec), [spec])
|
|
100
101
|
const [locked, setLocked] = useState(persisted?.locked === true)
|
|
101
102
|
const [round, setRound] = useState(0)
|
|
102
103
|
// Secret (password) field ids: their values never persist and never join
|
|
@@ -123,7 +124,7 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
|
|
|
123
124
|
})
|
|
124
125
|
}, [])
|
|
125
126
|
const setField = useCallback((id: string, value: string) => {
|
|
126
|
-
//
|
|
127
|
+
// Field state presence means "the user has touched this field" — a blank
|
|
127
128
|
// value is stored as '' instead of deleting the entry, so a user who
|
|
128
129
|
// CLEARS a model-provided default does not get the default back on the
|
|
129
130
|
// next mount. Blank values are still excluded from submit collection by
|
|
@@ -134,14 +135,6 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
|
|
|
134
135
|
const registerSecretField = useCallback((id: string) => {
|
|
135
136
|
setSecretFields(prev => (prev.has(id) ? prev : new Set(prev).add(id)))
|
|
136
137
|
}, [])
|
|
137
|
-
const registerMeta = useCallback((group: string, m: QuestionMeta) => {
|
|
138
|
-
setMeta(prev => {
|
|
139
|
-
const existing = prev[group]
|
|
140
|
-
if (existing !== undefined && existing.label === m.label && existing.answer === m.answer
|
|
141
|
-
&& existing.explanation === m.explanation) return prev
|
|
142
|
-
return { ...prev, [group]: m }
|
|
143
|
-
})
|
|
144
|
-
}, [])
|
|
145
138
|
const clear = useCallback(() => {
|
|
146
139
|
setAnswers({})
|
|
147
140
|
setMultiAnswers({})
|
|
@@ -150,10 +143,10 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
|
|
|
150
143
|
}, [])
|
|
151
144
|
const answersState = useMemo<AnswersState>(
|
|
152
145
|
() => ({
|
|
153
|
-
answers, multiAnswers, fields, secretFields,
|
|
154
|
-
setAnswer, setMultiAnswer, setField, registerSecretField,
|
|
146
|
+
answers, multiAnswers, fields, secretFields, registry: submissionRegistry, locked, round,
|
|
147
|
+
setAnswer, setMultiAnswer, setField, registerSecretField, clear, setLocked,
|
|
155
148
|
}),
|
|
156
|
-
[answers, multiAnswers, fields, secretFields,
|
|
149
|
+
[answers, multiAnswers, fields, secretFields, submissionRegistry, locked, round, setAnswer, setMultiAnswer, setField, registerSecretField, clear],
|
|
157
150
|
)
|
|
158
151
|
const durableState = useMemo(() => {
|
|
159
152
|
const safeFields = Object.fromEntries(Object.entries(fields).filter(([id]) => !secretFields.has(id)))
|
|
@@ -8,7 +8,37 @@
|
|
|
8
8
|
font: 12px/1.4 system-ui, sans-serif;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
.
|
|
11
|
+
/* 导出入口:无边框图标按钮。形状照项目里的 chrome 图标按钮 .panelClose
|
|
12
|
+
(26px 命中区 / 6px 圆角 / 透明底 / 无边框)。 */
|
|
13
|
+
.trigger {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
width: 26px;
|
|
18
|
+
height: 26px;
|
|
19
|
+
padding: 0;
|
|
20
|
+
border: 0;
|
|
21
|
+
border-radius: 6px;
|
|
22
|
+
background: transparent;
|
|
23
|
+
color: var(--dsw-alias-label-tertiary);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* 悬浮沿用区块内表格行 / .tab / .accHead 那条配方(--dsl-g-hover):主题文字色
|
|
28
|
+
4% 淡洗 + 文字色升到 label-primary。不用 rgba(255,255,255,…) 与没带兜底的
|
|
29
|
+
--dsw-alias-fill-hover —— 浅色主题下两者都看不见。 */
|
|
30
|
+
.trigger:hover,
|
|
31
|
+
.trigger:focus-visible {
|
|
32
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent);
|
|
33
|
+
color: var(--dsw-alias-label-primary);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* 聚焦轮廓同 .panelClose。 */
|
|
37
|
+
.trigger:focus-visible {
|
|
38
|
+
outline: 1px solid var(--dsw-alias-state-business-primary, #4f8ef7);
|
|
39
|
+
outline-offset: 1px;
|
|
40
|
+
}
|
|
41
|
+
|
|
12
42
|
.item {
|
|
13
43
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
14
44
|
border-radius: 7px;
|
|
@@ -18,9 +48,10 @@
|
|
|
18
48
|
cursor: pointer;
|
|
19
49
|
}
|
|
20
50
|
|
|
21
|
-
|
|
51
|
+
/* 菜单项沿用宿主的 fill-hover;第三方主题可能没有定义该 token(例如浅色
|
|
52
|
+
dream-skin),此时回退到区块内那条 4% 文字色淡洗,悬浮不会没有反馈。 */
|
|
22
53
|
.item:hover {
|
|
23
|
-
background: var(--dsw-alias-fill-hover);
|
|
54
|
+
background: var(--dsw-alias-fill-hover, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));
|
|
24
55
|
color: var(--dsw-alias-label-primary);
|
|
25
56
|
}
|
|
26
57
|
|
|
@@ -13,6 +13,17 @@ interface ArtifactExportMenuProps {
|
|
|
13
13
|
getArtifact: () => ReturnType<typeof createGenuiArtifact>
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** 导出入口的图标字形:托盘 + 下箭头(“导出为文件”),14px 描边、随文字取色。 */
|
|
17
|
+
function ExportGlyph() {
|
|
18
|
+
return (
|
|
19
|
+
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
|
20
|
+
<path d="M8 2.2v7.1" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
21
|
+
<path d="M5 6.4 8 9.4l3-3" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
|
22
|
+
<path d="M2.9 10.9v1.5a1 1 0 0 0 1 1h8.2a1 1 0 0 0 1-1v-1.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
/** 提供 JSON 和 HTML 的本地化导出菜单。 */
|
|
17
28
|
function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
|
|
18
29
|
const t = useT()
|
|
@@ -65,8 +76,18 @@ function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
|
|
|
65
76
|
<div className={css.chrome} ref={rootRef} data-genui-export>
|
|
66
77
|
{report.externalMedia.length > 0 && <span className={css.notice}>{t('artifact.externalMediaNotice')}</span>}
|
|
67
78
|
{errorMessage !== '' && <span className={css.statusError} aria-live="polite">{errorMessage}</span>}
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
{/* 无边框图标入口:完成态内容不再多出一个描边按钮。图标按钮的 aria-label
|
|
80
|
+
+ title 组合与项目里的 panelTpl / panelToggle 一致。 */}
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className={css.trigger}
|
|
84
|
+
aria-label={t('artifact.export')}
|
|
85
|
+
title={t('artifact.export')}
|
|
86
|
+
aria-haspopup="menu"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
onClick={() => setOpen(value => !value)}
|
|
89
|
+
>
|
|
90
|
+
<ExportGlyph />
|
|
70
91
|
</button>
|
|
71
92
|
{open && (
|
|
72
93
|
<div className={css.menu} role="menu">
|
|
@@ -20,6 +20,7 @@ import { t as tr, useT } from '../i18n/index.ts'
|
|
|
20
20
|
import css from '../GenuiBlock.module.css'
|
|
21
21
|
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
22
22
|
import type { GenuiChart, GenuiTable } from '../spec.ts'
|
|
23
|
+
import { isTableGroupHeaderRow, tableRowsForDetails } from '../table-details.ts'
|
|
23
24
|
|
|
24
25
|
export const CHART_COLORS = [
|
|
25
26
|
'var(--dsw-static-deepseek-400)',
|
|
@@ -85,6 +86,33 @@ function numericColumns(rows: GenuiTable['rows'], nCols: number): boolean[] {
|
|
|
85
86
|
})
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Does this cell carry real line breaks? Table cells default to `nowrap` (the
|
|
91
|
+
* data voice), which also collapses the leading whitespace of a pasted code
|
|
92
|
+
* block — indentation is lost and the snippet no longer runs. Cells that do
|
|
93
|
+
* contain a line break get `pre-line` (see `.tdMultiline`), so the line
|
|
94
|
+
* structure survives and the text still copies back as multiple lines.
|
|
95
|
+
*/
|
|
96
|
+
function hasLineBreak(value: string | number): boolean {
|
|
97
|
+
return typeof value === 'string' && /[\n\r]/.test(value)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Is this cell code rather than prose? Only then is leading indentation
|
|
102
|
+
* significant (`pre-wrap`), while a prose line break stays `pre-line` so its
|
|
103
|
+
* surrounding spaces collapse exactly like the rest of the UI.
|
|
104
|
+
*/
|
|
105
|
+
function isCodeCell(value: string | number): boolean {
|
|
106
|
+
if (typeof value !== 'string') return false
|
|
107
|
+
return /(^|\n)[ \t]/.test(value) || value.includes('```')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The whitespace class a multi-line cell needs, or undefined for single-line. */
|
|
111
|
+
function cellWrapClass(value: string | number): string | undefined {
|
|
112
|
+
if (!hasLineBreak(value)) return undefined
|
|
113
|
+
return isCodeCell(value) ? css.tdCode : css.tdMultiline
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
/** Signed cell text (`+12.4%`, `-3`, `−2.1k`) reads as a delta without any
|
|
89
117
|
* new spec field — the renderer classifies the string. */
|
|
90
118
|
function deltaTone(value: unknown): 'up' | 'down' | null {
|
|
@@ -206,9 +234,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
206
234
|
}) {
|
|
207
235
|
useT()
|
|
208
236
|
const columns = node.columns.slice(0, GENUI_LIMITS.maxTableCols)
|
|
209
|
-
const rows =
|
|
237
|
+
const rows = tableRowsForDetails<GenuiTable['rows'][number]>(node)
|
|
210
238
|
const types = node.types ?? []
|
|
211
|
-
const groupMode = types[0] === 'group'
|
|
212
239
|
const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null)
|
|
213
240
|
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
|
214
241
|
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
|
|
@@ -227,9 +254,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
227
254
|
// filled and every other cell empty opens a section. Data rows after it are
|
|
228
255
|
// its CHILDREN — indented, counted, and collapsible — so the relationship is
|
|
229
256
|
// unmistakable instead of "one more row at the same level".
|
|
230
|
-
const isGroupRow = (row: GenuiTable['rows'][number]): boolean =>
|
|
231
|
-
groupMode && String(row[0] ?? '').trim() !== ''
|
|
232
|
-
&& row.slice(1).every(cell => String(cell ?? '').trim() === '')
|
|
257
|
+
const isGroupRow = (row: GenuiTable['rows'][number]): boolean => isTableGroupHeaderRow(row, types)
|
|
233
258
|
|
|
234
259
|
// Local filtering (bound control): the model ships the full data set once and
|
|
235
260
|
// the reader narrows it live — no round trip, no re-generation.
|
|
@@ -308,13 +333,19 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
308
333
|
|
|
309
334
|
const renderCell = (cell: string | number, j: number, rowIndex: number): ReactNode => {
|
|
310
335
|
const type = types[j]
|
|
336
|
+
const wrap = cellWrapClass(cell)
|
|
311
337
|
const tone = type === 'delta'
|
|
312
338
|
? (String(cell).trim().startsWith('-') ? 'down' : 'up')
|
|
313
339
|
: deltaTone(cell)
|
|
314
340
|
return (
|
|
315
|
-
<td
|
|
341
|
+
<td
|
|
342
|
+
key={j}
|
|
343
|
+
className={[numeric[j] || type === 'num' ? css.tdNum : undefined, wrap]
|
|
344
|
+
.filter(part => part !== undefined)
|
|
345
|
+
.join(' ') || undefined}
|
|
346
|
+
>
|
|
316
347
|
{type === 'badge'
|
|
317
|
-
? <span className={css.cellBadge}>{renderInline(String(cell),
|
|
348
|
+
? <span className={css.cellBadge}>{renderInline(String(cell), 'file')}</span>
|
|
318
349
|
: type === 'bar'
|
|
319
350
|
? <CellBar cell={cell} />
|
|
320
351
|
: type === 'spark'
|
|
@@ -325,8 +356,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
325
356
|
? <span className={css.cellIndex}>{rowIndex + 1}</span>
|
|
326
357
|
: tone === null
|
|
327
358
|
// Format displayed text without changing sorting or exported data.
|
|
328
|
-
? renderInline(String(cell),
|
|
329
|
-
: <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell),
|
|
359
|
+
? renderInline(String(cell), 'file')
|
|
360
|
+
: <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), 'file')}</span>}
|
|
330
361
|
</td>
|
|
331
362
|
)
|
|
332
363
|
}
|
|
@@ -340,7 +371,9 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
340
371
|
{columns.map((c, i) => (
|
|
341
372
|
<th
|
|
342
373
|
key={i}
|
|
343
|
-
className={numeric[i] ? css.thNum : undefined
|
|
374
|
+
className={[numeric[i] ? css.thNum : undefined, cellWrapClass(c)]
|
|
375
|
+
.filter(part => part !== undefined)
|
|
376
|
+
.join(' ') || undefined}
|
|
344
377
|
aria-sort={sort !== null && sort.col === i ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}
|
|
345
378
|
>
|
|
346
379
|
<button type="button" className={css.thSort} onClick={() => clickHeader(i)}>
|
|
@@ -359,17 +392,25 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
359
392
|
<Fragment key={section.header === null ? `s-${si}` : `g-${section.header.index}`}>
|
|
360
393
|
{section.header !== null && (
|
|
361
394
|
<tr className={css.groupRow}>
|
|
362
|
-
<td colSpan={columns.length}>
|
|
395
|
+
<td colSpan={columns.length} className={cellWrapClass(String(section.header.row[0]))}>
|
|
363
396
|
<button
|
|
364
397
|
type="button"
|
|
365
398
|
className={css.groupToggle}
|
|
399
|
+
aria-label={tr(isCollapsed ? 'block.table.expandGroup' : 'block.table.collapseGroup', {
|
|
400
|
+
label: String(section.header.row[0]).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
|
|
401
|
+
})}
|
|
366
402
|
aria-expanded={!isCollapsed}
|
|
367
403
|
onClick={() => toggleSection(headerIndex)}
|
|
368
404
|
>
|
|
369
405
|
<span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
|
|
370
|
-
{renderInline(String(section.header.row[0]), false)}
|
|
371
|
-
<span className={css.groupCount}>{section.children.length}</span>
|
|
372
406
|
</button>
|
|
407
|
+
<span className={css.groupLabel} onClick={event => {
|
|
408
|
+
if (event.target instanceof Element && event.target.closest('button')) return
|
|
409
|
+
toggleSection(headerIndex)
|
|
410
|
+
}}>
|
|
411
|
+
{renderInline(String(section.header.row[0]), 'file')}
|
|
412
|
+
<span className={css.groupCount}>{section.children.length}</span>
|
|
413
|
+
</span>
|
|
373
414
|
</td>
|
|
374
415
|
</tr>
|
|
375
416
|
)}
|
|
@@ -386,12 +427,20 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
|
|
|
386
427
|
<button
|
|
387
428
|
type="button"
|
|
388
429
|
className={css.detailToggle}
|
|
430
|
+
aria-label={tr(open ? 'block.table.collapseDetails' : 'block.table.expandDetails', {
|
|
431
|
+
label: String(cell).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
|
|
432
|
+
})}
|
|
389
433
|
aria-expanded={open}
|
|
390
434
|
onClick={() => toggleDetail(child.index)}
|
|
391
435
|
>
|
|
392
436
|
<span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
|
|
393
|
-
{renderInline(String(cell), false)}
|
|
394
437
|
</button>
|
|
438
|
+
<span className={css.detailContent} onClick={event => {
|
|
439
|
+
if (event.target instanceof Element && event.target.closest('button')) return
|
|
440
|
+
toggleDetail(child.index)
|
|
441
|
+
}}>
|
|
442
|
+
{renderInline(String(cell), 'file')}
|
|
443
|
+
</span>
|
|
395
444
|
</td>
|
|
396
445
|
)
|
|
397
446
|
: renderCell(cell, j, child.index)
|
|
@@ -8,7 +8,9 @@ import { useEffect, useId, useRef, useState } from 'react'
|
|
|
8
8
|
import css from '../GenuiBlock.module.css'
|
|
9
9
|
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
10
10
|
import { useT } from '../i18n/index.ts'
|
|
11
|
-
import
|
|
11
|
+
import { resolveSubmitState } from '../submission-registry.ts'
|
|
12
|
+
import type { RadioSubmissionMember } from '../submission-registry.ts'
|
|
13
|
+
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
12
14
|
import type { GenuiInput, GenuiRadio, GenuiSelect, GenuiSlider, GenuiSubmit, GenuiSwitch, GenuiTextarea } from '../spec.ts'
|
|
13
15
|
|
|
14
16
|
export function RadioNode({ node, onAction, answers }: {
|
|
@@ -22,7 +24,7 @@ export function RadioNode({ node, onAction, answers }: {
|
|
|
22
24
|
const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
|
|
23
25
|
// No default selection unless the model explicitly sets `selected` — a
|
|
24
26
|
// pre-checked first option silently swallows the user's "keep the default"
|
|
25
|
-
// answer (the
|
|
27
|
+
// answer (the interaction state only records selected values). A DURABLE answer
|
|
26
28
|
// (restored from localStorage) wins over both. The parent key includes the
|
|
27
29
|
// reset round, so 重新作答 remounts this radio with a clean selection —
|
|
28
30
|
// no sync effect needed.
|
|
@@ -32,17 +34,8 @@ export function RadioNode({ node, onAction, answers }: {
|
|
|
32
34
|
const [selected, setSelected] = useState<number | null>(restoredIndex >= 0 ? restoredIndex : (node.selected ?? null))
|
|
33
35
|
const uid = useId()
|
|
34
36
|
const locked = grouped && answers?.locked === true
|
|
35
|
-
// Register question metadata for local grading (mount + when the question
|
|
36
|
-
// changes). `answers` is deliberately NOT a dep: the callback identity is
|
|
37
|
-
// stable and re-registering on every answers update is needless churn.
|
|
38
37
|
useEffect(() => {
|
|
39
38
|
if (group === undefined) return
|
|
40
|
-
answers?.registerMeta(group, {
|
|
41
|
-
label: node.label ?? group,
|
|
42
|
-
options,
|
|
43
|
-
answer: node.answer,
|
|
44
|
-
explanation: node.explanation,
|
|
45
|
-
})
|
|
46
39
|
// A model-provided default selection IS the answer — but only when the
|
|
47
40
|
// group has no durable answer yet (a restored user choice must win).
|
|
48
41
|
if (node.selected !== undefined && options[node.selected] !== undefined && answers?.answers[group] === undefined) {
|
|
@@ -77,16 +70,14 @@ export function RadioNode({ node, onAction, answers }: {
|
|
|
77
70
|
)
|
|
78
71
|
}
|
|
79
72
|
|
|
80
|
-
/**
|
|
81
|
-
export function correctLabelOf(m:
|
|
73
|
+
/** 根据静态 radio member 获取正确选项的标签。 */
|
|
74
|
+
export function correctLabelOf(m: RadioSubmissionMember): string | undefined {
|
|
82
75
|
if (m.answer === undefined) return undefined
|
|
83
76
|
if (typeof m.answer === 'number') return m.options[m.answer]
|
|
84
77
|
return m.answer
|
|
85
78
|
}
|
|
86
79
|
|
|
87
|
-
/**
|
|
88
|
-
* LOCAL-FIRST grading; when checkbox selections participate, the click falls
|
|
89
|
-
* back to the aggregation action so multi-select data is never discarded. */
|
|
80
|
+
/** 汇总当前 block 的表单状态,并根据 resolver 的结果提交或本地判卷。 */
|
|
90
81
|
export function SubmitNode({ node, onAction, answers }: {
|
|
91
82
|
node: GenuiSubmit
|
|
92
83
|
onAction?: GenuiBlockProps['onAction']
|
|
@@ -96,34 +87,21 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
96
87
|
const recorded = answers?.answers ?? {}
|
|
97
88
|
const multiRecorded = answers?.multiAnswers ?? {}
|
|
98
89
|
const fields = answers?.fields ?? {}
|
|
99
|
-
const meta = answers?.meta ?? {}
|
|
100
90
|
const expected = node.groups
|
|
101
91
|
// One shared notion of "filled fields" for answered/ready/payload: non-blank
|
|
102
92
|
// values only, secrets (password inputs) never collected into submit.
|
|
103
93
|
const filledFields = Object.fromEntries(
|
|
104
94
|
Object.entries(fields).filter(([id, v]) => v.trim() !== '' && !answers?.secretFields.has(id)),
|
|
105
95
|
)
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// not considered "answered".
|
|
116
|
-
const answered = expected === undefined
|
|
117
|
-
? Math.max(recordedGroups.size, Object.keys(filledFields).length)
|
|
118
|
-
: expected.filter(hasRecordedAnswer).length
|
|
119
|
-
const total = expected?.length ?? answered
|
|
120
|
-
const scope = expected ?? [...recordedGroups]
|
|
121
|
-
const hasMultiInScope = scope.some(group =>
|
|
122
|
-
Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0,
|
|
123
|
-
)
|
|
124
|
-
// Local grading must be radio-only. Otherwise grading would consume the
|
|
125
|
-
// click and silently omit checkbox selections that need model delivery.
|
|
126
|
-
const canGradeLocally = !hasMultiInScope && scope.some(group => meta[group]?.answer !== undefined)
|
|
96
|
+
const resolved = answers === undefined ? { scope: [], answered: 0, total: expected?.length ?? 0, localGradeEligible: false, hasOutOfScopePayload: false }
|
|
97
|
+
: resolveSubmitState({
|
|
98
|
+
registry: answers.registry,
|
|
99
|
+
...(expected === undefined ? {} : { groups: expected }),
|
|
100
|
+
state: { answers: recorded, multiAnswers: multiRecorded, fields, secretFields: answers.secretFields },
|
|
101
|
+
})
|
|
102
|
+
const { scope, answered, total } = resolved
|
|
103
|
+
const canSendAction = node.action !== undefined && onAction !== undefined
|
|
104
|
+
const shouldGradeLocally = resolved.localGradeEligible && (!resolved.hasOutOfScopePayload || !canSendAction)
|
|
127
105
|
const submitted = answers?.locked === true
|
|
128
106
|
const collectedAnswers: Record<string, string | string[]> = {
|
|
129
107
|
...recorded,
|
|
@@ -133,12 +111,12 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
133
111
|
// grading, or a real action name + provider. A submit with neither is a
|
|
134
112
|
// display-only control — honest disabled affordance.
|
|
135
113
|
const ready = answered > 0 && answered >= total
|
|
136
|
-
&& (
|
|
114
|
+
&& (shouldGradeLocally || canSendAction)
|
|
137
115
|
|
|
138
116
|
if (submitted) {
|
|
139
117
|
// ── local grading result ──
|
|
140
|
-
const graded = scope.filter(
|
|
141
|
-
const score = graded.filter(
|
|
118
|
+
const graded = scope.filter((member): member is RadioSubmissionMember => member.kind === 'radio' && recorded[member.key] !== undefined && member.answer !== undefined)
|
|
119
|
+
const score = graded.filter(member => recorded[member.key] === correctLabelOf(member)).length
|
|
142
120
|
return (
|
|
143
121
|
<div className={css.gradeWrap} data-genui-grade>
|
|
144
122
|
<div className={css.gradeScore}>
|
|
@@ -146,14 +124,14 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
146
124
|
<span className={css.gradeScoreLabel}>{t('block.score')}{graded.length < scope.length ? t('block.scoreUngraded', { count: scope.length - graded.length }) : ''}</span>
|
|
147
125
|
</div>
|
|
148
126
|
<div className={css.gradeList}>
|
|
149
|
-
{scope.map(
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
if (entry === undefined
|
|
127
|
+
{scope.map(m => {
|
|
128
|
+
if (m.kind !== 'radio') return null
|
|
129
|
+
const entry = recorded[m.key]
|
|
130
|
+
if (entry === undefined) return null
|
|
153
131
|
const correct = correctLabelOf(m)
|
|
154
132
|
if (correct === undefined) {
|
|
155
133
|
return (
|
|
156
|
-
<div key={
|
|
134
|
+
<div key={m.key} className={css.gradeItem}>
|
|
157
135
|
<span className={css.gradeQ}>{renderInline(m.label)}</span>
|
|
158
136
|
<span className={css.gradeAns}>{t('block.yourAnswer')}{renderInline(entry)}</span>
|
|
159
137
|
</div>
|
|
@@ -161,7 +139,7 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
161
139
|
}
|
|
162
140
|
const isCorrect = entry === correct
|
|
163
141
|
return (
|
|
164
|
-
<div key={
|
|
142
|
+
<div key={m.key} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
|
|
165
143
|
<span className={css.gradeQ}>{renderInline(m.label)}</span>
|
|
166
144
|
<span className={css.gradeTag}>{isCorrect ? '✓' : '✗'}</span>
|
|
167
145
|
<span className={css.gradeAns}>
|
|
@@ -196,7 +174,7 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
196
174
|
className={`${css.button} ${css.primary} ${css.submit}`}
|
|
197
175
|
disabled={!ready}
|
|
198
176
|
onClick={ready ? () => {
|
|
199
|
-
if (
|
|
177
|
+
if (shouldGradeLocally) {
|
|
200
178
|
// Local grading: immediate in-place result, no model round trip.
|
|
201
179
|
answers?.setLocked(true)
|
|
202
180
|
} else if (node.action !== undefined && onAction !== undefined) {
|