dsh-structured 0.7.4 → 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/src/client.js CHANGED
@@ -24,15 +24,15 @@ window.__ModuleLoader__.load({
24
24
  const STYLE_ID = 'dsh-structured-skin-style'
25
25
  const LEGACY_STYLE_ID = 'dsh-structured-card-style'
26
26
 
27
- const CARD_TYPES = [
28
- [/^结论|^总结|^答案/, 'conclusion'],
29
- [/^判断|^决策/, 'decision'],
30
- [/^指标|^数据/, 'metric'],
31
- [/^风险|^注意|^警告/, 'risk'],
32
- [/^下一步|^行动|^待办/, 'next'],
33
- [/^验收|^通过|^完成/, 'accept'],
34
- [/^重点|^关键/, 'key'],
35
- ]
27
+ const CARD_TYPES = new Map([
28
+ ['结论', 'conclusion'], ['总结', 'conclusion'], ['答案', 'conclusion'],
29
+ ['判断', 'decision'], ['决策', 'decision'],
30
+ ['指标', 'metric'], ['数据', 'metric'],
31
+ ['风险', 'risk'], ['注意', 'risk'], ['警告', 'risk'],
32
+ ['下一步', 'next'], ['行动', 'next'], ['待办', 'next'],
33
+ ['验收', 'accept'], ['通过', 'accept'], ['完成', 'accept'],
34
+ ['重点', 'key'], ['关键', 'key'],
35
+ ])
36
36
  const ICONS = [
37
37
  [/结论|总结|答案|判断/, '◆'],
38
38
  [/方案|计划|步骤|流程/, '▸'],
@@ -43,13 +43,6 @@ window.__ModuleLoader__.load({
43
43
  [/背景|原因|根因/, '◎'],
44
44
  [/指标|数据|结果/, '▦'],
45
45
  ]
46
- const TONES = [
47
- [/风险|注意|警告|坑/, 'warn'],
48
- [/验收|通过|成功/, 'success'],
49
- [/重点|关键|核心/, 'info'],
50
- [/下一步|行动|待办/, 'info'],
51
- [/结论|判断|答案/, 'info'],
52
- ]
53
46
 
54
47
  const CSS = `
55
48
  [class*="_markdown_"]{
@@ -172,19 +165,6 @@ window.__ModuleLoader__.load({
172
165
  margin:14px 0;padding:10px 14px;border-left:3px solid var(--ds-accent);
173
166
  background:color-mix(in srgb,var(--ds-accent) 6%,transparent);border-radius:0 10px 10px 0;
174
167
  }
175
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]{
176
- position:relative;border-left:4px solid var(--ds-accent);border-radius:12px;
177
- padding:14px 16px 14px 38px;
178
- background:linear-gradient(135deg,color-mix(in srgb,var(--ds-accent) 7%,var(--ds-surface)),var(--ds-surface) 62%);
179
- box-shadow:0 1px 2px rgba(0,0,0,.04),0 12px 32px -24px color-mix(in srgb,var(--ds-accent) 32%,transparent);
180
- }
181
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]::before{
182
- content:"◆";position:absolute;left:14px;top:15px;
183
- color:var(--ds-accent);font-size:13px;line-height:1;
184
- }
185
- [class*="_markdown_"] blockquote[${LEAD_ATTR}="lead"]>p:first-child{
186
- margin-top:0;font-size:16.5px;line-height:1.6;font-weight:600;color:var(--ds-text);
187
- }
188
168
  [class*="_markdown_"] blockquote[${TONE_ATTR}="warn"]{--ds-accent:var(--dsw-alias-state-warn-primary,#d97706)}
189
169
  [class*="_markdown_"] blockquote[${TONE_ATTR}="success"]{--ds-accent:var(--dsw-alias-state-success-primary,#16a34a)}
190
170
  [class*="_markdown_"] blockquote[${CARD_ATTR}="1"]{
@@ -273,9 +253,24 @@ window.__ModuleLoader__.load({
273
253
  return null
274
254
  }
275
255
 
276
- function cardTypeFor(text) {
277
- for (const [pattern, type] of CARD_TYPES) if (pattern.test(text)) return type
278
- return null
256
+ function firstParagraph(block) {
257
+ return [...(block.children ?? [])].find((element) => element.tagName === 'P') ?? null
258
+ }
259
+
260
+ function labelOf(block) {
261
+ const first = firstParagraph(block)
262
+ const strong = first?.firstElementChild
263
+ if (!strong || strong.tagName !== 'STRONG') return null
264
+ return (strong.textContent ?? '').replace(/[::]\s*$/u, '').trim()
265
+ }
266
+
267
+ function cardTypeFor(block) {
268
+ const label = labelOf(block)
269
+ return label === null ? null : (CARD_TYPES.get(label) ?? null)
270
+ }
271
+
272
+ function isLabelLike(text) {
273
+ return [...CARD_TYPES.keys()].some((label) => text.startsWith(label) && text !== label)
279
274
  }
280
275
 
281
276
  function inGenui(element) {
@@ -286,11 +281,6 @@ window.__ModuleLoader__.load({
286
281
  }
287
282
  }
288
283
 
289
- function toneFor(text) {
290
- for (const [pattern, tone] of TONES) if (pattern.test(text)) return tone
291
- return null
292
- }
293
-
294
284
  function statusFor(text) {
295
285
  if (/^✅/.test(text)) return 'success'
296
286
  if (/^⚠/.test(text)) return 'warn'
@@ -298,24 +288,27 @@ window.__ModuleLoader__.load({
298
288
  return null
299
289
  }
300
290
 
301
- function isFirstBlockquote(block) {
302
- const markdown = block.closest?.('[class*="_markdown_"]')
303
- return Boolean(markdown && markdown.querySelector('blockquote') === block)
304
- }
305
-
306
291
  function isStatementCard(block) {
307
292
  const markdown = block.closest?.('[class*="_markdown_"]')
308
- if (!markdown || markdown.querySelector('blockquote') !== block) return false
293
+ if (!markdown) return false
294
+ const first = [...markdown.querySelectorAll('blockquote')].find((element) => !inGenui(element))
295
+ if (first !== block) return false
309
296
  const paragraphs = [...block.children].filter((element) => element.tagName === 'P')
310
- if (paragraphs.length < 2) {
311
- const first = paragraphs[0]
312
- const strong = first?.firstElementChild
313
- if (!strong || strong.tagName !== 'STRONG') return false
314
- const rest = first.textContent.slice(strong.textContent.length).trim()
315
- return rest.length > 0 || Boolean(first.querySelector('br'))
297
+ const firstParagraph = paragraphs[0]
298
+ const strong = firstParagraph?.firstElementChild
299
+ if (!strong || strong.tagName !== 'STRONG') return false
300
+ let previous = strong.previousSibling
301
+ while (previous) {
302
+ if (previous.nodeType === 1) return false
303
+ if (previous.nodeType === 3 && (previous.textContent ?? '').trim() !== '') return false
304
+ previous = previous.previousSibling
316
305
  }
317
- const first = paragraphs[0]
318
- return Boolean(first?.firstElementChild && first.firstElementChild.tagName === 'STRONG')
306
+ const title = (strong.textContent ?? '').trim()
307
+ if (title.length === 0 || isLabelLike(title)) return false
308
+ let rest = ''
309
+ for (let node = strong.nextSibling; node; node = node.nextSibling) rest += node.textContent ?? ''
310
+ const secondBody = paragraphs.slice(1).some((element) => (element.textContent ?? '').trim().length > 0)
311
+ return rest.trim().length > 0 || secondBody
319
312
  }
320
313
 
321
314
  function decorate(root) {
@@ -328,17 +321,13 @@ window.__ModuleLoader__.load({
328
321
 
329
322
  for (const block of root.querySelectorAll('blockquote')) {
330
323
  if (inGenui(block)) continue
331
- const text = (block.textContent ?? '').trimStart()
332
- let type = cardTypeFor(text)
324
+ let type = cardTypeFor(block)
333
325
  if (type === null && isStatementCard(block)) type = 'statement'
334
326
  if (type === null) {
335
327
  block.removeAttribute(CARD_ATTR)
336
328
  block.removeAttribute(CARD_TYPE_ATTR)
337
- if (isFirstBlockquote(block)) block.setAttribute(LEAD_ATTR, 'lead')
338
- else block.removeAttribute(LEAD_ATTR)
339
- const tone = toneFor(text)
340
- if (tone === null) block.removeAttribute(TONE_ATTR)
341
- else block.setAttribute(TONE_ATTR, tone)
329
+ block.removeAttribute(LEAD_ATTR)
330
+ block.removeAttribute(TONE_ATTR)
342
331
  continue
343
332
  }
344
333
  block.setAttribute(CARD_ATTR, '1')
@@ -396,69 +385,123 @@ window.__ModuleLoader__.load({
396
385
  }
397
386
  }
398
387
 
399
- return {
400
- inject: [],
401
- apply(ctx) {
402
- if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
403
- if (document.body === null) return
388
+ let active = false
389
+ let disposeLayer = null
390
+
391
+ const enable = () => {
392
+ if (active) return
393
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
394
+ if (document.body === null) return
395
+ active = true
404
396
 
405
- document.getElementById(LEGACY_STYLE_ID)?.remove()
406
- document.getElementById(STYLE_ID)?.remove()
407
- const style = document.createElement('style')
408
- style.id = STYLE_ID
409
- style.textContent = CSS
410
- document.head.appendChild(style)
411
- document.documentElement.setAttribute(ROOT_ATTR, 'active')
397
+ document.getElementById(LEGACY_STYLE_ID)?.remove()
398
+ document.getElementById(STYLE_ID)?.remove()
399
+ const style = document.createElement('style')
400
+ style.id = STYLE_ID
401
+ style.textContent = CSS
402
+ document.head.appendChild(style)
403
+ document.documentElement.setAttribute(ROOT_ATTR, 'active')
404
+ try {
405
+ console.info('[dsh-structured] visual layer active')
406
+ } catch {
407
+ // console 不可用时忽略。
408
+ }
409
+
410
+ let disposed = false
411
+ let scheduled = false
412
+ let pending = null
413
+ const run = () => {
414
+ if (disposed) return
415
+ scheduled = false
416
+ pending = null
417
+ decorate(document)
418
+ }
419
+ const schedule = () => {
420
+ if (disposed || scheduled) return
421
+ scheduled = true
422
+ if (typeof requestAnimationFrame === 'function') pending = requestAnimationFrame(run)
423
+ else pending = setTimeout(run, 16)
424
+ }
425
+ const observer = new MutationObserver(schedule)
426
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
427
+ run()
428
+
429
+ disposeLayer = () => {
430
+ if (disposed) return
431
+ disposed = true
432
+ active = false
433
+ disposeLayer = null
434
+ observer.disconnect()
435
+ if (pending !== null) {
436
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(pending)
437
+ else clearTimeout(pending)
438
+ }
439
+ style.remove()
440
+ document.documentElement.removeAttribute(ROOT_ATTR)
441
+ for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
442
+ for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
443
+ }
412
444
  try {
413
- console.info('[dsh-structured] visual layer active')
445
+ console.info('[dsh-structured] visual layer disposed')
414
446
  } catch {
415
447
  // console 不可用时忽略。
416
448
  }
449
+ }
450
+ }
417
451
 
418
- let disposed = false
419
- let scheduled = false
420
- let pending = null
421
- const run = () => {
422
- if (disposed) return
423
- scheduled = false
424
- pending = null
425
- decorate(document)
426
- }
427
- const schedule = () => {
428
- if (disposed || scheduled) return
429
- scheduled = true
430
- if (typeof requestAnimationFrame === 'function') pending = requestAnimationFrame(run)
431
- else pending = setTimeout(run, 16)
432
- }
433
- const observer = new MutationObserver(schedule)
434
- observer.observe(document.body, { childList: true, subtree: true, characterData: true })
435
- run()
452
+ const disable = () => {
453
+ const layer = disposeLayer
454
+ disposeLayer = null
455
+ if (typeof layer === 'function') layer()
456
+ if (typeof document === 'undefined') return
457
+ document.getElementById(STYLE_ID)?.remove()
458
+ document.documentElement.removeAttribute(ROOT_ATTR)
459
+ for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
460
+ for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
461
+ }
462
+ }
463
+
464
+ const applyVisual = (on) => {
465
+ if (on) enable()
466
+ else disable()
467
+ }
436
468
 
437
- const dispose = () => {
438
- if (disposed) return
439
- disposed = true
440
- observer.disconnect()
441
- if (pending !== null) {
442
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(pending)
443
- else clearTimeout(pending)
444
- }
445
- style.remove()
446
- document.documentElement.removeAttribute(ROOT_ATTR)
447
- for (const attr of [CARD_ATTR, CARD_TYPE_ATTR, ICON_ATTR, TONE_ATTR, LANG_ATTR, STATUS_ATTR, STATUS_TABLE_ATTR, LEAD_ATTR, ROW_ATTR]) {
448
- for (const element of document.querySelectorAll('[' + attr + ']')) element.removeAttribute(attr)
449
- }
450
- try {
451
- console.info('[dsh-structured] visual layer disposed')
452
- } catch {
453
- // console 不可用时忽略。
454
- }
469
+ return {
470
+ inject: [],
471
+ apply(ctx) {
472
+ // 默认开启,保持 0.8.1 的现有观感;随后由插件设置里的 visual 覆盖。
473
+ enable()
474
+ try {
475
+ ctx?.inject?.(['configForms'], (child) => {
476
+ try {
477
+ const form = child.configForms?.get('dsh-structured')
478
+ if (!form || typeof form.getSnapshot !== 'function') return
479
+ const read = () => {
480
+ try {
481
+ const raw = form.getSnapshot()?.value?.visual
482
+ const visual = raw && typeof raw.get === 'function' ? raw.get() : raw
483
+ applyVisual(visual !== false)
484
+ } catch {
485
+ // 读取失败时保持当前状态,不打断回复。
486
+ }
487
+ }
488
+ read()
489
+ if (typeof form.subscribe === 'function') {
490
+ child.effect?.(() => form.subscribe(read), 'dsh-structured: visual preference')
491
+ }
492
+ } catch {
493
+ // configForms 不可用时保持默认开启。
494
+ }
495
+ })
496
+ } catch {
497
+ // 无 ctx.inject 的宿主保持默认开启。
455
498
  }
456
499
  try {
457
- ctx?.effect?.(() => dispose, 'dsh-structured: visual layer')
500
+ ctx?.effect?.(() => disable, 'dsh-structured: visual layer')
458
501
  } catch {
459
- dispose()
502
+ // 没有 effect 释放器时,视觉层保持到页面卸载。
460
503
  }
461
- return dispose
504
+ return disable
462
505
  },
463
506
  }
464
507
  },
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from 'node:fs'
23
23
  import { homedir } from 'node:os'
24
24
  import { dirname, join } from 'node:path'
25
+ import Schema from '@deepseek-ai/schemastery'
25
26
 
26
27
  /** Cordis 插件名。 */
27
28
  export const name = 'dsh-structured'
@@ -45,6 +46,24 @@ const REMINDER_ORDER = 10300
45
46
  /** 会话条目上限:按最近使用淘汰,防止 state.json 无界增长。 */
46
47
  export const MAX_SESSION_ENTRIES = 500
47
48
 
49
+ /** 把字段标记为 volatile,让 DSH 设置表单可以实时读写。 */
50
+ const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
51
+
52
+ /** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
53
+ const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
54
+
55
+ /**
56
+ * DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
57
+ * 不把 visual 写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
58
+ */
59
+ export const Config = Schema.object({
60
+ visual: k(Schema.boolean().default(true)),
61
+ defaultEnabled: k(Schema.boolean().default(true)),
62
+ tailReminder: k(Schema.boolean().default(true)),
63
+ includeSubagents: k(Schema.boolean().default(false)),
64
+ genui: k(Schema.union(['auto', 'on', 'off']).default('auto').loose()),
65
+ })
66
+
48
67
  /**
49
68
  * 内置规则文本:中文,与 `~/.dsh/AGENTS.md` 第 7 节(响应格式)同源,只规定形状、不另立第二套写作规则。
50
69
  * 逐条对应:首句结论(结论卡)、组件化正文、禁过程叙事与复述、结构化不等于变长、支撑信息带证据、
@@ -56,9 +75,9 @@ export const BUILTIN_STYLE_TEXT = [
56
75
  '面向用户的实质回复按下面的形状组织。这是硬约束,不是风格建议。',
57
76
  '',
58
77
  '1. **首句给结论,并把它做成卡片**:第一段固定两行——`> **一句标题**`(加粗放大,≤60 个汉字或一句短句)→ `> 1–2 行正文`;标题即结论,细节放正文,不要写「结论」标签,不要再用普通段落重复。',
59
- '2. **按内容选卡,最多两个强调块**:3 条以上要点用清单或表格;每条实质回复 1 个主卡(首段标题无标签);判断/指标/重点卡用 `> **判断**` / `> **指标**` / `> **重点**`;最多再 1 个行动卡(`风险` / `下一步` / `验收`);表格前先给一行 `**表格结论:** ...`;状态行/状态单元格用 `✅ 完成` / `⚠️ 待做` / `❌ 阻塞` 开头;表格 ≤5 行,正文 ≤500 字(含表格);短答不套卡,其余保持中性。',
78
+ '2. **按信息类型选组件**:3 条以上要点用清单或表格;每条实质回复 1 个主卡(首段标题无标签);判断/指标/重点卡用 `> **判断**` / `> **指标**` / `> **重点**`;最多再 1 个行动卡(`风险` / `下一步` / `验收`);表格前先给一行 `**表格结论:** ...`;状态行/状态单元格用 `✅ 完成` / `⚠️ 待做` / `❌ 阻塞` 开头;表格优先 ≤5 行;信息确实多时拆表或使用宿主支持的大表,不硬砍内容;不为了格式加内容,也不为了字数删信息;短答不套卡,其余保持中性。',
60
79
  '3. **一段一个主题**:同一信息只出现一次,不要正文和组件各说一遍;不写过程叙事(我先…/已经…/接下来…),不写收尾总结,不加「还需要我…吗?」。',
61
- '4. **结构化不等于变长**:组件是压缩阅读时间的工具,不是加信息的借口;对话汇报正文不超过 500 字,超出的细节写进文件,回复只留结论加文件路径。',
80
+ '4. **清晰优先,不设硬性篇幅上限**:组件是帮读者快速定位结论的工具,不是加信息的借口;短答不套卡,长答按信息量组织;判断标准是读者只看首段标题 + 表格首列/列表首句,就能知道结论和下一步;细节写文件,回复保留结论与路径。',
62
81
  '5. **支撑信息带证据**:每条支撑尽量带数字、路径或结果;没有证据的判断明确标成推断。',
63
82
  '6. **可复制内容用代码块**:提示词、命令、配置、JSON、SQL 等需要复制的文本,用带语言标签的代码块包裹(如 `text` / `powershell`),不要用引用块或普通段落;代码块右上角有复制按钮。',
64
83
  ].join('\n')
@@ -70,7 +89,7 @@ export const GENUI_ROUTING_TEXT = [
70
89
 
71
90
  /** 尾部提醒:一句话重复契约中最容易衰减的四件事,长度控制在几十个 token。 */
72
91
  export const TAIL_REMINDER_TEXT = [
73
- '提醒:本轮最终回复第一段用无标签标题卡:加粗标题(≤60 字)/ 正文两行;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格 ≤5 行,正文不超过 500 字;可复制内容用代码块。',
92
+ '提醒:本轮最终回复第一段用无标签标题卡:加粗标题(≤60 字)/ 正文两行;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格优先 ≤5 行;不设硬性篇幅上限;清晰优先;可复制内容用代码块。',
74
93
  ].join('\n')
75
94
 
76
95
  /** DSH home:优先 DSH_HOME,缺省 ~/.dsh。 */
@@ -284,7 +303,7 @@ export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
284
303
 
285
304
  /**
286
305
  * 生成 /structured 的状态文本(不含副作用)。
287
- * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean }} input
306
+ * @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean }} input
288
307
  * @returns {string}
289
308
  */
290
309
  export function statusText(input) {
@@ -294,7 +313,7 @@ export function statusText(input) {
294
313
  '结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
295
314
  '新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
296
315
  '规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
297
- '视觉层:始终开启(/structured 只控制提示词注入,视觉样式不随开关关闭)',
316
+ '视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
298
317
  'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
299
318
  '状态文件:' + stateFile(),
300
319
  '用法:/structured on | off | status',
@@ -311,7 +330,7 @@ export function handleCommand(invocation, api) {
311
330
  const sessionId = sessionIdOf(invocation)
312
331
  const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
313
332
  if (arg === '' || arg === 'status') {
314
- return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui }) }
333
+ return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual }) }
315
334
  }
316
335
  if (arg !== 'on' && arg !== 'off') {
317
336
  return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
@@ -333,17 +352,19 @@ export function handleCommand(invocation, api) {
333
352
  /**
334
353
  * 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
335
354
  * @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
336
- * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off' }} [config]
355
+ * @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean }} [config]
337
356
  */
338
357
  export function apply(ctx, config = {}) {
339
- const defaultEnabled = config?.defaultEnabled !== false
340
- const tailReminder = config?.tailReminder !== false
341
- const includeSubagents = config?.includeSubagents === true
342
- const genui = detectGenui(config, ctx)
358
+ const defaultEnabled = readConfig(config?.defaultEnabled) !== false
359
+ const visualNow = () => readConfig(config?.visual) !== false
360
+ const tailReminder = readConfig(config?.tailReminder) !== false
361
+ const includeSubagents = readConfig(config?.includeSubagents) === true
362
+ const genuiConfig = { genui: readConfig(config?.genui) }
363
+ const genui = detectGenui(genuiConfig, ctx)
343
364
  let genuiKnown = genui
344
365
  const genuiNow = () => {
345
366
  if (genuiKnown) return true
346
- genuiKnown = detectGenui(config, ctx)
367
+ genuiKnown = detectGenui(genuiConfig, ctx)
347
368
  return genuiKnown
348
369
  }
349
370
  const builtinStyleText = () => (genuiNow() ? BUILTIN_STYLE_TEXT + '\n\n' + GENUI_ROUTING_TEXT : BUILTIN_STYLE_TEXT)
@@ -460,6 +481,7 @@ export function apply(ctx, config = {}) {
460
481
  sessions,
461
482
  defaultEnabled,
462
483
  genui: genuiNow(),
484
+ visual: visualNow(),
463
485
  onChange: (sessionId, enabled, result) => {
464
486
  warn(
465
487
  'session ' + sessionId + ' structured ' + (enabled ? 'on' : 'off') + (result.ok ? '' : '(持久化失败)'),
@@ -0,0 +1,140 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import vm from 'node:vm'
5
+ import { parseHTML } from 'linkedom'
6
+
7
+ const clientSource = readFileSync('src/client.js', 'utf8')
8
+
9
+ function render(html) {
10
+ const { window, document } = parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`)
11
+ let definition
12
+ const sandbox = {
13
+ window: {
14
+ __ModuleLoader__: {
15
+ load(value) {
16
+ definition = value
17
+ },
18
+ },
19
+ },
20
+ document,
21
+ MutationObserver: class {
22
+ observe() {}
23
+ disconnect() {}
24
+ },
25
+ requestAnimationFrame(fn) {
26
+ fn()
27
+ return 1
28
+ },
29
+ cancelAnimationFrame() {},
30
+ setTimeout,
31
+ clearTimeout,
32
+ console,
33
+ }
34
+ sandbox.globalThis = sandbox
35
+ vm.runInNewContext(clientSource, sandbox)
36
+ assert.ok(definition, 'client module definition captured')
37
+ const plugin = definition.factory()
38
+ plugin.apply({
39
+ inject(_services, callback) {
40
+ callback({ configForms: { get: () => null } })
41
+ },
42
+ effect() {},
43
+ })
44
+ return document
45
+ }
46
+
47
+ function block(document, id) {
48
+ const element = document.querySelector(`#${id}`)
49
+ assert.ok(element, `missing #${id}`)
50
+ return element
51
+ }
52
+
53
+ function assertPlain(document, id) {
54
+ const element = block(document, id)
55
+ assert.equal(element.getAttribute('data-dsh-structured-card'), null, `${id} card`)
56
+ assert.equal(element.getAttribute('data-dsh-structured-card-type'), null, `${id} type`)
57
+ assert.equal(element.getAttribute('data-dsh-structured-tone'), null, `${id} tone`)
58
+ assert.equal(element.getAttribute('data-dsh-structured-lead'), null, `${id} lead`)
59
+ }
60
+
61
+ function assertCard(document, id, type, tone) {
62
+ const element = block(document, id)
63
+ assert.equal(element.getAttribute('data-dsh-structured-card'), '1', `${id} card`)
64
+ assert.equal(element.getAttribute('data-dsh-structured-card-type'), type, `${id} type`)
65
+ assert.equal(element.getAttribute('data-dsh-structured-tone'), tone, `${id} tone`)
66
+ assert.equal(element.getAttribute('data-dsh-structured-lead'), null, `${id} lead`)
67
+ }
68
+
69
+ test('严格卡片:复合标签、普通引用不升级,完整标签才成卡', () => {
70
+ const document = render(`
71
+ <div class="_markdown_q2"><blockquote id="q2"><p><strong>风险提示</strong><br>影响:宿主升级后可能失效。</p></blockquote></div>
72
+ <div class="_markdown_test">
73
+ <blockquote id="q1"><p>风险提示:宿主升级后可能失效。</p></blockquote>
74
+ <blockquote id="q3"><p>结论:这是用户引用的原文。</p></blockquote>
75
+ <blockquote id="q4"><p>这是普通引用,没有加粗标题。</p></blockquote>
76
+ <blockquote id="q9"><p><strong>判断依据</strong><br>不应成卡。</p></blockquote>
77
+ </div>
78
+ <div class="_markdown_q10"><blockquote id="q10"><p>前缀 <strong>标题</strong></p></blockquote></div>
79
+ <div class="_markdown_q11"><blockquote id="q11"><p> <strong>标题</strong></p></blockquote></div>
80
+ <div class="_markdown_q18"><blockquote id="q18"><p>前缀 <strong>标题</strong>尾正文</p></blockquote></div>
81
+ <div class="_markdown_q16"><blockquote id="q16"><p><strong>结论性说明</strong><br>不应成卡。</p></blockquote></div>
82
+ <div class="_markdown_q17"><blockquote id="q17"><p><strong>指标数据</strong><br>不应成卡。</p></blockquote></div>
83
+ <div class="_markdown_test2">
84
+ <blockquote id="q5"><p><strong>Phase 1 完成</strong><br>28/28 通过。</p></blockquote>
85
+ <blockquote id="q5b"><p><strong>Phase 2 完成</strong><br>不应升级为 statement。</p></blockquote>
86
+ <blockquote id="q6"><p><strong>风险</strong><br>影响:宿主升级后可能失效。</p></blockquote>
87
+ <blockquote id="q7"><p><strong>下一步</strong><br>1. 安装测试 profile。</p></blockquote>
88
+ <blockquote id="q8"><p><strong>验收</strong><br>✅ 3/3</p></blockquote>
89
+ <blockquote id="q12"><p><strong>结论</strong><br>结论卡。</p></blockquote>
90
+ <blockquote id="q13"><p><strong>决策</strong><br>决策卡。</p></blockquote>
91
+ <blockquote id="q14"><p><strong>指标</strong><br>指标卡。</p></blockquote>
92
+ <blockquote id="q15"><p><strong>重点</strong><br>重点卡。</p></blockquote>
93
+ <div data-genui="1">
94
+ <blockquote id="gen"><p><strong>风险</strong><br>GenUI 内不处理。</p></blockquote>
95
+ </div>
96
+ </div>
97
+ <div class="_markdown_test5">
98
+ <div data-genui="1">
99
+ <blockquote id="genfirst"><p><strong>风险</strong><br>GenUI 内不处理,也不应阻断真实首块。</p></blockquote>
100
+ </div>
101
+ <blockquote id="real"><p><strong>Phase 9 完成</strong><br>真实首块仍应升级 statement。</p></blockquote>
102
+ </div>
103
+ `)
104
+
105
+ // 反例:前缀/复合词、普通引用、strong 前有正文或空白
106
+ assertPlain(document, 'q1')
107
+ assertPlain(document, 'q2')
108
+ assertPlain(document, 'q3')
109
+ assertPlain(document, 'q4')
110
+ assertPlain(document, 'q9')
111
+ assertPlain(document, 'q10')
112
+ assertPlain(document, 'q11')
113
+ assertPlain(document, 'q18')
114
+ assertPlain(document, 'q16')
115
+ assertPlain(document, 'q17')
116
+
117
+ // 正例:全部卡型 + 仅第一块可升级 + GenUI 前置不阻断真实首块
118
+ assertCard(document, 'q5', 'statement', 'info')
119
+ assertCard(document, 'q6', 'risk', 'warn')
120
+ assertCard(document, 'q7', 'next', 'info')
121
+ assertCard(document, 'q8', 'accept', 'success')
122
+ assertCard(document, 'q12', 'conclusion', 'info')
123
+ assertCard(document, 'q13', 'decision', 'info')
124
+ assertCard(document, 'q14', 'metric', 'info')
125
+ assertCard(document, 'q15', 'key', 'info')
126
+ assertCard(document, 'real', 'statement', 'info')
127
+ assertPlain(document, 'q5b')
128
+
129
+ // GenUI 区域跳过
130
+ assertPlain(document, 'gen')
131
+ assertPlain(document, 'genfirst')
132
+ })
133
+
134
+ test('严格卡片:不再有普通首引用 lead 与文本前缀 tone 逻辑', () => {
135
+ assert.doesNotMatch(clientSource, /isFirstBlockquote/)
136
+ assert.doesNotMatch(clientSource, /toneFor/)
137
+ assert.doesNotMatch(clientSource, /setAttribute\(LEAD_ATTR,\s*'lead'\)/)
138
+ assert.match(clientSource, /CARD_TYPES = new Map/)
139
+ assert.match(clientSource, /isStatementCard/)
140
+ })