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.
@@ -12,6 +12,30 @@
12
12
  */
13
13
 
14
14
  const { cutAssistantBlock, cutToolResultEvent, createToolNameIndex } = require('./wire-cut.js');
15
+ const { rememberToolResults, toolResultsFor } = require('./tool-result-cache.js');
16
+
17
+ /** 每一轮出过的图(工具结果里的图片块),轮号 → 图。`lookback` 也扫:它才装得下更早那一页。 */
18
+ function collectTurnImages(entries, lookbackEntries) {
19
+ const byTurn = new Map();
20
+ for (const list of [entries, lookbackEntries]) {
21
+ for (const entry of list || []) {
22
+ const e = entry && entry.event ? entry.event : entry;
23
+ if (!e || e.type !== 'tool/result' || !e.data || !e.data.message) continue;
24
+ const turn = typeof e.data.turn === 'number' ? e.data.turn : undefined;
25
+ if (turn === undefined) continue;
26
+ const { parts } = toolResultParts(cutToolResultEvent(e, '').data.message);
27
+ const images = toolResultImages(parts);
28
+ if (images.length === 0) continue;
29
+ const bucket = byTurn.get(turn) || [];
30
+ for (const image of images) {
31
+ if (bucket.some((kept) => kept.attachmentId && kept.attachmentId === image.attachmentId)) continue;
32
+ bucket.push(image);
33
+ }
34
+ byTurn.set(turn, bucket);
35
+ }
36
+ }
37
+ return byTurn;
38
+ }
15
39
  const {
16
40
  reasoningStepSummary,
17
41
  toolStepSummary,
@@ -61,12 +85,14 @@ function toolResultParts(msg) {
61
85
  * 图片块 → 移动端图片条目(`ChatMessage.images[]` / 工具块的 `images[]` 同一个形状)。
62
86
  *
63
87
  * 两条互斥的出口:
64
- * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, attachmentId }`,**不带 data**。
65
- * 字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
88
+ * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, width?, height?, attachmentId }`,
89
+ * **不带 data**。字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
66
90
  * 历史响应因此不再背着几十张截图的 base64。
67
- * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name? }`。
91
+ * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name?, width?, height? }`。
68
92
  * 这条兜底不能删——老日志、工具结果里存在这种直接带字节的图片块。
69
93
  *
94
+ * 宽高两格见 `imageDimsOf`:那是「手机能不能在挂载那一帧就占好位置」的前提。
95
+ *
70
96
  * 两条都要求 mediaType 可读(块上没有就退到 attachment 上的),否则 App 认不出来、
71
97
  * 也没有可显示的动作,返回 null 让调用方丢掉它。
72
98
  */
@@ -81,11 +107,28 @@ function imageEntryOf(block) {
81
107
  const name = typeof block.name === 'string'
82
108
  ? block.name
83
109
  : (typeof attachment.name === 'string' ? attachment.name : undefined);
84
- if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), attachmentId };
85
- if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}) };
110
+ const dims = imageDimsOf(block, attachment);
111
+ if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), ...dims, attachmentId };
112
+ if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}), ...dims };
86
113
  return null;
87
114
  }
88
115
 
116
+ /**
117
+ * 图片的原始宽高 —— 宿主的附件里本来就有(`attachment.width` / `attachment.height`)。
118
+ *
119
+ * **不许省掉这一格。** 手机侧要靠它把图片行的高度在**挂载那一帧就占好**:没有宽高,那一行
120
+ * 只能先按 240 方盒占位、等图解码完 onLoad 再改成真比例;而行高在挂载之后变高,正是对话页
121
+ * 「先落到底、再被顶回去」的来源(贴底列表上方一变高,屏幕上的内容就往下退一段)。
122
+ * 宽高非法(缺失 / 非正数)时整格不出现,调用方仍按旧兜底盒处理。
123
+ */
124
+ function imageDimsOf(block, attachment) {
125
+ const pick = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined);
126
+ const width = pick(block.width) ?? pick(attachment.width);
127
+ const height = pick(block.height) ?? pick(attachment.height);
128
+ if (width === undefined || height === undefined) return {};
129
+ return { width, height };
130
+ }
131
+
89
132
  /** 把 tool/result 的图片块收成移动端图片条目(两种出口见 imageEntryOf)。 */
90
133
  function toolResultImages(parts) {
91
134
  const out = [];
@@ -324,10 +367,23 @@ function presentedPathsOf(events) {
324
367
  * - command:command/run 发出行,command/done 原地升级。
325
368
  * 结构化项与消息按 seq 升序混排;旧载荷字段缺失时跳过对应项,绝不抛错。
326
369
  */
327
- function deriveMessages(events) {
370
+ function deriveMessages(events, sessionId, lookbackEvents) {
328
371
  const out = [];
329
372
  // 预扫描 tool/result 事件(同批次事件内,结果可能在调用块之前/之后出现)。
330
- const toolResults = buildToolResultMap(events);
373
+ // `lookbackEvents` 是这一页**再往前**的一小段:倒着分页时工具结果常常落在比消息更早的位置
374
+ // (一轮里先跑工具、最后才写正文),只看这一页就看不出这一轮出过哪些图。
375
+ const toolResults = buildToolResultMap(lookbackEvents && lookbackEvents.length ? events.concat(lookbackEvents) : events);
376
+ // 倒着分页时,一页的边界可能正好切在「工具调用」与「它的结果」之间:这一页只有调用,
377
+ // 结果在更新的一页(本机早就拿到过)。把那些记在进程里的结果并进来,图就跟着消息一起
378
+ // 下发,手机侧那一行在挂载那一帧就是终高(见 tool-result-cache.js)。
379
+ const remembered = toolResultsFor(sessionId);
380
+ if (remembered) {
381
+ for (const [callId, result] of remembered) {
382
+ if (!toolResults.has(callId)) toolResults.set(callId, result);
383
+ }
384
+ }
385
+ // 这一页看到的结果记下来,供更早的页用(同一进程里,后到的页是更早的那几页)。
386
+ rememberToolResults(sessionId, toolResults);
331
387
  // 预扫描 compaction/summary:检查点 user/message 与 summary 事件在日志里的先后不固定,
332
388
  // 用 compactionId → summary 数据把 counts 补全到同一压缩行上。
333
389
  const summaries = new Map();
@@ -505,12 +561,17 @@ function deriveMessages(events) {
505
561
  });
506
562
  const text = blocksToText(blocks);
507
563
  // 图片条目统一投影:引用模式带 attachmentId 不带 data,内联兜底带 data 不带 attachmentId。
564
+ //
565
+ // **宽高必须一起带上**(`imageDimsOf` 的口径):手机侧靠它在挂载那一帧就把图片行的高度占好,
566
+ // 少这两格就只能先占 240 方盒、等解码完再改,而贴底列表上方一变高,屏幕上看到的内容就
567
+ // 往下退一段 —— 这正是「先落到底、再被顶回去」那一跳。
508
568
  const images = [
509
569
  ...blocks.filter((b) => b.type === 'image'),
510
570
  ...toolImages,
511
- ].map(({ mediaType, data, name, attachmentId }) => ({
571
+ ].map(({ mediaType, data, name, attachmentId, width, height }) => ({
512
572
  mediaType,
513
573
  ...(typeof name === 'string' ? { name } : {}),
574
+ ...(Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : {}),
514
575
  ...(typeof attachmentId === 'string' && attachmentId ? { attachmentId } : {}),
515
576
  ...(typeof attachmentId === 'string' && attachmentId ? {} : { data }),
516
577
  }));
@@ -568,6 +629,16 @@ function deriveMessages(events) {
568
629
  if (item.role === 'user') lastOpener = item;
569
630
  };
570
631
 
632
+ /**
633
+ * 这次压缩由哪条 slash 命令触发(`compaction/*` 上的 `sourceCommandId`)。
634
+ *
635
+ * 手动 `/compact` 时压缩行与命令行是同一件事的两半(桌面 `CompactionCommandCard` 只画一个
636
+ * `CompactionItem`),靠这个 id 配对,见出循环后的「认领」。
637
+ */
638
+ const commandIdOf = (data) => (data && typeof data.sourceCommandId === 'string' && data.sourceCommandId !== ''
639
+ ? data.sourceCommandId
640
+ : null);
641
+
571
642
  // 放入一行 compaction:同一 compactionId 只保留一行,检查点行原地替换 start 行(最终按 seq 排序归位)。
572
643
  const placeCompaction = (item) => {
573
644
  let index = compactionAt.get(item.id);
@@ -611,6 +682,9 @@ function deriveMessages(events) {
611
682
  : (msg.id != null ? String(msg.id) : null);
612
683
  if (id === null) continue;
613
684
  const s = summaries.get(id);
685
+ // 触发命令的 id 在检查点自己的 source 上(桌面 `compactSource` 读的就是它)。只认
686
+ // `compaction/*` 的 data 时,检查点先到 / 那些事件在窗口外就配不上命令行。
687
+ const commandId = typeof src.sourceCommandId === 'string' && src.sourceCommandId !== '' ? src.sourceCommandId : null;
614
688
  placeCompaction({
615
689
  id,
616
690
  kind: 'compaction',
@@ -618,6 +692,7 @@ function deriveMessages(events) {
618
692
  summary: s ? compactionSummaryText(s.summary) : null,
619
693
  shadowedItemCount: s ? shadowedCount(s.shadowedSeqs) : null,
620
694
  shadowedTokenCount: s ? shadowedTokens(s.shadowedTokenCount) : null,
695
+ ...(commandId ? { compactionCommandId: commandId } : {}),
621
696
  seq,
622
697
  time,
623
698
  });
@@ -655,6 +730,7 @@ function deriveMessages(events) {
655
730
  // 行已由检查点发出(done)时跳过,避免重复行。同一轮里已有没落定的行就**原地顶替**
656
731
  // (换 id/状态,位置留在第一次尝试那一处)—— 否则一次跑几十步就是几十条行。
657
732
  const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
733
+ const commandId = commandIdOf(e.data);
658
734
  if (idx < 0) {
659
735
  placeCompaction({
660
736
  id: e.data.compactionId,
@@ -666,6 +742,7 @@ function deriveMessages(events) {
666
742
  seq,
667
743
  time,
668
744
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
745
+ ...(commandId ? { compactionCommandId: commandId } : {}),
669
746
  });
670
747
  } else {
671
748
  const existing = out[idx];
@@ -675,6 +752,7 @@ function deriveMessages(events) {
675
752
  state: 'running',
676
753
  error: null,
677
754
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
755
+ ...(commandId ? { compactionCommandId: commandId } : {}),
678
756
  };
679
757
  compactionAt.set(e.data.compactionId, idx);
680
758
  }
@@ -683,6 +761,7 @@ function deriveMessages(events) {
683
761
  // 带原因。这条以前整条丢掉,于是被拒绝的压缩永远停在「正在压缩…」,失败一次都看不见。
684
762
  const error = typeof e.data.error === 'string' && e.data.error !== '' ? e.data.error : null;
685
763
  const idx = pendingCompactionAt(e.data.turn, e.data.compactionId);
764
+ const commandId = commandIdOf(e.data);
686
765
  if (idx < 0) {
687
766
  placeCompaction({
688
767
  id: e.data.compactionId,
@@ -695,6 +774,7 @@ function deriveMessages(events) {
695
774
  seq,
696
775
  time,
697
776
  ...(typeof e.data.turn === 'number' ? { turn: e.data.turn } : {}),
777
+ ...(commandId ? { compactionCommandId: commandId } : {}),
698
778
  });
699
779
  } else {
700
780
  const existing = out[idx];
@@ -703,6 +783,7 @@ function deriveMessages(events) {
703
783
  id: e.data.compactionId,
704
784
  state: error ? 'failed' : 'done',
705
785
  error,
786
+ ...(commandId ? { compactionCommandId: commandId } : {}),
706
787
  };
707
788
  compactionAt.set(e.data.compactionId, idx);
708
789
  }
@@ -710,12 +791,14 @@ function deriveMessages(events) {
710
791
  // 已有同 compactionId 的行(start 或检查点发出)→ 升级为 done 并补 counts;否则新增 done 行。
711
792
  const id = e.data.compactionId;
712
793
  const existing = compactionAt.has(id) ? out[compactionAt.get(id)] : null;
794
+ const commandId = commandIdOf(e.data);
713
795
  if (existing) {
714
796
  existing.state = 'done';
715
797
  existing.error = null;
716
798
  existing.summary = compactionSummaryText(e.data.summary);
717
799
  existing.shadowedItemCount = shadowedCount(e.data.shadowedSeqs);
718
800
  existing.shadowedTokenCount = shadowedTokens(e.data.shadowedTokenCount);
801
+ if (commandId) existing.compactionCommandId = commandId;
719
802
  } else {
720
803
  placeCompaction({
721
804
  id,
@@ -726,6 +809,7 @@ function deriveMessages(events) {
726
809
  shadowedTokenCount: shadowedTokens(e.data.shadowedTokenCount),
727
810
  seq,
728
811
  time,
812
+ ...(commandId ? { compactionCommandId: commandId } : {}),
729
813
  });
730
814
  }
731
815
  } else if (e.type === 'llm/retry' && e.data && typeof e.data.retryId === 'string' && e.data.retryId !== '' && typeof e.data.retry === 'number') {
@@ -783,7 +867,9 @@ function deriveMessages(events) {
783
867
  } else if (e.type === 'command/done' && e.data && typeof e.data.commandId === 'string' && e.data.commandId !== '') {
784
868
  // 升级同 commandId 行(name 保 run 的);done 先到(罕见)则按 done 内容建行。
785
869
  const id = e.data.commandId;
786
- const done = { kind: 'command', status: 'done', seq, time };
870
+ // `id` 必须落在行上:done 先到(增量窗口从命令行尾巴开始时就是这种)时这条行是新建的,
871
+ // 没有 id 的话 App 按 id 合并就配不上那半截 run 行 —— 屏幕上会多出一行「命令 · …」。
872
+ const done = { id, kind: 'command', status: 'done', seq, time };
787
873
  if (e.data.kind === 'error' || e.data.kind === 'success') done.resultKind = e.data.kind;
788
874
  if (typeof e.data.text === 'string' && e.data.text !== '') done.text = e.data.text;
789
875
  const existing = commandAt.has(id) ? out[commandAt.get(id)] : null;
@@ -825,6 +911,24 @@ function deriveMessages(events) {
825
911
  };
826
912
  }
827
913
  }
914
+ /**
915
+ * 这一轮出过的图也按轮回填到**最后一条正文**上。
916
+ *
917
+ * 倒着翻页时,工具结果(图在这里)的 seq 往往比这条正文更早 —— 那一页后到,正文先上屏,
918
+ * 于是这一行先按「没图」的高度排好,等下一页把图带回来才长高;它在用户头顶上,一长高就把
919
+ * 正看的内容推走。`lookbackEvents` 里已经能看到这一轮的结果,就先挂到正文那条上(与 App
920
+ * `chatRenderRows` 的 settleImages 同口径),行在挂载那一帧就是终高。
921
+ */
922
+ if (producedItems.length > 0) {
923
+ const byTurn = collectTurnImages(events, lookbackEvents);
924
+ for (const [turn, images] of byTurn) {
925
+ // 这一轮已经有消息带图(工具调用那条就在这一页里):别重复挂,App 那边也会去重。
926
+ if (producedItems.some((entry) => entry.turn === turn && (entry.item.images?.length ?? 0) > 0)) continue;
927
+ const carriers = producedItems.filter((entry) => entry.turn === turn && entry.item.role !== 'user' && entry.item.text);
928
+ const last = carriers[carriers.length - 1];
929
+ if (last) last.item.images = images;
930
+ }
931
+ }
828
932
  // 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
829
933
  // 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
830
934
  // 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
@@ -835,6 +939,41 @@ function deriveMessages(events) {
835
939
  if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
836
940
  }
837
941
  }
942
+ // 手动 `/compact` 在桌面上是**一件事**:`CompactionCommandCard` 只画一个 `CompactionItem`
943
+ // (标题取命令名,摘要位放条数与 token 数,条数不齐时放命令结果原文),命令行那块根本不出现。
944
+ // 宿主的 command/run·done 与压缩检查点由 `sourceCommandId` 连在一起,所以这里让压缩行认领
945
+ // 命令行:压缩行继承命令名,命令行整条不发给手机 —— 否则屏幕上会多出一行「命令 ·
946
+ // Compacted N history items」(名字在增量窗口里丢了就掉成「命令」,摘要是宿主 en locale 的
947
+ // 英文原句,手机不本地化)。
948
+ const commandNames = new Map();
949
+ for (const item of out) {
950
+ if (!item || item.kind !== 'command') continue;
951
+ const entry = {};
952
+ if (typeof item.name === 'string' && item.name !== '') entry.name = item.name;
953
+ if (typeof item.text === 'string' && item.text !== '') entry.text = item.text;
954
+ if (entry.name !== undefined || entry.text !== undefined) commandNames.set(item.id, entry);
955
+ }
956
+ const claimedCommands = new Set();
957
+ for (const item of out) {
958
+ if (!item || item.kind !== 'compaction') continue;
959
+ const commandId = item.compactionCommandId;
960
+ if (typeof commandId !== 'string' || commandId === '') continue;
961
+ const paired = commandNames.get(commandId);
962
+ // 标题取命令名;命令行那半截不在窗口里也写 compact —— 只有 /compact 能触发压缩,桌面
963
+ // `fallbackState` 同样把名字兜成 'compact',否则这一行会掉回「上下文已压缩」。
964
+ item.compactionTitle = paired && paired.name !== undefined ? paired.name : 'compact';
965
+ // 条数不齐时摘要位用命令结果原文顶上(桌面 `fallbackSummary: node.outcome?.text`)。
966
+ if (paired && paired.text !== undefined && (item.shadowedItemCount == null || item.shadowedTokenCount == null)) {
967
+ item.compactionFallbackSummary = paired.text;
968
+ }
969
+ claimedCommands.add(commandId);
970
+ }
971
+ if (claimedCommands.size > 0) {
972
+ for (let index = out.length - 1; index >= 0; index -= 1) {
973
+ const item = out[index];
974
+ if (item && item.kind === 'command' && claimedCommands.has(item.id)) out.splice(index, 1);
975
+ }
976
+ }
838
977
  // 压缩行带一个空正文:直播侧的同款行本来就带 `text: ''`(App 的 `structuralItems`
839
978
  // 一条不落),历史这边少这一格时,没跟上判空修复的旧安装包会在这条行上直接读正文、
840
979
  // 当场抛错、整页崩掉(2026-10-27 真机)。标题与摘要照旧按 kind 走,不读这个字段;
@@ -1,25 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 正文图片内联:把消息文本里指向本机文件的图片语法(`![](/abs/path.png)`)换成 data URI,
5
- * 让手机端能直接画出来。
4
+ * 正文里本机图片 → 一个短编号(`![alt](dsh-local-image:<id>?w=..&h=..)`)。
6
5
  *
7
- * 手机拿到的只是文本,`/Users/…/a.png` 既不是手机上的文件,移动 API 也没有「按路径读文件」
8
- * 的能力面,原样过去只会显示成一串字面 markdown。这里把「正文里写的本机图片」拉齐到附件那种
9
- * 内联形状,App 不需要新增通道。
6
+ * 为什么不内联 base64(2026-10-29 改):整张图的字节塞进消息正文有三个代价,全落在用户身上 ——
7
+ * ① 正文里没有宽高,手机只能先按固定尺寸占位、等解码完再改;翻页时那一行高度晚到,内容被顶;
8
+ * ② 体积:一张 135KB 的图进正文就是 180KB 字符,而正文整段会进本地缓存(1MiB 预算),
9
+ * 六七张图就把缓存吃穿 —— 于是「打开过很多次还是要重新加载」;
10
+ * ③ 每次重拉历史都要重传一遍。
11
+ * 业界(WhatsApp / Telegram / Slack / 各家大模型客户端)都是「消息里只放引用 + 宽高」,
12
+ * 这里对齐同一条口径:正文只留编号,字节按编号单张取(`/session/local-image`)。
13
+ *
14
+ * 安全边界不变:**不是「按路径读文件」**。编号只由本模块在投影历史时登记,取图口只认
15
+ * 登记表里的编号 —— 构造一个编号读不到任何别的文件。登记表活在进程内;插件重启后老编号
16
+ * 取不到就如实 404,App 走「失败 + 重试」那条现成的路。
10
17
  *
11
18
  * 保守取舍——宁可保留字面 markdown,也不给半截东西:
12
19
  * - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
13
20
  * 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
14
21
  * - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
15
- * - 三级体积预算(单图 / 单段 / 单次响应),超预算就保持字面;
16
- * - 读不到、不是普通文件、读取失败一律原样保留;
22
+ * - 宽高只读文件头(最多 64KB);读不出来就不带宽高,App 那边照旧现量;
17
23
  * - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
18
24
  */
19
-
20
25
  const fs = require('node:fs/promises');
21
26
  const os = require('node:os');
22
27
  const path = require('node:path');
28
+ const crypto = require('node:crypto');
23
29
 
24
30
  /**
25
31
  * `![alt](dest)`。dest 两种写法都认:裸路径(不含空白与右括号)与 `<>` 包裹(里面可以有空格,
@@ -38,17 +44,21 @@ const MEDIA_TYPES = {
38
44
  '.gif': 'image/gif',
39
45
  };
40
46
 
41
- /** 单张原图上限(base64 后约 1.33 倍,见下面的字符预算)。 */
47
+ /** 单张图上限:再大的东西不该整包回给手机(超出如实保持字面 markdown)。 */
42
48
  const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
43
- /** 一段文本里内联进去的 data URI 总字符上限。 */
44
- const MAX_TEXT_CHARS = 3 * 1024 * 1024;
45
- /** 一次响应(一页历史 / 一帧 SSE / 一次镜像快照)共用的字符上限。 */
46
- const MAX_RESPONSE_CHARS = 5 * 1024 * 1024;
47
- /** data URI 复用条数:同一张图常在同一轮里出现多次(历史 + 流式 + 镜像)。 */
48
- const MAX_CACHE_ENTRIES = 8;
49
+ /** 读宽高只需要文件头:JPEG 的 SOF 段可能在几百字节之后,64KB 足够覆盖绝大多实拍图。 */
50
+ const HEAD_BYTES = 64 * 1024;
51
+ /** 登记表上限:翻历史时同一张图反复出现也只登记一次,512 条够一两百个会话轮着用。 */
52
+ const MAX_REGISTRY_ENTRIES = 512;
53
+ /** 一张响应(一页历史 / 一帧 SSE / 一次镜像快照)里最多换几张:防一段文字里几十上百个图片语法。 */
54
+ const MAX_TEXT_IMAGES = 64;
49
55
 
50
- /** path+mtime+size → data URI。会话里同一张图会被反复解析,但文件内容只读一次。 */
51
- const cache = new Map();
56
+ /**
57
+ * 编号 → { filePath, mediaType, width?, height? }。
58
+ *
59
+ * 只由本模块登记,取图口只认这里面的编号 —— 这是「不扩能力面」的全部依据(见文件头)。
60
+ */
61
+ const registry = new Map();
52
62
 
53
63
  function mediaTypeFor(filePath) {
54
64
  return MEDIA_TYPES[path.extname(filePath).toLowerCase()] || null;
@@ -119,40 +129,103 @@ function hasRelativeImage(text) {
119
129
  * `cwd` 由调用方按需补上(相对地址的解析基准,见 localImagePathOf):`undefined` 表示
120
130
  * 「还没查过工作区目录」,空串表示「查过了,这条会话没有」。
121
131
  */
122
- function createTextImageBudget(limit = MAX_RESPONSE_CHARS) {
132
+ function createTextImageBudget(limit = MAX_TEXT_IMAGES) {
123
133
  return { remaining: limit };
124
134
  }
125
135
 
126
- /** 读文件 → data URI(带缓存);不满足条件一律 null。 */
127
- async function dataUriFor(filePath) {
128
- let stat;
136
+ /** 从文件头解析宽高;认不出来返回 null(不是错误:App 那边照旧现量)。 */
137
+ function imageSizeOf(buf) {
138
+ if (buf.length >= 24 && buf[0] === 0x89 && buf.toString('latin1', 1, 4) === 'PNG') {
139
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
140
+ }
141
+ if (buf.length >= 10 && buf.toString('latin1', 0, 3) === 'GIF') {
142
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
143
+ }
144
+ if (buf.length >= 30 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {
145
+ const kind = buf.toString('latin1', 12, 16);
146
+ if (kind === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
147
+ if (kind === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
148
+ if (kind === 'VP8L' && buf.length >= 25) {
149
+ const bits = buf.readUInt32LE(21);
150
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
151
+ }
152
+ return null;
153
+ }
154
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
155
+ let at = 2;
156
+ while (at + 9 < buf.length) {
157
+ if (buf[at] !== 0xff) { at += 1; continue; }
158
+ const marker = buf[at + 1];
159
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
160
+ const length = buf.readUInt16BE(at + 2);
161
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
162
+ return { width: buf.readUInt16BE(at + 7), height: buf.readUInt16BE(at + 5) };
163
+ }
164
+ if (length < 2) return null;
165
+ at += 2 + length;
166
+ }
167
+ return null;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** 读文件头(最多 HEAD_BYTES);打不开就 null。 */
173
+ async function readHead(filePath, size) {
174
+ let handle;
129
175
  try {
130
- stat = await fs.stat(filePath);
176
+ handle = await fs.open(filePath, 'r');
177
+ const length = Math.min(size, HEAD_BYTES);
178
+ const buf = Buffer.alloc(length);
179
+ await handle.read(buf, 0, length, 0);
180
+ return buf;
131
181
  } catch {
132
182
  return null;
183
+ } finally {
184
+ if (handle) await handle.close().catch(() => {});
133
185
  }
134
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
135
- const key = `${filePath}:${stat.size}:${stat.mtimeMs}`;
136
- const hit = cache.get(key);
137
- if (hit) {
138
- cache.delete(key);
139
- cache.set(key, hit);
140
- return hit;
141
- }
142
- let buf;
186
+ }
187
+
188
+ /**
189
+ * 登记一张本机图片,返回它的编号与宽高。
190
+ *
191
+ * 编号取「路径 + 大小 + 修改时间」的短哈希:同一张图在同一轮历史里会被反复解析,
192
+ * 这个组合稳定,且改过之后自然换号(图片内容变了本来就该换)。
193
+ */
194
+ async function registerLocalImage(filePath) {
195
+ let stat;
143
196
  try {
144
- buf = await fs.readFile(filePath);
197
+ stat = await fs.stat(filePath);
145
198
  } catch {
146
199
  return null;
147
200
  }
148
- const mediaType = sniffMediaType(buf);
201
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
202
+ const id = crypto.createHash('sha1').update(`${filePath}:${stat.size}:${stat.mtimeMs}`).digest('hex').slice(0, 16);
203
+ const known = registry.get(id);
204
+ if (known) return { id, ...known };
205
+ const head = await readHead(filePath, stat.size);
206
+ if (!head) return null;
207
+ const mediaType = sniffMediaType(head);
149
208
  if (!mediaType) return null;
150
- const uri = `data:${mediaType};base64,${buf.toString('base64')}`;
151
- cache.set(key, uri);
152
- while (cache.size > MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value);
153
- return uri;
209
+ const parsed = imageSizeOf(head);
210
+ const sane = parsed && parsed.width > 0 && parsed.height > 0
211
+ && parsed.width <= 30000 && parsed.height <= 30000;
212
+ const size = sane ? parsed : {};
213
+ const entry = { filePath, mediaType, ...size };
214
+ registry.set(id, entry);
215
+ while (registry.size > MAX_REGISTRY_ENTRIES) registry.delete(registry.keys().next().value);
216
+ return { id, ...entry };
217
+ }
218
+
219
+ /** 取图口用:只认登记表里的编号(构造不出来的编号一律 null → 404)。 */
220
+ function localImageOf(id) {
221
+ if (typeof id !== 'string' || !id) return null;
222
+ return registry.get(id) || null;
154
223
  }
155
224
 
225
+ /** 清空登记表(测试用)。 */
226
+ function clearLocalImages() {
227
+ registry.clear();
228
+ }
156
229
  /**
157
230
  * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
158
231
  *
@@ -232,8 +305,10 @@ function inRanges(index, ranges) {
232
305
  }
233
306
 
234
307
  /**
235
- * 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
236
- * 调用方据此免掉不必要的对象拷贝。
308
+ * 把一段文本里的本机图片语法换成**短编号**(带宽高):`![alt](dsh-local-image:<id>?w=..&h=..)`。
309
+ *
310
+ * 返回原字符串本身(`===`)表示一个字没动,调用方据此免掉不必要的对象拷贝。
311
+ * `budget` 只为兼容调用点保留:标识只有几十字节,不再有字符预算这回事。
237
312
  */
238
313
  async function inlineTextImages(text, budget) {
239
314
  if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
@@ -241,21 +316,99 @@ async function inlineTextImages(text, budget) {
241
316
  const matches = [...text.matchAll(IMAGE_RE)].filter((m) => !inRanges(m.index, ranges));
242
317
  if (!matches.length) return text;
243
318
  const pool = budget || createTextImageBudget();
244
- let textBudget = MAX_TEXT_CHARS;
319
+ let count = pool.remaining;
245
320
  let out = '';
246
321
  let cursor = 0;
247
322
  for (const m of matches) {
248
- if (textBudget <= 0 || pool.remaining <= 0) break;
323
+ if (count <= 0) break;
249
324
  const filePath = localImagePathOf(m[2], pool.cwd);
250
325
  if (!filePath) continue;
251
- const uri = await dataUriFor(filePath);
252
- if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
253
- textBudget -= uri.length;
254
- pool.remaining -= uri.length;
255
- out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
326
+ const entry = await registerLocalImage(filePath);
327
+ if (!entry) continue;
328
+ count -= 1;
329
+ pool.remaining = count;
330
+ const query = entry.width && entry.height ? `?w=${entry.width}&h=${entry.height}` : '';
331
+ out += text.slice(cursor, m.index) + `![${m[1]}](dsh-local-image:${entry.id}${query})`;
256
332
  cursor = m.index + m[0].length;
257
333
  }
258
334
  return cursor === 0 ? text : out + text.slice(cursor);
259
335
  }
336
+ /**
337
+ * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
338
+ *
339
+ * 代码块里的 `![](/tmp/a.png)` 是**写法示例**而不是要画的图。旧实现整段正则替换,
340
+ * 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
341
+ * 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
342
+ * `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
343
+ */
344
+ function literalRanges(text) {
345
+ const ranges = [];
346
+ const lines = text.split('\n');
347
+ const lineStarts = [];
348
+ let offset = 0;
349
+ for (const line of lines) {
350
+ lineStarts.push(offset);
351
+ offset += line.length + 1;
352
+ }
353
+ let fence = null;
354
+ let fenceStart = -1;
355
+ for (let index = 0; index < lines.length; index += 1) {
356
+ const line = lines[index];
357
+ if (fence) {
358
+ const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
359
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
360
+ ranges.push([fenceStart, lineStarts[index] + line.length]);
361
+ fence = null;
362
+ }
363
+ continue;
364
+ }
365
+ const open = /^(`{3,}|~{3,})/.exec(line.trim());
366
+ if (!open) continue;
367
+ fence = { char: open[1][0], length: open[1].length };
368
+ fenceStart = lineStarts[index];
369
+ }
370
+ // 没闭合的围栏按 App 的解析口径一直吃到文末。
371
+ if (fence) ranges.push([fenceStart, text.length]);
372
+
373
+ // 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
374
+ let cursor = 0;
375
+ while (cursor < text.length) {
376
+ if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
377
+ cursor += 1;
378
+ continue;
379
+ }
380
+ let ticks = 1;
381
+ while (text[cursor + ticks] === '`') ticks += 1;
382
+ let scan = cursor + ticks;
383
+ let closed = -1;
384
+ while (scan < text.length) {
385
+ if (text[scan] !== '`' || inRanges(scan, ranges)) {
386
+ scan += 1;
387
+ continue;
388
+ }
389
+ let run = 1;
390
+ while (text[scan + run] === '`') run += 1;
391
+ if (run === ticks) {
392
+ closed = scan + run;
393
+ break;
394
+ }
395
+ scan += run;
396
+ }
397
+ if (closed < 0) {
398
+ cursor += ticks;
399
+ continue;
400
+ }
401
+ ranges.push([cursor, closed]);
402
+ cursor = closed;
403
+ }
404
+ return ranges;
405
+ }
406
+
407
+ function inRanges(index, ranges) {
408
+ for (const [start, end] of ranges) {
409
+ if (index >= start && index < end) return true;
410
+ }
411
+ return false;
412
+ }
260
413
 
261
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage };
414
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 正文图的字节(POST /api/mobile/v1/session/local-image)。
5
+ *
6
+ * 正文里那张图在历史投影时已经被登记成一个短编号(见 `local-images.js`),手机画到它时
7
+ * 拿着编号来取字节。**这不是「按路径读文件」**:只认登记表里的编号,构造一个编号读不到
8
+ * 任何别的文件 —— 能力面与附件那条 `session/attachment` 同一个口径。
9
+ *
10
+ * 只认 `id`、不认 sessionId:授权依据是「这张图在投影这条会话时被登记过」,而不是调用方
11
+ * 自报的会话号。登记表是进程级的,同一个编号从哪条会话取都一样(本来就是同一份文件)。
12
+ *
13
+ * 回 base64 JSON 而不是流:正文图都是单张位图、上限 2.5MB,与附件那条一致;大文件
14
+ * (视频 / 交付物)走 `session/file` 的流式那条,别混。
15
+ */
16
+
17
+ const fs = require('node:fs/promises');
18
+ const { sendJson, readJsonBody } = require('../shared/http-util');
19
+ const { localImageOf } = require('./local-images.js');
20
+
21
+ async function handleSessionLocalImage(ctx, req, res) {
22
+ const body = await readJsonBody(req);
23
+ if (!body || typeof body.id !== 'string' || !body.id) {
24
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { id }' } });
25
+ }
26
+ const entry = localImageOf(body.id);
27
+ // 插件重启、或那条记录被挤出登记表:如实 404,让 App 把重试按钮的失败说清楚。
28
+ if (!entry) {
29
+ return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '这张图没有登记,或登记已失效' } });
30
+ }
31
+ let data;
32
+ try {
33
+ data = await fs.readFile(entry.filePath);
34
+ } catch {
35
+ return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '图片文件已经不在了' } });
36
+ }
37
+ sendJson(res, 200, {
38
+ ok: true,
39
+ mediaType: entry.mediaType,
40
+ data: data.toString('base64'),
41
+ ...(entry.width && entry.height ? { width: entry.width, height: entry.height } : {}),
42
+ });
43
+ }
44
+
45
+ module.exports = { handleSessionLocalImage };