dsh-mobile-plugin 0.4.8 → 0.5.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/icon.png +0 -0
- package/lib/mobile/history-derive.js +86 -2
- package/lib/mobile/host-settings.js +103 -6
- package/lib/mobile/live-steps.js +356 -0
- package/lib/mobile/model-prices-table.cjs +23 -0
- package/lib/mobile/model-prices.cjs +88 -0
- package/lib/mobile/plugin-update.js +16 -1
- package/lib/mobile/session-cache.js +17 -1
- package/lib/mobile/session-step.js +226 -0
- package/lib/mobile/stats-collect.cjs +40 -0
- package/lib/mobile/stats-ledger.cjs +45 -0
- package/lib/mobile/step-index.js +108 -0
- package/lib/mobile/stream.js +29 -0
- package/lib/panel/index.html +11 -11
- package/lib/reconnect-scheduler.cjs +19 -6
- package/lib/relay/connection-check.js +115 -0
- package/lib/relay/firewall-probe.js +300 -0
- package/lib/relay/preview.js +147 -24
- package/lib/relay/tunnel.js +26 -1
- package/lib/routes/control-plane.js +47 -1
- package/lib/routes/mobile-api.js +3 -0
- package/lib/sidecar-facade-worker.cjs +44 -2
- package/lib/update-install.cjs +301 -43
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +5 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 模型单价:把账本里那四个 token 桶折成一个金额(美元,估算值)。
|
|
5
|
+
*
|
|
6
|
+
* 价目来自 `model-prices-table.cjs`(models.dev 的快照,与宿主内置那套模型目录同源;
|
|
7
|
+
* 重新生成见 `scripts/build-model-prices.mjs`)。宿主自己有这份价,但它对外那两份模型清单
|
|
8
|
+
* 是逐字段拼的、价格被剪掉了,注释里也写着「没有任何消费者报账」—— 所以只能由插件带一份。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么先查「渠道 + 模型」再查「模型」
|
|
11
|
+
*
|
|
12
|
+
* 账本里的渠道 id 就是宿主的 provider id:官方渠道(`deepseek` / `anthropic` / `openai`…)
|
|
13
|
+
* 与表里的写法一致,能精确命中,同名不同价的那几个(同一个模型各家报不一样的价)也就不会串。
|
|
14
|
+
* 自建路由的 id 是用户自己起的(`route-model-a`),认不出时退到「同名模型各家同价」那一份;
|
|
15
|
+
* 各家报的价不一致时不猜 —— 一份错的钱比一个「未计价」还糟。
|
|
16
|
+
*
|
|
17
|
+
* ## 口径
|
|
18
|
+
*
|
|
19
|
+
* `cost = (未缓存输入 × 输入价 + 输出 × 输出价 + 缓存读 × 缓存读价 + 缓存写 × 缓存写价) / 1e6`
|
|
20
|
+
*
|
|
21
|
+
* 四档分开算,因为缓存读通常只有输入价的十分之一、缓存写反而更贵(Anthropic 是 1.25 倍),
|
|
22
|
+
* 合成一个均价会把最省钱的那一块算没。缺档按 0 计(见生成脚本的文件头)。
|
|
23
|
+
*
|
|
24
|
+
* 纯函数、不碰文件不碰网络(表在 require 时读一次),守卫直接跑真实现。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { AS_OF, BY_MODEL, BY_PROVIDER_MODEL } = require('./model-prices-table.cjs');
|
|
28
|
+
|
|
29
|
+
/** 表里的价都是「每百万 token」,账本里是 token 数。 */
|
|
30
|
+
const PER_MILLION = 1e6;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 归一化:小写、只留字母与数字(汉字保留)。
|
|
34
|
+
*
|
|
35
|
+
* 必须与 `scripts/build-model-prices.mjs` 的 `foldKey` 逐字一致 —— 两边各写一套,
|
|
36
|
+
* 一改就整张表都查不到,而症状是「成本那一行突然没了」而不是报错。
|
|
37
|
+
* 与 `model-vendors.js` 的 `foldText` 同一条规则(`v4.1` / `v4-1` 落到同一个串上)。
|
|
38
|
+
*/
|
|
39
|
+
function foldKey(value) {
|
|
40
|
+
return typeof value === 'string' ? value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '') : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finite(value) {
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 查一份四档价:`[输入, 输出, 缓存读, 缓存写]`(每百万 token 的美元)。
|
|
49
|
+
*
|
|
50
|
+
* 查不到返回 `null` —— 调用方据此**不画**这个数,而不是画一个 0。
|
|
51
|
+
*
|
|
52
|
+
* `table` 只在守卫里传:真表太大,测查找规则时喂一份假的就行。
|
|
53
|
+
*/
|
|
54
|
+
function priceOf(provider, model, table) {
|
|
55
|
+
const source = table || { byModel: BY_MODEL, byProviderModel: BY_PROVIDER_MODEL };
|
|
56
|
+
const modelKey = foldKey(model);
|
|
57
|
+
if (!modelKey) return null;
|
|
58
|
+
const providerKey = foldKey(provider);
|
|
59
|
+
if (providerKey) {
|
|
60
|
+
const exact = source.byProviderModel[`${providerKey}/${modelKey}`];
|
|
61
|
+
if (exact) return exact;
|
|
62
|
+
}
|
|
63
|
+
return source.byModel[modelKey] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 一份用量折成金额(美元)。`price` 为空返回 `null`(=这个模型没价,不算进合计)。
|
|
68
|
+
*
|
|
69
|
+
* 返回的是未取整的原值:合计由调用方加完再取整,逐行先取整再加会和合计差几个零头。
|
|
70
|
+
*/
|
|
71
|
+
function costOfUsage(usage, price) {
|
|
72
|
+
if (!price || !usage) return null;
|
|
73
|
+
const amount = (
|
|
74
|
+
finite(usage.uncachedInputTokens) * finite(price[0])
|
|
75
|
+
+ finite(usage.outputTokens) * finite(price[1])
|
|
76
|
+
+ finite(usage.cacheReadTokens) * finite(price[2])
|
|
77
|
+
+ finite(usage.cacheWriteTokens) * finite(price[3])
|
|
78
|
+
) / PER_MILLION;
|
|
79
|
+
return amount;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
AS_OF,
|
|
84
|
+
PER_MILLION,
|
|
85
|
+
foldKey,
|
|
86
|
+
priceOf,
|
|
87
|
+
costOfUsage,
|
|
88
|
+
};
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
20
20
|
const { checkForUpdate } = require('../update-registry.cjs');
|
|
21
|
-
const { declaredSource } = require('../update-install.cjs');
|
|
21
|
+
const { declaredSource, profileRegistry } = require('../update-install.cjs');
|
|
22
22
|
const { version: generationVersion } = require('../../package.json');
|
|
23
23
|
|
|
24
24
|
/** apply 的请求体只有 `force` 一个布尔,给 4KB 都很宽。 */
|
|
@@ -113,9 +113,24 @@ async function handlePluginUpdateStatus(ctx, _req, res) {
|
|
|
113
113
|
async function handlePluginUpdateCheck(ctx, _req, res) {
|
|
114
114
|
const status = snapshot(ctx);
|
|
115
115
|
try {
|
|
116
|
+
// 与装用同一个源:问这台机器上 pnpm 真正在用的注册表(镜像 / 私有源机器上,写死官方源
|
|
117
|
+
// 等于永远查不到)。问不到才退回官方源。
|
|
118
|
+
const runtime = runtimeOf(ctx);
|
|
119
|
+
const live = runtime && typeof runtime.status === 'function' ? runtime.status() : null;
|
|
120
|
+
let packageManager = null;
|
|
121
|
+
try {
|
|
122
|
+
packageManager = typeof ctx?.get === 'function' ? ctx.get('profileContext')?.packageManager ?? null : null;
|
|
123
|
+
} catch {
|
|
124
|
+
packageManager = null;
|
|
125
|
+
}
|
|
126
|
+
const registry = await profileRegistry({
|
|
127
|
+
profileNodeModules: typeof live?.profileNodeModules === 'string' ? live.profileNodeModules : null,
|
|
128
|
+
packageManager,
|
|
129
|
+
});
|
|
116
130
|
const update = await checkForUpdate({
|
|
117
131
|
installedVersion: status.installedVersion ?? status.runningVersion ?? generationVersion,
|
|
118
132
|
source: status.source,
|
|
133
|
+
base: registry ?? undefined,
|
|
119
134
|
});
|
|
120
135
|
sendJson(res, 200, { ok: true, update, status });
|
|
121
136
|
} catch (error) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 会话侧的按 ctx
|
|
4
|
+
* 会话侧的按 ctx 隔离状态:十五张表,一个入口。
|
|
5
5
|
*
|
|
6
6
|
* 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
|
|
7
7
|
* 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
|
|
@@ -51,6 +51,11 @@ const workspaceShortcutStates = new WeakMap();
|
|
|
51
51
|
// 只用来挡事件流重连的重放;续了几次由会话历史数出来,不放这里。
|
|
52
52
|
const autoContinueTurns = new WeakMap();
|
|
53
53
|
|
|
54
|
+
// 直播过程副本:还没落盘的那一轮里,推理增量、工具参数增量与工具结果先在内存里留一份,
|
|
55
|
+
// 用户点开「正在读取文件」那一步时由 `/session/step` 就地答(见 mobile/live-steps.js)。
|
|
56
|
+
// 它属于宿主进程状态(这一轮跑到哪了),所以按 ctx 隔离,和上面那些表放在一起。
|
|
57
|
+
const liveStepTurns = new WeakMap();
|
|
58
|
+
|
|
54
59
|
/** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
|
|
55
60
|
function canonicalDeviceKey(req, fallback = 'legacy') {
|
|
56
61
|
const value = req?.headers?.['x-dsh-device'];
|
|
@@ -385,8 +390,19 @@ function dropSessionListCache(ctx, deviceKey) {
|
|
|
385
390
|
}
|
|
386
391
|
}
|
|
387
392
|
|
|
393
|
+
/** 直播过程副本的容器:`ctx → Map<sessionId, turn>`,换一轮就地覆盖(见 live-steps.js)。 */
|
|
394
|
+
function liveStepTurnOf(ctx) {
|
|
395
|
+
let state = liveStepTurns.get(ctx);
|
|
396
|
+
if (!state) {
|
|
397
|
+
state = new Map();
|
|
398
|
+
liveStepTurns.set(ctx, state);
|
|
399
|
+
}
|
|
400
|
+
return state;
|
|
401
|
+
}
|
|
402
|
+
|
|
388
403
|
module.exports = {
|
|
389
404
|
canonicalDeviceKey,
|
|
405
|
+
liveStepTurnOf,
|
|
390
406
|
sessionModelsStateOf,
|
|
391
407
|
invalidateSessionModels,
|
|
392
408
|
dropSessionModelsForDevice,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一步的详情:`GET /api/mobile/v1/session/step`。
|
|
5
|
+
*
|
|
6
|
+
* 清单(`/session/messages` 里每条 `turnProcess.steps[]`)只带摘要与位置,正文不上线路;
|
|
7
|
+
* 用户点开清单里的某一步,才由这条路由把**那一步**的完整内容取回来:工具的参数 / 输出 /
|
|
8
|
+
* diff,或思考的全文。
|
|
9
|
+
*
|
|
10
|
+
* 两个数据源,先内存后历史:
|
|
11
|
+
* - 还在跑的那一轮:直播流里的原始内容被 `live-steps.js` 留了一份副本,宿主历史里还没有
|
|
12
|
+
* 落盘,只有这里答得出来;
|
|
13
|
+
* - 已经落定的轮:按清单给的 `seq` / `resultSeq` 向宿主回拉**一小窗**事件(宿主没有
|
|
14
|
+
* 「按 seq 取一条」的接口,只能 beforeSeq 窗口取,代价是两次宿主往返,用户点一下才付)。
|
|
15
|
+
*
|
|
16
|
+
* 取不到不是错误状态机的一部分:回 404 + 一句人话,App 那边给一次重试。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { sendJson } = require('../shared/http-util');
|
|
20
|
+
const { unwrap } = require('./envelope.js');
|
|
21
|
+
const { callSessions } = require('./rpc.js');
|
|
22
|
+
const { liveStepOf } = require('./live-steps.js');
|
|
23
|
+
const { textOfParts } = require('./step-index.js');
|
|
24
|
+
|
|
25
|
+
/** 详情单段文本的上限(参数 / 输出 / 思考全文各自适用)。 */
|
|
26
|
+
const DETAIL_MAX_CHARS = 200_000;
|
|
27
|
+
|
|
28
|
+
/** 回拉窗口的条数上限:宿主 page 接口按消息条数算,这个量级覆盖一次调用到它的结果。 */
|
|
29
|
+
const WINDOW_MAX_MESSAGES = 200;
|
|
30
|
+
|
|
31
|
+
function intParam(url, name) {
|
|
32
|
+
const raw = url.searchParams.get(name);
|
|
33
|
+
if (raw === null || raw === '') return null;
|
|
34
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
35
|
+
const value = Number(raw);
|
|
36
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function strParam(url, name) {
|
|
40
|
+
const raw = url.searchParams.get(name);
|
|
41
|
+
return typeof raw === 'string' && raw !== '' ? raw : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function capText(text) {
|
|
45
|
+
if (typeof text !== 'string' || text === '') return { text: '' };
|
|
46
|
+
if (text.length <= DETAIL_MAX_CHARS) return { text };
|
|
47
|
+
return { text: text.slice(0, DETAIL_MAX_CHARS), truncated: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function historyStatus(error) {
|
|
51
|
+
switch (error && error.code) {
|
|
52
|
+
case 'bad-request': return 400;
|
|
53
|
+
case 'session-not-found':
|
|
54
|
+
case 'subagent-not-found': return 404;
|
|
55
|
+
case 'subagent-unauthorized': return 403;
|
|
56
|
+
case 'host-unavailable': return 503;
|
|
57
|
+
default: return 502;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeError(error) {
|
|
62
|
+
return {
|
|
63
|
+
code: error && typeof error.code === 'string' ? error.code : 'host-call-failed',
|
|
64
|
+
message: error && typeof error.message === 'string' && error.message ? error.message : '取这一步的过程失败',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 向宿主回拉 `beforeSeq` 之前的一小窗事件(`beforeSeq` 是开区间,所以传 `seq + 1`)。 */
|
|
69
|
+
async function fetchWindow(ctx, sessionId, beforeSeq, address) {
|
|
70
|
+
const payload = { sessionId, maxMessages: WINDOW_MAX_MESSAGES, beforeSeq };
|
|
71
|
+
const { value, error } = unwrap(await callSessions(ctx, 'history', { ...payload, ...address }));
|
|
72
|
+
if (error) throw error;
|
|
73
|
+
const events = (value && Array.isArray(value.events)) ? value.events : [];
|
|
74
|
+
return events.map((entry) => (entry && entry.event ? entry.event : entry)).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toolBlocksOf(event, callId) {
|
|
78
|
+
if (!event || event.type !== 'assistant/message') return null;
|
|
79
|
+
const message = event.data && event.data.message;
|
|
80
|
+
const content = message && Array.isArray(message.content) ? message.content : null;
|
|
81
|
+
if (!content) return null;
|
|
82
|
+
for (const block of content) {
|
|
83
|
+
if (!block || block.type !== 'tool-call') continue;
|
|
84
|
+
if (block.id != null && String(block.id) === callId) return block;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resultEventOf(events, callId) {
|
|
90
|
+
for (const event of events) {
|
|
91
|
+
if (!event || event.type !== 'tool/result') continue;
|
|
92
|
+
const message = event.data && event.data.message;
|
|
93
|
+
const src = message && message.source;
|
|
94
|
+
if (src && typeof src.callId === 'string' && src.callId === callId) return event;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 一步的详情(历史那条路):按清单给的两个位置分别定位调用与结果。 */
|
|
100
|
+
async function historyStep(ctx, { sessionId, kind, callId, seq, index, resultSeq, address }) {
|
|
101
|
+
const beforeSeq = Number.isSafeInteger(resultSeq) ? resultSeq + 1 : seq + 1;
|
|
102
|
+
let events = await fetchWindow(ctx, sessionId, beforeSeq, address);
|
|
103
|
+
if (kind === 'think') {
|
|
104
|
+
const target = events.find((event) => event.seq === seq && event.type === 'assistant/message')
|
|
105
|
+
|| (await fetchWindow(ctx, sessionId, seq + 1, address)).find((event) => event.type === 'assistant/message');
|
|
106
|
+
const message = target && target.data && target.data.message;
|
|
107
|
+
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
108
|
+
const block = content[index];
|
|
109
|
+
const text = block && block.type === 'reasoning' && typeof block.text === 'string' ? block.text : '';
|
|
110
|
+
if (!text) return null;
|
|
111
|
+
const capped = capText(text);
|
|
112
|
+
return { kind: 'think', state: 'ok', text: capped.text, ...(capped.truncated ? { truncated: true } : {}) };
|
|
113
|
+
}
|
|
114
|
+
// 工具:调用块给出参数,结果事件给出输出与 diff。两者常常挨着,先在同一次窗口里找,
|
|
115
|
+
// 找不到再单独补一次(长回合里中间可能夹着别的步骤)。
|
|
116
|
+
let call = null;
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
const block = toolBlocksOf(event, callId);
|
|
119
|
+
if (block) {
|
|
120
|
+
call = { block, event };
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!call && Number.isSafeInteger(seq)) {
|
|
125
|
+
const again = await fetchWindow(ctx, sessionId, seq + 1, address);
|
|
126
|
+
for (const event of again) {
|
|
127
|
+
const block = toolBlocksOf(event, callId);
|
|
128
|
+
if (block) {
|
|
129
|
+
call = { block, event };
|
|
130
|
+
events = again;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!call) return null;
|
|
136
|
+
const result = resultEventOf(events, callId);
|
|
137
|
+
const rawArgs = typeof call.block.arguments === 'string'
|
|
138
|
+
? call.block.arguments
|
|
139
|
+
: (call.block.arguments != null ? JSON.stringify(call.block.arguments) : '');
|
|
140
|
+
const args = capText(rawArgs);
|
|
141
|
+
let output = '';
|
|
142
|
+
let diffs;
|
|
143
|
+
let state = 'running';
|
|
144
|
+
if (result) {
|
|
145
|
+
const message = result.data && result.data.message;
|
|
146
|
+
const direct = message && Array.isArray(message.content) ? message.content : [];
|
|
147
|
+
const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
|
|
148
|
+
&& typeof direct[0].type !== 'string' ? direct[0] : null;
|
|
149
|
+
const parts = envelope ? (Array.isArray(envelope.content) ? envelope.content : []) : direct;
|
|
150
|
+
const failed = (envelope ? envelope.isError === true : false) || (message && message.isError === true);
|
|
151
|
+
const code = result.data && result.data.error && typeof result.data.error.code === 'string'
|
|
152
|
+
? result.data.error.code : undefined;
|
|
153
|
+
state = failed ? (code === 'interrupted' ? 'stopped' : 'error') : 'ok';
|
|
154
|
+
const text = capText(textOfParts(parts));
|
|
155
|
+
output = text.text;
|
|
156
|
+
if (text.truncated) diffs = undefined;
|
|
157
|
+
const meta = result.data && result.data.meta;
|
|
158
|
+
if (meta && Array.isArray(meta.diffs) && meta.diffs.length > 0) diffs = meta.diffs;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
kind: 'tool',
|
|
162
|
+
...(typeof call.block.name === 'string' && call.block.name ? { name: call.block.name } : {}),
|
|
163
|
+
state,
|
|
164
|
+
...(args.text ? { argsRaw: args.text } : {}),
|
|
165
|
+
...(output ? { output } : {}),
|
|
166
|
+
...(Array.isArray(diffs) && diffs.length > 0 ? { diffs } : {}),
|
|
167
|
+
...(args.truncated ? { truncated: true } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 取一步的详情。
|
|
173
|
+
*
|
|
174
|
+
* 参数:`sessionId` 必填;`kind` = `tool` | `think`;`callId`(工具)/ `index`(思考)
|
|
175
|
+
* 用于直播副本;`seq` / `resultSeq` 是清单里带的位置,用于回落历史。
|
|
176
|
+
* 子代理会话要额外带 `parentSessionId` + `mode`。
|
|
177
|
+
*/
|
|
178
|
+
async function handleSessionStep(ctx, req, res) {
|
|
179
|
+
const url = new URL(req.url, 'http://x');
|
|
180
|
+
const sessionId = strParam(url, 'sessionId');
|
|
181
|
+
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
182
|
+
const kind = url.searchParams.get('kind') === 'think' ? 'think' : 'tool';
|
|
183
|
+
const callId = strParam(url, 'callId');
|
|
184
|
+
const index = intParam(url, 'index');
|
|
185
|
+
const seq = intParam(url, 'seq');
|
|
186
|
+
const resultSeq = intParam(url, 'resultSeq');
|
|
187
|
+
const parentSessionId = url.searchParams.get('parentSessionId');
|
|
188
|
+
const mode = url.searchParams.get('mode');
|
|
189
|
+
const hasSubagentAddress = parentSessionId !== null || mode !== null;
|
|
190
|
+
if (hasSubagentAddress && (
|
|
191
|
+
typeof parentSessionId !== 'string' || parentSessionId.trim() === ''
|
|
192
|
+
|| (mode !== 'one-shot' && mode !== 'continuable')
|
|
193
|
+
)) {
|
|
194
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '子代理会话需要 parentSessionId 和有效 mode' } });
|
|
195
|
+
}
|
|
196
|
+
if (kind === 'tool' && !callId) {
|
|
197
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 callId' } });
|
|
198
|
+
}
|
|
199
|
+
// 还在跑的那一轮:直播副本里就有,不必去问宿主(那边还没落盘),也就不必要求位置 ——
|
|
200
|
+
// 跑到一半的调用只有 `callId`、思考只有块序号。
|
|
201
|
+
const live = liveStepOf(ctx, sessionId, { kind, callId, index });
|
|
202
|
+
if (live) return sendJson(res, 200, { ok: true, step: live });
|
|
203
|
+
if (kind === 'think' && (index === null || seq === null)) {
|
|
204
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少这一步的位置' } });
|
|
205
|
+
}
|
|
206
|
+
if (kind === 'tool' && seq === null && resultSeq === null) {
|
|
207
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少这一步的位置' } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const address = hasSubagentAddress ? { parentSessionId, mode } : {};
|
|
211
|
+
try {
|
|
212
|
+
const step = await historyStep(ctx, { sessionId, kind, callId, seq, index, resultSeq, address });
|
|
213
|
+
if (!step) {
|
|
214
|
+
return sendJson(res, 404, {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: { code: 'step-not-found', message: '这一步的过程已经不在电脑上了' },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return sendJson(res, 200, { ok: true, step });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const normalized = normalizeError(error);
|
|
222
|
+
return sendJson(res, historyStatus(normalized), { ok: false, error: normalized });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { handleSessionStep, DETAIL_MAX_CHARS };
|
|
@@ -29,6 +29,16 @@ const ledger = require('./stats-ledger.cjs');
|
|
|
29
29
|
/** 同一条会话两次采集之间的最小间隔(打开页面时的重复请求直接复用上一轮结果)。 */
|
|
30
30
|
const COLLECT_INTERVAL_MS = 60_000;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 老记录补模型的重试间隔。
|
|
34
|
+
*
|
|
35
|
+
* 「模型维度」上线之前记过的会话没有模型这一栏,之后再没动静就一直补不上 —— 而统计页的成本
|
|
36
|
+
* 是按模型桶折算的,缺一条就少一整块钱。补法是读一次宿主的当前模型(很便宜的读),顺手做在
|
|
37
|
+
* 跳过分支里;读不到(会话没了 / 渠道卸了)也把尝试时间记下,一天最多问一次,否则空闲那几轮
|
|
38
|
+
* 会被几百条老记录拖成每轮几百次宿主调用,把「没变动就不花钱」那条设计作废。
|
|
39
|
+
*/
|
|
40
|
+
const MODEL_BACKFILL_RETRY_MS = 24 * 3600_000;
|
|
41
|
+
|
|
32
42
|
/** 并发读投影的条数:宿主那侧是内存里读缓存,四条并行不会把它压住。 */
|
|
33
43
|
const CONCURRENCY = 4;
|
|
34
44
|
|
|
@@ -208,6 +218,34 @@ async function readSessionModel(ctx, sessionId) {
|
|
|
208
218
|
}
|
|
209
219
|
}
|
|
210
220
|
|
|
221
|
+
/**
|
|
222
|
+
* 老记录补模型:这条会话在「模型维度」上线之前就记过量、之后一直没动静,模型卡里永远没有
|
|
223
|
+
* 它那一份 —— 而统计页的成本是按模型桶折算的,缺一条就少一整块钱。
|
|
224
|
+
*
|
|
225
|
+
* 只做一件事:读一次宿主的当前模型(不碰投影、不碰水位),把这条会话的**累计量**复制进模型桶,
|
|
226
|
+
* 与升级当天那次回填同一个口径(`record.model` 一旦有了,之后的增量就照常记)。读不到也把
|
|
227
|
+
* 尝试时间记下 —— 试过这件事本身要落盘,否则下一轮还会为同一条再问一次。
|
|
228
|
+
*
|
|
229
|
+
* @returns 账本有没有改动(改了就落盘)。
|
|
230
|
+
*/
|
|
231
|
+
async function backfillModel(ctx, book, sessionId, record, at) {
|
|
232
|
+
if (!record || record.gone || record.model) return false;
|
|
233
|
+
if (billedOf(record.billed) <= 0) return false;
|
|
234
|
+
const tried = num(record.modelTriedAt);
|
|
235
|
+
if (tried > 0 && at - tried < MODEL_BACKFILL_RETRY_MS) return false;
|
|
236
|
+
record.modelTriedAt = at;
|
|
237
|
+
const model = await readSessionModel(ctx, sessionId);
|
|
238
|
+
if (model) {
|
|
239
|
+
ledger.applyModelUsage(book, {
|
|
240
|
+
model,
|
|
241
|
+
day: ledger.dayKeyOf(num(record.lastAt) || at),
|
|
242
|
+
usage: record.billed,
|
|
243
|
+
});
|
|
244
|
+
record.model = model;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
211
249
|
/**
|
|
212
250
|
* 采集串行化:同一时刻只跑一轮。
|
|
213
251
|
*
|
|
@@ -300,12 +338,14 @@ async function collectOnce(ctx, options = {}) {
|
|
|
300
338
|
if (!options.force && record && record.collectedAt && now - record.collectedAt < COLLECT_INTERVAL_MS) {
|
|
301
339
|
// 还没到下一次采集的点:只把可能变了的名字补上,数字一个字不动。
|
|
302
340
|
renameIfNeeded();
|
|
341
|
+
if (await backfillModel(ctx, book, sessionId, record, now)) touched = true;
|
|
303
342
|
continue;
|
|
304
343
|
}
|
|
305
344
|
// 宿主摘要的 `updatedAt` 与上次读到的一模一样 = 这条一点没动过,水位差必然是 0。
|
|
306
345
|
// 定期采集每几分钟一轮,靠这一条把空闲那几轮压成「一次列表调用」。
|
|
307
346
|
if (!options.force && record && record.seenUpdatedAt && num(summary.updatedAt) <= num(record.seenUpdatedAt)) {
|
|
308
347
|
renameIfNeeded();
|
|
348
|
+
if (await backfillModel(ctx, book, sessionId, record, now)) touched = true;
|
|
309
349
|
continue;
|
|
310
350
|
}
|
|
311
351
|
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* 会话正在用的那个选择(见 stats-collect.cjs 文件头),会话中途换模型的那一段整体归到换后的
|
|
17
17
|
* 那个名下。这是水位法本来就有的近似,不是这里另加的简化。
|
|
18
18
|
*
|
|
19
|
+
* 金额(`cost`)是**估算**,不落盘:折算时按模型桶的四个 token 桶乘单价(`model-prices.cjs`),
|
|
20
|
+
* 价格变了下次出数就跟着变,账本里不留一份会过期的钱。模型归属的近似照旧继承下来;查不到单价
|
|
21
|
+
* 的那一截 token 由 `unpricedBilled` 明说,不摊进合计。
|
|
22
|
+
*
|
|
19
23
|
* 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
|
|
20
24
|
*/
|
|
21
25
|
|
|
@@ -24,6 +28,7 @@ const path = require('node:path');
|
|
|
24
28
|
|
|
25
29
|
const paths = require('../shared/paths');
|
|
26
30
|
const { identifyVendor, vendorLogoOf } = require('./model-vendors.js');
|
|
31
|
+
const { AS_OF, costOfUsage, priceOf } = require('./model-prices.cjs');
|
|
27
32
|
|
|
28
33
|
/** 账本格式版本:将来改结构时据此决定是丢弃还是迁移。 */
|
|
29
34
|
const VERSION = 1;
|
|
@@ -263,6 +268,9 @@ function sanitizeLedger(raw) {
|
|
|
263
268
|
composition: readComposition(value.composition),
|
|
264
269
|
// 采集那一刻这条会话用的渠道 + 模型。升级前的老记录没有这一栏,当没有。
|
|
265
270
|
model: sanitizeModelSelection(value.model),
|
|
271
|
+
// 上一次为这条老记录补模型的时间(见 stats-collect 的 `backfillModel`):读不到也记,
|
|
272
|
+
// 否则每一轮都会再问一次宿主,把「空闲时不花钱」那条设计作废。
|
|
273
|
+
modelTriedAt: num(value.modelTriedAt),
|
|
266
274
|
// 水位是「宿主那边这条会话的累计值」,缺省为 null 表示还没采过(首采全量入账)。
|
|
267
275
|
watermark: value.watermark && typeof value.watermark === 'object' ? sanitizeUsage(value.watermark) : null,
|
|
268
276
|
};
|
|
@@ -413,6 +421,7 @@ function emptySession(at) {
|
|
|
413
421
|
daily: {},
|
|
414
422
|
composition: null,
|
|
415
423
|
model: null,
|
|
424
|
+
modelTriedAt: 0,
|
|
416
425
|
watermark: null,
|
|
417
426
|
};
|
|
418
427
|
}
|
|
@@ -496,6 +505,16 @@ function billedOf(usage) {
|
|
|
496
505
|
return num(usage.uncachedInputTokens) + num(usage.cacheReadTokens) + num(usage.cacheWriteTokens) + num(usage.outputTokens);
|
|
497
506
|
}
|
|
498
507
|
|
|
508
|
+
/**
|
|
509
|
+
* 金额取到六位小数。
|
|
510
|
+
*
|
|
511
|
+
* 逐行先取整再加会和合计对不上(一条几十万 token 的行差几厘,几十行就有分位上的差),
|
|
512
|
+
* 所以行与合计都从原值算、最后各自取整;六位是「十万 token 的一档价」还看得见的精度。
|
|
513
|
+
*/
|
|
514
|
+
function moneyOf(value) {
|
|
515
|
+
return Math.round(num(value) * 1e6) / 1e6;
|
|
516
|
+
}
|
|
517
|
+
|
|
499
518
|
/**
|
|
500
519
|
* 一条会话在 [startDay, today] 里的用量。
|
|
501
520
|
*
|
|
@@ -633,6 +652,10 @@ function summarize(ledger, range, now) {
|
|
|
633
652
|
// 这一份里认出来的厂商,只带**用到的那几个**图标(`icon` 是一段 SVG 标记,App 用 SvgXml
|
|
634
653
|
// 直接画):一次响应几十条模型行各自背一份图标没有意义。
|
|
635
654
|
const vendors = {};
|
|
655
|
+
/** 有价的那几行加起来:金额、它们覆盖的 token、行数(0 行就整项不给,页面不画)。 */
|
|
656
|
+
let costTotal = 0;
|
|
657
|
+
let costPricedBilled = 0;
|
|
658
|
+
let costPricedRows = 0;
|
|
636
659
|
for (const bucket of Object.values(ledger.models || {})) {
|
|
637
660
|
if (!bucket || typeof bucket !== 'object') continue;
|
|
638
661
|
const usage = usageInRange(bucket, startDay, today);
|
|
@@ -645,6 +668,13 @@ function summarize(ledger, range, now) {
|
|
|
645
668
|
const vendor = identifyVendor(bucket);
|
|
646
669
|
const logo = vendor ? vendorLogoOf(vendor) : null;
|
|
647
670
|
if (logo) vendors[vendor] = logo;
|
|
671
|
+
// 金额只在查得到单价时给:查不到不画 0,那一截 token 由下面的 `unpricedBilled` 交代。
|
|
672
|
+
const cost = costOfUsage(usage, priceOf(bucket.provider, bucket.model));
|
|
673
|
+
if (cost !== null) {
|
|
674
|
+
costTotal += cost;
|
|
675
|
+
costPricedBilled += billedTokens;
|
|
676
|
+
costPricedRows += 1;
|
|
677
|
+
}
|
|
648
678
|
models.push({
|
|
649
679
|
provider: bucket.provider || '',
|
|
650
680
|
providerName: bucket.providerName || '',
|
|
@@ -660,6 +690,7 @@ function summarize(ledger, range, now) {
|
|
|
660
690
|
// 请求数取 `usage.steps`,与按天账同一个口径(见 applyDelta 里的那条注释)。
|
|
661
691
|
requests: num(usage.steps),
|
|
662
692
|
lastAt: num(bucket.lastAt),
|
|
693
|
+
...(cost === null ? {} : { cost: moneyOf(cost) }),
|
|
663
694
|
});
|
|
664
695
|
}
|
|
665
696
|
models.sort((a, b) => (b.billedTokens !== a.billedTokens ? b.billedTokens - a.billedTokens : b.lastAt - a.lastAt));
|
|
@@ -678,6 +709,20 @@ function summarize(ledger, range, now) {
|
|
|
678
709
|
models,
|
|
679
710
|
// 一个厂商都没认出来时整项不出:老 App 不认识这个字段,新 App 缺它就是「没有图标」。
|
|
680
711
|
...(Object.keys(vendors).length > 0 ? { vendors } : {}),
|
|
712
|
+
// 成本合计只加「查得到单价」的那几行:一行都没有就整项不给 —— 缺这个字段,页面就不画
|
|
713
|
+
// 这一条(与 `models` 同一条规矩),比画一个 $0 强。`unpricedBilled` 是第 4 档:
|
|
714
|
+
// 范围内有量、但这台机器没有价(自建路由 / 免费渠道 / 老账本)的那一截。
|
|
715
|
+
...(costPricedRows > 0
|
|
716
|
+
? {
|
|
717
|
+
cost: {
|
|
718
|
+
currency: 'USD',
|
|
719
|
+
total: moneyOf(costTotal),
|
|
720
|
+
pricedBilled: costPricedBilled,
|
|
721
|
+
unpricedBilled: Math.max(0, rangeBilled - costPricedBilled),
|
|
722
|
+
asOf: AS_OF,
|
|
723
|
+
},
|
|
724
|
+
}
|
|
725
|
+
: {}),
|
|
681
726
|
};
|
|
682
727
|
}
|
|
683
728
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 过程清单的一步:手机端只拿「一行摘要 + 状态 + 位置」,正文(推理全文、工具参数、
|
|
5
|
+
* 输出、diff)一律走 `/session/step` 按需取。
|
|
6
|
+
*
|
|
7
|
+
* 这里只有纯函数:摘要怎么算、状态怎么判、参数里的路径怎么取。投影层(history-derive)
|
|
8
|
+
* 与详情端点(session-step)都从这里取,避免「清单里写一种说法、详情里又是另一种」。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 一步摘要的字数上限:清单是一行省略,再长也看不见,白白占线路。 */
|
|
12
|
+
const STEP_SUMMARY_MAX = 72;
|
|
13
|
+
|
|
14
|
+
/** 摘要里保留的第一个非空行(推理正文常带 markdown,去掉粗体标记与多余空白)。 */
|
|
15
|
+
function firstLine(text) {
|
|
16
|
+
const line = String(text || '').replace(/\r\n/g, '\n').split('\n').find((part) => part.trim() !== '') || '';
|
|
17
|
+
return line.replace(/\*\*/g, '').trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 截断到上限,超出用一个省略号收尾。 */
|
|
21
|
+
function clampSummary(text, max = STEP_SUMMARY_MAX) {
|
|
22
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
23
|
+
if (value.length <= max) return value;
|
|
24
|
+
return `${value.slice(0, max - 1)}…`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 推理正文的一行摘要。 */
|
|
28
|
+
function reasoningStepSummary(text) {
|
|
29
|
+
return clampSummary(firstLine(text));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 参数 JSON → 对象;解析不了就返回空对象(畸形参数不该让清单少一条)。 */
|
|
33
|
+
function parseArgs(raw) {
|
|
34
|
+
if (raw && typeof raw === 'object') return raw;
|
|
35
|
+
if (typeof raw !== 'string' || raw.trim() === '') return {};
|
|
36
|
+
try {
|
|
37
|
+
const value = JSON.parse(raw);
|
|
38
|
+
return value && typeof value === 'object' ? value : {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 参数里第一个像路径/命令/查询词的字段(桌面各工具卡片也是这么挑的)。 */
|
|
45
|
+
function firstStringArg(args, keys) {
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const value = args[key];
|
|
48
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 工具调用的一行摘要:读/写/改给路径,命令给命令,搜索给查询词,其余退回描述或工具名。
|
|
55
|
+
*
|
|
56
|
+
* 摘要必须带信息量:用户是看着这一行决定「值不值得点开」的,只写「读取文件」等于没写。
|
|
57
|
+
*/
|
|
58
|
+
function toolStepSummary(name, rawArgs) {
|
|
59
|
+
const args = parseArgs(rawArgs);
|
|
60
|
+
switch (name) {
|
|
61
|
+
case 'bash':
|
|
62
|
+
case 'pwsh':
|
|
63
|
+
case 'terminal_send':
|
|
64
|
+
return clampSummary(firstStringArg(args, ['command', 'cmd']) || firstStringArg(args, ['description']));
|
|
65
|
+
case 'write':
|
|
66
|
+
case 'edit':
|
|
67
|
+
case 'str_replace_editor':
|
|
68
|
+
case 'read':
|
|
69
|
+
case 'read_image':
|
|
70
|
+
return clampSummary(firstStringArg(args, ['file_path', 'path']) || firstStringArg(args, ['description']));
|
|
71
|
+
case 'grep':
|
|
72
|
+
case 'glob':
|
|
73
|
+
return clampSummary(firstStringArg(args, ['pattern', 'query']) || firstStringArg(args, ['path']));
|
|
74
|
+
case 'web_fetch':
|
|
75
|
+
return clampSummary(firstStringArg(args, ['url']));
|
|
76
|
+
case 'web_search':
|
|
77
|
+
return clampSummary(firstStringArg(args, ['query']) || firstStringArg(args, ['q']));
|
|
78
|
+
default:
|
|
79
|
+
return clampSummary(firstStringArg(args, ['description', 'path', 'file_path', 'command', 'query']));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 工具结果的成败 → 一步的状态(与 App 那份 UI 用同一套词)。 */
|
|
84
|
+
function toolStepState(result) {
|
|
85
|
+
if (!result) return 'running';
|
|
86
|
+
if (result.isError) return result.code === 'interrupted' ? 'stopped' : 'error';
|
|
87
|
+
return 'ok';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 内容块里那段文本(工具结果两种形状都认,见 history-derive 的 toolResultParts)。 */
|
|
91
|
+
function textOfParts(parts) {
|
|
92
|
+
if (!Array.isArray(parts)) return '';
|
|
93
|
+
return parts
|
|
94
|
+
.map((part) => (part && typeof part.text === 'string' ? part.text : ''))
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
STEP_SUMMARY_MAX,
|
|
101
|
+
firstLine,
|
|
102
|
+
clampSummary,
|
|
103
|
+
reasoningStepSummary,
|
|
104
|
+
parseArgs,
|
|
105
|
+
toolStepSummary,
|
|
106
|
+
toolStepState,
|
|
107
|
+
textOfParts,
|
|
108
|
+
};
|