@musnows/scriverse 0.9.4 → 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.
@@ -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=20260827-context-input-output-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,6 +1985,37 @@ function scheduleChapterLineNumbers(delay = 0) {
1937
1985
  }, wait);
1938
1986
  }
1939
1987
 
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
+ });
2017
+ }
2018
+
1940
2019
  function lineIndexAtPointer(clientY) {
1941
2020
  const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
1942
2021
  if (!rows.length) return 0;
@@ -2696,6 +2775,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2696
2775
  recall_other: "回忆相识角色",
2697
2776
  recall_known: "回忆知情设定",
2698
2777
  recall_story: "回忆故事",
2778
+ recall_roleplay_memory: "回忆当前扮演线",
2779
+ remember_roleplay: "整理扮演记忆",
2699
2780
  calculate_time: "计算日期"
2700
2781
  };
2701
2782
 
@@ -2712,6 +2793,8 @@ const AI_TOOL_DESCRIPTIONS = {
2712
2793
  recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
2713
2794
  recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
2714
2795
  recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2796
+ recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
2797
+ remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
2715
2798
  calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
2716
2799
  };
2717
2800
 
@@ -2875,6 +2958,10 @@ function openAiToolCallDetail(toolCall) {
2875
2958
 
2876
2959
  function createAiToolCallButton(toolCall) {
2877
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
+ }
2878
2965
  const button = document.createElement("button");
2879
2966
  button.type = "button";
2880
2967
  button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
@@ -2885,6 +2972,392 @@ function createAiToolCallButton(toolCall) {
2885
2972
  return button;
2886
2973
  }
2887
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. */
2888
3361
  function aiToolProcessStep(toolCall, round = 1) {
2889
3362
  const normalizedToolCall = { ...toolCall };
2890
3363
  delete normalizedToolCall.round;
@@ -2945,7 +3418,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2945
3418
  if (!renderableSteps.length) return;
2946
3419
  const details = document.createElement("details");
2947
3420
  details.className = "ai-process-details";
2948
- details.open = !completed;
3421
+ // 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
3422
+ details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
2949
3423
  const summary = document.createElement("summary");
2950
3424
  const title = document.createElement("span");
2951
3425
  title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
@@ -3209,6 +3683,266 @@ function renderAiConversationHistory() {
3209
3683
  }
3210
3684
  }
3211
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
+
3212
3946
  function defaultAiConversationTitle(prompt) {
3213
3947
  const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
3214
3948
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -3297,7 +4031,7 @@ async function ensureAiConversationsLoaded() {
3297
4031
  }
3298
4032
  }
3299
4033
 
3300
- async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
4034
+ async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
3301
4035
  if (!state.work) return null;
3302
4036
  const existingTab = aiChatTabManager.findByConversation(conversationId);
3303
4037
  const existingMessage = focusMessageId
@@ -3318,6 +4052,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
3318
4052
  try {
3319
4053
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
3320
4054
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
4055
+ if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
3321
4056
  const [conversation] = await Promise.all([
3322
4057
  api(`/api/ai-conversations/${conversationId}?${parameters}`),
3323
4058
  ensureAiReferencesLoaded()
@@ -5617,12 +6352,44 @@ function setSaveState(text, dirty = false) {
5617
6352
  });
5618
6353
  }
5619
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
+
5620
6384
  function chapterDraftSnapshot() {
5621
6385
  if (!state.chapter) return null;
6386
+ const content = $("#chapter-content").value;
6387
+ const lineIds = syncChapterDraftLineIds(content);
5622
6388
  return {
5623
6389
  chapterId: state.chapter.id,
5624
6390
  title: $("#chapter-title").value.trim(),
5625
- content: $("#chapter-content").value
6391
+ content,
6392
+ ...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
5626
6393
  };
5627
6394
  }
5628
6395
 
@@ -5697,7 +6464,7 @@ async function persistChapter({ automatic = false } = {}) {
5697
6464
  const request = (async () => {
5698
6465
  const chapter = await api(`/api/chapters/${draft.chapterId}`, {
5699
6466
  method: "PATCH",
5700
- 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" }
5701
6468
  });
5702
6469
  const work = await api(`/api/works/${workId}`);
5703
6470
  return { chapter, work };
@@ -5708,9 +6475,15 @@ async function persistChapter({ automatic = false } = {}) {
5708
6475
  if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
5709
6476
  state.chapter = saved.chapter;
5710
6477
  state.work = saved.work;
6478
+ resetChapterDraftLineIds(state.chapter);
5711
6479
  lastSavedChapterSnapshot = draft;
5712
6480
  renderTree();
5713
6481
  updateChapterStats();
6482
+ try {
6483
+ await loadChapterAnnotationCounts(saved.chapter.id);
6484
+ } catch (error) {
6485
+ toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
6486
+ }
5714
6487
  const currentDraft = chapterDraftSnapshot();
5715
6488
  if (sameChapterSnapshot(currentDraft, draft)) {
5716
6489
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
@@ -5785,13 +6558,35 @@ function restoredSettingsReturnContext(route) {
5785
6558
  }
5786
6559
 
5787
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;
5788
6565
  const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
5789
6566
  if (!authenticated) {
5790
6567
  restoringPageRoute = false;
5791
6568
  return;
5792
6569
  }
5793
- const route = parsePageRoute(window.location.hash);
5794
- 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;
5795
6590
  try {
5796
6591
  if (route.view === "shelf") {
5797
6592
  showShelf();
@@ -5832,7 +6627,7 @@ async function initializePage() {
5832
6627
  return;
5833
6628
  }
5834
6629
  const options = { readOnly: route.entityMode === "read" };
5835
- if (route.entity === "setting") openSettingEditor(item, options);
6630
+ if (route.entity === "setting") await openSettingEditor(item, options);
5836
6631
  else if (route.entity === "character") await openCharacterEditor(item, options);
5837
6632
  else if (route.entity === "race") await openRaceDialog(item, options);
5838
6633
  else if (route.entity === "organization") await openOrganizationDialog(item, options);
@@ -5863,7 +6658,14 @@ async function initializePage() {
5863
6658
  settingsReturnContext = restoredSettingsReturnContext(route);
5864
6659
  }
5865
6660
  } finally {
6661
+ delete window.__scriverseReaderChapterPrefetch;
6662
+ delete window.__scriverseReaderWorksPrefetch;
6663
+ delete window.__scriverseReaderWorkPrefetch;
6664
+ initialWorkDetail = null;
6665
+ initialReaderChapterRequest = null;
5866
6666
  document.body.classList.remove("auth-pending");
6667
+ document.documentElement.removeAttribute("data-pending-view");
6668
+ document.documentElement.classList.remove("pending-shelf-mode");
5867
6669
  restoringPageRoute = false;
5868
6670
  replacePageRoute(currentPageRoute());
5869
6671
  scheduleFirstUseOnboarding();
@@ -6971,6 +7773,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
6971
7773
  const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
6972
7774
  if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
6973
7775
  state.chapter = chapter;
7776
+ resetChapterDraftLineIds(state.chapter);
6974
7777
  mergeChapterDirectoryEntry(chapter);
6975
7778
  lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
6976
7779
  await loadVolumeChapters(chapter.volumeId);
@@ -7392,7 +8195,9 @@ async function selectWork(workId, preferredChapterId = null) {
7392
8195
  volumeChapterLoadingIds.clear();
7393
8196
  volumeChapterRequests.clear();
7394
8197
  }
7395
- 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`);
7396
8201
  if (selectionGeneration !== workSelectionRequestGeneration) return false;
7397
8202
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
7398
8203
  showSystemStatus();
@@ -7462,9 +8267,12 @@ async function loadVolumeChapters(volumeId) {
7462
8267
  async function loadAllVolumeChapters(workId) {
7463
8268
  const generation = workScopedUiGeneration;
7464
8269
  const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
7465
- for (const volumeId of volumeIds) {
7466
- if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
7467
- 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
+ }));
7468
8276
  }
7469
8277
  }
7470
8278
 
@@ -7837,9 +8645,14 @@ function currentChapterForeshadowReminder() {
7837
8645
 
7838
8646
  function syncMobileAiPanelSafeTop() {
7839
8647
  const container = $("#chapter-foreshadow-reminder");
7840
- const safeTop = container.classList.contains("hidden")
8648
+ const toolbar = $("#editor-view .editor-toolbar");
8649
+ const reminderBottom = container.classList.contains("hidden")
7841
8650
  ? 0
7842
- : Math.ceil(container.getBoundingClientRect().bottom);
8651
+ : container.getBoundingClientRect().bottom;
8652
+ const toolbarBottom = $("#editor-view").classList.contains("hidden")
8653
+ ? 0
8654
+ : toolbar.getBoundingClientRect().bottom;
8655
+ const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
7843
8656
  $("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
7844
8657
  }
7845
8658
 
@@ -8061,6 +8874,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
8061
8874
  $("#chapter-path").title = chapterPath;
8062
8875
  $("#chapter-title").value = state.chapter.title;
8063
8876
  $("#chapter-content").value = state.chapter.content;
8877
+ resetChapterDraftLineIds(state.chapter);
8064
8878
  chapterAnnotationCounts = new Map();
8065
8879
  clearChapterLineSelection();
8066
8880
  scheduleChapterLineNumbers();
@@ -8183,6 +8997,7 @@ function renderReadingNavigation() {
8183
8997
  $("#reader-next").disabled = !next || readingLoading;
8184
8998
  $("#reader-continue").disabled = !next || readingLoading;
8185
8999
  $("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
9000
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8186
9001
  const paged = readingPreferences.mode === "paged";
8187
9002
  const previousPage = current && paged ? resolvePagedReadingStep({
8188
9003
  sequence: readingSequence,
@@ -8220,7 +9035,7 @@ function applyReadingPreferences() {
8220
9035
  $("#reader-font-size").value = String(readingPreferences.fontSize);
8221
9036
  $("#reader-line-height").value = String(readingPreferences.lineHeight);
8222
9037
  $("#reader-theme").value = readingPreferences.theme;
8223
- $("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
9038
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8224
9039
  $("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
8225
9040
  $("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
8226
9041
  }
@@ -8355,7 +9170,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
8355
9170
  renderReadingStatus("正在载入章节……");
8356
9171
  replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
8357
9172
  try {
8358
- 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 });
8359
9180
  if (!readingRequestGate.isCurrent(request)) return false;
8360
9181
  if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
8361
9182
  throw new Error("章节响应与当前作品不匹配");
@@ -8509,7 +9330,7 @@ function closeReadingPreview() {
8509
9330
  replacePageRoute(returnRoute);
8510
9331
  const focus = readingPreviousFocus;
8511
9332
  readingPreviousFocus = null;
8512
- const focusCandidates = [focus, $("#chapter-reader-button"), $("#reader-open-button"), $("#home-button")];
9333
+ const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
8513
9334
  for (const candidate of focusCandidates) {
8514
9335
  if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
8515
9336
  const rect = candidate.getBoundingClientRect();
@@ -8748,6 +9569,14 @@ function pencilIconMarkup() {
8748
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>';
8749
9570
  }
8750
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
+
8751
9580
  function characterFavoriteIconMarkup() {
8752
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>';
8753
9582
  }
@@ -12432,15 +13261,19 @@ async function renderBookAiSettings() {
12432
13261
  clearTimeout(relationshipSearchIndexRefreshTimer);
12433
13262
  relationshipSearchIndexRefreshTimer = null;
12434
13263
  }
12435
- const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
13264
+ const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions, writeTools] = await Promise.all([
12436
13265
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
12437
13266
  moduleApi("ai-settings", "/api/platform/ai/providers"),
12438
13267
  moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
12439
13268
  moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
12440
13269
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
12441
13270
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
12442
- 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)
12443
13274
  ]);
13275
+ const writeToolsState = writeTools?.tools ?? null;
13276
+ const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
12444
13277
  const host = $("#module-content");
12445
13278
  platformAiProtocolOptions = protocolOptions;
12446
13279
  const workId = String(state.work.id);
@@ -12490,6 +13323,7 @@ async function renderBookAiSettings() {
12490
13323
  host.querySelectorAll(".config-section").forEach((section) => {
12491
13324
  if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
12492
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>`);
12493
13327
  bindUsageCalendarInteractions(host);
12494
13328
  scrollUsageCalendarsToLatest(host);
12495
13329
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
@@ -12748,6 +13582,23 @@ async function renderBookAiSettings() {
12748
13582
  button.disabled = false;
12749
13583
  }
12750
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
+ });
12751
13602
  host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
12752
13603
  const select = event.currentTarget;
12753
13604
  select.disabled = true;
@@ -12886,7 +13737,7 @@ function renderAiContextDistribution(usage) {
12886
13737
  const description = document.createElement("small");
12887
13738
  description.textContent = item.key === "skills"
12888
13739
  ? "待加入"
12889
- : item.key === "input" ? "用户和 agent 的交互" : "模型输出预留";
13740
+ : item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
12890
13741
  title.append(" ", description);
12891
13742
  }
12892
13743
  const value = document.createElement("strong");
@@ -13205,7 +14056,7 @@ function commitRelationshipKeywordInputs(container) {
13205
14056
  container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
13206
14057
  }
13207
14058
 
13208
- function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
14059
+ async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13209
14060
  formDialogVditors.forEach(destroyVditorEditor);
13210
14061
  formDialogVditors = [];
13211
14062
  void discardPendingMarkdownAttachments();
@@ -13255,6 +14106,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13255
14106
  dialog.classList.toggle("editor-dialog", Boolean(options.editor));
13256
14107
  bindDynamicListControls($("#dialog-fields"));
13257
14108
  bindRelationshipKeywordControls($("#dialog-fields"));
14109
+ if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
13258
14110
  formDialogVditors = bindVditorEditors($("#dialog-fields"));
13259
14111
  form.onclick = null;
13260
14112
  form.onkeydown = null;
@@ -13498,18 +14350,36 @@ function openWorkSettingsDialog(work) {
13498
14350
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
13499
14351
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
13500
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>`;
13501
14360
  openDialog("作品信息",
13502
- 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,
13503
14362
  async (form) => {
13504
- 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
+ } });
13505
14370
  state.works = (await apiPage("/api/works")).items;
13506
14371
  const updated = state.works.find((item) => item.id === work.id);
13507
14372
  if (updated) Object.assign(work, updated);
13508
14373
  if (state.work?.id === work.id) {
13509
- state.work.title = String(form.get("title") ?? state.work.title);
13510
- state.work.author = String(form.get("author") ?? state.work.author);
13511
- state.work.description = String(form.get("description") ?? state.work.description);
13512
- 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();
13513
14383
  updateDocumentTitle(state.work);
13514
14384
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
13515
14385
  }
@@ -13673,7 +14543,8 @@ function syncSettingEditorDirty(markdown = null) {
13673
14543
  entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
13674
14544
  }
13675
14545
 
13676
- function openSettingEditor(item = null, { readOnly = false } = {}) {
14546
+ async function openSettingEditor(item = null, { readOnly = false } = {}) {
14547
+ if (!(await loadVditorResources())) return;
13677
14548
  entityEditorReadOnly = readOnly;
13678
14549
  destroyVditorEditor(settingEditorVditor);
13679
14550
  settingEditorVditor = null;
@@ -13688,7 +14559,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13688
14559
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
13689
14560
  const editButton = $("#setting-editor-edit");
13690
14561
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13691
- editButton.onclick = () => openSettingEditor(settingEditorItem);
14562
+ editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
13692
14563
  bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
13693
14564
  settingEditorItem = updated;
13694
14565
  state.settings = upsertEntityCollection(state.settings, updated);
@@ -13801,9 +14672,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13801
14672
  (readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
13802
14673
  }
13803
14674
 
13804
- function characterEditorSection(key, title, description, content) {
13805
- return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}" data-character-editor-panel="${esc(key)}" role="tabpanel">
13806
- <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}
13807
14682
  <div class="character-editor-section-fields">${content}</div>
13808
14683
  </section>`;
13809
14684
  }
@@ -13824,6 +14699,14 @@ function activateCharacterEditorTab(key) {
13824
14699
  ) {
13825
14700
  void loadCharacterEditorRelationships(characterEditorItem.id);
13826
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
+ }
13827
14710
  }
13828
14711
 
13829
14712
  function setCharacterHistoryVisible(visible) {
@@ -14052,12 +14935,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
14052
14935
  };
14053
14936
  }
14054
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
+
14055
14996
  function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
14056
14997
  if (!window.Vditor) {
14057
14998
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
14058
14999
  return null;
14059
15000
  }
14060
- ensureVditorIconScript();
14061
15001
  let editor = null;
14062
15002
  editor = new window.Vditor(host, {
14063
15003
  cdn: "/vendor/vditor",
@@ -14078,7 +15018,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
14078
15018
  className: "vditor-line-number-button",
14079
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>',
14080
15020
  click: () => toggleVditorLineNumbers(editor)
14081
- }, "edit-mode", "fullscreen"],
15021
+ }, "edit-mode"],
14082
15022
  upload: {
14083
15023
  accept: "image/*",
14084
15024
  max: 10 * 1024 * 1024,
@@ -14291,14 +15231,6 @@ function normalizeVditorAttachmentImages(editor) {
14291
15231
  });
14292
15232
  }
14293
15233
 
14294
- function ensureVditorIconScript() {
14295
- if (document.getElementById("vditorIconScript")) return;
14296
- const script = document.createElement("script");
14297
- script.id = "vditorIconScript";
14298
- script.src = "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2";
14299
- document.body.appendChild(script);
14300
- }
14301
-
14302
15234
  function destroyVditorEditor(editor) {
14303
15235
  if (!editor) return;
14304
15236
  editor.__attachmentObserver?.disconnect();
@@ -14407,6 +15339,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
14407
15339
 
14408
15340
  async function openKnowledgeSectionEditor(index = null) {
14409
15341
  if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
15342
+ if (!(await loadVditorResources())) return;
14410
15343
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
14411
15344
  destroyVditorEditor(knowledgeSectionVditor);
14412
15345
  knowledgeSectionVditor = null;
@@ -14519,6 +15452,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
14519
15452
  }
14520
15453
 
14521
15454
  async function openCharacterSectionEditor(section = null) {
15455
+ if (!(await loadVditorResources())) return;
14522
15456
  await discardPendingCharacterAttachments();
14523
15457
  destroyVditorEditor(characterSectionVditor);
14524
15458
  characterSectionVditor = null;
@@ -14699,6 +15633,30 @@ function renderCharacterAvatar(item) {
14699
15633
  }
14700
15634
  }
14701
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
+
14702
15660
  function renderCharacterEditorFields(item) {
14703
15661
  const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
14704
15662
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
@@ -14747,7 +15705,12 @@ function renderCharacterEditorFields(item) {
14747
15705
  '<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
14748
15706
  field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
14749
15707
  characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
14750
- '<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() : "")
14751
15714
  ].join("");
14752
15715
  const name = $("#character-editor-fields [name='name']");
14753
15716
  if (name) name.required = true;
@@ -14755,6 +15718,7 @@ function renderCharacterEditorFields(item) {
14755
15718
  renderCharacterAvatar(item);
14756
15719
  renderCharacterEditorRelationships();
14757
15720
  renderCharacterMarkdownSections();
15721
+ bindRoleplayMemorySurface(item);
14758
15722
  activateCharacterEditorTab("basic");
14759
15723
  }
14760
15724
 
@@ -14923,6 +15887,10 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14923
15887
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
14924
15888
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
14925
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
+ }
14926
15894
  }
14927
15895
  $("#character-change-note").readOnly = viewOnly;
14928
15896
  $("#character-editor-submit").classList.toggle("hidden", viewOnly);
@@ -14949,6 +15917,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14949
15917
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
14950
15918
  relationshipTab.disabled = !item || !canReadModule("relationships");
14951
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 ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
14952
15923
  const form = $("#character-editor-form");
14953
15924
  form.onsubmit = async (event) => {
14954
15925
  event.preventDefault();
@@ -16246,6 +17217,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16246
17217
  const tab = activeAiChatTab();
16247
17218
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
16248
17219
  if (aiRequestManager.hasActive(tab.id)) return;
17220
+ if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
16249
17221
  const composerSnapshot = captureAiPromptComposer();
16250
17222
  const requestComposerSnapshot = retry
16251
17223
  ? {
@@ -16727,6 +17699,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16727
17699
  toolCalls.push(toolCall);
16728
17700
  processSteps.push(aiToolProcessStep(toolCall, round));
16729
17701
  renderStreamingProcessSteps(false, elapsedProcessTime());
17702
+ handleInteractiveToolCallEvent(toolCall);
16730
17703
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
16731
17704
  scrollAiFeedToBottom(feed);
16732
17705
  } else if (eventName === "context_compacted") {
@@ -16986,6 +17959,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null, option
16986
17959
  try {
16987
17960
  const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
16988
17961
  state.chapter = result.chapter;
17962
+ resetChapterDraftLineIds(state.chapter);
16989
17963
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
16990
17964
  $("#chapter-content").value = state.chapter.content;
16991
17965
  scheduleChapterLineNumbers();
@@ -17079,6 +18053,7 @@ async function showVersions() {
17079
18053
  }
17080
18054
  try {
17081
18055
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
18056
+ resetChapterDraftLineIds(state.chapter);
17082
18057
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
17083
18058
  $("#chapter-title").value = state.chapter.title;
17084
18059
  $("#chapter-content").value = state.chapter.content;
@@ -18436,10 +19411,7 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
18436
19411
  renderChapterForeshadowReminder();
18437
19412
  $("#chapter-foreshadow-reminder-details-button").focus();
18438
19413
  });
18439
- $("#chapter-delete-button").addEventListener("click", () => {
18440
- if (state.chapter) void deleteChapter(state.chapter.id);
18441
- });
18442
- $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
19414
+ $("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
18443
19415
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
18444
19416
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
18445
19417
  $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
@@ -18552,11 +19524,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
18552
19524
  toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
18553
19525
  });
18554
19526
  $("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
18555
- $("#chapter-content").addEventListener("input", () => {
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
+ });
19565
+ $("#chapter-content").addEventListener("input", (event) => {
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;
18556
19575
  updateChapterStats();
18557
19576
  scheduleChapterAutoSave();
18558
19577
  clearChapterLineSelection();
18559
19578
  scheduleChapterLineNumbers(chapterLineInputRenderDelay);
19579
+ scheduleChapterCaretScroll();
18560
19580
  setAiContextMeter(null);
18561
19581
  });
18562
19582
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
@@ -19279,6 +20299,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
19279
20299
  if (label) label.textContent = "导出 Markdown";
19280
20300
  }
19281
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
+ });
19282
20405
  $("#ai-prompt").addEventListener("keydown", (event) => {
19283
20406
  const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
19284
20407
  if (mentionMenuVisible) {
@@ -19373,9 +20496,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
19373
20496
  $("#reader-open-button").addEventListener("click", () => {
19374
20497
  void openReadingPreview({ restorePosition: true });
19375
20498
  });
19376
- $("#chapter-reader-button").addEventListener("click", () => {
19377
- void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
19378
- });
19379
20499
  $("#reader-close").addEventListener("click", closeReadingPreview);
19380
20500
  $("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
19381
20501
  $("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));