@changfenhuang/dsh-genui 0.9.8 → 0.9.9
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 +13 -0
- package/README.md +13 -8
- package/README.zh-CN.md +13 -8
- package/SKILL.md +5 -5
- package/lib/client.js +3 -3
- package/lib/index.js +1273 -338
- package/lib/types/client/blocks/advanced.d.ts +1 -1
- package/lib/types/client/blocks/checkbox.d.ts +17 -0
- package/lib/types/client/blocks/forms.d.ts +3 -8
- package/lib/types/client/blocks/state.d.ts +18 -14
- package/lib/types/client/dom-fence.d.ts +1 -1
- package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
- package/lib/types/client/genui-runtime/index.d.ts +1 -0
- package/lib/types/client/genui-runtime/limits.d.ts +54 -0
- package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
- package/lib/types/client/genui-runtime/schema.d.ts +73 -0
- package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
- package/lib/types/client/guard.d.ts +60 -71
- package/lib/types/client/interaction-store.d.ts +7 -2
- package/lib/types/client/panel-command.d.ts +1 -1
- package/lib/types/client/primitive-labels.d.ts +11 -0
- package/lib/types/client/spec.d.ts +17 -17
- package/lib/types/plugin/chart-contract.d.ts +6 -4
- package/lib/types/plugin/index.d.ts +1 -4
- package/package.json +74 -13
- package/src/client/GenuiBlock.tsx +27 -11
- package/src/client/blocks/advanced.tsx +6 -5
- package/src/client/blocks/charts.tsx +1 -1
- package/src/client/blocks/checkbox.tsx +60 -0
- package/src/client/blocks/diagram/index.tsx +1 -1
- package/src/client/blocks/forms.tsx +51 -28
- package/src/client/blocks/render-node.tsx +3 -16
- package/src/client/blocks/state.ts +18 -14
- package/src/client/dom-fence.tsx +2 -1
- package/src/client/fence-render.tsx +26 -15
- package/src/client/genui-runtime/diagnostics.ts +143 -0
- package/src/client/genui-runtime/index.ts +1 -0
- package/src/client/genui-runtime/limits.ts +54 -0
- package/src/client/genui-runtime/normalize.ts +115 -0
- package/src/client/genui-runtime/schema.ts +291 -0
- package/src/client/genui-runtime/value-utils.ts +67 -0
- package/src/client/guard.ts +314 -169
- package/src/client/index.tsx +3 -2
- package/src/client/interaction-store.ts +7 -2
- package/src/client/panel-command.ts +2 -1
- package/src/client/panel.tsx +1 -1
- package/src/client/primitive-labels.ts +37 -0
- package/src/client/spec.ts +17 -17
- package/src/client/toolview.tsx +2 -1
- package/src/index.ts +3 -6
- package/src/plugin/chart-contract.ts +7 -97
- package/src/plugin/index.ts +2 -6
- package/src/plugin/tool.ts +60 -40
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@changfenhuang/dsh-genui",
|
|
3
3
|
"description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/plugin/index.d.ts",
|
|
@@ -65,21 +65,82 @@
|
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-client-
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-
|
|
74
|
-
"@deepseek-ai/dsh-
|
|
75
|
-
"@deepseek-ai/dsh-
|
|
76
|
-
"@deepseek-ai/dsh-
|
|
77
|
-
"@deepseek-ai/dsh-
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
68
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
69
|
+
"@deepseek-ai/dsh-client-store": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-session": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-tool": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
78
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
79
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
80
|
+
"@deepseek-ai/dsh-session": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
81
|
+
"@deepseek-ai/dsh-skill": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
82
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
83
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
84
|
+
"@deepseek-ai/dsh-util-values": "^0.1.2-rc.1 || ^0.1.5-alpha.1",
|
|
80
85
|
"react": "^18.0.0 || ^19.0.0"
|
|
81
86
|
},
|
|
82
87
|
"devDependencies": {
|
|
88
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
89
|
+
"@deepseek-ai/cordis-plugin-include": "1.0.7",
|
|
90
|
+
"@deepseek-ai/cordis-plugin-loader": "1.0.3",
|
|
91
|
+
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
|
|
92
|
+
"@deepseek-ai/dsh-agent-default-model": "0.1.2-rc.1",
|
|
93
|
+
"@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
|
|
94
|
+
"@deepseek-ai/dsh-api-gateway": "0.1.2-rc.1",
|
|
95
|
+
"@deepseek-ai/dsh-api-session-controller": "0.1.2-rc.1",
|
|
96
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.2-rc.1",
|
|
97
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
|
|
98
|
+
"@deepseek-ai/dsh-brand": "0.1.2-rc.1",
|
|
99
|
+
"@deepseek-ai/dsh-client-connection": "0.1.2-rc.1",
|
|
100
|
+
"@deepseek-ai/dsh-client-store": "0.1.2-rc.1",
|
|
101
|
+
"@deepseek-ai/dsh-client-ui-chat": "0.1.2-rc.1",
|
|
102
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-rc.1",
|
|
103
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "0.1.2-rc.1",
|
|
104
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1",
|
|
105
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.1.2-rc.1",
|
|
106
|
+
"@deepseek-ai/dsh-client-ui-session": "0.1.2-rc.1",
|
|
107
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
|
|
108
|
+
"@deepseek-ai/dsh-client-ui-tool": "0.1.2-rc.1",
|
|
109
|
+
"@deepseek-ai/dsh-code-runtime": "0.1.2-rc.1",
|
|
110
|
+
"@deepseek-ai/dsh-credentials": "0.1.2-rc.1",
|
|
111
|
+
"@deepseek-ai/dsh-deque": "0.1.2-rc.1",
|
|
112
|
+
"@deepseek-ai/dsh-file-reference": "0.1.2-rc.1",
|
|
113
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
|
|
114
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
|
|
115
|
+
"@deepseek-ai/dsh-jobs": "0.1.2-rc.1",
|
|
116
|
+
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
117
|
+
"@deepseek-ai/dsh-native-command": "0.1.2-rc.1",
|
|
118
|
+
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
|
119
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
|
120
|
+
"@deepseek-ai/dsh-scope": "0.1.2-rc.1",
|
|
121
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
122
|
+
"@deepseek-ai/dsh-session-persistence": "0.1.2-rc.1",
|
|
123
|
+
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
|
|
124
|
+
"@deepseek-ai/dsh-session-projection-cache": "0.1.2-rc.1",
|
|
125
|
+
"@deepseek-ai/dsh-session-query": "0.1.2-rc.1",
|
|
126
|
+
"@deepseek-ai/dsh-session-title": "0.1.2-rc.1",
|
|
127
|
+
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
|
128
|
+
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
|
129
|
+
"@deepseek-ai/dsh-storage": "0.1.2-rc.1",
|
|
130
|
+
"@deepseek-ai/dsh-storage-domain": "0.1.2-rc.1",
|
|
131
|
+
"@deepseek-ai/dsh-subagent": "0.1.2-rc.1",
|
|
132
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
|
133
|
+
"@deepseek-ai/dsh-timeout": "0.1.2-rc.1",
|
|
134
|
+
"@deepseek-ai/dsh-tool-todo": "0.1.2-rc.1",
|
|
135
|
+
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
136
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.2-rc.1",
|
|
137
|
+
"@deepseek-ai/dsh-typert-registry": "0.1.2-rc.1",
|
|
138
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-rc.1",
|
|
139
|
+
"@deepseek-ai/dsh-util-crypto": "0.1.2-rc.1",
|
|
140
|
+
"@deepseek-ai/dsh-util-time": "0.1.2-rc.1",
|
|
141
|
+
"@deepseek-ai/dsh-util-values": "0.1.2-rc.1",
|
|
142
|
+
"@deepseek-ai/dsh-util-workspace-path": "0.1.2-rc.1",
|
|
143
|
+
"@deepseek-ai/dsh-workspace": "0.1.2-rc.1",
|
|
83
144
|
"@testing-library/dom": "^10.4.1",
|
|
84
145
|
"@testing-library/react": "^16.0.0",
|
|
85
146
|
"@types/node": "^22.19.0",
|
|
@@ -74,15 +74,13 @@ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
|
|
|
74
74
|
function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
75
75
|
const gap = spec.gap ?? 16
|
|
76
76
|
const onAction = useDebouncedAction(useGenuiAction())
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// v2.7 durability: with a stateKey the state ALSO survives refresh/reopen —
|
|
82
|
-
// loaded once at mount (seed for re-renders of the same content) and saved
|
|
83
|
-
// on every change.
|
|
77
|
+
// Grouped radios and grouped checkboxes record their local selections here;
|
|
78
|
+
// `submit` either grades radio-only papers locally or aggregates all form
|
|
79
|
+
// state into one action. Block-local state survives streaming/panel
|
|
80
|
+
// re-renders, and with a stateKey it also survives refresh/reopen.
|
|
84
81
|
const [persisted] = useState(() => (stateKey === undefined ? null : loadBlockState(stateKey)))
|
|
85
82
|
const [answers, setAnswers] = useState<Record<string, string>>(persisted?.answers ?? {})
|
|
83
|
+
const [multiAnswers, setMultiAnswers] = useState<Record<string, string[]>>(persisted?.multiAnswers ?? {})
|
|
86
84
|
const [fields, setFields] = useState<Record<string, string>>(persisted?.fields ?? {})
|
|
87
85
|
const [meta, setMeta] = useState<Record<string, QuestionMeta>>({})
|
|
88
86
|
const [locked, setLocked] = useState(persisted?.locked === true)
|
|
@@ -94,6 +92,22 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
94
92
|
const setAnswer = useCallback((group: string, choice: string) => {
|
|
95
93
|
setAnswers(prev => (prev[group] === choice ? prev : { ...prev, [group]: choice }))
|
|
96
94
|
}, [])
|
|
95
|
+
const setMultiAnswer = useCallback((group: string, choice: string, checked: boolean) => {
|
|
96
|
+
setMultiAnswers(prev => {
|
|
97
|
+
const current = prev[group] ?? []
|
|
98
|
+
const next = checked
|
|
99
|
+
? current.includes(choice) ? current : [...current, choice]
|
|
100
|
+
: current.filter(item => item !== choice)
|
|
101
|
+
const hadGroup = Object.prototype.hasOwnProperty.call(prev, group)
|
|
102
|
+
if (hadGroup && next.length === current.length && next.every((item, index) => item === current[index])) {
|
|
103
|
+
return prev
|
|
104
|
+
}
|
|
105
|
+
// Keep an explicit empty array: it distinguishes "user cleared this
|
|
106
|
+
// group" from "group never had local state", so checked defaults do not
|
|
107
|
+
// reappear after a refresh.
|
|
108
|
+
return { ...prev, [group]: next }
|
|
109
|
+
})
|
|
110
|
+
}, [])
|
|
97
111
|
const setField = useCallback((id: string, value: string) => {
|
|
98
112
|
// Field invariant: a blank (trim-empty) value leaves the shared registry.
|
|
99
113
|
setFields(prev => {
|
|
@@ -119,15 +133,16 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
119
133
|
}, [])
|
|
120
134
|
const clear = useCallback(() => {
|
|
121
135
|
setAnswers({})
|
|
136
|
+
setMultiAnswers({})
|
|
122
137
|
setLocked(false)
|
|
123
138
|
setRound(r => r + 1) // radios remount (key carries the round) with clean selections
|
|
124
139
|
}, [])
|
|
125
140
|
const answersState = useMemo<AnswersState>(
|
|
126
141
|
() => ({
|
|
127
|
-
answers, fields, secretFields, meta, locked, round,
|
|
128
|
-
setAnswer, setField, registerSecretField, registerMeta, clear, setLocked,
|
|
142
|
+
answers, multiAnswers, fields, secretFields, meta, locked, round,
|
|
143
|
+
setAnswer, setMultiAnswer, setField, registerSecretField, registerMeta, clear, setLocked,
|
|
129
144
|
}),
|
|
130
|
-
[answers, fields, secretFields, meta, locked, round, setAnswer, setField, registerSecretField, registerMeta, clear],
|
|
145
|
+
[answers, multiAnswers, fields, secretFields, meta, locked, round, setAnswer, setMultiAnswer, setField, registerSecretField, registerMeta, clear],
|
|
131
146
|
)
|
|
132
147
|
// Achievement telemetry: every emitted action counts as one interaction
|
|
133
148
|
// (the debounced emit fires once per real user action).
|
|
@@ -148,12 +163,13 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
148
163
|
)
|
|
149
164
|
saveBlockState(stateKey, {
|
|
150
165
|
answers,
|
|
166
|
+
...(Object.keys(multiAnswers).length > 0 ? { multiAnswers } : {}),
|
|
151
167
|
locked,
|
|
152
168
|
...(Object.keys(safeFields).length > 0 ? { fields: safeFields } : {}),
|
|
153
169
|
})
|
|
154
170
|
}, 300)
|
|
155
171
|
return () => clearTimeout(timer)
|
|
156
|
-
}, [stateKey, answers, locked, fields, secretFields])
|
|
172
|
+
}, [stateKey, answers, multiAnswers, locked, fields, secretFields])
|
|
157
173
|
// Achievement telemetry (0.9.5): the store dedupes by spec fingerprint, so
|
|
158
174
|
// streaming re-renders and replays count once per distinct content.
|
|
159
175
|
useEffect(() => {
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
8
8
|
import { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
9
|
import css from '../GenuiBlock.module.css'
|
|
10
|
-
import { GENUI_LIMITS } from '../
|
|
10
|
+
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
11
11
|
import { PlotBlock } from '../PlotBlock.tsx'
|
|
12
12
|
import { renderNode } from './render-node.tsx'
|
|
13
|
+
import { CODE_BLOCK_LABELS, DIFF_BLOCK_LABELS, JSON_TREE_LABELS } from '../primitive-labels.ts'
|
|
13
14
|
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
14
15
|
import type {
|
|
15
16
|
GenuiAccordion, GenuiBreadcrumb, GenuiCallout, GenuiCode, GenuiCopy, GenuiDiff, GenuiFileTree, GenuiFileTreeNode,
|
|
@@ -89,7 +90,7 @@ export const PlotNode = memo(function PlotNode({ plot }: { plot: GenuiPlot }) {
|
|
|
89
90
|
|
|
90
91
|
/** Diff: 收编 dsh DiffBlock (same path/oldText/newText shape as DiffHunk). */
|
|
91
92
|
export const DiffNode = memo(function DiffNode({ node }: { node: GenuiDiff }) {
|
|
92
|
-
return <DiffBlock diffs={node.diffs} />
|
|
93
|
+
return <DiffBlock diffs={node.diffs} labels={DIFF_BLOCK_LABELS} />
|
|
93
94
|
})
|
|
94
95
|
|
|
95
96
|
/** Json: 收编 dsh JsonTree. */
|
|
@@ -98,12 +99,12 @@ export const JsonNode = memo(function JsonNode({ node }: { node: GenuiJson }) {
|
|
|
98
99
|
if (typeof data !== 'object' || data === null) {
|
|
99
100
|
return <div className={css.jsonScalar}>{String(data)}</div>
|
|
100
101
|
}
|
|
101
|
-
return <JsonTree data={data as object | unknown[]} copyable />
|
|
102
|
+
return <JsonTree data={data as object | unknown[]} label="JSON" labels={JSON_TREE_LABELS} copyable />
|
|
102
103
|
})
|
|
103
104
|
|
|
104
105
|
/** Code: 收编 dsh CodeBlock with explicit language. */
|
|
105
106
|
export const CodeNode = memo(function CodeNode({ node }: { node: GenuiCode }) {
|
|
106
|
-
return <CodeBlock code={node.code.slice(0, GENUI_LIMITS.maxCode)} lang={node.lang} />
|
|
107
|
+
return <CodeBlock {...CODE_BLOCK_LABELS} code={node.code.slice(0, GENUI_LIMITS.maxCode)} lang={node.lang} />
|
|
107
108
|
})
|
|
108
109
|
|
|
109
110
|
/**
|
|
@@ -473,4 +474,4 @@ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: Gen
|
|
|
473
474
|
* names stay independent. The model round-trip takes seconds, so a few
|
|
474
475
|
* hundred ms of trailing delay is imperceptible — and it stops bursts of
|
|
475
476
|
* queued user turns.
|
|
476
|
-
*/
|
|
477
|
+
*/
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { memo, useState } from 'react'
|
|
7
7
|
import css from '../GenuiBlock.module.css'
|
|
8
|
-
import { GENUI_LIMITS } from '../
|
|
8
|
+
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
9
9
|
import type { GenuiChart, GenuiTable } from '../spec.ts'
|
|
10
10
|
|
|
11
11
|
export const CHART_COLORS = [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import css from '../GenuiBlock.module.css'
|
|
3
|
+
import type { GenuiCheckbox } from '../spec.ts'
|
|
4
|
+
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Checkbox with two backwards-compatible modes:
|
|
8
|
+
*
|
|
9
|
+
* - no `group`: legacy local toggle + optional per-click action;
|
|
10
|
+
* - with `group`: local-only multi-select state collected by a sibling submit.
|
|
11
|
+
*
|
|
12
|
+
* A persisted group key wins over `checked`, including an explicit empty
|
|
13
|
+
* array. This lets a user clear every default-checked option without those
|
|
14
|
+
* defaults reappearing after refresh.
|
|
15
|
+
*/
|
|
16
|
+
export function CheckboxNode({ node, onAction, answers }: {
|
|
17
|
+
node: GenuiCheckbox
|
|
18
|
+
onAction?: GenuiBlockProps['onAction']
|
|
19
|
+
answers?: AnswersState | undefined
|
|
20
|
+
}) {
|
|
21
|
+
const group = node.group
|
|
22
|
+
const grouped = group !== undefined
|
|
23
|
+
const hasRecordedGroup = group !== undefined
|
|
24
|
+
&& Object.prototype.hasOwnProperty.call(answers?.multiAnswers ?? {}, group)
|
|
25
|
+
const recorded = group === undefined ? undefined : answers?.multiAnswers[group]
|
|
26
|
+
const groupChecked = hasRecordedGroup
|
|
27
|
+
? Array.isArray(recorded) && recorded.includes(node.label)
|
|
28
|
+
: node.checked === true
|
|
29
|
+
const [checked, setChecked] = useState(node.checked === true)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (group === undefined || node.checked !== true || hasRecordedGroup) return
|
|
33
|
+
// Model-provided defaults are real initial selections, but never override
|
|
34
|
+
// a restored user choice (including an explicitly empty group).
|
|
35
|
+
answers?.setMultiAnswer(group, node.label, true)
|
|
36
|
+
// answers is intentionally omitted: the callbacks are stable and a
|
|
37
|
+
// registry update should not re-register the same default.
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [group, node.checked, node.label, hasRecordedGroup])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<label className={css.checkbox}>
|
|
43
|
+
<input
|
|
44
|
+
type="checkbox"
|
|
45
|
+
checked={grouped ? groupChecked : checked}
|
|
46
|
+
onChange={e => {
|
|
47
|
+
const next = e.currentTarget.checked
|
|
48
|
+
setChecked(next)
|
|
49
|
+
if (grouped) {
|
|
50
|
+
// Aggregation mode: update local multi-answer state only.
|
|
51
|
+
answers?.setMultiAnswer(group, node.label, next)
|
|
52
|
+
} else if (node.action !== undefined && onAction !== undefined) {
|
|
53
|
+
onAction(node.action, { type: 'checkbox', checked: next })
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<span>{node.label}</span>
|
|
58
|
+
</label>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useMemo } from 'react'
|
|
12
12
|
import type { GenuiDiagram } from '../../spec.ts'
|
|
13
|
-
import { GENUI_LIMITS } from '../../
|
|
13
|
+
import { GENUI_LIMITS } from '../../genui-runtime/index.ts'
|
|
14
14
|
import { resolveLayout } from './layout.ts'
|
|
15
15
|
import { routeEdge, labelGeometry, type Box } from './geometry.ts'
|
|
16
16
|
import { resolvePalette, nodeTreatment, edgeStroke, inkAt } from './theme.ts'
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { useEffect, useId, useRef, useState } from 'react'
|
|
7
7
|
import css from '../GenuiBlock.module.css'
|
|
8
|
-
import { GENUI_LIMITS } from '../
|
|
8
|
+
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
9
9
|
import type { AnswersState, GenuiBlockProps, QuestionMeta } from './state.ts'
|
|
10
10
|
import type { GenuiInput, GenuiRadio, GenuiSelect, GenuiSlider, GenuiSubmit, GenuiSwitch, GenuiTextarea } from '../spec.ts'
|
|
11
11
|
|
|
@@ -82,20 +82,16 @@ export function correctLabelOf(m: QuestionMeta): string | undefined {
|
|
|
82
82
|
return m.answer
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** Submit:
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* trip, and locks the questions until 重新作答 resets them. Only when NO
|
|
89
|
-
* question has answers does it fall back to firing ONE action
|
|
90
|
-
* (`{type:'submit', answers, total, answered}`). Disabled until the
|
|
91
|
-
* selection criteria are met (all listed groups answered, or ≥1 answer
|
|
92
|
-
* without a group list); the hint shows the progress. */
|
|
85
|
+
/** Submit: collect grouped radio and checkbox answers. Radio-only scopes keep
|
|
86
|
+
* LOCAL-FIRST grading; when checkbox selections participate, the click falls
|
|
87
|
+
* back to the aggregation action so multi-select data is never discarded. */
|
|
93
88
|
export function SubmitNode({ node, onAction, answers }: {
|
|
94
89
|
node: GenuiSubmit
|
|
95
90
|
onAction?: GenuiBlockProps['onAction']
|
|
96
91
|
answers?: AnswersState | undefined
|
|
97
92
|
}) {
|
|
98
93
|
const recorded = answers?.answers ?? {}
|
|
94
|
+
const multiRecorded = answers?.multiAnswers ?? {}
|
|
99
95
|
const fields = answers?.fields ?? {}
|
|
100
96
|
const meta = answers?.meta ?? {}
|
|
101
97
|
const expected = node.groups
|
|
@@ -104,21 +100,35 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
104
100
|
const filledFields = Object.fromEntries(
|
|
105
101
|
Object.entries(fields).filter(([id, v]) => v.trim() !== '' && !answers?.secretFields.has(id)),
|
|
106
102
|
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
const nonEmptyMultiGroups = Object.entries(multiRecorded)
|
|
104
|
+
.filter(([, values]) => Array.isArray(values) && values.length > 0)
|
|
105
|
+
.map(([group]) => group)
|
|
106
|
+
const recordedGroups = new Set([...Object.keys(recorded), ...nonEmptyMultiGroups])
|
|
107
|
+
const hasRecordedAnswer = (group: string): boolean =>
|
|
108
|
+
recorded[group] !== undefined || (Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0)
|
|
109
|
+
|
|
110
|
+
// Explicit groups require at least one selected checkbox (or a radio answer)
|
|
111
|
+
// per group. Empty checkbox arrays remain durable state but are deliberately
|
|
112
|
+
// not considered "answered".
|
|
110
113
|
const answered = expected === undefined
|
|
111
|
-
? Math.max(
|
|
112
|
-
: expected.filter(
|
|
114
|
+
? Math.max(recordedGroups.size, Object.keys(filledFields).length)
|
|
115
|
+
: expected.filter(hasRecordedAnswer).length
|
|
113
116
|
const total = expected?.length ?? answered
|
|
114
|
-
const scope = expected ??
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
const scope = expected ?? [...recordedGroups]
|
|
118
|
+
const hasMultiInScope = scope.some(group =>
|
|
119
|
+
Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0,
|
|
120
|
+
)
|
|
121
|
+
// Local grading must be radio-only. Otherwise grading would consume the
|
|
122
|
+
// click and silently omit checkbox selections that need model delivery.
|
|
123
|
+
const canGradeLocally = !hasMultiInScope && scope.some(group => meta[group]?.answer !== undefined)
|
|
117
124
|
const submitted = answers?.locked === true
|
|
125
|
+
const collectedAnswers: Record<string, string | string[]> = {
|
|
126
|
+
...recorded,
|
|
127
|
+
...multiRecorded,
|
|
128
|
+
}
|
|
118
129
|
// Ready = enough answers AND the click can do something: either local
|
|
119
130
|
// grading, or a real action name + provider. A submit with neither is a
|
|
120
|
-
// display-only control — honest disabled affordance
|
|
121
|
-
// local grading needs no round trip).
|
|
131
|
+
// display-only control — honest disabled affordance.
|
|
122
132
|
const ready = answered > 0 && answered >= total
|
|
123
133
|
&& (canGradeLocally || (node.action !== undefined && onAction !== undefined))
|
|
124
134
|
|
|
@@ -191,7 +201,7 @@ export function SubmitNode({ node, onAction, answers }: {
|
|
|
191
201
|
// the optional-action type honest.
|
|
192
202
|
onAction(node.action, {
|
|
193
203
|
type: 'submit',
|
|
194
|
-
answers:
|
|
204
|
+
answers: collectedAnswers,
|
|
195
205
|
...(Object.keys(filledFields).length > 0 ? { fields: filledFields } : {}),
|
|
196
206
|
total,
|
|
197
207
|
answered,
|
|
@@ -396,11 +406,16 @@ export function InputNode({ node, onAction, answers }: {
|
|
|
396
406
|
const action = node.action
|
|
397
407
|
const id = node.id
|
|
398
408
|
const secret = node.inputType === 'password'
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
const [value, setValue] = useState<string>(() =>
|
|
403
|
-
secret ? '' : (node.value ??
|
|
409
|
+
const restored = !secret && id !== undefined ? answers?.fields[id] : undefined
|
|
410
|
+
// Initial value: durable state wins over the spec default. Secrets always
|
|
411
|
+
// restore as blank so a password can never survive a refresh.
|
|
412
|
+
const [value, setValue] = useState<string>(() => {
|
|
413
|
+
const initial = secret ? '' : (restored ?? node.value ?? '')
|
|
414
|
+
// Match the native color input's opaque hex value, including its black default.
|
|
415
|
+
return node.inputType === 'color'
|
|
416
|
+
? /^#[0-9a-f]{6}$/i.test(initial) ? initial.toLowerCase() : '#000000'
|
|
417
|
+
: initial
|
|
418
|
+
})
|
|
404
419
|
// Last value actually DELIVERED to the model: blur only sends when the
|
|
405
420
|
// value changed since the last delivery (a focus-in/focus-out with no edit
|
|
406
421
|
// used to fire a pointless action round trip). Seeded with the mount value
|
|
@@ -413,13 +428,13 @@ export function InputNode({ node, onAction, answers }: {
|
|
|
413
428
|
}
|
|
414
429
|
}
|
|
415
430
|
const ime = useImeComposing()
|
|
416
|
-
//
|
|
431
|
+
// Register the displayed initial value, including the native color default.
|
|
417
432
|
const mounted = useRef(false)
|
|
418
433
|
useEffect(() => {
|
|
419
434
|
if (mounted.current) return
|
|
420
435
|
mounted.current = true
|
|
421
|
-
if (!secret && id !== undefined &&
|
|
422
|
-
answers?.setField(id,
|
|
436
|
+
if (!secret && id !== undefined && value.trim() !== '') {
|
|
437
|
+
answers?.setField(id, value)
|
|
423
438
|
}
|
|
424
439
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
425
440
|
}, [])
|
|
@@ -434,6 +449,14 @@ export function InputNode({ node, onAction, answers }: {
|
|
|
434
449
|
<input
|
|
435
450
|
className={css.input}
|
|
436
451
|
type={node.inputType ?? 'text'}
|
|
452
|
+
style={node.inputType === 'color' ? {
|
|
453
|
+
width: 64,
|
|
454
|
+
height: 40,
|
|
455
|
+
padding: 4,
|
|
456
|
+
boxSizing: 'border-box',
|
|
457
|
+
alignSelf: 'flex-start',
|
|
458
|
+
cursor: 'pointer',
|
|
459
|
+
} : undefined}
|
|
437
460
|
placeholder={node.placeholder}
|
|
438
461
|
value={value}
|
|
439
462
|
onChange={e => {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { type ReactNode, type ComponentType } from 'react'
|
|
8
8
|
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
9
|
import css from '../GenuiBlock.module.css'
|
|
10
|
-
import { GENUI_LIMITS } from '../
|
|
10
|
+
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
11
11
|
import type { GenuiList, GenuiNode } from '../spec.ts'
|
|
12
12
|
import type { AnswersState, GenuiBlockProps } from './state.ts'
|
|
13
13
|
import { AudioNode, avatarColor, ClickFeedbackButton, VideoNode } from './basic.tsx'
|
|
@@ -15,6 +15,7 @@ import { ChartNode, TableNode } from './charts.tsx'
|
|
|
15
15
|
import {
|
|
16
16
|
InputNode, RadioNode, SelectNode, SliderNode, SubmitNode, SwitchNode, TextareaNode,
|
|
17
17
|
} from './forms.tsx'
|
|
18
|
+
import { CheckboxNode } from './checkbox.tsx'
|
|
18
19
|
import {
|
|
19
20
|
AccordionNode, BreadcrumbNode, CalloutNode, CodeNode, CopyNode, DiffNode, FileTreeNode, JsonNode, KeyValueNode,
|
|
20
21
|
MermaidNode, PlotNode, QuizNode, Scene3DNode, StepsNode, TabsNode, TimelineNode,
|
|
@@ -121,21 +122,7 @@ export function renderNode(
|
|
|
121
122
|
}
|
|
122
123
|
case 'input': return <InputNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
123
124
|
case 'select': return <SelectNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
124
|
-
case 'checkbox': {
|
|
125
|
-
const action = node.action
|
|
126
|
-
return (
|
|
127
|
-
<label key={key} className={css.checkbox}>
|
|
128
|
-
<input
|
|
129
|
-
type="checkbox"
|
|
130
|
-
defaultChecked={node.checked === true}
|
|
131
|
-
onChange={action !== undefined && onAction !== undefined
|
|
132
|
-
? e => onAction(action, { type: 'checkbox', checked: e.currentTarget.checked })
|
|
133
|
-
: undefined}
|
|
134
|
-
/>
|
|
135
|
-
<span>{node.label}</span>
|
|
136
|
-
</label>
|
|
137
|
-
)
|
|
138
|
-
}
|
|
125
|
+
case 'checkbox': return <CheckboxNode key={key} node={node} onAction={onAction} answers={answers} />
|
|
139
126
|
case 'link': {
|
|
140
127
|
// Honest affordance: with a whitelisted href this is a REAL anchor;
|
|
141
128
|
// without one it is plain styled text (a dead clickable-looking button
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared block-level state types: the block props contract and the answers
|
|
3
|
-
* registry (grouped radios → submit
|
|
4
|
-
* the per-family block modules can import the types without
|
|
5
|
-
* into the block shell.
|
|
3
|
+
* registry (grouped radios / checkboxes → submit aggregation). Lives OUTSIDE
|
|
4
|
+
* GenuiBlock.tsx so the per-family block modules can import the types without
|
|
5
|
+
* a cycle back into the block shell.
|
|
6
6
|
* @module @changfenhuang/dsh-genui/client/blocks/state
|
|
7
7
|
*/
|
|
8
8
|
import type { GenuiSpec } from '../spec.ts'
|
|
@@ -18,10 +18,11 @@ export interface GenuiBlockProps {
|
|
|
18
18
|
onAction?: ((action: string, payload: Record<string, unknown>) => void) | undefined
|
|
19
19
|
/**
|
|
20
20
|
* v2.7: durable-state key (session + slot + content fingerprint). When set,
|
|
21
|
-
* interaction state (radio answers, submit lock, field
|
|
22
|
-
* localStorage and restores on refresh / re-render of
|
|
23
|
-
* Changing this key also starts a fresh in-memory
|
|
24
|
-
* callers only provide stateKey — GenuiBlock
|
|
21
|
+
* interaction state (radio answers, checkbox groups, submit lock, field
|
|
22
|
+
* values) persists to localStorage and restores on refresh / re-render of
|
|
23
|
+
* the same content. Changing this key also starts a fresh in-memory
|
|
24
|
+
* interaction-state lifetime; callers only provide stateKey — GenuiBlock
|
|
25
|
+
* owns the matching React identity.
|
|
25
26
|
*/
|
|
26
27
|
stateKey?: string | undefined
|
|
27
28
|
}
|
|
@@ -36,15 +37,17 @@ export interface QuestionMeta {
|
|
|
36
37
|
explanation?: string | undefined
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/** Block-wide
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* plain group → chosen-option-label strings (the display label lives in
|
|
45
|
-
* QuestionMeta; localStorage stores the same string table — no migration). */
|
|
40
|
+
/** Block-wide aggregation state. Grouped radios store one selected label in
|
|
41
|
+
* `answers`; grouped checkboxes store a selected-label array in
|
|
42
|
+
* `multiAnswers`. Keeping the registries separate preserves the existing
|
|
43
|
+
* radio/local-grading string contract while allowing submit to expose one
|
|
44
|
+
* unified `answers` payload (`string | string[]`). */
|
|
46
45
|
export interface AnswersState {
|
|
47
46
|
answers: Record<string, string>
|
|
47
|
+
/** Grouped checkbox selections. An empty array is meaningful: the user
|
|
48
|
+
* explicitly cleared the group, so model-provided `checked` defaults must
|
|
49
|
+
* not reappear after a durable restore. */
|
|
50
|
+
multiAnswers: Record<string, string[]>
|
|
48
51
|
/** Field values by id (input/textarea with an `id`), collected by submit. */
|
|
49
52
|
fields: Record<string, string>
|
|
50
53
|
/** Field ids whose value must never be persisted or collected (secrets). */
|
|
@@ -55,6 +58,7 @@ export interface AnswersState {
|
|
|
55
58
|
/** Bumped by every reset; radios use it as their remount key. */
|
|
56
59
|
round: number
|
|
57
60
|
setAnswer: (group: string, choice: string) => void
|
|
61
|
+
setMultiAnswer: (group: string, choice: string, checked: boolean) => void
|
|
58
62
|
setField: (id: string, value: string) => void
|
|
59
63
|
registerSecretField: (id: string) => void
|
|
60
64
|
registerMeta: (group: string, meta: QuestionMeta) => void
|
package/src/client/dom-fence.tsx
CHANGED
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
import { Fragment, isValidElement, type Key, type ReactNode } from 'react'
|
|
48
48
|
import { createRoot, type Root } from 'react-dom/client'
|
|
49
49
|
import type { Context } from '@deepseek-ai/cordis'
|
|
50
|
-
import type {
|
|
50
|
+
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
51
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
51
52
|
import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
|
|
52
53
|
import { renderResolvedFenceNode, type GenuiFenceContext } from './fence-render.tsx'
|
|
53
54
|
|