@telosmaylx/dsh-session-notify 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/lib/core.js CHANGED
@@ -1,303 +1,303 @@
1
- /**
2
- * dsh-session-complete-notify —— 纯逻辑层(零外部依赖,可独立测试)。
3
- *
4
- * 职责:
5
- * - 轮次计时/用量跟踪(turn/start 起表,assistant/message 累计 token)
6
- * - 会话完成系统消息的文案构建(summary 一行 + content 全文)
7
- */
8
-
9
- /** 语言代码(设置面板可选值)。 */
10
- export const LANGUAGES = ['zh', 'zh-tw', 'en', 'ja', 'ko']
11
-
12
- /**
13
- * 各语言文案表:reason 标签 + 通用标签 + 分隔符/用时/消耗措辞。
14
- * 键为 LANGUAGES 中的代码;缺省回落 zh。
15
- */
16
- const LABELS = {
17
- zh: { completed: '会话已完成', aborted: '会话已中止', blocked: '会话被阻塞', error: '会话出错', 'max-tokens': '会话达到输出上限', interrupted: '会话已中断', generic: '会话已结束', sep: ':', dur: '用时', use: '消耗' },
18
- 'zh-tw': { completed: '會話已完成', aborted: '會話已中止', blocked: '會話被阻塞', error: '會話出錯', 'max-tokens': '會話達到輸出上限', interrupted: '會話已中斷', generic: '會話已結束', sep: ':', dur: '用時', use: '消耗' },
19
- en: { completed: 'Session completed', aborted: 'Session aborted', blocked: 'Session blocked', error: 'Session failed', 'max-tokens': 'Session hit the output-token cap', interrupted: 'Session interrupted', generic: 'Session ended', sep: ': ', dur: 'took', use: 'used' },
20
- ja: { completed: 'セッション完了', aborted: 'セッション中止', blocked: 'セッションがブロックされました', error: 'セッションエラー', 'max-tokens': '出力トークン上限に到達', interrupted: 'セッションが中断されました', generic: 'セッション終了', sep: ':', dur: '所要', use: '消費' },
21
- ko: { completed: '세션 완료', aborted: '세션 중단됨', blocked: '세션 차단됨', error: '세션 오류', 'max-tokens': '출력 토큰 한도 도달', interrupted: '세션 중단됨', generic: '세션 종료', sep: ': ', dur: '소요', use: '소모' },
22
- }
23
-
24
- function tableFor(language) {
25
- return LABELS[language] || LABELS.zh
26
- }
27
-
28
- /**
29
- * 按语言取 reason 标签。
30
- * @param {string} kind
31
- * @param {string} language - LANGUAGES 之一
32
- * @returns {string}
33
- */
34
- export function labelFor(kind, language = 'zh') {
35
- return tableFor(language)[kind] ?? tableFor(language).generic
36
- }
37
-
38
- /**
39
- * 判断一个会话是否为子代理会话(子代理由父会话编排,通常不需要逐轮提醒)。
40
- * @param {{ header?: { origin?: string; delegationDepth?: number } }} session
41
- * @returns {boolean}
42
- */
43
- export function isSubagentSession(session) {
44
- const header = session?.header ?? {}
45
- return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0
46
- }
47
-
48
- /**
49
- * 每个(会话, 轮次)的进行时状态:开始时间 + 累计 token 用量。
50
- * key 约定:`${sessionId}:${turn}`。
51
- */
52
- export function createTurnTracker() {
53
- const turns = new Map()
54
- return {
55
- /** turn/start:起表。 */
56
- start(key, time) {
57
- turns.set(key, { startedAt: time, usage: null })
58
- },
59
- /** assistant/message:一步的 usage 并入轮次累计。 */
60
- addUsage(key, usage) {
61
- const state = turns.get(key)
62
- if (!state || !usage) return
63
- state.usage = state.usage ? addTokenUsage(state.usage, usage) : { ...usage }
64
- },
65
- /** turn/end:读取并清除。缺起表(如恢复日志边界)时以 end 时间作答。 */
66
- end(key, time) {
67
- const state = turns.get(key)
68
- turns.delete(key)
69
- return {
70
- startedAt: state?.startedAt ?? time,
71
- endedAt: time,
72
- usage: state?.usage ?? null,
73
- }
74
- },
75
- }
76
- }
77
-
78
- /** 合并两次 token 用量(字段按需相加,缺失字段保持缺席)。 */
79
- function addTokenUsage(a, b) {
80
- const sum = (x, y) => (Number.isFinite(x) ? x : 0) + (Number.isFinite(y) ? y : 0)
81
- const merged = {
82
- inputTokens: sum(a.inputTokens, b.inputTokens),
83
- outputTokens: sum(a.outputTokens, b.outputTokens),
84
- }
85
- if (a.cacheReadTokens != null || b.cacheReadTokens != null) merged.cacheReadTokens = sum(a.cacheReadTokens, b.cacheReadTokens)
86
- if (a.cacheWriteTokens != null || b.cacheWriteTokens != null) merged.cacheWriteTokens = sum(a.cacheWriteTokens, b.cacheWriteTokens)
87
- if (a.reasoningTokens != null || b.reasoningTokens != null) merged.reasoningTokens = sum(a.reasoningTokens, b.reasoningTokens)
88
- return merged
89
- }
90
-
91
- /**
92
- * 人类可读时长:zh/zh-tw `12 秒` `3 分 25 秒`;en `12s` `3m25s`;
93
- * ja `12 秒` `3 分 25 秒` `1 時間 4 分`;ko `12초` `3분 25초` `1시간 4분`。
94
- * @param {number} ms
95
- * @param {string} [language]
96
- * @returns {string}
97
- */
98
- export function formatDuration(ms, language = 'zh') {
99
- const totalSeconds = Math.max(0, Math.round(ms / 1000))
100
- const lang = LABELS[language] ? language : 'zh'
101
- if (lang === 'en') {
102
- if (totalSeconds < 60) return `${totalSeconds}s`
103
- const minutes = Math.floor(totalSeconds / 60)
104
- const seconds = totalSeconds % 60
105
- if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`
106
- return `${Math.floor(minutes / 60)}h${minutes % 60}m`
107
- }
108
- if (lang === 'ja') {
109
- if (totalSeconds < 60) return `${totalSeconds} 秒`
110
- const minutes = Math.floor(totalSeconds / 60)
111
- const seconds = totalSeconds % 60
112
- if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分`
113
- return `${Math.floor(minutes / 60)} 時間 ${minutes % 60} 分`
114
- }
115
- if (lang === 'ko') {
116
- if (totalSeconds < 60) return `${totalSeconds}초`
117
- const minutes = Math.floor(totalSeconds / 60)
118
- const seconds = totalSeconds % 60
119
- if (minutes < 60) return seconds ? `${minutes}분 ${seconds}초` : `${minutes}분`
120
- return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
121
- }
122
- // zh / zh-tw
123
- if (totalSeconds < 60) return `${totalSeconds} 秒`
124
- const minutes = Math.floor(totalSeconds / 60)
125
- const seconds = totalSeconds % 60
126
- if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`
127
- const hours = Math.floor(minutes / 60)
128
- return `${hours} 小时 ${minutes % 60} 分`
129
- }
130
-
131
- /**
132
- * 用量一行:zh/zh-tw `1,240 輸入 / 3,560 輸出`;en `1,240 in / 3,560 out`;
133
- * ja `1,240 入力 / 3,560 出力`;ko `1,240 입력 / 3,560 출력`(不带 tokens 单位后缀)。
134
- * 输入 = 未缓存 + 缓存读 + 缓存写。
135
- * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
136
- * @param {string} [language]
137
- * @returns {string | null}
138
- */
139
- export function summarizeUsage(usage, language = 'zh') {
140
- if (!usage) return null
141
- const input = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
142
- const output = usage.outputTokens ?? 0
143
- const fmt = (n) => n.toLocaleString('en-US')
144
- const lang = LABELS[language] ? language : 'zh'
145
- const iw = { zh: '输入', 'zh-tw': '輸入', en: 'in', ja: '入力', ko: '입력' }[lang]
146
- const ow = { zh: '输出', 'zh-tw': '輸出', en: 'out', ja: '出力', ko: '출력' }[lang]
147
- return `${fmt(input)} ${iw} / ${fmt(output)} ${ow}`
148
- }
149
-
150
- /**
151
- * 缓存命中率:缓存读 token /(未缓存输入 + 缓存读 + 缓存写)——无缓存数据返回空串。
152
- * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
153
- * @returns {string}
154
- */
155
- export function summarizeCache(usage) {
156
- if (!usage) return ''
157
- const read = usage.cacheReadTokens ?? 0
158
- const write = usage.cacheWriteTokens ?? 0
159
- const plain = usage.inputTokens ?? 0
160
- const total = plain + read + write
161
- if (!(total > 0)) return ''
162
- const pct = Math.round((read / total) * 1000) / 10
163
- return `${pct}%`
164
- }
165
-
166
- /**
167
- * 缓存命中率(官方 tokenUsage 投影口径,四桶不重叠):
168
- * 缓存读 /(未缓存输入 + 缓存读 + 缓存写)——与 dsh-web-ui 同源。
169
- * @param {{ uncachedInputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
170
- * @returns {string}
171
- */
172
- export function officialCacheRate(usage) {
173
- if (!usage) return ''
174
- const read = usage.cacheReadTokens ?? 0
175
- const total = (usage.uncachedInputTokens ?? 0) + read + (usage.cacheWriteTokens ?? 0)
176
- if (!(total > 0)) return ''
177
- return `${Math.round((read / total) * 1000) / 10}%`
178
- }
179
-
180
- /**
181
- * 生成速度(官方 sessionStats 投影口径):输出 token ÷ 解码耗时(tok/s)——
182
- * 与 dsh-web-ui 状态栏同口径(不含排队/准备/工具时间)。
183
- * @param {{ decodeTokens?: number; decodeMs?: number }} stats
184
- * @returns {string}
185
- */
186
- export function officialTps(stats) {
187
- if (!stats || !(stats.decodeMs > 0)) return ''
188
- return `${Math.round((stats.decodeTokens ?? 0) / (stats.decodeMs / 1000))} tok/s`
189
- }
190
-
191
- /**
192
- * 生成速度:输出 token / 用时秒(tok/s)——无数据返回空串。
193
- * @param {{ outputTokens?: number }} usage
194
- * @param {number} ms
195
- * @returns {string}
196
- */
197
- export function tpsOf(usage, ms) {
198
- if (!usage || !(ms > 0)) return ''
199
- const output = usage.outputTokens ?? 0
200
- return `${Math.round(output / (ms / 1000))} tok/s`
201
- }
202
-
203
- /**
204
- * 构建系统消息:可折叠行的 summary(= 正文渲染结果的截断,≤120 字符)
205
- * + 展开/模型可见的全文。
206
- *
207
- * @param {string} kind - turn/end 的 reason.kind。
208
- * @param {{ error?: { message?: string } } | undefined} reasonData
209
- * @param {{ startedAt: number; endedAt: number; usage?: object | null; includeDuration?: boolean; includeUsage?: boolean; cacheValue?: string; tpsValue?: string; titleValue?: string }} info
210
- * @param {{ language?: 'zh' | 'en'; templates?: Record<string, string> }} [custom] -
211
- * language 切换文案语言;templates[reason] 非空时作为正文模板,
212
- * 支持占位符:{label} {title} {duration} {usage} {error} {cache} {tps}。
213
- * @returns {{ summary: string; text: string }}
214
- */
215
- export function buildNotice(kind, reasonData, { startedAt, endedAt, usage = null, includeDuration = true, includeUsage = true, cacheValue, tpsValue, titleValue }, custom = {}) {
216
- const language = LABELS[custom.language] ? custom.language : 'zh'
217
- const L = tableFor(language)
218
- const templates = custom.templates ?? {}
219
- const label = labelFor(kind, language)
220
- const detail = detailFor(reasonData, language)
221
- let summary = `${label}${detail}`
222
- const durationLine = includeDuration ? formatDuration(endedAt - startedAt, language) : null
223
- const usageLine = includeUsage ? summarizeUsage(usage, language) : null
224
- // 缓存命中/速度:优先官方投影口径(调用方传入),无则退回用量聚合估算
225
- const cacheLine = typeof cacheValue === 'string' ? cacheValue : (includeUsage ? summarizeCache(usage) : '')
226
- const tpsLine = typeof tpsValue === 'string' ? tpsValue : (includeUsage ? tpsOf(usage, endedAt - startedAt) : '')
227
- const parts = []
228
- if (durationLine) parts.push(`${L.dur} ${durationLine}`)
229
- if (usageLine) parts.push(`${L.use} ${usageLine}`)
230
- const suffix = parts.length ? `(${parts.join(',')})` : ''
231
- const template = templates[kind]
232
- let text
233
- if (typeof template === 'string' && template.trim() !== '') {
234
- // 自定义模板:占位符替换,保持 summary 与正文一致的语义
235
- text = renderTemplate(template, {
236
- label,
237
- duration: durationLine ?? '',
238
- usage: usageLine ?? '',
239
- error: plainError(reasonData) || 'none', // 无报错时显示 none,而非空串
240
- cache: cacheLine,
241
- tps: tpsLine,
242
- title: typeof titleValue === 'string' ? titleValue : '',
243
- })
244
- } else if (language === 'en') {
245
- text = `${summary}${suffix ? ` (${parts.join(', ')}).` : '.'}`
246
- } else if (language === 'ja') {
247
- text = `${summary}${suffix ? `(${parts.join('、')})。` : '。'}`
248
- } else if (language === 'ko') {
249
- text = `${summary}${suffix ? `(${parts.join(', ')})。` : '。'}`
250
- } else {
251
- text = `${summary}${suffix}。`
252
- }
253
- return {
254
- // 折叠行 summary 与正文同源:自定义模板(含 {title})与默认文案都展示
255
- // 渲染结果(截断至 120 字符)——只看折叠行的用户必须能看见真实标题与
256
- // 用时/消耗,否则会误以为 {title} 没有生效(历史教训:summary 只放标签
257
- // 「会话已完成」,用户展开前完全看不到内容)。
258
- summary: bound(text, 120),
259
- text,
260
- }
261
- }
262
-
263
- /**
264
- * 模板占位符替换:{label} 已全面移除(模板中直接剥除);支持 {duration} {usage} {error} {cache} {tps}。
265
- * @param {string} template
266
- * @param {{ label: string; duration: string; usage: string; error: string; cache: string; tps: string }} vars
267
- * @returns {string}
268
- */
269
- export function renderTemplate(template, vars) {
270
- return String(template)
271
- .replace(/\{label\}/g, '')
272
- .replaceAll('{title}', vars.title ?? '')
273
- .replaceAll('{duration}', vars.duration)
274
- .replaceAll('{usage}', vars.usage)
275
- .replaceAll('{error}', vars.error)
276
- .replaceAll('{cache}', vars.cache)
277
- .replaceAll('{tps}', vars.tps)
278
- }
279
-
280
- /** error reason 的纯文本详情(单行、截断;无则空串)。 */
281
- function plainError(reasonData) {
282
- const message = reasonData?.error?.message
283
- if (!message) return ''
284
- const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
285
- return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat
286
- }
287
-
288
- /** error reason 的错误详情(单行、截断,分隔符随语言)。 */
289
- function detailFor(reasonData, language = 'zh') {
290
- const message = reasonData?.error?.message
291
- if (!message) return ''
292
- const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
293
- if (!flat) return ''
294
- const sep = tableFor(language).sep
295
- return `${sep}${flat.length > 40 ? `${flat.slice(0, 40)}…` : flat}`
296
- }
297
-
298
- /** 按字符数截断(≤ max)。 */
299
- function bound(text, max) {
300
- return text.length > max ? `${text.slice(0, max - 1)}…` : text
301
- }
302
-
303
- export const __internals = { LABELS, addTokenUsage, detailFor, bound }
1
+ /**
2
+ * dsh-session-complete-notify —— 纯逻辑层(零外部依赖,可独立测试)。
3
+ *
4
+ * 职责:
5
+ * - 轮次计时/用量跟踪(turn/start 起表,assistant/message 累计 token)
6
+ * - 会话完成系统消息的文案构建(summary 一行 + content 全文)
7
+ */
8
+
9
+ /** 语言代码(设置面板可选值)。 */
10
+ export const LANGUAGES = ['zh', 'zh-tw', 'en', 'ja', 'ko']
11
+
12
+ /**
13
+ * 各语言文案表:reason 标签 + 通用标签 + 分隔符/用时/消耗措辞。
14
+ * 键为 LANGUAGES 中的代码;缺省回落 zh。
15
+ */
16
+ const LABELS = {
17
+ zh: { completed: '会话已完成', aborted: '会话已中止', blocked: '会话被阻塞', error: '会话出错', 'max-tokens': '会话达到输出上限', interrupted: '会话已中断', generic: '会话已结束', sep: ':', dur: '用时', use: '消耗' },
18
+ 'zh-tw': { completed: '會話已完成', aborted: '會話已中止', blocked: '會話被阻塞', error: '會話出錯', 'max-tokens': '會話達到輸出上限', interrupted: '會話已中斷', generic: '會話已結束', sep: ':', dur: '用時', use: '消耗' },
19
+ en: { completed: 'Session completed', aborted: 'Session aborted', blocked: 'Session blocked', error: 'Session failed', 'max-tokens': 'Session hit the output-token cap', interrupted: 'Session interrupted', generic: 'Session ended', sep: ': ', dur: 'took', use: 'used' },
20
+ ja: { completed: 'セッション完了', aborted: 'セッション中止', blocked: 'セッションがブロックされました', error: 'セッションエラー', 'max-tokens': '出力トークン上限に到達', interrupted: 'セッションが中断されました', generic: 'セッション終了', sep: ':', dur: '所要', use: '消費' },
21
+ ko: { completed: '세션 완료', aborted: '세션 중단됨', blocked: '세션 차단됨', error: '세션 오류', 'max-tokens': '출력 토큰 한도 도달', interrupted: '세션 중단됨', generic: '세션 종료', sep: ': ', dur: '소요', use: '소모' },
22
+ }
23
+
24
+ function tableFor(language) {
25
+ return LABELS[language] || LABELS.zh
26
+ }
27
+
28
+ /**
29
+ * 按语言取 reason 标签。
30
+ * @param {string} kind
31
+ * @param {string} language - LANGUAGES 之一
32
+ * @returns {string}
33
+ */
34
+ export function labelFor(kind, language = 'zh') {
35
+ return tableFor(language)[kind] ?? tableFor(language).generic
36
+ }
37
+
38
+ /**
39
+ * 判断一个会话是否为子代理会话(子代理由父会话编排,通常不需要逐轮提醒)。
40
+ * @param {{ header?: { origin?: string; delegationDepth?: number } }} session
41
+ * @returns {boolean}
42
+ */
43
+ export function isSubagentSession(session) {
44
+ const header = session?.header ?? {}
45
+ return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0
46
+ }
47
+
48
+ /**
49
+ * 每个(会话, 轮次)的进行时状态:开始时间 + 累计 token 用量。
50
+ * key 约定:`${sessionId}:${turn}`。
51
+ */
52
+ export function createTurnTracker() {
53
+ const turns = new Map()
54
+ return {
55
+ /** turn/start:起表。 */
56
+ start(key, time) {
57
+ turns.set(key, { startedAt: time, usage: null })
58
+ },
59
+ /** assistant/message:一步的 usage 并入轮次累计。 */
60
+ addUsage(key, usage) {
61
+ const state = turns.get(key)
62
+ if (!state || !usage) return
63
+ state.usage = state.usage ? addTokenUsage(state.usage, usage) : { ...usage }
64
+ },
65
+ /** turn/end:读取并清除。缺起表(如恢复日志边界)时以 end 时间作答。 */
66
+ end(key, time) {
67
+ const state = turns.get(key)
68
+ turns.delete(key)
69
+ return {
70
+ startedAt: state?.startedAt ?? time,
71
+ endedAt: time,
72
+ usage: state?.usage ?? null,
73
+ }
74
+ },
75
+ }
76
+ }
77
+
78
+ /** 合并两次 token 用量(字段按需相加,缺失字段保持缺席)。 */
79
+ function addTokenUsage(a, b) {
80
+ const sum = (x, y) => (Number.isFinite(x) ? x : 0) + (Number.isFinite(y) ? y : 0)
81
+ const merged = {
82
+ inputTokens: sum(a.inputTokens, b.inputTokens),
83
+ outputTokens: sum(a.outputTokens, b.outputTokens),
84
+ }
85
+ if (a.cacheReadTokens != null || b.cacheReadTokens != null) merged.cacheReadTokens = sum(a.cacheReadTokens, b.cacheReadTokens)
86
+ if (a.cacheWriteTokens != null || b.cacheWriteTokens != null) merged.cacheWriteTokens = sum(a.cacheWriteTokens, b.cacheWriteTokens)
87
+ if (a.reasoningTokens != null || b.reasoningTokens != null) merged.reasoningTokens = sum(a.reasoningTokens, b.reasoningTokens)
88
+ return merged
89
+ }
90
+
91
+ /**
92
+ * 人类可读时长:zh/zh-tw `12 秒` `3 分 25 秒`;en `12s` `3m25s`;
93
+ * ja `12 秒` `3 分 25 秒` `1 時間 4 分`;ko `12초` `3분 25초` `1시간 4분`。
94
+ * @param {number} ms
95
+ * @param {string} [language]
96
+ * @returns {string}
97
+ */
98
+ export function formatDuration(ms, language = 'zh') {
99
+ const totalSeconds = Math.max(0, Math.round(ms / 1000))
100
+ const lang = LABELS[language] ? language : 'zh'
101
+ if (lang === 'en') {
102
+ if (totalSeconds < 60) return `${totalSeconds}s`
103
+ const minutes = Math.floor(totalSeconds / 60)
104
+ const seconds = totalSeconds % 60
105
+ if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`
106
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`
107
+ }
108
+ if (lang === 'ja') {
109
+ if (totalSeconds < 60) return `${totalSeconds} 秒`
110
+ const minutes = Math.floor(totalSeconds / 60)
111
+ const seconds = totalSeconds % 60
112
+ if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分`
113
+ return `${Math.floor(minutes / 60)} 時間 ${minutes % 60} 分`
114
+ }
115
+ if (lang === 'ko') {
116
+ if (totalSeconds < 60) return `${totalSeconds}초`
117
+ const minutes = Math.floor(totalSeconds / 60)
118
+ const seconds = totalSeconds % 60
119
+ if (minutes < 60) return seconds ? `${minutes}분 ${seconds}초` : `${minutes}분`
120
+ return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
121
+ }
122
+ // zh / zh-tw
123
+ if (totalSeconds < 60) return `${totalSeconds} 秒`
124
+ const minutes = Math.floor(totalSeconds / 60)
125
+ const seconds = totalSeconds % 60
126
+ if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`
127
+ const hours = Math.floor(minutes / 60)
128
+ return `${hours} 小时 ${minutes % 60} 分`
129
+ }
130
+
131
+ /**
132
+ * 用量一行:zh/zh-tw `1,240 輸入 / 3,560 輸出`;en `1,240 in / 3,560 out`;
133
+ * ja `1,240 入力 / 3,560 出力`;ko `1,240 입력 / 3,560 출력`(不带 tokens 单位后缀)。
134
+ * 输入 = 未缓存 + 缓存读 + 缓存写。
135
+ * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
136
+ * @param {string} [language]
137
+ * @returns {string | null}
138
+ */
139
+ export function summarizeUsage(usage, language = 'zh') {
140
+ if (!usage) return null
141
+ const input = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
142
+ const output = usage.outputTokens ?? 0
143
+ const fmt = (n) => n.toLocaleString('en-US')
144
+ const lang = LABELS[language] ? language : 'zh'
145
+ const iw = { zh: '输入', 'zh-tw': '輸入', en: 'in', ja: '入力', ko: '입력' }[lang]
146
+ const ow = { zh: '输出', 'zh-tw': '輸出', en: 'out', ja: '出力', ko: '출력' }[lang]
147
+ return `${fmt(input)} ${iw} / ${fmt(output)} ${ow}`
148
+ }
149
+
150
+ /**
151
+ * 缓存命中率:缓存读 token /(未缓存输入 + 缓存读 + 缓存写)——无缓存数据返回空串。
152
+ * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
153
+ * @returns {string}
154
+ */
155
+ export function summarizeCache(usage) {
156
+ if (!usage) return ''
157
+ const read = usage.cacheReadTokens ?? 0
158
+ const write = usage.cacheWriteTokens ?? 0
159
+ const plain = usage.inputTokens ?? 0
160
+ const total = plain + read + write
161
+ if (!(total > 0)) return ''
162
+ const pct = Math.round((read / total) * 1000) / 10
163
+ return `${pct}%`
164
+ }
165
+
166
+ /**
167
+ * 缓存命中率(官方 tokenUsage 投影口径,四桶不重叠):
168
+ * 缓存读 /(未缓存输入 + 缓存读 + 缓存写)——与 dsh-web-ui 同源。
169
+ * @param {{ uncachedInputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
170
+ * @returns {string}
171
+ */
172
+ export function officialCacheRate(usage) {
173
+ if (!usage) return ''
174
+ const read = usage.cacheReadTokens ?? 0
175
+ const total = (usage.uncachedInputTokens ?? 0) + read + (usage.cacheWriteTokens ?? 0)
176
+ if (!(total > 0)) return ''
177
+ return `${Math.round((read / total) * 1000) / 10}%`
178
+ }
179
+
180
+ /**
181
+ * 生成速度(官方 sessionStats 投影口径):输出 token ÷ 解码耗时(tok/s)——
182
+ * 与 dsh-web-ui 状态栏同口径(不含排队/准备/工具时间)。
183
+ * @param {{ decodeTokens?: number; decodeMs?: number }} stats
184
+ * @returns {string}
185
+ */
186
+ export function officialTps(stats) {
187
+ if (!stats || !(stats.decodeMs > 0)) return ''
188
+ return `${Math.round((stats.decodeTokens ?? 0) / (stats.decodeMs / 1000))} tok/s`
189
+ }
190
+
191
+ /**
192
+ * 生成速度:输出 token / 用时秒(tok/s)——无数据返回空串。
193
+ * @param {{ outputTokens?: number }} usage
194
+ * @param {number} ms
195
+ * @returns {string}
196
+ */
197
+ export function tpsOf(usage, ms) {
198
+ if (!usage || !(ms > 0)) return ''
199
+ const output = usage.outputTokens ?? 0
200
+ return `${Math.round(output / (ms / 1000))} tok/s`
201
+ }
202
+
203
+ /**
204
+ * 构建系统消息:可折叠行的 summary(= 正文渲染结果的截断,≤120 字符)
205
+ * + 展开/模型可见的全文。
206
+ *
207
+ * @param {string} kind - turn/end 的 reason.kind。
208
+ * @param {{ error?: { message?: string } } | undefined} reasonData
209
+ * @param {{ startedAt: number; endedAt: number; usage?: object | null; includeDuration?: boolean; includeUsage?: boolean; cacheValue?: string; tpsValue?: string; titleValue?: string }} info
210
+ * @param {{ language?: 'zh' | 'en'; templates?: Record<string, string> }} [custom] -
211
+ * language 切换文案语言;templates[reason] 非空时作为正文模板,
212
+ * 支持占位符:{label} {title} {duration} {usage} {error} {cache} {tps}。
213
+ * @returns {{ summary: string; text: string }}
214
+ */
215
+ export function buildNotice(kind, reasonData, { startedAt, endedAt, usage = null, includeDuration = true, includeUsage = true, cacheValue, tpsValue, titleValue }, custom = {}) {
216
+ const language = LABELS[custom.language] ? custom.language : 'zh'
217
+ const L = tableFor(language)
218
+ const templates = custom.templates ?? {}
219
+ const label = labelFor(kind, language)
220
+ const detail = detailFor(reasonData, language)
221
+ let summary = `${label}${detail}`
222
+ const durationLine = includeDuration ? formatDuration(endedAt - startedAt, language) : null
223
+ const usageLine = includeUsage ? summarizeUsage(usage, language) : null
224
+ // 缓存命中/速度:优先官方投影口径(调用方传入),无则退回用量聚合估算
225
+ const cacheLine = typeof cacheValue === 'string' ? cacheValue : (includeUsage ? summarizeCache(usage) : '')
226
+ const tpsLine = typeof tpsValue === 'string' ? tpsValue : (includeUsage ? tpsOf(usage, endedAt - startedAt) : '')
227
+ const parts = []
228
+ if (durationLine) parts.push(`${L.dur} ${durationLine}`)
229
+ if (usageLine) parts.push(`${L.use} ${usageLine}`)
230
+ const suffix = parts.length ? `(${parts.join(',')})` : ''
231
+ const template = templates[kind]
232
+ let text
233
+ if (typeof template === 'string' && template.trim() !== '') {
234
+ // 自定义模板:占位符替换,保持 summary 与正文一致的语义
235
+ text = renderTemplate(template, {
236
+ label,
237
+ duration: durationLine ?? '',
238
+ usage: usageLine ?? '',
239
+ error: plainError(reasonData) || 'none', // 无报错时显示 none,而非空串
240
+ cache: cacheLine,
241
+ tps: tpsLine,
242
+ title: typeof titleValue === 'string' ? titleValue : '',
243
+ })
244
+ } else if (language === 'en') {
245
+ text = `${summary}${suffix ? ` (${parts.join(', ')}).` : '.'}`
246
+ } else if (language === 'ja') {
247
+ text = `${summary}${suffix ? `(${parts.join('、')})。` : '。'}`
248
+ } else if (language === 'ko') {
249
+ text = `${summary}${suffix ? `(${parts.join(', ')})。` : '。'}`
250
+ } else {
251
+ text = `${summary}${suffix}。`
252
+ }
253
+ return {
254
+ // 折叠行 summary 与正文同源:自定义模板(含 {title})与默认文案都展示
255
+ // 渲染结果(截断至 120 字符)——只看折叠行的用户必须能看见真实标题与
256
+ // 用时/消耗,否则会误以为 {title} 没有生效(历史教训:summary 只放标签
257
+ // 「会话已完成」,用户展开前完全看不到内容)。
258
+ summary: bound(text, 120),
259
+ text,
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 模板占位符替换:{label} 已全面移除(模板中直接剥除);支持 {duration} {usage} {error} {cache} {tps}。
265
+ * @param {string} template
266
+ * @param {{ label: string; duration: string; usage: string; error: string; cache: string; tps: string }} vars
267
+ * @returns {string}
268
+ */
269
+ export function renderTemplate(template, vars) {
270
+ return String(template)
271
+ .replace(/\{label\}/g, '')
272
+ .replaceAll('{title}', vars.title ?? '')
273
+ .replaceAll('{duration}', vars.duration)
274
+ .replaceAll('{usage}', vars.usage)
275
+ .replaceAll('{error}', vars.error)
276
+ .replaceAll('{cache}', vars.cache)
277
+ .replaceAll('{tps}', vars.tps)
278
+ }
279
+
280
+ /** error reason 的纯文本详情(单行、截断;无则空串)。 */
281
+ function plainError(reasonData) {
282
+ const message = reasonData?.error?.message
283
+ if (!message) return ''
284
+ const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
285
+ return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat
286
+ }
287
+
288
+ /** error reason 的错误详情(单行、截断,分隔符随语言)。 */
289
+ function detailFor(reasonData, language = 'zh') {
290
+ const message = reasonData?.error?.message
291
+ if (!message) return ''
292
+ const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
293
+ if (!flat) return ''
294
+ const sep = tableFor(language).sep
295
+ return `${sep}${flat.length > 40 ? `${flat.slice(0, 40)}…` : flat}`
296
+ }
297
+
298
+ /** 按字符数截断(≤ max)。 */
299
+ function bound(text, max) {
300
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
301
+ }
302
+
303
+ export const __internals = { LABELS, addTokenUsage, detailFor, bound }