@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,32 @@
1
+ import { processGenuiSpec } from '../guard.ts'
2
+ import type { BlockInteractionState } from '../interaction-store.ts'
3
+ import { GENUI_ARTIFACT_FORMAT, GENUI_ARTIFACT_VERSION, type GenuiArtifactTheme, type GenuiArtifactV1 } from './types.ts'
4
+ import { createGenuiArtifact } from './create.ts'
5
+
6
+ /** 判断值是否为普通记录对象。 */
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
9
+ }
10
+
11
+ /** 验证 artifact 持久化交互状态的字段类型。 */
12
+ function validState(value: unknown): boolean {
13
+ if (!isRecord(value)) return false
14
+ if (value.answers !== undefined && (!isRecord(value.answers) || !Object.values(value.answers).every(item => typeof item === 'string'))) return false
15
+ if (value.multiAnswers !== undefined && (!isRecord(value.multiAnswers) || !Object.values(value.multiAnswers).every(item => Array.isArray(item) && item.every(entry => typeof entry === 'string')))) return false
16
+ if (value.locked !== undefined && typeof value.locked !== 'boolean') return false
17
+ return value.fields === undefined || (isRecord(value.fields) && Object.values(value.fields).every(item => typeof item === 'string'))
18
+ }
19
+
20
+ /** 校验 artifact 格式并重新执行当前 GenUI 规范化流程。 */
21
+ export function parseGenuiArtifact(value: unknown): GenuiArtifactV1 | null {
22
+ if (!isRecord(value) || value.format !== GENUI_ARTIFACT_FORMAT || value.version !== GENUI_ARTIFACT_VERSION || !isRecord(value.presentation)) return null
23
+ if (value.presentation.locale !== 'en' && value.presentation.locale !== 'zh') return null
24
+ if (value.presentation.theme !== 'light' && value.presentation.theme !== 'dark') return null
25
+ if (value.state !== undefined && !validState(value.state)) return null
26
+ const processed = processGenuiSpec(value.spec)
27
+ if (processed.spec === null || processed.errors.length > 0) return null
28
+ return createGenuiArtifact(processed.spec, value.state as BlockInteractionState | undefined, {
29
+ locale: value.presentation.locale,
30
+ theme: value.presentation.theme as GenuiArtifactTheme,
31
+ })
32
+ }
@@ -0,0 +1,47 @@
1
+ import { echartEngineFor } from '../echarts-engine.ts'
2
+ import type { GenuiNode, GenuiSpec } from '../spec.ts'
3
+ import { GENUI_NATIVE_TYPES } from '../genui-runtime/schema.ts'
4
+ import { safeMediaSrc } from '../genui-runtime/value-utils.ts'
5
+ import type { GenuiPortabilityReport, GenuiStandaloneAsset } from './types.ts'
6
+
7
+ const ASSET_ORDER: GenuiStandaloneAsset[] = ['mermaid', 'three', 'echarts-core', 'echarts-full']
8
+
9
+ /** 递归收集当前节点树依赖的引擎和媒体信息。 */
10
+ function scan(items: unknown[], report: GenuiPortabilityReport, assets: Set<GenuiStandaloneAsset>): void {
11
+ for (const item of items) {
12
+ if (typeof item !== 'object' || item === null || !('type' in item) || typeof item.type !== 'string') continue
13
+ const node = item as GenuiNode & Record<string, unknown>
14
+ if (!GENUI_NATIVE_TYPES.has(node.type)) report.customTypes.push(node.type)
15
+ if (node.type === 'mermaid') assets.add('mermaid')
16
+ if (node.type === 'scene3d') assets.add('three')
17
+ if (node.type === 'echart') assets.add(echartEngineFor(node as Extract<GenuiNode, { type: 'echart' }>))
18
+ if (typeof node.action === 'string' || node.type === 'submit' && (typeof node.action === 'string' || typeof node.resetAction === 'string')) report.hasModelActions = true
19
+ if (node.type === 'image' || node.type === 'audio' || node.type === 'video') {
20
+ const src = safeMediaSrc(node.src)
21
+ if (src !== undefined) report.externalMedia.push(src)
22
+ if (node.type === 'video') {
23
+ const poster = safeMediaSrc(node.poster)
24
+ if (poster !== undefined) report.externalMedia.push(poster)
25
+ }
26
+ }
27
+ if (Array.isArray(node.items)) {
28
+ if (node.type === 'list') scan(node.items.filter(child => typeof child === 'object' && child !== null && 'type' in child) as unknown[], report, assets)
29
+ else if (node.type === 'accordion') {
30
+ for (const section of node.items) if (typeof section === 'object' && section !== null && 'items' in section && Array.isArray(section.items)) scan(section.items, report, assets)
31
+ } else scan(node.items, report, assets)
32
+ }
33
+ if (Array.isArray(node.tabs)) for (const tab of node.tabs) if (typeof tab === 'object' && tab !== null && 'items' in tab && Array.isArray(tab.items)) scan(tab.items, report, assets)
34
+ if (node.type === 'table' && Array.isArray(node.details)) for (const row of node.details) if (Array.isArray(row)) scan(row, report, assets)
35
+ }
36
+ }
37
+
38
+ /** 扫描嵌套 GenUI 节点,选择运行资源并列出独立页面限制。 */
39
+ export function analyzeGenuiPortability(spec: GenuiSpec): GenuiPortabilityReport {
40
+ const report: GenuiPortabilityReport = { requiredAssets: [], customTypes: [], externalMedia: [], hasModelActions: false }
41
+ const assets = new Set<GenuiStandaloneAsset>()
42
+ scan(spec.items, report, assets)
43
+ report.requiredAssets = ASSET_ORDER.filter(name => assets.has(name))
44
+ report.customTypes = [...new Set(report.customTypes)]
45
+ report.externalMedia = [...new Set(report.externalMedia)]
46
+ return report
47
+ }
@@ -0,0 +1,94 @@
1
+ /** standalone 页面使用的基础颜色和版面样式。 */
2
+ export const STANDALONE_THEME_CSS = `
3
+ :root {
4
+ color-scheme: light;
5
+ --dsw-static-neutral-bluish-00: rgb(255, 255, 255);
6
+ --dsw-static-neutral-bluish-50: rgb(249, 250, 251);
7
+ --dsw-static-neutral-bluish-300: rgb(207, 211, 214);
8
+ --dsw-static-neutral-bluish-400: rgb(173, 178, 184);
9
+ --dsw-static-neutral-bluish-600: rgb(129, 133, 140);
10
+ --dsw-static-neutral-bluish-700: rgb(97, 102, 107);
11
+ --dsw-static-neutral-bluish-800: rgb(53, 54, 56);
12
+ --dsw-static-neutral-bluish-850: rgb(44, 44, 46);
13
+ --dsw-static-neutral-bluish-875: rgb(35, 35, 36);
14
+ --dsw-static-neutral-bluish-900: rgb(27, 27, 28);
15
+ --dsw-static-neutral-bluish-950: rgb(21, 21, 23);
16
+ --dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
17
+ --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
18
+ --dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
19
+ --dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
20
+ --dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00);
21
+ --dsw-alias-border-l1: rgba(0, 0, 0, 0.04);
22
+ --dsw-alias-border-l2: rgba(0, 0, 0, 0.1);
23
+ --dsw-alias-fill-hover: #eef2f7;
24
+ --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000);
25
+ --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700);
26
+ --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600);
27
+ --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
28
+ --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50);
29
+ --dsw-alias-markdown-hr: #cbd5e1;
30
+ /* DSH 静态调色板供语义状态别名使用。 */
31
+ --dsw-static-deepseek-100: rgb(228, 237, 253);
32
+ --dsw-static-deepseek-400: rgb(122, 170, 255);
33
+ --dsw-static-deepseek-500: rgb(65, 118, 230);
34
+ --dsw-static-deepseek-800: rgb(52, 65, 91);
35
+ --dsw-static-green-100: rgb(230, 250, 237);
36
+ --dsw-static-green-400: rgb(78, 209, 126);
37
+ --dsw-static-green-500: rgb(34, 197, 94);
38
+ --dsw-static-green-900: rgb(35, 60, 44);
39
+ --dsw-static-amber-100: rgb(254, 245, 231);
40
+ --dsw-static-amber-400: rgb(247, 173, 49);
41
+ --dsw-static-amber-500: rgb(245, 158, 11);
42
+ --dsw-static-amber-600: rgb(221, 134, 41);
43
+ --dsw-static-amber-900: rgb(39, 36, 31);
44
+ --dsw-static-red-400: rgb(242, 90, 90);
45
+ --dsw-static-red-600: rgb(236, 19, 19);
46
+
47
+ /* DSH 浅色主题语义状态别名。 */
48
+ --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
49
+ --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
50
+ --dsw-alias-state-success-primary: var(--dsw-static-green-500);
51
+ --dsw-alias-state-success-secondary: var(--dsw-static-green-400);
52
+ --dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
53
+ --dsw-alias-state-warn-label: var(--dsw-static-amber-600);
54
+ --dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
55
+ --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
56
+ --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100);
57
+ --dsw-alias-state-error-primary: var(--dsw-static-red-600);
58
+ --dsw-alias-state-error-secondary: var(--dsw-static-red-400);
59
+ --dsw-static-deepseek-300: rgb(183, 200, 254);
60
+ --dsw-static-deepseek-450: rgb(86, 134, 254);
61
+ --dsw-static-blue-450: rgb(77, 147, 248);
62
+ }
63
+ body[data-ds-dark-theme] {
64
+ color-scheme: dark;
65
+ --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950);
66
+ --dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875);
67
+ --dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850);
68
+ --dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-800);
69
+ --dsw-alias-border-l1: rgba(255, 255, 255, 0.06);
70
+ --dsw-alias-border-l2: rgba(255, 255, 255, 0.12);
71
+ --dsw-alias-fill-hover: #2b3542;
72
+ --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50);
73
+ --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300);
74
+ --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400);
75
+ --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
76
+ --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900);
77
+ --dsw-alias-markdown-hr: #465365;
78
+ /* DSH 深色主题语义状态别名。 */
79
+ --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
80
+ --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
81
+ --dsw-alias-state-success-primary: var(--dsw-static-green-500);
82
+ --dsw-alias-state-success-secondary: var(--dsw-static-green-400);
83
+ --dsw-alias-state-success-tertiary: var(--dsw-static-green-900);
84
+ --dsw-alias-state-warn-label: var(--dsw-static-amber-600);
85
+ --dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
86
+ --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
87
+ --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900);
88
+ --dsw-alias-state-error-primary: var(--dsw-static-red-400);
89
+ --dsw-alias-state-error-secondary: var(--dsw-static-red-400);
90
+ }
91
+ html { min-height: 100%; }
92
+ body { margin: 0; min-height: 100vh; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
93
+ #genui-root { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
94
+ `
@@ -0,0 +1,44 @@
1
+ import type { LocaleId } from '../i18n/runtime.ts'
2
+ import type { GenuiSpec } from '../spec.ts'
3
+ import type { BlockInteractionState } from '../interaction-store.ts'
4
+
5
+ export const GENUI_ARTIFACT_FORMAT = 'dsh-genui'
6
+ export const GENUI_ARTIFACT_VERSION = 1 as const
7
+
8
+ export type GenuiArtifactTheme = 'light' | 'dark'
9
+ export type GenuiStandaloneAsset = 'mermaid' | 'three' | 'echarts-core' | 'echarts-full'
10
+
11
+ export interface GenuiArtifactV1 {
12
+ format: typeof GENUI_ARTIFACT_FORMAT
13
+ version: typeof GENUI_ARTIFACT_VERSION
14
+ spec: GenuiSpec
15
+ state?: BlockInteractionState
16
+ presentation: { locale: LocaleId; theme: GenuiArtifactTheme }
17
+ }
18
+
19
+ export interface GenuiPortabilityReport {
20
+ requiredAssets: GenuiStandaloneAsset[]
21
+ customTypes: string[]
22
+ externalMedia: string[]
23
+ hasModelActions: boolean
24
+ }
25
+
26
+ export type GenuiExportErrorCode =
27
+ | 'unsupported-custom-component'
28
+ | 'runtime-fetch-failed'
29
+ | 'asset-fetch-failed'
30
+ | 'artifact-invalid'
31
+ | 'download-failed'
32
+
33
+ /** 导出流程遇到可供界面识别的错误时使用。 */
34
+ export class GenuiExportError extends Error {
35
+ constructor(readonly code: GenuiExportErrorCode, message: string) {
36
+ super(message)
37
+ this.name = 'GenuiExportError'
38
+ }
39
+ }
40
+
41
+ export interface CreateGenuiArtifactOptions {
42
+ locale?: LocaleId
43
+ theme?: GenuiArtifactTheme
44
+ }
@@ -40,6 +40,12 @@ interface AssetGlobal {
40
40
  __GenuiAssets__?: Record<string, unknown>
41
41
  }
42
42
 
43
+ /** Resolve an engine API registered by an inline standalone bundle. */
44
+ function preloadedAsset(name: string): unknown {
45
+ const key = name.replace(/-(\w)/g, (_match, char: string) => char.toUpperCase())
46
+ return (window as unknown as AssetGlobal).__GenuiAssets__?.[key]
47
+ }
48
+
43
49
  /** Resolve an asset URL, appending the bundle rev for cache busting when the
44
50
  * boot graph exposes it. */
45
51
  export function assetUrl(file: string): string {
@@ -60,6 +66,8 @@ const pending = new Map<string, Promise<Record<string, unknown>>>()
60
66
  * @returns the registered engine surface.
61
67
  */
62
68
  export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts-core' | 'echarts-full'): Promise<T> {
69
+ const preloaded = preloadedAsset(name)
70
+ if (preloaded !== undefined) return Promise.resolve(preloaded as T)
63
71
  const file = `${name}.js`
64
72
  const existing = pending.get(file)
65
73
  if (existing !== undefined) return existing as Promise<T>
@@ -5,7 +5,7 @@
5
5
  * @module @changfenhuang/dsh-genui/client/blocks/advanced
6
6
  */
7
7
  import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
8
- import { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
8
+ import { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '../primitive-adapter.ts'
9
9
  import { renderInline } from '../inline.ts'
10
10
  import css from '../GenuiBlock.module.css'
11
11
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
@@ -446,6 +446,14 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
446
446
  }) {
447
447
  const t = useT()
448
448
  const [selected, setSelected] = useState<number | null>(null)
449
+ // `id` is the documented reset signal: when the model swaps the quiz in
450
+ // place (same tree position, new question), the component instance is
451
+ // reused, so the answered state must not leak onto the new question.
452
+ const [prevId, setPrevId] = useState(node.id)
453
+ if (node.id !== prevId) {
454
+ setPrevId(node.id)
455
+ setSelected(null)
456
+ }
449
457
  const options = node.options.slice(0, GENUI_LIMITS.maxQuizOptions)
450
458
  const answered = selected !== null
451
459
  const chosen = selected === null ? undefined : options[selected]
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { Fragment, memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
14
14
  import type { MouseEvent as ReactMouseEvent, ReactNode, RefObject } from 'react'
15
- import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
15
+ import { writeClipboard } from '../primitive-adapter.ts'
16
16
  import { renderInline } from '../inline.ts'
17
17
  // Aliased: `t` is already used throughout this module as a tick-value
18
18
  // local (ticks.map(t => …)), so the translator rides a distinct name.
@@ -495,8 +495,9 @@ export function TextareaNode({ node, onAction, answers }: {
495
495
  }) {
496
496
  const action = node.action
497
497
  const id = node.id
498
- const [value, setValue] = useState<string>(() =>
499
- node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : ''))
498
+ // Durable state wins over the spec default (same contract as InputNode).
499
+ const restored = id !== undefined ? answers?.fields[id] : undefined
500
+ const [value, setValue] = useState<string>(() => restored ?? node.value ?? '')
500
501
  // Last value delivered to the model: blur sends only on change. Seeded
501
502
  // with the mount value so an unedited blur stays silent.
502
503
  const lastSent = useRef<string | null>(value)
@@ -507,13 +508,15 @@ export function TextareaNode({ node, onAction, answers }: {
507
508
  }
508
509
  }
509
510
  const ime = useImeComposing()
510
- // Field invariant: a spec-provided non-blank default registers at mount.
511
+ // Field invariant: a non-blank initial value registers at mount. When a
512
+ // durable value was restored, registering it is a no-op (setField dedupes);
513
+ // otherwise the spec default registers — never the other way around.
511
514
  const mounted = useRef(false)
512
515
  useEffect(() => {
513
516
  if (mounted.current) return
514
517
  mounted.current = true
515
- if (id !== undefined && node.value !== undefined && node.value.trim() !== '') {
516
- answers?.setField(id, node.value)
518
+ if (id !== undefined && value.trim() !== '') {
519
+ answers?.setField(id, value)
517
520
  }
518
521
  // eslint-disable-next-line react-hooks/exhaustive-deps
519
522
  }, [])
@@ -5,7 +5,7 @@
5
5
  * @module @changfenhuang/dsh-genui/client/blocks/render-node
6
6
  */
7
7
  import { type ComponentType, type ReactNode, useEffect, useState, type CSSProperties } from 'react'
8
- import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
8
+ import * as primitives from '../primitive-adapter.ts'
9
9
  import css from '../GenuiBlock.module.css'
10
10
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
11
11
  import { renderInline } from '../inline.ts'
@@ -31,6 +31,8 @@ export interface GenuiBlockProps {
31
31
  /** Native hosts own durable state instead of the browser store. Read once per stateKey. */
32
32
  initialState?: BlockInteractionState | undefined
33
33
  onStateChange?: ((state: BlockInteractionState) => void) | undefined
34
+ /** Observe the current secret-free durable state without changing persistence ownership. */
35
+ onStateSnapshot?: ((state: BlockInteractionState) => void) | undefined
34
36
  }
35
37
 
36
38
  /** Per-question metadata registered by grouped radios for local grading. */
@@ -0,0 +1,9 @@
1
+ import type { GenuiEChart } from './spec.ts'
2
+
3
+ export const CORE_PRESETS: ReadonlySet<string> = new Set(['bar', 'line', 'area', 'pie', 'scatter', 'bigline'])
4
+
5
+ /** 依照 renderer 共用的 preset 规则选择 ECharts 资源。 */
6
+ export function echartEngineFor(node: GenuiEChart): 'echarts-core' | 'echarts-full' {
7
+ if (node.option !== undefined) return 'echarts-full'
8
+ return CORE_PRESETS.has(node.preset ?? 'bar') ? 'echarts-core' : 'echarts-full'
9
+ }
@@ -8,6 +8,7 @@
8
8
  * @module @changfenhuang/dsh-genui/client/echarts-lazy
9
9
  */
10
10
  import { loadGenuiAsset } from './asset-loader.ts'
11
+ export { CORE_PRESETS } from './echarts-engine.ts'
11
12
 
12
13
  /** The ECharts instance surface (the subset the component uses). */
13
14
  export interface EChartsInstance {
@@ -38,7 +39,3 @@ export async function createChart(
38
39
  const api = await loadGenuiAsset<EChartsAssetApi>(engine === 'full' ? 'echarts-full' : 'echarts-core')
39
40
  return api.createChart(el, option, opts)
40
41
  }
41
-
42
- /** Presets the core bundle can draw; anything else (or a raw `option`) needs
43
- * the full engine. Kept next to the loader so the split stays in one place. */
44
- export const CORE_PRESETS: ReadonlySet<string> = new Set(['bar', 'line', 'area', 'pie', 'scatter', 'bigline'])
@@ -7,3 +7,13 @@ export { isRenderableProcess, processGenuiSpec } from './guard.ts'
7
7
  export { setGenuiAssetBase } from './asset-loader.ts'
8
8
  export type { GenuiSpec } from './spec.ts'
9
9
  export type { BlockInteractionState } from './interaction-store.ts'
10
+ export {
11
+ analyzeGenuiPortability,
12
+ buildStandaloneHtml,
13
+ createGenuiArtifact,
14
+ downloadGenuiArtifactHtml,
15
+ downloadGenuiArtifactJson,
16
+ parseGenuiArtifact,
17
+ serializeGenuiArtifact,
18
+ } from './artifact/index.ts'
19
+ export type { GenuiArtifactV1, GenuiPortabilityReport } from './artifact/index.ts'
@@ -20,7 +20,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
20
20
  import { codeBlockLabels } from './primitive-labels.ts'
21
21
  import { t, useT } from './i18n/index.ts'
22
22
  import { ErrorBoundary } from './ErrorBoundary.tsx'
23
- import { GenuiBlock } from './GenuiBlock.tsx'
23
+ import { ExportableGenuiBlock } from './artifact/ExportableGenuiBlock.tsx'
24
24
  import { fenceStateKey } from './interaction-store.ts'
25
25
  import { applyPanelOperation, diagnosePanelBudget, type PanelOperationStatus } from './panel-store.ts'
26
26
  import type { GenuiSpec } from './spec.ts'
@@ -191,6 +191,9 @@ export function resolveGenuiSpec(raw: string, context?: GenuiFenceContext): Genu
191
191
  /** The inline GenuiBlock tree for a resolved non-panel spec. */
192
192
  function renderInlineFence(key: Key, context: GenuiFenceContext | undefined, spec: GenuiSpec): ReactNode {
193
193
  const sessionId = context?.sessionId
194
+ const stateKey = sessionId === undefined || context?.source === undefined
195
+ ? undefined
196
+ : fenceStateKey(sessionId, context.source.id, JSON.stringify(spec))
194
197
  return (
195
198
  // Keep the document slot mounted across streaming→settled; GenuiBlock
196
199
  // owns durable-state changes, while a session change resets the tree.
@@ -198,18 +201,7 @@ function renderInlineFence(key: Key, context: GenuiFenceContext | undefined, spe
198
201
  // tells the user something was wrong — only an unrecoverable body keeps
199
202
  // the red diagnostic.
200
203
  <ErrorBoundary key={JSON.stringify([sessionId, key])} label={t('err.boundary.fence')}>
201
- <GenuiBlock
202
- spec={spec}
203
- animateEntrance={context?.source === undefined}
204
- // v2.7 durable state: session + stable source + content fingerprint —
205
- // replaying the same content restores answers/lock/field values; new
206
- // content (换题, edited spec) gets a fresh key. Without a stable
207
- // source (streaming / non-conversation surfaces) state is not
208
- // persisted.
209
- stateKey={sessionId === undefined || context?.source === undefined
210
- ? undefined
211
- : fenceStateKey(sessionId, context.source.id, JSON.stringify(spec))}
212
- />
204
+ <ExportableGenuiBlock spec={spec} animateEntrance={context?.source === undefined} stateKey={stateKey} exportEnabled={context?.source !== undefined} />
213
205
  </ErrorBoundary>
214
206
  )
215
207
  }
@@ -239,7 +239,7 @@ function normalizeNode(value: unknown, path: string, warnings: GenuiDiagnostic[]
239
239
  normalizedHolder.items = Array.isArray(normalizedHolder.items)
240
240
  ? normalizedHolder.items.map((child, childIndex) => normalizeNodeValue(child, `${path}.tabs[${index}].items[${childIndex}]`))
241
241
  : normalizedHolder.items === undefined
242
- ? normalizedHolder.items
242
+ ? []
243
243
  : [normalizeNodeValue(normalizedHolder.items, `${path}.tabs[${index}].items[0]`)]
244
244
  return normalizedHolder
245
245
  })
@@ -1524,7 +1524,8 @@ export function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
1524
1524
  }
1525
1525
  }
1526
1526
  walk(v.items, 0, 'items')
1527
- return { ok: errors.length === 0, errors }
1527
+ const uniqueErrors = [...new Set(errors)]
1528
+ return { ok: uniqueErrors.length === 0, errors: uniqueErrors }
1528
1529
  }
1529
1530
 
1530
1531
  /**
@@ -9,6 +9,14 @@
9
9
  * `block.*`, `err.*`.
10
10
  */
11
11
  export const EN = {
12
+ // --------------------------------------------------------- artifacts
13
+ 'artifact.export': 'Export',
14
+ 'artifact.exportHtml': 'HTML',
15
+ 'artifact.exportJson': 'GenUI JSON',
16
+ 'artifact.exportFailed': 'Export failed. Try again.',
17
+ 'artifact.unsupportedCustom': 'Cannot export HTML because the standalone runtime cannot render custom components: {types}. Export GenUI JSON to preserve these nodes.',
18
+ 'artifact.externalMediaNotice': 'External media may not appear when this file is opened offline.',
19
+
12
20
  // ---------------------------------------------------------------- panel
13
21
  'panel.badge': 'Panel',
14
22
  'panel.title.default': 'GenUI panel',
@@ -7,6 +7,14 @@
7
7
  import type { EN } from './en.ts'
8
8
 
9
9
  export const ZH: Record<keyof typeof EN, string> = {
10
+ // --------------------------------------------------------- 成果文件
11
+ 'artifact.export': '导出',
12
+ 'artifact.exportHtml': 'HTML',
13
+ 'artifact.exportJson': 'GenUI JSON',
14
+ 'artifact.exportFailed': '导出失败,请重试。',
15
+ 'artifact.unsupportedCustom': '无法导出 HTML:独立运行环境无法显示自定义组件 {types}。可导出 GenUI JSON 保存这些节点。',
16
+ 'artifact.externalMediaNotice': '此页面含有外部媒体,离线打开时部分内容可能无法显示。',
17
+
10
18
  // ---------------------------------------------------------------- panel
11
19
  'panel.badge': '面板',
12
20
  'panel.title.default': 'GenUI 面板',
@@ -23,7 +23,8 @@ function InlineMath({ source, display }: { source: string; display: boolean }) {
23
23
  // A real newline in the string is its own token rendered as <br>, so text
24
24
  // fields express a line break via JSON "\n" — no HTML parsing, and the
25
25
  // single-line (nowrap) chrome classes never contain one.
26
- const INLINE = /`[^`\n]+`|\\\\|\\\$|(?<![\\$])\$\$(?:\\.|[^\\])*?\$\$|\\\[(?:\\(?!\])[^]|[^\\])*?\\\]|\\\((?:\\(?!\))[^]|[^\\])*?\\\)|(?<![\\$])\$(?!\s|\$)(?:\\.|[^$\\\n])+(?<!\s)\$(?!\d|\$)|\*\*[\s\S]+?\*\*|==[\s\S]+?==|\[[^\]\n]+\]\([^)\s]+\)|\r?\n/g
26
+ const INLINE = /(?<!`)`[^`\n]+`(?!`)|\\\\|\\\$|(?<![\\$])\$\$(?:\\.|[^\\])*?\$\$|\\\[(?:\\(?!\])[^]|[^\\])*?\\\]|\\\((?:\\(?!\))[^]|[^\\])*?\\\)|(?<![\\$])\$(?!\s|\$)(?:\\.|[^$\\\n])+(?<!\s)\$(?!\d|\$)|\*\*[\s\S]+?\*\*|==[\s\S]+?==|\[[^\]\n]+\]\([^)\s]+\)|\r?\n/g
27
+ const FENCE_MARKER = /`{3,}|~{3,}/g
27
28
 
28
29
  export function hasInlineMarkup(text: string): boolean {
29
30
  return typeof text === 'string' && /[`*=$\\\n\r]|\[/.test(text)
@@ -32,6 +33,30 @@ export function hasInlineMarkup(text: string): boolean {
32
33
  /** Render safe phrasing content, usable in headings, buttons and labels too. */
33
34
  export function renderInline(text: string, allowLinks = true, depth = 0): ReactNode {
34
35
  if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text) || depth >= 8) return text
36
+ const segments: ReactNode[] = []
37
+ let fenceEnd = 0
38
+ for (const opening of text.matchAll(FENCE_MARKER)) {
39
+ const openingStart = opening.index ?? 0
40
+ if (openingStart < fenceEnd) continue
41
+ const marker = opening[0]
42
+ const contentStart = openingStart + marker.length
43
+ let closingEnd: number | undefined
44
+ for (const candidate of text.slice(contentStart).matchAll(FENCE_MARKER)) {
45
+ if (candidate[0][0] === marker[0] && candidate[0].length >= marker.length) {
46
+ closingEnd = contentStart + (candidate.index ?? 0) + candidate[0].length
47
+ break
48
+ }
49
+ }
50
+ if (openingStart > fenceEnd) segments.push(renderInline(text.slice(fenceEnd, openingStart), allowLinks, depth))
51
+ // 围栏及其中的代码、换行和行内标记都保持原文。
52
+ fenceEnd = closingEnd ?? text.length
53
+ segments.push(text.slice(openingStart, fenceEnd))
54
+ if (closingEnd === undefined) break
55
+ }
56
+ if (segments.length > 0) {
57
+ if (fenceEnd < text.length) segments.push(renderInline(text.slice(fenceEnd), allowLinks, depth))
58
+ return segments
59
+ }
35
60
  const out: ReactNode[] = []
36
61
  let last = 0
37
62
  let key = 0
@@ -27,7 +27,7 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from '
27
27
  import type {} from '@deepseek-ai/dsh-client-ui-session/client'
28
28
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
29
29
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
30
- import { GenuiBlock } from './GenuiBlock.tsx'
30
+ import { ExportableGenuiBlock } from './artifact/ExportableGenuiBlock.tsx'
31
31
  import { ErrorBoundary } from './ErrorBoundary.tsx'
32
32
  import { panelStateKey } from './interaction-store.ts'
33
33
  import { TemplateDrawer } from './TemplateDrawer.tsx'
@@ -273,7 +273,7 @@ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: Genui
273
273
  <GenuiActionContext.Provider value={sendGenuiAction}>
274
274
  <ErrorBoundary label={t('panel.boundary')}>
275
275
  {/* content-fingerprinted: same panel spec re-published restores its state */}
276
- <GenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
276
+ <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
277
277
  </ErrorBoundary>
278
278
  </GenuiActionContext.Provider>
279
279
  ) : null}
@@ -0,0 +1,13 @@
1
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
2
+
3
+ export { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ export type { DiffBlockLabels, JsonTreeLabels } from '@deepseek-ai/dsh-client-ui-primitives'
5
+
6
+ type HostGenuiExtensions = {
7
+ getGenuiComponent?: (type: string) => unknown
8
+ GenuiActionContext?: typeof primitives extends { GenuiActionContext: infer T } ? T : unknown
9
+ }
10
+
11
+ const hostExtensions = primitives as unknown as HostGenuiExtensions
12
+ export const getGenuiComponent = hostExtensions.getGenuiComponent
13
+ export const GenuiActionContext = hostExtensions.GenuiActionContext
@@ -6,7 +6,7 @@
6
6
  * module load — a language switch must reach a diff or JSON tree that is
7
7
  * already on screen.
8
8
  */
9
- import type { DiffBlockLabels, JsonTreeLabels } from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import type { DiffBlockLabels, JsonTreeLabels } from './primitive-adapter.ts'
10
10
  import { t } from './i18n/index.ts'
11
11
 
12
12
  /** Chrome labels for an inline GenUI diff block, in the active locale. */
@@ -0,0 +1,9 @@
1
+ type StandaloneAssets = { __GenuiAssets__?: Record<string, unknown> }
2
+
3
+ /** 从启动代码预先载入的全局资源读取引擎。 */
4
+ export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts-core' | 'echarts-full'): Promise<T> {
5
+ const key = name.replace(/-(\w)/g, (_match, character: string) => character.toUpperCase())
6
+ const asset = (window as unknown as StandaloneAssets).__GenuiAssets__?.[key]
7
+ if (asset === undefined) return Promise.reject(new Error(`standalone asset '${name}' was not preloaded`))
8
+ return Promise.resolve(asset as T)
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { GenuiArtifactV1 } from '../artifact/types.ts'
2
+
3
+ declare global {
4
+ interface Window {
5
+ __GenuiStandalone__?: {
6
+ mount(root: HTMLElement, artifact: GenuiArtifactV1): () => void
7
+ }
8
+ }
9
+ }
10
+
11
+ export {}
@@ -0,0 +1 @@
1
+ export {}