@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.
- package/CHANGELOG.md +28 -0
- package/README.md +12 -4
- package/README.zh-CN.md +12 -4
- package/SKILL.md +2 -2
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +9 -9
- package/lib/client.js +30 -30
- package/lib/index.js +101 -14
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- package/lib/types/plugin/fence-feedback.d.ts +6 -2
- package/lib/types/plugin/index.d.ts +4 -0
- package/lib/types/shared/fence-repair.d.ts +1 -0
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +35 -3
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +22 -6
- package/src/client/blocks/render-node.tsx +10 -7
- package/src/client/dom-fence.tsx +97 -30
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/guard.ts +5 -2
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +17 -6
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- package/src/plugin/fence-feedback.ts +17 -5
- package/src/plugin/index.ts +14 -4
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +36 -0
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
package/src/client/i18n/en.ts
CHANGED
|
@@ -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': '
|
|
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',
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -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 渲染失败',
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
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',
|
|
158
|
-
|
|
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
|
-
},
|
|
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 =>
|
|
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),
|
package/src/client/inline.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
package/src/client/panel.tsx
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/plugin/tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
249
|
-
|
|
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
|
-
//
|
|
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>;
|
package/src/plugin/invariant.ts
DELETED
|
@@ -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 */
|