@yfwu2020/dsh-selection-explain 0.1.2 → 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 +56 -10
- 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/package.json +2 -1
- package/scripts/build-demo-pages.mjs +296 -0
- package/scripts/crop-png.mjs +142 -0
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> 在 [DSH](https://github.com/deepseek-ai) Web 界面里**选中任意文字**,就地得到**专业翻译** + **这段文字在当前上下文里到底是什么意思**,还能接着追问、或一键升格成正式会话。
|
|
4
4
|
|
|
5
|
-
[](https://github.com/yfwu2020/dsh-selection-explain/releases)
|
|
6
|
+
[](https://www.npmjs.com/package/@yfwu2020/dsh-selection-explain)
|
|
6
7
|
[](./package.json)
|
|
7
8
|
[](https://github.com/yfwu2020/dsh-selection-explain)
|
|
8
9
|
|
|
@@ -24,6 +25,8 @@
|
|
|
24
25
|
|
|
25
26
|
点开面板**先只出翻译**(上下文窗口收窄到 8 条消息,首字最快);翻译出来后才出现 **`↓ 展开详解`**,点了才用完整会话背景(24 条消息)做深度解读。想要快就停在第一段,想要透就展开——**不强迫你为深度等首字**。
|
|
26
27
|
|
|
28
|
+
<img src="assets/stage1.png" alt="首轮:只有翻译一节 + 展开详解按钮" width="540">
|
|
29
|
+
|
|
27
30
|
### 翻译:按选中内容的语言和形态自适应
|
|
28
31
|
|
|
29
32
|
不是无脑翻译,而是先判断这段文字是什么,只给对应的那一种:
|
|
@@ -49,6 +52,8 @@
|
|
|
49
52
|
|
|
50
53
|
如果选中的是代码(在 `<pre>/<code>` 里,或文本特征像代码),首轮标题变成「**注释**」:整个回答是一个代码块,里面是**注释 + 原代码交替**的批注清单——每条语句**前面**一行该语言的注释符号(`//`、`#`、`--`、`<!--`…),原代码逐字不变。注释行弱化显示,一眼分得清哪行是代码、哪行是批注。缩进按原行起始列对齐,折行也不会跑到注释左边。
|
|
51
54
|
|
|
55
|
+
<img src="assets/code.png" alt="选中代码:注释与原代码交替的批注清单" width="540">
|
|
56
|
+
|
|
52
57
|
### 临时对话小窗
|
|
53
58
|
|
|
54
59
|
翻译一出来就能直接追问,不必先展开详解:
|
|
@@ -59,6 +64,30 @@
|
|
|
59
64
|
- 生成中可以点**停止**打断;没有可用结果时给「重新生成」。
|
|
60
65
|
- 关掉面板**不会丢**:点右下角胶囊原样回来,不重新请求。
|
|
61
66
|
|
|
67
|
+
<img src="assets/stage2.png" alt="展开详解后:翻译与详解两节卡片,下方可继续追问" width="540">
|
|
68
|
+
|
|
69
|
+
### 输出偏好:Markdown ↔ 网页
|
|
70
|
+
|
|
71
|
+
输入区左下角那个两格开关(`M↓` = Markdown / `▭` = 网页)决定**追问的回答用什么形式**。默认是 **Markdown**,选择记在浏览器本地,下次打开还是它。
|
|
72
|
+
|
|
73
|
+
| 档位 | 行为 |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| **Markdown**(默认) | 普通文字 / Markdown 回答——标题、列表、表格、代码块都可以。**并不禁止 HTML**:如果某个问题用一页网页明显讲得更清楚,照样会给网页;只是不会为了"好看"把简单问题做成页面 |
|
|
76
|
+
| **网页** | **网页优先**(不是"一律网页"):需要对比、流程、层级、图表,或文字答案会长到不好读 → 直接给**完整的单文件 HTML**,页面本身就是答案;一两段话能说清的简单问题**仍然用文字回答** |
|
|
77
|
+
|
|
78
|
+
<img src="assets/web.png" alt="网页模式:回答里嵌一张可交互的页面,带「预览 / 源码」切换" width="540">
|
|
79
|
+
|
|
80
|
+
**网页输出的优点**(也是它值得单独一档的原因):
|
|
81
|
+
|
|
82
|
+
- **复杂信息能"看"而不是"读"**:三种方案的取舍做成对照表 + 条形图,一眼看出差别;文字版要读三段才建立同样的印象。
|
|
83
|
+
- **页面就是答案,不是附件**:生成的是**完整单文件 HTML**(样式与脚本全内联),小窗当场渲染成可交互页面——能悬停、能点、能滚,不用保存成文件再打开。
|
|
84
|
+
- **两个视图随时切**:回答上方有 `预览 / 源码` 两个页签,切到源码就能把 HTML 拿走。
|
|
85
|
+
- **默认就整页展示**:预览高度**由页面内容决定**(内部不滚动),所以一页对比表能完整看下来;想收起来点右侧 **`⤡ 还原`**,回到固定高度(320px、内部滚动)。
|
|
86
|
+
- **有设计规范兜底**:开网页模式时,插件会把自己带的 `skills/web-design/SKILL.md` 注入提示词——按小窗**约 500px 宽的窄容器**写的单列布局规范,避免生成那种"三栏卡片 + 渐变大标题"的通用 AI 模板感。(该文件读不到时有一份内置兜底,不会因此失败。)
|
|
87
|
+
- **回答仍然可追问**:网页结果会作为前情带回下一轮,可以继续说"把第二列改成时间线"。
|
|
88
|
+
|
|
89
|
+
**什么时候别用网页**:只想要一段解释、或要的是可以直接复制的文本时,Markdown 更快也更省 token——网页要生成一整份 HTML,等待时间和输出长度都更大。
|
|
90
|
+
|
|
62
91
|
### 「最近聊过的」历史
|
|
63
92
|
|
|
64
93
|
面板头部 `🕘 最近` 打开独立浮层列表(贴面板侧边,空间不够自动翻到另一侧)。重划同一个词**直接回放上次的对话,一次模型都不调**。默认保留最近 20 个划词条目(每个条目含它自己的全部追问),LRU 淘汰,**升格过的条目不会被淘汰**。
|
|
@@ -77,6 +106,15 @@
|
|
|
77
106
|
|
|
78
107
|
面板自带 Markdown 渲染器(段落、多级列表、小标题、表格、引用、代码块)和**语法高亮 tokenizer**(自研,零依赖):注释 / 字符串 / 数字 / 关键字 / 函数名 / 类型 / 标签 / 属性 / 变量 / 运算符分色,按围栏语言标记选规则。配色跟随**面板实际底色**判定,不是看系统偏好——所以 App 深色 + 系统浅色也不会出现深底配深字。文字对比度实测全部 ≥ WCAG AA 4.5。
|
|
79
108
|
|
|
109
|
+
<!--
|
|
110
|
+
上面几张演示图的来源(要换图看这里):
|
|
111
|
+
· 图由 scripts/build-demo-pages.mjs 生成演示页 —— 样式直接从 lib/client.js 抽取真实 CSS,
|
|
112
|
+
DOM 结构与文案照 src/client/index.js 的渲染器写,所以和真实面板一致(非手绘示意图)。
|
|
113
|
+
· 截图:Chrome 无头(--headless --screenshot --force-device-scale-factor=2)
|
|
114
|
+
· 裁边:node scripts/crop-png.mjs <in.png> assets/<name>.png 24
|
|
115
|
+
· 想换成真实环境截图,直接用同名文件覆盖 assets/ 下的图片即可。
|
|
116
|
+
-->
|
|
117
|
+
|
|
80
118
|
---
|
|
81
119
|
|
|
82
120
|
## 安装
|
|
@@ -93,21 +131,26 @@ dsh plugin --profile web add @yfwu2020/dsh-selection-explain
|
|
|
93
131
|
|
|
94
132
|
### 方式二:从 Release 安装
|
|
95
133
|
|
|
96
|
-
每个 Release 都附了**构建好的 `.tgz`**(含 `lib/` 产物,装完即可用,无需 clone
|
|
134
|
+
每个 Release 都附了**构建好的 `.tgz`**(含 `lib/` 产物,装完即可用,无需 clone 源码)。
|
|
135
|
+
用 `latest` 路径可以不写版本号、永远取最新:
|
|
97
136
|
|
|
98
137
|
```bash
|
|
99
|
-
# 下载最新 release
|
|
100
|
-
curl -
|
|
138
|
+
# 下载最新 release 的安装包(无需知道版本号)
|
|
139
|
+
VER=$(curl -sI https://github.com/yfwu2020/dsh-selection-explain/releases/latest | grep -i '^location:' | sed 's|.*/tag/v||' | tr -d '\r\n')
|
|
140
|
+
curl -LO "https://github.com/yfwu2020/dsh-selection-explain/releases/download/v$VER/yfwu2020-dsh-selection-explain-$VER.tgz"
|
|
101
141
|
|
|
102
142
|
# 解包到插件目录
|
|
103
143
|
mkdir -p ~/.dsh/plugins/dsh-selection-explain
|
|
104
|
-
tar -xzf yfwu2020-dsh-selection-explain
|
|
144
|
+
tar -xzf "yfwu2020-dsh-selection-explain-$VER.tgz" \
|
|
105
145
|
-C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
|
|
106
146
|
|
|
107
147
|
# 装配到 profile(重启后由官方接管)
|
|
108
148
|
dsh plugin --profile web add ~/.dsh/plugins/dsh-selection-explain
|
|
109
149
|
```
|
|
110
150
|
|
|
151
|
+
> 也可以直接下某个固定版本,把上面的 `$VER` 换成版本号即可,例如
|
|
152
|
+
> `.../releases/download/v0.1.2/yfwu2020-dsh-selection-explain-0.1.2.tgz`。
|
|
153
|
+
|
|
111
154
|
### 方式三:从源码安装
|
|
112
155
|
|
|
113
156
|
```bash
|
|
@@ -277,13 +320,14 @@ curl -s http://127.0.0.1:3080/selection-explain/api/ping
|
|
|
277
320
|
|
|
278
321
|
```bash
|
|
279
322
|
npm run build # 构建(= bash scripts/build.sh)
|
|
280
|
-
npm test #
|
|
323
|
+
npm test # 489 条断言(filters / prompt / client / transcript / guard)
|
|
281
324
|
npm run typecheck # tsc --noEmit
|
|
282
325
|
```
|
|
283
326
|
|
|
284
327
|
| 脚本 | 用途 |
|
|
285
328
|
| --- | --- |
|
|
286
|
-
| `scripts/test-client.mjs` | 无浏览器集成测试:真实 host 路由 + 最小 DOM 桩,覆盖槽注册 → 划词浮标 → 点击 → SSE 流式渲染 → 两节内容 →
|
|
329
|
+
| `scripts/test-client.mjs` | 无浏览器集成测试:真实 host 路由 + 最小 DOM 桩,覆盖槽注册 → 划词浮标 → 点击 → SSE 流式渲染 → 两节内容 → 清理(host 不在跑时自动跳过在线断言) |
|
|
330
|
+
| `scripts/test-prompt.mjs` | **提示词契约测试**:把两节结构标题、结论条格式、音标规则、详解来源判断、追问网页模式要求等关键约束固化成断言,误删即 CI 红 |
|
|
287
331
|
| `scripts/test-filters.mjs` | host 侧流式过滤器单测(思考泄漏、工具调用残渣等) |
|
|
288
332
|
| `scripts/test-transcript.mjs` | 会话背景窗口的离线测试(锚点 / 条数 / 不截断 / 噪音剔除 / 退化路径) |
|
|
289
333
|
| `scripts/test-guard.mjs` | 文件工具读取边界的路径校验 |
|
|
@@ -305,7 +349,7 @@ node scripts/dump-prompt.mjs --no-session "<选中文字>" # 不带会话背
|
|
|
305
349
|
git tag v0.2.0 && git push origin v0.2.0
|
|
306
350
|
```
|
|
307
351
|
|
|
308
|
-
`.github/workflows/publish.yml` 会自动:校验 tag 与 `version` 一致 → `npm install` → 构建 → 跑
|
|
352
|
+
`.github/workflows/publish.yml` 会自动:校验 tag 与 `version` 一致 → `npm install` → 构建 → 跑 489 条测试 → 类型检查 → `npm publish --provenance`(附 provenance 签名)。
|
|
309
353
|
|
|
310
354
|
npm 侧只需配一次:包设置 → **Trusted Publisher** → GitHub Actions,填 `yfwu2020` / `dsh-selection-explain` / `publish.yml`,并勾选允许 **`npm publish`**(默认只允许 `npm stage publish`)。
|
|
311
355
|
|
|
@@ -316,10 +360,12 @@ npm 侧只需配一次:包设置 → **Trusted Publisher** → GitHub Actions
|
|
|
316
360
|
## 目录
|
|
317
361
|
|
|
318
362
|
```
|
|
319
|
-
src/index.ts host 半:SSE 路由 / 模型调用 /
|
|
363
|
+
src/index.ts host 半:SSE 路由 / 模型调用 / 会话背景 / 历史落盘 / 过滤器
|
|
364
|
+
src/prompts.ts 提示词与写作规则(纯字符串常量,无逻辑)
|
|
320
365
|
src/client/index.js client 半:划词浮标 + 面板 + 渲染器(手写 ModuleLoader bundle,无打包器)
|
|
321
|
-
scripts/ build.sh +
|
|
366
|
+
scripts/ build.sh + 5 个测试 + dump-prompt + 演示图生成
|
|
322
367
|
skills/web-design/ 网页模式注入的设计规范(运行时读取)
|
|
368
|
+
assets/ README 的演示图(由 scripts/build-demo-pages.mjs 生成)
|
|
323
369
|
cordis.patch.yml 官方装配用的 bundle patch
|
|
324
370
|
lib/ 构建产物(已 gitignore,克隆后需 npm run build)
|
|
325
371
|
```
|
package/assets/code.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/assets/web.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yfwu2020/dsh-selection-explain",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"scripts",
|
|
12
12
|
"cordis.patch.yml",
|
|
13
13
|
"skills",
|
|
14
|
+
"assets",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
16
17
|
"license": "MIT",
|
|
@@ -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}`)
|