dsh-mobile-plugin 0.1.96 → 0.1.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/updater.js +3 -3
- package/package.json +3 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 模型附加选项:模型之外还能调的几档(速度、输出详略……)。
|
|
5
|
+
*
|
|
6
|
+
* 宿主的模型目录只有「模型 + 推理等级」两层;别的档位是插件自己的偏好(例如 Codex
|
|
7
|
+
* 订阅插件的 speedMode / outputVerbosity),桌面端由那个插件接管整个模型菜单自己画,
|
|
8
|
+
* 所以宿主接口里根本没有这些行。这里把它们翻译成**数据**:App 收到几行就画几行,
|
|
9
|
+
* 不认识任何具体插件。
|
|
10
|
+
*
|
|
11
|
+
* 一条来源 = 一个 provider + 它的设置命名空间 + 行描述。加一个插件就是加一条;没有
|
|
12
|
+
* 来源时 `options` 为空,App 仍然只画模型和推理等级。读写都只经过宿主自己的 settings
|
|
13
|
+
* 服务(describe / mutate),不碰插件私有接口;可写字段也仅限这张表里列出的那些。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
17
|
+
const { unwrap } = require('./envelope.js');
|
|
18
|
+
const { invalidateSessionModels } = require('./session-cache.js');
|
|
19
|
+
|
|
20
|
+
/** 行描述:id/title 给 App 画,field 是插件设置里的字段名。 */
|
|
21
|
+
const OPTION_SOURCES = [
|
|
22
|
+
{
|
|
23
|
+
provider: 'openai-codex',
|
|
24
|
+
settingsNs: 'codex-subscription',
|
|
25
|
+
rows: [
|
|
26
|
+
{
|
|
27
|
+
id: 'speed',
|
|
28
|
+
title: '速度',
|
|
29
|
+
field: 'speedMode',
|
|
30
|
+
choices: [
|
|
31
|
+
{ id: 'standard', name: '标准', description: '标准速度' },
|
|
32
|
+
{ id: 'fast', name: '高速', description: '优先处理,消耗更多 Credits' },
|
|
33
|
+
{ id: 'ultrafast', name: 'Ultrafast', description: '账号目录提供时可用,消耗更快' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'verbosity',
|
|
38
|
+
title: '输出详略',
|
|
39
|
+
field: 'outputVerbosity',
|
|
40
|
+
choices: [
|
|
41
|
+
{ id: 'default', name: '模型默认', description: '使用官方模型目录推荐值' },
|
|
42
|
+
{ id: 'low', name: '简洁', description: '更短、更直接' },
|
|
43
|
+
{ id: 'medium', name: '均衡', description: '兼顾完整性与长度' },
|
|
44
|
+
{ id: 'high', name: '详细', description: '更充分的说明与结构' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const MAX_ROWS = 12;
|
|
52
|
+
const MAX_CHOICES = 32;
|
|
53
|
+
|
|
54
|
+
function sourceFor(provider) {
|
|
55
|
+
return OPTION_SOURCES.find((source) => source.provider === provider);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 宿主 settings 里这个插件的命名空间视图;读不到(插件没装 / 没装 settings)返回 null。 */
|
|
59
|
+
async function describeNamespace(ctx, source) {
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await ctx.apiProxy.settings.describe({
|
|
63
|
+
rpcId: 'mobile',
|
|
64
|
+
method: 'settings.describe',
|
|
65
|
+
payload: {},
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// 旧宿主的 apiProxy 未必有 settings 这一域:附加选项是锦上添花,别让它拖垮模型目录。
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const { value, error } = unwrap(response);
|
|
72
|
+
if (error) return null;
|
|
73
|
+
// 远程那层(SettingsDescribeValue)把命名空间放在 `namespaces` 里,只有宿主进程内的
|
|
74
|
+
// settings.describe 才直接回数组 —— 两种都认,别只按其中一种写。
|
|
75
|
+
const rows = Array.isArray(value)
|
|
76
|
+
? value
|
|
77
|
+
: (value && Array.isArray(value.namespaces) ? value.namespaces : []);
|
|
78
|
+
// 先按 profile 条目 id 认;条目 id 在别处被改过时,退回「这个命名空间里有我们认识的
|
|
79
|
+
// 字段」——总比整个功能静默消失好。
|
|
80
|
+
const namespace = rows.find((row) => row && row.ns === source.settingsNs)
|
|
81
|
+
|| rows.find((row) => row && row.value && typeof row.value === 'object'
|
|
82
|
+
&& source.rows.every((option) => Object.hasOwn(row.value, option.field)));
|
|
83
|
+
if (!namespace || !namespace.value || typeof namespace.value !== 'object') return null;
|
|
84
|
+
return namespace;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 这个 provider 的当前行;没有来源或读不到设置时返回空数组。 */
|
|
88
|
+
async function readModelOptions(ctx, provider) {
|
|
89
|
+
const source = typeof provider === 'string' ? sourceFor(provider) : undefined;
|
|
90
|
+
if (!source) return [];
|
|
91
|
+
const namespace = await describeNamespace(ctx, source);
|
|
92
|
+
if (!namespace) return [];
|
|
93
|
+
const options = [];
|
|
94
|
+
for (const row of source.rows) {
|
|
95
|
+
const value = namespace.value[row.field];
|
|
96
|
+
if (typeof value !== 'string' || !value) continue;
|
|
97
|
+
options.push({
|
|
98
|
+
id: row.id,
|
|
99
|
+
title: row.title,
|
|
100
|
+
value,
|
|
101
|
+
choices: row.choices.map((choice) => ({ ...choice })),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return options.slice(0, MAX_ROWS);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 响应清洗,与 `safeModelsValue` 同一套路:宿主(以及那一层描述表)会随版本变,
|
|
109
|
+
* 进 App 之前逐字段挑出来,宁可少一行也不把半截对象糊上去。
|
|
110
|
+
*/
|
|
111
|
+
function safeModelOptions(value) {
|
|
112
|
+
if (!Array.isArray(value)) return [];
|
|
113
|
+
return value
|
|
114
|
+
.filter((row) => row && typeof row.id === 'string' && typeof row.title === 'string' &&
|
|
115
|
+
typeof row.value === 'string' && Array.isArray(row.choices))
|
|
116
|
+
.slice(0, MAX_ROWS)
|
|
117
|
+
.map((row) => ({
|
|
118
|
+
id: row.id.slice(0, 256),
|
|
119
|
+
title: row.title.slice(0, 512),
|
|
120
|
+
value: row.value.slice(0, 256),
|
|
121
|
+
choices: row.choices
|
|
122
|
+
.filter((choice) => choice && typeof choice.id === 'string' && typeof choice.name === 'string')
|
|
123
|
+
.slice(0, MAX_CHOICES)
|
|
124
|
+
.map((choice) => ({
|
|
125
|
+
id: choice.id.slice(0, 256),
|
|
126
|
+
name: choice.name.slice(0, 512),
|
|
127
|
+
...(typeof choice.description === 'string' ? { description: choice.description.slice(0, 4096) } : {}),
|
|
128
|
+
})),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 写一行。只认表里声明过的行与取值 —— 这是移动 API 上唯一一处写插件设置的入口,
|
|
134
|
+
* 不开放任意 ns / 字段。
|
|
135
|
+
*/
|
|
136
|
+
async function writeModelOption(ctx, provider, optionId, value) {
|
|
137
|
+
const source = sourceFor(provider);
|
|
138
|
+
const row = source ? source.rows.find((candidate) => candidate.id === optionId) : undefined;
|
|
139
|
+
if (!row) return { error: { code: 'bad-request', message: '未知的模型选项' } };
|
|
140
|
+
if (!row.choices.some((choice) => choice.id === value)) {
|
|
141
|
+
return { error: { code: 'bad-request', message: '未知的选项值' } };
|
|
142
|
+
}
|
|
143
|
+
const namespace = await describeNamespace(ctx, source);
|
|
144
|
+
if (!namespace) return { error: { code: 'host-unavailable', message: `设置里没有 ${source.settingsNs} 的可写字段` } };
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await ctx.apiProxy.settings.mutate({
|
|
148
|
+
rpcId: 'mobile',
|
|
149
|
+
method: 'settings.mutate',
|
|
150
|
+
payload: {
|
|
151
|
+
ns: source.settingsNs,
|
|
152
|
+
ops: [{ op: 'set', path: [row.field], value }],
|
|
153
|
+
...(Number.isSafeInteger(namespace.revision) ? { expectedRevision: namespace.revision } : {}),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { error: { code: 'host-call-failed', message: error instanceof Error ? error.message : String(error) } };
|
|
158
|
+
}
|
|
159
|
+
const { error } = unwrap(response);
|
|
160
|
+
if (error) return { error };
|
|
161
|
+
return { value: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* POST /api/mobile/v1/session/model-option(token){sessionId, provider, optionId, value}
|
|
166
|
+
*
|
|
167
|
+
* 写完作废这条会话的目录缓存,否则手机上会拿着旧值直到 20 秒 TTL 到期
|
|
168
|
+
* (与切模型那条同样的坑)。
|
|
169
|
+
*/
|
|
170
|
+
async function handleModelOption(ctx, req, res) {
|
|
171
|
+
const body = await readJsonBody(req);
|
|
172
|
+
if (!body || typeof body.sessionId !== 'string' || !body.sessionId ||
|
|
173
|
+
typeof body.provider !== 'string' || !body.provider ||
|
|
174
|
+
typeof body.optionId !== 'string' || !body.optionId ||
|
|
175
|
+
typeof body.value !== 'string' || !body.value) {
|
|
176
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, provider, optionId, value }' } });
|
|
177
|
+
}
|
|
178
|
+
const result = await writeModelOption(ctx, body.provider, body.optionId, body.value);
|
|
179
|
+
if (result.error) return sendJson(res, 400, { ok: false, error: result.error });
|
|
180
|
+
invalidateSessionModels(ctx, req, body.sessionId);
|
|
181
|
+
sendJson(res, 200, { ok: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { OPTION_SOURCES, readModelOptions, safeModelOptions, writeModelOption, handleModelOption };
|
|
@@ -14,6 +14,7 @@ const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
|
14
14
|
const { unwrap } = require('./envelope.js');
|
|
15
15
|
const { callSessions } = require('./rpc.js');
|
|
16
16
|
const { canonicalDeviceKey, sessionModelsStateOf, invalidateSessionModels } = require('./session-cache.js');
|
|
17
|
+
const { readModelOptions, safeModelOptions } = require('./session-model-options.js');
|
|
17
18
|
|
|
18
19
|
const SESSION_MODELS_CACHE_MS = 20000; // 模型目录短 TTL;成功后 stale-while-revalidate
|
|
19
20
|
|
|
@@ -32,6 +33,16 @@ function safeModelsValue(value) {
|
|
|
32
33
|
if (text) result.current[key] = text;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
// 全局默认(新会话会用的那个):App 拿它当「新对话」显示,provider/model 缺一个就不下发。
|
|
37
|
+
if (value.defaultModel && typeof value.defaultModel === 'object') {
|
|
38
|
+
const selection = {};
|
|
39
|
+
for (const key of ['provider', 'model', 'reasoningEffort']) {
|
|
40
|
+
const max = key === 'provider' ? 256 : key === 'model' ? 512 : 256;
|
|
41
|
+
const text = modelText(value.defaultModel[key], max);
|
|
42
|
+
if (text) selection[key] = text;
|
|
43
|
+
}
|
|
44
|
+
if (selection.provider && selection.model) result.defaultModel = selection;
|
|
45
|
+
}
|
|
35
46
|
if (typeof value.routable === 'boolean') result.routable = value.routable;
|
|
36
47
|
if (Array.isArray(value.routableProviders)) {
|
|
37
48
|
result.routableProviders = [...new Set(value.routableProviders
|
|
@@ -69,6 +80,12 @@ function safeModelsValue(value) {
|
|
|
69
80
|
})),
|
|
70
81
|
}));
|
|
71
82
|
}
|
|
83
|
+
// 模型之外的附加选项行(速度、输出详略……):行数与内容都由 session-model-options.js
|
|
84
|
+
// 那张描述表决定,App 只按收到的数组逐行画。空数组不下发(旧宿主没有这个字段)。
|
|
85
|
+
if (Array.isArray(value.options)) {
|
|
86
|
+
const options = safeModelOptions(value.options);
|
|
87
|
+
if (options.length > 0) result.options = options;
|
|
88
|
+
}
|
|
72
89
|
if (Array.isArray(value.failures)) result.failures = value.failures.slice(0, 64).map((failure) => ({
|
|
73
90
|
...(typeof failure?.id === 'string' ? { id: modelText(failure.id, 256) } : {}),
|
|
74
91
|
...(typeof failure?.name === 'string' ? { name: modelText(failure.name, 512) } : {}),
|
|
@@ -103,10 +120,13 @@ function startModelsRequest(ctx, entry, sessionId, fresh = false) {
|
|
|
103
120
|
return entry.promise;
|
|
104
121
|
}
|
|
105
122
|
const generation = entry.generation || 0;
|
|
106
|
-
const request = Promise.resolve(callSessions(ctx, 'models', { sessionId })).then((response) => {
|
|
123
|
+
const request = Promise.resolve(callSessions(ctx, 'models', { sessionId })).then(async (response) => {
|
|
107
124
|
const { value, error } = unwrap(response);
|
|
108
125
|
if (error) return { error };
|
|
109
126
|
const safeValue = safeModelsValue(value);
|
|
127
|
+
// 附加选项行按**当前模型所在渠道**取(宿主目录里没有这一层,见 session-model-options.js)。
|
|
128
|
+
const options = await readModelOptions(ctx, safeValue.current && safeValue.current.provider);
|
|
129
|
+
if (options.length > 0) safeValue.options = options;
|
|
110
130
|
const fetchedAt = Date.now();
|
|
111
131
|
if (generation === (entry.generation || 0)) {
|
|
112
132
|
entry.value = safeValue;
|
|
@@ -133,7 +153,9 @@ async function buildModelsSnapshot(ctx, sessionId) {
|
|
|
133
153
|
|
|
134
154
|
/** GET /api/mobile/v1/session/models?sessionId=…(token)
|
|
135
155
|
* 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
|
|
136
|
-
* 响应:{ ok, current:{provider,model,reasoningEffort?}, routable, groups:[{id,name,models:[{id,name,description?,reasoning?}]}], failures:[{id,name,message}] }
|
|
156
|
+
* 响应:{ ok, current:{provider,model,reasoningEffort?}, defaultModel:{provider,model,reasoningEffort?}, routable, groups:[{id,name,models:[{id,name,description?,reasoning?}]}], options?:[{id,title,value,choices:[{id,name,description?}]}], failures:[{id,name,message}] }
|
|
157
|
+
* `current` 是**这个会话**实际会用的;`defaultModel` 是全局默认(宿主新建会话时的落点)。
|
|
158
|
+
* `options` 是当前渠道的附加选项行(速度、输出详略……),没有就是没有这个字段。
|
|
137
159
|
*/
|
|
138
160
|
async function handleModels(ctx, req, res) {
|
|
139
161
|
const url = new URL(req.url, 'http://x');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 会话行左边那只时钟:**这一条会话有没有还在生效的自动化任务**。
|
|
5
|
+
*
|
|
6
|
+
* 桌面上那个标记在 `@deepseek-ai/dsh-client-ui-schedule` 的 `SessionScheduleMark`:它占
|
|
7
|
+
* 会话行标题前那格 16px,画一颗 14px 的 `IconClockOutlineRegular`;只在 「这一行自己的
|
|
8
|
+
* 主状态是空闲」时才挂得上(有审批 / 有新消息 / 正在跑时,那一格留给状态点),归档行那格
|
|
9
|
+
* 留空。手机端此前没有这个标记,用户在桌面上排了定时任务,手机上那条会话看不出任何区别。
|
|
10
|
+
*
|
|
11
|
+
* 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status,
|
|
12
|
+
* 按 `status === 'active'` 收成一份「哪些会话有活动任务」的集合,列表每行只要查表。**不**
|
|
13
|
+
* 每行发一次 `schedule/list` —— 会话列表每 10 秒重建一次,逐行请求等于每轮几十次往返。
|
|
14
|
+
*
|
|
15
|
+
* 三条取舍:
|
|
16
|
+
* - **服务不在就当没有任务**(老宿主 / 极简 profile 没装):时钟是提示性信息,为它让整个
|
|
17
|
+
* 会话列表失败是拿主要功能换装饰;
|
|
18
|
+
* - **读失败也当没有**,但只吞这一条,不缓存:下一个 10 秒周期照常重问,宿主的
|
|
19
|
+
* `schedule/changed` 与目录本身都是活的;
|
|
20
|
+
* - 只看 `active`:一次性任务投完就自动结束(`status` 转 inactive),桌面上那颗也跟着消失。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 目录一次最多收多少条(与 schedule.js 的 SCHEDULE_MAX_ENTRIES 同量级,这里只用来防异常宿主)。 */
|
|
24
|
+
const CATALOG_MAX_ENTRIES = 500;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 宿主 `schedule` 服务:同进程直查。
|
|
28
|
+
*
|
|
29
|
+
* 用 `ctx.get` 而不是 inject,理由与 mobile/schedule.js 相同:本插件不依赖它,缺了只该
|
|
30
|
+
* 少一个标记,不该让整个插件装不起来。
|
|
31
|
+
*/
|
|
32
|
+
function scheduleService(ctx) {
|
|
33
|
+
try {
|
|
34
|
+
if (!ctx || typeof ctx.get !== 'function') return null;
|
|
35
|
+
return ctx.get('schedule') || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 哪些会话还有活动自动化任务。
|
|
43
|
+
*
|
|
44
|
+
* @param ctx 插件上下文(取宿主 `schedule` 服务)
|
|
45
|
+
* @returns {Promise<Set<string>>} 有活动任务的 sessionId 集合;服务缺失或读失败时为空集
|
|
46
|
+
*/
|
|
47
|
+
async function activeScheduleSessions(ctx) {
|
|
48
|
+
const service = scheduleService(ctx);
|
|
49
|
+
if (!service || typeof service.catalog !== 'function') return new Set();
|
|
50
|
+
let value;
|
|
51
|
+
try {
|
|
52
|
+
value = await service.catalog();
|
|
53
|
+
} catch {
|
|
54
|
+
return new Set();
|
|
55
|
+
}
|
|
56
|
+
const records = Array.isArray(value)
|
|
57
|
+
? value
|
|
58
|
+
: (value && Array.isArray(value.records) ? value.records : []);
|
|
59
|
+
const out = new Set();
|
|
60
|
+
for (const record of records) {
|
|
61
|
+
if (!record || typeof record !== 'object') continue;
|
|
62
|
+
if (record.status !== 'active') continue;
|
|
63
|
+
if (typeof record.sessionId !== 'string' || !record.sessionId) continue;
|
|
64
|
+
out.add(record.sessionId);
|
|
65
|
+
if (out.size >= CATALOG_MAX_ENTRIES) break;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { activeScheduleSessions };
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const { sendJson } = require('../shared/http-util');
|
|
16
16
|
const { unwrap } = require('./envelope.js');
|
|
17
17
|
const { callSessions } = require('./rpc.js');
|
|
18
|
-
const { deriveMessages } = require('./history-derive.js');
|
|
18
|
+
const { deriveMessages, buildToolResultMap } = require('./history-derive.js');
|
|
19
19
|
const { resolveEventAttachments } = require('./attachments.js');
|
|
20
20
|
const { createTextImageBudget } = require('./local-images.js');
|
|
21
21
|
|
|
@@ -165,6 +165,16 @@ async function handleMessages(ctx, req, res) {
|
|
|
165
165
|
const budget = createTextImageBudget();
|
|
166
166
|
const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
|
|
167
167
|
const messages = deriveMessages(resolvedEvents);
|
|
168
|
+
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁必须
|
|
169
|
+
// 取自整个窗口(不只是 seq > afterSeq 的那一段):调用方游标可能已经越过结果 seq,
|
|
170
|
+
// 而工具卡还停在 running;把窗口内所有落定结果都回传,客户端按 callId 幂等打补丁。
|
|
171
|
+
const toolResults = [...buildToolResultMap(events).entries()].map(([callId, result]) => ({
|
|
172
|
+
callId,
|
|
173
|
+
state: result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok',
|
|
174
|
+
...(result.output ? { output: result.output } : {}),
|
|
175
|
+
...(result.images && result.images.length ? { images: result.images } : {}),
|
|
176
|
+
...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
|
|
177
|
+
}));
|
|
168
178
|
const hasMore = !!(value && value.hasMore);
|
|
169
179
|
const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
|
|
170
180
|
const oldestSeq = seqs.length ? Math.min(...seqs) : null;
|
|
@@ -181,6 +191,7 @@ async function handleMessages(ctx, req, res) {
|
|
|
181
191
|
lastSeq,
|
|
182
192
|
...(oldestSeq !== null ? { oldestSeq } : {}),
|
|
183
193
|
...(resyncRequired ? { resyncRequired: true } : {}),
|
|
194
|
+
...(toolResults.length ? { toolResults } : {}),
|
|
184
195
|
});
|
|
185
196
|
}
|
|
186
197
|
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -54,6 +54,8 @@ const { handleSessionSearch } = require('./session-search.js');
|
|
|
54
54
|
const { handleAgentPresets } = require('./session-presets.js');
|
|
55
55
|
const { handleGoalAction } = require('./session-goal.js');
|
|
56
56
|
const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
|
|
57
|
+
// 行首时钟:这一条会话还有没有活动的自动化任务(一次任务目录查询喂满整份列表)。
|
|
58
|
+
const { activeScheduleSessions } = require('./session-schedule-mark.js');
|
|
57
59
|
// 预览(lastMessage 补全)与它自己的重读节流:只读日志、不发写,单列一个子模块。
|
|
58
60
|
const { HISTORY_FOR_ENRICH, sessionPreviewFor } = require('./session-preview.js');
|
|
59
61
|
|
|
@@ -189,6 +191,9 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
189
191
|
// 它们,筛选视图正是为了把它们找回来(查看 / 取消归档)。
|
|
190
192
|
const deleted = deletedSessionIdsOf(ctx);
|
|
191
193
|
const { archived, pinned } = await workspaceSessionFlags(ctx);
|
|
194
|
+
// 行首那只时钟的判据:这一条会话还有没有活动的自动化任务(见 session-schedule-mark.js)。
|
|
195
|
+
// 与置顶 / 归档同一个位置取,一次目录查询喂满整份列表,别在渲染层逐行发请求。
|
|
196
|
+
const scheduled = await activeScheduleSessions(ctx);
|
|
192
197
|
const allSummaries = summaries.filter((item) => item && !deleted.has(item.sessionId)
|
|
193
198
|
&& (archivedOnly === archived.has(item.sessionId)));
|
|
194
199
|
// 状态跟踪覆盖完整列表,不能受预览补全上限影响。
|
|
@@ -226,6 +231,9 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
226
231
|
// 与归档同源同一份快照。App 侧栏据此把这条排到工作区最前、并在行尾画图钉标记。
|
|
227
232
|
// 和归档一样只在真置顶时出现:旧 App 忽略未知字段,行为不变。
|
|
228
233
|
...(pinned.has(s.sessionId) ? { pinned: true } : {}),
|
|
234
|
+
// 这一条会话还排着活动的自动化任务:App 侧栏在行首画那只时钟(桌面
|
|
235
|
+
// SessionScheduleMark 同口径)。只在真有任务时出现,旧 App 忽略未知字段。
|
|
236
|
+
...(scheduled.has(s.sessionId) ? { scheduled: true } : {}),
|
|
229
237
|
updatedAt: s.updatedAt || 0,
|
|
230
238
|
lastMessage: null,
|
|
231
239
|
...(s.origin === 'subagent' ? { origin: s.origin } : {}),
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 统计采集:把宿主每条会话的累计用量读出来,和水位一比,增量记进账本。
|
|
5
|
+
*
|
|
6
|
+
* 为什么不重放会话事件:那是 dsh-context 在桌面上干的事(它让宿主注册一份投影,
|
|
7
|
+
* 每个事件折一次)。手机这边要的是「按天看个大概」,重放整条日志在宿主上是一笔
|
|
8
|
+
* 每秒都在烧的开销,而我们每轮只需要一个数 —— 会话的累计用量。
|
|
9
|
+
*
|
|
10
|
+
* 所以走水位差:
|
|
11
|
+
* 宿主累计值 − 账本记下的水位 = 这次该入账的量。
|
|
12
|
+
* 好处是零依赖(只用现有的会话投影接口,不需要宿主新能力,也不用装别家插件),
|
|
13
|
+
* 代价如实说:入账日期取「插件看到增量的那一天」,跨零点的一轮会落在后一天。
|
|
14
|
+
* 这是刻意的近似 —— 为一个几十分钟的边界去重放事件流不值得。
|
|
15
|
+
*
|
|
16
|
+
* 首采(账本里没有这条会话)会把它的历史全量入账,日期取它最后一次活动的日期:
|
|
17
|
+
* 这是一次性回填,用户装上这个版本之后第一次打开统计页就会看见过去的用量,
|
|
18
|
+
* 而不是从今天零点重新计数。
|
|
19
|
+
*
|
|
20
|
+
* 采集是**按需**的(打开统计页 / 下拉刷新 / 清理已归档前补一刀),没有后台定时器:
|
|
21
|
+
* 手机上没人看统计的时候不该让宿主一直读盘。
|
|
22
|
+
*
|
|
23
|
+
* 清理前那次补采(`skipPreview`)刻意不读「最后一句」:那一步要读会话日志,是整轮里
|
|
24
|
+
* 最重的一趟,而删除不可恢复、时间又卡在手机那条请求的预算里。为了多把几十上百条会话的
|
|
25
|
+
* 数字记进账本,宁可让首次见到的会话卡片少一行摘要 —— 标题与项目名还认得出来。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { unwrap } = require('./envelope.js');
|
|
29
|
+
const { callSessions } = require('./rpc.js');
|
|
30
|
+
const { sessionPreviewFor } = require('./session-preview.js');
|
|
31
|
+
const ledger = require('./stats-ledger.cjs');
|
|
32
|
+
|
|
33
|
+
/** 同一条会话两次采集之间的最小间隔(打开页面时的重复请求直接复用上一轮结果)。 */
|
|
34
|
+
const COLLECT_INTERVAL_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/** 并发读投影的条数:宿主那侧是内存里读缓存,四条并行不会把它压住。 */
|
|
37
|
+
const CONCURRENCY = 4;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 一次采集的时间预算。
|
|
41
|
+
*
|
|
42
|
+
* 手机上点开统计页时,插件会当场把每条会话的累计用量读一遍;一百多条会话串起来能拖到
|
|
43
|
+
* 十几秒(每条两趟宿主调用),用户看到的就是「点统计,转好久」(2026-10-22 真机)。
|
|
44
|
+
* 所以给这一趟设上限:到点就停,把**已经采到的**那部分记进账本先回给手机,剩下的等
|
|
45
|
+
* 下一次打开 / 下拉刷新接着采 —— 水位差是累积的,采一半不会算错,只是数字暂时偏小。
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_BUDGET_MS = 1500;
|
|
48
|
+
|
|
49
|
+
const num = ledger.num;
|
|
50
|
+
|
|
51
|
+
function normalizePath(value) {
|
|
52
|
+
if (typeof value !== 'string') return '';
|
|
53
|
+
return value.replace(/\/+$/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 宿主会话列表:拿不到就当没有(清空或报错都不该把账本弄乱)。 */
|
|
57
|
+
async function listSessions(ctx) {
|
|
58
|
+
try {
|
|
59
|
+
const { value } = unwrap(await callSessions(ctx, 'list', {}, 'mobile'));
|
|
60
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
|
|
61
|
+
if (items === null) return null;
|
|
62
|
+
return items.filter((item) => item && typeof item.sessionId === 'string' && item.sessionId);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** cwd → 项目展示名。宿主没给可用的工作区表就返回空表,卡片上就不显示项目名。 */
|
|
69
|
+
async function workspaceNames(ctx) {
|
|
70
|
+
const byCwd = new Map();
|
|
71
|
+
try {
|
|
72
|
+
const { value } = unwrap(await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} }));
|
|
73
|
+
const workspaces = value && Array.isArray(value.workspaces) ? value.workspaces : [];
|
|
74
|
+
for (const workspace of workspaces) {
|
|
75
|
+
if (!workspace || typeof workspace !== 'object') continue;
|
|
76
|
+
const cwd = normalizePath(workspace.cwd);
|
|
77
|
+
const name = typeof workspace.name === 'string' ? workspace.name : '';
|
|
78
|
+
if (cwd && name) byCwd.set(cwd, name);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* 拿不到项目名不影响数字 */
|
|
82
|
+
}
|
|
83
|
+
return byCwd;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 一条会话的投影(tokenUsage / sessionStats / contextBreakdown);读不到返回 null。 */
|
|
87
|
+
async function readProjections(ctx, sessionId) {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
try {
|
|
90
|
+
const { value } = unwrap(await callSessions(ctx, 'projections', { sessionId }, 'mobile', controller.signal));
|
|
91
|
+
const values = value && value.values && typeof value.values === 'object' ? value.values : null;
|
|
92
|
+
return values;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 宿主那份累计用量 → 账本口径。缺哪项就当 0,不猜。 */
|
|
99
|
+
function hostUsageOf(projections) {
|
|
100
|
+
const usage = ledger.emptyUsage();
|
|
101
|
+
if (!projections || typeof projections !== 'object') return usage;
|
|
102
|
+
const token = projections.tokenUsage;
|
|
103
|
+
if (token && typeof token === 'object') {
|
|
104
|
+
usage.uncachedInputTokens = num(token.uncachedInputTokens);
|
|
105
|
+
usage.cacheReadTokens = num(token.cacheReadTokens);
|
|
106
|
+
usage.cacheWriteTokens = num(token.cacheWriteTokens);
|
|
107
|
+
usage.outputTokens = num(token.outputTokens);
|
|
108
|
+
}
|
|
109
|
+
const stats = projections.sessionStats;
|
|
110
|
+
if (stats && typeof stats === 'object') {
|
|
111
|
+
usage.turns = num(stats.turns);
|
|
112
|
+
usage.steps = num(stats.steps);
|
|
113
|
+
usage.llmMs = num(stats.llmMs);
|
|
114
|
+
usage.toolMs = num(stats.toolMs);
|
|
115
|
+
}
|
|
116
|
+
return usage;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 增量:宿主累计减水位。累计值比水位还小只可能是会话日志被重写 / 换了宿主,
|
|
121
|
+
* 这时按新值全额入账(宁可多记一次,也不要从此永远少记)。
|
|
122
|
+
*/
|
|
123
|
+
function diffUsage(host, watermark) {
|
|
124
|
+
const delta = ledger.emptyUsage();
|
|
125
|
+
for (const key of Object.keys(delta)) {
|
|
126
|
+
const current = num(host[key]);
|
|
127
|
+
const previous = watermark ? num(watermark[key]) : 0;
|
|
128
|
+
delta[key] = current >= previous ? current - previous : current;
|
|
129
|
+
}
|
|
130
|
+
return delta;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasDelta(usage) {
|
|
134
|
+
return Object.values(usage).some((value) => value > 0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 采一轮。
|
|
139
|
+
*
|
|
140
|
+
* `sessionIds` 给了就只采那几条(清理前补采用),否则采整份列表;
|
|
141
|
+
* `force` 跳过 60 秒的最小间隔(用户手动刷新时要真读一次);
|
|
142
|
+
* `skipPreview` 不读「最后一句」摘要(清理前补采用,见文件头)。
|
|
143
|
+
*/
|
|
144
|
+
async function collectStats(ctx, options = {}) {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const budgetMs = Number.isFinite(options.budgetMs) && options.budgetMs > 0 ? options.budgetMs : DEFAULT_BUDGET_MS;
|
|
147
|
+
const deadline = now + budgetMs;
|
|
148
|
+
const skipPreview = options.skipPreview === true;
|
|
149
|
+
const book = ledger.readLedger();
|
|
150
|
+
const items = await listSessions(ctx);
|
|
151
|
+
if (items === null) return book;
|
|
152
|
+
|
|
153
|
+
const wanted = Array.isArray(options.sessionIds) ? new Set(options.sessionIds) : null;
|
|
154
|
+
const names = await workspaceNames(ctx);
|
|
155
|
+
|
|
156
|
+
// 消失检测只在「采整份列表」时做:定向补采拿到的是子集,凭它判「会话没了」会误标。
|
|
157
|
+
if (!wanted) {
|
|
158
|
+
const liveIds = new Set(items.map((item) => item.sessionId));
|
|
159
|
+
for (const [sessionId, record] of Object.entries(book.sessions)) {
|
|
160
|
+
if (!liveIds.has(sessionId)) record.gone = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 最近有动作的排前面:预算不够采完全部时,先把用户真正在用的那几条账补上。
|
|
165
|
+
const targets = items
|
|
166
|
+
.filter((item) => !wanted || wanted.has(item.sessionId))
|
|
167
|
+
.sort((a, b) => num(b.updatedAt) - num(a.updatedAt));
|
|
168
|
+
let cursor = 0;
|
|
169
|
+
|
|
170
|
+
const worker = async () => {
|
|
171
|
+
for (;;) {
|
|
172
|
+
const index = cursor;
|
|
173
|
+
cursor += 1;
|
|
174
|
+
if (index >= targets.length) return;
|
|
175
|
+
// 预算到点就停手:剩下的等下一次(水位法不会漏,只会晚)。
|
|
176
|
+
if (Date.now() > deadline) return;
|
|
177
|
+
const summary = targets[index];
|
|
178
|
+
const sessionId = summary.sessionId;
|
|
179
|
+
const record = book.sessions[sessionId];
|
|
180
|
+
if (!options.force && record && record.collectedAt && now - record.collectedAt < COLLECT_INTERVAL_MS) {
|
|
181
|
+
// 还没到下一次采集的点:只把可能变了的名字补上,数字一个字不动。
|
|
182
|
+
if (typeof summary.title === 'string' && summary.title) record.title = summary.title;
|
|
183
|
+
record.gone = false;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const projections = await readProjections(ctx, sessionId);
|
|
188
|
+
if (!projections) continue;
|
|
189
|
+
|
|
190
|
+
const host = hostUsageOf(projections);
|
|
191
|
+
const watermark = record ? record.watermark : null;
|
|
192
|
+
const delta = diffUsage(host, watermark);
|
|
193
|
+
const firstSight = watermark === null;
|
|
194
|
+
// 首采:历史量一次性入账,日期取这条会话最后一次活动那天(见文件头)。
|
|
195
|
+
// 之后再有的增量一律记在「今天」—— 也就是这次观测到的日期。
|
|
196
|
+
const lastActiveAt = num(summary.updatedAt) || now;
|
|
197
|
+
|
|
198
|
+
const cwd = normalizePath(summary.cwd);
|
|
199
|
+
const workspaceName = cwd ? names.get(cwd) : undefined;
|
|
200
|
+
|
|
201
|
+
if (hasDelta(delta) || firstSight) {
|
|
202
|
+
// 最后一条消息的摘要只在真有增量时去读(它是日志,属于重活):
|
|
203
|
+
// 卡片上那一行「最后一句说了什么」比什么都更能让人认出这是哪条会话。
|
|
204
|
+
// 清理前补采跳过这一步,把有限的时间预算全留给「多采几条会话的数字」。
|
|
205
|
+
let lastText = '';
|
|
206
|
+
if (!skipPreview) {
|
|
207
|
+
try {
|
|
208
|
+
const preview = await sessionPreviewFor(ctx, summary);
|
|
209
|
+
const text = preview && preview.lastMessage && preview.lastMessage.text;
|
|
210
|
+
if (typeof text === 'string') lastText = text.slice(0, ledger.LAST_TEXT_MAX);
|
|
211
|
+
} catch {
|
|
212
|
+
/* 读不到就不显示那一行 */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
ledger.applyDelta(book, {
|
|
216
|
+
sessionId,
|
|
217
|
+
at: firstSight ? lastActiveAt : now,
|
|
218
|
+
delta: firstSight ? host : delta,
|
|
219
|
+
title: typeof summary.title === 'string' ? summary.title : '',
|
|
220
|
+
cwd,
|
|
221
|
+
workspaceName,
|
|
222
|
+
lastText,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const saved = book.sessions[sessionId];
|
|
227
|
+
if (saved) {
|
|
228
|
+
saved.watermark = host;
|
|
229
|
+
saved.collectedAt = now;
|
|
230
|
+
saved.gone = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const breakdown = projections.contextBreakdown;
|
|
234
|
+
if (breakdown && typeof breakdown === 'object') {
|
|
235
|
+
book.composition = {
|
|
236
|
+
systemTokens: num(breakdown.systemTokens),
|
|
237
|
+
toolsTokens: num(breakdown.toolsTokens),
|
|
238
|
+
messageTokens: num(breakdown.messageTokens),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
245
|
+
|
|
246
|
+
ledger.trim(book);
|
|
247
|
+
ledger.saveLedger(book);
|
|
248
|
+
return book;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
COLLECT_INTERVAL_MS,
|
|
253
|
+
DEFAULT_BUDGET_MS,
|
|
254
|
+
collectStats,
|
|
255
|
+
hostUsageOf,
|
|
256
|
+
diffUsage,
|
|
257
|
+
};
|