@changfenhuang/dsh-genui 0.11.1 → 0.11.2

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 (76) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/SKILL.md +4 -2
  5. package/lib/assets/standalone-runtime.js +1505 -0
  6. package/lib/client.js +163 -25
  7. package/lib/index.js +222 -18
  8. package/lib/types/client/artifact/ExportableGenuiBlock.d.ts +6 -0
  9. package/lib/types/client/artifact/create.d.ts +5 -0
  10. package/lib/types/client/artifact/download.d.ts +9 -0
  11. package/lib/types/client/artifact/html.d.ts +5 -0
  12. package/lib/types/client/artifact/index.d.ts +8 -0
  13. package/lib/types/client/artifact/json.d.ts +3 -0
  14. package/lib/types/client/artifact/parse.d.ts +3 -0
  15. package/lib/types/client/artifact/portability.d.ts +4 -0
  16. package/lib/types/client/artifact/standalone-theme.d.ts +2 -0
  17. package/lib/types/client/artifact/types.d.ts +33 -0
  18. package/lib/types/client/blocks/state.d.ts +2 -0
  19. package/lib/types/client/echarts-engine.d.ts +4 -0
  20. package/lib/types/client/echarts-lazy.d.ts +1 -3
  21. package/lib/types/client/embed.d.ts +2 -0
  22. package/lib/types/client/i18n/en.d.ts +6 -0
  23. package/lib/types/client/primitive-adapter.d.ts +4 -0
  24. package/lib/types/client/primitive-labels.d.ts +1 -1
  25. package/lib/types/client/standalone/asset-loader.d.ts +2 -0
  26. package/lib/types/client/standalone/bootstrap-types.d.ts +9 -0
  27. package/lib/types/client/standalone/katex-style.d.ts +1 -0
  28. package/lib/types/client/standalone/primitive-adapter.d.ts +54 -0
  29. package/lib/types/client/standalone/runtime.d.ts +2 -0
  30. package/lib/types/plugin/fence-feedback.d.ts +4 -1
  31. package/lib/types/plugin/index.d.ts +1 -1
  32. package/lib/types/plugin/inline-content-diagnostic.d.ts +8 -0
  33. package/package.json +3 -2
  34. package/src/client/EChartNode.tsx +4 -8
  35. package/src/client/GenuiBlock.tsx +17 -13
  36. package/src/client/action-context.ts +1 -1
  37. package/src/client/artifact/ArtifactExport.module.css +64 -0
  38. package/src/client/artifact/ExportableGenuiBlock.tsx +100 -0
  39. package/src/client/artifact/create.ts +69 -0
  40. package/src/client/artifact/download.ts +42 -0
  41. package/src/client/artifact/html.ts +157 -0
  42. package/src/client/artifact/index.ts +15 -0
  43. package/src/client/artifact/json.ts +6 -0
  44. package/src/client/artifact/parse.ts +32 -0
  45. package/src/client/artifact/portability.ts +47 -0
  46. package/src/client/artifact/standalone-theme.ts +94 -0
  47. package/src/client/artifact/types.ts +44 -0
  48. package/src/client/asset-loader.ts +8 -0
  49. package/src/client/blocks/advanced.tsx +9 -1
  50. package/src/client/blocks/charts.tsx +1 -1
  51. package/src/client/blocks/forms.tsx +8 -5
  52. package/src/client/blocks/render-node.tsx +1 -1
  53. package/src/client/blocks/state.ts +2 -0
  54. package/src/client/echarts-engine.ts +9 -0
  55. package/src/client/echarts-lazy.ts +1 -4
  56. package/src/client/embed.ts +10 -0
  57. package/src/client/fence-render.tsx +5 -13
  58. package/src/client/genui-runtime/normalize.ts +1 -1
  59. package/src/client/guard.ts +2 -1
  60. package/src/client/i18n/en.ts +8 -0
  61. package/src/client/i18n/zh.ts +8 -0
  62. package/src/client/inline.ts +26 -1
  63. package/src/client/panel.tsx +2 -2
  64. package/src/client/primitive-adapter.ts +13 -0
  65. package/src/client/primitive-labels.ts +1 -1
  66. package/src/client/standalone/asset-loader.ts +9 -0
  67. package/src/client/standalone/bootstrap-types.ts +11 -0
  68. package/src/client/standalone/katex-style.ts +1 -0
  69. package/src/client/standalone/primitive-adapter.tsx +94 -0
  70. package/src/client/standalone/runtime.tsx +27 -0
  71. package/src/client/toolview.tsx +2 -2
  72. package/src/plugin/fence-feedback.ts +25 -9
  73. package/src/plugin/index.ts +4 -3
  74. package/src/plugin/inline-content-diagnostic.ts +178 -0
  75. package/src/plugin/tool.ts +18 -3
  76. package/tsdown.config.ts +82 -1
@@ -0,0 +1,94 @@
1
+ import { createContext, useState, type CSSProperties, type ReactNode } from 'react'
2
+ import { t } from '../i18n/index.ts'
3
+
4
+ export interface DiffBlockLabels {
5
+ copy: string
6
+ copied: string
7
+ codeLabel: string
8
+ wrapLabel: string
9
+ unwrapLabel: string
10
+ collapseAria: string
11
+ expandAria: (hidden: number) => string
12
+ collapse: string
13
+ expand: (hidden: number) => string
14
+ files: (count: number) => string
15
+ }
16
+
17
+ export interface JsonTreeLabels {
18
+ copyValue: string
19
+ copyJson: string
20
+ copyPath: string
21
+ copyPrettyJson: string
22
+ copyCompactJson: string
23
+ copied: string
24
+ copyFailed: string
25
+ collapseNode: string
26
+ expandNode: string
27
+ copyButtonTitle: (action: string) => string
28
+ }
29
+
30
+ const codeStyle: CSSProperties = { overflow: 'auto', padding: 12, borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2)', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12, lineHeight: 1.5, whiteSpace: 'pre-wrap' }
31
+
32
+ /** 在独立页面显示可复制的代码内容。 */
33
+ export function CodeBlock({ code, lang, copyLabel, copiedLabel }: { code: string; lang?: string; copyLabel?: string; copiedLabel?: string }): ReactNode {
34
+ const [copied, setCopied] = useState(false)
35
+ const copy = async (): Promise<void> => {
36
+ if (!await writeClipboard(code)) return
37
+ setCopied(true)
38
+ window.setTimeout(() => setCopied(false), 1200)
39
+ }
40
+ return <div><div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 11, color: 'var(--dsw-alias-label-secondary)' }}><span>{lang ?? t('label.code')}</span><button type="button" onClick={() => void copy()}>{copied ? copiedLabel ?? t('label.copied') : copyLabel ?? t('label.copy')}</button></div><pre style={codeStyle}><code>{code}</code></pre></div>
41
+ }
42
+
43
+ /** 在独立页面以差异文本显示文件变更。 */
44
+ export function DiffBlock({ diffs }: { diffs: Array<{ path: string; oldText?: string; newText?: string }>; labels?: DiffBlockLabels }): ReactNode {
45
+ return <div>{diffs.map((diff, index) => <section key={`${diff.path}:${index}`}><strong>{diff.path}</strong><pre style={codeStyle}>{(diff.oldText ?? '').split('\n').map(line => `- ${line}`).concat((diff.newText ?? '').split('\n').map(line => `+ ${line}`)).join('\n')}</pre></section>)}</div>
46
+ }
47
+
48
+ /** 在独立页面显示 JSON,并提供本地折叠控制。 */
49
+ export function JsonTree({ data, label, copyable, labels }: { data: object | unknown[]; label?: string; copyable?: boolean; labels?: JsonTreeLabels }): ReactNode {
50
+ const [expanded, setExpanded] = useState(true)
51
+ const [copied, setCopied] = useState(false)
52
+ const json = JSON.stringify(data, null, 2)
53
+ /** 复制当前 JSON 内容并更新按钮反馈。 */
54
+ const copy = async (): Promise<void> => {
55
+ if (!await writeClipboard(json)) return
56
+ setCopied(true)
57
+ window.setTimeout(() => setCopied(false), 1200)
58
+ }
59
+ return <section><div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><button type="button" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{expanded ? t('label.collapse') : t('label.expandNode')} {label ?? 'JSON'}</button>{copyable && <button type="button" onClick={() => void copy()}>{copied ? labels?.copied ?? t('label.copied') : labels?.copyJson ?? t('label.copy')}</button>}</div>{expanded && <pre style={codeStyle}>{json}</pre>}</section>
60
+ }
61
+
62
+ /** 将文本复制到系统剪贴板。 */
63
+ export async function writeClipboard(text: string): Promise<boolean> {
64
+ if (typeof navigator.clipboard?.writeText === 'function') {
65
+ try {
66
+ await navigator.clipboard.writeText(text)
67
+ return true
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+ const textarea = document.createElement('textarea')
73
+ textarea.value = text
74
+ textarea.setAttribute('readonly', '')
75
+ textarea.style.position = 'fixed'
76
+ textarea.style.left = '-9999px'
77
+ document.body.appendChild(textarea)
78
+ try {
79
+ textarea.select()
80
+ return document.execCommand('copy')
81
+ } catch {
82
+ return false
83
+ } finally {
84
+ textarea.remove()
85
+ }
86
+ }
87
+
88
+ /** standalone 模式不连接 DSH 注册表,因此自定义组件始终不可用。 */
89
+ export function getGenuiComponent(_type: string): undefined {
90
+ return undefined
91
+ }
92
+
93
+ /** standalone 模式专用的空动作上下文。 */
94
+ export const GenuiActionContext = createContext<((action: string, payload: Record<string, unknown>) => void) | undefined>(undefined)
@@ -0,0 +1,27 @@
1
+ import { createRoot } from 'react-dom/client'
2
+ import { GenuiBlock } from '../GenuiBlock.tsx'
3
+ import { GenuiActionContext } from '../action-context.ts'
4
+ import { ErrorBoundary } from '../ErrorBoundary.tsx'
5
+ import { parseGenuiArtifact } from '../artifact/parse.ts'
6
+ import { setLocale } from '../i18n/runtime.ts'
7
+ import './katex-style.ts'
8
+ import './bootstrap-types.ts'
9
+
10
+ /** 挂载独立 artifact,并返回卸载该界面的函数。 */
11
+ function mount(root: HTMLElement, rawArtifact: unknown): () => void {
12
+ const artifact = parseGenuiArtifact(rawArtifact)
13
+ if (artifact === null) throw new Error('invalid GenUI artifact')
14
+ setLocale(artifact.presentation.locale)
15
+ document.body.toggleAttribute('data-ds-dark-theme', artifact.presentation.theme === 'dark')
16
+ const reactRoot = createRoot(root)
17
+ reactRoot.render(
18
+ <GenuiActionContext.Provider value={undefined}>
19
+ <ErrorBoundary label="GenUI">
20
+ <GenuiBlock spec={artifact.spec} initialState={artifact.state} animateEntrance={false} />
21
+ </ErrorBoundary>
22
+ </GenuiActionContext.Provider>,
23
+ )
24
+ return () => reactRoot.unmount()
25
+ }
26
+
27
+ window.__GenuiStandalone__ = { mount }
@@ -14,7 +14,7 @@ import { useEffect, useMemo } from 'react'
14
14
  import type {} from '@deepseek-ai/dsh-client-ui-session/client'
15
15
  import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
16
16
  import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
17
- import { GenuiBlock } from './GenuiBlock.tsx'
17
+ import { ExportableGenuiBlock } from './artifact/ExportableGenuiBlock.tsx'
18
18
  import { ErrorBoundary } from './ErrorBoundary.tsx'
19
19
  import { repairGenuiSpec } from './guard.ts'
20
20
  import { toolStateKey } from './interaction-store.ts'
@@ -60,7 +60,7 @@ export function GenuiToolView({ toolName, block, sessionId }: ToolCallViewProps)
60
60
  <div className={css.tool} data-genui-tool>
61
61
  <ErrorBoundary label="工具卡片">
62
62
  {/* callId is stable across replay → tool-card interaction state is durable */}
63
- <GenuiBlock spec={spec} stateKey={toolStateKey(sessionId, block.callId)} />
63
+ <ExportableGenuiBlock spec={spec} stateKey={toolStateKey(sessionId, block.callId)} />
64
64
  </ErrorBoundary>
65
65
  </div>
66
66
  )
@@ -33,6 +33,8 @@ import { resolveFence } from '../shared/fence-resolve.ts'
33
33
 
34
34
  /** Plugin name recorded on every message this loop steers. */
35
35
  export const FEEDBACK_PLUGIN_NAME = '@changfenhuang/dsh-genui'
36
+ /** Source kind persisted by this plugin in Session format v4. */
37
+ export const FEEDBACK_SOURCE_KIND = `plugin:${FEEDBACK_PLUGIN_NAME}` as const
36
38
 
37
39
  /** Marker prefix inside the correction text: `[genui-fence-repair #<fingerprint>]`. */
38
40
  const MARKER_PREFIX = '[genui-fence-repair #'
@@ -147,19 +149,27 @@ export function fenceCorrectionText(failures: readonly FenceFailure[]): string {
147
149
  * copy resolves identically on every host.
148
150
  *
149
151
  * @param text - the correction text.
152
+ * @param sessionFormatVersion - the format recorded by the active session.
150
153
  * @returns a frozen user message attributed to this plugin as a notice.
151
154
  */
152
- export function createFeedbackMessage(text: string): UserMessage {
155
+ export function createFeedbackMessage(text: string, sessionFormatVersion: number): UserMessage {
156
+ const source = sessionFormatVersion >= 4
157
+ ? {
158
+ kind: FEEDBACK_SOURCE_KIND,
159
+ form: 'notice' as const,
160
+ summary: 'genui fence repair requested',
161
+ }
162
+ : {
163
+ kind: 'plugin' as const,
164
+ plugin: FEEDBACK_PLUGIN_NAME,
165
+ form: 'notice' as const,
166
+ summary: 'genui fence repair requested',
167
+ }
153
168
  const message = {
154
169
  id: randomUUID(),
155
170
  role: 'user',
156
171
  content: [{ type: 'text', text }],
157
- source: {
158
- kind: 'plugin',
159
- plugin: FEEDBACK_PLUGIN_NAME,
160
- form: 'notice',
161
- summary: 'genui fence repair requested',
162
- },
172
+ source,
163
173
  }
164
174
  Object.freeze(message.content)
165
175
  return Object.freeze(message) as unknown as UserMessage
@@ -244,6 +254,12 @@ function markersIn(text: string): string[] {
244
254
  return out
245
255
  }
246
256
 
257
+ /** Identify this plugin's source across current and migrated session shapes. */
258
+ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefined): boolean {
259
+ return source?.kind === FEEDBACK_SOURCE_KIND
260
+ || (source?.kind === 'plugin' && source.plugin === FEEDBACK_PLUGIN_NAME)
261
+ }
262
+
247
263
  /**
248
264
  * 根据插件配置启用围栏反馈流程。
249
265
  *
@@ -280,7 +296,7 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
280
296
  }
281
297
  if (event.type !== 'user/message') return
282
298
  const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
283
- if (data.source?.kind === 'plugin' && data.source.plugin === FEEDBACK_PLUGIN_NAME) {
299
+ if (isFeedbackSource(data.source)) {
284
300
  // Our own correction (re-observed after a plugin reload): adopt its
285
301
  // fingerprints so a second boundary cannot repeat it.
286
302
  const fingerprints = markersIn(textOfContent(data.content))
@@ -312,7 +328,7 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
312
328
  for (const fingerprint of plan.fingerprints) state.corrected.add(fingerprint)
313
329
  state.lastCorrectedTurn = plan.turn
314
330
  try {
315
- agent.steer(createFeedbackMessage(plan.text))
331
+ agent.steer(createFeedbackMessage(plan.text, agent.session.header.version))
316
332
  } catch (error) {
317
333
  ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`)
318
334
  }
@@ -104,8 +104,8 @@ Allowed \`type\` values; the \`genui\` skill, when available, carries the full c
104
104
  **默认就该出 UI**:出现下列情况至少出一个围栏:
105
105
  - ≥3 条并列要点 → \`list\`;数字对比 → \`table\`;指标/进度/状态 → \`stat\`/\`progress\`/\`badge\`
106
106
  - 步骤/时间线 → \`steps\`/\`timeline\`/\`mermaid\`;架构/流程 → \`diagram\` 或 \`mermaid\`;风险/结论 → \`callout\`;代码/改动 → \`code\`/\`diff\`/\`json\`
107
- - 行内富文本:\`text\`/\`list\`/表格文本列/\`keyvalue\`/\`callout\` 里可写 $公式$、$$块公式$$、\`code\`、**加粗**、==高亮==、[文字](url)。
108
- - 默认无卡 ≠ 少用组件:硬触发照常出组件,**组件多不是问题**——判据是每个组件承载不同信息、有焦点与层次。卡片只用于并排项与数据对象;单段文字用「标题 + 正文 + 间距」。
107
+ - 行内富文本:支持公式、\`code\`、加粗、高亮、链接;禁用 Markdown table / fenced code,改用 table / code / diff / json。
108
+ - 默认无卡:按触发条件使用组件,每个组件承载不同信息;卡片只用于并排项与数据对象,单段文字用标题、正文与间距。
109
109
 
110
110
  **发回答前最后自检一次**:这段内容里有没有 ≥3 条并列要点、任何对比、任何数字/指标、任何步骤或流程?有就先转成组件再开口。**状态汇报、进度说明、提交与改动清单同样算**。
111
111
  - 趋势/占比 → \`chart\`(≤8 点)或 \`echart\`(多序列/要交互时);配色默认跟随主题,只有语义需要时才用 \`palette\`/\`card.accent\`;grid 子节点用 \`"span":2\` 跨列做宽窄混排;数据多时给 \`table\`/\`chart\`/\`list\` 配一个 \`input\`(id) + \`filter\` 绑定,读者能就地筛选。
@@ -116,7 +116,8 @@ Allowed \`type\` values; the \`genui\` skill, when available, carries the full c
116
116
 
117
117
  Rules:
118
118
  - LANGUAGE: reply+UI=conversation language; schema fixed. NEVER infer it from prompt/skill/examples/tools. Replace \`<user-language ...>\`; never emit these placeholders literally.
119
- - JSON 严格: 坏组件被丢弃、坏围栏降级为代码块;≥3 节点或含 table 的围栏发出前必须调 validate_dsh_ui,按结构化诊断修好再发;小围栏字段没把握也必须先验证。
119
+ - JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
120
+ - warning=block_markdown:按 replacement 改写并重验。
120
121
  - 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
121
122
  - LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
122
123
  - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
@@ -0,0 +1,178 @@
1
+ import type { GenuiNode, GenuiSpec } from '../client/spec.ts'
2
+
3
+ export interface InlineContentWarning {
4
+ path: string
5
+ kind: 'fenced_code' | 'markdown_table'
6
+ replacement: 'code' | 'table'
7
+ }
8
+
9
+ /** 识别文字字段中的连续代码围栏标记。 */
10
+ function hasFencedCode(value: string): boolean {
11
+ return /`{3,}|~{3,}/.test(value)
12
+ }
13
+
14
+ /** 判断相邻表头下方的 Markdown 表格分隔行。 */
15
+ function isMarkdownTableSeparator(line: string): boolean {
16
+ let value = line.trim()
17
+ if (value.startsWith('|')) value = value.slice(1)
18
+ if (value.endsWith('|')) value = value.slice(0, -1)
19
+ const cells = value.split('|').map(cell => cell.trim())
20
+ return cells.length >= 2 && cells.every(cell => /^:?-{2,}:?$/.test(cell))
21
+ }
22
+
23
+ /** 仅在相邻表头和分隔行构成表格时报告。 */
24
+ function hasMarkdownTable(value: string): boolean {
25
+ const lines = value.split(/\r?\n/)
26
+ for (let i = 0; i + 1 < lines.length; i++) {
27
+ if (lines[i]!.includes('|') && isMarkdownTableSeparator(lines[i + 1]!)) return true
28
+ }
29
+ return false
30
+ }
31
+
32
+ /** 按组件结构检查需要块级内容诊断的 canonical 显示字段。 */
33
+ export function collectInlineContentWarnings(spec: GenuiSpec): InlineContentWarning[] {
34
+ const warnings: InlineContentWarning[] = []
35
+
36
+ /** 将一个可见文字字段中的块级 Markdown 记录为稳定诊断。 */
37
+ function check(value: string | undefined, path: string): void {
38
+ if (value === undefined) return
39
+ if (hasFencedCode(value)) warnings.push({ path, kind: 'fenced_code', replacement: 'code' })
40
+ if (hasMarkdownTable(value)) warnings.push({ path, kind: 'markdown_table', replacement: 'table' })
41
+ }
42
+
43
+ /** 只沿 GenUI 组件的子节点字段访问下一层。 */
44
+ function visit(node: GenuiNode, path: string): void {
45
+ switch (node.type) {
46
+ case 'text': check(node.content, `${path}.content`); break
47
+ case 'card':
48
+ check(node.title, `${path}.title`)
49
+ node.items.forEach((child, i) => visit(child, `${path}.items[${i}]`))
50
+ break
51
+ case 'row':
52
+ case 'col':
53
+ case 'grid':
54
+ node.items.forEach((child, i) => visit(child, `${path}.items[${i}]`))
55
+ break
56
+ case 'button':
57
+ case 'checkbox':
58
+ case 'link':
59
+ case 'badge':
60
+ case 'switch':
61
+ case 'submit':
62
+ check(node.label, `${path}.label`)
63
+ break
64
+ case 'input':
65
+ case 'select':
66
+ case 'slider':
67
+ case 'textarea':
68
+ check(node.label, `${path}.label`)
69
+ break
70
+ case 'radio':
71
+ check(node.label, `${path}.label`)
72
+ node.options.forEach((option, i) => check(option, `${path}.options[${i}]`))
73
+ if (typeof node.answer === 'string') check(node.answer, `${path}.answer`)
74
+ check(node.explanation, `${path}.explanation`)
75
+ break
76
+ case 'image':
77
+ case 'audio':
78
+ case 'video':
79
+ check(node.alt, `${path}.alt`)
80
+ break
81
+ case 'hero':
82
+ for (const field of ['label', 'value', 'delta', 'title', 'subtitle'] as const) check(node[field], `${path}.${field}`)
83
+ break
84
+ case 'stat':
85
+ for (const field of ['label', 'value', 'delta'] as const) check(node[field], `${path}.${field}`)
86
+ break
87
+ case 'progress':
88
+ check(node.label, `${path}.label`)
89
+ check(node.valueLabel, `${path}.valueLabel`)
90
+ break
91
+ case 'list':
92
+ node.items.forEach((item, i) => {
93
+ const itemPath = `${path}.items[${i}]`
94
+ if (typeof item === 'string') check(item, itemPath)
95
+ else if ('type' in item && typeof item.type === 'string') visit(item as GenuiNode, itemPath)
96
+ else {
97
+ check(item.title, `${itemPath}.title`)
98
+ check(item.desc, `${itemPath}.desc`)
99
+ }
100
+ })
101
+ break
102
+ case 'table':
103
+ node.columns.forEach((column, i) => check(column, `${path}.columns[${i}]`))
104
+ node.rows.forEach((row, i) => {
105
+ const detail = node.details?.[i] ?? null
106
+ row.forEach((cell, j) => {
107
+ if (node.types?.[j] === 'index' && (j !== 0 || detail === null)) return
108
+ if (typeof cell === 'string') check(cell, `${path}.rows[${i}][${j}]`)
109
+ })
110
+ })
111
+ node.details?.forEach((detail, i) => detail?.forEach((child, j) => visit(child, `${path}.details[${i}][${j}]`)))
112
+ break
113
+ case 'chart':
114
+ node.series?.forEach((series, i) => check(series.label, `${path}.series[${i}].label`))
115
+ break
116
+ case 'tabs':
117
+ node.tabs.forEach((tab, i) => {
118
+ check(tab.label, `${path}.tabs[${i}].label`)
119
+ tab.items.forEach((child, j) => visit(child, `${path}.tabs[${i}].items[${j}]`))
120
+ })
121
+ break
122
+ case 'accordion':
123
+ node.items.forEach((item, i) => {
124
+ check(item.title, `${path}.items[${i}].title`)
125
+ item.items.forEach((child, j) => visit(child, `${path}.items[${i}].items[${j}]`))
126
+ })
127
+ break
128
+ case 'callout':
129
+ check(node.title, `${path}.title`)
130
+ check(node.content, `${path}.content`)
131
+ break
132
+ case 'steps':
133
+ node.steps.forEach((step, i) => {
134
+ check(step.title, `${path}.steps[${i}].title`)
135
+ check(step.desc, `${path}.steps[${i}].desc`)
136
+ })
137
+ break
138
+ case 'keyvalue':
139
+ node.pairs.forEach((pair, i) => {
140
+ check(pair.key, `${path}.pairs[${i}].key`)
141
+ check(pair.value, `${path}.pairs[${i}].value`)
142
+ })
143
+ break
144
+ case 'timeline':
145
+ node.items.forEach((item, i) => {
146
+ check(item.title, `${path}.items[${i}].title`)
147
+ check(item.time, `${path}.items[${i}].time`)
148
+ check(item.desc, `${path}.items[${i}].desc`)
149
+ })
150
+ break
151
+ case 'breadcrumb':
152
+ node.items.forEach((item, i) => check(item, `${path}.items[${i}]`))
153
+ break
154
+ case 'quiz':
155
+ check(node.question, `${path}.question`)
156
+ node.options.forEach((option, i) => {
157
+ check(option.label, `${path}.options[${i}].label`)
158
+ check(option.feedback, `${path}.options[${i}].feedback`)
159
+ })
160
+ check(node.explanation, `${path}.explanation`)
161
+ break
162
+ case 'plot':
163
+ case 'echart':
164
+ case 'diagram':
165
+ case 'scene3d':
166
+ case 'svg':
167
+ check(node.title, `${path}.title`)
168
+ break
169
+ case 'copy':
170
+ check(node.label, `${path}.label`)
171
+ break
172
+ }
173
+ }
174
+
175
+ check(spec.title, 'title')
176
+ spec.items.forEach((node, i) => visit(node, `items[${i}]`))
177
+ return warnings
178
+ }
@@ -27,6 +27,8 @@ import {
27
27
  import type { GenuiProcessResult } from '../client/guard.ts'
28
28
  import { completeFenceJson } from '../shared/fence-repair.ts'
29
29
  import { droppedNodeFailure } from './genui-diagnostic.ts'
30
+ import { collectInlineContentWarnings } from './inline-content-diagnostic.ts'
31
+ import type { GenuiSpec } from '../client/spec.ts'
30
32
 
31
33
  /**
32
34
  * Arguments schema: an open `spec` slot. The schema must NOT reject anything
@@ -164,6 +166,12 @@ function formatProcessWarnings(processed: GenuiProcessResult): string[] {
164
166
  })
165
167
  }
166
168
 
169
+ /** 将行内字段的块级 Markdown 诊断写入稳定的验证协议。 */
170
+ function formatInlineContentWarnings(spec: GenuiSpec): string[] {
171
+ return collectInlineContentWarnings(spec).map(warning =>
172
+ `warning=block_markdown path=${warning.path} kind=${warning.kind} replacement=${warning.replacement}`)
173
+ }
174
+
167
175
  /** Format chart-specific process errors while keeping other schema errors generic. */
168
176
  function formatProcessFailure(processed: GenuiProcessResult): string | undefined {
169
177
  const chartErrors = processed.errors.filter(error => /(?:variant is unsupported|kind must be bars, line, or donut|requires data or series|(?:data|series) is required for|(?:\.data|\.series)(?:\[\d+\])?(?:\.(?:data|label|value|color))? must|series is only supported for bars)/.test(error))
@@ -251,7 +259,8 @@ const VALIDATE_DESCRIPTION =
251
259
  'Validate the JSON body of a ```dsh-ui fence BEFORE emitting it — use for non-trivial specs (≥3 nodes or containing a table); skip for trivial ones (≤2 nodes). '
252
260
  + 'Pass the exact JSON text you are about to put inside the fence as the "spec" argument (a string). '
253
261
  + 'Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. '
254
- + 'When invalid JSON is repairable, next=emit_repaired_fence and repaired_json contain the exact fence body to emit.'
262
+ + 'When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. '
263
+ + 'A warning=block_markdown requires the indicated replacement node and another validation call.'
255
264
 
256
265
  const VALIDATE_PARAMETERS: Record<string, unknown> = {
257
266
  type: 'object',
@@ -358,15 +367,17 @@ export function createValidateDshUiTool(): ToolDefinition {
358
367
  if (chartFailure !== undefined) return chartFailure
359
368
  if (processed.spec !== null && processed.errors.length === 0) {
360
369
  const warnings = formatProcessWarnings(processed)
370
+ const inlineWarnings = formatInlineContentWarnings(processed.spec)
361
371
  return `${validationProtocol([
362
372
  'status=invalid',
363
373
  'error=invalid_json',
364
374
  `detail=${JSON.stringify(detail)}`,
365
375
  ...bracketDiagnostic(raw),
366
376
  ...warnings,
377
+ ...inlineWarnings,
367
378
  'repair=applied',
368
379
  `repair_count=${repaired.repairs}`,
369
- 'next=emit_repaired_fence',
380
+ inlineWarnings.length === 0 ? 'next=emit_repaired_fence' : 'next=fix_and_revalidate',
370
381
  ])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``
371
382
  }
372
383
  }
@@ -397,7 +408,11 @@ export function createValidateDshUiTool(): ToolDefinition {
397
408
  : validationProtocol(['status=invalid', ...dropped, 'next=fix_and_revalidate'])
398
409
  }
399
410
  const warnings = formatProcessWarnings(processed)
400
- return validationProtocol(['status=valid', `rendered=${processed.renderedCount}`, ...warnings, 'next=emit_fence'])
411
+ const inlineWarnings = formatInlineContentWarnings(processed.spec)
412
+ return validationProtocol([
413
+ 'status=valid', `rendered=${processed.renderedCount}`, ...warnings, ...inlineWarnings,
414
+ inlineWarnings.length === 0 ? 'next=emit_fence' : 'next=fix_and_revalidate',
415
+ ])
401
416
  },
402
417
  presentCall(): GenericCallView | undefined {
403
418
  return { card: 'generic', title: '验证 dsh-ui 围栏', kind: 'other' }
package/tsdown.config.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * builds byte-identical client.js every time.
11
11
  */
12
12
  import { readFile } from 'node:fs/promises'
13
- import { basename, dirname, relative, resolve as resolvePath } from 'node:path'
13
+ import { basename, dirname, extname, relative, resolve as resolvePath } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import type { UserConfig } from 'tsdown'
16
16
  import { transform } from 'lightningcss'
@@ -90,6 +90,66 @@ function purityGate(): NonNullable<UserConfig['plugins']>[number] {
90
90
  }
91
91
  }
92
92
 
93
+ function standaloneAliasPlugin(): NonNullable<UserConfig['plugins']>[number] {
94
+ const adapterPath = resolvePath(PROJECT_ROOT, 'src/client/primitive-adapter.ts')
95
+ const standaloneAdapterPath = resolvePath(PROJECT_ROOT, 'src/client/standalone/primitive-adapter.tsx')
96
+ const assetLoaderPath = resolvePath(PROJECT_ROOT, 'src/client/asset-loader.ts')
97
+ const standaloneAssetLoaderPath = resolvePath(PROJECT_ROOT, 'src/client/standalone/asset-loader.ts')
98
+ return {
99
+ name: 'dsh-genui-standalone-primitives',
100
+ resolveId(source: string, importer: string | undefined) {
101
+ if (importer === undefined) return null
102
+ const resolved = resolvePath(dirname(importer), source)
103
+ if (resolved === adapterPath) return standaloneAdapterPath
104
+ return resolved === assetLoaderPath ? standaloneAssetLoaderPath : null
105
+ },
106
+ }
107
+ }
108
+
109
+ function standaloneKatexCssPlugin(): NonNullable<UserConfig['plugins']>[number] {
110
+ const sourcePath = resolvePath(PROJECT_ROOT, 'src/client/standalone/katex-style.ts')
111
+ const virtualId = '\0dsh-genui-standalone-katex'
112
+ const cssPath = resolvePath(PROJECT_ROOT, 'node_modules/katex/dist/katex.min.css')
113
+ return {
114
+ name: 'dsh-genui-standalone-katex',
115
+ resolveId(source: string, importer: string | undefined) {
116
+ if (importer !== undefined && resolvePath(dirname(importer), source) === sourcePath) return virtualId
117
+ return null
118
+ },
119
+ async load(id: string) {
120
+ if (id !== virtualId) return null
121
+ const transformed = transform({
122
+ filename: cssPath,
123
+ code: await readFile(cssPath),
124
+ analyzeDependencies: true,
125
+ visitor: {
126
+ Rule: {
127
+ /** 独立 HTML 面向支持 WOFF2 的浏览器,每种字体只需内嵌一份。 */
128
+ 'font-face'(rule) {
129
+ const source = rule.value.properties.find(property => property.type === 'source')
130
+ if (source === undefined) throw new Error('KaTeX font face has no source')
131
+ const woff2 = source.value.filter(entry => entry.type === 'url' && entry.value.format?.type === 'woff2')
132
+ if (woff2.length !== 1) throw new Error('KaTeX font face must have exactly one WOFF2 source')
133
+ source.value = woff2
134
+ return rule
135
+ },
136
+ },
137
+ },
138
+ })
139
+ let css = transformed.code.toString()
140
+ for (const dependency of transformed.dependencies ?? []) {
141
+ if (dependency.type !== 'url' || dependency.url.startsWith('data:') || dependency.url.startsWith('#')) continue
142
+ const fontPath = resolvePath(dirname(cssPath), dependency.url)
143
+ const extension = extname(new URL(dependency.url, 'https://genui.invalid/').pathname)
144
+ if (extension !== '.woff2') throw new Error(`unsupported KaTeX asset: ${dependency.url}`)
145
+ const font = await readFile(fontPath)
146
+ css = css.replaceAll(dependency.placeholder, `data:font/woff2;base64,${font.toString('base64')}`)
147
+ }
148
+ return `const style = document.createElement('style'); style.dataset.genuiStandaloneKatex = ''; style.textContent = ${JSON.stringify(css)}; document.head.appendChild(style);`
149
+ },
150
+ }
151
+ }
152
+
93
153
  const clientConfig: UserConfig = {
94
154
  name: `${ID}/client`,
95
155
  entry: { client: 'src/client/index.tsx' },
@@ -127,6 +187,26 @@ const clientConfig: UserConfig = {
127
187
  },
128
188
  }
129
189
 
190
+ const standaloneConfig: UserConfig = {
191
+ name: `${ID}/standalone`,
192
+ entry: { 'assets/standalone-runtime': 'src/client/standalone/runtime.tsx' },
193
+ outDir: 'lib',
194
+ format: 'iife',
195
+ platform: 'browser',
196
+ dts: false,
197
+ minify: true,
198
+ sourcemap: false,
199
+ clean: false,
200
+ deps: { alwaysBundle: () => true },
201
+ define: {
202
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
203
+ 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
204
+ 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
205
+ },
206
+ plugins: [standaloneAliasPlugin(), cssModulesPlugin(), standaloneKatexCssPlugin()],
207
+ outputOptions: { entryFileNames: '[name].js', codeSplitting: false },
208
+ }
209
+
130
210
  /**
131
211
  * Lazy engine assets: mermaid and three ship as standalone single-entry
132
212
  * IIFEs that register themselves on `window.__GenuiAssets__` (no
@@ -187,6 +267,7 @@ const libConfig: UserConfig = {
187
267
  export default [
188
268
  libConfig,
189
269
  clientConfig,
270
+ standaloneConfig,
190
271
  assetConfig('mermaid', 'src/client/asset-mermaid.ts'),
191
272
  assetConfig('three', 'src/client/asset-three.ts'),
192
273
  assetConfig('echarts-core', 'src/client/asset-echarts-core.ts'),