@yfwu2020/dsh-selection-explain 0.1.0 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yfwu2020/dsh-selection-explain",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -14,19 +14,37 @@
14
14
  "README.md"
15
15
  ],
16
16
  "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/yfwu2020/dsh-selection-explain.git"
20
+ },
21
+ "homepage": "https://github.com/yfwu2020/dsh-selection-explain#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/yfwu2020/dsh-selection-explain/issues"
24
+ },
17
25
  "peerDependencies": {
18
- "@deepseek-ai/cordis": ">=4 <5",
19
- "@deepseek-ai/dsh-agent-default-model": ">=0.0.1-rc <2",
20
- "@deepseek-ai/dsh-host-webserver": ">=0.0.1-rc <2",
21
- "@deepseek-ai/dsh-llm": ">=0.0.1-rc <2",
22
- "@deepseek-ai/schemastery": ">=3 <4"
26
+ "@deepseek-ai/cordis": ">=4.0.2 <5",
27
+ "@deepseek-ai/dsh-agent-default-model": ">=0.1.5-rc <2",
28
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.5-rc <2",
29
+ "@deepseek-ai/dsh-llm": ">=0.1.5-rc <2",
30
+ "@deepseek-ai/schemastery": ">=3.18 <4"
31
+ },
32
+ "devDependencies": {
33
+ "@deepseek-ai/cordis": "4.0.2",
34
+ "@deepseek-ai/dsh-agent-default-model": "0.1.5-rc.3",
35
+ "@deepseek-ai/dsh-host-webserver": "0.1.5-rc.3",
36
+ "@deepseek-ai/dsh-llm": "0.1.5-rc.3",
37
+ "@deepseek-ai/schemastery": "3.18.2",
38
+ "@types/node": "^22.14.0",
39
+ "typescript": "^5.9.3"
23
40
  },
24
41
  "scripts": {
25
42
  "build": "bash scripts/build.sh",
26
43
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
- "test": "node scripts/test-filters.mjs && node scripts/test-client.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs",
44
+ "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-client.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs",
28
45
  "test:transcript": "node scripts/test-transcript.mjs",
29
- "test:guard": "node scripts/test-guard.mjs"
46
+ "test:guard": "node scripts/test-guard.mjs",
47
+ "test:prompt": "node scripts/test-prompt.mjs"
30
48
  },
31
49
  "exports": {
32
50
  ".": {
package/scripts/build.sh CHANGED
@@ -23,6 +23,11 @@ probe() {
23
23
 
24
24
  find_runtime() {
25
25
  local found=""
26
+ # 0) 项目自己的 node_modules —— CI(GitHub Actions)走这条路:
27
+ # npm install 会把 peerDependencies(@deepseek-ai/dsh-llm 等)装进来,
28
+ # 没有这一步的话干净环境必然探测失败。
29
+ found="$(probe "$ROOT/node_modules" || true)"
30
+ if [ -n "$found" ]; then echo "$found"; return; fi
26
31
  # 1) 显式指定
27
32
  if [ -n "${DSH_CHECKOUT:-}" ]; then
28
33
  found="$(probe "$DSH_CHECKOUT" || true)"
@@ -66,15 +71,32 @@ fi
66
71
  echo "=== DSH 运行时:$RUNTIME ==="
67
72
 
68
73
  # ── ② 链接 @deepseek-ai / @types(整层 scope 链接,类型与运行时共用) ──
74
+ # 两种情况必须区分:
75
+ # · 本机开发:node_modules 里没有这些包,链接到 DSH 运行时(原来唯一的行为)
76
+ # · CI(npm install 过):peerDependencies / devDependencies 已经装成**真实目录**,
77
+ # 此时绝不能动它 —— 否则会把 node_modules/@deepseek-ai 链成它自己(自引用死循环,
78
+ # probe 随后报 "Too many levels of symbolic links",node_modules 被破坏)。
69
79
  node -e '
70
80
  const fs = require("fs")
71
81
  const path = require("path")
72
82
  const runtime = process.argv[1]
73
83
  const root = process.argv[2]
84
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return "" } }
74
85
  for (const name of ["@deepseek-ai", "@types"]) {
75
86
  const target = path.join(runtime, name)
76
87
  if (!fs.existsSync(target)) continue
77
88
  const link = path.join(root, "node_modules", name)
89
+ const linkReal = real(link)
90
+ // 已是真实目录(非符号链接)→ 保留,npm 装的才是权威版本
91
+ if (linkReal && !fs.lstatSync(link).isSymbolicLink()) {
92
+ console.log("kept node_modules/" + name + "(真实目录,不链接)")
93
+ continue
94
+ }
95
+ // 已经指向同一个地方 → 无需重建
96
+ if (linkReal && linkReal === real(target)) {
97
+ console.log("ok node_modules/" + name + "(已指向运行时)")
98
+ continue
99
+ }
78
100
  fs.rmSync(link, { recursive: true, force: true })
79
101
  fs.mkdirSync(path.dirname(link), { recursive: true })
80
102
  fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir")
@@ -83,10 +105,14 @@ for (const name of ["@deepseek-ai", "@types"]) {
83
105
  ' "$RUNTIME" "$ROOT"
84
106
 
85
107
  # ── ③ 编译 host 半 ──
86
- TSC="$(command -v tsc || true)"
87
- if [ -z "$TSC" ] && [ -x "$RUNTIME/node_modules/.bin/tsc" ]; then TSC="$RUNTIME/node_modules/.bin/tsc"; fi
108
+ # tsc 查找顺序:项目自己的 node_modules/.bin(CI 走这条,devDependencies 里已声明)
109
+ # → PATH 上的全局 tsc → DSH 运行时里的 tsc。
110
+ TSC=""
111
+ if [ -x "$ROOT/node_modules/.bin/tsc" ]; then TSC="$ROOT/node_modules/.bin/tsc"; fi
112
+ if [ -z "$TSC" ]; then TSC="$(command -v tsc || true)"; fi
113
+ if [ -z "$TSC" ] && [ -x "$RUNTIME/.bin/tsc" ]; then TSC="$RUNTIME/.bin/tsc"; fi
88
114
  if [ -z "$TSC" ]; then
89
- echo "build: 找不到 tsc(npm i -g typescript 或设置 DSH_CHECKOUT)" >&2
115
+ echo "build: 找不到 tsc —— 在项目里装一个(npm i -D typescript)或设置 DSH_CHECKOUT" >&2
90
116
  exit 1
91
117
  fi
92
118
  echo "=== tsc $("$TSC" -v) ==="
@@ -903,15 +903,32 @@ assert('弹窗显示选中文字', !!panel && textOf(panel).indexOf('the migrati
903
903
 
904
904
  const hook = windowStub.__dshSelectionExplain
905
905
  assert('自检钩子可用', !!hook && typeof hook.state === 'function')
906
- const ping = await hook.ping()
907
- assert('host ping 可达', !!ping && ping.ok === true, JSON.stringify(ping && ping.route))
908
- assert('请求载荷携带当前会话 id', sent.length > 0 && sent[0].sessionId === 'session-stub-1', JSON.stringify(sent[0] && { sessionId: sent[0].sessionId, label: sent[0].label }))
909
- assert('载荷不带 effort(推理档位固定在 host 配置)', sent[0] && sent[0].effort === undefined, String(sent[0] && sent[0].effort))
910
- assert(
911
- '三档档位都能从 /ping 读到',
912
- !!ping && !!ping.reasoningEffortByStage && typeof ping.reasoningEffortByStage.translation === 'string' && typeof ping.reasoningEffortByStage.detail === 'string' && typeof ping.reasoningEffortByStage.chat === 'string',
913
- JSON.stringify(ping && ping.reasoningEffortByStage),
914
- )
906
+
907
+ // ── 宿主可达性探测 ──
908
+ // 本脚本的一部分断言是**真实 host 集成测试**(连 ORIGIN 上的 /ping 与 SSE /analyze)。
909
+ // 本地开发时 host 在跑,这些断言全跑;CI(GitHub Actions)里没有宿主,
910
+ // 此时**跳过**在线断言并打印说明,而不是抛 ECONNREFUSED 崩掉整条发布流水线。
911
+ let HOST_UP = false
912
+ let ping = null
913
+ try {
914
+ ping = await hook.ping()
915
+ HOST_UP = !!(ping && ping.ok === true)
916
+ } catch (error) {
917
+ HOST_UP = false
918
+ }
919
+ if (HOST_UP) {
920
+ assert('host ping 可达', !!ping && ping.ok === true, JSON.stringify(ping && ping.route))
921
+ assert('请求载荷携带当前会话 id', sent.length > 0 && sent[0].sessionId === 'session-stub-1', JSON.stringify(sent[0] && { sessionId: sent[0].sessionId, label: sent[0].label }))
922
+ assert('载荷不带 effort(推理档位固定在 host 配置)', sent[0] && sent[0].effort === undefined, String(sent[0] && sent[0].effort))
923
+ assert(
924
+ '三档档位都能从 /ping 读到',
925
+ !!ping && !!ping.reasoningEffortByStage && typeof ping.reasoningEffortByStage.translation === 'string' && typeof ping.reasoningEffortByStage.detail === 'string' && typeof ping.reasoningEffortByStage.chat === 'string',
926
+ JSON.stringify(ping && ping.reasoningEffortByStage),
927
+ )
928
+ } else {
929
+ console.log(`SKIP host 不可达(${ORIGIN})—— 跳过在线断言(/ping 可达性、SSE 流式渲染、面板交互)`)
930
+ console.log('SKIP 本地开发想跑全量:先启动 DSH(默认 http://127.0.0.1:3080)再执行本脚本')
931
+ }
915
932
  {
916
933
  // 默认值断言走源码(不依赖 3080 上那份部署配置改没改)
917
934
  const hostSrc = readFileSync(resolve(HERE, '..', 'src', 'index.ts'), 'utf8')
@@ -959,6 +976,10 @@ assert(
959
976
  )
960
977
  }
961
978
 
979
+ // ── 以下全部是真实 host 集成断言(SSE 流式渲染 / 面板交互 / 历史 / 清理)──
980
+ // CI 里没有宿主,跳过;本地开发有宿主时全跑。
981
+ if (HOST_UP) {
982
+
962
983
  const deadline = Date.now() + 90000
963
984
  while (Date.now() < deadline) {
964
985
  const state = hook.state()
@@ -1973,9 +1994,20 @@ assert(
1973
1994
  )
1974
1995
  assert('标题文字完整(未被截断成省略号的短写)', textOf(rowParts[0]).indexOf('缓存探测词') >= 0, textOf(rowParts[0]))
1975
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
1976
2003
  targetRow.dispatch('click', { stopPropagation() {} })
1977
2004
  await sleep(80)
1978
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
+ )
1979
2011
  assert('点完之后列表收起', histList.style.display === 'none', String(histList.style.display))
1980
2012
 
1981
2013
  // Esc:一次到位 —— 面板与侧边栏一起收(原先"第一下只收列表"那一级已按需求合并)
@@ -2484,3 +2516,12 @@ for (const dispose of disposers.reverse()) {
2484
2516
  assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
2485
2517
  assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
2486
2518
  console.log('\n=== 客户端集成测试结束 ===')
2519
+
2520
+ } else {
2521
+ console.log('\n=== 客户端集成测试结束(离线模式:仅跑了不依赖宿主的断言)===')
2522
+ console.log(` 跳过原因:${ORIGIN} 不可达。完整验证请在 DSH 运行中执行本脚本。`)
2523
+ }
2524
+
2525
+ // 显式退出:离线分支跳过了在线部分的 dispose(),客户端留下的定时器/监听会让
2526
+ // 事件循环一直空转(表现为"测试跑完但不退出")。按 process.exitCode 正常退出。
2527
+ process.exit(process.exitCode ?? 0)
@@ -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)
@@ -2953,11 +2953,15 @@ window.__ModuleLoader__.load({
2953
2953
  }
2954
2954
 
2955
2955
  /**
2956
- * 取一条历史回放。anchor 省略时**默认贴着胶囊上方**——
2957
- * 以前这里只调 keepInsideViewport(),面板从没被摆过位置,
2958
- * 于是"就绪状态点胶囊"(内存里没有小窗、走历史回放)会跑到屏幕左上角。
2956
+ * 取一条历史回放。anchor 省略时:
2957
+ * · 从「最近」列表里点一条 → **面板不挪位置**:列表本来就贴着面板摆,
2958
+ * 把面板重新锚到胶囊上会让人眼看着面板"跳到右下角胶囊上方"(实测反馈的 bug)。
2959
+ * · 就绪状态点胶囊回放 → 锚到胶囊上方;否则面板从没被摆过位置,会跑到屏幕左上角。
2960
+ *
2961
+ * 注意:`applyHistoryEntry` 内部会收起列表,所以"是否从列表进入"必须在调用**之前**判断。
2959
2962
  */
2960
2963
  function loadHistoryEntry(key, anchor) {
2964
+ var fromList = historyList.style.display === 'flex'
2961
2965
  fetch(HISTORY + '?key=' + encodeURIComponent(key))
2962
2966
  .then(function (response) {
2963
2967
  return response.json()
@@ -2968,7 +2972,9 @@ window.__ModuleLoader__.load({
2968
2972
  panel.style.display = 'flex'
2969
2973
  applyHistoryEntry(data.entry, 'history')
2970
2974
  // 先画内容再摆位置:showPanel 要量面板高度才能决定放上面还是下面
2971
- showPanel(anchor || rectOfPill())
2975
+ if (!fromList) showPanel(anchor || rectOfPill())
2976
+ // 内容换了、面板高度可能变 → 列表跟着重摆(列表此时已被 applyHistoryEntry 收起,按需再开)
2977
+ if (historyList.style.display === 'flex') placeHistoryList()
2972
2978
  })
2973
2979
  .catch(function () {
2974
2980
  /* noop */
package/src/index.ts CHANGED
@@ -23,6 +23,15 @@ import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
23
23
  import { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
24
24
  import type { ContentBlock, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
25
25
  import z from '@deepseek-ai/schemastery'
26
+ // 提示词与写作规则(纯文本常量)单独成模块:本文件是 2400+ 行的 host 全量逻辑,
27
+ // 提示词放这里既难找也容易误改。组装逻辑仍在下方(按 stage / kind 选一套)。
28
+ import {
29
+ CHAT_SYSTEM_PROMPT,
30
+ CODE_PROMPT,
31
+ DETAIL_PROMPT,
32
+ PROMPT_HEAD,
33
+ TRANSLATION_PROMPT,
34
+ } from './prompts.js'
26
35
 
27
36
  /** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
28
37
  export const name = '@yfwu2020/dsh-selection-explain'
@@ -162,139 +171,6 @@ function resolveToolNames(preferred: string[], fallback: string[], visible: stri
162
171
  return out
163
172
  }
164
173
 
165
- /** 系统提示词:固定文本,两节结构,前端按标题切分。 */
166
- /** 公共前言与尾部规则(两套提示词共用,避免重复维护)。 */
167
- const PROMPT_HEAD = [
168
- '你是一位跨领域的资深解读专家,覆盖中英双语语言学、软件工程与系统架构、数据与算法、产品与交互设计。',
169
- '先判断【选中文字】实际属于哪个领域,再按该领域资深从业者的标准解读:',
170
- '术语取该领域的准确定义(必要时补一句通用含义做对照),关注点也对齐该领域的关注点——',
171
- '代码看行为、边界与副作用,架构看约束与取舍,设计看目标与权衡,语言看语域、搭配与言外之意。',
172
- '领域判断是内部动作:**不要把判断过程写出来**,也不要写「这属于工程领域」这类交代,直接给解读。',
173
- '',
174
- '用户消息可能是:先给一段【会话背景】(可选,最前面),最后给出要解读的【选中文字】及其上下文。',
175
- ].join('\n')
176
-
177
- /**
178
- * 「总结 / 详解」的表达要求:既要专业,也要说人话。
179
- *
180
- * 只讲术语等于没解释(换个说法还是不懂),只打比方又拿不去用(不知道确切边界)——
181
- * 所以**内容上两层都要在**。但**形式上不设模板**:不要求小标题、不要求固定句式、
182
- * 不要求先后顺序,由内容自己决定怎么说最自然;否则每段都长成同一个模子,读起来很僵。
183
- */
184
- const TWO_LAYER_RULE = [
185
- '**写法:内容有要求,形式随内容走。**',
186
- '- 要让人看懂:出现术语、缩写、行话,就用日常话把它说清楚——',
187
- ' 顺在句子里讲明白就行,不必另起一句、也不必加小标题分栏。',
188
- '- 反过来,只给了比方或感觉,就把准确的说法和边界补上(叫什么、在什么前提下成立、到什么程度)。',
189
- '- 专业体现在**说得准**,不是用词生僻:术语用对、该给英文原词或全称就给、',
190
- ' 该有的限定不能省;也不要同义反复(「是指……的一种……」)或用术语解释术语。',
191
- '- **别写成同一个模子**:不必每段都按同一个顺序铺陈,也不必凑够三条要点;',
192
- ' 一句能说清就一句,需要展开再展开;不要为整齐而分点,不要用「第一/第二」这类序号。',
193
- ].join('\n')
194
-
195
- /** 只输出「翻译」一节时用。 */
196
- const TRANSLATION_PROMPT = [
197
- PROMPT_HEAD,
198
- '',
199
- '**本轮只输出一个章节**(标题见下),不要输出其它章节,不要前言与结语。',
200
- '先看【选中文字】里**有没有需要翻译的英文**,据此决定标题与内容(判断过程不要写出来):',
201
- '',
202
- '### 情形一:选中文字里有英文(含中英混排)→ 标题写 `## 翻译`',
203
- '- **以中文为主的句子**(可能夹着英文词):中文部分**一律不翻译**、也不要复述原句;',
204
- ' 只把【选中文字】**自己**含着的**英文**片段(由拉丁字母组成的单词、词组或整句)挑出来逐条译成',
205
- ' 中文,一个片段一条,且**层级分明**:',
206
- ' · 是**单词** → 父项写「词头 + 音标」(IPA,斜杠包起来),词性与义项**缩进两个空格**作为子项,',
207
- ' 每行以 `**n.**`、`**v.**` 这样的词性标记开头(**词性必须标**,一词多义就多行):',
208
- ' 形如 `- **migration** /maɪˈɡreɪʃn/`,下一行缩进写 `- **n.** 迁移;移居`;',
209
- ' · 是**词组或整句** → 一行 `- **原英文**:中文意思` 即可(不标音标、不拆词性)。',
210
- ' 音标拿不准就不写(缩写、专有名词尤其常见);宁可不标也不要编。',
211
- ' 中文词句即使有对应的英文说法(如「缓存层」「灰度」)也**不要列**,不要到上下文或标题里去找英文;',
212
- ' 写完自查:列出的每一条都必须①由英文字母构成、②能在【选中文字】里逐字找到,否则删掉。',
213
- '- **以英文为主**(整句、词组或单词都算):',
214
- ' · 句子或词组 → 译文**单独成段**,段落以译文的第一个字开头(长句可拆成 2~3 行;',
215
- ' 句中的中文词原样保留,关键术语用括号注明原词);',
216
- ' · 单词 → **两级结构,层级要看得出来**:',
217
- ' 第一行是**词头 + 音标**(IPA,斜杠包起来),它是父项:`- **migrate** /maɪˈɡreɪt/`;',
218
- ' 英式与美式读音不同就都写(`/英 …/ `/美 …/` 并列,标上「英」「美」),相同只写一个;拿不准就不写,不要编。',
219
- ' 下面**每个词性义项各占一行、缩进两个空格**作为子项,行首是 `**v.**` 这样的词性标记,',
220
- ' 后面跟中文义项,常用义项在前;词头行本身**不要再写义项**。',
221
- ' 形如:父项 `- **long** /英 lɒŋ/ /美 lɔːŋ/`,子项缩进两格 `- **adj.** 长的;远的`、`- **v.** 渴望,极想`。',
222
- '- **以中英之外的语言为主**(日、韩、法、德等):译成中文。',
223
- '',
224
- '### 情形二:选中文字**全是中文**(纯汉字的词、短语或句子)→ 标题写 `## 解读`',
225
- '- **不要翻译**:不译成英文,不给英文说法,也不列义项、不做词典式解释;',
226
- '- 只是把这段中文**在当前上下文里是什么意思**讲清楚,一两句就够,直接说结论。',
227
- '',
228
- '两种情形的**最后一行都固定**写成引用行,作为本节的总结(**这一行是本节的重点**):',
229
- '> 在本句中:<它在这段上下文里到底是什么意思>',
230
- '- 一行说清:用日常话讲明白,专业上的准确说法和必要限定顺在里面带出来即可,',
231
- ' 不用套固定句式、也不用刻意分两截;',
232
- '- **不要照抄原句**,不要写「可能」「取决于」:给确定答案;',
233
- '- 说清楚为止:该长就长、该短就短,不卡字数;也不要为凑字数把一行写成一段。',
234
- '',
235
- '规则:',
236
- '- 只处理选中文字,不要整段翻译「上下文」,也不要复述指令。',
237
- '- 正文里不要出现「翻译:」「中文句子:」这类分类标签,直接给内容。',
238
- '- 用简洁 Markdown:要点可以用「- 」,但别把什么都做成列表;强调用 **加粗**,别整段加粗;',
239
- ' 整段输出不要用代码块包裹。',
240
- '- 不要出现「以下是」「希望对你有所帮助」之类的客套话。',
241
- '- **不要叙述查资料的过程**("让我先搜一下""我再确认一下"这类一律不写);查到什么直接给结论,',
242
- ' 有链接就自然带在结论里。',
243
- ].join('\n')
244
-
245
- /** 选中文字是代码时用:代码块原样呈现 + 逐句注释 + 小结(首轮,只要快)。 */
246
- const CODE_PROMPT = [
247
- PROMPT_HEAD,
248
- '',
249
- '**本轮只输出「## 注释」这一节**,不要输出「## 详解」或其它章节,不要前言与结语:',
250
- '',
251
- '## 注释',
252
- '**整节只有一个代码块**(用 ``` 围起来,尽可能标上语言),**注释就写在代码块里面**,',
253
- '贴着它解释的那条语句,看起来像一份"带批注的代码清单":',
254
- '',
255
- '```',
256
- '// 这条语句在做什么(用该语言的注释符号)',
257
- '<原代码这一句>',
258
- '// 下一条语句在做什么',
259
- '<原代码下一句>',
260
- '```',
261
- '',
262
- '块内规则:',
263
- '- 原代码**逐字不变**:缩进、空行、注释、变量名全部保留;不要补全、不要精简、不要加行号、不要改写成伪代码。',
264
- '- 在**每一条语句/逻辑块之前**加一行注释,用**该语言的注释符号**(`//`、`#`、`--`、`<!-- -->`、`<!--` 等),',
265
- ' 注释紧贴代码、中间不插空行;注释的缩进跟着它解释的那行代码走。',
266
- '- **每一句都要覆盖**,不要跳句;讲「这行为什么这么写、和整体什么关系、有什么副作用或坑」,',
267
- ' 不要复述语言基础语法("这是一个 if 语句"这种不要写)。',
268
- '- 太长的语句可以在其后补一行注释,但不要撕裂代码本身。',
269
- '- 每条注释尽量一两句说完;说不完就换行再起一行注释(小窗很窄,一条注释太长会被折成好几行),不要写成一大段。',
270
- '',
271
- '代码块**外面**只留最后一行,固定写成引用行作为总结:',
272
- '> 小结:<这段代码整体在干什么、值得点出的地方>',
273
- '- 一行说清,话要顺:用日常话讲它在做什么,顺带点出关键路径、复杂度、副作用或坑;',
274
- ' 没什么可点就只说在做什么,别硬凑,也别把这一行拆成好几段;',
275
- '- 用自己的话讲,不要照抄代码,也不要复述块内的逐句注释;',
276
- '- 说清楚为止:该长就长、该短就短,不卡字数;也不要为整齐而拉长。',
277
- '',
278
- '规则:',
279
- '- 只处理【选中文字】这段代码;上下文只用来理解它,不要注释上下文里的其它代码。',
280
- '- 除了最后那一行小结,**不要在代码块外再写逐句注释**(注释已经在块内了)。',
281
- '- 用中文讲解;术语、API 名保留原文。',
282
- '- 不要出现「以下是」「希望对你有所帮助」之类的客套话。',
283
- ].join('\n')
284
-
285
- /** 只输出「详解」一节时用(第二阶段,带完整会话背景)。 */
286
- const DETAIL_PROMPT = [
287
- PROMPT_HEAD,
288
- '',
289
- '**本轮只输出「## 详解」这一节**,不要输出「## 翻译」或其它章节,不要前言与结语:',
290
- '',
291
- '## 详解',
292
- '选中的这段文字是什么意思?把意思讲透——既专业,也让人看得懂;该长就长、该短就短。',
293
- '(先看一眼它是怎么来的:真实对话/正文,还是示例、测试用例、演示输出、引用别人的话;',
294
- '是后者就先说一句,再按它实际的意思解释。)',
295
- '选中文字是单个英文单词时,带上音标(IPA;英式美式不同就都写,拿不准就不写)。',
296
- ].join('\n')
297
-
298
174
  /** 一次请求的入参。 */
299
175
  interface AnalyzeBody {
300
176
  text?: unknown
@@ -675,15 +551,6 @@ async function loadDesignSkill(): Promise<string> {
675
551
  }
676
552
  }
677
553
 
678
- /** 追问模式(对话小窗)的系统提示词:不再要求两节结构,就是围绕这段文字聊天。 */
679
- const CHAT_SYSTEM_PROMPT = [
680
- '你是 DeepSeek Harness 中「划词解读」小窗里的助手,正在辅助用户对一段【选中文字】及其上下文进行理解。',
681
- '用户问什么就答什么:可以问这段文字本身,也可以顺着它问到别的,不必把话题拉回选中文字。',
682
- '回答简洁、准确、具体;前情里已经给过的翻译/详解不用重复,引用时一句话带过即可。',
683
- '要画图、做可视化或写页面时,直接给**完整的单文件 HTML**(放进 ```html 代码块,样式和脚本都内联),',
684
- '小窗会把它当场渲染成可交互的网页;不要只给片段,也不要说"你可以把它保存成 .html 再打开"。',
685
- ].join('\n')
686
-
687
554
  /**
688
555
  * 粗略判断"是不是公网地址",口径与 dsh-web-fetch-http 的 SSRF 防护一致(非 unicast 一律拒绝)。
689
556
  *