@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. 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
+ }
@@ -0,0 +1,5 @@
1
+ /** CSS Modules: bundlers compile *.module.css to a hashed class map. */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }
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
+ }