dsh-mobile-plugin 0.5.18 → 0.5.20
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/config/agent-presets/standard/agent.cordis.yml +9 -0
- package/host.js +26 -4
- package/lib/canvas-app.html +441 -0
- package/lib/client.js +40 -1
- package/lib/host-compat.cjs +51 -4
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/cli/config-prefs.js +4 -21
- package/lib/mobile/cli/mobile.js +36 -4
- 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/phone/tools.js +94 -0
- package/lib/mobile/questions.js +9 -3
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +29 -29
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +8 -8
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +40 -0
- package/lib/routes/mobile-api.js +6 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -508,7 +508,10 @@ window.__ModuleLoader__.load({
|
|
|
508
508
|
nav: '移动端',
|
|
509
509
|
heading: '移动端',
|
|
510
510
|
cardPanel: '配置页面',
|
|
511
|
+
cardCanvas: '画布',
|
|
511
512
|
openPanel: '打开配置页面',
|
|
513
|
+
openCanvas: '打开画布',
|
|
514
|
+
canvasHint: '在浏览器里打开无限画布;它的库与手机共用同一份。',
|
|
512
515
|
openHint: '配对、网络配置与插件更新都在这个页面里。',
|
|
513
516
|
panelPending: '正在读取页面地址…',
|
|
514
517
|
panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
|
|
@@ -566,7 +569,10 @@ window.__ModuleLoader__.load({
|
|
|
566
569
|
nav: 'Mobile',
|
|
567
570
|
heading: 'Mobile',
|
|
568
571
|
cardPanel: 'Configuration page',
|
|
572
|
+
cardCanvas: 'Canvas',
|
|
569
573
|
openPanel: 'Open the configuration page',
|
|
574
|
+
openCanvas: 'Open the canvas',
|
|
575
|
+
canvasHint: 'Open the infinite canvas in your browser; it shares one library with your phone.',
|
|
570
576
|
openHint: 'Pairing, network settings and plugin updates all live on this page.',
|
|
571
577
|
panelPending: 'Reading the page address…',
|
|
572
578
|
panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
|
|
@@ -794,6 +800,23 @@ window.__ModuleLoader__.load({
|
|
|
794
800
|
}
|
|
795
801
|
}
|
|
796
802
|
|
|
803
|
+
/**
|
|
804
|
+
* 画布页在**这个页面自己**的同源地址上(与面板同一台宿主、同一个前缀)。
|
|
805
|
+
*
|
|
806
|
+
* 桌面用户在 DSH 里点「打开画布」,就是跳到浏览器打开这一页;页面由插件托管,库与手机共用。
|
|
807
|
+
*/
|
|
808
|
+
function sameOriginCanvasUrl() {
|
|
809
|
+
try {
|
|
810
|
+
const base = typeof document !== 'undefined' && document.baseURI
|
|
811
|
+
? document.baseURI
|
|
812
|
+
: (typeof location !== 'undefined' ? location.href : '');
|
|
813
|
+
if (!base) return '';
|
|
814
|
+
return new URL('api/relay/canvas', base).href;
|
|
815
|
+
} catch {
|
|
816
|
+
return '';
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
797
820
|
/**
|
|
798
821
|
* 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
|
|
799
822
|
*
|
|
@@ -1557,6 +1580,10 @@ window.__ModuleLoader__.load({
|
|
|
1557
1580
|
}
|
|
1558
1581
|
|
|
1559
1582
|
const hint = url === null ? t('panelPending') : url === '' ? t('panelMissing') : t('openHint');
|
|
1583
|
+
// 画布页与面板同源同宿主,地址从面板地址换尾巴;拿不到面板地址就按页面自身推。
|
|
1584
|
+
const canvasUrl = typeof url === 'string' && url
|
|
1585
|
+
? url.replace(/\/panel(?:\/(?:setup|settings))?$/, '/canvas')
|
|
1586
|
+
: sameOriginCanvasUrl();
|
|
1560
1587
|
return h('div', { className: 'dshm_root' },
|
|
1561
1588
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
1562
1589
|
h('div', { className: 'dshm_card' },
|
|
@@ -1574,7 +1601,19 @@ window.__ModuleLoader__.load({
|
|
|
1574
1601
|
failure
|
|
1575
1602
|
? h('p', { className: 'dshm_error' },
|
|
1576
1603
|
`${failure.kind === 'load' ? t('loadFailed') : t('openFailed')}:${failure.message}`)
|
|
1577
|
-
: null)
|
|
1604
|
+
: null),
|
|
1605
|
+
// 画布单独一张薄卡:点「打开画布」跳到浏览器打开插件托管的画布页(库与手机共用),
|
|
1606
|
+
// 不嵌在桌面端里。跟面板同源同宿主,地址从面板地址换尾巴拿。
|
|
1607
|
+
h('div', { className: 'dshm_card' },
|
|
1608
|
+
h('h3', { className: 'dshm_cardTitle' }, t('cardCanvas')),
|
|
1609
|
+
h('p', { className: 'dshm_note' }, t('canvasHint')),
|
|
1610
|
+
h('div', { className: 'dshm_actions' },
|
|
1611
|
+
h('a', {
|
|
1612
|
+
className: 'dshm_btn dshm_btnPrimary',
|
|
1613
|
+
href: canvasUrl || undefined,
|
|
1614
|
+
target: '_blank',
|
|
1615
|
+
rel: 'noreferrer',
|
|
1616
|
+
}, t('openCanvas')))));
|
|
1578
1617
|
}
|
|
1579
1618
|
|
|
1580
1619
|
return {
|
package/lib/host-compat.cjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
|
|
9
9
|
* 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
|
|
10
10
|
*
|
|
11
|
+
* 还有另一种形状:入口本身没了(0.2.2 把 `list_agents` 并进主包,子路径随之下线)。
|
|
12
|
+
* 那一条换成哪一份都没用,只能整行从预设副本里拿掉 —— 见 `dropRows`。
|
|
13
|
+
*
|
|
11
14
|
* 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
12
15
|
*/
|
|
13
16
|
|
|
@@ -76,21 +79,64 @@ function rewriteToolNames(source, targets) {
|
|
|
76
79
|
* @param {string} presetDir 包内 `config/agent-presets`
|
|
77
80
|
* @param {string} outDir 宿主可写的目标目录
|
|
78
81
|
* @param {Record<string, string>} targets 行 id → 入口绝对路径
|
|
82
|
+
* @param {{ dropIds?: string[] }} [options] 这一代运行时里不再有独立入口的行
|
|
79
83
|
* @returns {string} 真正该交给 `agent-presets` roots 的目录
|
|
80
84
|
*/
|
|
81
|
-
function syncPresetDir(presetDir, outDir, targets) {
|
|
85
|
+
function syncPresetDir(presetDir, outDir, targets, options = {}) {
|
|
82
86
|
fs.rmSync(outDir, { recursive: true, force: true })
|
|
83
87
|
fs.cpSync(presetDir, outDir, { recursive: true })
|
|
84
|
-
|
|
88
|
+
const dropIds = options.dropIds ?? []
|
|
89
|
+
if (Object.keys(targets).length === 0 && dropIds.length === 0) return outDir
|
|
85
90
|
for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
|
|
86
91
|
if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
|
|
87
92
|
const file = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
88
|
-
const
|
|
89
|
-
|
|
93
|
+
const original = fs.readFileSync(file, 'utf8')
|
|
94
|
+
const trimmed = dropRows(original, dropIds)
|
|
95
|
+
const { text, changed } = rewriteToolNames(trimmed, targets)
|
|
96
|
+
if (changed.length > 0 || trimmed !== original) fs.writeFileSync(file, text)
|
|
90
97
|
}
|
|
91
98
|
return outDir
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 去掉预设里指定的整行(含它下面缩进更深的配置)。
|
|
103
|
+
*
|
|
104
|
+
* 给「这一代运行时里已经没有这个独立入口」的行用:留着它,Loader 会按裸名去 import
|
|
105
|
+
* 一个不存在的子路径,那一行起不来;能力本身可能已经并进主包,所以不是缺工具,
|
|
106
|
+
* 只是多了一条注定失败的行。
|
|
107
|
+
*
|
|
108
|
+
* @param {string} source 预设文件内容
|
|
109
|
+
* @param {string[]} ids 要整行删掉的入口 id
|
|
110
|
+
* @returns {string} 删完的文本(没命中就原样返回)
|
|
111
|
+
*/
|
|
112
|
+
function dropRows(source, ids) {
|
|
113
|
+
if (ids.length === 0) return source
|
|
114
|
+
const targets = new Set(ids)
|
|
115
|
+
const lines = source.split('\n')
|
|
116
|
+
const indentOf = (line) => line.length - line.trimStart().length
|
|
117
|
+
const kept = []
|
|
118
|
+
let dropping = false
|
|
119
|
+
let dropIndent = 0
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const id = ROW_ID.exec(line)
|
|
122
|
+
if (dropping) {
|
|
123
|
+
// 同级的下一行或更浅的一行就是这条的边界;更深的仍是它的配置。
|
|
124
|
+
if (id || (line.trim() !== '' && indentOf(line) <= dropIndent)) dropping = false
|
|
125
|
+
}
|
|
126
|
+
if (id && targets.has(id[2])) {
|
|
127
|
+
dropIndent = indentOf(line)
|
|
128
|
+
// 紧挨着这一行上方的注释块是在说它,跟着一起删;留下会说反话的说明。
|
|
129
|
+
while (kept.length > 0 && kept[kept.length - 1].trim().startsWith('#') && indentOf(kept[kept.length - 1]) >= dropIndent) {
|
|
130
|
+
kept.pop()
|
|
131
|
+
}
|
|
132
|
+
dropping = true
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (!dropping) kept.push(line)
|
|
136
|
+
}
|
|
137
|
+
return kept.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
94
140
|
/**
|
|
95
141
|
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
96
142
|
*
|
|
@@ -115,6 +161,7 @@ module.exports = {
|
|
|
115
161
|
TOOL_ROWS,
|
|
116
162
|
DIRECTORY_PICKER_ROW,
|
|
117
163
|
rewriteToolNames,
|
|
164
|
+
dropRows,
|
|
118
165
|
syncPresetDir,
|
|
119
166
|
pinInsertedRows,
|
|
120
167
|
}
|
|
@@ -25,6 +25,10 @@ const path = require('node:path');
|
|
|
25
25
|
|
|
26
26
|
const { dshHome } = require('../shared/paths.js');
|
|
27
27
|
const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
|
|
28
|
+
const { imageSizeOf, sniffMediaType } = require('./local-images.js');
|
|
29
|
+
|
|
30
|
+
/** 量宽高只需要文件头;JPEG 的 SOF 段可能在几百字节之后,64KB 覆盖绝大多数实拍图。 */
|
|
31
|
+
const HEAD_BYTES = 64 * 1024;
|
|
28
32
|
|
|
29
33
|
/** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
|
|
30
34
|
const MAX_IDS_PER_SESSION = 4096;
|
|
@@ -36,29 +40,52 @@ const TTL_MS = 30 * 60 * 1000;
|
|
|
36
40
|
/** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
|
|
37
41
|
const SESSIONS = new Map();
|
|
38
42
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 宿主附件编号的形状:`sha256:<64 位小写十六进制>`(见宿主 `attachment-local` 的 `ID_PATTERN`)。
|
|
45
|
+
*
|
|
46
|
+
* **前缀不能省**:宿主发的编号带 `sha256:`,磁盘上的文件名却是去掉前缀的那段哈希。以前这里
|
|
47
|
+
* 只认裸哈希,于是每个真实编号都被当成「不是我们发过的」,直读这条路一次都没生效过。
|
|
48
|
+
*/
|
|
49
|
+
const OBJECT_ID = /^sha256:([0-9a-f]{64})$/;
|
|
41
50
|
|
|
42
51
|
function nowMs() {
|
|
43
52
|
return Date.now();
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/** 从附件编号里取出裸哈希(磁盘文件名那段);形状不对回 null。 */
|
|
56
|
+
function hexOf(attachmentId) {
|
|
57
|
+
if (typeof attachmentId !== 'string') return null;
|
|
58
|
+
const match = OBJECT_ID.exec(attachmentId);
|
|
59
|
+
return match ? match[1] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
/**
|
|
47
63
|
* 附件 id → 宿主附件库里的绝对路径。
|
|
48
64
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
65
|
+
* 宿主 `normalizedImagePath` 的落盘形状是 `objects/<前两位>/<完整 64 位哈希>` —— 子目录取前两位、
|
|
66
|
+
* **文件名仍是完整哈希**(不是去掉前两位的那段)。曾经拼成 `objects/<前两位>/<其余>`,于是每一份
|
|
67
|
+
* 真实附件都找不到、直读与现量元数据全部落空(取图恒 404)。
|
|
68
|
+
*
|
|
69
|
+
* 形状不对(不是 `sha256:<64 位小写十六进制>`)直接回 null:这条路径要拼进
|
|
70
|
+
* `createReadStream`,校验放在这里、而不是靠调用方记得先校验。
|
|
51
71
|
*/
|
|
52
72
|
function objectPathOf(attachmentId) {
|
|
53
|
-
|
|
73
|
+
const hex = hexOf(attachmentId);
|
|
74
|
+
if (!hex) return null;
|
|
54
75
|
const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
|
|
55
|
-
return path.join(root,
|
|
76
|
+
return path.join(root, hex.slice(0, 2), hex);
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。
|
|
81
|
+
*
|
|
82
|
+
* 顺手把**完整引用**(媒体类型 / 字节数 / 宽高 / 名字)也记下来。回退去问宿主时,宿主新版
|
|
83
|
+
* 的取附件接口要的是整份 `ImageAttachmentRef`,只有编号它不收。取字节本身宿主只用到编号,
|
|
84
|
+
* 但请求形状这五个字段都必填。
|
|
85
|
+
*/
|
|
86
|
+
function remember(sessionId, attachmentId, ref) {
|
|
60
87
|
if (typeof sessionId !== 'string' || !sessionId) return;
|
|
61
|
-
if (
|
|
88
|
+
if (!hexOf(attachmentId)) return;
|
|
62
89
|
const at = nowMs();
|
|
63
90
|
let slot = SESSIONS.get(sessionId);
|
|
64
91
|
if (!slot) {
|
|
@@ -72,11 +99,16 @@ function remember(sessionId, attachmentId, mediaType) {
|
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
101
|
slot.at = at;
|
|
75
|
-
|
|
76
|
-
slot.entries.set(attachmentId, {
|
|
102
|
+
const entry = {
|
|
77
103
|
at,
|
|
78
|
-
mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
|
|
79
|
-
}
|
|
104
|
+
mediaType: ref && typeof ref.mediaType === 'string' && ref.mediaType ? ref.mediaType : 'application/octet-stream',
|
|
105
|
+
};
|
|
106
|
+
if (ref && Number.isFinite(ref.bytes)) entry.bytes = ref.bytes;
|
|
107
|
+
if (ref && Number.isFinite(ref.width)) entry.width = ref.width;
|
|
108
|
+
if (ref && Number.isFinite(ref.height)) entry.height = ref.height;
|
|
109
|
+
if (ref && typeof ref.name === 'string' && ref.name) entry.name = ref.name;
|
|
110
|
+
slot.entries.delete(attachmentId);
|
|
111
|
+
slot.entries.set(attachmentId, entry);
|
|
80
112
|
while (slot.entries.size > MAX_IDS_PER_SESSION) {
|
|
81
113
|
const oldest = slot.entries.keys().next().value;
|
|
82
114
|
if (oldest === undefined) break;
|
|
@@ -84,7 +116,9 @@ function remember(sessionId, attachmentId, mediaType) {
|
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* 这个 id 是不是我们为这条会话下发过的;是就带上当时的完整引用(少 `attachmentId` 与时间戳)。
|
|
121
|
+
*/
|
|
88
122
|
function lookup(sessionId, attachmentId, now = nowMs()) {
|
|
89
123
|
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
90
124
|
if (typeof attachmentId !== 'string' || !attachmentId) return null;
|
|
@@ -100,7 +134,8 @@ function lookup(sessionId, attachmentId, now = nowMs()) {
|
|
|
100
134
|
slot.entries.delete(attachmentId);
|
|
101
135
|
return null;
|
|
102
136
|
}
|
|
103
|
-
|
|
137
|
+
const { at: _at, ...ref } = hit;
|
|
138
|
+
return ref;
|
|
104
139
|
}
|
|
105
140
|
|
|
106
141
|
/** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
|
|
@@ -139,6 +174,66 @@ async function streamIfRemembered(req, res, sessionId, attachmentId) {
|
|
|
139
174
|
return true;
|
|
140
175
|
}
|
|
141
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 从盘上这份附件对象量出它的真实元数据(类型 / 字节数 / 宽高)。
|
|
179
|
+
*
|
|
180
|
+
* 宿主取附件时会读同一份对象文件、用 sharp 探一遍,再跟我们递过去的引用**逐字段比对**:
|
|
181
|
+
* 类型、字节数、宽高任一对不上就回 `Stored attachment metadata does not match its reference.`
|
|
182
|
+
* (400)。所以引用不能瞎填占位值,必须和盘上那份字节对上。
|
|
183
|
+
*
|
|
184
|
+
* 附件存的是**归一化后**的字节(携带 EXIF 的图会被重编码、朝向烘进像素),因此文件头读出来的
|
|
185
|
+
* 就是宿主 `probeImage` 会量到的同一组数;`imageSizeOf` 本就按 EXIF 5–8 对调,口径一致。
|
|
186
|
+
* 读不出(不是位图 / 文件不在)回 null,由调用方决定怎么回。
|
|
187
|
+
*/
|
|
188
|
+
function probeObject(attachmentId) {
|
|
189
|
+
const target = objectPathOf(attachmentId);
|
|
190
|
+
if (!target) return null;
|
|
191
|
+
let stat;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.statSync(target);
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
198
|
+
let fd = null;
|
|
199
|
+
let head;
|
|
200
|
+
try {
|
|
201
|
+
fd = fs.openSync(target, 'r');
|
|
202
|
+
const length = Math.min(stat.size, HEAD_BYTES);
|
|
203
|
+
head = Buffer.alloc(length);
|
|
204
|
+
fs.readSync(fd, head, 0, length, 0);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
} finally {
|
|
208
|
+
if (fd !== null) {
|
|
209
|
+
try { fs.closeSync(fd); } catch { /* 关不上就算了,元数据已经读到 */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const mediaType = sniffMediaType(head);
|
|
213
|
+
const size = imageSizeOf(head);
|
|
214
|
+
if (!mediaType || !size || !(size.width > 0) || !(size.height > 0)) return null;
|
|
215
|
+
return { mediaType, bytes: stat.size, width: size.width, height: size.height };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 给宿主取附件接口拼一份完整 `ImageAttachmentRef`。
|
|
220
|
+
*
|
|
221
|
+
* 记过账的用记下的整份引用;没记到的(过期 / 超上限 / 插件重启后没重新登记 / 排队条里透传的图)
|
|
222
|
+
* 就从盘上那份对象现量一遍 —— 宿主校验要的就是盘上这份字节的真实元数据,占位值一定对不上。
|
|
223
|
+
* 对象文件不在或量不出时回 null(调用方回 404),不再拿假引用去碰宿主的那条校验。
|
|
224
|
+
*/
|
|
225
|
+
function refFor(sessionId, attachmentId) {
|
|
226
|
+
const known = lookup(sessionId, attachmentId);
|
|
227
|
+
if (known && Number.isFinite(known.bytes) && Number.isFinite(known.width) && Number.isFinite(known.height)) {
|
|
228
|
+
return { attachmentId, ...known };
|
|
229
|
+
}
|
|
230
|
+
const probed = probeObject(attachmentId);
|
|
231
|
+
if (probed) {
|
|
232
|
+
return { attachmentId, ...probed, ...(known && typeof known.name === 'string' ? { name: known.name } : {}) };
|
|
233
|
+
}
|
|
234
|
+
return known ? { attachmentId, ...known } : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
142
237
|
/** 测试用:清空记账。 */
|
|
143
238
|
function reset() {
|
|
144
239
|
SESSIONS.clear();
|
|
@@ -151,6 +246,7 @@ module.exports = {
|
|
|
151
246
|
objectPathOf,
|
|
152
247
|
remember,
|
|
153
248
|
lookup,
|
|
249
|
+
refFor,
|
|
154
250
|
openObject,
|
|
155
251
|
streamIfRemembered,
|
|
156
252
|
reset,
|
|
@@ -58,12 +58,16 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
|
|
|
58
58
|
if (referencesOnly) {
|
|
59
59
|
// 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
|
|
60
60
|
// 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
|
|
61
|
-
|
|
61
|
+
// 记的是**整份引用**:回退问宿主时要的正是这个形状,光有 id 不够。
|
|
62
|
+
attachmentStore.remember(sessionId, attachmentId, {
|
|
63
|
+
...block.attachment,
|
|
64
|
+
mediaType: block.attachment.mediaType || block.mediaType,
|
|
65
|
+
});
|
|
62
66
|
return block;
|
|
63
67
|
}
|
|
64
68
|
let result;
|
|
65
69
|
try {
|
|
66
|
-
result = unwrap(await callSessions(ctx, 'attachment', { sessionId,
|
|
70
|
+
result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachment: block.attachment }));
|
|
67
71
|
} catch {
|
|
68
72
|
return dataUnavailableBlock(block);
|
|
69
73
|
}
|
|
@@ -3,27 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 「这个 CLI 该按哪一套配置跑」—— 一台电脑每个 CLI 记一份。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 这一轮没结束就改不动配置 —— 用户说的「得等这一轮结束才能去调权限」正是这个。
|
|
11
|
-
*
|
|
12
|
-
* 所以这里做的是「**记住,然后开局就套上**」:用户在配置页里定的那一套(权限档 / 模型 /
|
|
13
|
-
* 思考档位 / 其它会话级下拉)存在这台电脑上,按 `agentId` 记;新建会话握手完、第一次发消息之前
|
|
14
|
-
* 逐项套过去。ACP 没有「一次设置管所有会话」的开关,但对用户来说效果就是全局的。
|
|
15
|
-
*
|
|
16
|
-
* 三条口径:
|
|
17
|
-
*
|
|
18
|
-
* 1. **只记用户显式选过的项**(`values`)。没选过的不记,新会话就让 CLI 用它自己的默认 ——
|
|
19
|
-
* 替用户猜一个档位,等于悄悄改了它的行为。
|
|
20
|
-
* 2. **选项表也记一份**(`options`)。配置页要在**没开会话**的时候就能画出「权限档有哪几个」,
|
|
21
|
-
* 而那是 CLI 握手之后才给的东西;不缓存就只能等开一条对话才看得见。缓存只用于显示,
|
|
22
|
-
* 真正写的时候仍然按新会话自己给的那张表校验(CLI 升级换了档位名这类情况就不会写错)。
|
|
23
|
-
* 3. **写不进去不算错**。新会话里那一项不存在(换了名字 / 这一版没这个选项)就跳过它,
|
|
24
|
-
* 停在 CLI 自己的默认上,别让一条过期的偏好把整条会话建不起来。
|
|
25
|
-
*
|
|
26
|
-
* 文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
|
|
6
|
+
* ACP 的配置是**会话级**的:`session/new` 之后才有 `configOptions`,`set_config_option` 只对那一条
|
|
7
|
+
* 会话生效,每开一条新对话都回到 CLI 默认档。所以这里把用户定的那套按 `agentId` 存下来,新会话
|
|
8
|
+
* 握手完、第一次发消息之前逐项套过去。只记用户显式选过的项、没选过的不猜;选项表缓存一份供没开
|
|
9
|
+
* 会话时画,但写入仍按新会话校验;写不进去不算错。文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
|
|
27
10
|
*/
|
|
28
11
|
|
|
29
12
|
const fs = require('node:fs');
|
package/lib/mobile/cli/mobile.js
CHANGED
|
@@ -488,6 +488,24 @@ function createCliMobile(deps) {
|
|
|
488
488
|
const states = new WeakMap()
|
|
489
489
|
/** rpcId → { session, requestId }:`/approval/respond` 靠它找回 ACP 的挂起项。 */
|
|
490
490
|
const approvals = new Map()
|
|
491
|
+
/**
|
|
492
|
+
* 投递去重账:`${sessionId}:${clientMessageId}` → 收下的时刻。
|
|
493
|
+
*
|
|
494
|
+
* CLI 这条发送没有宿主那本日志,`/session/delivery` 早先恒回 `delivered:false` —— 于是 App
|
|
495
|
+
* 在一发超时后会认为「没送到」再发一次;若这一轮刚好结束,就会真的把同一句喂给模型两遍。
|
|
496
|
+
* 这里记一笔:`/session/send` 见到同一个投递 id 直接按成功收(幂等),`/session/delivery`
|
|
497
|
+
* 照实回「早就收下了」。只留最近若干条,旧的自然淘汰。
|
|
498
|
+
*/
|
|
499
|
+
const deliveries = new Map()
|
|
500
|
+
const DELIVERIES_MAX = 300
|
|
501
|
+
function rememberDelivery(key) {
|
|
502
|
+
deliveries.set(key, Date.now())
|
|
503
|
+
while (deliveries.size > DELIVERIES_MAX) {
|
|
504
|
+
const oldest = deliveries.keys().next().value
|
|
505
|
+
if (oldest === undefined) break
|
|
506
|
+
deliveries.delete(oldest)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
491
509
|
|
|
492
510
|
function stateOf(session) {
|
|
493
511
|
let state = states.get(session)
|
|
@@ -556,8 +574,10 @@ function createCliMobile(deps) {
|
|
|
556
574
|
cwd: session.cwd,
|
|
557
575
|
// 侧栏按 workspaceId 分组:给不准就会把 CLI 会话扔进「未分组」。
|
|
558
576
|
workspaceId: session.workspaceId,
|
|
559
|
-
// 对话页「⋯ →
|
|
577
|
+
// 对话页「⋯ → 终端」靠这几格:哪个 CLI、展示名、在哪个目录。
|
|
560
578
|
cliAgent: session.agentId,
|
|
579
|
+
// 展示名(`Codex CLI` 之类):终端页 / 模型管理页标题用它,别把 id 直接摆给用户看。
|
|
580
|
+
...(session.label ? { cliAgentLabel: session.label } : {}),
|
|
561
581
|
updatedAt: session.updatedAt || 0,
|
|
562
582
|
// 置顶与宿主同一个字段:App 的行尾图钉读它(排序见 mergeList)。
|
|
563
583
|
...(state.pinned ? { pinned: true } : {}),
|
|
@@ -1005,9 +1025,13 @@ function createCliMobile(deps) {
|
|
|
1005
1025
|
case '/session/goal':
|
|
1006
1026
|
sendJsonQuiet(res, {})
|
|
1007
1027
|
return
|
|
1008
|
-
case '/session/delivery':
|
|
1009
|
-
|
|
1028
|
+
case '/session/delivery': {
|
|
1029
|
+
// App 的离线发件箱超时后回查这一发到底收下没有(见 useOutboxFlush):照实回答,
|
|
1030
|
+
// 免得它再发一遍。没有 clientMessageId 的老客户端只能回「不知道」。
|
|
1031
|
+
const cid = textOf(queryOf(req).get('clientMessageId'))
|
|
1032
|
+
sendJsonQuiet(res, { delivered: Boolean(cid && deliveries.has(`${session.id}:${cid}`)) })
|
|
1010
1033
|
return
|
|
1034
|
+
}
|
|
1011
1035
|
case '/session/rename': {
|
|
1012
1036
|
const title = textOf(body && body.title).trim()
|
|
1013
1037
|
if (title) setTitle(session, title)
|
|
@@ -1025,6 +1049,13 @@ function createCliMobile(deps) {
|
|
|
1025
1049
|
case '/session/send': {
|
|
1026
1050
|
const content = Array.isArray(body && body.content) ? body.content : []
|
|
1027
1051
|
const message = textOf(body && body.text) || textOfContent(content)
|
|
1052
|
+
// 同一个投递 id 再来一次就是重发:早就收下了,直接按成功回(幂等),别再喂模型一遍。
|
|
1053
|
+
const clientMessageId = textOf(body && body.clientMessageId)
|
|
1054
|
+
const deliveryKey = clientMessageId ? `${session.id}:${clientMessageId}` : ''
|
|
1055
|
+
if (deliveryKey && deliveries.has(deliveryKey)) {
|
|
1056
|
+
sendJson(res, 200, { ok: true, accepted: true })
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1028
1059
|
// 这一发带的图:先认下字节(手机发上来时已经落在收件箱里),块表里只记元数据。
|
|
1029
1060
|
const sent = await saveImages(content)
|
|
1030
1061
|
// 图的毛病先说清楚(上传过期 / 读不出来),比「这一发没有正文」有用得多。
|
|
@@ -1054,6 +1085,7 @@ function createCliMobile(deps) {
|
|
|
1054
1085
|
}
|
|
1055
1086
|
const result = runPrompt(session, message, sent.images)
|
|
1056
1087
|
if (!result.ok) return sendJson(res, 409, { ok: false, error: { code: 'cli-busy', message: result.error || '这一轮还没结束' } })
|
|
1088
|
+
if (deliveryKey) rememberDelivery(deliveryKey)
|
|
1057
1089
|
sendJson(res, 200, { ok: true, accepted: true })
|
|
1058
1090
|
return
|
|
1059
1091
|
}
|
|
@@ -1063,7 +1095,7 @@ function createCliMobile(deps) {
|
|
|
1063
1095
|
case '/subagents':
|
|
1064
1096
|
sendJsonQuiet(res, { entries: [], parentAvailable: true })
|
|
1065
1097
|
return
|
|
1066
|
-
case '/extension
|
|
1098
|
+
case '/extension/cards':
|
|
1067
1099
|
sendJsonQuiet(res, { cards: [] })
|
|
1068
1100
|
return
|
|
1069
1101
|
default:
|
|
@@ -57,7 +57,13 @@ const ENDPOINTS = {
|
|
|
57
57
|
'sessions.create': inv('session', 'create'),
|
|
58
58
|
'sessions.search': inv('session', 'search'),
|
|
59
59
|
'sessions.selectModel': inv('session', 'selectModel'),
|
|
60
|
-
|
|
60
|
+
// 宿主 `SessionAttachmentRequest` 是 `{ sessionId, attachment: ImageAttachmentRef }`,
|
|
61
|
+
// `attachment` 必填且内含 attachmentId / mediaType / bytes / width / height。只传
|
|
62
|
+
// `{ sessionId, attachmentId }` 会在 strict codec 的 parse 那一步被挡回
|
|
63
|
+
// `wire field "request" failed boundary validation`(400),App 把 400 catch 掉后图片就没了。
|
|
64
|
+
'sessions.attachment': inv('session', 'attachment', (p) => ({
|
|
65
|
+
request: { sessionId: p && p.sessionId, attachment: p && p.attachment },
|
|
66
|
+
})),
|
|
61
67
|
'sessions.updateQueue': inv('session', 'updateQueue'),
|
|
62
68
|
// 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
|
|
63
69
|
'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
|
|
@@ -467,4 +467,4 @@ function inRanges(index, ranges) {
|
|
|
467
467
|
return false;
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
|
|
470
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };
|