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/src/index.js
CHANGED
|
@@ -52,16 +52,20 @@ const k = (x) => (typeof x.volatile === 'function' ? x.volatile() : x)
|
|
|
52
52
|
/** volatile 字段在 host 侧是可读信号;普通字段原样返回。 */
|
|
53
53
|
const readConfig = (x) => (x && typeof x.get === 'function' ? x.get() : x)
|
|
54
54
|
|
|
55
|
+
/** 主题白名单:host 与 client 共用同一组值。 */
|
|
56
|
+
export const THEMES = ['deepseek', 'codex', 'anthropic']
|
|
57
|
+
|
|
55
58
|
/**
|
|
56
59
|
* DSH 插件配置 schema:设置表单据此渲染,并持久化到 active profile 的 Cordis patch。
|
|
57
|
-
*
|
|
60
|
+
* 不把主题/视觉写进 state.json,避免 structured-test 与 desktop 共享同一状态文件。
|
|
58
61
|
*/
|
|
59
62
|
export const Config = Schema.object({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
theme: k(Schema.union(THEMES).default('deepseek').description('主题 / Theme: deepseek | codex | anthropic').loose()),
|
|
64
|
+
visual: k(Schema.boolean().default(true).description('视觉层 / Visual layer: 关闭后不加载回复区样式')),
|
|
65
|
+
defaultEnabled: k(Schema.boolean().default(true).description('新会话默认开启 / Default on for new sessions')),
|
|
66
|
+
tailReminder: k(Schema.boolean().default(true).description('尾部提醒 / Tail reminder: 每轮追加一句结构化提醒')),
|
|
67
|
+
includeSubagents: k(Schema.boolean().default(false).description('子代理注入 / Include subagents: 默认关闭')),
|
|
68
|
+
genui: k(Schema.union(['auto', 'on', 'off']).default('auto').description('GenUI 联动 / GenUI routing: auto | on | off').loose()),
|
|
65
69
|
})
|
|
66
70
|
|
|
67
71
|
/**
|
|
@@ -80,6 +84,7 @@ export const BUILTIN_STYLE_TEXT = [
|
|
|
80
84
|
'4. **清晰优先,不设硬性篇幅上限**:组件是帮读者快速定位结论的工具,不是加信息的借口;短答不套卡,长答按信息量组织;判断标准是读者只看首段标题 + 表格首列/列表首句,就能知道结论和下一步;细节写文件,回复保留结论与路径。',
|
|
81
85
|
'5. **支撑信息带证据**:每条支撑尽量带数字、路径或结果;没有证据的判断明确标成推断。',
|
|
82
86
|
'6. **可复制内容用代码块**:提示词、命令、配置、JSON、SQL 等需要复制的文本,用带语言标签的代码块包裹(如 `text` / `powershell`),不要用引用块或普通段落;代码块右上角有复制按钮。',
|
|
87
|
+
'7. **代码/补丁例外**:当本轮主要是代码、代码 diff、文件修改或补丁时,代码块或 diff 卡可以直接作为第一块,不强制标题卡;不要为了结构化加标题卡、表格或「下一步」卡;仍不写过程叙事。',
|
|
83
88
|
].join('\n')
|
|
84
89
|
|
|
85
90
|
/** GenUI 已安装时才追加的路由规则:交互/图表走 dsh-ui,普通卡片仍走 markdown。 */
|
|
@@ -89,7 +94,7 @@ export const GENUI_ROUTING_TEXT = [
|
|
|
89
94
|
|
|
90
95
|
/** 尾部提醒:一句话重复契约中最容易衰减的四件事,长度控制在几十个 token。 */
|
|
91
96
|
export const TAIL_REMINDER_TEXT = [
|
|
92
|
-
'提醒:本轮最终回复第一段必须是 markdown
|
|
97
|
+
'提醒:本轮最终回复第一段必须是 markdown 无标签标题卡(代码/diff 回复例外:代码块或 diff 卡可直接作首块,不强制标题卡):加粗标题(≤60 字)/ 正文两行(即使后文要用组件也一样);组件只能排在标题卡之后,不得用组件充当首卡;表格前给 `**表格结论:**`;状态用 `✅ 完成` / `⚠️ 待做`;全文最多两个强调块;表格优先 ≤5 行;不设硬性篇幅上限;清晰优先;可复制内容用代码块。',
|
|
93
98
|
].join('\n')
|
|
94
99
|
|
|
95
100
|
/** DSH home:优先 DSH_HOME,缺省 ~/.dsh。 */
|
|
@@ -303,16 +308,18 @@ export function setEnabledFor(sessions, defaultEnabled, sessionId, enabled) {
|
|
|
303
308
|
|
|
304
309
|
/**
|
|
305
310
|
* 生成 /structured 的状态文本(不含副作用)。
|
|
306
|
-
* @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean }} input
|
|
311
|
+
* @param {{ sessionId: string | null, sessions: Record<string, { enabled: boolean, at: number }>, defaultEnabled: boolean, genui?: boolean, visual?: boolean, theme?: string }} input
|
|
307
312
|
* @returns {string}
|
|
308
313
|
*/
|
|
309
314
|
export function statusText(input) {
|
|
310
315
|
const style = activeStyle()
|
|
311
316
|
const enabled = isEnabledFor(input.sessions, input.defaultEnabled, input.sessionId)
|
|
317
|
+
const theme = THEMES.includes(input.theme) ? input.theme : 'deepseek'
|
|
312
318
|
return [
|
|
313
319
|
'结构化提示词:' + (enabled ? '已开启' : '已关闭') + '(本会话)',
|
|
314
320
|
'新会话默认:' + (input.defaultEnabled ? '开启' : '关闭'),
|
|
315
321
|
'规则来源:' + (style.source === 'custom' ? '自定义 ' + styleFile() : '内置(无标签标题卡 + 结构化组件)'),
|
|
322
|
+
'主题:' + theme + '(由插件设置 theme 控制)',
|
|
316
323
|
'视觉层:' + (input.visual === false ? '已关闭' : '已开启') + '(由插件设置 visual 控制;/structured 只控制提示词)',
|
|
317
324
|
'GenUI 联动:' + (input.genui ? '已检测(图表/交互走 dsh-ui,普通卡片走 fallback)' : '未检测(纯 markdown fallback)'),
|
|
318
325
|
'状态文件:' + stateFile(),
|
|
@@ -330,7 +337,7 @@ export function handleCommand(invocation, api) {
|
|
|
330
337
|
const sessionId = sessionIdOf(invocation)
|
|
331
338
|
const arg = String(invocation?.rawInput ?? '').trim().toLowerCase()
|
|
332
339
|
if (arg === '' || arg === 'status') {
|
|
333
|
-
return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual }) }
|
|
340
|
+
return { kind: 'success', text: statusText({ sessionId, sessions: api.sessions, defaultEnabled: api.defaultEnabled, genui: api.genui, visual: api.visual, theme: api.theme }) }
|
|
334
341
|
}
|
|
335
342
|
if (arg !== 'on' && arg !== 'off') {
|
|
336
343
|
return { kind: 'error', text: '未知参数:' + arg + '\n用法:/structured on | off | status' }
|
|
@@ -352,10 +359,14 @@ export function handleCommand(invocation, api) {
|
|
|
352
359
|
/**
|
|
353
360
|
* 挂载结构化输出:提示词 section + 尾部提醒 + 会话开关 + /structured 命令。
|
|
354
361
|
* @param {import('@deepseek-ai/cordis').Context} ctx host 根上下文。
|
|
355
|
-
* @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean }} [config]
|
|
362
|
+
* @param {{ defaultEnabled?: boolean, tailReminder?: boolean, includeSubagents?: boolean, genui?: 'auto' | 'on' | 'off', visual?: boolean, theme?: 'deepseek' | 'codex' | 'anthropic' }} [config]
|
|
356
363
|
*/
|
|
357
364
|
export function apply(ctx, config = {}) {
|
|
358
365
|
const defaultEnabled = readConfig(config?.defaultEnabled) !== false
|
|
366
|
+
const themeNow = () => {
|
|
367
|
+
const theme = readConfig(config?.theme)
|
|
368
|
+
return THEMES.includes(theme) ? theme : 'deepseek'
|
|
369
|
+
}
|
|
359
370
|
const visualNow = () => readConfig(config?.visual) !== false
|
|
360
371
|
const tailReminder = readConfig(config?.tailReminder) !== false
|
|
361
372
|
const includeSubagents = readConfig(config?.includeSubagents) === true
|
|
@@ -482,6 +493,7 @@ export function apply(ctx, config = {}) {
|
|
|
482
493
|
defaultEnabled,
|
|
483
494
|
genui: genuiNow(),
|
|
484
495
|
visual: visualNow(),
|
|
496
|
+
theme: themeNow(),
|
|
485
497
|
onChange: (sessionId, enabled, result) => {
|
|
486
498
|
warn(
|
|
487
499
|
'session ' + sessionId + ' structured ' + (enabled ? 'on' : 'off') + (result.ok ? '' : '(持久化失败)'),
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
|
|
6
|
+
// 插件板块详情页配置区:宿主不会从 Config schema 自动生成表单,
|
|
7
|
+
// 配置页必须由插件 client 注册进 plugins.bundle.config(键 = 包名)。
|
|
8
|
+
// 本文件验证:注册条件、slot 键、注入面、卡片渲染与字段控件。
|
|
9
|
+
|
|
10
|
+
function makeDom() {
|
|
11
|
+
const element = () => ({
|
|
12
|
+
children: [],
|
|
13
|
+
appendChild() {},
|
|
14
|
+
remove() {},
|
|
15
|
+
setAttribute() {},
|
|
16
|
+
removeAttribute() {},
|
|
17
|
+
querySelectorAll() {
|
|
18
|
+
return []
|
|
19
|
+
},
|
|
20
|
+
closest() {
|
|
21
|
+
return null
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
return {
|
|
25
|
+
body: element(),
|
|
26
|
+
documentElement: { setAttribute() {}, removeAttribute() {} },
|
|
27
|
+
head: { children: [], appendChild() {} },
|
|
28
|
+
createElement: () => element(),
|
|
29
|
+
getElementById: () => null,
|
|
30
|
+
querySelectorAll: () => [],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeRuntime() {
|
|
35
|
+
const node = (type, props) => ({ type, props })
|
|
36
|
+
return {
|
|
37
|
+
require(spec) {
|
|
38
|
+
if (spec === 'react/jsx-runtime') {
|
|
39
|
+
return { jsx: (type, props) => node(type, props), jsxs: (type, props) => node(type, props) }
|
|
40
|
+
}
|
|
41
|
+
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
|
|
42
|
+
return {
|
|
43
|
+
Switch: function Switch(props) {
|
|
44
|
+
return { type: Switch, props }
|
|
45
|
+
},
|
|
46
|
+
SegmentedControl: function SegmentedControl(props) {
|
|
47
|
+
return { type: SegmentedControl, props }
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new Error('unexpected require: ' + spec)
|
|
52
|
+
},
|
|
53
|
+
node,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadClient(dom, fakeRequire) {
|
|
58
|
+
const code = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
59
|
+
let module = null
|
|
60
|
+
const window = {
|
|
61
|
+
__ModuleLoader__: {
|
|
62
|
+
load(definition) {
|
|
63
|
+
module = definition.factory(fakeRequire)
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
vm.runInNewContext(code, {
|
|
68
|
+
window,
|
|
69
|
+
document: dom,
|
|
70
|
+
MutationObserver: class {
|
|
71
|
+
observe() {}
|
|
72
|
+
disconnect() {}
|
|
73
|
+
},
|
|
74
|
+
requestAnimationFrame: (fn) => fn(),
|
|
75
|
+
cancelAnimationFrame() {},
|
|
76
|
+
setTimeout: (fn) => fn(),
|
|
77
|
+
clearTimeout() {},
|
|
78
|
+
console: { info() {}, warn() {}, error() {} },
|
|
79
|
+
}, { filename: 'src/client.js' })
|
|
80
|
+
assert.ok(module, 'client module should register')
|
|
81
|
+
return module
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeChild(form, slots) {
|
|
85
|
+
return {
|
|
86
|
+
configForms: {
|
|
87
|
+
get(id) {
|
|
88
|
+
return id === 'dsh-structured' ? form : undefined
|
|
89
|
+
},
|
|
90
|
+
whileServed(namespaces, register) {
|
|
91
|
+
if (served) register()
|
|
92
|
+
return () => {}
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
slots,
|
|
96
|
+
effect(fn) {
|
|
97
|
+
return fn()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let served = true
|
|
103
|
+
|
|
104
|
+
function collectRegistration() {
|
|
105
|
+
let registered = null
|
|
106
|
+
const slots = {
|
|
107
|
+
inject(name, factory) {
|
|
108
|
+
assert.equal(name, 'plugins.bundle.config')
|
|
109
|
+
factory()
|
|
110
|
+
},
|
|
111
|
+
register(options, component) {
|
|
112
|
+
registered = { options, component }
|
|
113
|
+
return () => {}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
return { slots, get: () => registered }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test('config page:宿主服务 describe 命名空间时注册 bundle 配置页', () => {
|
|
120
|
+
served = true
|
|
121
|
+
const { require: fakeRequire } = fakeRuntime()
|
|
122
|
+
const module = loadClient(makeDom(), fakeRequire)
|
|
123
|
+
const snapshot = { status: 'ready', writable: true, value: { theme: 'deepseek' } }
|
|
124
|
+
const form = {
|
|
125
|
+
setCalls: [],
|
|
126
|
+
set(field, value) {
|
|
127
|
+
this.setCalls.push([field, value])
|
|
128
|
+
return Promise.resolve(true)
|
|
129
|
+
},
|
|
130
|
+
getSnapshot() {
|
|
131
|
+
return snapshot
|
|
132
|
+
},
|
|
133
|
+
subscribe() {
|
|
134
|
+
return () => {}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
const { slots, get } = collectRegistration()
|
|
138
|
+
module.__test.setupConfigPage(makeChild(form, slots), form)
|
|
139
|
+
|
|
140
|
+
const registration = get()
|
|
141
|
+
assert.ok(registration, 'should register into plugins.bundle.config')
|
|
142
|
+
assert.equal(registration.options.name, 'plugins.bundle.config')
|
|
143
|
+
assert.equal(registration.options.key, 'dsh-structured')
|
|
144
|
+
|
|
145
|
+
const face = registration.options.inject()
|
|
146
|
+
assert.equal(face.hooks.structuredConfig, form)
|
|
147
|
+
assert.equal(typeof face.setField, 'function')
|
|
148
|
+
face.setField('visual', false)
|
|
149
|
+
assert.deepEqual(form.setCalls[0], ['visual', false])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('config page:describe 未服务命名空间时不注册', () => {
|
|
153
|
+
served = false
|
|
154
|
+
const { require: fakeRequire } = fakeRuntime()
|
|
155
|
+
const module = loadClient(makeDom(), fakeRequire)
|
|
156
|
+
const form = { getSnapshot() { return null }, subscribe() { return () => {} } }
|
|
157
|
+
const { slots, get } = collectRegistration()
|
|
158
|
+
module.__test.setupConfigPage(makeChild(form, slots), form)
|
|
159
|
+
assert.equal(get(), null)
|
|
160
|
+
served = true
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('config page:primitives 或 require 缺失时静默跳过', () => {
|
|
164
|
+
const module = loadClient(makeDom(), undefined)
|
|
165
|
+
const form = { getSnapshot() { return null }, subscribe() { return () => {} } }
|
|
166
|
+
const { slots, get } = collectRegistration()
|
|
167
|
+
module.__test.setupConfigPage(makeChild(form, slots), form)
|
|
168
|
+
assert.equal(get(), null)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('config page:卡片渲染六字段,switch/segmented 值来自快照', () => {
|
|
172
|
+
served = true
|
|
173
|
+
const { require: fakeRequire, node } = fakeRuntime()
|
|
174
|
+
const module = loadClient(makeDom(), fakeRequire)
|
|
175
|
+
const snapshot = {
|
|
176
|
+
status: 'ready',
|
|
177
|
+
writable: true,
|
|
178
|
+
value: {
|
|
179
|
+
theme: 'codex',
|
|
180
|
+
visual: false,
|
|
181
|
+
defaultEnabled: true,
|
|
182
|
+
tailReminder: true,
|
|
183
|
+
includeSubagents: false,
|
|
184
|
+
genui: 'auto',
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
const form = {
|
|
188
|
+
set() {
|
|
189
|
+
return Promise.resolve(true)
|
|
190
|
+
},
|
|
191
|
+
getSnapshot() {
|
|
192
|
+
return snapshot
|
|
193
|
+
},
|
|
194
|
+
subscribe() {
|
|
195
|
+
return () => {}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
const { slots, get } = collectRegistration()
|
|
199
|
+
module.__test.setupConfigPage(makeChild(form, slots), form)
|
|
200
|
+
const { component } = get()
|
|
201
|
+
|
|
202
|
+
const tree = component({
|
|
203
|
+
view: 'page',
|
|
204
|
+
useStructuredConfig: (selector) => selector(snapshot),
|
|
205
|
+
setField: () => {},
|
|
206
|
+
})
|
|
207
|
+
assert.equal(tree.props['data-dsh-structured-config'], '1')
|
|
208
|
+
|
|
209
|
+
const flat = []
|
|
210
|
+
const walk = (value) => {
|
|
211
|
+
if (!value || typeof value !== 'object') return
|
|
212
|
+
flat.push(value)
|
|
213
|
+
if (Array.isArray(value.props?.children)) value.props.children.forEach((child) => walk(child))
|
|
214
|
+
else walk(value.props?.children)
|
|
215
|
+
}
|
|
216
|
+
walk(tree)
|
|
217
|
+
|
|
218
|
+
const switches = flat.filter((element) => element.type?.name === 'Switch')
|
|
219
|
+
const segmented = flat.filter((element) => element.type?.name === 'SegmentedControl')
|
|
220
|
+
assert.equal(switches.length, 4)
|
|
221
|
+
assert.equal(segmented.length, 2)
|
|
222
|
+
|
|
223
|
+
const visual = switches.find((element) => element.props.label.includes('视觉层'))
|
|
224
|
+
assert.equal(visual.props.checked, false)
|
|
225
|
+
const includeSubagents = switches.find((element) => element.props.label.includes('子代理'))
|
|
226
|
+
assert.equal(includeSubagents.props.checked, false)
|
|
227
|
+
const theme = segmented.find((element) => element.props.id === 'dsh-structured-theme')
|
|
228
|
+
assert.equal(theme.props.value, 'codex')
|
|
229
|
+
// 卡片在 vm 沙箱里构建:数组跨 realm,序列化后比较。
|
|
230
|
+
assert.equal(JSON.stringify(theme.props.options.map((option) => option.value)), JSON.stringify(['deepseek', 'codex', 'anthropic']))
|
|
231
|
+
const genui = segmented.find((element) => element.props.id === 'dsh-structured-genui')
|
|
232
|
+
assert.equal(genui.props.value, 'auto')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('config page:插件未就绪与只读态的降级渲染', () => {
|
|
236
|
+
served = true
|
|
237
|
+
const { require: fakeRequire } = fakeRuntime()
|
|
238
|
+
const module = loadClient(makeDom(), fakeRequire)
|
|
239
|
+
const form = {
|
|
240
|
+
set() {
|
|
241
|
+
return Promise.resolve(true)
|
|
242
|
+
},
|
|
243
|
+
getSnapshot() {
|
|
244
|
+
return null
|
|
245
|
+
},
|
|
246
|
+
subscribe() {
|
|
247
|
+
return () => {}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
const { slots, get } = collectRegistration()
|
|
251
|
+
module.__test.setupConfigPage(makeChild(form, slots), form)
|
|
252
|
+
const { component } = get()
|
|
253
|
+
|
|
254
|
+
const unavailable = component({ view: 'page', useStructuredConfig: (selector) => selector({ status: 'unavailable' }) })
|
|
255
|
+
assert.match(String(unavailable.props.children), /暂不可用/)
|
|
256
|
+
|
|
257
|
+
const readonly = component({
|
|
258
|
+
view: 'page',
|
|
259
|
+
useStructuredConfig: (selector) => selector({ status: 'ready', writable: false, value: { theme: 'deepseek' } }),
|
|
260
|
+
setField: () => {},
|
|
261
|
+
})
|
|
262
|
+
const flat = []
|
|
263
|
+
const walk = (value) => {
|
|
264
|
+
if (!value || typeof value !== 'object') return
|
|
265
|
+
flat.push(value)
|
|
266
|
+
if (Array.isArray(value.props?.children)) value.props.children.forEach((child) => walk(child))
|
|
267
|
+
else walk(value.props?.children)
|
|
268
|
+
}
|
|
269
|
+
walk(readonly)
|
|
270
|
+
const switches = flat.filter((element) => element.type?.name === 'Switch')
|
|
271
|
+
assert.equal(switches.length, 4)
|
|
272
|
+
for (const element of switches) assert.equal(element.props.disabled, true)
|
|
273
|
+
})
|
|
@@ -116,7 +116,7 @@ function makeContext(form) {
|
|
|
116
116
|
test('client:visual=false 停用视觉层,visual=true 恢复', () => {
|
|
117
117
|
const dom = makeDom()
|
|
118
118
|
const form = {
|
|
119
|
-
value: { visual: false },
|
|
119
|
+
value: { visual: false, theme: 'codex' },
|
|
120
120
|
listeners: new Set(),
|
|
121
121
|
getSnapshot() {
|
|
122
122
|
return { value: this.value }
|
|
@@ -131,16 +131,27 @@ test('client:visual=false 停用视觉层,visual=true 恢复', () => {
|
|
|
131
131
|
|
|
132
132
|
assert.equal(dom.styles.has('dsh-structured-skin-style'), false)
|
|
133
133
|
assert.equal(dom.attrs.has('data-dsh-structured'), false)
|
|
134
|
+
assert.equal(dom.document.body['data-dsh-structured-theme'], undefined)
|
|
134
135
|
|
|
135
|
-
form.value = { visual: true }
|
|
136
|
+
form.value = { visual: true, theme: 'codex' }
|
|
136
137
|
for (const listener of form.listeners) listener()
|
|
137
138
|
assert.equal(dom.styles.has('dsh-structured-skin-style'), true)
|
|
138
139
|
assert.equal(dom.attrs.get('data-dsh-structured'), 'active')
|
|
140
|
+
assert.equal(dom.document.body['data-dsh-structured-theme'], 'codex')
|
|
139
141
|
|
|
140
|
-
form.value = { visual:
|
|
142
|
+
form.value = { visual: true, theme: 'anthropic' }
|
|
143
|
+
for (const listener of form.listeners) listener()
|
|
144
|
+
assert.equal(dom.document.body['data-dsh-structured-theme'], 'anthropic')
|
|
145
|
+
|
|
146
|
+
form.value = { visual: true, theme: 'bogus' }
|
|
147
|
+
for (const listener of form.listeners) listener()
|
|
148
|
+
assert.equal(dom.document.body['data-dsh-structured-theme'], 'deepseek')
|
|
149
|
+
|
|
150
|
+
form.value = { visual: false, theme: 'anthropic' }
|
|
141
151
|
for (const listener of form.listeners) listener()
|
|
142
152
|
assert.equal(dom.styles.has('dsh-structured-skin-style'), false)
|
|
143
153
|
assert.equal(dom.attrs.has('data-dsh-structured'), false)
|
|
154
|
+
assert.equal(dom.document.body['data-dsh-structured-theme'], undefined)
|
|
144
155
|
|
|
145
156
|
dispose()
|
|
146
157
|
})
|
|
@@ -161,3 +172,119 @@ test('client:configForms 不可用时视觉层保持默认开启', () => {
|
|
|
161
172
|
assert.equal(dom.attrs.get('data-dsh-structured'), 'active')
|
|
162
173
|
dispose()
|
|
163
174
|
})
|
|
175
|
+
|
|
176
|
+
function attrsElement(extra = {}) {
|
|
177
|
+
const attrs = new Map()
|
|
178
|
+
return {
|
|
179
|
+
attrs,
|
|
180
|
+
setAttribute(name, value) {
|
|
181
|
+
attrs.set(name, value)
|
|
182
|
+
},
|
|
183
|
+
removeAttribute(name) {
|
|
184
|
+
attrs.delete(name)
|
|
185
|
+
},
|
|
186
|
+
getAttribute(name) {
|
|
187
|
+
return attrs.get(name)
|
|
188
|
+
},
|
|
189
|
+
...extra,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
test('client:diff 代码块不套插件 chrome,普通代码块才套', () => {
|
|
194
|
+
const dom = makeDom()
|
|
195
|
+
const module = loadClient(dom)
|
|
196
|
+
const diffCode = attrsElement({
|
|
197
|
+
querySelector: () => ({ textContent: 'diff' }),
|
|
198
|
+
closest: () => null,
|
|
199
|
+
})
|
|
200
|
+
const jsCode = attrsElement({
|
|
201
|
+
querySelector: () => ({ textContent: 'js' }),
|
|
202
|
+
closest: () => null,
|
|
203
|
+
})
|
|
204
|
+
const diffCopyCode = attrsElement({
|
|
205
|
+
querySelector: () => ({ textContent: 'diff复制' }),
|
|
206
|
+
closest: () => null,
|
|
207
|
+
})
|
|
208
|
+
assert.equal(module.__test.isDiffCode(diffCopyCode), true)
|
|
209
|
+
const root = {
|
|
210
|
+
querySelectorAll(selector) {
|
|
211
|
+
if (selector.includes('.md-code-block')) return [diffCode, jsCode, diffCopyCode]
|
|
212
|
+
return []
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
module.__test.decorate(root)
|
|
216
|
+
assert.equal(diffCode.attrs.get('data-dsh-structured-skip'), 'code')
|
|
217
|
+
assert.equal(diffCode.attrs.get('data-dsh-structured-code'), undefined)
|
|
218
|
+
assert.equal(diffCode.attrs.get('data-dsh-structured-lang'), undefined)
|
|
219
|
+
assert.equal(diffCopyCode.attrs.get('data-dsh-structured-skip'), 'code')
|
|
220
|
+
assert.equal(jsCode.attrs.get('data-dsh-structured-code'), '1')
|
|
221
|
+
assert.equal(jsCode.attrs.get('data-dsh-structured-lang'), 'js')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('client:普通状态表仍套插件表格样式并标状态行', () => {
|
|
225
|
+
const dom = makeDom()
|
|
226
|
+
const module = loadClient(dom)
|
|
227
|
+
const row = attrsElement({
|
|
228
|
+
textContent: '✅ 完成',
|
|
229
|
+
cells: [{ textContent: '✅ 完成' }],
|
|
230
|
+
closest: () => null,
|
|
231
|
+
})
|
|
232
|
+
const table = attrsElement({
|
|
233
|
+
closest: () => null,
|
|
234
|
+
querySelectorAll: (selector) => (selector === 'tbody tr' ? [row] : []),
|
|
235
|
+
})
|
|
236
|
+
const root = {
|
|
237
|
+
querySelectorAll(selector) {
|
|
238
|
+
if (selector === '[class*="_markdown_"] table') return [table]
|
|
239
|
+
if (selector === '[class*="_markdown_"] tbody tr') return [row]
|
|
240
|
+
if (selector === '[class*="_markdown_"] table tr') return [row]
|
|
241
|
+
return []
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
module.__test.decorate(root)
|
|
245
|
+
assert.equal(table.attrs.get('data-dsh-structured-status-table'), '1')
|
|
246
|
+
assert.equal(table.attrs.get('data-dsh-structured-skip'), undefined)
|
|
247
|
+
assert.equal(row.attrs.get('data-dsh-structured-row'), 'success')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('client:GenUI 区域不写入 skip,保持旧行为', () => {
|
|
251
|
+
const dom = makeDom()
|
|
252
|
+
const module = loadClient(dom)
|
|
253
|
+
const code = attrsElement({
|
|
254
|
+
querySelector: () => ({ textContent: 'js' }),
|
|
255
|
+
closest: (selector) => (selector.includes('genui') ? {} : null),
|
|
256
|
+
})
|
|
257
|
+
const table = attrsElement({
|
|
258
|
+
closest: (selector) => (selector.includes('genui') ? {} : null),
|
|
259
|
+
querySelectorAll: () => [],
|
|
260
|
+
})
|
|
261
|
+
const root = {
|
|
262
|
+
querySelectorAll(selector) {
|
|
263
|
+
if (selector.includes('.md-code-block')) return [code]
|
|
264
|
+
if (selector === '[class*="_markdown_"] table') return [table]
|
|
265
|
+
return []
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
module.__test.decorate(root)
|
|
269
|
+
assert.equal(code.attrs.get('data-dsh-structured-skip'), undefined)
|
|
270
|
+
assert.equal(code.attrs.get('data-dsh-structured-code'), undefined)
|
|
271
|
+
assert.equal(table.attrs.get('data-dsh-structured-skip'), undefined)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('client:diff 表格不套插件表格样式', () => {
|
|
275
|
+
const dom = makeDom()
|
|
276
|
+
const module = loadClient(dom)
|
|
277
|
+
const table = attrsElement({
|
|
278
|
+
closest: (selector) => (selector.includes('[data-diff]') ? {} : null),
|
|
279
|
+
querySelectorAll: () => [],
|
|
280
|
+
})
|
|
281
|
+
const root = {
|
|
282
|
+
querySelectorAll(selector) {
|
|
283
|
+
if (selector === '[class*="_markdown_"] table') return [table]
|
|
284
|
+
return []
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
module.__test.decorate(root)
|
|
288
|
+
assert.equal(table.attrs.get('data-dsh-structured-skip'), 'table')
|
|
289
|
+
assert.equal(table.attrs.get('data-dsh-structured-status-table'), undefined)
|
|
290
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { firstScreenStructured } from '../scripts/measure-prose.mjs'
|
|
4
|
+
|
|
5
|
+
test('代码/diff 优先回复:首屏 fenced code 计为结构化', () => {
|
|
6
|
+
assert.equal(firstScreenStructured('```js\nconst answer = 42\n```\n\n说明。'), true)
|
|
7
|
+
assert.equal(firstScreenStructured('```diff\n- old\n+ new\n```\n\n说明。'), true)
|
|
8
|
+
assert.equal(firstScreenStructured('第一段是普通散文。\n\n第二段也是普通散文。'), false)
|
|
9
|
+
})
|
package/test/structured.test.mjs
CHANGED
|
@@ -87,6 +87,9 @@ test('内置规则覆盖首句结论、组件、清晰度三件事', () => {
|
|
|
87
87
|
assert.match(BUILTIN_STYLE_TEXT, /过程叙事/)
|
|
88
88
|
assert.match(BUILTIN_STYLE_TEXT, /推断/)
|
|
89
89
|
assert.match(BUILTIN_STYLE_TEXT, /短答不套卡/)
|
|
90
|
+
assert.match(BUILTIN_STYLE_TEXT, /代码\/补丁例外/)
|
|
91
|
+
assert.match(BUILTIN_STYLE_TEXT, /不强制标题卡/)
|
|
92
|
+
assert.match(TAIL_REMINDER_TEXT, /代码\/diff 回复例外/)
|
|
90
93
|
assert.doesNotMatch(BUILTIN_STYLE_TEXT, /500\s*字|≤\s*500|字数上限/)
|
|
91
94
|
assert.match(BUILTIN_STYLE_TEXT, /可复制内容用代码块/)
|
|
92
95
|
assert.match(TAIL_REMINDER_TEXT, /加粗标题/)
|
|
@@ -532,7 +535,7 @@ test('client:视觉层可由 configForms 的 visual 设置关闭', () => {
|
|
|
532
535
|
const client = readFileSync('src/client.js', 'utf8')
|
|
533
536
|
assert.match(client, /configForms/)
|
|
534
537
|
assert.match(client, /getSnapshot/)
|
|
535
|
-
assert.match(client, /visual !== false/)
|
|
538
|
+
assert.match(client, /readValue\(value\.visual\) !== false/)
|
|
536
539
|
assert.match(client, /applyVisual/)
|
|
537
540
|
assert.doesNotMatch(client, /state\.json/)
|
|
538
541
|
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { Config, statusText, THEMES } from '../src/index.js'
|
|
5
|
+
import { checkThemeContrast, THEME_TOKENS } from '../scripts/theme-contrast.mjs'
|
|
6
|
+
|
|
7
|
+
const value = (field) => (field && typeof field.get === 'function' ? field.get() : field)
|
|
8
|
+
|
|
9
|
+
test('0.9.0 Config:theme 默认 deepseek,三值可选', () => {
|
|
10
|
+
assert.deepEqual(THEMES, ['deepseek', 'codex', 'anthropic'])
|
|
11
|
+
assert.equal(value(Config({}).theme), 'deepseek')
|
|
12
|
+
assert.equal(value(Config({ theme: 'codex' }).theme), 'codex')
|
|
13
|
+
assert.equal(value(Config({ theme: 'anthropic' }).theme), 'anthropic')
|
|
14
|
+
assert.equal(value(Config({ defaultEnabled: false }).defaultEnabled), false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('0.9.1 Config:六个字段的默认值与显式值', () => {
|
|
18
|
+
const defaults = Config({})
|
|
19
|
+
assert.equal(value(defaults.theme), 'deepseek')
|
|
20
|
+
assert.equal(value(defaults.visual), true)
|
|
21
|
+
assert.equal(value(defaults.defaultEnabled), true)
|
|
22
|
+
assert.equal(value(defaults.tailReminder), true)
|
|
23
|
+
assert.equal(value(defaults.includeSubagents), false)
|
|
24
|
+
assert.equal(value(defaults.genui), 'auto')
|
|
25
|
+
const custom = Config({
|
|
26
|
+
theme: 'codex',
|
|
27
|
+
visual: false,
|
|
28
|
+
defaultEnabled: false,
|
|
29
|
+
tailReminder: false,
|
|
30
|
+
includeSubagents: true,
|
|
31
|
+
genui: 'on',
|
|
32
|
+
})
|
|
33
|
+
assert.equal(value(custom.theme), 'codex')
|
|
34
|
+
assert.equal(value(custom.visual), false)
|
|
35
|
+
assert.equal(value(custom.defaultEnabled), false)
|
|
36
|
+
assert.equal(value(custom.tailReminder), false)
|
|
37
|
+
assert.equal(value(custom.includeSubagents), true)
|
|
38
|
+
assert.equal(value(custom.genui), 'on')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('0.9.1 Config:六个字段全部 volatile,且带中英 description', () => {
|
|
42
|
+
for (const field of ['theme', 'visual', 'defaultEnabled', 'tailReminder', 'includeSubagents', 'genui']) {
|
|
43
|
+
const meta = Config.dict[field].meta
|
|
44
|
+
assert.ok(meta, `${field} meta`)
|
|
45
|
+
assert.equal(meta.volatile, true, `${field} volatile=true`)
|
|
46
|
+
assert.ok(String(meta.description ?? '').length > 8, `${field} description`)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('0.9.1 client:token 语义色/代码块实际使用', () => {
|
|
51
|
+
const client = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
52
|
+
assert.match(client, /THEMES\.includes\(theme\)/)
|
|
53
|
+
assert.match(client, /body\[data-dsh-structured-theme="codex"\]/)
|
|
54
|
+
assert.match(client, /body\[data-ds-dark-theme\]\[data-dsh-structured-theme="anthropic"\]/)
|
|
55
|
+
assert.match(client, /\.md-code-block\[\$\{CODE_ATTR\}="1"\]\{[^}]*background:var\(--ds-code-bg\)/s)
|
|
56
|
+
assert.match(client, /\.md-code-block\[\$\{CODE_ATTR\}="1"\] pre\{[^}]*color:var\(--ds-code-text\)/s)
|
|
57
|
+
assert.match(client, /var\(--ds-success\)/)
|
|
58
|
+
assert.match(client, /var\(--ds-warn\)/)
|
|
59
|
+
assert.match(client, /var\(--ds-error\)/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('0.9.0 statusText:显示 theme 与 visual', () => {
|
|
63
|
+
const text = statusText({
|
|
64
|
+
sessionId: 'session-a',
|
|
65
|
+
sessions: {},
|
|
66
|
+
defaultEnabled: true,
|
|
67
|
+
theme: 'anthropic',
|
|
68
|
+
visual: false,
|
|
69
|
+
})
|
|
70
|
+
assert.match(text, /主题:anthropic/)
|
|
71
|
+
assert.match(text, /视觉层:已关闭/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('0.9.0 client:主题 token/暗色 selector/code token 存在,旧 selector 已删', () => {
|
|
75
|
+
const client = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
76
|
+
assert.match(client, /body\[data-ds-dark-theme\]/)
|
|
77
|
+
assert.match(client, /data-dsh-structured-theme/)
|
|
78
|
+
assert.match(client, /--ds-code-bg/)
|
|
79
|
+
assert.match(client, /--ds-code-text/)
|
|
80
|
+
assert.match(client, /--ds-accent-text/)
|
|
81
|
+
assert.doesNotMatch(client, /\[data-theme="dark"\]/)
|
|
82
|
+
assert.doesNotMatch(client, /--dsw-alias-state-/)
|
|
83
|
+
assert.match(client, /--ds-accent:#2563EB/)
|
|
84
|
+
for (const theme of ['codex', 'anthropic']) assert.match(client, new RegExp(`data-dsh-structured-theme="${theme}"`))
|
|
85
|
+
assert.match(client, /a\{color:var\(--ds-accent-text\)/)
|
|
86
|
+
assert.match(client, /li::marker\{color:var\(--ds-accent-text\)/)
|
|
87
|
+
assert.match(client, /p\[\$\{LEAD_ATTR\}="table"\] strong\{color:var\(--ds-accent-text\)/)
|
|
88
|
+
assert.match(client, /color:var\(--ds-accent-text\);overflow-wrap:anywhere/)
|
|
89
|
+
assert.match(client, /blockquote\[\$\{TONE_ATTR\}\]>p:first-child>strong:first-child\{color:var\(--ds-accent-text\)/)
|
|
90
|
+
const narrow = client.slice(client.indexOf('@media (max-width:720px)'), client.indexOf('@media (prefers-reduced-motion'))
|
|
91
|
+
assert.match(narrow, /table:not\(\[\$\{SKIP_ATTR\}\]\)/)
|
|
92
|
+
assert.doesNotMatch(narrow, /\[class\*="_markdown_"\] table\{/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('0.9.1 主题 v2:三套 token 精确值与 accentText', () => {
|
|
96
|
+
assert.equal(THEME_TOKENS.deepseek.light.accent, '#2563EB')
|
|
97
|
+
assert.equal(THEME_TOKENS.deepseek.light.accentText, '#2563EB')
|
|
98
|
+
assert.equal(THEME_TOKENS.deepseek.dark.accentText, '#60A5FA')
|
|
99
|
+
assert.equal(THEME_TOKENS.codex.light.accent, '#10A37F')
|
|
100
|
+
assert.equal(THEME_TOKENS.codex.light.accentText, '#0B6B4F')
|
|
101
|
+
assert.equal(THEME_TOKENS.codex.light.surface2, '#F0F7F4')
|
|
102
|
+
assert.equal(THEME_TOKENS.codex.dark.accent, '#34D399')
|
|
103
|
+
assert.equal(THEME_TOKENS.codex.dark.accentText, '#6EE7B7')
|
|
104
|
+
assert.equal(THEME_TOKENS.codex.dark.surface, '#0B1512')
|
|
105
|
+
assert.equal(THEME_TOKENS.anthropic.light.accent, '#CC785C')
|
|
106
|
+
assert.equal(THEME_TOKENS.anthropic.light.accentText, '#A14A2E')
|
|
107
|
+
assert.equal(THEME_TOKENS.anthropic.light.surface, '#FAF9F5')
|
|
108
|
+
assert.equal(THEME_TOKENS.anthropic.light.text, '#141413')
|
|
109
|
+
assert.equal(THEME_TOKENS.anthropic.dark.accent, '#E8A087')
|
|
110
|
+
const client = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
111
|
+
assert.match(client, /--ds-accent:#10A37F;--ds-accent-text:#0B6B4F/)
|
|
112
|
+
assert.match(client, /--ds-accent:#CC785C;--ds-accent-text:#A14A2E/)
|
|
113
|
+
assert.match(client, /blockquote\[\$\{TONE_ATTR\}="warn"\]\{--ds-accent:var\(--ds-warn\);--ds-accent-text:var\(--ds-warn\)\}/)
|
|
114
|
+
assert.match(client, /blockquote\[\$\{TONE_ATTR\}="success"\]\{--ds-accent:var\(--ds-success\);--ds-accent-text:var\(--ds-success\)\}/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('0.9.0 对比度:3×2 全组 60 项通过', () => {
|
|
118
|
+
const { results, failures } = checkThemeContrast()
|
|
119
|
+
assert.equal(failures.length, 0, JSON.stringify(failures, null, 2))
|
|
120
|
+
assert.equal(results.length, 60)
|
|
121
|
+
})
|