dsh-mobile-plugin 0.5.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +80 -1
- package/lib/index.js +9 -3
- package/lib/mobile/agent-presets.js +67 -1
- package/lib/mobile/debug-trace.js +180 -0
- package/lib/mobile/deepseek-account.js +3 -0
- package/lib/mobile/entry-window.js +6 -1
- package/lib/mobile/history-derive.js +148 -9
- package/lib/mobile/local-images.js +200 -47
- package/lib/mobile/session-local-image.js +45 -0
- package/lib/mobile/session-mirror.js +2 -1
- package/lib/mobile/session-send.js +13 -1
- package/lib/mobile/session-snapshot.js +37 -54
- package/lib/mobile/session-stats.js +51 -0
- package/lib/mobile/sessions.js +3 -2
- package/lib/mobile/tool-result-cache.js +75 -0
- package/lib/panel/index.html +6 -6
- package/lib/relay/firewall-probe.js +11 -5
- package/lib/routes/mobile-api.js +11 -0
- package/lib/shell/half-route.js +73 -0
- package/lib/shell/half-view.js +37 -0
- package/lib/shell/runtime.js +117 -21
- package/lib/sidecar-lifecycle.cjs +18 -1
- package/lib/update-install.cjs +2 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -224,6 +224,15 @@ window.__ModuleLoader__.load({
|
|
|
224
224
|
/** 自检上报的出口与间隔:只为可观测性存在,不做任何状态变更。 */
|
|
225
225
|
const CLIENT_HALF_ENDPOINT = '/api/relay/client-half';
|
|
226
226
|
const CLIENT_HALF_REPORT_MS = 10000;
|
|
227
|
+
/**
|
|
228
|
+
* 宿主半边的自检口(由外壳注册,实现挂了它也还在)。
|
|
229
|
+
*
|
|
230
|
+
* 实现装载失败时整张 `/api/relay/*` 都 404,这一项只显示一句「插件宿主侧没有加载」,
|
|
231
|
+
* 真正的原因(等哪个服务超时、哪一代装不上)就没人看得见。这两条是那个原因唯一的出口;
|
|
232
|
+
* 第二条点一下就在原地重新装载一次,不必重启整个 DSH。
|
|
233
|
+
*/
|
|
234
|
+
const HOST_HALF_ENDPOINT = '/api/relay/host-half';
|
|
235
|
+
const HOST_HALF_RELOAD_ENDPOINT = '/api/relay/host-half/reload';
|
|
227
236
|
|
|
228
237
|
/**
|
|
229
238
|
* 把 sessionStatus 的快照摊成 [id, status] 列表。
|
|
@@ -469,6 +478,10 @@ window.__ModuleLoader__.load({
|
|
|
469
478
|
panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
|
|
470
479
|
loadFailed: '读取宿主状态失败',
|
|
471
480
|
loadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
|
|
481
|
+
hostHalfReason: '宿主侧报的原因:',
|
|
482
|
+
hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
|
|
483
|
+
hostHalfReload: '重新装载',
|
|
484
|
+
hostHalfReloadFailed: '重新装载没成:',
|
|
472
485
|
webAccessOn: '公网网页管理已开放,到期时间',
|
|
473
486
|
webAccessOff: '公网网页管理已关闭',
|
|
474
487
|
webAccessOpen: '开放网页管理 2 小时',
|
|
@@ -487,6 +500,10 @@ window.__ModuleLoader__.load({
|
|
|
487
500
|
panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
|
|
488
501
|
loadFailed: 'Failed to read host status',
|
|
489
502
|
loadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
|
|
503
|
+
hostHalfReason: 'Reported by the host: ',
|
|
504
|
+
hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
|
|
505
|
+
hostHalfReload: 'Reload now',
|
|
506
|
+
hostHalfReloadFailed: 'Reload failed: ',
|
|
490
507
|
webAccessOn: 'Public web management is open until',
|
|
491
508
|
webAccessOff: 'Public web management is closed',
|
|
492
509
|
webAccessOpen: 'Open web management for 2 hours',
|
|
@@ -514,6 +531,10 @@ window.__ModuleLoader__.load({
|
|
|
514
531
|
const [webAccessBusy, setWebAccessBusy] = useState(false);
|
|
515
532
|
/** 最近一次失败的 HTTP 状态码(0 = 网络异常 / 还没失败过)。404 单独说。 */
|
|
516
533
|
const [errorStatus, setErrorStatus] = useState(0);
|
|
534
|
+
/** 宿主半边自检的结果(外壳给的那份),404 时才有内容。 */
|
|
535
|
+
const [half, setHalf] = useState(null);
|
|
536
|
+
const [halfBusy, setHalfBusy] = useState(false);
|
|
537
|
+
const [halfError, setHalfError] = useState(null);
|
|
517
538
|
|
|
518
539
|
useEffect(() => {
|
|
519
540
|
let alive = true;
|
|
@@ -528,6 +549,8 @@ window.__ModuleLoader__.load({
|
|
|
528
549
|
if (!alive) return;
|
|
529
550
|
setError(null);
|
|
530
551
|
setErrorStatus(0);
|
|
552
|
+
setHalf(null);
|
|
553
|
+
setHalfError(null);
|
|
531
554
|
setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
|
|
532
555
|
setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
|
|
533
556
|
if (retryTimer !== null) {
|
|
@@ -549,6 +572,20 @@ window.__ModuleLoader__.load({
|
|
|
549
572
|
clearTimer(refreshTimer);
|
|
550
573
|
refreshTimer = null;
|
|
551
574
|
}
|
|
575
|
+
// 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
|
|
576
|
+
// 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到就当没有,别报错。
|
|
577
|
+
if (problem?.status === 404) {
|
|
578
|
+
try {
|
|
579
|
+
const picture = await call(HOST_HALF_ENDPOINT);
|
|
580
|
+
if (!alive) return;
|
|
581
|
+
setHalf(picture && typeof picture.half === 'object' ? picture.half : null);
|
|
582
|
+
} catch {
|
|
583
|
+
if (!alive) return;
|
|
584
|
+
setHalf(null);
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
setHalf(null);
|
|
588
|
+
}
|
|
552
589
|
// 无权的远程设置页不轮询;宿主还没启动等临时故障每 3s 重试。
|
|
553
590
|
if (problem?.status !== 403 && problem?.status !== 423 && retryTimer === null) {
|
|
554
591
|
retryTimer = setInterval(() => {
|
|
@@ -580,6 +617,27 @@ window.__ModuleLoader__.load({
|
|
|
580
617
|
}
|
|
581
618
|
};
|
|
582
619
|
|
|
620
|
+
/**
|
|
621
|
+
* 让外壳当场把实现再装一次。
|
|
622
|
+
*
|
|
623
|
+
* 装载失败那阵子,这是唯一不必重启整个 DSH 的出路;成不成以宿主回来的那份自检为准,
|
|
624
|
+
* 失败原因原样贴出来(它往往就是「等哪个服务超时」,过一会儿自己就好了)。
|
|
625
|
+
*/
|
|
626
|
+
const reloadHalf = async () => {
|
|
627
|
+
if (halfBusy) return;
|
|
628
|
+
setHalfBusy(true);
|
|
629
|
+
setHalfError(null);
|
|
630
|
+
try {
|
|
631
|
+
const next = await postJson(HOST_HALF_RELOAD_ENDPOINT, {});
|
|
632
|
+
if (next && typeof next.half === 'object') setHalf(next.half);
|
|
633
|
+
if (next && next.ok === false) setHalfError(errorText(next, 200));
|
|
634
|
+
} catch (problem) {
|
|
635
|
+
setHalfError(String((problem && problem.message) || problem));
|
|
636
|
+
} finally {
|
|
637
|
+
setHalfBusy(false);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
583
641
|
const url = typeof panelUrl === 'string' ? panelUrl : '';
|
|
584
642
|
const webAccessEnabled = Boolean(webAccess && webAccess.enabled);
|
|
585
643
|
const expiresAt = webAccess && Number.isFinite(webAccess.expiresAt)
|
|
@@ -588,11 +646,32 @@ window.__ModuleLoader__.load({
|
|
|
588
646
|
let webAccessStatus = t('webAccessUnavailable');
|
|
589
647
|
if (webAccess) webAccessStatus = webAccessEnabled ? `${t('webAccessOn')} ${expiresAt}` : t('webAccessOff');
|
|
590
648
|
|
|
649
|
+
const missing = errorStatus === 404;
|
|
650
|
+
|
|
591
651
|
return h('div', { className: 'dshm_root' }, [
|
|
592
652
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
593
653
|
error
|
|
594
|
-
? h('p', { className: 'dshm_error' },
|
|
654
|
+
? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
|
|
655
|
+
: null,
|
|
656
|
+
// 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
|
|
657
|
+
// 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
|
|
658
|
+
missing && half && typeof half.lastError === 'string' && half.lastError
|
|
659
|
+
? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
|
|
660
|
+
: null,
|
|
661
|
+
missing && half && half.retrying === true
|
|
662
|
+
? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
|
|
663
|
+
: null,
|
|
664
|
+
missing
|
|
665
|
+
? h('div', { className: 'dshm_actions' }, [
|
|
666
|
+
h('button', {
|
|
667
|
+
type: 'button',
|
|
668
|
+
className: 'dshm_btn',
|
|
669
|
+
disabled: halfBusy,
|
|
670
|
+
onClick: reloadHalf,
|
|
671
|
+
}, t('hostHalfReload')),
|
|
672
|
+
])
|
|
595
673
|
: null,
|
|
674
|
+
halfError ? h('p', { className: 'dshm_error' }, `${t('hostHalfReloadFailed')}${halfError}`) : null,
|
|
596
675
|
|
|
597
676
|
// 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
|
|
598
677
|
// 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
|
package/lib/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { createRuntime } from './shell/runtime.js'
|
|
|
28
28
|
import * as updater from './shell/updater.js'
|
|
29
29
|
import { installedPackageDir, installedVersion, profileNodeModules, snapshotRunningGeneration } from './shell/manager.js'
|
|
30
30
|
import { createDeviceOta } from './shell/device-ota.js'
|
|
31
|
+
import { installHostHalf } from './shell/half-route.js'
|
|
31
32
|
|
|
32
33
|
export const name = 'dsh-mobile-plugin'
|
|
33
34
|
export const inject = ['webServer', 'sessions']
|
|
@@ -118,16 +119,21 @@ export function apply(ctx) {
|
|
|
118
119
|
}
|
|
119
120
|
}, 'dsh-mobile-plugin: generation runtime')
|
|
120
121
|
|
|
122
|
+
const installedShellHash = () => updater.shellFingerprint(installedPackageDir())
|
|
123
|
+
|
|
124
|
+
// 宿主半边的自检口(实现在 `shell/half-route.js`):实现装载失败时整张控制面路由
|
|
125
|
+
// 都不在,只有外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
|
|
126
|
+
ctx.effect(() => installHostHalf(ctx, runtime, { installedVersion, installedShellHash, runningShellHash }), 'dsh-mobile-plugin: host half self check')
|
|
127
|
+
|
|
121
128
|
ctx.reflect.provide('dshMobilePluginRuntime', {
|
|
122
129
|
status: () => ({
|
|
123
130
|
...runtime.status(),
|
|
124
131
|
/** 盘上装着的版本(市场装完立刻变,和运行中的版本对照就能看出「要不要换」)。 */
|
|
125
132
|
installedVersion: installedVersion(),
|
|
126
|
-
/** 盘上那份的外壳指纹与跑着的对不上 = 新版本连外壳一起改了,那部分要等下次启动才生效。 */
|
|
127
133
|
runningShellHash,
|
|
128
|
-
installedShellHash:
|
|
134
|
+
installedShellHash: installedShellHash(),
|
|
129
135
|
shellChanged: (() => {
|
|
130
|
-
const installed =
|
|
136
|
+
const installed = installedShellHash()
|
|
131
137
|
return installed !== null && runningShellHash !== null && installed !== runningShellHash
|
|
132
138
|
})(),
|
|
133
139
|
profileNodeModules: profileNodeModules(),
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
|
|
5
5
|
*
|
|
6
6
|
* 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
|
|
7
|
-
*
|
|
7
|
+
* 手机一律吃宿主默认档。四条路由:read / copy / remove 转发宿主的 agentPreset 域;default
|
|
8
|
+
* 写宿主 settings 里存「默认档」的那一格(见 handleAgentPresetDefault)。
|
|
8
9
|
*
|
|
9
10
|
* 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
|
|
10
11
|
* 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
|
|
@@ -143,10 +144,73 @@ async function handleAgentPresetRemove(ctx, req, res) {
|
|
|
143
144
|
sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/** 宿主 settings 里存「默认预设」的那一格:桌面端「设为默认」写的就是它。 */
|
|
148
|
+
const PRESET_SETTINGS_NS = 'agent-preset-registry';
|
|
149
|
+
const PRESET_DEFAULT_FIELD = 'selectedDefault';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* POST /api/mobile/v1/agent-presets/default(token){ agentPreset } → { ok, agentPreset }
|
|
153
|
+
*
|
|
154
|
+
* 「哪一档是新会话的默认档」不是预设自己的属性:宿主注册表从 settings 的
|
|
155
|
+
* `agent-preset-registry.selectedDefault` 读(没写过就退回部署时那个 default),桌面端那颗
|
|
156
|
+
* 「设为默认」写的就是这一格。手机此前没有这个入口,新会话只能吃部署时那一档。
|
|
157
|
+
*
|
|
158
|
+
* 先按名单确认这一档真在,再写:写一个不存在的 id 不会报错,但之后每次新建会话都会被宿主
|
|
159
|
+
* 拒(agent-preset-not-found),那时用户已经不知道是自己哪一步弄坏的。
|
|
160
|
+
*/
|
|
161
|
+
async function handleAgentPresetDefault(ctx, req, res) {
|
|
162
|
+
const body = await readJsonBody(req, PRESET_MAX_BODY);
|
|
163
|
+
const id = normalizeId(body && body.agentPreset);
|
|
164
|
+
if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
|
|
165
|
+
|
|
166
|
+
const listed = unwrap(await callAgentPresets(ctx, 'list', {}));
|
|
167
|
+
if (listed.error) return sendJson(res, statusOf(listed.error.code), { ok: false, error: failure(listed.error) });
|
|
168
|
+
const rows = listed.value && Array.isArray(listed.value.presets) ? listed.value.presets : [];
|
|
169
|
+
const row = rows.find((item) => item && item.id === id.value);
|
|
170
|
+
if (!row) {
|
|
171
|
+
return sendJson(res, 404, {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {
|
|
174
|
+
code: 'agent-preset-not-found',
|
|
175
|
+
message: presetMessage('agent-preset-not-found'),
|
|
176
|
+
details: { agentPreset: id.value, available: rows.map((item) => item && item.id).filter(Boolean) },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// 已经是默认档就不再写:settings.mutate 一次就推进一次 revision,白写等于让别处正在
|
|
181
|
+
// 改设置的人撞上冲突。
|
|
182
|
+
if (row.isDefault === true) return sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
183
|
+
|
|
184
|
+
const settings = ctx.apiProxy && ctx.apiProxy.settings;
|
|
185
|
+
if (!settings || typeof settings.mutate !== 'function') {
|
|
186
|
+
return sendJson(res, 400, {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: { code: 'unsupported', message: '这台宿主没有设置写入接口,改不了默认预设' },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const { error } = unwrap(await settings.mutate({
|
|
192
|
+
rpcId: 'mobile',
|
|
193
|
+
method: 'settings.mutate',
|
|
194
|
+
payload: { ns: PRESET_SETTINGS_NS, ops: [{ op: 'set', path: [PRESET_DEFAULT_FIELD], value: id.value }] },
|
|
195
|
+
}));
|
|
196
|
+
if (error) {
|
|
197
|
+
const hostMessage = error && typeof error.message === 'string' ? error.message : '';
|
|
198
|
+
return sendJson(res, 400, {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: {
|
|
201
|
+
code: error && typeof error.code === 'string' && error.code ? error.code : 'internal',
|
|
202
|
+
message: hostMessage ? `默认预设没写进去:${hostMessage}` : '默认预设没写进去',
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
sendJson(res, 200, { ok: true, agentPreset: id.value });
|
|
207
|
+
}
|
|
208
|
+
|
|
146
209
|
module.exports = {
|
|
147
210
|
handleAgentPresetRead,
|
|
148
211
|
handleAgentPresetCopy,
|
|
149
212
|
handleAgentPresetRemove,
|
|
213
|
+
handleAgentPresetDefault,
|
|
150
214
|
// 下面这些是给守卫测试直接驱动的纯函数(与 references.js 同做法)
|
|
151
215
|
normalizeId,
|
|
152
216
|
normalizeName,
|
|
@@ -159,4 +223,6 @@ module.exports = {
|
|
|
159
223
|
PRESET_ID_MAX_CHARS,
|
|
160
224
|
PRESET_NAME_MAX_CHARS,
|
|
161
225
|
PRESET_BODY_MAX_CHARS,
|
|
226
|
+
PRESET_SETTINGS_NS,
|
|
227
|
+
PRESET_DEFAULT_FIELD,
|
|
162
228
|
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机端调试轨迹的落盘口(`POST /api/mobile/v1/debug/trace`)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有它:滑动手感出问题时,现场在手机上(偏移轨迹、每一行的实际高度、图片什么时候
|
|
7
|
+
* 才到),而能看这些东西的人在这台电脑上。让人把几万行日志抄进聊天不现实,所以 App 边录边把
|
|
8
|
+
* 一行行 JSONL 打到这里,插件落进 `<DSH_HOME>/mobile-debug/<capture>.jsonl`,维护者在这台
|
|
9
|
+
* 电脑上直接读文件。
|
|
10
|
+
*
|
|
11
|
+
* 三条边界,别放宽:
|
|
12
|
+
* - **不是任意文件写入**:文件名只由 `capture` 生成,且只认 `[A-Za-z0-9._-]`。构造一个别的
|
|
13
|
+
* capture 只会在这一个目录里多一份空文件,碰不到用户的任何东西;
|
|
14
|
+
* - **只追加**:一次请求往 `<capture>.jsonl` 末尾续一段,从不覆盖、从不删别的文件;
|
|
15
|
+
* - **封顶**:一份轨迹 8MB 之后停写(回 `capped: true`,App 收到就停手),目录里最多留 10 份,
|
|
16
|
+
* 第 11 份进来时删掉最旧那份。录音不会把盘写满。
|
|
17
|
+
*
|
|
18
|
+
* 与 `session/file`、`session/attachment` 那些读口方向相反:这条只写、写的位置固定,回给手机
|
|
19
|
+
* 的只有文件名与字节数,不回路径。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const fsp = require('node:fs/promises');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const { dshHome } = require('../shared/paths');
|
|
27
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
28
|
+
|
|
29
|
+
/** 一份轨迹的字节上限。真到这一步说明录得太久,该停了。 */
|
|
30
|
+
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
/** 目录里最多留几份(按 mtime,新的来、旧的走)。 */
|
|
32
|
+
const MAX_CAPTURES = 10;
|
|
33
|
+
/** 一次请求最多带多少行;App 那边按 40KB 切批,正常远小于这个数。 */
|
|
34
|
+
const MAX_LINES_PER_BATCH = 4000;
|
|
35
|
+
/** 一次请求最多多少字节(`readJsonBody` 本身还有 344KB 的封顶,这里再收一道)。 */
|
|
36
|
+
const MAX_BATCH_BYTES = 512 * 1024;
|
|
37
|
+
/** 轨迹名(也就是文件名):只认这几类字符,别的一律 400。 */
|
|
38
|
+
const CAPTURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
39
|
+
|
|
40
|
+
/** 轨迹目录。按调用时解析,测试可注入 `DSH_HOME`。 */
|
|
41
|
+
function traceDir() {
|
|
42
|
+
return path.join(dshHome(), 'mobile-debug');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 某一份轨迹的落盘路径(`capture` 已经过白名单校验)。 */
|
|
46
|
+
function capturePath(capture) {
|
|
47
|
+
return path.join(traceDir(), `${capture}.jsonl`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 目录里留最近 `MAX_CAPTURES` 份。
|
|
52
|
+
*
|
|
53
|
+
* 按 mtime 而不是文件名:轨迹名由 App 带时间戳生成,但同一秒的两份、或者用户手改过的名字都
|
|
54
|
+
* 不该让清理算错。读不动 stat 的(正在被删)直接跳过,清理失败不该让这次上传失败。
|
|
55
|
+
*/
|
|
56
|
+
async function pruneCaptures() {
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = await fsp.readdir(traceDir());
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const files = [];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
66
|
+
const full = path.join(traceDir(), name);
|
|
67
|
+
try {
|
|
68
|
+
const info = await fsp.stat(full);
|
|
69
|
+
if (info.isFile()) files.push({ name, full, mtime: info.mtimeMs });
|
|
70
|
+
} catch { /* 刚被删掉 / 读不动:跳过 */ }
|
|
71
|
+
}
|
|
72
|
+
if (files.length <= MAX_CAPTURES) return;
|
|
73
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
74
|
+
for (const file of files.slice(0, files.length - MAX_CAPTURES)) {
|
|
75
|
+
try {
|
|
76
|
+
await fsp.unlink(file.full);
|
|
77
|
+
} catch { /* 删不掉就留着,下次再说 */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 续写一段轨迹。
|
|
83
|
+
*
|
|
84
|
+
* `lines` 是已经序列化好的 JSON 行(App 侧拼的),这里只做「拼成一段文本 + 追加」。一行都
|
|
85
|
+
* 不合法的批次按空处理:回 ok、写 0 字节,别让手机那头因为一次形状不对就整段录不上。
|
|
86
|
+
*/
|
|
87
|
+
async function handleDebugTrace(_ctx, req, res) {
|
|
88
|
+
const body = await readJsonBody(req);
|
|
89
|
+
const capture = body && typeof body.capture === 'string' ? body.capture : '';
|
|
90
|
+
if (!CAPTURE_RE.test(capture)) {
|
|
91
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { capture },且只认字母数字与 ._-' } });
|
|
92
|
+
}
|
|
93
|
+
const raw = body && Array.isArray(body.lines) ? body.lines : null;
|
|
94
|
+
if (!raw) {
|
|
95
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { lines: string[] }' } });
|
|
96
|
+
}
|
|
97
|
+
if (raw.length > MAX_LINES_PER_BATCH) {
|
|
98
|
+
return sendJson(res, 413, { ok: false, error: { code: 'too-many-lines', message: `一次最多 ${MAX_LINES_PER_BATCH} 行` } });
|
|
99
|
+
}
|
|
100
|
+
const lines = raw.filter((line) => typeof line === 'string' && line.length > 0);
|
|
101
|
+
if (lines.length === 0) {
|
|
102
|
+
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: 0, lines: 0, capped: false });
|
|
103
|
+
}
|
|
104
|
+
const text = `${lines.join('\n')}\n`;
|
|
105
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
106
|
+
if (bytes > MAX_BATCH_BYTES) {
|
|
107
|
+
return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: `一次最多 ${MAX_BATCH_BYTES} 字节` } });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const file = capturePath(capture);
|
|
111
|
+
try {
|
|
112
|
+
await fsp.mkdir(traceDir(), { recursive: true });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `建目录失败:${error && error.message ? error.message : ''}` } });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let existing = 0;
|
|
118
|
+
let created = false;
|
|
119
|
+
try {
|
|
120
|
+
existing = (await fsp.stat(file)).size;
|
|
121
|
+
} catch {
|
|
122
|
+
created = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (existing >= MAX_CAPTURE_BYTES) {
|
|
126
|
+
// 已经写满:回 capped 让 App 停手。文件留着,人要看的正是前面那一段。
|
|
127
|
+
return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: existing, lines: 0, capped: true });
|
|
128
|
+
}
|
|
129
|
+
// 快写满时**按整行留**:切在字节中间会让最后一行变成半截 JSON,读的人只会看到一行坏数据。
|
|
130
|
+
const room = MAX_CAPTURE_BYTES - existing;
|
|
131
|
+
let payload = text;
|
|
132
|
+
if (bytes > room) {
|
|
133
|
+
const kept = [];
|
|
134
|
+
let used = 0;
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const size = Buffer.byteLength(line, 'utf8') + 1;
|
|
137
|
+
if (used + size > room) break;
|
|
138
|
+
kept.push(line);
|
|
139
|
+
used += size;
|
|
140
|
+
}
|
|
141
|
+
payload = kept.length > 0 ? `${kept.join('\n')}\n` : '';
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await fsp.appendFile(file, payload);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return sendJson(res, 500, { ok: false, error: { code: 'io', message: `写盘失败:${error && error.message ? error.message : ''}` } });
|
|
147
|
+
}
|
|
148
|
+
// 清理放在写完之后:这一份刚建出来的文件按 mtime 是最新的,留下来的正是它。
|
|
149
|
+
if (created) await pruneCaptures();
|
|
150
|
+
let total = existing;
|
|
151
|
+
try {
|
|
152
|
+
total = (await fsp.stat(file)).size;
|
|
153
|
+
} catch { /* 写成功但 stat 失败:回一个估计值,不影响 App */ }
|
|
154
|
+
sendJson(res, 200, {
|
|
155
|
+
ok: true,
|
|
156
|
+
file: `${capture}.jsonl`,
|
|
157
|
+
bytes: total,
|
|
158
|
+
lines: payload === text ? lines.length : (payload ? payload.split('\n').length - 1 : 0),
|
|
159
|
+
// 「这一段被切了」或「已经顶到上限」都算满:App 收到就停手,别继续往一条死路上发。
|
|
160
|
+
capped: payload !== text || total >= MAX_CAPTURE_BYTES,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 同步读一份轨迹(维护者在电脑上直接看文件;这里只给测试与将来的面板用)。 */
|
|
165
|
+
function readCapture(capture) {
|
|
166
|
+
if (!CAPTURE_RE.test(capture)) return null;
|
|
167
|
+
try {
|
|
168
|
+
return fs.readFileSync(capturePath(capture), 'utf8');
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
handleDebugTrace,
|
|
176
|
+
readCapture,
|
|
177
|
+
traceDir,
|
|
178
|
+
MAX_CAPTURE_BYTES,
|
|
179
|
+
MAX_CAPTURES,
|
|
180
|
+
};
|
|
@@ -45,6 +45,9 @@ function normalizeProfile(result) {
|
|
|
45
45
|
status: 'ready',
|
|
46
46
|
name: textOrNull(profile.name),
|
|
47
47
|
contact: textOrNull(profile.contact),
|
|
48
|
+
// 头像是资料的一部分(和名字、联系方式同级),手机拿去当 <Image> 的 uri 用;
|
|
49
|
+
// 账号 ID、token、attempt 仍然不出这一层。
|
|
50
|
+
avatarUrl: textOrNull(profile.avatarUrl),
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -15,9 +15,14 @@
|
|
|
15
15
|
* 2000 不是随手取的上限:本机 1420 个真实回合量下来,最长一轮 576 条消息、超过 500 的
|
|
16
16
|
* 有 3 个、超过 200 的有 18 个。它只是**上界**,宿主在跨过 `minTurns` 且遇到 turn/start
|
|
17
17
|
* 时就会停,所以正常情况下读的仍是那几十条;只有碰上超长回合才会真往上读到 2000。
|
|
18
|
+
*
|
|
19
|
+
* `minTurns` 取 1,**不是 6**:进对话要的是「尾部这一屏」,不是「六轮上下文」。
|
|
20
|
+
* 取 6 的时候,一轮 40 步的会话会把窗口撑到一百多条,手机侧要铺十几屏才追得到底 ——
|
|
21
|
+
* 用户看到的是「进来之后内容一路往下滑」。窗口大小是这条链的总闸:窗口小,
|
|
22
|
+
* 「一屏就是终态」才成立;更早的内容由「加载更早」按需补。
|
|
18
23
|
*/
|
|
19
24
|
const ENTRY_THIN_MAX_MESSAGES = 2000;
|
|
20
|
-
const ENTRY_THIN_MIN_TURNS =
|
|
25
|
+
const ENTRY_THIN_MIN_TURNS = 1;
|
|
21
26
|
|
|
22
27
|
/** 把入口窗口的取数参数写进宿主请求体(就地修改并返回,调用方只关心副作用)。 */
|
|
23
28
|
function entryWindowPayload(payload, limit) {
|