dsh-structured 0.8.1 → 0.8.2

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 — 2026-10-22
4
+
5
+ - 视觉层从“始终开启”改为 profile 级 `visual` 配置(`Config` + `.volatile()`,默认 true);通过 DSH 插件设置页(bundle 详情表单)切换,不再写共享的 `state.json`。
6
+ - `/structured on|off` 保持原义:只控制按会话的提示词注入;视觉层由 `visual` 控制,`/structured status` 会显示两者状态。
7
+ - client 通过 `configForms.get('dsh-structured')` 读取 `visual` 并启用/停用视觉层;`configForms` 不可用时保持默认开启,不阻断回复。
8
+ - 严格卡片识别:只认完整标签(结论/总结/答案、判断/决策、指标/数据、风险/注意/警告、下一步/行动/待办、验收/通过/完成、重点/关键);前缀/复合词不染色;普通引用不再自动 lead;statement 仅第一块;GenUI 跳过。
9
+ - 新增 `@deepseek-ai/schemastery` peer/dev 依赖;`desktop-check` 允许宿主 schemastery import 并检查 `Config` 导出;版本 0.8.2。
10
+
3
11
  ## 0.8.1 — 2026-10-22
4
12
 
5
13
  - 规则文本从“500 字硬上限”改为清晰度约束:标题即结论、按信息类型选组件、一段一主题、证据优先、短答不套卡、长答不设硬性篇幅上限。
package/README.md CHANGED
@@ -81,7 +81,7 @@ pnpm add $tgz
81
81
 
82
82
  ### 兼容性门禁
83
83
 
84
- 本包不声明 `@deepseek-ai/*` peerDependencies:插件只使用 host service,不直接 import 官方包。`engines.dsh` 与 `dsh.compatibility.dshReleases` 是 advisory;本机 `evaluatePluginCompatibility` 只读 peer,因此版本门禁不生效(P2 风险)。实测 DSH CLI `0.1.7-rc.2` 与 desktop `0.2.1-rc.2`。
84
+ 本包声明 `@deepseek-ai/schemastery` peer:`Config`(visual/defaultEnabled/tailReminder/includeSubagents/genui)用它生成设置表单;这是 host 提供的服务,不是门禁。真正门禁是 `engines.dsh`(`>=0.1.7-rc.2 <0.3.0-0`),`dsh-app-boot` 在有 `engines.dsh` 时读取它;`dsh.compatibility.dshReleases` 是 advisory。实测 DSH CLI `0.1.7-rc.2` 与 desktop `0.2.1-rc.2`。
85
85
 
86
86
  ## 使用
87
87
 
@@ -94,6 +94,7 @@ pnpm add $tgz
94
94
 
95
95
  - 开关按会话独立,状态落在 `$DSH_HOME/dsh-structured/state.json`(tmp + rename 原子写)。
96
96
  - 新会话默认开启;`defaultEnabled: false` 可改默认值(见配置)。
97
+ - 视觉层由插件设置 `visual` 控制(默认开启),不写 `state.json`;`/structured on|off` 只控制提示词注入,不影响视觉层。
97
98
  - 自定义规则:写 `$DSH_HOME/dsh-structured/style.md`,**非空即整份覆盖**内置文本,保存后下一轮生效;文件为空或删除时回落内置。
98
99
  - 内置文本与 `~/.dsh/AGENTS.md` 第 7 节同源:首句结论、组件化正文、一段一主题、禁过程叙事与复述、证据优先、短答不套卡;清晰优先,不设硬性篇幅上限。
99
100
 
@@ -103,7 +104,7 @@ pnpm add $tgz
103
104
 
104
105
  视觉方向只做一套:**Linear 的秩序 + Notion 的卡片 + Stripe 的信息密度 + Vercel AI Elements 的回复结构**,见 [`docs/DESIGN-REFERENCES.md`](docs/DESIGN-REFERENCES.md)。卡片用图标方块 + 13px 标签 + 淡色表面;状态只在 68px 状态列着色,不再整行 pastel;表格结论用强调左规线。不做主题菜单。
105
106
 
106
- 设计与分阶段计划见 [`docs/VISUAL-PLAN.md`](docs/VISUAL-PLAN.md),视觉热点/层级诊断见 [`docs/VISUAL-CRITIQUE.md`](docs/VISUAL-CRITIQUE.md),卡片类型与热点预算见 [`docs/CARD-SYSTEM.md`](docs/CARD-SYSTEM.md)。视觉层默认开启,**只提供一套「工程蓝图」方案,不做主题选择**;`/structured on|off` 是唯一开关。
107
+ 设计与分阶段计划见 [`docs/VISUAL-PLAN.md`](docs/VISUAL-PLAN.md),视觉热点/层级诊断见 [`docs/VISUAL-CRITIQUE.md`](docs/VISUAL-CRITIQUE.md),卡片类型与热点预算见 [`docs/CARD-SYSTEM.md`](docs/CARD-SYSTEM.md)。视觉层由插件设置 `visual` 控制(默认开启);`/structured on|off` 只控制提示词注入。仍只提供一套「工程蓝图」视觉,不做主题选择。
107
108
 
108
109
  Phase 2 已落地 2.1–2.4:卡片类型(结论/判断/指标/风险/下一步/验收/重点)、`data-dsh-structured-card-type` 归属标记、GenUI 软联动(检测到才追加 `dsh-ui` 路由,未检测走纯 markdown fallback,不硬依赖)。
109
110
 
@@ -130,8 +131,11 @@ Phase 2 已落地 2.1–2.4:卡片类型(结论/判断/指标/风险/下一
130
131
  tailReminder: true # system prompt 末尾追加一句提醒
131
132
  includeSubagents: false # 子代理会话不注入(它的产出不直接面向用户)
132
133
  genui: auto # auto | on | off;auto 检测到 GenUI 才追加 dsh-ui 路由
134
+ visual: true # 客户端视觉层;由插件设置页切换,不写 state.json
133
135
  ```
134
136
 
137
+ `visual`、`defaultEnabled`、`tailReminder`、`includeSubagents`、`genui` 都是 `.volatile()` 配置,插件设置页(bundle 详情表单)可编辑;写入走 active profile 的 Cordis patch,home patch / CLI overlay 优先。`/structured on|off` 只影响 `state.json` 里的按会话提示词开关。
138
+
135
139
  ## 验证注入生效
136
140
 
137
141
  会话日志是 `$DSH_HOME/sessions/<cwd-group>/<session-id>/session.v4.jsonl.zstd`(独立 zstd 帧拼接,普通 grep 读不了)。仓库自带读取脚本:
@@ -153,7 +157,7 @@ node scripts/session-inspect.mjs --session session-xxxx --last-system --grep "
153
157
  搜索:"结构化输出(本会话已启用" → 命中 1 条 system/message
154
158
  ```
155
159
 
156
- 关闭后(`/structured off` 或把该会话在 `state.json` 里置为 `false`),同一个会话下一轮应看到 `命中 0 条`。视觉样式仍保留;`/structured` 只控制提示词注入,不关闭 client 视觉层。
160
+ 关闭后(`/structured off` 或把该会话在 `state.json` 里置为 `false`),同一个会话下一轮应看到 `命中 0 条`。视觉层由插件设置 `visual` 控制,`/structured` 只控制提示词注入,不关闭 client 视觉层。
157
161
 
158
162
  > 以上命令在仓库根执行。已装到 profile 时,脚本路径是 `<profile>\node_modules\dsh-structured\scripts\`;把命令里的 `scripts/` 换成 `node_modules/dsh-structured/scripts/` 即可。
159
163
 
@@ -222,6 +226,8 @@ scripts/desktop-check.mjs desktop 兼容性预检
222
226
  scripts/measure-prose.mjs 散文率统计(可复跑)
223
227
  scripts/lib/session-log.mjs v4 会话日志 zstd 帧读取
224
228
  test/structured.test.mjs 单元测试 + 边界测试
229
+ test/client-visual.test.mjs client visual 配置开关测试
230
+ test/client-cards.test.mjs 严格卡片识别 DOM 测试
225
231
  docs/METRICS.md A/B 指标与原始明细
226
232
  docs/ROADMAP.md Phase 1/2 路线图与 Phase 2 任务
227
233
  docs/CARD-SYSTEM.md 卡片类型、anatomy 与热点预算
package/cordis.patch.yml CHANGED
@@ -6,6 +6,7 @@
6
6
  # tailReminder 是否在 system prompt 末尾追加一句提醒(默认 true)
7
7
  # includeSubagents 是否也给子代理会话注入(默认 false,子代理产出不直接面向用户)
8
8
  # genui auto | on | off;auto 检测到 dsh-genui 才追加 dsh-ui 路由(默认 auto)
9
+ # visual 是否启用客户端视觉层(默认 true;由插件设置页切换,不写 state.json)
9
10
  #
10
11
  # 需要改默认值时,在 profile 的 cordis.patch.yml 里覆盖同一行(整份 config 替换,不是深合并):
11
12
  # - id: dsh-structured
@@ -18,3 +19,4 @@
18
19
  tailReminder: true
19
20
  includeSubagents: false
20
21
  genui: auto
22
+ visual: true
@@ -0,0 +1,3 @@
1
+ [AC-001/002] `npm test` 36/36(含严格卡片 10 反例 + 9 正例 + GenUI 跳过);`node scripts/desktop-check.mjs` 全 PASS;`lib/index.js`、`lib/client.js` 与 src 一致。
2
+ [AC-003] `structured-test` 已安装 0.8.2(B 构建,`lib/client.js` hash 与仓库一致),headless 一轮 `completed`;`session-inspect --session session-343e363b-73ca-4c9c-80ed-52d099f5cb8b --last-system --grep "结构化输出(本会话已启用"` 命中 1;`npm pack` 38 文件 / 763106 B / unpacked 962368 B。
3
+ [AC-004/005] `visual` 配置与 client `configForms` 读取测试通过,`/structured off` 仍只控制提示词;桌面 bundle 详情页 `visual` 字段的实机可见/可切换 **未验**(未触碰 desktop profile);未 push、未 publish、未提市场 PR。
@@ -55,7 +55,7 @@ pnpm 和 npm 二选一,给结论和一张不超过 4 行的对比表。
55
55
 
56
56
  | 门 | 判据 | 方法 |
57
57
  | --- | --- | --- |
58
- | R1 卡层级 | 标签 13px;标题独立成块、16px/700;正文 15.5px;普通首段走 16.5px/600 lead 兜底 | DOM 测字号;无 DevTools 时只判层级(标签 < 标题 < 正文),字号记未验证 |
58
+ | R1 卡层级 | 标签 13px;标题独立成块、16px/700;正文 15.5px;普通首段不升级 lead,只有 `**表格结论:**` 段落使用表格 lead | DOM 测字号;无 DevTools 时只判层级(标签 < 标题 < 正文),字号记未验证 |
59
59
  | R2 热点预算 | 强调块 ≤2;无卡中卡;结论不在普通段落重复一遍。强调块 = 卡片 blockquote + 带色 callout,不含表格与代码 | 原文 + 截图 |
60
60
  | R3 表格 | 表前一行 `**表格结论:**`;无竖线;表头底色可辨;状态只染单元格/行首,不整行铺色 | 原文 + 截图 |
61
61
  | R4 状态 | ✅/⚠️/❌ 字形保留;色不是唯一语义;状态文字对比度 ≥4.5:1 | DOM 取色;无 DevTools 时目检并标未验证 |
@@ -73,7 +73,7 @@ pnpm 和 npm 二选一,给结论和一张不超过 4 行的对比表。
73
73
  | C3 状态符号 | 凡状态行/状态表,保留 ✅/⚠️/❌ | 100% |
74
74
  | C4 代码语言 | 凡围栏代码块,有语言标签 | 100% |
75
75
  | C5 短答克制 | P06:0 卡、0 表、≤2 句 | 1/1 |
76
- | C6 长度 | 去代码块、表格行、卡片标签行后的可见字符 ≤500(P08/P09 豁免) | 100% |
76
+ | C6 篇幅 | 不设硬性字数上限;长回复信息完整、不因字数删信息(人工抽查) | 100% |
77
77
 
78
78
  > 目标组件命中率(实际出现的卡型是否等于期望)只**报告**,不单独判失败;低于 8/12 时复盘 prompt 与注入契约,不要直接改 CSS。
79
79
 
@@ -157,7 +157,7 @@ pnpm 和 npm 二选一,给结论和一张不超过 4 行的对比表。
157
157
  | 级别 | 定义 | 处理 |
158
158
  | --- | --- | --- |
159
159
  | S1 | 渲染断裂:卡没样式、标题塌成正文、溢出/裁切、对比度不达标、GenUI 双渲染 | 停跑,修完重跑全套 |
160
- | S2 | 契约未达:无卡、>2 卡、缺表格结论、缺状态符号、代码无语言、超 500 字 | 记录,修完只复跑相关用例 |
160
+ | S2 | 契约未达:无卡、>2 卡、缺表格结论、缺状态符号、代码无语言、信息不完整或为凑长度删信息 | 记录,修完只复跑相关用例 |
161
161
  | S3 | 观感:间距、图标语义、色调偏差 | 记录,可延后 |
162
162
 
163
163
  - 每个用例只按第一次结果计分。模型没按格式写,就是没写,不靠重跑刷到通过;诊断性重跑单独记,不计入分数。
@@ -165,7 +165,7 @@ pnpm 和 npm 二选一,给结论和一张不超过 4 行的对比表。
165
165
 
166
166
  ## 8. 跑之前必须确认的已知缺口
167
167
 
168
- 1. **卡标题层级**:0.7.3 起,one-p 首段由 16.5px/600 lead 兜底;首段带 `<strong>` 的 one-p 由 statement 兜底;R1 不再要求两个 `<p>`。
168
+ 1. **卡标题层级**:0.8.2 起只认完整卡型标签;只有第一块、首行加粗标题、且有正文/第二段的 blockquote 才升级 statement;普通 one-p 引用不再兜底 lead;表格 lead 只属于 `**表格结论:**` 段落。
169
169
  2. **GenUI 是否安装未知。** 以 `/structured status` 为准;未装时 P12 记 `N/A`,不能算通过,覆盖数写 11/12。
170
170
  3. **DevTools 不一定可用。** desktop 是 Electron,若打不开控制台,R1/R4 退化为 200% 截图目检,R8 的控制台项记“未验证”,并在报告方法列写明,不要写成测量。
171
171
 
package/lib/client.js CHANGED
@@ -26,15 +26,15 @@ window.__ModuleLoader__.load({
26
26
  const STYLE_ID = 'dsh-structured-skin-style'
27
27
  const LEGACY_STYLE_ID = 'dsh-structured-card-style'
28
28
 
29
- const CARD_TYPES = [
30
- [/^结论|^总结|^答案/, 'conclusion'],
31
- [/^判断|^决策/, 'decision'],
32
- [/^指标|^数据/, 'metric'],
33
- [/^风险|^注意|^警告/, 'risk'],
34
- [/^下一步|^行动|^待办/, 'next'],
35
- [/^验收|^通过|^完成/, 'accept'],
36
- [/^重点|^关键/, 'key'],
37
- ]
29
+ const CARD_TYPES = new Map([
30
+ ['结论', 'conclusion'], ['总结', 'conclusion'], ['答案', 'conclusion'],
31
+ ['判断', 'decision'], ['决策', 'decision'],
32
+ ['指标', 'metric'], ['数据', 'metric'],
33
+ ['风险', 'risk'], ['注意', 'risk'], ['警告', 'risk'],
34
+ ['下一步', 'next'], ['行动', 'next'], ['待办', 'next'],
35
+ ['验收', 'accept'], ['通过', 'accept'], ['完成', 'accept'],
36
+ ['重点', 'key'], ['关键', 'key'],
37
+ ])
38
38
  const ICONS = [
39
39
  [/结论|总结|答案|判断/, '◆'],
40
40
  [/方案|计划|步骤|流程/, '▸'],
@@ -45,13 +45,6 @@ window.__ModuleLoader__.load({
45
45
  [/背景|原因|根因/, '◎'],
46
46
  [/指标|数据|结果/, '▦'],
47
47
  ]
48
- const TONES = [
49
- [/风险|注意|警告|坑/, 'warn'],
50
- [/验收|通过|成功/, 'success'],
51
- [/重点|关键|核心/, 'info'],
52
- [/下一步|行动|待办/, 'info'],
53
- [/结论|判断|答案/, 'info'],
54
- ]
55
48
 
56
49
  const CSS = `
57
50
  [class*="_markdown_"]{
@@ -174,19 +167,6 @@ window.__ModuleLoader__.load({
174
167
  margin:14px 0;padding:10px 14px;border-left:3px solid var(--ds-accent);
175
168
  background:color-mix(in srgb,var(--ds-accent) 6%,transparent);border-radius:0 10px 10px 0;
176
169
  }
177
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]{
178
- position:relative;border-left:4px solid var(--ds-accent);border-radius:12px;
179
- padding:14px 16px 14px 38px;
180
- background:linear-gradient(135deg,color-mix(in srgb,var(--ds-accent) 7%,var(--ds-surface)),var(--ds-surface) 62%);
181
- box-shadow:0 1px 2px rgba(0,0,0,.04),0 12px 32px -24px color-mix(in srgb,var(--ds-accent) 32%,transparent);
182
- }
183
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]::before{
184
- content:"◆";position:absolute;left:14px;top:15px;
185
- color:var(--ds-accent);font-size:13px;line-height:1;
186
- }
187
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]>p:first-child{
188
- margin-top:0;font-size:16.5px;line-height:1.6;font-weight:600;color:var(--ds-text);
189
- }
190
170
  [class*="_markdown_"] blockquote[${TONE_ATTR}="warn"]{--ds-accent:var(--dsw-alias-state-warn-primary,#d97706)}
191
171
  [class*="_markdown_"] blockquote[${TONE_ATTR}="success"]{--ds-accent:var(--dsw-alias-state-success-primary,#16a34a)}
192
172
  [class*="_markdown_"] blockquote[${CARD_ATTR}="1"]{
@@ -275,9 +255,24 @@ window.__ModuleLoader__.load({
275
255
  return null
276
256
  }
277
257
 
278
- function cardTypeFor(text) {
279
- for (const [pattern, type] of CARD_TYPES) if (pattern.test(text)) return type
280
- return null
258
+ function firstParagraph(block) {
259
+ return [...(block.children ?? [])].find((element) => element.tagName === 'P') ?? null
260
+ }
261
+
262
+ function labelOf(block) {
263
+ const first = firstParagraph(block)
264
+ const strong = first?.firstElementChild
265
+ if (!strong || strong.tagName !== 'STRONG') return null
266
+ return (strong.textContent ?? '').replace(/[::]\s*$/u, '').trim()
267
+ }
268
+
269
+ function cardTypeFor(block) {
270
+ const label = labelOf(block)
271
+ return label === null ? null : (CARD_TYPES.get(label) ?? null)
272
+ }
273
+
274
+ function isLabelLike(text) {
275
+ return [...CARD_TYPES.keys()].some((label) => text.startsWith(label) && text !== label)
281
276
  }
282
277
 
283
278
  function inGenui(element) {
@@ -288,11 +283,6 @@ window.__ModuleLoader__.load({
288
283
  }
289
284
  }
290
285
 
291
- function toneFor(text) {
292
- for (const [pattern, tone] of TONES) if (pattern.test(text)) return tone
293
- return null
294
- }
295
-
296
286
  function statusFor(text) {
297
287
  if (/^✅/.test(text)) return 'success'
298
288
  if (/^⚠/.test(text)) return 'warn'
@@ -300,24 +290,27 @@ window.__ModuleLoader__.load({
300
290
  return null
301
291
  }
302
292
 
303
- function isFirstBlockquote(block) {
304
- const markdown = block.closest?.('[class*="_markdown_"]')
305
- return Boolean(markdown && markdown.querySelector('blockquote') === block)
306
- }
307
-
308
293
  function isStatementCard(block) {
309
294
  const markdown = block.closest?.('[class*="_markdown_"]')
310
- if (!markdown || markdown.querySelector('blockquote') !== block) return false
295
+ if (!markdown) return false
296
+ const first = [...markdown.querySelectorAll('blockquote')].find((element) => !inGenui(element))
297
+ if (first !== block) return false
311
298
  const paragraphs = [...block.children].filter((element) => element.tagName === 'P')
312
- if (paragraphs.length < 2) {
313
- const first = paragraphs[0]
314
- const strong = first?.firstElementChild
315
- if (!strong || strong.tagName !== 'STRONG') return false
316
- const rest = first.textContent.slice(strong.textContent.length).trim()
317
- return rest.length > 0 || Boolean(first.querySelector('br'))
299
+ const firstParagraph = paragraphs[0]
300
+ const strong = firstParagraph?.firstElementChild
301
+ if (!strong || strong.tagName !== 'STRONG') return false
302
+ let previous = strong.previousSibling
303
+ while (previous) {
304
+ if (previous.nodeType === 1) return false
305
+ if (previous.nodeType === 3 && (previous.textContent ?? '').trim() !== '') return false
306
+ previous = previous.previousSibling
318
307
  }
319
- const first = paragraphs[0]
320
- return Boolean(first?.firstElementChild && first.firstElementChild.tagName === 'STRONG')
308
+ const title = (strong.textContent ?? '').trim()
309
+ if (title.length === 0 || isLabelLike(title)) return false
310
+ let rest = ''
311
+ for (let node = strong.nextSibling; node; node = node.nextSibling) rest += node.textContent ?? ''
312
+ const secondBody = paragraphs.slice(1).some((element) => (element.textContent ?? '').trim().length > 0)
313
+ return rest.trim().length > 0 || secondBody
321
314
  }
322
315
 
323
316
  function decorate(root) {
@@ -330,17 +323,13 @@ window.__ModuleLoader__.load({
330
323
 
331
324
  for (const block of root.querySelectorAll('blockquote')) {
332
325
  if (inGenui(block)) continue
333
- const text = (block.textContent ?? '').trimStart()
334
- let type = cardTypeFor(text)
326
+ let type = cardTypeFor(block)
335
327
  if (type === null && isStatementCard(block)) type = 'statement'
336
328
  if (type === null) {
337
329
  block.removeAttribute(CARD_ATTR)
338
330
  block.removeAttribute(CARD_TYPE_ATTR)
339
- if (isFirstBlockquote(block)) block.setAttribute(LEAD_ATTR, 'lead')
340
- else block.removeAttribute(LEAD_ATTR)
341
- const tone = toneFor(text)
342
- if (tone === null) block.removeAttribute(TONE_ATTR)
343
- else block.setAttribute(TONE_ATTR, tone)
331
+ block.removeAttribute(LEAD_ATTR)
332
+ block.removeAttribute(TONE_ATTR)
344
333
  continue
345
334
  }
346
335
  block.setAttribute(CARD_ATTR, '1')
@@ -398,69 +387,123 @@ window.__ModuleLoader__.load({
398
387
  }
399
388
  }
400
389
 
401
- return {
402
- inject: [],
403
- apply(ctx) {
404
- if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
405
- if (document.body === null) return
390
+ let active = false
391
+ let disposeLayer = null
392
+
393
+ const enable = () => {
394
+ if (active) return
395
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
396
+ if (document.body === null) return
397
+ active = true
406
398
 
407
- document.getElementById(LEGACY_STYLE_ID)?.remove()
408
- document.getElementById(STYLE_ID)?.remove()
409
- const style = document.createElement('style')
410
- style.id = STYLE_ID
411
- style.textContent = CSS
412
- document.head.appendChild(style)
413
- document.documentElement.setAttribute(ROOT_ATTR, 'active')
399
+ document.getElementById(LEGACY_STYLE_ID)?.remove()
400
+ document.getElementById(STYLE_ID)?.remove()
401
+ const style = document.createElement('style')
402
+ style.id = STYLE_ID
403
+ style.textContent = CSS
404
+ document.head.appendChild(style)
405
+ document.documentElement.setAttribute(ROOT_ATTR, 'active')
406
+ try {
407
+ console.info('[dsh-structured] visual layer active')
408
+ } catch {
409
+ // console 不可用时忽略。
410
+ }
411
+
412
+ let disposed = false
413
+ let scheduled = false
414
+ let pending = null
415
+ const run = () => {
416
+ if (disposed) return
417
+ scheduled = false
418
+ pending = null
419
+ decorate(document)
420
+ }
421
+ const schedule = () => {
422
+ if (disposed || scheduled) return
423
+ scheduled = true
424
+ if (typeof requestAnimationFrame === 'function') pending = requestAnimationFrame(run)
425
+ else pending = setTimeout(run, 16)
426
+ }
427
+ const observer = new MutationObserver(schedule)
428
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
429
+ run()
430
+
431
+ disposeLayer = () => {
432
+ if (disposed) return
433
+ disposed = true
434
+ active = false
435
+ disposeLayer = null
436
+ observer.disconnect()
437
+ if (pending !== null) {
438
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(pending)
439
+ else clearTimeout(pending)
440
+ }
441
+ style.remove()
442
+ document.documentElement.removeAttribute(ROOT_ATTR)
443
+ for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
444
+ for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
445
+ }
414
446
  try {
415
- console.info('[dsh-structured] visual layer active')
447
+ console.info('[dsh-structured] visual layer disposed')
416
448
  } catch {
417
449
  // console 不可用时忽略。
418
450
  }
451
+ }
452
+ }
419
453
 
420
- let disposed = false
421
- let scheduled = false
422
- let pending = null
423
- const run = () => {
424
- if (disposed) return
425
- scheduled = false
426
- pending = null
427
- decorate(document)
428
- }
429
- const schedule = () => {
430
- if (disposed || scheduled) return
431
- scheduled = true
432
- if (typeof requestAnimationFrame === 'function') pending = requestAnimationFrame(run)
433
- else pending = setTimeout(run, 16)
434
- }
435
- const observer = new MutationObserver(schedule)
436
- observer.observe(document.body, { childList: true, subtree: true, characterData: true })
437
- run()
454
+ const disable = () => {
455
+ const layer = disposeLayer
456
+ disposeLayer = null
457
+ if (typeof layer === 'function') layer()
458
+ if (typeof document === 'undefined') return
459
+ document.getElementById(STYLE_ID)?.remove()
460
+ document.documentElement.removeAttribute(ROOT_ATTR)
461
+ for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
462
+ for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
463
+ }
464
+ }
465
+
466
+ const applyVisual = (on) => {
467
+ if (on) enable()
468
+ else disable()
469
+ }
438
470
 
439
- const dispose = () => {
440
- if (disposed) return
441
- disposed = true
442
- observer.disconnect()
443
- if (pending !== null) {
444
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(pending)
445
- else clearTimeout(pending)
446
- }
447
- style.remove()
448
- document.documentElement.removeAttribute(ROOT_ATTR)
449
- for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
450
- for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
451
- }
452
- try {
453
- console.info('[dsh-structured] visual layer disposed')
454
- } catch {
455
- // console 不可用时忽略。
456
- }
471
+ return {
472
+ inject: [],
473
+ apply(ctx) {
474
+ // 默认开启,保持 0.8.1 的现有观感;随后由插件设置里的 visual 覆盖。
475
+ enable()
476
+ try {
477
+ ctx?.inject?.(['configForms'], (child) => {
478
+ try {
479
+ const form = child.configForms?.get('dsh-structured')
480
+ if (!form || typeof form.getSnapshot !== 'function') return
481
+ const read = () => {
482
+ try {
483
+ const raw = form.getSnapshot()?.value?.visual
484
+ const visual = raw && typeof raw.get === 'function' ? raw.get() : raw
485
+ applyVisual(visual !== false)
486
+ } catch {
487
+ // 读取失败时保持当前状态,不打断回复。
488
+ }
489
+ }
490
+ read()
491
+ if (typeof form.subscribe === 'function') {
492
+ child.effect?.(() => form.subscribe(read), 'dsh-structured: visual preference')
493
+ }
494
+ } catch {
495
+ // configForms 不可用时保持默认开启。
496
+ }
497
+ })
498
+ } catch {
499
+ // 无 ctx.inject 的宿主保持默认开启。
457
500
  }
458
501
  try {
459
- ctx?.effect?.(() => dispose, 'dsh-structured: visual layer')
502
+ ctx?.effect?.(() => disable, 'dsh-structured: visual layer')
460
503
  } catch {
461
- dispose()
504
+ // 没有 effect 释放器时,视觉层保持到页面卸载。
462
505
  }
463
- return dispose
506
+ return disable
464
507
  },
465
508
  }
466
509
  },
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from 'node:fs'
25
25
  import { homedir } from 'node:os'
26
26
  import { dirname, join } from 'node:path'
27
+ import Schema from '@deepseek-ai/schemastery'
27
28
 
28
29
  /** Cordis 插件名。 */
29
30
  export const name = 'dsh-structured'
@@ -47,6 +48,24 @@ const REMINDER_ORDER = 10300
47
48
  /** 会话条目上限:按最近使用淘汰,防止 state.json 无界增长。 */
48
49
  export const MAX_SESSION_ENTRIES = 500
49
50
 
51
+ /** 把字段标记为 volatile,让 DSH 设置表单可以实时读写。 */
52
+ const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
53
+
54
+ /** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
55
+ const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
56
+
57
+ /**
58
+ * DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
59
+ * 不把 visual 写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
60
+ */
61
+ export const Config = Schema.object({
62
+ visual: k(Schema.boolean().default(true)),
63
+ defaultEnabled: k(Schema.boolean().default(true)),
64
+ tailReminder: k(Schema.boolean().default(true)),
65
+ includeSubagents: k(Schema.boolean().default(false)),
66
+ genui: k(Schema.union(['auto', 'on', 'off']).default('auto').loose()),
67
+ })
68
+
50
69
  /**
51
70
  * 内置规则文本:中文,与 `~/.dsh/AGENTS.md` 第 7 节(响应格式)同源,只规定形状、不另立第二套写作规则。
52
71
  * 逐条对应:首句结论(结论卡)、组件化正文、禁过程叙事与复述、结构化不等于变长、支撑信息带证据、
@@ -286,7 +305,7 @@ export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
286
305
 
287
306
  /**
288
307
  * 生成 /structured 的状态文本(不含副作用)。
289
- * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean }} input
308
+ * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean }} input
290
309
  * @returns {string}
291
310
  */
292
311
  export function statusText(input) {
@@ -296,7 +315,7 @@ export function statusText(input) {
296
315
  '结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
297
316
  '新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
298
317
  '规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
299
- '视觉层:始终开启(/structured 只控制提示词注入,视觉样式不随开关关闭)',
318
+ '视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
300
319
  'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
301
320
  '状态文件:' + stateFile(),
302
321
  '用法:/structured on | off | status',
@@ -313,7 +332,7 @@ export function handleCommand(invocation, api) {
313
332
  const sessionId = sessionIdOf(invocation)
314
333
  const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
315
334
  if (arg === '' || arg === 'status') {
316
- return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui }) }
335
+ return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual }) }
317
336
  }
318
337
  if (arg !== 'on' && arg !== 'off') {
319
338
  return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
@@ -335,17 +354,19 @@ export function handleCommand(invocation, api) {
335
354
  /**
336
355
  * 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
337
356
  * @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
338
- * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off' }} [config]
357
+ * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean }} [config]
339
358
  */
340
359
  export function apply(ctx, config = {}) {
341
- const defaultEnabled = config?.defaultEnabled !== false
342
- const tailReminder = config?.tailReminder !== false
343
- const includeSubagents = config?.includeSubagents === true
344
- const genui = detectGenui(config, ctx)
360
+ const defaultEnabled = readConfig(config?.defaultEnabled) !== false
361
+ const visualNow = () => readConfig(config?.visual) !== false
362
+ const tailReminder = readConfig(config?.tailReminder) !== false
363
+ const includeSubagents = readConfig(config?.includeSubagents) === true
364
+ const genuiConfig = { genui: readConfig(config?.genui) }
365
+ const genui = detectGenui(genuiConfig, ctx)
345
366
  let genuiKnown = genui
346
367
  const genuiNow = () => {
347
368
  if (genuiKnown) return true
348
- genuiKnown = detectGenui(config, ctx)
369
+ genuiKnown = detectGenui(genuiConfig, ctx)
349
370
  return genuiKnown
350
371
  }
351
372
  const builtinStyleText = () => (genuiNow() ? BUILTIN_STYLE_TEXT + '\n\n' + GENUI_ROUTING_TEXT : BUILTIN_STYLE_TEXT)
@@ -462,6 +483,7 @@ export function apply(ctx, config = {}) {
462
483
  sessions,
463
484
  defaultEnabled,
464
485
  genui: genuiNow(),
486
+ visual: visualNow(),
465
487
  onChange: (sessionId, enabled, result) => {
466
488
  warn(
467
489
  'session ' + sessionId + ' structured ' + (enabled ? 'on' : 'off') + (result.ok ? '' : '(持久化失败)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-structured",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Structured output for DSH: no-label title card first, then tables/lists/steps; per-session switch and style.md override. 让 DSH 的每条实质回复默认结构化。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,7 +39,9 @@
39
39
  },
40
40
  "client": {
41
41
  "platform": "web",
42
- "inject": [],
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-ui-settings"
44
+ ],
43
45
  "immediately": true
44
46
  },
45
47
  "compatibility": {
@@ -82,5 +84,12 @@
82
84
  "engines": {
83
85
  "node": ">=22",
84
86
  "dsh": ">=0.1.7-rc.2 <0.3.0-0"
87
+ },
88
+ "peerDependencies": {
89
+ "@deepseek-ai/schemastery": "^3.18.0"
90
+ },
91
+ "devDependencies": {
92
+ "@deepseek-ai/schemastery": "^3.18.0",
93
+ "linkedom": "0.18.14"
85
94
  }
86
95
  }
@@ -73,6 +73,8 @@ check('files 含 lib / src', files.includes('lib') && files.includes('src'), fil
73
73
  check('main 指向 lib/index.js', pkg.main === 'lib/index.js', pkg.main ?? '缺失')
74
74
  check('exports . 指向 lib/index.js', pkg.exports?.['.'] === './lib/index.js', String(pkg.exports?.['.'] ?? '缺失'))
75
75
  check('桌面 client 半区已声明', pkg?.dsh?.client?.platform === 'web', 'dsh.client.platform = web')
76
+ check('client 半区注入 settings', (pkg?.dsh?.client?.inject ?? []).includes('@deepseek-ai/dsh-client-ui-settings'), 'visual 通过 configForms 读取')
77
+ check('声明 schemastery peer', typeof pkg?.peerDependencies?.['@deepseek-ai/schemastery'] === 'string', pkg?.peerDependencies?.['@deepseek-ai/schemastery'] ?? '缺失')
76
78
  check('exports ./client 指向 lib/client.js', pkg.exports?.['./client'] === './lib/client.js', String(pkg.exports?.['./client'] ?? '缺失'))
77
79
  check('engines.dsh 声明兼容范围', typeof pkg?.engines?.dsh === 'string', pkg?.engines?.dsh ?? '缺失')
78
80
  check('dsh.compatibility 声明兼容版本', Boolean(pkg?.dsh?.compatibility?.dshReleases), JSON.stringify(pkg?.dsh?.compatibility?.dshReleases ?? {}))
@@ -105,8 +107,10 @@ try {
105
107
  check('client 模块 id 与包名一致', /id:\s*'dsh-structured'/.test(clientText), '')
106
108
  check('client 模块是纯 DOM 增强(无 React 强依赖)', !/require\(['"]react['"]\)/.test(clientText), '')
107
109
  const imports = [...libText.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1])
108
- const nonNodeImports = imports.filter((spec) => !spec.startsWith('node:'))
109
- check('运行时只 import node: 内置模块', nonNodeImports.length === 0, nonNodeImports.join(', ') || 'ok')
110
+ const allowedRuntimeImports = new Set(['@deepseek-ai/schemastery'])
111
+ const nonNodeImports = imports.filter((spec) => !spec.startsWith('node:') && !allowedRuntimeImports.has(spec))
112
+ check('运行时只 import node: + 宿主 schemastery', nonNodeImports.length === 0, nonNodeImports.join(', ') || 'ok')
113
+ check('导出 Config(设置表单)', /export const Config =/.test(libText), 'theme/visual 等字段由插件设置页渲染')
110
114
  check('inject 只硬依赖 systemPrompt', /export const inject = \['systemPrompt'\]/.test(libText), 'commands 缺失时只降级命令')
111
115
  check('没有 desktop/structured-test 分支', !/['"](?:desktop|structured-test)['"]/.test(libText), '')
112
116
  check('状态目录走 DSH_HOME', /process\.env\.DSH_HOME/.test(libText), '')