dsh-mobile-plugin 0.5.17 → 0.5.19

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.
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ACP 的 `session/update` 事件 → 手机对话页认识的块(`CliBlock`)。
5
+ *
6
+ * 这一层就是整个「对话式 CLI」的翻译器,它之所以能这么薄,是因为 ACP 本身就是照着
7
+ * 「编辑器里的一个对话」设计的:正文、思考、工具调用、计划、审批各是独立事件,不需要
8
+ * 去猜终端屏幕。
9
+ *
10
+ * 两条只在这里成立的口径:
11
+ *
12
+ * - **流式正文按 `messageId` 合并成一条块**。ACP 的 `agent_message_chunk` 是逐段推的,
13
+ * 同一个 `messageId` 的若干 chunk 属于同一条消息;换成新 `messageId`(或没有 id)才
14
+ * 另起一条。块的 `id` 直接由它派生成 `a:<messageId>`,所以重连重放与实时增量落到同一处。
15
+ * - **不加 `user_message_chunk`**。宿主自己记得用户发过什么(`pushUser`),ACP 那边回显
16
+ * 的那一份要是也画出来,手机上每条提问都会出现两遍。
17
+ *
18
+ * 块只增不改顺序:工具调用按 `callId` 原地更新,计划整表替换(ACP 就是这么定的)。
19
+ */
20
+
21
+ let autoSeq = 0;
22
+
23
+ function nextId(prefix) {
24
+ autoSeq += 1;
25
+ return `${prefix}:${Date.now().toString(36)}-${autoSeq.toString(36)}`;
26
+ }
27
+
28
+ /** ACP 的工具状态四档 → 手机的三档(pending / in_progress 都还是「在跑」)。 */
29
+ function toolState(status) {
30
+ if (status === 'completed') return 'ok';
31
+ if (status === 'failed') return 'error';
32
+ return 'running';
33
+ }
34
+
35
+ /** 工具内容的正文:ACP 允许文本 / diff / 终端引用三种,手机上只画文本那一种。 */
36
+ function textOfToolContent(content) {
37
+ if (!Array.isArray(content)) return '';
38
+ const parts = [];
39
+ for (const item of content) {
40
+ if (!item || typeof item !== 'object') continue;
41
+ if (item.type === 'content' && item.content && item.content.type === 'text' && typeof item.content.text === 'string') {
42
+ parts.push(item.content.text);
43
+ } else if (item.type === 'diff' && typeof item.path === 'string') {
44
+ parts.push(item.path);
45
+ }
46
+ }
47
+ return parts.join('\n');
48
+ }
49
+
50
+ /**
51
+ * 工具调用的正文。
52
+ *
53
+ * 优先取结构化的 `content`(ACP 里是文本 / diff / 终端引用),没有就退到 `rawOutput`:
54
+ * codex 那边执行结果只出现在 `rawOutput` 里,`content` 一直是空的 —— 只认前者的话手机上
55
+ * 每一条工具卡都不带结果。
56
+ */
57
+ function outputOf(update) {
58
+ const fromContent = textOfToolContent(update.content);
59
+ if (fromContent) return fromContent;
60
+ const raw = update.rawOutput;
61
+ if (typeof raw === 'string') return clip(raw, 4000);
62
+ if (raw && typeof raw === 'object') return clip(JSON.stringify(raw), 4000);
63
+ return '';
64
+ }
65
+
66
+ function textOfBlock(block) {
67
+ if (!block || typeof block !== 'object') return '';
68
+ if (block.type === 'text' && typeof block.text === 'string') return block.text;
69
+ if (block.type === 'resource_link' && typeof block.uri === 'string') return block.uri;
70
+ return '';
71
+ }
72
+
73
+ function clip(value, max) {
74
+ return value.length > max ? `${value.slice(0, max)}…` : value;
75
+ }
76
+
77
+ /**
78
+ * 一条会话的块表。
79
+ *
80
+ * `apply` 只改表并把「变了的块」交出来,广播由服务层做 —— 它才知道该不该攒批。
81
+ */
82
+ function createBlockLog() {
83
+ /** 有序块;`index` 存同一批对象的引用,改属性即改表里那一份。 */
84
+ const blocks = [];
85
+ const index = new Map();
86
+
87
+ function put(block) {
88
+ blocks.push(block);
89
+ index.set(block.id, block);
90
+ return block;
91
+ }
92
+
93
+ function appendText(id, kind, text, at) {
94
+ const existing = index.get(id);
95
+ if (!existing) return put({ id, kind, text, at, streaming: true });
96
+ existing.text += text;
97
+ existing.streaming = true;
98
+ return existing;
99
+ }
100
+
101
+ return {
102
+ /** 只读快照(`hello` 帧用),复制一层免得调用方改了表里的对象。 */
103
+ list() {
104
+ return blocks.map((block) => ({ ...block }));
105
+ },
106
+
107
+ size() {
108
+ return blocks.length;
109
+ },
110
+
111
+ /** 用户发出去的这一条(我们自己的记录,见文件头)。`images` 是已存好的图片元数据。 */
112
+ pushUser(text, at, images) {
113
+ return put({
114
+ id: nextId('u'),
115
+ kind: 'user',
116
+ text,
117
+ at,
118
+ ...(Array.isArray(images) && images.length ? { images } : {}),
119
+ });
120
+ },
121
+
122
+ notice(level, text, at) {
123
+ return put({ id: nextId('n'), kind: 'notice', level, text, at });
124
+ },
125
+
126
+ /** 一轮结束了:把还在流式的正文收口(否则那一行永远带着光标)。 */
127
+ settle(at) {
128
+ const changed = [];
129
+ for (const block of blocks) {
130
+ if (!block.streaming) continue;
131
+ block.streaming = false;
132
+ block.at = at || block.at;
133
+ changed.push(block);
134
+ }
135
+ return changed;
136
+ },
137
+
138
+ /**
139
+ * 收下一条 `session/update`。
140
+ *
141
+ * 返回 `{ block }`(有块变了,广播它)、`{ title }`(只改了标题)、或 `null`(不关心)。
142
+ */
143
+ apply(update, at) {
144
+ if (!update || typeof update !== 'object') return null;
145
+ const kind = update.sessionUpdate;
146
+
147
+ if (kind === 'agent_message_chunk' || kind === 'agent_thought_chunk') {
148
+ const text = textOfBlock(update.content);
149
+ if (!text) return null;
150
+ const isText = kind === 'agent_message_chunk';
151
+ const id = `${isText ? 't' : 'r'}:${update.messageId || 'main'}`;
152
+ return { block: appendText(id, isText ? 'text' : 'reasoning', text, at) };
153
+ }
154
+
155
+ if (kind === 'tool_call') {
156
+ const id = `c:${update.toolCallId}`;
157
+ const block = {
158
+ id,
159
+ kind: 'tool',
160
+ callId: update.toolCallId,
161
+ name: typeof update.name === 'string' ? update.name : '',
162
+ title: typeof update.title === 'string' ? update.title : '',
163
+ state: toolState(update.status),
164
+ argsRaw: update.rawInput === undefined ? '' : clip(JSON.stringify(update.rawInput), 2000),
165
+ output: outputOf(update),
166
+ at,
167
+ };
168
+ const existing = index.get(id);
169
+ if (!existing) return { block: put(block) };
170
+ Object.assign(existing, block, { at: existing.at });
171
+ return { block: existing };
172
+ }
173
+
174
+ if (kind === 'tool_call_update') {
175
+ const id = `c:${update.toolCallId}`;
176
+ const existing = index.get(id);
177
+ if (!existing) return null;
178
+ if (typeof update.title === 'string' && update.title) existing.title = update.title;
179
+ if (typeof update.name === 'string' && update.name) existing.name = update.name;
180
+ if (update.status) existing.state = toolState(update.status);
181
+ if (update.rawInput !== undefined) existing.argsRaw = clip(JSON.stringify(update.rawInput), 2000);
182
+ const output = outputOf(update);
183
+ if (output) existing.output = output;
184
+ return { block: existing };
185
+ }
186
+
187
+ if (kind === 'plan') {
188
+ const entries = Array.isArray(update.entries) ? update.entries : [];
189
+ const steps = entries.map((entry) => ({
190
+ text: typeof entry.content === 'string' ? entry.content : '',
191
+ status: typeof entry.status === 'string' ? entry.status : 'pending',
192
+ }));
193
+ const existing = index.get('plan');
194
+ if (existing) {
195
+ existing.steps = steps;
196
+ existing.at = at;
197
+ return { block: existing };
198
+ }
199
+ return { block: put({ id: 'plan', kind: 'plan', steps, at }) };
200
+ }
201
+
202
+ if (kind === 'session_info_update') {
203
+ const title = typeof update.title === 'string' ? update.title.trim() : '';
204
+ return title ? { title } : null;
205
+ }
206
+
207
+ if (kind === 'notice') {
208
+ const title = typeof update.title === 'string' ? update.title : '';
209
+ const detail = typeof update.description === 'string' ? update.description : '';
210
+ if (!title && !detail) return null;
211
+ return {
212
+ block: put({
213
+ id: nextId('n'),
214
+ kind: 'notice',
215
+ level: update.severity === 'error' ? 'error' : 'info',
216
+ text: detail ? `${title}:${detail}` : title,
217
+ at,
218
+ }),
219
+ };
220
+ }
221
+
222
+ // `usage_update` / `available_commands_update` / 压缩那几档这一版不画。
223
+ return null;
224
+ },
225
+ };
226
+ }
227
+
228
+ module.exports = { createBlockLog, toolState };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「这个 CLI 该按哪一套配置跑」—— 一台电脑每个 CLI 记一份。
5
+ *
6
+ * ACP 的配置是**会话级**的:`session/new` 之后才有 `configOptions`,`set_config_option` 只对那一条
7
+ * 会话生效,每开一条新对话都回到 CLI 默认档。所以这里把用户定的那套按 `agentId` 存下来,新会话
8
+ * 握手完、第一次发消息之前逐项套过去。只记用户显式选过的项、没选过的不猜;选项表缓存一份供没开
9
+ * 会话时画,但写入仍按新会话校验;写不进去不算错。文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const paths = require('../../shared/paths');
16
+
17
+ function storeFile() {
18
+ return path.join(paths.dshHome(), 'storages', 'mobile-plugin', 'cli-config.json');
19
+ }
20
+
21
+ function text(value) {
22
+ return typeof value === 'string' ? value.trim() : '';
23
+ }
24
+
25
+ function load() {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(storeFile(), 'utf8'));
28
+ const agents = raw && typeof raw === 'object' ? raw.agents : null;
29
+ return agents && typeof agents === 'object' ? agents : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function save(agents) {
36
+ try {
37
+ fs.mkdirSync(path.dirname(storeFile()), { recursive: true });
38
+ fs.writeFileSync(storeFile(), JSON.stringify({ agents }, null, 0), 'utf8');
39
+ } catch {
40
+ // 写不进去只是下次开局回到 CLI 自己的默认,不影响这一条会话。
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 把 ACP 的一项配置压成「够画一个下拉」的形状。
46
+ *
47
+ * ACP 的 `options` 有两种:扁平的一项一条,或者按组(`{group, name, options}`)。手机那边
48
+ * 只认平铺的一行选项,所以这里摊平并保留 `description`(档位说明对「这一档到底放没放开」很
49
+ * 关键,用户要靠它决定选哪个)。
50
+ */
51
+ function choicesOf(option) {
52
+ const raw = Array.isArray(option && option.options) ? option.options : [];
53
+ const out = [];
54
+ for (const item of raw) {
55
+ if (!item || typeof item !== 'object') continue;
56
+ if (Array.isArray(item.options)) {
57
+ for (const child of item.options) {
58
+ if (child && child.value !== undefined) {
59
+ out.push({
60
+ value: String(child.value),
61
+ name: text(child.name) || String(child.value),
62
+ ...(text(child.description) ? { description: text(child.description) } : {}),
63
+ ...(text(item.name) ? { group: text(item.name) } : {}),
64
+ });
65
+ }
66
+ }
67
+ continue;
68
+ }
69
+ if (item.value !== undefined) {
70
+ out.push({
71
+ value: String(item.value),
72
+ name: text(item.name) || String(item.value),
73
+ ...(text(item.description) ? { description: text(item.description) } : {}),
74
+ });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * 会话的整份配置项 → 配置页能直接画的那张表。
82
+ *
83
+ * `category` 原样带出去:手机靠它分组(权限档 / 模型 / 思考档位 / 其它),而它是 ACP 定的
84
+ * 语义分类,不是我们猜的。
85
+ */
86
+ function describe(configOptions) {
87
+ const list = Array.isArray(configOptions) ? configOptions : [];
88
+ const out = [];
89
+ for (const option of list) {
90
+ if (!option || typeof option.id !== 'string' || !option.id) continue;
91
+ const choices = choicesOf(option);
92
+ if (choices.length === 0) continue;
93
+ out.push({
94
+ id: option.id,
95
+ name: text(option.name) || option.id,
96
+ ...(text(option.category) ? { category: text(option.category) } : {}),
97
+ ...(text(option.description) ? { description: text(option.description) } : {}),
98
+ ...(option.currentValue !== undefined ? { currentValue: String(option.currentValue) } : {}),
99
+ choices,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** 这个 CLI 记着的那一套:`{ values: {<configId>: <value>}, options: [...] }`。 */
106
+ function prefsOf(agentId) {
107
+ const entry = load()[text(agentId)];
108
+ const values = entry && entry.values && typeof entry.values === 'object' ? entry.values : {};
109
+ const clean = {};
110
+ for (const [key, value] of Object.entries(values)) {
111
+ if (key && typeof value === 'string' && value) clean[key] = value;
112
+ }
113
+ return { values: clean, options: Array.isArray(entry && entry.options) ? entry.options : [] };
114
+ }
115
+
116
+ /** 记下「这个 CLI 的某一项该用哪个值」(值空 = 撤掉这一项,回到 CLI 自己的默认)。 */
117
+ function setValue(agentId, configId, value) {
118
+ const agent = text(agentId);
119
+ const id = text(configId);
120
+ if (!agent || !id) return;
121
+ const agents = load();
122
+ const entry = agents[agent] && typeof agents[agent] === 'object' ? { ...agents[agent] } : {};
123
+ const values = entry.values && typeof entry.values === 'object' ? { ...entry.values } : {};
124
+ const clean = text(value);
125
+ if (clean) values[id] = clean;
126
+ else delete values[id];
127
+ if (Object.keys(values).length) entry.values = values;
128
+ else delete entry.values;
129
+ if (entry.values || (Array.isArray(entry.options) && entry.options.length)) agents[agent] = entry;
130
+ else delete agents[agent];
131
+ save(agents);
132
+ }
133
+
134
+ /** 记下这个 CLI 的选项表(配置页在没开会话时也要能画出来)。 */
135
+ function rememberOptions(agentId, configOptions) {
136
+ const agent = text(agentId);
137
+ if (!agent) return;
138
+ const options = describe(configOptions);
139
+ const agents = load();
140
+ if (options.length === 0) {
141
+ // 一家 CLI 本来就不给配置项:把旧表清掉,别让配置页停在上一版 CLI 的样子。
142
+ const entry = agents[agent];
143
+ if (!entry || !Array.isArray(entry.options) || entry.options.length === 0) return;
144
+ const next = { ...entry };
145
+ delete next.options;
146
+ if (next.values) agents[agent] = next;
147
+ else delete agents[agent];
148
+ } else {
149
+ const entry = agents[agent] && typeof agents[agent] === 'object' ? { ...agents[agent] } : {};
150
+ agents[agent] = { ...entry, options };
151
+ }
152
+ save(agents);
153
+ }
154
+
155
+ module.exports = { storeFile, describe, prefsOf, setValue, rememberOptions };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI 品牌图标:注册表里每条都带一枚官方 SVG(`icon` 字段),这里是它的本地缓存。
5
+ *
6
+ * 为什么放插件侧而不写进 App:清单里有二十来家、还在增加,每一家一枚 SVG 写进 App 就是
7
+ * 每来一家都要发一次版。插件本来就在读那份注册表,顺手把图标存下来,App 只按 id 取。
8
+ *
9
+ * 缓存规则与注册表同一套:内存 → 盘上(`$DSH_HOME/storages/mobile-plugin/acp-icons.json`)
10
+ * → 联网拉。拉不到就空着,App 退回首字母 / 方块 —— 不为此报错。
11
+ *
12
+ * **原样留着那份 SVG,只摘掉尺寸与命名空间**:注册表里的图标有的靠 `fill`(Claude、OpenCode),
13
+ * 有的靠 `stroke`(Agoragentic 那一枚就是圆 + 三条线)。在插件这一层按「只留 path」筛一遍,
14
+ * 描边那几家会整枚消失;颜色也不在这里定 —— App 侧统一把 `currentColor` 换成主题色(见
15
+ * `components/CliLogo`),与 `ModelVendorBadge` 同一个规矩。
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const paths = require('../../shared/paths');
22
+
23
+ /** 一次拉取最多等这么久:图标是锦上添花,不该把「列清单」拖住。 */
24
+ const FETCH_TIMEOUT_MS = 6000;
25
+
26
+ /** 一枚图标在缓存里最多留这么久;注册表按小时更新,图标跟着换。 */
27
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ let memory = null;
30
+
31
+ function cacheFile() {
32
+ return path.join(paths.dshHome(), 'storages', 'mobile-plugin', 'acp-icons.json');
33
+ }
34
+
35
+ /**
36
+ * 官方 SVG → App 画得出来的那一份。
37
+ *
38
+ * 去掉 `width` / `height` 与 `xmlns`(尺寸由调用方给),`viewBox` 留着 —— 各家的画布不一样
39
+ * (Claude 是 1200×1200),丢了它图标会被裁掉大半。解析不出 `viewBox` 的按 24×24 处理,
40
+ * 这是 Simple Icons 的通用画布,也是我们内置那四家的。
41
+ */
42
+ function normalizeSvg(svg) {
43
+ if (typeof svg !== 'string' || !svg.trim()) return '';
44
+ const open = svg.match(/<svg\b[^>]*>/i);
45
+ if (!open) return '';
46
+ const viewBox = (open[0].match(/viewBox="([^"]+)"/i) || [])[1] || '0 0 24 24';
47
+ const inner = svg
48
+ .slice(open.index + open[0].length)
49
+ .replace(/<\/svg>\s*$/i, '')
50
+ .replace(/<(defs|style|title|desc)\b[\s\S]*?<\/\1>/gi, '')
51
+ .trim();
52
+ if (!inner) return '';
53
+ return `<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
54
+ }
55
+
56
+ function readCache() {
57
+ try {
58
+ const raw = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
59
+ const icons = raw && raw.icons && typeof raw.icons === 'object' ? raw.icons : null;
60
+ if (!icons) return null;
61
+ return { fetchedAt: Number(raw.fetchedAt) || 0, icons };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function writeCache(icons) {
68
+ try {
69
+ fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
70
+ fs.writeFileSync(cacheFile(), JSON.stringify({ fetchedAt: Date.now(), icons }), 'utf8');
71
+ } catch {
72
+ // 写不进去只是下次再拉一遍。
73
+ }
74
+ }
75
+
76
+ /**
77
+ * id → SVG 串。
78
+ *
79
+ * `entries` 是注册表条目(`{ id, icon }`),`want` 是要哪几个 id(空 = 全都要)。
80
+ * 只拉还没缓存过的那些,并发拉完;任何一条失败就当它没有图标。
81
+ */
82
+ async function loadIcons(entries, want) {
83
+ const targets = (Array.isArray(entries) ? entries : [])
84
+ .filter((entry) => entry && typeof entry.id === 'string' && typeof entry.icon === 'string' && entry.icon);
85
+ const naming = new Set(Array.isArray(want) ? want : []);
86
+ const picked = (list) => {
87
+ const out = {};
88
+ for (const id of naming.size ? naming : Object.keys(list)) {
89
+ if (list[id]) out[id] = list[id];
90
+ }
91
+ return out;
92
+ };
93
+ if (targets.length === 0) return {};
94
+
95
+ const now = Date.now();
96
+ if (!memory || now - memory.at >= TTL_MS) {
97
+ const cached = readCache();
98
+ memory = cached && now - cached.fetchedAt < TTL_MS
99
+ ? { at: cached.fetchedAt, icons: cached.icons }
100
+ : { at: 0, icons: (cached && cached.icons) || {} };
101
+ }
102
+ const icons = { ...memory.icons };
103
+ const todo = targets.filter((entry) => (naming.size === 0 || naming.has(entry.id)) && !icons[entry.id]);
104
+ if (todo.length > 0) {
105
+ const fetched = await Promise.all(todo.map(async (entry) => {
106
+ try {
107
+ const res = await fetch(entry.icon, {
108
+ // 这台 CDN 不认裸 Node 的默认 UA(会回 403)。
109
+ headers: { accept: 'image/svg+xml', 'user-agent': 'dsh-mobile' },
110
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
111
+ });
112
+ if (!res.ok) return null;
113
+ const svg = normalizeSvg(await res.text());
114
+ return svg ? [entry.id, svg] : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }));
119
+ let changed = false;
120
+ for (const pair of fetched) {
121
+ if (!pair) continue;
122
+ icons[pair[0]] = pair[1];
123
+ changed = true;
124
+ }
125
+ if (changed) {
126
+ memory = { at: now, icons };
127
+ writeCache(icons);
128
+ }
129
+ }
130
+ return picked(icons);
131
+ }
132
+
133
+ /** 测试用:清掉内存那一份。 */
134
+ function resetIconMemory() {
135
+ memory = null;
136
+ }
137
+
138
+ module.exports = { loadIcons, normalizeSvg, resetIconMemory, cacheFile };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI 会话里的图片:发出去的那几张,以及手机上回头按 id 来取字节。
5
+ *
6
+ * 为什么不能沿用宿主那套附件:宿主的 `attachments/v1` 是会话日志的东西,CLI 会话根本没有
7
+ * 那份日志。所以图片在插件这边自己存一份 —— 手机发上来时字节已经在盘上(分片上传落在收件箱),
8
+ * 这里按**内容哈希**把它认下来(同一张图发两次只有一份),历史和实时帧里只下发一个
9
+ * `attachmentId`,字节等手机真的要看时再取。
10
+ *
11
+ * 两条形状与宿主那套逐字对齐(App 的 `useAttachmentUri` / 原生通道只认这两种):
12
+ *
13
+ * - `{ type:'image', attachment:{ attachmentId, mediaType, name?, width?, height? } }`
14
+ * —— 历史和实时帧里的引用;
15
+ * - `/session/attachment` 上的三种回法:默认整份 base64、`raw:true` 走分块信封、
16
+ * `dims:true` 只要宽高。
17
+ *
18
+ * 宽高在存的时候就量好(`imageSizeOf` 读文件头):不带它的话 App 只能先按 240 方盒占位、
19
+ * 等解码完再变高,贴底的列表里那一跳就是「内容先停对了又被顶回去」。
20
+ */
21
+
22
+ const crypto = require('node:crypto');
23
+ const fs = require('node:fs');
24
+ const fsp = require('node:fs/promises');
25
+ const path = require('node:path');
26
+
27
+ const paths = require('../../shared/paths');
28
+ const { imageSizeOf } = require('../local-images.js');
29
+
30
+ /** 单张上限:与宿主那侧的附件限制同一个量级(10MB),超了就在发送时回绝。 */
31
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
32
+
33
+ /** 只认这四种:App 的图片块本来就只有这几种,别的一律当不支持。 */
34
+ const MEDIA_EXT = {
35
+ 'image/png': 'png',
36
+ 'image/jpeg': 'jpg',
37
+ 'image/webp': 'webp',
38
+ 'image/gif': 'gif',
39
+ };
40
+
41
+ function imageRoot() {
42
+ return path.join(paths.dshHome(), 'storages', 'mobile-plugin', 'cli-images');
43
+ }
44
+
45
+ /** 内容哈希当 id(与宿主那套同一个口径:64 位小写十六进制)。 */
46
+ function idOf(bytes) {
47
+ return crypto.createHash('sha256').update(bytes).digest('hex');
48
+ }
49
+
50
+ function objectPath(attachmentId) {
51
+ if (typeof attachmentId !== 'string' || !/^[0-9a-f]{64}$/.test(attachmentId)) return null;
52
+ return path.join(imageRoot(), attachmentId);
53
+ }
54
+
55
+ /** 存一份字节 + 一份元数据(媒体类型与名字),回引用块用的那几格。 */
56
+ async function saveImage({ bytes, mediaType, name }) {
57
+ const type = typeof mediaType === 'string' && MEDIA_EXT[mediaType] ? mediaType : 'image/png';
58
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) return null;
59
+ if (bytes.length > MAX_IMAGE_BYTES) return null;
60
+ const attachmentId = idOf(bytes);
61
+ const target = objectPath(attachmentId);
62
+ const root = imageRoot();
63
+ await fsp.mkdir(root, { recursive: true });
64
+ // 同一张图第二次发过来:文件已经在,元数据也够用,别再写一遍。
65
+ if (!fs.existsSync(target)) await fsp.writeFile(target, bytes);
66
+ const size = imageSizeOf(bytes);
67
+ const meta = { mediaType: type, ...(typeof name === 'string' && name ? { name } : {}), ...(size || {}) };
68
+ try {
69
+ await fsp.writeFile(`${target}.json`, JSON.stringify(meta), 'utf8');
70
+ } catch {
71
+ // 元数据写不进去也不该让这一发失败:媒体类型还能从文件头猜。
72
+ }
73
+ return { attachmentId, ...meta };
74
+ }
75
+
76
+ /** 按 id 取回元数据(不认识 / 不在了回 null)。 */
77
+ function metaOf(attachmentId) {
78
+ const target = objectPath(attachmentId);
79
+ if (!target) return null;
80
+ try {
81
+ const raw = JSON.parse(fs.readFileSync(`${target}.json`, 'utf8'));
82
+ if (!raw || typeof raw !== 'object') return null;
83
+ return {
84
+ attachmentId,
85
+ mediaType: typeof raw.mediaType === 'string' && MEDIA_EXT[raw.mediaType] ? raw.mediaType : 'image/png',
86
+ ...(typeof raw.name === 'string' && raw.name ? { name: raw.name } : {}),
87
+ ...(Number.isFinite(raw.width) && Number.isFinite(raw.height) ? { width: raw.width, height: raw.height } : {}),
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** 按 id 打开字节(返回 `{ path, size }`;不在了回 null)。 */
95
+ function openImage(attachmentId) {
96
+ const target = objectPath(attachmentId);
97
+ if (!target) return null;
98
+ try {
99
+ const stat = fs.statSync(target);
100
+ if (!stat.isFile() || stat.size <= 0) return null;
101
+ return { path: target, size: stat.size, ...(metaOf(attachmentId) || {}) };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** 历史 / 实时帧里的引用块(App 的 `extractImages` 认这一种)。 */
108
+ function imageBlockOf(meta) {
109
+ if (!meta || typeof meta.attachmentId !== 'string') return null;
110
+ return {
111
+ type: 'image',
112
+ attachment: {
113
+ attachmentId: meta.attachmentId,
114
+ mediaType: meta.mediaType,
115
+ ...(meta.name ? { name: meta.name } : {}),
116
+ ...(meta.width && meta.height ? { width: meta.width, height: meta.height } : {}),
117
+ },
118
+ mediaType: meta.mediaType,
119
+ ...(meta.name ? { name: meta.name } : {}),
120
+ ...(meta.width && meta.height ? { width: meta.width, height: meta.height } : {}),
121
+ };
122
+ }
123
+
124
+ module.exports = {
125
+ MAX_IMAGE_BYTES,
126
+ MEDIA_EXT,
127
+ imageRoot,
128
+ saveImage,
129
+ metaOf,
130
+ openImage,
131
+ imageBlockOf,
132
+ };