@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -8
  3. package/README.zh-CN.md +13 -8
  4. package/SKILL.md +5 -5
  5. package/lib/client.js +3 -3
  6. package/lib/index.js +1273 -338
  7. package/lib/types/client/blocks/advanced.d.ts +1 -1
  8. package/lib/types/client/blocks/checkbox.d.ts +17 -0
  9. package/lib/types/client/blocks/forms.d.ts +3 -8
  10. package/lib/types/client/blocks/state.d.ts +18 -14
  11. package/lib/types/client/dom-fence.d.ts +1 -1
  12. package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
  13. package/lib/types/client/genui-runtime/index.d.ts +1 -0
  14. package/lib/types/client/genui-runtime/limits.d.ts +54 -0
  15. package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
  16. package/lib/types/client/genui-runtime/schema.d.ts +73 -0
  17. package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
  18. package/lib/types/client/guard.d.ts +60 -71
  19. package/lib/types/client/interaction-store.d.ts +7 -2
  20. package/lib/types/client/panel-command.d.ts +1 -1
  21. package/lib/types/client/primitive-labels.d.ts +11 -0
  22. package/lib/types/client/spec.d.ts +17 -17
  23. package/lib/types/plugin/chart-contract.d.ts +6 -4
  24. package/lib/types/plugin/index.d.ts +1 -4
  25. package/package.json +74 -13
  26. package/src/client/GenuiBlock.tsx +27 -11
  27. package/src/client/blocks/advanced.tsx +6 -5
  28. package/src/client/blocks/charts.tsx +1 -1
  29. package/src/client/blocks/checkbox.tsx +60 -0
  30. package/src/client/blocks/diagram/index.tsx +1 -1
  31. package/src/client/blocks/forms.tsx +51 -28
  32. package/src/client/blocks/render-node.tsx +3 -16
  33. package/src/client/blocks/state.ts +18 -14
  34. package/src/client/dom-fence.tsx +2 -1
  35. package/src/client/fence-render.tsx +26 -15
  36. package/src/client/genui-runtime/diagnostics.ts +143 -0
  37. package/src/client/genui-runtime/index.ts +1 -0
  38. package/src/client/genui-runtime/limits.ts +54 -0
  39. package/src/client/genui-runtime/normalize.ts +115 -0
  40. package/src/client/genui-runtime/schema.ts +291 -0
  41. package/src/client/genui-runtime/value-utils.ts +67 -0
  42. package/src/client/guard.ts +314 -169
  43. package/src/client/index.tsx +3 -2
  44. package/src/client/interaction-store.ts +7 -2
  45. package/src/client/panel-command.ts +2 -1
  46. package/src/client/panel.tsx +1 -1
  47. package/src/client/primitive-labels.ts +37 -0
  48. package/src/client/spec.ts +17 -17
  49. package/src/client/toolview.tsx +2 -1
  50. package/src/index.ts +3 -6
  51. package/src/plugin/chart-contract.ts +7 -97
  52. package/src/plugin/index.ts +2 -6
  53. 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.8",
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-client-runtime": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
69
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
70
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
71
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
72
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
73
- "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
74
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
75
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
76
- "@deepseek-ai/dsh-session": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
77
- "@deepseek-ai/dsh-skill": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
78
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
79
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.8 || >=0.1.1-rc.0 <0.2.0 || ^0.1.2-rc.1 || ^0.1.3-alpha.1",
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
- // v2.5/v2.6 answers registry: grouped radios record selections + question
78
- // metadata here; `submit` nodes grade locally (locked until 重新作答) or
79
- // collect into one action. Block-local state survives re-renders (streaming
80
- // settle, panel updates) — selections persist while the block is mounted.
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 '../guard.ts'
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 '../guard.ts'
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 '../../guard.ts'
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 '../guard.ts'
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: the "交卷" control of a grouped-radio block. LOCAL-FIRST (v2.6):
86
- * when at least one question carries `answer` data the click grades IN PLACE
87
- * — score, per-question right/wrong, explanations — with zero model round
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
- // Without an explicit group list, the submit counts radio answers AND
108
- // filled fields — a fields-only form (inputs with id + submit) enables
109
- // once any field has a value.
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(Object.keys(recorded).length, Object.keys(filledFields).length)
112
- : expected.filter(g => recorded[g] !== undefined).length
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 ?? Object.keys(recorded)
115
- // Local grading is possible when ANY in-scope question carries answers.
116
- const canGradeLocally = scope.some(g => meta[g]?.answer !== undefined)
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 (action is optional:
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: recorded,
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
- // Initial value: spec default, else durable state (restored after refresh).
400
- // Secrets restore as blank: a password that survives a refresh would be a
401
- // stored secret, which is exactly what the boundary forbids.
402
- const [value, setValue] = useState<string>(() =>
403
- secret ? '' : (node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : '')))
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
- // Field invariant: a spec-provided non-blank default registers at mount.
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 && node.value !== undefined && node.value.trim() !== '') {
422
- answers?.setField(id, node.value)
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 '../guard.ts'
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 grading). Lives OUTSIDE GenuiBlock.tsx so
4
- * the per-family block modules can import the types without a cycle back
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 values) persists to
22
- * localStorage and restores on refresh / re-render of the same content.
23
- * Changing this key also starts a fresh in-memory interaction-state lifetime;
24
- * callers only provide stateKey — GenuiBlock owns the matching React identity.
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 answers registry (v2.5/v2.6): grouped radios record selections
40
- * and register their question metadata here; a sibling `submit` node either
41
- * grades IN PLACE (questions carry `answer` data) or collects everything
42
- * into ONE action. Lives in GenuiBlock state so re-renders keep the
43
- * selections, threaded down through the recursive render walk. Answers are
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
@@ -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 { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
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