@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.
- package/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/README.zh-CN.md +9 -1
- package/SKILL.md +4 -0
- package/lib/client.js +261 -5
- package/lib/index.js +14 -12
- package/lib/types/client/asset-loader.d.ts +2 -0
- package/lib/types/client/blocks/basic.d.ts +0 -5
- package/lib/types/client/blocks/diagram/theme.d.ts +3 -1
- package/lib/types/client/blocks/state.d.ts +4 -0
- package/lib/types/client/embed.d.ts +8 -0
- package/lib/types/client/inline.d.ts +3 -27
- package/lib/types/plugin/index.d.ts +2 -1
- package/package.json +63 -2
- package/src/client/EChartNode.tsx +3 -2
- package/src/client/GenuiBlock.module.css +25 -14
- package/src/client/GenuiBlock.tsx +24 -16
- package/src/client/PlotBlock.tsx +2 -1
- package/src/client/asset-loader.ts +7 -0
- package/src/client/blocks/advanced.tsx +15 -15
- package/src/client/blocks/basic.tsx +3 -2
- package/src/client/blocks/charts.tsx +18 -19
- package/src/client/blocks/checkbox.tsx +2 -1
- package/src/client/blocks/diagram/index.tsx +33 -24
- package/src/client/blocks/diagram/layout.ts +4 -1
- package/src/client/blocks/diagram/theme.ts +22 -3
- package/src/client/blocks/forms.tsx +15 -14
- package/src/client/blocks/image.tsx +2 -1
- package/src/client/blocks/render-node.tsx +18 -18
- package/src/client/blocks/state.ts +4 -0
- package/src/client/dom-fence.tsx +19 -10
- package/src/client/embed.ts +9 -0
- package/src/client/gallery.ts +6 -0
- package/src/client/guard.ts +9 -3
- package/src/client/inline.ts +36 -44
- package/src/plugin/index.ts +8 -6
- 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
|
-
: <><
|
|
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. */
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
/**
|
|
290
|
-
let plausibilityWarned =
|
|
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 =
|
|
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'
|
package/src/client/gallery.ts
CHANGED
|
@@ -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] },
|
package/src/client/guard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/client/inline.ts
CHANGED
|
@@ -1,45 +1,34 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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' && /[
|
|
26
|
+
return typeof text === 'string' && /[`*=$\\]|\[/.test(text)
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
out.push(createElement(
|
|
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
|
-
|
|
64
|
-
|
|
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
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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\` 里可写
|
|
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"
|
|
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
|
package/src/plugin/tool.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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}
|
|
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. */
|