@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.
@@ -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 = node.rows.slice(0, GENUI_LIMITS.maxTableRows)
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 key={j} className={numeric[j] || type === 'num' ? css.tdNum : undefined}>
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 type { AnswersState, GenuiBlockProps, QuestionMeta } from './state.ts'
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 registry only records real change events). A DURABLE answer
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
- /** Resolve a question's correct label from its metadata. */
81
- export function correctLabelOf(m: QuestionMeta): string | undefined {
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
- /** Submit: collect grouped radio and checkbox answers. Radio-only scopes keep
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 nonEmptyMultiGroups = Object.entries(multiRecorded)
107
- .filter(([, values]) => Array.isArray(values) && values.length > 0)
108
- .map(([group]) => group)
109
- const recordedGroups = new Set([...Object.keys(recorded), ...nonEmptyMultiGroups])
110
- const hasRecordedAnswer = (group: string): boolean =>
111
- recorded[group] !== undefined || (Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0)
112
-
113
- // Explicit groups require at least one selected checkbox (or a radio answer)
114
- // per group. Empty checkbox arrays remain durable state but are deliberately
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
- && (canGradeLocally || (node.action !== undefined && onAction !== undefined))
114
+ && (shouldGradeLocally || canSendAction)
137
115
 
138
116
  if (submitted) {
139
117
  // ── local grading result ──
140
- const graded = scope.filter(g => recorded[g] !== undefined && meta[g]?.answer !== undefined)
141
- const score = graded.filter(g => recorded[g] === correctLabelOf(meta[g]!)).length
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(g => {
150
- const entry = recorded[g]
151
- const m = meta[g]
152
- if (entry === undefined || m === undefined) return null
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={g} className={css.gradeItem}>
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={g} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
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 (canGradeLocally) {
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
- meta: Record<string, QuestionMeta>
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
  }
@@ -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`, parses the raw fence body and mounts the
8
- * plugin's own React tree next to the (hidden) stock block:
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>`, so a host DOM drift degrades to a rendered fence, never a
17
- * silently skipped one:
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 stay stock code blocks.
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
- const GENERIC_CODE_LABELS = new Set(['Code', 'Code block', '代码块'])
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
- /** Read a language that the host still exposes in its CodeBlock banner. */
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 === '' || GENERIC_CODE_LABELS.has(label) ? null : 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
- /** 仅在通用 CodeBlock 的完整 JSON 通过现有 GenUI 规范时恢复丢失的围栏语言。
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
- if (!GENERIC_CODE_LABELS.has(labelTextOf(block))) return false
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(raw)
282
+ value = JSON.parse(candidate)
227
283
  } catch {
228
- return false
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
- /** Raw fence body from the stock block's code surface. */
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`, outer-most first, deduped.
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
- return out
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 hostFenceBlocksOf(row)) {
465
+ for (const candidate of findFenceCandidates(row)) {
395
466
  if (candidate.closest(STREAMING) !== null) continue
396
- const language = domLanguageOf(candidate) ?? sourceLanguage(candidate)
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)) {