dsh-superinput 0.0.0-stage → 0.7.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/CHANGELOG.md +140 -0
- package/README.md +38 -2
- package/client.js +5292 -0
- package/cordis.patch.yml +6 -0
- package/index.js +34 -0
- package/modules/cost.js +1346 -0
- package/modules/enhancer.js +726 -0
- package/modules/followup.js +718 -0
- package/modules/hub.client.js +218 -0
- package/modules/hub.js +78 -0
- package/modules/picker.js +133 -0
- package/modules/prompts.client.js +698 -0
- package/modules/prompts.js +305 -0
- package/modules/voice.js +17 -0
- package/package.json +61 -4
- package/scripts/build-suite.mjs +315 -0
- package/scripts/smoke.mjs +121 -0
- package/server.js +3428 -0
package/server.js
ADDED
|
@@ -0,0 +1,3428 @@
|
|
|
1
|
+
/* dsh-superinput 服务端核心(由 scripts/build-suite.mjs 生成;勿手改)。
|
|
2
|
+
* 入口拆分:index.js 是零依赖探针(先 trace 再动态 import 本文件并捕获失败)——
|
|
3
|
+
* 顶层静态 import 的失败发生在入口任何语句之前,静默且永远取不到证据。 */
|
|
4
|
+
import { appendFileSync as diagAppend, mkdirSync as diagMkdir } from 'node:fs'
|
|
5
|
+
import { homedir as diagHomedir } from 'node:os'
|
|
6
|
+
import { dirname as diagDirname, join as pathJoin } from 'node:path'
|
|
7
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
+
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
11
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
const SUITE_DIAG = pathJoin(diagHomedir(), '.dsh', 'superinput-debug.log')
|
|
18
|
+
const trace = (line) => {
|
|
19
|
+
try {
|
|
20
|
+
diagMkdir(diagDirname(SUITE_DIAG), { recursive: true })
|
|
21
|
+
diagAppend(SUITE_DIAG, `${new Date().toISOString()} ${line}\n`, 'utf8')
|
|
22
|
+
} catch { /* ignore */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ==== module: cost(原 dsh-cost) ==== */
|
|
26
|
+
const costModule = (() => {
|
|
27
|
+
/* 由 scripts/build-suite.mjs 从 dsh-cost/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
28
|
+
/**
|
|
29
|
+
* dsh-cost — DeepSeek Harness (dsh) 会话费用统计插件
|
|
30
|
+
*
|
|
31
|
+
* 借鉴 Han-1413141/dsh-cost-meter(v1.9.39)的核心数据模式,MVP 落地:
|
|
32
|
+
* - 主通道 ctx.on('llm/stream', listener, { global: true }):llm 服务官方
|
|
33
|
+
* 瀑布事件,链尾旁路观察逐 chunk 透传,捕获 usage 块(五桶
|
|
34
|
+
* input/output/cacheRead/cacheWrite/reasoning,宿主的 input 与
|
|
35
|
+
* cacheRead/cacheWrite 是互斥桶),流结束后按 DeepSeek 官方价格入账。
|
|
36
|
+
* - 计费数学与峰谷规则照抄 cost-meter lib/pricing.js:现行价(2026-09-10
|
|
37
|
+
* 北京时间中午后)flash 谷 $0.003/$0.15/$0.60、pro 谷 $0.022/$0.66/$1.98,
|
|
38
|
+
* 峰时 ×2;峰窗 UTC [01,04)∪[06,10)(北京 09-12 / 14-18 点),北京周六
|
|
39
|
+
* 日与法定节假日全天谷价;历史流量按当时档位(rateHistory / legacyBase)。
|
|
40
|
+
* - 账本 ~/.dsh/storages/dsh-cost/ledger.json:按日聚合(总/按模型/按会话),
|
|
41
|
+
* 金额恒为美元存储(与展示币种解耦,cost-meter 同款),临时文件原子
|
|
42
|
+
* 重命名 + 防抖写盘。
|
|
43
|
+
* - HTTP 端点 GET /api/dsh-cost/state|prices 供客户端渲染。
|
|
44
|
+
* - 异步局部存储(ALS)深度标记防包装路由插件嵌套重复入账(cost-meter
|
|
45
|
+
* 同款;本插件自己从不发起 llm 调用)。
|
|
46
|
+
*
|
|
47
|
+
* 已核对的 dsh API(对照 dsh-reference 与 cost-meter 源码):
|
|
48
|
+
* - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
|
|
49
|
+
* 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套时原样
|
|
50
|
+
* 返回 next() 的下游
|
|
51
|
+
* - GenerateOptions:{ sessionId?, model, provider, messages, ... }
|
|
52
|
+
* - StreamChunk:{ type:'usage', usage:{inputTokens,outputTokens,
|
|
53
|
+
* cacheReadTokens,cacheWriteTokens,reasoningTokens} }(usage 位于 finish 前)
|
|
54
|
+
* - webServer.register({ kind:'prefix', path, handler }, label)
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const name = 'cost'
|
|
65
|
+
|
|
66
|
+
const DIAG_LOG = join(homedir(), '.dsh', 'cost-debug.log')
|
|
67
|
+
const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-cost')
|
|
68
|
+
const LEDGER_PATH = join(STORE_DIR, 'ledger.json')
|
|
69
|
+
const JOURNAL_PATH = join(STORE_DIR, 'journal.jsonl')
|
|
70
|
+
|
|
71
|
+
const report = async (line) => {
|
|
72
|
+
try {
|
|
73
|
+
await mkdir(dirname(DIAG_LOG), { recursive: true })
|
|
74
|
+
await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
|
|
75
|
+
} catch { /* ignore */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
/* 价格表(USD / 1M tokens;照抄 cost-meter lib/pricing.js 现行档) */
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
|
|
82
|
+
/** 峰谷时代分界(2026-08-16 16:00 UTC):此前按历史基础价,无峰谷概念。 */
|
|
83
|
+
const LEGACY_BASE_BOUNDARY = Date.parse('2026-08-16T16:00:00Z')
|
|
84
|
+
/** Flash 调价边界(2026-09-10 北京时间中午 12:00 = UTC 04:00);Pro 未调价。 */
|
|
85
|
+
const FLASH_PRICE_EFFECTIVE_AT = Date.parse('2026-09-10T04:00:00Z')
|
|
86
|
+
/** 周末全天谷价生效时刻(2026-08-23 北京时间零点)。 */
|
|
87
|
+
const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.parse('2026-08-22T16:00:00Z')
|
|
88
|
+
|
|
89
|
+
/** 峰时段窗口(UTC 小时,半开区间 [start,end))= 北京 09-12 / 14-18 点。 */
|
|
90
|
+
const PEAK_WINDOWS = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
|
|
91
|
+
/** 国务院办公厅 2026 年安排中峰谷规则生效后的剩余公众假期(北京日历日)。 */
|
|
92
|
+
const PEAK_HOLIDAYS = new Set([
|
|
93
|
+
'2026-09-25', '2026-09-26', '2026-09-27',
|
|
94
|
+
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
|
|
95
|
+
'2026-10-05', '2026-10-06', '2026-10-07',
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
const double = (tier) => Object.fromEntries(Object.entries(tier).map(([key, value]) => [key, value * 2]))
|
|
99
|
+
|
|
100
|
+
/** 档位形状:{ legacyBase, august, offPeak, peak }(后三者带峰谷两档)。 */
|
|
101
|
+
const deepseekFlash = {
|
|
102
|
+
legacyBase: { cacheHit: 0.0028, cacheMiss: 0.14, output: 0.28 },
|
|
103
|
+
august: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66, peak: double({ cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }) },
|
|
104
|
+
offPeak: { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 },
|
|
105
|
+
peak: double({ cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 }),
|
|
106
|
+
}
|
|
107
|
+
const deepseekPro = {
|
|
108
|
+
legacyBase: { cacheHit: 0.003625, cacheMiss: 0.435, output: 0.87 },
|
|
109
|
+
august: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98, peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }) },
|
|
110
|
+
offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
|
|
111
|
+
peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }),
|
|
112
|
+
}
|
|
113
|
+
/** 已下架旧模型的唯一历史价(只可能出现在峰谷时代之前)。 */
|
|
114
|
+
const legacyOnlyModels = {
|
|
115
|
+
deepseekchat: { legacyBase: { cacheHit: 0.07, cacheMiss: 0.27, output: 1.1 } },
|
|
116
|
+
deepseekreasoner: { legacyBase: { cacheHit: 0.14, cacheMiss: 0.55, output: 2.19 } },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const normalizeModelId = (value) => String(value ?? '').toLowerCase().replace(/[._\-\s]/g, '')
|
|
120
|
+
|
|
121
|
+
function isPeakAt(atMs) {
|
|
122
|
+
// 北京日界 = UTC+8;1970-01-01 为周四(weekday 4)。
|
|
123
|
+
const day = Math.floor((atMs + 8 * 3600000) / 86400000)
|
|
124
|
+
const weekday = (day + 4) % 7
|
|
125
|
+
if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && (weekday === 6 || weekday === 0)) return false
|
|
126
|
+
const key = new Date(day * 86400000).toISOString().slice(0, 10)
|
|
127
|
+
if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && PEAK_HOLIDAYS.has(key)) return false
|
|
128
|
+
const hour = new Date(atMs).getUTCHours()
|
|
129
|
+
return PEAK_WINDOWS.some(({ start, end }) => hour >= start && hour < end)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 选档:历史时刻回落历史价(rateHistory 语义的简化实现)。 */
|
|
133
|
+
function tierFor(entry, atMs, peak) {
|
|
134
|
+
if (entry.legacyBase && atMs < LEGACY_BASE_BOUNDARY) return entry.legacyBase
|
|
135
|
+
if (entry.august && atMs < FLASH_PRICE_EFFECTIVE_AT) return entry.august
|
|
136
|
+
return (peak && entry.peak) || entry.offPeak || entry.peak || entry.august || entry.legacyBase || { cacheHit: 0, cacheMiss: 0, output: 0 }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 单次调用成本(USD)。数学照抄 cost-meter costOf:五桶互斥,
|
|
141
|
+
* cacheWrite 缺价按 cacheHit,reasoning 缺价按 0(DeepSeek 推理并入输出)。
|
|
142
|
+
*/
|
|
143
|
+
function costOfBuckets(buckets, tier) {
|
|
144
|
+
const amount = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
|
|
145
|
+
const cost = (amount(buckets.input) * tier.cacheMiss
|
|
146
|
+
+ amount(buckets.output) * tier.output
|
|
147
|
+
+ amount(buckets.cacheRead) * tier.cacheHit
|
|
148
|
+
+ amount(buckets.cacheWrite) * (tier.cacheWrite ?? tier.cacheHit)
|
|
149
|
+
+ amount(buckets.reasoning) * (typeof tier.reasoning === 'number' ? tier.reasoning : 0)) / 1_000_000
|
|
150
|
+
return Number.isFinite(cost) && cost > 0 ? cost : 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------------------ */
|
|
154
|
+
/* 用量指纹去重(cost-meter issue #70/#76 同款兜底) */
|
|
155
|
+
/* ------------------------------------------------------------------ */
|
|
156
|
+
|
|
157
|
+
/** 桶名 → 价格键(costOfBuckets 同款映射;cacheWrite 缺价按 cacheHit)。 */
|
|
158
|
+
const BUCKET_PRICE_KEYS = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
|
|
159
|
+
|
|
160
|
+
/** 一次调用的分桶计价行(cost-meter costFromSamples 同形状),供回合明细。 */
|
|
161
|
+
function bucketRows(buckets, tier) {
|
|
162
|
+
const rows = []
|
|
163
|
+
for (const [bucket, priceKey] of Object.entries(BUCKET_PRICE_KEYS)) {
|
|
164
|
+
const tokens = Number(buckets[bucket]) > 0 ? Number(buckets[bucket]) : 0
|
|
165
|
+
if (!tokens) continue
|
|
166
|
+
const rate = Number(tier[priceKey] ?? (priceKey === 'cacheWrite' ? tier.cacheHit : 0))
|
|
167
|
+
rows.push({ bucket, tokens, rate, cost: tokens * rate / 1_000_000 })
|
|
168
|
+
}
|
|
169
|
+
return rows
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* (model, 五桶指纹) 10s 窗互斥:包装路由插件在 ALS 标记逃逸的异步上下文里
|
|
174
|
+
* 发起上游调用时,同一请求的两份 usage 都会到达这里——五桶逐位相同即判重,
|
|
175
|
+
* 无论到达顺序只记第一次。正常独立调用五桶逐位相同的概率可忽略。
|
|
176
|
+
*/
|
|
177
|
+
function createUsageDeduper(windowMs = 10_000) {
|
|
178
|
+
const seen = new Map()
|
|
179
|
+
return (model, buckets, atMs) => {
|
|
180
|
+
const fingerprint = `${normalizeModelId(model)}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
|
|
181
|
+
const last = seen.get(fingerprint)
|
|
182
|
+
if (last !== undefined && atMs - last < windowMs) return false
|
|
183
|
+
seen.set(fingerprint, atMs)
|
|
184
|
+
if (seen.size > 512) {
|
|
185
|
+
const cutoff = atMs - windowMs
|
|
186
|
+
for (const [key, seenAt] of seen) if (seenAt < cutoff) seen.delete(key)
|
|
187
|
+
}
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ------------------------------------------------------------------ */
|
|
193
|
+
/* 调用明细 journal(JSONL 追加;rotate 保留最近 3 卷) */
|
|
194
|
+
/* ------------------------------------------------------------------ */
|
|
195
|
+
|
|
196
|
+
const JOURNAL_MAX_BYTES = 8 * 1024 * 1024
|
|
197
|
+
const JOURNAL_KEEP = 3
|
|
198
|
+
|
|
199
|
+
function createJournal() {
|
|
200
|
+
let lines = 0
|
|
201
|
+
return {
|
|
202
|
+
/** 明细纯记录,写失败绝不影响聚合账本。 */
|
|
203
|
+
async write(record) {
|
|
204
|
+
try {
|
|
205
|
+
lines += 1
|
|
206
|
+
if (lines % 500 === 0) {
|
|
207
|
+
const info = await stat(JOURNAL_PATH).catch(() => null)
|
|
208
|
+
if (info && info.size > JOURNAL_MAX_BYTES) {
|
|
209
|
+
await rename(JOURNAL_PATH, join(STORE_DIR, `journal-${Date.now()}.jsonl`)).catch(() => {})
|
|
210
|
+
try {
|
|
211
|
+
const entries = await readdir(STORE_DIR)
|
|
212
|
+
const archives = entries.filter((name) => /^journal-\d+\.jsonl$/.test(name)).sort()
|
|
213
|
+
while (archives.length > JOURNAL_KEEP) {
|
|
214
|
+
await unlink(join(STORE_DIR, archives.shift())).catch(() => {})
|
|
215
|
+
}
|
|
216
|
+
} catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
await appendFile(JOURNAL_PATH, `${JSON.stringify(record)}\n`, 'utf8')
|
|
220
|
+
} catch { /* ignore */ }
|
|
221
|
+
},
|
|
222
|
+
/** 读当前卷的倒序明细(归档卷不参与,最近 UI 只看当下)。 */
|
|
223
|
+
async recent({ sessionId = '', day = '', limit = 20 } = {}) {
|
|
224
|
+
let text = ''
|
|
225
|
+
try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return [] }
|
|
226
|
+
const rows = []
|
|
227
|
+
for (const line of text.split('\n')) {
|
|
228
|
+
if (!line) continue
|
|
229
|
+
try {
|
|
230
|
+
const row = JSON.parse(line)
|
|
231
|
+
if (sessionId && row.session !== sessionId) continue
|
|
232
|
+
if (day && row.day !== day) continue
|
|
233
|
+
rows.push(row)
|
|
234
|
+
} catch { /* 半行跳过 */ }
|
|
235
|
+
}
|
|
236
|
+
rows.reverse()
|
|
237
|
+
return rows.slice(0, Math.min(200, Math.max(1, limit)))
|
|
238
|
+
},
|
|
239
|
+
/**
|
|
240
|
+
* 增量读取:从字节偏移起新增的完整行(字节级,避免多字节字符错位;
|
|
241
|
+
* 轮转后新文件比偏移小 → 从头读新卷,天然不会重放旧卷)。
|
|
242
|
+
* 供锁持有者把 journal 折叠进 ledger(跨进程单写者模型)。
|
|
243
|
+
*/
|
|
244
|
+
async recordsAfter(offset = 0) {
|
|
245
|
+
try {
|
|
246
|
+
const info = await stat(JOURNAL_PATH)
|
|
247
|
+
if (info.size === offset) return { rows: [], offset }
|
|
248
|
+
if (info.size < offset) offset = 0
|
|
249
|
+
const buf = await readFile(JOURNAL_PATH)
|
|
250
|
+
let start = offset
|
|
251
|
+
if (offset > 0) {
|
|
252
|
+
// 偏移已在行边界(前一字节是换行)则直接用;落在行中间才跳到下一行。
|
|
253
|
+
if (buf[offset - 1] !== 10) {
|
|
254
|
+
const nl = buf.indexOf(10, offset)
|
|
255
|
+
if (nl < 0) return { rows: [], offset: buf.length }
|
|
256
|
+
start = nl + 1
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const rows = []
|
|
260
|
+
for (const line of buf.slice(start).toString('utf8').split('\n')) {
|
|
261
|
+
if (!line) continue
|
|
262
|
+
try { rows.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
|
|
263
|
+
}
|
|
264
|
+
return { rows, offset: buf.length }
|
|
265
|
+
} catch { return { rows: [], offset } }
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 回合费用:会话在 [from,to] 时间窗内的调用聚合(客户端 turnTail 槽位
|
|
270
|
+
* 传宿主 turn/start~turn/end 事件时间)。journal 只保当前卷——轮转掉的
|
|
271
|
+
* 旧回合查不到,金额对 UI 恒标「≈」估算口径。
|
|
272
|
+
*/ async range({ sessionId = '', from = 0, to = Number.MAX_SAFE_INTEGER } = {}) {
|
|
273
|
+
let text = ''
|
|
274
|
+
try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return { found: false, cost: 0, rows: [], calls: [] } }
|
|
275
|
+
const calls = []
|
|
276
|
+
const parts = new Map()
|
|
277
|
+
for (const line of text.split('\n')) {
|
|
278
|
+
if (!line) continue
|
|
279
|
+
let row
|
|
280
|
+
try { row = JSON.parse(line) } catch { continue }
|
|
281
|
+
if (row.session !== sessionId || !(row.at >= from && row.at <= to)) continue
|
|
282
|
+
calls.push({ model: row.model, provider: row.provider, at: row.at, cost: row.cost })
|
|
283
|
+
for (const item of Array.isArray(row.rows) ? row.rows : []) {
|
|
284
|
+
const key = JSON.stringify([row.provider, row.model, item.bucket])
|
|
285
|
+
const part = parts.get(key) ?? { provider: row.provider, model: row.model, bucket: item.bucket, tokens: 0, rate: item.rate, cost: 0 }
|
|
286
|
+
part.tokens += item.tokens
|
|
287
|
+
part.cost += item.cost
|
|
288
|
+
parts.set(key, part)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
calls.sort((a, b) => a.at - b.at)
|
|
292
|
+
const rows = [...parts.values()].sort((a, b) => b.cost - a.cost)
|
|
293
|
+
return { found: calls.length > 0, cost: calls.reduce((sum, call) => sum + call.cost, 0), rows, calls }
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ------------------------------------------------------------------ */
|
|
299
|
+
/* 账本 */
|
|
300
|
+
/* ------------------------------------------------------------------ */
|
|
301
|
+
|
|
302
|
+
const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
|
|
303
|
+
const localDayKey = (atMs) => {
|
|
304
|
+
const d = new Date(atMs)
|
|
305
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
306
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createLedger(retentionDays) {
|
|
310
|
+
let state = { version: 1, days: {}, imported: {} }
|
|
311
|
+
let dirty = false
|
|
312
|
+
let flushTimer = null
|
|
313
|
+
/** 最近调用明细(内存,重启清空):费用弹卡按时间倒序展示。 */
|
|
314
|
+
const RECENT_CALLS_MAX = 200
|
|
315
|
+
const recentCalls = []
|
|
316
|
+
|
|
317
|
+
const load = () => {
|
|
318
|
+
try {
|
|
319
|
+
const raw = JSON.parse(readFileSync(LEDGER_PATH, 'utf8'))
|
|
320
|
+
if (raw && typeof raw === 'object' && raw.days && typeof raw.days === 'object') state = raw
|
|
321
|
+
} catch { /* 首次运行或损坏:从空账本开始 */ }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const flushSync = () => {
|
|
325
|
+
if (!dirty) return
|
|
326
|
+
try {
|
|
327
|
+
// 保留天数裁剪:超龄日直接移除(cost-meter 同口径)。
|
|
328
|
+
if (Number.isFinite(retentionDays) && retentionDays > 0) {
|
|
329
|
+
const cutoff = localDayKey(Date.now() - retentionDays * 86400000)
|
|
330
|
+
for (const key of Object.keys(state.days)) if (key < cutoff) delete state.days[key]
|
|
331
|
+
}
|
|
332
|
+
mkdirSync(STORE_DIR, { recursive: true })
|
|
333
|
+
const tmp = `${LEDGER_PATH}.${process.pid}.tmp`
|
|
334
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf8')
|
|
335
|
+
renameSync(tmp, LEDGER_PATH)
|
|
336
|
+
dirty = false
|
|
337
|
+
} catch (error) {
|
|
338
|
+
void report(`ledger flush failed: ${String(error?.message ?? error).slice(0, 200)}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const scheduleFlush = () => {
|
|
343
|
+
dirty = true
|
|
344
|
+
if (flushTimer) return
|
|
345
|
+
flushTimer = setTimeout(() => { flushTimer = null; flushSync() }, 800)
|
|
346
|
+
flushTimer.unref?.()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const dayOf = (atMs) => {
|
|
350
|
+
const key = localDayKey(atMs)
|
|
351
|
+
let day = state.days[key]
|
|
352
|
+
if (!day) {
|
|
353
|
+
day = { ...zeroBuckets(), calls: 0, cost: 0, byModel: {}, sessions: {} }
|
|
354
|
+
state.days[key] = day
|
|
355
|
+
}
|
|
356
|
+
return day
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
load,
|
|
361
|
+
flushSync,
|
|
362
|
+
dispose() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null } flushSync() },
|
|
363
|
+
/** 回填游标:该会话日志已覆盖到的事件时刻(实时记账同样推进,幂等切割)。 */
|
|
364
|
+
importedAt(sessionKey) {
|
|
365
|
+
return state.imported?.[sessionKey] ?? 0
|
|
366
|
+
},
|
|
367
|
+
markImported(sessionKey, atMs) {
|
|
368
|
+
if (!state.imported) state.imported = {}
|
|
369
|
+
const current = state.imported[sessionKey] ?? 0
|
|
370
|
+
if (atMs <= current) return false
|
|
371
|
+
state.imported[sessionKey] = atMs
|
|
372
|
+
// 裁剪:只保留最近 500 个会话的游标。
|
|
373
|
+
const keys = Object.keys(state.imported)
|
|
374
|
+
if (keys.length > 500) {
|
|
375
|
+
for (const key of keys.slice(0, keys.length - 500)) delete state.imported[key]
|
|
376
|
+
}
|
|
377
|
+
scheduleFlush()
|
|
378
|
+
return true
|
|
379
|
+
},
|
|
380
|
+
/** journal 折叠进度(字节偏移,随 ledger 一起落盘 → 崩溃安全,不重不漏)。 */
|
|
381
|
+
journalOffset() { return state.journalOffset ?? 0 },
|
|
382
|
+
setJournalOffset(offset) {
|
|
383
|
+
if (!Number.isFinite(offset) || offset <= (state.journalOffset ?? 0)) return
|
|
384
|
+
state.journalOffset = offset
|
|
385
|
+
scheduleFlush()
|
|
386
|
+
},
|
|
387
|
+
/** 官方同步的现行价格(flash/pro 峰谷档)与汇率——覆盖内置表,历史档不动。 */
|
|
388
|
+
livePrices() { return state.live?.prices ?? null },
|
|
389
|
+
setLivePrices(tiers) {
|
|
390
|
+
state.live = { ...(state.live ?? {}), prices: tiers }
|
|
391
|
+
scheduleFlush()
|
|
392
|
+
},
|
|
393
|
+
liveExchangeRate() { return state.live?.exchangeRate ?? null },
|
|
394
|
+
setExchangeRate(rate, fetchedAt) {
|
|
395
|
+
state.live = { ...(state.live ?? {}), exchangeRate: { rate, fetchedAt } }
|
|
396
|
+
scheduleFlush()
|
|
397
|
+
},
|
|
398
|
+
/** 最近调用明细,时间倒序(费用弹卡用)。 */
|
|
399
|
+
recentCalls(limit = 50) {
|
|
400
|
+
return recentCalls.slice(-Math.max(1, limit)).reverse()
|
|
401
|
+
},
|
|
402
|
+
/** 累加一次模型调用的用量与费用。source 区分对话 / 增强 / 建议生成。 */
|
|
403
|
+
account({ sessionId, model, buckets, cost, atMs, source }) {
|
|
404
|
+
const day = dayOf(atMs)
|
|
405
|
+
const src = typeof source === 'string' && source ? source : 'chat'
|
|
406
|
+
const addTo = (target) => {
|
|
407
|
+
for (const key of Object.keys(zeroBuckets())) target[key] += buckets[key] ?? 0
|
|
408
|
+
target.calls += 1
|
|
409
|
+
target.cost += cost
|
|
410
|
+
}
|
|
411
|
+
addTo(day)
|
|
412
|
+
// 按来源分账,费用弹卡才能把「增强」和「对话」分开显示。
|
|
413
|
+
const bySource = day.bySource ?? (day.bySource = {})
|
|
414
|
+
const srcEntry = bySource[src] ?? (bySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
|
|
415
|
+
addTo(srcEntry)
|
|
416
|
+
// 最近调用明细(内存环形缓冲,重启清空;历史总额仍走 days)。
|
|
417
|
+
recentCalls.push({
|
|
418
|
+
at: atMs, session: sessionId || '', model: model || '', source: src,
|
|
419
|
+
tokens: (buckets.input ?? 0) + (buckets.output ?? 0) + (buckets.cacheRead ?? 0) + (buckets.cacheWrite ?? 0),
|
|
420
|
+
input: buckets.input ?? 0, output: buckets.output ?? 0,
|
|
421
|
+
cacheRead: buckets.cacheRead ?? 0, cost,
|
|
422
|
+
})
|
|
423
|
+
if (recentCalls.length > RECENT_CALLS_MAX) recentCalls.splice(0, recentCalls.length - RECENT_CALLS_MAX)
|
|
424
|
+
if (model) {
|
|
425
|
+
const entry = day.byModel[model] ?? (day.byModel[model] = { ...zeroBuckets(), calls: 0, cost: 0 })
|
|
426
|
+
addTo(entry)
|
|
427
|
+
}
|
|
428
|
+
if (sessionId) {
|
|
429
|
+
const session = day.sessions[sessionId] ?? (day.sessions[sessionId] = { ...zeroBuckets(), calls: 0, cost: 0, at: 0 })
|
|
430
|
+
addTo(session)
|
|
431
|
+
const sessionBySource = session.bySource ?? (session.bySource = {})
|
|
432
|
+
const sessionSrc = sessionBySource[src] ?? (sessionBySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
|
|
433
|
+
addTo(sessionSrc)
|
|
434
|
+
session.at = Math.max(session.at, atMs)
|
|
435
|
+
// 回填切割游标同步推进:回填只补「游标之后」的日志事件,幂等不重复。
|
|
436
|
+
if (!state.imported) state.imported = {}
|
|
437
|
+
if (atMs > (state.imported[sessionId] ?? 0)) state.imported[sessionId] = atMs
|
|
438
|
+
}
|
|
439
|
+
scheduleFlush()
|
|
440
|
+
},
|
|
441
|
+
snapshot() { return state },
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* ------------------------------------------------------------------ */
|
|
446
|
+
/* 历史回填(cost-meter backfill 同思路:重放会话日志的 usage 事件) */
|
|
447
|
+
/* ------------------------------------------------------------------ */
|
|
448
|
+
|
|
449
|
+
const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
|
|
450
|
+
/** 单日志解压上限(大会话整串解压占内存,超限跳过并留痕)。 */
|
|
451
|
+
const BACKFILL_MAX_LOG_BYTES = 64 * 1024 * 1024
|
|
452
|
+
const num = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
|
|
453
|
+
|
|
454
|
+
/** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
|
|
455
|
+
async function decompressSessionLog(file) {
|
|
456
|
+
const info = await stat(file).catch(() => null)
|
|
457
|
+
if (!info || info.size > BACKFILL_MAX_LOG_BYTES) return null
|
|
458
|
+
if (file.endsWith('.zstd')) {
|
|
459
|
+
const zlib = await import('node:zlib')
|
|
460
|
+
if (typeof zlib.zstdDecompressSync !== 'function') return null
|
|
461
|
+
const buf = await readFile(file)
|
|
462
|
+
const idxs = [0]
|
|
463
|
+
for (let i = 4; i <= buf.length - 4; i += 1) {
|
|
464
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
|
|
465
|
+
}
|
|
466
|
+
let out = ''
|
|
467
|
+
for (let k = 0; k < idxs.length; k += 1) {
|
|
468
|
+
try { out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8') } catch { /* 截断/坏帧跳过 */ }
|
|
469
|
+
}
|
|
470
|
+
return out
|
|
471
|
+
}
|
|
472
|
+
return readFile(file, 'utf8')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 目录内最新的会话日志(版本号最大、zstd 优先)。 */
|
|
476
|
+
async function pickSessionLog(dir) {
|
|
477
|
+
const entries = await readdir(dir).catch(() => [])
|
|
478
|
+
const logs = entries.filter((name) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(name))
|
|
479
|
+
if (logs.length === 0) return null
|
|
480
|
+
logs.sort((a, b) => {
|
|
481
|
+
const versionOf = (name) => Number((name.match(/\.v(\d+)\./) ?? [])[1] ?? 0)
|
|
482
|
+
const zstdOf = (name) => (name.endsWith('.zstd') ? 1 : 0)
|
|
483
|
+
return (versionOf(a) - versionOf(b)) || (zstdOf(a) - zstdOf(b))
|
|
484
|
+
})
|
|
485
|
+
return join(dir, logs[logs.length - 1])
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 重放会话事件流 → 用量样本(cost-meter replaySessionRecords 核心子集):
|
|
490
|
+
* request/header 切换当前路由;usage 取自 assistant/chunk(usage 块) /
|
|
491
|
+
* assistant/message(data.usage) / compaction/summary,按「回合位置 + 五桶
|
|
492
|
+
* 指纹」去重(同一步的 chunk 与 message 双份只记一次,retry 用量不同照记);
|
|
493
|
+
* 只取游标之后的事件(与实时计量幂等切割)。
|
|
494
|
+
*/
|
|
495
|
+
function samplesFromEvents(events, cursorAtMs) {
|
|
496
|
+
let provider = 'deepseek'
|
|
497
|
+
let model = 'default'
|
|
498
|
+
let sessionId = ''
|
|
499
|
+
const seen = new Set()
|
|
500
|
+
const samples = []
|
|
501
|
+
for (const event of events) {
|
|
502
|
+
if (event.type === 'request/header') {
|
|
503
|
+
const header = event.data?.header?.config
|
|
504
|
+
if (typeof header?.model === 'string' && header.model) model = header.model
|
|
505
|
+
if (typeof header?.provider === 'string' && header.provider) provider = header.provider
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
if (event.type === 'session' && typeof event.id === 'string' && !sessionId) sessionId = event.id
|
|
509
|
+
let usage = null
|
|
510
|
+
let positionKey = ''
|
|
511
|
+
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
|
|
512
|
+
usage = event.data.chunk.usage
|
|
513
|
+
positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
|
|
514
|
+
} else if (event.type === 'assistant/message' && event.data?.usage != null) {
|
|
515
|
+
usage = event.data.usage
|
|
516
|
+
positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
|
|
517
|
+
const source = event.data?.message?.source
|
|
518
|
+
if (typeof source?.model === 'string' && source.model) model = source.model
|
|
519
|
+
if (typeof source?.provider === 'string' && source.provider) provider = source.provider
|
|
520
|
+
} else if (event.type === 'compaction/summary' && event.data?.usage != null) {
|
|
521
|
+
usage = event.data.usage
|
|
522
|
+
positionKey = `c:${event.seq ?? ''}`
|
|
523
|
+
} else {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
const atMs = Number(event.time)
|
|
527
|
+
if (!Number.isFinite(atMs) || atMs <= 0 || atMs <= cursorAtMs) continue
|
|
528
|
+
const buckets = {
|
|
529
|
+
input: num(usage.inputTokens),
|
|
530
|
+
output: num(usage.outputTokens),
|
|
531
|
+
cacheRead: num(usage.cacheReadTokens),
|
|
532
|
+
cacheWrite: num(usage.cacheWriteTokens),
|
|
533
|
+
reasoning: num(usage.reasoningTokens),
|
|
534
|
+
}
|
|
535
|
+
const fingerprint = `${positionKey}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
|
|
536
|
+
if (seen.has(fingerprint)) continue
|
|
537
|
+
seen.add(fingerprint)
|
|
538
|
+
samples.push({ provider, model, buckets, atMs })
|
|
539
|
+
}
|
|
540
|
+
return { sessionId, samples }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 扫描全部会话日志回填历史(仅锁持有者,boot 后延迟触发;游标幂等)。
|
|
545
|
+
* 0.7.0 起回填样本写 journal(与实时计量同源),由 ingest 统一折叠——
|
|
546
|
+
* 回填与实时对同一调用的重叠由 ingest 的指纹去重互斥,永不双计。
|
|
547
|
+
*/
|
|
548
|
+
async function runBackfill(journal, ledger, priceEntryFor) {
|
|
549
|
+
const workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }).catch(() => [])
|
|
550
|
+
let touchedSessions = 0
|
|
551
|
+
let importedCalls = 0
|
|
552
|
+
for (const workspace of workspaces) {
|
|
553
|
+
if (!workspace.isDirectory()) continue
|
|
554
|
+
const workspaceDir = join(SESSIONS_ROOT, workspace.name)
|
|
555
|
+
const sessionDirs = await readdir(workspaceDir, { withFileTypes: true }).catch(() => [])
|
|
556
|
+
for (const sessionDir of sessionDirs) {
|
|
557
|
+
if (!sessionDir.isDirectory()) continue
|
|
558
|
+
const logFile = await pickSessionLog(join(workspaceDir, sessionDir.name))
|
|
559
|
+
if (!logFile) continue
|
|
560
|
+
try {
|
|
561
|
+
const text = await decompressSessionLog(logFile)
|
|
562
|
+
if (text === null) continue
|
|
563
|
+
const events = []
|
|
564
|
+
for (const line of text.split('\n')) {
|
|
565
|
+
if (!line) continue
|
|
566
|
+
try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
|
|
567
|
+
}
|
|
568
|
+
const probe = samplesFromEvents(events, 0)
|
|
569
|
+
const sessionId = probe.sessionId
|
|
570
|
+
const { samples } = samplesFromEvents(events, sessionId ? ledger.importedAt(sessionId) : 0)
|
|
571
|
+
let maxAt = 0
|
|
572
|
+
for (const sample of samples) {
|
|
573
|
+
const entry = priceEntryFor(sample.model)
|
|
574
|
+
if (!entry) continue
|
|
575
|
+
const peak = isPeakAt(sample.atMs)
|
|
576
|
+
const tier = tierFor(entry, sample.atMs, peak)
|
|
577
|
+
const cost = costOfBuckets(sample.buckets, tier)
|
|
578
|
+
await journal.write({
|
|
579
|
+
at: sample.atMs,
|
|
580
|
+
day: localDayKey(sample.atMs),
|
|
581
|
+
session: sessionId || '',
|
|
582
|
+
model: sample.model,
|
|
583
|
+
provider: sample.provider,
|
|
584
|
+
...sample.buckets,
|
|
585
|
+
rows: bucketRows(sample.buckets, tier),
|
|
586
|
+
cost,
|
|
587
|
+
peak,
|
|
588
|
+
})
|
|
589
|
+
maxAt = Math.max(maxAt, sample.atMs)
|
|
590
|
+
importedCalls += 1
|
|
591
|
+
}
|
|
592
|
+
if (sessionId && maxAt > 0 && ledger.markImported(sessionId, maxAt)) touchedSessions += 1
|
|
593
|
+
// 让出事件循环:几百个会话的解压不能卡住宿主启动。
|
|
594
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
595
|
+
} catch (error) {
|
|
596
|
+
void report(`backfill ${sessionDir.name} failed: ${String(error?.message ?? error).slice(0, 160)}`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (importedCalls > 0) void report(`backfill appended ${importedCalls} journal rows across ${touchedSessions} sessions`)
|
|
601
|
+
else void report('backfill done: nothing new')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 下一次峰谷状态翻转时刻(48h 内逐分钟扫描;周末/节假日边界自然覆盖)。 */
|
|
605
|
+
function nextPeakSwitch(now) {
|
|
606
|
+
const current = isPeakAt(now)
|
|
607
|
+
const start = Math.floor(now / 60_000) * 60_000 + 60_000
|
|
608
|
+
for (let ms = start; ms <= now + 48 * 3_600_000; ms += 60_000) {
|
|
609
|
+
if (isPeakAt(ms) !== current) return { atMs: ms, isPeak: isPeakAt(ms) }
|
|
610
|
+
}
|
|
611
|
+
return null
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 官方定价页(预渲染 SSR)解析 → flash/pro 峰谷八字段(USD/1M)。 */
|
|
615
|
+
const OFFICIAL_PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'
|
|
616
|
+
|
|
617
|
+
function parseOfficialPricing(html) {
|
|
618
|
+
const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]*>/g, ' ')
|
|
619
|
+
.replace(/&/g, '&').replace(/\s+/g, ' ')
|
|
620
|
+
const startIdx = text.indexOf('PRICING')
|
|
621
|
+
const endIdx = text.indexOf('Concurrency Limit', startIdx)
|
|
622
|
+
if (startIdx < 0 || endIdx < 0) throw new Error('pricing section not found')
|
|
623
|
+
const section = text.slice(startIdx, endIdx)
|
|
624
|
+
const nums = [...section.matchAll(/\$([0-9.]+)/g)].map((m) => Number(m[1]))
|
|
625
|
+
if (nums.length < 12) throw new Error(`pricing layout changed: expected 12 rates, got ${nums.length}`)
|
|
626
|
+
// 页面顺序(deepseek-flash | deepseek-v4-pro 两列):
|
|
627
|
+
// CACHE HIT off(a,b) peak(c,d) · CACHE MISS off(e,f) peak(g,h) · OUTPUT off(i,j) peak(k,l)
|
|
628
|
+
const [fhOff, phOff, fhPk, phPk, fmOff, pmOff, fmPk, pmPk, foOff, poOff, foPk, poPk] = nums
|
|
629
|
+
const pair = (flash, pro, label) => {
|
|
630
|
+
if (!(flash > 0 && pro > 0)) throw new Error(`pricing layout changed: invalid ${label}`)
|
|
631
|
+
return { flash, pro }
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
fetchedAt: Date.now(),
|
|
635
|
+
currency: 'USD',
|
|
636
|
+
cacheHit: { offPeak: pair(fhOff, phOff, 'cacheHit.offPeak'), peak: pair(fhPk, phPk, 'cacheHit.peak') },
|
|
637
|
+
cacheMiss: { offPeak: pair(fmOff, pmOff, 'cacheMiss.offPeak'), peak: pair(fmPk, pmPk, 'cacheMiss.peak') },
|
|
638
|
+
output: { offPeak: pair(foOff, poOff, 'output.offPeak'), peak: pair(foPk, poPk, 'output.peak') },
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function fetchOfficialPricing() {
|
|
643
|
+
const res = await fetch(OFFICIAL_PRICING_URL, { headers: { 'user-agent': 'dsh-cost' }, signal: AbortSignal.timeout(15_000) })
|
|
644
|
+
if (!res.ok) throw new Error(`official pricing page HTTP ${res.status}`)
|
|
645
|
+
return parseOfficialPricing(await res.text())
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 汇率(USD→CNY),frankfurter.dev 免钥公开端点。 */
|
|
649
|
+
async function fetchExchangeRate() {
|
|
650
|
+
const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD&symbols=CNY', { signal: AbortSignal.timeout(10_000) })
|
|
651
|
+
if (!res.ok) throw new Error(`frankfurter HTTP ${res.status}`)
|
|
652
|
+
const data = await res.json()
|
|
653
|
+
const rate = Number(data?.rates?.CNY)
|
|
654
|
+
if (!(rate > 0)) throw new Error('frankfurter returned no CNY rate')
|
|
655
|
+
return rate
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 把官方解析结果折成 tier 形状(flash/pro 各一),并校验峰=谷×2(±1%)。 */
|
|
659
|
+
function tiersFromOfficial(parsed) {
|
|
660
|
+
const build = (family) => {
|
|
661
|
+
const offPeak = { cacheHit: parsed.cacheHit.offPeak[family], cacheMiss: parsed.cacheMiss.offPeak[family], output: parsed.output.offPeak[family] }
|
|
662
|
+
const peak = { cacheHit: parsed.cacheHit.peak[family], cacheMiss: parsed.cacheMiss.peak[family], output: parsed.output.peak[family] }
|
|
663
|
+
for (const key of Object.keys(offPeak)) {
|
|
664
|
+
if (Math.abs(peak[key] - offPeak[key] * 2) > offPeak[key] * 0.011) throw new Error(`peak != 2×offPeak for ${family}.${key} (official layout changed)`)
|
|
665
|
+
}
|
|
666
|
+
return { offPeak, peak: double(offPeak) }
|
|
667
|
+
}
|
|
668
|
+
return { fetchedAt: parsed.fetchedAt, currency: parsed.currency, flash: build('flash'), pro: build('pro') }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/* ------------------------------------------------------------------ */
|
|
672
|
+
/* 跨进程单写者锁(双进程同 boot 实锤;非持有者只 append journal) */
|
|
673
|
+
/* ------------------------------------------------------------------ */
|
|
674
|
+
|
|
675
|
+
const LOCK_STALE_MS = 30_000
|
|
676
|
+
|
|
677
|
+
function acquireLedgerLock(path) {
|
|
678
|
+
const tryWrite = () => {
|
|
679
|
+
try {
|
|
680
|
+
const fd = openSync(path, 'wx')
|
|
681
|
+
writeSync(fd, String(process.pid))
|
|
682
|
+
closeSync(fd)
|
|
683
|
+
return true
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (error?.code === 'EEXIST') return false
|
|
686
|
+
throw error
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (tryWrite()) return { owner: true }
|
|
690
|
+
try {
|
|
691
|
+
const info = statSync(path)
|
|
692
|
+
const holder = Number(readFileSync(path, 'utf8'))
|
|
693
|
+
let alive = false
|
|
694
|
+
if (Number.isSafeInteger(holder) && holder > 0) {
|
|
695
|
+
try { process.kill(holder, 0); alive = true } catch (error) { alive = error?.code === 'EPERM' }
|
|
696
|
+
}
|
|
697
|
+
if (!alive || Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
698
|
+
// 持有者已死或心跳超时:抢占(万一并发抢占,后写者败走非持有者,无害)。
|
|
699
|
+
try { unlinkSync(path) } catch { /* ignore */ }
|
|
700
|
+
return { owner: tryWrite() }
|
|
701
|
+
}
|
|
702
|
+
} catch { return { owner: tryWrite() } }
|
|
703
|
+
return { owner: false }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function touchLedgerLock(path) {
|
|
707
|
+
try { utimesSync(path, new Date(), new Date()) } catch { /* ignore */ }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function releaseLedgerLock(path) {
|
|
711
|
+
try {
|
|
712
|
+
if (Number(readFileSync(path, 'utf8')) === process.pid) unlinkSync(path)
|
|
713
|
+
} catch { /* ignore */ }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ------------------------------------------------------------------ */
|
|
717
|
+
/* 插件入口 */
|
|
718
|
+
/* ------------------------------------------------------------------ */
|
|
719
|
+
const moduleInject = []
|
|
720
|
+
|
|
721
|
+
/** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
|
|
722
|
+
const BALANCE_HOST = 'api.deepseek.com'
|
|
723
|
+
|
|
724
|
+
const Config = Schema.object({
|
|
725
|
+
enabled: Schema.boolean().default(true).description('是否启用费用统计'),
|
|
726
|
+
/** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
|
|
727
|
+
exchangeRate: Schema.number().default(7.2).description('展示汇率(USD→CNY)'),
|
|
728
|
+
/** 自定义单价覆盖:{ "模型id": { cacheHit, cacheMiss, output } }(USD/1M)。 */
|
|
729
|
+
customPrices: Schema.object({}).default({}).description('自定义模型单价(USD/1M tokens)'),
|
|
730
|
+
/** 账本保留天数。 */
|
|
731
|
+
retentionDays: Schema.number().default(180).description('历史保留天数'),
|
|
732
|
+
budgetEnabled: Schema.boolean().default(false).description('启用预算'),
|
|
733
|
+
budgetAmount: Schema.number().default(50).description('预算金额(CNY)'),
|
|
734
|
+
budgetPeriod: Schema.union(['day', 'month', 'all']).default('month').description('预算周期'),
|
|
735
|
+
backfillEnabled: Schema.boolean().default(true).description('启动时回填历史会话费用'),
|
|
736
|
+
peakAlertEnabled: Schema.boolean().default(false).description('峰谷切换前提醒'),
|
|
737
|
+
peakAlertLeadMinutes: Schema.number().default(2).description('峰谷切换提前量(分钟,1-30)'),
|
|
738
|
+
peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
|
|
739
|
+
/** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
|
|
740
|
+
apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
function apply(ctx, config) {
|
|
744
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
745
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
746
|
+
const live = { ...config }
|
|
747
|
+
// 官方余额缓存:10 分钟内直接回缓存;换 key 即失效。
|
|
748
|
+
let balanceCache = null
|
|
749
|
+
if (!live.enabled) return
|
|
750
|
+
|
|
751
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
|
|
752
|
+
const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey']
|
|
753
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
|
|
754
|
+
try {
|
|
755
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
756
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
757
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
758
|
+
let settingsTimer = null
|
|
759
|
+
const persistSettings = () => {
|
|
760
|
+
if (settingsTimer) return
|
|
761
|
+
settingsTimer = setTimeout(() => {
|
|
762
|
+
settingsTimer = null
|
|
763
|
+
try {
|
|
764
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
765
|
+
const out = {}
|
|
766
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
767
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
768
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
769
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
770
|
+
} catch { /* ignore */ }
|
|
771
|
+
}, 400)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const lockPath = join(STORE_DIR, 'ledger.lock')
|
|
775
|
+
const lock = acquireLedgerLock(lockPath)
|
|
776
|
+
void report(`server boot v0.8.2 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'}`)
|
|
777
|
+
|
|
778
|
+
const ledger = createLedger(live.retentionDays)
|
|
779
|
+
if (lock.owner) ledger.load()
|
|
780
|
+
ctx.effect(() => { ledger.dispose(); if (lock.owner) releaseLedgerLock(lockPath) }, 'dsh-cost: ledger flush + lock release')
|
|
781
|
+
|
|
782
|
+
const admitUsage = createUsageDeduper()
|
|
783
|
+
const journal = createJournal()
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 锁持有者把 journal 增量折叠进 ledger(0.7.0 单写者模型):
|
|
787
|
+
* 两个进程都会 boot 并各听各的 llm/stream——都只 append journal(O_APPEND
|
|
788
|
+
* 单行跨进程安全);只有锁持有者写 ledger.json,彻底消除整文件互踩。
|
|
789
|
+
* 指纹去重兜底:同一事件被两进程捕获 / 回填与实时重叠,10s 窗内互斥。
|
|
790
|
+
*/
|
|
791
|
+
const ingestDedup = createUsageDeduper()
|
|
792
|
+
let ingestedCount = 0
|
|
793
|
+
let ingestBusy = false
|
|
794
|
+
async function ingestJournal() {
|
|
795
|
+
if (!lock.owner || ingestBusy) return
|
|
796
|
+
ingestBusy = true
|
|
797
|
+
try {
|
|
798
|
+
const { rows, offset } = await journal.recordsAfter(ledger.journalOffset())
|
|
799
|
+
for (const row of rows) {
|
|
800
|
+
if (!row || !Number.isFinite(row.at) || !Number.isFinite(row.cost) || !row.model) continue
|
|
801
|
+
const buckets = { input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0, cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0 }
|
|
802
|
+
if (!ingestDedup(`${row.session ?? ''}|${row.model}`, buckets, row.at)) continue
|
|
803
|
+
ledger.account({ sessionId: row.session, model: row.model, buckets, cost: row.cost, atMs: row.at, source: row.source })
|
|
804
|
+
// +60s 余量:日志事件记的是消息完成时刻,晚于实时流发起时刻;
|
|
805
|
+
// 游标盖住整个调用时长,防回填重放同一条。
|
|
806
|
+
if (row.session) ledger.markImported(row.session, row.at + 60_000)
|
|
807
|
+
ingestedCount += 1
|
|
808
|
+
}
|
|
809
|
+
ledger.setJournalOffset(offset)
|
|
810
|
+
if (rows.length > 0) touchLedgerLock(lockPath)
|
|
811
|
+
} catch (error) {
|
|
812
|
+
void report(`ingest failed: ${String(error?.message ?? error).slice(0, 160)}`)
|
|
813
|
+
} finally {
|
|
814
|
+
ingestBusy = false
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (lock.owner) {
|
|
819
|
+
const ingestTimer = setInterval(() => { void ingestJournal() }, 5000)
|
|
820
|
+
ingestTimer.unref?.()
|
|
821
|
+
ctx.effect(() => () => clearInterval(ingestTimer), 'dsh-cost: journal ingest timer')
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 历史回填:仅锁持有者执行(boot 后 3s,避开宿主启动高峰);0.7.0 起
|
|
825
|
+
// 改写 journal(与实时同源),由 ingest 统一折叠 + 指纹去重,天然不重。
|
|
826
|
+
if (live.backfillEnabled && lock.owner) {
|
|
827
|
+
const backfillTimer = setTimeout(() => { void runBackfill(journal, ledger, priceEntryFor).catch((error) => { void report(`backfill crashed: ${String(error?.message ?? error).slice(0, 200)}`) }) }, 3000)
|
|
828
|
+
backfillTimer.unref?.()
|
|
829
|
+
ctx.effect(() => () => clearTimeout(backfillTimer), 'dsh-cost: backfill timer')
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 汇率自动刷新:仅锁持有者,启动 5s 后首刷,之后每小时。失败静默(用旧值)。
|
|
833
|
+
if (lock.owner) {
|
|
834
|
+
const refreshRate = async () => {
|
|
835
|
+
try {
|
|
836
|
+
const rate = await fetchExchangeRate()
|
|
837
|
+
ledger.setExchangeRate(rate, Date.now())
|
|
838
|
+
void report(`exchange rate auto: ${rate}`)
|
|
839
|
+
} catch (error) {
|
|
840
|
+
void report(`exchange rate auto failed: ${String(error?.message ?? error).slice(0, 120)}`)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const rateStart = setTimeout(() => { void refreshRate() }, 5000)
|
|
844
|
+
rateStart.unref?.()
|
|
845
|
+
const rateTimer = setInterval(() => { void refreshRate() }, 3_600_000)
|
|
846
|
+
rateTimer.unref?.()
|
|
847
|
+
ctx.effect(() => () => { clearTimeout(rateStart); clearInterval(rateTimer) }, 'dsh-cost: exchange rate timers')
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** 深度标记:存在即「已知会、不需要再入账」的 llm 流(嵌套排除)。 */
|
|
851
|
+
const llmDepth = new AsyncLocalStorage()
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* 跨插件的调用来源标记。prompt-enhancer / followup 在发起自己的
|
|
855
|
+
* llm.stream 前写 globalThis.__dshPluginCall,这里按 5 秒窗口读回:
|
|
856
|
+
* 落在窗口内算它们的调用,否则一律算对话。读一次就清,避免串到下一次。
|
|
857
|
+
*/
|
|
858
|
+
const CALL_SOURCE_TTL_MS = 5000
|
|
859
|
+
|
|
860
|
+
/** 各插件自己的 prompt 特征:跨进程时 globalThis 不共享,只能看内容。 */
|
|
861
|
+
const SOURCE_SIGNATURES = [
|
|
862
|
+
{ source: 'followup', needle: 'You generate follow-up suggestions' },
|
|
863
|
+
{ source: 'enhance', needle: "You are a Prompt Engineering Expert" },
|
|
864
|
+
]
|
|
865
|
+
|
|
866
|
+
function callSource(options) {
|
|
867
|
+
// 1) 同进程时的显式标记(prompt-enhancer / followup 调用前写入)
|
|
868
|
+
const marker = globalThis.__dshPluginCall
|
|
869
|
+
if (marker && typeof marker.at === 'number' && Date.now() - marker.at < CALL_SOURCE_TTL_MS) {
|
|
870
|
+
const source = typeof marker.source === 'string' && marker.source ? marker.source : 'chat'
|
|
871
|
+
globalThis.__dshPluginCall = undefined
|
|
872
|
+
return source
|
|
873
|
+
}
|
|
874
|
+
// 2) 兜底:按 prompt 特征识别,插件跑在别的进程时也能分开记
|
|
875
|
+
try {
|
|
876
|
+
const messages = options?.messages
|
|
877
|
+
if (messages) {
|
|
878
|
+
const text = JSON.stringify(messages)
|
|
879
|
+
for (const entry of SOURCE_SIGNATURES) {
|
|
880
|
+
if (text.includes(entry.needle)) return entry.source
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} catch { /* 序列化失败就当普通对话 */ }
|
|
884
|
+
return 'chat'
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const unmatchedModels = new Set()
|
|
888
|
+
let streamArmed = false
|
|
889
|
+
let accountCount = 0
|
|
890
|
+
|
|
891
|
+
function priceEntryFor(model) {
|
|
892
|
+
if (model && live.customPrices && live.customPrices[model]) return { custom: true, ...live.customPrices[model] }
|
|
893
|
+
const norm = normalizeModelId(model)
|
|
894
|
+
if (legacyOnlyModels[norm]) return legacyOnlyModels[norm]
|
|
895
|
+
if (norm.includes('deepseek')) {
|
|
896
|
+
// 官方同步的现行价优先(fetch-prices 成功后覆盖内置表;历史档不变)。
|
|
897
|
+
const live = ledger.livePrices()
|
|
898
|
+
if (live && live.currency === 'USD') {
|
|
899
|
+
const family = norm.includes('pro') ? 'pro' : 'flash'
|
|
900
|
+
const t = live[family]
|
|
901
|
+
return { legacyBase: (norm.includes('pro') ? deepseekPro : deepseekFlash).legacyBase, august: undefined, offPeak: t.offPeak, peak: t.peak, live: true }
|
|
902
|
+
}
|
|
903
|
+
return norm.includes('pro') ? deepseekPro : deepseekFlash
|
|
904
|
+
}
|
|
905
|
+
return null
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
ctx.on('llm/stream', (options, next) => {
|
|
909
|
+
// 包装路由插件在嵌套上下文里再发起的上游调用:原样透传,不重复入账。
|
|
910
|
+
if (llmDepth.getStore() !== undefined) return next()
|
|
911
|
+
const downstream = next()
|
|
912
|
+
if (!streamArmed) {
|
|
913
|
+
streamArmed = true
|
|
914
|
+
void report(`llm/stream first fire pid=${process.pid} — capture channel ALIVE in this process`)
|
|
915
|
+
}
|
|
916
|
+
const startedAtMs = Date.now()
|
|
917
|
+
const model = typeof options?.model === 'string' ? options.model : ''
|
|
918
|
+
const provider = typeof options?.provider === 'string' ? options.provider : ''
|
|
919
|
+
const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
|
|
920
|
+
let usage = null
|
|
921
|
+
let downstreamIter = null
|
|
922
|
+
let completed = false
|
|
923
|
+
return (async function* costObserve() {
|
|
924
|
+
try {
|
|
925
|
+
downstreamIter = downstream[Symbol.asyncIterator]()
|
|
926
|
+
for (;;) {
|
|
927
|
+
// 深度标记内拉取:下游适配器(包装路由)在消费上下文里再发起的
|
|
928
|
+
// ctx.llm.stream() 继承标记,内层监听器判定为嵌套、透传。
|
|
929
|
+
const result = await llmDepth.run(1, () => downstreamIter.next())
|
|
930
|
+
if (result.done) break
|
|
931
|
+
const chunk = result.value
|
|
932
|
+
// usage 块位于 finish 之前;多块时最后一块生效(cost-meter 同款)。
|
|
933
|
+
if (chunk?.type === 'usage' && chunk.usage != null) usage = chunk.usage
|
|
934
|
+
yield chunk
|
|
935
|
+
}
|
|
936
|
+
completed = true
|
|
937
|
+
} finally {
|
|
938
|
+
// 消费方提前中断也向下游传播 return(),防上游 HTTP 流悬挂。
|
|
939
|
+
if (!completed) {
|
|
940
|
+
try { await downstreamIter?.return?.() } catch { /* ignore */ }
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
if (usage) {
|
|
944
|
+
const buckets = {
|
|
945
|
+
input: usage.inputTokens ?? 0,
|
|
946
|
+
output: usage.outputTokens ?? 0,
|
|
947
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
948
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
949
|
+
reasoning: usage.reasoningTokens ?? 0,
|
|
950
|
+
}
|
|
951
|
+
const entry = priceEntryFor(model)
|
|
952
|
+
if (entry) {
|
|
953
|
+
// 指纹去重兜底:本进程内 ALS 逃逸/同流双 usage 互斥(10s 窗)。
|
|
954
|
+
if (admitUsage(model, buckets, startedAtMs)) {
|
|
955
|
+
const peak = isPeakAt(startedAtMs)
|
|
956
|
+
const tier = tierFor(entry, startedAtMs, peak)
|
|
957
|
+
const cost = costOfBuckets(buckets, tier)
|
|
958
|
+
// 0.7.0 单写者模型:所有进程只 append journal(跨进程安全),
|
|
959
|
+
// ledger 由锁持有者的 ingest 统一折叠。
|
|
960
|
+
void journal.write({
|
|
961
|
+
at: startedAtMs,
|
|
962
|
+
day: localDayKey(startedAtMs),
|
|
963
|
+
session: sessionId || '',
|
|
964
|
+
model,
|
|
965
|
+
provider,
|
|
966
|
+
...buckets,
|
|
967
|
+
rows: bucketRows(buckets, tier),
|
|
968
|
+
cost,
|
|
969
|
+
peak,
|
|
970
|
+
source: callSource(options),
|
|
971
|
+
})
|
|
972
|
+
accountCount += 1
|
|
973
|
+
// 采样留痕(前 10 条全记,之后 1/20),足以判定通路与发现异常。
|
|
974
|
+
if (accountCount <= 10 || accountCount % 20 === 0) {
|
|
975
|
+
void report(`journaled #${accountCount} pid=${process.pid} owner=${lock.owner ? 1 : 0} model=${model} provider=${provider} session=${sessionId ? sessionId.slice(0, 40) : '(none)'} peak=${peak ? 1 : 0} buckets=${JSON.stringify(buckets)} cost=$${cost.toFixed(6)}`)
|
|
976
|
+
}
|
|
977
|
+
} else {
|
|
978
|
+
void report(`dedup skip model=${model} buckets=${JSON.stringify(buckets)}`)
|
|
979
|
+
}
|
|
980
|
+
} else if (model && !unmatchedModels.has(model)) {
|
|
981
|
+
// 未内置价格的第三方模型按 default 档(flash 现行价)计费,
|
|
982
|
+
// 首见留痕提醒用户在 customPrices 里补充精确单价。
|
|
983
|
+
unmatchedModels.add(model)
|
|
984
|
+
void report(`unmatched model "${model}" billed at default tier; add customPrices entry for accuracy`)
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} catch (error) {
|
|
988
|
+
void report(`account failed: ${String(error?.message ?? error).slice(0, 200)}`)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
})()
|
|
992
|
+
}, { global: true })
|
|
993
|
+
|
|
994
|
+
/* -- HTTP 端点 ------------------------------------------------------ */
|
|
995
|
+
|
|
996
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
997
|
+
/** /state、/export 共用:锁持有者先折叠+落盘再读文件;非持有者直读
|
|
998
|
+
(持有者 5s ingest + 800ms 防抖,两个进程的 UI 数据都足够新鲜)。 */
|
|
999
|
+
async function ledgerSnapshotFresh() {
|
|
1000
|
+
if (lock.owner) {
|
|
1001
|
+
await ingestJournal()
|
|
1002
|
+
ledger.flushSync()
|
|
1003
|
+
}
|
|
1004
|
+
try { return JSON.parse(await readFile(LEDGER_PATH, 'utf8')) } catch { return { days: {} } }
|
|
1005
|
+
}
|
|
1006
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1007
|
+
kind: 'prefix',
|
|
1008
|
+
path: '/api/dsh-cost',
|
|
1009
|
+
handler: async (req, res) => {
|
|
1010
|
+
const send = (status, body) => {
|
|
1011
|
+
res.statusCode = status
|
|
1012
|
+
res.setHeader('content-type', 'application/json')
|
|
1013
|
+
res.end(JSON.stringify(body))
|
|
1014
|
+
}
|
|
1015
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-cost/, '')
|
|
1016
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
1017
|
+
send(200, {
|
|
1018
|
+
enabled: live.enabled,
|
|
1019
|
+
exchangeRate: live.exchangeRate,
|
|
1020
|
+
budgetEnabled: live.budgetEnabled,
|
|
1021
|
+
budgetAmount: live.budgetAmount,
|
|
1022
|
+
budgetPeriod: live.budgetPeriod,
|
|
1023
|
+
peakAlertEnabled: live.peakAlertEnabled,
|
|
1024
|
+
peakAlertLeadMinutes: live.peakAlertLeadMinutes,
|
|
1025
|
+
peakAlertNotify: live.peakAlertNotify,
|
|
1026
|
+
retentionDays: live.retentionDays,
|
|
1027
|
+
})
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
// 运行时改参数:retentionDays 与 customPrices 不在白名单——前者启动时
|
|
1031
|
+
// 已传给 createLedger,后者是对象结构,运行时改要么无效要么要重建缓存。
|
|
1032
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
1033
|
+
let raw = ''
|
|
1034
|
+
for await (const chunk of req) raw += chunk
|
|
1035
|
+
let body
|
|
1036
|
+
try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
|
|
1037
|
+
if (body.budgetEnabled !== undefined) live.budgetEnabled = body.budgetEnabled === true
|
|
1038
|
+
if (body.budgetPeriod === 'day' || body.budgetPeriod === 'month') live.budgetPeriod = body.budgetPeriod
|
|
1039
|
+
if (body.peakAlertEnabled !== undefined) live.peakAlertEnabled = body.peakAlertEnabled === true
|
|
1040
|
+
if (body.peakAlertNotify !== undefined) live.peakAlertNotify = body.peakAlertNotify === true
|
|
1041
|
+
if (body.budgetAmount !== undefined) {
|
|
1042
|
+
const value = Number(body.budgetAmount)
|
|
1043
|
+
if (Number.isFinite(value)) live.budgetAmount = Math.min(1_000_000, Math.max(0, value))
|
|
1044
|
+
}
|
|
1045
|
+
if (body.exchangeRate !== undefined) {
|
|
1046
|
+
const value = Number(body.exchangeRate)
|
|
1047
|
+
if (Number.isFinite(value) && value > 0) live.exchangeRate = Math.min(20, Math.max(1, value))
|
|
1048
|
+
}
|
|
1049
|
+
if (body.peakAlertLeadMinutes !== undefined) {
|
|
1050
|
+
const value = Number(body.peakAlertLeadMinutes)
|
|
1051
|
+
if (Number.isFinite(value)) live.peakAlertLeadMinutes = Math.min(30, Math.max(1, Math.round(value)))
|
|
1052
|
+
}
|
|
1053
|
+
if (body.apiKey !== undefined) {
|
|
1054
|
+
live.apiKey = String(body.apiKey ?? '').trim()
|
|
1055
|
+
balanceCache = null // 换 key 立即失效缓存
|
|
1056
|
+
}
|
|
1057
|
+
persistSettings()
|
|
1058
|
+
send(200, { ok: true })
|
|
1059
|
+
return
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
if (path === '/state' && req.method === 'GET') {
|
|
1064
|
+
const snap = await ledgerSnapshotFresh()
|
|
1065
|
+
const now = Date.now()
|
|
1066
|
+
const todayKey = localDayKey(now)
|
|
1067
|
+
const monthPrefix = todayKey.slice(0, 7)
|
|
1068
|
+
const weekStartKey = localDayKey(now - 6 * 86400000)
|
|
1069
|
+
let month = null
|
|
1070
|
+
const total = { ...zeroBuckets(), calls: 0, cost: 0 }
|
|
1071
|
+
const recent = []
|
|
1072
|
+
const sessions = []
|
|
1073
|
+
for (const key of Object.keys(snap.days).sort()) {
|
|
1074
|
+
const day = snap.days[key]
|
|
1075
|
+
for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += day[bucketKey] ?? 0
|
|
1076
|
+
total.calls += day.calls ?? 0
|
|
1077
|
+
total.cost += day.cost ?? 0
|
|
1078
|
+
if (key.startsWith(monthPrefix)) month = month ? mergeDay(month, day) : summarize(day)
|
|
1079
|
+
if (key >= weekStartKey) {
|
|
1080
|
+
recent.push({ date: key, calls: day.calls ?? 0, cost: day.cost ?? 0, tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) })
|
|
1081
|
+
for (const [id, session] of Object.entries(day.sessions ?? {})) {
|
|
1082
|
+
sessions.push({ id, date: key, calls: session.calls ?? 0, cost: session.cost ?? 0, input: session.input ?? 0, output: session.output ?? 0, at: session.at ?? 0 })
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
sessions.sort((a, b) => b.at - a.at)
|
|
1087
|
+
// 预算:以展示币种(CNY)计,used 由对应周期的账本金额换算。
|
|
1088
|
+
const budgetReady = live.budgetEnabled && Number(live.budgetAmount) > 0
|
|
1089
|
+
const budgetUsd = budgetReady
|
|
1090
|
+
? (live.budgetPeriod === 'day'
|
|
1091
|
+
? (snap.days[todayKey]?.cost ?? 0)
|
|
1092
|
+
: live.budgetPeriod === 'month'
|
|
1093
|
+
? (month?.cost ?? 0)
|
|
1094
|
+
: total.cost)
|
|
1095
|
+
: 0
|
|
1096
|
+
send(200, {
|
|
1097
|
+
serverNow: now,
|
|
1098
|
+
display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate, rateSource: ledger.liveExchangeRate() ? 'auto' : 'config', rateFetchedAt: ledger.liveExchangeRate()?.fetchedAt ?? null },
|
|
1099
|
+
peak: { isPeak: isPeakAt(now), windows: PEAK_WINDOWS, nextSwitchAtMs: nextPeakSwitch(now)?.atMs ?? null, nextIsPeak: nextPeakSwitch(now)?.isPeak ?? null },
|
|
1100
|
+
today: todayKey in snap.days ? summarize(snap.days[todayKey]) : null,
|
|
1101
|
+
month,
|
|
1102
|
+
total: summarize(total),
|
|
1103
|
+
budget: budgetReady
|
|
1104
|
+
? { amount: live.budgetAmount, period: live.budgetPeriod, used: budgetUsd * live.exchangeRate, usedPct: budgetUsd * live.exchangeRate / live.budgetAmount }
|
|
1105
|
+
: null,
|
|
1106
|
+
recent,
|
|
1107
|
+
sessions: sessions.slice(0, 20),
|
|
1108
|
+
unmatched: [...unmatchedModels],
|
|
1109
|
+
streamAlive: streamArmed,
|
|
1110
|
+
alerts: {
|
|
1111
|
+
peakEnabled: live.peakAlertEnabled === true,
|
|
1112
|
+
leadMinutes: Math.min(30, Math.max(1, Math.round(live.peakAlertLeadMinutes ?? 2))),
|
|
1113
|
+
notify: live.peakAlertNotify === true,
|
|
1114
|
+
},
|
|
1115
|
+
})
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* GET /forecast —— 月底花费预测。
|
|
1121
|
+
*
|
|
1122
|
+
* 用最近 7 个**完整**历史日的日均推算,不用今天(半天数据会把均值拉低)。
|
|
1123
|
+
* 样本少于 3 天时标 basis=insufficient,前端据此提示「数据不足」而不是
|
|
1124
|
+
* 显示一个不可信的数字。
|
|
1125
|
+
*/
|
|
1126
|
+
if (path === '/forecast' && req.method === 'GET') {
|
|
1127
|
+
const snap = await ledgerSnapshotFresh()
|
|
1128
|
+
const now = Date.now()
|
|
1129
|
+
const todayKey = localDayKey(now)
|
|
1130
|
+
const monthPrefix = todayKey.slice(0, 7)
|
|
1131
|
+
|
|
1132
|
+
let monthCost = 0
|
|
1133
|
+
let monthCalls = 0
|
|
1134
|
+
const recent = []
|
|
1135
|
+
|
|
1136
|
+
for (const key of Object.keys(snap.days).sort()) {
|
|
1137
|
+
const day = snap.days[key]
|
|
1138
|
+
if (key.startsWith(monthPrefix)) {
|
|
1139
|
+
monthCost += day.cost ?? 0
|
|
1140
|
+
monthCalls += day.calls ?? 0
|
|
1141
|
+
}
|
|
1142
|
+
// 只取完整历史日,今天的数据还在增长
|
|
1143
|
+
if (key < todayKey && key >= localDayKey(now - 7 * 86400000)) {
|
|
1144
|
+
recent.push({ date: key, cost: day.cost ?? 0, calls: day.calls ?? 0 })
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const n = recent.length
|
|
1149
|
+
const sumCost = recent.reduce((acc, d) => acc + d.cost, 0)
|
|
1150
|
+
const sumCalls = recent.reduce((acc, d) => acc + d.calls, 0)
|
|
1151
|
+
const avgDaily = n > 0 ? sumCost / n : 0
|
|
1152
|
+
const avgCalls = n > 0 ? sumCalls / n : 0
|
|
1153
|
+
|
|
1154
|
+
const nowDate = new Date(now)
|
|
1155
|
+
const daysInMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate()
|
|
1156
|
+
const daysLeft = daysInMonth - nowDate.getDate()
|
|
1157
|
+
const projected = monthCost + avgDaily * daysLeft
|
|
1158
|
+
|
|
1159
|
+
return json({
|
|
1160
|
+
monthCost,
|
|
1161
|
+
monthCalls,
|
|
1162
|
+
avgDaily,
|
|
1163
|
+
avgCalls,
|
|
1164
|
+
daysLeft,
|
|
1165
|
+
projected,
|
|
1166
|
+
avgPerCall: monthCalls > 0 ? monthCost / monthCalls : 0,
|
|
1167
|
+
sampleDays: n,
|
|
1168
|
+
basis: n >= 3 ? 'recent' : 'insufficient',
|
|
1169
|
+
recent,
|
|
1170
|
+
})
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (path === '/calls' && req.method === 'GET') {
|
|
1174
|
+
const url = new URL(req.url ?? '/api/dsh-cost/calls', 'http://localhost')
|
|
1175
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1176
|
+
const day = url.searchParams.get('day') ?? ''
|
|
1177
|
+
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 20)))
|
|
1178
|
+
// 全量按来源汇总(对话 / 增强 / 建议生成),供状态行费用弹卡分组显示。
|
|
1179
|
+
const snap = await ledgerSnapshotFresh()
|
|
1180
|
+
const bySource = {}
|
|
1181
|
+
let totalCost = 0
|
|
1182
|
+
let totalCalls = 0
|
|
1183
|
+
for (const key of Object.keys(snap.days)) {
|
|
1184
|
+
const d = snap.days[key]
|
|
1185
|
+
totalCost += d.cost ?? 0
|
|
1186
|
+
totalCalls += d.calls ?? 0
|
|
1187
|
+
for (const [src, entry] of Object.entries(d.bySource ?? {})) {
|
|
1188
|
+
const t = bySource[src] ?? (bySource[src] = { cost: 0, calls: 0 })
|
|
1189
|
+
t.cost += entry.cost ?? 0
|
|
1190
|
+
t.calls += entry.calls ?? 0
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
send(200, { total: { cost: totalCost, calls: totalCalls, bySource }, calls: await journal.recent({ sessionId, day, limit }) })
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// 本对话累计费用:跨所有日期聚合 day.sessions[sessionId],含来源分组。
|
|
1198
|
+
if (path === '/session' && req.method === 'GET') {
|
|
1199
|
+
const url = new URL(req.url ?? '/api/dsh-cost/session', 'http://localhost')
|
|
1200
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1201
|
+
if (!sessionId) { send(400, { error: 'missing session' }); return }
|
|
1202
|
+
const snap = await ledgerSnapshotFresh()
|
|
1203
|
+
const total = { ...zeroBuckets(), calls: 0, cost: 0, bySource: {} }
|
|
1204
|
+
for (const key of Object.keys(snap.days)) {
|
|
1205
|
+
const entry = snap.days[key].sessions?.[sessionId]
|
|
1206
|
+
if (!entry) continue
|
|
1207
|
+
for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += entry[bucketKey] ?? 0
|
|
1208
|
+
total.calls += entry.calls ?? 0
|
|
1209
|
+
total.cost += entry.cost ?? 0
|
|
1210
|
+
for (const [src, sub] of Object.entries(entry.bySource ?? {})) {
|
|
1211
|
+
const target = total.bySource[src] ?? (total.bySource[src] = { cost: 0, calls: 0 })
|
|
1212
|
+
target.cost += sub.cost ?? 0
|
|
1213
|
+
target.calls += sub.calls ?? 0
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
send(200, {
|
|
1217
|
+
session: sessionId,
|
|
1218
|
+
total,
|
|
1219
|
+
calls: ledger.recentCalls(100).filter((call) => call.session === sessionId),
|
|
1220
|
+
})
|
|
1221
|
+
return
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// 回合费用:turnTail 槽位传宿主 turn/start~turn/end 事件时间窗。
|
|
1225
|
+
if (path === '/turn' && req.method === 'GET') {
|
|
1226
|
+
const url = new URL(req.url ?? '/api/dsh-cost/turn', 'http://localhost')
|
|
1227
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
1228
|
+
const from = Number(url.searchParams.get('from') ?? 0)
|
|
1229
|
+
const to = Number(url.searchParams.get('to') ?? Number.MAX_SAFE_INTEGER)
|
|
1230
|
+
if (!sessionId || !Number.isFinite(from) || !Number.isFinite(to)) { send(400, { error: 'missing session/from/to' }); return }
|
|
1231
|
+
send(200, await journal.range({ sessionId, from, to }))
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// 客户端调试上报:锚定/装配事件落 debug log(真机取证)。
|
|
1236
|
+
if (path === '/client-debug' && req.method === 'POST') {
|
|
1237
|
+
let body = ''
|
|
1238
|
+
for await (const chunk of req) body += chunk
|
|
1239
|
+
let message = ''
|
|
1240
|
+
try { message = String(JSON.parse(body).message ?? '').slice(0, 300) } catch { message = '' }
|
|
1241
|
+
if (message) void report(`client: ${message}`)
|
|
1242
|
+
send(200, { ok: true })
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// 导出:全量 JSON(含日/模型/会话聚合)或日汇总 CSV。
|
|
1247
|
+
if (path === '/export' && req.method === 'GET') {
|
|
1248
|
+
const url = new URL(req.url ?? '/api/dsh-cost/export', 'http://localhost')
|
|
1249
|
+
const snap = await ledgerSnapshotFresh()
|
|
1250
|
+
if ((url.searchParams.get('format') ?? 'json') === 'csv') {
|
|
1251
|
+
const lines = ['date,input,output,cache_read,cache_write,reasoning,calls,cost_usd']
|
|
1252
|
+
for (const key of Object.keys(snap.days).sort()) {
|
|
1253
|
+
const day = snap.days[key]
|
|
1254
|
+
lines.push([key, day.input ?? 0, day.output ?? 0, day.cacheRead ?? 0, day.cacheWrite ?? 0, day.reasoning ?? 0, day.calls ?? 0, (day.cost ?? 0).toFixed(6)].join(','))
|
|
1255
|
+
}
|
|
1256
|
+
res.statusCode = 200
|
|
1257
|
+
res.setHeader('content-type', 'text/csv; charset=utf-8')
|
|
1258
|
+
res.setHeader('content-disposition', 'attachment; filename="dsh-cost-days.csv"')
|
|
1259
|
+
res.end(lines.join('\n'))
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
res.statusCode = 200
|
|
1263
|
+
res.setHeader('content-type', 'application/json')
|
|
1264
|
+
res.setHeader('content-disposition', 'attachment; filename="dsh-cost-ledger.json"')
|
|
1265
|
+
res.end(JSON.stringify({ exportedAt: Date.now(), display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate }, days: snap.days }))
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (path === '/prices' && req.method === 'GET') {
|
|
1270
|
+
const now = Date.now()
|
|
1271
|
+
const peak = isPeakAt(now)
|
|
1272
|
+
const live = ledger.livePrices()
|
|
1273
|
+
send(200, {
|
|
1274
|
+
currency: 'USD',
|
|
1275
|
+
peak,
|
|
1276
|
+
peakWindowsUtc: PEAK_WINDOWS,
|
|
1277
|
+
holidays: [...PEAK_HOLIDAYS],
|
|
1278
|
+
source: live ? 'official-sync' : 'built-in',
|
|
1279
|
+
fetchedAt: live?.fetchedAt ?? null,
|
|
1280
|
+
models: {
|
|
1281
|
+
'deepseek-v4.1-flash / deepseek-flash': live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
|
|
1282
|
+
'deepseek-v4-pro': live ? tierFor({ offPeak: live.pro.offPeak, peak: live.pro.peak }, now, peak) : currentTier(deepseekPro, now, peak),
|
|
1283
|
+
},
|
|
1284
|
+
default: live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
|
|
1285
|
+
})
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// 官方价格同步:抓官方定价页解析,校验峰=谷×2 后覆盖现行档。
|
|
1290
|
+
if (path === '/fetch-prices' && req.method === 'POST') {
|
|
1291
|
+
try {
|
|
1292
|
+
const parsed = await fetchOfficialPricing()
|
|
1293
|
+
const tiers = tiersFromOfficial(parsed)
|
|
1294
|
+
ledger.setLivePrices(tiers)
|
|
1295
|
+
void report(`price sync ok: flash off ${JSON.stringify(tiers.flash.offPeak)} pro off ${JSON.stringify(tiers.pro.offPeak)}`)
|
|
1296
|
+
send(200, { ok: true, fetchedAt: tiers.fetchedAt, flash: tiers.flash, pro: tiers.pro })
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
void report(`price sync failed: ${String(error?.message ?? error).slice(0, 200)}`)
|
|
1299
|
+
send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
|
|
1300
|
+
}
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// 官方余额(选配 apiKey):host 白名单写死官方域名,https 证书校验兜底。
|
|
1305
|
+
if (path === '/balance' && req.method === 'GET') {
|
|
1306
|
+
const key = String(live.apiKey ?? '').trim()
|
|
1307
|
+
if (!key) { send(200, { ok: false, reason: 'no-key' }); return }
|
|
1308
|
+
if (balanceCache && Date.now() - balanceCache.at < 600_000) { send(200, balanceCache.data); return }
|
|
1309
|
+
if (BALANCE_HOST !== 'api.deepseek.com') { send(200, { ok: false, reason: 'bad-host' }); return }
|
|
1310
|
+
try {
|
|
1311
|
+
const res = await fetch(`https://${BALANCE_HOST}/user/balance`, {
|
|
1312
|
+
headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': 'dsh-cost' },
|
|
1313
|
+
signal: AbortSignal.timeout(8000),
|
|
1314
|
+
})
|
|
1315
|
+
if (!res.ok) throw new Error(`balance ${res.status}`)
|
|
1316
|
+
const data = await res.json()
|
|
1317
|
+
const info = Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
|
|
1318
|
+
const out = info
|
|
1319
|
+
? { ok: true, currency: info.currency ?? 'CNY', total: String(info.total_balance ?? ''), granted: String(info.granted_balance ?? ''), toppedUp: String(info.topped_up_balance ?? ''), at: Date.now() }
|
|
1320
|
+
: { ok: false, reason: 'empty' }
|
|
1321
|
+
balanceCache = { at: Date.now(), data: out }
|
|
1322
|
+
send(200, out)
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
const out = { ok: false, reason: String(error?.message ?? error).slice(0, 120) }
|
|
1325
|
+
balanceCache = { at: Date.now(), data: out }
|
|
1326
|
+
send(200, out)
|
|
1327
|
+
}
|
|
1328
|
+
return
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// 汇率手动刷新(自动刷新由每小时定时器执行)。
|
|
1332
|
+
if (path === '/fetch-rate' && req.method === 'POST') {
|
|
1333
|
+
try {
|
|
1334
|
+
const rate = await fetchExchangeRate()
|
|
1335
|
+
ledger.setExchangeRate(rate, Date.now())
|
|
1336
|
+
void report(`exchange rate ok: ${rate}`)
|
|
1337
|
+
send(200, { ok: true, rate })
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
void report(`exchange rate failed: ${String(error?.message ?? error).slice(0, 200)}`)
|
|
1340
|
+
send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
|
|
1341
|
+
}
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
send(404, { error: 'not-found' })
|
|
1346
|
+
},
|
|
1347
|
+
}), 'dsh-cost: /api/dsh-cost/*')
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
function summarize(row) {
|
|
1351
|
+
return {
|
|
1352
|
+
input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0,
|
|
1353
|
+
cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0,
|
|
1354
|
+
calls: row.calls ?? 0, cost: row.cost ?? 0,
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function mergeDay(a, b) {
|
|
1359
|
+
const merged = { ...zeroBuckets(), calls: 0, cost: 0 }
|
|
1360
|
+
for (const part of [a, b]) {
|
|
1361
|
+
for (const key of Object.keys(zeroBuckets())) merged[key] += part[key] ?? 0
|
|
1362
|
+
merged.calls += part.calls ?? 0
|
|
1363
|
+
merged.cost += part.cost ?? 0
|
|
1364
|
+
}
|
|
1365
|
+
return merged
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function currentTier(entry, now, peak) {
|
|
1369
|
+
return tierFor(entry, now, peak)
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
1373
|
+
})()
|
|
1374
|
+
|
|
1375
|
+
/* ==== module: picker(原 dsh-power-picker) ==== */
|
|
1376
|
+
const pickerModule = (() => {
|
|
1377
|
+
/* 由 scripts/build-suite.mjs 从 dsh-power-picker/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
1378
|
+
/**
|
|
1379
|
+
* dsh-power-picker — Codex 风格的模型 + 推理强度选择器(DeepSeek Harness / dsh)
|
|
1380
|
+
*
|
|
1381
|
+
* 服务端半很薄:`enabled` 开关 + 诊断落盘。所有真正的逻辑都在客户端——
|
|
1382
|
+
* 以更低 priority 注册 `conversation.input.model` 槽位遮蔽内置选择器,
|
|
1383
|
+
* 数据走官方 modelDirectories 会话模型目录。服务端唯一的职责:
|
|
1384
|
+
* 1. 让「关闭插件」有一个不用卸载的开关(客户端注册遮蔽前先来确认);
|
|
1385
|
+
* 2. 接收客户端诊断事件,落到 ~/.dsh/power-picker-debug.log,
|
|
1386
|
+
* 座位异常时看这份文件就能定位卡在哪一环(服务端起没起、客户端
|
|
1387
|
+
* 模块加载没有、inject 是否失败、快照字段长什么样)。
|
|
1388
|
+
*
|
|
1389
|
+
* 已核对的 dsh API(0.2.1-rc.3 已装运行时反推 + dsh-reference 对照):
|
|
1390
|
+
* - 客户端 ctx.modelDirectories.directoryFor(sessionId) → 目录
|
|
1391
|
+
* - 组件拿到的是 directory.store(SnapshotStore):subscribe/getSnapshot
|
|
1392
|
+
* - 快照:{ current: {provider, model, reasoningEffort?} | null,
|
|
1393
|
+
* groups: [{ id, name, models: [{ id, name, reasoning? }] }],
|
|
1394
|
+
* retainedEffort?, pending, ... }
|
|
1395
|
+
* 档位阶梯在 groups[].models[].reasoning = { defaultEffort?, efforts: [{id, name}] }
|
|
1396
|
+
* - directory.select({ provider, model, reasoningEffort? }) 官方切换
|
|
1397
|
+
* - conversation.input.model 是 single 槽位,多注册按 priority 只渲染最低者
|
|
1398
|
+
*/
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
const name = 'power-picker'
|
|
1407
|
+
|
|
1408
|
+
const DIAG_LOG = join(homedir(), '.dsh', 'power-picker-debug.log')
|
|
1409
|
+
|
|
1410
|
+
const Config = Schema.object({
|
|
1411
|
+
/** 关闭后客户端不再注册任何入口,模型选择恢复原样(无需卸载插件)。 */
|
|
1412
|
+
enabled: Schema.boolean().default(true).description('是否启用 Codex 风格选择器'),
|
|
1413
|
+
/**
|
|
1414
|
+
* Codex 实测形态:入口就是输入栏右下角「选择推理强度」按钮,面板从其
|
|
1415
|
+
* 上方弹出。默认 true;false 时输入栏不渲染入口(无任何入口)。
|
|
1416
|
+
*/
|
|
1417
|
+
composerSeat: Schema.boolean().default(true).description('输入栏保留模型入口(Codex 同位:右下角,发送键旁)'),
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
function apply(ctx, config) {
|
|
1421
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
1422
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
1423
|
+
const live = { ...config }
|
|
1424
|
+
|
|
1425
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
|
|
1426
|
+
const SETTINGS_KEYS = ['enabled', 'composerSeat']
|
|
1427
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-power-picker', 'settings.json')
|
|
1428
|
+
try {
|
|
1429
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
1430
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
1431
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
1432
|
+
let settingsTimer = null
|
|
1433
|
+
const persistSettings = () => {
|
|
1434
|
+
if (settingsTimer) return
|
|
1435
|
+
settingsTimer = setTimeout(() => {
|
|
1436
|
+
settingsTimer = null
|
|
1437
|
+
try {
|
|
1438
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
1439
|
+
const out = {}
|
|
1440
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
1441
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
1442
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
1443
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
1444
|
+
} catch { /* ignore */ }
|
|
1445
|
+
}, 400)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const say = (message) => {
|
|
1449
|
+
try { console.info(`[dsh-power-picker] ${message}`) } catch { /* ignore */ }
|
|
1450
|
+
}
|
|
1451
|
+
const report = async (line) => {
|
|
1452
|
+
try {
|
|
1453
|
+
await mkdir(dirname(DIAG_LOG), { recursive: true })
|
|
1454
|
+
await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
|
|
1455
|
+
} catch { /* ignore */ }
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
say(`server boot, enabled=${live.enabled !== false}`)
|
|
1459
|
+
void report(`server boot enabled=${live.enabled !== false}`)
|
|
1460
|
+
|
|
1461
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
1462
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1463
|
+
kind: 'prefix',
|
|
1464
|
+
path: '/api/dsh-power-picker',
|
|
1465
|
+
handler: async (req, res) => {
|
|
1466
|
+
const send = (status, body) => {
|
|
1467
|
+
res.statusCode = status
|
|
1468
|
+
res.setHeader('content-type', 'application/json')
|
|
1469
|
+
res.end(JSON.stringify(body))
|
|
1470
|
+
}
|
|
1471
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-power-picker/, '')
|
|
1472
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
1473
|
+
let raw = ''
|
|
1474
|
+
for await (const chunk of req) raw += chunk
|
|
1475
|
+
let body
|
|
1476
|
+
try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
|
|
1477
|
+
if (body.enabled !== undefined) live.enabled = body.enabled === true
|
|
1478
|
+
if (typeof body.composerSeat === 'string') live.composerSeat = body.composerSeat.slice(0, 40)
|
|
1479
|
+
persistSettings()
|
|
1480
|
+
send(200, { ...live })
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
// 客户端注册前读取设置:enabled 总开关 + composerSeat 入口位置。
|
|
1486
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
1487
|
+
send(200, {
|
|
1488
|
+
enabled: live.enabled !== false,
|
|
1489
|
+
composerSeat: live.composerSeat === true,
|
|
1490
|
+
name,
|
|
1491
|
+
})
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// 诊断上报:一行一条 JSON,出问题时看 ~/.dsh/power-picker-debug.log。
|
|
1496
|
+
if (path === '/diag' && req.method === 'POST') {
|
|
1497
|
+
let raw = ''
|
|
1498
|
+
for await (const chunk of req) raw += chunk
|
|
1499
|
+
await report(raw.slice(0, 2000))
|
|
1500
|
+
send(200, { ok: true })
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
send(404, { error: 'not-found' })
|
|
1505
|
+
},
|
|
1506
|
+
}), 'dsh-power-picker: /api/dsh-power-picker/*')
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
1510
|
+
})()
|
|
1511
|
+
|
|
1512
|
+
/* ==== module: enhancer(原 dsh-prompt-enhancer) ==== */
|
|
1513
|
+
const enhancerModule = (() => {
|
|
1514
|
+
/* 由 scripts/build-suite.mjs 从 dsh-prompt-enhancer/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
1515
|
+
/**
|
|
1516
|
+
* dsh-prompt-enhancer — 对话框提示词自动增强插件(DeepSeek Harness / dsh)
|
|
1517
|
+
*
|
|
1518
|
+
* 工作原理:
|
|
1519
|
+
* 挂载 `agent/pre-step` 瀑布流。每一步开始前,dsh 会把「即将进入本轮的
|
|
1520
|
+
* 用户消息批次」交给所有 pre-step 监听器裁决(PreStepDecision)。本插件
|
|
1521
|
+
* 在拿到默认裁决(kind === 'enter')后,把批次里的纯文本用户消息先发给
|
|
1522
|
+
* 一次轻量模型调用,重写成清晰、具体、自包含的提示词,再放行进入会话——
|
|
1523
|
+
* 即 WorkBuddy / ChatGPT 风格的「发送前提示词增强」。
|
|
1524
|
+
*
|
|
1525
|
+
* 安全设计(fail-open):
|
|
1526
|
+
* 增强调用失败、超时、找不到模型路由时,一律原样放行原始消息,
|
|
1527
|
+
* 绝不阻塞用户的正常对话。
|
|
1528
|
+
*
|
|
1529
|
+
* 已核对的 dsh API(deepseek-ai/deepseek-harness,developer preview):
|
|
1530
|
+
* - PreStepDecision = { kind: 'reject' } | { kind: 'enter', messages: UserMessage[], startsRequestSeries?: true }
|
|
1531
|
+
* - ctx.on('agent/pre-step', async ({ agent }, next) => PreStepDecision)
|
|
1532
|
+
* - ctx.llm.stream({ provider, model, messages }) → AsyncIterable<StreamChunk>
|
|
1533
|
+
* (文本块为 { type: 'text-delta', text };终帧为 { type: 'finish', reason })
|
|
1534
|
+
* - createUserMessage({ content, source }) 来自 '@deepseek-ai/dsh-llm'
|
|
1535
|
+
* - defineTool(...) 来自 '@deepseek-ai/dsh-tools';服务名 'llm' / 'tools'
|
|
1536
|
+
*/
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
/** 排查日志:客户端把拦截决策写到这里,出问题时直接看文件。 */
|
|
1548
|
+
const DEBUG_LOG = join(homedir(), '.dsh', 'prompt-enhancer-debug.log')
|
|
1549
|
+
|
|
1550
|
+
const name = 'prompt-enhancer'
|
|
1551
|
+
|
|
1552
|
+
const moduleInject = ['llm', 'tools']
|
|
1553
|
+
|
|
1554
|
+
const Config = Schema.object({
|
|
1555
|
+
/** 总开关:关闭后插件完全不参与 pre-step,也不注册工具行为变化。 */
|
|
1556
|
+
enabled: Schema.boolean().default(true).description('是否启用自动提示词增强'),
|
|
1557
|
+
/** auto:每条人类消息发送前自动增强;manual:只注册 enhance_prompt 工具,不自动改写。 */
|
|
1558
|
+
mode: Schema.union(['auto', 'manual']).default('auto').description('auto=自动增强, manual=仅工具'),
|
|
1559
|
+
/** 默认增强强度;前端选择器会覆盖它。off 表示默认不增强。 */
|
|
1560
|
+
level: Schema.union(['off', 'low', 'high', 'max']).default('high').description('默认增强强度:off/low/high/max'),
|
|
1561
|
+
/**
|
|
1562
|
+
* pre-step 兜底增强。默认关闭:前端拦截路径已经负责增强,服务端再动一次
|
|
1563
|
+
* 会让「自动增强关闭」失效(用户看不到,消息却被改了)。
|
|
1564
|
+
* 只有当前端拦截失效、又确实想要兜底时才手动打开。
|
|
1565
|
+
*/
|
|
1566
|
+
preStepAuto: Schema.boolean().default(false).description('是否在 pre-step 兜底自动增强(默认关闭,前端已负责增强)'),
|
|
1567
|
+
/** 留空则跟随当前会话 Agent 的 provider 路由。 */
|
|
1568
|
+
provider: Schema.string().description('增强调用使用的 provider(留空跟随会话)'),
|
|
1569
|
+
/** 留空则跟随当前会话 Agent 的 model。建议配一个快模型降低延迟。 */
|
|
1570
|
+
model: Schema.string().description('增强调用使用的模型(留空跟随会话,建议快模型)'),
|
|
1571
|
+
/** 单次增强调用的超时时间,超时即放弃增强、原样放行。 */
|
|
1572
|
+
timeoutMs: Schema.number().default(20000).description('增强调用超时(毫秒)'),
|
|
1573
|
+
/** 短于该长度(字符)的输入不做增强(如「继续」「ok」)。 */
|
|
1574
|
+
minPromptLength: Schema.number().default(4).description('触发增强的最小输入长度(字符)'),
|
|
1575
|
+
/** 超过该长度的输入视为已经是完整提示词,不再增强。 */
|
|
1576
|
+
maxInputChars: Schema.number().default(4000).description('超过此长度不再增强(字符)'),
|
|
1577
|
+
/** 增强后提示词的语言:auto=跟随原始输入。 */
|
|
1578
|
+
language: Schema.union(['auto', 'zh', 'en']).default('auto').description('输出语言'),
|
|
1579
|
+
/** 追加到内置改写规则之后的自定义风格要求(可为空)。 */
|
|
1580
|
+
styleHint: Schema.string().default('').description('自定义风格要求(追加到改写规则后)'),
|
|
1581
|
+
/** 增强时把最近一轮会话拼进改写参考:代词/指代(它、这个)能被上下文消解。 */
|
|
1582
|
+
useContext: Schema.boolean().default(false).description('参考最近会话上下文(输入框按钮生效)'),
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
/* ------------------------------------------------------------------ */
|
|
1586
|
+
/* 增强强度分级 */
|
|
1587
|
+
/* ------------------------------------------------------------------ */
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* 四档强度的差异不在「想得多深」,而在「改多少、补多少、要不要改结构」。
|
|
1591
|
+
* 每档给独立的规则、token 上限和长度上限,避免 Max 档把一句话膨胀成论文。
|
|
1592
|
+
*/
|
|
1593
|
+
const LEVELS = {
|
|
1594
|
+
off: null,
|
|
1595
|
+
low: {
|
|
1596
|
+
label: 'Low',
|
|
1597
|
+
maxTokens: 900,
|
|
1598
|
+
maxRatio: 1.4,
|
|
1599
|
+
rules: [
|
|
1600
|
+
'INTENSITY: LOW (minimal edit).',
|
|
1601
|
+
'Change only what is necessary to remove ambiguity. Keep the original wording, tone and length as much as possible.',
|
|
1602
|
+
'Do: fix vague pronouns, unclear verbs, missing objects, redundancy, obvious typos. Split run-on sentences.',
|
|
1603
|
+
'Do NOT: add new sections, add headings, invent requirements, add constraints the user did not imply, expand into a spec.',
|
|
1604
|
+
'Target length: at most 1.4x the original. Shorter is better.',
|
|
1605
|
+
'If the input is already clear, return it almost unchanged.',
|
|
1606
|
+
],
|
|
1607
|
+
},
|
|
1608
|
+
high: {
|
|
1609
|
+
label: 'High',
|
|
1610
|
+
maxTokens: 1800,
|
|
1611
|
+
maxRatio: 2.2,
|
|
1612
|
+
rules: [
|
|
1613
|
+
'INTENSITY: HIGH (structured completion).',
|
|
1614
|
+
'Rewrite into a clear, self-contained, directly executable request. Add the context, constraints and output expectation that a competent assistant would need.',
|
|
1615
|
+
'Do: state the goal explicitly, make implicit requirements explicit, specify the expected output form, keep the user\'s intent and scope.',
|
|
1616
|
+
'Do NOT: invent facts, technologies, file names or numbers the user never mentioned. When a detail is genuinely unknown, express it conditionally ("if X, then Y") or mark a default as "(default: ...)".',
|
|
1617
|
+
'Do NOT: reply with "please confirm these details first" or ask the user anything. That converts the prompt into a conversation. Fill the gap yourself and keep the request executable.',
|
|
1618
|
+
'Do NOT: turn the request into a tutorial, a plan, or an answer. It must stay a request.',
|
|
1619
|
+
'Prefer flowing paragraphs over markdown headings unless the task is clearly multi-part.',
|
|
1620
|
+
'Target length: 1.3x to 2.2x the original. For very short inputs (under 20 characters), 2-4x is fine — a bare phrase needs more scaffolding than a half-written spec.',
|
|
1621
|
+
],
|
|
1622
|
+
},
|
|
1623
|
+
max: {
|
|
1624
|
+
label: 'Max',
|
|
1625
|
+
maxTokens: 3200,
|
|
1626
|
+
maxRatio: 3.5,
|
|
1627
|
+
rules: [
|
|
1628
|
+
'INTENSITY: MAX (first-principles reconstruction).',
|
|
1629
|
+
'First reason about what outcome the user actually wants, then rebuild the request from that outcome rather than patching the wording.',
|
|
1630
|
+
'Do: decompose into goal, context, constraints, deliverables, acceptance criteria when the task warrants it; surface hidden assumptions and mark each one inline as "(assumption: ...)"; make success verifiable.',
|
|
1631
|
+
'Do: point out missing critical information as conditional requirements inside the prompt, never as a question back to the user.',
|
|
1632
|
+
'Do NOT: invent facts. Every assumption must be labeled.',
|
|
1633
|
+
'Do NOT: pad with generic prompt-engineering boilerplate ("You are a world-class expert..."), role-play scaffolding, or restated instructions.',
|
|
1634
|
+
'FORMAT: plain text only, because the output lands in a plain-text composer. No "#" headings, no "**bold**", no "-" or "*" bullets, no code fences.',
|
|
1635
|
+
'Structure with short labeled lines instead: 目标:/ 背景:/ 约束:/ 产出:/ 验收: (or Goal:/Context:/Constraints:/Deliverables: when writing English). One requirement per line, sub-points indented with two spaces.',
|
|
1636
|
+
'Target length: 2x to 3.5x the original. Never exceed 3500 characters.',
|
|
1637
|
+
],
|
|
1638
|
+
},
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/* ------------------------------------------------------------------ */
|
|
1642
|
+
/* 内置改写元提示词 */
|
|
1643
|
+
/* ------------------------------------------------------------------ */
|
|
1644
|
+
|
|
1645
|
+
// 基础规则来自 WorkBuddy 客户端内置的 enhance-prompt 代理(逆向自 app.asar,
|
|
1646
|
+
// agentName = 'enhance-prompt'),改写为通用 agent 场景,并按强度分级。
|
|
1647
|
+
const BASE_RULES = [
|
|
1648
|
+
'You are a Prompt Engineering Expert. You rewrite a user\'s rough input into a more effective prompt for an AI assistant that can read files, run commands and write code.',
|
|
1649
|
+
'',
|
|
1650
|
+
'CORE PRINCIPLES:',
|
|
1651
|
+
'- Preserve the user\'s original intent and scope. You are rewriting a request, not answering it.',
|
|
1652
|
+
'- Never fabricate specifics the user did not provide: no invented tech stack, file paths, numbers, names, or requirements.',
|
|
1653
|
+
'- Keep the output in the exact same language as the input. If the input mixes languages, keep the mix.',
|
|
1654
|
+
'- Remove redundancy, resolve ambiguity, make the ask actionable.',
|
|
1655
|
+
'- Do NOT explain HOW to do the task, do NOT answer the question, do NOT add a preamble like "Here is the enhanced prompt".',
|
|
1656
|
+
'- Do NOT request guides, tutorials, or code snippets unless the user asked for them.',
|
|
1657
|
+
'- Do NOT suggest specific technologies unless they appear in the input.',
|
|
1658
|
+
'',
|
|
1659
|
+
'MISSING INFORMATION — the most important rule:',
|
|
1660
|
+
'- NEVER turn a gap into a question for the user. You are writing a prompt that gets executed immediately, not starting a conversation. Output containing "please confirm...", "请先确认...", "我需要知道..." is a failure.',
|
|
1661
|
+
'- Handle a gap in one of two ways: state a reasonable default inline as "(default: ...)", or phrase it as a conditional requirement inside the prompt ("if the site requires login, ...").',
|
|
1662
|
+
'- Prefer filling in generally applicable constraints (runnable output, dependencies, error handling, acceptance criteria) over interrogating the user about specifics.',
|
|
1663
|
+
'- The rewritten prompt must be executable as-is. Whoever reads it should be able to start working without replying first.',
|
|
1664
|
+
'',
|
|
1665
|
+
'OUTPUT: only the rewritten prompt text. No commentary, no code fences, no quotes around it.',
|
|
1666
|
+
].join('\n')
|
|
1667
|
+
|
|
1668
|
+
function buildMetaPrompt(raw, config, level, contextTurn) {
|
|
1669
|
+
const spec = LEVELS[level] ?? LEVELS.high
|
|
1670
|
+
const lines = [BASE_RULES, '', spec.rules.join('\n')]
|
|
1671
|
+
// 参考上下文:只用于消解指代、贴合讨论,绝不据此发明新需求。
|
|
1672
|
+
if (config.useContext && contextTurn?.userText && contextTurn?.assistantText) {
|
|
1673
|
+
lines.push(
|
|
1674
|
+
'',
|
|
1675
|
+
'Recent conversation context (the most recent user/assistant turn). Use it ONLY to resolve references (it / that / 上面提到的) and align the rewrite with the ongoing discussion; never invent requirements from it:',
|
|
1676
|
+
'<context>',
|
|
1677
|
+
`User: ${contextTurn.userText.slice(-1200)}`,
|
|
1678
|
+
`Assistant: ${contextTurn.assistantText.slice(-1200)}`,
|
|
1679
|
+
'</context>',
|
|
1680
|
+
)
|
|
1681
|
+
}
|
|
1682
|
+
// WorkBuddy 的提示词已内置「跟随输入语言」;这两个配置只用于强制覆盖。
|
|
1683
|
+
if (config.language === 'zh') lines.push('', 'OVERRIDE: write the rewritten prompt in Simplified Chinese.')
|
|
1684
|
+
else if (config.language === 'en') lines.push('', 'OVERRIDE: write the rewritten prompt in English.')
|
|
1685
|
+
if (config.styleHint && config.styleHint.trim().length > 0) {
|
|
1686
|
+
lines.push('', `Additional style requirements from the host config: ${config.styleHint.trim()}`)
|
|
1687
|
+
}
|
|
1688
|
+
// 用标签包住原文:即使原文本身长得像指令,也不会和改写规则混在一起。
|
|
1689
|
+
lines.push('', 'Raw input:', '<original_prompt>', raw, '</original_prompt>')
|
|
1690
|
+
return lines.join('\n')
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/* ------------------------------------------------------------------ */
|
|
1694
|
+
/* 最近一轮会话捕获(参考上下文用;深度标记模式照搬 followup 模块) */
|
|
1695
|
+
/* ------------------------------------------------------------------ */
|
|
1696
|
+
|
|
1697
|
+
const llmDepth = new AsyncLocalStorage()
|
|
1698
|
+
const turnCache = new Map()
|
|
1699
|
+
const TURN_CACHE_MAX = 24
|
|
1700
|
+
const TURN_CACHE_TTL_MS = 10 * 60_000
|
|
1701
|
+
|
|
1702
|
+
/** options.messages 里最后一条用户消息的纯文本。 */
|
|
1703
|
+
function lastUserTextOf(messages) {
|
|
1704
|
+
if (!Array.isArray(messages)) return ''
|
|
1705
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1706
|
+
const m = messages[i]
|
|
1707
|
+
if (m?.role !== 'user') continue
|
|
1708
|
+
const blocks = Array.isArray(m.content) ? m.content : []
|
|
1709
|
+
const joined = blocks
|
|
1710
|
+
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
1711
|
+
.map((b) => b.text)
|
|
1712
|
+
.join('')
|
|
1713
|
+
if (joined.trim()) return joined.trim()
|
|
1714
|
+
}
|
|
1715
|
+
return ''
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function cachedTurnOf(sessionId) {
|
|
1719
|
+
const hit = turnCache.get(sessionId)
|
|
1720
|
+
if (!hit) return null
|
|
1721
|
+
if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
|
|
1722
|
+
return hit
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function rememberTurn(sessionId, userText, assistantText) {
|
|
1726
|
+
if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
|
|
1727
|
+
const prev = turnCache.get(sessionId)
|
|
1728
|
+
// 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积。
|
|
1729
|
+
const merged = prev && prev.userText === userText
|
|
1730
|
+
? `${prev.assistantText}\n\n${assistantText}`
|
|
1731
|
+
: assistantText
|
|
1732
|
+
turnCache.set(sessionId, { userText, assistantText: merged.slice(-6000), at: Date.now() })
|
|
1733
|
+
if (turnCache.size > TURN_CACHE_MAX) {
|
|
1734
|
+
let oldestKey = null
|
|
1735
|
+
let oldestAt = Infinity
|
|
1736
|
+
for (const [key, value] of turnCache) {
|
|
1737
|
+
if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
|
|
1738
|
+
}
|
|
1739
|
+
if (oldestKey !== null) turnCache.delete(oldestKey)
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/* ------------------------------------------------------------------ */
|
|
1744
|
+
/* 工具函数 */
|
|
1745
|
+
/* ------------------------------------------------------------------ */
|
|
1746
|
+
|
|
1747
|
+
/** 提取消息里的纯文本内容;非「聊天框用户消息 / 单个文本块」返回 null(不处理)。 */
|
|
1748
|
+
function singleTextOf(message) {
|
|
1749
|
+
if (!message || message.role !== 'user') return null
|
|
1750
|
+
// dsh 内部还会产生 role 为 user、source.kind 为 goal / session-reference /
|
|
1751
|
+
// notice 等的消息;它们不是用户在对话框里输入的内容,绝不能改写。
|
|
1752
|
+
if (message.source?.kind !== 'user') return null
|
|
1753
|
+
if (!Array.isArray(message.content)) return null
|
|
1754
|
+
if (message.content.length !== 1) return null
|
|
1755
|
+
const block = message.content[0]
|
|
1756
|
+
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** 判断是否应该跳过增强。 */
|
|
1760
|
+
function shouldSkip(text, config) {
|
|
1761
|
+
if (!text) return true
|
|
1762
|
+
const trimmed = text.trim()
|
|
1763
|
+
if (trimmed.length === 0) return true
|
|
1764
|
+
// 斜杠命令(/plan、/goal 等)绝不改写
|
|
1765
|
+
if (trimmed.startsWith('/')) return true
|
|
1766
|
+
if (trimmed.length < config.minPromptLength) return true
|
|
1767
|
+
if (trimmed.length > config.maxInputChars) return true
|
|
1768
|
+
return false
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** 去掉模型输出里常见的包裹物:代码围栏、引号、前缀说明行。 */
|
|
1772
|
+
function cleanModelOutput(text) {
|
|
1773
|
+
let out = String(text ?? '').trim()
|
|
1774
|
+
// 去掉成对代码围栏 ```...```(含语言标注)
|
|
1775
|
+
const fence = out.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```$/)
|
|
1776
|
+
if (fence) out = fence[1].trim()
|
|
1777
|
+
// 去掉「增强后提示词:」「Enhanced prompt:」之类前缀行
|
|
1778
|
+
out = out.replace(/^(?:enhanced prompt|提示词增强|增强后的提示词|改写后(?:的提示词)?)\s*[::]\s*/i, '')
|
|
1779
|
+
// 去掉最外层成对引号
|
|
1780
|
+
const q = out.match(/^"([\s\S]*)"$|^“([\s\S]*)”$/)
|
|
1781
|
+
if (q) out = (q[1] ?? q[2]).trim()
|
|
1782
|
+
// 兜底去 markdown:输入框是纯文本,留着 # 和 ** 只会变成噪音
|
|
1783
|
+
out = out
|
|
1784
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
1785
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
1786
|
+
.replace(/^(\s*)[-*]\s+/gm, '$1· ')
|
|
1787
|
+
return out
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** 调一次 LLM 做改写;任何失败都抛错,由上层兜底放行。 */
|
|
1791
|
+
async function rewriteWithLlm(ctx, route, raw, config, level, outerSignal, contextTurn) {
|
|
1792
|
+
const spec = LEVELS[level] ?? LEVELS.high
|
|
1793
|
+
// 超时与外部取消(用户中断 pre-step)收敛到同一个内部 signal,
|
|
1794
|
+
// 底层流会被及时中断,不会在后台继续烧 token。
|
|
1795
|
+
const controller = new AbortController()
|
|
1796
|
+
const forwardAbort = () => controller.abort(outerSignal.reason)
|
|
1797
|
+
if (outerSignal?.aborted) forwardAbort()
|
|
1798
|
+
else outerSignal?.addEventListener('abort', forwardAbort, { once: true })
|
|
1799
|
+
const timer = setTimeout(
|
|
1800
|
+
() => controller.abort(new Error('prompt-enhancer timeout')),
|
|
1801
|
+
config.timeoutMs,
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
const aborted = new Promise((_, reject) => {
|
|
1805
|
+
const fail = () => reject(controller.signal.reason ?? new Error('prompt-enhancer aborted'))
|
|
1806
|
+
// 外层 signal 可能一开始就已 abort:先检查,再挂监听。
|
|
1807
|
+
if (controller.signal.aborted) fail()
|
|
1808
|
+
else controller.signal.addEventListener('abort', fail, { once: true })
|
|
1809
|
+
})
|
|
1810
|
+
// 成功路径下 aborted 不会被消费,挂空 catch 避免 unhandled rejection。
|
|
1811
|
+
void aborted.catch(() => {})
|
|
1812
|
+
|
|
1813
|
+
// 给 dsh-cost 的计量通道打来源标记:增强调用单独归类,费用弹卡里才能
|
|
1814
|
+
// 把「增强」和「对话」分开算。5 秒窗口足够覆盖一次调用的启动。
|
|
1815
|
+
globalThis.__dshPluginCall = { source: 'enhance', at: Date.now() }
|
|
1816
|
+
|
|
1817
|
+
const iterator = ctx.llm.stream({
|
|
1818
|
+
provider: route.provider,
|
|
1819
|
+
model: route.model,
|
|
1820
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: buildMetaPrompt(raw, config, level, contextTurn) }] }],
|
|
1821
|
+
maxTokens: spec.maxTokens,
|
|
1822
|
+
signal: controller.signal,
|
|
1823
|
+
})[Symbol.asyncIterator]()
|
|
1824
|
+
|
|
1825
|
+
async function collect() {
|
|
1826
|
+
let text = ''
|
|
1827
|
+
for (;;) {
|
|
1828
|
+
// 深度标记内拉取:改写调用自己的流不进会话轮缓存。
|
|
1829
|
+
const result = await llmDepth.run(1, () => iterator.next())
|
|
1830
|
+
const { done, value } = result
|
|
1831
|
+
if (done) break
|
|
1832
|
+
if (value.type === 'text-delta') text += value.text
|
|
1833
|
+
else if (value.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
|
|
1834
|
+
throw new Error(`prompt-enhancer llm ${value.reason.kind}: ${value.reason.failure?.message ?? 'unknown'}`)
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return text
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
try {
|
|
1841
|
+
const collected = await Promise.race([collect(), aborted])
|
|
1842
|
+
const cleaned = cleanModelOutput(collected)
|
|
1843
|
+
if (!cleaned) throw new Error('prompt-enhancer got empty output')
|
|
1844
|
+
// 失控保护:模型把一句话膨胀成小作文时判失败,回退原文而不是塞给用户。
|
|
1845
|
+
// 短输入天然膨胀率高(12 字补成 300 字是合理的),所以保底 600 字;
|
|
1846
|
+
// 长输入按倍数算,但封顶 4000 字,避免 4000 字输入算出 2 万字上限。
|
|
1847
|
+
const limit = Math.min(Math.max(Math.round(raw.length * spec.maxRatio * 1.6), 600), 4000)
|
|
1848
|
+
if (cleaned.length > limit) {
|
|
1849
|
+
throw new Error(`prompt-enhancer output runaway: ${cleaned.length} chars > ${limit}`)
|
|
1850
|
+
}
|
|
1851
|
+
return cleaned
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
// 中断底层流,避免失败后还在后台烧 token。
|
|
1854
|
+
// 不 await:底层若卡死,等待 return() 会拖住 fail-open。
|
|
1855
|
+
try {
|
|
1856
|
+
const closing = iterator.return?.()
|
|
1857
|
+
if (closing && typeof closing.catch === 'function') closing.catch(() => {})
|
|
1858
|
+
} catch { /* ignore */ }
|
|
1859
|
+
throw err
|
|
1860
|
+
} finally {
|
|
1861
|
+
clearTimeout(timer)
|
|
1862
|
+
outerSignal?.removeEventListener('abort', forwardAbort)
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/* ------------------------------------------------------------------ */
|
|
1867
|
+
/* 插件入口 */
|
|
1868
|
+
/* ------------------------------------------------------------------ */
|
|
1869
|
+
|
|
1870
|
+
function apply(ctx, config) {
|
|
1871
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
1872
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
1873
|
+
const live = { ...config }
|
|
1874
|
+
|
|
1875
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。必须先回放
|
|
1876
|
+
// 再初始化 currentLevel,重启后上次的强度档位才生效。
|
|
1877
|
+
const SETTINGS_KEYS = ['level', 'mode', 'preStepAuto', 'language', 'styleHint', 'timeoutMs', 'minPromptLength', 'maxInputChars', 'useContext']
|
|
1878
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'prompt-enhancer', 'settings.json')
|
|
1879
|
+
try {
|
|
1880
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
1881
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
1882
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
1883
|
+
let settingsTimer = null
|
|
1884
|
+
const persistSettings = () => {
|
|
1885
|
+
if (settingsTimer) return
|
|
1886
|
+
settingsTimer = setTimeout(() => {
|
|
1887
|
+
settingsTimer = null
|
|
1888
|
+
try {
|
|
1889
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
1890
|
+
const out = {}
|
|
1891
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
1892
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
1893
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
1894
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
1895
|
+
} catch { /* ignore */ }
|
|
1896
|
+
}, 400)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const log = (level, msg) => console[level === 'error' ? 'error' : 'log'](`[dsh-prompt-enhancer] ${msg}`)
|
|
1900
|
+
|
|
1901
|
+
/** 最近一次见到的会话路由,供 enhance_prompt 工具和 web 端点在没有显式配置时复用。 */
|
|
1902
|
+
let lastRoute = null
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* 运行时增强强度。前端选择器通过 /api/prompt-enhancer/settings 改这里,
|
|
1906
|
+
* 让 pre-step 与手动按钮共用同一档位。初始值来自配置。
|
|
1907
|
+
*/
|
|
1908
|
+
let currentLevel = Object.prototype.hasOwnProperty.call(LEVELS, live.level) ? live.level : 'high'
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* 前端刚增强过的文本指纹。发送拦截路径增强完再 submit 时,pre-step 会再看到
|
|
1912
|
+
* 这条消息;用它去重,避免同一句话被改两遍。两分钟自动过期。
|
|
1913
|
+
*/
|
|
1914
|
+
const recentEnhanced = new Map()
|
|
1915
|
+
const DEDUPE_TTL = 120_000
|
|
1916
|
+
|
|
1917
|
+
function fingerprint(text) {
|
|
1918
|
+
let h = 0
|
|
1919
|
+
for (let i = 0; i < text.length; i++) {
|
|
1920
|
+
h = (h * 31 + text.charCodeAt(i)) | 0
|
|
1921
|
+
}
|
|
1922
|
+
return `${text.length}:${h}`
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function rememberEnhanced(text) {
|
|
1926
|
+
const now = Date.now()
|
|
1927
|
+
for (const [key, ts] of recentEnhanced) {
|
|
1928
|
+
if (now - ts > DEDUPE_TTL) recentEnhanced.delete(key)
|
|
1929
|
+
}
|
|
1930
|
+
recentEnhanced.set(fingerprint(text), now)
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function wasJustEnhanced(text) {
|
|
1934
|
+
const ts = recentEnhanced.get(fingerprint(text))
|
|
1935
|
+
return ts !== undefined && Date.now() - ts < DEDUPE_TTL
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* 全局默认模型选择(composer 里的模型选择器改的就是它),
|
|
1940
|
+
* 让输入框按钮在「会话还没创建、没有 agent」时也能拿到路由。
|
|
1941
|
+
*/
|
|
1942
|
+
function defaultRoute() {
|
|
1943
|
+
const defaults = ctx.get('agentDefaultModel')
|
|
1944
|
+
const selection = defaults?.currentSelection?.()
|
|
1945
|
+
return selection?.provider && selection?.model
|
|
1946
|
+
? { provider: selection.provider, model: selection.model }
|
|
1947
|
+
: null
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/** 配置里显式指定的路由。 */
|
|
1951
|
+
function configRoute() {
|
|
1952
|
+
return live.provider && live.model ? { provider: live.provider, model: live.model } : null
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function resolveRoute(agent) {
|
|
1956
|
+
const provider = agent?.options?.provider
|
|
1957
|
+
const model = agent?.options?.model
|
|
1958
|
+
return configRoute()
|
|
1959
|
+
?? (provider && model ? { provider, model } : null)
|
|
1960
|
+
?? defaultRoute()
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** web 端点没有 agent:配置 > 最近一次会话路由 > 全局默认选择。 */
|
|
1964
|
+
function endpointRoute() {
|
|
1965
|
+
return configRoute() ?? lastRoute ?? defaultRoute()
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// 会话一建立就记住路由,这样输入框按钮在「还没发过消息」时也能工作。
|
|
1969
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
1970
|
+
const route = resolveRoute(agent)
|
|
1971
|
+
if (route) lastRoute = route
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1974
|
+
/** 对单条消息做增强;不需要增强时原样返回同一个对象。 */
|
|
1975
|
+
async function enhanceOne(message, agent, signal) {
|
|
1976
|
+
const raw = singleTextOf(message)
|
|
1977
|
+
if (raw === null || shouldSkip(raw, config)) return message
|
|
1978
|
+
// 前端发送拦截路径已经增强过,这里放行,避免二次改写。
|
|
1979
|
+
if (wasJustEnhanced(raw.trim())) return message
|
|
1980
|
+
const level = currentLevel
|
|
1981
|
+
if (!level || level === 'off') return message
|
|
1982
|
+
const route = resolveRoute(agent) ?? lastRoute
|
|
1983
|
+
if (!route) return message
|
|
1984
|
+
if (!llmRef) return message // llm 服务尚未就绪(inject 未解析完):fail-open 放行原文
|
|
1985
|
+
const enhanced = await rewriteWithLlm({ llm: llmRef }, route, raw.trim(), config, level, signal)
|
|
1986
|
+
log('log', `enhanced[${level}] ${raw.trim().length} -> ${enhanced.length} chars`)
|
|
1987
|
+
// 用官方工厂重建消息:获得全新稳定 id,source 沿用原消息
|
|
1988
|
+
return createUserMessage({
|
|
1989
|
+
content: [{ type: 'text', text: enhanced }],
|
|
1990
|
+
source: message.source,
|
|
1991
|
+
})
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** 批量增强;没有任何改动时原样返回原数组。 */
|
|
1995
|
+
async function enhanceBatch(messages, agent, signal) {
|
|
1996
|
+
const out = []
|
|
1997
|
+
let changed = false
|
|
1998
|
+
for (const message of messages) {
|
|
1999
|
+
let next = message
|
|
2000
|
+
try {
|
|
2001
|
+
next = await enhanceOne(message, agent, signal)
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
// fail-open:增强失败绝不阻塞对话
|
|
2004
|
+
log('error', `enhance failed, pass through original: ${err?.message ?? err}`)
|
|
2005
|
+
next = message
|
|
2006
|
+
}
|
|
2007
|
+
if (next !== message) changed = true
|
|
2008
|
+
out.push(next)
|
|
2009
|
+
}
|
|
2010
|
+
return changed ? out : messages
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// pre-step 自动增强用的 llm 引用:在下方嵌套 inject 作用域里捕获。
|
|
2014
|
+
// 套件宿主不按插件级 inject 并集给顶层作用域授权属性访问——真机 0.3.0:
|
|
2015
|
+
// suite 联合声明了 tools,apply 里 ctx.tools 仍报 cannot get property
|
|
2016
|
+
// without inject;0.3.2 同款实锤蔓延到请求期 ctx.llm(/rewrite 全挂)。
|
|
2017
|
+
// 凡要用的服务,必须写进所在嵌套作用域自己的 inject 数组。
|
|
2018
|
+
let llmRef = null
|
|
2019
|
+
|
|
2020
|
+
if (live.enabled) {
|
|
2021
|
+
// 最近一轮会话捕获:输入框按钮带 sessionId 来取(参考上下文开关)。
|
|
2022
|
+
// 自己的改写调用经 llmDepth 标记透传,不会被误当成对话轮。
|
|
2023
|
+
ctx.on('llm/stream', (options, next) => {
|
|
2024
|
+
if (llmDepth.getStore() !== undefined) return next()
|
|
2025
|
+
const downstream = next()
|
|
2026
|
+
const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
|
|
2027
|
+
const userText = lastUserTextOf(options?.messages)
|
|
2028
|
+
let assistantText = ''
|
|
2029
|
+
let downstreamIter = null
|
|
2030
|
+
let completed = false
|
|
2031
|
+
return (async function* peObserve() {
|
|
2032
|
+
try {
|
|
2033
|
+
downstreamIter = downstream[Symbol.asyncIterator]()
|
|
2034
|
+
for (;;) {
|
|
2035
|
+
const result = await llmDepth.run(1, () => downstreamIter.next())
|
|
2036
|
+
if (result.done) break
|
|
2037
|
+
const chunk = result.value
|
|
2038
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
2039
|
+
assistantText += chunk.text
|
|
2040
|
+
if (assistantText.length > 8000) assistantText = assistantText.slice(-4000)
|
|
2041
|
+
}
|
|
2042
|
+
yield chunk
|
|
2043
|
+
}
|
|
2044
|
+
completed = true
|
|
2045
|
+
} finally {
|
|
2046
|
+
if (!completed) {
|
|
2047
|
+
try { await downstreamIter?.return?.() } catch { /* ignore */ }
|
|
2048
|
+
}
|
|
2049
|
+
try { rememberTurn(sessionId, userText, assistantText) } catch { /* 缓存失败不影响主对话流 */ }
|
|
2050
|
+
}
|
|
2051
|
+
})()
|
|
2052
|
+
}, { global: true })
|
|
2053
|
+
|
|
2054
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
2055
|
+
const decision = await next()
|
|
2056
|
+
if (decision.kind !== 'enter') return decision
|
|
2057
|
+
const route = resolveRoute(agent)
|
|
2058
|
+
if (route) lastRoute = route
|
|
2059
|
+
if (live.mode !== 'auto') return decision
|
|
2060
|
+
// 前端拦截发送并自行增强时,这里不再二次增强(否则同一句话被改两遍)。
|
|
2061
|
+
if (!live.preStepAuto) return decision
|
|
2062
|
+
const messages = await enhanceBatch(decision.messages, agent, signal)
|
|
2063
|
+
if (messages === decision.messages) return decision
|
|
2064
|
+
// spread 保留未来新增的 decision 字段
|
|
2065
|
+
return { ...decision, messages }
|
|
2066
|
+
})
|
|
2067
|
+
|
|
2068
|
+
// 注册模型可调用的工具:agent 自己也能主动增强某段提示词。
|
|
2069
|
+
// 显式嵌套声明 ['llm','tools'](原因见上方 llmRef 注释);顺手捕获
|
|
2070
|
+
// llm 引用,execute 里也不能用外层 ctx(同款报错,见 0.3.2)。
|
|
2071
|
+
ctx.inject(['llm', 'tools'], (scope) => {
|
|
2072
|
+
llmRef = scope.llm
|
|
2073
|
+
scope.tools.register(defineTool({
|
|
2074
|
+
name: 'enhance_prompt',
|
|
2075
|
+
description: [
|
|
2076
|
+
'Rewrite a rough user prompt into a clear, specific, self-contained prompt before using it.',
|
|
2077
|
+
'Use it when the user asks to improve/expand their prompt, or before re-running a vague earlier request.',
|
|
2078
|
+
].join(' '),
|
|
2079
|
+
parameters: {
|
|
2080
|
+
prompt: { type: 'string', required: true, description: 'The raw prompt text to enhance' },
|
|
2081
|
+
},
|
|
2082
|
+
output: {
|
|
2083
|
+
schema: { type: 'string' },
|
|
2084
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
2085
|
+
},
|
|
2086
|
+
async execute(args, exec) {
|
|
2087
|
+
const route = endpointRoute()
|
|
2088
|
+
if (!route) throw new Error('No model route available: set provider/model in the plugin config.')
|
|
2089
|
+
const level = currentLevel === 'off' ? 'high' : currentLevel
|
|
2090
|
+
const enhanced = await rewriteWithLlm(scope, route, String(args.prompt ?? '').trim(), config, level, exec?.signal)
|
|
2091
|
+
return enhanced
|
|
2092
|
+
},
|
|
2093
|
+
}))
|
|
2094
|
+
})
|
|
2095
|
+
|
|
2096
|
+
log('log', `loaded (mode=${live.mode}, timeout=${live.timeoutMs}ms)`)
|
|
2097
|
+
} else {
|
|
2098
|
+
log('log', 'loaded but disabled by config')
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/* ------------------------------------------------------------------ */
|
|
2102
|
+
/* web 端「增强提示词」端点(只在有 webServer 的宿主里注册) */
|
|
2103
|
+
/* ------------------------------------------------------------------ */
|
|
2104
|
+
|
|
2105
|
+
// 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有输入框按钮。
|
|
2106
|
+
// webServer 作用域里请求期要调 llm:必须写进这个嵌套作用域自己的 inject
|
|
2107
|
+
// (真机 0.3.1:/rewrite 里 rewriteWithLlm(ctx) 报 cannot get property
|
|
2108
|
+
// "llm" without inject——顶层并集不授权属性访问,见上方 llmRef 注释)。
|
|
2109
|
+
ctx.inject(['webServer', 'llm'], (webCtx) => {
|
|
2110
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
2111
|
+
kind: 'prefix',
|
|
2112
|
+
path: '/api/prompt-enhancer',
|
|
2113
|
+
handler: async (req, res) => {
|
|
2114
|
+
const send = (status, body) => {
|
|
2115
|
+
res.statusCode = status
|
|
2116
|
+
res.setHeader('content-type', 'application/json')
|
|
2117
|
+
res.end(JSON.stringify(body))
|
|
2118
|
+
}
|
|
2119
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/prompt-enhancer/, '')
|
|
2120
|
+
|
|
2121
|
+
// 前端初始化:读当前档位,避免刷新后选择器回落到默认值。
|
|
2122
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
2123
|
+
send(200, { ...live, level: currentLevel, levels: Object.keys(LEVELS) })
|
|
2124
|
+
return
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// 前端改档位:写进运行时状态,让 pre-step 与按钮共用同一档。
|
|
2128
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
2129
|
+
let raw = ''
|
|
2130
|
+
for await (const chunk of req) raw += chunk
|
|
2131
|
+
let body
|
|
2132
|
+
try {
|
|
2133
|
+
body = JSON.parse(raw)
|
|
2134
|
+
} catch {
|
|
2135
|
+
send(400, { error: 'invalid-json' })
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
// level 是运行时主开关,必须合法;其余字段宽松校验后写入 live。
|
|
2139
|
+
if (body?.level !== undefined) {
|
|
2140
|
+
const next = body.level
|
|
2141
|
+
if (typeof next !== 'string' || !(next in LEVELS)) {
|
|
2142
|
+
send(400, { error: `level must be one of ${Object.keys(LEVELS).join('/')}` })
|
|
2143
|
+
return
|
|
2144
|
+
}
|
|
2145
|
+
currentLevel = next
|
|
2146
|
+
live.level = next
|
|
2147
|
+
log('log', `level -> ${next}`)
|
|
2148
|
+
}
|
|
2149
|
+
if (body?.mode === 'auto' || body?.mode === 'manual') live.mode = body.mode
|
|
2150
|
+
if (body?.preStepAuto !== undefined) live.preStepAuto = body.preStepAuto === true
|
|
2151
|
+
if (body?.useContext !== undefined) live.useContext = body.useContext === true
|
|
2152
|
+
if (body?.language === 'auto' || body?.language === 'zh' || body?.language === 'en') live.language = body.language
|
|
2153
|
+
if (typeof body?.styleHint === 'string') live.styleHint = body.styleHint.slice(0, 500)
|
|
2154
|
+
for (const [key, min, max] of [['timeoutMs', 1000, 120000], ['minPromptLength', 1, 200], ['maxInputChars', 100, 100000]]) {
|
|
2155
|
+
if (body?.[key] === undefined) continue
|
|
2156
|
+
const value = Number(body[key])
|
|
2157
|
+
if (Number.isFinite(value)) live[key] = Math.min(max, Math.max(min, Math.round(value)))
|
|
2158
|
+
}
|
|
2159
|
+
persistSettings()
|
|
2160
|
+
send(200, { ...live, level: currentLevel })
|
|
2161
|
+
return
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// 前端把拦截决策写进 ~/.dsh/prompt-enhancer-debug.log,排查用。
|
|
2165
|
+
if (path === '/debug' && req.method === 'POST') {
|
|
2166
|
+
let raw = ''
|
|
2167
|
+
for await (const chunk of req) raw += chunk
|
|
2168
|
+
try {
|
|
2169
|
+
await mkdir(dirname(DEBUG_LOG), { recursive: true })
|
|
2170
|
+
await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${raw}\n`, 'utf8')
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
log('error', `debug log failed: ${err?.message ?? err}`)
|
|
2173
|
+
}
|
|
2174
|
+
send(200, { ok: true })
|
|
2175
|
+
return
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
if (path !== '/rewrite') {
|
|
2179
|
+
send(404, { error: 'not-found' })
|
|
2180
|
+
return
|
|
2181
|
+
}
|
|
2182
|
+
if (req.method !== 'POST') {
|
|
2183
|
+
res.setHeader('allow', 'POST')
|
|
2184
|
+
send(405, { error: 'method-not-allowed' })
|
|
2185
|
+
return
|
|
2186
|
+
}
|
|
2187
|
+
let raw = ''
|
|
2188
|
+
for await (const chunk of req) raw += chunk
|
|
2189
|
+
let body
|
|
2190
|
+
try {
|
|
2191
|
+
body = JSON.parse(raw)
|
|
2192
|
+
} catch {
|
|
2193
|
+
send(400, { error: 'invalid-json' })
|
|
2194
|
+
return
|
|
2195
|
+
}
|
|
2196
|
+
const text = typeof body?.text === 'string' ? body.text.trim() : ''
|
|
2197
|
+
if (text.length === 0) {
|
|
2198
|
+
send(400, { error: 'text must be a non-empty string' })
|
|
2199
|
+
return
|
|
2200
|
+
}
|
|
2201
|
+
if (text.length > live.maxInputChars) {
|
|
2202
|
+
send(413, { error: `text exceeds ${live.maxInputChars} chars` })
|
|
2203
|
+
return
|
|
2204
|
+
}
|
|
2205
|
+
// 单次请求可以覆盖档位(发送拦截路径会带上当前选择),否则用运行时档位。
|
|
2206
|
+
const requested = typeof body?.level === 'string' && body.level in LEVELS ? body.level : currentLevel
|
|
2207
|
+
const level = requested === 'off' ? null : requested
|
|
2208
|
+
if (!level) {
|
|
2209
|
+
send(200, { text, skipped: 'level-off' })
|
|
2210
|
+
return
|
|
2211
|
+
}
|
|
2212
|
+
const route = endpointRoute()
|
|
2213
|
+
if (!route) {
|
|
2214
|
+
send(409, { error: 'no model route available; set provider/model in the plugin config' })
|
|
2215
|
+
return
|
|
2216
|
+
}
|
|
2217
|
+
// 参考上下文开关:前端带 sessionId,服务端取该会话最近一轮。
|
|
2218
|
+
const contextTurn = live.useContext && typeof body?.sessionId === 'string'
|
|
2219
|
+
? cachedTurnOf(body.sessionId)
|
|
2220
|
+
: null
|
|
2221
|
+
try {
|
|
2222
|
+
const enhanced = await rewriteWithLlm(webCtx, route, text, config, level, undefined, contextTurn)
|
|
2223
|
+
// 记下指纹:这条文本随后经 submit 进入 pre-step 时不再二次增强。
|
|
2224
|
+
rememberEnhanced(enhanced)
|
|
2225
|
+
send(200, { text: enhanced, level })
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
log('error', `composer enhance failed: ${err?.message ?? err}`)
|
|
2228
|
+
// 落盘:服务端错误只进 console 等于没有(排障教训)。
|
|
2229
|
+
try {
|
|
2230
|
+
await mkdir(dirname(DEBUG_LOG), { recursive: true })
|
|
2231
|
+
await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${JSON.stringify({ phase: 'rewrite-error', error: String(err?.message ?? err).slice(0, 200) })}\n`, 'utf8')
|
|
2232
|
+
} catch { /* ignore */ }
|
|
2233
|
+
send(502, { error: 'enhance-failed' })
|
|
2234
|
+
}
|
|
2235
|
+
},
|
|
2236
|
+
}), 'prompt-enhancer: /api/prompt-enhancer/*')
|
|
2237
|
+
})
|
|
2238
|
+
}
|
|
2239
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
2240
|
+
})()
|
|
2241
|
+
|
|
2242
|
+
/* ==== module: followup(原 dsh-followup) ==== */
|
|
2243
|
+
const followupModule = (() => {
|
|
2244
|
+
/* 由 scripts/build-suite.mjs 从 dsh-followup/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
2245
|
+
/**
|
|
2246
|
+
* dsh-followup — 每轮对话结束后的智能跟进推荐(DeepSeek Harness / dsh)
|
|
2247
|
+
*
|
|
2248
|
+
* Trae「智能跟进推荐」的同款交互:助手回复落定后,用一次轻量模型调用
|
|
2249
|
+
* 生成 2-3 条短跟进建议,缓存在内存里;web 端在输入框上方浮出可点击
|
|
2250
|
+
* 的 chip,点击把建议填进草稿(不自动发送)。
|
|
2251
|
+
*
|
|
2252
|
+
* 工作原理(服务端半):
|
|
2253
|
+
* 主通道(0.2.3 起,借鉴 dsh-cost-meter 的链尾监听模式):
|
|
2254
|
+
* - ctx.on('llm/stream', listener, { global: true }):llm 服务的瀑布事件,
|
|
2255
|
+
* 每次 ctx.llm.stream() 都会经过。监听器拿到下游流后包一层旁路观察
|
|
2256
|
+
* (逐 chunk 透传,不改流协议),累积 text-delta 得到助手回复;
|
|
2257
|
+
* options.sessionId(GenerateOptions.sessionId,dsh-reference types.ts)
|
|
2258
|
+
* 关联会话,options.messages 里最后一个真实用户消息作为本轮输入。
|
|
2259
|
+
* { global: true } 让监听器收到隔离 llm 作用域(记忆插件等)的流。
|
|
2260
|
+
* AsyncLocalStorage 深度标记排除两类重复捕获:包装路由插件在自己
|
|
2261
|
+
* stream() 体内再发起的上游调用(pull 下游时打标记),以及本插件
|
|
2262
|
+
* 自己发起的建议生成调用(发起时打标记)。
|
|
2263
|
+
* 回落通道:
|
|
2264
|
+
* - 客户端回合落定后 POST /generate {sessionId};服务端优先用 llm/stream
|
|
2265
|
+
* 实时捕获的缓存(10 分钟内),未命中才解压 session.v4.jsonl.zstd 直读
|
|
2266
|
+
* (日志驱动,0.2.0 引入,对新架构进程分裂免疫)
|
|
2267
|
+
* 旧架构事件监听(agent/pre-step / assistant-stream / status)保留:
|
|
2268
|
+
* 收不到事件的宿主上它们是死代码,旧版本 dsh 上仍可用。
|
|
2269
|
+
*
|
|
2270
|
+
* 已核对的 dsh API(deepseek-ai/deepseek-harness,对照 dsh-reference):
|
|
2271
|
+
* - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
|
|
2272
|
+
* 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套/排除
|
|
2273
|
+
* 时原样返回 next() 的下游
|
|
2274
|
+
* - GenerateOptions.sessionId?: Branded<'SessionId'>(packages/llm/llm/src/types.ts)
|
|
2275
|
+
* - ctx.on('agent/status', ({ agent, status }) => ...) // idle ⇄ running
|
|
2276
|
+
* - ctx.on('agent/pre-step', async ({ agent, messages }, next) => PreStepDecision)
|
|
2277
|
+
* - ctx.on('agent/assistant-stream', ({ agent, frame }) => ...)
|
|
2278
|
+
* frame = { type: 'start' | 'chunk' | 'end', chunk?: StreamChunk, ... }
|
|
2279
|
+
* 文本块 chunk = { type: 'text-delta', text }
|
|
2280
|
+
* - ctx.llm.stream({ provider, model, messages, maxTokens, signal })
|
|
2281
|
+
* → AsyncIterable<{ type: 'text-delta', text } | { type: 'finish', reason }>
|
|
2282
|
+
* - agent.options.provider / agent.options.model:会话当前的路由
|
|
2283
|
+
* - ctx.get('agentDefaultModel')?.currentSelection?.() → { provider, model }
|
|
2284
|
+
* - webServer.register({ kind: 'prefix', path, handler }, label)
|
|
2285
|
+
*/
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
const name = 'followup'
|
|
2295
|
+
|
|
2296
|
+
const DIAG_LOG = join(homedir(), '.dsh', 'followup-debug.log')
|
|
2297
|
+
const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
|
|
2298
|
+
const PROFILES_ROOT = join(homedir(), '.dsh', 'profiles')
|
|
2299
|
+
const SUGGEST_FILE = join(homedir(), '.dsh', 'followup-suggestions.json')
|
|
2300
|
+
|
|
2301
|
+
/** 每环节一行落盘:事件是否到、文本抓没抓到、生成成没成,一次复现即可定位断点。 */
|
|
2302
|
+
const report = async (line) => {
|
|
2303
|
+
try {
|
|
2304
|
+
await mkdir(dirname(DIAG_LOG), { recursive: true })
|
|
2305
|
+
await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
|
|
2306
|
+
} catch { /* ignore */ }
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/* ------------------------------------------------------------------ */
|
|
2310
|
+
/* 会话日志直读(新架构进程分裂后 agent/* 事件到不了插件进程, */
|
|
2311
|
+
/* 0.2.0 起改为日志驱动:解压 session.v4.jsonl.zstd 取最后一轮文本) */
|
|
2312
|
+
/* ------------------------------------------------------------------ */
|
|
2313
|
+
|
|
2314
|
+
/** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
|
|
2315
|
+
async function decompressLog(file) {
|
|
2316
|
+
const zlib = await import('node:zlib')
|
|
2317
|
+
if (typeof zlib.zstdDecompressSync !== 'function') {
|
|
2318
|
+
throw new Error('node zlib 无 zstd 支持(需 Node ≥23.8)')
|
|
2319
|
+
}
|
|
2320
|
+
const buf = await readFile(file)
|
|
2321
|
+
const MAGIC = 0x28
|
|
2322
|
+
const idxs = [0]
|
|
2323
|
+
for (let i = 4; i <= buf.length - 4; i += 1) {
|
|
2324
|
+
if (buf[i] === MAGIC && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
|
|
2325
|
+
}
|
|
2326
|
+
let out = ''
|
|
2327
|
+
for (let k = 0; k < idxs.length; k += 1) {
|
|
2328
|
+
try {
|
|
2329
|
+
out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8')
|
|
2330
|
+
} catch { /* 截断/坏帧跳过 */ }
|
|
2331
|
+
}
|
|
2332
|
+
return out
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/** 找会话日志文件:目录名可能是裸 uuid 或 session-<uuid>。 */
|
|
2336
|
+
async function findSessionFile(sessionId) {
|
|
2337
|
+
const bare = String(sessionId).replace(/^session-/, '')
|
|
2338
|
+
let workspaces = []
|
|
2339
|
+
try { workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }) } catch { return null }
|
|
2340
|
+
for (const ws of workspaces) {
|
|
2341
|
+
if (!ws.isDirectory()) continue
|
|
2342
|
+
for (const name of [sessionId, `session-${bare}`, bare]) {
|
|
2343
|
+
const file = join(SESSIONS_ROOT, ws.name, name, 'session.v4.jsonl.zstd')
|
|
2344
|
+
try { await readFile(file); return file } catch { /* 尝试下一个 */ }
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return null
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** 从持久日志提取最后一轮:最后一个真实用户消息 + 其后最后的助手文本。 */
|
|
2351
|
+
async function lastTurnFromLog(sessionId) {
|
|
2352
|
+
const file = await findSessionFile(sessionId)
|
|
2353
|
+
if (!file) return null
|
|
2354
|
+
const text = await decompressLog(file)
|
|
2355
|
+
const events = []
|
|
2356
|
+
for (const line of text.split('\n')) {
|
|
2357
|
+
if (line.length === 0) continue
|
|
2358
|
+
try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
|
|
2359
|
+
}
|
|
2360
|
+
const isRealUser = (e) => e?.type === 'user/message'
|
|
2361
|
+
&& e.data?.source?.kind === 'user'
|
|
2362
|
+
&& Array.isArray(e.data.content) && e.data.content.length === 1
|
|
2363
|
+
&& e.data.content[0]?.type === 'text' && typeof e.data.content[0].text === 'string'
|
|
2364
|
+
const isAssistant = (e) => e?.type === 'assistant/message'
|
|
2365
|
+
let assistantText = null
|
|
2366
|
+
let userText = null
|
|
2367
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2368
|
+
const e = events[i]
|
|
2369
|
+
if (assistantText === null && isAssistant(e)) {
|
|
2370
|
+
const blocks = e.data?.message?.content
|
|
2371
|
+
if (Array.isArray(blocks)) {
|
|
2372
|
+
const joined = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
2373
|
+
.map((b) => b.text).join('')
|
|
2374
|
+
if (joined.trim().length > 0) assistantText = joined
|
|
2375
|
+
}
|
|
2376
|
+
continue
|
|
2377
|
+
}
|
|
2378
|
+
if (assistantText !== null && isRealUser(e)) {
|
|
2379
|
+
userText = e.data.content[0].text
|
|
2380
|
+
break
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (userText === null || assistantText === null) return null
|
|
2384
|
+
return { userText: userText.trim(), assistantText: assistantText.trim() }
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/** 从 profile 用户补丁层读 agent-default-model 的 provider/model(兜底路由)。 */
|
|
2388
|
+
async function patchDefaultRoute() {
|
|
2389
|
+
let profiles = []
|
|
2390
|
+
try { profiles = await readdir(PROFILES_ROOT, { withFileTypes: true }) } catch { return null }
|
|
2391
|
+
for (const p of profiles) {
|
|
2392
|
+
if (!p.isDirectory()) continue
|
|
2393
|
+
try {
|
|
2394
|
+
const raw = await readFile(join(PROFILES_ROOT, p.name, 'cordis.patch.yml'), 'utf8')
|
|
2395
|
+
const m = raw.match(/id:\s*agent-default-model[\s\S]{0,300}?provider:\s*(\S+)[\s\S]{0,120}?model:\s*(\S+)/)
|
|
2396
|
+
if (m) return { provider: m[1].replace(/^['"]|['"]$/g, ''), model: m[2].replace(/^['"]|['"]$/g, '') }
|
|
2397
|
+
} catch { /* 下一个 profile */ }
|
|
2398
|
+
}
|
|
2399
|
+
return null
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const moduleInject = ['llm']
|
|
2403
|
+
|
|
2404
|
+
const Config = Schema.object({
|
|
2405
|
+
/** 总开关:关闭后不监听任何事件,也不注册 web 端点。 */
|
|
2406
|
+
enabled: Schema.boolean().default(true).description('是否启用智能跟进推荐'),
|
|
2407
|
+
/** 留空则跟随会话当前模型(agent.options)。 */
|
|
2408
|
+
provider: Schema.string().description('生成建议使用的 provider(留空跟随会话)'),
|
|
2409
|
+
/** 建议配一个快模型:这条调用在每轮回复结束后立刻发生。 */
|
|
2410
|
+
model: Schema.string().description('生成建议使用的模型(留空跟随会话,建议快模型)'),
|
|
2411
|
+
/** 单次生成超时;超时即放弃本轮建议。 */
|
|
2412
|
+
timeoutMs: Schema.number().default(12000).description('生成超时(毫秒)'),
|
|
2413
|
+
/** 回合落定后等多久再生成:缓冲多步回合的中间 idle。 */
|
|
2414
|
+
idleDelayMs: Schema.number().default(1200).description('回合结束后的缓冲时间(毫秒)'),
|
|
2415
|
+
/** 生成几条建议(最多 3)。 */
|
|
2416
|
+
maxSuggestions: Schema.number().default(2).description('最多生成几条建议(1-2)'),
|
|
2417
|
+
/** 上下文裁剪:超出截断,控制这条调用的 token 开销。 */
|
|
2418
|
+
userChars: Schema.number().default(400).description('用户消息最多带多少字符'),
|
|
2419
|
+
assistantChars: Schema.number().default(1200).description('助手回复最多带多少字符'),
|
|
2420
|
+
/** 助手回复短于此不生成:「好的」「完成」这类没有可跟进的实质内容。 */
|
|
2421
|
+
minAssistantChars: Schema.number().default(30).description('助手回复最小长度(字符)'),
|
|
2422
|
+
})
|
|
2423
|
+
|
|
2424
|
+
/* ------------------------------------------------------------------ */
|
|
2425
|
+
/* 提示词与解析 */
|
|
2426
|
+
/* ------------------------------------------------------------------ */
|
|
2427
|
+
|
|
2428
|
+
function clampTail(text, limit) {
|
|
2429
|
+
const clean = typeof text === 'string' ? text.trim() : ''
|
|
2430
|
+
if (clean.length <= limit) return clean
|
|
2431
|
+
return `…${clean.slice(-limit)}`
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function buildPrompt(userText, assistantText, count, config) {
|
|
2435
|
+
return [
|
|
2436
|
+
`You generate follow-up suggestions for an AI chat session (DeepSeek Harness). Based on the exchange below, propose ${count} short follow-up prompts the user would most plausibly send next.`,
|
|
2437
|
+
'',
|
|
2438
|
+
'Rules:',
|
|
2439
|
+
'- Continue the conversation: refine, extend, verify or apply the assistant\'s last answer. Never generic filler like "还有吗" / "继续" / "tell me more".',
|
|
2440
|
+
'- One imperative sentence each: 8~24 Chinese characters or 6~16 English words. No trailing period, no quotes, no numbering, no emoji.',
|
|
2441
|
+
'- They must be sendable as-is: self-contained requests, not fragments.',
|
|
2442
|
+
'- Write in the same language as the exchange.',
|
|
2443
|
+
'- Output STRICT JSON only: an array of strings, nothing else. Example: ["第一条建议","第二条建议"]',
|
|
2444
|
+
'',
|
|
2445
|
+
'[User]',
|
|
2446
|
+
clampTail(userText, config.userChars),
|
|
2447
|
+
'',
|
|
2448
|
+
'[Assistant]',
|
|
2449
|
+
clampTail(assistantText, config.assistantChars),
|
|
2450
|
+
].join('\n')
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/** 从模型输出里抠 JSON 数组;抠不出就返回空数组,宁缺毋滥。 */
|
|
2454
|
+
function parseSuggestions(text, count) {
|
|
2455
|
+
const start = text.indexOf('[')
|
|
2456
|
+
const end = text.lastIndexOf(']')
|
|
2457
|
+
if (start < 0 || end <= start) return []
|
|
2458
|
+
let parsed
|
|
2459
|
+
try {
|
|
2460
|
+
parsed = JSON.parse(text.slice(start, end + 1))
|
|
2461
|
+
} catch {
|
|
2462
|
+
return []
|
|
2463
|
+
}
|
|
2464
|
+
if (!Array.isArray(parsed)) return []
|
|
2465
|
+
return parsed
|
|
2466
|
+
.filter((item) => typeof item === 'string')
|
|
2467
|
+
.map((item) => item.trim().replace(/^[-*•\d.)、\s]+/, '').slice(0, 60))
|
|
2468
|
+
.filter((item) => item.length >= 2)
|
|
2469
|
+
.slice(0, count)
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/* ------------------------------------------------------------------ */
|
|
2473
|
+
/* 插件入口 */
|
|
2474
|
+
/* ------------------------------------------------------------------ */
|
|
2475
|
+
|
|
2476
|
+
function apply(ctx, config) {
|
|
2477
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
2478
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
2479
|
+
const live = { ...config }
|
|
2480
|
+
if (!live.enabled) return
|
|
2481
|
+
|
|
2482
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
|
|
2483
|
+
const SETTINGS_KEYS = ['maxSuggestions', 'idleDelayMs', 'timeoutMs', 'minAssistantChars', 'userChars', 'assistantChars']
|
|
2484
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-followup', 'settings.json')
|
|
2485
|
+
try {
|
|
2486
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
2487
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
2488
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
2489
|
+
let settingsTimer = null
|
|
2490
|
+
const persistSettings = () => {
|
|
2491
|
+
if (settingsTimer) return
|
|
2492
|
+
settingsTimer = setTimeout(() => {
|
|
2493
|
+
settingsTimer = null
|
|
2494
|
+
try {
|
|
2495
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
2496
|
+
const out = {}
|
|
2497
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
2498
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
2499
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
2500
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
2501
|
+
} catch { /* ignore */ }
|
|
2502
|
+
}, 400)
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
void report(`server boot v0.3.3 enabled=true minAssistantChars=${live.minAssistantChars} idleDelayMs=${live.idleDelayMs} route=${live.provider || ''}/${live.model || ''}(follow-session)`)
|
|
2506
|
+
|
|
2507
|
+
/** 每个 agent 的对话尾部累积:userText 来自 pre-step,assistantText 来自流帧。 */
|
|
2508
|
+
const buffers = new WeakMap()
|
|
2509
|
+
const seenFrameTypes = new Set()
|
|
2510
|
+
let bufCounter = 0
|
|
2511
|
+
const agentConstructorName = (agent) => (agent === undefined || agent === null
|
|
2512
|
+
? 'none'
|
|
2513
|
+
: `${agent.constructor?.name ?? typeof agent}`)
|
|
2514
|
+
const bufferOf = (agent) => {
|
|
2515
|
+
let buf = buffers.get(agent)
|
|
2516
|
+
if (buf === undefined) {
|
|
2517
|
+
buf = { id: ++bufCounter, userText: '', assistantText: '', controller: null }
|
|
2518
|
+
buffers.set(agent, buf)
|
|
2519
|
+
void report(`buffer new #${buf.id} for agent=${agentConstructorName(agent)}`)
|
|
2520
|
+
}
|
|
2521
|
+
return buf
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/** 供 web 端拉取的最近一份建议;seq 递增,客户端凭它去重。 */
|
|
2525
|
+
let latest = { seq: 0, suggestions: [], at: 0 }
|
|
2526
|
+
/** 最近一次生成的上下文,供「换一批」重生成。 */
|
|
2527
|
+
let lastContext = null
|
|
2528
|
+
let seqCounter = 0
|
|
2529
|
+
let pendingTimer = 0
|
|
2530
|
+
|
|
2531
|
+
/* -- 建议持久化(0.2.4):重启后打开会话还能拿到最近一轮的建议 ----- */
|
|
2532
|
+
/** sessionId → { seq, suggestions, at },boot 时读回,成功生成时写入。 */
|
|
2533
|
+
let persisted = {}
|
|
2534
|
+
try {
|
|
2535
|
+
persisted = JSON.parse(readFileSync(SUGGEST_FILE, 'utf8'))
|
|
2536
|
+
if (!persisted || typeof persisted !== 'object') persisted = {}
|
|
2537
|
+
for (const entry of Object.values(persisted)) {
|
|
2538
|
+
if (Number.isInteger(entry?.seq) && entry.seq > seqCounter) seqCounter = entry.seq
|
|
2539
|
+
}
|
|
2540
|
+
say('info', `restored ${Object.keys(persisted).length} persisted suggestion set(s)`)
|
|
2541
|
+
} catch { /* 首次或损坏:空表开始 */ }
|
|
2542
|
+
|
|
2543
|
+
function persistSuggestions(sessionId, seq, suggestions) {
|
|
2544
|
+
if (!sessionId || !Array.isArray(suggestions) || suggestions.length === 0) return
|
|
2545
|
+
persisted[sessionId] = { seq, suggestions, at: Date.now() }
|
|
2546
|
+
const keys = Object.keys(persisted)
|
|
2547
|
+
if (keys.length > 24) {
|
|
2548
|
+
let oldest = null
|
|
2549
|
+
let oldestAt = Infinity
|
|
2550
|
+
for (const key of keys) {
|
|
2551
|
+
if (persisted[key].at < oldestAt) { oldestAt = persisted[key].at; oldest = key }
|
|
2552
|
+
}
|
|
2553
|
+
if (oldest !== null) delete persisted[oldest]
|
|
2554
|
+
}
|
|
2555
|
+
void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
|
|
2556
|
+
.catch(() => { /* 持久化失败不影响主流程 */ })
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/** Trae 逻辑:建议属于「每轮响应」——本轮没有新建议就清掉该会话的旧建议。 */
|
|
2560
|
+
function clearSuggestions(sessionId) {
|
|
2561
|
+
if (!sessionId || !(sessionId in persisted)) return
|
|
2562
|
+
delete persisted[sessionId]
|
|
2563
|
+
void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
|
|
2564
|
+
.catch(() => { /* ignore */ })
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const say = (level, message) => {
|
|
2568
|
+
try {
|
|
2569
|
+
console[level === 'error' ? 'error' : 'info'](`[dsh-followup] ${message}`)
|
|
2570
|
+
} catch { /* ignore */ }
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
function singleTextOf(message) {
|
|
2574
|
+
if (!message || message.role !== 'user') return null
|
|
2575
|
+
// dsh 内部还有 goal / session-reference / notice 等来源的 user 消息,
|
|
2576
|
+
// 不是用户在对话框里输入的内容,不进上下文。
|
|
2577
|
+
if (message.source?.kind !== 'user') return null
|
|
2578
|
+
if (!Array.isArray(message.content) || message.content.length !== 1) return null
|
|
2579
|
+
const block = message.content[0]
|
|
2580
|
+
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/** llm/stream 捕获用:请求 messages 里最后一个真实用户文本。 */
|
|
2584
|
+
function singleTextOfLastUser(messages) {
|
|
2585
|
+
if (!Array.isArray(messages)) return null
|
|
2586
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
2587
|
+
const text = singleTextOf(messages[i])
|
|
2588
|
+
if (text !== null) return text
|
|
2589
|
+
}
|
|
2590
|
+
return null
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function defaultRoute() {
|
|
2594
|
+
const defaults = ctx.get('agentDefaultModel')
|
|
2595
|
+
const selection = defaults?.currentSelection?.()
|
|
2596
|
+
return selection?.provider && selection?.model
|
|
2597
|
+
? { provider: selection.provider, model: selection.model }
|
|
2598
|
+
: null
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function configRoute() {
|
|
2602
|
+
return live.provider && live.model ? { provider: live.provider, model: live.model } : null
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function resolveRoute(agent) {
|
|
2606
|
+
const provider = agent?.options?.provider
|
|
2607
|
+
const model = agent?.options?.model
|
|
2608
|
+
return configRoute()
|
|
2609
|
+
?? (provider && model ? { provider, model } : null)
|
|
2610
|
+
?? defaultRoute()
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function cancelPending(buf, reason) {
|
|
2614
|
+
if (buf.controller) {
|
|
2615
|
+
try { buf.controller.abort(new Error(reason)) } catch { /* ignore */ }
|
|
2616
|
+
buf.controller = null
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/* -- 事件监听:拼出对话尾部(旧架构回落通道) ---------------------- */
|
|
2621
|
+
/* 新架构进程分裂下这些 agent/* 事件到不了插件进程(0.1.x 实证零事件),
|
|
2622
|
+
保留是为了旧版本 dsh 仍然可用;新架构走上面的 llm/stream 捕获 + 日志回落。 */
|
|
2623
|
+
|
|
2624
|
+
ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
|
2625
|
+
const buf = bufferOf(agent)
|
|
2626
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
2627
|
+
const text = singleTextOf(messages[i])
|
|
2628
|
+
if (text !== null) { buf.userText = text; break }
|
|
2629
|
+
}
|
|
2630
|
+
buf.assistantText = ''
|
|
2631
|
+
// 用户已经开始说下一句:上一份没生成的建议直接作废。
|
|
2632
|
+
cancelPending(buf, 'superseded by next user message')
|
|
2633
|
+
void report(`pre-step buf#${buf.id} agent=${agentConstructorName(agent)} msgs=${messages.length} userLen=${(buf.userText ?? '').length} userHead=${JSON.stringify((buf.userText ?? '').slice(0, 40))}`)
|
|
2634
|
+
return next()
|
|
2635
|
+
})
|
|
2636
|
+
|
|
2637
|
+
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
2638
|
+
const buf = bufferOf(agent)
|
|
2639
|
+
if (frame?.type && !seenFrameTypes.has(frame.type)) {
|
|
2640
|
+
seenFrameTypes.add(frame.type)
|
|
2641
|
+
void report(`stream frame-type first-seen: ${frame.type} agent=${agentConstructorName(agent)}`)
|
|
2642
|
+
}
|
|
2643
|
+
if (frame?.type === 'start') { buf.assistantText = ''; return }
|
|
2644
|
+
const chunk = frame?.type === 'chunk' ? frame.chunk : null
|
|
2645
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
2646
|
+
buf.assistantText += chunk.text
|
|
2647
|
+
// 超长回复只保留尾部,控制内存。
|
|
2648
|
+
if (buf.assistantText.length > 20000) buf.assistantText = buf.assistantText.slice(-10000)
|
|
2649
|
+
return
|
|
2650
|
+
}
|
|
2651
|
+
if (frame?.type === 'end') {
|
|
2652
|
+
void report(`stream end buf#${buf.id} agent=${agentConstructorName(agent)} assistantLen=${(buf.assistantText ?? '').length}`)
|
|
2653
|
+
}
|
|
2654
|
+
})
|
|
2655
|
+
|
|
2656
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
2657
|
+
const buf = bufferOf(agent)
|
|
2658
|
+
if (status !== 'idle') {
|
|
2659
|
+
// 回合重新运转(工具链下一步):撤销缓冲中的生成计划。
|
|
2660
|
+
cancelPending(buf, 'agent running again')
|
|
2661
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = 0 }
|
|
2662
|
+
return
|
|
2663
|
+
}
|
|
2664
|
+
const userText = (buf.userText ?? '').trim()
|
|
2665
|
+
const assistantText = (buf.assistantText ?? '').trim()
|
|
2666
|
+
if (!userText || assistantText.length < live.minAssistantChars) {
|
|
2667
|
+
void report(`idle skip buf#${buf.id} agent=${agentConstructorName(agent)} userLen=${userText.length} assistantLen=${assistantText.length} (need user>0 & assistant>=${live.minAssistantChars})`)
|
|
2668
|
+
return
|
|
2669
|
+
}
|
|
2670
|
+
// 缓冲片刻:多步回合的步骤之间也会闪现 idle,期间回到 running 就作废。
|
|
2671
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
2672
|
+
pendingTimer = setTimeout(() => {
|
|
2673
|
+
pendingTimer = 0
|
|
2674
|
+
void generateFor(agent, buf, userText, assistantText)
|
|
2675
|
+
}, Math.max(200, live.idleDelayMs))
|
|
2676
|
+
})
|
|
2677
|
+
|
|
2678
|
+
/* -- 主通道:llm/stream 瀑布旁路捕获(0.2.3,借鉴 dsh-cost-meter) -- */
|
|
2679
|
+
|
|
2680
|
+
/** 深度标记:存在即「本插件已知会、不需要再捕获」的 llm 流。 */
|
|
2681
|
+
const llmDepth = new AsyncLocalStorage()
|
|
2682
|
+
|
|
2683
|
+
/** 实时捕获的最近一轮:sessionId → { userText, assistantText, at }。 */
|
|
2684
|
+
const turnCache = new Map()
|
|
2685
|
+
const TURN_CACHE_MAX = 24
|
|
2686
|
+
const TURN_CACHE_TTL_MS = 10 * 60_000
|
|
2687
|
+
|
|
2688
|
+
function cachedTurnOf(sessionId) {
|
|
2689
|
+
const hit = turnCache.get(sessionId)
|
|
2690
|
+
if (!hit) return null
|
|
2691
|
+
if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
|
|
2692
|
+
return hit
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
function rememberTurn(sessionId, userText, assistantText) {
|
|
2696
|
+
if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
|
|
2697
|
+
const prev = turnCache.get(sessionId)
|
|
2698
|
+
// 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积,
|
|
2699
|
+
// 让「最终答案」落在缓存尾部(生成时 clampTail 取尾部正好命中)。
|
|
2700
|
+
const merged = prev && prev.userText === userText
|
|
2701
|
+
? `${prev.assistantText}\n\n${assistantText}`
|
|
2702
|
+
: assistantText
|
|
2703
|
+
turnCache.set(sessionId, { userText, assistantText: merged.slice(-20000), at: Date.now() })
|
|
2704
|
+
if (turnCache.size > TURN_CACHE_MAX) {
|
|
2705
|
+
let oldestKey = null
|
|
2706
|
+
let oldestAt = Infinity
|
|
2707
|
+
for (const [key, value] of turnCache) {
|
|
2708
|
+
if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
|
|
2709
|
+
}
|
|
2710
|
+
if (oldestKey !== null) turnCache.delete(oldestKey)
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// llm/stream 捕获是否活着是 0.2.3 的关键真机问题(新架构进程分裂下
|
|
2715
|
+
// 事件归属未实证),首帧与每次入缓存都留痕,一次对话即可从日志判定。
|
|
2716
|
+
let llmStreamArmed = false
|
|
2717
|
+
|
|
2718
|
+
ctx.on('llm/stream', (options, next) => {
|
|
2719
|
+
// 自己发起的建议生成调用 / 下游包装路由的嵌套调用:原样透传。
|
|
2720
|
+
if (llmDepth.getStore() !== undefined) return next()
|
|
2721
|
+
const downstream = next()
|
|
2722
|
+
if (!llmStreamArmed) {
|
|
2723
|
+
llmStreamArmed = true
|
|
2724
|
+
void report('llm/stream first fire: capture channel is ALIVE in this process')
|
|
2725
|
+
}
|
|
2726
|
+
const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
|
|
2727
|
+
const userText = (singleTextOfLastUser(options?.messages) ?? '').trim()
|
|
2728
|
+
let assistantText = ''
|
|
2729
|
+
let downstreamIter = null
|
|
2730
|
+
let completed = false
|
|
2731
|
+
return (async function* followupObserve() {
|
|
2732
|
+
try {
|
|
2733
|
+
downstreamIter = downstream[Symbol.asyncIterator]()
|
|
2734
|
+
for (;;) {
|
|
2735
|
+
// 深度标记内拉取:包装路由适配器在消费上下文里再发起的
|
|
2736
|
+
// ctx.llm.stream() 继承标记,内层监听器判定为已处理、透传。
|
|
2737
|
+
const result = await llmDepth.run(1, () => downstreamIter.next())
|
|
2738
|
+
if (result.done) break
|
|
2739
|
+
const chunk = result.value
|
|
2740
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
2741
|
+
assistantText += chunk.text
|
|
2742
|
+
if (assistantText.length > 20000) assistantText = assistantText.slice(-10000)
|
|
2743
|
+
}
|
|
2744
|
+
yield chunk
|
|
2745
|
+
}
|
|
2746
|
+
completed = true
|
|
2747
|
+
} finally {
|
|
2748
|
+
// 消费方提前中断(用户改下一句、错误终止)也要收尾;流失败时
|
|
2749
|
+
// assistantText 不完整,通常过不了 minAssistantChars,无害。
|
|
2750
|
+
if (!completed) {
|
|
2751
|
+
try { await downstreamIter?.return?.() } catch { /* ignore */ }
|
|
2752
|
+
}
|
|
2753
|
+
try {
|
|
2754
|
+
rememberTurn(sessionId, userText, assistantText)
|
|
2755
|
+
void report(`llm/stream captured session=${sessionId ? sessionId.slice(0, 50) : '(none)'} userLen=${userText.length} assistantLen=${assistantText.length} ${!completed ? '(interrupted)' : ''}`)
|
|
2756
|
+
} catch { /* 缓存失败绝不影响主对话流 */ }
|
|
2757
|
+
}
|
|
2758
|
+
})()
|
|
2759
|
+
}, { global: true })
|
|
2760
|
+
|
|
2761
|
+
/* -- 生成 --------------------------------------------------------- */
|
|
2762
|
+
|
|
2763
|
+
async function runGeneration(llm, route, userText, assistantText, signal) {
|
|
2764
|
+
const count = Math.min(2, Math.max(1, Math.round(live.maxSuggestions)))
|
|
2765
|
+
// 深度标记内发起:本次生成调用经过 llm/stream 瀑布时,监听器读到
|
|
2766
|
+
// 标记即透传——建议生成的 prompt 不会被误缓存为对话轮。
|
|
2767
|
+
// reasoningEffort low + maxTokens 2000:真机 04:50 实测 profile 默认
|
|
2768
|
+
// high 推理把 600 上限全部烧光(raw len=0 → 无建议渲染)。
|
|
2769
|
+
// 给 dsh-cost 打来源标记:建议生成的费用单独归类,不混进对话。
|
|
2770
|
+
globalThis.__dshPluginCall = { source: 'followup', at: Date.now() }
|
|
2771
|
+
|
|
2772
|
+
const iterator = llmDepth.run(1, () => llm.stream({
|
|
2773
|
+
provider: route.provider,
|
|
2774
|
+
model: route.model,
|
|
2775
|
+
reasoningEffort: 'low',
|
|
2776
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: buildPrompt(userText, assistantText, count, config) }] }],
|
|
2777
|
+
maxTokens: 2000,
|
|
2778
|
+
signal,
|
|
2779
|
+
})[Symbol.asyncIterator]())
|
|
2780
|
+
let text = ''
|
|
2781
|
+
try {
|
|
2782
|
+
for (;;) {
|
|
2783
|
+
const { done, value } = await iterator.next()
|
|
2784
|
+
if (done) break
|
|
2785
|
+
if (value?.type === 'text-delta' && typeof value.text === 'string') text += value.text
|
|
2786
|
+
else if (value?.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
|
|
2787
|
+
throw new Error(`followup llm ${value.reason.kind}`)
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
} catch (err) {
|
|
2791
|
+
// 中断底层流,失败后不在后台继续烧 token。
|
|
2792
|
+
try {
|
|
2793
|
+
const closing = iterator.return?.()
|
|
2794
|
+
if (closing && typeof closing.catch === 'function') closing.catch(() => {})
|
|
2795
|
+
} catch { /* ignore */ }
|
|
2796
|
+
throw err
|
|
2797
|
+
}
|
|
2798
|
+
void report(`raw output len=${text.length}: ${JSON.stringify(text.slice(0, 300))}`)
|
|
2799
|
+
return parseSuggestions(text, count)
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
async function generateFor(agent, buf, userText, assistantText) {
|
|
2803
|
+
const route = resolveRoute(agent)
|
|
2804
|
+
if (!route) {
|
|
2805
|
+
say('info', 'no model route available, skip')
|
|
2806
|
+
void report('generate skip: no model route (config unset, agent.options empty, agentDefaultModel missing)')
|
|
2807
|
+
return
|
|
2808
|
+
}
|
|
2809
|
+
const controller = new AbortController()
|
|
2810
|
+
buf.controller = controller
|
|
2811
|
+
const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
|
|
2812
|
+
// 根作用域没声明 llm inject 时属性访问直接抛(真机 07:41 实证);
|
|
2813
|
+
// 捕住降级——cache/日志路径不受影响。
|
|
2814
|
+
let eventLlm = null
|
|
2815
|
+
try { eventLlm = ctx.llm } catch { eventLlm = null }
|
|
2816
|
+
if (eventLlm === null) {
|
|
2817
|
+
void report('event-path skip: llm not injectable in this scope (cache path covers it)')
|
|
2818
|
+
return
|
|
2819
|
+
}
|
|
2820
|
+
try {
|
|
2821
|
+
void report(`generate start route=${route.provider}/${route.model} userLen=${userText.length} assistantLen=${assistantText.length}`)
|
|
2822
|
+
const suggestions = await runGeneration(eventLlm, route, userText, assistantText, controller.signal)
|
|
2823
|
+
if (suggestions.length) {
|
|
2824
|
+
lastContext = { route, userText, assistantText }
|
|
2825
|
+
latest = { seq: ++seqCounter, suggestions, at: Date.now() }
|
|
2826
|
+
let eventSessionId = ''
|
|
2827
|
+
try { eventSessionId = agent?.session?.id ?? '' } catch { eventSessionId = '' }
|
|
2828
|
+
persistSuggestions(eventSessionId, latest.seq, suggestions)
|
|
2829
|
+
say('info', `turn ${latest.seq}: ${suggestions.length} suggestions`)
|
|
2830
|
+
void report(`generate ok seq=${latest.seq} count=${suggestions.length} ${JSON.stringify(suggestions)}`)
|
|
2831
|
+
} else {
|
|
2832
|
+
void report('generate empty: model returned no parseable suggestions')
|
|
2833
|
+
}
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
if (!/aborted|superseded|timeout/i.test(String(err?.message ?? err))) {
|
|
2836
|
+
say('error', `generate failed: ${err?.message ?? err}`)
|
|
2837
|
+
void report(`generate failed: ${String(err?.message ?? err).slice(0, 300)}`)
|
|
2838
|
+
} else {
|
|
2839
|
+
void report(`generate aborted: ${String(err?.message ?? err).slice(0, 120)}`)
|
|
2840
|
+
}
|
|
2841
|
+
} finally {
|
|
2842
|
+
clearTimeout(timer)
|
|
2843
|
+
if (buf.controller === controller) buf.controller = null
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/* -- web 端点 ------------------------------------------------------ */
|
|
2848
|
+
|
|
2849
|
+
// 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有 chips。
|
|
2850
|
+
// llm 必须在此作用域声明——cordis 的属性访问按作用域校验 inject,
|
|
2851
|
+
// 根作用域的静态 inject 不传导进嵌套作用域(真机实测报
|
|
2852
|
+
// cannot get property "llm" without inject)。
|
|
2853
|
+
ctx.inject(['webServer', 'llm'], (webCtx) => {
|
|
2854
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
2855
|
+
kind: 'prefix',
|
|
2856
|
+
path: '/api/dsh-followup',
|
|
2857
|
+
handler: async (req, res) => {
|
|
2858
|
+
const send = (status, body) => {
|
|
2859
|
+
res.statusCode = status
|
|
2860
|
+
res.setHeader('content-type', 'application/json')
|
|
2861
|
+
res.end(JSON.stringify(body))
|
|
2862
|
+
}
|
|
2863
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-followup/, '')
|
|
2864
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
2865
|
+
send(200, { ...live })
|
|
2866
|
+
return
|
|
2867
|
+
}
|
|
2868
|
+
// 运行时改参数:白名单字段,provider/model 不在其中(改了要重建路由)。
|
|
2869
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
2870
|
+
let raw = ''
|
|
2871
|
+
for await (const chunk of req) raw += chunk
|
|
2872
|
+
let body
|
|
2873
|
+
try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
|
|
2874
|
+
const numeric = { maxSuggestions: [1, 3], idleDelayMs: [200, 10000], timeoutMs: [1000, 120000], minAssistantChars: [0, 500], userChars: [100, 4000], assistantChars: [100, 8000] }
|
|
2875
|
+
for (const [key, [min, max]] of Object.entries(numeric)) {
|
|
2876
|
+
if (body[key] === undefined) continue
|
|
2877
|
+
const value = Number(body[key])
|
|
2878
|
+
if (!Number.isFinite(value)) continue
|
|
2879
|
+
live[key] = Math.min(max, Math.max(min, Math.round(value)))
|
|
2880
|
+
}
|
|
2881
|
+
persistSettings()
|
|
2882
|
+
send(200, { ...live })
|
|
2883
|
+
return
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
// 客户端在每次「生成中 → 空闲」后拉取;seq 相同就静默忽略。
|
|
2888
|
+
// Trae 逻辑:建议随会话持久化、无时间过期,只被下一轮替换或清除。
|
|
2889
|
+
if (path === '/latest' && req.method === 'GET') {
|
|
2890
|
+
const qs = new URL(req.url ?? '', 'http://local').searchParams
|
|
2891
|
+
const wantSession = qs.get('sessionId') ?? ''
|
|
2892
|
+
const entry = wantSession ? persisted[wantSession] : undefined
|
|
2893
|
+
if (entry && Array.isArray(entry.suggestions) && entry.suggestions.length > 0) {
|
|
2894
|
+
send(200, entry)
|
|
2895
|
+
return
|
|
2896
|
+
}
|
|
2897
|
+
send(200, latest)
|
|
2898
|
+
return
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/**
|
|
2902
|
+
* 生成建议(0.2.3 双通道):优先用 llm/stream 实时捕获的对话轮
|
|
2903
|
+
* (零文件 IO、无解析时延),未命中才回落日志驱动(0.2.0 引入,
|
|
2904
|
+
* 解压 session.v4.jsonl.zstd 直读)。
|
|
2905
|
+
*/
|
|
2906
|
+
if ((path === '/generate' || path === '/refresh') && req.method === 'POST') {
|
|
2907
|
+
let body = ''
|
|
2908
|
+
for await (const chunk of req) body += chunk
|
|
2909
|
+
let sessionId = ''
|
|
2910
|
+
try { sessionId = JSON.parse(body).sessionId ?? '' } catch { sessionId = '' }
|
|
2911
|
+
if (!sessionId) { send(400, { error: 'missing sessionId' }); return }
|
|
2912
|
+
const cached = cachedTurnOf(sessionId)
|
|
2913
|
+
const turn = cached
|
|
2914
|
+
? { userText: cached.userText, assistantText: cached.assistantText }
|
|
2915
|
+
: await lastTurnFromLog(sessionId)
|
|
2916
|
+
if (!turn) {
|
|
2917
|
+
void report(`generate(${cached ? 'cache' : 'log'}) ${path}: no usable turn in session ${String(sessionId).slice(0, 50)}`)
|
|
2918
|
+
// Trae 逻辑:建议属于每轮响应——本轮拿不到可跟进内容即清掉旧建议
|
|
2919
|
+
clearSuggestions(String(sessionId))
|
|
2920
|
+
send(409, { error: 'no-turn' })
|
|
2921
|
+
return
|
|
2922
|
+
}
|
|
2923
|
+
const route = configRoute() ?? defaultRoute() ?? await patchDefaultRoute()
|
|
2924
|
+
if (!route) {
|
|
2925
|
+
void report(`generate(${cached ? 'cache' : 'log'}): no route (config/agentDefaultModel/patch all missing)`)
|
|
2926
|
+
send(503, { error: 'no-route' })
|
|
2927
|
+
return
|
|
2928
|
+
}
|
|
2929
|
+
const source = cached ? 'cache' : 'log'
|
|
2930
|
+
const controller = new AbortController()
|
|
2931
|
+
const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
|
|
2932
|
+
try {
|
|
2933
|
+
void report(`generate(${source}) start route=${route.provider}/${route.model} userLen=${turn.userText.length} assistantLen=${turn.assistantText.length}`)
|
|
2934
|
+
const suggestions = await runGeneration(webCtx.llm, route, turn.userText, turn.assistantText, controller.signal)
|
|
2935
|
+
if (!suggestions.length) {
|
|
2936
|
+
void report(`generate(${source}) empty`)
|
|
2937
|
+
// 同上:本轮生成失败/为空,旧建议不再属于最新一轮
|
|
2938
|
+
clearSuggestions(String(sessionId))
|
|
2939
|
+
send(502, { error: 'generate-failed' })
|
|
2940
|
+
return
|
|
2941
|
+
}
|
|
2942
|
+
lastContext = { route, ...turn }
|
|
2943
|
+
latest = { seq: ++seqCounter, suggestions, at: Date.now() }
|
|
2944
|
+
persistSuggestions(String(sessionId), latest.seq, suggestions)
|
|
2945
|
+
void report(`generate(${source}) ok seq=${latest.seq} ${JSON.stringify(suggestions)}`)
|
|
2946
|
+
send(200, latest)
|
|
2947
|
+
} catch (err) {
|
|
2948
|
+
void report(`generate(${source}) failed: ${String(err?.message ?? err).slice(0, 200)}`)
|
|
2949
|
+
send(502, { error: 'generate-failed' })
|
|
2950
|
+
} finally {
|
|
2951
|
+
clearTimeout(timer)
|
|
2952
|
+
}
|
|
2953
|
+
return
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
send(404, { error: 'not-found' })
|
|
2957
|
+
},
|
|
2958
|
+
}), 'dsh-followup: /api/dsh-followup/*')
|
|
2959
|
+
})
|
|
2960
|
+
}
|
|
2961
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
2962
|
+
})()
|
|
2963
|
+
|
|
2964
|
+
/* ==== module: voice(原 dsh-siri-voice) ==== */
|
|
2965
|
+
const voiceModule = (() => {
|
|
2966
|
+
/* 由 scripts/build-suite.mjs 从 dsh-siri-voice/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
2967
|
+
/**
|
|
2968
|
+
* dsh-siri-voice — 服务端半(无逻辑)。
|
|
2969
|
+
*
|
|
2970
|
+
* 这个插件只做 web 客户端的视觉层,但 dsh 的客户端模块扫描是从 Host 的
|
|
2971
|
+
* loader entries 出发的,所以包仍然需要一个可被加载的服务端入口:
|
|
2972
|
+
* 声明 `dsh.bundle.patch` 把一行插件插进 profile,`dsh.client.platform = 'web'`
|
|
2973
|
+
* 让 web 宿主把 client.js 挂到页面里。
|
|
2974
|
+
*/
|
|
2975
|
+
|
|
2976
|
+
const name = 'siri-voice'
|
|
2977
|
+
|
|
2978
|
+
/** 不注册任何服务;所有效果都在客户端。 */
|
|
2979
|
+
function apply() {}
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
const Config = Schema.object({ enabled: Schema.boolean().default(true).description('启用') })
|
|
2983
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
2984
|
+
})()
|
|
2985
|
+
|
|
2986
|
+
/* ==== module: prompts(手写) ==== */
|
|
2987
|
+
const promptsModule = (() => {
|
|
2988
|
+
/**
|
|
2989
|
+
* dsh-superinput · prompts 模块(提示词模板库)
|
|
2990
|
+
*
|
|
2991
|
+
* prompts 是手动保存的常用模板(划选正文可一键存入)。
|
|
2992
|
+
* 支持 {{变量}} 占位符,插入时由前端弹窗填值;服务端只存原文,不做替换。
|
|
2993
|
+
*
|
|
2994
|
+
* 存储 ~/.dsh/storages/dsh-superinput/prompts.json(结构 { items: [...] })。
|
|
2995
|
+
*/
|
|
2996
|
+
|
|
2997
|
+
|
|
2998
|
+
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
const moduleInject = []
|
|
3003
|
+
|
|
3004
|
+
const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
|
|
3005
|
+
const STORE_PATH = join(STORE_DIR, 'prompts.json')
|
|
3006
|
+
const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
|
|
3007
|
+
|
|
3008
|
+
const Config = Schema.object({
|
|
3009
|
+
enabled: Schema.boolean().default(true).description('启用提示词模板库'),
|
|
3010
|
+
maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
|
|
3011
|
+
})
|
|
3012
|
+
|
|
3013
|
+
function createStore(maxItems) {
|
|
3014
|
+
let state = { items: [] }
|
|
3015
|
+
let dirty = false
|
|
3016
|
+
let timer = null
|
|
3017
|
+
|
|
3018
|
+
const load = () => {
|
|
3019
|
+
try { state = JSON.parse(readFileSync(STORE_PATH, 'utf8')) } catch { /* 首次或损坏:空库 */ }
|
|
3020
|
+
if (!state || !Array.isArray(state.items)) state = { items: [] }
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
const flush = () => {
|
|
3024
|
+
if (!dirty) return
|
|
3025
|
+
try {
|
|
3026
|
+
mkdirSync(STORE_DIR, { recursive: true })
|
|
3027
|
+
const tmp = `${STORE_PATH}.${process.pid}.tmp`
|
|
3028
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf8')
|
|
3029
|
+
renameSync(tmp, STORE_PATH)
|
|
3030
|
+
dirty = false
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
console.error('[dsh-superinput] prompts flush failed:', error?.message ?? error)
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
const schedule = () => {
|
|
3037
|
+
dirty = true
|
|
3038
|
+
if (timer) return
|
|
3039
|
+
timer = setTimeout(() => { timer = null; flush() }, 500)
|
|
3040
|
+
timer.unref?.()
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
return {
|
|
3044
|
+
load,
|
|
3045
|
+
dispose() { if (timer) { clearTimeout(timer); timer = null } flush() },
|
|
3046
|
+
list() { return state.items },
|
|
3047
|
+
|
|
3048
|
+
/** 新建或更新。title 为空时用正文前 40 字兜底,避免列表里出现无标题条目。 */
|
|
3049
|
+
save({ id, title, body, tags }) {
|
|
3050
|
+
const cleanBody = String(body ?? '').replace(/\0/g, '')
|
|
3051
|
+
if (!cleanBody.trim()) return { ok: false, reason: 'empty-body' }
|
|
3052
|
+
const cleanTitle = String(title ?? '').trim().slice(0, 80) || cleanBody.trim().slice(0, 40)
|
|
3053
|
+
const cleanTags = Array.isArray(tags)
|
|
3054
|
+
? tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
|
|
3055
|
+
: []
|
|
3056
|
+
|
|
3057
|
+
if (id) {
|
|
3058
|
+
const item = state.items.find((entry) => entry.id === id)
|
|
3059
|
+
if (!item) return { ok: false, reason: 'not-found' }
|
|
3060
|
+
item.title = cleanTitle
|
|
3061
|
+
item.body = cleanBody
|
|
3062
|
+
item.tags = cleanTags
|
|
3063
|
+
item.at = Date.now()
|
|
3064
|
+
schedule()
|
|
3065
|
+
return { ok: true, id, created: false }
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
const newId = randomUUID()
|
|
3069
|
+
state.items.unshift({
|
|
3070
|
+
id: newId,
|
|
3071
|
+
title: cleanTitle,
|
|
3072
|
+
body: cleanBody,
|
|
3073
|
+
tags: cleanTags,
|
|
3074
|
+
at: Date.now(),
|
|
3075
|
+
pinned: false,
|
|
3076
|
+
})
|
|
3077
|
+
// 收藏条目不占上限
|
|
3078
|
+
const overflow = state.items.filter((item) => !item.pinned).length - maxItems
|
|
3079
|
+
if (overflow > 0) {
|
|
3080
|
+
const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
|
|
3081
|
+
state.items = state.items.filter((item) => !drop.has(item.id))
|
|
3082
|
+
}
|
|
3083
|
+
schedule()
|
|
3084
|
+
return { ok: true, id: newId, created: true }
|
|
3085
|
+
},
|
|
3086
|
+
|
|
3087
|
+
remove(id) {
|
|
3088
|
+
const before = state.items.length
|
|
3089
|
+
state.items = state.items.filter((item) => item.id !== id)
|
|
3090
|
+
if (state.items.length !== before) schedule()
|
|
3091
|
+
return state.items.length !== before
|
|
3092
|
+
},
|
|
3093
|
+
|
|
3094
|
+
pin(id, pinned) {
|
|
3095
|
+
const item = state.items.find((entry) => entry.id === id)
|
|
3096
|
+
if (!item) return false
|
|
3097
|
+
item.pinned = !!pinned
|
|
3098
|
+
schedule()
|
|
3099
|
+
return true
|
|
3100
|
+
},
|
|
3101
|
+
|
|
3102
|
+
/** 使用计数:面板点插入时调用,列表按最近使用排序。 */
|
|
3103
|
+
use(id) {
|
|
3104
|
+
const item = state.items.find((entry) => entry.id === id)
|
|
3105
|
+
if (!item) return false
|
|
3106
|
+
item.usedAt = Date.now()
|
|
3107
|
+
item.uses = (item.uses ?? 0) + 1
|
|
3108
|
+
schedule()
|
|
3109
|
+
return true
|
|
3110
|
+
},
|
|
3111
|
+
|
|
3112
|
+
/** 是否注册为 / 斜杠命令(客户端按此标记同步 commandUi)。 */
|
|
3113
|
+
slash(id, on) {
|
|
3114
|
+
const item = state.items.find((entry) => entry.id === id)
|
|
3115
|
+
if (!item) return false
|
|
3116
|
+
item.slash = !!on
|
|
3117
|
+
schedule()
|
|
3118
|
+
return true
|
|
3119
|
+
},
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* 导入:按 id 合并;同 id 冲突保留 at(更新时间)较新的一方。
|
|
3123
|
+
* id 不在库中则原 id 收编(跨机重复导入可去重),缺 id 才新分配。
|
|
3124
|
+
*/
|
|
3125
|
+
import(items) {
|
|
3126
|
+
let added = 0
|
|
3127
|
+
let updated = 0
|
|
3128
|
+
for (const raw of items.slice(0, 5000)) {
|
|
3129
|
+
const body = String(raw?.body ?? '').replace(/\0/g, '')
|
|
3130
|
+
if (!body.trim()) continue
|
|
3131
|
+
const incoming = {
|
|
3132
|
+
title: String(raw?.title ?? '').trim().slice(0, 80) || body.trim().slice(0, 40),
|
|
3133
|
+
body,
|
|
3134
|
+
tags: Array.isArray(raw?.tags)
|
|
3135
|
+
? raw.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
|
|
3136
|
+
: [],
|
|
3137
|
+
at: Number.isFinite(raw?.at) ? raw.at : Date.now(),
|
|
3138
|
+
pinned: raw?.pinned === true,
|
|
3139
|
+
slash: raw?.slash === true,
|
|
3140
|
+
usedAt: Number.isFinite(raw?.usedAt) ? raw.usedAt : undefined,
|
|
3141
|
+
uses: Number.isFinite(raw?.uses) ? Math.max(0, Math.round(raw.uses)) : 0,
|
|
3142
|
+
}
|
|
3143
|
+
const existing = raw?.id ? state.items.find((entry) => entry.id === raw.id) : null
|
|
3144
|
+
if (existing) {
|
|
3145
|
+
if (incoming.at > (existing.at ?? 0)) {
|
|
3146
|
+
Object.assign(existing, incoming)
|
|
3147
|
+
updated++
|
|
3148
|
+
}
|
|
3149
|
+
continue
|
|
3150
|
+
}
|
|
3151
|
+
state.items.unshift({ id: raw?.id ? String(raw.id).slice(0, 64) : randomUUID(), ...incoming })
|
|
3152
|
+
added++
|
|
3153
|
+
}
|
|
3154
|
+
const overflow = state.items.filter((item) => !item.pinned).length - maxItems
|
|
3155
|
+
if (overflow > 0) {
|
|
3156
|
+
const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
|
|
3157
|
+
state.items = state.items.filter((item) => !drop.has(item.id))
|
|
3158
|
+
}
|
|
3159
|
+
if (added + updated > 0) schedule()
|
|
3160
|
+
return { added, updated }
|
|
3161
|
+
},
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function apply(ctx, config) {
|
|
3166
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
3167
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。回放必须在
|
|
3168
|
+
// createStore 之前,重启后上次的 maxItems 才对裁剪生效。
|
|
3169
|
+
const live = { ...config }
|
|
3170
|
+
if (!live.enabled) return
|
|
3171
|
+
|
|
3172
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
|
|
3173
|
+
const SETTINGS_KEYS = ['maxItems']
|
|
3174
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-superinput', 'prompts.settings.json')
|
|
3175
|
+
try {
|
|
3176
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
3177
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
3178
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
3179
|
+
let settingsTimer = null
|
|
3180
|
+
const persistSettings = () => {
|
|
3181
|
+
if (settingsTimer) return
|
|
3182
|
+
settingsTimer = setTimeout(() => {
|
|
3183
|
+
settingsTimer = null
|
|
3184
|
+
try {
|
|
3185
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
3186
|
+
const out = {}
|
|
3187
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
3188
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
3189
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
3190
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
3191
|
+
} catch { /* ignore */ }
|
|
3192
|
+
}, 400)
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
const store = createStore(Math.max(20, Math.round(live.maxItems ?? 500)))
|
|
3196
|
+
store.load()
|
|
3197
|
+
ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
|
|
3198
|
+
|
|
3199
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
3200
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
3201
|
+
kind: 'prefix',
|
|
3202
|
+
path: '/api/dsh-superinput/prompts',
|
|
3203
|
+
handler: async (req, res) => {
|
|
3204
|
+
const send = (status, body) => {
|
|
3205
|
+
res.statusCode = status
|
|
3206
|
+
res.setHeader('content-type', 'application/json')
|
|
3207
|
+
res.end(JSON.stringify(body))
|
|
3208
|
+
}
|
|
3209
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/prompts/, '')
|
|
3210
|
+
const readBody = async () => {
|
|
3211
|
+
let body = ''
|
|
3212
|
+
for await (const chunk of req) body += chunk
|
|
3213
|
+
try { return JSON.parse(body) } catch { return {} }
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
3217
|
+
send(200, { ...live })
|
|
3218
|
+
return
|
|
3219
|
+
}
|
|
3220
|
+
// 运行时改参数:只接受白名单字段,其余忽略(provider/model 这类
|
|
3221
|
+
// 改了要重建路由,运行时改无效,不如不给入口)。
|
|
3222
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
3223
|
+
let raw = ''
|
|
3224
|
+
for await (const chunk of req) raw += chunk
|
|
3225
|
+
let body
|
|
3226
|
+
try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
|
|
3227
|
+
if (body.maxItems !== undefined) { const v = Number(body.maxItems); if (Number.isFinite(v)) live.maxItems = Math.min(5000, Math.max(20, Math.round(v))) }
|
|
3228
|
+
persistSettings()
|
|
3229
|
+
send(200, { ...live })
|
|
3230
|
+
return
|
|
3231
|
+
}
|
|
3232
|
+
// 套件客户端统一诊断通道(0.4.0 起从 snippets 迁来):落
|
|
3233
|
+
// ~/.dsh/superinput-debug.log,与服务端 trace 同文件便于一次排查。
|
|
3234
|
+
if (path === '/diag' && req.method === 'POST') {
|
|
3235
|
+
let raw = ''
|
|
3236
|
+
for await (const chunk of req) raw += chunk
|
|
3237
|
+
try {
|
|
3238
|
+
let message = raw
|
|
3239
|
+
try { message = JSON.parse(raw).message ?? raw } catch { /* 纯文本也收 */ }
|
|
3240
|
+
mkdirSync(dirname(CLIENT_DIAG), { recursive: true })
|
|
3241
|
+
appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} ${String(message).slice(0, 500)}\n`, 'utf8')
|
|
3242
|
+
} catch { /* 落盘失败不影响应答 */ }
|
|
3243
|
+
send(200, { ok: true })
|
|
3244
|
+
return
|
|
3245
|
+
}
|
|
3246
|
+
if (path === '/list' && req.method === 'GET') {
|
|
3247
|
+
send(200, { items: store.list() })
|
|
3248
|
+
return
|
|
3249
|
+
}
|
|
3250
|
+
if (path === '/save' && req.method === 'POST') {
|
|
3251
|
+
send(200, store.save(await readBody()))
|
|
3252
|
+
return
|
|
3253
|
+
}
|
|
3254
|
+
if (path === '/remove' && req.method === 'POST') {
|
|
3255
|
+
const body = await readBody()
|
|
3256
|
+
send(200, { ok: store.remove(String(body.id ?? '')) })
|
|
3257
|
+
return
|
|
3258
|
+
}
|
|
3259
|
+
if (path === '/pin' && req.method === 'POST') {
|
|
3260
|
+
const body = await readBody()
|
|
3261
|
+
send(200, { ok: store.pin(String(body.id ?? ''), body.pinned === true) })
|
|
3262
|
+
return
|
|
3263
|
+
}
|
|
3264
|
+
if (path === '/use' && req.method === 'POST') {
|
|
3265
|
+
const body = await readBody()
|
|
3266
|
+
send(200, { ok: store.use(String(body.id ?? '')) })
|
|
3267
|
+
return
|
|
3268
|
+
}
|
|
3269
|
+
if (path === '/slash' && req.method === 'POST') {
|
|
3270
|
+
const body = await readBody()
|
|
3271
|
+
send(200, { ok: store.slash(String(body.id ?? ''), body.slash === true) })
|
|
3272
|
+
return
|
|
3273
|
+
}
|
|
3274
|
+
if (path === '/export' && req.method === 'GET') {
|
|
3275
|
+
send(200, { version: 1, exportedAt: Date.now(), items: store.list() })
|
|
3276
|
+
return
|
|
3277
|
+
}
|
|
3278
|
+
// 兼容两种载荷:导出文件原样({items:[...]})和裸数组。
|
|
3279
|
+
if (path === '/import' && req.method === 'POST') {
|
|
3280
|
+
const body = await readBody()
|
|
3281
|
+
const items = Array.isArray(body) ? body : Array.isArray(body?.items) ? body.items : null
|
|
3282
|
+
if (!items) { send(400, { error: 'invalid-payload' }); return }
|
|
3283
|
+
send(200, { ok: true, ...store.import(items) })
|
|
3284
|
+
return
|
|
3285
|
+
}
|
|
3286
|
+
send(404, { error: 'not-found' })
|
|
3287
|
+
},
|
|
3288
|
+
}), 'dsh-superinput: prompts endpoints')
|
|
3289
|
+
})
|
|
3290
|
+
}
|
|
3291
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
3292
|
+
})()
|
|
3293
|
+
|
|
3294
|
+
/* ==== module: hub(手写) ==== */
|
|
3295
|
+
const hubModule = (() => {
|
|
3296
|
+
/**
|
|
3297
|
+
* dsh-superinput · hub 模块(设置总览)
|
|
3298
|
+
*
|
|
3299
|
+
* 把各模块的**最终生效配置**聚合成一张表,供设置页展示。
|
|
3300
|
+
*
|
|
3301
|
+
* 为什么不做写入:dsh 的插件 Config 在启动时由宿主读取,运行时改不了。
|
|
3302
|
+
* 各模块自己的 /settings 端点改的是内存 live 副本 + storages 持久化,
|
|
3303
|
+
* 重启后由模块自己回放。这里只回答「现在是什么状态」。
|
|
3304
|
+
*
|
|
3305
|
+
* 数据来源两层:apply 时聚合层塞进来的 snapshot(defaults 与宿主配置合并),
|
|
3306
|
+
* 叠加各模块落盘的显式改动(<storages>/dsh-superinput/<mod>.settings.json)——
|
|
3307
|
+
* 只吐快照会在重启后与实际运行值不符(快照不知道回放)。
|
|
3308
|
+
*/
|
|
3309
|
+
|
|
3310
|
+
|
|
3311
|
+
|
|
3312
|
+
|
|
3313
|
+
|
|
3314
|
+
const moduleInject = []
|
|
3315
|
+
|
|
3316
|
+
const Config = Schema.object({
|
|
3317
|
+
enabled: Schema.boolean().default(true).description('在设置页显示「输入栏」总览'),
|
|
3318
|
+
})
|
|
3319
|
+
|
|
3320
|
+
/** 各模块持久化文件名(与各模块 SETTINGS_PATH 保持一致)。 */
|
|
3321
|
+
const PERSISTED_FILES = [
|
|
3322
|
+
['cost', 'cost.settings.json'],
|
|
3323
|
+
['enhancer', 'enhancer.settings.json'],
|
|
3324
|
+
['followup', 'followup.settings.json'],
|
|
3325
|
+
['picker', 'picker.settings.json'],
|
|
3326
|
+
['prompts', 'prompts.settings.json'],
|
|
3327
|
+
]
|
|
3328
|
+
|
|
3329
|
+
function loadPersisted() {
|
|
3330
|
+
const persisted = {}
|
|
3331
|
+
const dir = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
|
|
3332
|
+
for (const [mod, file] of PERSISTED_FILES) {
|
|
3333
|
+
try {
|
|
3334
|
+
const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'))
|
|
3335
|
+
if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) persisted[mod] = saved
|
|
3336
|
+
} catch { /* 模块无存档:跟随配置 */ }
|
|
3337
|
+
}
|
|
3338
|
+
return persisted
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
function apply(ctx, config) {
|
|
3342
|
+
if (!config.enabled) return
|
|
3343
|
+
const snapshot = config.snapshot ?? {}
|
|
3344
|
+
|
|
3345
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
3346
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
3347
|
+
kind: 'prefix',
|
|
3348
|
+
path: '/api/dsh-superinput/hub',
|
|
3349
|
+
handler: async (req, res) => {
|
|
3350
|
+
const send = (status, body) => {
|
|
3351
|
+
res.statusCode = status
|
|
3352
|
+
res.setHeader('content-type', 'application/json')
|
|
3353
|
+
res.end(JSON.stringify(body))
|
|
3354
|
+
}
|
|
3355
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/hub/, '')
|
|
3356
|
+
if (path === '/state' && req.method === 'GET') {
|
|
3357
|
+
const persisted = loadPersisted()
|
|
3358
|
+
const modules = {}
|
|
3359
|
+
for (const [mod, cfg] of Object.entries(snapshot)) modules[mod] = { ...cfg, ...(persisted[mod] ?? {}) }
|
|
3360
|
+
for (const [mod, saved] of Object.entries(persisted)) {
|
|
3361
|
+
if (!modules[mod]) modules[mod] = { ...saved }
|
|
3362
|
+
else modules[mod].persistedKeys = Object.keys(saved)
|
|
3363
|
+
}
|
|
3364
|
+
send(200, { modules })
|
|
3365
|
+
return
|
|
3366
|
+
}
|
|
3367
|
+
send(404, { error: 'not-found' })
|
|
3368
|
+
},
|
|
3369
|
+
}), 'dsh-superinput: hub endpoint')
|
|
3370
|
+
})
|
|
3371
|
+
}
|
|
3372
|
+
return { apply, Config, inject: typeof moduleInject === 'undefined' ? undefined : moduleInject }
|
|
3373
|
+
})()
|
|
3374
|
+
|
|
3375
|
+
const MODULES = [
|
|
3376
|
+
['cost', costModule],
|
|
3377
|
+
['picker', pickerModule],
|
|
3378
|
+
['enhancer', enhancerModule],
|
|
3379
|
+
['followup', followupModule],
|
|
3380
|
+
['voice', voiceModule],
|
|
3381
|
+
['prompts', promptsModule],
|
|
3382
|
+
['hub', hubModule],
|
|
3383
|
+
]
|
|
3384
|
+
|
|
3385
|
+
export const name = 'superinput'
|
|
3386
|
+
|
|
3387
|
+
export const inject = [...new Set(MODULES.flatMap(([, mod]) => mod.inject ?? []))]
|
|
3388
|
+
|
|
3389
|
+
export const Config = Schema.object({
|
|
3390
|
+
cost: costModule.Config,
|
|
3391
|
+
picker: pickerModule.Config,
|
|
3392
|
+
enhancer: enhancerModule.Config,
|
|
3393
|
+
followup: followupModule.Config,
|
|
3394
|
+
voice: voiceModule.Config,
|
|
3395
|
+
prompts: promptsModule.Config,
|
|
3396
|
+
hub: hubModule.Config,
|
|
3397
|
+
})
|
|
3398
|
+
|
|
3399
|
+
/**
|
|
3400
|
+
* 嵌套默认值兜底:宿主对插件 row config 的解析深度未知——套件层自己跑
|
|
3401
|
+
* 一遍每个模块的 Config({}) 拿完整默认,与宿主传入的 slice 合并。
|
|
3402
|
+
*/
|
|
3403
|
+
const DEFAULTS = new Map()
|
|
3404
|
+
for (const [key, mod] of MODULES) {
|
|
3405
|
+
try { DEFAULTS.set(key, mod.Config ? mod.Config({}) : {}) } catch { DEFAULTS.set(key, {}) }
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
export function apply(ctx, config) {
|
|
3409
|
+
const keys = Object.keys(config ?? {})
|
|
3410
|
+
console.info(`[dsh-superinput] server apply: host config keys=${keys.length ? keys.join(',') : '(none)'} — merging schema defaults`)
|
|
3411
|
+
// 先算出每个模块的最终生效配置,hub 用它做总览(自己拿不到别人的 config)。
|
|
3412
|
+
const merged = {}
|
|
3413
|
+
for (const [key] of MODULES) {
|
|
3414
|
+
merged[key] = { ...(DEFAULTS.get(key) ?? {}), ...(config?.[key] ?? {}) }
|
|
3415
|
+
}
|
|
3416
|
+
for (const [key, mod] of MODULES) {
|
|
3417
|
+
try {
|
|
3418
|
+
const own = merged[key]
|
|
3419
|
+
mod.apply(ctx, key === 'hub' ? { ...own, snapshot: merged } : own)
|
|
3420
|
+
trace(`module "${key}" applied OK`)
|
|
3421
|
+
} catch (error) {
|
|
3422
|
+
console.error(`[dsh-superinput] server module "${key}" failed:`, error)
|
|
3423
|
+
trace(`module "${key}" FAILED: ${String(error?.stack ?? error).slice(0, 600)}`)
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
export default { name, Config, apply }
|