@musnows/scriverse 0.9.3 → 0.9.5

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,5 +1,5 @@
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
- import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
2
+ import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
5
  import {
@@ -15,6 +15,7 @@ import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.j
15
15
  import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
16
16
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
17
17
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
18
+ import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
18
19
  import {
19
20
  FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
20
21
  foreshadowReminderRequestTargetsState,
@@ -32,14 +33,26 @@ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/
32
33
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
33
34
  import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount } from "/ai-usage.js?v=20260821-ai-usage-pricing-v1";
34
35
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
35
- import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260819-context-percent-v1";
36
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260828-context-output-usage-v1";
36
37
  import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
37
38
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
39
+ import {
40
+ AI_WRITE_TOOLS_META,
41
+ cacheAiQuestionView,
42
+ cacheAiWritePlanDetail,
43
+ createInteractiveToolCard,
44
+ parseInteractiveToolPayload,
45
+ renderApprovalCenterRows,
46
+ renderWritePlanDetailMarkup,
47
+ isInteractiveToolPending,
48
+ aiFormatDateTime
49
+ } from "/ai-interactive.js?v=20260829-question-tool-result-v5";
38
50
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
39
51
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
40
52
  import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
41
53
  import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatImageFile, normalizeAiChatImageAttachments } from "/ai-image-attachments.js?v=20260820-ai-chat-image-attachments-v2";
42
54
  import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
55
+ import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft } from "/chapter-line-id-tracker.js?v=20260828-stable-line-ids-v1";
43
56
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
44
57
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
45
58
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
@@ -274,6 +287,7 @@ let readingReturnRoute = null;
274
287
  let readingPreviousFocus = null;
275
288
  let readingPositionSaveTimer = null;
276
289
  let readingResizeFrame = null;
290
+ const readingVolumeDirectoryConcurrency = 4;
277
291
 
278
292
  let timelineMultiSelectEnabled = false;
279
293
  let timelineActiveTrackId = null;
@@ -444,6 +458,10 @@ function canGlobalReplaceAny(work = state.work) {
444
458
  return Boolean(work) && (canGlobalReplaceScope("prose", work) || canGlobalReplaceScope("settings", work));
445
459
  }
446
460
 
461
+ function applyChapterEditorPreferences() {
462
+ $("#app").classList.toggle("editor-typewriter-mode", Boolean(state.work?.editorTypewriterModeEnabled));
463
+ }
464
+
447
465
  function applyWorkAccessMode() {
448
466
  const viewOnly = Boolean(state.work) && !canEditWork();
449
467
  const proseReadOnly = Boolean(state.work) && !canEditProse();
@@ -456,6 +474,7 @@ function applyWorkAccessMode() {
456
474
  $("#app").classList.toggle("prose-hidden-mode", proseHidden);
457
475
  $("#app").classList.toggle("ai-hidden-mode", aiHidden);
458
476
  document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
477
+ applyChapterEditorPreferences();
459
478
  for (const item of WORK_PERMISSION_MODULES) {
460
479
  if (!item.uiModule) continue;
461
480
  const button = $(`#module-nav [data-module="${item.uiModule}"]`);
@@ -621,7 +640,10 @@ function assertAiRequestCurrent(request) {
621
640
  }
622
641
 
623
642
  function aiInteractionBusy() {
624
- return aiRequestManager.hasActive(activeAiChatTab()?.id) || aiConversationNavigationPending !== null;
643
+ const activeTabId = activeAiChatTab()?.id;
644
+ return aiRequestManager.hasActive(activeTabId)
645
+ || (activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false)
646
+ || aiConversationNavigationPending !== null;
625
647
  }
626
648
 
627
649
  function aiSendButtonIconMarkup(stateName) {
@@ -631,12 +653,14 @@ function aiSendButtonIconMarkup(stateName) {
631
653
  }
632
654
 
633
655
  function syncAiRequestControls() {
634
- const sending = aiRequestManager.hasActive(activeAiChatTab()?.id);
656
+ const activeTabId = activeAiChatTab()?.id;
657
+ const sending = aiRequestManager.hasActive(activeTabId);
658
+ const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
635
659
  const switching = aiConversationNavigationPending !== null;
636
660
  const button = $("#ai-send");
637
- const stateName = sending ? "stop" : switching ? "switching" : "send";
638
- const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
639
- button.disabled = switching;
661
+ const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
662
+ const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
663
+ button.disabled = switching || continuingQuestion;
640
664
  button.dataset.state = stateName;
641
665
  button.classList.toggle("is-stop", sending);
642
666
  button.setAttribute("aria-label", label);
@@ -779,6 +803,8 @@ const aiConversationHistoryPageLimit = 20;
779
803
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
780
804
  let workScopedUiGeneration = 0;
781
805
  let workSelectionRequestGeneration = 0;
806
+ let initialWorkDetail = null;
807
+ let initialReaderChapterRequest = null;
782
808
  let chapterSelectionRequestGeneration = 0;
783
809
  let aiConversationNavigationGeneration = 0;
784
810
  let aiConversationNavigationPending = null;
@@ -1461,6 +1487,7 @@ let chapterLineNumberFrame = null;
1461
1487
  let chapterLineNumberTimer = null;
1462
1488
  let chapterLineLayout = null;
1463
1489
  let chapterLineVirtualWindow = null;
1490
+ let chapterCaretScrollFrame = null;
1464
1491
  let chapterLineSelection = null;
1465
1492
  let chapterLineDrag = null;
1466
1493
  let chapterWhitespaceVisible = true;
@@ -1468,6 +1495,8 @@ let chapterAutoSaveTimer = null;
1468
1495
  let chapterSaveInFlight = null;
1469
1496
  let chapterSaveGuardInFlight = null;
1470
1497
  let lastSavedChapterSnapshot = null;
1498
+ let chapterDraftLineIdState = null;
1499
+ let chapterBeforeInputState = null;
1471
1500
  let chapterSearchMatchIndex = -1;
1472
1501
  let chapterSelectionRequestId = 0;
1473
1502
  let chapterForeshadowReminderRequestId = 0;
@@ -1553,6 +1582,7 @@ let characterSectionEditorDirty = false;
1553
1582
  let settingEditorVditor = null;
1554
1583
  let knowledgeSectionVditor = null;
1555
1584
  let characterSectionVditor = null;
1585
+ let vditorResourcesPromise = null;
1556
1586
  const settingEditorDirtyTracker = createEditorDirtyTracker();
1557
1587
  const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
1558
1588
  let formDialogVditors = [];
@@ -1562,17 +1592,22 @@ let moduleContentInteractionsBound = false;
1562
1592
  function applyChapterEditorMode() {
1563
1593
  const permissionBlocked = Boolean(state.work) && !canEditProse();
1564
1594
  const viewOnly = permissionBlocked || chapterEditorReadOnly;
1595
+ const editButton = $("#chapter-edit-button");
1565
1596
  $("#editor-view").classList.toggle("is-read-only", viewOnly);
1566
1597
  $("#chapter-title").readOnly = viewOnly;
1567
1598
  $("#chapter-content").readOnly = viewOnly;
1568
1599
  $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
1569
1600
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
1570
- $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
1571
- $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1601
+ editButton.classList.toggle("hidden", permissionBlocked || !state.chapter);
1602
+ editButton.classList.toggle("primary-button", chapterEditorReadOnly);
1603
+ editButton.classList.toggle("ghost-button", !chapterEditorReadOnly);
1604
+ editButton.textContent = chapterEditorReadOnly ? "编辑" : "预览";
1605
+ editButton.setAttribute("aria-pressed", String(!chapterEditorReadOnly));
1606
+ editButton.setAttribute("aria-label", chapterEditorReadOnly ? "切换到编辑模式" : "切换到预览模式");
1572
1607
  $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
1573
- $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1574
1608
  syncChapterSearchControls();
1575
1609
  if (viewOnly) cancelChapterAutoSave();
1610
+ syncMobileAiPanelSafeTop();
1576
1611
  }
1577
1612
 
1578
1613
  function enterChapterEditMode() {
@@ -1585,6 +1620,19 @@ function enterChapterEditMode() {
1585
1620
  $("#chapter-content").focus();
1586
1621
  }
1587
1622
 
1623
+ function toggleChapterEditPreviewMode() {
1624
+ if (!state.chapter || !canEditProse()) return;
1625
+ if (chapterEditorReadOnly) {
1626
+ enterChapterEditMode();
1627
+ return;
1628
+ }
1629
+ chapterEditorReadOnly = true;
1630
+ cancelChapterAutoSave();
1631
+ applyChapterEditorMode();
1632
+ setSaveState(state.dirty ? "预览中 · 有未保存修改" : "预览中", state.dirty);
1633
+ $("#chapter-edit-button").focus();
1634
+ }
1635
+
1588
1636
  function showEntityEditorPage(type, { readOnly = false } = {}) {
1589
1637
  const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
1590
1638
  const viewOnly = readOnly || !canEditModule(module);
@@ -1937,17 +1985,35 @@ function scheduleChapterLineNumbers(delay = 0) {
1937
1985
  }, wait);
1938
1986
  }
1939
1987
 
1940
- function collapseChapterInputBlankLines(input) {
1941
- const value = input.value;
1942
- const normalized = collapseExcessBlankLines(value);
1943
- if (normalized === value) return false;
1944
- const selectionStart = input.selectionStart ?? value.length;
1945
- const selectionEnd = input.selectionEnd ?? selectionStart;
1946
- const nextStart = collapseExcessBlankLines(value.slice(0, selectionStart)).length;
1947
- const nextEnd = collapseExcessBlankLines(value.slice(0, selectionEnd)).length;
1948
- input.value = normalized;
1949
- input.setSelectionRange(nextStart, nextEnd);
1950
- return true;
1988
+ function scheduleChapterCaretScroll() {
1989
+ if (!state.work?.editorTypewriterModeEnabled || chapterCaretScrollFrame !== null) return;
1990
+ chapterCaretScrollFrame = requestAnimationFrame(() => {
1991
+ chapterCaretScrollFrame = null;
1992
+ const input = $("#chapter-content");
1993
+ const measure = $("#chapter-line-measure");
1994
+ if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
1995
+ const style = getComputedStyle(input);
1996
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1997
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1998
+ const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
1999
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
2000
+ const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
2001
+ const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
2002
+ const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
2003
+ const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
2004
+ const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
2005
+ const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
2006
+ const nextScrollTop = calculateChapterCaretScroll({
2007
+ caretBottom,
2008
+ scrollTop: input.scrollTop,
2009
+ clientHeight: input.clientHeight,
2010
+ scrollHeight: input.scrollHeight
2011
+ });
2012
+ if (nextScrollTop === input.scrollTop) return;
2013
+ input.scrollTop = nextScrollTop;
2014
+ syncChapterLineNumberScroll();
2015
+ scheduleChapterLineNumbers();
2016
+ });
1951
2017
  }
1952
2018
 
1953
2019
  function lineIndexAtPointer(clientY) {
@@ -2709,6 +2775,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2709
2775
  recall_other: "回忆相识角色",
2710
2776
  recall_known: "回忆知情设定",
2711
2777
  recall_story: "回忆故事",
2778
+ recall_roleplay_memory: "回忆当前扮演线",
2779
+ remember_roleplay: "整理扮演记忆",
2712
2780
  calculate_time: "计算日期"
2713
2781
  };
2714
2782
 
@@ -2725,6 +2793,8 @@ const AI_TOOL_DESCRIPTIONS = {
2725
2793
  recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
2726
2794
  recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
2727
2795
  recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2796
+ recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
2797
+ remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
2728
2798
  calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
2729
2799
  };
2730
2800
 
@@ -2888,6 +2958,10 @@ function openAiToolCallDetail(toolCall) {
2888
2958
 
2889
2959
  function createAiToolCallButton(toolCall) {
2890
2960
  const name = String(toolCall?.name ?? "unknown");
2961
+ if (name === "propose_write_plan" || name === "ask_user_question") {
2962
+ const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
2963
+ if (card) return card;
2964
+ }
2891
2965
  const button = document.createElement("button");
2892
2966
  button.type = "button";
2893
2967
  button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
@@ -2898,6 +2972,392 @@ function createAiToolCallButton(toolCall) {
2898
2972
  return button;
2899
2973
  }
2900
2974
 
2975
+ // ---------------------------------------------------------------------------
2976
+ // AI 可写工具:审批卡片动作、修改计划详情、撤销与用户提问
2977
+ // ---------------------------------------------------------------------------
2978
+
2979
+ let aiWritePlanDialogPlanId = null;
2980
+ let aiWritePlanDialogBusy = false;
2981
+ let currentPlanDialogDetail = null;
2982
+ let currentPlanDialogFocusConfirm = false;
2983
+ let aiQuestionDialogQuestionId = null;
2984
+ let currentAiQuestionDialogView = null;
2985
+ let aiQuestionDialogBusy = false;
2986
+ const aiQuestionContinuationTabIds = new Set();
2987
+ let autoOpenedQuestionIds = new Set();
2988
+ const aiApprovalCenterState = { status: "" };
2989
+
2990
+ /** 消息流中交互工具卡片绑定的动作:全部先取服务端最新状态,再弹窗确认。 */
2991
+ const AI_TOOL_CARD_ACTIONS = {
2992
+ openPlanDetail(planId) {
2993
+ openAiWritePlanDetail(planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
2994
+ },
2995
+ confirmPlan(planId) {
2996
+ openAiWritePlanDetail(planId, { focusConfirm: true }).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
2997
+ },
2998
+ rejectPlan(planId) {
2999
+ decideAiWritePlan(planId, "reject").catch((error) => toast(`拒绝失败:${error.message}`, "error"));
3000
+ },
3001
+ openQuestionDialog(questionId) {
3002
+ openAiUserQuestionDialog(questionId).catch((error) => toast(`提问加载失败:${error.message}`, "error"));
3003
+ },
3004
+ rejectQuestion(questionId) {
3005
+ respondAiUserQuestion(questionId, { action: "reject" }).catch((error) => toast(`操作失败:${error.message}`, "error"));
3006
+ },
3007
+ openApprovalCenter() {
3008
+ openAiApprovalCenter();
3009
+ }
3010
+ };
3011
+
3012
+ /** 流式收到交互式可写工具调用时的提示与自动弹出提问。 */
3013
+ function handleInteractiveToolCallEvent(toolCall) {
3014
+ const name = String(toolCall?.name ?? "");
3015
+ if (name === "propose_write_plan") {
3016
+ if (toolCall.status === "failed") {
3017
+ const message = String(parseInteractiveToolPayload(toolCall)?.error?.message ?? "未知错误");
3018
+ toast(`AI 的写入审批提交失败:${message}`, "error");
3019
+ return;
3020
+ }
3021
+ const payload = parseInteractiveToolPayload(toolCall);
3022
+ const targets = Array.isArray(payload?.plan?.targets) ? payload.plan.targets.join("、") : "待确认操作";
3023
+ const summary = String(payload?.plan?.aiSummary ?? "");
3024
+ toast(`AI 提交了写入审批:${targets}${summary ? ` · ${summary}` : ""}`);
3025
+ return;
3026
+ }
3027
+ if (name !== "ask_user_question" || toolCall.status === "failed") return;
3028
+ const question = toolCall.result?.question;
3029
+ if (!question?.id) return;
3030
+ cacheAiQuestionView(question);
3031
+ const questionId = String(question.id);
3032
+ if (autoOpenedQuestionIds.has(questionId)) return;
3033
+ autoOpenedQuestionIds.add(questionId);
3034
+ // 直接弹出回答框等待作者选择;不会预填任何答案。
3035
+ openAiUserQuestionDialog(questionId).catch(() => undefined);
3036
+ }
3037
+
3038
+ function questionsEndpoint(path) {
3039
+ return `/api/works/${state.work.id}/ai/questions${path}`;
3040
+ }
3041
+
3042
+ function plansEndpoint(path) {
3043
+ return `/api/works/${state.work.id}/ai/write-plans${path}`;
3044
+ }
3045
+
3046
+ async function fetchAiWritePlanDetail(planId) {
3047
+ const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}`));
3048
+ cacheAiWritePlanDetail(detail);
3049
+ return detail;
3050
+ }
3051
+
3052
+ async function decideAiWritePlan(planId, action) {
3053
+ const buttonLabel = action === "confirm" ? "确认执行" : "拒绝";
3054
+ const targetButton = $(action === "confirm" ? "#ai-write-plan-confirm" : "#ai-write-plan-reject");
3055
+ if (targetButton?.disabled || aiWritePlanDialogBusy) return;
3056
+ if (targetButton) targetButton.disabled = true;
3057
+ aiWritePlanDialogBusy = true;
3058
+ try {
3059
+ const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/${action}`), { method: "POST" });
3060
+ cacheAiWritePlanDetail(detail);
3061
+ toast(action === "confirm"
3062
+ ? `已执行 AI 写入审批:${detail.aiSummary}`
3063
+ : `已拒绝该写入审批,未产生任何写入`);
3064
+ if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(detail.id)) {
3065
+ applyPlanDetailToDialog(detail);
3066
+ }
3067
+ if ($("#ai-approval-center-dialog").open) loadAiApprovalCenterPlans().catch(() => undefined);
3068
+ return detail;
3069
+ } catch (error) {
3070
+ // 已被处理(409)等冲突也需要刷新展示的明细状态。
3071
+ try {
3072
+ const fresh = await fetchAiWritePlanDetail(planId);
3073
+ if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(planId)) applyPlanDetailToDialog(fresh);
3074
+ else if (targetButton) targetButton.disabled = false;
3075
+ } catch {
3076
+ if (targetButton) targetButton.disabled = false;
3077
+ }
3078
+ throw new Error(`${buttonLabel}失败:${error.message}`);
3079
+ } finally {
3080
+ aiWritePlanDialogBusy = false;
3081
+ }
3082
+ }
3083
+
3084
+ async function undoAiWritePlan(planId) {
3085
+ if (aiWritePlanDialogBusy) return;
3086
+ const button = $("#ai-write-plan-undo");
3087
+ button.disabled = true;
3088
+ try {
3089
+ const undoPlan = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/undo`), { method: "POST" });
3090
+ cacheAiWritePlanDetail(undoPlan);
3091
+ toast("已创建撤销审批,请在新弹窗中单独确认执行");
3092
+ await openAiWritePlanDetail(undoPlan.id);
3093
+ } catch (error) {
3094
+ toast(`创建撤销审批失败:${error.message}`, "error");
3095
+ button.disabled = false;
3096
+ }
3097
+ }
3098
+
3099
+ function applyPlanDetailToDialog(detail) {
3100
+ currentPlanDialogDetail = detail;
3101
+ aiWritePlanDialogPlanId = String(detail.id);
3102
+ $("#ai-write-plan-body").innerHTML = renderWritePlanDetailMarkup(detail);
3103
+ const isPending = detail.status === "pending";
3104
+ const canUndo = Boolean(detail.undoAvailable) && detail.status === "executed";
3105
+ $("#ai-write-plan-confirm").classList.toggle("hidden", !isPending);
3106
+ $("#ai-write-plan-reject").classList.toggle("hidden", !isPending);
3107
+ $("#ai-write-plan-undo").classList.toggle("hidden", !canUndo);
3108
+ $("#ai-write-plan-confirm").disabled = false;
3109
+ $("#ai-write-plan-reject").disabled = false;
3110
+ $("#ai-write-plan-undo").disabled = false;
3111
+ if (isPending && currentPlanDialogFocusConfirm) {
3112
+ $("#ai-write-plan-dialog").querySelector(".card-actions")?.scrollIntoView({ block: "end" });
3113
+ }
3114
+ }
3115
+
3116
+ async function openAiWritePlanDetail(planId, options = {}) {
3117
+ currentPlanDialogFocusConfirm = options.focusConfirm === true;
3118
+ const dialog = $("#ai-write-plan-dialog");
3119
+ if (!dialog.open) dialog.showModal();
3120
+ $("#ai-write-plan-body").innerHTML = '<p class="usage-measurement-note">正在加载系统生成的完整修改明细……</p>';
3121
+ try {
3122
+ const detail = await fetchAiWritePlanDetail(planId);
3123
+ applyPlanDetailToDialog(detail);
3124
+ } catch (error) {
3125
+ $("#ai-write-plan-body").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
3126
+ throw error;
3127
+ }
3128
+ }
3129
+
3130
+ async function loadAiApprovalCenterPlans() {
3131
+ const statusQuery = aiApprovalCenterState.status ? `?status=${encodeURIComponent(aiApprovalCenterState.status)}&limit=50` : "?limit=50";
3132
+ const [planPayload, questionPayload] = await Promise.all([
3133
+ api(plansEndpoint(statusQuery)),
3134
+ api(questionsEndpoint(statusQuery))
3135
+ ]);
3136
+ const plans = Array.isArray(planPayload) ? planPayload : (Array.isArray(planPayload?.plans) ? planPayload.plans : []);
3137
+ const questions = Array.isArray(questionPayload) ? questionPayload : (Array.isArray(questionPayload?.questions) ? questionPayload.questions : []);
3138
+ $("#ai-approval-list-host").innerHTML = renderApprovalCenterRows(plans, questions);
3139
+ }
3140
+
3141
+ function openAiApprovalCenter() {
3142
+ const dialog = $("#ai-approval-center-dialog");
3143
+ if (!dialog.open) dialog.showModal();
3144
+ $("#ai-approval-center-toggle").setAttribute("aria-expanded", "true");
3145
+ $("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
3146
+ loadAiApprovalCenterPlans().catch((error) => {
3147
+ $("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
3148
+ });
3149
+ }
3150
+
3151
+ async function fetchAiUserQuestion(questionId) {
3152
+ const question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}`));
3153
+ cacheAiQuestionView(question);
3154
+ return question;
3155
+ }
3156
+
3157
+ function syncAiQuestionOptionPresentation() {
3158
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
3159
+ for (const label of document.querySelectorAll(".ai-question-option-item")) {
3160
+ const input = label.querySelector('input[name="ai-question-choice"]');
3161
+ const isSelected = Boolean(input && checked === input);
3162
+ label.classList.toggle("is-selected", isSelected);
3163
+ label.classList.toggle("is-recommended", label.dataset.recommended === "true" && (!checked || isSelected));
3164
+ }
3165
+ }
3166
+
3167
+ function syncAiQuestionAnswerCount() {
3168
+ const input = $("#ai-question-custom-answer");
3169
+ const maximum = Number(input.maxLength) || 3000;
3170
+ $("#ai-question-answer-count").textContent = `已填写 ${input.value.length} / ${maximum}`;
3171
+ }
3172
+
3173
+ function renderAiUserQuestionOptions(question) {
3174
+ const host = $("#ai-question-options");
3175
+ const customInput = $("#ai-question-custom-answer");
3176
+ const isPending = question.status === "pending";
3177
+ host.replaceChildren();
3178
+ for (const option of question.options ?? []) {
3179
+ const label = document.createElement("label");
3180
+ label.className = "ai-question-option-item";
3181
+ label.dataset.recommended = String(option.recommended === true);
3182
+ const input = document.createElement("input");
3183
+ input.type = "radio";
3184
+ input.name = "ai-question-choice";
3185
+ input.value = String(option.index);
3186
+ input.checked = question.selectedOption === option.index;
3187
+ input.disabled = !isPending;
3188
+ const span = document.createElement("span");
3189
+ span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
3190
+ label.append(input, span);
3191
+ host.append(label);
3192
+ }
3193
+ const customLabel = document.createElement("label");
3194
+ customLabel.className = "ai-question-option-item ai-question-custom-choice";
3195
+ const customRadio = document.createElement("input");
3196
+ customRadio.type = "radio";
3197
+ customRadio.name = "ai-question-choice";
3198
+ customRadio.value = "custom";
3199
+ customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
3200
+ customRadio.disabled = !isPending;
3201
+ const customText = document.createElement("span");
3202
+ customText.textContent = "自定义回答";
3203
+ customLabel.append(customRadio, customText);
3204
+ host.append(customLabel);
3205
+ customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
3206
+ customInput.disabled = !isPending;
3207
+ syncAiQuestionAnswerCount();
3208
+ syncAiQuestionOptionPresentation();
3209
+ // 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
3210
+ if (isPending) syncAiQuestionSubmitState();
3211
+ else $("#ai-question-submit").disabled = true;
3212
+ $("#ai-question-skip").disabled = !isPending;
3213
+ $("#ai-question-expiry").textContent = isPending
3214
+ ? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3215
+ : `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
3216
+ }
3217
+
3218
+ async function refreshAiQuestionDialog() {
3219
+ const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
3220
+ currentAiQuestionDialogView = question;
3221
+ $("#ai-question-text").textContent = question.question;
3222
+ renderAiUserQuestionOptions(question);
3223
+ return question;
3224
+ }
3225
+
3226
+ async function openAiUserQuestionDialog(questionId) {
3227
+ aiQuestionDialogQuestionId = String(questionId);
3228
+ const dialog = $("#ai-question-dialog");
3229
+ if (!dialog.open) dialog.showModal();
3230
+ $("#ai-question-text").textContent = "";
3231
+ $("#ai-question-options").replaceChildren();
3232
+ $("#ai-question-custom-answer").value = "";
3233
+ syncAiQuestionAnswerCount();
3234
+ $("#ai-question-expiry").textContent = "正在加载问题……";
3235
+ try {
3236
+ return await refreshAiQuestionDialog();
3237
+ } catch (error) {
3238
+ $("#ai-question-expiry").textContent = `加载失败:${error.message}`;
3239
+ throw error;
3240
+ }
3241
+ }
3242
+
3243
+ function beginAiQuestionContinuationUi(conversationId, questionId) {
3244
+ const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
3245
+ if (!tab) return null;
3246
+ const card = questionId
3247
+ ? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
3248
+ : null;
3249
+ const note = card?.querySelector(".ai-interactive-note") ?? null;
3250
+ const status = card?.querySelector(".ai-status-chip") ?? null;
3251
+ const previousNote = note?.textContent ?? "";
3252
+ const previousStatus = status?.textContent ?? "";
3253
+ const controlStates = card
3254
+ ? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
3255
+ : [];
3256
+ if (card) {
3257
+ card.classList.add("is-resuming");
3258
+ card.setAttribute("aria-busy", "true");
3259
+ controlStates.forEach(({ button }) => { button.disabled = true; });
3260
+ if (status) status.textContent = "处理中";
3261
+ if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
3262
+ }
3263
+ aiQuestionContinuationTabIds.add(tab.id);
3264
+ setAiChatTabStatus(tab, "streaming");
3265
+ if (isActiveAiChatTab(tab)) syncAiRequestControls();
3266
+ scrollAiFeedToBottom(tab.feed);
3267
+ return {
3268
+ tab,
3269
+ card,
3270
+ note,
3271
+ status,
3272
+ previousNote,
3273
+ previousStatus,
3274
+ controlStates
3275
+ };
3276
+ }
3277
+
3278
+ function finishAiQuestionContinuationUi(continuationUi, failed = false) {
3279
+ if (!continuationUi) return;
3280
+ aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
3281
+ if (continuationUi.card?.isConnected) {
3282
+ continuationUi.card.classList.remove("is-resuming");
3283
+ continuationUi.card.removeAttribute("aria-busy");
3284
+ continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
3285
+ if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
3286
+ if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
3287
+ }
3288
+ if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
3289
+ if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
3290
+ }
3291
+
3292
+ async function reloadAiQuestionConversation(conversationId) {
3293
+ if (!conversationId) return null;
3294
+ const tab = aiChatTabManager.findByConversation(conversationId);
3295
+ if (!tab) return openAiConversation(conversationId);
3296
+ const conversation = await api(`/api/ai-conversations/${encodeURIComponent(conversationId)}?page=1&limit=100`);
3297
+ if (String(conversation.workId ?? "") !== String(state.work?.id ?? "")) throw new Error("AI 对话不属于当前作品");
3298
+ upsertAiConversationSummary(conversation);
3299
+ applyConversationToAiChatTab(tab, conversation);
3300
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3301
+ return conversation;
3302
+ }
3303
+
3304
+ async function respondAiUserQuestion(questionId, payload) {
3305
+ if (aiQuestionDialogBusy) return;
3306
+ aiQuestionDialogBusy = true;
3307
+ $("#ai-question-submit").disabled = true;
3308
+ $("#ai-question-skip").disabled = true;
3309
+ const questionDialog = $("#ai-question-dialog");
3310
+ const approvalCenterDialog = $("#ai-approval-center-dialog");
3311
+ let continuationUi = null;
3312
+ let continuationFailed = false;
3313
+ try {
3314
+ const knownQuestion = currentAiQuestionDialogView?.id === String(questionId)
3315
+ ? currentAiQuestionDialogView
3316
+ : await fetchAiUserQuestion(questionId);
3317
+ const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
3318
+ if (questionDialog.open) questionDialog.close();
3319
+ if (approvalCenterDialog.open) approvalCenterDialog.close();
3320
+ continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
3321
+ let question;
3322
+ if (payload.action === "reject") {
3323
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
3324
+ } else if (payload.action === "custom") {
3325
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
3326
+ } else {
3327
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
3328
+ method: "POST",
3329
+ body: {
3330
+ selectedOption: payload.selectedOption,
3331
+ ...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
3332
+ }
3333
+ });
3334
+ }
3335
+ cacheAiQuestionView(question);
3336
+ currentAiQuestionDialogView = question;
3337
+ aiQuestionDialogQuestionId = String(question.id);
3338
+ await reloadAiQuestionConversation(question.conversationId ?? conversationId);
3339
+ toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
3340
+ return question;
3341
+ } catch (error) {
3342
+ continuationFailed = true;
3343
+ const latestQuestion = await fetchAiUserQuestion(questionId).catch(() => null);
3344
+ if (latestQuestion) {
3345
+ currentAiQuestionDialogView = latestQuestion;
3346
+ cacheAiQuestionView(latestQuestion);
3347
+ if (latestQuestion.status === "pending") {
3348
+ $("#ai-question-text").textContent = latestQuestion.question;
3349
+ renderAiUserQuestionOptions(latestQuestion);
3350
+ if (!questionDialog.open) questionDialog.showModal();
3351
+ }
3352
+ }
3353
+ throw error;
3354
+ } finally {
3355
+ finishAiQuestionContinuationUi(continuationUi, continuationFailed);
3356
+ aiQuestionDialogBusy = false;
3357
+ }
3358
+ }
3359
+
3360
+ /** Convert a persisted tool call back into a process step for history rendering. */
2901
3361
  function aiToolProcessStep(toolCall, round = 1) {
2902
3362
  const normalizedToolCall = { ...toolCall };
2903
3363
  delete normalizedToolCall.round;
@@ -2958,7 +3418,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2958
3418
  if (!renderableSteps.length) return;
2959
3419
  const details = document.createElement("details");
2960
3420
  details.className = "ai-process-details";
2961
- details.open = !completed;
3421
+ // 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
3422
+ details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
2962
3423
  const summary = document.createElement("summary");
2963
3424
  const title = document.createElement("span");
2964
3425
  title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
@@ -3222,6 +3683,266 @@ function renderAiConversationHistory() {
3222
3683
  }
3223
3684
  }
3224
3685
 
3686
+ const roleplayMemoryCategoryLabels = Object.freeze({
3687
+ event: "事件",
3688
+ state: "状态",
3689
+ relationship: "关系",
3690
+ commitment: "承诺",
3691
+ knowledge: "知识",
3692
+ scene: "场景"
3693
+ });
3694
+ const roleplayMemoryImportanceLabels = Object.freeze({ low: "低重要度", medium: "中重要度", high: "高重要度" });
3695
+ const roleplayMemoryCertaintyLabels = Object.freeze({ experienced: "亲历", observed: "观察", heard: "听说", believed: "相信" });
3696
+ const roleplayMemoryStatusLabels = Object.freeze({ active: "生效中", superseded: "已取代", archived: "已删除" });
3697
+ let roleplayMemoryItems = [];
3698
+ let roleplayMemoryCharacter = null;
3699
+ let roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
3700
+ let roleplayMemoryCursorHistory = [0];
3701
+ let roleplayMemorySearchTimer = null;
3702
+ let roleplayMemoryLoaded = false;
3703
+ let roleplayMemoryLoading = false;
3704
+
3705
+ function roleplayMemoryReadOnly() {
3706
+ return Boolean(state.work) && !canEditModule("characters");
3707
+ }
3708
+
3709
+ function roleplayMemoryQuery() {
3710
+ const parameters = new URLSearchParams({
3711
+ cursor: String(roleplayMemoryCursorHistory.at(-1) ?? 0),
3712
+ limit: "20"
3713
+ });
3714
+ const query = $("#roleplay-memory-search").value.trim();
3715
+ const category = $("#roleplay-memory-category").value;
3716
+ const status = $("#roleplay-memory-status").value;
3717
+ if (query) parameters.set("q", query);
3718
+ if (category) parameters.set("categories", category);
3719
+ if (status === "all") parameters.set("statuses", "active,superseded,archived");
3720
+ else parameters.set("statuses", status || "active");
3721
+ return parameters;
3722
+ }
3723
+
3724
+ function roleplayMemorySourceHtml(source) {
3725
+ const canOpen = source.canOpen === true && source.conversationId && source.messageId;
3726
+ const sourceLabel = source.role === "assistant" ? "角色回复" : "用户消息";
3727
+ const detail = source.restricted
3728
+ ? "来自其他用户的角色扮演对话,无权查看原文"
3729
+ : source.evidence || "来源消息已删除,保留来源时间";
3730
+ return `<article class="roleplay-memory-source"><p><strong>${sourceLabel}</strong> · ${esc(formatDateTime(source.sourceAt))} · ${esc(detail)}</p>${canOpen ? `<button type="button" data-roleplay-memory-source-id="${esc(source.id)}" data-roleplay-memory-source-conversation="${esc(source.conversationId)}" data-roleplay-memory-source-message="${esc(source.messageId)}">查看来源</button>` : ""}</article>`;
3731
+ }
3732
+
3733
+ function renderRoleplayMemoryList() {
3734
+ const host = $("#roleplay-memory-list");
3735
+ if (!host) return;
3736
+ $("#roleplay-memory-add").disabled = roleplayMemoryReadOnly();
3737
+ if (roleplayMemoryLoading) {
3738
+ host.innerHTML = '<p class="roleplay-memory-empty">正在读取角色扮演记忆……</p>';
3739
+ return;
3740
+ }
3741
+ if (!roleplayMemoryLoaded) {
3742
+ host.innerHTML = '<p class="roleplay-memory-empty">打开角色扮演记忆分区后读取该角色的共享记忆库。</p>';
3743
+ return;
3744
+ }
3745
+ if (!roleplayMemoryItems.length) {
3746
+ host.innerHTML = '<p class="roleplay-memory-empty">当前筛选下没有记忆。可以手工添加,AI 也会在成功回复后整理值得保留的扮演经历。</p>';
3747
+ } else {
3748
+ host.innerHTML = roleplayMemoryItems.map((memory) => {
3749
+ const sources = Array.isArray(memory.sources) ? memory.sources : [];
3750
+ const editable = !roleplayMemoryReadOnly();
3751
+ const pinAction = memory.isPinned ? "取消置顶" : "置顶";
3752
+ const actions = editable
3753
+ ? memory.status === "archived"
3754
+ ? `<button type="button" data-roleplay-memory-action="restore" data-memory-id="${esc(memory.id)}">恢复</button>`
3755
+ : `<button class="danger-button roleplay-memory-icon-action" type="button" data-roleplay-memory-action="archive" data-memory-id="${esc(memory.id)}" aria-label="删除角色扮演记忆" title="删除">${trashIconMarkup()}</button><button class="roleplay-memory-icon-action${memory.isPinned ? " is-pinned" : ""}" type="button" data-roleplay-memory-action="pin" data-memory-id="${esc(memory.id)}" aria-label="${pinAction}角色扮演记忆" aria-pressed="${memory.isPinned === true}" title="${pinAction}">${roleplayMemoryPinIconMarkup()}</button><button class="roleplay-memory-icon-action" type="button" data-roleplay-memory-action="edit" data-memory-id="${esc(memory.id)}" aria-label="编辑角色扮演记忆" title="编辑">${pencilIconMarkup()}</button>`
3756
+ : "";
3757
+ return `<article class="roleplay-memory-card${memory.status === "archived" ? " is-archived" : ""}" data-roleplay-memory-id="${esc(memory.id)}">
3758
+ <header class="roleplay-memory-card-header"><div class="roleplay-memory-card-badges"><span class="roleplay-memory-badge">${esc(roleplayMemoryCategoryLabels[memory.category] ?? memory.category)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryImportanceLabels[memory.importance] ?? memory.importance)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryCertaintyLabels[memory.certainty] ?? memory.certainty)}</span><span class="roleplay-memory-badge">${esc(roleplayMemoryStatusLabels[memory.status] ?? memory.status)}</span><span class="roleplay-memory-badge is-noncanonical">非正史</span>${memory.isPinned ? '<span class="roleplay-memory-badge is-pinned">已置顶</span>' : ""}</div><time datetime="${esc(memory.updatedAt)}">${esc(formatDateTime(memory.updatedAt))}</time></header>
3759
+ <p class="roleplay-memory-content">${esc(memory.content)}</p>
3760
+ <p class="roleplay-memory-card-meta">${memory.sourceType === "ai" ? "AI 整理" : "手工添加"} · 版本 ${Number(memory.versionNo ?? 1)} · 角色共享</p>
3761
+ ${sources.length ? `<details class="roleplay-memory-sources"><summary>来源信息 · ${sources.length} 条</summary><div class="roleplay-memory-source-list">${sources.map(roleplayMemorySourceHtml).join("")}</div></details>` : ""}
3762
+ ${actions ? `<div class="roleplay-memory-card-actions">${actions}</div>` : ""}
3763
+ </article>`;
3764
+ }).join("");
3765
+ }
3766
+ const page = roleplayMemoryCursorHistory.length;
3767
+ const hasPrevious = roleplayMemoryCursorHistory.length > 1;
3768
+ const hasNext = roleplayMemoryPagination.nextCursor !== null;
3769
+ $("#roleplay-memory-pagination").classList.toggle("hidden", !hasPrevious && !hasNext);
3770
+ $("#roleplay-memory-previous").disabled = !hasPrevious;
3771
+ $("#roleplay-memory-next").disabled = !hasNext;
3772
+ $("#roleplay-memory-page-label").textContent = `第 ${page} 页 · 共 ${Number(roleplayMemoryPagination.total ?? 0)} 条`;
3773
+ }
3774
+
3775
+ async function loadRoleplayMemories({ resetCursor = false } = {}) {
3776
+ if (!roleplayMemoryCharacter?.id || roleplayMemoryLoading) return;
3777
+ if (resetCursor) roleplayMemoryCursorHistory = [0];
3778
+ const characterId = roleplayMemoryCharacter.id;
3779
+ roleplayMemoryLoading = true;
3780
+ renderRoleplayMemoryList();
3781
+ try {
3782
+ const result = await api(`/api/characters/${encodeURIComponent(characterId)}/roleplay-memories?${roleplayMemoryQuery()}`);
3783
+ if (roleplayMemoryCharacter?.id !== characterId) return;
3784
+ roleplayMemoryItems = Array.isArray(result.items) ? result.items : [];
3785
+ roleplayMemoryPagination = result.pagination ?? { cursor: 0, limit: 20, total: roleplayMemoryItems.length, nextCursor: null };
3786
+ if (result.character) roleplayMemoryCharacter = { ...roleplayMemoryCharacter, ...result.character };
3787
+ roleplayMemoryLoaded = true;
3788
+ } catch (error) {
3789
+ if (roleplayMemoryCharacter?.id === characterId) {
3790
+ roleplayMemoryItems = [];
3791
+ roleplayMemoryLoaded = false;
3792
+ }
3793
+ throw error;
3794
+ } finally {
3795
+ if (roleplayMemoryCharacter?.id === characterId) {
3796
+ roleplayMemoryLoading = false;
3797
+ renderRoleplayMemoryList();
3798
+ }
3799
+ }
3800
+ }
3801
+
3802
+ function bindRoleplayMemorySurface(character) {
3803
+ const surface = $("#character-roleplay-memory-surface");
3804
+ if (!surface || !character?.id) return;
3805
+ roleplayMemoryCharacter = { id: character.id, name: character.name, workId: character.workId ?? state.work?.id };
3806
+ roleplayMemoryItems = [];
3807
+ roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
3808
+ roleplayMemoryCursorHistory = [0];
3809
+ roleplayMemoryLoaded = false;
3810
+ roleplayMemoryLoading = false;
3811
+ if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
3812
+ roleplayMemorySearchTimer = null;
3813
+ renderRoleplayMemoryList();
3814
+ $("#roleplay-memory-filter-toggle").addEventListener("click", (event) => {
3815
+ const panel = $("#roleplay-memory-filter-panel");
3816
+ const expanded = panel.classList.contains("hidden");
3817
+ panel.classList.toggle("hidden", !expanded);
3818
+ event.currentTarget.setAttribute("aria-expanded", String(expanded));
3819
+ if (expanded) $("#roleplay-memory-search").focus();
3820
+ });
3821
+ $("#roleplay-memory-add").addEventListener("click", () => openRoleplayMemoryEditor());
3822
+ $("#roleplay-memory-list").addEventListener("click", async (event) => {
3823
+ const sourceButton = event.target.closest("[data-roleplay-memory-source-conversation]");
3824
+ if (sourceButton) {
3825
+ try {
3826
+ if (!$("#character-editor-form").classList.contains("hidden")) await closeEntityEditor({ force: true });
3827
+ await openAiConversation(
3828
+ sourceButton.dataset.roleplayMemorySourceConversation,
3829
+ true,
3830
+ sourceButton.dataset.roleplayMemorySourceMessage,
3831
+ sourceButton.dataset.roleplayMemorySourceId
3832
+ );
3833
+ } catch (error) {
3834
+ toast(`来源消息打开失败:${error.message}`, "error");
3835
+ }
3836
+ return;
3837
+ }
3838
+ const actionButton = event.target.closest("[data-roleplay-memory-action]");
3839
+ if (!actionButton) return;
3840
+ const memory = roleplayMemoryItems.find((item) => item.id === actionButton.dataset.memoryId);
3841
+ actionButton.disabled = true;
3842
+ try {
3843
+ await updateRoleplayMemoryAction(memory, actionButton.dataset.roleplayMemoryAction);
3844
+ } catch (error) {
3845
+ toast(`记忆操作失败:${error.message}`, "error");
3846
+ } finally {
3847
+ if (actionButton.isConnected) actionButton.disabled = false;
3848
+ }
3849
+ });
3850
+ $("#roleplay-memory-search").addEventListener("input", () => {
3851
+ if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
3852
+ roleplayMemorySearchTimer = window.setTimeout(() => {
3853
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆搜索失败:${error.message}`, "error"));
3854
+ }, 250);
3855
+ });
3856
+ for (const select of [$("#roleplay-memory-category"), $("#roleplay-memory-status")]) {
3857
+ select.addEventListener("change", () => {
3858
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选失败:${error.message}`, "error"));
3859
+ });
3860
+ }
3861
+ $("#roleplay-memory-filter-reset").addEventListener("click", () => {
3862
+ $("#roleplay-memory-search").value = "";
3863
+ $("#roleplay-memory-category").value = "";
3864
+ $("#roleplay-memory-status").value = "active";
3865
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选重置失败:${error.message}`, "error"));
3866
+ });
3867
+ $("#roleplay-memory-previous").addEventListener("click", () => {
3868
+ if (roleplayMemoryCursorHistory.length <= 1) return;
3869
+ roleplayMemoryCursorHistory.pop();
3870
+ void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
3871
+ });
3872
+ $("#roleplay-memory-next").addEventListener("click", () => {
3873
+ if (roleplayMemoryPagination.nextCursor === null) return;
3874
+ roleplayMemoryCursorHistory.push(roleplayMemoryPagination.nextCursor);
3875
+ void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
3876
+ });
3877
+ }
3878
+
3879
+ function roleplayMemoryEditorFields(memory = null) {
3880
+ return field("category", "记忆类别", "select", memory?.category ?? "event", Object.entries(roleplayMemoryCategoryLabels))
3881
+ + field("importance", "重要度", "select", memory?.importance ?? "medium", Object.entries(roleplayMemoryImportanceLabels))
3882
+ + field("certainty", "可信状态", "select", memory?.certainty ?? "experienced", Object.entries(roleplayMemoryCertaintyLabels))
3883
+ + field("isPinned", "置顶这条记忆", "checkbox", memory?.isPinned === true)
3884
+ + field("content", "记忆内容", "textarea", memory?.content ?? "");
3885
+ }
3886
+
3887
+ function openRoleplayMemoryEditor(memory = null) {
3888
+ openDialog(memory ? "编辑角色扮演记忆" : "手工添加角色扮演记忆", roleplayMemoryEditorFields(memory), async (form) => {
3889
+ const body = {
3890
+ category: String(form.get("category") ?? "event"),
3891
+ importance: String(form.get("importance") ?? "medium"),
3892
+ certainty: String(form.get("certainty") ?? "experienced"),
3893
+ isPinned: form.get("isPinned") === "on",
3894
+ content: String(form.get("content") ?? "").trim(),
3895
+ ...(memory ? { expectedVersion: Number(memory.versionNo) } : {})
3896
+ };
3897
+ if (!body.content) throw new Error("请输入记忆内容");
3898
+ await api(memory
3899
+ ? `/api/roleplay-memories/${encodeURIComponent(memory.id)}`
3900
+ : `/api/characters/${encodeURIComponent(roleplayMemoryCharacter.id)}/roleplay-memories`, {
3901
+ method: memory ? "PATCH" : "POST",
3902
+ body
3903
+ });
3904
+ await loadRoleplayMemories();
3905
+ toast(memory ? "角色扮演记忆已更新" : "角色扮演记忆已添加");
3906
+ }, memory ? "版本化编辑" : "非正史 · 手工添加", {
3907
+ submitLabel: memory ? "保存记忆" : "添加记忆",
3908
+ errorPrefix: "记忆保存失败:",
3909
+ meta: "记录该角色在作品内共享的非正史互动,不会写入正文、角色卡字段或设定库。"
3910
+ });
3911
+ const textarea = $("#dialog-fields textarea[name='content']");
3912
+ if (textarea) {
3913
+ textarea.maxLength = 2_000;
3914
+ textarea.rows = 7;
3915
+ textarea.focus();
3916
+ }
3917
+ }
3918
+
3919
+ async function updateRoleplayMemoryAction(memory, action) {
3920
+ if (!memory) return;
3921
+ if (action === "edit") return openRoleplayMemoryEditor(memory);
3922
+ if (action === "archive" && !await confirmToast("删除后 AI 不再召回这条记忆,可稍后从“已删除”筛选中恢复。", {
3923
+ title: "删除角色扮演记忆",
3924
+ confirmLabel: "确认删除"
3925
+ })) return;
3926
+ if (action === "pin") {
3927
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
3928
+ method: "PATCH",
3929
+ body: { expectedVersion: Number(memory.versionNo), isPinned: memory.isPinned !== true }
3930
+ });
3931
+ } else if (action === "archive") {
3932
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
3933
+ method: "DELETE",
3934
+ body: { expectedVersion: Number(memory.versionNo) }
3935
+ });
3936
+ } else if (action === "restore") {
3937
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}/restore`, {
3938
+ method: "POST",
3939
+ body: { expectedVersion: Number(memory.versionNo) }
3940
+ });
3941
+ }
3942
+ await loadRoleplayMemories();
3943
+ toast(action === "pin" ? (memory.isPinned ? "已取消置顶" : "记忆已置顶") : action === "archive" ? "记忆已删除" : "记忆已恢复");
3944
+ }
3945
+
3225
3946
  function defaultAiConversationTitle(prompt) {
3226
3947
  const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
3227
3948
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -3310,7 +4031,7 @@ async function ensureAiConversationsLoaded() {
3310
4031
  }
3311
4032
  }
3312
4033
 
3313
- async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
4034
+ async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
3314
4035
  if (!state.work) return null;
3315
4036
  const existingTab = aiChatTabManager.findByConversation(conversationId);
3316
4037
  const existingMessage = focusMessageId
@@ -3331,6 +4052,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
3331
4052
  try {
3332
4053
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
3333
4054
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
4055
+ if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
3334
4056
  const [conversation] = await Promise.all([
3335
4057
  api(`/api/ai-conversations/${conversationId}?${parameters}`),
3336
4058
  ensureAiReferencesLoaded()
@@ -5630,12 +6352,44 @@ function setSaveState(text, dirty = false) {
5630
6352
  });
5631
6353
  }
5632
6354
 
6355
+ function resetChapterDraftLineIds(chapter = state.chapter) {
6356
+ chapterBeforeInputState = null;
6357
+ chapterDraftLineIdState = chapter ? {
6358
+ chapterId: chapter.id,
6359
+ content: String(chapter.content ?? ""),
6360
+ lineIds: normalizeChapterLineIdDraft(chapter.content, chapter.lineIds)
6361
+ } : null;
6362
+ }
6363
+
6364
+ function syncChapterDraftLineIds(content, hint = null) {
6365
+ if (!state.chapter) return [];
6366
+ if (!chapterDraftLineIdState || chapterDraftLineIdState.chapterId !== state.chapter.id) {
6367
+ resetChapterDraftLineIds(state.chapter);
6368
+ }
6369
+ if (chapterDraftLineIdState.content !== content) {
6370
+ chapterDraftLineIdState = {
6371
+ chapterId: state.chapter.id,
6372
+ content,
6373
+ lineIds: reconcileChapterLineIdDraft(
6374
+ chapterDraftLineIdState.content,
6375
+ content,
6376
+ chapterDraftLineIdState.lineIds,
6377
+ hint
6378
+ )
6379
+ };
6380
+ }
6381
+ return chapterDraftLineIdState.lineIds;
6382
+ }
6383
+
5633
6384
  function chapterDraftSnapshot() {
5634
6385
  if (!state.chapter) return null;
6386
+ const content = $("#chapter-content").value;
6387
+ const lineIds = syncChapterDraftLineIds(content);
5635
6388
  return {
5636
6389
  chapterId: state.chapter.id,
5637
6390
  title: $("#chapter-title").value.trim(),
5638
- content: normalizeParagraphSpacing($("#chapter-content").value)
6391
+ content,
6392
+ ...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
5639
6393
  };
5640
6394
  }
5641
6395
 
@@ -5692,11 +6446,6 @@ async function persistChapter({ automatic = false } = {}) {
5692
6446
  if (!automatic) toast("章节标题不能为空", "error");
5693
6447
  return null;
5694
6448
  }
5695
- const input = $("#chapter-content");
5696
- if (input.value !== draft.content) {
5697
- input.value = draft.content;
5698
- scheduleChapterLineNumbers();
5699
- }
5700
6449
  if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
5701
6450
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
5702
6451
  if (!automatic) await loadChapterForeshadowReminders();
@@ -5715,7 +6464,7 @@ async function persistChapter({ automatic = false } = {}) {
5715
6464
  const request = (async () => {
5716
6465
  const chapter = await api(`/api/chapters/${draft.chapterId}`, {
5717
6466
  method: "PATCH",
5718
- body: { title: draft.title, content: draft.content, source: automatic ? "auto" : "manual" }
6467
+ body: { title: draft.title, content: draft.content, lineIds: draft.lineIds, source: automatic ? "auto" : "manual" }
5719
6468
  });
5720
6469
  const work = await api(`/api/works/${workId}`);
5721
6470
  return { chapter, work };
@@ -5726,9 +6475,15 @@ async function persistChapter({ automatic = false } = {}) {
5726
6475
  if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
5727
6476
  state.chapter = saved.chapter;
5728
6477
  state.work = saved.work;
6478
+ resetChapterDraftLineIds(state.chapter);
5729
6479
  lastSavedChapterSnapshot = draft;
5730
6480
  renderTree();
5731
6481
  updateChapterStats();
6482
+ try {
6483
+ await loadChapterAnnotationCounts(saved.chapter.id);
6484
+ } catch (error) {
6485
+ toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
6486
+ }
5732
6487
  const currentDraft = chapterDraftSnapshot();
5733
6488
  if (sameChapterSnapshot(currentDraft, draft)) {
5734
6489
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
@@ -5803,13 +6558,35 @@ function restoredSettingsReturnContext(route) {
5803
6558
  }
5804
6559
 
5805
6560
  async function initializePage() {
6561
+ const route = parsePageRoute(window.location.hash);
6562
+ const earlyReaderChapterRequest = window.__scriverseReaderChapterPrefetch;
6563
+ const earlyReaderWorksRequest = window.__scriverseReaderWorksPrefetch;
6564
+ const earlyReaderWorkRequest = window.__scriverseReaderWorkPrefetch;
5806
6565
  const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
5807
6566
  if (!authenticated) {
5808
6567
  restoringPageRoute = false;
5809
6568
  return;
5810
6569
  }
5811
- const route = parsePageRoute(window.location.hash);
5812
- state.works = (await apiPage("/api/works")).items;
6570
+ const requestedWorkDetailRequest = route.workId
6571
+ ? earlyReaderWorkRequest?.workId === route.workId
6572
+ ? earlyReaderWorkRequest.request.then((result) => result.work ?? api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null))
6573
+ : api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null)
6574
+ : Promise.resolve(null);
6575
+ initialReaderChapterRequest = route.view === "reader" && route.chapterId
6576
+ ? earlyReaderChapterRequest?.chapterId === route.chapterId
6577
+ ? earlyReaderChapterRequest
6578
+ : {
6579
+ chapterId: route.chapterId,
6580
+ request: api(`/api/chapters/${encodeURIComponent(route.chapterId)}`)
6581
+ .then((chapter) => ({ chapter }), (error) => ({ error }))
6582
+ }
6583
+ : null;
6584
+ const [worksPage, requestedWorkDetail] = await Promise.all([
6585
+ earlyReaderWorksRequest?.request.then((result) => result.works ?? apiPage("/api/works")) ?? apiPage("/api/works"),
6586
+ requestedWorkDetailRequest
6587
+ ]);
6588
+ state.works = worksPage.items;
6589
+ initialWorkDetail = requestedWorkDetail;
5813
6590
  try {
5814
6591
  if (route.view === "shelf") {
5815
6592
  showShelf();
@@ -5850,7 +6627,7 @@ async function initializePage() {
5850
6627
  return;
5851
6628
  }
5852
6629
  const options = { readOnly: route.entityMode === "read" };
5853
- if (route.entity === "setting") openSettingEditor(item, options);
6630
+ if (route.entity === "setting") await openSettingEditor(item, options);
5854
6631
  else if (route.entity === "character") await openCharacterEditor(item, options);
5855
6632
  else if (route.entity === "race") await openRaceDialog(item, options);
5856
6633
  else if (route.entity === "organization") await openOrganizationDialog(item, options);
@@ -5881,7 +6658,14 @@ async function initializePage() {
5881
6658
  settingsReturnContext = restoredSettingsReturnContext(route);
5882
6659
  }
5883
6660
  } finally {
6661
+ delete window.__scriverseReaderChapterPrefetch;
6662
+ delete window.__scriverseReaderWorksPrefetch;
6663
+ delete window.__scriverseReaderWorkPrefetch;
6664
+ initialWorkDetail = null;
6665
+ initialReaderChapterRequest = null;
5884
6666
  document.body.classList.remove("auth-pending");
6667
+ document.documentElement.removeAttribute("data-pending-view");
6668
+ document.documentElement.classList.remove("pending-shelf-mode");
5885
6669
  restoringPageRoute = false;
5886
6670
  replacePageRoute(currentPageRoute());
5887
6671
  scheduleFirstUseOnboarding();
@@ -6989,6 +7773,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
6989
7773
  const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
6990
7774
  if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
6991
7775
  state.chapter = chapter;
7776
+ resetChapterDraftLineIds(state.chapter);
6992
7777
  mergeChapterDirectoryEntry(chapter);
6993
7778
  lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
6994
7779
  await loadVolumeChapters(chapter.volumeId);
@@ -7410,7 +8195,9 @@ async function selectWork(workId, preferredChapterId = null) {
7410
8195
  volumeChapterLoadingIds.clear();
7411
8196
  volumeChapterRequests.clear();
7412
8197
  }
7413
- const nextWork = await api(`/api/works/${workId}?directory=volumes`);
8198
+ const prefetchedWork = initialWorkDetail?.id === workId ? initialWorkDetail : null;
8199
+ initialWorkDetail = null;
8200
+ const nextWork = prefetchedWork ?? await api(`/api/works/${workId}?directory=volumes`);
7414
8201
  if (selectionGeneration !== workSelectionRequestGeneration) return false;
7415
8202
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
7416
8203
  showSystemStatus();
@@ -7480,9 +8267,12 @@ async function loadVolumeChapters(volumeId) {
7480
8267
  async function loadAllVolumeChapters(workId) {
7481
8268
  const generation = workScopedUiGeneration;
7482
8269
  const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
7483
- for (const volumeId of volumeIds) {
7484
- if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
7485
- await loadVolumeChapters(volumeId);
8270
+ for (let index = 0; index < volumeIds.length; index += readingVolumeDirectoryConcurrency) {
8271
+ const batch = volumeIds.slice(index, index + readingVolumeDirectoryConcurrency);
8272
+ await Promise.all(batch.map(async (volumeId) => {
8273
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
8274
+ await loadVolumeChapters(volumeId);
8275
+ }));
7486
8276
  }
7487
8277
  }
7488
8278
 
@@ -7855,9 +8645,14 @@ function currentChapterForeshadowReminder() {
7855
8645
 
7856
8646
  function syncMobileAiPanelSafeTop() {
7857
8647
  const container = $("#chapter-foreshadow-reminder");
7858
- const safeTop = container.classList.contains("hidden")
8648
+ const toolbar = $("#editor-view .editor-toolbar");
8649
+ const reminderBottom = container.classList.contains("hidden")
8650
+ ? 0
8651
+ : container.getBoundingClientRect().bottom;
8652
+ const toolbarBottom = $("#editor-view").classList.contains("hidden")
7859
8653
  ? 0
7860
- : Math.ceil(container.getBoundingClientRect().bottom);
8654
+ : toolbar.getBoundingClientRect().bottom;
8655
+ const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
7861
8656
  $("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
7862
8657
  }
7863
8658
 
@@ -8078,9 +8873,8 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
8078
8873
  $("#chapter-path").textContent = chapterPath;
8079
8874
  $("#chapter-path").title = chapterPath;
8080
8875
  $("#chapter-title").value = state.chapter.title;
8081
- const normalizedContent = normalizeParagraphSpacing(state.chapter.content);
8082
- const spacingChanged = normalizedContent !== state.chapter.content;
8083
- $("#chapter-content").value = normalizedContent;
8876
+ $("#chapter-content").value = state.chapter.content;
8877
+ resetChapterDraftLineIds(state.chapter);
8084
8878
  chapterAnnotationCounts = new Map();
8085
8879
  clearChapterLineSelection();
8086
8880
  scheduleChapterLineNumbers();
@@ -8091,7 +8885,6 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
8091
8885
  updateChapterStats();
8092
8886
  if (!canEditProse()) setSaveState("正文只读");
8093
8887
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
8094
- else if (spacingChanged) scheduleChapterAutoSave(120);
8095
8888
  else setSaveState("已保存");
8096
8889
  renderTree();
8097
8890
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
@@ -8204,6 +8997,7 @@ function renderReadingNavigation() {
8204
8997
  $("#reader-next").disabled = !next || readingLoading;
8205
8998
  $("#reader-continue").disabled = !next || readingLoading;
8206
8999
  $("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
9000
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8207
9001
  const paged = readingPreferences.mode === "paged";
8208
9002
  const previousPage = current && paged ? resolvePagedReadingStep({
8209
9003
  sequence: readingSequence,
@@ -8241,7 +9035,7 @@ function applyReadingPreferences() {
8241
9035
  $("#reader-font-size").value = String(readingPreferences.fontSize);
8242
9036
  $("#reader-line-height").value = String(readingPreferences.lineHeight);
8243
9037
  $("#reader-theme").value = readingPreferences.theme;
8244
- $("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
9038
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8245
9039
  $("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
8246
9040
  $("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
8247
9041
  }
@@ -8376,7 +9170,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
8376
9170
  renderReadingStatus("正在载入章节……");
8377
9171
  replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
8378
9172
  try {
8379
- const chapter = await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
9173
+ const initialRequest = initialReaderChapterRequest?.chapterId === target.id
9174
+ ? initialReaderChapterRequest.request
9175
+ : null;
9176
+ if (initialRequest) initialReaderChapterRequest = null;
9177
+ const prefetchedResult = initialRequest ? await initialRequest : null;
9178
+ if (prefetchedResult?.error) throw prefetchedResult.error;
9179
+ const chapter = prefetchedResult?.chapter ?? await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
8380
9180
  if (!readingRequestGate.isCurrent(request)) return false;
8381
9181
  if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
8382
9182
  throw new Error("章节响应与当前作品不匹配");
@@ -8530,7 +9330,7 @@ function closeReadingPreview() {
8530
9330
  replacePageRoute(returnRoute);
8531
9331
  const focus = readingPreviousFocus;
8532
9332
  readingPreviousFocus = null;
8533
- const focusCandidates = [focus, $("#chapter-reader-button"), $("#reader-open-button"), $("#home-button")];
9333
+ const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
8534
9334
  for (const candidate of focusCandidates) {
8535
9335
  if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
8536
9336
  const rect = candidate.getBoundingClientRect();
@@ -8769,6 +9569,14 @@ function pencilIconMarkup() {
8769
9569
  return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><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>';
8770
9570
  }
8771
9571
 
9572
+ function trashIconMarkup() {
9573
+ return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16"></path><path d="M9 7V4h6v3M6.5 7l.8 13h9.4l.8-13M10 11v5M14 11v5"></path></svg>';
9574
+ }
9575
+
9576
+ function roleplayMemoryPinIconMarkup() {
9577
+ return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 4h6"></path><path d="M10 4v5l-3 4h10l-3-4V4"></path><path d="M12 13v7"></path></svg>';
9578
+ }
9579
+
8772
9580
  function characterFavoriteIconMarkup() {
8773
9581
  return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>';
8774
9582
  }
@@ -12453,15 +13261,19 @@ async function renderBookAiSettings() {
12453
13261
  clearTimeout(relationshipSearchIndexRefreshTimer);
12454
13262
  relationshipSearchIndexRefreshTimer = null;
12455
13263
  }
12456
- const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
13264
+ const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions, writeTools] = await Promise.all([
12457
13265
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
12458
13266
  moduleApi("ai-settings", "/api/platform/ai/providers"),
12459
13267
  moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
12460
13268
  moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
12461
13269
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
12462
13270
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
12463
- moduleApi("ai-settings", "/api/platform/ai/protocols")
13271
+ moduleApi("ai-settings", "/api/platform/ai/protocols"),
13272
+ // 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
13273
+ api(`/api/works/${state.work.id}/ai/tools`).catch(() => null)
12464
13274
  ]);
13275
+ const writeToolsState = writeTools?.tools ?? null;
13276
+ const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
12465
13277
  const host = $("#module-content");
12466
13278
  platformAiProtocolOptions = protocolOptions;
12467
13279
  const workId = String(state.work.id);
@@ -12511,6 +13323,7 @@ async function renderBookAiSettings() {
12511
13323
  host.querySelectorAll(".config-section").forEach((section) => {
12512
13324
  if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
12513
13325
  });
13326
+ host.insertAdjacentHTML("beforeend", `<section class="config-section"><div class="config-section-header"><div><h2>AI 可写工具</h2><p>默认全部关闭:逐项开启后,侧边栏 AI 才能在对应模块提交修改计划。计划只包含操作描述与 AI 简述;确认前系统会按当前数据库生成字段级明细(含修改前后值),执行时整体原子完成并再次校验权限、开关与目标版本,全程可在「AI 操作审批中心」追溯。AI 不能删除任何条目,也不能改写正文。</p></div><div class="card-actions"><button id="open-ai-approval-center-from-settings" class="ghost-button" type="button">打开 AI 操作审批中心</button></div></div><div class="ai-agent-tools ai-write-tools">${AI_WRITE_TOOLS_META.map((tool) => `<label><input name="ai-write-tool" type="checkbox" value="${esc(tool.id)}" ${writeToolsState?.[tool.id] === true ? "checked" : ""}><span><strong>${esc(tool.label)}</strong><small>${esc(tool.description)}</small></span></label>`).join("")}</div><p class="usage-measurement-note">${writeTools ? `当前单次审批最多 ${writeToolsMaxOperations} 个操作,可通过环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 调整。` : "工具开关状态暂时无法加载,显示的勾选可能不是最新值。"}</p><div class="card-actions"><button id="save-ai-write-tools" class="ghost-button config-save-button" type="button">保存开关设置</button></div></section>`);
12514
13327
  bindUsageCalendarInteractions(host);
12515
13328
  scrollUsageCalendarsToLatest(host);
12516
13329
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
@@ -12769,6 +13582,23 @@ async function renderBookAiSettings() {
12769
13582
  button.disabled = false;
12770
13583
  }
12771
13584
  });
13585
+ $("#open-ai-approval-center-from-settings").addEventListener("click", () => openAiApprovalCenter());
13586
+ $("#save-ai-write-tools").addEventListener("click", async () => {
13587
+ const button = $("#save-ai-write-tools");
13588
+ button.disabled = true;
13589
+ try {
13590
+ const tools = {};
13591
+ host.querySelectorAll('input[name="ai-write-tool"]').forEach((input) => {
13592
+ tools[input.value] = input.checked;
13593
+ });
13594
+ await api(`/api/works/${state.work.id}/ai/tools`, { method: "PUT", body: { tools } });
13595
+ toast("AI 可写工具开关已保存");
13596
+ } catch (error) {
13597
+ toast(error.message, "error");
13598
+ } finally {
13599
+ button.disabled = false;
13600
+ }
13601
+ });
12772
13602
  host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
12773
13603
  const select = event.currentTarget;
12774
13604
  select.disabled = true;
@@ -12903,9 +13733,11 @@ function renderAiContextDistribution(usage) {
12903
13733
  label.className = "ai-context-distribution-label";
12904
13734
  const title = document.createElement("span");
12905
13735
  title.textContent = item.label;
12906
- if (item.key === "skills" || item.key === "context") {
13736
+ if (item.key === "skills" || item.key === "input" || item.key === "output") {
12907
13737
  const description = document.createElement("small");
12908
- description.textContent = item.key === "skills" ? "待加入" : "用户和 agent 的交互";
13738
+ description.textContent = item.key === "skills"
13739
+ ? "待加入"
13740
+ : item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
12909
13741
  title.append(" ", description);
12910
13742
  }
12911
13743
  const value = document.createElement("strong");
@@ -12922,6 +13754,7 @@ function renderAiContextDistribution(usage) {
12922
13754
  return row;
12923
13755
  }));
12924
13756
  popover.dataset.hasUsage = String(Boolean(usage));
13757
+ return distribution;
12925
13758
  }
12926
13759
 
12927
13760
  function setAiContextMeter(usage, allowShrink = true) {
@@ -12931,7 +13764,7 @@ function setAiContextMeter(usage, allowShrink = true) {
12931
13764
  latestAiContextUsage = displayUsage;
12932
13765
  const meter = $("#ai-context-meter");
12933
13766
  const value = meter.querySelector("b");
12934
- renderAiContextDistribution(displayUsage);
13767
+ const distribution = renderAiContextDistribution(displayUsage);
12935
13768
  if (!displayUsage) {
12936
13769
  meter.classList.add("is-empty");
12937
13770
  meter.classList.remove("is-warning", "is-danger");
@@ -12942,14 +13775,14 @@ function setAiContextMeter(usage, allowShrink = true) {
12942
13775
  meter.setAttribute("aria-label", tooltip);
12943
13776
  return;
12944
13777
  }
12945
- const percent = Math.max(0, Math.min(100, Number(displayUsage.usagePercent) || 0));
13778
+ const percent = distribution.contextWindow > 0
13779
+ ? Math.min(100, distribution.occupiedTokens / distribution.contextWindow * 100)
13780
+ : 0;
12946
13781
  meter.classList.remove("is-empty");
12947
13782
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
12948
13783
  meter.classList.toggle("is-danger", percent >= 90);
12949
13784
  meter.style.setProperty("--context-usage", String(percent));
12950
- value.textContent = Number(displayUsage.inputTokens) > 0
12951
- ? formatAiContextUsagePercent(displayUsage.inputTokens, displayUsage.contextWindow)
12952
- : `${percent}%`;
13785
+ value.textContent = formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow);
12953
13786
  const tooltip = formatAiContextUsageTooltip(displayUsage);
12954
13787
  meter.dataset.tooltip = tooltip;
12955
13788
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
@@ -13223,7 +14056,7 @@ function commitRelationshipKeywordInputs(container) {
13223
14056
  container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
13224
14057
  }
13225
14058
 
13226
- function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
14059
+ async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13227
14060
  formDialogVditors.forEach(destroyVditorEditor);
13228
14061
  formDialogVditors = [];
13229
14062
  void discardPendingMarkdownAttachments();
@@ -13273,6 +14106,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13273
14106
  dialog.classList.toggle("editor-dialog", Boolean(options.editor));
13274
14107
  bindDynamicListControls($("#dialog-fields"));
13275
14108
  bindRelationshipKeywordControls($("#dialog-fields"));
14109
+ if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
13276
14110
  formDialogVditors = bindVditorEditors($("#dialog-fields"));
13277
14111
  form.onclick = null;
13278
14112
  form.onkeydown = null;
@@ -13516,18 +14350,36 @@ function openWorkSettingsDialog(work) {
13516
14350
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
13517
14351
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
13518
14352
  </section>` : "";
14353
+ const editorPreferencesField = `<section class="work-access-field work-editor-preferences-field" aria-labelledby="work-editor-preferences-title">
14354
+ <div><strong id="work-editor-preferences-title">正文编辑辅助</strong><small>仅对当前作品生效。新建作品默认关闭,不影响其他作品或系统设置。</small></div>
14355
+ <div class="work-editor-preference-options" role="group" aria-labelledby="work-editor-preferences-title">
14356
+ <label class="work-editor-preference-option"><input name="editorAutoIndentEnabled" type="checkbox" ${work.editorAutoIndentEnabled ? "checked" : ""}><span><b>自动空两字</b><small>按 Enter 新建段落时自动插入两个全角空格。</small></span></label>
14357
+ <label class="work-editor-preference-option"><input name="editorTypewriterModeEnabled" type="checkbox" ${work.editorTypewriterModeEnabled ? "checked" : ""}><span><b>打字机模式</b><small>输入位置超过页面六成后,将当前行保持在页面中部。</small></span></label>
14358
+ </div>
14359
+ </section>`;
13519
14360
  openDialog("作品信息",
13520
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
14361
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + editorPreferencesField + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
13521
14362
  async (form) => {
13522
- await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
14363
+ await api(`/api/works/${work.id}`, { method: "PATCH", body: {
14364
+ title: form.get("title"),
14365
+ author: form.get("author"),
14366
+ description: form.get("description"),
14367
+ editorAutoIndentEnabled: form.has("editorAutoIndentEnabled"),
14368
+ editorTypewriterModeEnabled: form.has("editorTypewriterModeEnabled")
14369
+ } });
13523
14370
  state.works = (await apiPage("/api/works")).items;
13524
14371
  const updated = state.works.find((item) => item.id === work.id);
13525
14372
  if (updated) Object.assign(work, updated);
13526
14373
  if (state.work?.id === work.id) {
13527
- state.work.title = String(form.get("title") ?? state.work.title);
13528
- state.work.author = String(form.get("author") ?? state.work.author);
13529
- state.work.description = String(form.get("description") ?? state.work.description);
13530
- if (updated?.coverUrl !== undefined) state.work.coverUrl = updated.coverUrl;
14374
+ if (updated) Object.assign(state.work, updated);
14375
+ else {
14376
+ state.work.title = String(form.get("title") ?? state.work.title);
14377
+ state.work.author = String(form.get("author") ?? state.work.author);
14378
+ state.work.description = String(form.get("description") ?? state.work.description);
14379
+ state.work.editorAutoIndentEnabled = form.has("editorAutoIndentEnabled");
14380
+ state.work.editorTypewriterModeEnabled = form.has("editorTypewriterModeEnabled");
14381
+ }
14382
+ applyChapterEditorPreferences();
13531
14383
  updateDocumentTitle(state.work);
13532
14384
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
13533
14385
  }
@@ -13691,7 +14543,8 @@ function syncSettingEditorDirty(markdown = null) {
13691
14543
  entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
13692
14544
  }
13693
14545
 
13694
- function openSettingEditor(item = null, { readOnly = false } = {}) {
14546
+ async function openSettingEditor(item = null, { readOnly = false } = {}) {
14547
+ if (!(await loadVditorResources())) return;
13695
14548
  entityEditorReadOnly = readOnly;
13696
14549
  destroyVditorEditor(settingEditorVditor);
13697
14550
  settingEditorVditor = null;
@@ -13706,7 +14559,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13706
14559
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
13707
14560
  const editButton = $("#setting-editor-edit");
13708
14561
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13709
- editButton.onclick = () => openSettingEditor(settingEditorItem);
14562
+ editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
13710
14563
  bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
13711
14564
  settingEditorItem = updated;
13712
14565
  state.settings = upsertEntityCollection(state.settings, updated);
@@ -13819,9 +14672,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13819
14672
  (readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
13820
14673
  }
13821
14674
 
13822
- function characterEditorSection(key, title, description, content) {
13823
- return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}" data-character-editor-panel="${esc(key)}" role="tabpanel">
13824
- <header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>
14675
+ function characterEditorSection(key, title, description, content, headerActions = "") {
14676
+ const hasHeaderActions = Boolean(headerActions);
14677
+ const header = hasHeaderActions
14678
+ ? `<header><div class="character-editor-section-header-copy"><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3><p>${esc(description)}</p></div>${headerActions}</header>`
14679
+ : `<header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>`;
14680
+ return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}${hasHeaderActions ? " has-header-actions" : ""}" data-character-editor-panel="${esc(key)}" role="tabpanel">
14681
+ ${header}
13825
14682
  <div class="character-editor-section-fields">${content}</div>
13826
14683
  </section>`;
13827
14684
  }
@@ -13842,6 +14699,14 @@ function activateCharacterEditorTab(key) {
13842
14699
  ) {
13843
14700
  void loadCharacterEditorRelationships(characterEditorItem.id);
13844
14701
  }
14702
+ if (
14703
+ key === "roleplay-memory"
14704
+ && characterEditorItem?.id
14705
+ && !roleplayMemoryLoaded
14706
+ && !roleplayMemoryLoading
14707
+ ) {
14708
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`角色扮演记忆加载失败:${error.message}`, "error"));
14709
+ }
13845
14710
  }
13846
14711
 
13847
14712
  function setCharacterHistoryVisible(visible) {
@@ -14070,12 +14935,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
14070
14935
  };
14071
14936
  }
14072
14937
 
14938
+ function loadVditorStylesheet() {
14939
+ const existing = document.getElementById("vditorStylesheet");
14940
+ if (existing?.dataset.loaded === "true") return Promise.resolve();
14941
+ return new Promise((resolve, reject) => {
14942
+ const link = existing ?? document.createElement("link");
14943
+ link.id = "vditorStylesheet";
14944
+ link.rel = "stylesheet";
14945
+ link.href = "/vendor/vditor/dist/index.css?v=3.11.2";
14946
+ link.addEventListener("load", () => {
14947
+ link.dataset.loaded = "true";
14948
+ resolve();
14949
+ }, { once: true });
14950
+ link.addEventListener("error", () => {
14951
+ link.remove();
14952
+ reject(new Error("Vditor stylesheet failed to load"));
14953
+ }, { once: true });
14954
+ if (!existing) document.head.append(link);
14955
+ });
14956
+ }
14957
+
14958
+ function loadVditorScript(id, src) {
14959
+ const existing = document.getElementById(id);
14960
+ if (existing?.dataset.loaded === "true") return Promise.resolve();
14961
+ return new Promise((resolve, reject) => {
14962
+ const script = existing ?? document.createElement("script");
14963
+ script.id = id;
14964
+ script.src = src;
14965
+ script.addEventListener("load", () => {
14966
+ script.dataset.loaded = "true";
14967
+ resolve();
14968
+ }, { once: true });
14969
+ script.addEventListener("error", () => {
14970
+ script.remove();
14971
+ reject(new Error(`Vditor script failed to load: ${id}`));
14972
+ }, { once: true });
14973
+ if (!existing) document.body.append(script);
14974
+ });
14975
+ }
14976
+
14977
+ async function loadVditorResources() {
14978
+ if (window.Vditor && document.getElementById("vditorStylesheet")) return true;
14979
+ if (!vditorResourcesPromise) {
14980
+ vditorResourcesPromise = Promise.all([
14981
+ loadVditorStylesheet(),
14982
+ loadVditorScript("vditorIconScript", "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"),
14983
+ loadVditorScript("vditorMainScript", "/vendor/vditor/dist/index.min.js?v=3.11.2")
14984
+ ]).then(() => {
14985
+ if (!window.Vditor) throw new Error("Vditor constructor is unavailable");
14986
+ return true;
14987
+ }).catch(() => {
14988
+ vditorResourcesPromise = null;
14989
+ toast("Markdown 编辑器资源加载失败,请检查网络后重试", "error");
14990
+ return false;
14991
+ });
14992
+ }
14993
+ return vditorResourcesPromise;
14994
+ }
14995
+
14073
14996
  function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
14074
14997
  if (!window.Vditor) {
14075
14998
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
14076
14999
  return null;
14077
15000
  }
14078
- ensureVditorIconScript();
14079
15001
  let editor = null;
14080
15002
  editor = new window.Vditor(host, {
14081
15003
  cdn: "/vendor/vditor",
@@ -14096,7 +15018,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
14096
15018
  className: "vditor-line-number-button",
14097
15019
  icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h3M4 9h3M4 13h3M4 17h3M10 5h10M10 9h10M10 13h10M10 17h10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.6"/></svg>',
14098
15020
  click: () => toggleVditorLineNumbers(editor)
14099
- }, "edit-mode", "fullscreen"],
15021
+ }, "edit-mode"],
14100
15022
  upload: {
14101
15023
  accept: "image/*",
14102
15024
  max: 10 * 1024 * 1024,
@@ -14309,14 +15231,6 @@ function normalizeVditorAttachmentImages(editor) {
14309
15231
  });
14310
15232
  }
14311
15233
 
14312
- function ensureVditorIconScript() {
14313
- if (document.getElementById("vditorIconScript")) return;
14314
- const script = document.createElement("script");
14315
- script.id = "vditorIconScript";
14316
- script.src = "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2";
14317
- document.body.appendChild(script);
14318
- }
14319
-
14320
15234
  function destroyVditorEditor(editor) {
14321
15235
  if (!editor) return;
14322
15236
  editor.__attachmentObserver?.disconnect();
@@ -14425,6 +15339,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
14425
15339
 
14426
15340
  async function openKnowledgeSectionEditor(index = null) {
14427
15341
  if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
15342
+ if (!(await loadVditorResources())) return;
14428
15343
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
14429
15344
  destroyVditorEditor(knowledgeSectionVditor);
14430
15345
  knowledgeSectionVditor = null;
@@ -14537,6 +15452,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
14537
15452
  }
14538
15453
 
14539
15454
  async function openCharacterSectionEditor(section = null) {
15455
+ if (!(await loadVditorResources())) return;
14540
15456
  await discardPendingCharacterAttachments();
14541
15457
  destroyVditorEditor(characterSectionVditor);
14542
15458
  characterSectionVditor = null;
@@ -14717,6 +15633,30 @@ function renderCharacterAvatar(item) {
14717
15633
  }
14718
15634
  }
14719
15635
 
15636
+ function roleplayMemoryToolbarMarkup() {
15637
+ return `<div class="roleplay-memory-toolbar">
15638
+ <button id="roleplay-memory-filter-toggle" class="module-filter-toggle" type="button" aria-label="筛选角色扮演记忆" aria-controls="roleplay-memory-filter-panel" aria-expanded="false" title="筛选角色扮演记忆"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>
15639
+ <button id="roleplay-memory-add" class="primary-button" type="button">手工添加</button>
15640
+ </div>`;
15641
+ }
15642
+
15643
+ function roleplayMemorySurfaceMarkup() {
15644
+ return `<div id="character-roleplay-memory-surface" class="character-roleplay-memory-surface">
15645
+ <section id="roleplay-memory-filter-panel" class="roleplay-memory-filter-panel hidden" aria-label="角色扮演记忆筛选">
15646
+ <label for="roleplay-memory-search">搜索<input id="roleplay-memory-search" type="search" maxlength="200" placeholder="搜索事件、承诺、场景或角色状态"></label>
15647
+ <label for="roleplay-memory-category">类别<select id="roleplay-memory-category"><option value="">全部类别</option><option value="event">事件</option><option value="state">状态</option><option value="relationship">关系</option><option value="commitment">承诺</option><option value="knowledge">知识</option><option value="scene">场景</option></select></label>
15648
+ <label for="roleplay-memory-status">状态<select id="roleplay-memory-status"><option value="active">生效中</option><option value="superseded">已取代</option><option value="archived">已删除</option><option value="all">全部状态</option></select></label>
15649
+ <button id="roleplay-memory-filter-reset" class="ghost-button" type="button">重置筛选</button>
15650
+ </section>
15651
+ <div id="roleplay-memory-list" class="roleplay-memory-list" aria-live="polite"></div>
15652
+ <nav id="roleplay-memory-pagination" class="module-pagination roleplay-memory-pagination hidden" aria-label="角色扮演记忆分页">
15653
+ <button id="roleplay-memory-previous" type="button" disabled>上一页</button>
15654
+ <span id="roleplay-memory-page-label">第 1 页</span>
15655
+ <button id="roleplay-memory-next" type="button" disabled>下一页</button>
15656
+ </nav>
15657
+ </div>`;
15658
+ }
15659
+
14720
15660
  function renderCharacterEditorFields(item) {
14721
15661
  const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
14722
15662
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
@@ -14765,7 +15705,12 @@ function renderCharacterEditorFields(item) {
14765
15705
  '<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
14766
15706
  field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
14767
15707
  characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
14768
- '<div id="character-editor-relationships" class="character-editor-relationships-field"></div>')
15708
+ '<div id="character-editor-relationships" class="character-editor-relationships-field"></div>'),
15709
+ characterEditorSection("roleplay-memory", "角色扮演记忆", "该角色在作品内唯一、所有有权用户共享的非正史角色扮演记忆库。",
15710
+ item?.id
15711
+ ? roleplayMemorySurfaceMarkup()
15712
+ : '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
15713
+ item?.id ? roleplayMemoryToolbarMarkup() : "")
14769
15714
  ].join("");
14770
15715
  const name = $("#character-editor-fields [name='name']");
14771
15716
  if (name) name.required = true;
@@ -14773,6 +15718,7 @@ function renderCharacterEditorFields(item) {
14773
15718
  renderCharacterAvatar(item);
14774
15719
  renderCharacterEditorRelationships();
14775
15720
  renderCharacterMarkdownSections();
15721
+ bindRoleplayMemorySurface(item);
14776
15722
  activateCharacterEditorTab("basic");
14777
15723
  }
14778
15724
 
@@ -14941,6 +15887,10 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14941
15887
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
14942
15888
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
14943
15889
  $("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
15890
+ if (item) {
15891
+ $("#character-roleplay-memory-surface")?.querySelectorAll("button, input, select").forEach((control) => { control.disabled = false; });
15892
+ renderRoleplayMemoryList();
15893
+ }
14944
15894
  }
14945
15895
  $("#character-change-note").readOnly = viewOnly;
14946
15896
  $("#character-editor-submit").classList.toggle("hidden", viewOnly);
@@ -14967,6 +15917,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14967
15917
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
14968
15918
  relationshipTab.disabled = !item || !canReadModule("relationships");
14969
15919
  relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
15920
+ const roleplayMemoryTab = document.querySelector("[data-character-editor-tab='roleplay-memory']");
15921
+ roleplayMemoryTab.disabled = !item;
15922
+ roleplayMemoryTab.title = item ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
14970
15923
  const form = $("#character-editor-form");
14971
15924
  form.onsubmit = async (event) => {
14972
15925
  event.preventDefault();
@@ -16264,6 +17217,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16264
17217
  const tab = activeAiChatTab();
16265
17218
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
16266
17219
  if (aiRequestManager.hasActive(tab.id)) return;
17220
+ if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
16267
17221
  const composerSnapshot = captureAiPromptComposer();
16268
17222
  const requestComposerSnapshot = retry
16269
17223
  ? {
@@ -16745,6 +17699,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16745
17699
  toolCalls.push(toolCall);
16746
17700
  processSteps.push(aiToolProcessStep(toolCall, round));
16747
17701
  renderStreamingProcessSteps(false, elapsedProcessTime());
17702
+ handleInteractiveToolCallEvent(toolCall);
16748
17703
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
16749
17704
  scrollAiFeedToBottom(feed);
16750
17705
  } else if (eventName === "context_compacted") {
@@ -17004,6 +17959,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null, option
17004
17959
  try {
17005
17960
  const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
17006
17961
  state.chapter = result.chapter;
17962
+ resetChapterDraftLineIds(state.chapter);
17007
17963
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
17008
17964
  $("#chapter-content").value = state.chapter.content;
17009
17965
  scheduleChapterLineNumbers();
@@ -17097,6 +18053,7 @@ async function showVersions() {
17097
18053
  }
17098
18054
  try {
17099
18055
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
18056
+ resetChapterDraftLineIds(state.chapter);
17100
18057
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
17101
18058
  $("#chapter-title").value = state.chapter.title;
17102
18059
  $("#chapter-content").value = state.chapter.content;
@@ -18454,10 +19411,7 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
18454
19411
  renderChapterForeshadowReminder();
18455
19412
  $("#chapter-foreshadow-reminder-details-button").focus();
18456
19413
  });
18457
- $("#chapter-delete-button").addEventListener("click", () => {
18458
- if (state.chapter) void deleteChapter(state.chapter.id);
18459
- });
18460
- $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
19414
+ $("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
18461
19415
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
18462
19416
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
18463
19417
  $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
@@ -18570,12 +19524,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
18570
19524
  toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
18571
19525
  });
18572
19526
  $("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
19527
+ $("#chapter-content").addEventListener("beforeinput", (event) => {
19528
+ if (!state.chapter) return;
19529
+ const input = event.currentTarget;
19530
+ syncChapterDraftLineIds(input.value);
19531
+ chapterBeforeInputState = {
19532
+ chapterId: state.chapter.id,
19533
+ content: input.value,
19534
+ selectionStart: input.selectionStart,
19535
+ selectionEnd: input.selectionEnd,
19536
+ inputType: event.inputType
19537
+ };
19538
+ });
19539
+ $("#chapter-content").addEventListener("keydown", (event) => {
19540
+ const input = event.currentTarget;
19541
+ if (
19542
+ event.key !== "Enter"
19543
+ || !state.work?.editorAutoIndentEnabled
19544
+ || event.isComposing
19545
+ || event.altKey
19546
+ || event.ctrlKey
19547
+ || event.metaKey
19548
+ || !state.chapter
19549
+ || input.readOnly
19550
+ ) return;
19551
+ event.preventDefault();
19552
+ syncChapterDraftLineIds(input.value);
19553
+ chapterBeforeInputState = {
19554
+ chapterId: state.chapter.id,
19555
+ content: input.value,
19556
+ selectionStart: input.selectionStart,
19557
+ selectionEnd: input.selectionEnd,
19558
+ inputType: "insertLineBreak"
19559
+ };
19560
+ const next = insertIndentedParagraph(input.value, input.selectionStart, input.selectionEnd);
19561
+ input.setRangeText(`\n${CHAPTER_PARAGRAPH_INDENT}`, input.selectionStart, input.selectionEnd, "end");
19562
+ input.setSelectionRange(next.selectionStart, next.selectionEnd);
19563
+ input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertLineBreak" }));
19564
+ });
18573
19565
  $("#chapter-content").addEventListener("input", (event) => {
18574
- if (!event.isComposing) collapseChapterInputBlankLines(event.currentTarget);
19566
+ const input = event.currentTarget;
19567
+ const beforeInput = chapterBeforeInputState;
19568
+ const hint = beforeInput
19569
+ && beforeInput.chapterId === state.chapter?.id
19570
+ && chapterDraftLineIdState?.content === beforeInput.content
19571
+ ? beforeInput
19572
+ : null;
19573
+ syncChapterDraftLineIds(input.value, hint);
19574
+ chapterBeforeInputState = null;
18575
19575
  updateChapterStats();
18576
19576
  scheduleChapterAutoSave();
18577
19577
  clearChapterLineSelection();
18578
19578
  scheduleChapterLineNumbers(chapterLineInputRenderDelay);
19579
+ scheduleChapterCaretScroll();
18579
19580
  setAiContextMeter(null);
18580
19581
  });
18581
19582
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
@@ -19298,6 +20299,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
19298
20299
  if (label) label.textContent = "导出 Markdown";
19299
20300
  }
19300
20301
  });
20302
+ // --- AI 操作审批中心与写入审批 / 提问弹窗 ---
20303
+ $("#ai-approval-center-toggle").addEventListener("click", () => {
20304
+ const dialog = $("#ai-approval-center-dialog");
20305
+ if (dialog.open) {
20306
+ dialog.close();
20307
+ return;
20308
+ }
20309
+ openAiApprovalCenter();
20310
+ });
20311
+ $("#ai-approval-center-close").addEventListener("click", () => $("#ai-approval-center-dialog").close());
20312
+ $("#ai-approval-center-dialog").addEventListener("close", () => {
20313
+ $("#ai-approval-center-toggle").setAttribute("aria-expanded", "false");
20314
+ });
20315
+ $("#ai-approval-center-dialog .ai-approval-filters").addEventListener("click", (event) => {
20316
+ const chip = event.target.closest("[data-status-filter]");
20317
+ if (!chip) return;
20318
+ aiApprovalCenterState.status = String(chip.dataset.statusFilter ?? "");
20319
+ document.querySelectorAll("#ai-approval-center-dialog [data-status-filter]").forEach((item) => {
20320
+ item.setAttribute("aria-pressed", String(item === chip));
20321
+ });
20322
+ $("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
20323
+ loadAiApprovalCenterPlans().catch((error) => {
20324
+ $("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
20325
+ });
20326
+ });
20327
+ $("#ai-approval-list-host").addEventListener("click", (event) => {
20328
+ const row = event.target.closest("[data-plan-id]");
20329
+ if (row) {
20330
+ openAiWritePlanDetail(row.dataset.planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
20331
+ return;
20332
+ }
20333
+ const questionRow = event.target.closest("[data-question-id]");
20334
+ if (questionRow) openAiUserQuestionDialog(questionRow.dataset.questionId).catch((error) => toast(`提问详情加载失败:${error.message}`, "error"));
20335
+ });
20336
+ $("#ai-write-plan-close").addEventListener("click", () => $("#ai-write-plan-dialog").close());
20337
+ $("#ai-write-plan-refresh").addEventListener("click", () => {
20338
+ if (!aiWritePlanDialogPlanId) return;
20339
+ openAiWritePlanDetail(aiWritePlanDialogPlanId).catch((error) => toast(`状态刷新失败:${error.message}`, "error"));
20340
+ });
20341
+ $("#ai-write-plan-confirm").addEventListener("click", () => {
20342
+ if (!aiWritePlanDialogPlanId) return;
20343
+ decideAiWritePlan(aiWritePlanDialogPlanId, "confirm").catch((error) => toast(error.message, "error"));
20344
+ });
20345
+ $("#ai-write-plan-reject").addEventListener("click", () => {
20346
+ if (!aiWritePlanDialogPlanId) return;
20347
+ decideAiWritePlan(aiWritePlanDialogPlanId, "reject").catch((error) => toast(error.message, "error"));
20348
+ });
20349
+ $("#ai-write-plan-undo").addEventListener("click", () => {
20350
+ if (!aiWritePlanDialogPlanId) return;
20351
+ undoAiWritePlan(aiWritePlanDialogPlanId);
20352
+ });
20353
+ $("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
20354
+
20355
+ function syncAiQuestionSubmitState() {
20356
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20357
+ const customInput = $("#ai-question-custom-answer");
20358
+ const submit = $("#ai-question-submit");
20359
+ const disabled = !checked
20360
+ || checked.disabled
20361
+ || (checked.value === "custom" && !String(customInput.value).trim());
20362
+ submit.disabled = disabled;
20363
+ submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
20364
+ }
20365
+ $("#ai-question-form").addEventListener("change", (event) => {
20366
+ const control = event.target;
20367
+ if (control?.name !== "ai-question-choice") return;
20368
+ const isCustom = control.value === "custom";
20369
+ const customInput = $("#ai-question-custom-answer");
20370
+ if (isCustom && !control.disabled) customInput.focus();
20371
+ syncAiQuestionOptionPresentation();
20372
+ syncAiQuestionSubmitState();
20373
+ });
20374
+ $("#ai-question-custom-answer").addEventListener("input", () => {
20375
+ const customInput = $("#ai-question-custom-answer");
20376
+ syncAiQuestionAnswerCount();
20377
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20378
+ if (!checked && String(customInput.value).trim()) {
20379
+ const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
20380
+ if (customRadio && !customRadio.disabled) customRadio.checked = true;
20381
+ }
20382
+ syncAiQuestionOptionPresentation();
20383
+ syncAiQuestionSubmitState();
20384
+ });
20385
+ $("#ai-question-submit").addEventListener("click", () => {
20386
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20387
+ if (!checked || aiQuestionDialogQuestionId == null) return;
20388
+ if (checked.value === "custom") {
20389
+ const text = String($("#ai-question-custom-answer").value).trim();
20390
+ if (!text) return;
20391
+ respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
20392
+ return;
20393
+ }
20394
+ const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
20395
+ respondAiUserQuestion(aiQuestionDialogQuestionId, {
20396
+ action: "option",
20397
+ selectedOption: Number(checked.value),
20398
+ ...(supplementalAnswer ? { customAnswer: supplementalAnswer } : {})
20399
+ }).catch((error) => toast(error.message, "error"));
20400
+ });
20401
+ $("#ai-question-skip").addEventListener("click", () => {
20402
+ if (aiQuestionDialogQuestionId == null) return;
20403
+ respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "reject" }).catch((error) => toast(error.message, "error"));
20404
+ });
19301
20405
  $("#ai-prompt").addEventListener("keydown", (event) => {
19302
20406
  const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
19303
20407
  if (mentionMenuVisible) {
@@ -19392,9 +20496,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
19392
20496
  $("#reader-open-button").addEventListener("click", () => {
19393
20497
  void openReadingPreview({ restorePosition: true });
19394
20498
  });
19395
- $("#chapter-reader-button").addEventListener("click", () => {
19396
- void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
19397
- });
19398
20499
  $("#reader-close").addEventListener("click", closeReadingPreview);
19399
20500
  $("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
19400
20501
  $("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));