cc-viewer 1.6.293 → 1.6.294

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.
Files changed (88) hide show
  1. package/dist/assets/App-C66LoBEz.js +2 -0
  2. package/dist/assets/App-eFrjLzF_.css +1 -0
  3. package/dist/assets/{MdxEditorPanel-Cf01KF6Z.js → MdxEditorPanel-B8xrlDZJ.js} +1 -1
  4. package/dist/assets/{Mobile-BJlGkvAP.js → Mobile-fsi8-Lpb.js} +1 -1
  5. package/dist/assets/{_baseUniq-CPUnJ5bQ.js → _baseUniq-r3p3rodd.js} +1 -1
  6. package/dist/assets/{arc-WhuJ-oY5.js → arc-CjTV5gxc.js} +1 -1
  7. package/dist/assets/{architectureDiagram-Q4EWVU46-CWx77Yhd.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
  8. package/dist/assets/{blockDiagram-DXYQGD6D-D7AQLCoj.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
  9. package/dist/assets/{c4Diagram-AHTNJAMY-BoPHNqCF.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
  10. package/dist/assets/{channel-B9Ja6Xkc.js → channel-yOyhvOLV.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-B-b0RYab.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
  12. package/dist/assets/{chunk-4TB4RGXK-BK_V34yf.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-D-kMbu-2.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
  14. package/dist/assets/{chunk-EDXVE4YY-CEtSkZzd.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-BXa_7Pn3.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
  16. package/dist/assets/{chunk-OYMX7WX6-tvM_OApS.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-DrEmcVHf.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
  18. package/dist/assets/{chunk-YZCP3GAM-D2M9T_R5.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
  19. package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
  20. package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
  21. package/dist/assets/clone-GDqN3kwT.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-H7bkwu5F.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
  23. package/dist/assets/{dagre-KV5264BT-DKXEGN18.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
  24. package/dist/assets/{diagram-5BDNPKRD-DZFhwpI3.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
  25. package/dist/assets/{diagram-G4DWMVQ6-Crg9GlIk.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
  26. package/dist/assets/{diagram-MMDJMWI5-B8Qn1fKP.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
  27. package/dist/assets/{diagram-TYMM5635-BHE1LjtY.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
  28. package/dist/assets/{erDiagram-SMLLAGMA-BaEqFWLd.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-b2ukTawV.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
  30. package/dist/assets/{ganttDiagram-T4ZO3ILL-D5quyFgK.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BE1H5_fN.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
  32. package/dist/assets/{graph-D_JLoOax.js → graph-I1olozIg.js} +1 -1
  33. package/dist/assets/{index-Cx8bk0Tp.js → index-7vxIrUNA.js} +1 -1
  34. package/dist/assets/index-BTZqk5O5.js +2 -0
  35. package/dist/assets/{index-BDUs32pN.css → index-Be9T-kDq.css} +1 -1
  36. package/dist/assets/{index-CtrY6gFZ.js → index-C1RNAzAB.js} +1 -1
  37. package/dist/assets/{index-CQrdpZQb.js → index-Cf4FBg-V.js} +1 -1
  38. package/dist/assets/{index-B8UmlA4F.js → index-D-HPuqxB.js} +1 -1
  39. package/dist/assets/{index-k0AH8cvI.js → index-D2QUxu18.js} +1 -1
  40. package/dist/assets/{index-DiZ9CErG.js → index-DhzoJ5wE.js} +1 -1
  41. package/dist/assets/{index-CWjqMDrs.js → index-fhI0i2p3.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-DQKlrVkw.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BchFlpPc.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-Dg1mt4df.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
  45. package/dist/assets/{jszip.min-LIb2SFoK.js → jszip.min-DDU-_oA-.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-226va2PS.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
  47. package/dist/assets/{layout-rSa8rcPi.js → layout-DBmqcl9N.js} +1 -1
  48. package/dist/assets/{linear-BeARi8nH.js → linear-Br9n7mCI.js} +1 -1
  49. package/dist/assets/{mermaid.core-CDgdx9l7.js → mermaid.core-BV3ugHFm.js} +2 -2
  50. package/dist/assets/{min-B9yebCuj.js → min-D-YA3MGY.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-C3apVbdg.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-xjOQoQeL.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-Dq8x_VN2.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-CLmO1Gai.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-BuUP1Eqq.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
  56. package/dist/assets/seqResourceLoaders-6k4uXcNn.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-B18koU20.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-Cj57OCcO.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-C01a2p--.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-cOlsEN_F.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
  62. package/dist/assets/{vendor-antd-DqFS7Zj9.js → vendor-antd-Bur5ZxWE.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-B_pF4DrA.js → vendor-codemirror-Si44UqBp.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-B_IrHcWH.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-C4PneAS5.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-CEGeJyDP.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-BCjdwiDk.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-CRmLlBwn.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-BJYVDJ4F.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-el5C4S1Z.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
  71. package/dist/index.html +5 -5
  72. package/package.json +1 -1
  73. package/server/lib/adapters/dingtalk-adapter.js +7 -0
  74. package/server/lib/adapters/discord-adapter.js +9 -1
  75. package/server/lib/adapters/feishu-adapter.js +19 -0
  76. package/server/lib/adapters/wecom-adapter.js +4 -0
  77. package/server/lib/im-bridge-core.js +59 -7
  78. package/server/lib/im-claude-md.js +37 -1
  79. package/server/lib/im-senders.js +73 -0
  80. package/server/routes/im.js +117 -3
  81. package/server/routes/skills.js +180 -165
  82. package/dist/assets/App-DRvRd96X.css +0 -1
  83. package/dist/assets/App-OM2oqZRW.js +0 -1
  84. package/dist/assets/classDiagram-6PBFFD2Q-CCwGJXEA.js +0 -1
  85. package/dist/assets/classDiagram-v2-HSJHXN6E-CCwGJXEA.js +0 -1
  86. package/dist/assets/clone-BuQbTPQO.js +0 -1
  87. package/dist/assets/index-CnWSVlWW.js +0 -2
  88. package/dist/assets/seqResourceLoaders-BZ6M3Jb-.js +0 -2
@@ -23,6 +23,7 @@ import { existsSync, readFileSync, appendFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { LOG_DIR } from '../../findcc.js';
25
25
  import { t } from '../i18n.js';
26
+ import { upsertSender } from './im-senders.js';
26
27
 
27
28
  // ─── tunables (shared across platforms; per-platform rate caps come from adapter.rateLimit) ───
28
29
  const SEEN_MAX = 500;
@@ -93,6 +94,51 @@ function ctxFor(inst) {
93
94
  return { fetch: coreFetch, store: inst.store };
94
95
  }
95
96
 
97
+ // 发送者身份解析的缓存有效期:同一 senderId 在此窗口内只解析一次(避免每条消息打 contact API)。
98
+ const SENDER_RESOLVE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
99
+ // 负缓存有效期:解析「没拿到任何身份」(外部用户 / 无 scope / API 失败)也要短期记一笔,否则像飞书这种
100
+ // 无免费字段、靠 contact API 的平台会对每条消息重复打 API(触发租户限流)。10min 后再重试,兼顾「拿到瞬时失败可恢复」。
101
+ const SENDER_NEG_TTL_MS = 10 * 60 * 1000;
102
+
103
+ /**
104
+ * 后台解析发送者 {name, avatar} 并持久化到 IM_<id>/im-senders.json(供 /senders 路由 + 对话记录展示)。
105
+ * 优先用 normalizeInbound 免费带出的 senderName/senderAvatar;缺失再调可选的 adapter.resolveSender
106
+ * (需打 contact API 的平台)。整段静默:任何失败都不得影响消息注入。**调用方必须 void(不 await)。**
107
+ */
108
+ async function resolveAndPersistSender(inst, senderId, normalized) {
109
+ if (!senderId) return;
110
+ const store = inst.store || (inst.store = {});
111
+ const cache = store.senderCache || (store.senderCache = {});
112
+ const now = Date.now();
113
+ // 缓存项形如 { ts, ok }:解析成功用 7d TTL,负缓存(没拿到)用 10min TTL。
114
+ const c = cache[senderId];
115
+ if (c && (now - c.ts) < (c.ok ? SENDER_RESOLVE_TTL_MS : SENDER_NEG_TTL_MS)) return;
116
+ try {
117
+ let name = normalized.senderName != null ? String(normalized.senderName) : null;
118
+ let avatar = normalized.senderAvatar != null ? String(normalized.senderAvatar) : null;
119
+ // 免费字段不全且适配器支持 → 调一次 contact API 补齐(仅 feishu;dingtalk/wecom 无 resolveSender)。
120
+ let attemptedResolve = false;
121
+ if ((!name || !avatar) && typeof inst.adapter.resolveSender === 'function') {
122
+ attemptedResolve = true;
123
+ const r = await inst.adapter.resolveSender(inst.bridgeDeps.getConfig(), senderId, ctxFor(inst));
124
+ if (r) {
125
+ if (!name && r.name != null) name = String(r.name);
126
+ if (!avatar && r.avatar != null) avatar = String(r.avatar);
127
+ }
128
+ }
129
+ if (name || avatar) {
130
+ upsertSender(inst.adapter.id, senderId, { name, avatar });
131
+ cache[senderId] = { ts: now, ok: true };
132
+ } else if (attemptedResolve) {
133
+ // 调过 contact API 但什么都没拿到 → 负缓存,避免对该发送者每条消息反复打 API。
134
+ cache[senderId] = { ts: now, ok: false };
135
+ }
136
+ } catch (e) {
137
+ cache[senderId] = { ts: now, ok: false }; // 失败也负缓存(短 TTL,10min 后自动重试)
138
+ audit(inst, 'resolve-sender-error', { senderId, error: String(e?.message || e) });
139
+ }
140
+ }
141
+
96
142
  function queueCap(inst) {
97
143
  return inst.maxQueueOverride ?? MAX_QUEUE;
98
144
  }
@@ -129,12 +175,15 @@ function bracketPasteSubmit(text) {
129
175
  return ['\x1b[200~' + text + '\x1b[201~', '\r'];
130
176
  }
131
177
 
132
- /** Prepend the IM-origin marker `⟦im:<id>⟧`, EXCEPT for slash commands (a marker prefix would
133
- * stop the CLI from recognizing `/clear` etc.). trim() guards leading whitespace / full-width
134
- * spaces. KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js. */
135
- function markOrigin(id, content) {
178
+ /** Prepend the IM-origin marker `⟦im:<id>⟧` or `⟦im:<id>:<senderId>⟧`, EXCEPT for slash commands
179
+ * (a marker prefix would stop the CLI from recognizing `/clear` etc.). trim() guards leading
180
+ * whitespace / full-width spaces. senderId is embedded only when it's "safe" (no space / `:` / `⟧`)
181
+ * so the marker stays unambiguous; otherwise it degrades to the platform-only form.
182
+ * KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js. */
183
+ function markOrigin(id, senderId, content) {
136
184
  if (content.trim().startsWith('/')) return content;
137
- return `⟦im:${id}⟧` + content;
185
+ const safe = (typeof senderId === 'string' && /^[^\s:⟧]+$/.test(senderId)) ? `:${senderId}` : '';
186
+ return `⟦im:${id}${safe}⟧` + content;
138
187
  }
139
188
 
140
189
  /**
@@ -312,6 +361,9 @@ function handleInboundInner(inst, normalized) {
312
361
  }
313
362
 
314
363
  audit(inst, 'in', { msgId, senderId, conversationId, len: text.length });
364
+ // 后台解析并持久化发送者身份(姓名/头像)供「对话记录」展示。fire-and-forget:
365
+ // 绝不 await(解析可能要打 contact API),不阻塞/不影响下面的消息注入。
366
+ void resolveAndPersistSender(inst, senderId, normalized);
315
367
  if (!text) return; // non-text messages (image/voice/file) are ignored in v1
316
368
 
317
369
  if (isStopCommand(text)) {
@@ -331,7 +383,7 @@ function handleInboundInner(inst, normalized) {
331
383
  void sendReply(inst, target, tr(inst, 'queueFull'));
332
384
  return;
333
385
  }
334
- inst.queue.push({ ...target, content: text });
386
+ inst.queue.push({ ...target, senderId, content: text });
335
387
  if (activeInjection || inst.bridgeDeps.isStreaming()) {
336
388
  void sendReply(inst, target, tr(inst, 'busyQueued'));
337
389
  }
@@ -374,7 +426,7 @@ function drainQueue(inst) {
374
426
  // React to a failed injection (PTY gone/died mid-write → onComplete(false)). Without this the
375
427
  // prompt never submits, no turn_end ever comes, and the slot wedges until the timeout. Only
376
428
  // act if THIS injection still owns the slot (a /stop or timeout may have released it).
377
- d.writeToPtySequential(bracketPasteSubmit(markOrigin(inst.adapter.id, item.content)), (ok) => {
429
+ d.writeToPtySequential(bracketPasteSubmit(markOrigin(inst.adapter.id, item.senderId, item.content)), (ok) => {
378
430
  if (ok) return;
379
431
  if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
380
432
  audit(inst, 'inject-failed', { conversationId: item.conversationId });
@@ -3,9 +3,14 @@
3
3
  // + 强制 allowlist(见 plan §安全)。但它仍是抑制交互式工具、控制回复风格的主要手段。
4
4
  //
5
5
  // 用 openSync(path,'wx') 原子创建:从不覆盖用户已编辑的文件(避免 existsSync→write 的 TOCTOU)。
6
- import { openSync, writeFileSync, closeSync, mkdirSync } from 'node:fs';
6
+ import { openSync, writeFileSync, closeSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import { imDir } from './im-lock.js';
10
+ import { renameSyncWithRetry } from './file-api.js';
11
+
12
+ // CLAUDE.md 写入上限(字符数,按 String.length / UTF-16 码元计):远超任何合理人格定义,纯防失控大 body。
13
+ export const MAX_CLAUDE_MD_CHARS = 256 * 1024;
9
14
 
10
15
  // id → 双语展示名(CLAUDE.md 为内联内容,不走 i18n.js)。
11
16
  const PLATFORM_LABELS = {
@@ -80,3 +85,34 @@ export function ensureImClaudeMd(id, dir = imDir(id)) {
80
85
  }
81
86
  return true;
82
87
  }
88
+
89
+ /**
90
+ * 读取 IM_<id>/CLAUDE.md 当前内容(供「模型性格定义」编辑器加载)。
91
+ * 文件不存在 → 返回预置文本(尚未落盘),让编辑器展示默认人格供用户定制;保存时才写盘。
92
+ */
93
+ export function readImClaudeMd(id) {
94
+ try {
95
+ return readFileSync(join(imDir(id), 'CLAUDE.md'), 'utf-8');
96
+ } catch (e) {
97
+ if (e && e.code === 'ENOENT') return buildImClaudeMdPreset(id);
98
+ throw e;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 覆盖写 IM_<id>/CLAUDE.md(原子:temp + rename,mode 0600)。下次该 IM worker 重启时生效
104
+ * (CLAUDE.md 仅在 worker 启动时读取一次)。
105
+ */
106
+ export function writeImClaudeMd(id, content) {
107
+ const text = String(content ?? '');
108
+ const dir = imDir(id);
109
+ mkdirSync(dir, { recursive: true });
110
+ const tmp = join(dir, `CLAUDE.md.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
111
+ try {
112
+ writeFileSync(tmp, text, { mode: 0o600 });
113
+ renameSyncWithRetry(tmp, join(dir, 'CLAUDE.md'));
114
+ } catch (err) {
115
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
116
+ throw err;
117
+ }
118
+ }
@@ -0,0 +1,73 @@
1
+ // IM 发送者身份持久化 —— 每个 IM worker 把解析到的「发送者 senderId → {name, avatar}」写到
2
+ // ~/.claude/cc-viewer/IM_<id>/im-senders.json,供主进程的 /api/im/:platform/senders 读取,
3
+ // 让「对话记录」弹窗能按 senderId 显示真实姓名 + 头像。
4
+ //
5
+ // 设计(与 im-lock.js 同风格):
6
+ // - 原子写:temp + renameSyncWithRetry,读方永不见半写 JSON。
7
+ // - 读容忍:坏 / 缺 / 非对象一律降级为 {},绝不抛。
8
+ // - 容量上限:按 ts 保留最近 MAX_SENDERS 个,避免无限增长(群聊发送者可能很多)。
9
+ // - 这些是本地数据(不入 preferences、不外发),与现有本地日志同级。
10
+ import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { imDir } from './im-lock.js';
14
+ import { renameSyncWithRetry } from './file-api.js';
15
+
16
+ export const MAX_SENDERS = 500;
17
+
18
+ function sendersPath(id) { return join(imDir(id), 'im-senders.json'); }
19
+
20
+ /** 读发送者映射;不存在 / 坏 JSON / 非对象 → {}。 */
21
+ export function readSenders(id) {
22
+ try {
23
+ const o = JSON.parse(readFileSync(sendersPath(id), 'utf-8'));
24
+ return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {};
25
+ } catch { return {}; }
26
+ }
27
+
28
+ /** 原子写整张映射(temp + rename)。 */
29
+ function writeSenders(id, map) {
30
+ const dir = imDir(id);
31
+ mkdirSync(dir, { recursive: true });
32
+ const tmp = join(dir, `im-senders.json.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
33
+ try {
34
+ writeFileSync(tmp, JSON.stringify(map), { mode: 0o600 });
35
+ renameSyncWithRetry(tmp, sendersPath(id));
36
+ } catch (err) {
37
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * upsert 一个发送者。merge 进现有映射,盖上 ts;超过 MAX_SENDERS 时丢弃最旧的。
44
+ * name/avatar 任一为空都接受(部分平台只有名字没头像)。整段失败静默(持久化失败非致命)。
45
+ * @returns {boolean} 是否实际写入
46
+ */
47
+ export function upsertSender(id, senderId, profile = {}) {
48
+ if (typeof senderId !== 'string' || !senderId) return false;
49
+ const name = profile.name != null ? String(profile.name) : null;
50
+ const avatar = profile.avatar != null ? String(profile.avatar) : null;
51
+ try {
52
+ const map = readSenders(id);
53
+ const prev = map[senderId];
54
+ // 无新信息(name/avatar 都没变且都已存在)→ 跳过写盘,省 IO。
55
+ if (prev && prev.name === name && prev.avatar === avatar) return false;
56
+ map[senderId] = { name, avatar, ts: tsNow() };
57
+
58
+ const keys = Object.keys(map);
59
+ if (keys.length > MAX_SENDERS) {
60
+ keys
61
+ .sort((a, b) => (map[a].ts || 0) - (map[b].ts || 0))
62
+ .slice(0, keys.length - MAX_SENDERS)
63
+ .forEach((k) => { delete map[k]; });
64
+ }
65
+ writeSenders(id, map);
66
+ return true;
67
+ } catch {
68
+ return false; // 持久化失败不影响消息流
69
+ }
70
+ }
71
+
72
+ // 单独抽出便于测试注入(避免直接用 Date.now 影响可测性,但保持简单)。
73
+ function tsNow() { return Date.now(); }
@@ -15,11 +15,16 @@
15
15
  // reports its own in-process adapter (deps.im.getBridgeStatus) — that's what the manager probes.
16
16
  import { getDescriptor, loadConfig, loadState, saveConfig } from '../lib/im-config.js';
17
17
  import { findRecentLog } from '../lib/interceptor-core.js';
18
+ import { readSenders } from '../lib/im-senders.js';
19
+ import { readImClaudeMd, writeImClaudeMd, MAX_CLAUDE_MD_CHARS } from '../lib/im-claude-md.js';
20
+ import { imDir } from '../lib/im-lock.js';
21
+ import { listSkills, moveSkill } from '../lib/skills-api.js';
22
+ import { importSkillTo } from './skills.js';
18
23
  import { LOG_DIR } from '../../findcc.js';
19
24
  import { join, basename } from 'node:path';
20
25
 
21
26
  const JSON_HEADERS = { 'Content-Type': 'application/json' };
22
- const IM_RE = /^\/api\/im\/([a-z0-9_-]+)\/(status|config|test|process|logs)$/;
27
+ const IM_RE = /^\/api\/im\/([a-z0-9_-]+)\/(status|config|test|process|logs|senders|claude-md|skills|skills\/toggle|skills\/import)$/;
23
28
 
24
29
  /** Resolve a known platform id from the URL, or null (→ 404) for an unknown one. */
25
30
  function platformOf(url) {
@@ -126,10 +131,15 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
126
131
  }
127
132
  }
128
133
  const saved = saveConfig(id, incoming);
134
+ // applyProcess(默认 true,保持旧调用方语义):前端 onBlur 自动保存传 false → 仅存盘、不驱动进程,
135
+ // 否则每次输入框失焦都会重启 worker。显式「启动/停止」按钮则不传(=true),沿用下述驱动逻辑。
136
+ // 注:applyProcess 是未知字段,saveConfig/normalize 不会把它写盘。
129
137
  // 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker(吸收新凭证),停用→停 worker。
130
138
  try {
131
- if (saved.enabled) await deps.im.restartProcess(id);
132
- else await deps.im.stopProcess(id);
139
+ if (incoming.applyProcess !== false) {
140
+ if (saved.enabled) await deps.im.restartProcess(id);
141
+ else await deps.im.stopProcess(id);
142
+ }
133
143
  } catch (e) {
134
144
  // 进程操作失败不应阻塞配置保存的响应,但必须记录——否则 worker 起不来时用户看到乐观的
135
145
  // running:true 却毫无线索(spawn 失败 / EACCES on process.out.log 等)。
@@ -209,10 +219,114 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
209
219
  res.end(JSON.stringify({ project, latest }));
210
220
  }
211
221
 
222
+ // 发送者身份映射(senderId → {name, avatar, ts}):供「对话记录」按 senderId 显示真实姓名+头像。
223
+ // loopback-only:姓名/头像属个人信息,不向局域网暴露(与 config/test/process 同级)。
224
+ function imSenders(req, res, parsedUrl, isLocal, deps) {
225
+ const id = platformOf(parsedUrl.pathname);
226
+ if (!id) { notFound(res); return; }
227
+ if (!isLocal) { loopbackOnly(res); return; }
228
+ res.writeHead(200, JSON_HEADERS);
229
+ res.end(JSON.stringify({ platform: id, senders: readSenders(id) }));
230
+ }
231
+
232
+ // 「模型性格定义」= 该 IM worker 工作目录下的 CLAUDE.md。loopback-only:本地文件内容、admin-only。
233
+ // CLAUDE.md 仅在 worker 启动时读取一次,故保存后需重启该 IM worker 才生效(前端据此提示用户)。
234
+ function imClaudeMdGet(req, res, parsedUrl, isLocal, deps) {
235
+ const id = platformOf(parsedUrl.pathname);
236
+ if (!id) { notFound(res); return; }
237
+ if (!isLocal) { loopbackOnly(res); return; }
238
+ try {
239
+ res.writeHead(200, JSON_HEADERS);
240
+ res.end(JSON.stringify({ platform: id, content: readImClaudeMd(id) }));
241
+ } catch (e) {
242
+ res.writeHead(500, JSON_HEADERS);
243
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
244
+ }
245
+ }
246
+
247
+ function imClaudeMdPost(req, res, parsedUrl, isLocal, deps) {
248
+ const id = platformOf(parsedUrl.pathname);
249
+ if (!id) { notFound(res); return; }
250
+ if (!isLocal) { loopbackOnly(res); return; }
251
+ readBody(req, deps, (body) => {
252
+ let incoming;
253
+ try { incoming = JSON.parse(body); }
254
+ catch { res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
255
+ if (typeof incoming.content !== 'string') {
256
+ res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'content must be a string' })); return;
257
+ }
258
+ if (incoming.content.length > MAX_CLAUDE_MD_CHARS) {
259
+ res.writeHead(413, JSON_HEADERS); res.end(JSON.stringify({ error: 'content too large' })); return;
260
+ }
261
+ try {
262
+ writeImClaudeMd(id, incoming.content);
263
+ res.writeHead(200, JSON_HEADERS);
264
+ res.end(JSON.stringify({ ok: true, platform: id }));
265
+ } catch (e) {
266
+ res.writeHead(500, JSON_HEADERS);
267
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
268
+ }
269
+ });
270
+ }
271
+
272
+ // 「${IM} SKILL 管理」= 该 IM worker 工作目录下的 .claude/skills/。loopback-only(本地文件操作、admin-only)。
273
+ // 复用 skills-api 的 listSkills/moveSkill(按 projectDir 参数化)+ skills.js 的 importSkillTo(按 skillsRoot 参数化)。
274
+ // IM worker 仅在启动时读取 skills,故增删/启停后需重启该 IM worker 才生效(前端提示用户)。
275
+ function imSkills(req, res, parsedUrl, isLocal, deps) {
276
+ const id = platformOf(parsedUrl.pathname);
277
+ if (!id) { notFound(res); return; }
278
+ if (!isLocal) { loopbackOnly(res); return; }
279
+ try {
280
+ const dir = imDir(id);
281
+ // projectDir 与 homeDir 都指向 IM 目录:两次扫描命中同一 .claude/skills(user+project 重复),
282
+ // 过滤 source==='project' 去重并排除 plugin/builtin → 恰好是该 IM 自己的 skills(+skills-skip)。
283
+ const skills = listSkills({ projectDir: dir, homeDir: dir }).filter((s) => s.source === 'project');
284
+ res.writeHead(200, JSON_HEADERS);
285
+ res.end(JSON.stringify({ platform: id, skills }));
286
+ } catch (e) {
287
+ res.writeHead(500, JSON_HEADERS);
288
+ res.end(JSON.stringify({ error: String(e?.message || e) }));
289
+ }
290
+ }
291
+
292
+ function imSkillsToggle(req, res, parsedUrl, isLocal, deps) {
293
+ const id = platformOf(parsedUrl.pathname);
294
+ if (!id) { notFound(res); return; }
295
+ if (!isLocal) { loopbackOnly(res); return; }
296
+ readBody(req, deps, (body) => {
297
+ let incoming;
298
+ try { incoming = JSON.parse(body); }
299
+ catch { res.writeHead(400, JSON_HEADERS); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
300
+ try {
301
+ moveSkill({ source: 'project', name: incoming.name, enable: !!incoming.enable, projectDir: imDir(id) });
302
+ res.writeHead(200, JSON_HEADERS);
303
+ res.end(JSON.stringify({ ok: true }));
304
+ } catch (err) {
305
+ const statusMap = { INVALID_NAME: 400, INVALID_SOURCE: 400, PATH_ESCAPE: 400, SYMLINK: 400, SOURCE_MISSING: 404, DEST_CONFLICT: 409 };
306
+ const status = statusMap[err?.code] || 500;
307
+ res.writeHead(status, JSON_HEADERS);
308
+ res.end(JSON.stringify({ error: err?.message || 'internal_error', code: err?.code || 'unknown' }));
309
+ }
310
+ });
311
+ }
312
+
313
+ function imSkillsImport(req, res, parsedUrl, isLocal, deps) {
314
+ const id = platformOf(parsedUrl.pathname);
315
+ if (!id) { notFound(res); return; }
316
+ if (!isLocal) { loopbackOnly(res); return; }
317
+ importSkillTo(req, res, { skillsRoot: join(imDir(id), '.claude', 'skills'), windowsReserved: deps.WINDOWS_RESERVED_NAMES });
318
+ }
319
+
212
320
  export const imRoutes = [
213
321
  { predicate: imPredicate('status', 'GET'), handler: imStatus },
214
322
  { predicate: imPredicate('config', 'POST'), handler: imConfigPost },
215
323
  { predicate: imPredicate('test', 'POST'), handler: imTestPost },
216
324
  { predicate: imPredicate('process', 'POST'), handler: imProcessPost },
217
325
  { predicate: imPredicate('logs', 'GET'), handler: imLogs },
326
+ { predicate: imPredicate('senders', 'GET'), handler: imSenders },
327
+ { predicate: imPredicate('claude-md', 'GET'), handler: imClaudeMdGet },
328
+ { predicate: imPredicate('claude-md', 'POST'), handler: imClaudeMdPost },
329
+ { predicate: imPredicate('skills', 'GET'), handler: imSkills },
330
+ { predicate: imPredicate('skills/toggle', 'POST'), handler: imSkillsToggle },
331
+ { predicate: imPredicate('skills/import', 'POST'), handler: imSkillsImport },
218
332
  ];