@changfenhuang/dsh-genui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +9 -1
  4. package/SKILL.md +4 -0
  5. package/lib/client.js +261 -5
  6. package/lib/index.js +14 -12
  7. package/lib/types/client/asset-loader.d.ts +2 -0
  8. package/lib/types/client/blocks/basic.d.ts +0 -5
  9. package/lib/types/client/blocks/diagram/theme.d.ts +3 -1
  10. package/lib/types/client/blocks/state.d.ts +4 -0
  11. package/lib/types/client/embed.d.ts +8 -0
  12. package/lib/types/client/inline.d.ts +3 -27
  13. package/lib/types/plugin/index.d.ts +2 -1
  14. package/package.json +63 -2
  15. package/src/client/EChartNode.tsx +3 -2
  16. package/src/client/GenuiBlock.module.css +25 -14
  17. package/src/client/GenuiBlock.tsx +24 -16
  18. package/src/client/PlotBlock.tsx +2 -1
  19. package/src/client/asset-loader.ts +7 -0
  20. package/src/client/blocks/advanced.tsx +15 -15
  21. package/src/client/blocks/basic.tsx +3 -2
  22. package/src/client/blocks/charts.tsx +18 -19
  23. package/src/client/blocks/checkbox.tsx +2 -1
  24. package/src/client/blocks/diagram/index.tsx +33 -24
  25. package/src/client/blocks/diagram/layout.ts +4 -1
  26. package/src/client/blocks/diagram/theme.ts +22 -3
  27. package/src/client/blocks/forms.tsx +15 -14
  28. package/src/client/blocks/image.tsx +2 -1
  29. package/src/client/blocks/render-node.tsx +18 -18
  30. package/src/client/blocks/state.ts +4 -0
  31. package/src/client/dom-fence.tsx +19 -10
  32. package/src/client/embed.ts +9 -0
  33. package/src/client/gallery.ts +6 -0
  34. package/src/client/guard.ts +9 -3
  35. package/src/client/inline.ts +36 -44
  36. package/src/plugin/index.ts +8 -6
  37. package/src/plugin/tool.ts +2 -2
@@ -166,8 +166,8 @@ function AnimatedValue({ value, unitClass, animate = false }: {
166
166
  const animated = useCountUp(target ?? 0, parts.decimals, animate && target !== undefined)
167
167
  return (
168
168
  <>
169
- {target === undefined ? parts.num : animated}
170
- {parts.unit !== '' && <span className={unitClass ?? css.statUnit}>{parts.unit}</span>}
169
+ {target === undefined ? renderInline(parts.num) : animated}
170
+ {parts.unit !== '' && <span className={unitClass ?? css.statUnit}>{renderInline(parts.unit)}</span>}
171
171
  </>
172
172
  )
173
173
  }
@@ -252,7 +252,7 @@ export function renderNode(
252
252
  } as CSSProperties
253
253
  return (
254
254
  <div key={key} className={`${css.card}${toneClass}`} style={accentStyle}>
255
- {node.title !== undefined && <div className={css.cardTitle}>{node.title}</div>}
255
+ {node.title !== undefined && <div className={css.cardTitle}>{renderInline(node.title)}</div>}
256
256
  {node.items.map((c, i) => renderNode(c, i, onAction, depth + 1, answers))}
257
257
  </div>
258
258
  )
@@ -273,7 +273,7 @@ export function renderNode(
273
273
  onClick={interactive ? () => onAction(action, { type: 'button', label: node.label }) : undefined}
274
274
  >
275
275
  {node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
276
- {node.label}
276
+ {renderInline(node.label, false)}
277
277
  </ClickFeedbackButton>
278
278
  )
279
279
  }
@@ -286,8 +286,8 @@ export function renderNode(
286
286
  // was the same complaint class as the disabled-button fix).
287
287
  const href = node.href
288
288
  return href !== undefined
289
- ? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{node.label}</a>
290
- : <span key={key} className={css.linkText}>{node.label}</span>
289
+ ? <a key={key} className={css.link} href={href} target="_blank" rel="noopener noreferrer">{renderInline(node.label, false)}</a>
290
+ : <span key={key} className={css.linkText}>{renderInline(node.label, false)}</span>
291
291
  }
292
292
  case 'image': return <ImageNode key={`${key}:${node.src}`} node={node} />
293
293
  case 'audio': return <AudioNode key={`${key}:${node.src}`} node={node} />
@@ -297,7 +297,7 @@ export function renderNode(
297
297
  return (
298
298
  <span key={key} className={`${css.badge} ${css[tone] || ''}`}>
299
299
  {node.icon !== undefined && <span aria-hidden>{node.icon} </span>}
300
- {node.label}
300
+ {renderInline(node.label, false)}
301
301
  </span>
302
302
  )
303
303
  }
@@ -307,18 +307,18 @@ export function renderNode(
307
307
  const heroDown = node.delta !== undefined && node.delta.startsWith('-')
308
308
  return (
309
309
  <div key={key} className={`${css.hero} ${heroTone}`}>
310
- {node.label !== undefined && <span className={css.heroLabel}>{node.label}</span>}
310
+ {node.label !== undefined && <span className={css.heroLabel}>{renderInline(node.label, false)}</span>}
311
311
  <div className={css.heroTop}>
312
312
  {node.value !== undefined && (
313
313
  <span className={css.heroValue}><AnimatedValue value={node.value} unitClass={css.heroUnit} animate /></span>
314
314
  )}
315
315
  {node.delta !== undefined && (
316
- <span className={`${css.statDelta} ${heroDown ? css.down : css.up}`}>{node.delta}</span>
316
+ <span className={`${css.statDelta} ${heroDown ? css.down : css.up}`}>{renderInline(node.delta)}</span>
317
317
  )}
318
318
  {node.spark !== undefined && <span className={css.heroSpark}><Sparkline values={node.spark} /></span>}
319
319
  </div>
320
- <span className={css.heroTitle}>{node.title}</span>
321
- {node.subtitle !== undefined && <span className={css.heroSubtitle}>{node.subtitle}</span>}
320
+ <span className={css.heroTitle}>{renderInline(node.title)}</span>
321
+ {node.subtitle !== undefined && <span className={css.heroSubtitle}>{renderInline(node.subtitle)}</span>}
322
322
  </div>
323
323
  )
324
324
  }
@@ -326,11 +326,11 @@ export function renderNode(
326
326
  const down = node.delta !== undefined && node.delta.startsWith('-')
327
327
  return (
328
328
  <div key={key} className={`${css.stat}${node.size === 'hero' ? ` ${css.statHero}` : ''}`}>
329
- <span className={css.statLabel}>{node.label}</span>
329
+ <span className={css.statLabel}>{renderInline(node.label, false)}</span>
330
330
  <span className={css.statValue}>
331
331
  <AnimatedValue value={node.value} animate={node.size === 'hero'} />
332
332
  </span>
333
- {node.delta !== undefined && <span className={`${css.statDelta} ${down ? css.down : css.up}`}>{node.delta}</span>}
333
+ {node.delta !== undefined && <span className={`${css.statDelta} ${down ? css.down : css.up}`}>{renderInline(node.delta)}</span>}
334
334
  {node.spark !== undefined && <Sparkline values={node.spark} />}
335
335
  </div>
336
336
  )
@@ -368,8 +368,8 @@ export function renderNode(
368
368
  </svg>
369
369
  {(node.label !== undefined || node.valueLabel !== undefined) && (
370
370
  <div className={css.ringMeta}>
371
- {node.label !== undefined && <span className={css.ringLabel}>{node.label}</span>}
372
- {node.valueLabel !== undefined && <span className={css.ringSub}>{node.valueLabel}</span>}
371
+ {node.label !== undefined && <span className={css.ringLabel}>{renderInline(node.label, false)}</span>}
372
+ {node.valueLabel !== undefined && <span className={css.ringSub}>{renderInline(node.valueLabel)}</span>}
373
373
  </div>
374
374
  )}
375
375
  </div>
@@ -387,8 +387,8 @@ export function renderNode(
387
387
  >
388
388
  {(node.label !== undefined || node.valueLabel !== undefined) && (
389
389
  <div className={css.progressRow}>
390
- <span>{node.label}</span>
391
- {node.valueLabel !== undefined && <span>{node.valueLabel}</span>}
390
+ <span>{renderInline(node.label ?? '')}</span>
391
+ {node.valueLabel !== undefined && <span>{renderInline(node.valueLabel)}</span>}
392
392
  </div>
393
393
  )}
394
394
  <div className={css.track}>
@@ -413,7 +413,7 @@ export function renderNode(
413
413
  <div key={i} className={css.li}>
414
414
  {isListItemNode(item)
415
415
  ? renderNode(item, i, onAction, depth + 1, answers)
416
- : <><span className={css.liTitle}>{renderInline(typeof item === 'string' ? item : item.title)}</span>{typeof item !== 'string' && item.desc !== undefined && <span className={css.liDesc}>{renderInline(item.desc)}</span>}</>}
416
+ : <><div className={css.liTitle}>{renderInline(typeof item === 'string' ? item : item.title)}</div>{typeof item !== 'string' && item.desc !== undefined && <div className={css.liDesc}>{renderInline(item.desc)}</div>}</>}
417
417
  </div>
418
418
  ))}
419
419
  {bound !== undefined && <span className={css.filterHint}>匹配 {items.length} / {all.length} 项</span>}
@@ -6,6 +6,7 @@
6
6
  * @module @changfenhuang/dsh-genui/client/blocks/state
7
7
  */
8
8
  import type { GenuiSpec } from '../spec.ts'
9
+ import type { BlockInteractionState } from '../interaction-store.ts'
9
10
 
10
11
  export interface GenuiBlockProps {
11
12
  /** Parsed spec to render. */
@@ -27,6 +28,9 @@ export interface GenuiBlockProps {
27
28
  stateKey?: string | undefined
28
29
  /** Animate newly arriving items; settled message replays opt out. */
29
30
  animateEntrance?: boolean | undefined
31
+ /** Native hosts own durable state instead of the browser store. Read once per stateKey. */
32
+ initialState?: BlockInteractionState | undefined
33
+ onStateChange?: ((state: BlockInteractionState) => void) | undefined
30
34
  }
31
35
 
32
36
  /** Per-question metadata registered by grouped radios for local grading. */
@@ -159,7 +159,7 @@ function infostringOf(block: Element): string | null {
159
159
  const pre = block.querySelector('pre')
160
160
  for (const el of block.querySelectorAll('*')) {
161
161
  if (el.childElementCount !== 0) continue
162
- if (el.textContent !== 'dsh-ui') continue
162
+ if (el.textContent?.trim() !== 'dsh-ui') continue
163
163
  if (pre !== null && pre.contains(el)) continue
164
164
  // A leaf label that belongs to a NESTED known code surface is that
165
165
  // surface's banner, not `block`'s own banner. Only accept labels whose
@@ -181,7 +181,7 @@ function labelTextOf(block: Element): string {
181
181
  for (const el of block.querySelectorAll('*')) {
182
182
  if (el.childElementCount !== 0) continue
183
183
  if (pre !== null && pre.contains(el)) continue
184
- return el.textContent ?? ''
184
+ return el.textContent?.trim() ?? ''
185
185
  }
186
186
  return ''
187
187
  }
@@ -247,7 +247,10 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
247
247
  if (seen.has(el)) continue
248
248
  // Message-level containers that happen to carry a surface class must
249
249
  // not be taken over: hiding them hides the whole answer (issue #19).
250
- if (!isPlausibleFenceSurface(el)) continue
250
+ if (!isPlausibleFenceSurface(el)) {
251
+ if (infostringOf(el) === 'dsh-ui') warnImplausibleSurface(el)
252
+ continue
253
+ }
251
254
  out.push(el)
252
255
  seen.add(el)
253
256
  }
@@ -263,10 +266,8 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
263
266
  if (surface === null) {
264
267
  // Diagnose the issue #19 guard: a labeled ancestor that is NOT a code
265
268
  // surface (prose/multiple code bodies) was skipped on purpose.
266
- if (implausibleLabeledAncestorOf(pre, scope) !== null && !plausibilityWarned) {
267
- plausibilityWarned = true
268
- console.warn('[dsh-genui] 跳过带 dsh-ui 标签但疑似消息容器的节点(含段落或多个代码体)——防止 DOM 通道隐藏整条消息(issue #19)')
269
- }
269
+ const rejected = implausibleLabeledAncestorOf(pre, scope)
270
+ if (rejected !== null) warnImplausibleSurface(rejected)
270
271
  continue
271
272
  }
272
273
  if (seen.has(surface)) continue
@@ -286,8 +287,16 @@ function findFenceCandidates(scope: ParentNode = document): HTMLElement[] {
286
287
  /** One-time-per-install drift diagnostic flag (reset per install, so tests
287
288
  * and hot re-installs each get a fresh warning budget). */
288
289
  let driftWarned = false
289
- /** One-time-per-install issue #19 guard diagnostic (same budget). */
290
- let plausibilityWarned = false
290
+ /** A rejected surface gets one diagnostic; never log its conversation text. */
291
+ let plausibilityWarned = new WeakSet<Element>()
292
+ function warnImplausibleSurface(surface: Element): void {
293
+ if (plausibilityWarned.has(surface)) return
294
+ plausibilityWarned.add(surface)
295
+ const pres = surface.querySelectorAll('pre')
296
+ const tags = [...surface.querySelectorAll(BLOCK_CONTENT_SELECTOR)]
297
+ .filter(el => !el.closest('pre')).map(el => el.tagName.toLowerCase())
298
+ console.warn(`[dsh-genui] 跳过带 dsh-ui 标签但疑似消息容器的节点:pre=${pres.length}, outside-code=${[...new Set(tags)].join(',') || 'none'};保留原文,防止隐藏整条消息(issue #19)`)
299
+ }
291
300
 
292
301
  /** Root factory seam (tests / tuning): the DOM channel creates one React root
293
302
  * per taken-over fence through this indirection so mount-failure cleanup is
@@ -384,7 +393,7 @@ export function installDomFenceRenderer(
384
393
  ): () => void {
385
394
  if (typeof document === 'undefined') return () => {}
386
395
  driftWarned = false
387
- plausibilityWarned = false
396
+ plausibilityWarned = new WeakSet<Element>()
388
397
  const mounts = new Map<HTMLElement, Mount>()
389
398
  let disposed = false
390
399
  let rafId: number | null = null
@@ -0,0 +1,9 @@
1
+ /// <reference path="../css-modules.d.ts" />
2
+ /** Public bundler entry for hosts that embed GenUI without the DSH plugin runtime. */
3
+ export { GenuiBlock } from './GenuiBlock.tsx'
4
+ export { GenuiActionContext } from './action-context.ts'
5
+ export { ErrorBoundary } from './ErrorBoundary.tsx'
6
+ export { isRenderableProcess, processGenuiSpec } from './guard.ts'
7
+ export { setGenuiAssetBase } from './asset-loader.ts'
8
+ export type { GenuiSpec } from './spec.ts'
9
+ export type { BlockInteractionState } from './interaction-store.ts'
@@ -178,6 +178,12 @@ export const gallerySpec: GenuiSpec = {
178
178
  ] },
179
179
  { type: 'code', lang: 'json', code: '{"hello": "world"}' },
180
180
  { type: 'mermaid', code: 'graph TD\nA[模型] --> B[渲染器]\nB --> C[组件]' },
181
+ { type: 'diagram', kind: 'architecture', title: '架构图(配色跟随宿主令牌)', nodes: [
182
+ { id: 'm', label: '模型', type: 'external', x: 20, y: 40, w: 100, h: 44 },
183
+ { id: 'f', label: '围栏 JSON', type: 'focal', x: 160, y: 40, w: 112, h: 44 },
184
+ { id: 'g', label: 'guard', type: 'backend', x: 312, y: 40, w: 104, h: 44 },
185
+ { id: 's', label: '组件库', type: 'store', x: 456, y: 40, w: 100, h: 44 },
186
+ ], edges: [{ from: 'm', to: 'f' }, { from: 'f', to: 'g' }, { from: 'g', to: 's' }] },
181
187
  { type: 'scene3d', title: '几何演示', ambient: 1, meshes: [
182
188
  { shape: 'box', color: '#4f8ef7', position: [-1.4, 0, 0], rotation: [0.5, 0.8, 0] },
183
189
  { shape: 'sphere', color: '#3ecf8e', position: [0, 0, 0] },
@@ -1061,7 +1061,11 @@ function repairDiagram(v: unknown): GenuiDiagram | null {
1061
1061
  const zones = repairDiagramZones(o.zones)
1062
1062
  if (zones === undefined) return null
1063
1063
  return {
1064
- type: 'diagram', kind, nodes, edges, zones,
1064
+ // `zones` is optional: emitting `zones: []` made every diagram node differ
1065
+ // from its input for no reason (and broke the "gallery survives the guard
1066
+ // unchanged" contract).
1067
+ type: 'diagram', kind, nodes, edges,
1068
+ ...opt('zones', zones.length > 0 ? zones : undefined),
1065
1069
  ...opt('variant', enu(o.variant, DIAGRAM_VARIANTS)),
1066
1070
  ...opt('title', str(o.title, 256)),
1067
1071
  ...opt('theme', repairDiagramTheme(o.theme)),
@@ -1196,7 +1200,7 @@ function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeB
1196
1200
  const s = sanitizeEChartOption(v[i], depth + 1, budget)
1197
1201
  if (s !== undefined) arr.push(s)
1198
1202
  }
1199
- return arr.length > 0 ? arr : undefined
1203
+ return arr.length > 0 || v.length === 0 ? arr : undefined
1200
1204
  }
1201
1205
  const o = obj(v)
1202
1206
  if (o === undefined) return undefined
@@ -1212,7 +1216,9 @@ function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeB
1212
1216
  }
1213
1217
  out[key] = s
1214
1218
  }
1215
- return Object.keys(out).length > 0 ? out : undefined
1219
+ // Empty axes/tooltip enable ECharts defaults; an empty data array clears it.
1220
+ // Keep intentional empties, while still dropping objects stripped by guards.
1221
+ return Object.keys(out).length > 0 || Object.keys(o).length === 0 ? out : undefined
1216
1222
  }
1217
1223
 
1218
1224
  /**
@@ -1,45 +1,34 @@
1
- /**
2
- * Minimal inline markup for text-bearing fields.
3
- *
4
- * Prose-heavy answers need emphasis INSIDE a sentence: a command, a term, a key
5
- * phrase, a link. Until now every field was a plain string, so emphasis meant
6
- * splitting one sentence into several nodes and the paragraph read as broken.
7
- *
8
- * Supported (deliberately tiny, no nesting):
9
- * `code` inline code chip
10
- * **bold** emphasis that does not wrap or become a block
11
- * ==mark== highlight
12
- * [text](https://…) inline link (http/https/mailto only)
13
- *
14
- * Safety: this NEVER produces HTML. Each token becomes a React element, so the
15
- * host's HTML parser is never involved (the whole client builds elements, the
16
- * only innerHTML in the project is mermaid's sanitized SVG). An unterminated or
17
- * unknown marker is rendered literally rather than erroring, and a link whose
18
- * href fails {@link safeHref} degrades to its label text.
19
- * @module @changfenhuang/dsh-genui/client/inline
20
- */
21
- import { createElement, type ReactNode } from 'react'
1
+ /** Shared rich text for GenUI labels and content. Data values stay unchanged. */
2
+ import { createElement, useLayoutEffect, useRef, type ReactNode } from 'react'
3
+ import katex from 'katex'
22
4
  import css from './GenuiBlock.module.css'
23
5
  import { safeHref } from './genui-runtime/value-utils.ts'
24
6
 
25
- /** One pass, no nesting: each alternative is a self-contained token. */
26
- // Links match ANY target here and are validated by safeHref afterwards, so a
27
- // `javascript:` target degrades to its label text instead of showing as raw
28
- // markup the reader has to parse themselves.
29
- const INLINE = /`[^`\n]+`|\*\*[^*\n]+\*\*|==[^=\n]+==|\[[^\]\n]+\]\([^)\s]+\)/g
7
+ /** KaTeX owns this span's children; React owns the span and its lifecycle.
8
+ * The host's ui-primitives already supplies KaTeX CSS/fonts, including embeds. */
9
+ function InlineMath({ source, display }: { source: string; display: boolean }) {
10
+ const ref = useRef<HTMLSpanElement>(null)
11
+ useLayoutEffect(() => {
12
+ if (ref.current === null) return
13
+ katex.render(source, ref.current, {
14
+ displayMode: display, throwOnError: false, trust: false,
15
+ maxExpand: 1000, maxSize: 20, output: 'htmlAndMathml',
16
+ })
17
+ }, [source, display])
18
+ return createElement('span', { ref, className: css.inlineMath })
19
+ }
20
+
21
+ // Code is literal. TeX tokens are opaque to emphasis/link parsing; the other
22
+ // rich-text tokens recurse so **$x$** and ==\\(x\\)== work without nested DOM roots.
23
+ const INLINE = /`[^`\n]+`|\\\\|\\\$|(?<![\\$])\$\$(?:\\.|[^\\])*?\$\$|\\\[(?:\\(?!\])[^]|[^\\])*?\\\]|\\\((?:\\(?!\))[^]|[^\\])*?\\\)|(?<![\\$])\$(?!\s|\$)(?:\\.|[^$\\\n])+(?<!\s)\$(?!\d|\$)|\*\*[\s\S]+?\*\*|==[\s\S]+?==|\[[^\]\n]+\]\([^)\s]+\)/g
30
24
 
31
- /** True when the string contains anything the parser understands. */
32
25
  export function hasInlineMarkup(text: string): boolean {
33
- return typeof text === 'string' && /[`*=]|\[/.test(text)
26
+ return typeof text === 'string' && /[`*=$\\]|\[/.test(text)
34
27
  }
35
28
 
36
- /**
37
- * Render one string as React nodes with inline markup applied.
38
- * @param text - the raw field value.
39
- * @returns the original string when nothing matched, else the node list.
40
- */
41
- export function renderInline(text: string): ReactNode {
42
- if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text)) return text
29
+ /** Render safe phrasing content, usable in headings, buttons and labels too. */
30
+ export function renderInline(text: string, allowLinks = true, depth = 0): ReactNode {
31
+ if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text) || depth >= 8) return text
43
32
  const out: ReactNode[] = []
44
33
  let last = 0
45
34
  let key = 0
@@ -49,21 +38,24 @@ export function renderInline(text: string): ReactNode {
49
38
  if (index > last) out.push(text.slice(last, index))
50
39
  if (token.startsWith('`')) {
51
40
  out.push(createElement('code', { key: key++, className: css.inlineCode }, token.slice(1, -1)))
52
- } else if (token.startsWith('**')) {
53
- out.push(createElement('strong', { key: key++, className: css.inlineStrong }, token.slice(2, -2)))
54
- } else if (token.startsWith('==')) {
55
- out.push(createElement('mark', { key: key++, className: css.inlineMark }, token.slice(2, -2)))
41
+ } else if (token.startsWith('$') || token.startsWith('\\(') || token.startsWith('\\[')) {
42
+ const display = token.startsWith('$$') || token.startsWith('\\[')
43
+ const width = token.startsWith('$') && !display ? 1 : 2
44
+ out.push(createElement(InlineMath, { key: key++, source: token.slice(width, -width), display }))
45
+ } else if (token.startsWith('**') || token.startsWith('==')) {
46
+ const bold = token.startsWith('**')
47
+ out.push(createElement(bold ? 'strong' : 'mark', {
48
+ key: key++, className: bold ? css.inlineStrong : css.inlineMark,
49
+ }, renderInline(token.slice(2, -2), allowLinks, depth + 1)))
56
50
  } else {
57
51
  const parts = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/.exec(token)
58
52
  if (parts === null) {
59
53
  out.push(token)
60
54
  } else {
61
55
  const href = safeHref(parts[2])
62
- out.push(href === undefined
63
- ? parts[1]
64
- : createElement('a', {
65
- key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
66
- }, parts[1]))
56
+ out.push(href === undefined || !allowLinks ? renderInline(parts[1]!, false, depth + 1) : createElement('a', {
57
+ key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
58
+ }, renderInline(parts[1]!, false, depth + 1)))
67
59
  }
68
60
  }
69
61
  last = index + token.length
@@ -92,26 +92,27 @@ export const GENUI_SECTION_TEXT = `You can render interactive UI components INSI
92
92
  {"title":"可选标题","gap":14,"items":[...]}
93
93
  \`\`\`
94
94
 
95
- The spec is a white-listed component tree rendered inline where the fence sits. Only these \`type\` values; the \`genui\` skill, when available, carries the full content→component mapping and per-component field details:
95
+ Allowed \`type\` values; the \`genui\` skill, when available, carries the full content→component mapping and per-component field details:
96
96
 
97
97
  - 布局: text · row · col · grid · card · divider · spacer · hero(封面块:超大数字 + 标题 + tone 渐变底色,一条回答最多一个)
98
98
  - 展示: badge · stat · progress · list · table · keyvalue · avatar · audio · video · timeline · file-tree · breadcrumb · callout · steps · diff · json · code · copy
99
- - 图表: chart {"kind":"bars|line|donut","data":[{"label":"...","value":n}],"series":[{"label":"...","data":[...]}]?,"horizontal":true?,"stacked":true?}(series:bars 分组/堆叠 / line 多序列;horizontal 横向柱) · echart (preset: bar/line/area/pie/scatter/radar/gauge/funnel/treemap/sankey/graph/heatmap/bigline,或 option 直通) · plot (函数图)
99
+ - 图表: chart {"kind":"bars|line|donut","data":[{"label":"...","value":n}],"series":[{"label":"...","data":[...]}]?,"horizontal":true?,"stacked":true?}(series:bars 分组/堆叠 / line 多序列;horizontal 横向柱) · echart (preset 名或 option 直通,预设清单见 skill) · plot (函数图)
100
100
  - 交互: button · input · textarea · select · checkbox · switch · slider · radio · submit · quiz · link · tabs · accordion
101
- - 高级: mermaid (flowchart/sequence/class/gantt/pie/er/state/journey) · diagram (编辑级架构/流程图,27 种 kind) · scene3d (3D WebGL)
101
+ - 高级: mermaid (流程图/时序/类图/甘特/饼图/ER/状态/旅程,关键字见 skill) · diagram (架构/流程图) · scene3d (3D WebGL)
102
102
 
103
103
  **默认就该出 UI**:出现下列情况至少出一个围栏:
104
104
  - ≥3 条并列要点 → \`list\`;数字对比 → \`table\`;指标/进度/状态 → \`stat\`/\`progress\`/\`badge\`
105
105
  - 步骤/时间线 → \`steps\`/\`timeline\`/\`mermaid\`;架构/流程 → \`diagram\` 或 \`mermaid\`;风险/结论 → \`callout\`;代码/改动 → \`code\`/\`diff\`/\`json\`
106
- - 行内富文本:\`text\`/\`list\`/表格文本列/\`keyvalue\`/\`callout\` 里可写 \`code\`、**加粗**、==高亮==、[文字](url):重点留在句中,不必为一个词单起组件。
106
+ - 行内富文本:\`text\`/\`list\`/表格文本列/\`keyvalue\`/\`callout\` 里可写 $公式$、$$块公式$$、\`code\`、**加粗**、==高亮==、[文字](url):重点留在句中,不必为一个词单起组件。
107
107
  - 默认无卡 ≠ 少用组件:硬触发照常出组件,**组件多不是问题**——判据是每个组件承载不同信息、有焦点与层次、同一批数据不重复表达。卡片只用于并排项与数据对象;单段文字用「标题 + 正文 + 间距」。
108
108
 
109
- **发回答前最后自检一次**:这段内容里有没有 ≥3 条并列要点、任何对比、任何数字/指标、任何步骤或流程?有就先转成组件再开口。**状态汇报、进度说明、提交与改动清单同样算**——不要因为它是"说明文"就用纯文字写。这一条踩过的坑:连续几条汇报全靠文字,一条围栏都没发。
109
+ **发回答前最后自检一次**:这段内容里有没有 ≥3 条并列要点、任何对比、任何数字/指标、任何步骤或流程?有就先转成组件再开口。**状态汇报、进度说明、提交与改动清单同样算**。
110
110
  - 趋势/占比 → \`chart\`(≤8 点)或 \`echart\`(多序列/要交互时);配色默认跟随主题,只有语义需要时才用 \`palette\` / \`card.accent\`;排版用 grid 子节点的 \`"span":2\` 跨列做宽窄混排(bento),不要一列方块堆到底;数据多时给 \`table\`/\`chart\`/\`list\` 配一个 \`input\`(id) + \`filter\` 绑定,读者能就地筛选,不用再问一遍
111
111
 
112
- **字段速查**(完整见 genui skill):\`stat\` \`{"label","value","delta"?}\` · \`table\` \`{"columns","rows","types"?,"total"?,"details"?,"filter"?,"export"?}\` · \`callout\` \`{"tone","title","content"}\` · \`progress\` \`{"value","variant"?,"target"?}\`
112
+ **字段速查**(完整见 genui skill):\`stat\` \`{"label","value","delta"?}\` · \`table\` \`{"columns":[...],"rows":[[...]],"types"?,"total"?:true,"details"?:[[...]],"filter"?:id,"export"?:true}\` · \`callout\` \`{"tone":"info|success|warning|error","title","content"}\` · \`progress\` \`{"value":0-100,"label"?,"variant"?,"target"?}\` · \`keyvalue\` \`{"pairs":[{"key","value"}]}\` · \`steps\` \`{"steps":[{"title","desc"?}]}\`
113
113
 
114
114
  Rules:
115
+ - Match the user’s language in prose and UI text. Chinese examples are schema examples, not a language instruction. Keep JSON keys/type values unchanged.
115
116
  - JSON 严格: 坏围栏降级为代码块;≥3 节点或含 table 的围栏发出前调用 validate_dsh_ui,❌ 修好再发(若附「已自动修复」JSON 照抄即可)。
116
117
  - 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
117
118
  - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
@@ -129,6 +130,7 @@ Rules:
129
130
  // plugin — hosts without tool access keep the fence channel working. Cordis
130
131
  // inject entries are hard requirements, so the registry is probed at runtime
131
132
  // instead (see apply).
133
+ export const name = '@changfenhuang/dsh-genui'
132
134
  export const inject = ['systemPrompt']
133
135
 
134
136
  const BUNDLED_SKILL_RANK = 600
@@ -164,11 +164,11 @@ function formatProcessFailure(processed: GenuiProcessResult): string | undefined
164
164
  return chartErrors.length === 0 ? undefined : `❌ chart 字段验证失败:\n- ${chartErrors.join('\n- ')}`
165
165
  }
166
166
 
167
- /** Return the legacy validation text for a process that dropped native nodes. */
167
+ /** Report dropped components without hiding their actionable field errors. */
168
168
  function droppedNodeFailure(processed: GenuiProcessResult): string | undefined {
169
169
  if (!processed.errors.some(error => error.startsWith('repair dropped '))) return undefined
170
170
  const dropped = processed.declaredNativeCount - processed.renderedNativeCount
171
- return `❌ 验证未通过:检测到声明了 ${processed.declaredNativeCount} 个组件,但仅成功解析出 ${processed.renderedNativeCount} 个(有 ${dropped} 个组件因字段格式异常被丢弃)。常见原因:table 的 columns/rows 不是二维字符串数组、tabs 的 items/content 缺失、嵌套组件字段类型不符。请修正后重新验证。`
171
+ return `❌ 验证未通过:检测到声明了 ${processed.declaredNativeCount} 个组件,但仅成功解析出 ${processed.renderedNativeCount} 个(有 ${dropped} 个组件因字段格式异常被丢弃)。请修正后重新验证。\n- ${processed.errors.join('\n- ')}`
172
172
  }
173
173
 
174
174
  /** Tool-call title shared by the pending and completed presentations. */