@changfenhuang/dsh-genui 0.11.2 → 0.11.4
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 +25 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/SKILL.md +16 -4
- package/lib/assets/standalone-runtime.js +7 -6
- package/lib/client.js +15 -17
- package/lib/index.js +956 -354
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- 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 +50 -11
- package/lib/types/plugin/index.d.ts +3 -3
- package/lib/types/shared/fence-repair.d.ts +18 -7
- package/package.json +20 -19
- package/src/client/GenuiBlock.module.css +65 -1
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/blocks/charts.tsx +41 -8
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +94 -21
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +93 -151
- package/src/client/inline.ts +11 -4
- 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 +372 -53
- package/src/plugin/index.ts +7 -6
- package/src/shared/fence-repair.ts +309 -128
|
@@ -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,11 +333,17 @@ 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
348
|
? <span className={css.cellBadge}>{renderInline(String(cell), false)}</span>
|
|
318
349
|
: type === 'bar'
|
|
@@ -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,7 +392,7 @@ 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}
|
|
@@ -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) {
|
|
@@ -299,7 +299,7 @@ export function renderNode(
|
|
|
299
299
|
return (
|
|
300
300
|
<span key={key} className={`${css.badge} ${css[tone] || ''}`}>
|
|
301
301
|
{node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
|
|
302
|
-
{renderInline(node.label, false)}
|
|
302
|
+
<span className={css.badgeLabel}>{renderInline(node.label, false)}</span>
|
|
303
303
|
</span>
|
|
304
304
|
)
|
|
305
305
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { GenuiSpec } from '../spec.ts'
|
|
9
9
|
import type { BlockInteractionState } from '../interaction-store.ts'
|
|
10
|
+
import type { SubmissionRegistry } from '../submission-registry.ts'
|
|
10
11
|
|
|
11
12
|
export interface GenuiBlockProps {
|
|
12
13
|
/** Parsed spec to render. */
|
|
@@ -35,16 +36,6 @@ export interface GenuiBlockProps {
|
|
|
35
36
|
onStateSnapshot?: ((state: BlockInteractionState) => void) | undefined
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** Per-question metadata registered by grouped radios for local grading. */
|
|
39
|
-
export interface QuestionMeta {
|
|
40
|
-
label: string
|
|
41
|
-
options: string[]
|
|
42
|
-
/** Correct option: index (number) or label (string); absent = no local grading. */
|
|
43
|
-
answer?: number | string | undefined
|
|
44
|
-
/** Shown after local grading. */
|
|
45
|
-
explanation?: string | undefined
|
|
46
|
-
}
|
|
47
|
-
|
|
48
39
|
/** Block-wide aggregation state. Grouped radios store one selected label in
|
|
49
40
|
* `answers`; grouped checkboxes store a selected-label array in
|
|
50
41
|
* `multiAnswers`. Keeping the registries separate preserves the existing
|
|
@@ -60,7 +51,7 @@ export interface AnswersState {
|
|
|
60
51
|
fields: Record<string, string>
|
|
61
52
|
/** Field ids whose value must never be persisted or collected (secrets). */
|
|
62
53
|
secretFields: ReadonlySet<string>
|
|
63
|
-
|
|
54
|
+
registry: SubmissionRegistry
|
|
64
55
|
/** True after a local grading: questions are locked until 重新作答. */
|
|
65
56
|
locked: boolean
|
|
66
57
|
/** Bumped by every reset; radios use it as their remount key. */
|
|
@@ -69,7 +60,6 @@ export interface AnswersState {
|
|
|
69
60
|
setMultiAnswer: (group: string, choice: string, checked: boolean) => void
|
|
70
61
|
setField: (id: string, value: string) => void
|
|
71
62
|
registerSecretField: (id: string) => void
|
|
72
|
-
registerMeta: (group: string, meta: QuestionMeta) => void
|
|
73
63
|
clear: () => void
|
|
74
64
|
setLocked: (locked: boolean) => void
|
|
75
65
|
}
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Stock DSH renders every fenced code block through the shared CodeBlock
|
|
5
5
|
* surface (stable class `md-code-block`, language label rendered as the
|
|
6
6
|
* banner's childless label div). This channel observes the conversation DOM,
|
|
7
|
-
* finds blocks labelled `dsh-ui
|
|
8
|
-
* plugin's own React tree next to
|
|
7
|
+
* finds blocks labelled `dsh-ui` or complete `<dsh-ui>…</dsh-ui>` text spans,
|
|
8
|
+
* parses the raw fence body and mounts the plugin's own React tree next to
|
|
9
|
+
* the hidden host element:
|
|
9
10
|
*
|
|
10
11
|
* Fence discovery is **multi-surface** (issue #6): besides `md-code-block`,
|
|
11
12
|
* the channel also matches the deepsuite-style surfaces some host builds
|
|
@@ -13,8 +14,9 @@
|
|
|
13
14
|
* structural backstop — ANY element whose banner labels it `dsh-ui` and
|
|
14
15
|
* which contains a `<pre>` body. The only invariants are the language label
|
|
15
16
|
* (a leaf element with the exact text `dsh-ui`, outside the code body) and
|
|
16
|
-
* the `<pre
|
|
17
|
-
*
|
|
17
|
+
* the `<pre>`. Desktop 0.11.0 instead emits a plain-text tag span for the
|
|
18
|
+
* same fence; the DOM channel recognizes that exact wrapper in assistant
|
|
19
|
+
* messages:
|
|
18
20
|
*
|
|
19
21
|
* - **Streaming takeover**: the channel takes over a dsh-ui block as soon as
|
|
20
22
|
* ONE finished component parses (the partial parser), and re-renders the
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
*
|
|
47
49
|
* Security posture matches the registry channel: only code shipped in this
|
|
48
50
|
* plugin's browser bundle mounts React roots, the model can only author
|
|
49
|
-
* fence text, and unrepairable bodies
|
|
51
|
+
* fence text, and unrepairable bodies remain visible in their host form.
|
|
50
52
|
*/
|
|
51
53
|
import { Fragment, isValidElement, type Key, type ReactNode } from 'react'
|
|
52
54
|
import { createRoot, type Root } from 'react-dom/client'
|
|
@@ -58,6 +60,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
58
60
|
import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
|
|
59
61
|
import css from './GenuiBlock.module.css'
|
|
60
62
|
import { renderSvgFence } from './svg-fence.tsx'
|
|
63
|
+
import { repairFenceJson, trimToBalancedRoot } from '../shared/fence-repair.ts'
|
|
61
64
|
import { describeFenceFailure, FenceDiagnostic, renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'
|
|
62
65
|
import { resolveViewedSessionId } from './session-resolver.ts'
|
|
63
66
|
import { validateCanonicalGenuiSpec } from './guard.ts'
|
|
@@ -161,6 +164,18 @@ function isTextNode(node: Node): node is Text {
|
|
|
161
164
|
return node.nodeType === Node.TEXT_NODE
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
/** 读取 DSH Desktop 0.11.0 将 dsh-ui 围栏呈现为纯文本标签时的正文。 */
|
|
168
|
+
function tagTextBodyOf(block: Element): string | null {
|
|
169
|
+
if (block.tagName !== 'SPAN' || block.childElementCount !== 0) return null
|
|
170
|
+
const flowKind = block.closest('[data-chat-flow-kind]')?.getAttribute('data-chat-flow-kind')
|
|
171
|
+
if (flowKind !== undefined && flowKind !== null && flowKind !== 'assistant-step') return null
|
|
172
|
+
const text = block.textContent?.trim() ?? ''
|
|
173
|
+
const opening = '<dsh-ui>'
|
|
174
|
+
const closing = '</dsh-ui>'
|
|
175
|
+
if (!text.startsWith(opening) || !text.endsWith(closing)) return null
|
|
176
|
+
return text.slice(opening.length, -closing.length).trim()
|
|
177
|
+
}
|
|
178
|
+
|
|
164
179
|
/** The banner's language label: a leaf element whose text is exactly the
|
|
165
180
|
* lang. CodeBlock renders the label as a childless div; deepsuite-style
|
|
166
181
|
* surfaces use a span; the ONLY structural invariants across hosts are "a
|
|
@@ -188,12 +203,39 @@ function infostringOf(block: Element): 'dsh-ui' | 'svg' | null {
|
|
|
188
203
|
return null
|
|
189
204
|
}
|
|
190
205
|
|
|
191
|
-
|
|
206
|
+
/**
|
|
207
|
+
* 已知**真实语言标识符**的闭集:语言 id 有限、由本插件维护,白名单列它是对的。
|
|
208
|
+
* 反例——宿主各语言里通用代码块标题的叫法(`Code` / `代码块` / «Код» / Código /
|
|
209
|
+
* Codice / Kode …)——是**无界集合**,白名单永远列不全:旧实现 `GENERIC_CODE_LABELS`
|
|
210
|
+
* 只列了英/中两种,宿主 locale 为其它语言时本地化标题被当成真语言,围栏永远不被
|
|
211
|
+
* 接管(issue #258)。因此判定反转:标签命中本集合才视为「带语言」,其余(空标签、
|
|
212
|
+
* 本地化通用词)一律视为「无语言」,由 {@link isGenericGenuiFence} 的内容校验把关——
|
|
213
|
+
* 同一份正文的渲染结果不因宿主语言而不同。
|
|
214
|
+
*/
|
|
215
|
+
const KNOWN_LANGUAGE_LABELS = new Set([
|
|
216
|
+
// 插件自有语言
|
|
217
|
+
'dsh-ui', 'svg',
|
|
218
|
+
// 数据 / 标记 / 纯文本
|
|
219
|
+
'json', 'json5', 'yaml', 'yml', 'toml', 'ini', 'xml', 'html', 'css', 'scss', 'sass', 'less',
|
|
220
|
+
'markdown', 'md', 'text', 'txt', 'plain', 'plaintext', 'log', 'console', 'diff', 'patch',
|
|
221
|
+
// 助手回复里常见的编程语言
|
|
222
|
+
'js', 'jsx', 'javascript', 'ts', 'tsx', 'typescript', 'python', 'py', 'bash', 'sh', 'shell', 'zsh',
|
|
223
|
+
'sql', 'graphql', 'gql', 'rust', 'go', 'golang', 'java', 'kotlin', 'kt', 'swift', 'scala', 'dart',
|
|
224
|
+
'c', 'h', 'cpp', 'c++', 'cxx', 'cs', 'csharp', 'php', 'ruby', 'rb', 'perl', 'lua', 'r', 'matlab',
|
|
225
|
+
'haskell', 'hs', 'elixir', 'ex', 'erlang', 'clojure', 'clj', 'groovy', 'objc', 'objective-c',
|
|
226
|
+
'dockerfile', 'docker', 'makefile', 'make', 'cmake', 'protobuf', 'proto',
|
|
227
|
+
])
|
|
192
228
|
|
|
193
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* Read a language that the host still exposes in its CodeBlock banner.
|
|
231
|
+
*
|
|
232
|
+
* 只有标签命中 {@link KNOWN_LANGUAGE_LABELS} 才返回语言;空标签与本地化通用词
|
|
233
|
+
* («Код» / Código / 代码块 …)是宿主的**呈现文案**、不是语义,一律返回 null——
|
|
234
|
+
* 此前它们会被当成真语言,连 ChatSnapshot 来源都被短路(issue #258)。
|
|
235
|
+
*/
|
|
194
236
|
function domLanguageOf(block: Element): string | null {
|
|
195
237
|
const label = labelTextOf(block)
|
|
196
|
-
return label
|
|
238
|
+
return label !== '' && KNOWN_LANGUAGE_LABELS.has(label.toLowerCase()) ? label : null
|
|
197
239
|
}
|
|
198
240
|
|
|
199
241
|
/** The banner label's raw text (empty while streaming — the host renders the
|
|
@@ -210,7 +252,18 @@ function labelTextOf(block: Element): string {
|
|
|
210
252
|
return ''
|
|
211
253
|
}
|
|
212
254
|
|
|
213
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* 仅在通用 CodeBlock 的内容经过**与带标签路径相同的标点级修复**后仍是合规 GenUI
|
|
257
|
+
* 规范时,恢复丢失的围栏语言。
|
|
258
|
+
*
|
|
259
|
+
* 宿主会隐去它不认识的语言(高亮器不支持 `dsh-ui`),于是同一份围栏在 DOM 里表现为
|
|
260
|
+
* 一个通用标题的普通代码块(标题随宿主 locale 本地化:`Code` / `代码块` / «Код» …);
|
|
261
|
+
* ChatSnapshot 的语言来源在部分行上不可用
|
|
262
|
+
* 时,内容识别是唯一出路。此前这里要求 `JSON.parse(raw)` 直接通过,导致**正文只差一个
|
|
263
|
+
* 未转义引号(tier-1 能修)的围栏永远不会被接管**——用户看到一个能渲染却始终是代码块的
|
|
264
|
+
* 围栏(真实会话 seq 40530:正文经 tier-1 修 14 处后可渲染,界面却停在代码块)。
|
|
265
|
+
* 结构级修复(tier-2)刻意不参与:内容识别是兜底,不该接管只是"长得像 JSON"的普通代码。
|
|
266
|
+
|
|
214
267
|
*
|
|
215
268
|
* @param block - 宿主提供的代码块元素。
|
|
216
269
|
* @param raw - 未修改的围栏正文。
|
|
@@ -219,20 +272,33 @@ function labelTextOf(block: Element): string {
|
|
|
219
272
|
function isGenericGenuiFence(block: Element, raw: string): boolean {
|
|
220
273
|
const row = block.closest<HTMLElement>(ASSISTANT_FLOW_ROW)
|
|
221
274
|
if (row === null || row.dataset.chatGroupPart === 'reasoning') return false
|
|
275
|
+
// domLanguageOf 已反转(#258):banner 是已知真实语言 ⇒ 不是通用块;空标签或
|
|
276
|
+
// 本地化通用词 ⇒ null,进入内容校验。这里不再要求标题命中任何白名单。
|
|
222
277
|
if (domLanguageOf(block) !== null || !block.querySelector('[data-code-block-banner]')) return false
|
|
223
|
-
|
|
278
|
+
const repaired = repairFenceJson(raw)
|
|
279
|
+
const candidate = repaired === null ? raw : repaired.text
|
|
224
280
|
let value: unknown
|
|
225
281
|
try {
|
|
226
|
-
value = JSON.parse(
|
|
282
|
+
value = JSON.parse(candidate)
|
|
227
283
|
} catch {
|
|
228
|
-
|
|
284
|
+
// 「合法 JSON + 尾部杂字符」(真实样本:模型把工具调用模板泄漏在 JSON 之后,
|
|
285
|
+
// 而且围栏没闭合)同样要能认出来:裁到平衡根值再试一次。只裁剪、不补全结构。
|
|
286
|
+
const trimmed = trimToBalancedRoot(candidate)
|
|
287
|
+
if (trimmed === null) return false
|
|
288
|
+
try {
|
|
289
|
+
value = JSON.parse(trimmed)
|
|
290
|
+
} catch {
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
229
293
|
}
|
|
230
294
|
if (!validateCanonicalGenuiSpec(value).ok || diagnoseUnknownGenuiFields(value).length > 0) return false
|
|
231
295
|
return JSON.stringify(normalizeGenuiSpec(value).value) === JSON.stringify(value)
|
|
232
296
|
}
|
|
233
297
|
|
|
234
|
-
/**
|
|
298
|
+
/** 读取宿主代码块或纯文本标签中的围栏正文。 */
|
|
235
299
|
function rawOf(block: Element): string {
|
|
300
|
+
const tagTextBody = tagTextBodyOf(block)
|
|
301
|
+
if (tagTextBody !== null) return tagTextBody
|
|
236
302
|
const pre = block.querySelector('pre')
|
|
237
303
|
if (pre === null) return ''
|
|
238
304
|
let text = ''
|
|
@@ -274,7 +340,7 @@ function implausibleLabeledAncestorOf(pre: HTMLElement, scope: ParentNode = docu
|
|
|
274
340
|
}
|
|
275
341
|
|
|
276
342
|
/**
|
|
277
|
-
* Every dsh-ui fence surface under `scope`,
|
|
343
|
+
* Every dsh-ui fence surface or complete tag-text span under `scope`, in DOM order.
|
|
278
344
|
* Known surface classes first (cheap, ordered), then a structural sweep —
|
|
279
345
|
* every `<pre>` whose banner labels it `dsh-ui` — so a host with an
|
|
280
346
|
* unlisted surface shape still renders. The label + `<pre>` gates make the
|
|
@@ -327,7 +393,12 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
|
|
|
327
393
|
out.push(surface)
|
|
328
394
|
seen.add(surface)
|
|
329
395
|
}
|
|
330
|
-
|
|
396
|
+
for (const span of scope.querySelectorAll<HTMLElement>('span')) {
|
|
397
|
+
if (span.closest(`.${CONTAINER_CLASS}, .${DIAGNOSTIC_CLASS}, ${CODE_BLOCK_SELECTORS}, [data-chat-group-part="reasoning"], [data-tool], [data-sidebar-chat], [data-sidebar-right-session], [data-sidebar-right-panel], [data-panel-conversation], [data-plugin-panel]`) !== null) continue
|
|
398
|
+
if (tagTextBodyOf(span) === null) continue
|
|
399
|
+
out.push(span)
|
|
400
|
+
}
|
|
401
|
+
return out.sort((left, right) => left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
|
331
402
|
}
|
|
332
403
|
|
|
333
404
|
/** One-time-per-install drift diagnostic flag (reset per install, so tests
|
|
@@ -383,7 +454,7 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
|
|
|
383
454
|
let fallbackIndex = 0
|
|
384
455
|
for (const candidate of findFenceCandidates(scope)) {
|
|
385
456
|
if (candidate.closest(STREAMING) !== null) continue
|
|
386
|
-
const language = domLanguageOf(candidate) ?? sourceLanguage(candidate)
|
|
457
|
+
const language = tagTextBodyOf(candidate) !== null ? 'dsh-ui' : domLanguageOf(candidate) ?? sourceLanguage(candidate)
|
|
387
458
|
if (language !== 'dsh-ui' && !(language === undefined && isGenericGenuiFence(candidate, rawOf(candidate)))) continue
|
|
388
459
|
fallbackIndex += 1
|
|
389
460
|
if (candidate === block) return fallbackIndex
|
|
@@ -391,9 +462,11 @@ function fenceIndexOf(ctx: Context, row: Element, block: Element, sourceLanguage
|
|
|
391
462
|
return fallbackIndex + 1
|
|
392
463
|
}
|
|
393
464
|
let index = 0
|
|
394
|
-
for (const candidate of
|
|
465
|
+
for (const candidate of findFenceCandidates(row)) {
|
|
395
466
|
if (candidate.closest(STREAMING) !== null) continue
|
|
396
|
-
|
|
467
|
+
if (candidate.closest(ASSISTANT_FLOW_ROW) !== row) continue
|
|
468
|
+
if (candidate.closest('[data-chat-group-part="reasoning"]') !== null) continue
|
|
469
|
+
const language = tagTextBodyOf(candidate) !== null ? 'dsh-ui' : domLanguageOf(candidate) ?? sourceLanguage(candidate)
|
|
397
470
|
if (language !== 'dsh-ui' && !(language === undefined && isGenericGenuiFence(candidate, rawOf(candidate)))) continue
|
|
398
471
|
index += 1
|
|
399
472
|
if (candidate === block) return index
|
|
@@ -744,7 +817,7 @@ export function installDomFenceRenderer(
|
|
|
744
817
|
if (block.hasAttribute(PROCESSED)) return
|
|
745
818
|
const row = rowOf(block)
|
|
746
819
|
const settled = isSettled(block)
|
|
747
|
-
const domLanguage = domLanguageOf(block)
|
|
820
|
+
const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
|
|
748
821
|
const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
|
|
749
822
|
const language = domLanguage ?? sourceLanguage
|
|
750
823
|
const raw = rawOf(block)
|
|
@@ -877,7 +950,7 @@ export function installDomFenceRenderer(
|
|
|
877
950
|
}
|
|
878
951
|
const raw = rawOf(block)
|
|
879
952
|
const settled = isSettled(block)
|
|
880
|
-
const domLanguage = domLanguageOf(block)
|
|
953
|
+
const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
|
|
881
954
|
const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
|
|
882
955
|
const language = domLanguage ?? sourceLanguage
|
|
883
956
|
const validGenui = language === 'dsh-ui'
|
|
@@ -972,7 +1045,7 @@ export function installDomFenceRenderer(
|
|
|
972
1045
|
// label is no longer dsh-ui is somebody else's fence, so our explanation
|
|
973
1046
|
// would be about the wrong block.
|
|
974
1047
|
if (isSettled(block)) {
|
|
975
|
-
const domLanguage = domLanguageOf(block)
|
|
1048
|
+
const domLanguage = tagTextBodyOf(block) !== null ? 'dsh-ui' : domLanguageOf(block)
|
|
976
1049
|
const sourceLanguage = domLanguage === null ? sourceLanguages.get(block) : undefined
|
|
977
1050
|
if ((domLanguage ?? sourceLanguage) !== 'dsh-ui') {
|
|
978
1051
|
clearDiagnostic(block)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { COMPONENT_SCHEMAS, GENUI_SPEC_SCHEMA } from './schema.ts'
|
|
3
3
|
import { isComponentRoot } from '../spec.ts'
|
|
4
4
|
import type { ComponentRecordSchema, ComponentSchema } from './schema.ts'
|
|
5
|
+
import { isTableDetailReachable } from '../table-details.ts'
|
|
5
6
|
|
|
6
7
|
export interface GenuiDiagnostic {
|
|
7
8
|
readonly kind: 'alias' | 'unknown-field'
|
|
@@ -47,6 +48,13 @@ function visitNativeNodes(value: unknown, path: string, visit: (node: Record<str
|
|
|
47
48
|
const holder = record(item)
|
|
48
49
|
if (holder?.items !== undefined && Array.isArray(holder.items)) holder.items.forEach((child, childIndex) => children(child, `${path}.items[${index}].items[${childIndex}]`))
|
|
49
50
|
})
|
|
51
|
+
} else if (type === 'table' && Array.isArray(value.details)) {
|
|
52
|
+
const table = { columns: value.columns, rows: value.rows, types: value.types }
|
|
53
|
+
value.details.forEach((detail, rowIndex) => {
|
|
54
|
+
if (Array.isArray(detail) && isTableDetailReachable(table, rowIndex)) {
|
|
55
|
+
detail.forEach((child, childIndex) => children(child, `${path}.details[${rowIndex}][${childIndex}]`))
|
|
56
|
+
}
|
|
57
|
+
})
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -123,6 +131,18 @@ export function diagnoseUnknownGenuiFields(value: unknown): GenuiDiagnostic[] {
|
|
|
123
131
|
pushUnknownField(warnings, path, field, node.type as string)
|
|
124
132
|
}
|
|
125
133
|
diagnoseNestedFields(node, path, definition, warnings)
|
|
134
|
+
if (node.type === 'list' && Array.isArray(node.items)) {
|
|
135
|
+
node.items.forEach((item, index) => {
|
|
136
|
+
if (isNode(item)) return
|
|
137
|
+
const holder = record(item)
|
|
138
|
+
if (holder === undefined) return
|
|
139
|
+
for (const field of Object.keys(holder)) {
|
|
140
|
+
if (field === 'title' || field === 'desc') continue
|
|
141
|
+
const itemPath = `${path}.items[${index}]`
|
|
142
|
+
warnings.push({ kind: 'unknown-field', path: `${itemPath}.${field}`, message: `${itemPath}.${field}: list items render 'title' and 'desc' only — '${field}' is dropped`, type: 'list', field })
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
126
146
|
}
|
|
127
147
|
if (Array.isArray(root.items) && !isComponentRoot(root)) {
|
|
128
148
|
for (const field of Object.keys(root)) {
|