@musnows/scriverse 0.9.9 → 1.0.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.
@@ -0,0 +1,1899 @@
1
+ const mentionPattern = /mention:\/\/(character|user)\/([A-Za-z0-9_.:-]{1,200})/gu;
2
+
3
+ function array(value) {
4
+ return Array.isArray(value) ? value : [];
5
+ }
6
+
7
+ function value(record, key, fallback = "") {
8
+ return record && typeof record === "object" && !Array.isArray(record) && record[key] !== undefined
9
+ ? record[key]
10
+ : fallback;
11
+ }
12
+
13
+ function requestId() {
14
+ return `im-${crypto.randomUUID()}`;
15
+ }
16
+
17
+ function imAvatarInitial(item, kind) {
18
+ const label = kind === "user"
19
+ ? item?.displayName || item?.username || "人"
20
+ : item?.name || "角";
21
+ return Array.from(String(label))[0] ?? (kind === "user" ? "人" : "角");
22
+ }
23
+
24
+ function bindImAvatarFallbacks(root) {
25
+ root.querySelectorAll("[data-im-avatar-image]").forEach((image) => {
26
+ image.addEventListener("error", () => image.remove(), { once: true });
27
+ });
28
+ }
29
+
30
+ export function serializeImComposer(root) {
31
+ const visit = (node) => {
32
+ if (node.nodeType === Node.TEXT_NODE) return node.nodeValue ?? "";
33
+ if (!(node instanceof HTMLElement)) return "";
34
+ if (node.dataset.imMentionUri) return node.dataset.imMentionUri;
35
+ if (node.tagName === "BR") return "\n";
36
+ const content = [...node.childNodes].map(visit).join("");
37
+ return node !== root && ["DIV", "P"].includes(node.tagName) ? `${content}\n` : content;
38
+ };
39
+ return visit(root).replace(/\u00a0/gu, " ").trim();
40
+ }
41
+
42
+ export function normalizeImComposerHeight(value, maximumHeight, minimumHeight = 64) {
43
+ const maximum = Math.max(minimumHeight, Number(maximumHeight) || minimumHeight);
44
+ return Math.min(maximum, Math.max(minimumHeight, Number(value) || minimumHeight));
45
+ }
46
+
47
+ export function normalizeImConversationWidth(value, maximumWidth, minimumWidth = 72, defaultWidth = 300) {
48
+ const maximum = Math.max(minimumWidth, Number(maximumWidth) || minimumWidth);
49
+ const requested = Number.isFinite(Number(value)) ? Number(value) : defaultWidth;
50
+ return Math.min(maximum, Math.max(minimumWidth, requested));
51
+ }
52
+
53
+ export function resolveImConversationWidth(preferredWidth, viewportWidth, maximumWidth) {
54
+ return Number(viewportWidth) <= 620 ? 72 : normalizeImConversationWidth(preferredWidth, maximumWidth);
55
+ }
56
+
57
+ export function normalizeImDetailsWidth(value, maximumWidth, minimumWidth = 240, defaultWidth = 320) {
58
+ const maximum = Math.max(minimumWidth, Number(maximumWidth) || minimumWidth);
59
+ const requested = Number.isFinite(Number(value)) ? Number(value) : defaultWidth;
60
+ return Math.min(maximum, Math.max(minimumWidth, requested));
61
+ }
62
+
63
+ export function shouldMarkImConversationRead(opened, visibilityState) {
64
+ return opened === true && visibilityState !== "hidden";
65
+ }
66
+
67
+ export function shouldRefreshImConversationListForEvent(type) {
68
+ return ["conversation", "message", "chain"].includes(String(type));
69
+ }
70
+
71
+ export function matchImProvisionalReplyTurn(replies, message) {
72
+ const chainId = String(message?.chainId || "");
73
+ const characterId = String(message?.senderCharacterId || "");
74
+ if (!chainId || !characterId) return null;
75
+ const matches = array(replies).filter((reply) => reply.chainId === chainId
76
+ && reply.characterId === characterId
77
+ && ["pending", "running"].includes(String(reply.status)));
78
+ return matches.find((reply) => reply.status === "running")?.turnId ?? matches[0]?.turnId ?? null;
79
+ }
80
+
81
+ export function sameImGroupSettings(left, right) {
82
+ return String(left?.title || "") === String(right?.title || "")
83
+ && String(left?.replyMode || "mention") === String(right?.replyMode || "mention")
84
+ && Number(left?.responseThreshold) === Number(right?.responseThreshold)
85
+ && Number(left?.maxAiMessages) === Number(right?.maxAiMessages);
86
+ }
87
+
88
+ export function imDiagnosticStatusLabel(turn) {
89
+ if (turn?.selected === true) return "已选择";
90
+ const labels = {
91
+ pending: "等待判断",
92
+ running: "判断中",
93
+ completed: "未选择 · 低于阈值",
94
+ failed: "判断失败",
95
+ cancelled: "已取消",
96
+ skipped: "已跳过"
97
+ };
98
+ return labels[String(turn?.status || "")] ?? "状态未知";
99
+ }
100
+
101
+ export function isImRealtimeChainCurrent(activeChain, payload) {
102
+ const activeChainId = String(activeChain?.id || "");
103
+ const eventChainId = String(payload?.chainId || "");
104
+ return Boolean(activeChainId && eventChainId && activeChainId === eventChainId);
105
+ }
106
+
107
+ export function imConversationAccessibleLabel(title, subtitle, unreadCount = 0, mentionUnreadCount = 0) {
108
+ const unread = Math.max(0, Number(unreadCount) || 0);
109
+ const mentions = Math.max(0, Number(mentionUnreadCount) || 0);
110
+ const unreadLabel = mentions > 0
111
+ ? `${mentions} 条提及未读${unread > mentions ? `,共 ${unread} 条未读` : ""}`
112
+ : unread > 0 ? `${unread} 条未读` : "";
113
+ return [title, subtitle, unreadLabel].filter(Boolean).join(",");
114
+ }
115
+
116
+ export function findImMentionQuery(text, caretOffset = String(text).length) {
117
+ const source = String(text);
118
+ const offset = Math.max(0, Math.min(source.length, Number(caretOffset) || 0));
119
+ const match = source.slice(0, offset).match(/@([^@\s]*)$/u);
120
+ return match ? { query: match[1], startOffset: offset - match[0].length, endOffset: offset } : null;
121
+ }
122
+
123
+ export function shouldFollowImFeed(scrollHeight, scrollTop, clientHeight, force = false) {
124
+ return force === true || Number(scrollHeight) - Number(scrollTop) - Number(clientHeight) < 80;
125
+ }
126
+
127
+ export function mergeImMessagePages(previousMessages, ...nextPages) {
128
+ const byId = new Map();
129
+ for (const message of [...array(previousMessages), ...nextPages.flatMap(array)]) {
130
+ const key = String(message?.id || `sequence:${Number(message?.sequence)}`);
131
+ byId.set(key, message);
132
+ }
133
+ return [...byId.values()].sort((left, right) => Number(left.sequence) - Number(right.sequence));
134
+ }
135
+
136
+ export function mergeImFailedReplyPages(...pages) {
137
+ const byId = new Map();
138
+ for (const reply of pages.flatMap(array)) byId.set(String(reply?.id || ""), reply);
139
+ byId.delete("");
140
+ return [...byId.values()].sort((left, right) => Number(left.triggerSequence) - Number(right.triggerSequence)
141
+ || String(left.createdAt || "").localeCompare(String(right.createdAt || ""))
142
+ || String(left.id || "").localeCompare(String(right.id || "")));
143
+ }
144
+
145
+ export function imMessageSequenceBounds(messages) {
146
+ const sequences = array(messages).map((message) => Number(message?.sequence)).filter(Number.isFinite);
147
+ return sequences.length ? { minimum: Math.min(...sequences), maximum: Math.max(...sequences) } : null;
148
+ }
149
+
150
+ export function hasImMessageSequenceGap(previousMessages, nextMessages) {
151
+ const previous = imMessageSequenceBounds(previousMessages);
152
+ const next = imMessageSequenceBounds(nextMessages);
153
+ return Boolean(previous && next && previous.maximum + 1 < next.minimum);
154
+ }
155
+
156
+ export async function collectImMessageGap(previousMessages, nextMessages, loadPage) {
157
+ const gapMessages = [];
158
+ const nextBounds = imMessageSequenceBounds(nextMessages);
159
+ let cursor = imMessageSequenceBounds(previousMessages)?.maximum ?? 0;
160
+ while (nextBounds && cursor + 1 < nextBounds.minimum) {
161
+ const page = await loadPage(cursor);
162
+ const messages = array(page?.messages);
163
+ const pageBounds = imMessageSequenceBounds(messages);
164
+ if (!pageBounds || pageBounds.maximum <= cursor) throw new Error("IM 历史消息补齐失败,请重试");
165
+ gapMessages.push(...messages);
166
+ cursor = pageBounds.maximum;
167
+ if (page?.hasMoreMessagesAfter !== true && cursor + 1 < nextBounds.minimum) {
168
+ throw new Error("IM 历史消息存在缺口,请重新打开会话");
169
+ }
170
+ }
171
+ return gapMessages;
172
+ }
173
+
174
+ export function createImWorkspace({ api, esc, renderMarkdown, toast, confirmToast, state, showShelf, onRouteChange, beforeOpen }) {
175
+ const workspace = document.querySelector("#im-view");
176
+ const listHost = document.querySelector("#im-conversation-list");
177
+ const feed = document.querySelector("#im-message-feed");
178
+ const composer = document.querySelector("#im-composer");
179
+ const composerResize = document.querySelector("#im-composer-resize");
180
+ const conversationsPanel = workspace.querySelector(".im-conversations");
181
+ const conversationsResize = document.querySelector("#im-conversations-resize");
182
+ const detailsPanel = document.querySelector("#im-details");
183
+ const detailsResize = document.querySelector("#im-details-resize");
184
+ const mentionMenu = document.querySelector("#im-mention-menu");
185
+ const unreadBadge = document.querySelector("#im-unread-count");
186
+ const detailsDrawerMedia = window.matchMedia("(max-width: 980px)");
187
+ const mobileConversationMedia = window.matchMedia("(max-width: 620px)");
188
+ let conversations = [];
189
+ let conversationNextCursor = null;
190
+ let conversationUnreadTotal = 0;
191
+ let conversationPageLoading = false;
192
+ let unreadRequest = 0;
193
+ let current = null;
194
+ let works = [];
195
+ let createCharacters = [];
196
+ let createCharacterNextCursor = null;
197
+ const createSelectedCharacters = new Map();
198
+ let createHumans = [];
199
+ const createSelectedHumans = new Map();
200
+ let createSearchTimer = null;
201
+ let createSearchRequest = 0;
202
+ let createHumanSearchTimer = null;
203
+ let createHumanSearchRequest = 0;
204
+ let memberAddKind = null;
205
+ let memberAddCandidates = [];
206
+ let memberAddCharacterNextCursor = null;
207
+ let memberAddSelectedId = "";
208
+ let memberAddSearchTimer = null;
209
+ let memberAddRequest = 0;
210
+ let conversationListRequest = 0;
211
+ const conversationSummaryRequests = new Map();
212
+ let conversationRequest = 0;
213
+ let diagnosticsRequest = 0;
214
+ let ownerConfirmationPending = false;
215
+ const pendingMessageRequests = new Map();
216
+ const sendingConversations = new Set();
217
+ const pendingAnnouncementRequests = new Map();
218
+ let requestedConversationId = null;
219
+ let models = [];
220
+ let settings = null;
221
+ let eventSource = null;
222
+ const provisionalReplies = new Map();
223
+ const conversationDrafts = new Map();
224
+ const groupSettingsDrafts = new Map();
225
+ let mentionOptions = [];
226
+ let mentionIndex = -1;
227
+ let mentionCaretState = null;
228
+ let opened = false;
229
+ let composerHeight = 68;
230
+ let conversationsWidth = 300;
231
+ let detailsWidth = 320;
232
+ let detailsHidden = false;
233
+ let bound = false;
234
+ let preferredConversationsWidth = 300;
235
+ let preferredDetailsWidth = 320;
236
+
237
+ const conversationsWidthStorageKey = "scriverse.im.conversations-width.v1";
238
+ const detailsWidthStorageKey = "scriverse.im.details-width.v1";
239
+ const conversationsMaximumWidth = () => Math.max(72, Math.min(420, window.innerWidth - (window.innerWidth > 980 ? 680 : 360)));
240
+ const detailsMaximumWidth = () => Math.max(240, Math.min(520, window.innerWidth - conversationsWidth - 420));
241
+
242
+ function applyConversationsWidth(width, persist = false) {
243
+ conversationsWidth = resolveImConversationWidth(width, window.innerWidth, conversationsMaximumWidth());
244
+ if (window.innerWidth > 620) preferredConversationsWidth = conversationsWidth;
245
+ workspace.style.setProperty("--im-conversations-width", `${conversationsWidth}px`);
246
+ conversationsPanel.classList.toggle("is-compact", conversationsWidth <= 180);
247
+ conversationsResize.setAttribute("aria-valuemax", String(conversationsMaximumWidth()));
248
+ conversationsResize.setAttribute("aria-valuenow", String(Math.round(conversationsWidth)));
249
+ if (window.innerWidth > 980) applyDetailsWidth(preferredDetailsWidth);
250
+ if (persist && window.innerWidth > 620) {
251
+ try { localStorage.setItem(conversationsWidthStorageKey, String(Math.round(preferredConversationsWidth))); } catch { /* 浏览器禁用存储时仅保留当前布局。 */ }
252
+ }
253
+ }
254
+
255
+ function setupConversationsResize() {
256
+ let resize = null;
257
+ try { preferredConversationsWidth = Number(localStorage.getItem(conversationsWidthStorageKey)) || preferredConversationsWidth; } catch { /* 浏览器禁用存储时使用默认宽度。 */ }
258
+ conversationsWidth = preferredConversationsWidth;
259
+ conversationsResize.addEventListener("pointerdown", (event) => {
260
+ if (event.button !== 0 || window.innerWidth <= 620) return;
261
+ resize = { pointerId: event.pointerId, startX: event.clientX, startWidth: conversationsWidth };
262
+ conversationsResize.setPointerCapture(event.pointerId);
263
+ document.body.classList.add("is-im-conversations-resizing");
264
+ });
265
+ conversationsResize.addEventListener("pointermove", (event) => {
266
+ if (!resize || event.pointerId !== resize.pointerId) return;
267
+ applyConversationsWidth(resize.startWidth + event.clientX - resize.startX);
268
+ });
269
+ const finish = (event) => {
270
+ if (!resize || event.pointerId !== resize.pointerId) return;
271
+ resize = null;
272
+ document.body.classList.remove("is-im-conversations-resizing");
273
+ applyConversationsWidth(conversationsWidth, true);
274
+ };
275
+ conversationsResize.addEventListener("pointerup", finish);
276
+ conversationsResize.addEventListener("pointercancel", finish);
277
+ conversationsResize.addEventListener("keydown", (event) => {
278
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) || window.innerWidth <= 620) return;
279
+ event.preventDefault();
280
+ if (event.key === "Home") applyConversationsWidth(72, true);
281
+ else if (event.key === "End") applyConversationsWidth(conversationsMaximumWidth(), true);
282
+ else applyConversationsWidth(conversationsWidth + (event.key === "ArrowRight" ? 16 : -16), true);
283
+ });
284
+ window.addEventListener("resize", () => applyConversationsWidth(preferredConversationsWidth));
285
+ applyConversationsWidth(preferredConversationsWidth);
286
+ }
287
+
288
+ function applyDetailsWidth(width, persist = false) {
289
+ detailsWidth = normalizeImDetailsWidth(width, detailsMaximumWidth());
290
+ if (window.innerWidth > 980) preferredDetailsWidth = detailsWidth;
291
+ workspace.style.setProperty("--im-details-width", `${detailsWidth}px`);
292
+ detailsResize.setAttribute("aria-valuemax", String(detailsMaximumWidth()));
293
+ detailsResize.setAttribute("aria-valuenow", String(Math.round(detailsWidth)));
294
+ if (persist && window.innerWidth > 980) {
295
+ try { localStorage.setItem(detailsWidthStorageKey, String(Math.round(preferredDetailsWidth))); } catch { /* 浏览器禁用存储时仅保留当前布局。 */ }
296
+ }
297
+ }
298
+
299
+ function setupDetailsResize() {
300
+ let resize = null;
301
+ try { preferredDetailsWidth = Number(localStorage.getItem(detailsWidthStorageKey)) || preferredDetailsWidth; } catch { /* 浏览器禁用存储时使用默认宽度。 */ }
302
+ detailsWidth = preferredDetailsWidth;
303
+ detailsResize.addEventListener("pointerdown", (event) => {
304
+ if (event.button !== 0 || window.innerWidth <= 980 || detailsHidden) return;
305
+ resize = { pointerId: event.pointerId, startX: event.clientX, startWidth: detailsWidth };
306
+ detailsResize.setPointerCapture(event.pointerId);
307
+ document.body.classList.add("is-im-details-resizing");
308
+ });
309
+ detailsResize.addEventListener("pointermove", (event) => {
310
+ if (!resize || event.pointerId !== resize.pointerId) return;
311
+ applyDetailsWidth(resize.startWidth + resize.startX - event.clientX);
312
+ });
313
+ const finish = (event) => {
314
+ if (!resize || event.pointerId !== resize.pointerId) return;
315
+ resize = null;
316
+ document.body.classList.remove("is-im-details-resizing");
317
+ applyDetailsWidth(detailsWidth, true);
318
+ };
319
+ detailsResize.addEventListener("pointerup", finish);
320
+ detailsResize.addEventListener("pointercancel", finish);
321
+ detailsResize.addEventListener("keydown", (event) => {
322
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) || window.innerWidth <= 980 || detailsHidden) return;
323
+ event.preventDefault();
324
+ if (event.key === "Home") applyDetailsWidth(240, true);
325
+ else if (event.key === "End") applyDetailsWidth(detailsMaximumWidth(), true);
326
+ else applyDetailsWidth(detailsWidth + (event.key === "ArrowLeft" ? 16 : -16), true);
327
+ });
328
+ window.addEventListener("resize", () => applyDetailsWidth(preferredDetailsWidth));
329
+ applyDetailsWidth(preferredDetailsWidth);
330
+ }
331
+
332
+ const composerMaximumHeight = () => Math.max(64, Math.min(420, window.innerHeight - 280));
333
+
334
+ function applyComposerHeight(height) {
335
+ composerHeight = normalizeImComposerHeight(height, composerMaximumHeight());
336
+ composer.style.height = `${composerHeight}px`;
337
+ composerResize.setAttribute("aria-valuemax", String(composerMaximumHeight()));
338
+ composerResize.setAttribute("aria-valuenow", String(Math.round(composerHeight)));
339
+ }
340
+
341
+ function setupComposerResize() {
342
+ let resize = null;
343
+ composerResize.addEventListener("pointerdown", (event) => {
344
+ if (event.button !== 0) return;
345
+ resize = { pointerId: event.pointerId, startY: event.clientY, startHeight: composerHeight };
346
+ composerResize.setPointerCapture(event.pointerId);
347
+ document.body.classList.add("is-im-composer-resizing");
348
+ });
349
+ composerResize.addEventListener("pointermove", (event) => {
350
+ if (!resize || event.pointerId !== resize.pointerId) return;
351
+ applyComposerHeight(resize.startHeight + resize.startY - event.clientY);
352
+ });
353
+ const finish = (event) => {
354
+ if (!resize || event.pointerId !== resize.pointerId) return;
355
+ resize = null;
356
+ document.body.classList.remove("is-im-composer-resizing");
357
+ };
358
+ composerResize.addEventListener("pointerup", finish);
359
+ composerResize.addEventListener("pointercancel", finish);
360
+ composerResize.addEventListener("keydown", (event) => {
361
+ if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
362
+ event.preventDefault();
363
+ if (event.key === "Home") applyComposerHeight(64);
364
+ else if (event.key === "End") applyComposerHeight(composerMaximumHeight());
365
+ else applyComposerHeight(composerHeight + (event.key === "ArrowUp" ? 24 : -24));
366
+ });
367
+ window.addEventListener("resize", () => applyComposerHeight(composerHeight));
368
+ applyComposerHeight(composerHeight);
369
+ }
370
+
371
+ const hideMainViews = () => {
372
+ [
373
+ "shelf-view", "platform-ai-view", "platform-usage-view", "work-audit-view", "settings-hub-view",
374
+ "welcome-view", "editor-view", "module-view", "members-view", "admin-ai-conversations-view"
375
+ ].forEach((id) => document.querySelector(`#${id}`)?.classList.add("hidden"));
376
+ };
377
+
378
+ const currentUserId = () => state.user?.userId ?? "";
379
+
380
+ function imAvatarHtml(item, kind, extraClass = "") {
381
+ const avatarClass = kind === "user" ? "user-avatar" : "character-avatar";
382
+ const fallbackClass = kind === "user" ? "user-avatar-fallback" : "character-avatar-fallback";
383
+ const image = item?.avatarUrl
384
+ ? `<img src="${esc(item.avatarUrl)}" alt="" loading="lazy" decoding="async" data-im-avatar-image>`
385
+ : "";
386
+ return `<span class="${avatarClass}${extraClass ? ` ${esc(extraClass)}` : ""}" aria-hidden="true"><span class="${fallbackClass}">${esc(imAvatarInitial(item, kind))}</span>${image}</span>`;
387
+ }
388
+
389
+ function renderUnread() {
390
+ const count = conversationUnreadTotal;
391
+ unreadBadge.textContent = count > 99 ? "99+" : String(count);
392
+ unreadBadge.classList.toggle("hidden", count === 0);
393
+ document.querySelector("#im-open-button")?.setAttribute("aria-label", count ? `打开 IM,${count} 条未读` : "打开 IM");
394
+ }
395
+
396
+ async function refreshUnreadTotal() {
397
+ const request = ++unreadRequest;
398
+ const totals = await api("/api/im/unread");
399
+ if (request !== unreadRequest) return;
400
+ conversationUnreadTotal = Number(totals.unreadCount ?? 0);
401
+ renderUnread();
402
+ }
403
+
404
+ async function refreshConversations() {
405
+ const request = ++conversationListRequest;
406
+ const unreadGeneration = ++unreadRequest;
407
+ const page = await api("/api/im/conversations?limit=50");
408
+ if (request !== conversationListRequest) return;
409
+ conversations = array(page.items ?? page);
410
+ if (current && !conversations.some((conversation) => conversation.id === current.id)) conversations.push(current);
411
+ conversationNextCursor = page.nextCursor ?? null;
412
+ if (unreadGeneration === unreadRequest) {
413
+ conversationUnreadTotal = Number(page.unreadCount ?? conversations.reduce((total, item) => total + Number(item.unreadCount || 0), 0));
414
+ }
415
+ renderUnread();
416
+ renderConversationList();
417
+ }
418
+
419
+ async function loadMoreConversations() {
420
+ if (conversationNextCursor === null || conversationPageLoading) return;
421
+ conversationPageLoading = true;
422
+ const unreadGeneration = ++unreadRequest;
423
+ const cursor = conversationNextCursor;
424
+ try {
425
+ const page = await api(`/api/im/conversations?limit=50&cursor=${encodeURIComponent(cursor)}`);
426
+ if (cursor !== conversationNextCursor) return;
427
+ const known = new Set(conversations.map((conversation) => conversation.id));
428
+ conversations.push(...array(page.items).filter((conversation) => !known.has(conversation.id)));
429
+ conversationNextCursor = page.nextCursor ?? null;
430
+ if (unreadGeneration === unreadRequest) conversationUnreadTotal = Number(page.unreadCount ?? conversationUnreadTotal);
431
+ renderUnread();
432
+ renderConversationList();
433
+ } finally {
434
+ conversationPageLoading = false;
435
+ }
436
+ }
437
+
438
+ function upsertConversationSummary(summary) {
439
+ const index = conversations.findIndex((conversation) => conversation.id === summary.id);
440
+ if (index >= 0) conversations[index] = summary;
441
+ else conversations.push(summary);
442
+ conversations.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")));
443
+ renderUnread();
444
+ renderConversationList();
445
+ }
446
+
447
+ async function refreshConversationSummary(conversationId) {
448
+ const request = (conversationSummaryRequests.get(conversationId) || 0) + 1;
449
+ conversationSummaryRequests.set(conversationId, request);
450
+ const summary = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/summary`);
451
+ if (conversationSummaryRequests.get(conversationId) !== request) return;
452
+ upsertConversationSummary(summary);
453
+ }
454
+
455
+ function conversationSubtitle(item) {
456
+ if (item.status === "disbanded") return "已解散 · 历史只读";
457
+ if (item.active === false) return "已退出 · 历史只读";
458
+ if (item.kind === "direct") return "角色单聊";
459
+ return item.replyMode === "proactive" ? `主动交流 · 阈值 ${item.responseThreshold}` : "Mention 模式";
460
+ }
461
+
462
+ function conversationAvatarHtml(item) {
463
+ if (item.kind === "direct") {
464
+ const character = array(item.avatarCharacters)[0];
465
+ return character
466
+ ? imAvatarHtml(character, "character", "im-conversation-single-avatar")
467
+ : '<span class="im-conversation-avatar" aria-hidden="true">角</span>';
468
+ }
469
+ const members = array(item.avatarMembers).slice(0, 9);
470
+ const gridSize = members.length <= 4 ? 4 : 9;
471
+ const cells = [
472
+ ...members.map((member) => imAvatarHtml(member, member.kind === "user" ? "user" : "character", "im-group-avatar-cell")),
473
+ ...Array.from({ length: Math.max(0, gridSize - members.length) }, () => '<span class="im-group-avatar-empty"></span>')
474
+ ];
475
+ return `<span class="im-group-avatar-grid" data-grid-size="${gridSize}" aria-hidden="true">${cells.join("")}</span>`;
476
+ }
477
+
478
+ function renderConversationList() {
479
+ const focusedConversationId = listHost.contains(document.activeElement)
480
+ ? document.activeElement.closest?.("[data-im-conversation]")?.dataset.imConversation
481
+ : null;
482
+ const items = conversations.length
483
+ ? conversations.map((item) => `<button class="im-conversation-item${current?.id === item.id ? " is-active" : ""}" type="button" data-im-conversation="${esc(item.id)}"${current?.id === item.id ? ' aria-current="true"' : ""} aria-label="${esc(imConversationAccessibleLabel(item.title, conversationSubtitle(item), item.unreadCount, item.mentionUnreadCount))}" title="${esc(item.title)}">
484
+ ${conversationAvatarHtml(item)}
485
+ <span><strong>${esc(item.title)}</strong><small>${esc(conversationSubtitle(item))}</small></span>
486
+ ${item.mentionUnreadCount ? `<b class="im-mention-unread">@${Number(item.mentionUnreadCount)}</b>` : item.unreadCount ? `<b class="im-item-unread">${Number(item.unreadCount)}</b>` : ""}
487
+ </button>`).join("")
488
+ : '<p class="im-empty">还没有 IM 会话。点击“新建会话”,先选书籍,再选择一个或多个角色。</p>';
489
+ listHost.innerHTML = `${items}${conversationNextCursor === null ? "" : '<button class="im-button im-button-secondary im-load-more-conversations" type="button" data-im-load-more-conversations>加载更多会话</button>'}`;
490
+ bindImAvatarFallbacks(listHost);
491
+ if (focusedConversationId) {
492
+ [...listHost.querySelectorAll("[data-im-conversation]")]
493
+ .find((button) => button.dataset.imConversation === focusedConversationId)?.focus();
494
+ }
495
+ }
496
+
497
+ function mentionLabel(mention) {
498
+ const snapshot = value(mention, "snapshot", {});
499
+ return mention.kind === "user"
500
+ ? snapshot.displayName || snapshot.username || mention.id
501
+ : snapshot.name || mention.id;
502
+ }
503
+
504
+ function messageHtml(message) {
505
+ let source = String(message.content ?? "");
506
+ const tokens = [];
507
+ const mentions = array(message.mentions);
508
+ const consumedMentions = new Set();
509
+ source = source.replace(mentionPattern, (raw, kind, id) => {
510
+ const mentionIndex = mentions.findIndex((mention, index) => !consumedMentions.has(index) && mention.kind === kind && mention.id === id);
511
+ if (mentionIndex < 0) return raw;
512
+ const mention = mentions[mentionIndex];
513
+ consumedMentions.add(mentionIndex);
514
+ const token = `IMMENTION${String(message.id).replace(/[^A-Za-z0-9]/gu, "")}TOKEN${mentionIndex}END`;
515
+ tokens.push({ token, mention });
516
+ return token;
517
+ });
518
+ let html = renderMarkdown(source);
519
+ for (const token of tokens) {
520
+ html = html.replaceAll(
521
+ token.token,
522
+ `<span class="im-inline-mention" data-im-rendered-mention="${esc(token.mention.kind)}:${esc(token.mention.id)}">@${esc(mentionLabel(token.mention))}</span>`
523
+ );
524
+ }
525
+ return html;
526
+ }
527
+
528
+ function upsertProvisionalReply(payload) {
529
+ const turnId = String(payload?.turnId || payload?.id || "");
530
+ if (!turnId) return null;
531
+ const previous = provisionalReplies.get(turnId) || {};
532
+ const eventCharacter = value(payload, "character", {});
533
+ const characterId = String(payload?.characterId || eventCharacter.characterId || previous.characterId || "");
534
+ const character = activeCharacters().find((item) => item.characterId === characterId);
535
+ const eventError = value(payload, "error", {});
536
+ const next = {
537
+ ...previous,
538
+ turnId,
539
+ chainId: String(payload?.chainId || previous.chainId || ""),
540
+ characterId,
541
+ name: eventCharacter.name || character?.name || previous.name || "角色",
542
+ avatarUrl: eventCharacter.avatarUrl || character?.avatarUrl || previous.avatarUrl || null,
543
+ status: String(payload?.status || previous.status || "pending"),
544
+ error: eventError.message || payload?.failure || previous.error || "",
545
+ content: payload?.content !== undefined ? String(payload.content) : previous.content || ""
546
+ };
547
+ provisionalReplies.set(turnId, next);
548
+ return next;
549
+ }
550
+
551
+ function syncProvisionalReplies() {
552
+ const previous = new Map(provisionalReplies);
553
+ const streaming = new Map(array(current?.streamingReplies).map((reply) => [String(reply.turnId || ""), reply]));
554
+ provisionalReplies.clear();
555
+ for (const turn of array(current?.activeChain?.turns)) {
556
+ if (!['pending', 'running'].includes(String(turn.status))) continue;
557
+ const retained = previous.get(String(turn.id));
558
+ const snapshot = streaming.get(String(turn.id));
559
+ const next = upsertProvisionalReply({ ...turn, ...snapshot, turnId: turn.id });
560
+ if (next && !snapshot?.content && retained?.content) next.content = retained.content;
561
+ }
562
+ }
563
+
564
+ function provisionalReplyBodyHtml(reply) {
565
+ const failed = ["failed", "skipped"].includes(reply.status);
566
+ const pendingCopy = reply.status === "pending" ? "等待角色开始回答…" : "正在组织回答…";
567
+ const content = reply.content ? renderMarkdown(reply.content) : failed ? "" : `<p class="im-provisional-placeholder">${pendingCopy}</p>`;
568
+ const failure = failed ? `<p class="im-provisional-error">${esc(reply.error || "角色回答生成失败")}</p>` : "";
569
+ return `${content}${failure}`;
570
+ }
571
+
572
+ function syncGeneratingSummary() {
573
+ const count = [...provisionalReplies.values()].filter((reply) => ["pending", "running"].includes(reply.status)).length;
574
+ const summary = feed.querySelector(".im-generating-summary");
575
+ if (summary && count > 0) summary.textContent = `${count} 个角色正在生成回答`;
576
+ else if (summary) summary.remove();
577
+ return count;
578
+ }
579
+
580
+ function updateProvisionalReplyElement(reply) {
581
+ const article = [...feed.querySelectorAll("[data-im-provisional-turn]")]
582
+ .find((item) => item.dataset.imProvisionalTurn === reply.turnId);
583
+ if (!article) {
584
+ renderMessages();
585
+ return;
586
+ }
587
+ const follow = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
588
+ const failed = ["failed", "skipped"].includes(reply.status);
589
+ const statusLabel = reply.status === "pending" ? "等待生成" : reply.status === "running" ? "正在生成" : reply.status === "skipped" ? "未生成" : "生成失败";
590
+ article.classList.toggle("is-failed", failed);
591
+ article.dataset.imProvisionalStatus = reply.status;
592
+ article.querySelector(".im-provisional-status").textContent = statusLabel;
593
+ article.querySelector(".im-message-body").innerHTML = provisionalReplyBodyHtml(reply);
594
+ syncGeneratingSummary();
595
+ if (follow) feed.scrollTop = feed.scrollHeight;
596
+ }
597
+
598
+ function commitRealtimeMessage(message) {
599
+ if (!message?.id) return;
600
+ current.messages = mergeImMessagePages(current.messages, [message]);
601
+ current.latestSequence = Math.max(Number(current.latestSequence || 0), Number(message.sequence || 0));
602
+ const turnId = matchImProvisionalReplyTurn([...provisionalReplies.values()], message);
603
+ if (turnId) provisionalReplies.delete(turnId);
604
+ renderMessages();
605
+ }
606
+
607
+ function renderMessages({ scrollToBottom = false } = {}) {
608
+ const follow = shouldFollowImFeed(feed.scrollHeight, feed.scrollTop, feed.clientHeight, scrollToBottom);
609
+ const previousTop = feed.scrollTop;
610
+ const messages = array(current?.messages);
611
+ const provisional = [...provisionalReplies.values()];
612
+ const failedRepliesByMessage = new Map();
613
+ for (const reply of array(current?.failedReplies)) {
614
+ const triggerMessageId = String(reply.triggerMessageId || "");
615
+ if (!triggerMessageId) continue;
616
+ const replies = failedRepliesByMessage.get(triggerMessageId) ?? [];
617
+ replies.push(reply);
618
+ failedRepliesByMessage.set(triggerMessageId, replies);
619
+ }
620
+ if (!messages.length && !provisional.length && failedRepliesByMessage.size === 0) {
621
+ feed.innerHTML = '<p class="im-feed-empty">从一条消息开始。角色单聊会直接回复;群聊按当前回复模式调度 AI。</p>';
622
+ return;
623
+ }
624
+ const loadOlder = current?.hasMoreMessages
625
+ ? '<button class="im-load-older" type="button" data-im-load-older>加载更早消息</button>'
626
+ : "";
627
+ const generatingCount = provisional.filter((reply) => ['pending', 'running'].includes(reply.status)).length;
628
+ const generatingSummary = generatingCount
629
+ ? `<div class="im-generating-summary" role="status">${generatingCount} 个角色正在生成回答</div>`
630
+ : "";
631
+ const provisionalHtml = provisional.map((reply) => {
632
+ const failed = ['failed', 'skipped'].includes(reply.status);
633
+ const statusLabel = reply.status === "pending" ? "等待生成" : reply.status === "running" ? "正在生成" : reply.status === "skipped" ? "未生成" : "生成失败";
634
+ return `<article class="im-message is-character is-provisional${failed ? " is-failed" : ""}" data-im-provisional-turn="${esc(reply.turnId)}" data-im-provisional-status="${esc(reply.status)}">
635
+ <header>${imAvatarHtml(reply, "character", "im-message-avatar")}<strong>${esc(reply.name || "角色")}</strong><span class="im-provisional-status">${statusLabel}</span></header>
636
+ <div class="im-message-body message-body">${provisionalReplyBodyHtml(reply)}</div>
637
+ </article>`;
638
+ }).join("");
639
+ feed.innerHTML = loadOlder + messages.map((message) => {
640
+ const sender = value(message, "sender", {});
641
+ const model = value(message, "metadata", {});
642
+ const announcement = model.type === "announcement";
643
+ const label = announcement ? "旁白" : sender.name || sender.displayName || (message.senderKind === "system" ? "系统" : "成员");
644
+ const own = message.senderUserId === currentUserId();
645
+ const avatar = announcement || message.senderKind === "system"
646
+ ? ""
647
+ : imAvatarHtml(sender, message.senderKind === "character" ? "character" : "user", "im-message-avatar");
648
+ const failedReplies = array(failedRepliesByMessage.get(String(message.id))).map((reply) => {
649
+ const character = value(reply, "character", {});
650
+ return `<article class="im-message is-character is-provisional is-failed" data-im-failed-turn="${esc(reply.id)}">
651
+ <header>${imAvatarHtml(character, "character", "im-message-avatar")}<strong>${esc(character.name || "角色")}</strong><span class="im-provisional-status">${reply.status === "skipped" ? "未生成" : "生成失败"}</span></header>
652
+ <div class="im-message-body message-body">${provisionalReplyBodyHtml({ status: reply.status, error: reply.failure || "角色回答生成失败", content: "" })}</div>
653
+ </article>`;
654
+ }).join("");
655
+ return `<article class="im-message is-${esc(message.senderKind)}${announcement ? " is-announcement" : ""}${own ? " is-own" : ""}" data-im-message="${esc(message.id)}">
656
+ <header>${avatar}<strong>${esc(label)}</strong><time>${esc(new Date(message.createdAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }))}</time></header>
657
+ <div class="im-message-body message-body">${messageHtml(message)}</div>
658
+ ${message.senderKind === "character" ? `<details class="im-model-details"><summary>调用详情</summary><span>${esc(model.modelDisplayName || model.modelId || "未知模型")} · ${model.modelStage === "fallback" ? "fallback" : "主模型"} · ${Number(model.attemptCount || 1)} 次请求 · ${Number(model.durationMs || 0)} ms</span></details>` : ""}
659
+ </article>${failedReplies}`;
660
+ }).join("") + generatingSummary + provisionalHtml;
661
+ bindImAvatarFallbacks(feed);
662
+ feed.scrollTop = follow ? feed.scrollHeight : previousTop;
663
+ }
664
+
665
+ async function loadOlderMessages() {
666
+ const conversationId = current?.id;
667
+ const oldestSequence = Math.min(...array(current?.messages).map((message) => Number(message.sequence)).filter(Number.isFinite));
668
+ if (!conversationId || !Number.isFinite(oldestSequence) || !current?.hasMoreMessages) return;
669
+ const button = feed.querySelector("[data-im-load-older]");
670
+ if (button) button.disabled = true;
671
+ const previousHeight = feed.scrollHeight;
672
+ const previousTop = feed.scrollTop;
673
+ try {
674
+ const page = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}?beforeSequence=${encodeURIComponent(oldestSequence)}`);
675
+ if (current?.id !== conversationId) return;
676
+ const messagesById = new Map([
677
+ ...array(page.messages),
678
+ ...array(current.messages)
679
+ ].map((message) => [message.id, message]));
680
+ current.messages = [...messagesById.values()].sort((left, right) => Number(left.sequence) - Number(right.sequence));
681
+ current.failedReplies = mergeImFailedReplyPages(page.failedReplies, current.failedReplies);
682
+ current.hasMoreMessages = page.hasMoreMessages === true;
683
+ renderMessages();
684
+ feed.scrollTop = previousTop + Math.max(0, feed.scrollHeight - previousHeight);
685
+ } catch (error) {
686
+ if (button) button.disabled = false;
687
+ toast(error.message, "error");
688
+ }
689
+ }
690
+
691
+ function activeHumans() {
692
+ return array(current?.participants?.humans).filter((item) => !item.leftAt);
693
+ }
694
+
695
+ function presentCharacters() {
696
+ return array(current?.participants?.characters).filter((item) => !item.leftAt);
697
+ }
698
+
699
+ function activeCharacters() {
700
+ return presentCharacters().filter((item) => item.status === "active");
701
+ }
702
+
703
+ function readGroupSettingsForm() {
704
+ const form = document.querySelector("#im-group-settings");
705
+ if (!form) return null;
706
+ return {
707
+ title: document.querySelector("#im-detail-title").value,
708
+ replyMode: document.querySelector("#im-detail-mode").value,
709
+ responseThreshold: Number(document.querySelector("#im-detail-threshold").value),
710
+ maxAiMessages: Number(document.querySelector("#im-detail-limit").value)
711
+ };
712
+ }
713
+
714
+ function captureGroupSettingsDraft() {
715
+ const form = document.querySelector("#im-group-settings");
716
+ if (!form || !current?.id || current.kind !== "group" || current.ownerUserId !== currentUserId()) return null;
717
+ const draft = readGroupSettingsForm();
718
+ if (!draft) return null;
719
+ if (sameImGroupSettings(draft, current)) groupSettingsDrafts.delete(current.id);
720
+ else groupSettingsDrafts.set(current.id, draft);
721
+ const control = form.contains(document.activeElement) ? document.activeElement : null;
722
+ return control?.id ? {
723
+ id: control.id,
724
+ selectionStart: typeof control.selectionStart === "number" ? control.selectionStart : null,
725
+ selectionEnd: typeof control.selectionEnd === "number" ? control.selectionEnd : null
726
+ } : null;
727
+ }
728
+
729
+ function restoreGroupSettingsFocus(snapshot) {
730
+ if (!snapshot) return;
731
+ const control = document.querySelector(`#${snapshot.id}`);
732
+ control?.focus();
733
+ if (control && snapshot.selectionStart !== null && snapshot.selectionEnd !== null) {
734
+ control.setSelectionRange(snapshot.selectionStart, snapshot.selectionEnd);
735
+ }
736
+ }
737
+
738
+ function syncComposer() {
739
+ const writable = current?.active === true && current?.status === "active" && activeCharacters().length > 0;
740
+ const canAnnounce = writable && current?.kind === "group" && current?.ownerUserId === currentUserId();
741
+ composer.contentEditable = String(writable);
742
+ composer.setAttribute("aria-disabled", String(!writable));
743
+ document.querySelector("#im-send").disabled = !writable;
744
+ document.querySelector("#im-announcement-button").classList.toggle("hidden", !canAnnounce);
745
+ document.querySelector("#im-announcement-button").disabled = !canAnnounce;
746
+ document.querySelector("#im-stop").classList.toggle("hidden", !["queued", "running"].includes(current?.activeChain?.status));
747
+ document.querySelector("#im-retry").classList.toggle("hidden", !["waiting_config", "failed", "interrupted"].includes(current?.activeChain?.status));
748
+ }
749
+
750
+ function syncDetailsDrawerAccessibility() {
751
+ const expanded = detailsDrawerMedia.matches ? detailsPanel.classList.contains("is-open") : !detailsHidden;
752
+ workspace.classList.toggle("is-details-hidden", !detailsDrawerMedia.matches && detailsHidden);
753
+ detailsPanel.toggleAttribute("inert", !expanded);
754
+ detailsPanel.setAttribute("aria-hidden", String(!expanded));
755
+ document.querySelector("#im-details-toggle").setAttribute("aria-expanded", String(expanded));
756
+ }
757
+
758
+ function setDetailsDrawerOpen(expanded, focusTarget = null) {
759
+ if (detailsDrawerMedia.matches) detailsPanel.classList.toggle("is-open", expanded);
760
+ else {
761
+ detailsHidden = !expanded;
762
+ detailsPanel.classList.remove("is-open");
763
+ }
764
+ syncDetailsDrawerAccessibility();
765
+ if (focusTarget === "drawer") document.querySelector("#im-details-close").focus();
766
+ if (focusTarget === "toggle") document.querySelector("#im-details-toggle").focus();
767
+ }
768
+
769
+ function resetDetailsDrawer() {
770
+ detailsPanel.classList.remove("is-open");
771
+ syncDetailsDrawerAccessibility();
772
+ }
773
+
774
+ function renderDetails() {
775
+ const host = document.querySelector("#im-details-content");
776
+ if (!current) {
777
+ host.innerHTML = '<p class="im-empty">选择会话后查看成员与设置。</p>';
778
+ return;
779
+ }
780
+ const owner = current.ownerUserId === currentUserId();
781
+ const groupSettings = groupSettingsDrafts.get(current.id) ?? current;
782
+ const canManageMembers = owner && current.kind === "group" && current.active === true;
783
+ const addButton = (kind, label) => canManageMembers
784
+ ? `<button class="im-member-add-button" type="button" data-im-open-member-add="${kind}" aria-label="${label}" title="${label}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg></button>`
785
+ : "";
786
+ const editableHumanMembers = canManageMembers && activeHumans().some((item) => item.userId !== currentUserId());
787
+ const activeCharacterCount = activeCharacters().length;
788
+ const editableCharacterMembers = canManageMembers && presentCharacters().some((item) => item.status !== "active" || activeCharacterCount > 1);
789
+ const editButton = (kind, label, enabled) => enabled
790
+ ? `<button class="im-member-edit-button" type="button" data-im-toggle-member-edit="${kind}" aria-label="${label}" aria-pressed="false" title="${label}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg></button>`
791
+ : "";
792
+ const memberActions = (kind, addLabel, editLabel, editable) => `<span class="im-member-section-actions">${editButton(kind, editLabel, editable)}${addButton(kind, addLabel)}</span>`;
793
+ const humanRows = activeHumans().map((item) => `<li><span class="im-member-identity">${imAvatarHtml(item, "user", "im-member-avatar")}<span>${esc(item.displayName)} <small>@${esc(item.username)}</small>${item.role === "owner" ? " · 群主" : ""}</span></span>${canManageMembers && item.userId !== currentUserId() ? `<button class="im-button im-button-danger-quiet" type="button" data-im-remove-human="${esc(item.userId)}" aria-label="移除 ${esc(item.displayName)}" hidden>移除</button>` : ""}</li>`).join("");
794
+ const characterRows = presentCharacters().map((item) => {
795
+ const removable = canManageMembers && (item.status !== "active" || activeCharacterCount > 1);
796
+ const unavailable = item.status === "active" ? "" : '<b class="im-member-unavailable">不可用</b>';
797
+ return `<li><span class="im-member-identity">${imAvatarHtml(item, "character", "im-member-avatar")}<span>${esc(item.name)} ${unavailable}<small>${esc(item.workTitle)}</small></span></span>${removable ? `<button class="im-button im-button-danger-quiet" type="button" data-im-remove-character="${esc(item.characterId)}" aria-label="移除 ${esc(item.name)}" hidden>移除</button>` : ""}</li>`;
798
+ }).join("");
799
+ host.innerHTML = `<section><div class="im-member-section-heading"><h3>AI 角色</h3>${memberActions("character", "添加 AI 角色", "编辑 AI 角色", editableCharacterMembers)}</div><ul class="im-member-list" data-im-member-list="character">${characterRows}</ul></section>
800
+ <section><div class="im-member-section-heading"><h3>人类成员</h3>${memberActions("human", "添加人类成员", "编辑人类成员", editableHumanMembers)}</div><ul class="im-member-list" data-im-member-list="human">${humanRows}</ul></section>
801
+ ${current.kind === "group" && owner ? `<section id="im-group-settings" class="im-owner-settings"><h3>群设置</h3>
802
+ <label>群名称<input id="im-detail-title" maxlength="80" value="${esc(groupSettings.title)}"></label>
803
+ <label>回复模式<select id="im-detail-mode"><option value="mention" ${groupSettings.replyMode === "mention" ? "selected" : ""}>Mention 模式</option><option value="proactive" ${groupSettings.replyMode === "proactive" ? "selected" : ""}>主动交流</option></select></label>
804
+ <label>主动阈值 <output id="im-detail-threshold-output">${Number(groupSettings.responseThreshold)}</output><input id="im-detail-threshold" type="range" min="0" max="100" value="${Number(groupSettings.responseThreshold)}"></label>
805
+ <label>链路上限<input id="im-detail-limit" type="number" min="1" max="100" value="${Number(groupSettings.maxAiMessages)}"></label>
806
+ <button id="im-save-group-settings" class="primary-button" type="button">保存群设置</button>
807
+ </section>` : ""}
808
+ ${owner && current.kind === "group" ? '<section><h3>主动判断诊断</h3><div id="im-diagnostics"><p class="im-empty">尚无诊断记录。</p></div></section>' : ""}
809
+ ${current.kind === "group" && owner ? `<section class="im-owner-actions"><h3>群主操作</h3><label>转让给<select id="im-transfer-select"><option value="">选择成员</option>${activeHumans().filter((item) => item.userId !== currentUserId()).map((item) => `<option value="${esc(item.userId)}">${esc(item.displayName)}</option>`).join("")}</select></label><div class="im-owner-action-buttons"><button id="im-transfer" class="im-button im-button-secondary" type="button">转让群主</button><button id="im-disband" class="danger-button" type="button">解散群聊</button></div></section>` : ""}
810
+ ${current.kind === "group" && !owner && current.active ? '<button id="im-leave" class="danger-button" type="button">退出群聊</button>' : ""}`;
811
+ bindImAvatarFallbacks(host);
812
+ bindDetailActions(owner);
813
+ if (owner && current.kind === "group") void loadDiagnostics();
814
+ }
815
+
816
+ async function loadDiagnostics() {
817
+ const conversationId = current?.id;
818
+ const request = ++diagnosticsRequest;
819
+ if (!conversationId) return;
820
+ try {
821
+ const result = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/diagnostics`);
822
+ if (request !== diagnosticsRequest || current?.id !== conversationId) return;
823
+ const host = document.querySelector("#im-diagnostics");
824
+ if (!host) return;
825
+ host.innerHTML = array(result.turns).filter((turn) => turn.kind === "judge").length
826
+ ? array(result.turns).filter((turn) => turn.kind === "judge").map((turn) => `<div class="im-diagnostic-row"><span>${esc(turn.characterName)}</span><strong>${turn.score ?? "失败"}</strong><small>${imDiagnosticStatusLabel(turn)}</small></div>`).join("")
827
+ : '<p class="im-empty">尚无主动判断记录。</p>';
828
+ } catch (error) {
829
+ if (request !== diagnosticsRequest || current?.id !== conversationId) return;
830
+ toast(error.message, "error");
831
+ }
832
+ }
833
+
834
+ async function performMutation(control, action) {
835
+ if (control?.disabled || control?.getAttribute("aria-busy") === "true") return { ok: false, value: null };
836
+ if (control) {
837
+ control.disabled = true;
838
+ control.setAttribute("aria-busy", "true");
839
+ }
840
+ try {
841
+ return { ok: true, value: await action() };
842
+ } catch (error) {
843
+ toast(error.message, "error");
844
+ return { ok: false, value: null };
845
+ } finally {
846
+ if (control) {
847
+ control.disabled = false;
848
+ control.removeAttribute("aria-busy");
849
+ }
850
+ }
851
+ }
852
+
853
+ async function refreshAfterMutation(label, action) {
854
+ try {
855
+ await action();
856
+ } catch (error) {
857
+ toast(`${label}已完成,但刷新界面失败:${error.message}`, "error");
858
+ }
859
+ }
860
+
861
+ function bindDetailActions(owner) {
862
+ const threshold = document.querySelector("#im-detail-threshold");
863
+ threshold?.addEventListener("input", () => { document.querySelector("#im-detail-threshold-output").textContent = threshold.value; });
864
+ document.querySelector("#im-save-group-settings")?.addEventListener("click", async (event) => {
865
+ const conversationId = current.id;
866
+ const submittedForm = readGroupSettingsForm();
867
+ if (!submittedForm) return;
868
+ const submittedSettings = { ...submittedForm, title: submittedForm.title.trim() };
869
+ const result = await performMutation(event.currentTarget, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}`, {
870
+ method: "PATCH",
871
+ body: submittedSettings
872
+ }));
873
+ if (result.ok) {
874
+ const latestForm = current?.id === conversationId ? readGroupSettingsForm() : groupSettingsDrafts.get(conversationId);
875
+ if (latestForm && !sameImGroupSettings(latestForm, submittedForm)) groupSettingsDrafts.set(conversationId, latestForm);
876
+ else groupSettingsDrafts.delete(conversationId);
877
+ }
878
+ if (result.ok && current?.id === conversationId) await refreshAfterMutation("群设置保存", () => openConversation(conversationId));
879
+ });
880
+ document.querySelectorAll("[data-im-open-member-add]").forEach((button) => button.addEventListener("click", () => openMemberAddDialog(button.dataset.imOpenMemberAdd)));
881
+ document.querySelectorAll("[data-im-toggle-member-edit]").forEach((button) => button.addEventListener("click", () => {
882
+ const editing = button.getAttribute("aria-pressed") !== "true";
883
+ button.setAttribute("aria-pressed", String(editing));
884
+ const list = document.querySelector(`[data-im-member-list="${button.dataset.imToggleMemberEdit}"]`);
885
+ list?.querySelectorAll("[data-im-remove-human], [data-im-remove-character]").forEach((removeButton) => { removeButton.hidden = !editing; });
886
+ }));
887
+ document.querySelectorAll("[data-im-remove-human]").forEach((button) => button.addEventListener("click", async () => {
888
+ const conversationId = current.id;
889
+ const result = await performMutation(button, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/humans/${encodeURIComponent(button.dataset.imRemoveHuman)}`, { method: "DELETE", body: {} }));
890
+ if (result.ok && current?.id === conversationId) await refreshAfterMutation("成员移除", () => openConversation(conversationId));
891
+ }));
892
+ document.querySelectorAll("[data-im-remove-character]").forEach((button) => button.addEventListener("click", async () => {
893
+ const conversationId = current.id;
894
+ const result = await performMutation(button, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/characters/${encodeURIComponent(button.dataset.imRemoveCharacter)}`, { method: "DELETE", body: {} }));
895
+ if (result.ok && current?.id === conversationId) await refreshAfterMutation("角色移除", () => openConversation(conversationId));
896
+ }));
897
+ document.querySelector("#im-transfer")?.addEventListener("click", async () => {
898
+ if (ownerConfirmationPending) return;
899
+ const userId = document.querySelector("#im-transfer-select").value;
900
+ if (!userId) return;
901
+ const button = document.querySelector("#im-transfer");
902
+ const nextOwner = activeHumans().find((item) => item.userId === userId);
903
+ const conversationId = current.id;
904
+ const conversationTitle = current.title;
905
+ ownerConfirmationPending = true;
906
+ button.disabled = true;
907
+ button.setAttribute("aria-busy", "true");
908
+ let confirmed = false;
909
+ try {
910
+ confirmed = await confirmToast(
911
+ `确认把群聊“${conversationTitle}”的群主转让给“${nextOwner?.displayName || nextOwner?.username || "所选成员"}”吗?转让后你将失去群主专属操作权限。`,
912
+ { title: "转让群主", confirmLabel: "确认转让" }
913
+ );
914
+ } catch (error) {
915
+ toast(error.message, "error");
916
+ } finally {
917
+ ownerConfirmationPending = false;
918
+ button.disabled = false;
919
+ button.removeAttribute("aria-busy");
920
+ }
921
+ if (!confirmed) return;
922
+ const result = await performMutation(button, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/transfer`, { method: "POST", body: { userId } }));
923
+ if (!result.ok) return;
924
+ await refreshAfterMutation("群主转让", () => current?.id === conversationId ? openConversation(conversationId) : refreshConversations());
925
+ toast("群主已转让", "success");
926
+ });
927
+ document.querySelector("#im-disband")?.addEventListener("click", async () => {
928
+ if (ownerConfirmationPending) return;
929
+ const button = document.querySelector("#im-disband");
930
+ const conversationId = current.id;
931
+ const conversationTitle = current.title;
932
+ ownerConfirmationPending = true;
933
+ button.disabled = true;
934
+ button.setAttribute("aria-busy", "true");
935
+ let confirmed = false;
936
+ try {
937
+ confirmed = await confirmToast(
938
+ `确认解散群聊“${conversationTitle}”吗?解散后所有成员只能查看各自可见的历史,群聊不能恢复。`,
939
+ { title: "解散群聊", confirmLabel: "确认解散" }
940
+ );
941
+ } catch (error) {
942
+ toast(error.message, "error");
943
+ } finally {
944
+ ownerConfirmationPending = false;
945
+ button.disabled = false;
946
+ button.removeAttribute("aria-busy");
947
+ }
948
+ if (!confirmed) return;
949
+ const result = await performMutation(button, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/disband`, { method: "POST", body: {} }));
950
+ if (!result.ok) return;
951
+ await refreshAfterMutation("群聊解散", () => current?.id === conversationId ? openConversation(conversationId) : refreshConversations());
952
+ toast("群聊已解散", "success");
953
+ });
954
+ document.querySelector("#im-leave")?.addEventListener("click", async (event) => {
955
+ const conversationId = current.id;
956
+ const result = await performMutation(event.currentTarget, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/leave`, { method: "POST", body: {} }));
957
+ if (result.ok && current?.id === conversationId) await refreshAfterMutation("退出群聊", () => openConversation(conversationId));
958
+ });
959
+ if (!owner) return;
960
+ }
961
+
962
+ function renderConversation(scrollToBottom = false) {
963
+ document.querySelector("#im-chat-title").textContent = current?.title || "选择会话";
964
+ document.querySelector("#im-chat-subtitle").textContent = current ? conversationSubtitle(current) : "角色单聊或混合群聊";
965
+ document.querySelector("#im-details-toggle").disabled = !current;
966
+ renderMessages({ scrollToBottom });
967
+ renderDetails();
968
+ syncComposer();
969
+ }
970
+
971
+ async function openConversation(conversationId, userInitiated = false) {
972
+ captureGroupSettingsDraft();
973
+ if (current?.id && current.id !== conversationId) {
974
+ if (serializeImComposer(composer)) conversationDrafts.set(current.id, composer.innerHTML);
975
+ else conversationDrafts.delete(current.id);
976
+ }
977
+ if (userInitiated) requestedConversationId = conversationId;
978
+ else if (requestedConversationId && requestedConversationId !== conversationId) return;
979
+ const request = ++conversationRequest;
980
+ let nextConversation;
981
+ try {
982
+ nextConversation = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}`);
983
+ } catch (error) {
984
+ if (userInitiated && requestedConversationId === conversationId) requestedConversationId = null;
985
+ throw error;
986
+ }
987
+ if (request !== conversationRequest || (requestedConversationId && requestedConversationId !== conversationId)) return;
988
+ const previousConversation = current?.id === conversationId ? current : null;
989
+ const conversationChanged = !previousConversation;
990
+ if (previousConversation) {
991
+ const gapFailedReplies = [];
992
+ const gapMessages = await collectImMessageGap(previousConversation.messages, nextConversation.messages, async (cursor) => {
993
+ const page = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}?afterSequence=${encodeURIComponent(cursor)}`);
994
+ if (request !== conversationRequest || (requestedConversationId && requestedConversationId !== conversationId)) {
995
+ throw new Error("IM 会话请求已失效");
996
+ }
997
+ gapFailedReplies.push(...array(page.failedReplies));
998
+ return page;
999
+ });
1000
+ if (request !== conversationRequest || (requestedConversationId && requestedConversationId !== conversationId)) return;
1001
+ nextConversation.messages = mergeImMessagePages(previousConversation.messages, gapMessages, nextConversation.messages);
1002
+ nextConversation.failedReplies = mergeImFailedReplyPages(previousConversation.failedReplies, gapFailedReplies, nextConversation.failedReplies);
1003
+ nextConversation.hasMoreMessages = previousConversation.hasMoreMessages === true;
1004
+ }
1005
+ if (current?.id && current.id !== conversationId) {
1006
+ if (serializeImComposer(composer)) conversationDrafts.set(current.id, composer.innerHTML);
1007
+ else conversationDrafts.delete(current.id);
1008
+ }
1009
+ const detailsFocus = captureGroupSettingsDraft();
1010
+ current = nextConversation;
1011
+ const savedGroupSettings = groupSettingsDrafts.get(conversationId);
1012
+ if (savedGroupSettings && sameImGroupSettings(savedGroupSettings, nextConversation)) groupSettingsDrafts.delete(conversationId);
1013
+ if (nextConversation.kind !== "group" || nextConversation.ownerUserId !== currentUserId()) groupSettingsDrafts.delete(conversationId);
1014
+ if (conversationChanged) composer.innerHTML = conversationDrafts.get(conversationId) ?? "";
1015
+ if (requestedConversationId === conversationId) requestedConversationId = null;
1016
+ workspace.classList.add("has-conversation");
1017
+ syncProvisionalReplies();
1018
+ const shouldRestoreDetailsFocus = detailsFocus && document.activeElement?.id === detailsFocus.id;
1019
+ renderConversationList();
1020
+ renderConversation(conversationChanged);
1021
+ if (shouldRestoreDetailsFocus) restoreGroupSettingsFocus(detailsFocus);
1022
+ if (userInitiated && mobileConversationMedia.matches) document.querySelector("#im-mobile-back").focus();
1023
+ if (current.active && current.latestSequence > 0 && shouldMarkImConversationRead(opened, document.visibilityState)) {
1024
+ if (request !== conversationRequest) return;
1025
+ const summary = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/read`, { method: "POST", body: { sequence: current.latestSequence } });
1026
+ if (request !== conversationRequest) return;
1027
+ upsertConversationSummary(summary);
1028
+ void refreshUnreadTotal().catch(() => undefined);
1029
+ }
1030
+ }
1031
+
1032
+ async function loadCatalogs() {
1033
+ [works, models, settings] = await Promise.all([
1034
+ api("/api/im/works"),
1035
+ api("/api/im/models"),
1036
+ api("/api/im/settings")
1037
+ ]);
1038
+ }
1039
+
1040
+ function openSettings() {
1041
+ const dialog = document.querySelector("#im-settings-dialog");
1042
+ document.querySelector("#im-setting-name").value = settings.preferredName || state.user?.displayName || "";
1043
+ document.querySelector("#im-setting-pronouns").value = settings.pronouns || "";
1044
+ document.querySelector("#im-setting-identity").value = settings.identitySummary || "";
1045
+ document.querySelector("#im-setting-notes").value = settings.additionalNotes || "";
1046
+ const modelOptions = (selectedId, placeholder) => {
1047
+ const unavailable = selectedId && !models.some((model) => model.id === selectedId)
1048
+ ? `<option value="${esc(selectedId)}" selected>当前模型暂不可用 · ${esc(selectedId)}</option>`
1049
+ : "";
1050
+ return `<option value="">${placeholder}</option>${unavailable}${models.map((model) => `<option value="${esc(model.id)}" ${selectedId === model.id ? "selected" : ""}>${esc(model.displayName)} · ${esc(model.providerName)}</option>`).join("")}`;
1051
+ };
1052
+ document.querySelector("#im-setting-primary").innerHTML = modelOptions(settings.primaryModelId, "选择主模型");
1053
+ document.querySelector("#im-setting-fallback").innerHTML = modelOptions(settings.fallbackModelId, "选择 fallback 模型");
1054
+ document.querySelector("#im-setting-model-note").textContent = settings.primaryModelId && settings.fallbackModelId
1055
+ ? "主模型和 fallback 已配置;保存后 AI 角色将使用这组模型。"
1056
+ : "可先单独保存身份;主模型和 fallback 都配置后,AI 角色才能生成回答。";
1057
+ document.querySelector("#im-setting-retries").value = String(settings.retryCount || 3);
1058
+ dialog.showModal();
1059
+ }
1060
+
1061
+ function openAnnouncementDialog() {
1062
+ if (current?.kind !== "group" || current?.ownerUserId !== currentUserId() || current?.active !== true) return;
1063
+ const dialog = document.querySelector("#im-announcement-dialog");
1064
+ document.querySelector("#im-announcement-form").reset();
1065
+ dialog.showModal();
1066
+ document.querySelector("#im-announcement-content").focus();
1067
+ }
1068
+
1069
+ function characterPreferenceBadges(item) {
1070
+ return [
1071
+ item.isPinned ? '<b class="im-character-preference is-pinned">置顶</b>' : "",
1072
+ item.isFavorite ? '<b class="im-character-preference is-favorite">已收藏</b>' : ""
1073
+ ].filter(Boolean).join("");
1074
+ }
1075
+
1076
+ function renderMemberAddOptions() {
1077
+ const host = document.querySelector("#im-member-add-options");
1078
+ const emptyText = memberAddKind === "character" ? "没有可添加的角色。" : "没有匹配的可添加用户。";
1079
+ host.setAttribute("aria-label", memberAddKind === "character" ? "可添加 AI 角色" : "可添加人类成员");
1080
+ const options = memberAddCandidates.length
1081
+ ? memberAddCandidates.map((item) => {
1082
+ const candidateId = memberAddKind === "character" ? item.id : item.userId;
1083
+ const selected = candidateId === memberAddSelectedId;
1084
+ const detail = memberAddKind === "character"
1085
+ ? `${characterPreferenceBadges(item)}${item.code ? `<em>${esc(item.code)}</em>` : ""}<span>${esc(item.workTitle)}</span>`
1086
+ : `<span>@${esc(item.username)}</span>`;
1087
+ return `<button class="im-member-picker-option" type="button" aria-pressed="${selected}" data-im-member-add-candidate="${esc(candidateId)}">${imAvatarHtml(item, memberAddKind === "character" ? "character" : "user", "im-member-picker-avatar")}<span><strong>${esc(memberAddKind === "character" ? item.name : item.displayName)}</strong><small>${detail}</small></span></button>`;
1088
+ }).join("")
1089
+ : `<p class="im-empty">${emptyText}</p>`;
1090
+ const loadMore = memberAddKind === "character" && memberAddCharacterNextCursor !== null
1091
+ ? '<button class="im-button im-button-secondary im-load-more-options" type="button" data-im-load-more-member-characters>加载更多角色</button>'
1092
+ : "";
1093
+ host.innerHTML = options + loadMore;
1094
+ bindImAvatarFallbacks(host);
1095
+ syncMemberAddSelection();
1096
+ }
1097
+
1098
+ function syncMemberAddSelection() {
1099
+ document.querySelectorAll("#im-member-add-options [data-im-member-add-candidate]").forEach((button) => {
1100
+ button.setAttribute("aria-pressed", String(button.dataset.imMemberAddCandidate === memberAddSelectedId));
1101
+ });
1102
+ const submit = document.querySelector("#im-member-add-submit");
1103
+ submit.disabled = !memberAddSelectedId;
1104
+ submit.textContent = memberAddKind === "character" ? "添加角色" : "添加用户";
1105
+ }
1106
+
1107
+ async function loadMemberAddCharacters(append = false) {
1108
+ const workId = document.querySelector("#im-member-add-work").value;
1109
+ const search = document.querySelector("#im-member-add-character-search");
1110
+ const requestGeneration = ++memberAddRequest;
1111
+ if (!workId) {
1112
+ memberAddCandidates = [];
1113
+ memberAddCharacterNextCursor = null;
1114
+ memberAddSelectedId = "";
1115
+ search.disabled = true;
1116
+ document.querySelector("#im-member-add-options").innerHTML = '<p class="im-empty">选择书籍后显示可添加角色。</p>';
1117
+ document.querySelector("#im-member-add-submit").disabled = true;
1118
+ return;
1119
+ }
1120
+ search.disabled = false;
1121
+ if (append && memberAddCharacterNextCursor === null) return;
1122
+ const query = search.value.trim();
1123
+ const cursor = append ? memberAddCharacterNextCursor : 0;
1124
+ const activeCharacterIds = new Set(presentCharacters().map((item) => item.characterId));
1125
+ const page = await api(`/api/im/characters?workId=${encodeURIComponent(workId)}&q=${encodeURIComponent(query)}&limit=50&cursor=${encodeURIComponent(cursor)}`);
1126
+ const candidates = array(page.items ?? page)
1127
+ .filter((item) => !activeCharacterIds.has(item.id));
1128
+ if (requestGeneration !== memberAddRequest) return;
1129
+ memberAddCandidates = append
1130
+ ? [...new Map([...memberAddCandidates, ...candidates].map((item) => [item.id, item])).values()]
1131
+ : candidates;
1132
+ memberAddCharacterNextCursor = page.nextCursor ?? null;
1133
+ if (!memberAddCandidates.some((item) => item.id === memberAddSelectedId)) memberAddSelectedId = "";
1134
+ renderMemberAddOptions();
1135
+ }
1136
+
1137
+ async function loadMemberAddHumans() {
1138
+ const requestGeneration = ++memberAddRequest;
1139
+ const query = document.querySelector("#im-member-add-human-search").value.trim();
1140
+ const activeHumanIds = new Set(activeHumans().map((item) => item.userId));
1141
+ const candidates = array(await api(`/api/users/directory?q=${encodeURIComponent(query)}`))
1142
+ .filter((item) => item.userId !== currentUserId() && !activeHumanIds.has(item.userId));
1143
+ if (requestGeneration !== memberAddRequest) return;
1144
+ memberAddCandidates = candidates;
1145
+ if (!candidates.some((item) => item.userId === memberAddSelectedId)) memberAddSelectedId = "";
1146
+ renderMemberAddOptions();
1147
+ }
1148
+
1149
+ function openMemberAddDialog(kind) {
1150
+ if (!current?.active || current.kind !== "group" || current.ownerUserId !== currentUserId()) return;
1151
+ memberAddKind = kind === "human" ? "human" : "character";
1152
+ memberAddCandidates = [];
1153
+ memberAddCharacterNextCursor = null;
1154
+ memberAddSelectedId = "";
1155
+ memberAddRequest += 1;
1156
+ if (memberAddSearchTimer !== null) window.clearTimeout(memberAddSearchTimer);
1157
+ memberAddSearchTimer = null;
1158
+ const dialog = document.querySelector("#im-member-add-dialog");
1159
+ document.querySelector("#im-member-add-form").reset();
1160
+ const characterMode = memberAddKind === "character";
1161
+ document.querySelector("#im-member-add-eyebrow").textContent = characterMode ? "AI 角色" : "人类成员";
1162
+ document.querySelector("#im-member-add-title").textContent = characterMode ? "添加 AI 角色" : "添加人类成员";
1163
+ document.querySelector("#im-member-add-guidance").textContent = characterMode
1164
+ ? "先选择书籍,再按名字搜索一个要加入群聊的角色。置顶和收藏角色优先显示。"
1165
+ : "按用户名或显示名称搜索一个要加入群聊的人类用户。";
1166
+ document.querySelector("#im-member-add-character-fields").classList.toggle("hidden", !characterMode);
1167
+ document.querySelector("#im-member-add-human-fields").classList.toggle("hidden", characterMode);
1168
+ document.querySelector("#im-member-add-submit").textContent = characterMode ? "添加角色" : "添加用户";
1169
+ document.querySelector("#im-member-add-submit").disabled = true;
1170
+ if (characterMode) {
1171
+ const workSelect = document.querySelector("#im-member-add-work");
1172
+ workSelect.innerHTML = '<option value="">请选择书籍</option>' + works.map((work) => `<option value="${esc(work.id)}">${esc(work.title)} · ${Number(work.characterCount)} 个角色</option>`).join("");
1173
+ document.querySelector("#im-member-add-character-search").disabled = true;
1174
+ document.querySelector("#im-member-add-options").innerHTML = '<p class="im-empty">选择书籍后显示可添加角色。</p>';
1175
+ } else {
1176
+ document.querySelector("#im-member-add-options").innerHTML = '<p class="im-empty">正在读取可添加用户…</p>';
1177
+ }
1178
+ dialog.showModal();
1179
+ if (characterMode) document.querySelector("#im-member-add-work").focus();
1180
+ else {
1181
+ document.querySelector("#im-member-add-human-search").focus();
1182
+ void loadMemberAddHumans().catch((error) => toast(error.message, "error"));
1183
+ }
1184
+ }
1185
+
1186
+ function renderCreateCharacterOptions() {
1187
+ const host = document.querySelector("#im-group-character-options");
1188
+ const options = createCharacters.length
1189
+ ? createCharacters.map((item) => `<label class="im-character-option"><input type="checkbox" value="${esc(item.id)}" ${createSelectedCharacters.has(item.id) ? "checked" : ""}>${imAvatarHtml(item, "character", "im-option-avatar")}<span><strong>${esc(item.name)}</strong><small>${characterPreferenceBadges(item)}${item.code ? `<em>${esc(item.code)}</em>` : ""}</small></span></label>`).join("")
1190
+ : '<p class="im-empty">没有匹配的角色。</p>';
1191
+ host.innerHTML = options + (createCharacterNextCursor === null
1192
+ ? ""
1193
+ : '<button class="im-button im-button-secondary im-load-more-options" type="button" data-im-load-more-create-characters>加载更多角色</button>');
1194
+ bindImAvatarFallbacks(host);
1195
+ }
1196
+
1197
+ function renderCreateSelectedCharacters() {
1198
+ const host = document.querySelector("#im-create-selected");
1199
+ const selected = [...createSelectedCharacters.values()];
1200
+ host.classList.toggle("hidden", selected.length === 0);
1201
+ host.innerHTML = selected.length
1202
+ ? `<div><strong>已选角色</strong><small>${selected.length} / 10</small></div><div>${selected.map((item) => `<button type="button" data-im-remove-selected="${esc(item.id)}" aria-label="移除角色 ${esc(item.name)}(${esc(item.workTitle)})">${imAvatarHtml(item, "character", "im-selected-avatar")}<span>${esc(item.name)}</span><small>${esc(item.workTitle)}</small><b aria-hidden="true">×</b></button>`).join("")}</div>`
1203
+ : "";
1204
+ bindImAvatarFallbacks(host);
1205
+ }
1206
+
1207
+ function renderCreateHumanOptions() {
1208
+ const host = document.querySelector("#im-group-human-options");
1209
+ host.innerHTML = createHumans.length
1210
+ ? createHumans.filter((item) => item.userId !== currentUserId()).map((item) => `<label><input type="checkbox" value="${esc(item.userId)}" ${createSelectedHumans.has(item.userId) ? "checked" : ""}>${imAvatarHtml(item, "user", "im-option-avatar")}<span><strong>${esc(item.displayName)}</strong><small>@${esc(item.username)}</small></span></label>`).join("")
1211
+ : '<p class="im-empty">没有匹配的用户。</p>';
1212
+ bindImAvatarFallbacks(host);
1213
+ }
1214
+
1215
+ function renderCreateSelectedHumans() {
1216
+ const host = document.querySelector("#im-create-selected-humans");
1217
+ const selected = [...createSelectedHumans.values()];
1218
+ host.classList.toggle("hidden", selected.length === 0);
1219
+ host.innerHTML = selected.length
1220
+ ? `<div><strong>已选人类成员</strong><small>${selected.length} / 49</small></div><div>${selected.map((item) => `<button type="button" data-im-remove-selected-human="${esc(item.userId)}" aria-label="移除用户 ${esc(item.displayName)}">${imAvatarHtml(item, "user", "im-selected-avatar")}<span>${esc(item.displayName)}</span><small>@${esc(item.username)}</small><b aria-hidden="true">×</b></button>`).join("")}</div>`
1221
+ : "";
1222
+ bindImAvatarFallbacks(host);
1223
+ }
1224
+
1225
+ async function loadCreateHumans() {
1226
+ const search = document.querySelector("#im-create-human-search");
1227
+ const query = search.value.trim();
1228
+ const requestId = ++createHumanSearchRequest;
1229
+ if (!query) {
1230
+ createHumans = [];
1231
+ document.querySelector("#im-group-human-options").innerHTML = '<p class="im-empty">输入关键词搜索用户。</p>';
1232
+ return;
1233
+ }
1234
+ const page = await api(`/api/users/directory?q=${encodeURIComponent(query)}&limit=50&cursor=0`);
1235
+ if (requestId !== createHumanSearchRequest) return;
1236
+ createHumans = array(page.items ?? page);
1237
+ renderCreateHumanOptions();
1238
+ }
1239
+
1240
+ function syncCreateSelection() {
1241
+ const selected = [...createSelectedCharacters.values()];
1242
+ const count = selected.length;
1243
+ const hasWork = Boolean(document.querySelector("#im-create-work").value);
1244
+ const groupMode = count >= 2;
1245
+ const groupSection = document.querySelector("#im-create-group-settings");
1246
+ const humanSection = document.querySelector("#im-create-human-section");
1247
+ const title = document.querySelector("#im-create-group-title");
1248
+ const submit = document.querySelector("#im-create-submit");
1249
+ groupSection.classList.toggle("hidden", !groupMode);
1250
+ humanSection.classList.toggle("hidden", !groupMode);
1251
+ groupSection.querySelectorAll("input, select").forEach((control) => { control.disabled = !groupMode; });
1252
+ humanSection.querySelectorAll("input").forEach((control) => { control.disabled = !groupMode; });
1253
+ renderCreateSelectedCharacters();
1254
+ renderCreateSelectedHumans();
1255
+ title.required = groupMode;
1256
+ if (groupMode && !title.value.trim()) title.value = selected.slice(0, 3).map((item) => item.name).join("、").slice(0, 80);
1257
+ submit.disabled = count === 0 || !hasWork;
1258
+ submit.textContent = !hasWork ? "请先选择书籍" : count === 0 ? "请选择角色" : count === 1 ? "创建单聊" : `创建群聊(${count} 个角色)`;
1259
+ document.querySelector("#im-create-guidance").textContent = !hasWork
1260
+ ? "请先选择一本书,再选择要开始会话的角色。"
1261
+ : count === 0
1262
+ ? "请选择角色。置顶和收藏的角色会优先显示。"
1263
+ : count === 1
1264
+ ? `将创建与“${selected[0].name}”的单聊。`
1265
+ : `将创建包含 ${count} 个角色的群聊,可继续添加人类成员。`;
1266
+ }
1267
+
1268
+ async function loadCreateCharacters(append = false) {
1269
+ const workId = document.querySelector("#im-create-work").value;
1270
+ const search = document.querySelector("#im-create-search");
1271
+ const requestId = ++createSearchRequest;
1272
+ if (!workId) {
1273
+ createCharacters = [];
1274
+ createCharacterNextCursor = null;
1275
+ search.disabled = true;
1276
+ document.querySelector("#im-group-character-options").innerHTML = '<p class="im-empty">选择书籍后显示角色。</p>';
1277
+ return;
1278
+ }
1279
+ search.disabled = false;
1280
+ if (append && createCharacterNextCursor === null) return;
1281
+ const query = search.value.trim();
1282
+ const cursor = append ? createCharacterNextCursor : 0;
1283
+ const page = await api(`/api/im/characters?workId=${encodeURIComponent(workId)}&q=${encodeURIComponent(query)}&limit=50&cursor=${encodeURIComponent(cursor)}`);
1284
+ const nextCharacters = array(page.items ?? page);
1285
+ if (requestId !== createSearchRequest) return;
1286
+ createCharacters = append
1287
+ ? [...new Map([...createCharacters, ...nextCharacters].map((item) => [item.id, item])).values()]
1288
+ : nextCharacters;
1289
+ createCharacterNextCursor = page.nextCursor ?? null;
1290
+ renderCreateCharacterOptions();
1291
+ }
1292
+
1293
+ function openConversationDialog() {
1294
+ const dialog = document.querySelector("#im-group-dialog");
1295
+ document.querySelector("#im-group-form").reset();
1296
+ if (createSearchTimer !== null) window.clearTimeout(createSearchTimer);
1297
+ if (createHumanSearchTimer !== null) window.clearTimeout(createHumanSearchTimer);
1298
+ createSearchTimer = null;
1299
+ createHumanSearchTimer = null;
1300
+ createSearchRequest += 1;
1301
+ createHumanSearchRequest += 1;
1302
+ createSelectedCharacters.clear();
1303
+ createSelectedHumans.clear();
1304
+ createCharacters = [];
1305
+ createCharacterNextCursor = null;
1306
+ createHumans = [];
1307
+ const workSelect = document.querySelector("#im-create-work");
1308
+ workSelect.innerHTML = '<option value="">请选择书籍</option>' + works.map((work) => `<option value="${esc(work.id)}">${esc(work.title)} · ${Number(work.characterCount)} 个角色</option>`).join("");
1309
+ const search = document.querySelector("#im-create-search");
1310
+ search.value = "";
1311
+ search.disabled = true;
1312
+ document.querySelector("#im-group-character-options").innerHTML = '<p class="im-empty">选择书籍后显示角色。</p>';
1313
+ document.querySelector("#im-create-human-search").value = "";
1314
+ document.querySelector("#im-group-human-options").innerHTML = '<p class="im-empty">输入关键词搜索用户。</p>';
1315
+ syncCreateSelection();
1316
+ dialog.showModal();
1317
+ }
1318
+
1319
+ function composerCaret() {
1320
+ const selection = document.getSelection();
1321
+ let anchor = selection?.anchorNode ?? null;
1322
+ let offset = selection?.anchorOffset ?? 0;
1323
+ if (anchor === composer) {
1324
+ anchor = composer.childNodes[Math.max(0, offset - 1)] ?? composer.lastChild;
1325
+ offset = anchor?.nodeType === Node.TEXT_NODE ? anchor.nodeValue?.length ?? 0 : 0;
1326
+ }
1327
+ if ((!anchor || anchor.nodeType !== Node.TEXT_NODE || !composer.contains(anchor)) && document.activeElement === composer) {
1328
+ const walker = document.createTreeWalker(composer, NodeFilter.SHOW_TEXT);
1329
+ let candidate = walker.nextNode();
1330
+ while (candidate) {
1331
+ anchor = candidate;
1332
+ candidate = walker.nextNode();
1333
+ }
1334
+ offset = anchor?.nodeType === Node.TEXT_NODE ? anchor.nodeValue?.length ?? 0 : 0;
1335
+ }
1336
+ return anchor?.nodeType === Node.TEXT_NODE && composer.contains(anchor) ? { selection, anchor, offset } : null;
1337
+ }
1338
+
1339
+ function updateMentionMenu() {
1340
+ const caret = composerCaret();
1341
+ const match = caret ? findImMentionQuery(caret.anchor.nodeValue ?? "", caret.offset) : null;
1342
+ if (!caret || !match) {
1343
+ closeMentionMenu();
1344
+ return;
1345
+ }
1346
+ const query = match.query.toLocaleLowerCase("zh-CN");
1347
+ mentionCaretState = {
1348
+ anchor: caret.anchor,
1349
+ startOffset: match.startOffset,
1350
+ endOffset: match.endOffset
1351
+ };
1352
+ mentionOptions = [
1353
+ ...activeCharacters().map((item) => ({ ...item, kind: "character", id: item.characterId, label: item.name, detail: item.workTitle })),
1354
+ ...activeHumans().map((item) => ({ ...item, kind: "user", id: item.userId, label: item.displayName, detail: `@${item.username}` }))
1355
+ ].filter((item) => [item.label, item.kind === "user" ? item.username : ""]
1356
+ .some((value) => String(value || "").toLocaleLowerCase("zh-CN").includes(query))).slice(0, 12);
1357
+ mentionIndex = mentionOptions.length ? 0 : -1;
1358
+ mentionMenu.innerHTML = mentionOptions.length
1359
+ ? mentionOptions.map((item, index) => `<button id="im-mention-option-${index}" type="button" role="option" aria-selected="${index === mentionIndex}" data-im-mention-index="${index}">${imAvatarHtml(item, item.kind, "im-mention-avatar")}<span><strong>${esc(item.label)}</strong><em>${item.kind === "character" ? "角色" : "用户"} · ${esc(item.detail)}</em></span></button>`).join("")
1360
+ : '<p class="im-empty">没有匹配的群成员</p>';
1361
+ bindImAvatarFallbacks(mentionMenu);
1362
+ mentionMenu.classList.remove("hidden");
1363
+ composer.setAttribute("aria-expanded", "true");
1364
+ syncMentionSelection();
1365
+ }
1366
+
1367
+ function syncMentionSelection() {
1368
+ const options = mentionMenu.querySelectorAll("[role=option]");
1369
+ options.forEach((item, index) => item.setAttribute("aria-selected", String(index === mentionIndex)));
1370
+ const active = mentionIndex >= 0 ? options[mentionIndex] : null;
1371
+ if (active) {
1372
+ composer.setAttribute("aria-activedescendant", active.id);
1373
+ active.scrollIntoView({ block: "nearest" });
1374
+ } else {
1375
+ composer.removeAttribute("aria-activedescendant");
1376
+ }
1377
+ }
1378
+
1379
+ function closeMentionMenu() {
1380
+ mentionMenu.classList.add("hidden");
1381
+ composer.setAttribute("aria-expanded", "false");
1382
+ composer.removeAttribute("aria-activedescendant");
1383
+ mentionOptions = [];
1384
+ mentionIndex = -1;
1385
+ }
1386
+
1387
+ function selectMention(index) {
1388
+ const item = mentionOptions[index];
1389
+ const caret = mentionCaretState;
1390
+ if (!item || !caret || caret.anchor.nodeType !== Node.TEXT_NODE || !composer.contains(caret.anchor)) return;
1391
+ const text = caret.anchor.nodeValue ?? "";
1392
+ if (caret.startOffset < 0 || caret.endOffset > text.length || !findImMentionQuery(text, caret.endOffset)) return;
1393
+ const chip = document.createElement("span");
1394
+ chip.className = "im-composer-mention";
1395
+ chip.contentEditable = "false";
1396
+ chip.dataset.imMentionUri = `mention://${item.kind}/${item.id}`;
1397
+ chip.textContent = `@${item.label}`;
1398
+ const tail = document.createTextNode(" ");
1399
+ const range = document.createRange();
1400
+ range.setStart(caret.anchor, caret.startOffset);
1401
+ range.setEnd(caret.anchor, caret.endOffset);
1402
+ range.deleteContents();
1403
+ range.insertNode(chip);
1404
+ chip.after(tail);
1405
+ range.setStart(tail, 1);
1406
+ range.collapse(true);
1407
+ const selection = document.getSelection();
1408
+ selection.removeAllRanges();
1409
+ selection.addRange(range);
1410
+ mentionCaretState = null;
1411
+ closeMentionMenu();
1412
+ composer.focus();
1413
+ }
1414
+
1415
+ async function send() {
1416
+ if (!current?.active) return;
1417
+ const conversationId = current.id;
1418
+ if (sendingConversations.has(conversationId)) return;
1419
+ const content = serializeImComposer(composer);
1420
+ if (!content) return;
1421
+ const submittedHtml = composer.innerHTML;
1422
+ const pendingRequest = pendingMessageRequests.get(conversationId);
1423
+ const messageRequestId = pendingRequest?.content === content ? pendingRequest.id : requestId();
1424
+ pendingMessageRequests.set(conversationId, { content, id: messageRequestId });
1425
+ sendingConversations.add(conversationId);
1426
+ composer.replaceChildren();
1427
+ conversationDrafts.delete(conversationId);
1428
+ closeMentionMenu();
1429
+ provisionalReplies.clear();
1430
+ let committed = false;
1431
+ try {
1432
+ const result = await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/messages`, {
1433
+ method: "POST",
1434
+ body: { content, requestId: messageRequestId }
1435
+ });
1436
+ committed = true;
1437
+ if (pendingMessageRequests.get(conversationId)?.id === messageRequestId) pendingMessageRequests.delete(conversationId);
1438
+ if (current?.id !== conversationId) {
1439
+ await refreshConversationSummary(conversationId);
1440
+ return;
1441
+ }
1442
+ const existing = array(current.messages).some((message) => message.id === result.message.id);
1443
+ if (!existing) current.messages.push(result.message);
1444
+ current.activeChain = result.chain;
1445
+ renderConversation(true);
1446
+ await openConversation(current.id);
1447
+ await refreshConversationSummary(conversationId);
1448
+ if (result.chain?.status === "waiting_config") toast("消息已发送;请配置主模型和 fallback 后重试 AI 链路", "warning");
1449
+ } catch (error) {
1450
+ if (!committed) {
1451
+ if (current?.id === conversationId) {
1452
+ const newerDraftHtml = composer.innerHTML;
1453
+ composer.innerHTML = newerDraftHtml ? `${submittedHtml}<div><br></div>${newerDraftHtml}` : submittedHtml;
1454
+ } else {
1455
+ const savedDraft = conversationDrafts.get(conversationId) ?? "";
1456
+ conversationDrafts.set(conversationId, savedDraft ? `${submittedHtml}<div><br></div>${savedDraft}` : submittedHtml);
1457
+ }
1458
+ }
1459
+ toast(committed ? `消息已发送,但刷新会话失败:${error.message}` : error.message, "error");
1460
+ } finally {
1461
+ sendingConversations.delete(conversationId);
1462
+ }
1463
+ }
1464
+
1465
+ async function handleRealtime(event) {
1466
+ const envelope = JSON.parse(event.data);
1467
+ const eventConversationId = envelope.conversationId;
1468
+ if (envelope.type === "message" || envelope.type === "conversation") {
1469
+ void refreshUnreadTotal().catch(() => undefined);
1470
+ }
1471
+ if (!opened) {
1472
+ if (shouldRefreshImConversationListForEvent(envelope.type)) await refreshConversationSummary(eventConversationId);
1473
+ return;
1474
+ }
1475
+ if (envelope.type === "message" && current?.id === eventConversationId && envelope.payload.message) {
1476
+ if (Object.prototype.hasOwnProperty.call(envelope.payload, "chain")) current.activeChain = envelope.payload.chain ?? null;
1477
+ commitRealtimeMessage(envelope.payload.message);
1478
+ }
1479
+ if (envelope.type === "turn" && current?.id === eventConversationId && envelope.payload.kind === "reply") {
1480
+ if (!isImRealtimeChainCurrent(current.activeChain, envelope.payload)) return;
1481
+ const status = String(envelope.payload.status || "");
1482
+ const turnId = String(envelope.payload.turnId || "");
1483
+ if (status === "completed" || status === "cancelled") {
1484
+ provisionalReplies.delete(turnId);
1485
+ feed.querySelector(`[data-im-provisional-turn="${turnId}"]`)?.remove();
1486
+ syncGeneratingSummary();
1487
+ } else {
1488
+ const provisional = upsertProvisionalReply(envelope.payload);
1489
+ if (provisional) updateProvisionalReplyElement(provisional);
1490
+ }
1491
+ return;
1492
+ }
1493
+ if (envelope.type === "delta" && current?.id === eventConversationId) {
1494
+ if (!isImRealtimeChainCurrent(current.activeChain, envelope.payload)) return;
1495
+ const provisional = upsertProvisionalReply({ ...envelope.payload, status: "running" });
1496
+ if (provisional) provisional.content += envelope.payload.delta || "";
1497
+ if (provisional) updateProvisionalReplyElement(provisional);
1498
+ return;
1499
+ }
1500
+ if (envelope.type === "reset" && current?.id === eventConversationId) {
1501
+ if (!isImRealtimeChainCurrent(current.activeChain, envelope.payload)) return;
1502
+ for (const reply of provisionalReplies.values()) {
1503
+ if (reply.chainId !== String(envelope.payload.chainId || "")) continue;
1504
+ if (envelope.payload.turnId && reply.turnId !== envelope.payload.turnId) continue;
1505
+ if (envelope.payload.characterId && reply.characterId !== envelope.payload.characterId) continue;
1506
+ reply.content = "";
1507
+ reply.status = "running";
1508
+ updateProvisionalReplyElement(reply);
1509
+ }
1510
+ return;
1511
+ }
1512
+ if (current?.id === eventConversationId) await openConversation(eventConversationId);
1513
+ else if (shouldRefreshImConversationListForEvent(envelope.type)) await refreshConversationSummary(eventConversationId);
1514
+ }
1515
+
1516
+ function connectEvents() {
1517
+ eventSource?.close();
1518
+ eventSource = new EventSource("/api/im/events");
1519
+ eventSource.addEventListener("ready", () => {
1520
+ void Promise.all([
1521
+ refreshConversations(),
1522
+ opened && current ? openConversation(current.id) : Promise.resolve()
1523
+ ]).catch(() => undefined);
1524
+ });
1525
+ for (const type of ["conversation", "message", "chain", "turn", "delta", "reset"]) {
1526
+ eventSource.addEventListener(type, (event) => void handleRealtime(event).catch(() => undefined));
1527
+ }
1528
+ }
1529
+
1530
+ async function open() {
1531
+ start();
1532
+ await Promise.all([loadCatalogs(), refreshConversations()]);
1533
+ if (current) await openConversation(current.id);
1534
+ else renderConversation();
1535
+ if (!opened && beforeOpen && !await beforeOpen()) return false;
1536
+ opened = true;
1537
+ hideMainViews();
1538
+ document.querySelector("#app").classList.add("shelf-mode", "im-mode");
1539
+ workspace.classList.remove("hidden");
1540
+ document.querySelector("#work-meta").textContent = "";
1541
+ document.querySelector("#top-search-button").disabled = true;
1542
+ document.title = "IM · 叙界";
1543
+ window.history.replaceState(null, "", "#view=im");
1544
+ onRouteChange?.();
1545
+ if (!eventSource) connectEvents();
1546
+ if (current?.active && current.latestSequence > 0 && shouldMarkImConversationRead(opened, document.visibilityState)) {
1547
+ const conversationId = current.id;
1548
+ const sequence = current.latestSequence;
1549
+ void api(`/api/im/conversations/${encodeURIComponent(conversationId)}/read`, { method: "POST", body: { sequence } })
1550
+ .then((summary) => {
1551
+ upsertConversationSummary(summary);
1552
+ void refreshUnreadTotal().catch(() => undefined);
1553
+ })
1554
+ .catch((error) => toast(`IM 已打开,但标记已读失败:${error.message}`, "error"));
1555
+ }
1556
+ return true;
1557
+ }
1558
+
1559
+ function close() {
1560
+ if (current?.id) {
1561
+ if (serializeImComposer(composer)) conversationDrafts.set(current.id, composer.innerHTML);
1562
+ else conversationDrafts.delete(current.id);
1563
+ }
1564
+ captureGroupSettingsDraft();
1565
+ opened = false;
1566
+ conversationRequest += 1;
1567
+ requestedConversationId = null;
1568
+ workspace.classList.add("hidden");
1569
+ workspace.classList.remove("has-conversation");
1570
+ resetDetailsDrawer();
1571
+ document.querySelector("#app").classList.remove("im-mode");
1572
+ provisionalReplies.clear();
1573
+ closeMentionMenu();
1574
+ }
1575
+
1576
+ function bind() {
1577
+ setupConversationsResize();
1578
+ setupDetailsResize();
1579
+ setupComposerResize();
1580
+ detailsDrawerMedia.addEventListener("change", syncDetailsDrawerAccessibility);
1581
+ syncDetailsDrawerAccessibility();
1582
+ document.querySelector("#im-open-button").addEventListener("click", () => void open().catch((error) => toast(error.message, "error")));
1583
+ document.querySelector("#im-settings-button").addEventListener("click", openSettings);
1584
+ document.querySelector("#im-announcement-button").addEventListener("click", openAnnouncementDialog);
1585
+ document.querySelector("#im-new-conversation").addEventListener("click", openConversationDialog);
1586
+ document.querySelector("#im-create-work").addEventListener("change", () => {
1587
+ createCharacters = [];
1588
+ createCharacterNextCursor = null;
1589
+ document.querySelector("#im-create-search").value = "";
1590
+ document.querySelector("#im-group-character-options").innerHTML = document.querySelector("#im-create-work").value
1591
+ ? '<p class="im-empty">正在载入角色…</p>'
1592
+ : '<p class="im-empty">选择书籍后显示角色。</p>';
1593
+ syncCreateSelection();
1594
+ void loadCreateCharacters().catch((error) => toast(error.message, "error"));
1595
+ });
1596
+ document.addEventListener("visibilitychange", () => {
1597
+ if (!current || !shouldMarkImConversationRead(opened, document.visibilityState) || !current.active || current.latestSequence <= 0) return;
1598
+ void api(`/api/im/conversations/${encodeURIComponent(current.id)}/read`, { method: "POST", body: { sequence: current.latestSequence } })
1599
+ .then((summary) => {
1600
+ upsertConversationSummary(summary);
1601
+ void refreshUnreadTotal().catch(() => undefined);
1602
+ })
1603
+ .catch(() => undefined);
1604
+ });
1605
+ document.querySelector("#im-create-search").addEventListener("input", () => {
1606
+ if (createSearchTimer !== null) window.clearTimeout(createSearchTimer);
1607
+ createSearchTimer = window.setTimeout(() => {
1608
+ createSearchTimer = null;
1609
+ void loadCreateCharacters().catch((error) => toast(error.message, "error"));
1610
+ }, 160);
1611
+ });
1612
+ document.querySelector("#im-group-character-options").addEventListener("change", (event) => {
1613
+ const checkbox = event.target.closest('input[type="checkbox"]');
1614
+ if (!checkbox) return;
1615
+ const item = createCharacters.find((character) => character.id === checkbox.value);
1616
+ if (!item) return;
1617
+ if (checkbox.checked && createSelectedCharacters.size >= 10) {
1618
+ checkbox.checked = false;
1619
+ toast("一个群聊最多选择 10 个 AI 角色", "warning");
1620
+ return;
1621
+ }
1622
+ if (checkbox.checked) createSelectedCharacters.set(item.id, item);
1623
+ else createSelectedCharacters.delete(item.id);
1624
+ syncCreateSelection();
1625
+ });
1626
+ document.querySelector("#im-group-character-options").addEventListener("click", (event) => {
1627
+ if (event.target.closest("[data-im-load-more-create-characters]")) {
1628
+ void loadCreateCharacters(true).catch((error) => toast(error.message, "error"));
1629
+ }
1630
+ });
1631
+ document.querySelector("#im-create-selected").addEventListener("click", (event) => {
1632
+ const button = event.target.closest("[data-im-remove-selected]");
1633
+ if (!button) return;
1634
+ createSelectedCharacters.delete(button.dataset.imRemoveSelected);
1635
+ renderCreateCharacterOptions();
1636
+ syncCreateSelection();
1637
+ });
1638
+ document.querySelector("#im-create-human-search").addEventListener("input", () => {
1639
+ if (createHumanSearchTimer !== null) window.clearTimeout(createHumanSearchTimer);
1640
+ createHumanSearchTimer = window.setTimeout(() => {
1641
+ createHumanSearchTimer = null;
1642
+ void loadCreateHumans().catch((error) => toast(error.message, "error"));
1643
+ }, 160);
1644
+ });
1645
+ document.querySelector("#im-group-human-options").addEventListener("change", (event) => {
1646
+ const checkbox = event.target.closest('input[type="checkbox"]');
1647
+ if (!checkbox) return;
1648
+ const item = createHumans.find((user) => user.userId === checkbox.value);
1649
+ if (!item) return;
1650
+ if (checkbox.checked && createSelectedHumans.size >= 49) {
1651
+ checkbox.checked = false;
1652
+ toast("一个群聊最多选择 49 个人类成员", "warning");
1653
+ return;
1654
+ }
1655
+ if (checkbox.checked) createSelectedHumans.set(item.userId, item);
1656
+ else createSelectedHumans.delete(item.userId);
1657
+ renderCreateSelectedHumans();
1658
+ });
1659
+ document.querySelector("#im-create-selected-humans").addEventListener("click", (event) => {
1660
+ const button = event.target.closest("[data-im-remove-selected-human]");
1661
+ if (!button) return;
1662
+ createSelectedHumans.delete(button.dataset.imRemoveSelectedHuman);
1663
+ renderCreateHumanOptions();
1664
+ renderCreateSelectedHumans();
1665
+ });
1666
+ document.querySelector("#im-member-add-work").addEventListener("change", () => {
1667
+ memberAddCandidates = [];
1668
+ memberAddCharacterNextCursor = null;
1669
+ memberAddSelectedId = "";
1670
+ document.querySelector("#im-member-add-character-search").value = "";
1671
+ document.querySelector("#im-member-add-options").innerHTML = document.querySelector("#im-member-add-work").value
1672
+ ? '<p class="im-empty">正在读取可添加角色…</p>'
1673
+ : '<p class="im-empty">选择书籍后显示可添加角色。</p>';
1674
+ document.querySelector("#im-member-add-submit").disabled = true;
1675
+ void loadMemberAddCharacters().catch((error) => toast(error.message, "error"));
1676
+ });
1677
+ document.querySelector("#im-member-add-character-search").addEventListener("input", () => {
1678
+ if (memberAddSearchTimer !== null) window.clearTimeout(memberAddSearchTimer);
1679
+ memberAddSearchTimer = window.setTimeout(() => {
1680
+ memberAddSearchTimer = null;
1681
+ void loadMemberAddCharacters().catch((error) => toast(error.message, "error"));
1682
+ }, 160);
1683
+ });
1684
+ document.querySelector("#im-member-add-human-search").addEventListener("input", () => {
1685
+ if (memberAddSearchTimer !== null) window.clearTimeout(memberAddSearchTimer);
1686
+ memberAddSearchTimer = window.setTimeout(() => {
1687
+ memberAddSearchTimer = null;
1688
+ void loadMemberAddHumans().catch((error) => toast(error.message, "error"));
1689
+ }, 160);
1690
+ });
1691
+ document.querySelector("#im-member-add-options").addEventListener("click", (event) => {
1692
+ if (event.target.closest("[data-im-load-more-member-characters]")) {
1693
+ void loadMemberAddCharacters(true).catch((error) => toast(error.message, "error"));
1694
+ return;
1695
+ }
1696
+ const button = event.target.closest("[data-im-member-add-candidate]");
1697
+ if (!button) return;
1698
+ memberAddSelectedId = button.dataset.imMemberAddCandidate;
1699
+ syncMemberAddSelection();
1700
+ });
1701
+ listHost.addEventListener("click", (event) => {
1702
+ if (event.target.closest("[data-im-load-more-conversations]")) {
1703
+ void loadMoreConversations().catch((error) => toast(error.message, "error"));
1704
+ return;
1705
+ }
1706
+ const button = event.target.closest("[data-im-conversation]");
1707
+ if (button) void openConversation(button.dataset.imConversation, true).catch((error) => toast(error.message, "error"));
1708
+ });
1709
+ feed.addEventListener("click", (event) => {
1710
+ if (event.target.closest("[data-im-load-older]")) void loadOlderMessages();
1711
+ });
1712
+ document.querySelector("#im-send").addEventListener("click", () => void send());
1713
+ document.querySelector("#im-stop").addEventListener("click", async (event) => {
1714
+ const conversationId = current.id;
1715
+ const result = await performMutation(event.currentTarget, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/stop`, { method: "POST", body: {} }));
1716
+ if (!result.ok) return;
1717
+ provisionalReplies.clear();
1718
+ if (current?.id === conversationId) await refreshAfterMutation("停止 AI", () => openConversation(conversationId));
1719
+ });
1720
+ document.querySelector("#im-retry").addEventListener("click", async (event) => {
1721
+ if (!current?.activeChain?.id) return;
1722
+ const button = event.currentTarget;
1723
+ if (button.disabled) return;
1724
+ const conversationId = current.id;
1725
+ const chainId = current.activeChain.id;
1726
+ const result = await performMutation(button, () => api(`/api/im/conversations/${encodeURIComponent(conversationId)}/chains/${encodeURIComponent(chainId)}/retry`, { method: "POST", body: {} }));
1727
+ if (result.ok && current?.id === conversationId) await refreshAfterMutation("AI 重试", () => openConversation(conversationId));
1728
+ });
1729
+ document.querySelector("#im-details-toggle").addEventListener("click", (event) => {
1730
+ const expanded = detailsDrawerMedia.matches ? !detailsPanel.classList.contains("is-open") : detailsHidden;
1731
+ setDetailsDrawerOpen(expanded, expanded ? "drawer" : null);
1732
+ });
1733
+ document.querySelector("#im-details-close").addEventListener("click", () => setDetailsDrawerOpen(false, "toggle"));
1734
+ document.querySelector("#im-mobile-back").addEventListener("click", () => {
1735
+ const previousConversationId = current?.id ?? null;
1736
+ conversationRequest += 1;
1737
+ requestedConversationId = null;
1738
+ if (current?.id) {
1739
+ if (serializeImComposer(composer)) conversationDrafts.set(current.id, composer.innerHTML);
1740
+ else conversationDrafts.delete(current.id);
1741
+ }
1742
+ captureGroupSettingsDraft();
1743
+ current = null;
1744
+ workspace.classList.remove("has-conversation");
1745
+ resetDetailsDrawer();
1746
+ provisionalReplies.clear();
1747
+ renderConversationList();
1748
+ renderConversation();
1749
+ const previousConversationButton = [...listHost.querySelectorAll("[data-im-conversation]")]
1750
+ .find((button) => button.dataset.imConversation === previousConversationId);
1751
+ (previousConversationButton ?? document.querySelector("#im-new-conversation")).focus();
1752
+ });
1753
+ composer.addEventListener("input", updateMentionMenu);
1754
+ composer.addEventListener("paste", (event) => {
1755
+ event.preventDefault();
1756
+ document.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? "");
1757
+ });
1758
+ composer.addEventListener("keydown", (event) => {
1759
+ if (event.isComposing) return;
1760
+ if (!mentionMenu.classList.contains("hidden")) {
1761
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1762
+ event.preventDefault();
1763
+ mentionIndex = (mentionIndex + (event.key === "ArrowDown" ? 1 : -1) + mentionOptions.length) % mentionOptions.length;
1764
+ syncMentionSelection();
1765
+ return;
1766
+ }
1767
+ if (event.key === "Enter" && mentionIndex >= 0) {
1768
+ event.preventDefault();
1769
+ selectMention(mentionIndex);
1770
+ return;
1771
+ }
1772
+ if (event.key === "Escape") {
1773
+ event.preventDefault();
1774
+ closeMentionMenu();
1775
+ return;
1776
+ }
1777
+ }
1778
+ if (event.key === "Enter" && !event.shiftKey) {
1779
+ event.preventDefault();
1780
+ void send();
1781
+ }
1782
+ });
1783
+ mentionMenu.addEventListener("pointerdown", (event) => event.preventDefault());
1784
+ mentionMenu.addEventListener("click", (event) => {
1785
+ const button = event.target.closest("[data-im-mention-index]");
1786
+ if (button) selectMention(Number(button.dataset.imMentionIndex));
1787
+ });
1788
+ document.querySelector("#im-settings-form").addEventListener("submit", async (event) => {
1789
+ event.preventDefault();
1790
+ const form = new FormData(event.currentTarget);
1791
+ const submit = event.currentTarget.querySelector('button[type="submit"]');
1792
+ const result = await performMutation(submit, () => api("/api/im/settings", { method: "PATCH", body: {
1793
+ preferredName: String(form.get("preferredName") || "").trim(),
1794
+ pronouns: String(form.get("pronouns") || "").trim(),
1795
+ identitySummary: String(form.get("identitySummary") || "").trim(),
1796
+ additionalNotes: String(form.get("additionalNotes") || "").trim(),
1797
+ primaryModelId: String(form.get("primaryModelId") || "") || null,
1798
+ fallbackModelId: String(form.get("fallbackModelId") || "") || null,
1799
+ retryCount: Number(form.get("retryCount"))
1800
+ } }));
1801
+ if (!result.ok) return;
1802
+ settings = result.value;
1803
+ document.querySelector("#im-settings-dialog").close();
1804
+ toast("IM 身份与模型设置已保存", "success");
1805
+ if (current?.activeChain?.status === "waiting_config") {
1806
+ const conversationId = current.id;
1807
+ await refreshAfterMutation("IM 设置保存", () => openConversation(conversationId));
1808
+ }
1809
+ });
1810
+ document.querySelector("#im-announcement-form").addEventListener("submit", async (event) => {
1811
+ event.preventDefault();
1812
+ const form = event.currentTarget;
1813
+ const submit = form.querySelector('button[type="submit"]');
1814
+ const content = document.querySelector("#im-announcement-content").value.trim();
1815
+ if (!content || !current?.id || submit.disabled) return;
1816
+ const conversationId = current.id;
1817
+ const pendingRequest = pendingAnnouncementRequests.get(conversationId);
1818
+ const announcementRequestId = pendingRequest?.content === content ? pendingRequest.id : requestId();
1819
+ pendingAnnouncementRequests.set(conversationId, { content, id: announcementRequestId });
1820
+ submit.disabled = true;
1821
+ try {
1822
+ await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/announcements`, {
1823
+ method: "POST",
1824
+ body: { content, requestId: announcementRequestId }
1825
+ });
1826
+ if (pendingAnnouncementRequests.get(conversationId)?.id === announcementRequestId) {
1827
+ pendingAnnouncementRequests.delete(conversationId);
1828
+ }
1829
+ document.querySelector("#im-announcement-dialog").close();
1830
+ if (current?.id === conversationId) await refreshAfterMutation("旁白公告发布", () => openConversation(conversationId));
1831
+ toast("旁白公告已发布", "success");
1832
+ } catch (error) {
1833
+ toast(error.message, "error");
1834
+ } finally {
1835
+ submit.disabled = false;
1836
+ }
1837
+ });
1838
+ document.querySelector("#im-member-add-form").addEventListener("submit", async (event) => {
1839
+ event.preventDefault();
1840
+ if (!memberAddSelectedId || !current?.id) return;
1841
+ const submit = document.querySelector("#im-member-add-submit");
1842
+ if (submit.disabled) return;
1843
+ const conversationId = current.id;
1844
+ const selectedId = memberAddSelectedId;
1845
+ submit.disabled = true;
1846
+ try {
1847
+ const path = memberAddKind === "character" ? "characters" : "humans";
1848
+ const body = memberAddKind === "character" ? { characterId: selectedId } : { userId: selectedId };
1849
+ await api(`/api/im/conversations/${encodeURIComponent(conversationId)}/${path}`, { method: "POST", body });
1850
+ document.querySelector("#im-member-add-dialog").close();
1851
+ if (current?.id === conversationId) await refreshAfterMutation("成员添加", () => openConversation(conversationId));
1852
+ toast(memberAddKind === "character" ? "角色已加入群聊" : "用户已加入群聊", "success");
1853
+ } catch (error) {
1854
+ toast(error.message, "error");
1855
+ } finally {
1856
+ submit.disabled = false;
1857
+ }
1858
+ });
1859
+ document.querySelector("#im-group-form").addEventListener("submit", async (event) => {
1860
+ event.preventDefault();
1861
+ const form = new FormData(event.currentTarget);
1862
+ const characterIds = [...createSelectedCharacters.keys()];
1863
+ if (!characterIds.length) return;
1864
+ const submit = event.currentTarget.querySelector('button[type="submit"]');
1865
+ const result = await performMutation(submit, () => characterIds.length === 1
1866
+ ? api("/api/im/conversations/direct", { method: "POST", body: { characterId: characterIds[0] } })
1867
+ : api("/api/im/conversations/group", { method: "POST", body: {
1868
+ title: String(form.get("title") || "").trim(),
1869
+ characterIds,
1870
+ humanUserIds: [...createSelectedHumans.keys()],
1871
+ replyMode: String(form.get("replyMode") || "mention"),
1872
+ responseThreshold: Number(form.get("responseThreshold") || 60),
1873
+ maxAiMessages: Number(form.get("maxAiMessages") || 20)
1874
+ } }));
1875
+ if (!result.ok) return;
1876
+ const conversation = result.value;
1877
+ document.querySelector("#im-group-dialog").close();
1878
+ await refreshAfterMutation("会话创建", async () => {
1879
+ await refreshConversations();
1880
+ await openConversation(conversation.id, true);
1881
+ });
1882
+ });
1883
+ document.querySelectorAll("[data-im-dialog-close]").forEach((button) => button.addEventListener("click", () => button.closest("dialog")?.close()));
1884
+ }
1885
+
1886
+ function start() {
1887
+ if (bound) return;
1888
+ bound = true;
1889
+ bind();
1890
+ }
1891
+
1892
+ async function activate() {
1893
+ start();
1894
+ await refreshConversations();
1895
+ if (!eventSource) connectEvents();
1896
+ }
1897
+
1898
+ return { start, activate, open, close, refreshUnread: refreshConversations, get opened() { return opened; } };
1899
+ }