@changfenhuang/dsh-genui 0.11.4 → 0.12.0

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 (45) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -4
  3. package/README.zh-CN.md +12 -4
  4. package/SKILL.md +2 -2
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +9 -9
  7. package/lib/client.js +30 -30
  8. package/lib/index.js +101 -14
  9. package/lib/types/client/dom-fence.d.ts +2 -1
  10. package/lib/types/client/file-address.d.ts +4 -0
  11. package/lib/types/client/file-link-context.d.ts +41 -0
  12. package/lib/types/client/file-link.d.ts +17 -0
  13. package/lib/types/client/i18n/en.d.ts +5 -1
  14. package/lib/types/client/inline.d.ts +1 -1
  15. package/lib/types/client/mermaid-colors.d.ts +2 -0
  16. package/lib/types/client/panel.d.ts +6 -1
  17. package/lib/types/plugin/fence-feedback.d.ts +6 -2
  18. package/lib/types/plugin/index.d.ts +4 -0
  19. package/lib/types/shared/fence-repair.d.ts +1 -0
  20. package/package.json +1 -12
  21. package/src/client/GenuiBlock.module.css +35 -3
  22. package/src/client/artifact/ArtifactExport.module.css +34 -3
  23. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  24. package/src/client/blocks/charts.tsx +22 -6
  25. package/src/client/blocks/render-node.tsx +10 -7
  26. package/src/client/dom-fence.tsx +97 -30
  27. package/src/client/file-address.ts +37 -0
  28. package/src/client/file-link-context.tsx +114 -0
  29. package/src/client/file-link.ts +46 -0
  30. package/src/client/guard.ts +5 -2
  31. package/src/client/i18n/en.ts +5 -1
  32. package/src/client/i18n/zh.ts +5 -1
  33. package/src/client/index.tsx +23 -5
  34. package/src/client/inline.ts +17 -6
  35. package/src/client/mermaid-colors.ts +39 -0
  36. package/src/client/mermaid-core.ts +7 -3
  37. package/src/client/panel.tsx +18 -5
  38. package/src/plugin/fence-feedback.ts +17 -5
  39. package/src/plugin/index.ts +14 -4
  40. package/src/plugin/tool.ts +8 -2
  41. package/src/shared/fence-repair.ts +36 -0
  42. package/tsdown.config.ts +3 -7
  43. package/lib/invariant.js +0 -19
  44. package/lib/types/plugin/invariant.d.ts +0 -15
  45. package/src/plugin/invariant.ts +0 -30
@@ -288,6 +288,10 @@ export const EN = {
288
288
  // ------------------------------------------------- component chrome
289
289
  'block.copyMarkdown': 'Copy Markdown',
290
290
  'block.copyCsv': 'Copy CSV',
291
+ 'block.table.expandDetails': 'Expand details for {label}',
292
+ 'block.table.collapseDetails': 'Collapse details for {label}',
293
+ 'block.table.expandGroup': 'Expand group {label}',
294
+ 'block.table.collapseGroup': 'Collapse group {label}',
291
295
  'block.total': 'Total',
292
296
  'block.filteredRows': '{visible} / {total} rows after filtering',
293
297
  'block.filterMatch': '{shown} / {total} matched',
@@ -303,7 +307,7 @@ export const EN = {
303
307
  'block.svgPreview': 'Preview',
304
308
  'block.svgSource': 'Source',
305
309
  'block.svgError': 'SVG could not be displayed — showing the source instead',
306
- 'block.mermaidError': 'Invalid diagram syntax — showing the source instead',
310
+ 'block.mermaidError': 'Diagram rendering failed — showing the source instead',
307
311
  'block.mermaidLoading': 'Rendering…',
308
312
  'block.scene3dLoading': 'Loading 3D scene…',
309
313
  'block.scene3dError': '3D rendering failed',
@@ -283,6 +283,10 @@ export const ZH: Record<keyof typeof EN, string> = {
283
283
  // ------------------------------------------------- component chrome
284
284
  'block.copyMarkdown': '复制 Markdown',
285
285
  'block.copyCsv': '复制 CSV',
286
+ 'block.table.expandDetails': '展开 {label} 的明细',
287
+ 'block.table.collapseDetails': '收起 {label} 的明细',
288
+ 'block.table.expandGroup': '展开分组 {label}',
289
+ 'block.table.collapseGroup': '收起分组 {label}',
286
290
  'block.total': '合计',
287
291
  'block.filteredRows': '筛选后 {visible} / {total} 行',
288
292
  'block.filterMatch': '匹配 {shown} / {total} 项',
@@ -298,7 +302,7 @@ export const ZH: Record<keyof typeof EN, string> = {
298
302
  'block.svgPreview': '预览',
299
303
  'block.svgSource': '源码',
300
304
  'block.svgError': 'SVG 无法显示,已降级显示源码',
301
- 'block.mermaidError': '图语法有误,已降级显示源码',
305
+ 'block.mermaidError': '图表渲染失败,已显示源码',
302
306
  'block.mermaidLoading': '渲染中…',
303
307
  'block.scene3dLoading': '加载 3D 场景…',
304
308
  'block.scene3dError': '3D 渲染失败',
@@ -26,9 +26,11 @@ import type {} from '@deepseek-ai/dsh-api-session-controller/client'
26
26
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
27
27
  import type { SessionId } from '@deepseek-ai/dsh-session/types'
28
28
  import type { IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
29
- import type { Key, ReactNode } from 'react'
29
+ import { createElement, type Key, type ReactNode } from 'react'
30
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
30
31
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
31
32
  import { installDomFenceRenderer } from './dom-fence.tsx'
33
+ import { createFileLinkAvailability, withFileLinkContext, type FileLinkCordisContext } from './file-link-context.tsx'
32
34
  import { renderGenuiFence, type GenuiFenceContext } from './fence-render.tsx'
33
35
  import { renderSvgFence } from './svg-fence.tsx'
34
36
  import { createPanelSlashSource } from './panel-command.ts'
@@ -151,11 +153,18 @@ export function apply(ctx: Context): () => void {
151
153
  const localeDispose = bridgeHostLocale(ctx)
152
154
  const registerFn = (primitives as unknown as HostFenceExt).registerFenceRenderer
153
155
  const useRegistry = typeof registerFn === 'function' && !forcedDomChannel()
156
+ const fileLinkAvailability = createFileLinkAvailability(ctx as unknown as FileLinkCordisContext)
154
157
  const channel = useRegistry ? 'registry' : 'dom'
155
158
  console.info(`[genui] client active; fence-channel=${channel}`)
156
159
  const disposers: Array<() => void> = useRegistry
157
- ? [localeDispose, registerFn!('dsh-ui', renderGenuiFence), registerFn!('svg', renderSvgFence)]
158
- : [localeDispose, installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload))]
160
+ ? [localeDispose, registerFn!('dsh-ui', (raw, key, context) => withFileLinkContext(
161
+ ctx as unknown as FileLinkCordisContext,
162
+ fileLinkAvailability,
163
+ context?.sessionId,
164
+ context?.source === undefined,
165
+ renderGenuiFence(raw, key, context),
166
+ )), registerFn!('svg', renderSvgFence)]
167
+ : [localeDispose, installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload), fileLinkAvailability)]
159
168
  // Idle prefetch of the lazy engine assets: the browser downloads them at
160
169
  // LOW priority whenever the page is idle, so the first mermaid/3D node in
161
170
  // a session usually hits a warm cache instead of paying the fetch on first
@@ -170,7 +179,13 @@ export function apply(ctx: Context): () => void {
170
179
  disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
171
180
  name: 'tool.call.toolview',
172
181
  key: 'render_ui',
173
- }, GenuiToolView)))
182
+ }, (props: ToolCallViewProps) => withFileLinkContext(
183
+ ctx as unknown as FileLinkCordisContext,
184
+ fileLinkAvailability,
185
+ props.sessionId,
186
+ false,
187
+ createElement(GenuiToolView, props),
188
+ ))))
174
189
  // Session panel dock: a session-scoped, always-present seat above the
175
190
  // composer (TodoDock posture). Renders the session's latest render_ui
176
191
  // spec in place; absent spec = no panel.
@@ -178,7 +193,10 @@ export function apply(ctx: Context): () => void {
178
193
  name: 'conversation.input.dock',
179
194
  id: 'genui-panel',
180
195
  order: 50,
181
- inject: (sessionId: SessionId): GenuiPanelInjected => panelActionSend(ctx, sessionId),
196
+ inject: (sessionId: SessionId): GenuiPanelInjected => ({
197
+ ...panelActionSend(ctx, sessionId),
198
+ fileLinkContext: { ctx: ctx as unknown as FileLinkCordisContext, availability: fileLinkAvailability },
199
+ }),
182
200
  }, GenuiPanel)))
183
201
  // /panel slash command: a deterministic, client-side entry point that
184
202
  // opens the panel dock (publishes the default spec + expand request),
@@ -2,7 +2,8 @@
2
2
  import { createElement, useLayoutEffect, useRef, type ReactNode } from 'react'
3
3
  import katex from 'katex'
4
4
  import css from './GenuiBlock.module.css'
5
- import { safeHref } from './genui-runtime/value-utils.ts'
5
+ import { classifyLinkTarget } from './file-link.ts'
6
+ import { FileLink } from './file-link-context.tsx'
6
7
 
7
8
  /** KaTeX owns this span's children; React owns the span and its lifecycle.
8
9
  * The host's ui-primitives already supplies KaTeX CSS/fonts, including embeds. */
@@ -35,7 +36,7 @@ export function hasInlineMarkup(text: string): boolean {
35
36
  }
36
37
 
37
38
  /** Render safe phrasing content, usable in headings, buttons and labels too. */
38
- export function renderInline(text: string, allowLinks = true, depth = 0): ReactNode {
39
+ export function renderInline(text: string, allowLinks: boolean | 'file' = true, depth = 0): ReactNode {
39
40
  if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text) || depth >= 8) return text
40
41
  const segments: ReactNode[] = []
41
42
  let fenceEnd = 0
@@ -93,10 +94,20 @@ export function renderInline(text: string, allowLinks = true, depth = 0): ReactN
93
94
  if (parts === null) {
94
95
  out.push(token)
95
96
  } else {
96
- const href = safeHref(parts[2])
97
- out.push(href === undefined || !allowLinks ? renderInline(parts[1]!, false, depth + 1) : createElement('a', {
98
- key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
99
- }, renderInline(parts[1]!, false, depth + 1)))
97
+ const target = classifyLinkTarget(parts[2])
98
+ const label = renderInline(parts[1]!, false, depth + 1)
99
+ if (target.type === 'external' && allowLinks === true) {
100
+ out.push(createElement('a', {
101
+ key: key++, className: css.inlineLink, href: target.href, target: '_blank', rel: 'noreferrer noopener',
102
+ }, label))
103
+ } else if (target.type === 'file' && allowLinks !== false) {
104
+ out.push(createElement(FileLink, {
105
+ key: key++, path: target.file.path, label: parts[1]!,
106
+ ...(target.file.line === undefined ? {} : { line: target.file.line }),
107
+ }, label))
108
+ } else {
109
+ out.push(label)
110
+ }
100
111
  }
101
112
  }
102
113
  last = index + token.length
@@ -0,0 +1,39 @@
1
+ /** Resolve host CSS colors before passing them to Mermaid's limited parser. */
2
+ export function readMermaidThemeColor(name: string, fallback: string): string {
3
+ if (typeof document === 'undefined' || document.body === null) return fallback
4
+ const value = getComputedStyle(document.body).getPropertyValue(name).trim()
5
+ if (value === '') return fallback
6
+
7
+ // Computed custom properties still contain calc(), unlike computed color.
8
+ // A real color property also resolves currentColor and inherited variables.
9
+ const probe = document.createElement('span')
10
+ probe.style.setProperty('color', value, 'important')
11
+ if (probe.style.color === '') return fallback
12
+ probe.style.setProperty('display', 'none', 'important')
13
+ document.body.appendChild(probe)
14
+ let color: string
15
+ try {
16
+ color = getComputedStyle(probe).color
17
+ } finally {
18
+ probe.remove()
19
+ }
20
+ if (/^rgba?\([\d.,\s]+\)$/i.test(color)) return color
21
+
22
+ // Modern colors such as oklch() and color-mix() may remain in those formats
23
+ // after style resolution. Canvas converts them to the sRGB Mermaid accepts.
24
+ const canvas = document.createElement('canvas')
25
+ canvas.width = canvas.height = 1
26
+ const context = canvas.getContext('2d')
27
+ if (context === null) return fallback
28
+ context.fillStyle = '#010203'
29
+ const initialFillStyle = context.fillStyle
30
+ context.fillStyle = color
31
+ if (context.fillStyle === initialFillStyle) return fallback
32
+ try {
33
+ context.fillRect(0, 0, 1, 1)
34
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data
35
+ return `rgba(${r}, ${g}, ${b}, ${a! / 255})`
36
+ } catch {
37
+ return fallback
38
+ }
39
+ }
@@ -16,6 +16,7 @@
16
16
  * @module @changfenhuang/dsh-genui/client/mermaid-core
17
17
  */
18
18
  import { assertSafeSvg, ensureFlowchartKind, repairMermaidSource } from './mermaid-safe.ts'
19
+ import { readMermaidThemeColor } from './mermaid-colors.ts'
19
20
 
20
21
  let mermaidPromise: Promise<typeof import('mermaid')> | null = null
21
22
 
@@ -30,10 +31,13 @@ function loadMermaid(): Promise<typeof import('mermaid')> {
30
31
  // strokes and their own font, which sat next to our components as a
31
32
  // visibly foreign object. `base` + themeVariables is the supported way to
32
33
  // drive every colour (the dark/light branch is gone with it).
34
+ const colors = new Map<string, string>()
33
35
  const token = (name: string, fallback: string): string => {
34
- if (typeof document === 'undefined') return fallback
35
- const value = getComputedStyle(document.body).getPropertyValue(name).trim()
36
- return value === '' ? fallback : value
36
+ const cached = colors.get(name)
37
+ if (cached !== undefined) return cached
38
+ const color = readMermaidThemeColor(name, fallback)
39
+ colors.set(name, color)
40
+ return color
37
41
  }
38
42
  api.initialize({
39
43
  startOnLoad: false,
@@ -26,6 +26,7 @@
26
26
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
27
27
  import type {} from '@deepseek-ai/dsh-client-ui-session/client'
28
28
  import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
29
+ import { withFileLinkContext, type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx'
29
30
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
30
31
  import { ExportableGenuiBlock } from './artifact/ExportableGenuiBlock.tsx'
31
32
  import { ErrorBoundary } from './ErrorBoundary.tsx'
@@ -50,6 +51,7 @@ import { recordPanel, recordTemplateUse } from './achievement-store.ts'
50
51
  export interface GenuiPanelInjected {
51
52
  sessionId: string
52
53
  sendGenuiAction: GenuiActionHandler
54
+ fileLinkContext?: { ctx: FileLinkCordisContext; availability: FileLinkAvailability }
53
55
  /** Template center "try it": insert the template instruction into the
54
56
  * current composer draft (standard conversation.input.for channel). */
55
57
  insertTemplate: (text: string) => void
@@ -63,7 +65,7 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
63
65
  * default so the dock never steals the message flow's scroll room; the
64
66
  * header always shows the current panel title.
65
67
  */
66
- export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps) {
68
+ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate, fileLinkContext }: GenuiPanelProps) {
67
69
  const t = useT()
68
70
  const spec = useSyncExternalStore(subscribePanel, () => getPanelSpec(sessionId))
69
71
  const expandToken = useSyncExternalStore(subscribePanelExpand, () => getPanelExpandToken(sessionId))
@@ -271,10 +273,21 @@ export function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: Genui
271
273
  />
272
274
  ) : spec !== null ? (
273
275
  <GenuiActionContext.Provider value={sendGenuiAction}>
274
- <ErrorBoundary label={t('panel.boundary')}>
275
- {/* content-fingerprinted: same panel spec re-published restores its state */}
276
- <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
277
- </ErrorBoundary>
276
+ {fileLinkContext === undefined
277
+ ? <ErrorBoundary label={t('panel.boundary')}>
278
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
279
+ <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
280
+ </ErrorBoundary>
281
+ : withFileLinkContext(
282
+ fileLinkContext.ctx,
283
+ fileLinkContext.availability,
284
+ sessionId,
285
+ false,
286
+ <ErrorBoundary label={t('panel.boundary')}>
287
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
288
+ <ExportableGenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
289
+ </ErrorBoundary>,
290
+ )}
278
291
  </GenuiActionContext.Provider>
279
292
  ) : null}
280
293
  </div>
@@ -123,9 +123,10 @@ export function fenceFailures(text: string): FenceFailure[] {
123
123
 
124
124
  /** `null` when this fence renders; otherwise the reason it does not. */
125
125
  function fenceFailureDetail(fence: ExtractedFence): string | null {
126
- if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
127
126
  const resolution = resolveFence(fence.raw, { settled: true })
128
127
  if (resolution.spec !== null) return null
128
+ // 缺少 Markdown 收尾不等于渲染失败;先复用 renderer 判断,避免把已显示的卡片重发(#288)。
129
+ if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
129
130
  if (resolution.processed !== null) {
130
131
  const dropped = droppedNodeFailure(resolution.processed, resolution.value)
131
132
  return dropped?.join('\n')
@@ -243,6 +244,8 @@ export interface FenceFeedbackPlanInput {
243
244
  readonly correctionsThisTurn?: number | undefined
244
245
  /** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
245
246
  readonly correctionsTurn?: number | undefined
247
+ /** Whether the host has registered the `render_ui` tool. Defaults to enabled. */
248
+ readonly renderUiToolEnabled?: boolean | undefined
246
249
  }
247
250
 
248
251
  /** A correction the caller must account for before steering. */
@@ -289,7 +292,12 @@ export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackP
289
292
  // then delivered nothing, remind the model once for this turn.
290
293
  if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null
291
294
  if (input.deliveryRemindedTurns?.has(input.turn) === true) return null
292
- return { text: missingBodyCorrectionText(input.turn, used + 1), fingerprints: [], turn: input.turn, kind: 'delivery' }
295
+ return {
296
+ text: missingBodyCorrectionText(input.turn, used + 1, input.renderUiToolEnabled ?? true),
297
+ fingerprints: [],
298
+ turn: input.turn,
299
+ kind: 'delivery',
300
+ }
293
301
  }
294
302
 
295
303
  /**
@@ -298,12 +306,14 @@ export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackP
298
306
  *
299
307
  * @param turn - turn that reached the boundary.
300
308
  * @param attempt - correction number within the shared turn budget.
309
+ * @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
301
310
  * @returns the message text to steer into the running turn.
302
311
  */
303
- export function missingBodyCorrectionText(turn: number, attempt = 1): string {
312
+ export function missingBodyCorrectionText(turn: number, attempt = 1, renderUiToolEnabled = true): string {
304
313
  const head = `${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`
305
314
  const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)`
306
- return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
315
+ const renderUiInstruction = renderUiToolEnabled ? ',或明确调用 render_ui' : ''
316
+ return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏${renderUiInstruction}。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
307
317
  }
308
318
 
309
319
  /**
@@ -471,8 +481,9 @@ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefin
471
481
  *
472
482
  * @param ctx - 宿主 Context。
473
483
  * @param enabled - 是否启用同回合围栏修正。
484
+ * @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
474
485
  */
475
- export function installFenceFeedback(ctx: Context, enabled: boolean): void {
486
+ export function installFenceFeedback(ctx: Context, enabled: boolean, renderUiToolEnabled = true): void {
476
487
  const sessions = new Map<string, SessionFeedback>()
477
488
  const stateOf = (sessionId: string): SessionFeedback => {
478
489
  let state = sessions.get(sessionId)
@@ -637,6 +648,7 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
637
648
  aborted: signal.aborted,
638
649
  correctionsThisTurn: usedThisTurn,
639
650
  correctionsTurn: state.correctionsTurn,
651
+ renderUiToolEnabled,
640
652
  })
641
653
  if (plan === null) return
642
654
  // Account BEFORE sending: a re-entrant boundary must not deliver twice.
@@ -83,6 +83,8 @@ async function serveGenuiAsset(req: IncomingMessage, res: ServerResponse): Promi
83
83
  }
84
84
  }
85
85
 
86
+ const RENDER_UI_TOOL_GUIDANCE = '- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。'
87
+
86
88
  /** The fence language description injected into every assembled system prompt.
87
89
  * Deliberately slim: the `genui` skill carries the full component→field
88
90
  * mapping; this section keeps only the contract that must always be
@@ -123,7 +125,7 @@ Rules:
123
125
  - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
124
126
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
125
127
  - Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
126
- - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。
128
+ ${RENDER_UI_TOOL_GUIDANCE}
127
129
  - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
128
130
  - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`
129
131
 
@@ -189,21 +191,29 @@ export interface GenuiPluginConfig {
189
191
  * 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
190
192
  */
191
193
  fenceFeedback?: boolean
194
+
195
+ /**
196
+ * 是否注册 render_ui 模型工具。默认开启,设置为 false 时关闭;不影响 validate_dsh_ui 和 dsh-ui 围栏渲染。
197
+ */
198
+ renderUiTool?: boolean
192
199
  }
193
200
 
194
201
  export function apply(ctx: Context, config?: GenuiPluginConfig): void {
202
+ const renderUiToolEnabled = config?.renderUiTool !== false
195
203
  ctx.systemPrompt.section({
196
204
  name: 'genui:fence',
197
205
  order: ctx.systemPrompt.getSectionOrder('STRUCTURED_OUTPUT'),
198
- text: GENUI_SECTION_TEXT,
206
+ text: renderUiToolEnabled ? GENUI_SECTION_TEXT : GENUI_SECTION_TEXT.replace(`${RENDER_UI_TOOL_GUIDANCE}\n`, ''),
199
207
  })
200
- installFenceFeedback(ctx, config?.fenceFeedback !== false)
208
+ installFenceFeedback(ctx, config?.fenceFeedback !== false, renderUiToolEnabled)
201
209
  // Hosts without tool access keep the fence channel. The dependency fiber
202
210
  // starts whenever tools becomes available and unloads its registrations
203
211
  // before either the service or this plugin is replaced.
204
212
  ctx.inject(['tools'], (toolsCtx) => {
205
213
  toolsCtx.effect(function* () {
206
- yield toolsCtx.tools.register(createRenderUiTool())
214
+ if (renderUiToolEnabled) {
215
+ yield toolsCtx.tools.register(createRenderUiTool())
216
+ }
207
217
  yield toolsCtx.tools.register(createValidateDshUiTool())
208
218
  }, 'dsh-genui: model tools')
209
219
  })
@@ -162,6 +162,9 @@ function formatProcessWarnings(processed: GenuiProcessResult): string[] {
162
162
  ? `warning=alias_ignored path=${warning.path} canonical=${canonicalPath}`
163
163
  : `warning=alias_normalized path=${warning.path} canonical=${canonicalPath}`
164
164
  }
165
+ if (warning.kind === 'unknown-field') {
166
+ return `warning=unknown_field path=${JSON.stringify(warning.path)} detail=${JSON.stringify(warning.message)}`
167
+ }
165
168
  return `warning=process detail=${JSON.stringify(warning.message)}`
166
169
  })
167
170
  }
@@ -260,6 +263,7 @@ const VALIDATE_DESCRIPTION =
260
263
  + 'Pass the exact JSON text you are about to put inside the fence as the "spec" argument (a string). '
261
264
  + 'Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. '
262
265
  + 'When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. '
266
+ + 'A warning=unknown_field requires fixing the indicated path and another validation call. '
263
267
  + 'A warning=block_markdown requires the indicated replacement node and another validation call.'
264
268
 
265
269
  const VALIDATE_PARAMETERS: Record<string, unknown> = {
@@ -368,6 +372,7 @@ export function createValidateDshUiTool(): ToolDefinition {
368
372
  if (processed.spec !== null && processed.errors.length === 0) {
369
373
  const warnings = formatProcessWarnings(processed)
370
374
  const inlineWarnings = formatInlineContentWarnings(processed.spec)
375
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
371
376
  return `${validationProtocol([
372
377
  'status=invalid',
373
378
  'error=invalid_json',
@@ -377,7 +382,7 @@ export function createValidateDshUiTool(): ToolDefinition {
377
382
  ...inlineWarnings,
378
383
  'repair=applied',
379
384
  `repair_count=${repaired.repairs}`,
380
- inlineWarnings.length === 0 ? 'next=emit_repaired_fence' : 'next=fix_and_revalidate',
385
+ requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_repaired_fence',
381
386
  ])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``
382
387
  }
383
388
  }
@@ -409,9 +414,10 @@ export function createValidateDshUiTool(): ToolDefinition {
409
414
  }
410
415
  const warnings = formatProcessWarnings(processed)
411
416
  const inlineWarnings = formatInlineContentWarnings(processed.spec)
417
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
412
418
  return validationProtocol([
413
419
  'status=valid', `rendered=${processed.renderedCount}`, ...warnings, ...inlineWarnings,
414
- inlineWarnings.length === 0 ? 'next=emit_fence' : 'next=fix_and_revalidate',
420
+ requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_fence',
415
421
  ])
416
422
  },
417
423
  presentCall(): GenericCallView | undefined {
@@ -51,6 +51,7 @@ export function describeJsonFailure(raw: string): string | null {
51
51
  * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
52
52
  * fail near that quote with "Expected ',' or ']'...".
53
53
  * 2. Trailing commas before `}` / `]` or at end of input.
54
+ * 3. Valueless object keys immediately before a complete key-and-colon pair.
54
55
  *
55
56
  * A shared grammar-aware scan distinguishes object keys from values and
56
57
  * checks the continuation after a potential string terminator. Ambiguous
@@ -92,6 +93,29 @@ function isJsonSpace(ch: string | undefined): boolean {
92
93
  return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
93
94
  }
94
95
 
96
+ /** Skip only complete, valueless object keys followed by a valued member. */
97
+ function skipValuelessKeys(raw: string, start: number, limit = raw.length): { start: number; repairs: number } | null {
98
+ let cursor = start
99
+ let repairs = 0
100
+ while (cursor < limit && raw[cursor] === '"') {
101
+ const keyStart = cursor++
102
+ while (cursor < limit && raw[cursor] !== '"') {
103
+ if (raw[cursor] === '\\') cursor += 2
104
+ else cursor++
105
+ }
106
+ if (cursor >= limit) return null
107
+ cursor++
108
+ try { JSON.parse(raw.slice(keyStart, cursor)) } catch { return null }
109
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
110
+ if (raw[cursor] === ':') return repairs > 0 ? { start: keyStart, repairs } : null
111
+ if (raw[cursor] !== ',') return null
112
+ repairs++
113
+ cursor++
114
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
115
+ }
116
+ return null
117
+ }
118
+
95
119
  /**
96
120
  * Quote closure is contextual: only keys may be followed by `:`, and a comma
97
121
  * must introduce the next member of the enclosing object/array. Walk closing
@@ -130,6 +154,10 @@ function quoteCanClose(raw: string, index: number, key: boolean, scopes: JsonSco
130
154
  if (raw[cursor] === scope.closer) return true // a removable trailing comma
131
155
  if (scope.closer === ']') return /["[{tfn\-0-9]/.test(raw[cursor]!)
132
156
  if (raw[cursor] !== '"') return false
157
+ // A valueless sibling is removable; it must not make a real value's
158
+ // closing quote absorb that sibling into the preceding string.
159
+ const valueless = skipValuelessKeys(raw, cursor, limit)
160
+ if (valueless !== null) cursor = valueless.start
133
161
  // An object comma must be followed by a quoted key and its colon. Escapes
134
162
  // in that key are skipped without changing them.
135
163
  cursor++
@@ -205,6 +233,14 @@ function scanFenceCandidate(raw: string, complete: boolean, contentQuotes: Reado
205
233
  }
206
234
  if (ch === '"') {
207
235
  key = scopes[scopes.length - 1]?.expecting === 'key'
236
+ if (key) {
237
+ const valueless = skipValuelessKeys(raw, i)
238
+ if (valueless !== null) {
239
+ repairs += valueless.repairs
240
+ i = valueless.start - 1
241
+ continue
242
+ }
243
+ }
208
244
  interiorQuote = false
209
245
  inString = true
210
246
  out += ch
package/tsdown.config.ts CHANGED
@@ -245,19 +245,15 @@ function assetConfig(name: 'mermaid' | 'three' | 'echarts', entry: string): User
245
245
 
246
246
  const libConfig: UserConfig = {
247
247
  name: ID,
248
- // Named entries keep the published layout stable (lib/index.js +
249
- // lib/invariant.js) while src/index.ts becomes the canonical bundle
250
- // source entry that plugin_check's tool-bundle contract expects.
251
- entry: { index: 'src/index.ts', invariant: 'src/plugin/invariant.ts' },
248
+ // Keep the root plugin bundle at lib/index.js, built from src/index.ts.
249
+ entry: { index: 'src/index.ts' },
252
250
  outDir: 'lib',
253
251
  format: ['esm'],
254
252
  platform: 'node',
255
253
  target: 'es2024',
256
254
  fixedExtension: false,
257
255
  dts: false,
258
- // Flatten entry outputs to lib/<basename>.js so package exports keep the
259
- // stable paths lib/index.js + lib/invariant.js (a directory-mirroring
260
- // default would emit lib/plugin/invariant.js).
256
+ // Keep the output name aligned with the package's root export.
261
257
  outputOptions: { entryFileNames: '[name].js' },
262
258
  // Cleanup happens in the build script (`rm -rf lib` before tsc): tsdown
263
259
  // must never wipe lib/types (tsc's declaration output) mid-pipeline.
package/lib/invariant.js DELETED
@@ -1,19 +0,0 @@
1
- //#region src/plugin/invariant.ts
2
- const PACKAGE_NAME = "@changfenhuang/dsh-genui";
3
- /** Cordis companion plugin name. */
4
- const name = "genui-invariant";
5
- /** Service required before the companion can reserve package ownership. */
6
- const inject = ["invariants"];
7
- /**
8
- * No runtime invariant: this host plugin only registers a static prompt
9
- * section; the section text is pinned by its own tests.
10
- */
11
- const install = () => {};
12
- /**
13
- * Register this package's invariant companion.
14
- * @param ctx - Cordis context carrying the invariant service.
15
- * @returns the installed registration's disposer after setup succeeds.
16
- */
17
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
18
- //#endregion
19
- export { apply, inject, name };
@@ -1,15 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@changfenhuang/dsh-genui`.
3
- * @module @changfenhuang/dsh-genui/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- /** Cordis companion plugin name. */
7
- export declare const name = "genui-invariant";
8
- /** Service required before the companion can reserve package ownership. */
9
- export declare const inject: string[];
10
- /**
11
- * Register this package's invariant companion.
12
- * @param ctx - Cordis context carrying the invariant service.
13
- * @returns the installed registration's disposer after setup succeeds.
14
- */
15
- export declare const apply: (ctx: Context) => Promise<() => void>;
@@ -1,30 +0,0 @@
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 */