botmux 2.85.1 → 2.86.0

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 (116) hide show
  1. package/dist/core/command-handler.d.ts.map +1 -1
  2. package/dist/core/command-handler.js +209 -1
  3. package/dist/core/command-handler.js.map +1 -1
  4. package/dist/core/cost-calculator.d.ts.map +1 -1
  5. package/dist/core/cost-calculator.js +7 -106
  6. package/dist/core/cost-calculator.js.map +1 -1
  7. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  8. package/dist/core/dashboard-ipc-server.js +240 -2
  9. package/dist/core/dashboard-ipc-server.js.map +1 -1
  10. package/dist/core/passthrough-commands.d.ts.map +1 -1
  11. package/dist/core/passthrough-commands.js +1 -1
  12. package/dist/core/passthrough-commands.js.map +1 -1
  13. package/dist/core/role-resolver.d.ts +1 -0
  14. package/dist/core/role-resolver.d.ts.map +1 -1
  15. package/dist/core/role-resolver.js +14 -0
  16. package/dist/core/role-resolver.js.map +1 -1
  17. package/dist/dashboard/web/app.d.ts.map +1 -1
  18. package/dist/dashboard/web/app.js +15 -4
  19. package/dist/dashboard/web/app.js.map +1 -1
  20. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  21. package/dist/dashboard/web/bot-defaults.js +116 -0
  22. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  23. package/dist/dashboard/web/groups.d.ts +2 -0
  24. package/dist/dashboard/web/groups.d.ts.map +1 -1
  25. package/dist/dashboard/web/groups.js +419 -3
  26. package/dist/dashboard/web/groups.js.map +1 -1
  27. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  28. package/dist/dashboard/web/i18n.js +617 -3
  29. package/dist/dashboard/web/i18n.js.map +1 -1
  30. package/dist/dashboard/web/insights.d.ts +2 -0
  31. package/dist/dashboard/web/insights.d.ts.map +1 -0
  32. package/dist/dashboard/web/insights.js +1523 -0
  33. package/dist/dashboard/web/insights.js.map +1 -0
  34. package/dist/dashboard/web/role-profile-match.d.ts +31 -0
  35. package/dist/dashboard/web/role-profile-match.d.ts.map +1 -0
  36. package/dist/dashboard/web/role-profile-match.js +58 -0
  37. package/dist/dashboard/web/role-profile-match.js.map +1 -0
  38. package/dist/dashboard/web/roles.d.ts +1 -0
  39. package/dist/dashboard/web/roles.d.ts.map +1 -1
  40. package/dist/dashboard/web/roles.js +520 -27
  41. package/dist/dashboard/web/roles.js.map +1 -1
  42. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  43. package/dist/dashboard/web/sessions.js +84 -0
  44. package/dist/dashboard/web/sessions.js.map +1 -1
  45. package/dist/dashboard-web/app.js +1243 -831
  46. package/dist/dashboard-web/index.html +2 -1
  47. package/dist/dashboard-web/style.css +1085 -3
  48. package/dist/dashboard.js +215 -3
  49. package/dist/dashboard.js.map +1 -1
  50. package/dist/i18n/en.d.ts.map +1 -1
  51. package/dist/i18n/en.js +34 -1
  52. package/dist/i18n/en.js.map +1 -1
  53. package/dist/i18n/zh.d.ts.map +1 -1
  54. package/dist/i18n/zh.js +34 -1
  55. package/dist/i18n/zh.js.map +1 -1
  56. package/dist/services/group-creator.d.ts +6 -0
  57. package/dist/services/group-creator.d.ts.map +1 -1
  58. package/dist/services/group-creator.js +54 -5
  59. package/dist/services/group-creator.js.map +1 -1
  60. package/dist/services/insight/antigravity-span-reader.d.ts +3 -0
  61. package/dist/services/insight/antigravity-span-reader.d.ts.map +1 -0
  62. package/dist/services/insight/antigravity-span-reader.js +249 -0
  63. package/dist/services/insight/antigravity-span-reader.js.map +1 -0
  64. package/dist/services/insight/classify.d.ts +7 -0
  65. package/dist/services/insight/classify.d.ts.map +1 -0
  66. package/dist/services/insight/classify.js +46 -0
  67. package/dist/services/insight/classify.js.map +1 -0
  68. package/dist/services/insight/claude-span-reader.d.ts +3 -0
  69. package/dist/services/insight/claude-span-reader.d.ts.map +1 -0
  70. package/dist/services/insight/claude-span-reader.js +257 -0
  71. package/dist/services/insight/claude-span-reader.js.map +1 -0
  72. package/dist/services/insight/codex-span-reader.d.ts +3 -0
  73. package/dist/services/insight/codex-span-reader.d.ts.map +1 -0
  74. package/dist/services/insight/codex-span-reader.js +290 -0
  75. package/dist/services/insight/codex-span-reader.js.map +1 -0
  76. package/dist/services/insight/intent.d.ts +5 -0
  77. package/dist/services/insight/intent.d.ts.map +1 -0
  78. package/dist/services/insight/intent.js +145 -0
  79. package/dist/services/insight/intent.js.map +1 -0
  80. package/dist/services/insight/jsonl.d.ts +10 -0
  81. package/dist/services/insight/jsonl.d.ts.map +1 -0
  82. package/dist/services/insight/jsonl.js +36 -0
  83. package/dist/services/insight/jsonl.js.map +1 -0
  84. package/dist/services/insight/prompt.d.ts +3 -0
  85. package/dist/services/insight/prompt.d.ts.map +1 -0
  86. package/dist/services/insight/prompt.js +99 -0
  87. package/dist/services/insight/prompt.js.map +1 -0
  88. package/dist/services/insight/redact.d.ts +4 -0
  89. package/dist/services/insight/redact.d.ts.map +1 -0
  90. package/dist/services/insight/redact.js +67 -0
  91. package/dist/services/insight/redact.js.map +1 -0
  92. package/dist/services/insight/report.d.ts +29 -0
  93. package/dist/services/insight/report.d.ts.map +1 -0
  94. package/dist/services/insight/report.js +1126 -0
  95. package/dist/services/insight/report.js.map +1 -0
  96. package/dist/services/insight/safe-detail.d.ts +5 -0
  97. package/dist/services/insight/safe-detail.d.ts.map +1 -0
  98. package/dist/services/insight/safe-detail.js +59 -0
  99. package/dist/services/insight/safe-detail.js.map +1 -0
  100. package/dist/services/insight/scrub.d.ts +22 -0
  101. package/dist/services/insight/scrub.d.ts.map +1 -0
  102. package/dist/services/insight/scrub.js +70 -0
  103. package/dist/services/insight/scrub.js.map +1 -0
  104. package/dist/services/insight/types.d.ts +394 -0
  105. package/dist/services/insight/types.d.ts.map +1 -0
  106. package/dist/services/insight/types.js +2 -0
  107. package/dist/services/insight/types.js.map +1 -0
  108. package/dist/services/role-profile-store.d.ts +25 -0
  109. package/dist/services/role-profile-store.d.ts.map +1 -0
  110. package/dist/services/role-profile-store.js +171 -0
  111. package/dist/services/role-profile-store.js.map +1 -0
  112. package/dist/services/transcript-resolver.d.ts +26 -0
  113. package/dist/services/transcript-resolver.d.ts.map +1 -0
  114. package/dist/services/transcript-resolver.js +111 -0
  115. package/dist/services/transcript-resolver.js.map +1 -0
  116. package/package.json +1 -1
@@ -3,7 +3,26 @@
3
3
  // by chatId; the dashboard displays this as a matrix where each cell shows
4
4
  // whether a bot is a member of a given chat.
5
5
  import { chatAvatarHtml, escapeHtml, loadingHtml, t } from './ui.js';
6
+ import { hasExplicitChatRole, summarizeGroupProfileMatches, } from './role-profile-match.js';
6
7
  let cache = { chats: [], bots: [] };
8
+ const PROFILE_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
9
+ const roleKey = (larkAppId, chatId) => `${larkAppId}\u0000${chatId}`;
10
+ let roleProfiles = [];
11
+ let roleProfileEntriesById = new Map();
12
+ let groupRoleContentByBot = new Map();
13
+ let roleProfileContextLoaded = false;
14
+ function isValidProfileId(profileId) {
15
+ return PROFILE_ID_RE.test(profileId) && profileId !== '.' && profileId !== '..';
16
+ }
17
+ export function suggestRoleProfileIdFromChat(value) {
18
+ const cleaned = String(value ?? '')
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9._-]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 64);
24
+ return isValidProfileId(cleaned) ? cleaned : 'profile';
25
+ }
7
26
  function pageHtml() {
8
27
  return `<section class="page">
9
28
  <div class="page-heading">
@@ -39,6 +58,52 @@ async function fetchGroups() {
39
58
  const r = await fetch('/api/groups');
40
59
  return r.json();
41
60
  }
61
+ async function loadGroupRoleProfileContext() {
62
+ const profileResp = await fetch('/api/role-profiles');
63
+ const profileBody = await profileResp.json().catch(() => ({}));
64
+ const nextProfiles = Array.isArray(profileBody.profiles) ? profileBody.profiles : [];
65
+ const detailPairs = await Promise.all(nextProfiles.map(async (profile) => {
66
+ try {
67
+ const r = await fetch(`/api/role-profiles/${encodeURIComponent(profile.profileId)}`);
68
+ const body = await r.json().catch(() => ({}));
69
+ return [profile.profileId, Array.isArray(body.entries) ? body.entries : []];
70
+ }
71
+ catch {
72
+ return [profile.profileId, []];
73
+ }
74
+ }));
75
+ const nextGroupRoles = new Map();
76
+ const seenRoleKeys = new Set();
77
+ await Promise.all((cache.chats ?? []).flatMap((chat) => (chat.memberBots ?? [])
78
+ .filter((bot) => bot?.inChat && bot?.larkAppId)
79
+ .map(async (bot) => {
80
+ const key = roleKey(bot.larkAppId, chat.chatId);
81
+ if (seenRoleKeys.has(key))
82
+ return;
83
+ seenRoleKeys.add(key);
84
+ try {
85
+ const r = await fetch(`/api/roles/${encodeURIComponent(bot.larkAppId)}/${encodeURIComponent(chat.chatId)}`);
86
+ const body = await r.json().catch(() => ({}));
87
+ const hasEffectiveRole = body?.hasEffectiveRole ?? body?.hasRole;
88
+ const effectiveContent = 'effectiveContent' in body ? body.effectiveContent : body.content;
89
+ nextGroupRoles.set(key, {
90
+ content: hasEffectiveRole ? String(effectiveContent ?? '') : null,
91
+ source: body?.effectiveSource ?? (body?.hasRole ? 'chat' : 'none'),
92
+ });
93
+ }
94
+ catch {
95
+ nextGroupRoles.set(key, null);
96
+ }
97
+ })));
98
+ roleProfiles = nextProfiles;
99
+ roleProfileEntriesById = new Map(detailPairs);
100
+ groupRoleContentByBot = nextGroupRoles;
101
+ }
102
+ async function fetchRoleProfileSummaries() {
103
+ const r = await fetch('/api/role-profiles');
104
+ const body = await r.json().catch(() => ({}));
105
+ return Array.isArray(body.profiles) ? body.profiles : [];
106
+ }
42
107
  /** True iff every expected bot id appears in the row's memberBots with
43
108
  * inChat:true. Used by refreshUntilSeen to defer committing a canonical
44
109
  * snapshot until all invited bots have caught up Lark-side. Exported so
@@ -68,6 +133,25 @@ export function renderBotCheckboxes(bots, excludeIds) {
68
133
  </label>
69
134
  `).join('');
70
135
  }
136
+ export function renderRoleProfileBootstrapSummary(profileId, messageId, error) {
137
+ const cleanProfileId = String(profileId ?? '').trim();
138
+ if (!cleanProfileId)
139
+ return '';
140
+ if (error) {
141
+ return `<p class="hint-warn">${escapeHtml(t('groups.roleProfileBootstrapFailed', {
142
+ name: cleanProfileId,
143
+ reason: String(error),
144
+ }))}</p>`;
145
+ }
146
+ const cleanMessageId = typeof messageId === 'string' && messageId.trim() ? messageId.trim() : '';
147
+ if (cleanMessageId) {
148
+ return `<p class="hint-ok">${escapeHtml(t('groups.roleProfileBootstrapSent', {
149
+ name: cleanProfileId,
150
+ messageId: cleanMessageId,
151
+ }))}</p>`;
152
+ }
153
+ return `<p class="hint-ok">${escapeHtml(t('groups.roleProfileBootstrapDone', { name: cleanProfileId }))}</p>`;
154
+ }
71
155
  export async function renderGroupsPage(root) {
72
156
  root.innerHTML = pageHtml();
73
157
  const head = root.querySelector('#g-head');
@@ -80,13 +164,14 @@ export async function renderGroupsPage(root) {
80
164
  try {
81
165
  await loadGroups();
82
166
  rerender();
167
+ void refreshRoleProfileContext();
83
168
  }
84
169
  finally {
85
170
  refreshBtn.disabled = false;
86
171
  }
87
172
  };
88
173
  const createBtn = root.querySelector('#g-create');
89
- createBtn.onclick = () => openCreateModal();
174
+ createBtn.onclick = () => { void openCreateModal(); };
90
175
  // /api/groups 要扇出到所有 daemon、逐群查成员,慢——先亮 loading,回来再换表格。
91
176
  const loadingEl = root.querySelector('#g-loading');
92
177
  const tableWrap = root.querySelector('#g-table-wrap');
@@ -97,12 +182,33 @@ export async function renderGroupsPage(root) {
97
182
  loadingEl.remove();
98
183
  tableWrap.hidden = false;
99
184
  }
100
- function openCreateModal() {
185
+ async function refreshRoleProfileContext() {
186
+ try {
187
+ await loadGroupRoleProfileContext();
188
+ }
189
+ catch {
190
+ roleProfiles = [];
191
+ roleProfileEntriesById = new Map();
192
+ groupRoleContentByBot = new Map();
193
+ }
194
+ finally {
195
+ roleProfileContextLoaded = true;
196
+ rerender();
197
+ }
198
+ }
199
+ async function openCreateModal() {
101
200
  const allBots = cache.bots;
102
201
  if (allBots.length === 0) {
103
202
  alert(t('groups.noBotsOnline'));
104
203
  return;
105
204
  }
205
+ let roleProfiles = [];
206
+ try {
207
+ const r = await fetch('/api/role-profiles');
208
+ const data = await r.json();
209
+ roleProfiles = data.profiles ?? [];
210
+ }
211
+ catch { /* profile selector is optional */ }
106
212
  drawer.innerHTML = `
107
213
  <article>
108
214
  <header><h3>${t('groups.createTitle')}</h3></header>
@@ -117,6 +223,14 @@ export async function renderGroupsPage(root) {
117
223
  <input type="text" name="bindWorkingDir" placeholder="e.g. ~/projects/botmux">
118
224
  <small>${t('groups.bindDirHelp')}</small>
119
225
  </label>
226
+ <label class="form-row">
227
+ <span>${t('groups.roleProfile')}</span>
228
+ <select name="roleProfileId">
229
+ <option value="">${t('groups.roleProfileNone')}</option>
230
+ ${roleProfiles.map(p => `<option value="${escapeHtml(p.profileId)}">${escapeHtml(p.profileId)}</option>`).join('')}
231
+ </select>
232
+ <small>${t('groups.roleProfileHelp')}</small>
233
+ </label>
120
234
  <fieldset>
121
235
  <legend>${t('groups.botPicker')}</legend>
122
236
  ${renderBotCheckboxes(allBots)}
@@ -134,6 +248,7 @@ export async function renderGroupsPage(root) {
134
248
  const fd = new FormData(ev.target);
135
249
  const name = (fd.get('name') ?? '').trim();
136
250
  const bindWorkingDir = (fd.get('bindWorkingDir') ?? '').trim();
251
+ const roleProfileId = (fd.get('roleProfileId') ?? '').trim();
137
252
  const ids = fd.getAll('bot');
138
253
  if (ids.length === 0) {
139
254
  alert('Pick at least one bot.');
@@ -148,7 +263,12 @@ export async function renderGroupsPage(root) {
148
263
  const r = await fetch('/api/groups/create', {
149
264
  method: 'POST',
150
265
  headers: { 'content-type': 'application/json' },
151
- body: JSON.stringify({ name: name || undefined, larkAppIds: ids, bindWorkingDir: bindWorkingDir || undefined }),
266
+ body: JSON.stringify({
267
+ name: name || undefined,
268
+ larkAppIds: ids,
269
+ bindWorkingDir: bindWorkingDir || undefined,
270
+ roleProfileId: roleProfileId || undefined,
271
+ }),
152
272
  });
153
273
  const respBody = await r.json();
154
274
  if (respBody.ok && respBody.chatId) {
@@ -174,6 +294,7 @@ export async function renderGroupsPage(root) {
174
294
  expectedBotIds.add(respBody.creator);
175
295
  injectOptimisticChat(respBody.chatId, name || respBody.chatId, validIds, respBody.creator);
176
296
  rerender();
297
+ void refreshRoleProfileContext();
177
298
  void refreshUntilSeen(respBody.chatId, expectedBotIds).catch(() => { });
178
299
  }
179
300
  else {
@@ -227,6 +348,7 @@ export async function renderGroupsPage(root) {
227
348
  if (row && allExpectedInChat(row, expectedBotIds)) {
228
349
  cache = next;
229
350
  rerender();
351
+ void refreshRoleProfileContext();
230
352
  return;
231
353
  }
232
354
  }
@@ -280,6 +402,7 @@ export async function renderGroupsPage(root) {
280
402
  invalidBots.length ? `<li>无效 bot id: <code>${invalidBots.map(escapeHtml).join(', ')}</code></li>` : '',
281
403
  invalidUsers.length ? `<li>无效用户 open_id: <code>${invalidUsers.map(escapeHtml).join(', ')}</code></li>` : '',
282
404
  ].filter(Boolean).join('');
405
+ const profileNote = renderRoleProfileBootstrapSummary(typeof resp.roleProfileId === 'string' ? resp.roleProfileId : '', resp.roleProfileBootstrapMessageId, resp.roleProfileBootstrapError);
283
406
  drawer.innerHTML = `
284
407
  <article>
285
408
  <header><h3>${t('groups.successTitle')}</h3></header>
@@ -287,6 +410,7 @@ export async function renderGroupsPage(root) {
287
410
  <p><b>创建者:</b> <code>${escapeHtml(resp.creator ?? '?')}</code></p>
288
411
  ${inviteNote}
289
412
  ${bindNote}
413
+ ${profileNote}
290
414
  ${invalidNote ? `<ul>${invalidNote}</ul>` : ''}
291
415
  <div class="actions">
292
416
  <a class="btn-link primary" href="${appLink}" target="_blank" rel="noopener">${t('groups.openGroup')}</a>
@@ -302,6 +426,32 @@ export async function renderGroupsPage(root) {
302
426
  });
303
427
  drawer.querySelector('#g-create-close').onclick = () => drawer.close();
304
428
  }
429
+ function renderGroupProfileStatus(chat) {
430
+ if (!roleProfiles.length || !roleProfileContextLoaded)
431
+ return '';
432
+ const rolesByBot = new Map();
433
+ for (const bot of chat.memberBots ?? []) {
434
+ if (!bot?.inChat)
435
+ continue;
436
+ rolesByBot.set(bot.larkAppId, groupRoleContentByBot.get(roleKey(bot.larkAppId, chat.chatId)) ?? null);
437
+ }
438
+ if (!hasExplicitChatRole(rolesByBot))
439
+ return '';
440
+ const matches = summarizeGroupProfileMatches(chat.memberBots ?? [], roleProfiles, roleProfileEntriesById, rolesByBot);
441
+ const best = matches[0];
442
+ if (!best) {
443
+ return `<div class="g-profile-status muted">${t('groups.profileStatusUnmatched')}</div>`;
444
+ }
445
+ const key = best.kind === 'full' ? 'groups.profileStatusFullChat' : 'groups.profileStatusPartial';
446
+ return `<div class="g-profile-status ${best.kind}">
447
+ ${escapeHtml(t(key, {
448
+ name: best.profileId,
449
+ matched: best.matched,
450
+ total: best.total,
451
+ chat: best.chatMatched,
452
+ }))}
453
+ </div>`;
454
+ }
305
455
  function renderHead() {
306
456
  head.innerHTML = `<tr>
307
457
  <th>${t('groups.chat')}</th>
@@ -331,6 +481,7 @@ export async function renderGroupsPage(root) {
331
481
  <div class="g-chat-meta">
332
482
  <strong>${escapeHtml(c.name ?? c.chatId)}</strong><br>
333
483
  <small><code>${escapeHtml(c.chatId)}</code></small>
484
+ ${renderGroupProfileStatus(c)}
334
485
  </div>
335
486
  </div>
336
487
  </td>
@@ -342,11 +493,13 @@ export async function renderGroupsPage(root) {
342
493
  }).join('')}
343
494
  <td>
344
495
  <button class="add-bots" type="button">${t('groups.addBots')}</button>
496
+ <button class="save-profile" type="button">${t('groups.saveAsProfile')}</button>
345
497
  <button class="manage-chat" type="button">${t('groups.manage')}</button>
346
498
  </td>
347
499
  </tr>`).join('');
348
500
  }
349
501
  rerender();
502
+ void refreshRoleProfileContext();
350
503
  body.addEventListener('click', async (e) => {
351
504
  const btn = e.target.closest('button.add-bots');
352
505
  if (!btn)
@@ -416,6 +569,266 @@ export async function renderGroupsPage(root) {
416
569
  }
417
570
  };
418
571
  });
572
+ body.addEventListener('click', async (e) => {
573
+ const btn = e.target.closest('button.save-profile');
574
+ if (!btn)
575
+ return;
576
+ const tr = btn.closest('tr[data-chat]');
577
+ const chatId = tr.dataset.chat;
578
+ const chat = cache.chats.find(c => c.chatId === chatId);
579
+ if (!chat)
580
+ return;
581
+ await saveGroupRolesAsProfile(chat, btn);
582
+ });
583
+ async function saveGroupRolesAsProfile(chat, btn) {
584
+ const suggestedByName = suggestRoleProfileIdFromChat(chat.name ?? '');
585
+ const suggested = suggestedByName === 'profile'
586
+ ? suggestRoleProfileIdFromChat(chat.chatId)
587
+ : suggestedByName;
588
+ btn.disabled = true;
589
+ const originalText = btn.textContent;
590
+ btn.textContent = t('groups.saveProfileSaving');
591
+ try {
592
+ const [entries, profiles] = await Promise.all([
593
+ collectGroupProfileEntries(chat),
594
+ fetchRoleProfileSummaries(),
595
+ ]);
596
+ openSaveProfileDialog(chat, suggested, entries, profiles);
597
+ }
598
+ finally {
599
+ btn.disabled = false;
600
+ btn.textContent = originalText;
601
+ }
602
+ }
603
+ async function collectGroupProfileEntries(chat) {
604
+ const inChat = (chat.memberBots ?? []).filter((bot) => bot?.inChat && bot?.larkAppId);
605
+ const roleRows = await Promise.all(inChat.map(async (bot) => {
606
+ try {
607
+ const r = await fetch(`/api/roles/${encodeURIComponent(bot.larkAppId)}/${encodeURIComponent(chat.chatId)}`);
608
+ const body = await r.json().catch(() => ({}));
609
+ const hasEffectiveRole = body?.hasEffectiveRole ?? body?.hasRole;
610
+ const effectiveContent = 'effectiveContent' in body ? body.effectiveContent : body.content;
611
+ const content = hasEffectiveRole ? String(effectiveContent ?? '').trim() : '';
612
+ const source = body?.effectiveSource === 'chat' || body?.effectiveSource === 'team'
613
+ ? body.effectiveSource
614
+ : null;
615
+ return {
616
+ larkAppId: bot.larkAppId,
617
+ botName: bot.botName,
618
+ content,
619
+ status: content ? (source ?? 'chat') : 'empty',
620
+ };
621
+ }
622
+ catch {
623
+ return {
624
+ larkAppId: bot.larkAppId,
625
+ botName: bot.botName,
626
+ content: '',
627
+ status: 'error',
628
+ };
629
+ }
630
+ }));
631
+ return roleRows;
632
+ }
633
+ function openSaveProfileDialog(chat, suggestedProfileId, entries, profiles) {
634
+ const sortedProfiles = [...profiles].sort((a, b) => a.profileId.localeCompare(b.profileId));
635
+ const profileButtons = sortedProfiles
636
+ .map((profile, index) => `
637
+ <button type="button"
638
+ class="g-save-profile-pick ${index === 0 ? 'selected' : ''}"
639
+ data-profile-id="${escapeHtml(profile.profileId)}"
640
+ aria-pressed="${index === 0 ? 'true' : 'false'}">
641
+ <span>${escapeHtml(profile.profileId)}</span>
642
+ <small>${escapeHtml(t('groups.saveProfileExistingMeta', { count: profile.entryCount ?? 0 }))}</small>
643
+ </button>`)
644
+ .join('');
645
+ const hasExistingProfiles = sortedProfiles.length > 0;
646
+ const emptyCount = entries.filter(entry => entry.status === 'empty').length;
647
+ const failedCount = entries.filter(entry => entry.status === 'error').length;
648
+ const canSubmitSnapshot = entries.length > 0 && failedCount === 0;
649
+ const entryRows = entries.map(entry => `
650
+ <div class="g-save-profile-entry ${entry.status === 'error' ? 'error' : ''}">
651
+ <div>
652
+ <strong>${escapeHtml(entry.botName ?? entry.larkAppId)}</strong>
653
+ <code>${escapeHtml(entry.larkAppId)}</code>
654
+ </div>
655
+ <span class="g-save-profile-entry-status ${entry.status === 'error' ? 'error' : 'ok'}">
656
+ ${t(entry.status === 'error' ? 'groups.saveProfileStatus.error' : 'groups.saveProfileStatus.entry')}
657
+ </span>
658
+ </div>`).join('');
659
+ drawer.innerHTML = `
660
+ <article class="g-save-profile-dialog">
661
+ <header>
662
+ <h3>${t('groups.saveProfileTitle')}</h3>
663
+ <p>${escapeHtml(t('groups.saveProfileIntro', {
664
+ name: chat.name ?? chat.chatId,
665
+ count: entries.length,
666
+ }))}</p>
667
+ </header>
668
+ <form id="g-save-profile-form">
669
+ <section class="g-save-profile-panel">
670
+ <div class="g-save-profile-section-head">
671
+ <span>${t('groups.saveProfileScope')}</span>
672
+ <small>${t('groups.saveProfileScopeHelp')}</small>
673
+ </div>
674
+ <div class="g-save-profile-stats">
675
+ <span>${t('groups.saveProfileBotCount')} <strong>${entries.length}</strong></span>
676
+ ${failedCount ? `<span class="warn">${t('groups.saveProfileLoadFailed')} <strong>${failedCount}</strong></span>` : ''}
677
+ </div>
678
+ <div class="g-save-profile-entry-list">
679
+ ${entryRows || `<div class="g-save-profile-empty">${t('groups.saveProfileNoRoles')}</div>`}
680
+ </div>
681
+ </section>
682
+
683
+ <div class="form-row">
684
+ <span>${t('groups.saveProfileMode')}</span>
685
+ <div class="g-save-profile-switch" role="tablist" aria-label="${t('groups.saveProfileMode')}">
686
+ <button type="button" class="active" data-save-profile-mode="new">${t('groups.saveProfileNew')}</button>
687
+ <button type="button" data-save-profile-mode="overwrite" ${hasExistingProfiles ? '' : 'disabled'}>${t('groups.saveProfileOverwrite')}</button>
688
+ </div>
689
+ </div>
690
+ <label class="form-row" data-profile-mode-row="new">
691
+ <span>${t('groups.saveProfileIdLabel')}</span>
692
+ <input type="text" name="profileId" value="${escapeHtml(suggestedProfileId)}" maxlength="64" autocomplete="off">
693
+ <small>${t('groups.saveProfileInvalid')}</small>
694
+ </label>
695
+ <div class="form-row" data-profile-mode-row="overwrite" hidden>
696
+ <span>${t('groups.saveProfileExistingLabel')}</span>
697
+ ${hasExistingProfiles
698
+ ? `<div class="g-save-profile-picker">${profileButtons}</div>`
699
+ : `<div class="g-save-profile-summary warn">${t('groups.saveProfileExistingEmpty')}</div>`}
700
+ <small>${t('groups.saveProfileOverwriteHelp')}</small>
701
+ </div>
702
+ <div class="g-save-profile-target">
703
+ <span>${t('groups.saveProfileTarget')}</span>
704
+ <code data-save-profile-target>${escapeHtml(suggestedProfileId)}</code>
705
+ <small data-save-profile-target-mode>${t('groups.saveProfileTargetNew')}</small>
706
+ </div>
707
+ <div class="g-save-profile-summary ${canSubmitSnapshot ? '' : 'warn'}">
708
+ ${failedCount
709
+ ? escapeHtml(t('groups.saveProfileFailedLoadSummary', { count: failedCount }))
710
+ : entries.length
711
+ ? escapeHtml(emptyCount
712
+ ? t('groups.saveProfileEntrySummaryWithEmpty', { count: entries.length, emptyCount })
713
+ : t('groups.saveProfileEntrySummary', { count: entries.length }))
714
+ : escapeHtml(t('groups.saveProfileNoRoles'))}
715
+ </div>
716
+ <div class="g-save-profile-status" data-save-profile-status></div>
717
+ <div class="actions">
718
+ <button type="submit" class="primary" ${canSubmitSnapshot ? '' : 'disabled'}>${t('groups.saveProfileSubmit')}</button>
719
+ <button type="button" id="g-save-profile-cancel">${t('groups.cancel')}</button>
720
+ </div>
721
+ </form>
722
+ </article>`;
723
+ drawer.showModal();
724
+ const formEl = drawer.querySelector('#g-save-profile-form');
725
+ const modeRows = [...drawer.querySelectorAll('[data-profile-mode-row]')];
726
+ const statusEl = drawer.querySelector('[data-save-profile-status]');
727
+ const submitBtn = formEl.querySelector('button[type=submit]');
728
+ const modeButtons = [...formEl.querySelectorAll('[data-save-profile-mode]')];
729
+ const profilePickButtons = [...formEl.querySelectorAll('[data-profile-id]')];
730
+ const profileIdInput = formEl.querySelector('input[name=profileId]');
731
+ const targetEl = formEl.querySelector('[data-save-profile-target]');
732
+ const targetModeEl = formEl.querySelector('[data-save-profile-target-mode]');
733
+ let selectedMode = 'new';
734
+ let selectedExistingProfileId = sortedProfiles[0]?.profileId ?? '';
735
+ function currentProfileId() {
736
+ return selectedMode === 'overwrite'
737
+ ? selectedExistingProfileId
738
+ : profileIdInput.value.trim();
739
+ }
740
+ function updateMode() {
741
+ for (const row of modeRows) {
742
+ row.hidden = row.dataset.profileModeRow !== selectedMode;
743
+ }
744
+ for (const button of modeButtons) {
745
+ const isActive = button.dataset.saveProfileMode === selectedMode;
746
+ button.classList.toggle('active', isActive);
747
+ button.setAttribute('aria-pressed', String(isActive));
748
+ }
749
+ for (const button of profilePickButtons) {
750
+ const isSelected = button.dataset.profileId === selectedExistingProfileId;
751
+ button.classList.toggle('selected', isSelected);
752
+ button.setAttribute('aria-pressed', String(isSelected));
753
+ }
754
+ submitBtn.textContent = selectedMode === 'overwrite'
755
+ ? t('groups.saveProfileOverwriteSubmit')
756
+ : t('groups.saveProfileSubmit');
757
+ submitBtn.disabled = !canSubmitSnapshot || (selectedMode === 'overwrite' && !selectedExistingProfileId);
758
+ targetEl.textContent = currentProfileId() || '-';
759
+ targetModeEl.textContent = selectedMode === 'overwrite'
760
+ ? t('groups.saveProfileTargetOverwrite')
761
+ : t('groups.saveProfileTargetNew');
762
+ statusEl.textContent = '';
763
+ statusEl.className = 'g-save-profile-status';
764
+ }
765
+ modeButtons.forEach(button => {
766
+ button.addEventListener('click', () => {
767
+ const mode = button.dataset.saveProfileMode === 'overwrite' ? 'overwrite' : 'new';
768
+ if (mode === 'overwrite' && !hasExistingProfiles)
769
+ return;
770
+ selectedMode = mode;
771
+ updateMode();
772
+ });
773
+ });
774
+ profilePickButtons.forEach(button => {
775
+ button.addEventListener('click', () => {
776
+ selectedExistingProfileId = button.dataset.profileId ?? '';
777
+ selectedMode = 'overwrite';
778
+ updateMode();
779
+ });
780
+ });
781
+ profileIdInput.addEventListener('input', updateMode);
782
+ updateMode();
783
+ drawer.querySelector('#g-save-profile-cancel').onclick = () => drawer.close();
784
+ formEl.onsubmit = async (ev) => {
785
+ ev.preventDefault();
786
+ if (!canSubmitSnapshot)
787
+ return;
788
+ const profileId = currentProfileId();
789
+ if (!isValidProfileId(profileId)) {
790
+ statusEl.textContent = t('groups.saveProfileInvalid');
791
+ statusEl.className = 'g-save-profile-status error';
792
+ return;
793
+ }
794
+ submitBtn.disabled = true;
795
+ submitBtn.textContent = t('groups.saveProfileSaving');
796
+ statusEl.textContent = t('groups.saveProfileSaving');
797
+ statusEl.className = 'g-save-profile-status';
798
+ try {
799
+ const results = await Promise.all(entries.map(async (entry) => {
800
+ const r = await fetch(`/api/role-profiles/${encodeURIComponent(profileId)}/${encodeURIComponent(entry.larkAppId)}`, {
801
+ method: 'PUT',
802
+ headers: { 'content-type': 'application/json' },
803
+ body: JSON.stringify({ content: entry.content, allowEmpty: true }),
804
+ });
805
+ return r.ok;
806
+ }));
807
+ const saved = results.filter(Boolean).length;
808
+ if (saved !== entries.length) {
809
+ statusEl.textContent = t('groups.saveProfileFailed', { saved, total: entries.length });
810
+ statusEl.className = 'g-save-profile-status error';
811
+ submitBtn.disabled = false;
812
+ submitBtn.textContent = selectedMode === 'overwrite'
813
+ ? t('groups.saveProfileOverwriteSubmit')
814
+ : t('groups.saveProfileSubmit');
815
+ return;
816
+ }
817
+ statusEl.textContent = t('groups.saveProfileDone', { name: profileId, count: saved });
818
+ statusEl.className = 'g-save-profile-status ok';
819
+ await refreshRoleProfileContext();
820
+ setTimeout(() => drawer.close(), 700);
821
+ }
822
+ catch (err) {
823
+ statusEl.textContent = String(err);
824
+ statusEl.className = 'g-save-profile-status error';
825
+ submitBtn.disabled = false;
826
+ submitBtn.textContent = selectedMode === 'overwrite'
827
+ ? t('groups.saveProfileOverwriteSubmit')
828
+ : t('groups.saveProfileSubmit');
829
+ }
830
+ };
831
+ }
419
832
  body.addEventListener('click', async (e) => {
420
833
  const btn = e.target.closest('button.manage-chat');
421
834
  if (!btn)
@@ -528,6 +941,7 @@ export async function renderGroupsPage(root) {
528
941
  try {
529
942
  await loadGroups();
530
943
  rerender();
944
+ void refreshRoleProfileContext();
531
945
  }
532
946
  catch { /* tolerate */ }
533
947
  }
@@ -575,6 +989,7 @@ export async function renderGroupsPage(root) {
575
989
  alert(lines || `Unexpected: ${JSON.stringify(respBody)}`);
576
990
  await loadGroups();
577
991
  rerender();
992
+ void refreshRoleProfileContext();
578
993
  }
579
994
  catch (e) {
580
995
  alert('Network error: ' + e);
@@ -611,6 +1026,7 @@ export async function renderGroupsPage(root) {
611
1026
  alert(`已解散(由 ${m.botName ?? m.larkAppId} 执行)${closedNote}`);
612
1027
  await loadGroups();
613
1028
  rerender();
1029
+ void refreshRoleProfileContext();
614
1030
  drawer.close();
615
1031
  return;
616
1032
  }