@musnows/scriverse 0.9.8 → 0.9.10

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.
@@ -1,6 +1,7 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
2
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260830-adjacent-blockquotes-v1";
4
+ import { createImWorkspace } from "/im.js?v=20260903-global-im-v103";
4
5
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
6
  import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
6
7
  import {
@@ -47,7 +48,7 @@ import {
47
48
  renderWritePlanDetailMarkup,
48
49
  isInteractiveToolPending,
49
50
  aiFormatDateTime
50
- } from "/ai-interactive.js?v=20260829-question-tool-result-v5";
51
+ } from "/ai-interactive.js?v=20260903-question-batch-v7";
51
52
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
52
53
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
53
54
  import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
@@ -79,7 +80,7 @@ import {
79
80
  characterGenderLabel,
80
81
  characterStateFieldLabel
81
82
  } from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1&feature=ai-provider-responses-v1";
82
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
83
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1&feature=global-im-return-v1";
83
84
  import {
84
85
  READING_PREFERENCES_STORAGE_KEY,
85
86
  READING_PREFERENCES_VERSION,
@@ -100,7 +101,7 @@ import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathL
100
101
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
101
102
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260818-annotation-permissions-v1";
102
103
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
103
- import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
104
+ import { isGlobalSearchShortcut, isSaveShortcut } from "/keyboard-shortcuts.js?v=20260831-chapter-save-shortcut-v2";
104
105
  import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
105
106
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260817-character-filter-state-v1";
106
107
  import { filterRelationships, sortCharacterRelationships } from "/relationship-filters.js?v=20260818-character-relationship-group-v1";
@@ -305,6 +306,7 @@ let relationshipSearchIndexRefreshTimer = null;
305
306
  let semanticSearchIndexRefreshTimer = null;
306
307
  let aiSemanticSearchResults = [];
307
308
  let aiSemanticSearchQuery = "";
309
+ let aiCitationPopoverTrigger = null;
308
310
  let backgroundTaskCenterTimer = null;
309
311
  let backgroundTaskCenterRequest = 0;
310
312
  let backgroundTaskCenterWorkId = null;
@@ -1031,7 +1033,7 @@ function replacePageRoute(route) {
1031
1033
  }
1032
1034
 
1033
1035
  function presencePageForRoute(route = currentPageRoute()) {
1034
- if (!state.work || route.view === "shelf" || route.view === "platform-ai" || route.view === "platform-usage") return null;
1036
+ if (!state.work || route.view === "shelf" || route.view === "im" || route.view === "platform-ai" || route.view === "platform-usage") return null;
1035
1037
  if (route.view === "reader") return { kind: "welcome" };
1036
1038
  if (relationshipPresenceId) return { kind: "entity-editor", module: "relationship", resourceId: relationshipPresenceId };
1037
1039
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
@@ -1226,6 +1228,7 @@ async function confirmConcurrentSave() {
1226
1228
 
1227
1229
  function currentPageRoute() {
1228
1230
  const workId = state.work?.id ?? null;
1231
+ if (!$("#im-view").classList.contains("hidden")) return { view: "im" };
1229
1232
  if (!$("#reader-view").classList.contains("hidden") && workId) {
1230
1233
  return { view: "reader", workId, chapterId: readingTargetChapter?.id ?? null };
1231
1234
  }
@@ -2071,6 +2074,66 @@ function selectChapterLines(start, end) {
2071
2074
  return selection;
2072
2075
  }
2073
2076
 
2077
+ function aiCitationLineLabel(citation) {
2078
+ return citation.startLine === citation.endLine ? `第 ${citation.startLine} 行` : `第 ${citation.startLine}-${citation.endLine} 行`;
2079
+ }
2080
+
2081
+ function aiCitationTagLabel(citation) {
2082
+ return `${citation.chapterTitle} · L${citation.startLine}${citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
2083
+ }
2084
+
2085
+ function positionAiCitationPopover(trigger) {
2086
+ const panel = $("#ai-panel");
2087
+ const popover = $("#ai-citation-popover");
2088
+ if (!panel || !popover || !trigger?.isConnected || popover.classList.contains("hidden")) return;
2089
+ const panelRect = panel.getBoundingClientRect();
2090
+ const triggerRect = trigger.getBoundingClientRect();
2091
+ const gutter = 16;
2092
+ const maxLeft = Math.max(gutter, panelRect.width - popover.offsetWidth - gutter);
2093
+ const anchorCenter = triggerRect.left + triggerRect.width / 2 - panelRect.left;
2094
+ const left = Math.min(maxLeft, Math.max(gutter, anchorCenter - popover.offsetWidth / 2));
2095
+ const below = triggerRect.bottom - panelRect.top + 8;
2096
+ const above = triggerRect.top - panelRect.top - popover.offsetHeight - 8;
2097
+ const maxTop = Math.max(gutter, panelRect.height - popover.offsetHeight - gutter);
2098
+ const fitsBelow = below <= maxTop;
2099
+ const top = fitsBelow ? below : Math.max(gutter, Math.min(above, maxTop));
2100
+ const arrowOffset = Math.max(12, Math.min(popover.offsetWidth - 12, anchorCenter - left));
2101
+ popover.dataset.placement = fitsBelow ? "bottom" : "top";
2102
+ popover.style.left = `${left}px`;
2103
+ popover.style.top = `${top}px`;
2104
+ popover.style.setProperty("--ai-citation-arrow-offset", `${arrowOffset}px`);
2105
+ }
2106
+
2107
+ function closeAiCitationPopover({ restoreFocus = false } = {}) {
2108
+ const popover = $("#ai-citation-popover");
2109
+ if (!popover) return;
2110
+ const trigger = aiCitationPopoverTrigger;
2111
+ aiCitationPopoverTrigger = null;
2112
+ popover.classList.add("hidden");
2113
+ popover.setAttribute("aria-hidden", "true");
2114
+ popover.removeAttribute("data-placement");
2115
+ popover.style.removeProperty("left");
2116
+ popover.style.removeProperty("top");
2117
+ popover.style.removeProperty("--ai-citation-arrow-offset");
2118
+ trigger?.setAttribute("aria-expanded", "false");
2119
+ if (restoreFocus && trigger?.isConnected) trigger.focus({ preventScroll: true });
2120
+ }
2121
+
2122
+ function openAiCitationPopover(citation, trigger) {
2123
+ const popover = $("#ai-citation-popover");
2124
+ if (!popover || !trigger) return;
2125
+ closeAiCitationPopover();
2126
+ $("#ai-citation-popover-title").textContent = String(citation.chapterTitle ?? "正文引用");
2127
+ $("#ai-citation-popover-description").textContent = aiCitationLineLabel(citation);
2128
+ $("#ai-citation-popover-quote").textContent = citation.text || "(空白行)";
2129
+ aiCitationPopoverTrigger = trigger;
2130
+ trigger.setAttribute("aria-expanded", "true");
2131
+ popover.classList.remove("hidden");
2132
+ popover.setAttribute("aria-hidden", "false");
2133
+ positionAiCitationPopover(trigger);
2134
+ $("#ai-citation-popover-close").focus({ preventScroll: true });
2135
+ }
2136
+
2074
2137
  function renderAiCitations() {
2075
2138
  const host = $("#ai-citations");
2076
2139
  host.replaceChildren();
@@ -2084,7 +2147,7 @@ function renderAiCitations() {
2084
2147
  const source = document.createElement("strong");
2085
2148
  source.textContent = citation.chapterTitle;
2086
2149
  const range = document.createElement("small");
2087
- range.textContent = citation.startLine === citation.endLine ? `第 ${citation.startLine} 行` : `第 ${citation.startLine}-${citation.endLine} 行`;
2150
+ range.textContent = aiCitationLineLabel(citation);
2088
2151
  const excerpt = document.createElement("span");
2089
2152
  excerpt.textContent = citation.text.replace(/\s+/gu, " ").trim() || "空白行";
2090
2153
  main.append(source, range, excerpt);
@@ -2092,7 +2155,7 @@ function renderAiCitations() {
2092
2155
  const remove = document.createElement("button");
2093
2156
  remove.type = "button";
2094
2157
  remove.className = "ai-citation-remove";
2095
- remove.setAttribute("aria-label", `移除 ${citation.chapterTitle} 第 ${citation.startLine}-${citation.endLine} 行引用`);
2158
+ remove.setAttribute("aria-label", `移除 ${citation.chapterTitle} ${aiCitationLineLabel(citation)}引用`);
2096
2159
  remove.textContent = "×";
2097
2160
  remove.addEventListener("click", () => {
2098
2161
  state.aiCitations = state.aiCitations.filter((item) => item.id !== citation.id);
@@ -2610,6 +2673,7 @@ function renderAiChatTabs() {
2610
2673
  function activateAiChatTab(tabId, { persistCurrent = true, force = false } = {}) {
2611
2674
  const current = activeAiChatTab();
2612
2675
  if (!force && current?.id === tabId) return current;
2676
+ closeAiCitationPopover();
2613
2677
  if (persistCurrent) persistActiveAiChatTab();
2614
2678
  const tab = aiChatTabManager.activate(tabId);
2615
2679
  if (!tab) return null;
@@ -2700,6 +2764,7 @@ function resetAiFeed(
2700
2764
  roleplayCharacter = state.aiRoleplayCharacter,
2701
2765
  roleplayUserCharacter = state.aiRoleplayUserCharacter
2702
2766
  ) {
2767
+ closeAiCitationPopover();
2703
2768
  const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2704
2769
  if (tab) tab.lastMessageAt = null;
2705
2770
  if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
@@ -2940,7 +3005,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2940
3005
  recall_story: "回忆故事",
2941
3006
  recall_roleplay_memory: "回忆当前扮演线",
2942
3007
  remember_roleplay: "整理扮演记忆",
2943
- calculate_time: "计算日期"
3008
+ calculate_time: "计算日期",
3009
+ ask_user_question: "向作者提问"
2944
3010
  };
2945
3011
 
2946
3012
  const AI_TOOL_DESCRIPTIONS = {
@@ -2958,7 +3024,8 @@ const AI_TOOL_DESCRIPTIONS = {
2958
3024
  recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2959
3025
  recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
2960
3026
  remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
2961
- calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
3027
+ calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。",
3028
+ ask_user_question: "向作者批量提出单选问题;作者一次提交的全部选择会作为工具结果返回给 Agent。"
2962
3029
  };
2963
3030
 
2964
3031
  const aiFeedScrollFrames = new WeakMap();
@@ -3027,8 +3094,9 @@ function bindAiFeedAutoScroll(feed) {
3027
3094
  update();
3028
3095
  }
3029
3096
 
3030
- function scrollAiFeedToBottom(feed = $("#ai-feed")) {
3097
+ function scrollAiFeedToBottom(feed = $("#ai-feed"), { force = false } = {}) {
3031
3098
  bindAiFeedAutoScroll(feed);
3099
+ if (force) aiFeedAutoScrollStates.set(feed, true);
3032
3100
  if (!aiFeedAutoScrollStates.get(feed)) return;
3033
3101
  feed.scrollTop = feed.scrollHeight;
3034
3102
  const currentFrame = aiFeedScrollFrames.get(feed);
@@ -3121,7 +3189,7 @@ function openAiToolCallDetail(toolCall) {
3121
3189
 
3122
3190
  function createAiToolCallButton(toolCall) {
3123
3191
  const name = String(toolCall?.name ?? "unknown");
3124
- if (name === "propose_write_plan" || name === "ask_user_question") {
3192
+ if (name === "propose_write_plan") {
3125
3193
  const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
3126
3194
  if (card) return card;
3127
3195
  }
@@ -3146,6 +3214,8 @@ let currentPlanDialogFocusConfirm = false;
3146
3214
  let aiQuestionDialogQuestionId = null;
3147
3215
  let currentAiQuestionDialogView = null;
3148
3216
  let aiQuestionDialogBusy = false;
3217
+ let aiQuestionDialogIndex = 0;
3218
+ let aiQuestionDrafts = [];
3149
3219
  const aiQuestionContinuationTabIds = new Set();
3150
3220
  let autoOpenedQuestionIds = new Set();
3151
3221
  const aiApprovalCenterState = { status: "" };
@@ -3317,6 +3387,47 @@ async function fetchAiUserQuestion(questionId) {
3317
3387
  return question;
3318
3388
  }
3319
3389
 
3390
+ function aiQuestionItems(question) {
3391
+ const items = Array.isArray(question?.questions) && question.questions.length > 0
3392
+ ? question.questions
3393
+ : [{
3394
+ question: question?.question ?? "",
3395
+ options: question?.options ?? [],
3396
+ selectedOption: question?.selectedOption ?? null,
3397
+ customAnswer: question?.customAnswer ?? "",
3398
+ answerText: question?.answerText ?? "",
3399
+ isCustomAnswer: question?.isCustomAnswer === true
3400
+ }];
3401
+ return items.map((item, index) => ({
3402
+ ...item,
3403
+ index,
3404
+ question: String(item?.question ?? ""),
3405
+ options: Array.isArray(item?.options) ? item.options : []
3406
+ }));
3407
+ }
3408
+
3409
+ function resetAiQuestionDialogState(question) {
3410
+ const items = aiQuestionItems(question);
3411
+ aiQuestionDialogIndex = 0;
3412
+ aiQuestionDrafts = items.map((item) => ({
3413
+ selectedOption: Number.isInteger(item.selectedOption) ? Number(item.selectedOption) : null,
3414
+ customAnswer: String(item.customAnswer ?? "")
3415
+ }));
3416
+ }
3417
+
3418
+ function updateCurrentAiQuestionDraft() {
3419
+ if (currentAiQuestionDialogView?.status !== "pending") return;
3420
+ const draft = aiQuestionDrafts[aiQuestionDialogIndex];
3421
+ if (!draft) return;
3422
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
3423
+ draft.selectedOption = checked && checked.value !== "custom" ? Number(checked.value) : null;
3424
+ draft.customAnswer = String($("#ai-question-custom-answer").value ?? "");
3425
+ }
3426
+
3427
+ function aiQuestionDraftIsAnswered(draft) {
3428
+ return Boolean(draft && (Number.isInteger(draft.selectedOption) || String(draft.customAnswer ?? "").trim()));
3429
+ }
3430
+
3320
3431
  function syncAiQuestionOptionPresentation() {
3321
3432
  const checked = document.querySelector('input[name="ai-question-choice"]:checked');
3322
3433
  for (const label of document.querySelectorAll(".ai-question-option-item")) {
@@ -3337,8 +3448,23 @@ function renderAiUserQuestionOptions(question) {
3337
3448
  const host = $("#ai-question-options");
3338
3449
  const customInput = $("#ai-question-custom-answer");
3339
3450
  const isPending = question.status === "pending";
3451
+ const items = aiQuestionItems(question);
3452
+ aiQuestionDialogIndex = Math.min(Math.max(aiQuestionDialogIndex, 0), Math.max(0, items.length - 1));
3453
+ const item = items[aiQuestionDialogIndex] ?? items[0];
3454
+ const draft = isPending
3455
+ ? (aiQuestionDrafts[aiQuestionDialogIndex] ?? { selectedOption: null, customAnswer: "" })
3456
+ : { selectedOption: item?.selectedOption ?? null, customAnswer: item?.customAnswer ?? "" };
3457
+ const answeredCount = isPending
3458
+ ? aiQuestionDrafts.filter(aiQuestionDraftIsAnswered).length
3459
+ : items.filter((entry) => entry.answerText).length;
3460
+ const navigation = $("#ai-question-navigation");
3461
+ navigation.classList.toggle("hidden", items.length <= 1);
3462
+ $("#ai-question-progress").textContent = `问题 ${aiQuestionDialogIndex + 1} / ${items.length} · 已回答 ${answeredCount} / ${items.length}`;
3463
+ $("#ai-question-previous").disabled = aiQuestionDialogIndex <= 0;
3464
+ $("#ai-question-next").disabled = aiQuestionDialogIndex >= items.length - 1;
3465
+ $("#ai-question-text").textContent = item?.question ?? "";
3340
3466
  host.replaceChildren();
3341
- for (const option of question.options ?? []) {
3467
+ for (const option of item?.options ?? []) {
3342
3468
  const label = document.createElement("label");
3343
3469
  label.className = "ai-question-option-item";
3344
3470
  label.dataset.recommended = String(option.recommended === true);
@@ -3346,7 +3472,7 @@ function renderAiUserQuestionOptions(question) {
3346
3472
  input.type = "radio";
3347
3473
  input.name = "ai-question-choice";
3348
3474
  input.value = String(option.index);
3349
- input.checked = question.selectedOption === option.index;
3475
+ input.checked = draft.selectedOption === option.index;
3350
3476
  input.disabled = !isPending;
3351
3477
  const span = document.createElement("span");
3352
3478
  span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
@@ -3359,29 +3485,30 @@ function renderAiUserQuestionOptions(question) {
3359
3485
  customRadio.type = "radio";
3360
3486
  customRadio.name = "ai-question-choice";
3361
3487
  customRadio.value = "custom";
3362
- customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
3488
+ customRadio.checked = draft.selectedOption == null && Boolean(String(draft.customAnswer ?? "").trim());
3363
3489
  customRadio.disabled = !isPending;
3364
3490
  const customText = document.createElement("span");
3365
3491
  customText.textContent = "自定义回答";
3366
3492
  customLabel.append(customRadio, customText);
3367
3493
  host.append(customLabel);
3368
- customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
3494
+ customInput.value = draft.customAnswer ?? "";
3369
3495
  customInput.disabled = !isPending;
3370
3496
  syncAiQuestionAnswerCount();
3371
3497
  syncAiQuestionOptionPresentation();
3372
3498
  // 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
3373
3499
  if (isPending) syncAiQuestionSubmitState();
3374
3500
  else $("#ai-question-submit").disabled = true;
3501
+ $("#ai-question-submit").textContent = items.length > 1 ? "提交全部回答" : "提交回答";
3375
3502
  $("#ai-question-skip").disabled = !isPending;
3376
3503
  $("#ai-question-expiry").textContent = isPending
3377
- ? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3378
- : `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
3504
+ ? `请回答全部 ${items.length} 个问题后一次提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3505
+ : `该提问批次当前状态:${question.statusLabel}${item?.answerText ? ` · 当前回答:${item.answerText}` : ""}`;
3379
3506
  }
3380
3507
 
3381
3508
  async function refreshAiQuestionDialog() {
3382
3509
  const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
3383
3510
  currentAiQuestionDialogView = question;
3384
- $("#ai-question-text").textContent = question.question;
3511
+ resetAiQuestionDialogState(question);
3385
3512
  renderAiUserQuestionOptions(question);
3386
3513
  return question;
3387
3514
  }
@@ -3393,6 +3520,8 @@ async function openAiUserQuestionDialog(questionId) {
3393
3520
  $("#ai-question-text").textContent = "";
3394
3521
  $("#ai-question-options").replaceChildren();
3395
3522
  $("#ai-question-custom-answer").value = "";
3523
+ $("#ai-question-navigation").classList.add("hidden");
3524
+ $("#ai-question-progress").textContent = "正在加载问题";
3396
3525
  syncAiQuestionAnswerCount();
3397
3526
  $("#ai-question-expiry").textContent = "正在加载问题……";
3398
3527
  try {
@@ -3403,51 +3532,19 @@ async function openAiUserQuestionDialog(questionId) {
3403
3532
  }
3404
3533
  }
3405
3534
 
3406
- function beginAiQuestionContinuationUi(conversationId, questionId) {
3535
+ function beginAiQuestionContinuationUi(conversationId) {
3407
3536
  const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
3408
3537
  if (!tab) return null;
3409
- const card = questionId
3410
- ? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
3411
- : null;
3412
- const note = card?.querySelector(".ai-interactive-note") ?? null;
3413
- const status = card?.querySelector(".ai-status-chip") ?? null;
3414
- const previousNote = note?.textContent ?? "";
3415
- const previousStatus = status?.textContent ?? "";
3416
- const controlStates = card
3417
- ? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
3418
- : [];
3419
- if (card) {
3420
- card.classList.add("is-resuming");
3421
- card.setAttribute("aria-busy", "true");
3422
- controlStates.forEach(({ button }) => { button.disabled = true; });
3423
- if (status) status.textContent = "处理中";
3424
- if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
3425
- }
3426
3538
  aiQuestionContinuationTabIds.add(tab.id);
3427
3539
  setAiChatTabStatus(tab, "streaming");
3428
3540
  if (isActiveAiChatTab(tab)) syncAiRequestControls();
3429
3541
  scrollAiFeedToBottom(tab.feed);
3430
- return {
3431
- tab,
3432
- card,
3433
- note,
3434
- status,
3435
- previousNote,
3436
- previousStatus,
3437
- controlStates
3438
- };
3542
+ return { tab };
3439
3543
  }
3440
3544
 
3441
3545
  function finishAiQuestionContinuationUi(continuationUi, failed = false) {
3442
3546
  if (!continuationUi) return;
3443
3547
  aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
3444
- if (continuationUi.card?.isConnected) {
3445
- continuationUi.card.classList.remove("is-resuming");
3446
- continuationUi.card.removeAttribute("aria-busy");
3447
- continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
3448
- if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
3449
- if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
3450
- }
3451
3548
  if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
3452
3549
  if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
3453
3550
  }
@@ -3480,26 +3577,21 @@ async function respondAiUserQuestion(questionId, payload) {
3480
3577
  const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
3481
3578
  if (questionDialog.open) questionDialog.close();
3482
3579
  if (approvalCenterDialog.open) approvalCenterDialog.close();
3483
- continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
3580
+ continuationUi = beginAiQuestionContinuationUi(conversationId);
3484
3581
  let question;
3485
3582
  if (payload.action === "reject") {
3486
3583
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
3487
- } else if (payload.action === "custom") {
3488
- question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
3489
3584
  } else {
3490
3585
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
3491
3586
  method: "POST",
3492
- body: {
3493
- selectedOption: payload.selectedOption,
3494
- ...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
3495
- }
3587
+ body: { answers: payload.answers }
3496
3588
  });
3497
3589
  }
3498
3590
  cacheAiQuestionView(question);
3499
3591
  currentAiQuestionDialogView = question;
3500
3592
  aiQuestionDialogQuestionId = String(question.id);
3501
3593
  await reloadAiQuestionConversation(question.conversationId ?? conversationId);
3502
- toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
3594
+ toast(payload.action === "reject" ? "已跳过该提问批次,AI 已继续处理" : "全部回答已提交,AI 已继续处理");
3503
3595
  return question;
3504
3596
  } catch (error) {
3505
3597
  continuationFailed = true;
@@ -3735,6 +3827,30 @@ async function favoriteAiConversation(conversation) {
3735
3827
  toast(updated.isFavorite ? "对话已收藏" : "已取消收藏");
3736
3828
  }
3737
3829
 
3830
+ async function renameAiConversation(conversation) {
3831
+ if (!conversation?.id) return;
3832
+ const title = await inputToast("输入新的对话名称", {
3833
+ title: "重命名对话",
3834
+ inputLabel: "对话名称",
3835
+ value: conversation.title || "新对话",
3836
+ placeholder: "对话名称",
3837
+ confirmLabel: "保存",
3838
+ maxLength: 200
3839
+ });
3840
+ if (title === null) return;
3841
+ if (!title) {
3842
+ toast("对话名称不能为空", "error");
3843
+ return;
3844
+ }
3845
+ const updated = await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}/title`, {
3846
+ method: "PATCH",
3847
+ body: { title }
3848
+ });
3849
+ upsertAiConversationSummary(updated);
3850
+ applyAiConversationTitle(updated.title, updated.id);
3851
+ toast("对话已重命名");
3852
+ }
3853
+
3738
3854
  async function deleteAiConversation(conversation) {
3739
3855
  if (conversation.isFavorite === true) {
3740
3856
  toast("收藏的对话不能清理,请先取消收藏", "error");
@@ -5389,8 +5505,11 @@ function showLineCitationMenu(event, lineIndex) {
5389
5505
  selectChapterLines(lineIndex, lineIndex);
5390
5506
  }
5391
5507
  const menu = $("#line-citation-menu");
5392
- $("#add-line-annotation").classList.toggle("hidden", !canWritePermissionModule(state.work, "comments"));
5393
- $("#add-line-todo").classList.toggle("hidden", !canWritePermissionModule(state.work, "todos"));
5508
+ const canAddComment = canWritePermissionModule(state.work, "comments");
5509
+ const canAddTodo = canWritePermissionModule(state.work, "todos");
5510
+ $("#add-line-annotation").classList.toggle("hidden", !canAddComment);
5511
+ $("#add-line-todo").classList.toggle("hidden", !canAddTodo);
5512
+ $("#line-citation-annotation-separator").classList.toggle("hidden", !(canAddComment || canAddTodo));
5394
5513
  const { start, end } = chapterLineSelection;
5395
5514
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
5396
5515
  menu.classList.remove("hidden");
@@ -6767,6 +6886,7 @@ async function loadWorks(preferredId) {
6767
6886
  }
6768
6887
 
6769
6888
  function restoredSettingsReturnContext(route) {
6889
+ if (route.returnView === "im") return { view: "im" };
6770
6890
  if (route.returnView === "module" && route.returnModule) return { view: "module", module: route.returnModule };
6771
6891
  if (route.returnView === "editor" && route.returnChapterId) return { view: "editor", chapterId: route.returnChapterId };
6772
6892
  if (route.returnView === "welcome") return { view: "welcome" };
@@ -6811,6 +6931,10 @@ async function initializePage() {
6811
6931
  showShelf();
6812
6932
  return;
6813
6933
  }
6934
+ if (route.view === "im") {
6935
+ await imWorkspace.open();
6936
+ return;
6937
+ }
6814
6938
 
6815
6939
  const requestedWork = route.workId ? state.works.find((work) => work.id === route.workId) : null;
6816
6940
  if (route.workId && !requestedWork) {
@@ -6892,6 +7016,7 @@ async function initializePage() {
6892
7016
  }
6893
7017
 
6894
7018
  function showShelf() {
7019
+ imWorkspace.close();
6895
7020
  stopBackgroundTaskCenter();
6896
7021
  dismissChapterInsightToast();
6897
7022
  dismissDeleteToasts();
@@ -6916,6 +7041,7 @@ function showShelf() {
6916
7041
  }
6917
7042
 
6918
7043
  function captureSettingsReturnContext() {
7044
+ if (imWorkspace.opened) return { view: "im" };
6919
7045
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
6920
7046
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", chapterId: state.chapter?.id ?? null };
6921
7047
  if (!$("#module-view").classList.contains("hidden")) return { view: "module", module: state.module };
@@ -6943,7 +7069,9 @@ function renderSettingsHub() {
6943
7069
  $("#top-search-button").disabled = !canReadAggregate;
6944
7070
  $("#export-button").disabled = !canExportManuscript;
6945
7071
  $("#export-button").setAttribute("aria-expanded", "false");
6946
- $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
7072
+ $("#settings-return").textContent = settingsReturnContext?.view === "im"
7073
+ ? "返回 IM"
7074
+ : settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
6947
7075
  $("#settings-work-note").textContent = hasWork
6948
7076
  ? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP 或 DOCX;DOCX 在有封面时会嵌入为首页。`
6949
7077
  : "当前未选择作品;打开作品后可使用导出。";
@@ -8225,6 +8353,7 @@ async function showSettingsHub() {
8225
8353
  settingsReturnContext = captureSettingsReturnContext();
8226
8354
  state.dirty = false;
8227
8355
  }
8356
+ imWorkspace.close();
8228
8357
  dismissChapterInsightToast();
8229
8358
  dismissTokenUsageDetails({ restoreFocus: false });
8230
8359
  dismissDeleteToasts();
@@ -8261,6 +8390,7 @@ async function returnFromSettings() {
8261
8390
  $("#platform-ai-view").classList.add("hidden");
8262
8391
  $("#platform-usage-view").classList.add("hidden");
8263
8392
  $("#work-audit-view").classList.add("hidden");
8393
+ if (context.view === "im") return imWorkspace.open();
8264
8394
  if (context.view === "shelf" || !state.work) return showShelf();
8265
8395
  $("#app").classList.remove("shelf-mode");
8266
8396
  $("#shelf-view").classList.add("hidden");
@@ -8410,6 +8540,7 @@ function resetWorkScopedUiCaches() {
8410
8540
  }
8411
8541
 
8412
8542
  async function selectWork(workId, preferredChapterId = null) {
8543
+ imWorkspace.close();
8413
8544
  chapterSelectionRequestGeneration += 1;
8414
8545
  const discarding = state.work?.id !== workId && state.dirty;
8415
8546
  if (discarding && !(await confirmDiscardChanges())) return false;
@@ -8533,7 +8664,7 @@ function renderTree() {
8533
8664
  if (!state.work) return;
8534
8665
  const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
8535
8666
  const proseEditable = canEditProse();
8536
- $("#chapter-count").textContent = `${count} 章`;
8667
+ $("#chapter-count").querySelector(".chapter-count-number").textContent = String(count);
8537
8668
  $("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
8538
8669
  $("#novel-tree").classList.remove("empty-copy");
8539
8670
  $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
@@ -8554,7 +8685,7 @@ function renderTree() {
8554
8685
  return `
8555
8686
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
8556
8687
  <div class="volume-title">
8557
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
8688
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span class="volume-chapter-count"><span class="volume-chapter-count-number">${Number(volume.chapterCount ?? chapters.length)}</span><span class="volume-chapter-count-unit"> 章</span></span></button>
8558
8689
  ${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情" title="打开“${esc(volume.title)}”分卷详情"><svg class="volume-detail-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 10.5v5.5M12 7.5h.01"></path></svg></button>` : ""}
8559
8690
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
8560
8691
  </div>
@@ -9103,6 +9234,7 @@ async function resolveCurrentChapterForeshadowReminder() {
9103
9234
  }
9104
9235
 
9105
9236
  async function selectChapter(chapterId, { editMode = false } = {}) {
9237
+ imWorkspace.close();
9106
9238
  const workId = state.work?.id;
9107
9239
  if (!workId) return false;
9108
9240
  const selectionGeneration = ++chapterSelectionRequestGeneration;
@@ -9663,6 +9795,7 @@ function tidyChapterBlankLines() {
9663
9795
  }
9664
9796
 
9665
9797
  function showWelcome(hasWork = false) {
9798
+ imWorkspace.close();
9666
9799
  chapterSelectionRequestId += 1;
9667
9800
  clearChapterForeshadowReminders({ invalidateRequest: true });
9668
9801
  dismissChapterInsightToast();
@@ -9696,6 +9829,7 @@ const moduleMeta = {
9696
9829
  };
9697
9830
 
9698
9831
  async function showModule(module) {
9832
+ imWorkspace.close();
9699
9833
  if (!state.work) return showWelcome();
9700
9834
  if (!canReadModule(module)) {
9701
9835
  const fallback = firstReadableUiModule(state.work);
@@ -17909,6 +18043,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17909
18043
  }
17910
18044
  setAiChatTabStatus(tab, "streaming");
17911
18045
  syncAiRequestControls();
18046
+ scrollAiFeedToBottom(tab.feed, { force: true });
17912
18047
  try {
17913
18048
  try {
17914
18049
  await ensureAiModelsLoaded();
@@ -18507,8 +18642,21 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
18507
18642
  const references = document.createElement("div");
18508
18643
  references.className = "message-citations";
18509
18644
  for (const citation of citations) {
18510
- const reference = document.createElement("span");
18511
- reference.textContent = `${citation.chapterTitle} · L${citation.startLine}${citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
18645
+ const reference = document.createElement("button");
18646
+ reference.type = "button";
18647
+ reference.className = "message-citation";
18648
+ reference.setAttribute("aria-haspopup", "dialog");
18649
+ reference.setAttribute("aria-expanded", "false");
18650
+ reference.setAttribute("aria-controls", "ai-citation-popover");
18651
+ reference.setAttribute("aria-label", `${aiCitationTagLabel(citation)},点击查看引用正文`);
18652
+ const referenceLabel = document.createElement("span");
18653
+ referenceLabel.textContent = aiCitationTagLabel(citation);
18654
+ reference.append(referenceLabel);
18655
+ reference.addEventListener("click", () => {
18656
+ const popover = $("#ai-citation-popover");
18657
+ if (aiCitationPopoverTrigger === reference && popover && !popover.classList.contains("hidden")) closeAiCitationPopover();
18658
+ else openAiCitationPopover(citation, reference);
18659
+ });
18512
18660
  references.append(reference);
18513
18661
  }
18514
18662
  message.append(references);
@@ -20727,6 +20875,7 @@ document.addEventListener("pointerdown", (event) => {
20727
20875
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
20728
20876
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
20729
20877
  if (!event.target.closest("#ai-model-picker") && !event.target.closest("#ai-model-popover")) setAiModelPickerVisible(false);
20878
+ if (!event.target.closest("#ai-citation-popover") && !event.target.closest(".message-citation")) closeAiCitationPopover();
20730
20879
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
20731
20880
  $("#account-menu").classList.add("hidden");
20732
20881
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -20753,6 +20902,10 @@ document.addEventListener("keydown", (event) => {
20753
20902
  setAiModelPickerVisible(false);
20754
20903
  return;
20755
20904
  }
20905
+ if (!$("#ai-citation-popover").classList.contains("hidden")) {
20906
+ closeAiCitationPopover({ restoreFocus: true });
20907
+ return;
20908
+ }
20756
20909
  if (!$("#chapter-search-panel").classList.contains("hidden")) {
20757
20910
  closeChapterSearchPanel();
20758
20911
  return;
@@ -20776,6 +20929,13 @@ document.addEventListener("keydown", (event) => {
20776
20929
  setAiContextDistributionVisible(false);
20777
20930
  }
20778
20931
  });
20932
+ document.addEventListener("keydown", (event) => {
20933
+ if (!isSaveShortcut(event, navigator.platform) || !state.chapter || state.module !== "editor" || chapterEditorReadOnly || !canEditProse()) return;
20934
+ event.preventDefault();
20935
+ event.stopPropagation();
20936
+ if (event.repeat) return;
20937
+ $("#save-button").click();
20938
+ }, { capture: true });
20779
20939
  document.addEventListener("keydown", (event) => {
20780
20940
  if (!isGlobalSearchShortcut(event) || !state.work) return;
20781
20941
  event.preventDefault();
@@ -20792,6 +20952,7 @@ $("#ai-context-meter").addEventListener("click", () => {
20792
20952
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
20793
20953
  });
20794
20954
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
20955
+ $("#ai-citation-popover-close").addEventListener("click", () => closeAiCitationPopover({ restoreFocus: true }));
20795
20956
  $("#ai-send").addEventListener("click", activateAiSendControl);
20796
20957
  $("#ai-conversation-switcher").addEventListener("click", () => {
20797
20958
  setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
@@ -20936,6 +21097,19 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
20936
21097
  }
20937
21098
  return;
20938
21099
  }
21100
+ if (action === "rename") {
21101
+ option.disabled = true;
21102
+ try {
21103
+ await renameAiConversation(conversation);
21104
+ closeAiHistoryActionMenu(true);
21105
+ } catch (error) {
21106
+ toast(`对话重命名失败:${error.message}`, "error");
21107
+ option.focus();
21108
+ } finally {
21109
+ option.disabled = false;
21110
+ }
21111
+ return;
21112
+ }
20939
21113
  if (action === "copy-session-id") {
20940
21114
  option.disabled = true;
20941
21115
  try {
@@ -21033,14 +21207,17 @@ $("#ai-write-plan-undo").addEventListener("click", () => {
21033
21207
  $("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
21034
21208
 
21035
21209
  function syncAiQuestionSubmitState() {
21036
- const checked = document.querySelector('input[name="ai-question-choice"]:checked');
21037
- const customInput = $("#ai-question-custom-answer");
21038
21210
  const submit = $("#ai-question-submit");
21039
- const disabled = !checked
21040
- || checked.disabled
21041
- || (checked.value === "custom" && !String(customInput.value).trim());
21211
+ const questionCount = aiQuestionItems(currentAiQuestionDialogView).length;
21212
+ const answeredCount = aiQuestionDrafts.filter(aiQuestionDraftIsAnswered).length;
21213
+ const unansweredCount = Math.max(0, questionCount - answeredCount);
21214
+ $("#ai-question-progress").textContent = `问题 ${aiQuestionDialogIndex + 1} / ${questionCount} · 已回答 ${answeredCount} / ${questionCount}`;
21215
+ const disabled = aiQuestionDialogBusy
21216
+ || currentAiQuestionDialogView?.status !== "pending"
21217
+ || aiQuestionDrafts.length !== questionCount
21218
+ || unansweredCount > 0;
21042
21219
  submit.disabled = disabled;
21043
- submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
21220
+ submit.title = unansweredCount > 0 ? `还有 ${unansweredCount} 个问题未回答` : "";
21044
21221
  }
21045
21222
  $("#ai-question-form").addEventListener("change", (event) => {
21046
21223
  const control = event.target;
@@ -21048,6 +21225,7 @@ $("#ai-question-form").addEventListener("change", (event) => {
21048
21225
  const isCustom = control.value === "custom";
21049
21226
  const customInput = $("#ai-question-custom-answer");
21050
21227
  if (isCustom && !control.disabled) customInput.focus();
21228
+ updateCurrentAiQuestionDraft();
21051
21229
  syncAiQuestionOptionPresentation();
21052
21230
  syncAiQuestionSubmitState();
21053
21231
  });
@@ -21059,23 +21237,30 @@ $("#ai-question-custom-answer").addEventListener("input", () => {
21059
21237
  const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
21060
21238
  if (customRadio && !customRadio.disabled) customRadio.checked = true;
21061
21239
  }
21240
+ updateCurrentAiQuestionDraft();
21062
21241
  syncAiQuestionOptionPresentation();
21063
21242
  syncAiQuestionSubmitState();
21064
21243
  });
21244
+ function moveAiQuestionDialog(direction) {
21245
+ if (!currentAiQuestionDialogView) return;
21246
+ updateCurrentAiQuestionDraft();
21247
+ const maximumIndex = Math.max(0, aiQuestionItems(currentAiQuestionDialogView).length - 1);
21248
+ aiQuestionDialogIndex = Math.min(maximumIndex, Math.max(0, aiQuestionDialogIndex + direction));
21249
+ renderAiUserQuestionOptions(currentAiQuestionDialogView);
21250
+ $("#ai-question-text").focus();
21251
+ }
21252
+ $("#ai-question-previous").addEventListener("click", () => moveAiQuestionDialog(-1));
21253
+ $("#ai-question-next").addEventListener("click", () => moveAiQuestionDialog(1));
21065
21254
  $("#ai-question-submit").addEventListener("click", () => {
21066
- const checked = document.querySelector('input[name="ai-question-choice"]:checked');
21067
- if (!checked || aiQuestionDialogQuestionId == null) return;
21068
- if (checked.value === "custom") {
21069
- const text = String($("#ai-question-custom-answer").value).trim();
21070
- if (!text) return;
21071
- respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
21072
- return;
21073
- }
21074
- const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
21255
+ if (aiQuestionDialogQuestionId == null) return;
21256
+ updateCurrentAiQuestionDraft();
21257
+ if (!aiQuestionDrafts.every(aiQuestionDraftIsAnswered)) return syncAiQuestionSubmitState();
21075
21258
  respondAiUserQuestion(aiQuestionDialogQuestionId, {
21076
- action: "option",
21077
- selectedOption: Number(checked.value),
21078
- ...(supplementalAnswer ? { customAnswer: supplementalAnswer } : {})
21259
+ action: "answer",
21260
+ answers: aiQuestionDrafts.map((draft) => ({
21261
+ ...(Number.isInteger(draft.selectedOption) ? { selectedOption: draft.selectedOption } : {}),
21262
+ ...(String(draft.customAnswer ?? "").trim() ? { customAnswer: String(draft.customAnswer).trim() } : {})
21263
+ }))
21079
21264
  }).catch((error) => toast(error.message, "error"));
21080
21265
  });
21081
21266
  $("#ai-question-skip").addEventListener("click", () => {
@@ -21224,6 +21409,7 @@ window.addEventListener("online", () => {
21224
21409
  });
21225
21410
  window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
21226
21411
  window.addEventListener("resize", () => {
21412
+ if (aiCitationPopoverTrigger && !$("#ai-citation-popover").classList.contains("hidden")) positionAiCitationPopover(aiCitationPopoverTrigger);
21227
21413
  if ($("#reader-view").classList.contains("hidden") || readingPreferences.mode !== "paged") return;
21228
21414
  if (readingResizeFrame !== null) window.cancelAnimationFrame(readingResizeFrame);
21229
21415
  readingResizeFrame = window.requestAnimationFrame(() => {
@@ -21232,7 +21418,25 @@ window.addEventListener("resize", () => {
21232
21418
  });
21233
21419
  });
21234
21420
 
21421
+ const imWorkspace = createImWorkspace({
21422
+ api,
21423
+ esc,
21424
+ renderMarkdown,
21425
+ toast,
21426
+ confirmToast,
21427
+ state,
21428
+ showShelf,
21429
+ onRouteChange: schedulePresenceHeartbeat,
21430
+ beforeOpen: async () => {
21431
+ if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 IM 将放弃本地修改。是否继续?"))) return false;
21432
+ cancelChapterAutoSave();
21433
+ state.dirty = false;
21434
+ return true;
21435
+ }
21436
+ });
21437
+
21235
21438
  initializeAiChatTabs();
21439
+ imWorkspace.start();
21236
21440
  initializePage().catch((error) => {
21237
21441
  restoringPageRoute = false;
21238
21442
  if (state.user) {
@@ -21241,4 +21445,6 @@ initializePage().catch((error) => {
21241
21445
  }
21242
21446
  showShelf();
21243
21447
  toast(`系统初始化失败:${error.message}`, "error");
21448
+ }).finally(() => {
21449
+ if (state.user) void imWorkspace.activate().catch((error) => toast(`IM 初始化失败:${error.message}`, "error"));
21244
21450
  });