@yfwu2020/dsh-selection-explain 0.1.1 → 0.1.3
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/README.md +60 -12
- package/assets/code.png +0 -0
- package/assets/stage1.png +0 -0
- package/assets/stage2.png +0 -0
- package/assets/web.png +0 -0
- package/lib/client.js +10 -4
- package/lib/index.js +3 -136
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +135 -0
- package/lib/prompts.js.map +1 -0
- package/lib/types/prompts.d.ts +26 -0
- package/package.json +5 -3
- package/scripts/build-demo-pages.mjs +296 -0
- package/scripts/crop-png.mjs +142 -0
- package/scripts/test-client.mjs +11 -0
- package/scripts/test-prompt.mjs +178 -0
- package/src/client/index.js +10 -4
- package/src/index.ts +9 -142
- package/src/prompts.ts +139 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生成 README 用的小窗演示页(docs/demo/*.html)。
|
|
3
|
+
*
|
|
4
|
+
* 目的:README 里的功能截图必须**和真实面板长得一样**,所以样式不能手抄 ——
|
|
5
|
+
* 这里直接从 lib/client.js 里把模块级 `CSS` 常量抽出来注入演示页,
|
|
6
|
+
* 类名 / 结构 / 文案也照 src/client/index.js 的渲染器与真实输出写。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node scripts/build-demo-pages.mjs
|
|
9
|
+
* 产物:docs/demo/stage1.html / stage2.html / code.html(再交给 Chrome 截图)
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { resolve, dirname } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const ROOT = resolve(HERE, '..')
|
|
17
|
+
|
|
18
|
+
/** 从客户端 bundle 里取出模块级 CSS 常量(不手抄,保证与线上一致)。 */
|
|
19
|
+
function extractCss() {
|
|
20
|
+
const src = readFileSync(resolve(ROOT, 'lib', 'client.js'), 'utf8')
|
|
21
|
+
const m = src.match(/var CSS = \[([\s\S]*?)\]\s*\.join\(''\)/) || src.match(/var CSS = \[([\s\S]*?)\]\.join\('\\n'\)/)
|
|
22
|
+
if (!m) throw new Error('未能在 lib/client.js 里找到 CSS 常量')
|
|
23
|
+
// 元素是 '...' 单引号字符串字面量
|
|
24
|
+
const parts = []
|
|
25
|
+
const re = /'((?:[^'\\]|\\.)*)'/g
|
|
26
|
+
let e
|
|
27
|
+
while ((e = re.exec(m[1])) !== null) parts.push(e[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
|
|
28
|
+
return parts.join('\n')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** DSH 主题变量:用浅色主题的实际取值,让截图看起来像真的嵌在 DSH 里。 */
|
|
32
|
+
const THEME_VARS = `
|
|
33
|
+
--dsw-alias-bg-layer-1: #ffffff;
|
|
34
|
+
--dsw-alias-label-primary: #1a1a1a;
|
|
35
|
+
--dsw-alias-label-secondary: #6b7280;
|
|
36
|
+
--dsw-alias-label-primary-foreground: #ffffff;
|
|
37
|
+
--dsw-alias-interactive-bg-hover: rgba(140,140,140,.11);
|
|
38
|
+
--dsw-alias-border-l3: rgba(140,140,140,.35);
|
|
39
|
+
--dsw-alias-border-l4: rgba(140,140,140,.22);
|
|
40
|
+
--dsw-alias-button-primary-fill: #3b6ef5;
|
|
41
|
+
--sel-a1: #0d9488;
|
|
42
|
+
--sel-a2: #0f766e;
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
const PAGE = (title, css, body) => `<!doctype html>
|
|
46
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
|
|
47
|
+
<style>
|
|
48
|
+
/* 只渲染小窗本体:纯白底、无外边距,宽度固定 540 —— 截图后统一裁切 */
|
|
49
|
+
html,body{margin:0;padding:0;background:#ffffff;font-family:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif}
|
|
50
|
+
body{display:block;width:540px}
|
|
51
|
+
${THEME_VARS}
|
|
52
|
+
${css}
|
|
53
|
+
/* 演示页专用:把浮层定位改成静态排版、并显示面板(真实面板默认 display:none,由 JS 打开) */
|
|
54
|
+
.dsh-sel-layer{position:static !important;pointer-events:auto}
|
|
55
|
+
/* 截图只要"小窗本体":去掉投影、边框、圆角 —— 否则裁切后会在上下边缘留下一圈灰线
|
|
56
|
+
(真实面板的投影是给浮层用的,单独截图时只会变成灰边) */
|
|
57
|
+
.dsh-sel-panel{position:static !important;display:flex !important;max-height:none !important;width:540px !important;
|
|
58
|
+
box-shadow:none !important;border:0 !important;border-radius:0 !important}
|
|
59
|
+
/* 首轮:真实行为是「翻译一出来输入框就出现」(askRow.style.display = wide || translationReady),
|
|
60
|
+
所以演示页也照实显示,不再隐藏 */
|
|
61
|
+
.dsh-sel-ask{display:flex !important;border:0 !important}
|
|
62
|
+
</style></head>
|
|
63
|
+
<body>${body}</body></html>
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
const panel = (stage, inner) => `<div class="dsh-sel-layer">
|
|
67
|
+
<div class="dsh-sel-panel" data-stage="${stage}" data-theme="light" role="dialog" aria-label="划词解读">
|
|
68
|
+
<div class="dsh-sel-head">
|
|
69
|
+
<span class="dsh-sel-mark"></span>
|
|
70
|
+
<span class="dsh-sel-title">划词解读</span>
|
|
71
|
+
<span class="dsh-sel-headspace"></span>
|
|
72
|
+
<button class="dsh-sel-action">🕘 最近</button>
|
|
73
|
+
<button class="dsh-sel-action">↗ 升格</button>
|
|
74
|
+
<button class="dsh-sel-icon" title="关闭">✕</button>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="dsh-sel-body">
|
|
77
|
+
${inner}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>`
|
|
81
|
+
|
|
82
|
+
const quote = (text) => ` <div class="dsh-sel-quote">${text}</div>`
|
|
83
|
+
|
|
84
|
+
const section = (key, title, content) => ` <div class="dsh-sel-sec" data-sec="${key}">
|
|
85
|
+
<div class="dsh-sel-sh"><i></i><span>${title}</span><span class="dsh-sel-hint"></span></div>
|
|
86
|
+
<div class="dsh-sel-c">
|
|
87
|
+
${content}
|
|
88
|
+
</div>
|
|
89
|
+
</div>`
|
|
90
|
+
|
|
91
|
+
const p = (html) => ` <p>${html}</p>`
|
|
92
|
+
const list = (level, items) => ` <div class="dsh-sel-list" data-level="${level}">
|
|
93
|
+
${items.map((it) => ` <div class="dsh-sel-item"${it.num ? ' data-num="1"' : ''}><span class="dsh-sel-bullet">${it.bullet || '•'}</span><span>${it.text}</span></div>`).join('\n')}
|
|
94
|
+
</div>`
|
|
95
|
+
const callout = (tag, text) => ` <div class="dsh-sel-callout"><span class="dsh-sel-callout-tag">${tag}</span>${text}</div>`
|
|
96
|
+
|
|
97
|
+
const expand = ` <button class="dsh-sel-expand"><span>↓ 展开详解</span></button>`
|
|
98
|
+
|
|
99
|
+
/** 真实图标(照 src/client/index.js 的 markdownIcon / windowIcon / sendIcon)。 */
|
|
100
|
+
const mdIcon = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 17.5V7.5l4.4 5 4.4-5v10"></path><path d="M17.4 7v9.6M14.3 13.6l3.1 3.1 3.1-3.1"></path></svg>`
|
|
101
|
+
const webIcon = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="15" rx="3"></rect><path d="M3 9.6h18"></path></svg>`
|
|
102
|
+
const sendIcon = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13.2V3.4"></path><path d="M3.9 7.5 8 3.4l4.1 4.1"></path></svg>`
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 输入区(结构照 src/client/index.js 的真实构建顺序):
|
|
106
|
+
* .dsh-sel-ask > textarea.dsh-sel-askbox + .dsh-sel-asktools
|
|
107
|
+
* asktools = 输出偏好(文字 + 分段开关 + 滑动药丸) + askspace(撑开) + 模型胶囊 + 发送键
|
|
108
|
+
*/
|
|
109
|
+
const askRow = (webOn, model = 'deepseek-v4.1-flash', tier = '高') => ` <div class="dsh-sel-ask">
|
|
110
|
+
<textarea class="dsh-sel-askbox" rows="1" placeholder="就这段文字继续追问…(Enter 发送,Shift+Enter 换行)"></textarea>
|
|
111
|
+
<div class="dsh-sel-asktools">
|
|
112
|
+
<span class="dsh-sel-pref" data-on="${webOn ? '1' : '0'}">
|
|
113
|
+
<span class="dsh-sel-preftext">输出偏好</span>
|
|
114
|
+
<span class="dsh-sel-seg" role="radiogroup" aria-label="输出偏好">
|
|
115
|
+
<span class="dsh-sel-segpill"></span>
|
|
116
|
+
<button class="dsh-sel-segcell" role="radio" aria-label="Markdown:普通段落,最省时间"${webOn ? '' : ' data-on="1"'}>${mdIcon}</button>
|
|
117
|
+
<button class="dsh-sel-segcell" role="radio" aria-label="网页:复杂问题生成一个 HTML 页面"${webOn ? ' data-on="1"' : ''}>${webIcon}</button>
|
|
118
|
+
</span>
|
|
119
|
+
</span>
|
|
120
|
+
<span class="dsh-sel-askspace"></span>
|
|
121
|
+
<button class="dsh-sel-picker" type="button" aria-haspopup="menu" aria-expanded="false">
|
|
122
|
+
<span class="dsh-sel-picker-name">${model}</span>
|
|
123
|
+
<span class="dsh-sel-picker-tier">· ${tier}</span>
|
|
124
|
+
<span class="dsh-sel-picker-caret">▼</span>
|
|
125
|
+
</button>
|
|
126
|
+
<button class="dsh-sel-iconbtn dsh-sel-asksend" type="button" aria-label="发送">${sendIcon}</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>`
|
|
129
|
+
|
|
130
|
+
const chatBubble = (role, html) =>
|
|
131
|
+
` <div class="dsh-sel-bubble ${role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot'}">${html}</div>`
|
|
132
|
+
|
|
133
|
+
// ───────────────────────── 状态一:首轮只出翻译 ─────────────────────────
|
|
134
|
+
const stage1 = panel(
|
|
135
|
+
'translation',
|
|
136
|
+
[
|
|
137
|
+
quote('the migration ran long'),
|
|
138
|
+
section(
|
|
139
|
+
'translation',
|
|
140
|
+
'翻译',
|
|
141
|
+
[
|
|
142
|
+
p('这句话里需要翻译的是 <b>the migration ran long</b>:'),
|
|
143
|
+
list(0, [{ text: '<b>migration</b> /maɪˈɡreɪʃn/ —— 迁移(把数据/服务从一处搬到另一处)' }]),
|
|
144
|
+
list(0, [{ text: '<b>ran long</b> —— 跑得比预期久(ran = run 的过去式,long 指耗时超出预期)' }]),
|
|
145
|
+
p('合起来:<b>迁移跑得比预期久</b>。'),
|
|
146
|
+
callout('在本句中', '迁移作业耗时超出了原计划——这是在陈述进度延迟的事实,不含"失败了"的意思。'),
|
|
147
|
+
].join('\n'),
|
|
148
|
+
),
|
|
149
|
+
expand,
|
|
150
|
+
askRow(false),
|
|
151
|
+
].join('\n'),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// ───────────────────────── 状态二:展开详解 + 追问 ─────────────────────────
|
|
155
|
+
const stage2 = panel(
|
|
156
|
+
'detail',
|
|
157
|
+
[
|
|
158
|
+
quote('the migration ran long'),
|
|
159
|
+
section(
|
|
160
|
+
'translation',
|
|
161
|
+
'翻译',
|
|
162
|
+
[
|
|
163
|
+
p('这句话里需要翻译的是 <b>the migration ran long</b>:'),
|
|
164
|
+
list(0, [{ text: '<b>migration</b> /maɪˈɡreɪʃn/ —— 迁移(把数据/服务从一处搬到另一处)' }]),
|
|
165
|
+
list(0, [{ text: '<b>ran long</b> —— 跑得比预期久(ran = run 的过去式,long 指耗时超出预期)' }]),
|
|
166
|
+
callout('在本句中', '迁移作业耗时超出了原计划——这是在陈述进度延迟的事实,不含"失败了"的意思。'),
|
|
167
|
+
].join('\n'),
|
|
168
|
+
),
|
|
169
|
+
section(
|
|
170
|
+
'detail',
|
|
171
|
+
'详解',
|
|
172
|
+
[
|
|
173
|
+
p('这句话出自一句对话,是开发者在向 PM 说明<b>进度为什么需要重新安排</b>——它不是报错,而是在为后面的决定做铺垫。'),
|
|
174
|
+
p('两个关键说法各指什么:'),
|
|
175
|
+
list(0, [
|
|
176
|
+
{ text: '<b>migration</b>(迁移):把数据或服务从旧结构搬到新结构的一次作业。在数据库语境里通常指改表结构、搬数据这类"不能中途停"的操作。' },
|
|
177
|
+
{ text: '<b>ran long</b>(跑久了):完成时态,说的是这次已经发生的执行<b>耗时超过预期</b>,而不是"即将变慢"。' },
|
|
178
|
+
]),
|
|
179
|
+
p('承接关系:既然迁移比预期久,后面的 <b>freeze features</b>(冻结功能开发)就不是随口一说,而是把人力腾出来的实际动作。'),
|
|
180
|
+
callout('小结', '在说"迁移耗时超预期",用来解释接下来的功能冻结——重点在<b>排期</b>,不在迁移本身的对错。'),
|
|
181
|
+
].join('\n'),
|
|
182
|
+
),
|
|
183
|
+
` <div class="dsh-sel-chatlog" style="display:flex">`,
|
|
184
|
+
chatBubble('user', '那 freeze features 是指完全停止开发吗?'),
|
|
185
|
+
chatBubble('assistant', '不是完全停止,而是<b>暂停新功能合入</b>:已有功能的修 bug 与收尾照常,只是不再往主干加新特性,好让迁移期间的分支收敛。'),
|
|
186
|
+
' </div>',
|
|
187
|
+
askRow(false),
|
|
188
|
+
].join('\n'),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
/** 代码块:真实渲染器逐行输出 `.dsh-sel-pre-line`,注释行带 data-wrap(可折行),代码行不折。 */
|
|
192
|
+
const codeBlock = (lines) => ` <pre class="dsh-sel-pre" data-hl="light">${lines
|
|
193
|
+
.map((l) => `<div class="dsh-sel-pre-line"${l.wrap ? ' data-wrap="1"' : ''}>${l.wrap ? `<span class="dsh-sel-pre-cmt">${l.text}</span>` : l.text}</div>`)
|
|
194
|
+
.join('')}</pre>`
|
|
195
|
+
|
|
196
|
+
// ───────────────────────── 状态三:选中代码 → 出注释 ─────────────────────────
|
|
197
|
+
const codeDemo = panel(
|
|
198
|
+
'translation',
|
|
199
|
+
[
|
|
200
|
+
quote('function debounce(fn, wait) { … }'),
|
|
201
|
+
section(
|
|
202
|
+
'translation',
|
|
203
|
+
'注释',
|
|
204
|
+
[
|
|
205
|
+
codeBlock([
|
|
206
|
+
{ wrap: true, text: '// 防抖:把连续触发压成「最后一次之后 wait 毫秒才真正执行」' },
|
|
207
|
+
{ text: 'function debounce(fn, wait) {' },
|
|
208
|
+
{ wrap: true, text: ' // 闭包变量:保存上一次的定时器,用来取消它' },
|
|
209
|
+
{ text: ' let timer = null' },
|
|
210
|
+
{ wrap: true, text: ' // 每次触发都先清掉上一个定时器,再重新计时' },
|
|
211
|
+
{ text: ' return function (...args) {' },
|
|
212
|
+
{ text: ' clearTimeout(timer)' },
|
|
213
|
+
{ wrap: true, text: ' // wait 毫秒内没有新触发,才执行原函数' },
|
|
214
|
+
{ text: ' timer = setTimeout(() => fn.apply(this, args), wait)' },
|
|
215
|
+
{ text: ' }' },
|
|
216
|
+
{ text: '}' },
|
|
217
|
+
]),
|
|
218
|
+
callout('小结', '把高频触发收敛成一次执行——常用于搜索输入、窗口 resize 这类"只要最终结果"的场景。'),
|
|
219
|
+
].join('\n'),
|
|
220
|
+
),
|
|
221
|
+
].join('\n'),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
/** 网页模式:回答里嵌一张可交互的 HTML 页面(.dsh-sel-preview 结构照渲染器)。 */
|
|
225
|
+
const previewBlock = (srcdoc) => ` <div class="dsh-sel-preview" data-view="preview" data-full="1">
|
|
226
|
+
<div class="dsh-sel-previewbar">
|
|
227
|
+
<div class="dsh-sel-previewtabs">
|
|
228
|
+
<button class="dsh-sel-previewtab" data-on="1">预览</button>
|
|
229
|
+
<button class="dsh-sel-previewtab" data-on="0">源码</button>
|
|
230
|
+
</div>
|
|
231
|
+
<button class="dsh-sel-previewzoom">⤡ 还原</button>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="dsh-sel-previewbody">
|
|
234
|
+
<iframe class="dsh-sel-previewframe" srcdoc="${srcdoc.replace(/"/g, '"')}"></iframe>
|
|
235
|
+
</div>
|
|
236
|
+
</div>`
|
|
237
|
+
|
|
238
|
+
const DEMO_HTML = `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
239
|
+
body{margin:0;padding:14px 16px;font:13px/1.6 -apple-system,"PingFang SC",sans-serif;color:#1a1a1a;background:#fff}
|
|
240
|
+
h3{margin:0 0 4px;font-size:14px}
|
|
241
|
+
p.sub{margin:0 0 12px;color:#6b7280;font-size:12px}
|
|
242
|
+
table{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
243
|
+
th,td{border-bottom:1px solid #e6e8eb;padding:7px 8px;text-align:left}
|
|
244
|
+
th{background:#f6f8f9;font-weight:600;color:#374151}
|
|
245
|
+
td.y{color:#0f766e;font-weight:600}
|
|
246
|
+
td.n{color:#b45309}
|
|
247
|
+
.bars{display:flex;align-items:flex-end;gap:10px;height:74px;margin:14px 0 4px}
|
|
248
|
+
.bar{flex:1;border-radius:5px 5px 0 0;background:#0d9488;position:relative}
|
|
249
|
+
.bar span{position:absolute;bottom:-19px;left:0;right:0;text-align:center;font-size:11px;color:#6b7280}
|
|
250
|
+
.b1{height:34px;background:#99f6e4}.b2{height:58px}.b3{height:74px;background:#0f766e}
|
|
251
|
+
</style></head><body>
|
|
252
|
+
<h3>三种缓存策略的取舍</h3>
|
|
253
|
+
<p class="sub">按"命中率 / 一致性风险 / 实现成本"三个维度对比</p>
|
|
254
|
+
<table>
|
|
255
|
+
<tr><th>策略</th><th>命中率</th><th>一致性风险</th><th>实现成本</th></tr>
|
|
256
|
+
<tr><td>不缓存</td><td class="n">—</td><td class="y">无</td><td class="y">最低</td></tr>
|
|
257
|
+
<tr><td>进程内 LRU</td><td class="y">高</td><td class="n">中(多实例不一致)</td><td class="y">低</td></tr>
|
|
258
|
+
<tr><td>共享缓存 + TTL</td><td class="y">最高</td><td class="y">低(可配 TTL)</td><td class="n">中</td></tr>
|
|
259
|
+
</table>
|
|
260
|
+
<div class="bars"><div class="bar b1"><span>不缓存</span></div><div class="bar b2"><span>LRU</span></div><div class="bar b3"><span>共享缓存</span></div></div>
|
|
261
|
+
</body></html>`
|
|
262
|
+
|
|
263
|
+
// ───────────────────────── 状态四:网页模式(输出偏好拨到「网页」)─────────────────────────
|
|
264
|
+
// 场景要能体现"为什么该用网页":需要对比 + 图示的问题 → 直接给一页 HTML。
|
|
265
|
+
const webDemo = panel(
|
|
266
|
+
'detail',
|
|
267
|
+
[
|
|
268
|
+
quote('缓存策略'),
|
|
269
|
+
section(
|
|
270
|
+
'detail',
|
|
271
|
+
'详解',
|
|
272
|
+
[
|
|
273
|
+
p('三种做法的差别是并列的,画成一页更好看:'),
|
|
274
|
+
previewBlock(DEMO_HTML),
|
|
275
|
+
].join('\n'),
|
|
276
|
+
),
|
|
277
|
+
` <div class="dsh-sel-chatlog" style="display:flex">`,
|
|
278
|
+
chatBubble('user', '把三种缓存策略的差别画成一页对比'),
|
|
279
|
+
' </div>',
|
|
280
|
+
askRow(true, 'deepseek-v4.1-flash', '高'),
|
|
281
|
+
].join('\n'),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
const css = extractCss()
|
|
285
|
+
mkdirSync(resolve(ROOT, 'docs', 'demo'), { recursive: true })
|
|
286
|
+
const out = {
|
|
287
|
+
'stage1.html': PAGE('划词解读 · 首轮翻译', css, stage1),
|
|
288
|
+
'stage2.html': PAGE('划词解读 · 详解与追问', css, stage2),
|
|
289
|
+
'code.html': PAGE('划词解读 · 代码注释', css, codeDemo),
|
|
290
|
+
'web.html': PAGE('划词解读 · 网页模式', css, webDemo),
|
|
291
|
+
}
|
|
292
|
+
for (const [name, html] of Object.entries(out)) {
|
|
293
|
+
writeFileSync(resolve(ROOT, 'docs', 'demo', name), html, 'utf8')
|
|
294
|
+
console.log(`✓ docs/demo/${name} ${(html.length / 1024).toFixed(1)} kB`)
|
|
295
|
+
}
|
|
296
|
+
console.log(`\nCSS 来源:lib/client.js(${css.length} 字符,未手抄)`)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 裁掉截图四周的空白(仅用 Node 内置 zlib 解码/编码 PNG,不依赖 imagemagick)。
|
|
3
|
+
*
|
|
4
|
+
* 用途:README 的演示图需要紧凑。Chrome `--screenshot` 只能给固定窗口尺寸,
|
|
5
|
+
* 面板比窗口矮时底部会留下大片背景色 —— 这里把纯背景的边缘裁掉。
|
|
6
|
+
*
|
|
7
|
+
* 用法:node scripts/crop-png.mjs <in.png> <out.png> [边距px]
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { inflateSync, deflateSync } from 'node:zlib'
|
|
11
|
+
|
|
12
|
+
const [, , inPath, outPath, padArg, tolArg] = process.argv
|
|
13
|
+
if (!inPath || !outPath) {
|
|
14
|
+
console.error('用法: node scripts/crop-png.mjs <in.png> <out.png> [边距px] [背景容差]')
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
const PAD = Number(padArg ?? 0)
|
|
18
|
+
// 容差要能吃掉面板投影造成的渐变(实测同一张图背景在 247~255 之间浮动)
|
|
19
|
+
const TOL = Number(tolArg ?? 8)
|
|
20
|
+
|
|
21
|
+
// ── 读 PNG(假定 Chrome 输出的 8bit RGB/RGBA 非隔行)──
|
|
22
|
+
const buf = readFileSync(inPath)
|
|
23
|
+
if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('不是 PNG')
|
|
24
|
+
let pos = 8
|
|
25
|
+
let width = 0
|
|
26
|
+
let height = 0
|
|
27
|
+
let bitDepth = 0
|
|
28
|
+
let colorType = 0
|
|
29
|
+
const idat = []
|
|
30
|
+
while (pos < buf.length) {
|
|
31
|
+
const len = buf.readUInt32BE(pos)
|
|
32
|
+
const type = buf.toString('ascii', pos + 4, pos + 8)
|
|
33
|
+
const data = buf.subarray(pos + 8, pos + 8 + len)
|
|
34
|
+
if (type === 'IHDR') {
|
|
35
|
+
width = data.readUInt32BE(0)
|
|
36
|
+
height = data.readUInt32BE(4)
|
|
37
|
+
bitDepth = data[8]
|
|
38
|
+
colorType = data[9]
|
|
39
|
+
if (data[12] !== 0) throw new Error('不支持隔行 PNG')
|
|
40
|
+
} else if (type === 'IDAT') idat.push(data)
|
|
41
|
+
else if (type === 'IEND') break
|
|
42
|
+
pos += 12 + len
|
|
43
|
+
}
|
|
44
|
+
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) {
|
|
45
|
+
throw new Error(`不支持的 PNG 格式:bitDepth=${bitDepth} colorType=${colorType}`)
|
|
46
|
+
}
|
|
47
|
+
const bpp = colorType === 6 ? 4 : 3
|
|
48
|
+
const raw = inflateSync(Buffer.concat(idat))
|
|
49
|
+
|
|
50
|
+
// ── 反滤波 ──
|
|
51
|
+
const stride = width * bpp
|
|
52
|
+
const img = Buffer.alloc(height * stride)
|
|
53
|
+
for (let y = 0; y < height; y++) {
|
|
54
|
+
const filter = raw[y * (stride + 1)]
|
|
55
|
+
const line = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)
|
|
56
|
+
const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
|
|
57
|
+
const cur = img.subarray(y * stride, (y + 1) * stride)
|
|
58
|
+
for (let x = 0; x < stride; x++) {
|
|
59
|
+
const a = x >= bpp ? cur[x - bpp] : 0
|
|
60
|
+
const b = prev ? prev[x] : 0
|
|
61
|
+
const c = prev && x >= bpp ? prev[x - bpp] : 0
|
|
62
|
+
let v = line[x]
|
|
63
|
+
if (filter === 1) v += a
|
|
64
|
+
else if (filter === 2) v += b
|
|
65
|
+
else if (filter === 3) v += (a + b) >> 1
|
|
66
|
+
else if (filter === 4) {
|
|
67
|
+
const p = a + b - c
|
|
68
|
+
const pa = Math.abs(p - a)
|
|
69
|
+
const pb = Math.abs(p - b)
|
|
70
|
+
const pc = Math.abs(p - c)
|
|
71
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
|
|
72
|
+
}
|
|
73
|
+
cur[x] = v & 0xff
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 找内容边界:以左上角像素为背景色 ──
|
|
78
|
+
const bg = [img[0], img[1], img[2]]
|
|
79
|
+
const isBg = (x, y) => {
|
|
80
|
+
const o = y * stride + x * bpp
|
|
81
|
+
return (
|
|
82
|
+
Math.abs(img[o] - bg[0]) <= TOL && Math.abs(img[o + 1] - bg[1]) <= TOL && Math.abs(img[o + 2] - bg[2]) <= TOL
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
let top = 0
|
|
86
|
+
let bottom = height - 1
|
|
87
|
+
let left = 0
|
|
88
|
+
let right = width - 1
|
|
89
|
+
outer: for (; top < height; top++) for (let x = 0; x < width; x++) if (!isBg(x, top)) break outer
|
|
90
|
+
outer2: for (; bottom > top; bottom--) for (let x = 0; x < width; x++) if (!isBg(x, bottom)) break outer2
|
|
91
|
+
outer3: for (; left < width; left++) for (let y = top; y <= bottom; y++) if (!isBg(left, y)) break outer3
|
|
92
|
+
outer4: for (; right > left; right--) for (let y = top; y <= bottom; y++) if (!isBg(right, y)) break outer4
|
|
93
|
+
|
|
94
|
+
top = Math.max(0, top - PAD)
|
|
95
|
+
left = Math.max(0, left - PAD)
|
|
96
|
+
bottom = Math.min(height - 1, bottom + PAD)
|
|
97
|
+
right = Math.min(width - 1, right + PAD)
|
|
98
|
+
const w = right - left + 1
|
|
99
|
+
const h = bottom - top + 1
|
|
100
|
+
|
|
101
|
+
// ── 重新编码(每行用 filter 0)──
|
|
102
|
+
const outStride = w * bpp
|
|
103
|
+
const rawOut = Buffer.alloc(h * (outStride + 1))
|
|
104
|
+
for (let y = 0; y < h; y++) {
|
|
105
|
+
rawOut[y * (outStride + 1)] = 0
|
|
106
|
+
img.copy(rawOut, y * (outStride + 1) + 1, (top + y) * stride + left * bpp, (top + y) * stride + left * bpp + outStride)
|
|
107
|
+
}
|
|
108
|
+
const chunk = (type, data) => {
|
|
109
|
+
const len = Buffer.alloc(4)
|
|
110
|
+
len.writeUInt32BE(data.length)
|
|
111
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
|
112
|
+
const crc = Buffer.alloc(4)
|
|
113
|
+
crc.writeUInt32BE(crc32(body) >>> 0)
|
|
114
|
+
return Buffer.concat([len, body, crc])
|
|
115
|
+
}
|
|
116
|
+
let table = null
|
|
117
|
+
function crc32(b) {
|
|
118
|
+
if (!table) {
|
|
119
|
+
table = new Int32Array(256)
|
|
120
|
+
for (let n = 0; n < 256; n++) {
|
|
121
|
+
let c = n
|
|
122
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
123
|
+
table[n] = c
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let c = 0xffffffff
|
|
127
|
+
for (let i = 0; i < b.length; i++) c = table[(c ^ b[i]) & 0xff] ^ (c >>> 8)
|
|
128
|
+
return c ^ 0xffffffff
|
|
129
|
+
}
|
|
130
|
+
const ihdr = Buffer.alloc(13)
|
|
131
|
+
ihdr.writeUInt32BE(w, 0)
|
|
132
|
+
ihdr.writeUInt32BE(h, 4)
|
|
133
|
+
ihdr[8] = 8
|
|
134
|
+
ihdr[9] = colorType
|
|
135
|
+
const png = Buffer.concat([
|
|
136
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
137
|
+
chunk('IHDR', ihdr),
|
|
138
|
+
chunk('IDAT', deflateSync(rawOut, { level: 9 })),
|
|
139
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
140
|
+
])
|
|
141
|
+
writeFileSync(outPath, png)
|
|
142
|
+
console.log(`✓ ${outPath} ${width}x${height} → ${w}x${h}`)
|
package/scripts/test-client.mjs
CHANGED
|
@@ -1994,9 +1994,20 @@ assert(
|
|
|
1994
1994
|
)
|
|
1995
1995
|
assert('标题文字完整(未被截断成省略号的短写)', textOf(rowParts[0]).indexOf('缓存探测词') >= 0, textOf(rowParts[0]))
|
|
1996
1996
|
const sentBeforeClick = sent.length
|
|
1997
|
+
// 回归:点「最近」列表里的一条时,面板**不能**被重新锚到右下角胶囊上方。
|
|
1998
|
+
// 给胶囊一个特征坐标(左下角 10,860),若面板被挪到那里,下面的位置断言会立刻失败。
|
|
1999
|
+
const pillEl = Array.from(walk(panel.parentNode)).find((n) => n.className === 'dsh-sel-pill')
|
|
2000
|
+
if (pillEl) pillEl.getBoundingClientRect = () => ({ left: 10, top: 860, right: 150, bottom: 890, width: 140, height: 30 })
|
|
2001
|
+
const panelLeftBefore = panel.style.left
|
|
2002
|
+
const panelTopBefore = panel.style.top
|
|
1997
2003
|
targetRow.dispatch('click', { stopPropagation() {} })
|
|
1998
2004
|
await sleep(80)
|
|
1999
2005
|
assert('点一条能把那段对话调回面板(不调模型)', sent.length === sentBeforeClick && textOf(sections[0]).indexOf('缓存测试内容') >= 0, `新增请求 ${sent.length - sentBeforeClick}`)
|
|
2006
|
+
assert(
|
|
2007
|
+
'点列表条目时面板不跳到胶囊上方(保持原位)',
|
|
2008
|
+
panel.style.left === panelLeftBefore && panel.style.top === panelTopBefore,
|
|
2009
|
+
`before=${panelLeftBefore}/${panelTopBefore} after=${panel.style.left}/${panel.style.top}`,
|
|
2010
|
+
)
|
|
2000
2011
|
assert('点完之后列表收起', histList.style.display === 'none', String(histList.style.display))
|
|
2001
2012
|
|
|
2002
2013
|
// Esc:一次到位 —— 面板与侧边栏一起收(原先"第一下只收列表"那一级已按需求合并)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提示词契约测试 —— 保护提示词里的**关键约束**不被静默删除。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:提示词是产品行为的主要来源(两节结构、结论条、音标规则、来源判断、
|
|
5
|
+
* 网页模式要求…),但它们只是字符串常量,改动时**没有类型检查也没有编译错误**。
|
|
6
|
+
* 以前唯一的"验证"是手工看 PROMPTS.md,而那份快照会过期。这里把关键约束写成断言:
|
|
7
|
+
* 谁误删了,CI 直接红。
|
|
8
|
+
*
|
|
9
|
+
* 读取方式:优先直接 import src/prompts.ts(Node ≥ 22.14 的类型剥离支持直接加载 .ts,
|
|
10
|
+
* 与 GitHub Actions 的 Node 22.14 一致,无需编译步骤);如果运行环境的 Node 太旧、
|
|
11
|
+
* 不支持类型剥离,则回退到"读源码文本 + 解析数组字面量",保证测试在任何 Node 上都能跑。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { resolve, dirname } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const PROMPTS_TS = resolve(HERE, '..', 'src', 'prompts.ts')
|
|
19
|
+
|
|
20
|
+
const NAMES = [
|
|
21
|
+
'PROMPT_HEAD',
|
|
22
|
+
'TRANSLATION_PROMPT',
|
|
23
|
+
'CODE_PROMPT',
|
|
24
|
+
'DETAIL_PROMPT',
|
|
25
|
+
'CHAT_SYSTEM_PROMPT',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** 回退方案:从源码文本里解析 `export const X = [ '...', ... ].join('\n')`。 */
|
|
29
|
+
function loadFromSource() {
|
|
30
|
+
const src = readFileSync(PROMPTS_TS, 'utf8')
|
|
31
|
+
const out = {}
|
|
32
|
+
for (const name of NAMES) {
|
|
33
|
+
const re = new RegExp(`export const ${name} = \\[([\\s\\S]*?)\\]\\.join\\('\\\\n'\\)`)
|
|
34
|
+
const m = src.match(re)
|
|
35
|
+
if (!m) throw new Error(`回退解析失败:找不到 ${name}`)
|
|
36
|
+
const body = m[1]
|
|
37
|
+
// 元素既可能是字符串字面量,也可能是别的常量(片段复用),后者需要展开
|
|
38
|
+
const parts = []
|
|
39
|
+
const reElem = /'((?:[^'\\]|\\.)*)'|([A-Z_]+)/g
|
|
40
|
+
let em
|
|
41
|
+
while ((em = reElem.exec(body)) !== null) {
|
|
42
|
+
if (em[1] !== undefined) parts.push(em[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
|
|
43
|
+
else parts.push(out[em[2]] ?? '')
|
|
44
|
+
}
|
|
45
|
+
out[name] = parts.join('\n')
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let P
|
|
51
|
+
let loader = 'import src/prompts.ts(Node 类型剥离)'
|
|
52
|
+
try {
|
|
53
|
+
P = await import(PROMPTS_TS)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
loader = `源码文本解析回退(类型剥离不可用:${error && error.message})`
|
|
56
|
+
P = loadFromSource()
|
|
57
|
+
}
|
|
58
|
+
const { CHAT_SYSTEM_PROMPT, CODE_PROMPT, DETAIL_PROMPT, PROMPT_HEAD, TRANSLATION_PROMPT } = P
|
|
59
|
+
console.log(`提示词来源:${loader}\n`)
|
|
60
|
+
|
|
61
|
+
let failed = 0
|
|
62
|
+
let passed = 0
|
|
63
|
+
const assert = (label, ok, extra) => {
|
|
64
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
65
|
+
if (ok) passed += 1
|
|
66
|
+
else failed += 1
|
|
67
|
+
}
|
|
68
|
+
const has = (text, ...needles) => needles.every((n) => text.includes(n))
|
|
69
|
+
|
|
70
|
+
// ───────────────────────── 公共前言 ─────────────────────────
|
|
71
|
+
assert('人设:跨领域解读专家(含领域判断要求)', has(PROMPT_HEAD, '跨领域', '先判断【选中文字】实际属于哪个领域'))
|
|
72
|
+
assert('人设:领域判断是内部动作,不许写出来', has(PROMPT_HEAD, '不要把判断过程写出来'))
|
|
73
|
+
assert('人设:说明用户消息的形态(会话背景 + 选中文字)', has(PROMPT_HEAD, '【会话背景】', '【选中文字】'))
|
|
74
|
+
|
|
75
|
+
// ───────────────────────── 翻译 / 解读(首轮)─────────────────────────
|
|
76
|
+
assert('翻译提示词拼进了公共前言', TRANSLATION_PROMPT.startsWith(PROMPT_HEAD))
|
|
77
|
+
assert(
|
|
78
|
+
'翻译:只输出一个章节(不许前言结语)',
|
|
79
|
+
has(TRANSLATION_PROMPT, '本轮只输出一个章节', '不要输出其它章节', '不要前言与结语'),
|
|
80
|
+
)
|
|
81
|
+
assert(
|
|
82
|
+
'结构契约:含英文 → 标题 `## 翻译`',
|
|
83
|
+
has(TRANSLATION_PROMPT, '## 翻译'),
|
|
84
|
+
'客户端按此标题切分渲染,改标题会同时破坏渲染',
|
|
85
|
+
)
|
|
86
|
+
assert(
|
|
87
|
+
'结构契约:全中文 → 标题 `## 解读`',
|
|
88
|
+
has(TRANSLATION_PROMPT, '## 解读'),
|
|
89
|
+
)
|
|
90
|
+
assert(
|
|
91
|
+
'结论条:固定写成引用行「在本句中:」',
|
|
92
|
+
has(TRANSLATION_PROMPT, '> 在本句中:'),
|
|
93
|
+
'面板据此渲染成高亮结论条,格式不能丢',
|
|
94
|
+
)
|
|
95
|
+
assert(
|
|
96
|
+
'音标规则:单词要标 IPA,且拿不准宁可不标',
|
|
97
|
+
has(TRANSLATION_PROMPT, '音标', 'IPA') && /拿不准就不写|宁可不标/.test(TRANSLATION_PROMPT),
|
|
98
|
+
)
|
|
99
|
+
assert(
|
|
100
|
+
'词义规则:词性与义项缩进两个空格作为子项',
|
|
101
|
+
has(TRANSLATION_PROMPT, '缩进两个空格'),
|
|
102
|
+
)
|
|
103
|
+
assert(
|
|
104
|
+
'语言自适应:按"有没有需要翻译的英文"分情形,只出一种',
|
|
105
|
+
has(TRANSLATION_PROMPT, '有没有需要翻译的英文') && has(TRANSLATION_PROMPT, '情形一', '情形二'),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// ───────────────────────── 代码注释(首轮)─────────────────────────
|
|
109
|
+
assert('代码提示词拼进了公共前言', CODE_PROMPT.startsWith(PROMPT_HEAD))
|
|
110
|
+
assert('结构契约:代码模式标题 `## 注释`', has(CODE_PROMPT, '## 注释'))
|
|
111
|
+
assert(
|
|
112
|
+
'代码模式:原代码逐字不变、注释插在语句前',
|
|
113
|
+
has(CODE_PROMPT, '原代码') && /逐字不变|原样/.test(CODE_PROMPT),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// ───────────────────────── 详解(第二阶段)─────────────────────────
|
|
117
|
+
assert('详解提示词拼进了公共前言', DETAIL_PROMPT.startsWith(PROMPT_HEAD))
|
|
118
|
+
assert(
|
|
119
|
+
'详解:只输出 `## 详解` 一节',
|
|
120
|
+
has(DETAIL_PROMPT, '## 详解', '不要输出「## 翻译」或其它章节'),
|
|
121
|
+
)
|
|
122
|
+
assert(
|
|
123
|
+
'详解:要求先判断这段文字的来源(真实内容 vs 示例/测试/引用)',
|
|
124
|
+
has(DETAIL_PROMPT, '真实对话/正文', '示例', '测试用例', '演示输出', '引用别人的话'),
|
|
125
|
+
'早期缺这条时,示例句会被当成真实陈述解释',
|
|
126
|
+
)
|
|
127
|
+
assert('详解:单词仍带音标', has(DETAIL_PROMPT, '音标', 'IPA'))
|
|
128
|
+
|
|
129
|
+
// ───────────────────────── 追问(小窗对话)─────────────────────────
|
|
130
|
+
assert(
|
|
131
|
+
'追问:不要求两节结构,问什么答什么',
|
|
132
|
+
has(CHAT_SYSTEM_PROMPT, '用户问什么就答什么', '不必把话题拉回选中文字'),
|
|
133
|
+
)
|
|
134
|
+
assert(
|
|
135
|
+
'追问:不许重复前情已给的翻译/详解',
|
|
136
|
+
has(CHAT_SYSTEM_PROMPT, '不用重复', '一句话带过'),
|
|
137
|
+
)
|
|
138
|
+
assert(
|
|
139
|
+
'追问:网页模式要给完整单文件 HTML(内联样式与脚本)',
|
|
140
|
+
has(CHAT_SYSTEM_PROMPT, '完整的单文件 HTML', '```html', '样式和脚本都内联'),
|
|
141
|
+
'小窗会当场渲染成可交互页面,只给片段就渲染不出来',
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// ───────────────────────── 卫生检查(防低级事故)─────────────────────────
|
|
145
|
+
const ALL = [PROMPT_HEAD, TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].join('\n')
|
|
146
|
+
assert(
|
|
147
|
+
'没有未替换的占位符({{…}} / ${…} / TODO)',
|
|
148
|
+
!/\{\{[^}]*\}\}|\$\{[^}]*\}|TODO/.test(ALL),
|
|
149
|
+
)
|
|
150
|
+
assert(
|
|
151
|
+
'没有残留的调试口吻(console.log / debugger)',
|
|
152
|
+
!/console\.log|debugger/.test(ALL),
|
|
153
|
+
)
|
|
154
|
+
assert(
|
|
155
|
+
'提示词不是空串(防止整体被清空)',
|
|
156
|
+
ALL.length > 2000,
|
|
157
|
+
`总长 ${ALL.length}`,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// ───────────────────────── 防复活:已弃用的规则不得重新接回 ─────────────────────────
|
|
161
|
+
// TWO_LAYER_RULE(「专业 + 说人话、别套模板」的写作规则)**已弃用**:提示词瘦身时
|
|
162
|
+
// 把"别写成同一个模子"那五条写法砍掉了,只留两条硬约束(只出 `## 详解` 一节 + 音标)。
|
|
163
|
+
// 它曾作为常量残留(只定义、不使用),已于 2026-09-24 删除。
|
|
164
|
+
// 这条断言防止有人把那段文字重新塞回提示词里 —— 那是被明确废弃的设计。
|
|
165
|
+
{
|
|
166
|
+
const resurrected = [TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].some((p) =>
|
|
167
|
+
p.includes('别写成同一个模子') || p.includes('**写法:内容有要求,形式随内容走。**'),
|
|
168
|
+
)
|
|
169
|
+
assert(
|
|
170
|
+
'已弃用的「两层写法规则」没有被重新塞回提示词',
|
|
171
|
+
!resurrected,
|
|
172
|
+
resurrected ? '检测到已废弃规则的原文,请确认是否有意恢复该设计' : '符合当前设计(该规则已弃用并删除)',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`\n=== 提示词契约测试结束:${passed} 通过 / ${failed} 失败 ===`)
|
|
177
|
+
if (failed > 0) process.exitCode = 1
|
|
178
|
+
process.exit(process.exitCode ?? 0)
|