dsh-structured 0.8.3 → 0.9.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 +21 -0
- package/README.md +43 -18
- package/assets/screenshots/09-theme-light.png +0 -0
- package/assets/screenshots/10-theme-dark.png +0 -0
- package/cordis.patch.yml +2 -0
- package/docs/DESIGN-REFERENCES.md +1 -1
- package/docs/RISK-ROLLBACK.md +3 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/VISUAL-CRITIQUE.md +1 -1
- package/docs/VISUAL-PLAN.md +10 -10
- package/lib/client.js +724 -508
- package/lib/index.js +22 -10
- package/package.json +3 -1
- package/screenshots.json +3 -1
- package/scripts/measure-prose.mjs +3 -1
- package/scripts/theme-contrast.mjs +104 -0
- package/src/client.js +724 -508
- package/src/index.js +22 -10
- package/test/client-config-page.test.mjs +273 -0
- package/test/client-visual.test.mjs +130 -3
- package/test/measure-prose.test.mjs +9 -0
- package/test/structured.test.mjs +4 -1
- package/test/theme.test.mjs +121 -0
package/lib/index.js
CHANGED
|
@@ -54,16 +54,20 @@ const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
|
|
|
54
54
|
/** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
|
|
55
55
|
const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
|
|
56
56
|
|
|
57
|
+
/** 主题白名单:host 与 client 共用同一组值。 */
|
|
58
|
+
export const THEMES = ['deepseek', 'codex', 'anthropic']
|
|
59
|
+
|
|
57
60
|
/**
|
|
58
61
|
* DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
|
|
59
|
-
*
|
|
62
|
+
* 不把主题/视觉写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
|
|
60
63
|
*/
|
|
61
64
|
export const Config = Schema.object({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
theme: k(Schema.union(THEMES).default('deepseek').description('主题 / Theme: deepseek | codex | anthropic').loose()),
|
|
66
|
+
visual: k(Schema.boolean().default(true).description('视觉层 / Visual layer: 关闭后不加载回复区样式')),
|
|
67
|
+
defaultEnabled: k(Schema.boolean().default(true).description('新会话默认开启 / Default on for new sessions')),
|
|
68
|
+
tailReminder: k(Schema.boolean().default(true).description('尾部提醒 / Tail reminder: 每轮追加一句结构化提醒')),
|
|
69
|
+
includeSubagents: k(Schema.boolean().default(false).description('子代理注入 / Include subagents: 默认关闭')),
|
|
70
|
+
genui: k(Schema.union(['auto', 'on', 'off']).default('auto').description('GenUI 联动 / GenUI routing: auto | on | off').loose()),
|
|
67
71
|
})
|
|
68
72
|
|
|
69
73
|
/**
|
|
@@ -82,6 +86,7 @@ export const BUILTIN_STYLE_TEXT = [
|
|
|
82
86
|
'4. **清晰优先,不设硬性篇幅上限**:组件是帮读者快速定位结论的工具,不是加信息的借口;短答不套卡,长答按信息量组织;判断标准是读者只看首段标题 + 表格首列/列表首句,就能知道结论和下一步;细节写文件,回复保留结论与路径。',
|
|
83
87
|
'5. **支撑信息带证据**:每条支撑尽量带数字、路径或结果;没有证据的判断明确标成推断。',
|
|
84
88
|
'6. **可复制内容用代码块**:提示词、命令、配置、JSON、SQL 等需要复制的文本,用带语言标签的代码块包裹(如 `text` / `powershell`),不要用引用块或普通段落;代码块右上角有复制按钮。',
|
|
89
|
+
'7. **代码/补丁例外**:当本轮主要是代码、代码 diff、文件修改或补丁时,代码块或 diff 卡可以直接作为第一块,不强制标题卡;不要为了结构化加标题卡、表格或「下一步」卡;仍不写过程叙事。',
|
|
85
90
|
].join('\n')
|
|
86
91
|
|
|
87
92
|
/** GenUI 已安装时才追加的路由规则:交互/图表走 dsh-ui,普通卡片仍走 markdown。 */
|
|
@@ -91,7 +96,7 @@ export const GENUI_ROUTING_TEXT = [
|
|
|
91
96
|
|
|
92
97
|
/** 尾部提醒:一句话重复契约中最容易衰减的四件事,长度控制在几十个 token。 */
|
|
93
98
|
export const TAIL_REMINDER_TEXT = [
|
|
94
|
-
'提醒:本轮最终回复第一段必须是 markdown
|
|
99
|
+
'提醒:本轮最终回复第一段必须是 markdown 无标签标题卡(代码/diff 回复例外:代码块或 diff 卡可直接作首块,不强制标题卡):加粗标题(≤60 字)/ 正文两行(即使后文要用组件也一样);组件只能排在标题卡之后,不得用组件充当首卡;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格优先 ≤5 行;不设硬性篇幅上限;清晰优先;可复制内容用代码块。',
|
|
95
100
|
].join('\n')
|
|
96
101
|
|
|
97
102
|
/** DSH home:优先 DSH_HOME,缺省 ~/.dsh。 */
|
|
@@ -305,16 +310,18 @@ export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
|
|
|
305
310
|
|
|
306
311
|
/**
|
|
307
312
|
* 生成 /structured 的状态文本(不含副作用)。
|
|
308
|
-
* @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean }} input
|
|
313
|
+
* @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean, theme?: string }} input
|
|
309
314
|
* @returns {string}
|
|
310
315
|
*/
|
|
311
316
|
export function statusText(input) {
|
|
312
317
|
const style = activeStyle()
|
|
313
318
|
const enabled = isEnabledFor(input.sessions, input.defaultEnabled, input.sessionId)
|
|
319
|
+
const theme = THEMES.includes(input.theme) ? input.theme : 'deepseek'
|
|
314
320
|
return [
|
|
315
321
|
'结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
|
|
316
322
|
'新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
|
|
317
323
|
'规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
|
|
324
|
+
'主题:' + theme + '(由插件设置 theme 控制)',
|
|
318
325
|
'视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
|
|
319
326
|
'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
|
|
320
327
|
'状态文件:' + stateFile(),
|
|
@@ -332,7 +339,7 @@ export function handleCommand(invocation, api) {
|
|
|
332
339
|
const sessionId = sessionIdOf(invocation)
|
|
333
340
|
const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
|
|
334
341
|
if (arg === '' || arg === 'status') {
|
|
335
|
-
return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual }) }
|
|
342
|
+
return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual, theme: api.theme }) }
|
|
336
343
|
}
|
|
337
344
|
if (arg !== 'on' && arg !== 'off') {
|
|
338
345
|
return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
|
|
@@ -354,10 +361,14 @@ export function handleCommand(invocation, api) {
|
|
|
354
361
|
/**
|
|
355
362
|
* 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
|
|
356
363
|
* @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
|
|
357
|
-
* @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean }} [config]
|
|
364
|
+
* @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean, theme?: 'deepseek' | 'codex' | 'anthropic' }} [config]
|
|
358
365
|
*/
|
|
359
366
|
export function apply(ctx, config = {}) {
|
|
360
367
|
const defaultEnabled = readConfig(config?.defaultEnabled) !== false
|
|
368
|
+
const themeNow = () => {
|
|
369
|
+
const theme = readConfig(config?.theme)
|
|
370
|
+
return THEMES.includes(theme) ? theme : 'deepseek'
|
|
371
|
+
}
|
|
361
372
|
const visualNow = () => readConfig(config?.visual) !== false
|
|
362
373
|
const tailReminder = readConfig(config?.tailReminder) !== false
|
|
363
374
|
const includeSubagents = readConfig(config?.includeSubagents) === true
|
|
@@ -484,6 +495,7 @@ export function apply(ctx, config = {}) {
|
|
|
484
495
|
defaultEnabled,
|
|
485
496
|
genui: genuiNow(),
|
|
486
497
|
visual: visualNow(),
|
|
498
|
+
theme: themeNow(),
|
|
487
499
|
onChange: (sessionId, enabled, result) => {
|
|
488
500
|
warn(
|
|
489
501
|
'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.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"!docs/PUBLISH-READY.md",
|
|
65
65
|
"!docs/RELEASE-NOTES-0.7.4.md",
|
|
66
66
|
"!docs/MAINTENANCE*.md",
|
|
67
|
+
"!docs/ACCEPTANCE-0.9.1.md",
|
|
67
68
|
"assets/screenshots",
|
|
68
69
|
"screenshots.json",
|
|
69
70
|
"test",
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
"build": "node scripts/build.mjs",
|
|
80
81
|
"pretest": "node scripts/build.mjs",
|
|
81
82
|
"test": "node --test test/*.test.mjs",
|
|
83
|
+
"theme-contrast": "node scripts/theme-contrast.mjs",
|
|
82
84
|
"prepack": "node scripts/build.mjs"
|
|
83
85
|
},
|
|
84
86
|
"engines": {
|
package/screenshots.json
CHANGED
|
@@ -2,5 +2,7 @@
|
|
|
2
2
|
"assets/screenshots/01-title-card.png",
|
|
3
3
|
"assets/screenshots/06-status-table.png",
|
|
4
4
|
"assets/screenshots/07-code-block.png",
|
|
5
|
-
"assets/screenshots/08-narrow.png"
|
|
5
|
+
"assets/screenshots/08-narrow.png",
|
|
6
|
+
"assets/screenshots/09-theme-light.png",
|
|
7
|
+
"assets/screenshots/10-theme-dark.png"
|
|
6
8
|
]
|
|
@@ -92,7 +92,8 @@ export function analysisText(text) {
|
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* 首屏是否出现结构。按字符数截断而不是按行数,避免长段落把结构推到屏幕外。
|
|
95
|
-
*
|
|
95
|
+
* 代码/ diff 优先回复:首屏出现 fenced code(含 diff/patch)计为结构化;
|
|
96
|
+
* 非代码的 fenced block 仍先剥掉,避免代码里的 | / - 被误判。
|
|
96
97
|
* @param {string} text
|
|
97
98
|
* @param {number} limit
|
|
98
99
|
* @returns {boolean}
|
|
@@ -100,6 +101,7 @@ export function analysisText(text) {
|
|
|
100
101
|
export function firstScreenStructured(text, limit = DEFAULT_FIRST_SCREEN) {
|
|
101
102
|
const head = text.slice(0, limit)
|
|
102
103
|
if (head.includes('```dsh-ui')) return true
|
|
104
|
+
if (/```(?!dsh-ui)/.test(head)) return true
|
|
103
105
|
// 非 dsh-ui 的 fenced block:从开栅栏起整段丢弃(含未闭合到首屏末尾的情况)。
|
|
104
106
|
const cleaned = head.replace(/```(?!dsh-ui)[^\n]*\n[\s\S]*?(?:```|$)/g, '\n')
|
|
105
107
|
const lines = cleaned.split('\n')
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** 3×2 主题 token 对比度门禁:正文/次要文字/强调色/状态色/代码块。 */
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export const THEME_TOKENS = {
|
|
6
|
+
deepseek: {
|
|
7
|
+
light: {
|
|
8
|
+
surface: '#FFFFFF', surface2: '#F4F7FB', text: '#0F172A', muted: '#5B6B82',
|
|
9
|
+
border: '#E2E8F0', accent: '#2563EB', accentText: '#2563EB', success: '#166534', warn: '#92400E', error: '#991B1B',
|
|
10
|
+
codeBg: '#F6F8FA', codeText: '#0F172A',
|
|
11
|
+
},
|
|
12
|
+
dark: {
|
|
13
|
+
surface: '#111827', surface2: '#1F2937', text: '#E5E7EB', muted: '#9CA3AF',
|
|
14
|
+
border: '#374151', accent: '#60A5FA', accentText: '#60A5FA', success: '#4ADE80', warn: '#FBBF24', error: '#F87171',
|
|
15
|
+
codeBg: '#0B1220', codeText: '#E5E7EB',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
codex: {
|
|
19
|
+
light: {
|
|
20
|
+
surface: '#FFFFFF', surface2: '#F0F7F4', text: '#0D1117', muted: '#4F625A',
|
|
21
|
+
border: '#CFE3DB', accent: '#10A37F', accentText: '#0B6B4F', success: '#116329', warn: '#7D4E00', error: '#A40E26',
|
|
22
|
+
codeBg: '#F0F7F4', codeText: '#0D1117',
|
|
23
|
+
},
|
|
24
|
+
dark: {
|
|
25
|
+
surface: '#0B1512', surface2: '#12211B', text: '#E6F2EC', muted: '#9DB2A8',
|
|
26
|
+
border: '#233A31', accent: '#34D399', accentText: '#6EE7B7', success: '#3FB950', warn: '#D29922', error: '#F85149',
|
|
27
|
+
codeBg: '#07100D', codeText: '#E6F2EC',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
anthropic: {
|
|
31
|
+
light: {
|
|
32
|
+
surface: '#FAF9F5', surface2: '#F5F1E8', text: '#141413', muted: '#6B6359',
|
|
33
|
+
border: '#E5E0D5', accent: '#CC785C', accentText: '#A14A2E', success: '#266B4D', warn: '#92400E', error: '#8C1D18',
|
|
34
|
+
codeBg: '#F5F1E8', codeText: '#141413',
|
|
35
|
+
},
|
|
36
|
+
dark: {
|
|
37
|
+
surface: '#1F1E1D', surface2: '#2A2825', text: '#F5F1E8', muted: '#A8A29A',
|
|
38
|
+
border: '#3A3733', accent: '#E8A087', accentText: '#E8A087', success: '#6FBF9A', warn: '#E0A458', error: '#F28B82',
|
|
39
|
+
codeBg: '#171614', codeText: '#F5F1E8',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hex = (value) => {
|
|
45
|
+
const raw = value.replace('#', '')
|
|
46
|
+
return [0, 2, 4].map((offset) => parseInt(raw.slice(offset, offset + 2), 16))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const luminance = (rgb) => {
|
|
50
|
+
const [r, g, b] = rgb.map((channel) => {
|
|
51
|
+
const c = channel / 255
|
|
52
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
53
|
+
})
|
|
54
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function contrastRatio(foreground, background) {
|
|
58
|
+
const a = luminance(hex(foreground))
|
|
59
|
+
const b = luminance(hex(background))
|
|
60
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function blend(foreground, background, alpha) {
|
|
64
|
+
const fg = hex(foreground)
|
|
65
|
+
const bg = hex(background)
|
|
66
|
+
return '#' + fg.map((channel, index) => Math.round(channel * alpha + bg[index] * (1 - alpha)).toString(16).padStart(2, '0')).join('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function checkThemeContrast() {
|
|
70
|
+
const failures = []
|
|
71
|
+
const results = []
|
|
72
|
+
for (const [theme, modes] of Object.entries(THEME_TOKENS)) {
|
|
73
|
+
for (const [mode, token] of Object.entries(modes)) {
|
|
74
|
+
const pairs = [
|
|
75
|
+
['text/surface', token.text, token.surface, 4.5],
|
|
76
|
+
['text/surface2', token.text, token.surface2, 4.5],
|
|
77
|
+
['muted/surface2', token.muted, token.surface2, 4.5],
|
|
78
|
+
['accentText/surface2', token.accentText, token.surface2, 4.5],
|
|
79
|
+
['accentText/card6%', token.accentText, blend(token.accent, token.surface, 0.06), 4.5],
|
|
80
|
+
['success/12%', token.success, blend(token.success, token.surface, 0.12), 4.5],
|
|
81
|
+
['warn/12%', token.warn, blend(token.warn, token.surface, 0.12), 4.5],
|
|
82
|
+
['error/12%', token.error, blend(token.error, token.surface, 0.12), 4.5],
|
|
83
|
+
['codeText/codeBg', token.codeText, token.codeBg, 4.5],
|
|
84
|
+
['accent/surface', token.accent, token.surface, 3],
|
|
85
|
+
]
|
|
86
|
+
for (const [name, fg, bg, minimum] of pairs) {
|
|
87
|
+
const ratio = contrastRatio(fg, bg)
|
|
88
|
+
results.push({ theme, mode, pair: name, ratio, minimum })
|
|
89
|
+
if (ratio < minimum) failures.push({ theme, mode, pair: name, ratio, minimum })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { results, failures }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
97
|
+
const { results, failures } = checkThemeContrast()
|
|
98
|
+
for (const row of results) console.log(`${row.theme}/${row.mode} ${row.pair}: ${row.ratio.toFixed(2)}`)
|
|
99
|
+
if (failures.length > 0) {
|
|
100
|
+
console.error(JSON.stringify(failures, null, 2))
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
console.log(`PASS ${results.length} checks`)
|
|
104
|
+
}
|