@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.
@@ -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,
@@ -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();
@@ -3122,7 +3189,7 @@ function openAiToolCallDetail(toolCall) {
3122
3189
 
3123
3190
  function createAiToolCallButton(toolCall) {
3124
3191
  const name = String(toolCall?.name ?? "unknown");
3125
- if (name === "propose_write_plan" || name === "ask_user_question") {
3192
+ if (name === "propose_write_plan") {
3126
3193
  const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
3127
3194
  if (card) return card;
3128
3195
  }
@@ -3147,6 +3214,8 @@ let currentPlanDialogFocusConfirm = false;
3147
3214
  let aiQuestionDialogQuestionId = null;
3148
3215
  let currentAiQuestionDialogView = null;
3149
3216
  let aiQuestionDialogBusy = false;
3217
+ let aiQuestionDialogIndex = 0;
3218
+ let aiQuestionDrafts = [];
3150
3219
  const aiQuestionContinuationTabIds = new Set();
3151
3220
  let autoOpenedQuestionIds = new Set();
3152
3221
  const aiApprovalCenterState = { status: "" };
@@ -3318,6 +3387,47 @@ async function fetchAiUserQuestion(questionId) {
3318
3387
  return question;
3319
3388
  }
3320
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
+
3321
3431
  function syncAiQuestionOptionPresentation() {
3322
3432
  const checked = document.querySelector('input[name="ai-question-choice"]:checked');
3323
3433
  for (const label of document.querySelectorAll(".ai-question-option-item")) {
@@ -3338,8 +3448,23 @@ function renderAiUserQuestionOptions(question) {
3338
3448
  const host = $("#ai-question-options");
3339
3449
  const customInput = $("#ai-question-custom-answer");
3340
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 ?? "";
3341
3466
  host.replaceChildren();
3342
- for (const option of question.options ?? []) {
3467
+ for (const option of item?.options ?? []) {
3343
3468
  const label = document.createElement("label");
3344
3469
  label.className = "ai-question-option-item";
3345
3470
  label.dataset.recommended = String(option.recommended === true);
@@ -3347,7 +3472,7 @@ function renderAiUserQuestionOptions(question) {
3347
3472
  input.type = "radio";
3348
3473
  input.name = "ai-question-choice";
3349
3474
  input.value = String(option.index);
3350
- input.checked = question.selectedOption === option.index;
3475
+ input.checked = draft.selectedOption === option.index;
3351
3476
  input.disabled = !isPending;
3352
3477
  const span = document.createElement("span");
3353
3478
  span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
@@ -3360,29 +3485,30 @@ function renderAiUserQuestionOptions(question) {
3360
3485
  customRadio.type = "radio";
3361
3486
  customRadio.name = "ai-question-choice";
3362
3487
  customRadio.value = "custom";
3363
- customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
3488
+ customRadio.checked = draft.selectedOption == null && Boolean(String(draft.customAnswer ?? "").trim());
3364
3489
  customRadio.disabled = !isPending;
3365
3490
  const customText = document.createElement("span");
3366
3491
  customText.textContent = "自定义回答";
3367
3492
  customLabel.append(customRadio, customText);
3368
3493
  host.append(customLabel);
3369
- customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
3494
+ customInput.value = draft.customAnswer ?? "";
3370
3495
  customInput.disabled = !isPending;
3371
3496
  syncAiQuestionAnswerCount();
3372
3497
  syncAiQuestionOptionPresentation();
3373
3498
  // 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
3374
3499
  if (isPending) syncAiQuestionSubmitState();
3375
3500
  else $("#ai-question-submit").disabled = true;
3501
+ $("#ai-question-submit").textContent = items.length > 1 ? "提交全部回答" : "提交回答";
3376
3502
  $("#ai-question-skip").disabled = !isPending;
3377
3503
  $("#ai-question-expiry").textContent = isPending
3378
- ? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3379
- : `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
3504
+ ? `请回答全部 ${items.length} 个问题后一次提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3505
+ : `该提问批次当前状态:${question.statusLabel}${item?.answerText ? ` · 当前回答:${item.answerText}` : ""}`;
3380
3506
  }
3381
3507
 
3382
3508
  async function refreshAiQuestionDialog() {
3383
3509
  const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
3384
3510
  currentAiQuestionDialogView = question;
3385
- $("#ai-question-text").textContent = question.question;
3511
+ resetAiQuestionDialogState(question);
3386
3512
  renderAiUserQuestionOptions(question);
3387
3513
  return question;
3388
3514
  }
@@ -3394,6 +3520,8 @@ async function openAiUserQuestionDialog(questionId) {
3394
3520
  $("#ai-question-text").textContent = "";
3395
3521
  $("#ai-question-options").replaceChildren();
3396
3522
  $("#ai-question-custom-answer").value = "";
3523
+ $("#ai-question-navigation").classList.add("hidden");
3524
+ $("#ai-question-progress").textContent = "正在加载问题";
3397
3525
  syncAiQuestionAnswerCount();
3398
3526
  $("#ai-question-expiry").textContent = "正在加载问题……";
3399
3527
  try {
@@ -3404,51 +3532,19 @@ async function openAiUserQuestionDialog(questionId) {
3404
3532
  }
3405
3533
  }
3406
3534
 
3407
- function beginAiQuestionContinuationUi(conversationId, questionId) {
3535
+ function beginAiQuestionContinuationUi(conversationId) {
3408
3536
  const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
3409
3537
  if (!tab) return null;
3410
- const card = questionId
3411
- ? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
3412
- : null;
3413
- const note = card?.querySelector(".ai-interactive-note") ?? null;
3414
- const status = card?.querySelector(".ai-status-chip") ?? null;
3415
- const previousNote = note?.textContent ?? "";
3416
- const previousStatus = status?.textContent ?? "";
3417
- const controlStates = card
3418
- ? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
3419
- : [];
3420
- if (card) {
3421
- card.classList.add("is-resuming");
3422
- card.setAttribute("aria-busy", "true");
3423
- controlStates.forEach(({ button }) => { button.disabled = true; });
3424
- if (status) status.textContent = "处理中";
3425
- if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
3426
- }
3427
3538
  aiQuestionContinuationTabIds.add(tab.id);
3428
3539
  setAiChatTabStatus(tab, "streaming");
3429
3540
  if (isActiveAiChatTab(tab)) syncAiRequestControls();
3430
3541
  scrollAiFeedToBottom(tab.feed);
3431
- return {
3432
- tab,
3433
- card,
3434
- note,
3435
- status,
3436
- previousNote,
3437
- previousStatus,
3438
- controlStates
3439
- };
3542
+ return { tab };
3440
3543
  }
3441
3544
 
3442
3545
  function finishAiQuestionContinuationUi(continuationUi, failed = false) {
3443
3546
  if (!continuationUi) return;
3444
3547
  aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
3445
- if (continuationUi.card?.isConnected) {
3446
- continuationUi.card.classList.remove("is-resuming");
3447
- continuationUi.card.removeAttribute("aria-busy");
3448
- continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
3449
- if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
3450
- if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
3451
- }
3452
3548
  if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
3453
3549
  if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
3454
3550
  }
@@ -3481,26 +3577,21 @@ async function respondAiUserQuestion(questionId, payload) {
3481
3577
  const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
3482
3578
  if (questionDialog.open) questionDialog.close();
3483
3579
  if (approvalCenterDialog.open) approvalCenterDialog.close();
3484
- continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
3580
+ continuationUi = beginAiQuestionContinuationUi(conversationId);
3485
3581
  let question;
3486
3582
  if (payload.action === "reject") {
3487
3583
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
3488
- } else if (payload.action === "custom") {
3489
- question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
3490
3584
  } else {
3491
3585
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
3492
3586
  method: "POST",
3493
- body: {
3494
- selectedOption: payload.selectedOption,
3495
- ...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
3496
- }
3587
+ body: { answers: payload.answers }
3497
3588
  });
3498
3589
  }
3499
3590
  cacheAiQuestionView(question);
3500
3591
  currentAiQuestionDialogView = question;
3501
3592
  aiQuestionDialogQuestionId = String(question.id);
3502
3593
  await reloadAiQuestionConversation(question.conversationId ?? conversationId);
3503
- toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
3594
+ toast(payload.action === "reject" ? "已跳过该提问批次,AI 已继续处理" : "全部回答已提交,AI 已继续处理");
3504
3595
  return question;
3505
3596
  } catch (error) {
3506
3597
  continuationFailed = true;
@@ -3736,6 +3827,30 @@ async function favoriteAiConversation(conversation) {
3736
3827
  toast(updated.isFavorite ? "对话已收藏" : "已取消收藏");
3737
3828
  }
3738
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
+
3739
3854
  async function deleteAiConversation(conversation) {
3740
3855
  if (conversation.isFavorite === true) {
3741
3856
  toast("收藏的对话不能清理,请先取消收藏", "error");
@@ -5390,8 +5505,11 @@ function showLineCitationMenu(event, lineIndex) {
5390
5505
  selectChapterLines(lineIndex, lineIndex);
5391
5506
  }
5392
5507
  const menu = $("#line-citation-menu");
5393
- $("#add-line-annotation").classList.toggle("hidden", !canWritePermissionModule(state.work, "comments"));
5394
- $("#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));
5395
5513
  const { start, end } = chapterLineSelection;
5396
5514
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
5397
5515
  menu.classList.remove("hidden");
@@ -6768,6 +6886,7 @@ async function loadWorks(preferredId) {
6768
6886
  }
6769
6887
 
6770
6888
  function restoredSettingsReturnContext(route) {
6889
+ if (route.returnView === "im") return { view: "im" };
6771
6890
  if (route.returnView === "module" && route.returnModule) return { view: "module", module: route.returnModule };
6772
6891
  if (route.returnView === "editor" && route.returnChapterId) return { view: "editor", chapterId: route.returnChapterId };
6773
6892
  if (route.returnView === "welcome") return { view: "welcome" };
@@ -6812,6 +6931,10 @@ async function initializePage() {
6812
6931
  showShelf();
6813
6932
  return;
6814
6933
  }
6934
+ if (route.view === "im") {
6935
+ await imWorkspace.open();
6936
+ return;
6937
+ }
6815
6938
 
6816
6939
  const requestedWork = route.workId ? state.works.find((work) => work.id === route.workId) : null;
6817
6940
  if (route.workId && !requestedWork) {
@@ -6893,6 +7016,7 @@ async function initializePage() {
6893
7016
  }
6894
7017
 
6895
7018
  function showShelf() {
7019
+ imWorkspace.close();
6896
7020
  stopBackgroundTaskCenter();
6897
7021
  dismissChapterInsightToast();
6898
7022
  dismissDeleteToasts();
@@ -6917,6 +7041,7 @@ function showShelf() {
6917
7041
  }
6918
7042
 
6919
7043
  function captureSettingsReturnContext() {
7044
+ if (imWorkspace.opened) return { view: "im" };
6920
7045
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
6921
7046
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", chapterId: state.chapter?.id ?? null };
6922
7047
  if (!$("#module-view").classList.contains("hidden")) return { view: "module", module: state.module };
@@ -6944,7 +7069,9 @@ function renderSettingsHub() {
6944
7069
  $("#top-search-button").disabled = !canReadAggregate;
6945
7070
  $("#export-button").disabled = !canExportManuscript;
6946
7071
  $("#export-button").setAttribute("aria-expanded", "false");
6947
- $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
7072
+ $("#settings-return").textContent = settingsReturnContext?.view === "im"
7073
+ ? "返回 IM"
7074
+ : settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
6948
7075
  $("#settings-work-note").textContent = hasWork
6949
7076
  ? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP 或 DOCX;DOCX 在有封面时会嵌入为首页。`
6950
7077
  : "当前未选择作品;打开作品后可使用导出。";
@@ -8226,6 +8353,7 @@ async function showSettingsHub() {
8226
8353
  settingsReturnContext = captureSettingsReturnContext();
8227
8354
  state.dirty = false;
8228
8355
  }
8356
+ imWorkspace.close();
8229
8357
  dismissChapterInsightToast();
8230
8358
  dismissTokenUsageDetails({ restoreFocus: false });
8231
8359
  dismissDeleteToasts();
@@ -8262,6 +8390,7 @@ async function returnFromSettings() {
8262
8390
  $("#platform-ai-view").classList.add("hidden");
8263
8391
  $("#platform-usage-view").classList.add("hidden");
8264
8392
  $("#work-audit-view").classList.add("hidden");
8393
+ if (context.view === "im") return imWorkspace.open();
8265
8394
  if (context.view === "shelf" || !state.work) return showShelf();
8266
8395
  $("#app").classList.remove("shelf-mode");
8267
8396
  $("#shelf-view").classList.add("hidden");
@@ -8411,6 +8540,7 @@ function resetWorkScopedUiCaches() {
8411
8540
  }
8412
8541
 
8413
8542
  async function selectWork(workId, preferredChapterId = null) {
8543
+ imWorkspace.close();
8414
8544
  chapterSelectionRequestGeneration += 1;
8415
8545
  const discarding = state.work?.id !== workId && state.dirty;
8416
8546
  if (discarding && !(await confirmDiscardChanges())) return false;
@@ -8534,7 +8664,7 @@ function renderTree() {
8534
8664
  if (!state.work) return;
8535
8665
  const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
8536
8666
  const proseEditable = canEditProse();
8537
- $("#chapter-count").textContent = `${count} 章`;
8667
+ $("#chapter-count").querySelector(".chapter-count-number").textContent = String(count);
8538
8668
  $("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
8539
8669
  $("#novel-tree").classList.remove("empty-copy");
8540
8670
  $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
@@ -8555,7 +8685,7 @@ function renderTree() {
8555
8685
  return `
8556
8686
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
8557
8687
  <div class="volume-title">
8558
- <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>
8559
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>` : ""}
8560
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>` : ""}
8561
8691
  </div>
@@ -9104,6 +9234,7 @@ async function resolveCurrentChapterForeshadowReminder() {
9104
9234
  }
9105
9235
 
9106
9236
  async function selectChapter(chapterId, { editMode = false } = {}) {
9237
+ imWorkspace.close();
9107
9238
  const workId = state.work?.id;
9108
9239
  if (!workId) return false;
9109
9240
  const selectionGeneration = ++chapterSelectionRequestGeneration;
@@ -9664,6 +9795,7 @@ function tidyChapterBlankLines() {
9664
9795
  }
9665
9796
 
9666
9797
  function showWelcome(hasWork = false) {
9798
+ imWorkspace.close();
9667
9799
  chapterSelectionRequestId += 1;
9668
9800
  clearChapterForeshadowReminders({ invalidateRequest: true });
9669
9801
  dismissChapterInsightToast();
@@ -9697,6 +9829,7 @@ const moduleMeta = {
9697
9829
  };
9698
9830
 
9699
9831
  async function showModule(module) {
9832
+ imWorkspace.close();
9700
9833
  if (!state.work) return showWelcome();
9701
9834
  if (!canReadModule(module)) {
9702
9835
  const fallback = firstReadableUiModule(state.work);
@@ -18509,8 +18642,21 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
18509
18642
  const references = document.createElement("div");
18510
18643
  references.className = "message-citations";
18511
18644
  for (const citation of citations) {
18512
- const reference = document.createElement("span");
18513
- 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
+ });
18514
18660
  references.append(reference);
18515
18661
  }
18516
18662
  message.append(references);
@@ -20729,6 +20875,7 @@ document.addEventListener("pointerdown", (event) => {
20729
20875
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
20730
20876
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
20731
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();
20732
20879
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
20733
20880
  $("#account-menu").classList.add("hidden");
20734
20881
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -20755,6 +20902,10 @@ document.addEventListener("keydown", (event) => {
20755
20902
  setAiModelPickerVisible(false);
20756
20903
  return;
20757
20904
  }
20905
+ if (!$("#ai-citation-popover").classList.contains("hidden")) {
20906
+ closeAiCitationPopover({ restoreFocus: true });
20907
+ return;
20908
+ }
20758
20909
  if (!$("#chapter-search-panel").classList.contains("hidden")) {
20759
20910
  closeChapterSearchPanel();
20760
20911
  return;
@@ -20801,6 +20952,7 @@ $("#ai-context-meter").addEventListener("click", () => {
20801
20952
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
20802
20953
  });
20803
20954
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
20955
+ $("#ai-citation-popover-close").addEventListener("click", () => closeAiCitationPopover({ restoreFocus: true }));
20804
20956
  $("#ai-send").addEventListener("click", activateAiSendControl);
20805
20957
  $("#ai-conversation-switcher").addEventListener("click", () => {
20806
20958
  setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
@@ -20945,6 +21097,19 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
20945
21097
  }
20946
21098
  return;
20947
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
+ }
20948
21113
  if (action === "copy-session-id") {
20949
21114
  option.disabled = true;
20950
21115
  try {
@@ -21042,14 +21207,17 @@ $("#ai-write-plan-undo").addEventListener("click", () => {
21042
21207
  $("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
21043
21208
 
21044
21209
  function syncAiQuestionSubmitState() {
21045
- const checked = document.querySelector('input[name="ai-question-choice"]:checked');
21046
- const customInput = $("#ai-question-custom-answer");
21047
21210
  const submit = $("#ai-question-submit");
21048
- const disabled = !checked
21049
- || checked.disabled
21050
- || (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;
21051
21219
  submit.disabled = disabled;
21052
- submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
21220
+ submit.title = unansweredCount > 0 ? `还有 ${unansweredCount} 个问题未回答` : "";
21053
21221
  }
21054
21222
  $("#ai-question-form").addEventListener("change", (event) => {
21055
21223
  const control = event.target;
@@ -21057,6 +21225,7 @@ $("#ai-question-form").addEventListener("change", (event) => {
21057
21225
  const isCustom = control.value === "custom";
21058
21226
  const customInput = $("#ai-question-custom-answer");
21059
21227
  if (isCustom && !control.disabled) customInput.focus();
21228
+ updateCurrentAiQuestionDraft();
21060
21229
  syncAiQuestionOptionPresentation();
21061
21230
  syncAiQuestionSubmitState();
21062
21231
  });
@@ -21068,23 +21237,30 @@ $("#ai-question-custom-answer").addEventListener("input", () => {
21068
21237
  const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
21069
21238
  if (customRadio && !customRadio.disabled) customRadio.checked = true;
21070
21239
  }
21240
+ updateCurrentAiQuestionDraft();
21071
21241
  syncAiQuestionOptionPresentation();
21072
21242
  syncAiQuestionSubmitState();
21073
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));
21074
21254
  $("#ai-question-submit").addEventListener("click", () => {
21075
- const checked = document.querySelector('input[name="ai-question-choice"]:checked');
21076
- if (!checked || aiQuestionDialogQuestionId == null) return;
21077
- if (checked.value === "custom") {
21078
- const text = String($("#ai-question-custom-answer").value).trim();
21079
- if (!text) return;
21080
- respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
21081
- return;
21082
- }
21083
- const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
21255
+ if (aiQuestionDialogQuestionId == null) return;
21256
+ updateCurrentAiQuestionDraft();
21257
+ if (!aiQuestionDrafts.every(aiQuestionDraftIsAnswered)) return syncAiQuestionSubmitState();
21084
21258
  respondAiUserQuestion(aiQuestionDialogQuestionId, {
21085
- action: "option",
21086
- selectedOption: Number(checked.value),
21087
- ...(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
+ }))
21088
21264
  }).catch((error) => toast(error.message, "error"));
21089
21265
  });
21090
21266
  $("#ai-question-skip").addEventListener("click", () => {
@@ -21233,6 +21409,7 @@ window.addEventListener("online", () => {
21233
21409
  });
21234
21410
  window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
21235
21411
  window.addEventListener("resize", () => {
21412
+ if (aiCitationPopoverTrigger && !$("#ai-citation-popover").classList.contains("hidden")) positionAiCitationPopover(aiCitationPopoverTrigger);
21236
21413
  if ($("#reader-view").classList.contains("hidden") || readingPreferences.mode !== "paged") return;
21237
21414
  if (readingResizeFrame !== null) window.cancelAnimationFrame(readingResizeFrame);
21238
21415
  readingResizeFrame = window.requestAnimationFrame(() => {
@@ -21241,7 +21418,25 @@ window.addEventListener("resize", () => {
21241
21418
  });
21242
21419
  });
21243
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
+
21244
21438
  initializeAiChatTabs();
21439
+ imWorkspace.start();
21245
21440
  initializePage().catch((error) => {
21246
21441
  restoringPageRoute = false;
21247
21442
  if (state.user) {
@@ -21250,4 +21445,6 @@ initializePage().catch((error) => {
21250
21445
  }
21251
21446
  showShelf();
21252
21447
  toast(`系统初始化失败:${error.message}`, "error");
21448
+ }).finally(() => {
21449
+ if (state.user) void imWorkspace.activate().catch((error) => toast(`IM 初始化失败:${error.message}`, "error"));
21253
21450
  });