@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,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 }
|
package/src/client/toolview.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
|
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
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
+
}
|
package/src/plugin/tool.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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'),
|