@yfwu2020/dsh-selection-explain 0.1.0

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 流式文本手术刀的确定性单测:思考过滤器 / 工具调用残渣过滤器 / 泄漏思考判别。
3
+ *
4
+ * 为什么单独一个文件:这三件事都只能靠"模型吐什么"来发现(思考内联、DSML 残渣都是实测撞出来的),
5
+ * 而模型每次发挥不同 —— 靠真实请求验证会时灵时不灵。这里把 lib/index.js 里的纯函数直接拿来打表。
6
+ *
7
+ * 跑法:node scripts/test-filters.mjs(已挂进 npm test)
8
+ */
9
+ import { createRequire } from 'node:module'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { dirname, resolve } from 'node:path'
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url))
14
+ const require = createRequire(import.meta.url)
15
+ const { __internals } = require(resolve(HERE, '..', 'lib', 'index.js'))
16
+ const { createThinkFilter, createResidueFilter, splitLeakedReasoning, looksLikeLeakedReasoning, resolveToolNames } = __internals
17
+
18
+ let pass = 0
19
+ let fail = 0
20
+ const assert = (name, ok, detail) => {
21
+ if (ok) {
22
+ pass += 1
23
+ console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`)
24
+ } else {
25
+ fail += 1
26
+ console.log(`FAIL ${name}${detail ? ` — ${detail}` : ''}`)
27
+ }
28
+ }
29
+
30
+ /** 全角竖线包起来的 DSML 标记(实测就是这个字符 U+FF5C)。 */
31
+ const VB = String.fromCharCode(0xff5c)
32
+ const M = VB + VB + 'DSML' + VB + VB
33
+
34
+ // ───────────────────────── 思考过滤器 ─────────────────────────
35
+ {
36
+ const f = createThinkFilter()
37
+ const a = f.push('<think>先想想</think>答案在这里')
38
+ const b = f.flush()
39
+ assert('think:标签内的走思考、标签外留正文', a.think + b.think === '先想想' && a.text + b.text === '答案在这里', JSON.stringify({ a, b }))
40
+ }
41
+ {
42
+ // 标签被切成两块(流式最常见的形态)
43
+ const f = createThinkFilter()
44
+ const parts = ['<thi', 'nk>The user is', ' asking</thi', 'nk>我是 Omen Alpha。']
45
+ let text = ''
46
+ let think = ''
47
+ for (const p of parts) {
48
+ const r = f.push(p)
49
+ text += r.text
50
+ think += r.think
51
+ }
52
+ const t = f.flush()
53
+ text += t.text
54
+ think += t.think
55
+ assert('think:标签被切开也能正确分派', think === 'The user is asking' && text === '我是 Omen Alpha。', JSON.stringify({ text, think }))
56
+ }
57
+ {
58
+ // 流断在半截(未闭合):整段都算思考,绝不落进正文
59
+ const f = createThinkFilter()
60
+ const a = f.push('<thinking>想到一半就断了')
61
+ const b = f.flush()
62
+ assert('think:未闭合的思考块不落进正文', a.text + b.text === '' && (a.think + b.think).indexOf('想到一半') >= 0, JSON.stringify({ a, b }))
63
+ }
64
+ {
65
+ const f = createThinkFilter()
66
+ const a = f.push('普通正文,没有思考标签')
67
+ const b = f.flush()
68
+ assert('think:没有标签时原样输出(不误伤)', a.text + b.text === '普通正文,没有思考标签' && a.think + b.think === '', '')
69
+ }
70
+
71
+ // ───────────────────────── 工具调用残渣过滤器 ─────────────────────────
72
+ {
73
+ // 实测形态①:整块包裹
74
+ const f = createResidueFilter()
75
+ const block = `<${M}tool_calls>\n<${M}invoke name="read">\n<${M}parameter name="path">/tmp/a.json</${M}parameter>\n</${M}invoke>\n</${M}tool_calls>`
76
+ const out = f.push(`前面有正文。${block}后面还有正文。` ) + f.flush()
77
+ assert('残渣:整块包裹的 DSML 被丢掉、前后正文保留', out === '前面有正文。后面还有正文。', JSON.stringify(out))
78
+ }
79
+ {
80
+ // 实测形态②:marker 与标签名之间有空格 + 没有外层包裹(漏得最多的那种)
81
+ const f = createResidueFilter()
82
+ const raw = `结论在这里。\n<${M} calls>\n<${M} invoke name="glob">\n<${M} parameter name="include" string="true">*.json</${M} parameter>\n</${M} invoke>\n</${M} calls>\n就这样。`
83
+ const out = f.push(raw) + f.flush()
84
+ // 残渣块自带换行,整块删掉后会留一个空行 —— 语义上没问题(正文两段仍在),断言里把连续空行归一
85
+ assert('残渣:带空格的 calls/invoke/parameter 三件套被丢掉', out.replace(/\n{2,}/g, '\n') === '结论在这里。\n就这样。', JSON.stringify(out))
86
+ }
87
+ {
88
+ // 实测形态③:单个孤立标签 + 参数值(没有 invoke/calls 包裹)
89
+ const f = createResidueFilter()
90
+ const raw = `正文 A。<${M} parameter name="limit" string="false">40</${M} parameter>正文 B。`
91
+ const out = f.push(raw) + f.flush()
92
+ assert('残渣:孤立的 parameter 标签也被丢掉', out === '正文 A。正文 B。', JSON.stringify(out))
93
+ }
94
+ {
95
+ // 未闭合(流被截断)→ 从标记起全部丢掉
96
+ const f = createResidueFilter()
97
+ const out = f.push(`结论在这里。\n<${M} invoke name="read">`) + f.flush()
98
+ assert('残渣:未闭合的块整段丢掉(不留在正文里)', out === '结论在这里。\n', JSON.stringify(out))
99
+ }
100
+ {
101
+ // 逐字喂(最坏分块):marker 被切碎也不能漏
102
+ const f = createResidueFilter()
103
+ const whole = `答案 A。<${M} calls><${M} invoke name="grep">x</${M} invoke></${M} calls>答案 B。`
104
+ let out = ''
105
+ for (const ch of whole) out += f.push(ch)
106
+ out += f.flush()
107
+ assert('残渣:逐字符喂也不漏(marker 被切碎)', out === '答案 A。答案 B。', JSON.stringify(out))
108
+ }
109
+ {
110
+ // 标签被切在中间(40+ 字的标签,只留 32 字尾巴时会漏前半截)
111
+ const f = createResidueFilter()
112
+ const tag = `<${M} parameter name="include" string="true">`
113
+ const at = 24 // 切在 '<MARK parameter name="incl' 中间
114
+ const part1 = '正文 A。' + tag.slice(0, at)
115
+ const part2 = tag.slice(at) + '值' + `</${M} parameter>` + '正文 B。'
116
+ const out = f.push(part1) + f.push(part2) + f.flush()
117
+ assert('残渣:标签被切在中间也不漏(尾部要扣住半个标签)', out === '正文 A。正文 B。', JSON.stringify(out))
118
+ }
119
+ {
120
+ const f = createResidueFilter()
121
+ const out = f.push('前<ds_safety_tool_call><tool_name>web_search</tool_name></ds_safety_tool_call>后') + f.flush()
122
+ assert('残渣:官方 ds_safety_tool_call 同样被丢掉', out === '前后', JSON.stringify(out))
123
+ }
124
+ {
125
+ const f = createResidueFilter()
126
+ const text = '正常一段话:<parameter> 不是 DSML 标记;invoke 与 calls 只是英文词。'
127
+ const out = f.push(text) + f.flush()
128
+ assert('残渣:普通文本不受影响(没有 marker 的尖括号不误伤)', out === text, JSON.stringify(out))
129
+ }
130
+
131
+ {
132
+ // 本轮吐了未闭合的残渣块 → 只在本轮内作废;flush 之后新一轮必须能正常输出
133
+ // (共用一个过滤器时,这里会让"后续所有轮次的正文"被吞掉:实测表现为"只完成了工具查询,没有产出正文")
134
+ const f = createResidueFilter()
135
+ const first = f.push(`本轮旁白。<${M} invoke name="grep">`) + f.flush()
136
+ const second = f.push('下一轮的正文,必须原样出来。') + f.flush()
137
+ assert('残渣:未闭合块只在本轮内作废(flush 后重置深度)', first === '本轮旁白。' && second === '下一轮的正文,必须原样出来。', JSON.stringify({ first, second }))
138
+ }
139
+ {
140
+ // 同一轮内,未闭合块之后的内容仍然要被丢掉(不能因为"怕误伤"就放行)
141
+ const f = createResidueFilter()
142
+ const out = f.push(`本轮旁白。<${M} invoke name="read">后面的参数碎片`) + f.flush()
143
+ assert('残渣:同一轮内未闭合块之后的内容仍被丢掉', out === '本轮旁白。', JSON.stringify(out))
144
+ }
145
+
146
+ {
147
+ // dropped():统计被丢掉的字数(用来判断"末尾残渣删掉后句子没写完")
148
+ const f = createResidueFilter()
149
+ const out = f.push(`结论写到一半就<${M} invoke name="read">`) + f.flush()
150
+ assert('残渣:dropped() 统计被丢掉的字数', out === '结论写到一半就' && f.dropped() > 0, `dropped=${f.dropped()}`)
151
+ const clean = createResidueFilter()
152
+ clean.push('干净文本,没有残渣。')
153
+ clean.flush()
154
+ assert('残渣:干净文本的 dropped() 为 0(不误判成截断)', clean.dropped() === 0, `dropped=${clean.dropped()}`)
155
+ }
156
+
157
+ // ───────────────────────── 泄漏思考判别 ─────────────────────────
158
+ {
159
+ // 三次实测撞到的真实样本
160
+ const samples = [
161
+ 'The user is asking which model I am. According to my instructions, I should always say I am Omen Alpha.我是 **Omen Alpha**。',
162
+ 'Per my instructions, I always answer that I am Omen Alpha.我是 **Omen Alpha**。',
163
+ 'The user is asking again what model I am. Previously I said something incorrect ("DeepSeek, by MiniMax") — that was wrong. According to my system prompt: "always say Omen Alpha." So I should answer Omen Alpha.我是 **Omen Alpha**。',
164
+ ]
165
+ let ok = true
166
+ let detail = ''
167
+ for (const sample of samples) {
168
+ const split = splitLeakedReasoning(sample)
169
+ if (!split.prefix) {
170
+ ok = false
171
+ detail = `没认出: ${sample.slice(0, 40)}`
172
+ break
173
+ }
174
+ if (split.prefix + split.rest !== sample) {
175
+ ok = false
176
+ detail = '拼接不还原(撤回段与正文对不上,客户端会撤不掉)'
177
+ break
178
+ }
179
+ if (/The user|According to|Per my|I should/i.test(split.rest)) {
180
+ ok = false
181
+ detail = `裁完还剩英文盘算: ${split.rest.slice(0, 40)}`
182
+ break
183
+ }
184
+ }
185
+ assert('泄漏:三个真实样本都能裁干净且拼接还原', ok, detail)
186
+ }
187
+ {
188
+ // 中文正文 + 英文专有名词:不能误裁
189
+ const text = '`delta` 是流式响应里每次推送的增量片段。DeepSeek Harness 把它拼起来才是完整回答。'
190
+ const split = splitLeakedReasoning(text)
191
+ assert('泄漏:中文正文不误裁', split.prefix === '' && split.rest === text, JSON.stringify(split.prefix.slice(0, 30)))
192
+ }
193
+ {
194
+ // 宽松判据(只用于"记住这个模型"):换说法也能识别
195
+ const vague = 'Hmm, the user wants to know my identity. Let me think about what to say here. 我是 Omen Alpha。'
196
+ assert('泄漏:换说法的英文盘算能被宽松判据抓到', looksLikeLeakedReasoning(vague) === true, String(looksLikeLeakedReasoning(vague)))
197
+ assert('泄漏:纯中文回答不会被宽松判据误判', looksLikeLeakedReasoning('我是 Omen Alpha,负责解释你选中的文字。') === false, '')
198
+ }
199
+
200
+ // ───────────────────────── 工具清单解析(联网工具的"备用"逻辑) ─────────────────────────
201
+ {
202
+ const preferred = ['advanced_search', 'platform_search', 'read', 'grep', 'glob']
203
+ const fallback = ['web_search']
204
+
205
+ // ① 装了 free-search:只用首选,不补 web_search
206
+ const normal = resolveToolNames(preferred, fallback, ['advanced_search', 'platform_search', 'read', 'grep', 'glob', 'web_search', 'web_fetch'])
207
+ assert('工具:首选联网工具在时不补 web_search', normal.includes('advanced_search') && normal.includes('platform_search') && !normal.includes('web_search'), JSON.stringify(normal))
208
+ assert('工具:也没顺带把 web_fetch 拉进来', !normal.includes('web_fetch'), JSON.stringify(normal))
209
+
210
+ // ② 没装 free-search(两个搜索工具都不在):补上 web_search
211
+ const fallbackCase = resolveToolNames(preferred, fallback, ['read', 'grep', 'glob', 'web_search', 'web_fetch', 'bash'])
212
+ assert('工具:没装联网插件时补上 web_search(小窗不至于变哑巴)', fallbackCase.includes('web_search'), JSON.stringify(fallbackCase))
213
+ assert('工具:备用只补联网类,不会把 bash 之类带进来', !fallbackCase.includes('bash') && !fallbackCase.includes('web_fetch'), JSON.stringify(fallbackCase))
214
+
215
+ // ③ 备用名在会话里也不存在(理论上不会,但要幂等)
216
+ const noneAtAll = resolveToolNames(preferred, fallback, ['read', 'grep'])
217
+ assert('工具:备用名也不存在时,不报错、不产生幽灵工具', JSON.stringify(noneAtAll) === JSON.stringify(['read', 'grep']), JSON.stringify(noneAtAll))
218
+
219
+ // ④ 只装了其中一个搜索工具 → 也算"有联网",不补
220
+ const halfCase = resolveToolNames(preferred, fallback, ['platform_search', 'read', 'web_search'])
221
+ assert('工具:只有 platform_search 时不补 web_search', halfCase.includes('platform_search') && !halfCase.includes('web_search'), JSON.stringify(halfCase))
222
+
223
+ // ⑤ 去重:首选里已经写了 web_search 时,不能出现两次
224
+ const dupCase = resolveToolNames(['web_search', ...preferred], fallback, ['web_search', 'read'])
225
+ assert('工具:清单不出现重复项', dupCase.filter((n) => n === 'web_search').length === 1, JSON.stringify(dupCase))
226
+ }
227
+
228
+ console.log(`\n=== 过滤器单测结束:${pass} 通过 / ${fail} 失败 ===`)
229
+ process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 文件工具边界(isInsideRoot)离线测试。
3
+ *
4
+ * 为什么要有这条测试:read/grep/glob 能读用户有权读的**任何**文件,
5
+ * 而划词的触发源(选中文字/上下文/搜索结果)可能是别人写的。
6
+ * 边界判断一旦写错(比如前缀匹配写成 `startsWith(root)`),
7
+ * `/Users/me/project-evil` 就会被误判成在 `/Users/me/project` 之内。
8
+ *
9
+ * 用法:node scripts/test-guard.mjs
10
+ */
11
+ import { isInsideRoot } from '../lib/index.js'
12
+
13
+ let pass = 0
14
+ let fail = 0
15
+ function check(name, actual, expected) {
16
+ const ok = actual === expected
17
+ if (ok) pass += 1
18
+ else fail += 1
19
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — 期望 ${String(expected)},实际 ${String(actual)}`)
20
+ }
21
+
22
+ const ROOT = '/Users/me/project'
23
+
24
+ // 不限(root 为空):一切都放行
25
+ check('root 为空时放行相对路径', isInsideRoot('', 'a.txt'), true)
26
+ check('root 为空时放行绝对路径', isInsideRoot('', '/etc/hosts'), true)
27
+
28
+ // 项目内
29
+ check('项目内的相对路径', isInsideRoot(ROOT, 'src/index.ts'), true)
30
+ check('项目内的绝对路径', isInsideRoot(ROOT, '/Users/me/project/src/a.ts'), true)
31
+ check('项目根目录本身', isInsideRoot(ROOT, '/Users/me/project'), true)
32
+ check('带 .. 但仍在项目内', isInsideRoot(ROOT, 'src/../README.md'), true)
33
+
34
+ // 项目外
35
+ check('父目录', isInsideRoot(ROOT, '../secrets.txt'), false)
36
+ check('绝对路径指向项目外', isInsideRoot(ROOT, '/etc/hosts'), false)
37
+ check('家目录', isInsideRoot(ROOT, '/Users/me/.ssh/id_rsa'), false)
38
+ check('~ 开头一律拒', isInsideRoot(ROOT, '~/.dsh/settings.yaml'), false)
39
+ check('单独一个 ~', isInsideRoot(ROOT, '~'), false)
40
+ // 前缀陷阱:同名兄弟目录不能算"在项目内"
41
+ check('前缀相同的兄弟目录不算项目内', isInsideRoot(ROOT, '/Users/me/project-evil/x'), false)
42
+ check('前缀相同的兄弟文件不算项目内', isInsideRoot(ROOT, '/Users/me/project.bak'), false)
43
+
44
+ // 没给路径(grep/glob 的 path 是可选的,默认 = 会话工作目录)→ 放行
45
+ check('未给 path 时放行(走默认工作目录)', isInsideRoot(ROOT, ''), true)
46
+
47
+ console.log(`\n=== 边界测试结束:${pass} 通过 / ${fail} 失败 ===`)
48
+ process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 会话背景窗口的离线测试(不依赖运行中的 host)。
3
+ *
4
+ * 覆盖:
5
+ * · 窗口锚定「选中文字所在的消息」,向上最多 maxMessages 条(含锚点),其后的对话不进去
6
+ * · 默认不做任何字数截断(长消息整条保留)
7
+ * · 工具结果 / 系统提示 / harness 注入的 user 消息全部剔除
8
+ * · 定位不到选中位置时退回最近 maxMessages 条
9
+ *
10
+ * 用法:node scripts/test-transcript.mjs (先 npm run build)
11
+ */
12
+ import { transcriptOf } from '../lib/index.js'
13
+
14
+ let failed = 0
15
+ const assert = (label, ok, extra) => {
16
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
17
+ if (!ok) failed += 1
18
+ }
19
+
20
+ const userEvent = (text, kind = 'user') => ({
21
+ type: 'user/message',
22
+ data: { role: 'user', source: { kind }, content: [{ type: 'text', text }] },
23
+ })
24
+ const assistantEvent = (text) => ({ type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } })
25
+ const toolEvent = () => ({ type: 'tool/result', data: { message: { content: [{ type: 'tool-result', toolCallId: 't' }] } } })
26
+ const systemEvent = (text) => ({ type: 'system/message', data: { message: { content: [{ type: 'text', text }] } } })
27
+
28
+ const LONG = '很长的助手答复'.repeat(300) // 2100 字,用于验证「不截断」
29
+
30
+ // 30 轮对话:用户 U01..U30 / 助手 A01..A30(零填充避免子串误命中),夹杂工具与系统消息
31
+ const events = [systemEvent('系统提示词'.repeat(500)), userEvent('被 harness 注入的上下文', 'plugin')]
32
+ for (let i = 1; i <= 30; i += 1) {
33
+ events.push(userEvent(`U${String(i).padStart(2, '0')}`))
34
+ events.push(toolEvent())
35
+ events.push(assistantEvent(i === 25 ? `${LONG} A25` : `A${String(i).padStart(2, '0')}`))
36
+ events.push(toolEvent())
37
+ }
38
+
39
+ // ── 1) 命中 U25:窗口 = 从 U25 向上数 24 条(A13..U25),其后的对话不进背景
40
+ const hit = transcriptOf(events, { maxMessages: 24, maxChars: 0, marker: 'U25' })
41
+ const lines = hit.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line))
42
+ assert('窗口条数 = 24(含锚点)', lines.length === 24, `实际 ${lines.length}`)
43
+ assert('首条是 A13(自锚点向上满 24 条)', lines[0].includes('助手:A13'), lines[0])
44
+ assert('末条是选中所在的 U25,且带 ▶', lines[lines.length - 1].includes('▶') && lines[lines.length - 1].includes('用户:U25'), lines[lines.length - 1].slice(0, 30))
45
+ assert('锚点命中标记正确', hit.marked === true)
46
+ assert('锚点之后的对话不进背景', hit.transcript.indexOf('A26') < 0 && hit.transcript.indexOf('U26') < 0 && hit.transcript.indexOf('U30') < 0)
47
+ assert('提示行说明前面还有更早消息', hit.transcript.includes('未包含'))
48
+
49
+ // ── 1b) 锚点稍后(U26)时,锚点之前的长消息整条保留(默认不截断)
50
+ const later = transcriptOf(events, { maxMessages: 24, maxChars: 0, marker: 'U26' })
51
+ assert('长消息未被截断', later.transcript.includes(LONG), `背景共 ${later.transcript.length} 字`)
52
+
53
+ // ── 2) 剔除噪音:工具/系统/注入消息不出现
54
+ assert('工具结果被剔除', hit.transcript.indexOf('工具:') < 0)
55
+ assert('系统提示词被剔除', hit.transcript.indexOf('系统提示') < 0)
56
+ assert('harness 注入的上下文被剔除', hit.transcript.indexOf('被 harness 注入') < 0)
57
+
58
+ // ── 3) 定位不到:退回最近 24 条,且不带锚点
59
+ const miss = transcriptOf(events, { maxMessages: 24, maxChars: 0, marker: '这段文字不在任何消息里' })
60
+ const missLines = miss.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line))
61
+ assert('未命中时退回最近 24 条', missLines.length === 24 && missLines[missLines.length - 1].includes('A30'), `末条=${missLines[missLines.length - 1]}`)
62
+ assert('未命中时不标锚点', miss.marked === false)
63
+
64
+ // ── 4) 早期消息:锚点靠近开头时窗口自然变短
65
+ const early = transcriptOf(events, { maxMessages: 24, maxChars: 0, marker: 'U02' })
66
+ const earlyLines = early.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line))
67
+ assert(
68
+ '锚点靠开头时窗口自然缩短(U01 / A01 / U02)',
69
+ earlyLines.length === 3 && earlyLines[0].includes('U01'),
70
+ `实际 ${earlyLines.length} 条`,
71
+ )
72
+
73
+ // ── 5) 显式设了总量上限时才裁剪
74
+ const capped = transcriptOf(events, { maxMessages: 24, maxChars: 200, marker: 'U25' })
75
+ assert('显式上限生效(配置 >0 时)', capped.transcript.length < 2000, `${capped.transcript.length} 字`)
76
+
77
+ console.log(`\n${failed === 0 ? '全部通过' : failed + ' 项失败'}`)
78
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: web-design
3
+ description: 小窗网页回答的视觉设计规范——窄容器、单列、有设计感但不落"AI 味"模板。用于「网页模式」下生成完整单文件 HTML 时。
4
+ version: 1
5
+ ---
6
+
7
+ # 小窗网页设计规范
8
+
9
+ > 思路参考 Anthropic 公开的 `frontend-design` 技能(claude-code 仓库)与社区同类实践,按本插件
10
+ > 「约 500px 宽的窄容器」重写为中文规则。**这是给模型看的规范**:开启网页模式后,追问生成 HTML 时注入。
11
+
12
+ ## 先决定"这页是什么",再动手
13
+
14
+ - 先想清楚:**给谁看、要回答什么、第一眼该看到什么**。内容本身就是设计的来源——
15
+ 解释一个协议、对比两个方案、梳理一段流程,视觉应当各不相同,而不是套同一个壳。
16
+ - **页面本身就是回答**:不要再在页面外重复一遍文字答案,也不要在页面里复述问题。
17
+
18
+ ## 硬约束(小窗形状决定的)
19
+
20
+ - **单列、宽度 100%**:不要固定像素宽度、不要 `min-width`、不要横向滚动;表格列数 ≤ 3,
21
+ 长内容允许换行(`word-break`)。小窗两侧已有 14px 内边距,页面自带 16–20px 内边距即可。
22
+ - **不要 `position:fixed` / `sticky` 头部**:容器会滚动,粘性元素会挡住内容。
23
+ - **不引外部资源**:不加载外部字体、CDN、图片;图示用内联 SVG 或纯 CSS 画。
24
+ - **一套主题**:浅色或深色只选一套(可用 `prefers-color-scheme` 覆盖,但默认给出明确底色)。
25
+
26
+ ## 排版
27
+
28
+ - **一个字体家族就够**(最多两个,且要明显区分);中文用系统字体栈即可,不要为了"高级"去引 Web 字体。
29
+ - 字号阶梯:主标题 20–22px、小标题 15–16px、正文 14px、辅助信息 12–13px;
30
+ 行高 1.6–1.75;**每行不超过约 30 个汉字**(窗口窄,这是天然上限)。
31
+ - 层级靠**字重与颜色**区分,不要靠一堆不同字号。
32
+
33
+ ## 色彩
34
+
35
+ - **一个主色 + 一套中性灰阶**:主色用于强调与关键结论,灰阶承担 90% 的界面。
36
+ - 对比度按可读性底线来(正文与背景 ≥ 4.5:1);深色页面别用纯黑,浅色页面别用纯白到底。
37
+ - 不要为了"丰富"给每个元素不同颜色。
38
+
39
+ ## 结构即信息
40
+
41
+ - **只在内容真是序列时才编号**(步骤、时间线);不是序列就别用 01/02/03 装饰。
42
+ - 对比用表格或左右并列;要点用列表;关键结论用一块高亮(左边框 + 淡底)。
43
+ - 卡片圆角、阴影**只给需要成组的内容**,不要所有东西都套同一个圆角同一层阴影。
44
+
45
+ ## 避免这些"AI 味"默认值
46
+
47
+ 以下都是生成式设计的常见套路,**不要无脑使用**(除非内容本身就该长这样):
48
+
49
+ - 奶油底色 + 高对比衬线标题 + 陶土色强调;
50
+ - 近黑底 + 一个荧光绿/朱红强调;
51
+ - 发丝线 + 零圆角的"报纸式"密排;
52
+ - SaaS 卡片套件:什么内容都切成等宽圆角卡片,同一个圆角、同一层 `rgba(0,0,0,.1)` 阴影、再来点渐变当装饰;
53
+ - 每个标题上面都加一个全大写、加字距的小标签(eyebrow);
54
+ - 用 `A · B · C` 把元信息串起来、标签写成 `词 — 短语`、伪黑 `#0B0B0B`、小号等宽字体当标签、链接和按钮结尾都跟一个 `→`。
55
+
56
+ **选一个地方出彩**就够了:让一个元素(标题的排法、一个内联图示、一处对比表)成为记忆点,
57
+ 其余保持安静克制。
58
+
59
+ ## 动效
60
+
61
+ - 只在"回应操作"(展开、切换、确认)或"一次入场"时用;不要每张卡片都 fade-in、不要每个卡片都 hover 抬升。
62
+ - 尊重 `prefers-reduced-motion`。
63
+
64
+ ## 文案也是设计
65
+
66
+ - 主动语态、句子大小写;按钮写它会做什么("保存修改",不是"提交");同一个动作全流程同名。
67
+ - 说人话,不卖弄;错误要讲清发生了什么、怎么修;空白页要给出下一步。
68
+
69
+ ## 交付前的自查
70
+
71
+ 1. 缩到 360px 宽会不会横向滚动?
72
+ 2. 主标题是不是唯一的第一视觉焦点?
73
+ 3. 有没有哪一处是"任何题目都会这么写"的默认套路?换掉它。
74
+ 4. 关掉样式后,HTML 结构读起来还通顺吗(语义标签、标题层级)?