@yfwu2020/dsh-selection-explain 0.1.3 → 0.4.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.
- package/LICENSE +21 -0
- package/README.md +137 -7
- package/assets/quotes.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +3453 -203
- package/lib/index.js +533 -2
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +5 -0
- package/lib/prompts.js.map +1 -1
- package/lib/types/index.d.ts +76 -0
- package/package.json +8 -3
- package/scripts/build-demo-pages.mjs +58 -4
- package/scripts/build.sh +4 -2
- package/scripts/measure-cost.mjs +251 -0
- package/scripts/smoke-bridge.mjs +365 -0
- package/scripts/smoke-panel.mjs +433 -0
- package/scripts/smoke-voice.mjs +772 -0
- package/scripts/test-bridge.mjs +408 -0
- package/scripts/test-client.mjs +1562 -1
- package/scripts/test-prompt.mjs +15 -0
- package/scripts/test-speech.mjs +220 -0
- package/scripts/test-transcript.mjs +74 -1
- package/src/client/index.js +3453 -203
- package/src/index.ts +625 -2
- package/src/prompts.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yfwu2020/dsh-selection-explain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -42,10 +42,15 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "bash scripts/build.sh",
|
|
44
44
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
-
"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",
|
|
45
|
+
"test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-client.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-speech.mjs && node scripts/smoke-voice.mjs",
|
|
46
46
|
"test:transcript": "node scripts/test-transcript.mjs",
|
|
47
47
|
"test:guard": "node scripts/test-guard.mjs",
|
|
48
|
-
"test:
|
|
48
|
+
"test:bridge": "node scripts/test-bridge.mjs",
|
|
49
|
+
"test:smoke": "node scripts/smoke-bridge.mjs",
|
|
50
|
+
"test:panel": "node scripts/smoke-panel.mjs",
|
|
51
|
+
"test:prompt": "node scripts/test-prompt.mjs",
|
|
52
|
+
"test:speech": "node scripts/test-speech.mjs",
|
|
53
|
+
"test:voice": "node scripts/smoke-voice.mjs"
|
|
49
54
|
},
|
|
50
55
|
"exports": {
|
|
51
56
|
".": {
|
|
@@ -29,7 +29,7 @@ function extractCss() {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** DSH 主题变量:用浅色主题的实际取值,让截图看起来像真的嵌在 DSH 里。 */
|
|
32
|
-
const THEME_VARS =
|
|
32
|
+
const THEME_VARS = `:root{
|
|
33
33
|
--dsw-alias-bg-layer-1: #ffffff;
|
|
34
34
|
--dsw-alias-label-primary: #1a1a1a;
|
|
35
35
|
--dsw-alias-label-secondary: #6b7280;
|
|
@@ -40,7 +40,7 @@ const THEME_VARS = `
|
|
|
40
40
|
--dsw-alias-button-primary-fill: #3b6ef5;
|
|
41
41
|
--sel-a1: #0d9488;
|
|
42
42
|
--sel-a2: #0f766e;
|
|
43
|
-
`
|
|
43
|
+
}`
|
|
44
44
|
|
|
45
45
|
const PAGE = (title, css, body) => `<!doctype html>
|
|
46
46
|
<html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
|
|
@@ -103,10 +103,12 @@ const sendIcon = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" st
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* 输入区(结构照 src/client/index.js 的真实构建顺序):
|
|
106
|
-
* .dsh-sel-ask > textarea.dsh-sel-askbox + .dsh-sel-asktools
|
|
106
|
+
* .dsh-sel-ask > .dsh-sel-quotes + textarea.dsh-sel-askbox + .dsh-sel-asktools
|
|
107
107
|
* asktools = 输出偏好(文字 + 分段开关 + 滑动药丸) + askspace(撑开) + 模型胶囊 + 发送键
|
|
108
|
+
* quotes = 待发送的引用卡片(空串 = 空态,整块收起)
|
|
108
109
|
*/
|
|
109
|
-
const askRow = (webOn, model = 'deepseek-v4.1-flash', tier = '高') => ` <div class="dsh-sel-ask">
|
|
110
|
+
const askRow = (webOn, model = 'deepseek-v4.1-flash', tier = '高', quotes = '') => ` <div class="dsh-sel-ask">
|
|
111
|
+
<div class="dsh-sel-quotes"${quotes ? ' data-show="1"' : ''}>${quotes}</div>
|
|
110
112
|
<textarea class="dsh-sel-askbox" rows="1" placeholder="就这段文字继续追问…(Enter 发送,Shift+Enter 换行)"></textarea>
|
|
111
113
|
<div class="dsh-sel-asktools">
|
|
112
114
|
<span class="dsh-sel-pref" data-on="${webOn ? '1' : '0'}">
|
|
@@ -281,6 +283,57 @@ const webDemo = panel(
|
|
|
281
283
|
].join('\n'),
|
|
282
284
|
)
|
|
283
285
|
|
|
286
|
+
// ───────────────────────── 状态五:引用(❝)─────────────────────────
|
|
287
|
+
// 三个入口同框:气泡里的引用块(用户消息)、输入框上方的引用卡片、助手气泡末尾的「引用整条」。
|
|
288
|
+
// 浮标照真实行为摆:浮在"被划中的那段"右上角(真实浮层里它是 position:fixed,
|
|
289
|
+
// 演示页里改成绝对定位贴在回答上,好让一张图里同时看见三个入口)。
|
|
290
|
+
const quoteChip = (src, text) => `
|
|
291
|
+
<div class="dsh-sel-quotechip" data-quote="1">
|
|
292
|
+
<span class="dsh-sel-quotechip-src">❝ ${src}</span>
|
|
293
|
+
<span class="dsh-sel-quotechip-text" title="${text}">${text}</span>
|
|
294
|
+
<button class="dsh-sel-quotechip-x" type="button" aria-label="移除这条引用">✕</button>
|
|
295
|
+
</div>`
|
|
296
|
+
|
|
297
|
+
const bqItem = (src, text) => `<div class="dsh-sel-bqitem" title="${text}"><b>❝ ${src}</b>${text}</div>`
|
|
298
|
+
|
|
299
|
+
const quoteIcon = `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="width:13px;height:13px;display:block"><circle cx="5.4" cy="6.1" r="2.3"></circle><path d="M3.9 7.4 2.1 12.2 6.1 10.2z"></path><circle cx="11.6" cy="6.1" r="2.3"></circle><path d="M10.1 7.4 8.3 12.2 12.3 10.2z"></path></svg>`
|
|
300
|
+
|
|
301
|
+
const quoteFloat = `<button class="dsh-sel-btn dsh-sel-quotebtn" type="button" style="position:absolute;right:8px;top:-20px;display:inline-flex">${quoteIcon}<span>引用</span></button>`
|
|
302
|
+
|
|
303
|
+
const quotesDemo = panel(
|
|
304
|
+
'detail',
|
|
305
|
+
[
|
|
306
|
+
quote('the migration ran long'),
|
|
307
|
+
section(
|
|
308
|
+
'translation',
|
|
309
|
+
'翻译',
|
|
310
|
+
[
|
|
311
|
+
p('需要翻译的是 <b>the migration ran long</b>:<b>迁移跑得比预期久</b>。'),
|
|
312
|
+
callout('在本句中', '迁移作业耗时超出了原计划——陈述进度延迟的事实,不含"失败了"的意思。'),
|
|
313
|
+
].join('\n'),
|
|
314
|
+
),
|
|
315
|
+
` <div class="dsh-sel-chatlog" style="display:flex">`,
|
|
316
|
+
chatBubble('user', '这句话在排期上意味着什么?'),
|
|
317
|
+
chatBubble(
|
|
318
|
+
'assistant',
|
|
319
|
+
`<div style="position:relative"><p>意味着后面的 <b>freeze features</b> 是配套动作:把人力腾出来盯迁移,而不是随口一说。</p>${quoteFloat}</div>` +
|
|
320
|
+
'<button class="dsh-sel-bubquote" type="button" style="opacity:.66"><span>❝ 引用整条</span></button>',
|
|
321
|
+
),
|
|
322
|
+
chatBubble(
|
|
323
|
+
'user',
|
|
324
|
+
`<div class="dsh-sel-bq">${bqItem('小窗回答', '意味着后面的 freeze features 是配套动作:把人力腾出来盯迁移')}</div>` +
|
|
325
|
+
'<div>那这两件事的先后顺序能换吗?</div>',
|
|
326
|
+
),
|
|
327
|
+
' </div>',
|
|
328
|
+
askRow(
|
|
329
|
+
false,
|
|
330
|
+
'deepseek-v4.1-flash',
|
|
331
|
+
'高',
|
|
332
|
+
[quoteChip('小窗回答', '意味着后面的 freeze features 是配套动作…'), quoteChip('主界面选中', 'the migration ran long')].join(''),
|
|
333
|
+
),
|
|
334
|
+
].join('\n'),
|
|
335
|
+
)
|
|
336
|
+
|
|
284
337
|
const css = extractCss()
|
|
285
338
|
mkdirSync(resolve(ROOT, 'docs', 'demo'), { recursive: true })
|
|
286
339
|
const out = {
|
|
@@ -288,6 +341,7 @@ const out = {
|
|
|
288
341
|
'stage2.html': PAGE('划词解读 · 详解与追问', css, stage2),
|
|
289
342
|
'code.html': PAGE('划词解读 · 代码注释', css, codeDemo),
|
|
290
343
|
'web.html': PAGE('划词解读 · 网页模式', css, webDemo),
|
|
344
|
+
'quotes.html': PAGE('划词解读 · 引用', css, quotesDemo),
|
|
291
345
|
}
|
|
292
346
|
for (const [name, html] of Object.entries(out)) {
|
|
293
347
|
writeFileSync(resolve(ROOT, 'docs', 'demo', name), html, 'utf8')
|
package/scripts/build.sh
CHANGED
|
@@ -16,8 +16,10 @@ cd "$ROOT"
|
|
|
16
16
|
probe() {
|
|
17
17
|
local base="${1:-}"
|
|
18
18
|
[ -n "$base" ] || return 1
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# 同时要 dsh-llm 和 cordis:过期链接可能只剩一个 dsh-llm(实测踩过——tsc 报
|
|
20
|
+
# "Cannot find module '@deepseek-ai/cordis'",构建失败还会**静默跳过 client 拷贝**,测试于是拿旧 lib 跑出假结果)
|
|
21
|
+
if [ -d "$base/@deepseek-ai/dsh-llm" ] && [ -d "$base/@deepseek-ai/cordis" ]; then echo "$base"; return 0; fi
|
|
22
|
+
if [ -d "$base/node_modules/@deepseek-ai/dsh-llm" ] && [ -d "$base/node_modules/@deepseek-ai/cordis" ]; then echo "$base/node_modules"; return 0; fi
|
|
21
23
|
return 1
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「在小窗里问简单问题,比在主会话里问省吗?」—— 一次算完。
|
|
3
|
+
*
|
|
4
|
+
* 两边分别怎么量:
|
|
5
|
+
* · **小窗这一侧**:真发一次请求,拿 SSE `done` 事件里的 `usage`(真实计费口径);
|
|
6
|
+
* 跑 chat 追问两遍(冷 / 热缓存)与「不带会话背景」对照,隔离固定开销与背景开销。
|
|
7
|
+
* · **主会话这一侧**:不猜 —— 读 ~/.dsh/storages/dsh-spend-scan-cache.json,
|
|
8
|
+
* 那是每个会话每一步的真实 inputTokens / cacheReadTokens / outputTokens:
|
|
9
|
+
* 首步 input = 固定前缀(系统提示词 + 全部工具 schema + 运行时快照),
|
|
10
|
+
* 按轮次聚合就能得到「一次一问一答」的真实开销分布。
|
|
11
|
+
*
|
|
12
|
+
* 折算口径("等效未缓存输入 token",所有 token 归一到 miss 单价):
|
|
13
|
+
* cost = miss + cacheRead × 0.1 + output × 1.5
|
|
14
|
+
* 0.1 = 主流缓存命中价(DeepSeek 系 miss:hit = 10:1);1.5 = 输出单价 / 输入 miss 单价。
|
|
15
|
+
* 价格表换了就改这两个常数;结论对这俩数不敏感的方向会在输出里标注。
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node scripts/measure-cost.mjs # 用小窗历史里最近一次会话当背景
|
|
19
|
+
* node scripts/measure-cost.mjs <sessionId> # 指定主会话 id(拿它的对话当小窗背景)
|
|
20
|
+
* SEL_ORIGIN=http://127.0.0.1:3080 node scripts/measure-cost.mjs
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const ORIGIN = process.env.SEL_ORIGIN || 'http://127.0.0.1:3080'
|
|
27
|
+
const SPEND_CACHE = join(homedir(), '.dsh', 'storages', 'dsh-spend-scan-cache.json')
|
|
28
|
+
const CACHE_READ_RATIO = 0.1
|
|
29
|
+
const OUTPUT_RATIO = 1.5
|
|
30
|
+
|
|
31
|
+
const sessionId = process.argv[2] || ''
|
|
32
|
+
/** 折算:把 (miss, cacheRead, output) 归一到「等效未缓存输入 token」。 */
|
|
33
|
+
const cost = (miss, cacheRead, out) => miss + cacheRead * CACHE_READ_RATIO + out * OUTPUT_RATIO
|
|
34
|
+
const fmt = (n) => Math.round(n).toLocaleString('en-US')
|
|
35
|
+
/** 分位数(p 取 0~1);中位数就是 p=0.5。 */
|
|
36
|
+
const pct = (list, p) => {
|
|
37
|
+
if (list.length === 0) return 0
|
|
38
|
+
const sorted = [...list].sort((a, b) => a - b)
|
|
39
|
+
const at = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * p)))
|
|
40
|
+
return sorted[at]
|
|
41
|
+
}
|
|
42
|
+
const med = (list) => pct(list, 0.5)
|
|
43
|
+
|
|
44
|
+
/* ───────────────────────── 主会话:真实用量统计 ───────────────────────── */
|
|
45
|
+
|
|
46
|
+
function mainSessionStats() {
|
|
47
|
+
let cache
|
|
48
|
+
try {
|
|
49
|
+
cache = JSON.parse(readFileSync(SPEND_CACHE, 'utf8'))
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { error: `读不到用量缓存(${SPEND_CACHE}):${String(error?.message ?? error)}` }
|
|
52
|
+
}
|
|
53
|
+
const prefixes = []
|
|
54
|
+
const turns = new Map()
|
|
55
|
+
for (const [key, value] of Object.entries(cache)) {
|
|
56
|
+
const samples = value?.samples ?? []
|
|
57
|
+
if (samples.length === 0) continue
|
|
58
|
+
const first = samples.reduce((a, b) => ((a?.turn ?? 0) <= (b?.turn ?? 0) ? a : b))
|
|
59
|
+
if (first?.inputTokens) prefixes.push(first.inputTokens)
|
|
60
|
+
for (const sample of samples) {
|
|
61
|
+
const id = `${key}#${sample.turn}`
|
|
62
|
+
const turn = turns.get(id) ?? { steps: 0, miss: 0, cacheRead: 0, out: 0 }
|
|
63
|
+
turn.steps += 1
|
|
64
|
+
turn.miss += sample.inputTokens ?? 0
|
|
65
|
+
turn.cacheRead += sample.cacheReadTokens ?? 0
|
|
66
|
+
turn.out += sample.outputTokens ?? 0
|
|
67
|
+
turns.set(id, turn)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const all = [...turns.values()]
|
|
71
|
+
const bucket = (max) => all.filter((t) => t.steps <= max)
|
|
72
|
+
const summarize = (list) => ({
|
|
73
|
+
n: list.length,
|
|
74
|
+
steps: med(list.map((t) => t.steps)),
|
|
75
|
+
miss: med(list.map((t) => t.miss)),
|
|
76
|
+
cacheRead: med(list.map((t) => t.cacheRead)),
|
|
77
|
+
out: med(list.map((t) => t.out)),
|
|
78
|
+
costP25: pct(list.map((t) => cost(t.miss, t.cacheRead, t.out)), 0.25),
|
|
79
|
+
cost: med(list.map((t) => cost(t.miss, t.cacheRead, t.out))),
|
|
80
|
+
})
|
|
81
|
+
return {
|
|
82
|
+
sessions: prefixes.length,
|
|
83
|
+
turns: all.length,
|
|
84
|
+
prefix: med(prefixes),
|
|
85
|
+
prefixP25: med([...prefixes].sort((a, b) => a - b).slice(0, Math.max(1, prefixes.length >> 2))),
|
|
86
|
+
one: summarize(bucket(1)),
|
|
87
|
+
two: summarize(bucket(2)),
|
|
88
|
+
six: summarize(bucket(6)),
|
|
89
|
+
all: summarize(all),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ───────────────────────── 小窗:实测一次请求 ───────────────────────── */
|
|
94
|
+
|
|
95
|
+
const CONTEXT =
|
|
96
|
+
'PM:这周能发布吗?\nDev:【the migration ran long】,得顺延到周三。\nPM:那就周三,先冻结功能。\n' +
|
|
97
|
+
'Dev:迁移脚本在预发环境跑了 47 分钟,主要是 users 表的索引重建。\n'.repeat(6)
|
|
98
|
+
|
|
99
|
+
const HISTORY = [
|
|
100
|
+
{ role: 'user', text: 'the migration ran long' },
|
|
101
|
+
{
|
|
102
|
+
role: 'assistant',
|
|
103
|
+
text: '## 翻译\n- **migration** /maɪˈɡreɪʃn/\n - **n.** 迁移;移居\n- **ran long**:跑得久了。\n\n> 在本句中:迁移脚本耗时超出预期。',
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
/** 发一次小窗请求,只取 start 元信息与 done 的 usage。 */
|
|
108
|
+
async function ask(payload, withSession) {
|
|
109
|
+
const response = await fetch(`${ORIGIN}/selection-explain/api/analyze`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
text: 'the migration ran long',
|
|
114
|
+
context: CONTEXT,
|
|
115
|
+
label: '对话消息',
|
|
116
|
+
debug: true,
|
|
117
|
+
...(withSession && sessionId ? { sessionId } : {}),
|
|
118
|
+
...payload,
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
121
|
+
if (!response.ok) return { error: `HTTP ${response.status} ${(await response.text().catch(() => '')).slice(0, 120)}` }
|
|
122
|
+
const reader = response.body.getReader()
|
|
123
|
+
const decoder = new TextDecoder()
|
|
124
|
+
let buffer = ''
|
|
125
|
+
let start = null
|
|
126
|
+
let done = null
|
|
127
|
+
const startedAt = Date.now()
|
|
128
|
+
let firstDelta = null
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done: eof, value } = await reader.read()
|
|
131
|
+
if (eof) break
|
|
132
|
+
buffer += decoder.decode(value, { stream: true })
|
|
133
|
+
let index
|
|
134
|
+
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
135
|
+
const block = buffer.slice(0, index)
|
|
136
|
+
buffer = buffer.slice(index + 2)
|
|
137
|
+
const line = block.trim()
|
|
138
|
+
if (!line.startsWith('data:')) continue
|
|
139
|
+
let message
|
|
140
|
+
try {
|
|
141
|
+
message = JSON.parse(line.slice(5).trim())
|
|
142
|
+
} catch {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (message.type === 'start') start = message
|
|
146
|
+
else if (message.type === 'delta' && firstDelta === null) firstDelta = Date.now() - startedAt
|
|
147
|
+
else if (message.type === 'done') done = message
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { start, done, ms: Date.now() - startedAt, firstDelta }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const QUESTION = { question: '这句里 run long 是什么用法?', history: HISTORY }
|
|
154
|
+
|
|
155
|
+
async function smallWindowStats() {
|
|
156
|
+
const rows = []
|
|
157
|
+
const cold = await ask(QUESTION, true)
|
|
158
|
+
if (cold.error) return { error: cold.error }
|
|
159
|
+
const warm = await ask(QUESTION, true)
|
|
160
|
+
const bare = await ask(QUESTION, false)
|
|
161
|
+
for (const [label, run] of [
|
|
162
|
+
['第 1 跑', cold],
|
|
163
|
+
['再跑一次(前缀命中缓存)', warm],
|
|
164
|
+
['不带会话背景', bare],
|
|
165
|
+
]) {
|
|
166
|
+
const usage = run.done?.usage ?? {}
|
|
167
|
+
const miss = usage.inputTokens ?? 0
|
|
168
|
+
const cacheRead = usage.cacheReadTokens ?? 0
|
|
169
|
+
const out = usage.outputTokens ?? 0
|
|
170
|
+
rows.push({
|
|
171
|
+
label,
|
|
172
|
+
prompt: miss + cacheRead,
|
|
173
|
+
miss,
|
|
174
|
+
cacheRead,
|
|
175
|
+
out,
|
|
176
|
+
cost: cost(miss, cacheRead, out),
|
|
177
|
+
rounds: run.done?.rounds?.length ?? 1,
|
|
178
|
+
toolCalls: run.done?.toolCalls ?? 0,
|
|
179
|
+
system: run.start?.debug?.sizes?.system ?? 0,
|
|
180
|
+
user: run.start?.debug?.sizes?.user ?? 0,
|
|
181
|
+
background: run.start?.context?.session?.chars ?? 0,
|
|
182
|
+
backgroundMessages: run.start?.backgroundMessages ?? 0,
|
|
183
|
+
tools: run.start?.tools ?? [],
|
|
184
|
+
ms: run.ms,
|
|
185
|
+
firstDelta: run.firstDelta,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return { rows, model: cold.start?.model, effort: cold.start?.effort }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ───────────────────────────── 输出对照 ───────────────────────────── */
|
|
192
|
+
|
|
193
|
+
const main = mainSessionStats()
|
|
194
|
+
const small = await smallWindowStats()
|
|
195
|
+
|
|
196
|
+
console.log('='.repeat(78))
|
|
197
|
+
console.log('主会话(真实用量:%d 个会话 / %d 轮)', main.sessions ?? 0, main.turns ?? 0)
|
|
198
|
+
console.log('='.repeat(78))
|
|
199
|
+
if (main.error) console.log(' ' + main.error)
|
|
200
|
+
else {
|
|
201
|
+
console.log(`固定前缀(首步 input,= 系统提示词 + 全部工具 schema + 运行时快照):中位 ${fmt(main.prefix)} tokens`)
|
|
202
|
+
const line = (name, s) =>
|
|
203
|
+
console.log(
|
|
204
|
+
` ${name.padEnd(16)} n=${String(s.n).padStart(3)} 步数中位 ${String(s.steps).padStart(4)} ` +
|
|
205
|
+
`miss ${fmt(s.miss).padStart(8)} cacheRead ${fmt(s.cacheRead).padStart(9)} output ${fmt(s.out).padStart(6)}` +
|
|
206
|
+
` → 等效 token p25 ${fmt(s.costP25).padStart(9)} / 中位 ${fmt(s.cost).padStart(9)}`,
|
|
207
|
+
)
|
|
208
|
+
line('1 步轮次', main.one)
|
|
209
|
+
line('≤2 步轮次', main.two)
|
|
210
|
+
line('≤6 步轮次', main.six)
|
|
211
|
+
line('全部轮次', main.all)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log()
|
|
215
|
+
console.log('='.repeat(78))
|
|
216
|
+
console.log('小窗(实测:同一句简单提问,%s / %s)', small.model ?? '?', small.effort ?? '?')
|
|
217
|
+
console.log('='.repeat(78))
|
|
218
|
+
if (small.error) console.log(' ' + small.error)
|
|
219
|
+
else {
|
|
220
|
+
for (const r of small.rows) {
|
|
221
|
+
console.log(
|
|
222
|
+
` ${r.label}:prompt ${fmt(r.prompt)} tokens(miss ${fmt(r.miss)} + cacheRead ${fmt(r.cacheRead)})` +
|
|
223
|
+
` + output ${fmt(r.out)} → ≈ ${fmt(r.cost)} 等效 token`,
|
|
224
|
+
)
|
|
225
|
+
console.log(
|
|
226
|
+
` system ${r.system} 字 / user ${r.user} 字 / 背景 ${r.background} 字(${r.backgroundMessages} 条真实对话)` +
|
|
227
|
+
` / ${r.rounds} 轮 / ${r.toolCalls} 次工具调用 / 首字 ${r.firstDelta}ms / 全程 ${(r.ms / 1000).toFixed(1)}s`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const best = Math.min(...small.rows.map((r) => r.cost))
|
|
231
|
+
const worst = Math.max(...small.rows.map((r) => r.cost))
|
|
232
|
+
if (!main.error) {
|
|
233
|
+
console.log()
|
|
234
|
+
console.log('= 对照(等效未缓存输入 token,越小越省;主会话一侧给 p25~中位)')
|
|
235
|
+
const rows = [
|
|
236
|
+
['主会话 1 步(最省的一问)', main.one.costP25, main.one.cost],
|
|
237
|
+
['主会话 ≤2 步', main.two.costP25, main.two.cost],
|
|
238
|
+
['主会话 ≤6 步', main.six.costP25, main.six.cost],
|
|
239
|
+
['主会话中位轮次', main.all.costP25, main.all.cost],
|
|
240
|
+
]
|
|
241
|
+
for (const [name, low, high] of rows) {
|
|
242
|
+
console.log(
|
|
243
|
+
` ${name.padEnd(26)} ${fmt(low).padStart(8)} ~ ${fmt(high).padStart(8)}` +
|
|
244
|
+
` 小窗省 ${(low / worst).toFixed(1)} ~ ${(high / best).toFixed(1)}×`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
console.log(` ${'小窗(实测上下界)'.padEnd(26)} ${fmt(best).padStart(8)} ~ ${fmt(worst).padStart(8)}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
console.log()
|
|
251
|
+
console.log(`折算口径:miss + cacheRead×${CACHE_READ_RATIO} + output×${OUTPUT_RATIO}(改脚本顶部两个常数即可换价格表)`)
|