@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.
- package/CHANGELOG.md +20 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/SKILL.md +4 -2
- package/lib/assets/standalone-runtime.js +1505 -0
- package/lib/client.js +163 -25
- package/lib/index.js +222 -18
- package/lib/types/client/artifact/ExportableGenuiBlock.d.ts +6 -0
- package/lib/types/client/artifact/create.d.ts +5 -0
- package/lib/types/client/artifact/download.d.ts +9 -0
- package/lib/types/client/artifact/html.d.ts +5 -0
- package/lib/types/client/artifact/index.d.ts +8 -0
- package/lib/types/client/artifact/json.d.ts +3 -0
- package/lib/types/client/artifact/parse.d.ts +3 -0
- package/lib/types/client/artifact/portability.d.ts +4 -0
- package/lib/types/client/artifact/standalone-theme.d.ts +2 -0
- package/lib/types/client/artifact/types.d.ts +33 -0
- package/lib/types/client/blocks/state.d.ts +2 -0
- package/lib/types/client/echarts-engine.d.ts +4 -0
- package/lib/types/client/echarts-lazy.d.ts +1 -3
- package/lib/types/client/embed.d.ts +2 -0
- package/lib/types/client/i18n/en.d.ts +6 -0
- package/lib/types/client/primitive-adapter.d.ts +4 -0
- package/lib/types/client/primitive-labels.d.ts +1 -1
- package/lib/types/client/standalone/asset-loader.d.ts +2 -0
- package/lib/types/client/standalone/bootstrap-types.d.ts +9 -0
- package/lib/types/client/standalone/katex-style.d.ts +1 -0
- package/lib/types/client/standalone/primitive-adapter.d.ts +54 -0
- package/lib/types/client/standalone/runtime.d.ts +2 -0
- package/lib/types/plugin/fence-feedback.d.ts +4 -1
- package/lib/types/plugin/index.d.ts +1 -1
- package/lib/types/plugin/inline-content-diagnostic.d.ts +8 -0
- package/package.json +3 -2
- package/src/client/EChartNode.tsx +4 -8
- package/src/client/GenuiBlock.tsx +17 -13
- package/src/client/action-context.ts +1 -1
- package/src/client/artifact/ArtifactExport.module.css +64 -0
- package/src/client/artifact/ExportableGenuiBlock.tsx +100 -0
- package/src/client/artifact/create.ts +69 -0
- package/src/client/artifact/download.ts +42 -0
- package/src/client/artifact/html.ts +157 -0
- package/src/client/artifact/index.ts +15 -0
- package/src/client/artifact/json.ts +6 -0
- package/src/client/artifact/parse.ts +32 -0
- package/src/client/artifact/portability.ts +47 -0
- package/src/client/artifact/standalone-theme.ts +94 -0
- package/src/client/artifact/types.ts +44 -0
- package/src/client/asset-loader.ts +8 -0
- package/src/client/blocks/advanced.tsx +9 -1
- package/src/client/blocks/charts.tsx +1 -1
- package/src/client/blocks/forms.tsx +8 -5
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -0
- package/src/client/echarts-engine.ts +9 -0
- package/src/client/echarts-lazy.ts +1 -4
- package/src/client/embed.ts +10 -0
- package/src/client/fence-render.tsx +5 -13
- package/src/client/genui-runtime/normalize.ts +1 -1
- package/src/client/guard.ts +2 -1
- package/src/client/i18n/en.ts +8 -0
- package/src/client/i18n/zh.ts +8 -0
- package/src/client/inline.ts +26 -1
- package/src/client/panel.tsx +2 -2
- package/src/client/primitive-adapter.ts +13 -0
- package/src/client/primitive-labels.ts +1 -1
- package/src/client/standalone/asset-loader.ts +9 -0
- package/src/client/standalone/bootstrap-types.ts +11 -0
- package/src/client/standalone/katex-style.ts +1 -0
- package/src/client/standalone/primitive-adapter.tsx +94 -0
- package/src/client/standalone/runtime.tsx +27 -0
- package/src/client/toolview.tsx +2 -2
- package/src/plugin/fence-feedback.ts +25 -9
- package/src/plugin/index.ts +4 -3
- package/src/plugin/inline-content-diagnostic.ts +178 -0
- package/src/plugin/tool.ts +18 -3
- 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 '
|
|
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 '
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
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 &&
|
|
516
|
-
answers?.setField(id,
|
|
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 '
|
|
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'])
|
package/src/client/embed.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
?
|
|
242
|
+
? []
|
|
243
243
|
: [normalizeNodeValue(normalizedHolder.items, `${path}.tabs[${index}].items[0]`)]
|
|
244
244
|
return normalizedHolder
|
|
245
245
|
})
|
package/src/client/guard.ts
CHANGED
|
@@ -1524,7 +1524,8 @@ export function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
|
|
|
1524
1524
|
}
|
|
1525
1525
|
}
|
|
1526
1526
|
walk(v.items, 0, 'items')
|
|
1527
|
-
|
|
1527
|
+
const uniqueErrors = [...new Set(errors)]
|
|
1528
|
+
return { ok: uniqueErrors.length === 0, errors: uniqueErrors }
|
|
1528
1529
|
}
|
|
1529
1530
|
|
|
1530
1531
|
/**
|
package/src/client/i18n/en.ts
CHANGED
|
@@ -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',
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -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 面板',
|
package/src/client/inline.ts
CHANGED
|
@@ -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 =
|
|
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
|
package/src/client/panel.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
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 '
|
|
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 @@
|
|
|
1
|
+
export {}
|