dsh-mobile-plugin 0.5.19 → 0.5.21
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/canvas-app.html +441 -0
- package/lib/client.js +358 -28
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/host-bridge/endpoints.js +7 -1
- package/lib/mobile/local-images.js +1 -1
- package/lib/mobile/magpie-web.js +425 -0
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +21 -6
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +12 -11
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +49 -0
- package/lib/routes/mobile-api.js +6 -0
- package/lib/session-cleanup.cjs +22 -7
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 桌面端画布的库读写(`/api/relay/canvas/*`):本机信任、明文。
|
|
5
|
+
*
|
|
6
|
+
* 手机那条走的是 `/api/mobile/v1/canvas/*`,字节过设备加密信封;桌面端这一页由插件在同一个
|
|
7
|
+
* 源上吐出,同机回环直接用明文 HTTP 就够了,不必也不该把设备凭据搬到桌面。两条路由读写的
|
|
8
|
+
* 是**同一份库**(`mobile/canvas-store.js`):清单复用它的两个 handler,字节直接落
|
|
9
|
+
* `objects/`(盘上本来就是明文)。
|
|
10
|
+
*
|
|
11
|
+
* 能力边界:客户端只给域、哈希、字节,给不了任意路径;`file-put` 只按内容哈希落库(去重),
|
|
12
|
+
* 与手机那条同一口径。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const fsp = require('node:fs/promises');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
const { pipeline } = require('node:stream/promises');
|
|
20
|
+
|
|
21
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
22
|
+
const store = require('../mobile/canvas-store');
|
|
23
|
+
|
|
24
|
+
const MAX_UPLOAD_BYTES = store.MAX_OBJECT_BYTES;
|
|
25
|
+
|
|
26
|
+
/** POST /api/relay/canvas/file {hash,mimeType} → 原始字节(明文)。 */
|
|
27
|
+
async function handleFileGet(req, res) {
|
|
28
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
29
|
+
const hash = body && typeof body.hash === 'string' ? body.hash : '';
|
|
30
|
+
const target = store.objectPathOf(hash);
|
|
31
|
+
if (!target) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'hash 形状不对' } });
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = await fsp.stat(target);
|
|
35
|
+
} catch {
|
|
36
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
|
|
37
|
+
}
|
|
38
|
+
if (!stat.isFile()) return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '这份字节不在电脑上了' } });
|
|
39
|
+
const mime = body && typeof body.mimeType === 'string' && body.mimeType ? body.mimeType.slice(0, 120) : 'application/octet-stream';
|
|
40
|
+
res.writeHead(200, { 'content-type': mime, 'content-length': String(stat.size) });
|
|
41
|
+
try {
|
|
42
|
+
await pipeline(fs.createReadStream(target), res);
|
|
43
|
+
} catch {
|
|
44
|
+
// 头已经发出去了,只能断开让对端按截断处理(与会话文件那条一致)。
|
|
45
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** POST /api/relay/canvas/file-put?name=… 请求体是原始字节 → { hash, bytes }。 */
|
|
50
|
+
async function handleFilePut(req, res) {
|
|
51
|
+
const temp = path.join(store.rootDir(), `tmp-upload-${crypto.randomBytes(8).toString('hex')}`);
|
|
52
|
+
await fsp.mkdir(path.dirname(temp), { recursive: true });
|
|
53
|
+
let total = 0;
|
|
54
|
+
try {
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
const out = fs.createWriteStream(temp);
|
|
57
|
+
let aborted = false;
|
|
58
|
+
req.on('data', (chunk) => {
|
|
59
|
+
total += chunk.length;
|
|
60
|
+
if (total > MAX_UPLOAD_BYTES && !aborted) {
|
|
61
|
+
aborted = true;
|
|
62
|
+
const error = Object.assign(new Error('这份文件太大,画布库装不下'), { status: 413 });
|
|
63
|
+
req.unpipe(out);
|
|
64
|
+
out.destroy();
|
|
65
|
+
reject(error);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
out.on('error', reject);
|
|
70
|
+
out.on('finish', resolve);
|
|
71
|
+
req.pipe(out);
|
|
72
|
+
});
|
|
73
|
+
if (total === 0) {
|
|
74
|
+
try { await fsp.unlink(temp); } catch { /* 已经不在了 */ }
|
|
75
|
+
return sendJson(res, 400, { ok: false, error: { code: 'empty', message: '请求体里没有字节' } });
|
|
76
|
+
}
|
|
77
|
+
const result = await store.storeUploadedFile(temp);
|
|
78
|
+
return sendJson(res, 200, { ok: true, hash: result.hash, bytes: result.bytes });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
try { await fsp.unlink(temp); } catch { /* 已经不在了 */ }
|
|
81
|
+
if (error && error.status === 413) {
|
|
82
|
+
return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: error.message } });
|
|
83
|
+
}
|
|
84
|
+
return sendJson(res, 500, { ok: false, error: { code: 'upload-failed', message: String((error && error.message) || error) } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 转发请求体的上限(模型接口带 base64 图的 POST 也不小;给足但别让整份进内存失控)。 */
|
|
89
|
+
const MAX_PROXY_BODY_BYTES = 128 * 1024 * 1024;
|
|
90
|
+
/** 转发时不该带给上游的请求头:跳数头、压缩协商(交给 fetch 自己解压)。 */
|
|
91
|
+
const STRIP_REQUEST_HEADERS = new Set([
|
|
92
|
+
'host', 'connection', 'accept-encoding', 'content-length', 'transfer-encoding',
|
|
93
|
+
'origin', 'referer', 'upgrade', 'keep-alive',
|
|
94
|
+
]);
|
|
95
|
+
/** 回给页面时不该带回来的响应头:长度与压缩由这一层重新决定(body 已解压)。 */
|
|
96
|
+
const STRIP_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'keep-alive']);
|
|
97
|
+
|
|
98
|
+
function readRawBody(req) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let total = 0;
|
|
102
|
+
req.on('data', (chunk) => {
|
|
103
|
+
total += chunk.length;
|
|
104
|
+
if (total > MAX_PROXY_BODY_BYTES) {
|
|
105
|
+
reject(Object.assign(new Error('请求体太大'), { status: 413 }));
|
|
106
|
+
req.destroy();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
chunks.push(chunk);
|
|
110
|
+
});
|
|
111
|
+
req.on('error', reject);
|
|
112
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* GET/POST /api/relay/canvas/net?u=<上游地址> —— 桌面端画布的本地转发。
|
|
118
|
+
*
|
|
119
|
+
* 和手机上那只原生环回服务同一件事:页面以为自己在请求同源地址,插件用 Node 的 fetch 去取
|
|
120
|
+
* 上游,响应边收边写回。同源请求没有预检;`accept-encoding` 剥掉让 fetch 自己解压,回写时
|
|
121
|
+
* 再把长度 / 压缩头去掉,body 交给页面按流读(SSE 那条才有用)。
|
|
122
|
+
*/
|
|
123
|
+
async function handleNet(req, res) {
|
|
124
|
+
let upstreamUrl;
|
|
125
|
+
try {
|
|
126
|
+
upstreamUrl = new URL(new URL(req.url, 'http://x').searchParams.get('u') || '');
|
|
127
|
+
} catch {
|
|
128
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少或非法的上游地址' } });
|
|
129
|
+
}
|
|
130
|
+
if (upstreamUrl.protocol !== 'http:' && upstreamUrl.protocol !== 'https:') {
|
|
131
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '只支持 http / https' } });
|
|
132
|
+
}
|
|
133
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
134
|
+
const headers = {};
|
|
135
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
136
|
+
if (STRIP_REQUEST_HEADERS.has(name.toLowerCase())) continue;
|
|
137
|
+
if (value === undefined) continue;
|
|
138
|
+
headers[name] = Array.isArray(value) ? value.join(', ') : value;
|
|
139
|
+
}
|
|
140
|
+
let body;
|
|
141
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
142
|
+
try {
|
|
143
|
+
body = await readRawBody(req);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return sendJson(res, error && error.status === 413 ? 413 : 400, { ok: false, error: { code: 'bad-request', message: String((error && error.message) || error) } });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let upstream;
|
|
149
|
+
try {
|
|
150
|
+
upstream = await fetch(upstreamUrl, { method, headers, body, redirect: 'follow' });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// 上游根本没连上:回一个带标记的 502,页面据此还原成网络错误(与手机那条一致)。
|
|
153
|
+
res.writeHead(502, { 'content-type': 'application/json', 'x-dsh-net-error': '1' });
|
|
154
|
+
return res.end(JSON.stringify({ ok: false, error: { code: 'network', message: String((error && error.message) || error) } }));
|
|
155
|
+
}
|
|
156
|
+
const outHeaders = {};
|
|
157
|
+
for (const [name, value] of upstream.headers.entries()) {
|
|
158
|
+
if (STRIP_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
|
|
159
|
+
outHeaders[name] = value;
|
|
160
|
+
}
|
|
161
|
+
res.writeHead(upstream.status, outHeaders);
|
|
162
|
+
if (!upstream.body) return res.end();
|
|
163
|
+
try {
|
|
164
|
+
const reader = upstream.body.getReader();
|
|
165
|
+
for (;;) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
if (!res.write(Buffer.from(value))) {
|
|
169
|
+
await new Promise((resolve) => res.once('drain', resolve));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
res.end();
|
|
173
|
+
} catch {
|
|
174
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 挂四条桌面端画布数据路由。`ctx` 原样转给 canvas-store 的 handler(它们目前不用它)。
|
|
180
|
+
*
|
|
181
|
+
* @param {{ registerRoute: Function, localOnly: Function, ctx?: object }} deps
|
|
182
|
+
*/
|
|
183
|
+
function registerCanvasDesktopRoutes({ registerRoute, localOnly, ctx = null }) {
|
|
184
|
+
const manifestGet = localOnly('GET', (req, res) => store.handleCanvasManifestGet(ctx, req, res));
|
|
185
|
+
const manifestSave = localOnly('POST', (req, res) => store.handleCanvasManifestSave(ctx, req, res));
|
|
186
|
+
const fileGet = localOnly('POST', (req, res) => handleFileGet(req, res));
|
|
187
|
+
const filePut = localOnly('POST', (req, res) => handleFilePut(req, res));
|
|
188
|
+
const net = localOnly(['GET', 'POST'], (req, res) => handleNet(req, res));
|
|
189
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas/manifest', handler: manifestGet });
|
|
190
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas/manifest/save', handler: manifestSave });
|
|
191
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas/file', handler: fileGet });
|
|
192
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas/file-put', handler: filePut });
|
|
193
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas/net', handler: net });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { registerCanvasDesktopRoutes };
|
|
@@ -53,6 +53,8 @@ const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobi
|
|
|
53
53
|
const { recordSessionRead } = require('../mobile/session-last-turn.js');
|
|
54
54
|
// 收件目录的占用与清理:和移动端那条路由是同一份实现(`lib/mobile/inbox-clean.js`)。
|
|
55
55
|
const { scanInbox, clearInbox, usageView, setAutoClear } = require('../mobile/inbox-clean.js');
|
|
56
|
+
// 桌面端画布的库读写(本机明文,与手机共用同一份 `mobile/canvas-store.js`)。
|
|
57
|
+
const { registerCanvasDesktopRoutes } = require('./canvas-desktop.js');
|
|
56
58
|
|
|
57
59
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
58
60
|
const MAX_DESKTOP_READ_IDS = 200;
|
|
@@ -107,6 +109,12 @@ function createWebAccessLocalOnly({ localOnly, trustedLocalUi, webAccessWindow,
|
|
|
107
109
|
* 版本的页面。改版式只有一条路:`React/` 里重建 → 提交产物 → 发新版本 → 完整重启 DSH。
|
|
108
110
|
*/
|
|
109
111
|
const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
|
|
112
|
+
/**
|
|
113
|
+
* 桌面端画布页:与 `lib/panel/index.html` 同一个做法 —— 整页内联 HTML 整份吐给浏览器。
|
|
114
|
+
* 产物由 App 的 `scripts/build-canvas-page.mjs` 从同一份 `vendor/canvas/` 顺带生成过来,
|
|
115
|
+
* 源只有一份;这里只读不构建。
|
|
116
|
+
*/
|
|
117
|
+
const CANVAS_FILE = path.join(__dirname, '..', 'canvas-app.html');
|
|
110
118
|
|
|
111
119
|
/** 插件包根(`<pkg>/lib/routes` 往上两级):诊断时读它的 `package.json` 与 `React/`。 */
|
|
112
120
|
const PACKAGE_DIR = path.join(__dirname, '..', '..');
|
|
@@ -129,6 +137,7 @@ function generationVersion() {
|
|
|
129
137
|
|
|
130
138
|
/** 按 mtime 缓存:文件被换掉之后内容变了,缓存自然失效,不必手工清。 */
|
|
131
139
|
let panelCache = { file: '', mtime: -1, html: '' };
|
|
140
|
+
let canvasCache = { file: '', mtime: -1, html: '' };
|
|
132
141
|
|
|
133
142
|
function panelHtml() {
|
|
134
143
|
try {
|
|
@@ -142,6 +151,18 @@ function panelHtml() {
|
|
|
142
151
|
}
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
function canvasHtml() {
|
|
155
|
+
try {
|
|
156
|
+
const stat = fs.statSync(CANVAS_FILE);
|
|
157
|
+
if (canvasCache.file === CANVAS_FILE && canvasCache.mtime === stat.mtimeMs) return canvasCache.html;
|
|
158
|
+
const html = fs.readFileSync(CANVAS_FILE, 'utf8');
|
|
159
|
+
canvasCache = { file: CANVAS_FILE, mtime: stat.mtimeMs, html };
|
|
160
|
+
return html;
|
|
161
|
+
} catch {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
/**
|
|
146
167
|
* 面板读不到时,说清楚是哪一种读不到。
|
|
147
168
|
*
|
|
@@ -488,6 +509,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
488
509
|
// /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
|
|
489
510
|
// /api/relay/panel/setup 强制走引导(想再看一遍流程时用)
|
|
490
511
|
// /api/relay/panel/settings 强制进主页(演示 / 排查时跳过引导)
|
|
512
|
+
// /api/relay/panel/debug 强制进调试页(桌面端那颗手机图标点开的就是它)
|
|
491
513
|
// 整页 HTML 不需要 token,但这几条仍然走 localOnly —— 它读的是本机运维界面,
|
|
492
514
|
// 而局域网与中转那两条链路本来就不该看到它(它们带 dsh_pair/dsh_lan 凭据,会被挡掉)。
|
|
493
515
|
const servePanel = (res) => {
|
|
@@ -506,6 +528,32 @@ function registerControlPlaneRoutes(deps) {
|
|
|
506
528
|
registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
507
529
|
registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
508
530
|
registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
531
|
+
// 调试页与上面几条吐同一份 HTML,分流同样在 React 侧(`React/src/Root.tsx` 认 `/debug`)。
|
|
532
|
+
//
|
|
533
|
+
// 为什么它也放开 `allowDocumentNavigation`:这一页唯一的入口是桌面端那颗手机图标弹的窗
|
|
534
|
+
// (同源 iframe),而社区桌面壳会把接住的链接原地打开、从自定义协议页跳过来必带
|
|
535
|
+
// `sec-fetch-site: cross-site` —— 与面板页同一条理由。页面本身仍是只读 HTML,
|
|
536
|
+
// 里面的 fetch 照样被 localOnly 与两小时窗口挡着。
|
|
537
|
+
registerRoute({ kind: 'exact', path: '/api/relay/panel/debug', handler: localOnly('GET', async (_req, res) => servePanel(res), { allowDocumentNavigation: true }) })
|
|
538
|
+
// 桌面端画布页:整页吐同一份内联 HTML,并在 `</head>` 前补一句「这是桌面端」的标记 ——
|
|
539
|
+
// 画布据此把路由从 BrowserRouter 换成 HashRouter(它被挂在 `/api/relay/canvas` 这种非根
|
|
540
|
+
// 路径下,BrowserRouter 的 `/` 匹配不上会 404)。手机 WebView 那份不带这个标记,一个字不改。
|
|
541
|
+
const serveCanvas = (res) => {
|
|
542
|
+
const html = canvasHtml();
|
|
543
|
+
if (!html) {
|
|
544
|
+
process.stderr.write(`[dsh-mobile-plugin] canvas page unavailable ${CANVAS_FILE}\n`);
|
|
545
|
+
return sendJson(res, 500, { ok: false, error: { code: 'canvas_missing', message: '画布页产物不在:跑 node scripts/build-canvas-page.mjs 重新生成' } });
|
|
546
|
+
}
|
|
547
|
+
const withFlag = html.replace(
|
|
548
|
+
'</head>',
|
|
549
|
+
'<script>window.__dshCanvasDesktop=true;window.__dshCanvasHostBase=location.origin;</script></head>',
|
|
550
|
+
);
|
|
551
|
+
return sendHtml(res, 200, withFlag);
|
|
552
|
+
};
|
|
553
|
+
registerRoute({ kind: 'exact', path: '/api/relay/canvas', handler: localOnly('GET', async (_req, res) => serveCanvas(res), { allowDocumentNavigation: true }) })
|
|
554
|
+
// 桌面端画布的库读写:本机信任、明文,与手机 `/api/mobile/v1/canvas/*` 共用同一份存储
|
|
555
|
+
// (`mobile/canvas-store.js`)。桌面这一页是同源的,字节没必要再过设备加密信封。
|
|
556
|
+
registerCanvasDesktopRoutes({ registerRoute, localOnly, ctx })
|
|
509
557
|
// 桌面端工作区视图(侧栏展开态)的回传/读取。桌面端只把展开态记在浏览器
|
|
510
558
|
// localStorage 里,宿主进程读不到,只能由页面内的客户端半边(lib/client.js)
|
|
511
559
|
// 每 2s 变了就 POST 到这里;App 再经 /api/mobile/v1/desktop-view 取走。
|
|
@@ -1036,6 +1084,7 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
1036
1084
|
'/api/relay/panel',
|
|
1037
1085
|
'/api/relay/panel/setup',
|
|
1038
1086
|
'/api/relay/panel/settings',
|
|
1087
|
+
'/api/relay/panel/debug',
|
|
1039
1088
|
'/api/relay/faults',
|
|
1040
1089
|
// 配对二维码:里面是能派生整条链路密钥的一次性票据,所以它只在两小时窗口开着时给网页看
|
|
1041
1090
|
// ——窗口由本机设置页亲手开,服务器部署没有「本机屏幕」可看,这条是它唯一的出口。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -34,6 +34,7 @@ const hostSettings = require('../mobile/host-settings.js');
|
|
|
34
34
|
const channels = require('../mobile/channels.js');
|
|
35
35
|
const modelCaps = require('../mobile/model-caps.js');
|
|
36
36
|
const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
37
|
+
const magpieWeb = require('../mobile/magpie-web.js');
|
|
37
38
|
const skills = require('../mobile/skills.js');
|
|
38
39
|
const mobileCredentials = require('../mobile/credentials.js');
|
|
39
40
|
const subagents = require('../mobile/subagents.js');
|
|
@@ -426,6 +427,11 @@ function registerMobileRoutes(deps) {
|
|
|
426
427
|
route('/api/mobile/v1/host-settings', hostSettings.handleHostSettings)
|
|
427
428
|
// DeepSeek 账号只读投影:账户资料、钱包余额与官方用量/充值链接;不开放登录操作或凭据。
|
|
428
429
|
route('/api/mobile/v1/deepseek-account', deepSeekAccount.handleDeepSeekAccount)
|
|
430
|
+
// magpie 自带的网页版(Agents / Providers / Routing / Usage / Settings 全在里面)。
|
|
431
|
+
// 手机那边把这一页整个嵌进来,不再自己画一套:它本来就是给手机做的,页面里写着
|
|
432
|
+
// 「加到主屏后作为独立 App 打开」。网页版是独立进程,没在跑就在这里替用户拉起来。
|
|
433
|
+
// key 是凭据,所以走 guarded,与 `host-web` 那条同一个口径。
|
|
434
|
+
route('/api/mobile/v1/magpie/web', magpieWeb.handleMagpieWeb)
|
|
429
435
|
// 改设置:与桌面端同一个 settings.mutate(按路径寻址的 op + expectedRevision 乐观并发)。
|
|
430
436
|
route('/api/mobile/v1/host-settings/mutate', hostSettings.handleHostSettingsMutate)
|
|
431
437
|
route('/api/mobile/v1/llm/providers', hostSettings.handleLlmProviders)
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -316,7 +316,7 @@ function restoreQueuedArchivedClearIds(ctx) {
|
|
|
316
316
|
return queued
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
async function pruneOrphanedArchivedSessions(ctx) {
|
|
319
|
+
async function pruneOrphanedArchivedSessions(ctx, skip) {
|
|
320
320
|
const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
|
|
321
321
|
if (typeof workspaceList !== 'function') return 0
|
|
322
322
|
const rpcId = 'dsh-mobile-plugin-archive-prune'
|
|
@@ -325,7 +325,8 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
325
325
|
const archived = value && Array.isArray(value.archivedSessionIds)
|
|
326
326
|
? value.archivedSessionIds.filter((id) => typeof id === 'string' && id)
|
|
327
327
|
: []
|
|
328
|
-
const
|
|
328
|
+
const skipped = skip instanceof Set ? skip : new Set(Array.isArray(skip) ? skip : [])
|
|
329
|
+
const orphans = (await archivedIdsWithoutLogs(archived)).filter((id) => !skipped.has(id))
|
|
329
330
|
if (orphans.length === 0) return 0
|
|
330
331
|
const failed = await forgetArchivedSessions(ctx, orphans, rpcId)
|
|
331
332
|
return orphans.length - failed.length
|
|
@@ -339,10 +340,12 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
339
340
|
* 就是一次未捕获的 ENOENT,宿主直接退出。
|
|
340
341
|
* - **擦归档集合**:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件增量」,
|
|
341
342
|
* 进程活着的时候擦集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
343
|
+
* 那些其实已经没有日志、本来被归档藏着的行放回正常列表。所以只在启动时擦,而且**本次
|
|
344
|
+
* 刚删掉日志的 id 这一轮也先不擦**:宿主启动和删日志是并发的,桌面端的快照完全可能
|
|
345
|
+
* 在删完之前就取走、把这些行带在身上;这时擦集合照样会把它们放回正常列表(一次重启
|
|
346
|
+
* 之后它们又出现,再重启才消失)。留到下一次启动再擦,那时它们早已没有日志,任何新
|
|
347
|
+
* 快照里都不会再有这些行。留在集合里的死 id 依然不显示:「已归档」取的是会话列表与
|
|
348
|
+
* 归档集合的交集,没有日志就没有那一行。
|
|
346
349
|
*
|
|
347
350
|
* 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
|
|
348
351
|
*/
|
|
@@ -352,9 +355,21 @@ const PRUNE_RETRY_MS = 500
|
|
|
352
355
|
async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
353
356
|
if (globalThis[PRUNE_ONCE]) return
|
|
354
357
|
globalThis[PRUNE_ONCE] = true
|
|
358
|
+
// 本次启动前盘上就已经没有数据的 id:它们早就不在任何新快照里,这一轮可以直接擦。
|
|
359
|
+
const alreadyDataLess = new Set(sessionIdsWithoutAnyData(readPendingArchivedClearIds()))
|
|
360
|
+
let skip = []
|
|
355
361
|
// 删日志不碰宿主,先做:越早越好,别等宿主把 workspace 服务挂齐(那时桌面端可能已经连上)。
|
|
356
362
|
try {
|
|
357
363
|
const { deleted, kept } = await deleteQueuedArchivedSessions(log, ctx)
|
|
364
|
+
// 本次刚删掉日志的 id,这一轮先别擦归档集合,留到下一次启动再擦。
|
|
365
|
+
//
|
|
366
|
+
// 桌面端的会话列表是「启动时拉的一份快照 + 之后的事件增量」,宿主没有删除会话的接口,
|
|
367
|
+
// 插件在磁盘上删日志它看不见 —— 它快照里还留着这些行,本来靠「已归档」藏着。这时一擦
|
|
368
|
+
// 集合,宿主就广播「归档集合变了」,桌面端把这些其实已经没有日志的行放回正常列表,用户
|
|
369
|
+
// 看到的就是「重启一次它们又回来了」。留到下一次启动:那时它们早就没有日志,任何新快照
|
|
370
|
+
// 里都不会再有这些行,擦集合的广播也就放不出东西来。代价是这些空 id 在归档集合里多留
|
|
371
|
+
// 一次启动(「已归档」取会话列表与归档集合的交集,没有日志就看不见它)。
|
|
372
|
+
skip = deleted.filter((id) => !alreadyDataLess.has(id))
|
|
358
373
|
if (deleted.length > 0) log?.(`archived clear queue: deleted ${deleted.length} session logs`)
|
|
359
374
|
if (kept.length > 0) log?.(`archived clear queue: ${kept.length} session logs kept for the next start`)
|
|
360
375
|
} catch (error) {
|
|
@@ -365,7 +380,7 @@ async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
|
365
380
|
// 的死 id 一直没人清(实测宿主重启过一次,315 个没有日志的 id 原样挂着)。
|
|
366
381
|
for (let attempt = 1; attempt <= PRUNE_ATTEMPTS; attempt += 1) {
|
|
367
382
|
try {
|
|
368
|
-
const pruned = await pruneOrphanedArchivedSessions(ctx)
|
|
383
|
+
const pruned = await pruneOrphanedArchivedSessions(ctx, skip)
|
|
369
384
|
if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
|
|
370
385
|
return
|
|
371
386
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.21",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|