@changfenhuang/dsh-genui 0.9.1
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 +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- package/tsdown.config.ts +193 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `render_ui` tool's card in the tool row. The host projected the
|
|
3
|
+
* repaired spec into the result's `meta` (the tool's `presentationMeta`);
|
|
4
|
+
* this keyed toolview reads it and renders through GenuiBlock — the same
|
|
5
|
+
* renderer the ```dsh-ui fence uses. Falls back to a compact summary row
|
|
6
|
+
* when the meta is missing or invalid (e.g. a replay of a log recorded
|
|
7
|
+
* before the projection existed).
|
|
8
|
+
*
|
|
9
|
+
* Every settled spec is ALSO published to the session panel store: the
|
|
10
|
+
* conversation dock re-renders the same panel block in place, so repeated
|
|
11
|
+
* render_ui calls update one surface instead of stacking tool-row cards.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useMemo } from 'react'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
|
15
|
+
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
|
16
|
+
import { GenuiBlock } from './GenuiBlock.tsx'
|
|
17
|
+
import { ErrorBoundary } from './ErrorBoundary.tsx'
|
|
18
|
+
import { repairGenuiSpec } from './guard.ts'
|
|
19
|
+
import { toolStateKey } from './interaction-store.ts'
|
|
20
|
+
import { applyPanelOperation } from './panel-store.ts'
|
|
21
|
+
import css from './GenuiBlock.module.css'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Keyed toolview for the `render_ui` tool. `block` is the settled result
|
|
25
|
+
* node once the call completes; while it runs (or on replay without meta)
|
|
26
|
+
* the summary fallback is shown.
|
|
27
|
+
*/
|
|
28
|
+
export function GenuiToolView({ toolName, block, sessionId }: ToolCallViewProps) {
|
|
29
|
+
// `meta` exists only on the settled result node; running calls (and
|
|
30
|
+
// replayed logs without the projection) fall back to the summary row.
|
|
31
|
+
// Memoized so the publish effect only fires when the settled spec
|
|
32
|
+
// actually changes (same block → same object → no panel churn).
|
|
33
|
+
const meta = 'meta' in block ? block.meta : undefined
|
|
34
|
+
const spec = useMemo(() => (meta === undefined ? null : repairGenuiSpec(meta)), [meta])
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Publish the settled spec to the session panel (dock) as a REPLACE op
|
|
37
|
+
// with the result block's call identity and message seq: the same block
|
|
38
|
+
// re-rendered (replay/refresh) is idempotent, and an older result can
|
|
39
|
+
// never clobber a newer panel fence (real ordering in the fold). Running
|
|
40
|
+
// calls publish nothing — the panel keeps its last content.
|
|
41
|
+
if (spec !== null && spec.items.length > 0) {
|
|
42
|
+
applyPanelOperation(sessionId, {
|
|
43
|
+
sourceId: JSON.stringify(['render_ui', block.callId]),
|
|
44
|
+
order: ['seq' in block && typeof block.seq === 'number' ? block.seq : 0, -1, 0],
|
|
45
|
+
mode: 'replace',
|
|
46
|
+
spec,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}, [sessionId, spec, block])
|
|
50
|
+
if (spec === null || spec.items.length === 0) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={css.toolFallback} data-genui-tool>
|
|
53
|
+
<span className={css.toolFallbackTitle}>{toolName}</span>
|
|
54
|
+
<span className={css.toolFallbackMeta}>{block.callId}</span>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<div className={css.tool} data-genui-tool>
|
|
60
|
+
<ErrorBoundary label="工具卡片">
|
|
61
|
+
{/* callId is stable across replay → tool-card interaction state is durable */}
|
|
62
|
+
<GenuiBlock spec={spec} stateKey={toolStateKey(sessionId, block.callId)} />
|
|
63
|
+
</ErrorBoundary>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle source entry (plugin_check tool-bundle contract): the node-half
|
|
3
|
+
* surface the host loader resolves through `package.json#main`.
|
|
4
|
+
*
|
|
5
|
+
* The `import type {}` line is NOT decorative: importing the dsh client
|
|
6
|
+
* runtime's cordis Context augmentation BEFORE re-exporting the plugin keeps
|
|
7
|
+
* TypeScript's global-augmentation ordering deterministic — without it,
|
|
8
|
+
* TS 5.9 mis-resolves `ctx.sessions` in the client half when the plugin
|
|
9
|
+
* module is first reached through this root entry.
|
|
10
|
+
*/
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
export * from './plugin/index'
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenUI plugin: teaches the model the ```dsh-ui fence syntax for emitting
|
|
3
|
+
* declarative UI components inline in its reply. The browser half renders the
|
|
4
|
+
* fence through GenuiBlock (ui-primitives); this host half only tells the
|
|
5
|
+
* model the language exists, so a session without the plugin simply never
|
|
6
|
+
* emits fences and nothing changes.
|
|
7
|
+
*
|
|
8
|
+
* The section is a convention section (order 100-199), placed after the bash
|
|
9
|
+
* guidance so the model sees it among its output-format rules.
|
|
10
|
+
* @module @changfenhuang/dsh-genui
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { createRenderUiTool, createValidateDshUiTool } from './tool.ts'
|
|
21
|
+
|
|
22
|
+
/** Convention: tool guidance uses 100–199; bash's section is 104. */
|
|
23
|
+
export const GENUI_SECTION_ORDER = 105
|
|
24
|
+
|
|
25
|
+
/* ---------------- lazy engine asset route ---------------- */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The mermaid/three engines ship as standalone IIFE bundles under
|
|
29
|
+
* `lib/assets/` and are fetched by the client ONLY when a spec needs them.
|
|
30
|
+
* This route serves them from the plugin's own package directory through the
|
|
31
|
+
* host webserver service — the longest-prefix rule lets it win over the
|
|
32
|
+
* generic `/plugins` bundle route, and no host source change is needed. The
|
|
33
|
+
* service is optional at this plugin's start time (same ordering reality as
|
|
34
|
+
* the tools registry), so registration probes immediately AND on the
|
|
35
|
+
* `internal/service` event, exactly like the tools registration below.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Route prefix under /plugins; anything under it is this plugin's asset. */
|
|
39
|
+
const ASSET_ROUTE_PATH = '/plugins/@changfenhuang/dsh-genui/assets'
|
|
40
|
+
|
|
41
|
+
/** Safe flat file names only: no slashes, no traversal, js assets only. */
|
|
42
|
+
const ASSET_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
|
|
43
|
+
|
|
44
|
+
/** The handler itself (registered via the optional webServer probe). */
|
|
45
|
+
async function serveGenuiAsset(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
46
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
47
|
+
res.writeHead(405)
|
|
48
|
+
res.end()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
let pathname: string
|
|
52
|
+
try {
|
|
53
|
+
pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
|
54
|
+
} catch {
|
|
55
|
+
res.writeHead(400)
|
|
56
|
+
res.end()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const rel = pathname.startsWith(`${ASSET_ROUTE_PATH}/`) ? pathname.slice(ASSET_ROUTE_PATH.length) : null
|
|
60
|
+
if (rel === null) {
|
|
61
|
+
res.writeHead(404)
|
|
62
|
+
res.end()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const file = rel.slice(1)
|
|
66
|
+
if (!ASSET_FILE_RE.test(file)) {
|
|
67
|
+
res.writeHead(404)
|
|
68
|
+
res.end()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// lib/index.js → ./assets/ = <pkg>/lib/assets/ (the tsdown asset outDir).
|
|
73
|
+
const dir = fileURLToPath(new URL('./assets/', import.meta.url))
|
|
74
|
+
const body = await readFile(join(dir, file))
|
|
75
|
+
res.writeHead(200, {
|
|
76
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
77
|
+
'cache-control': 'no-cache',
|
|
78
|
+
})
|
|
79
|
+
res.end(body)
|
|
80
|
+
} catch {
|
|
81
|
+
// Missing asset (old build) — a loud 404; the client shows its fallback.
|
|
82
|
+
res.writeHead(404)
|
|
83
|
+
res.end()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The fence language description injected into every assembled system prompt.
|
|
88
|
+
* Deliberately slim: the `genui` skill carries the full component→field
|
|
89
|
+
* mapping; this section keeps only the contract that must always be
|
|
90
|
+
* present (fence syntax, type whitelist, and critical behavioral rules). */
|
|
91
|
+
export const GENUI_SECTION_TEXT = `You can render interactive UI components INSIDE your reply — between paragraphs — by emitting a fenced block with the language tag \`dsh-ui\` containing a JSON spec:
|
|
92
|
+
|
|
93
|
+
\`\`\`dsh-ui
|
|
94
|
+
{"title":"可选标题","gap":14,"items":[...]}
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
The spec is a white-listed component tree rendered inline where the fence sits. Only these \`type\` values; the \`genui\` skill, when available, carries the full content→component mapping and per-component field details:
|
|
98
|
+
|
|
99
|
+
- 布局: text · row · col · grid · card · divider · spacer
|
|
100
|
+
- 展示: badge · stat · progress · list · table · keyvalue · avatar · audio · video · timeline · file-tree · breadcrumb · callout · steps · diff · json · code · copy
|
|
101
|
+
- 图表: chart (bars|line|donut) · echart (preset|option) · plot (函数图)
|
|
102
|
+
- 交互: button · input · textarea · select · checkbox · switch · slider · radio · submit · quiz · link · tabs · accordion
|
|
103
|
+
- 高级: mermaid (flowchart/sequence/class/gantt/pie/er/state/journey) · diagram (编辑级架构/流程图,27 种 kind) · scene3d (3D WebGL)
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
- 触发: 结构化表达优于纯文本时主动用(要点、强调、对比、流程、步骤、状态、数据、演示),纯问答与一句话不套 UI;一个主题一个主组件,每次 3–8 个组件,同一数据不重复出现。
|
|
107
|
+
- JSON 严格: 坏围栏降级为代码块;≥3 节点或含 table 的围栏发出前调用 validate_dsh_ui,❌ 修好再发(若附「已自动修复」JSON 照抄即可)。
|
|
108
|
+
- 规模: ≤200 节点、嵌套≤8 层(超出被截断);3D mesh 1–5;plot 给合理 xMin/xMax。
|
|
109
|
+
- LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互组件带 "action":"name",交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
|
|
110
|
+
- Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;重渲染相同内容保留,新内容重置。
|
|
111
|
+
- 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
|
|
112
|
+
- Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
|
|
113
|
+
- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片(交付物型界面用);围栏用于回答内联 UI。
|
|
114
|
+
- Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并(同标签 tabs 追加/新标签加入/尾部追加);上限 200 节点/200 次追加,满了发 replace 重建。面板组件来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认,不解释、不用普通围栏。`
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register the GenUI output-language section and the render_ui tool.
|
|
118
|
+
* @param ctx - cordis context.
|
|
119
|
+
*/
|
|
120
|
+
// `tools` is intentionally NOT injected: the service is optional for this
|
|
121
|
+
// plugin — hosts without tool access keep the fence channel working. Cordis
|
|
122
|
+
// inject entries are hard requirements, so the registry is probed at runtime
|
|
123
|
+
// instead (see apply).
|
|
124
|
+
export const inject = ['systemPrompt']
|
|
125
|
+
|
|
126
|
+
export function apply(ctx: Context): void {
|
|
127
|
+
ctx.systemPrompt.section({
|
|
128
|
+
name: 'genui:fence',
|
|
129
|
+
order: GENUI_SECTION_ORDER,
|
|
130
|
+
text: GENUI_SECTION_TEXT,
|
|
131
|
+
})
|
|
132
|
+
// The tools service is optional: hosts without tool access (or minimal
|
|
133
|
+
// compositions) keep the fence channel; only when the registry exists does
|
|
134
|
+
// the render_ui tool join the model's tool set. `reflect.get(name, false)`
|
|
135
|
+
// is cordis's non-throwing optional service lookup (the proxy's own trap
|
|
136
|
+
// uses it) — property access without inject would throw instead.
|
|
137
|
+
//
|
|
138
|
+
// Start-up ordering: this plugin injects only `systemPrompt`, so cordis
|
|
139
|
+
// starts it EARLY — before the tools provider (which injects deeper
|
|
140
|
+
// dependencies) has bound its service. A one-shot probe at apply time
|
|
141
|
+
// therefore misses the registry on real hosts (the fence section lands,
|
|
142
|
+
// the tool never registers). Fix: probe immediately AND subscribe to
|
|
143
|
+
// `internal/service` (emitted by cordis on every service binding), so the
|
|
144
|
+
// registration lands the moment `tools` appears, whatever the order.
|
|
145
|
+
let registered = false
|
|
146
|
+
const tryRegister = (value: { register(tool: unknown): unknown } | undefined): void => {
|
|
147
|
+
if (registered) return
|
|
148
|
+
const tools = value ?? ctx.reflect.get('tools', false) as { register(tool: unknown): unknown } | undefined
|
|
149
|
+
if (tools === undefined) return
|
|
150
|
+
tools.register(createRenderUiTool())
|
|
151
|
+
tools.register(createValidateDshUiTool())
|
|
152
|
+
registered = true
|
|
153
|
+
}
|
|
154
|
+
tryRegister(undefined)
|
|
155
|
+
ctx.on('internal/service', (name: string, value: unknown) => {
|
|
156
|
+
if (name === 'tools') tryRegister(value as { register(tool: unknown): unknown })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Lazy-engine asset route: same optional-probe pattern as the tools
|
|
160
|
+
// registry — the webserver service may bind after this plugin starts.
|
|
161
|
+
let assetsRegistered = false
|
|
162
|
+
const tryRegisterAssets = (value: { register(route: unknown): unknown } | undefined): void => {
|
|
163
|
+
if (assetsRegistered) return
|
|
164
|
+
const webServer = value ?? ctx.reflect.get('webServer', false) as { register(route: unknown): unknown } | undefined
|
|
165
|
+
if (webServer === undefined) return
|
|
166
|
+
webServer.register({ kind: 'prefix', path: ASSET_ROUTE_PATH, handler: serveGenuiAsset })
|
|
167
|
+
assetsRegistered = true
|
|
168
|
+
}
|
|
169
|
+
tryRegisterAssets(undefined)
|
|
170
|
+
ctx.on('internal/service', (name: string, value: unknown) => {
|
|
171
|
+
if (name === 'webServer') tryRegisterAssets(value as { register(route: unknown): unknown })
|
|
172
|
+
})
|
|
173
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@changfenhuang/dsh-genui`.
|
|
3
|
+
* @module @changfenhuang/dsh-genui/invariant
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* jscpd:ignore-start */
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = '@changfenhuang/dsh-genui'
|
|
11
|
+
|
|
12
|
+
/** Cordis companion plugin name. */
|
|
13
|
+
export const name = 'genui-invariant'
|
|
14
|
+
/** Service required before the companion can reserve package ownership. */
|
|
15
|
+
export const inject = ['invariants']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* No runtime invariant: this host plugin only registers a static prompt
|
|
19
|
+
* section; the section text is pinned by its own tests.
|
|
20
|
+
*/
|
|
21
|
+
const install: InvariantInstaller = () => {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register this package's invariant companion.
|
|
25
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
26
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
27
|
+
*/
|
|
28
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
29
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
30
|
+
/* jscpd:ignore-end */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient declarations for the node: builtins the plugin node half
|
|
3
|
+
* uses (asset route: http/fs/path/url). The package compiles with
|
|
4
|
+
* `"types": []` and ships no @types/node devDependency, so these shims are
|
|
5
|
+
* the typecheck surface; tsdown bundles the real node modules at build time
|
|
6
|
+
* (platform: node). Declarations cover exactly the members in use.
|
|
7
|
+
* @module @changfenhuang/dsh-genui/plugin/node-shims
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
declare module 'node:http' {
|
|
11
|
+
export interface IncomingMessage {
|
|
12
|
+
method?: string
|
|
13
|
+
url?: string
|
|
14
|
+
}
|
|
15
|
+
export interface ServerResponse {
|
|
16
|
+
writeHead(statusCode: number, headers?: Record<string, string>): ServerResponse
|
|
17
|
+
end(data?: string | Buffer): void
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module 'node:fs/promises' {
|
|
22
|
+
export function readFile(path: string): Promise<Buffer>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module 'node:path' {
|
|
26
|
+
export function join(...paths: string[]): string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module 'node:url' {
|
|
30
|
+
export function fileURLToPath(url: URL): string
|
|
31
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `render_ui` tool: a model-facing channel that renders a GenUI spec as
|
|
3
|
+
* an interactive card in the conversation TOOL ROW (route A of the design
|
|
4
|
+
* doc). The ```dsh-ui fence channel renders inline in the reply; this tool
|
|
5
|
+
* renders in the tool row and rides the harness's result `meta` projection:
|
|
6
|
+
* `presentationMeta` stores the repaired spec, the browser toolview
|
|
7
|
+
* (`src/client/toolview.tsx`) reads it from the result node and renders.
|
|
8
|
+
*
|
|
9
|
+
* Zero runtime harness imports, deliberately: an external plugin's node half
|
|
10
|
+
* must not depend on the harness module graph at runtime (the profile
|
|
11
|
+
* resolves only the plugin package itself). The definition is therefore a
|
|
12
|
+
* plain `ToolDefinition` object — the exact shape `defineTool` returns — with
|
|
13
|
+
* the arguments schema authored as JSON Schema (the harness validates args
|
|
14
|
+
* and output with the same JSON Schema validator defineTool uses). Deep
|
|
15
|
+
* validation, deterministic repair, and resource limits live in the shared
|
|
16
|
+
* guard (`src/client/guard.ts`), which the schema deliberately stays loose
|
|
17
|
+
* enough to reach.
|
|
18
|
+
* @module @changfenhuang/dsh-genui/plugin/tool
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
23
|
+
import type { GenericCallView, GenericResultView, JsonSchemaNode, ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
24
|
+
import type { GenuiSpec } from '../client/spec.ts'
|
|
25
|
+
import { GENUI_LIMITS, countDeclaredGenuiNodes, countGenuiNodes, repairGenuiSpec } from '../client/guard.ts'
|
|
26
|
+
import { completeFenceJson } from '../shared/fence-repair.ts'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Arguments schema: an open `spec` slot. The schema must NOT reject anything
|
|
30
|
+
* the guard could repair — the model's component trees are imperfect by
|
|
31
|
+
* nature, and the guard heals them; argument validation would only strand
|
|
32
|
+
* them. `additionalProperties: false` keeps the call shape honest.
|
|
33
|
+
*
|
|
34
|
+
* `spec` IS typed `object` on purpose: the guard can only repair plain
|
|
35
|
+
* records (a serialized JSON string, array, or scalar root is unusable), so
|
|
36
|
+
* argument validation rejecting non-objects loses nothing repairable — and
|
|
37
|
+
* it stops the model from double-encoding the tree as a string (observed
|
|
38
|
+
* twice in the wild), failing fast with a clear schema error instead.
|
|
39
|
+
*/
|
|
40
|
+
const RENDER_UI_PARAMETERS: Record<string, unknown> = {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
spec: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
description: 'GenUI component tree (white-listed vocabulary, see the dsh-ui fence section in the system prompt). Deep-validated and repaired by the renderer. Pass the spec as a JSON OBJECT — never as a serialized JSON string (a string fails argument validation).',
|
|
46
|
+
// Structural hints for the tool-call bridge. A bare `object` here was
|
|
47
|
+
// observed to make some bridge layers stringify the whole spec into an
|
|
48
|
+
// OpenAI-style `{ arguments: "<JSON>" }` wrapper (and to corrupt long
|
|
49
|
+
// specs mid-stream). Declaring the known top-level fields gives the
|
|
50
|
+
// bridge a concrete shape to serialize directly, while the schema stays
|
|
51
|
+
// deliberately open (unknown keys tolerated, `items` elements are free
|
|
52
|
+
// objects) so the guard — not the schema — remains the repair authority.
|
|
53
|
+
properties: {
|
|
54
|
+
title: { type: 'string', description: 'Short title shown as the card banner.' },
|
|
55
|
+
gap: { type: 'number', description: 'Vertical gap between root items in px.' },
|
|
56
|
+
panel: { type: 'boolean', description: 'Panel-only: renders into the session panel dock instead of the message flow.' },
|
|
57
|
+
items: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
description: 'Root component list (white-listed vocabulary).',
|
|
60
|
+
items: { type: 'object' },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ['spec'],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The tool's canonical value is a short model-facing summary string. */
|
|
70
|
+
const RENDER_UI_OUTPUT_SCHEMA: JsonSchemaNode = { type: 'string', description: 'One-line human-readable render summary for the model.' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the `spec` argument defensively (presenters run on replayed args).
|
|
74
|
+
*
|
|
75
|
+
* The harness tool-call bridge has been observed to deliver arguments in
|
|
76
|
+
* shapes other than the authored `{ spec: <object> }`:
|
|
77
|
+
* - `{ spec: "<JSON string>" }` — spec serialized to text;
|
|
78
|
+
* - `{ arguments: "<JSON string>" }` / `{ arguments: <object> }` — a
|
|
79
|
+
* double-encoded wrapper from the SDK tool-call bridge (seen live in the
|
|
80
|
+
* web GUI: small specs arrived wrapped this way, large specs arrived with
|
|
81
|
+
* their JSON corrupted mid-stream);
|
|
82
|
+
* - a bare JSON string (double-encoded root).
|
|
83
|
+
* Each shape is unwrapped here so the guard can repair the actual tree.
|
|
84
|
+
* Corrupted JSON cannot be recovered (bytes were lost in transit): it yields
|
|
85
|
+
* `undefined` plus a diagnostic log line for the transport-layer bug.
|
|
86
|
+
*/
|
|
87
|
+
function specOf(args: unknown): unknown {
|
|
88
|
+
if (typeof args === 'string') {
|
|
89
|
+
return parseSpecJson(args, 'bare-string')
|
|
90
|
+
}
|
|
91
|
+
if (typeof args !== 'object' || args === null) return undefined
|
|
92
|
+
const record = args as Record<string, unknown>
|
|
93
|
+
if ('spec' in record) {
|
|
94
|
+
const s = record.spec
|
|
95
|
+
if (typeof s === 'string') return parseSpecJson(s, 'spec-string')
|
|
96
|
+
return unwrapSpec(s, 'spec')
|
|
97
|
+
}
|
|
98
|
+
if ('arguments' in record) {
|
|
99
|
+
const a = record.arguments
|
|
100
|
+
if (typeof a === 'string') return parseSpecJson(a, 'arguments-string')
|
|
101
|
+
if (typeof a === 'object' && a !== null) return unwrapSpec(a, 'arguments')
|
|
102
|
+
}
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Peel nested `{ spec: ... }` wrapper layers. Observed bridge shapes nest the
|
|
108
|
+
* authored `spec` object one or more levels deep (e.g. the serialized text
|
|
109
|
+
* inside `{ arguments: "..." }` is itself `{ spec: { title, gap, items } }`),
|
|
110
|
+
* so unwrapping stops only at a value that carries no `spec` key.
|
|
111
|
+
*/
|
|
112
|
+
function unwrapSpec(value: unknown, shape: string): unknown {
|
|
113
|
+
if (typeof value === 'object' && value !== null) {
|
|
114
|
+
const record = value as Record<string, unknown>
|
|
115
|
+
if ('spec' in record) {
|
|
116
|
+
const s = record.spec
|
|
117
|
+
if (typeof s === 'string') return parseSpecJson(s, `${shape}/spec-string`)
|
|
118
|
+
return unwrapSpec(s, `${shape}/spec`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Try to decode a serialized spec; log a diagnostic when it is broken. */
|
|
125
|
+
function parseSpecJson(raw: string, shape: string): unknown {
|
|
126
|
+
try {
|
|
127
|
+
return unwrapSpec(JSON.parse(raw), shape)
|
|
128
|
+
} catch (error: unknown) {
|
|
129
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
130
|
+
const pos = /position (\d+)/.exec(detail)?.[1] ?? '?'
|
|
131
|
+
console.error(`[genui-tool] spec wrapped as ${shape} but its JSON is broken (${raw.length} bytes, error at ${pos}); cannot recover — bytes lost in transit`)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Total node count of a repaired spec — the shared guard traversal
|
|
137
|
+
* (`countGenuiNodes`) so the tool reports the SAME number the panel fold and
|
|
138
|
+
* validation use. A local walker used to under-count specs whose content
|
|
139
|
+
* lives inside tabs/accordion/file-tree (their children are not `.items`). */
|
|
140
|
+
function countNodes(spec: GenuiSpec): number {
|
|
141
|
+
return countGenuiNodes(spec, GENUI_LIMITS.maxNodes)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Tool-call title shared by the pending and completed presentations. */
|
|
145
|
+
function cardTitle(args: unknown): string | undefined {
|
|
146
|
+
const spec = repairGenuiSpec(specOf(args))
|
|
147
|
+
return spec === null ? undefined : `渲染 UI:${spec.title ?? '未命名'}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the render_ui tool definition. Registered by the plugin node half;
|
|
152
|
+
* `ctx.tools.register` consumes it exactly like a `defineTool` result.
|
|
153
|
+
*/
|
|
154
|
+
export function createRenderUiTool(): ToolDefinition {
|
|
155
|
+
return {
|
|
156
|
+
name: 'render_ui',
|
|
157
|
+
description:
|
|
158
|
+
'Render an interactive UI card in the conversation tool row by passing a GenUI spec (a white-listed component tree; the same vocabulary as the ```dsh-ui fence, see the system prompt). '
|
|
159
|
+
+ 'Use it when the user asks for a structured panel, dashboard, or form that belongs in the tool row rather than inline in the reply. '
|
|
160
|
+
+ 'The card is interactive client-side (tabs, buttons, inputs, switches); components carrying an "action" field send [genui-action] back to you when the user interacts, and you should re-render the updated UI.',
|
|
161
|
+
parameters: RENDER_UI_PARAMETERS,
|
|
162
|
+
output: {
|
|
163
|
+
schema: RENDER_UI_OUTPUT_SCHEMA,
|
|
164
|
+
render(_args: unknown, value: JsonValue): ContentBlock[] {
|
|
165
|
+
return [{ type: 'text', text: String(value) }]
|
|
166
|
+
},
|
|
167
|
+
presentationMeta(args: unknown): JsonValue {
|
|
168
|
+
// The browser toolview reads the repaired spec from result meta. The
|
|
169
|
+
// spec is JSON-safe by construction (only string/number/boolean/array
|
|
170
|
+
// fields after repair), so the widening cast is lossless.
|
|
171
|
+
return repairGenuiSpec(specOf(args)) as unknown as JsonValue
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
async execute(args: unknown): Promise<JsonValue> {
|
|
175
|
+
const spec = repairGenuiSpec(specOf(args))
|
|
176
|
+
if (spec === null) {
|
|
177
|
+
return 'render_ui:spec 无效 —— 根对象需要 "items" 数组(组件树白名单见系统提示词),请修正后重试。'
|
|
178
|
+
}
|
|
179
|
+
const title = spec.title ?? '未命名'
|
|
180
|
+
return `已渲染 UI「${title}」(${countNodes(spec)} 个组件)。用户现在可以看到这张卡片;组件带 action 时,用户交互会以 [genui-action] 消息发回给你,届时请重新渲染更新后的界面。`
|
|
181
|
+
},
|
|
182
|
+
presentCall(args: unknown): GenericCallView | undefined {
|
|
183
|
+
const title = cardTitle(args)
|
|
184
|
+
return title === undefined ? undefined : { card: 'generic', title, kind: 'other' }
|
|
185
|
+
},
|
|
186
|
+
presentResult(args: unknown): GenericResultView | undefined {
|
|
187
|
+
const title = cardTitle(args)
|
|
188
|
+
return title === undefined ? undefined : { card: 'generic', title }
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The `validate_dsh_ui` tool: a model-facing pre-flight check for the
|
|
195
|
+
* ```dsh-ui fence channel. The model calls it with the JSON text it is about
|
|
196
|
+
* to put inside a fence; it reports whether the body parses as a valid GenUI
|
|
197
|
+
* spec, and when it does not, WHERE it breaks and WHAT is likely wrong
|
|
198
|
+
* (bracket counts, common typo classes) so the model can fix and re-validate
|
|
199
|
+
* before emitting — turning "render a red banner after the fact" into
|
|
200
|
+
* "verify before you send". Purely local: no LLM, no network, no DOM.
|
|
201
|
+
*/
|
|
202
|
+
const VALIDATE_DESCRIPTION =
|
|
203
|
+
'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). '
|
|
204
|
+
+ 'Pass the exact JSON text you are about to put inside the fence as the "spec" argument (a string). '
|
|
205
|
+
+ 'Returns ✅ when it parses as a valid GenUI spec, or ❌ with the exact position, bracket counts, and likely causes when it does not — fix the JSON, re-validate, and only then emit the fence. '
|
|
206
|
+
+ 'When the JSON is broken but repairable (unescaped quotes, trailing commas, missing closers), the ❌ reply INCLUDES the auto-repaired JSON — copy it verbatim into the fence instead of rewriting by hand.'
|
|
207
|
+
|
|
208
|
+
const VALIDATE_PARAMETERS: Record<string, unknown> = {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
spec: {
|
|
212
|
+
oneOf: [
|
|
213
|
+
{ type: 'string', description: 'The exact JSON text of the fence body.' },
|
|
214
|
+
{ type: 'object', description: 'The spec object (serialized before validation).' },
|
|
215
|
+
],
|
|
216
|
+
description: 'The dsh-ui fence body to validate: pass the JSON as a string for an exact check, or as the spec object.',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
required: ['spec'],
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Read the fence-body text from the call args (string preferred, object serialized). */
|
|
224
|
+
function fenceTextOf(args: unknown): string | null {
|
|
225
|
+
if (typeof args === 'string') return args
|
|
226
|
+
if (typeof args !== 'object' || args === null) return null
|
|
227
|
+
const record = args as Record<string, unknown>
|
|
228
|
+
const s = 'spec' in record ? record.spec : 'arguments' in record ? record.arguments : undefined
|
|
229
|
+
if (typeof s === 'string') return s
|
|
230
|
+
if (typeof s === 'object' && s !== null) return JSON.stringify(s)
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Count structural brackets outside string literals. */
|
|
235
|
+
function bracketCounts(raw: string): { '{': number; '}': number; '[': number; ']': number } {
|
|
236
|
+
const counts = { '{': 0, '}': 0, '[': 0, ']': 0 }
|
|
237
|
+
let inString = false
|
|
238
|
+
let escaped = false
|
|
239
|
+
for (let i = 0; i < raw.length; i++) {
|
|
240
|
+
const ch = raw[i]!
|
|
241
|
+
if (escaped) {
|
|
242
|
+
escaped = false
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (inString) {
|
|
246
|
+
if (ch === '\\') escaped = true
|
|
247
|
+
else if (ch === '"') inString = false
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (ch === '"') {
|
|
251
|
+
inString = true
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (ch === '{') counts['{'] += 1
|
|
255
|
+
else if (ch === '}') counts['}'] += 1
|
|
256
|
+
else if (ch === '[') counts['['] += 1
|
|
257
|
+
else if (ch === ']') counts[']'] += 1
|
|
258
|
+
}
|
|
259
|
+
return counts
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Short structural hint from bracket counts (empty when balanced). */
|
|
263
|
+
function bracketDiagnostic(raw: string): string {
|
|
264
|
+
const c = bracketCounts(raw)
|
|
265
|
+
const diffs: string[] = []
|
|
266
|
+
if (c['{'] !== c['}']) {
|
|
267
|
+
const d = c['{'] - c['}']
|
|
268
|
+
diffs.push(`{ ×${c['{']} / } ×${c['}']} → ${d > 0 ? `缺 ${d} 个 }` : `多 ${-d} 个 }`}`)
|
|
269
|
+
}
|
|
270
|
+
if (c['['] !== c[']']) {
|
|
271
|
+
const d = c['['] - c[']']
|
|
272
|
+
diffs.push(`[ ×${c['[']} / ] ×${c[']']} → ${d > 0 ? `缺 ${d} 个 ]` : `多 ${-d} 个 ]`}`)
|
|
273
|
+
}
|
|
274
|
+
return diffs.length === 0 ? '' : ` 括号计数:${diffs.join(';')}(长表格最易在收尾处错位,如把 ]]}]} 写成 ]}]}]})\n`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const COMMON_CAUSES =
|
|
278
|
+
'常见原因:① 收尾括号错位/缺失({ 与 }、[ 与 ] 数量不相等)② 字符串值内用了半角引号 "(中文引语请用 “” 或 「」)③ 尾随逗号 ④ 字符串未闭合'
|
|
279
|
+
|
|
280
|
+
/** Build the validate_dsh_ui tool definition (registered alongside render_ui). */
|
|
281
|
+
export function createValidateDshUiTool(): ToolDefinition {
|
|
282
|
+
return {
|
|
283
|
+
name: 'validate_dsh_ui',
|
|
284
|
+
description: VALIDATE_DESCRIPTION,
|
|
285
|
+
parameters: VALIDATE_PARAMETERS,
|
|
286
|
+
output: {
|
|
287
|
+
schema: { type: 'string', description: 'Validation verdict for the model.' },
|
|
288
|
+
render(_args: unknown, value: JsonValue): ContentBlock[] {
|
|
289
|
+
return [{ type: 'text', text: String(value) }]
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
async execute(args: unknown): Promise<JsonValue> {
|
|
293
|
+
const raw = fenceTextOf(args)
|
|
294
|
+
if (raw === null || raw.trim() === '') {
|
|
295
|
+
return '❌ validate_dsh_ui:缺少 spec 参数 —— 把围栏 JSON 文本作为 spec 传入。'
|
|
296
|
+
}
|
|
297
|
+
let parsed: unknown
|
|
298
|
+
try {
|
|
299
|
+
parsed = JSON.parse(raw)
|
|
300
|
+
} catch (error: unknown) {
|
|
301
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
302
|
+
// Pre-emission, both repair tiers are safe (no streaming half exists
|
|
303
|
+
// in a validation call). When the repair succeeds, hand the model the
|
|
304
|
+
// FIXED JSON instead of asking it to re-author the fix — re-writing
|
|
305
|
+
// the whole fence by hand is where the next typo comes from.
|
|
306
|
+
const repaired = completeFenceJson(raw)
|
|
307
|
+
if (repaired !== null && repairGenuiSpec(JSON.parse(repaired.text) as unknown) !== null) {
|
|
308
|
+
return `❌ dsh-ui 围栏 JSON 解析失败:${detail}。\n${bracketDiagnostic(raw)} 已自动修复 ${repaired.repairs} 处,下面是修复后的 JSON,直接作为围栏正文发出即可(无需再验证):\n\`\`\`\n${repaired.text}\n\`\`\``
|
|
309
|
+
}
|
|
310
|
+
return `❌ dsh-ui 围栏 JSON 解析失败:${detail}。\n${bracketDiagnostic(raw)} 自动修复未能恢复(结构损坏),请按错误信息修正后重新调用本工具验证,通过后再发出围栏。\n${COMMON_CAUSES}`
|
|
311
|
+
}
|
|
312
|
+
const spec = repairGenuiSpec(parsed)
|
|
313
|
+
if (spec === null) {
|
|
314
|
+
return '❌ 不是合法 GenUI spec:根对象需要 "items" 数组,且每个节点 type 必须在白名单内(见系统提示词)。请修正后重新验证。'
|
|
315
|
+
}
|
|
316
|
+
const validCount = countNodes(spec)
|
|
317
|
+
const declaredCount = countDeclaredGenuiNodes(parsed, GENUI_LIMITS.maxNodes + 1)
|
|
318
|
+
if (declaredCount > validCount) {
|
|
319
|
+
return `❌ 验证未通过:检测到声明了 ${declaredCount} 个组件,但仅成功解析出 ${validCount} 个(有 ${declaredCount - validCount} 个组件因字段格式异常被丢弃)。常见原因:table 的 columns/rows 不是二维字符串数组、tabs 的 items/content 缺失、嵌套组件字段类型不符。请修正后重新验证。`
|
|
320
|
+
}
|
|
321
|
+
return `✅ dsh-ui spec 合法(${validCount} 个组件),可以发出围栏。`
|
|
322
|
+
},
|
|
323
|
+
presentCall(): GenericCallView | undefined {
|
|
324
|
+
return { card: 'generic', title: '验证 dsh-ui 围栏', kind: 'other' }
|
|
325
|
+
},
|
|
326
|
+
presentResult(): GenericResultView | undefined {
|
|
327
|
+
return { card: 'generic', title: '验证 dsh-ui 围栏' }
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
}
|