@musnows/scriverse 0.9.4 → 0.9.6

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.
Files changed (45) hide show
  1. package/dist/ai-protocol.js +6 -0
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-skills.js +134 -0
  4. package/dist/ai-skills.js.map +1 -0
  5. package/dist/ai-write-plans.js +2279 -0
  6. package/dist/ai-write-plans.js.map +1 -0
  7. package/dist/ai.js +2781 -154
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +328 -17
  10. package/dist/app.js.map +1 -1
  11. package/dist/chapter-annotation-anchor.js +327 -0
  12. package/dist/chapter-annotation-anchor.js.map +1 -0
  13. package/dist/chapter-title-numbering.js +56 -0
  14. package/dist/chapter-title-numbering.js.map +1 -0
  15. package/dist/database.js +478 -3
  16. package/dist/database.js.map +1 -1
  17. package/dist/public/ai-context-meter.js +3 -3
  18. package/dist/public/ai-interactive.js +483 -0
  19. package/dist/public/app.js +1991 -268
  20. package/dist/public/chapter-editor-behavior.js +40 -0
  21. package/dist/public/chapter-line-id-tracker.d.ts +25 -0
  22. package/dist/public/chapter-line-id-tracker.js +151 -0
  23. package/dist/public/index.html +79 -19
  24. package/dist/public/model-config.d.ts +1 -0
  25. package/dist/public/model-config.js +9 -4
  26. package/dist/public/styles.css +337 -57
  27. package/dist/public/theme-init.js +25 -1
  28. package/dist/remote-mcp.js +496 -0
  29. package/dist/remote-mcp.js.map +1 -0
  30. package/dist/roleplay-memory.js +53 -0
  31. package/dist/roleplay-memory.js.map +1 -0
  32. package/dist/security.js +4 -0
  33. package/dist/security.js.map +1 -1
  34. package/dist/semantic-search.js +225 -0
  35. package/dist/semantic-search.js.map +1 -0
  36. package/dist/skills/continue-writing/SKILL.md +23 -0
  37. package/dist/skills/polish-writing/SKILL.md +23 -0
  38. package/dist/store.js +695 -51
  39. package/dist/store.js.map +1 -1
  40. package/dist/ui-module-preload.js +27 -0
  41. package/dist/ui-module-preload.js.map +1 -0
  42. package/dist/user-auth.js +33 -1
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
@@ -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,
@@ -24,7 +25,7 @@ import {
24
25
  visibleForeshadowReminders
25
26
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
26
27
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
27
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1";
28
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1&feature=semantic-search-v6";
28
29
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
29
30
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
30
31
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
@@ -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, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-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";
@@ -167,12 +180,16 @@ function normalizePageSizes(value) {
167
180
  ]));
168
181
  }
169
182
 
170
- function isSelectableModel(model) {
183
+ function isAvailableConfiguredModel(model) {
171
184
  return Boolean(model?.enabled)
172
185
  && model?.providerStatus === "enabled"
173
186
  && model?.providerConnectionStatus === "success";
174
187
  }
175
188
 
189
+ function isSelectableModel(model) {
190
+ return (model?.modelKind ?? "chat") === "chat" && isAvailableConfiguredModel(model);
191
+ }
192
+
176
193
  const state = {
177
194
  user: null,
178
195
  csrfToken: null,
@@ -188,6 +205,7 @@ const state = {
188
205
  aiCitations: [],
189
206
  aiReferences: [],
190
207
  aiImageAttachments: [],
208
+ aiSemanticSnapshot: null,
191
209
  aiPromptSent: false,
192
210
  aiTaskType: "chat",
193
211
  aiContextScope: { type: "none" },
@@ -274,6 +292,7 @@ let readingReturnRoute = null;
274
292
  let readingPreviousFocus = null;
275
293
  let readingPositionSaveTimer = null;
276
294
  let readingResizeFrame = null;
295
+ const readingVolumeDirectoryConcurrency = 4;
277
296
 
278
297
  let timelineMultiSelectEnabled = false;
279
298
  let timelineActiveTrackId = null;
@@ -282,6 +301,9 @@ let taskProgressRefreshTimer = null;
282
301
  let taskAutoRunEditing = false;
283
302
  let taskAutoRunEditingWorkId = null;
284
303
  let relationshipSearchIndexRefreshTimer = null;
304
+ let semanticSearchIndexRefreshTimer = null;
305
+ let aiSemanticSearchResults = [];
306
+ let aiSemanticSearchQuery = "";
285
307
  let backgroundTaskCenterTimer = null;
286
308
  let backgroundTaskCenterRequest = 0;
287
309
  let backgroundTaskCenterWorkId = null;
@@ -444,6 +466,10 @@ function canGlobalReplaceAny(work = state.work) {
444
466
  return Boolean(work) && (canGlobalReplaceScope("prose", work) || canGlobalReplaceScope("settings", work));
445
467
  }
446
468
 
469
+ function applyChapterEditorPreferences() {
470
+ $("#app").classList.toggle("editor-typewriter-mode", Boolean(state.work?.editorTypewriterModeEnabled));
471
+ }
472
+
447
473
  function applyWorkAccessMode() {
448
474
  const viewOnly = Boolean(state.work) && !canEditWork();
449
475
  const proseReadOnly = Boolean(state.work) && !canEditProse();
@@ -456,6 +482,7 @@ function applyWorkAccessMode() {
456
482
  $("#app").classList.toggle("prose-hidden-mode", proseHidden);
457
483
  $("#app").classList.toggle("ai-hidden-mode", aiHidden);
458
484
  document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
485
+ applyChapterEditorPreferences();
459
486
  for (const item of WORK_PERMISSION_MODULES) {
460
487
  if (!item.uiModule) continue;
461
488
  const button = $(`#module-nav [data-module="${item.uiModule}"]`);
@@ -621,7 +648,10 @@ function assertAiRequestCurrent(request) {
621
648
  }
622
649
 
623
650
  function aiInteractionBusy() {
624
- return aiRequestManager.hasActive(activeAiChatTab()?.id) || aiConversationNavigationPending !== null;
651
+ const activeTabId = activeAiChatTab()?.id;
652
+ return aiRequestManager.hasActive(activeTabId)
653
+ || (activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false)
654
+ || aiConversationNavigationPending !== null;
625
655
  }
626
656
 
627
657
  function aiSendButtonIconMarkup(stateName) {
@@ -631,12 +661,14 @@ function aiSendButtonIconMarkup(stateName) {
631
661
  }
632
662
 
633
663
  function syncAiRequestControls() {
634
- const sending = aiRequestManager.hasActive(activeAiChatTab()?.id);
664
+ const activeTabId = activeAiChatTab()?.id;
665
+ const sending = aiRequestManager.hasActive(activeTabId);
666
+ const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
635
667
  const switching = aiConversationNavigationPending !== null;
636
668
  const button = $("#ai-send");
637
- const stateName = sending ? "stop" : switching ? "switching" : "send";
638
- const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
639
- button.disabled = switching;
669
+ const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
670
+ const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
671
+ button.disabled = switching || continuingQuestion;
640
672
  button.dataset.state = stateName;
641
673
  button.classList.toggle("is-stop", sending);
642
674
  button.setAttribute("aria-label", label);
@@ -779,6 +811,8 @@ const aiConversationHistoryPageLimit = 20;
779
811
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
780
812
  let workScopedUiGeneration = 0;
781
813
  let workSelectionRequestGeneration = 0;
814
+ let initialWorkDetail = null;
815
+ let initialReaderChapterRequest = null;
782
816
  let chapterSelectionRequestGeneration = 0;
783
817
  let aiConversationNavigationGeneration = 0;
784
818
  let aiConversationNavigationPending = null;
@@ -815,7 +849,7 @@ const workspaceOnboardingSteps = [
815
849
  { selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲/伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
816
850
  { selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
817
851
  { selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
818
- { selector: ".quick-actions button[data-task=\"continue\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
852
+ { selector: ".quick-actions button[data-prompt^=\"续写\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
819
853
  { selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
820
854
  { selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和正文 ZIP 导出都集中在这里。", placement: "bottom" },
821
855
  { selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
@@ -1324,6 +1358,7 @@ function replaceCurrentChapterSearchMatch() {
1324
1358
  const input = $("#chapter-content");
1325
1359
  const replacement = $("#chapter-replace-query").value;
1326
1360
  input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
1361
+ syncChapterDraftLineIds(input.value);
1327
1362
  chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
1328
1363
  updateChapterStats();
1329
1364
  clearChapterLineSelection();
@@ -1343,6 +1378,7 @@ function replaceAllChapterSearchMatches() {
1343
1378
  const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
1344
1379
  if (!result.matches) return renderChapterSearchStatus();
1345
1380
  input.value = result.content;
1381
+ syncChapterDraftLineIds(input.value);
1346
1382
  chapterSearchMatchIndex = -1;
1347
1383
  updateChapterStats();
1348
1384
  clearChapterLineSelection();
@@ -1461,6 +1497,7 @@ let chapterLineNumberFrame = null;
1461
1497
  let chapterLineNumberTimer = null;
1462
1498
  let chapterLineLayout = null;
1463
1499
  let chapterLineVirtualWindow = null;
1500
+ let chapterCaretScrollFrame = null;
1464
1501
  let chapterLineSelection = null;
1465
1502
  let chapterLineDrag = null;
1466
1503
  let chapterWhitespaceVisible = true;
@@ -1468,6 +1505,8 @@ let chapterAutoSaveTimer = null;
1468
1505
  let chapterSaveInFlight = null;
1469
1506
  let chapterSaveGuardInFlight = null;
1470
1507
  let lastSavedChapterSnapshot = null;
1508
+ let chapterDraftLineIdState = null;
1509
+ let chapterBeforeInputState = null;
1471
1510
  let chapterSearchMatchIndex = -1;
1472
1511
  let chapterSelectionRequestId = 0;
1473
1512
  let chapterForeshadowReminderRequestId = 0;
@@ -1499,6 +1538,11 @@ let taskListPage = 1;
1499
1538
  let draftTypeFilter = "all";
1500
1539
  let draftBindingFilters = [];
1501
1540
  let draftFiltersPanelOpen = false;
1541
+ const chapterCommentFilters = { chapterId: "", keyword: "" };
1542
+ let chapterCommentFiltersPanelOpen = false;
1543
+ let chapterCommentChapterOptions = [];
1544
+ let chapterCommentSearchTimer = null;
1545
+ let chapterCommentRenderRequestId = 0;
1502
1546
  const moduleListPages = {
1503
1547
  drafts: 1,
1504
1548
  settings: 1,
@@ -1553,6 +1597,7 @@ let characterSectionEditorDirty = false;
1553
1597
  let settingEditorVditor = null;
1554
1598
  let knowledgeSectionVditor = null;
1555
1599
  let characterSectionVditor = null;
1600
+ let vditorResourcesPromise = null;
1556
1601
  const settingEditorDirtyTracker = createEditorDirtyTracker();
1557
1602
  const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
1558
1603
  let formDialogVditors = [];
@@ -1562,17 +1607,22 @@ let moduleContentInteractionsBound = false;
1562
1607
  function applyChapterEditorMode() {
1563
1608
  const permissionBlocked = Boolean(state.work) && !canEditProse();
1564
1609
  const viewOnly = permissionBlocked || chapterEditorReadOnly;
1610
+ const editButton = $("#chapter-edit-button");
1565
1611
  $("#editor-view").classList.toggle("is-read-only", viewOnly);
1566
1612
  $("#chapter-title").readOnly = viewOnly;
1567
1613
  $("#chapter-content").readOnly = viewOnly;
1568
1614
  $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
1569
1615
  $("#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);
1616
+ editButton.classList.toggle("hidden", permissionBlocked || !state.chapter);
1617
+ editButton.classList.toggle("primary-button", chapterEditorReadOnly);
1618
+ editButton.classList.toggle("ghost-button", !chapterEditorReadOnly);
1619
+ editButton.textContent = chapterEditorReadOnly ? "编辑" : "预览";
1620
+ editButton.setAttribute("aria-pressed", String(!chapterEditorReadOnly));
1621
+ editButton.setAttribute("aria-label", chapterEditorReadOnly ? "切换到编辑模式" : "切换到预览模式");
1572
1622
  $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
1573
- $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1574
1623
  syncChapterSearchControls();
1575
1624
  if (viewOnly) cancelChapterAutoSave();
1625
+ syncMobileAiPanelSafeTop();
1576
1626
  }
1577
1627
 
1578
1628
  function enterChapterEditMode() {
@@ -1585,6 +1635,19 @@ function enterChapterEditMode() {
1585
1635
  $("#chapter-content").focus();
1586
1636
  }
1587
1637
 
1638
+ function toggleChapterEditPreviewMode() {
1639
+ if (!state.chapter || !canEditProse()) return;
1640
+ if (chapterEditorReadOnly) {
1641
+ enterChapterEditMode();
1642
+ return;
1643
+ }
1644
+ chapterEditorReadOnly = true;
1645
+ cancelChapterAutoSave();
1646
+ applyChapterEditorMode();
1647
+ setSaveState(state.dirty ? "预览中 · 有未保存修改" : "预览中", state.dirty);
1648
+ $("#chapter-edit-button").focus();
1649
+ }
1650
+
1588
1651
  function showEntityEditorPage(type, { readOnly = false } = {}) {
1589
1652
  const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
1590
1653
  const viewOnly = readOnly || !canEditModule(module);
@@ -1937,6 +2000,37 @@ function scheduleChapterLineNumbers(delay = 0) {
1937
2000
  }, wait);
1938
2001
  }
1939
2002
 
2003
+ function scheduleChapterCaretScroll() {
2004
+ if (!state.work?.editorTypewriterModeEnabled || chapterCaretScrollFrame !== null) return;
2005
+ chapterCaretScrollFrame = requestAnimationFrame(() => {
2006
+ chapterCaretScrollFrame = null;
2007
+ const input = $("#chapter-content");
2008
+ const measure = $("#chapter-line-measure");
2009
+ if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
2010
+ const style = getComputedStyle(input);
2011
+ const paddingTop = parseFloat(style.paddingTop) || 0;
2012
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
2013
+ const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
2014
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
2015
+ const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
2016
+ const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
2017
+ const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
2018
+ const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
2019
+ const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
2020
+ const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
2021
+ const nextScrollTop = calculateChapterCaretScroll({
2022
+ caretBottom,
2023
+ scrollTop: input.scrollTop,
2024
+ clientHeight: input.clientHeight,
2025
+ scrollHeight: input.scrollHeight
2026
+ });
2027
+ if (nextScrollTop === input.scrollTop) return;
2028
+ input.scrollTop = nextScrollTop;
2029
+ syncChapterLineNumberScroll();
2030
+ scheduleChapterLineNumbers();
2031
+ });
2032
+ }
2033
+
1940
2034
  function lineIndexAtPointer(clientY) {
1941
2035
  const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
1942
2036
  if (!rows.length) return 0;
@@ -2010,6 +2104,148 @@ function renderAiCitations() {
2010
2104
  setAiContextMeter(null);
2011
2105
  }
2012
2106
 
2107
+ function setAiSemanticSearchVisible(visible) {
2108
+ const panel = $("#ai-semantic-search-panel");
2109
+ panel.classList.toggle("hidden", !visible);
2110
+ $("#ai-semantic-search-toggle").setAttribute("aria-expanded", String(visible));
2111
+ if (visible) $("#ai-semantic-query").focus();
2112
+ }
2113
+
2114
+ function semanticSearchScopeTypes(value) {
2115
+ return ({
2116
+ chapter: ["chapter"],
2117
+ setting: ["setting"],
2118
+ character: ["character"],
2119
+ race: ["race"],
2120
+ organization: ["organization"],
2121
+ timeline: ["timeline-track", "timeline-event"],
2122
+ relationship: ["relationship"],
2123
+ outlines: ["chapter-outline", "foreshadow"]
2124
+ })[value] ?? undefined;
2125
+ }
2126
+
2127
+ function syncAiSemanticSelectionSummary() {
2128
+ const selected = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')];
2129
+ const tokenCount = selected.reduce((total, input) => total + Number(input.dataset.estimatedTokens || 0), 0);
2130
+ $("#ai-semantic-selection-summary").textContent = selected.length
2131
+ ? `已选择 ${selected.length} 项,预计 ${tokenCount.toLocaleString("zh-CN")} Token`
2132
+ : "尚未选择结果";
2133
+ $("#ai-semantic-inject").disabled = selected.length === 0 || !state.work || !canWritePermissionModule(state.work, "ai-chat");
2134
+ }
2135
+
2136
+ function renderAiSemanticSearchResults(search) {
2137
+ aiSemanticSearchResults = Array.isArray(search?.results) ? search.results : [];
2138
+ const host = $("#ai-semantic-search-results");
2139
+ const matchLabels = { metadata: "资料", exact: "精确", phonetic: "拼音", semantic: "语义" };
2140
+ host.innerHTML = aiSemanticSearchResults.length
2141
+ ? aiSemanticSearchResults.map((item, index) => {
2142
+ const lineRange = Number.isInteger(item.startLine)
2143
+ ? `<span>${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
2144
+ : "";
2145
+ const matchKinds = (Array.isArray(item.matchKinds) ? item.matchKinds : [])
2146
+ .map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchLabels[kind] ?? kind)}</span>`).join("");
2147
+ const relevance = typeof item.semanticScore === "number" ? `<span>相似度 ${Math.round(item.semanticScore * 1000) / 10}%</span>` : "";
2148
+ const rerank = typeof item.rerankScore === "number" ? `<span>Rerank ${item.rerankScore > 0 ? "相关" : "不相关"}</span>` : "";
2149
+ return `<article class="ai-semantic-result" data-semantic-result-index="${index}"><label>${item.entryId ? `<input type="checkbox" data-semantic-entry-id="${esc(item.entryId)}" data-estimated-tokens="${esc(String(item.estimatedTokens ?? 0))}">` : '<span class="ai-semantic-view-only">仅查看</span>'}<span class="ai-semantic-result-copy"><strong>${esc(searchResultTypeLabel(item.type))} · ${esc(item.title)}</strong><small>${lineRange}${matchKinds}${relevance}${rerank}<span>预计 ${esc(String(item.estimatedTokens ?? 0))} Token</span></small><span>${esc(item.snippet || "无原文片段")}</span></span></label><button type="button" data-semantic-locate="${index}">定位来源</button></article>`;
2150
+ }).join("")
2151
+ : '<p class="ai-semantic-empty">没有找到可展示的结果。</p>';
2152
+ host.querySelectorAll('input[data-semantic-entry-id]').forEach((input) => input.addEventListener("change", syncAiSemanticSelectionSummary));
2153
+ host.querySelectorAll("[data-semantic-locate]").forEach((button) => button.addEventListener("click", async () => {
2154
+ const result = aiSemanticSearchResults[Number(button.dataset.semanticLocate)];
2155
+ if (!result) return;
2156
+ try {
2157
+ setAiSemanticSearchVisible(false);
2158
+ await openSearchResult(result);
2159
+ } catch (error) {
2160
+ toast(error.message, "error");
2161
+ }
2162
+ }));
2163
+ syncAiSemanticSelectionSummary();
2164
+ }
2165
+
2166
+ function renderAiSemanticInjection() {
2167
+ const host = $("#ai-semantic-injection");
2168
+ const snapshot = state.aiSemanticSnapshot;
2169
+ host.classList.toggle("hidden", !snapshot);
2170
+ if (!snapshot) {
2171
+ host.replaceChildren();
2172
+ setAiContextMeter(null);
2173
+ return;
2174
+ }
2175
+ host.innerHTML = `<div><strong>本轮语义快照</strong><span>${esc(String(snapshot.itemCount ?? snapshot.items?.length ?? 0))} 项 · 预计 ${Number(snapshot.estimatedTokens ?? 0).toLocaleString("zh-CN")} Token</span><small>${esc(snapshot.query ?? "")}</small></div><button type="button" aria-label="移除本轮语义快照">×</button>`;
2176
+ host.querySelector("button")?.addEventListener("click", () => {
2177
+ state.aiSemanticSnapshot = null;
2178
+ renderAiSemanticInjection();
2179
+ persistActiveAiChatTab();
2180
+ });
2181
+ setAiContextMeter(null);
2182
+ }
2183
+
2184
+ async function runAiSemanticSearch() {
2185
+ if (!state.work) return toast("请先选择作品", "error");
2186
+ const query = $("#ai-semantic-query").value.trim();
2187
+ if (!query) return toast("请输入自然语言检索问题", "error");
2188
+ const button = $("#ai-semantic-search-run");
2189
+ button.disabled = true;
2190
+ $("#ai-semantic-search-status").textContent = "正在执行主动语义检索……";
2191
+ try {
2192
+ const selection = state.chapter
2193
+ ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd).slice(0, 4_000)
2194
+ : "";
2195
+ aiSemanticSearchQuery = query;
2196
+ const search = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search`, {
2197
+ method: "POST",
2198
+ body: {
2199
+ query,
2200
+ types: semanticSearchScopeTypes($("#ai-semantic-scope").value),
2201
+ currentChapterId: state.chapter?.id,
2202
+ selection: selection || undefined,
2203
+ includeKeyword: true
2204
+ }
2205
+ });
2206
+ renderAiSemanticSearchResults(search);
2207
+ $("#ai-semantic-search-status").textContent = search.semanticUsed
2208
+ ? `${search.results.length} 项融合结果${search.degraded ? `;${search.reason}` : ";已标注 semantic 通道"}`
2209
+ : `${search.results.length} 项关键词降级结果;${search.reason}`;
2210
+ } catch (error) {
2211
+ aiSemanticSearchResults = [];
2212
+ renderAiSemanticSearchResults({ results: [] });
2213
+ $("#ai-semantic-search-status").textContent = error.message;
2214
+ toast(error.message, "error");
2215
+ } finally {
2216
+ button.disabled = false;
2217
+ }
2218
+ }
2219
+
2220
+ async function injectAiSemanticSelection() {
2221
+ if (!state.work) return;
2222
+ const entryIds = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')]
2223
+ .map((input) => input.dataset.semanticEntryId)
2224
+ .filter(Boolean);
2225
+ if (!entryIds.length) return;
2226
+ const button = $("#ai-semantic-inject");
2227
+ button.disabled = true;
2228
+ try {
2229
+ const snapshot = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search/snapshots`, {
2230
+ method: "POST",
2231
+ body: {
2232
+ query: aiSemanticSearchQuery,
2233
+ entryIds,
2234
+ scope: { types: semanticSearchScopeTypes($("#ai-semantic-scope").value) ?? [] },
2235
+ conversationId: state.aiConversationId || undefined
2236
+ }
2237
+ });
2238
+ state.aiSemanticSnapshot = snapshot;
2239
+ renderAiSemanticInjection();
2240
+ persistActiveAiChatTab();
2241
+ setAiSemanticSearchVisible(false);
2242
+ toast(`已将 ${snapshot.itemCount} 项语义原文加入本轮上下文`);
2243
+ } catch (error) {
2244
+ toast(error.message, "error");
2245
+ button.disabled = false;
2246
+ }
2247
+ }
2248
+
2013
2249
  function aiReferenceKey(reference) {
2014
2250
  return `${reference.kind}:${reference.id}`;
2015
2251
  }
@@ -2091,7 +2327,7 @@ function createAiChatTabState(input = {}) {
2091
2327
  roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2092
2328
  citations: input.citations ?? [],
2093
2329
  references: input.references ?? [],
2094
- composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2330
+ composer: input.composer ?? { text: "", citations: [], references: [], images: [], semanticSnapshot: null, sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2095
2331
  contextUsage: input.contextUsage ?? null,
2096
2332
  contextWarning: input.contextWarning === true,
2097
2333
  lastMessageAt: input.lastMessageAt ?? null,
@@ -2134,6 +2370,7 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
2134
2370
  citations: tab.citations.map((citation) => ({ ...citation })),
2135
2371
  references: tab.references.map((reference) => ({ ...reference })),
2136
2372
  images: normalizeAiChatImageAttachments(snapshot.images),
2373
+ semanticSnapshot: snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null,
2137
2374
  sceneDirection: String(snapshot.sceneDirection ?? ""),
2138
2375
  scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
2139
2376
  };
@@ -2145,6 +2382,7 @@ function clearAiChatTabComposer(tab) {
2145
2382
  citations: [],
2146
2383
  references: [],
2147
2384
  images: [],
2385
+ semanticSnapshot: null,
2148
2386
  sceneDirection: "",
2149
2387
  scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
2150
2388
  });
@@ -2158,6 +2396,7 @@ function applyAiChatTabState(tab) {
2158
2396
  state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
2159
2397
  state.aiReferences = tab.references.map((reference) => ({ ...reference }));
2160
2398
  state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
2399
+ state.aiSemanticSnapshot = tab.composer?.semanticSnapshot ? structuredClone(tab.composer.semanticSnapshot) : null;
2161
2400
  state.aiLastMessageAt = tab.lastMessageAt;
2162
2401
  $("#ai-conversation-title").textContent = tab.title || "新对话";
2163
2402
  applyAiConversationTaskType(tab.taskType);
@@ -2169,6 +2408,7 @@ function applyAiChatTabState(tab) {
2169
2408
  setAiPromptText(tab.composer.text);
2170
2409
  restoreAiSceneComposer(tab.composer);
2171
2410
  renderAiCitations();
2411
+ renderAiSemanticInjection();
2172
2412
  renderAiReferences();
2173
2413
  renderAiImageAttachments();
2174
2414
  latestAiContextUsage = null;
@@ -2465,7 +2705,7 @@ function resetAiFeed(
2465
2705
  const roleplayUserName = roleplayUserCharacter?.name;
2466
2706
  feed.innerHTML = roleplayName
2467
2707
  ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系、知情设定和故事正文回答。"}</p></div></div>`
2468
- : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2708
+ : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后即可开始问答;提到续写或润色时会自动加载对应 Skill,也可用 /continue-writing 或 /polish-writing 强制加载。所有引用都基于已保存正文。</p></div></div>';
2469
2709
  }
2470
2710
 
2471
2711
  function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
@@ -2696,6 +2936,8 @@ const AI_TOOL_DISPLAY_NAMES = {
2696
2936
  recall_other: "回忆相识角色",
2697
2937
  recall_known: "回忆知情设定",
2698
2938
  recall_story: "回忆故事",
2939
+ recall_roleplay_memory: "回忆当前扮演线",
2940
+ remember_roleplay: "整理扮演记忆",
2699
2941
  calculate_time: "计算日期"
2700
2942
  };
2701
2943
 
@@ -2712,6 +2954,8 @@ const AI_TOOL_DESCRIPTIONS = {
2712
2954
  recall_other: "读取自己通过关系、同一组织或共同参与时间线而认识的其他角色公开摘要。",
2713
2955
  recall_known: "读取自己所属种族、组织,以及与自己身份相关的世界设定。",
2714
2956
  recall_story: "查询自己姓名或别名出现过的正文段落,避免全知回忆。",
2957
+ recall_roleplay_memory: "查询当前所扮演角色在作品内唯一共享的非正史记忆库,不读取其他角色或作品正史。",
2958
+ remember_roleplay: "暂存本轮值得保持的扮演经历;最终角色回复成功保存后才提交。",
2715
2959
  calculate_time: "计算两个 YYYY-MM-DD 日期之间的天数差。"
2716
2960
  };
2717
2961
 
@@ -2875,6 +3119,10 @@ function openAiToolCallDetail(toolCall) {
2875
3119
 
2876
3120
  function createAiToolCallButton(toolCall) {
2877
3121
  const name = String(toolCall?.name ?? "unknown");
3122
+ if (name === "propose_write_plan" || name === "ask_user_question") {
3123
+ const card = createInteractiveToolCard(toolCall, AI_TOOL_CARD_ACTIONS);
3124
+ if (card) return card;
3125
+ }
2878
3126
  const button = document.createElement("button");
2879
3127
  button.type = "button";
2880
3128
  button.className = `ai-tool-call-summary${toolCall?.status === "failed" ? " is-failed" : ""}`;
@@ -2885,6 +3133,392 @@ function createAiToolCallButton(toolCall) {
2885
3133
  return button;
2886
3134
  }
2887
3135
 
3136
+ // ---------------------------------------------------------------------------
3137
+ // AI 可写工具:审批卡片动作、修改计划详情、撤销与用户提问
3138
+ // ---------------------------------------------------------------------------
3139
+
3140
+ let aiWritePlanDialogPlanId = null;
3141
+ let aiWritePlanDialogBusy = false;
3142
+ let currentPlanDialogDetail = null;
3143
+ let currentPlanDialogFocusConfirm = false;
3144
+ let aiQuestionDialogQuestionId = null;
3145
+ let currentAiQuestionDialogView = null;
3146
+ let aiQuestionDialogBusy = false;
3147
+ const aiQuestionContinuationTabIds = new Set();
3148
+ let autoOpenedQuestionIds = new Set();
3149
+ const aiApprovalCenterState = { status: "" };
3150
+
3151
+ /** 消息流中交互工具卡片绑定的动作:全部先取服务端最新状态,再弹窗确认。 */
3152
+ const AI_TOOL_CARD_ACTIONS = {
3153
+ openPlanDetail(planId) {
3154
+ openAiWritePlanDetail(planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
3155
+ },
3156
+ confirmPlan(planId) {
3157
+ openAiWritePlanDetail(planId, { focusConfirm: true }).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
3158
+ },
3159
+ rejectPlan(planId) {
3160
+ decideAiWritePlan(planId, "reject").catch((error) => toast(`拒绝失败:${error.message}`, "error"));
3161
+ },
3162
+ openQuestionDialog(questionId) {
3163
+ openAiUserQuestionDialog(questionId).catch((error) => toast(`提问加载失败:${error.message}`, "error"));
3164
+ },
3165
+ rejectQuestion(questionId) {
3166
+ respondAiUserQuestion(questionId, { action: "reject" }).catch((error) => toast(`操作失败:${error.message}`, "error"));
3167
+ },
3168
+ openApprovalCenter() {
3169
+ openAiApprovalCenter();
3170
+ }
3171
+ };
3172
+
3173
+ /** 流式收到交互式可写工具调用时的提示与自动弹出提问。 */
3174
+ function handleInteractiveToolCallEvent(toolCall) {
3175
+ const name = String(toolCall?.name ?? "");
3176
+ if (name === "propose_write_plan") {
3177
+ if (toolCall.status === "failed") {
3178
+ const message = String(parseInteractiveToolPayload(toolCall)?.error?.message ?? "未知错误");
3179
+ toast(`AI 的写入审批提交失败:${message}`, "error");
3180
+ return;
3181
+ }
3182
+ const payload = parseInteractiveToolPayload(toolCall);
3183
+ const targets = Array.isArray(payload?.plan?.targets) ? payload.plan.targets.join("、") : "待确认操作";
3184
+ const summary = String(payload?.plan?.aiSummary ?? "");
3185
+ toast(`AI 提交了写入审批:${targets}${summary ? ` · ${summary}` : ""}`);
3186
+ return;
3187
+ }
3188
+ if (name !== "ask_user_question" || toolCall.status === "failed") return;
3189
+ const question = toolCall.result?.question;
3190
+ if (!question?.id) return;
3191
+ cacheAiQuestionView(question);
3192
+ const questionId = String(question.id);
3193
+ if (autoOpenedQuestionIds.has(questionId)) return;
3194
+ autoOpenedQuestionIds.add(questionId);
3195
+ // 直接弹出回答框等待作者选择;不会预填任何答案。
3196
+ openAiUserQuestionDialog(questionId).catch(() => undefined);
3197
+ }
3198
+
3199
+ function questionsEndpoint(path) {
3200
+ return `/api/works/${state.work.id}/ai/questions${path}`;
3201
+ }
3202
+
3203
+ function plansEndpoint(path) {
3204
+ return `/api/works/${state.work.id}/ai/write-plans${path}`;
3205
+ }
3206
+
3207
+ async function fetchAiWritePlanDetail(planId) {
3208
+ const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}`));
3209
+ cacheAiWritePlanDetail(detail);
3210
+ return detail;
3211
+ }
3212
+
3213
+ async function decideAiWritePlan(planId, action) {
3214
+ const buttonLabel = action === "confirm" ? "确认执行" : "拒绝";
3215
+ const targetButton = $(action === "confirm" ? "#ai-write-plan-confirm" : "#ai-write-plan-reject");
3216
+ if (targetButton?.disabled || aiWritePlanDialogBusy) return;
3217
+ if (targetButton) targetButton.disabled = true;
3218
+ aiWritePlanDialogBusy = true;
3219
+ try {
3220
+ const detail = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/${action}`), { method: "POST" });
3221
+ cacheAiWritePlanDetail(detail);
3222
+ toast(action === "confirm"
3223
+ ? `已执行 AI 写入审批:${detail.aiSummary}`
3224
+ : `已拒绝该写入审批,未产生任何写入`);
3225
+ if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(detail.id)) {
3226
+ applyPlanDetailToDialog(detail);
3227
+ }
3228
+ if ($("#ai-approval-center-dialog").open) loadAiApprovalCenterPlans().catch(() => undefined);
3229
+ return detail;
3230
+ } catch (error) {
3231
+ // 已被处理(409)等冲突也需要刷新展示的明细状态。
3232
+ try {
3233
+ const fresh = await fetchAiWritePlanDetail(planId);
3234
+ if ($("#ai-write-plan-dialog").open && aiWritePlanDialogPlanId === String(planId)) applyPlanDetailToDialog(fresh);
3235
+ else if (targetButton) targetButton.disabled = false;
3236
+ } catch {
3237
+ if (targetButton) targetButton.disabled = false;
3238
+ }
3239
+ throw new Error(`${buttonLabel}失败:${error.message}`);
3240
+ } finally {
3241
+ aiWritePlanDialogBusy = false;
3242
+ }
3243
+ }
3244
+
3245
+ async function undoAiWritePlan(planId) {
3246
+ if (aiWritePlanDialogBusy) return;
3247
+ const button = $("#ai-write-plan-undo");
3248
+ button.disabled = true;
3249
+ try {
3250
+ const undoPlan = await api(plansEndpoint(`/${encodeURIComponent(String(planId))}/undo`), { method: "POST" });
3251
+ cacheAiWritePlanDetail(undoPlan);
3252
+ toast("已创建撤销审批,请在新弹窗中单独确认执行");
3253
+ await openAiWritePlanDetail(undoPlan.id);
3254
+ } catch (error) {
3255
+ toast(`创建撤销审批失败:${error.message}`, "error");
3256
+ button.disabled = false;
3257
+ }
3258
+ }
3259
+
3260
+ function applyPlanDetailToDialog(detail) {
3261
+ currentPlanDialogDetail = detail;
3262
+ aiWritePlanDialogPlanId = String(detail.id);
3263
+ $("#ai-write-plan-body").innerHTML = renderWritePlanDetailMarkup(detail);
3264
+ const isPending = detail.status === "pending";
3265
+ const canUndo = Boolean(detail.undoAvailable) && detail.status === "executed";
3266
+ $("#ai-write-plan-confirm").classList.toggle("hidden", !isPending);
3267
+ $("#ai-write-plan-reject").classList.toggle("hidden", !isPending);
3268
+ $("#ai-write-plan-undo").classList.toggle("hidden", !canUndo);
3269
+ $("#ai-write-plan-confirm").disabled = false;
3270
+ $("#ai-write-plan-reject").disabled = false;
3271
+ $("#ai-write-plan-undo").disabled = false;
3272
+ if (isPending && currentPlanDialogFocusConfirm) {
3273
+ $("#ai-write-plan-dialog").querySelector(".card-actions")?.scrollIntoView({ block: "end" });
3274
+ }
3275
+ }
3276
+
3277
+ async function openAiWritePlanDetail(planId, options = {}) {
3278
+ currentPlanDialogFocusConfirm = options.focusConfirm === true;
3279
+ const dialog = $("#ai-write-plan-dialog");
3280
+ if (!dialog.open) dialog.showModal();
3281
+ $("#ai-write-plan-body").innerHTML = '<p class="usage-measurement-note">正在加载系统生成的完整修改明细……</p>';
3282
+ try {
3283
+ const detail = await fetchAiWritePlanDetail(planId);
3284
+ applyPlanDetailToDialog(detail);
3285
+ } catch (error) {
3286
+ $("#ai-write-plan-body").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
3287
+ throw error;
3288
+ }
3289
+ }
3290
+
3291
+ async function loadAiApprovalCenterPlans() {
3292
+ const statusQuery = aiApprovalCenterState.status ? `?status=${encodeURIComponent(aiApprovalCenterState.status)}&limit=50` : "?limit=50";
3293
+ const [planPayload, questionPayload] = await Promise.all([
3294
+ api(plansEndpoint(statusQuery)),
3295
+ api(questionsEndpoint(statusQuery))
3296
+ ]);
3297
+ const plans = Array.isArray(planPayload) ? planPayload : (Array.isArray(planPayload?.plans) ? planPayload.plans : []);
3298
+ const questions = Array.isArray(questionPayload) ? questionPayload : (Array.isArray(questionPayload?.questions) ? questionPayload.questions : []);
3299
+ $("#ai-approval-list-host").innerHTML = renderApprovalCenterRows(plans, questions);
3300
+ }
3301
+
3302
+ function openAiApprovalCenter() {
3303
+ const dialog = $("#ai-approval-center-dialog");
3304
+ if (!dialog.open) dialog.showModal();
3305
+ $("#ai-approval-center-toggle").setAttribute("aria-expanded", "true");
3306
+ $("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
3307
+ loadAiApprovalCenterPlans().catch((error) => {
3308
+ $("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
3309
+ });
3310
+ }
3311
+
3312
+ async function fetchAiUserQuestion(questionId) {
3313
+ const question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}`));
3314
+ cacheAiQuestionView(question);
3315
+ return question;
3316
+ }
3317
+
3318
+ function syncAiQuestionOptionPresentation() {
3319
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
3320
+ for (const label of document.querySelectorAll(".ai-question-option-item")) {
3321
+ const input = label.querySelector('input[name="ai-question-choice"]');
3322
+ const isSelected = Boolean(input && checked === input);
3323
+ label.classList.toggle("is-selected", isSelected);
3324
+ label.classList.toggle("is-recommended", label.dataset.recommended === "true" && (!checked || isSelected));
3325
+ }
3326
+ }
3327
+
3328
+ function syncAiQuestionAnswerCount() {
3329
+ const input = $("#ai-question-custom-answer");
3330
+ const maximum = Number(input.maxLength) || 3000;
3331
+ $("#ai-question-answer-count").textContent = `已填写 ${input.value.length} / ${maximum}`;
3332
+ }
3333
+
3334
+ function renderAiUserQuestionOptions(question) {
3335
+ const host = $("#ai-question-options");
3336
+ const customInput = $("#ai-question-custom-answer");
3337
+ const isPending = question.status === "pending";
3338
+ host.replaceChildren();
3339
+ for (const option of question.options ?? []) {
3340
+ const label = document.createElement("label");
3341
+ label.className = "ai-question-option-item";
3342
+ label.dataset.recommended = String(option.recommended === true);
3343
+ const input = document.createElement("input");
3344
+ input.type = "radio";
3345
+ input.name = "ai-question-choice";
3346
+ input.value = String(option.index);
3347
+ input.checked = question.selectedOption === option.index;
3348
+ input.disabled = !isPending;
3349
+ const span = document.createElement("span");
3350
+ span.textContent = `${option.recommended ? "(最推荐)" : ""}${option.label}`;
3351
+ label.append(input, span);
3352
+ host.append(label);
3353
+ }
3354
+ const customLabel = document.createElement("label");
3355
+ customLabel.className = "ai-question-option-item ai-question-custom-choice";
3356
+ const customRadio = document.createElement("input");
3357
+ customRadio.type = "radio";
3358
+ customRadio.name = "ai-question-choice";
3359
+ customRadio.value = "custom";
3360
+ customRadio.checked = question.selectedOption == null && question.isCustomAnswer === true;
3361
+ customRadio.disabled = !isPending;
3362
+ const customText = document.createElement("span");
3363
+ customText.textContent = "自定义回答";
3364
+ customLabel.append(customRadio, customText);
3365
+ host.append(customLabel);
3366
+ customInput.value = question.customAnswer ?? (question.selectedOption == null && question.isCustomAnswer ? (question.answerText ?? "") : "");
3367
+ customInput.disabled = !isPending;
3368
+ syncAiQuestionAnswerCount();
3369
+ syncAiQuestionOptionPresentation();
3370
+ // 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
3371
+ if (isPending) syncAiQuestionSubmitState();
3372
+ else $("#ai-question-submit").disabled = true;
3373
+ $("#ai-question-skip").disabled = !isPending;
3374
+ $("#ai-question-expiry").textContent = isPending
3375
+ ? `请选择一个预设选项,或填写自定义回答后提交。有效期至 ${aiFormatDateTime(question.expiresAt)};过期未回答将自动失效,AI 不允许在未获得回答时自行假定答案。`
3376
+ : `该问题当前状态:${question.statusLabel}${question.answerText ? ` · 回答:${question.answerText}` : ""}`;
3377
+ }
3378
+
3379
+ async function refreshAiQuestionDialog() {
3380
+ const question = await fetchAiUserQuestion(aiQuestionDialogQuestionId);
3381
+ currentAiQuestionDialogView = question;
3382
+ $("#ai-question-text").textContent = question.question;
3383
+ renderAiUserQuestionOptions(question);
3384
+ return question;
3385
+ }
3386
+
3387
+ async function openAiUserQuestionDialog(questionId) {
3388
+ aiQuestionDialogQuestionId = String(questionId);
3389
+ const dialog = $("#ai-question-dialog");
3390
+ if (!dialog.open) dialog.showModal();
3391
+ $("#ai-question-text").textContent = "";
3392
+ $("#ai-question-options").replaceChildren();
3393
+ $("#ai-question-custom-answer").value = "";
3394
+ syncAiQuestionAnswerCount();
3395
+ $("#ai-question-expiry").textContent = "正在加载问题……";
3396
+ try {
3397
+ return await refreshAiQuestionDialog();
3398
+ } catch (error) {
3399
+ $("#ai-question-expiry").textContent = `加载失败:${error.message}`;
3400
+ throw error;
3401
+ }
3402
+ }
3403
+
3404
+ function beginAiQuestionContinuationUi(conversationId, questionId) {
3405
+ const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
3406
+ if (!tab) return null;
3407
+ const card = questionId
3408
+ ? tab.feed.querySelector(`.ai-question-card[data-question-id="${CSS.escape(String(questionId))}"]`)
3409
+ : null;
3410
+ const note = card?.querySelector(".ai-interactive-note") ?? null;
3411
+ const status = card?.querySelector(".ai-status-chip") ?? null;
3412
+ const previousNote = note?.textContent ?? "";
3413
+ const previousStatus = status?.textContent ?? "";
3414
+ const controlStates = card
3415
+ ? [...card.querySelectorAll("button")].map((button) => ({ button, disabled: button.disabled }))
3416
+ : [];
3417
+ if (card) {
3418
+ card.classList.add("is-resuming");
3419
+ card.setAttribute("aria-busy", "true");
3420
+ controlStates.forEach(({ button }) => { button.disabled = true; });
3421
+ if (status) status.textContent = "处理中";
3422
+ if (note) note.textContent = "回答已作为工具结果提交,正在根据你的回答继续处理…";
3423
+ }
3424
+ aiQuestionContinuationTabIds.add(tab.id);
3425
+ setAiChatTabStatus(tab, "streaming");
3426
+ if (isActiveAiChatTab(tab)) syncAiRequestControls();
3427
+ scrollAiFeedToBottom(tab.feed);
3428
+ return {
3429
+ tab,
3430
+ card,
3431
+ note,
3432
+ status,
3433
+ previousNote,
3434
+ previousStatus,
3435
+ controlStates
3436
+ };
3437
+ }
3438
+
3439
+ function finishAiQuestionContinuationUi(continuationUi, failed = false) {
3440
+ if (!continuationUi) return;
3441
+ aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
3442
+ if (continuationUi.card?.isConnected) {
3443
+ continuationUi.card.classList.remove("is-resuming");
3444
+ continuationUi.card.removeAttribute("aria-busy");
3445
+ continuationUi.controlStates.forEach(({ button, disabled }) => { button.disabled = disabled; });
3446
+ if (continuationUi.note) continuationUi.note.textContent = continuationUi.previousNote;
3447
+ if (continuationUi.status) continuationUi.status.textContent = continuationUi.previousStatus;
3448
+ }
3449
+ if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
3450
+ if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
3451
+ }
3452
+
3453
+ async function reloadAiQuestionConversation(conversationId) {
3454
+ if (!conversationId) return null;
3455
+ const tab = aiChatTabManager.findByConversation(conversationId);
3456
+ if (!tab) return openAiConversation(conversationId);
3457
+ const conversation = await api(`/api/ai-conversations/${encodeURIComponent(conversationId)}?page=1&limit=100`);
3458
+ if (String(conversation.workId ?? "") !== String(state.work?.id ?? "")) throw new Error("AI 对话不属于当前作品");
3459
+ upsertAiConversationSummary(conversation);
3460
+ applyConversationToAiChatTab(tab, conversation);
3461
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3462
+ return conversation;
3463
+ }
3464
+
3465
+ async function respondAiUserQuestion(questionId, payload) {
3466
+ if (aiQuestionDialogBusy) return;
3467
+ aiQuestionDialogBusy = true;
3468
+ $("#ai-question-submit").disabled = true;
3469
+ $("#ai-question-skip").disabled = true;
3470
+ const questionDialog = $("#ai-question-dialog");
3471
+ const approvalCenterDialog = $("#ai-approval-center-dialog");
3472
+ let continuationUi = null;
3473
+ let continuationFailed = false;
3474
+ try {
3475
+ const knownQuestion = currentAiQuestionDialogView?.id === String(questionId)
3476
+ ? currentAiQuestionDialogView
3477
+ : await fetchAiUserQuestion(questionId);
3478
+ const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
3479
+ if (questionDialog.open) questionDialog.close();
3480
+ if (approvalCenterDialog.open) approvalCenterDialog.close();
3481
+ continuationUi = beginAiQuestionContinuationUi(conversationId, questionId);
3482
+ let question;
3483
+ if (payload.action === "reject") {
3484
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
3485
+ } else if (payload.action === "custom") {
3486
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), { method: "POST", body: { customAnswer: payload.customAnswer } });
3487
+ } else {
3488
+ question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
3489
+ method: "POST",
3490
+ body: {
3491
+ selectedOption: payload.selectedOption,
3492
+ ...(payload.customAnswer ? { customAnswer: payload.customAnswer } : {})
3493
+ }
3494
+ });
3495
+ }
3496
+ cacheAiQuestionView(question);
3497
+ currentAiQuestionDialogView = question;
3498
+ aiQuestionDialogQuestionId = String(question.id);
3499
+ await reloadAiQuestionConversation(question.conversationId ?? conversationId);
3500
+ toast(payload.action === "reject" ? "已跳过该问题,AI 已继续处理" : "回答已提交,AI 已继续处理");
3501
+ return question;
3502
+ } catch (error) {
3503
+ continuationFailed = true;
3504
+ const latestQuestion = await fetchAiUserQuestion(questionId).catch(() => null);
3505
+ if (latestQuestion) {
3506
+ currentAiQuestionDialogView = latestQuestion;
3507
+ cacheAiQuestionView(latestQuestion);
3508
+ if (latestQuestion.status === "pending") {
3509
+ $("#ai-question-text").textContent = latestQuestion.question;
3510
+ renderAiUserQuestionOptions(latestQuestion);
3511
+ if (!questionDialog.open) questionDialog.showModal();
3512
+ }
3513
+ }
3514
+ throw error;
3515
+ } finally {
3516
+ finishAiQuestionContinuationUi(continuationUi, continuationFailed);
3517
+ aiQuestionDialogBusy = false;
3518
+ }
3519
+ }
3520
+
3521
+ /** Convert a persisted tool call back into a process step for history rendering. */
2888
3522
  function aiToolProcessStep(toolCall, round = 1) {
2889
3523
  const normalizedToolCall = { ...toolCall };
2890
3524
  delete normalizedToolCall.round;
@@ -2945,7 +3579,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2945
3579
  if (!renderableSteps.length) return;
2946
3580
  const details = document.createElement("details");
2947
3581
  details.className = "ai-process-details";
2948
- details.open = !completed;
3582
+ // 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
3583
+ details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
2949
3584
  const summary = document.createElement("summary");
2950
3585
  const title = document.createElement("span");
2951
3586
  title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
@@ -3120,9 +3755,7 @@ async function deleteAiConversation(conversation) {
3120
3755
 
3121
3756
  const aiConversationTaskTypeLabels = {
3122
3757
  chat: "问答",
3123
- roleplay: "角色扮演",
3124
- continue: "续写",
3125
- polish: "润色选中文本"
3758
+ roleplay: "角色扮演"
3126
3759
  };
3127
3760
 
3128
3761
  function aiConversationTaskTypeLabel(taskType) {
@@ -3209,6 +3842,266 @@ function renderAiConversationHistory() {
3209
3842
  }
3210
3843
  }
3211
3844
 
3845
+ const roleplayMemoryCategoryLabels = Object.freeze({
3846
+ event: "事件",
3847
+ state: "状态",
3848
+ relationship: "关系",
3849
+ commitment: "承诺",
3850
+ knowledge: "知识",
3851
+ scene: "场景"
3852
+ });
3853
+ const roleplayMemoryImportanceLabels = Object.freeze({ low: "低重要度", medium: "中重要度", high: "高重要度" });
3854
+ const roleplayMemoryCertaintyLabels = Object.freeze({ experienced: "亲历", observed: "观察", heard: "听说", believed: "相信" });
3855
+ const roleplayMemoryStatusLabels = Object.freeze({ active: "生效中", superseded: "已取代", archived: "已删除" });
3856
+ let roleplayMemoryItems = [];
3857
+ let roleplayMemoryCharacter = null;
3858
+ let roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
3859
+ let roleplayMemoryCursorHistory = [0];
3860
+ let roleplayMemorySearchTimer = null;
3861
+ let roleplayMemoryLoaded = false;
3862
+ let roleplayMemoryLoading = false;
3863
+
3864
+ function roleplayMemoryReadOnly() {
3865
+ return Boolean(state.work) && !canEditModule("characters");
3866
+ }
3867
+
3868
+ function roleplayMemoryQuery() {
3869
+ const parameters = new URLSearchParams({
3870
+ cursor: String(roleplayMemoryCursorHistory.at(-1) ?? 0),
3871
+ limit: "20"
3872
+ });
3873
+ const query = $("#roleplay-memory-search").value.trim();
3874
+ const category = $("#roleplay-memory-category").value;
3875
+ const status = $("#roleplay-memory-status").value;
3876
+ if (query) parameters.set("q", query);
3877
+ if (category) parameters.set("categories", category);
3878
+ if (status === "all") parameters.set("statuses", "active,superseded,archived");
3879
+ else parameters.set("statuses", status || "active");
3880
+ return parameters;
3881
+ }
3882
+
3883
+ function roleplayMemorySourceHtml(source) {
3884
+ const canOpen = source.canOpen === true && source.conversationId && source.messageId;
3885
+ const sourceLabel = source.role === "assistant" ? "角色回复" : "用户消息";
3886
+ const detail = source.restricted
3887
+ ? "来自其他用户的角色扮演对话,无权查看原文"
3888
+ : source.evidence || "来源消息已删除,保留来源时间";
3889
+ 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>`;
3890
+ }
3891
+
3892
+ function renderRoleplayMemoryList() {
3893
+ const host = $("#roleplay-memory-list");
3894
+ if (!host) return;
3895
+ $("#roleplay-memory-add").disabled = roleplayMemoryReadOnly();
3896
+ if (roleplayMemoryLoading) {
3897
+ host.innerHTML = '<p class="roleplay-memory-empty">正在读取角色扮演记忆……</p>';
3898
+ return;
3899
+ }
3900
+ if (!roleplayMemoryLoaded) {
3901
+ host.innerHTML = '<p class="roleplay-memory-empty">打开角色扮演记忆分区后读取该角色的共享记忆库。</p>';
3902
+ return;
3903
+ }
3904
+ if (!roleplayMemoryItems.length) {
3905
+ host.innerHTML = '<p class="roleplay-memory-empty">当前筛选下没有记忆。可以手工添加,AI 也会在成功回复后整理值得保留的扮演经历。</p>';
3906
+ } else {
3907
+ host.innerHTML = roleplayMemoryItems.map((memory) => {
3908
+ const sources = Array.isArray(memory.sources) ? memory.sources : [];
3909
+ const editable = !roleplayMemoryReadOnly();
3910
+ const pinAction = memory.isPinned ? "取消置顶" : "置顶";
3911
+ const actions = editable
3912
+ ? memory.status === "archived"
3913
+ ? `<button type="button" data-roleplay-memory-action="restore" data-memory-id="${esc(memory.id)}">恢复</button>`
3914
+ : `<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>`
3915
+ : "";
3916
+ return `<article class="roleplay-memory-card${memory.status === "archived" ? " is-archived" : ""}" data-roleplay-memory-id="${esc(memory.id)}">
3917
+ <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>
3918
+ <p class="roleplay-memory-content">${esc(memory.content)}</p>
3919
+ <p class="roleplay-memory-card-meta">${memory.sourceType === "ai" ? "AI 整理" : "手工添加"} · 版本 ${Number(memory.versionNo ?? 1)} · 角色共享</p>
3920
+ ${sources.length ? `<details class="roleplay-memory-sources"><summary>来源信息 · ${sources.length} 条</summary><div class="roleplay-memory-source-list">${sources.map(roleplayMemorySourceHtml).join("")}</div></details>` : ""}
3921
+ ${actions ? `<div class="roleplay-memory-card-actions">${actions}</div>` : ""}
3922
+ </article>`;
3923
+ }).join("");
3924
+ }
3925
+ const page = roleplayMemoryCursorHistory.length;
3926
+ const hasPrevious = roleplayMemoryCursorHistory.length > 1;
3927
+ const hasNext = roleplayMemoryPagination.nextCursor !== null;
3928
+ $("#roleplay-memory-pagination").classList.toggle("hidden", !hasPrevious && !hasNext);
3929
+ $("#roleplay-memory-previous").disabled = !hasPrevious;
3930
+ $("#roleplay-memory-next").disabled = !hasNext;
3931
+ $("#roleplay-memory-page-label").textContent = `第 ${page} 页 · 共 ${Number(roleplayMemoryPagination.total ?? 0)} 条`;
3932
+ }
3933
+
3934
+ async function loadRoleplayMemories({ resetCursor = false } = {}) {
3935
+ if (!roleplayMemoryCharacter?.id || roleplayMemoryLoading) return;
3936
+ if (resetCursor) roleplayMemoryCursorHistory = [0];
3937
+ const characterId = roleplayMemoryCharacter.id;
3938
+ roleplayMemoryLoading = true;
3939
+ renderRoleplayMemoryList();
3940
+ try {
3941
+ const result = await api(`/api/characters/${encodeURIComponent(characterId)}/roleplay-memories?${roleplayMemoryQuery()}`);
3942
+ if (roleplayMemoryCharacter?.id !== characterId) return;
3943
+ roleplayMemoryItems = Array.isArray(result.items) ? result.items : [];
3944
+ roleplayMemoryPagination = result.pagination ?? { cursor: 0, limit: 20, total: roleplayMemoryItems.length, nextCursor: null };
3945
+ if (result.character) roleplayMemoryCharacter = { ...roleplayMemoryCharacter, ...result.character };
3946
+ roleplayMemoryLoaded = true;
3947
+ } catch (error) {
3948
+ if (roleplayMemoryCharacter?.id === characterId) {
3949
+ roleplayMemoryItems = [];
3950
+ roleplayMemoryLoaded = false;
3951
+ }
3952
+ throw error;
3953
+ } finally {
3954
+ if (roleplayMemoryCharacter?.id === characterId) {
3955
+ roleplayMemoryLoading = false;
3956
+ renderRoleplayMemoryList();
3957
+ }
3958
+ }
3959
+ }
3960
+
3961
+ function bindRoleplayMemorySurface(character) {
3962
+ const surface = $("#character-roleplay-memory-surface");
3963
+ if (!surface || !character?.id) return;
3964
+ roleplayMemoryCharacter = { id: character.id, name: character.name, workId: character.workId ?? state.work?.id };
3965
+ roleplayMemoryItems = [];
3966
+ roleplayMemoryPagination = { cursor: 0, limit: 20, total: 0, nextCursor: null };
3967
+ roleplayMemoryCursorHistory = [0];
3968
+ roleplayMemoryLoaded = false;
3969
+ roleplayMemoryLoading = false;
3970
+ if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
3971
+ roleplayMemorySearchTimer = null;
3972
+ renderRoleplayMemoryList();
3973
+ $("#roleplay-memory-filter-toggle").addEventListener("click", (event) => {
3974
+ const panel = $("#roleplay-memory-filter-panel");
3975
+ const expanded = panel.classList.contains("hidden");
3976
+ panel.classList.toggle("hidden", !expanded);
3977
+ event.currentTarget.setAttribute("aria-expanded", String(expanded));
3978
+ if (expanded) $("#roleplay-memory-search").focus();
3979
+ });
3980
+ $("#roleplay-memory-add").addEventListener("click", () => openRoleplayMemoryEditor());
3981
+ $("#roleplay-memory-list").addEventListener("click", async (event) => {
3982
+ const sourceButton = event.target.closest("[data-roleplay-memory-source-conversation]");
3983
+ if (sourceButton) {
3984
+ try {
3985
+ if (!$("#character-editor-form").classList.contains("hidden")) await closeEntityEditor({ force: true });
3986
+ await openAiConversation(
3987
+ sourceButton.dataset.roleplayMemorySourceConversation,
3988
+ true,
3989
+ sourceButton.dataset.roleplayMemorySourceMessage,
3990
+ sourceButton.dataset.roleplayMemorySourceId
3991
+ );
3992
+ } catch (error) {
3993
+ toast(`来源消息打开失败:${error.message}`, "error");
3994
+ }
3995
+ return;
3996
+ }
3997
+ const actionButton = event.target.closest("[data-roleplay-memory-action]");
3998
+ if (!actionButton) return;
3999
+ const memory = roleplayMemoryItems.find((item) => item.id === actionButton.dataset.memoryId);
4000
+ actionButton.disabled = true;
4001
+ try {
4002
+ await updateRoleplayMemoryAction(memory, actionButton.dataset.roleplayMemoryAction);
4003
+ } catch (error) {
4004
+ toast(`记忆操作失败:${error.message}`, "error");
4005
+ } finally {
4006
+ if (actionButton.isConnected) actionButton.disabled = false;
4007
+ }
4008
+ });
4009
+ $("#roleplay-memory-search").addEventListener("input", () => {
4010
+ if (roleplayMemorySearchTimer) window.clearTimeout(roleplayMemorySearchTimer);
4011
+ roleplayMemorySearchTimer = window.setTimeout(() => {
4012
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆搜索失败:${error.message}`, "error"));
4013
+ }, 250);
4014
+ });
4015
+ for (const select of [$("#roleplay-memory-category"), $("#roleplay-memory-status")]) {
4016
+ select.addEventListener("change", () => {
4017
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选失败:${error.message}`, "error"));
4018
+ });
4019
+ }
4020
+ $("#roleplay-memory-filter-reset").addEventListener("click", () => {
4021
+ $("#roleplay-memory-search").value = "";
4022
+ $("#roleplay-memory-category").value = "";
4023
+ $("#roleplay-memory-status").value = "active";
4024
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`记忆筛选重置失败:${error.message}`, "error"));
4025
+ });
4026
+ $("#roleplay-memory-previous").addEventListener("click", () => {
4027
+ if (roleplayMemoryCursorHistory.length <= 1) return;
4028
+ roleplayMemoryCursorHistory.pop();
4029
+ void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
4030
+ });
4031
+ $("#roleplay-memory-next").addEventListener("click", () => {
4032
+ if (roleplayMemoryPagination.nextCursor === null) return;
4033
+ roleplayMemoryCursorHistory.push(roleplayMemoryPagination.nextCursor);
4034
+ void loadRoleplayMemories().catch((error) => toast(`记忆分页失败:${error.message}`, "error"));
4035
+ });
4036
+ }
4037
+
4038
+ function roleplayMemoryEditorFields(memory = null) {
4039
+ return field("category", "记忆类别", "select", memory?.category ?? "event", Object.entries(roleplayMemoryCategoryLabels))
4040
+ + field("importance", "重要度", "select", memory?.importance ?? "medium", Object.entries(roleplayMemoryImportanceLabels))
4041
+ + field("certainty", "可信状态", "select", memory?.certainty ?? "experienced", Object.entries(roleplayMemoryCertaintyLabels))
4042
+ + field("isPinned", "置顶这条记忆", "checkbox", memory?.isPinned === true)
4043
+ + field("content", "记忆内容", "textarea", memory?.content ?? "");
4044
+ }
4045
+
4046
+ function openRoleplayMemoryEditor(memory = null) {
4047
+ openDialog(memory ? "编辑角色扮演记忆" : "手工添加角色扮演记忆", roleplayMemoryEditorFields(memory), async (form) => {
4048
+ const body = {
4049
+ category: String(form.get("category") ?? "event"),
4050
+ importance: String(form.get("importance") ?? "medium"),
4051
+ certainty: String(form.get("certainty") ?? "experienced"),
4052
+ isPinned: form.get("isPinned") === "on",
4053
+ content: String(form.get("content") ?? "").trim(),
4054
+ ...(memory ? { expectedVersion: Number(memory.versionNo) } : {})
4055
+ };
4056
+ if (!body.content) throw new Error("请输入记忆内容");
4057
+ await api(memory
4058
+ ? `/api/roleplay-memories/${encodeURIComponent(memory.id)}`
4059
+ : `/api/characters/${encodeURIComponent(roleplayMemoryCharacter.id)}/roleplay-memories`, {
4060
+ method: memory ? "PATCH" : "POST",
4061
+ body
4062
+ });
4063
+ await loadRoleplayMemories();
4064
+ toast(memory ? "角色扮演记忆已更新" : "角色扮演记忆已添加");
4065
+ }, memory ? "版本化编辑" : "非正史 · 手工添加", {
4066
+ submitLabel: memory ? "保存记忆" : "添加记忆",
4067
+ errorPrefix: "记忆保存失败:",
4068
+ meta: "记录该角色在作品内共享的非正史互动,不会写入正文、角色卡字段或设定库。"
4069
+ });
4070
+ const textarea = $("#dialog-fields textarea[name='content']");
4071
+ if (textarea) {
4072
+ textarea.maxLength = 2_000;
4073
+ textarea.rows = 7;
4074
+ textarea.focus();
4075
+ }
4076
+ }
4077
+
4078
+ async function updateRoleplayMemoryAction(memory, action) {
4079
+ if (!memory) return;
4080
+ if (action === "edit") return openRoleplayMemoryEditor(memory);
4081
+ if (action === "archive" && !await confirmToast("删除后 AI 不再召回这条记忆,可稍后从“已删除”筛选中恢复。", {
4082
+ title: "删除角色扮演记忆",
4083
+ confirmLabel: "确认删除"
4084
+ })) return;
4085
+ if (action === "pin") {
4086
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
4087
+ method: "PATCH",
4088
+ body: { expectedVersion: Number(memory.versionNo), isPinned: memory.isPinned !== true }
4089
+ });
4090
+ } else if (action === "archive") {
4091
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}`, {
4092
+ method: "DELETE",
4093
+ body: { expectedVersion: Number(memory.versionNo) }
4094
+ });
4095
+ } else if (action === "restore") {
4096
+ await api(`/api/roleplay-memories/${encodeURIComponent(memory.id)}/restore`, {
4097
+ method: "POST",
4098
+ body: { expectedVersion: Number(memory.versionNo) }
4099
+ });
4100
+ }
4101
+ await loadRoleplayMemories();
4102
+ toast(action === "pin" ? (memory.isPinned ? "已取消置顶" : "记忆已置顶") : action === "archive" ? "记忆已删除" : "记忆已恢复");
4103
+ }
4104
+
3212
4105
  function defaultAiConversationTitle(prompt) {
3213
4106
  const normalized = roleplayUserTurnTitleSource(String(prompt ?? "")).replace(/\s+/gu, " ").trim();
3214
4107
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -3297,7 +4190,7 @@ async function ensureAiConversationsLoaded() {
3297
4190
  }
3298
4191
  }
3299
4192
 
3300
- async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
4193
+ async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null, roleplayMemorySourceId = null) {
3301
4194
  if (!state.work) return null;
3302
4195
  const existingTab = aiChatTabManager.findByConversation(conversationId);
3303
4196
  const existingMessage = focusMessageId
@@ -3318,6 +4211,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
3318
4211
  try {
3319
4212
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
3320
4213
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
4214
+ if (roleplayMemorySourceId) parameters.set("roleplayMemorySourceId", String(roleplayMemorySourceId));
3321
4215
  const [conversation] = await Promise.all([
3322
4216
  api(`/api/ai-conversations/${conversationId}?${parameters}`),
3323
4217
  ensureAiReferencesLoaded()
@@ -3657,7 +4551,7 @@ function syncAiTaskOptions() {
3657
4551
  }
3658
4552
 
3659
4553
  function applyAiConversationTaskType(taskType) {
3660
- const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
4554
+ const normalizedTaskType = taskType === "roleplay" ? "roleplay" : "chat";
3661
4555
  state.aiTaskType = normalizedTaskType;
3662
4556
  const tab = activeAiChatTab();
3663
4557
  if (tab) tab.taskType = normalizedTaskType;
@@ -3824,7 +4718,7 @@ async function persistAiRequestInterruption(request, interruption = null) {
3824
4718
  const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
3825
4719
  const metadata = cancelledByClient
3826
4720
  ? {
3827
- ...(partialContent ? interruption.metadata : {}),
4721
+ ...(interruption?.metadata ?? {}),
3828
4722
  interrupted: true,
3829
4723
  interruptionCode: "AI_REQUEST_CANCELLED",
3830
4724
  interruptionMessage: cancellationMessage.slice(0, 500)
@@ -3997,10 +4891,12 @@ function clearAiPromptComposer() {
3997
4891
  state.aiCitations = [];
3998
4892
  state.aiReferences = [];
3999
4893
  state.aiImageAttachments = [];
4894
+ state.aiSemanticSnapshot = null;
4000
4895
  setAiPromptText("");
4001
4896
  setAiSceneDirection("");
4002
4897
  renderAiCitations();
4003
4898
  renderAiImageAttachments();
4899
+ renderAiSemanticInjection();
4004
4900
  hideAiMentionMenu();
4005
4901
  syncAiSceneComposer();
4006
4902
  }
@@ -4011,6 +4907,7 @@ function captureAiPromptComposer() {
4011
4907
  citations: state.aiCitations.map((citation) => ({ ...citation })),
4012
4908
  references: state.aiReferences.map((reference) => ({ ...reference })),
4013
4909
  images: normalizeAiChatImageAttachments(state.aiImageAttachments),
4910
+ semanticSnapshot: state.aiSemanticSnapshot ? structuredClone(state.aiSemanticSnapshot) : null,
4014
4911
  sceneDirection: aiSceneDirectionText(),
4015
4912
  scenePin: captureAiScenePin()
4016
4913
  };
@@ -4020,10 +4917,12 @@ function restoreAiPromptComposer(snapshot) {
4020
4917
  state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
4021
4918
  state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
4022
4919
  state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
4920
+ state.aiSemanticSnapshot = snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null;
4023
4921
  setAiPromptText(snapshot.text);
4024
4922
  restoreAiSceneComposer(snapshot);
4025
4923
  renderAiCitations();
4026
4924
  renderAiImageAttachments();
4925
+ renderAiSemanticInjection();
4027
4926
  hideAiMentionMenu();
4028
4927
  }
4029
4928
 
@@ -4392,11 +5291,14 @@ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
4392
5291
  }
4393
5292
  const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
4394
5293
  if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
4395
- chapterAnnotationCounts = new Map(
5294
+ const savedLineIds = normalizeChapterLineIdDraft(state.chapter.content, state.chapter.lineIds);
5295
+ const draftLineIds = syncChapterDraftLineIds($("#chapter-content").value);
5296
+ const savedCounts = new Map(
4396
5297
  (Array.isArray(counts) ? counts : [])
4397
5298
  .map((item) => [Number(item.line), Number(item.count)])
4398
5299
  .filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
4399
5300
  );
5301
+ chapterAnnotationCounts = remapChapterLineCounts(savedLineIds, draftLineIds, savedCounts);
4400
5302
  scheduleChapterLineNumbers();
4401
5303
  }
4402
5304
 
@@ -5617,12 +6519,47 @@ function setSaveState(text, dirty = false) {
5617
6519
  });
5618
6520
  }
5619
6521
 
6522
+ function resetChapterDraftLineIds(chapter = state.chapter) {
6523
+ chapterBeforeInputState = null;
6524
+ chapterDraftLineIdState = chapter ? {
6525
+ chapterId: chapter.id,
6526
+ content: String(chapter.content ?? ""),
6527
+ lineIds: normalizeChapterLineIdDraft(chapter.content, chapter.lineIds)
6528
+ } : null;
6529
+ }
6530
+
6531
+ function syncChapterDraftLineIds(content, hint = null) {
6532
+ if (!state.chapter) return [];
6533
+ if (!chapterDraftLineIdState || chapterDraftLineIdState.chapterId !== state.chapter.id) {
6534
+ resetChapterDraftLineIds(state.chapter);
6535
+ }
6536
+ if (chapterDraftLineIdState.content !== content) {
6537
+ const beforeLineIds = chapterDraftLineIdState.lineIds;
6538
+ const lineIds = reconcileChapterLineIdDraft(
6539
+ chapterDraftLineIdState.content,
6540
+ content,
6541
+ beforeLineIds,
6542
+ hint
6543
+ );
6544
+ chapterAnnotationCounts = remapChapterLineCounts(beforeLineIds, lineIds, chapterAnnotationCounts);
6545
+ chapterDraftLineIdState = {
6546
+ chapterId: state.chapter.id,
6547
+ content,
6548
+ lineIds
6549
+ };
6550
+ }
6551
+ return chapterDraftLineIdState.lineIds;
6552
+ }
6553
+
5620
6554
  function chapterDraftSnapshot() {
5621
6555
  if (!state.chapter) return null;
6556
+ const content = $("#chapter-content").value;
6557
+ const lineIds = syncChapterDraftLineIds(content);
5622
6558
  return {
5623
6559
  chapterId: state.chapter.id,
5624
6560
  title: $("#chapter-title").value.trim(),
5625
- content: $("#chapter-content").value
6561
+ content,
6562
+ ...(lineIds.length <= MAX_CHAPTER_LINE_IDS ? { lineIds } : {})
5626
6563
  };
5627
6564
  }
5628
6565
 
@@ -5697,7 +6634,7 @@ async function persistChapter({ automatic = false } = {}) {
5697
6634
  const request = (async () => {
5698
6635
  const chapter = await api(`/api/chapters/${draft.chapterId}`, {
5699
6636
  method: "PATCH",
5700
- body: { title: draft.title, content: draft.content, source: automatic ? "auto" : "manual" }
6637
+ body: { title: draft.title, content: draft.content, lineIds: draft.lineIds, source: automatic ? "auto" : "manual" }
5701
6638
  });
5702
6639
  const work = await api(`/api/works/${workId}`);
5703
6640
  return { chapter, work };
@@ -5708,9 +6645,15 @@ async function persistChapter({ automatic = false } = {}) {
5708
6645
  if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
5709
6646
  state.chapter = saved.chapter;
5710
6647
  state.work = saved.work;
6648
+ resetChapterDraftLineIds(state.chapter);
5711
6649
  lastSavedChapterSnapshot = draft;
5712
6650
  renderTree();
5713
6651
  updateChapterStats();
6652
+ try {
6653
+ await loadChapterAnnotationCounts(saved.chapter.id);
6654
+ } catch (error) {
6655
+ toast("正文评论位置已更新,但评论数量刷新失败,请稍后重试", "error");
6656
+ }
5714
6657
  const currentDraft = chapterDraftSnapshot();
5715
6658
  if (sameChapterSnapshot(currentDraft, draft)) {
5716
6659
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
@@ -5785,13 +6728,35 @@ function restoredSettingsReturnContext(route) {
5785
6728
  }
5786
6729
 
5787
6730
  async function initializePage() {
6731
+ const route = parsePageRoute(window.location.hash);
6732
+ const earlyReaderChapterRequest = window.__scriverseReaderChapterPrefetch;
6733
+ const earlyReaderWorksRequest = window.__scriverseReaderWorksPrefetch;
6734
+ const earlyReaderWorkRequest = window.__scriverseReaderWorkPrefetch;
5788
6735
  const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
5789
6736
  if (!authenticated) {
5790
6737
  restoringPageRoute = false;
5791
6738
  return;
5792
6739
  }
5793
- const route = parsePageRoute(window.location.hash);
5794
- state.works = (await apiPage("/api/works")).items;
6740
+ const requestedWorkDetailRequest = route.workId
6741
+ ? earlyReaderWorkRequest?.workId === route.workId
6742
+ ? earlyReaderWorkRequest.request.then((result) => result.work ?? api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null))
6743
+ : api(`/api/works/${encodeURIComponent(route.workId)}?directory=volumes`).catch(() => null)
6744
+ : Promise.resolve(null);
6745
+ initialReaderChapterRequest = route.view === "reader" && route.chapterId
6746
+ ? earlyReaderChapterRequest?.chapterId === route.chapterId
6747
+ ? earlyReaderChapterRequest
6748
+ : {
6749
+ chapterId: route.chapterId,
6750
+ request: api(`/api/chapters/${encodeURIComponent(route.chapterId)}`)
6751
+ .then((chapter) => ({ chapter }), (error) => ({ error }))
6752
+ }
6753
+ : null;
6754
+ const [worksPage, requestedWorkDetail] = await Promise.all([
6755
+ earlyReaderWorksRequest?.request.then((result) => result.works ?? apiPage("/api/works")) ?? apiPage("/api/works"),
6756
+ requestedWorkDetailRequest
6757
+ ]);
6758
+ state.works = worksPage.items;
6759
+ initialWorkDetail = requestedWorkDetail;
5795
6760
  try {
5796
6761
  if (route.view === "shelf") {
5797
6762
  showShelf();
@@ -5832,7 +6797,7 @@ async function initializePage() {
5832
6797
  return;
5833
6798
  }
5834
6799
  const options = { readOnly: route.entityMode === "read" };
5835
- if (route.entity === "setting") openSettingEditor(item, options);
6800
+ if (route.entity === "setting") await openSettingEditor(item, options);
5836
6801
  else if (route.entity === "character") await openCharacterEditor(item, options);
5837
6802
  else if (route.entity === "race") await openRaceDialog(item, options);
5838
6803
  else if (route.entity === "organization") await openOrganizationDialog(item, options);
@@ -5863,7 +6828,14 @@ async function initializePage() {
5863
6828
  settingsReturnContext = restoredSettingsReturnContext(route);
5864
6829
  }
5865
6830
  } finally {
6831
+ delete window.__scriverseReaderChapterPrefetch;
6832
+ delete window.__scriverseReaderWorksPrefetch;
6833
+ delete window.__scriverseReaderWorkPrefetch;
6834
+ initialWorkDetail = null;
6835
+ initialReaderChapterRequest = null;
5866
6836
  document.body.classList.remove("auth-pending");
6837
+ document.documentElement.removeAttribute("data-pending-view");
6838
+ document.documentElement.classList.remove("pending-shelf-mode");
5867
6839
  restoringPageRoute = false;
5868
6840
  replacePageRoute(currentPageRoute());
5869
6841
  scheduleFirstUseOnboarding();
@@ -6971,6 +7943,7 @@ async function refreshWorkAfterGlobalReplace(route, result) {
6971
7943
  const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
6972
7944
  if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
6973
7945
  state.chapter = chapter;
7946
+ resetChapterDraftLineIds(state.chapter);
6974
7947
  mergeChapterDirectoryEntry(chapter);
6975
7948
  lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
6976
7949
  await loadVolumeChapters(chapter.volumeId);
@@ -7349,6 +8322,13 @@ function resetWorkScopedUiCaches() {
7349
8322
  draftTypeFilter = "all";
7350
8323
  draftBindingFilters = [];
7351
8324
  draftFiltersPanelOpen = false;
8325
+ chapterCommentFilters.chapterId = "";
8326
+ chapterCommentFilters.keyword = "";
8327
+ chapterCommentFiltersPanelOpen = false;
8328
+ chapterCommentChapterOptions = [];
8329
+ clearTimeout(chapterCommentSearchTimer);
8330
+ chapterCommentSearchTimer = null;
8331
+ chapterCommentRenderRequestId += 1;
7352
8332
  settingFilters.keyword = "";
7353
8333
  settingFilters.category = "";
7354
8334
  settingFilters.lockState = "all";
@@ -7392,7 +8372,9 @@ async function selectWork(workId, preferredChapterId = null) {
7392
8372
  volumeChapterLoadingIds.clear();
7393
8373
  volumeChapterRequests.clear();
7394
8374
  }
7395
- const nextWork = await api(`/api/works/${workId}?directory=volumes`);
8375
+ const prefetchedWork = initialWorkDetail?.id === workId ? initialWorkDetail : null;
8376
+ initialWorkDetail = null;
8377
+ const nextWork = prefetchedWork ?? await api(`/api/works/${workId}?directory=volumes`);
7396
8378
  if (selectionGeneration !== workSelectionRequestGeneration) return false;
7397
8379
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
7398
8380
  showSystemStatus();
@@ -7462,9 +8444,12 @@ async function loadVolumeChapters(volumeId) {
7462
8444
  async function loadAllVolumeChapters(workId) {
7463
8445
  const generation = workScopedUiGeneration;
7464
8446
  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);
8447
+ for (let index = 0; index < volumeIds.length; index += readingVolumeDirectoryConcurrency) {
8448
+ const batch = volumeIds.slice(index, index + readingVolumeDirectoryConcurrency);
8449
+ await Promise.all(batch.map(async (volumeId) => {
8450
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
8451
+ await loadVolumeChapters(volumeId);
8452
+ }));
7468
8453
  }
7469
8454
  }
7470
8455
 
@@ -7651,11 +8636,21 @@ function renderChapterBatchDialog() {
7651
8636
  function updateChapterBatchControls() {
7652
8637
  const count = chapterBatchSelectedIds.size;
7653
8638
  const action = $("#chapter-batch-action").value;
8639
+ const renumbering = action === "renumberTitles";
8640
+ const template = $("#chapter-batch-template").value.trim();
8641
+ const templateValid = template.split("{n}").length === 2;
8642
+ const startAt = Number($("#chapter-batch-start").value);
8643
+ const sequenceEnd = startAt + count - 1;
7654
8644
  $("#chapter-batch-count").textContent = `已选择 ${count} 章`;
7655
- $("#chapter-batch-apply").disabled = count === 0;
8645
+ $("#chapter-batch-apply").disabled = count === 0 || (renumbering && (!templateValid || !Number.isInteger(startAt) || startAt < 1 || sequenceEnd > 999999));
7656
8646
  $("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
7657
8647
  $("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
7658
- $("#chapter-batch-apply").textContent = action === "delete" ? "软删除所选章节" : "应用到所选章节";
8648
+ for (const id of ["chapter-batch-template-field", "chapter-batch-number-style-field", "chapter-batch-start-field", "chapter-batch-renumber-note"]) {
8649
+ $(`#${id}`).classList.toggle("hidden", !renumbering);
8650
+ }
8651
+ $("#chapter-batch-apply").textContent = action === "delete"
8652
+ ? "软删除所选章节"
8653
+ : renumbering ? "重排所选章节" : "应用到所选章节";
7659
8654
  }
7660
8655
 
7661
8656
  function openChapterBatchDialog() {
@@ -7675,16 +8670,41 @@ async function submitChapterBatch(event) {
7675
8670
  ? { type: "move", volumeId: $("#chapter-batch-volume").value }
7676
8671
  : actionValue === "setType"
7677
8672
  ? { type: "setType", chapterType: $("#chapter-batch-type").value }
7678
- : actionValue === "exclude" || actionValue === "include"
7679
- ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
7680
- : { type: "delete" };
8673
+ : actionValue === "renumberTitles"
8674
+ ? {
8675
+ type: "renumberTitles",
8676
+ template: $("#chapter-batch-template").value.trim(),
8677
+ numberStyle: $("#chapter-batch-number-style").value,
8678
+ startAt: Number($("#chapter-batch-start").value)
8679
+ }
8680
+ : actionValue === "exclude" || actionValue === "include"
8681
+ ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
8682
+ : { type: "delete" };
7681
8683
  const dialog = $("#chapter-batch-dialog");
7682
- if (action.type === "delete") {
8684
+ if (state.module === "editor" && state.chapter && state.dirty) {
7683
8685
  dialog.close();
7684
- const confirmed = await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
7685
- title: "批量删除需要再次确认",
7686
- confirmLabel: "确认软删除"
7687
- });
8686
+ const confirmedDiscard = await confirmDiscardChanges("当前章节有未保存修改,批量处理将丢弃这些修改。是否继续?");
8687
+ if (!confirmedDiscard) {
8688
+ dialog.showModal();
8689
+ return;
8690
+ }
8691
+ }
8692
+ if (action.type === "renumberTitles" && (action.template.split("{n}").length !== 2 || !Number.isInteger(action.startAt) || action.startAt < 1 || action.startAt + chapters.length - 1 > 999999)) {
8693
+ toast("标题格式必须且只能包含一个 {n},且所选章节的序号不能超过 999999", "error");
8694
+ $("#chapter-batch-template").focus();
8695
+ return;
8696
+ }
8697
+ if (action.type === "delete" || action.type === "renumberTitles") {
8698
+ dialog.close();
8699
+ const confirmed = action.type === "delete"
8700
+ ? await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
8701
+ title: "批量删除需要再次确认",
8702
+ confirmLabel: "确认软删除"
8703
+ })
8704
+ : await confirmToast(`将按目录顺序,把所选 ${chapters.length} 个章节从第 ${action.startAt} 个序号开始重排为“${action.template}”格式。每个改名章节都会保留版本,确认继续吗?`, {
8705
+ title: "重排标题需要再次确认",
8706
+ confirmLabel: "确认重排"
8707
+ });
7688
8708
  if (!confirmed) {
7689
8709
  dialog.showModal();
7690
8710
  return;
@@ -7692,21 +8712,26 @@ async function submitChapterBatch(event) {
7692
8712
  }
7693
8713
  $("#chapter-batch-apply").disabled = true;
7694
8714
  try {
7695
- await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
8715
+ const result = await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
7696
8716
  method: "POST",
7697
8717
  body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
7698
8718
  });
7699
8719
  const workId = state.work.id;
7700
8720
  state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
8721
+ const currentEditorVisible = state.module === "editor";
7701
8722
  const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
7702
8723
  if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
7703
8724
  if (state.chapter && !currentStillExists) {
7704
8725
  state.chapter = null;
7705
8726
  showWelcome(true);
8727
+ } else if (state.chapter && currentEditorVisible) {
8728
+ await selectChapter(state.chapter.id, { editMode: !chapterEditorReadOnly });
7706
8729
  } else renderTree();
7707
8730
  if (dialog.open) dialog.close();
7708
8731
  chapterBatchSelectedIds.clear();
7709
- toast(`已批量处理 ${chapters.length} 个章节`);
8732
+ toast(action.type === "renumberTitles"
8733
+ ? `已按目录顺序重排 ${Number(result.updated ?? chapters.length)} 个章节标题`
8734
+ : `已批量处理 ${chapters.length} 个章节`);
7710
8735
  } catch (error) {
7711
8736
  if (!dialog.open) dialog.showModal();
7712
8737
  $("#chapter-batch-apply").disabled = false;
@@ -7837,9 +8862,14 @@ function currentChapterForeshadowReminder() {
7837
8862
 
7838
8863
  function syncMobileAiPanelSafeTop() {
7839
8864
  const container = $("#chapter-foreshadow-reminder");
7840
- const safeTop = container.classList.contains("hidden")
8865
+ const toolbar = $("#editor-view .editor-toolbar");
8866
+ const reminderBottom = container.classList.contains("hidden")
8867
+ ? 0
8868
+ : container.getBoundingClientRect().bottom;
8869
+ const toolbarBottom = $("#editor-view").classList.contains("hidden")
7841
8870
  ? 0
7842
- : Math.ceil(container.getBoundingClientRect().bottom);
8871
+ : toolbar.getBoundingClientRect().bottom;
8872
+ const safeTop = Math.ceil(Math.max(reminderBottom, toolbarBottom));
7843
8873
  $("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
7844
8874
  }
7845
8875
 
@@ -8061,6 +9091,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
8061
9091
  $("#chapter-path").title = chapterPath;
8062
9092
  $("#chapter-title").value = state.chapter.title;
8063
9093
  $("#chapter-content").value = state.chapter.content;
9094
+ resetChapterDraftLineIds(state.chapter);
8064
9095
  chapterAnnotationCounts = new Map();
8065
9096
  clearChapterLineSelection();
8066
9097
  scheduleChapterLineNumbers();
@@ -8183,6 +9214,7 @@ function renderReadingNavigation() {
8183
9214
  $("#reader-next").disabled = !next || readingLoading;
8184
9215
  $("#reader-continue").disabled = !next || readingLoading;
8185
9216
  $("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
9217
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8186
9218
  const paged = readingPreferences.mode === "paged";
8187
9219
  const previousPage = current && paged ? resolvePagedReadingStep({
8188
9220
  sequence: readingSequence,
@@ -8220,7 +9252,7 @@ function applyReadingPreferences() {
8220
9252
  $("#reader-font-size").value = String(readingPreferences.fontSize);
8221
9253
  $("#reader-line-height").value = String(readingPreferences.lineHeight);
8222
9254
  $("#reader-theme").value = readingPreferences.theme;
8223
- $("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
9255
+ $("#reader-continuation").classList.toggle("hidden", readingLoading || readingPreferences.mode === "paged");
8224
9256
  $("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
8225
9257
  $("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
8226
9258
  }
@@ -8355,7 +9387,13 @@ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = n
8355
9387
  renderReadingStatus("正在载入章节……");
8356
9388
  replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
8357
9389
  try {
8358
- const chapter = await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
9390
+ const initialRequest = initialReaderChapterRequest?.chapterId === target.id
9391
+ ? initialReaderChapterRequest.request
9392
+ : null;
9393
+ if (initialRequest) initialReaderChapterRequest = null;
9394
+ const prefetchedResult = initialRequest ? await initialRequest : null;
9395
+ if (prefetchedResult?.error) throw prefetchedResult.error;
9396
+ const chapter = prefetchedResult?.chapter ?? await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
8359
9397
  if (!readingRequestGate.isCurrent(request)) return false;
8360
9398
  if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
8361
9399
  throw new Error("章节响应与当前作品不匹配");
@@ -8509,7 +9547,7 @@ function closeReadingPreview() {
8509
9547
  replacePageRoute(returnRoute);
8510
9548
  const focus = readingPreviousFocus;
8511
9549
  readingPreviousFocus = null;
8512
- const focusCandidates = [focus, $("#chapter-reader-button"), $("#reader-open-button"), $("#home-button")];
9550
+ const focusCandidates = [focus, $("#reader-open-button"), $("#home-button")];
8513
9551
  for (const candidate of focusCandidates) {
8514
9552
  if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
8515
9553
  const rect = candidate.getBoundingClientRect();
@@ -8568,6 +9606,7 @@ function tidyChapterBlankLines() {
8568
9606
  const normalized = normalizeParagraphSpacing(input.value);
8569
9607
  if (normalized === input.value) return toast("正文空行已经符合要求");
8570
9608
  input.value = normalized;
9609
+ syncChapterDraftLineIds(input.value);
8571
9610
  scheduleChapterLineNumbers();
8572
9611
  updateChapterStats();
8573
9612
  scheduleChapterAutoSave(120);
@@ -8748,6 +9787,14 @@ function pencilIconMarkup() {
8748
9787
  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
9788
  }
8750
9789
 
9790
+ function trashIconMarkup() {
9791
+ 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>';
9792
+ }
9793
+
9794
+ function roleplayMemoryPinIconMarkup() {
9795
+ 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>';
9796
+ }
9797
+
8751
9798
  function characterFavoriteIconMarkup() {
8752
9799
  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
9800
  }
@@ -9214,6 +10261,17 @@ function mountOutlineBoardFilterToggle() {
9214
10261
  });
9215
10262
  }
9216
10263
 
10264
+ function mountChapterCommentFilterToggle() {
10265
+ $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]')?.remove();
10266
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="chapter-comment-filter-toggle" aria-label="筛选正文评论与待办" aria-controls="chapter-comment-filter-panel" aria-expanded="${chapterCommentFiltersPanelOpen}" 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>`);
10267
+ const toggle = $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]');
10268
+ toggle?.addEventListener("click", () => {
10269
+ chapterCommentFiltersPanelOpen = !chapterCommentFiltersPanelOpen;
10270
+ $("#chapter-comment-filter-panel")?.classList.toggle("hidden", !chapterCommentFiltersPanelOpen);
10271
+ toggle.setAttribute("aria-expanded", String(chapterCommentFiltersPanelOpen));
10272
+ });
10273
+ }
10274
+
9217
10275
  function bindRecordPreview(selector, open) {
9218
10276
  $("#module-content").querySelectorAll(selector).forEach((card) => {
9219
10277
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -10464,32 +11522,102 @@ async function renderRelationships(page = moduleListPages.relationships) {
10464
11522
  }
10465
11523
 
10466
11524
  async function renderWorkChapterComments(page = moduleListPages.comments) {
10467
- const pageSize = pageSizeFor("comments");
10468
- const result = await moduleApiPage(
10469
- "comments",
10470
- `/api/works/${encodeURIComponent(state.work.id)}/chapter-annotations`,
10471
- page,
10472
- pageSize
10473
- );
10474
- if (!result.items.length && page > 1) return renderWorkChapterComments(page - 1);
10475
- const total = Number(result.total ?? result.items.length);
10476
- const pageCount = Math.max(1, Math.ceil(total / result.limit));
10477
- const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
10478
- moduleListPages.comments = pageResult.page;
10479
- mountModuleCount(total);
10480
- $("#module-content").innerHTML = result.items.length
10481
- ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
10482
- : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
10483
- bindModulePagination("comments", renderWorkChapterComments);
10484
- bindChapterAnnotationCards($("#module-content"), result.items, {
10485
- refresh: () => renderWorkChapterComments(pageResult.page),
10486
- locate: async (annotation) => {
10487
- const selected = await selectChapter(annotation.chapterId);
10488
- if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
10489
- await new Promise((resolve) => window.requestAnimationFrame(resolve));
10490
- revealChapterLines(annotation.startLine, annotation.endLine);
11525
+ const workId = state.work.id;
11526
+ const generation = workScopedUiGeneration;
11527
+ const hasFilters = () => Boolean(chapterCommentFilters.chapterId || chapterCommentFilters.keyword.trim());
11528
+ const chapterOptionsMarkup = () => `<option value="">全部章节</option>${chapterCommentChapterOptions.map((chapter) => `<option value="${esc(chapter.id)}" ${chapterCommentFilters.chapterId === chapter.id ? "selected" : ""}>${esc(chapter.volumeTitle)} / ${esc(chapter.title)}</option>`).join("")}`;
11529
+ const filterToolbar = `<section id="chapter-comment-filter-panel" class="character-filter-toolbar chapter-comment-filter-toolbar${chapterCommentFiltersPanelOpen ? "" : " hidden"}" aria-label="正文评论与待办筛选">
11530
+ <label class="setting-filter-field" for="chapter-comment-chapter-filter"><span>按章节筛选</span><select id="chapter-comment-chapter-filter" aria-label="按章节筛选正文评论与待办">${chapterOptionsMarkup()}</select></label>
11531
+ <label class="setting-filter-field" for="chapter-comment-keyword-filter"><span>按关键词搜索</span><input id="chapter-comment-keyword-filter" type="search" value="${esc(chapterCommentFilters.keyword)}" placeholder="搜索评论、待办或引用正文" aria-label="按关键词搜索正文评论与待办" autocomplete="off" maxlength="100"></label>
11532
+ <div class="character-filter-toolbar-actions"><span id="chapter-comment-filter-result-count" class="character-filter-result-count${hasFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-chapter-comment-filters" class="ghost-button" type="button" ${hasFilters() ? "" : "disabled"}>重置筛选</button></div>
11533
+ </section><div id="chapter-comment-filter-results"></div>`;
11534
+ $("#module-content").innerHTML = filterToolbar;
11535
+ mountChapterCommentFilterToggle();
11536
+
11537
+ const refreshResults = async (requestedPage = moduleListPages.comments) => {
11538
+ const requestId = ++chapterCommentRenderRequestId;
11539
+ const pageSize = pageSizeFor("comments");
11540
+ const parameters = new URLSearchParams();
11541
+ if (chapterCommentFilters.chapterId) parameters.set("chapterId", chapterCommentFilters.chapterId);
11542
+ if (chapterCommentFilters.keyword.trim()) parameters.set("q", chapterCommentFilters.keyword.trim());
11543
+ const path = `/api/works/${encodeURIComponent(workId)}/chapter-annotations${parameters.size ? `?${parameters}` : ""}`;
11544
+ $("#chapter-comment-filter-results")?.setAttribute("aria-busy", "true");
11545
+ let result;
11546
+ try {
11547
+ result = await moduleApiPage("comments", path, requestedPage, pageSize);
11548
+ } finally {
11549
+ if (requestId === chapterCommentRenderRequestId) $("#chapter-comment-filter-results")?.removeAttribute("aria-busy");
10491
11550
  }
11551
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== chapterCommentRenderRequestId || state.module !== "comments") return;
11552
+ chapterCommentChapterOptions = Array.isArray(result.chapterOptions) ? result.chapterOptions : [];
11553
+ if (chapterCommentFilters.chapterId && !chapterCommentChapterOptions.some((chapter) => chapter.id === chapterCommentFilters.chapterId)) {
11554
+ chapterCommentFilters.chapterId = "";
11555
+ $("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
11556
+ return refreshResults(1);
11557
+ }
11558
+ $("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
11559
+ if (!result.items.length && requestedPage > 1) return refreshResults(requestedPage - 1);
11560
+ const total = Number(result.total ?? result.items.length);
11561
+ const pageCount = Math.max(1, Math.ceil(total / result.limit));
11562
+ const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
11563
+ moduleListPages.comments = pageResult.page;
11564
+ mountModuleCount(total);
11565
+ const completedTodos = result.items.filter((annotation) => annotation.kind === "todo" && annotation.status === "resolved");
11566
+ const visibleAnnotations = result.items.filter((annotation) => annotation.kind !== "todo" || annotation.status !== "resolved");
11567
+ const visibleMarkup = visibleAnnotations.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("");
11568
+ const completedMarkup = completedTodos.length ? `<details class="chapter-comment-completed-group"><summary><span>已完成待办</span><strong>${completedTodos.length}</strong><small>默认折叠,点击展开</small></summary><div class="chapter-comment-completed-list">${completedTodos.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div></details>` : "";
11569
+ const filtersActive = hasFilters();
11570
+ $("#chapter-comment-filter-results").innerHTML = result.items.length
11571
+ ? `<div class="chapter-comment-module-list">${visibleMarkup}${completedMarkup}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
11572
+ : chapterCommentChapterOptions.length
11573
+ ? emptyModule("没有符合筛选条件的评论或待办", "可以切换章节、修改关键词或重置筛选。")
11574
+ : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
11575
+ const resultCount = $("#chapter-comment-filter-result-count");
11576
+ resultCount.textContent = filtersActive ? `筛选后共 ${total} 条评论与待办` : "";
11577
+ resultCount.classList.toggle("hidden", !filtersActive);
11578
+ $("#clear-chapter-comment-filters").disabled = !filtersActive;
11579
+ bindModulePagination("comments", refreshResults);
11580
+ bindChapterAnnotationCards($("#chapter-comment-filter-results"), result.items, {
11581
+ refresh: () => refreshResults(pageResult.page),
11582
+ locate: async (annotation) => {
11583
+ const selected = await selectChapter(annotation.chapterId);
11584
+ if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
11585
+ await new Promise((resolve) => window.requestAnimationFrame(resolve));
11586
+ revealChapterLines(annotation.startLine, annotation.endLine);
11587
+ }
11588
+ });
11589
+ };
11590
+
11591
+ $("#chapter-comment-chapter-filter").addEventListener("change", (event) => {
11592
+ chapterCommentFilters.chapterId = event.currentTarget.value;
11593
+ chapterCommentFiltersPanelOpen = true;
11594
+ moduleListPages.comments = 1;
11595
+ clearTimeout(chapterCommentSearchTimer);
11596
+ chapterCommentSearchTimer = null;
11597
+ void refreshResults(1).catch((error) => toast(error.message, "error"));
11598
+ });
11599
+ $("#chapter-comment-keyword-filter").addEventListener("input", (event) => {
11600
+ chapterCommentFilters.keyword = event.currentTarget.value;
11601
+ chapterCommentFiltersPanelOpen = true;
11602
+ moduleListPages.comments = 1;
11603
+ clearTimeout(chapterCommentSearchTimer);
11604
+ chapterCommentSearchTimer = window.setTimeout(() => {
11605
+ chapterCommentSearchTimer = null;
11606
+ void refreshResults(1).catch((error) => toast(error.message, "error"));
11607
+ }, 250);
11608
+ });
11609
+ $("#clear-chapter-comment-filters").addEventListener("click", () => {
11610
+ chapterCommentFilters.chapterId = "";
11611
+ chapterCommentFilters.keyword = "";
11612
+ chapterCommentFiltersPanelOpen = true;
11613
+ moduleListPages.comments = 1;
11614
+ clearTimeout(chapterCommentSearchTimer);
11615
+ chapterCommentSearchTimer = null;
11616
+ $("#chapter-comment-chapter-filter").value = "";
11617
+ $("#chapter-comment-keyword-filter").value = "";
11618
+ void refreshResults(1).then(() => $("#chapter-comment-keyword-filter")?.focus()).catch((error) => toast(error.message, "error"));
10492
11619
  });
11620
+ await refreshResults(page);
10493
11621
  }
10494
11622
 
10495
11623
  async function renderReviews(page = moduleListPages.reviews) {
@@ -11636,16 +12764,17 @@ function renderProviderCards(providers, models, protocolOptions) {
11636
12764
  <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
11637
12765
  ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n分析请求超时:${Number(provider.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS).toLocaleString("zh-CN")} 秒\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
11638
12766
  <div class="provider-models">${providerModels.map((model) => {
11639
- const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
12767
+ const modelUnavailable = !isAvailableConfiguredModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
11640
12768
  const modelStatus = !model.enabled
11641
12769
  ? `<span class="model-status-badge is-disabled">模型已停用</span>`
11642
12770
  : provider.connectionStatus !== "success"
11643
12771
  ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
11644
12772
  : "";
12773
+ const kindLabel = model.modelKind === "embedding" ? "Embedding" : model.modelKind === "rerank" ? "Rerank" : "Chat";
11645
12774
  const capability = model.multimodalEnabled ? " · 多模态" : "";
11646
12775
  const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
11647
12776
  const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
11648
- return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
12777
+ return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${esc(kindLabel)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge}${model.modelKind === "chat" ? ` · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}` : ""}</button>${modelStatus}</div>`;
11649
12778
  }).join("")}</div>
11650
12779
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
11651
12780
  }).join("")}</div>`
@@ -11769,7 +12898,7 @@ function renderTaskDefaults(models, providers, taskDefaults, settings, protocolO
11769
12898
  <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
11770
12899
  ${models.map((model) => {
11771
12900
  const provider = providerById.get(model.providerId);
11772
- const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
12901
+ const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
11773
12902
  return `<option value="${esc(model.id)}" ${model.id === settings.titleGenerationModelId ? "selected" : ""} ${available || model.id === settings.titleGenerationModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
11774
12903
  }).join("")}
11775
12904
  </select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
@@ -11827,6 +12956,34 @@ function relationshipIndexStatusMarkup(status) {
11827
12956
  </div>`;
11828
12957
  }
11829
12958
 
12959
+ const semanticIndexStatusLabels = Object.freeze({
12960
+ disabled: "未开启",
12961
+ unconfigured: "配置不完整",
12962
+ idle: "等待重建",
12963
+ building: "正在构建",
12964
+ ready: "可以检索",
12965
+ failed: "部分失败",
12966
+ paused: "已暂停"
12967
+ });
12968
+
12969
+ function semanticIndexStatusMarkup(status = {}) {
12970
+ const progress = Math.min(100, Math.max(0, Number(status.progress) || 0));
12971
+ const statusName = semanticIndexStatusLabels[status.status] ?? "状态未知";
12972
+ const model = status.embeddingModel
12973
+ ? `${status.embeddingModel.providerName} · ${status.embeddingModel.displayName}`
12974
+ : "尚未选择 embedding 模型";
12975
+ const rerank = status.rerankModel
12976
+ ? `${status.rerankModel.providerName} · ${status.rerankModel.displayName}`
12977
+ : "未启用 rerank";
12978
+ return `<div class="semantic-index-summary">
12979
+ <div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>Embedding <strong>${esc(model)}</strong></span><span>Rerank <strong>${esc(rerank)}</strong></span></div>
12980
+ <progress class="semantic-index-progress" max="100" value="${esc(String(progress))}" aria-label="RAG 构建进度 ${esc(String(progress))}%"></progress>
12981
+ <dl class="relationship-index-metrics"><div><dt>构建进度</dt><dd>${esc(String(progress))}%</dd></div><div><dt>已处理来源</dt><dd>${esc(String(status.processedSources ?? 0))} / ${esc(String(status.totalSources ?? 0))}</dd></div><div><dt>有效分片</dt><dd>${esc(String(status.indexedChunkCount ?? 0))}</dd></div><div><dt>失败来源</dt><dd>${esc(String(status.failedSources ?? 0))}</dd></div></dl>
12982
+ ${status.error ? `<p class="relationship-index-error">${esc(status.error)}</p>` : ""}
12983
+ ${status.status === "paused" ? `<p class="usage-measurement-note">已连续失败 ${esc(String(status.consecutiveFailures ?? 0))} 次。检查端点和模型后点击“完整重建 RAG”恢复。</p>` : ""}
12984
+ </div>`;
12985
+ }
12986
+
11830
12987
  function updateBackgroundTaskCenterVisibility() {
11831
12988
  const button = $("#background-task-button");
11832
12989
  if (!button) return;
@@ -12395,6 +13552,10 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
12395
13552
  <td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
12396
13553
  <td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
12397
13554
  </tr>`).join("");
13555
+ const callTypeLabels = { chat: "Chat / 分析", embedding: "Embedding", rerank: "Rerank" };
13556
+ const callTypeUsage = (Array.isArray(usage?.callTypes) ? usage.callTypes : [])
13557
+ .map((item) => `<span class="usage-call-type-chip"><strong>${esc(callTypeLabels[item.callType] ?? item.callType)}</strong><span>${esc(formatTokenCount(item.totalTokens))} Token · ${Number(item.requestCount || 0).toLocaleString("zh-CN")} 次</span></span>`)
13558
+ .join("");
12398
13559
  return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
12399
13560
  <div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
12400
13561
  <div class="usage-stat-grid">
@@ -12404,6 +13565,7 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
12404
13565
  <article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
12405
13566
  </div>
12406
13567
  <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
13568
+ ${callTypeUsage ? `<div class="usage-call-types" aria-label="按调用类型区分的 Token 用量">${callTypeUsage}</div>` : ""}
12407
13569
  <section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
12408
13570
  <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
12409
13571
  ${tokenUsageCalendarMarkup(usage?.daily)}
@@ -12432,18 +13594,35 @@ async function renderBookAiSettings() {
12432
13594
  clearTimeout(relationshipSearchIndexRefreshTimer);
12433
13595
  relationshipSearchIndexRefreshTimer = null;
12434
13596
  }
12435
- const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
13597
+ if (semanticSearchIndexRefreshTimer) {
13598
+ clearTimeout(semanticSearchIndexRefreshTimer);
13599
+ semanticSearchIndexRefreshTimer = null;
13600
+ }
13601
+ const [settings, providers, models, semanticModels, taskDefaults, relationshipIndex, semanticIndex, usage, protocolOptions, writeTools, remoteMcpSettings] = await Promise.all([
12436
13602
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
12437
13603
  moduleApi("ai-settings", "/api/platform/ai/providers"),
12438
13604
  moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
13605
+ moduleApi("ai-settings", `/api/works/${state.work.id}/semantic-models`),
12439
13606
  moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
12440
13607
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
13608
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/semantic-search-index`),
12441
13609
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
12442
- moduleApi("ai-settings", "/api/platform/ai/protocols")
13610
+ moduleApi("ai-settings", "/api/platform/ai/protocols"),
13611
+ // 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
13612
+ api(`/api/works/${state.work.id}/ai/tools`).catch(() => null),
13613
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/mcp-servers`)
12443
13614
  ]);
13615
+ const writeToolsState = writeTools?.tools ?? null;
13616
+ const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
12444
13617
  const host = $("#module-content");
12445
13618
  platformAiProtocolOptions = protocolOptions;
12446
13619
  const workId = String(state.work.id);
13620
+ const remoteMcpConfigText = JSON.stringify(remoteMcpSettings?.config ?? { mcpServers: {} }, null, 2);
13621
+ const remoteMcpServers = Array.isArray(remoteMcpSettings?.servers) ? remoteMcpSettings.servers : [];
13622
+ const remoteMcpToolCount = Math.max(0, Number(remoteMcpSettings?.totalToolCount) || 0);
13623
+ const remoteMcpStatusText = remoteMcpServers.length > 0
13624
+ ? `已验证 ${remoteMcpServers.length} 个远程 MCP Server,共发现 ${remoteMcpToolCount} 个工具。`
13625
+ : "尚未配置远程 MCP Server。";
12447
13626
  const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
12448
13627
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
12449
13628
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
@@ -12467,6 +13646,17 @@ async function renderBookAiSettings() {
12467
13646
  title: "本书 Token 用量",
12468
13647
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
12469
13648
  })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
13649
+ const workSystemPromptSection = host.querySelector("#work-system-prompt")?.closest(".config-section");
13650
+ workSystemPromptSection?.insertAdjacentHTML("afterend", `<section class="config-section remote-mcp-settings"><div class="config-section-header"><div><h2>远程 MCP 工具</h2><p>填写标准的 <code>mcpServers</code> JSON 配置。保存前会逐个检查 JSON、远程传输、安全地址、MCP 握手与工具列表;任一 Server 失败时都不会覆盖当前配置。</p></div></div><label class="field-label remote-mcp-config-field"><span>mcpServers JSON</span><textarea id="remote-mcp-config" rows="12" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="remote-mcp-config-help remote-mcp-status" placeholder='{"mcpServers":{"example":{"url":"https://example.com/mcp"}}}'>${esc(remoteMcpConfigText)}</textarea></label><small id="remote-mcp-config-help" class="remote-mcp-config-help">仅支持远程 MCP 工具(SSE / Streamable HTTP),不支持会执行本地命令的 stdio 配置。敏感 Header 会加密保存,页面中的 ${esc("********")} 掩码再次保存时会保留原值。</small><p id="remote-mcp-status" class="remote-mcp-status" role="status" aria-live="polite">${esc(remoteMcpStatusText)}</p><div class="card-actions"><button id="save-remote-mcp-config" class="ghost-button config-save-button" type="button">测试并保存 MCP 配置</button></div></section>`);
13651
+ const semanticModelOptions = (kind, selectedId) => semanticModels
13652
+ .filter((model) => model.modelKind === kind)
13653
+ .map((model) => {
13654
+ const available = isAvailableConfiguredModel(model);
13655
+ return `<option value="${esc(model.id)}" ${model.id === selectedId ? "selected" : ""} ${available || model.id === selectedId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel(model)}`)}</option>`;
13656
+ }).join("");
13657
+ const semanticSection = `<section id="semantic-search-settings" class="config-section semantic-search-settings"><div class="config-section-header"><div><h2>主动语义检索(RAG)</h2><p>与拼音索引并列维护。Embedding 和 rerank 复用平台供应商的端点与凭证保险库;普通聊天、续写、润色和分析不会自动调用此通道。</p></div></div><div class="semantic-settings-grid"><label class="checkbox-field config-checkbox-field semantic-enabled-field"><input id="semantic-search-enabled" type="checkbox" ${settings.semanticSearchEnabled ? "checked" : ""}>启用主动语义检索</label><label>Embedding 模型<select id="semantic-embedding-model" aria-label="RAG Embedding 模型"><option value="">请选择 embedding 模型</option>${semanticModelOptions("embedding", settings.semanticEmbeddingModelId)}</select></label><label>Rerank 模型(可选)<select id="semantic-rerank-model" aria-label="RAG Rerank 模型"><option value="">不使用 rerank</option>${semanticModelOptions("rerank", settings.semanticRerankModelId)}</select></label><label>向量维度<input id="semantic-vector-dimension" type="number" min="1" max="65536" value="${esc(String(settings.semanticVectorDimension ?? 1024))}"></label><label>语义召回数量<input id="semantic-recall-limit" type="number" min="1" max="200" value="${esc(String(settings.semanticRecallLimit ?? 20))}"></label><label>展示结果数量<input id="semantic-result-limit" type="number" min="1" max="100" value="${esc(String(settings.semanticResultLimit ?? 12))}"></label><label>上下文预算(Token)<input id="semantic-budget-tokens" type="number" min="256" max="100000" value="${esc(String(settings.semanticBudgetTokens ?? 4000))}"></label><label>RRF 语义通道权重<input id="semantic-channel-weight" type="number" min="0.1" max="5" step="0.1" value="${esc(String(settings.semanticChannelWeight ?? 1))}"></label></div><p class="usage-measurement-note">API Key 由所选模型所属供应商的凭证保险库加密保存;此页面不会读取或返回明文密钥。更换端点、模型、维度或分片规则后必须完整重建,旧向量不会继续参与检索。</p><div id="semantic-search-index-status" role="status" aria-live="polite">${semanticIndexStatusMarkup(semanticIndex)}</div><div class="relationship-index-actions"><button id="save-semantic-search-settings" class="primary-button config-save-button" type="button">保存 RAG 配置</button><button id="sync-semantic-search-index" class="ghost-button config-save-button" type="button">同步增量</button><button id="refresh-semantic-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-semantic-search-index" class="ghost-button config-save-button" type="button">完整重建 RAG</button></div></section>`;
13658
+ const relationshipSection = [...host.querySelectorAll(".config-section")].find((section) => section.querySelector("h2")?.textContent === "人物关系拼音索引");
13659
+ relationshipSection?.insertAdjacentHTML("afterend", semanticSection);
12470
13660
  bindTokenUsageDetails(host, usage, "本书 Token 用量");
12471
13661
  const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
12472
13662
  dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
@@ -12490,6 +13680,7 @@ async function renderBookAiSettings() {
12490
13680
  host.querySelectorAll(".config-section").forEach((section) => {
12491
13681
  if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
12492
13682
  });
13683
+ 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
13684
  bindUsageCalendarInteractions(host);
12494
13685
  scrollUsageCalendarsToLatest(host);
12495
13686
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
@@ -12500,6 +13691,10 @@ async function renderBookAiSettings() {
12500
13691
  "afterend",
12501
13692
  `<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
12502
13693
  );
13694
+ host.querySelector('input[name="agent-tool"][value="read_character_sections"]').closest("label").insertAdjacentHTML(
13695
+ "afterend",
13696
+ `<label><input name="agent-tool" type="checkbox" value="semantic_search_story" ${agentTools.has("semantic_search_story") ? "checked" : ""} ${settings.semanticSearchEnabled ? "" : "disabled"}><span><strong>语义检索作品原文</strong><small>允许 Agent 显式调用 semantic_search_story;只影响保存后新建的对话,普通消息不会自动检索。</small></span></label>`
13697
+ );
12503
13698
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
12504
13699
  "beforeend",
12505
13700
  `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算两个 YYYY-MM-DD 日期之间的天数差,不读取作品内容。</small></span></label>`
@@ -12536,6 +13731,109 @@ async function renderBookAiSettings() {
12536
13731
  return status;
12537
13732
  };
12538
13733
  updateRelationshipIndexStatus(relationshipIndex);
13734
+ const isCurrentSemanticIndexPanel = () => state.module === "ai-settings"
13735
+ && String(state.work?.id ?? "") === workId
13736
+ && Boolean($("#semantic-search-index-status"));
13737
+ const updateSemanticIndexStatus = (status) => {
13738
+ const statusHost = $("#semantic-search-index-status");
13739
+ if (!statusHost) return;
13740
+ statusHost.innerHTML = semanticIndexStatusMarkup(status);
13741
+ if (semanticSearchIndexRefreshTimer) clearTimeout(semanticSearchIndexRefreshTimer);
13742
+ semanticSearchIndexRefreshTimer = null;
13743
+ if (!["idle", "building"].includes(String(status.status))) return;
13744
+ semanticSearchIndexRefreshTimer = setTimeout(async () => {
13745
+ semanticSearchIndexRefreshTimer = null;
13746
+ if (!isCurrentSemanticIndexPanel()) return;
13747
+ try {
13748
+ const nextStatus = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
13749
+ if (isCurrentSemanticIndexPanel()) updateSemanticIndexStatus(nextStatus);
13750
+ } catch {
13751
+ // 后台轮询失败时保留当前进度,用户仍可手动刷新。
13752
+ }
13753
+ }, 1_200);
13754
+ };
13755
+ const refreshSemanticIndexStatus = async () => {
13756
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
13757
+ updateSemanticIndexStatus(status);
13758
+ return status;
13759
+ };
13760
+ updateSemanticIndexStatus(semanticIndex);
13761
+ $("#save-semantic-search-settings").addEventListener("click", async () => {
13762
+ const button = $("#save-semantic-search-settings");
13763
+ const enabled = $("#semantic-search-enabled").checked;
13764
+ const embeddingModelId = $("#semantic-embedding-model").value || null;
13765
+ const vectorDimension = Number($("#semantic-vector-dimension").value);
13766
+ const recallLimit = Number($("#semantic-recall-limit").value);
13767
+ const resultLimit = Number($("#semantic-result-limit").value);
13768
+ const budgetTokens = Number($("#semantic-budget-tokens").value);
13769
+ const channelWeight = Number($("#semantic-channel-weight").value);
13770
+ if (enabled && !embeddingModelId) return toast("开启 RAG 前必须选择 embedding 模型", "error");
13771
+ if (!Number.isInteger(vectorDimension) || vectorDimension < 1 || vectorDimension > 65_536) return toast("向量维度必须是 1 到 65536 的整数", "error");
13772
+ if (!Number.isInteger(recallLimit) || recallLimit < 1 || recallLimit > 200) return toast("语义召回数量必须是 1 到 200 的整数", "error");
13773
+ if (!Number.isInteger(resultLimit) || resultLimit < 1 || resultLimit > 100) return toast("展示结果数量必须是 1 到 100 的整数", "error");
13774
+ if (!Number.isInteger(budgetTokens) || budgetTokens < 256 || budgetTokens > 100_000) return toast("语义上下文预算必须是 256 到 100000 Token", "error");
13775
+ if (!Number.isFinite(channelWeight) || channelWeight < 0.1 || channelWeight > 5) return toast("语义通道权重必须在 0.1 到 5 之间", "error");
13776
+ button.disabled = true;
13777
+ try {
13778
+ const updated = await api(`/api/works/${workId}/ai-settings/semantic-search`, {
13779
+ method: "PATCH",
13780
+ body: {
13781
+ enabled,
13782
+ embeddingModelId,
13783
+ rerankModelId: $("#semantic-rerank-model").value || null,
13784
+ vectorDimension,
13785
+ recallLimit,
13786
+ resultLimit,
13787
+ budgetTokens,
13788
+ channelWeight
13789
+ }
13790
+ });
13791
+ updateSemanticIndexStatus(updated.semanticIndex);
13792
+ toast(enabled ? "RAG 配置已保存;请执行完整重建" : "主动语义检索已关闭");
13793
+ await renderBookAiSettings();
13794
+ } catch (error) {
13795
+ toast(error.message, "error");
13796
+ button.disabled = false;
13797
+ }
13798
+ });
13799
+ $("#sync-semantic-search-index").addEventListener("click", async () => {
13800
+ const button = $("#sync-semantic-search-index");
13801
+ button.disabled = true;
13802
+ try {
13803
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/sync`, { method: "POST" });
13804
+ updateSemanticIndexStatus({ ...status, status: "building" });
13805
+ toast("已开始同步 RAG 增量来源");
13806
+ } catch (error) {
13807
+ toast(error.message, "error");
13808
+ } finally {
13809
+ button.disabled = false;
13810
+ }
13811
+ });
13812
+ $("#refresh-semantic-search-index").addEventListener("click", async () => {
13813
+ const button = $("#refresh-semantic-search-index");
13814
+ button.disabled = true;
13815
+ try {
13816
+ await refreshSemanticIndexStatus();
13817
+ toast("RAG 状态已刷新", "info");
13818
+ } catch (error) {
13819
+ toast(error.message, "error");
13820
+ } finally {
13821
+ button.disabled = false;
13822
+ }
13823
+ });
13824
+ $("#rebuild-semantic-search-index").addEventListener("click", async () => {
13825
+ const button = $("#rebuild-semantic-search-index");
13826
+ button.disabled = true;
13827
+ try {
13828
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/rebuild`, { method: "POST" });
13829
+ updateSemanticIndexStatus({ ...status, status: "building", progress: 0 });
13830
+ toast("已开始完整重建 RAG");
13831
+ } catch (error) {
13832
+ toast(error.message, "error");
13833
+ } finally {
13834
+ button.disabled = false;
13835
+ }
13836
+ });
12539
13837
  const syncTokenQuotaWarnings = () => {
12540
13838
  for (const period of ["daily", "monthly"]) {
12541
13839
  const enabled = $(`#${period}-token-quota-enabled`).checked;
@@ -12628,6 +13926,39 @@ async function renderBookAiSettings() {
12628
13926
  button.disabled = false;
12629
13927
  }
12630
13928
  });
13929
+ $("#save-remote-mcp-config").addEventListener("click", async () => {
13930
+ const button = $("#save-remote-mcp-config");
13931
+ const editor = $("#remote-mcp-config");
13932
+ const status = $("#remote-mcp-status");
13933
+ let configuration;
13934
+ try {
13935
+ configuration = JSON.parse(editor.value);
13936
+ } catch {
13937
+ toast("MCP 配置不是合法 JSON,请检查逗号、引号和括号", "error");
13938
+ editor.focus();
13939
+ return;
13940
+ }
13941
+ button.disabled = true;
13942
+ button.textContent = "正在测试连接…";
13943
+ status.textContent = "正在逐个验证远程 MCP Server 的地址、协议握手与工具列表,请稍候。";
13944
+ try {
13945
+ const saved = await api(`/api/works/${state.work.id}/ai-settings/mcp-servers`, {
13946
+ method: "PUT",
13947
+ body: configuration
13948
+ });
13949
+ const serverCount = Array.isArray(saved.servers) ? saved.servers.length : 0;
13950
+ const toolCount = Math.max(0, Number(saved.totalToolCount) || 0);
13951
+ toast(serverCount > 0
13952
+ ? `已验证并保存 ${serverCount} 个 MCP Server,共 ${toolCount} 个工具`
13953
+ : "远程 MCP 配置已清空");
13954
+ await renderBookAiSettings();
13955
+ } catch (error) {
13956
+ status.textContent = "验证失败,当前已保存配置保持不变。";
13957
+ toast(error.message, "error");
13958
+ button.disabled = false;
13959
+ button.textContent = "测试并保存 MCP 配置";
13960
+ }
13961
+ });
12631
13962
  $("#sync-relationship-search-index").addEventListener("click", async () => {
12632
13963
  const button = $("#sync-relationship-search-index");
12633
13964
  button.disabled = true;
@@ -12748,6 +14079,23 @@ async function renderBookAiSettings() {
12748
14079
  button.disabled = false;
12749
14080
  }
12750
14081
  });
14082
+ $("#open-ai-approval-center-from-settings").addEventListener("click", () => openAiApprovalCenter());
14083
+ $("#save-ai-write-tools").addEventListener("click", async () => {
14084
+ const button = $("#save-ai-write-tools");
14085
+ button.disabled = true;
14086
+ try {
14087
+ const tools = {};
14088
+ host.querySelectorAll('input[name="ai-write-tool"]').forEach((input) => {
14089
+ tools[input.value] = input.checked;
14090
+ });
14091
+ await api(`/api/works/${state.work.id}/ai/tools`, { method: "PUT", body: { tools } });
14092
+ toast("AI 可写工具开关已保存");
14093
+ } catch (error) {
14094
+ toast(error.message, "error");
14095
+ } finally {
14096
+ button.disabled = false;
14097
+ }
14098
+ });
12751
14099
  host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
12752
14100
  const select = event.currentTarget;
12753
14101
  select.disabled = true;
@@ -12838,28 +14186,33 @@ function currentAiRequestScope() {
12838
14186
  if (!state.work) return null;
12839
14187
  const selectedTaskType = $("#ai-task").value;
12840
14188
  const roleplaySelected = selectedTaskType === "roleplay";
12841
- const taskType = roleplaySelected ? "chat" : selectedTaskType;
12842
- if (state.aiPromptSent) {
12843
- const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
12844
- conversationScope.includeSettingInfo = false;
12845
- const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
12846
- return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
12847
- }
12848
- const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
12849
- const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
12850
- if (requiresChapter && !state.chapter) return null;
12851
- const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
14189
+ const taskType = "chat";
14190
+ const scopeType = state.aiPromptSent
14191
+ ? null
14192
+ : roleplaySelected ? "none" : $("#ai-scope").value;
14193
+ if (!state.aiPromptSent && scopeType !== "none" && scopeType !== "settings-catalog" && !state.chapter) return null;
12852
14194
  const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
12853
- const includeBookSummary = scopeType === "chapter-summary";
12854
- const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
12855
- : scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
14195
+ const conversationScope = state.aiPromptSent
14196
+ ? JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }))
14197
+ : scopeType === "none" ? { type: "none" }
12856
14198
  : scopeType === "book" ? { type: "book" }
12857
14199
  : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
12858
14200
  : scopeType === "settings-catalog" ? { type: "settings-catalog" }
12859
14201
  : { type: "chapter", chapterId: state.chapter?.id };
12860
- if (includeBookSummary) conversationScope.includeBookSummary = true;
14202
+ if (!state.aiPromptSent && scopeType === "chapter-summary") conversationScope.includeBookSummary = true;
12861
14203
  conversationScope.includeSettingInfo = false;
12862
- const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
14204
+ const referencedScope = mergeAiReferenceScope(conversationScope, state.aiReferences);
14205
+ const chapterInput = state.chapter && !roleplaySelected ? $("#chapter-content") : null;
14206
+ const selectionStart = chapterInput?.selectionStart ?? 0;
14207
+ const selectionEnd = chapterInput?.selectionEnd ?? 0;
14208
+ const selection = chapterInput?.value.slice(selectionStart, selectionEnd) ?? "";
14209
+ const writingTarget = state.chapter && !roleplaySelected ? {
14210
+ chapterId: state.chapter.id,
14211
+ writingChapterVersion: state.chapter.versionNo,
14212
+ ...(selection ? { selection, selectionStart, selectionEnd } : {})
14213
+ } : {};
14214
+ const scope = { ...referencedScope, ...writingTarget };
14215
+ if (state.aiSemanticSnapshot?.id) scope.semanticSnapshotId = state.aiSemanticSnapshot.id;
12863
14216
  return { taskType, scope, conversationScope, selection };
12864
14217
  }
12865
14218
 
@@ -12885,8 +14238,8 @@ function renderAiContextDistribution(usage) {
12885
14238
  if (item.key === "skills" || item.key === "input" || item.key === "output") {
12886
14239
  const description = document.createElement("small");
12887
14240
  description.textContent = item.key === "skills"
12888
- ? "待加入"
12889
- : item.key === "input" ? "用户和 agent 的交互" : "模型输出预留";
14241
+ ? item.tokens > 0 ? "按需加载" : "未加载"
14242
+ : item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
12890
14243
  title.append(" ", description);
12891
14244
  }
12892
14245
  const value = document.createElement("strong");
@@ -13036,9 +14389,10 @@ function field(name, label, type = "text", value = "", options = []) {
13036
14389
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
13037
14390
  }
13038
14391
  if (type === "keyword-chips") {
14392
+ const chipLabel = String(label).includes("关键词") ? "关键词" : label;
13039
14393
  const values = uniqueRelationshipKeywords(Array.isArray(value) ? value : []);
13040
- const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`).join("");
13041
- return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入关键词后按回车添加;也可用逗号一次添加多个。</small></div>`;
14394
+ const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(chipLabel)}:${esc(keyword)}">×</button></span>`).join("");
14395
+ return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}" data-remove-label="${esc(chipLabel)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入${esc(chipLabel)}后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入${esc(chipLabel)}后按回车添加;也可用逗号一次添加多个。</small></div>`;
13042
14396
  }
13043
14397
  if (type === "key-value-list") {
13044
14398
  const config = Array.isArray(options) ? {} : options;
@@ -13050,9 +14404,10 @@ function field(name, label, type = "text", value = "", options = []) {
13050
14404
  const valueAriaLabel = config.valueAriaLabel ?? "扩展属性内容";
13051
14405
  const removeLabel = config.removeLabel ?? "删除此扩展属性";
13052
14406
  const addLabel = config.addLabel ?? "添加属性";
14407
+ const multilineValue = config.multilineValue ?? false;
13053
14408
  const values = normalizeCharacterDetails(value);
13054
14409
  const rows = values.length ? values : [{ label: "", value: "" }];
13055
- return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}"><input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}"><button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
14410
+ return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}">${multilineValue ? `<textarea class="key-value-list-value" name="${esc(valueName)}" rows="1" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}" data-auto-grow>${esc(item.value)}</textarea>` : `<input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}">`}<button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
13056
14411
  }
13057
14412
  if (type === "section-list") {
13058
14413
  const values = normalizeCharacterSections(value);
@@ -13125,6 +14480,10 @@ function renderKnowledgeMarkdownSections() {
13125
14480
  }
13126
14481
 
13127
14482
  function bindDynamicListControls(container) {
14483
+ resizeAutoGrowingTextareas(container);
14484
+ container.addEventListener("input", (event) => {
14485
+ if (event.target.matches?.("textarea[data-auto-grow]")) resizeAutoGrowingTextarea(event.target);
14486
+ });
13128
14487
  container.querySelectorAll("[data-item-list-add]").forEach((button) => button.addEventListener("click", () => {
13129
14488
  const rows = button.previousElementSibling;
13130
14489
  const row = document.createElement("div");
@@ -13146,6 +14505,7 @@ function bindDynamicListControls(container) {
13146
14505
  const row = rows.lastElementChild.cloneNode(true);
13147
14506
  row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
13148
14507
  rows.append(row);
14508
+ resizeAutoGrowingTextareas(row);
13149
14509
  row.querySelector("input").focus();
13150
14510
  }));
13151
14511
  container.onclick = (event) => {
@@ -13153,21 +14513,65 @@ function bindDynamicListControls(container) {
13153
14513
  if (!remove) return;
13154
14514
  const row = remove.closest(".item-list-row, .structured-list-row");
13155
14515
  const rows = row.parentElement;
13156
- if (rows.children.length === 1) row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
14516
+ if (rows.children.length === 1) {
14517
+ row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
14518
+ resizeAutoGrowingTextareas(row);
14519
+ }
13157
14520
  else row.remove();
13158
14521
  };
13159
14522
  }
13160
14523
 
14524
+ const supportsNativeTextareaContentSizing = typeof CSS !== "undefined" && CSS.supports("field-sizing", "content");
14525
+
14526
+ function resizeAutoGrowingTextarea(textarea) {
14527
+ if (supportsNativeTextareaContentSizing) {
14528
+ textarea.style.removeProperty("height");
14529
+ return;
14530
+ }
14531
+ textarea.style.height = "0px";
14532
+ const minimumHeight = Number.parseFloat(getComputedStyle(textarea).minHeight) || 0;
14533
+ textarea.style.height = `${Math.max(minimumHeight, textarea.scrollHeight)}px`;
14534
+ }
14535
+
14536
+ const autoGrowingTextareaWidths = new WeakMap();
14537
+ const autoGrowingTextareaObserver = typeof ResizeObserver === "function"
14538
+ ? new ResizeObserver((entries) => {
14539
+ entries.forEach(({ target }) => {
14540
+ const width = target.getBoundingClientRect().width;
14541
+ if (autoGrowingTextareaWidths.get(target) === width) return;
14542
+ autoGrowingTextareaWidths.set(target, width);
14543
+ resizeAutoGrowingTextarea(target);
14544
+ });
14545
+ })
14546
+ : null;
14547
+
14548
+ function resizeAutoGrowingTextareas(container) {
14549
+ container?.querySelectorAll("textarea[data-auto-grow]").forEach((textarea) => {
14550
+ resizeAutoGrowingTextarea(textarea);
14551
+ autoGrowingTextareaObserver?.observe(textarea);
14552
+ });
14553
+ }
14554
+
14555
+ let autoGrowingTextareaResizeFrame = null;
14556
+ window.addEventListener("resize", () => {
14557
+ if (autoGrowingTextareaResizeFrame !== null) cancelAnimationFrame(autoGrowingTextareaResizeFrame);
14558
+ autoGrowingTextareaResizeFrame = requestAnimationFrame(() => {
14559
+ autoGrowingTextareaResizeFrame = null;
14560
+ resizeAutoGrowingTextareas(document);
14561
+ });
14562
+ });
14563
+
13161
14564
  function appendRelationshipKeywordChips(editor, values) {
13162
14565
  const input = editor.querySelector("[data-keyword-input]");
13163
14566
  if (!input) return;
13164
14567
  const existing = new Set([...editor.querySelectorAll("[data-keyword-value]")].map((control) => String(control.value).toLocaleLowerCase("zh-CN")));
13165
14568
  const name = editor.dataset.name || "keywords";
14569
+ const removeLabel = editor.dataset.removeLabel || "关键词";
13166
14570
  for (const keyword of uniqueRelationshipKeywords(values)) {
13167
14571
  const key = keyword.toLocaleLowerCase("zh-CN");
13168
14572
  if (existing.has(key)) continue;
13169
14573
  existing.add(key);
13170
- input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`);
14574
+ input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(removeLabel)}:${esc(keyword)}">×</button></span>`);
13171
14575
  }
13172
14576
  }
13173
14577
 
@@ -13205,7 +14609,7 @@ function commitRelationshipKeywordInputs(container) {
13205
14609
  container.querySelectorAll("[data-keyword-chips]").forEach(commitRelationshipKeywordInput);
13206
14610
  }
13207
14611
 
13208
- function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
14612
+ async function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13209
14613
  formDialogVditors.forEach(destroyVditorEditor);
13210
14614
  formDialogVditors = [];
13211
14615
  void discardPendingMarkdownAttachments();
@@ -13255,6 +14659,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
13255
14659
  dialog.classList.toggle("editor-dialog", Boolean(options.editor));
13256
14660
  bindDynamicListControls($("#dialog-fields"));
13257
14661
  bindRelationshipKeywordControls($("#dialog-fields"));
14662
+ if ($("#dialog-fields").querySelector("[data-vditor-editor]") && !(await loadVditorResources())) return;
13258
14663
  formDialogVditors = bindVditorEditors($("#dialog-fields"));
13259
14664
  form.onclick = null;
13260
14665
  form.onkeydown = null;
@@ -13498,18 +14903,36 @@ function openWorkSettingsDialog(work) {
13498
14903
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
13499
14904
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
13500
14905
  </section>` : "";
14906
+ const editorPreferencesField = `<section class="work-access-field work-editor-preferences-field" aria-labelledby="work-editor-preferences-title">
14907
+ <div><strong id="work-editor-preferences-title">正文编辑辅助</strong><small>仅对当前作品生效。新建作品默认关闭,不影响其他作品或系统设置。</small></div>
14908
+ <div class="work-editor-preference-options" role="group" aria-labelledby="work-editor-preferences-title">
14909
+ <label class="work-editor-preference-option"><input name="editorAutoIndentEnabled" type="checkbox" ${work.editorAutoIndentEnabled ? "checked" : ""}><span><b>自动空两字</b><small>按 Enter 新建段落时自动插入两个全角空格。</small></span></label>
14910
+ <label class="work-editor-preference-option"><input name="editorTypewriterModeEnabled" type="checkbox" ${work.editorTypewriterModeEnabled ? "checked" : ""}><span><b>打字机模式</b><small>输入位置超过页面六成后,将当前行保持在页面中部。</small></span></label>
14911
+ </div>
14912
+ </section>`;
13501
14913
  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,
14914
+ 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
14915
  async (form) => {
13504
- await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
14916
+ await api(`/api/works/${work.id}`, { method: "PATCH", body: {
14917
+ title: form.get("title"),
14918
+ author: form.get("author"),
14919
+ description: form.get("description"),
14920
+ editorAutoIndentEnabled: form.has("editorAutoIndentEnabled"),
14921
+ editorTypewriterModeEnabled: form.has("editorTypewriterModeEnabled")
14922
+ } });
13505
14923
  state.works = (await apiPage("/api/works")).items;
13506
14924
  const updated = state.works.find((item) => item.id === work.id);
13507
14925
  if (updated) Object.assign(work, updated);
13508
14926
  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;
14927
+ if (updated) Object.assign(state.work, updated);
14928
+ else {
14929
+ state.work.title = String(form.get("title") ?? state.work.title);
14930
+ state.work.author = String(form.get("author") ?? state.work.author);
14931
+ state.work.description = String(form.get("description") ?? state.work.description);
14932
+ state.work.editorAutoIndentEnabled = form.has("editorAutoIndentEnabled");
14933
+ state.work.editorTypewriterModeEnabled = form.has("editorTypewriterModeEnabled");
14934
+ }
14935
+ applyChapterEditorPreferences();
13513
14936
  updateDocumentTitle(state.work);
13514
14937
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
13515
14938
  }
@@ -13673,7 +15096,8 @@ function syncSettingEditorDirty(markdown = null) {
13673
15096
  entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
13674
15097
  }
13675
15098
 
13676
- function openSettingEditor(item = null, { readOnly = false } = {}) {
15099
+ async function openSettingEditor(item = null, { readOnly = false } = {}) {
15100
+ if (!(await loadVditorResources())) return;
13677
15101
  entityEditorReadOnly = readOnly;
13678
15102
  destroyVditorEditor(settingEditorVditor);
13679
15103
  settingEditorVditor = null;
@@ -13688,7 +15112,7 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13688
15112
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
13689
15113
  const editButton = $("#setting-editor-edit");
13690
15114
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
13691
- editButton.onclick = () => openSettingEditor(settingEditorItem);
15115
+ editButton.onclick = () => { void openSettingEditor(settingEditorItem); };
13692
15116
  bindEntityDetailPinButton($("#setting-editor-pin"), "setting", () => viewOnly ? settingEditorItem : null, (updated) => {
13693
15117
  settingEditorItem = updated;
13694
15118
  state.settings = upsertEntityCollection(state.settings, updated);
@@ -13801,9 +15225,13 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
13801
15225
  (readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
13802
15226
  }
13803
15227
 
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>
15228
+ function characterEditorSection(key, title, description, content, headerActions = "") {
15229
+ const hasHeaderActions = Boolean(headerActions);
15230
+ const header = hasHeaderActions
15231
+ ? `<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>`
15232
+ : `<header><div><span class="eyebrow">${esc(title)}</span><h3>${esc(title)}</h3></div><p>${esc(description)}</p></header>`;
15233
+ return `<section class="character-editor-section${key === "basic" ? "" : " hidden"}${hasHeaderActions ? " has-header-actions" : ""}" data-character-editor-panel="${esc(key)}" role="tabpanel">
15234
+ ${header}
13807
15235
  <div class="character-editor-section-fields">${content}</div>
13808
15236
  </section>`;
13809
15237
  }
@@ -13815,6 +15243,7 @@ function activateCharacterEditorTab(key) {
13815
15243
  button.tabIndex = active ? 0 : -1;
13816
15244
  });
13817
15245
  document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
15246
+ resizeAutoGrowingTextareas(document.querySelector(`[data-character-editor-panel="${key}"]`));
13818
15247
  if (
13819
15248
  key === "relationships"
13820
15249
  && characterEditorItem?.id
@@ -13824,6 +15253,14 @@ function activateCharacterEditorTab(key) {
13824
15253
  ) {
13825
15254
  void loadCharacterEditorRelationships(characterEditorItem.id);
13826
15255
  }
15256
+ if (
15257
+ key === "roleplay-memory"
15258
+ && characterEditorItem?.id
15259
+ && !roleplayMemoryLoaded
15260
+ && !roleplayMemoryLoading
15261
+ ) {
15262
+ void loadRoleplayMemories({ resetCursor: true }).catch((error) => toast(`角色扮演记忆加载失败:${error.message}`, "error"));
15263
+ }
13827
15264
  }
13828
15265
 
13829
15266
  function setCharacterHistoryVisible(visible) {
@@ -14052,12 +15489,69 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
14052
15489
  };
14053
15490
  }
14054
15491
 
15492
+ function loadVditorStylesheet() {
15493
+ const existing = document.getElementById("vditorStylesheet");
15494
+ if (existing?.dataset.loaded === "true") return Promise.resolve();
15495
+ return new Promise((resolve, reject) => {
15496
+ const link = existing ?? document.createElement("link");
15497
+ link.id = "vditorStylesheet";
15498
+ link.rel = "stylesheet";
15499
+ link.href = "/vendor/vditor/dist/index.css?v=3.11.2";
15500
+ link.addEventListener("load", () => {
15501
+ link.dataset.loaded = "true";
15502
+ resolve();
15503
+ }, { once: true });
15504
+ link.addEventListener("error", () => {
15505
+ link.remove();
15506
+ reject(new Error("Vditor stylesheet failed to load"));
15507
+ }, { once: true });
15508
+ if (!existing) document.head.append(link);
15509
+ });
15510
+ }
15511
+
15512
+ function loadVditorScript(id, src) {
15513
+ const existing = document.getElementById(id);
15514
+ if (existing?.dataset.loaded === "true") return Promise.resolve();
15515
+ return new Promise((resolve, reject) => {
15516
+ const script = existing ?? document.createElement("script");
15517
+ script.id = id;
15518
+ script.src = src;
15519
+ script.addEventListener("load", () => {
15520
+ script.dataset.loaded = "true";
15521
+ resolve();
15522
+ }, { once: true });
15523
+ script.addEventListener("error", () => {
15524
+ script.remove();
15525
+ reject(new Error(`Vditor script failed to load: ${id}`));
15526
+ }, { once: true });
15527
+ if (!existing) document.body.append(script);
15528
+ });
15529
+ }
15530
+
15531
+ async function loadVditorResources() {
15532
+ if (window.Vditor && document.getElementById("vditorStylesheet")) return true;
15533
+ if (!vditorResourcesPromise) {
15534
+ vditorResourcesPromise = Promise.all([
15535
+ loadVditorStylesheet(),
15536
+ loadVditorScript("vditorIconScript", "/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"),
15537
+ loadVditorScript("vditorMainScript", "/vendor/vditor/dist/index.min.js?v=3.11.2")
15538
+ ]).then(() => {
15539
+ if (!window.Vditor) throw new Error("Vditor constructor is unavailable");
15540
+ return true;
15541
+ }).catch(() => {
15542
+ vditorResourcesPromise = null;
15543
+ toast("Markdown 编辑器资源加载失败,请检查网络后重试", "error");
15544
+ return false;
15545
+ });
15546
+ }
15547
+ return vditorResourcesPromise;
15548
+ }
15549
+
14055
15550
  function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
14056
15551
  if (!window.Vditor) {
14057
15552
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
14058
15553
  return null;
14059
15554
  }
14060
- ensureVditorIconScript();
14061
15555
  let editor = null;
14062
15556
  editor = new window.Vditor(host, {
14063
15557
  cdn: "/vendor/vditor",
@@ -14078,7 +15572,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
14078
15572
  className: "vditor-line-number-button",
14079
15573
  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
15574
  click: () => toggleVditorLineNumbers(editor)
14081
- }, "edit-mode", "fullscreen"],
15575
+ }, "edit-mode"],
14082
15576
  upload: {
14083
15577
  accept: "image/*",
14084
15578
  max: 10 * 1024 * 1024,
@@ -14291,14 +15785,6 @@ function normalizeVditorAttachmentImages(editor) {
14291
15785
  });
14292
15786
  }
14293
15787
 
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
15788
  function destroyVditorEditor(editor) {
14303
15789
  if (!editor) return;
14304
15790
  editor.__attachmentObserver?.disconnect();
@@ -14407,6 +15893,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
14407
15893
 
14408
15894
  async function openKnowledgeSectionEditor(index = null) {
14409
15895
  if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
15896
+ if (!(await loadVditorResources())) return;
14410
15897
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
14411
15898
  destroyVditorEditor(knowledgeSectionVditor);
14412
15899
  knowledgeSectionVditor = null;
@@ -14519,6 +16006,7 @@ function syncCharacterSectionEditorDirty(markdown = null) {
14519
16006
  }
14520
16007
 
14521
16008
  async function openCharacterSectionEditor(section = null) {
16009
+ if (!(await loadVditorResources())) return;
14522
16010
  await discardPendingCharacterAttachments();
14523
16011
  destroyVditorEditor(characterSectionVditor);
14524
16012
  characterSectionVditor = null;
@@ -14699,22 +16187,46 @@ function renderCharacterAvatar(item) {
14699
16187
  }
14700
16188
  }
14701
16189
 
16190
+ function roleplayMemoryToolbarMarkup() {
16191
+ return `<div class="roleplay-memory-toolbar">
16192
+ <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>
16193
+ <button id="roleplay-memory-add" class="primary-button" type="button">手工添加</button>
16194
+ </div>`;
16195
+ }
16196
+
16197
+ function roleplayMemorySurfaceMarkup() {
16198
+ return `<div id="character-roleplay-memory-surface" class="character-roleplay-memory-surface">
16199
+ <section id="roleplay-memory-filter-panel" class="roleplay-memory-filter-panel hidden" aria-label="角色扮演记忆筛选">
16200
+ <label for="roleplay-memory-search">搜索<input id="roleplay-memory-search" type="search" maxlength="200" placeholder="搜索事件、承诺、场景或角色状态"></label>
16201
+ <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>
16202
+ <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>
16203
+ <button id="roleplay-memory-filter-reset" class="ghost-button" type="button">重置筛选</button>
16204
+ </section>
16205
+ <div id="roleplay-memory-list" class="roleplay-memory-list" aria-live="polite"></div>
16206
+ <nav id="roleplay-memory-pagination" class="module-pagination roleplay-memory-pagination hidden" aria-label="角色扮演记忆分页">
16207
+ <button id="roleplay-memory-previous" type="button" disabled>上一页</button>
16208
+ <span id="roleplay-memory-page-label">第 1 页</span>
16209
+ <button id="roleplay-memory-next" type="button" disabled>下一页</button>
16210
+ </nav>
16211
+ </div>`;
16212
+ }
16213
+
14702
16214
  function renderCharacterEditorFields(item) {
14703
16215
  const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
14704
16216
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
14705
16217
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
14706
16218
  const stateEntries = characterStateEntries(item?.currentState ?? {});
16219
+ const raceField = !canReadModule("races")
16220
+ ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
16221
+ : state.races.length
16222
+ ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
16223
+ : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>';
14707
16224
  $("#character-editor-fields").innerHTML = [
14708
16225
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
14709
16226
  `<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
14710
- field("name", "标准名", "text", item?.name) +
16227
+ raceField +
14711
16228
  field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
14712
- field("aliases", "别名", "item-list", item?.aliases ?? []) +
14713
- (!canReadModule("races")
14714
- ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
14715
- : state.races.length
14716
- ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
14717
- : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
16229
+ field("aliases", "别名", "keyword-chips", item?.aliases ?? []) +
14718
16230
  (!canReadModule("organizations")
14719
16231
  ? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
14720
16232
  : organizationOptions.length
@@ -14730,7 +16242,7 @@ function renderCharacterEditorFields(item) {
14730
16242
  field("summary", "人物简介", "textarea", item?.profile?.summary) +
14731
16243
  '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
14732
16244
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
14733
- field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
16245
+ field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
14734
16246
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
14735
16247
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
14736
16248
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
@@ -14747,14 +16259,19 @@ function renderCharacterEditorFields(item) {
14747
16259
  '<p class="character-editor-field-help">未修改的数字、布尔值、数组和对象会保留原有数据类型;被修改的值会按文本保存。</p>' +
14748
16260
  field("lockedFields", "锁定字段", "item-list", item?.lockedFields ?? [])),
14749
16261
  characterEditorSection("relationships", "人物关系", "查看与其他人物的关系及关键词;编辑入口与“关系”面板共用同一份关系数据。",
14750
- '<div id="character-editor-relationships" class="character-editor-relationships-field"></div>')
16262
+ '<div id="character-editor-relationships" class="character-editor-relationships-field"></div>'),
16263
+ characterEditorSection("roleplay-memory", "角色扮演记忆", "该角色在作品内唯一、所有有权用户共享的非正史角色扮演记忆库。",
16264
+ item?.id
16265
+ ? roleplayMemorySurfaceMarkup()
16266
+ : '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
16267
+ item?.id ? roleplayMemoryToolbarMarkup() : "")
14751
16268
  ].join("");
14752
- const name = $("#character-editor-fields [name='name']");
14753
- if (name) name.required = true;
14754
16269
  bindDynamicListControls($("#character-editor-fields"));
16270
+ bindRelationshipKeywordControls($("#character-editor-fields"));
14755
16271
  renderCharacterAvatar(item);
14756
16272
  renderCharacterEditorRelationships();
14757
16273
  renderCharacterMarkdownSections();
16274
+ bindRoleplayMemorySurface(item);
14758
16275
  activateCharacterEditorTab("basic");
14759
16276
  }
14760
16277
 
@@ -14830,7 +16347,7 @@ function renderCharacterHistory() {
14830
16347
  const restored = await api(`/api/characters/${characterEditorItem.id}/restore`, { method: "POST", body: { versionNo } });
14831
16348
  characterEditorItem = restored;
14832
16349
  renderCharacterEditorFields(restored);
14833
- $("#character-editor-title").textContent = restored.name;
16350
+ $("#character-editor-name").value = restored.name;
14834
16351
  $("#character-editor-version").textContent = `v${restored.versionNo}`;
14835
16352
  $("#character-change-note").value = "";
14836
16353
  await Promise.all([renderCharacters(), loadAiReferences()]);
@@ -14869,7 +16386,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14869
16386
  characterEditorRelationshipsLoaded = false;
14870
16387
  characterEditorSections = [];
14871
16388
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
14872
- $("#character-editor-title").textContent = item?.name || "新建角色";
16389
+ $("#character-editor-name").value = item?.name ?? "";
14873
16390
  $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
14874
16391
  $("#character-change-note").value = "";
14875
16392
  $("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
@@ -14918,11 +16435,18 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14918
16435
  setCharacterHistoryVisible(false);
14919
16436
  renderCharacterEditorFields(item);
14920
16437
  const viewOnly = readOnly || !canEditModule("characters");
16438
+ $("#character-editor-form").classList.toggle("is-read-only", viewOnly);
16439
+ $("#character-editor-name").readOnly = viewOnly;
16440
+ $("#character-editor-name").setAttribute("aria-readonly", String(viewOnly));
14921
16441
  if (viewOnly) {
14922
16442
  $("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
14923
16443
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
14924
16444
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
14925
16445
  $("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
16446
+ if (item) {
16447
+ $("#character-roleplay-memory-surface")?.querySelectorAll("button, input, select").forEach((control) => { control.disabled = false; });
16448
+ renderRoleplayMemoryList();
16449
+ }
14926
16450
  }
14927
16451
  $("#character-change-note").readOnly = viewOnly;
14928
16452
  $("#character-editor-submit").classList.toggle("hidden", viewOnly);
@@ -14949,6 +16473,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14949
16473
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
14950
16474
  relationshipTab.disabled = !item || !canReadModule("relationships");
14951
16475
  relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
16476
+ const roleplayMemoryTab = document.querySelector("[data-character-editor-tab='roleplay-memory']");
16477
+ roleplayMemoryTab.disabled = !item;
16478
+ roleplayMemoryTab.title = item ? "查看和管理该角色的共享角色扮演记忆" : "创建人物档案后即可管理角色扮演记忆";
14952
16479
  const form = $("#character-editor-form");
14953
16480
  form.onsubmit = async (event) => {
14954
16481
  event.preventDefault();
@@ -14958,10 +16485,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14958
16485
  busyTarget: form,
14959
16486
  button: submit,
14960
16487
  prepare: async () => {
16488
+ commitRelationshipKeywordInputs(form);
14961
16489
  const body = collectCharacterBody(new FormData(form));
14962
16490
  if (!body.name) {
14963
16491
  toast("请填写角色标准名", "error");
14964
- form.querySelector("[name='name']")?.focus();
16492
+ $("#character-editor-name").focus();
14965
16493
  return null;
14966
16494
  }
14967
16495
  const currentItem = characterEditorItem;
@@ -14975,7 +16503,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
14975
16503
  state.characters = upsertEntityCollection(state.characters, saved);
14976
16504
  entityEditorDirty = false;
14977
16505
  renderCharacterAvatar(saved);
14978
- $("#character-editor-title").textContent = saved.name;
16506
+ $("#character-editor-name").value = saved.name;
14979
16507
  $("#character-editor-version").textContent = `v${saved.versionNo}`;
14980
16508
  $("#character-change-note").value = "";
14981
16509
  $("#character-history-button").disabled = false;
@@ -15001,6 +16529,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15001
16529
  if (item) {
15002
16530
  void loadCharacterMarkdownSections(item.id);
15003
16531
  }
16532
+ (viewOnly ? $("#character-editor-close") : $("#character-editor-name")).focus();
15004
16533
  }
15005
16534
 
15006
16535
  function knowledgeEditorSection(key, title, description, content) {
@@ -16154,11 +17683,16 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
16154
17683
 
16155
17684
  function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
16156
17685
  const values = modelFormValues(item);
17686
+ const modelKindFields = `<div class="form-field model-kind-fields" role="group" aria-labelledby="model-kind-heading"><span id="model-kind-heading">专用模型类型</span><label class="checkbox-field model-capability-option"><input id="model-kind-embedding" name="embeddingModel" type="checkbox" ${values.modelKind === "embedding" ? "checked" : ""}><span><strong>这是一个 embedding 模型</strong><small>只用于语义向量,不会出现在 chat 框或 AI 分析任务中。</small></span></label><label class="checkbox-field model-capability-option"><input id="model-kind-rerank" name="rerankModel" type="checkbox" ${values.modelKind === "rerank" ? "checked" : ""}><span><strong>这是一个 rerank 模型</strong><small>只用于语义候选重排,不会出现在 chat 框或 AI 分析任务中。</small></span></label><small>两项都不勾选时,该模型按普通 chat 模型使用。</small></div>`;
16157
17687
  const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
16158
17688
  const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
16159
17689
  const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
16160
17690
  const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
16161
- const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
17691
+ const connectionTestDescription = values.modelKind === "embedding"
17692
+ ? "使用当前供应商凭据调用 OpenAI-compatible embeddings 接口并校验向量。"
17693
+ : values.modelKind === "rerank"
17694
+ ? "使用 Qwen reranker 的 yes/no 模板发起最小相关性判定。"
17695
+ : values.multimodalEnabled && imageDefaultSupported
16162
17696
  ? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
16163
17697
  : "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
16164
17698
  const connectionTest = item && item.providerStatus === "enabled"
@@ -16167,8 +17701,9 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
16167
17701
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
16168
17702
  </section>`
16169
17703
  : "";
16170
- openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
16171
- const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
17704
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + modelKindFields + `<div data-chat-model-fields>` + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + `</div>` + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
17705
+ const modelKind = form.get("embeddingModel") === "on" ? "embedding" : form.get("rerankModel") === "on" ? "rerank" : "chat";
17706
+ const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), modelKind, purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
16172
17707
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
16173
17708
  await renderPlatformAiConfig();
16174
17709
  await loadModels();
@@ -16186,6 +17721,17 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
16186
17721
  const multimodalInput = $("#model-multimodal-enabled");
16187
17722
  const imageDefaultField = $("#model-image-tool-default-field");
16188
17723
  const imageDefaultInput = $("#model-image-tool-default");
17724
+ const embeddingModelInput = $("#model-kind-embedding");
17725
+ const rerankModelInput = $("#model-kind-rerank");
17726
+ const chatModelFields = $("#dialog-fields [data-chat-model-fields]");
17727
+ const syncModelKindFields = (changedInput = null) => {
17728
+ if (changedInput?.checked) {
17729
+ const other = changedInput === embeddingModelInput ? rerankModelInput : embeddingModelInput;
17730
+ if (other) other.checked = false;
17731
+ }
17732
+ const specialized = Boolean(embeddingModelInput?.checked || rerankModelInput?.checked);
17733
+ chatModelFields?.classList.toggle("hidden", specialized);
17734
+ };
16189
17735
  const syncMultimodalFields = () => {
16190
17736
  if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
16191
17737
  const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
@@ -16212,6 +17758,8 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
16212
17758
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
16213
17759
  thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
16214
17760
  multimodalInput?.addEventListener("change", syncMultimodalFields);
17761
+ embeddingModelInput?.addEventListener("change", () => syncModelKindFields(embeddingModelInput));
17762
+ rerankModelInput?.addEventListener("change", () => syncModelKindFields(rerankModelInput));
16215
17763
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
16216
17764
  const button = event.currentTarget;
16217
17765
  button.disabled = true;
@@ -16235,6 +17783,7 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
16235
17783
  syncKimiTemperature();
16236
17784
  syncThinkingEffort();
16237
17785
  syncMultimodalFields();
17786
+ syncModelKindFields();
16238
17787
  }
16239
17788
 
16240
17789
  async function sendAi() {
@@ -16246,6 +17795,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16246
17795
  const tab = activeAiChatTab();
16247
17796
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
16248
17797
  if (aiRequestManager.hasActive(tab.id)) return;
17798
+ if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
16249
17799
  const composerSnapshot = captureAiPromptComposer();
16250
17800
  const requestComposerSnapshot = retry
16251
17801
  ? {
@@ -16270,8 +17820,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16270
17820
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
16271
17821
  const requestScope = currentAiRequestScope();
16272
17822
  if (!requestScope) return toast("请先选择章节", "error");
16273
- const { taskType, scope, selection } = requestScope;
16274
- if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
17823
+ const { scope } = requestScope;
16275
17824
  const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
16276
17825
  const selectedTaskType = $("#ai-task").value;
16277
17826
  persistActiveAiChatTab();
@@ -16305,9 +17854,6 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16305
17854
  if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
16306
17855
  return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
16307
17856
  }
16308
- if (imageAttachmentIds.length > 0 && taskType !== "chat") {
16309
- return toast("图片附件目前仅支持问答对话", "error");
16310
- }
16311
17857
  try {
16312
17858
  await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
16313
17859
  } catch (error) {
@@ -16316,86 +17862,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16316
17862
  }
16317
17863
  setAiChatTabStatus(tab, "streaming");
16318
17864
  if (retry?.message?.isConnected) retry.message.remove();
16319
- if (taskType !== "chat") {
16320
- if (!retry) {
16321
- try {
16322
- const request = assertAiRequestCurrent(requestHolder.snapshot);
16323
- const persistedUserMessage = await persistAiConversationMessage(
16324
- request.conversationId,
16325
- "user",
16326
- instruction,
16327
- citations,
16328
- { modelId },
16329
- { signal: request.signal }
16330
- );
16331
- assertAiRequestCurrent(request);
16332
- updateAiConversationSummaryFromMessage(persistedUserMessage);
16333
- requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
16334
- tab.modelId = modelId;
16335
- tab.selectedModelId = modelId;
16336
- tab.promptSent = true;
16337
- clearAiChatTabComposer(tab);
16338
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
16339
- if (isActiveAiChatTab(tab)) {
16340
- state.aiConversationModelId = modelId;
16341
- state.aiPromptSent = true;
16342
- syncAiTaskOptions();
16343
- renderAiRoleplayCharacterSelect();
16344
- renderAiQuickActions();
16345
- clearAiPromptComposer();
16346
- }
16347
- } catch (error) {
16348
- if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
16349
- setAiChatTabStatus(tab, "error");
16350
- return toast(`对话记录创建失败:${error.message}`, "error");
16351
- }
16352
- } else {
16353
- prepareAiRetryState(tab, modelId);
16354
- }
16355
- } else if (retry) {
16356
- prepareAiRetryState(tab, modelId);
16357
- }
17865
+ if (retry) prepareAiRetryState(tab, modelId);
16358
17866
  let assistantContent = "";
16359
17867
  let assistantMessage;
16360
17868
  let assistantMetadata = {};
16361
17869
  let persistedStreamMessage = null;
16362
- let suggestion = null;
16363
- if (taskType === "chat") {
16364
- const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
16365
- instruction,
16366
- ...(sceneDirection ? { sceneDirection } : {}),
16367
- ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
16368
- scope,
16369
- modelId,
16370
- citations,
16371
- ...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
16372
- conversationId: requestHolder.snapshot.conversationId,
16373
- ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
16374
- }, retry), createAiIdempotencyKey());
16375
- const request = assertAiRequestCurrent(requestHolder.snapshot);
16376
- if (streamed.action === "warn") return;
16377
- assistantContent = streamed.content;
16378
- assistantMessage = streamed.message;
16379
- assistantMetadata = streamed.metadata;
16380
- persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
16381
- applyAiConversationTitle(streamed.conversationTitle, request.conversationId);
16382
- } else {
16383
- const request = assertAiRequestCurrent(requestHolder.snapshot);
16384
- suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
16385
- method: "POST",
16386
- body: { taskType, instruction, scope, modelId, citations, conversationId: requestHolder.snapshot.conversationId },
16387
- signal: request.signal
16388
- });
16389
- assertAiRequestCurrent(request);
16390
- const suggestionFailed = suggestion.guard?.status === "failed"
16391
- || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
16392
- || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
16393
- if (suggestionFailed) setAiChatTabStatus(tab, "error");
16394
- tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
16395
- if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
16396
- assistantContent = suggestion.content;
16397
- assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
16398
- }
17870
+ let writingSuggestion = null;
17871
+ const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
17872
+ instruction,
17873
+ ...(sceneDirection ? { sceneDirection } : {}),
17874
+ ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
17875
+ scope,
17876
+ modelId,
17877
+ citations,
17878
+ ...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
17879
+ conversationId: requestHolder.snapshot.conversationId,
17880
+ ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
17881
+ }, retry), createAiIdempotencyKey());
17882
+ const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
17883
+ if (streamed.action === "warn") return;
17884
+ assistantContent = streamed.content;
17885
+ assistantMessage = streamed.message;
17886
+ assistantMetadata = streamed.metadata;
17887
+ writingSuggestion = streamed.writingSuggestion;
17888
+ persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
17889
+ applyAiConversationTitle(streamed.conversationTitle, streamedRequest.conversationId);
16399
17890
  try {
16400
17891
  const request = assertAiRequestCurrent(requestHolder.snapshot);
16401
17892
  if (persistedStreamMessage) {
@@ -16414,19 +17905,19 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16414
17905
  assistantContent,
16415
17906
  [],
16416
17907
  assistantMetadata,
16417
- { signal: request.signal, requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
17908
+ { signal: request.signal, requestId: aiAssistantRequestId(request) }
16418
17909
  );
16419
17910
  assertAiRequestCurrent(request);
16420
17911
  updateAiConversationSummaryFromMessage(persistedAssistantMessage);
16421
17912
  if (assistantMessage) {
16422
17913
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
16423
17914
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
16424
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
17915
+ }
16425
17916
  }
17917
+ if (writingSuggestion && assistantMessage) attachWritingSuggestion(assistantMessage, writingSuggestion, { tab });
16426
17918
  } catch (error) {
16427
17919
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
16428
17920
  setAiChatTabStatus(tab, "error");
16429
- if (suggestion) appendSuggestion(suggestion, null, null, { tab });
16430
17921
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
16431
17922
  }
16432
17923
  } catch (error) {
@@ -16509,7 +18000,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
16509
18000
  failureMessage,
16510
18001
  [],
16511
18002
  {},
16512
- { requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
18003
+ { requestId: aiAssistantRequestId(request) }
16513
18004
  );
16514
18005
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
16515
18006
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -16589,6 +18080,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16589
18080
  let persistedMessageId = null;
16590
18081
  let persistedMessageCreatedAt = null;
16591
18082
  let conversationTitle = null;
18083
+ let writingSuggestion = null;
16592
18084
  let persistedUserMessage = null;
16593
18085
  let contextAction = "ready";
16594
18086
  let warningOnly = false;
@@ -16727,6 +18219,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16727
18219
  toolCalls.push(toolCall);
16728
18220
  processSteps.push(aiToolProcessStep(toolCall, round));
16729
18221
  renderStreamingProcessSteps(false, elapsedProcessTime());
18222
+ handleInteractiveToolCallEvent(toolCall);
16730
18223
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
16731
18224
  scrollAiFeedToBottom(feed);
16732
18225
  } else if (eventName === "context_compacted") {
@@ -16743,6 +18236,13 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16743
18236
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
16744
18237
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
16745
18238
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
18239
+ writingSuggestion = payload.writingSuggestion && typeof payload.writingSuggestion === "object"
18240
+ ? payload.writingSuggestion
18241
+ : null;
18242
+ const writingSuggestionFailed = writingSuggestion?.guard?.status === "failed"
18243
+ || writingSuggestion?.toolCalls?.some((toolCall) => toolCall.status === "failed")
18244
+ || writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
18245
+ if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
16746
18246
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
16747
18247
  setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
16748
18248
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
@@ -16755,7 +18255,18 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16755
18255
  const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
16756
18256
  ? payload.processDurationMs
16757
18257
  : elapsedProcessTime();
16758
- generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
18258
+ generatedMetadata = {
18259
+ modelDisplayName: payload.model?.displayName,
18260
+ outputTokens: payload.outputTokens,
18261
+ cacheHitPercent: payload.cacheHitPercent,
18262
+ toolCalls,
18263
+ processSteps,
18264
+ processDurationMs,
18265
+ ...(writingSuggestion ? {
18266
+ activeSkills: [writingSuggestion.taskType === "continue" ? "continue-writing" : "polish-writing"],
18267
+ writingSuggestionId: writingSuggestion.id
18268
+ } : {})
18269
+ };
16759
18270
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
16760
18271
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
16761
18272
  attachAssistantCopyAction(message, streamedText);
@@ -16772,18 +18283,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
16772
18283
  assertAiRequestCurrent(requestHolder.snapshot);
16773
18284
  if (streamError) throw streamError;
16774
18285
  assertAiStreamCompleted(streamCompleted);
16775
- return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
18286
+ return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage };
16776
18287
  } catch (error) {
16777
18288
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
16778
18289
  const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
16779
- const interruption = streamedText ? {
18290
+ const hasRenderableProcessSteps = processSteps.some(shouldRenderAiProcessStep);
18291
+ const interruption = streamedText || hasRenderableProcessSteps ? {
16780
18292
  content: streamedText,
16781
18293
  message,
16782
18294
  metadata: {
16783
18295
  interrupted: true,
16784
18296
  interruptionCode,
16785
18297
  interruptionMessage: streamFailure.message.slice(0, 500),
16786
- processDurationMs: Math.min(86_400_000, elapsedProcessTime())
18298
+ processDurationMs: Math.min(86_400_000, elapsedProcessTime()),
18299
+ ...(toolCalls.length ? { toolCalls } : {}),
18300
+ ...(processSteps.length ? { processSteps } : {})
16787
18301
  }
16788
18302
  } : null;
16789
18303
  if (interruption) streamFailure.streamInterruption = interruption;
@@ -16966,46 +18480,91 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
16966
18480
  if (isFailure) renderMessageCardActions(message);
16967
18481
  attachMessageIdentity(message, messageId);
16968
18482
  feed.append(message);
18483
+ const writingSuggestionId = role === "assistant" && typeof metadata?.writingSuggestionId === "string"
18484
+ ? metadata.writingSuggestionId
18485
+ : "";
18486
+ if (writingSuggestionId && !isFailure && !isInterrupted) {
18487
+ api(`/api/suggestions/${encodeURIComponent(writingSuggestionId)}`)
18488
+ .then((suggestion) => attachWritingSuggestion(message, suggestion, { tab }))
18489
+ .catch(() => undefined);
18490
+ }
16969
18491
  scrollAiFeedToBottom(feed);
18492
+ return message;
16970
18493
  }
16971
18494
 
16972
- function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
16973
- const tab = options.tab ?? activeAiChatTab();
16974
- const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
16975
- const message = document.createElement("div");
16976
- message.className = "assistant-message";
16977
- const applicable = suggestion.action !== "note";
16978
- const guard = suggestion.guard;
16979
- const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
16980
- message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`, suggestion.processDurationMs))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
16981
- attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
16982
- attachAssistantCopyAction(message, suggestion.content);
16983
- attachMessageIdentity(message, messageId);
16984
- if (applicable) {
16985
- message.querySelector('[data-action="accept"]').addEventListener("click", async () => {
18495
+ function continuationGuardMarkup(guard) {
18496
+ if (!guard) return "";
18497
+ const issues = Array.isArray(guard.issues) ? guard.issues : [];
18498
+ return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
18499
+ }
18500
+
18501
+ async function applyAcceptedWritingSuggestion(message, suggestion) {
18502
+ const result = await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/accept`, { method: "POST", body: {} });
18503
+ state.chapter = result.chapter;
18504
+ resetChapterDraftLineIds(state.chapter);
18505
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
18506
+ $("#chapter-content").value = state.chapter.content;
18507
+ scheduleChapterLineNumbers();
18508
+ updateChapterStats();
18509
+ state.work = await api(`/api/works/${state.work.id}`);
18510
+ renderTree();
18511
+ message.querySelector("[data-writing-suggestion-actions]").innerHTML = "<span>已采纳并生成新版本</span>";
18512
+ toast("AI 建议已采纳,正文已生成新版本");
18513
+ }
18514
+
18515
+ function attachWritingSuggestion(message, suggestion, options = {}) {
18516
+ if (!suggestion || suggestion.action === "note" || !suggestion.id) return message;
18517
+ const suggestionId = String(suggestion.id);
18518
+ if (message.dataset.writingSuggestionId === suggestionId) return message;
18519
+ message.dataset.writingSuggestionId = suggestionId;
18520
+ message.querySelector("[data-writing-suggestion-ui]")?.remove();
18521
+ const heading = message.querySelector(".message-heading > span");
18522
+ if (heading) heading.textContent = "助手建议";
18523
+ const host = document.createElement("div");
18524
+ host.dataset.writingSuggestionUi = "";
18525
+ host.className = "writing-suggestion-ui";
18526
+ host.innerHTML = `${continuationGuardMarkup(suggestion.guard)}<div class="message-actions" data-writing-suggestion-actions></div>`;
18527
+ const actions = host.querySelector("[data-writing-suggestion-actions]");
18528
+ if (suggestion.status === "accepted") {
18529
+ actions.innerHTML = "<span>已采纳并生成新版本</span>";
18530
+ } else if (suggestion.status === "rejected") {
18531
+ actions.innerHTML = "<span>已拒绝</span>";
18532
+ } else {
18533
+ actions.innerHTML = '<button type="button" data-action="accept">采纳到正文</button><button type="button" data-action="reject">拒绝</button>';
18534
+ actions.querySelector('[data-action="accept"]').addEventListener("click", async () => {
16986
18535
  try {
16987
- const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
16988
- state.chapter = result.chapter;
16989
- lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
16990
- $("#chapter-content").value = state.chapter.content;
16991
- scheduleChapterLineNumbers();
16992
- updateChapterStats();
16993
- state.work = await api(`/api/works/${state.work.id}`);
16994
- renderTree();
16995
- message.querySelector(".message-actions").innerHTML = "<span>已采纳并生成新版本</span>";
16996
- toast("AI 建议已采纳,正文已生成新版本");
16997
- } catch (error) { toast(error.message, "error"); }
18536
+ await applyAcceptedWritingSuggestion(message, suggestion);
18537
+ } catch (error) {
18538
+ toast(error.message, "error");
18539
+ }
16998
18540
  });
16999
- message.querySelector('[data-action="reject"]').addEventListener("click", async () => {
17000
- await api(`/api/suggestions/${suggestion.id}/reject`, { method: "POST", body: {} });
17001
- message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
18541
+ actions.querySelector('[data-action="reject"]').addEventListener("click", async () => {
18542
+ try {
18543
+ await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/reject`, { method: "POST", body: {} });
18544
+ actions.innerHTML = "<span>已拒绝</span>";
18545
+ } catch (error) {
18546
+ toast(error.message, "error");
18547
+ }
17002
18548
  });
17003
18549
  }
17004
- feed.append(message);
17005
- scrollAiFeedToBottom(feed);
18550
+ message.append(host);
18551
+ const tab = options.tab ?? activeAiChatTab();
18552
+ scrollAiFeedToBottom(options.feed ?? tab?.feed ?? $("#ai-feed"));
17006
18553
  return message;
17007
18554
  }
17008
18555
 
18556
+ function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
18557
+ const tab = options.tab ?? activeAiChatTab();
18558
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
18559
+ const message = appendMessage("assistant", suggestion.content, [], createdAt, {
18560
+ modelDisplayName: suggestion.model?.displayName,
18561
+ outputTokens: suggestion.outputTokens,
18562
+ cacheHitPercent: suggestion.cacheHitPercent,
18563
+ processDurationMs: suggestion.processDurationMs
18564
+ }, messageId, { tab, feed });
18565
+ return attachWritingSuggestion(message, suggestion, { tab, feed });
18566
+ }
18567
+
17009
18568
  function chapterVersionCompareOption(version) {
17010
18569
  return `<option value="version:${Number(version.versionNo)}">v${Number(version.versionNo)} · ${esc(chapterVersionSourceLabel(version.source))}</option>`;
17011
18570
  }
@@ -17079,6 +18638,7 @@ async function showVersions() {
17079
18638
  }
17080
18639
  try {
17081
18640
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
18641
+ resetChapterDraftLineIds(state.chapter);
17082
18642
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
17083
18643
  $("#chapter-title").value = state.chapter.title;
17084
18644
  $("#chapter-content").value = state.chapter.content;
@@ -18436,16 +19996,15 @@ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
18436
19996
  renderChapterForeshadowReminder();
18437
19997
  $("#chapter-foreshadow-reminder-details-button").focus();
18438
19998
  });
18439
- $("#chapter-delete-button").addEventListener("click", () => {
18440
- if (state.chapter) void deleteChapter(state.chapter.id);
18441
- });
18442
- $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
19999
+ $("#chapter-edit-button").addEventListener("click", toggleChapterEditPreviewMode);
18443
20000
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
18444
20001
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
18445
20002
  $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
18446
20003
  $("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
18447
20004
  $("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
18448
20005
  $("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
20006
+ $("#chapter-batch-template").addEventListener("input", updateChapterBatchControls);
20007
+ $("#chapter-batch-start").addEventListener("input", updateChapterBatchControls);
18449
20008
  $("#chapter-batch-search").addEventListener("input", renderChapterBatchDialog);
18450
20009
  $("#chapter-batch-select-all").addEventListener("click", () => {
18451
20010
  const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
@@ -18484,7 +20043,7 @@ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
18484
20043
  $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
18485
20044
  $("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
18486
20045
  $("#character-editor-fields").addEventListener("click", (event) => {
18487
- if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
20046
+ if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove], [data-keyword-chip-remove]")) markEntityEditorDirty();
18488
20047
  const uploadButton = event.target.closest("#character-avatar-upload-button");
18489
20048
  if (uploadButton) {
18490
20049
  if (!characterEditorItem?.id) {
@@ -18552,11 +20111,59 @@ $("#appearance-form").addEventListener("submit", (event) => {
18552
20111
  toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
18553
20112
  });
18554
20113
  $("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
18555
- $("#chapter-content").addEventListener("input", () => {
20114
+ $("#chapter-content").addEventListener("beforeinput", (event) => {
20115
+ if (!state.chapter) return;
20116
+ const input = event.currentTarget;
20117
+ syncChapterDraftLineIds(input.value);
20118
+ chapterBeforeInputState = {
20119
+ chapterId: state.chapter.id,
20120
+ content: input.value,
20121
+ selectionStart: input.selectionStart,
20122
+ selectionEnd: input.selectionEnd,
20123
+ inputType: event.inputType
20124
+ };
20125
+ });
20126
+ $("#chapter-content").addEventListener("keydown", (event) => {
20127
+ const input = event.currentTarget;
20128
+ if (
20129
+ event.key !== "Enter"
20130
+ || !state.work?.editorAutoIndentEnabled
20131
+ || event.isComposing
20132
+ || event.altKey
20133
+ || event.ctrlKey
20134
+ || event.metaKey
20135
+ || !state.chapter
20136
+ || input.readOnly
20137
+ ) return;
20138
+ event.preventDefault();
20139
+ syncChapterDraftLineIds(input.value);
20140
+ chapterBeforeInputState = {
20141
+ chapterId: state.chapter.id,
20142
+ content: input.value,
20143
+ selectionStart: input.selectionStart,
20144
+ selectionEnd: input.selectionEnd,
20145
+ inputType: "insertLineBreak"
20146
+ };
20147
+ const next = insertIndentedParagraph(input.value, input.selectionStart, input.selectionEnd);
20148
+ input.setRangeText(`\n${CHAPTER_PARAGRAPH_INDENT}`, input.selectionStart, input.selectionEnd, "end");
20149
+ input.setSelectionRange(next.selectionStart, next.selectionEnd);
20150
+ input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertLineBreak" }));
20151
+ });
20152
+ $("#chapter-content").addEventListener("input", (event) => {
20153
+ const input = event.currentTarget;
20154
+ const beforeInput = chapterBeforeInputState;
20155
+ const hint = beforeInput
20156
+ && beforeInput.chapterId === state.chapter?.id
20157
+ && chapterDraftLineIdState?.content === beforeInput.content
20158
+ ? beforeInput
20159
+ : null;
20160
+ syncChapterDraftLineIds(input.value, hint);
20161
+ chapterBeforeInputState = null;
18556
20162
  updateChapterStats();
18557
20163
  scheduleChapterAutoSave();
18558
20164
  clearChapterLineSelection();
18559
20165
  scheduleChapterLineNumbers(chapterLineInputRenderDelay);
20166
+ scheduleChapterCaretScroll();
18560
20167
  setAiContextMeter(null);
18561
20168
  });
18562
20169
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
@@ -18639,6 +20246,17 @@ $("#ai-panel-toggle").addEventListener("click", () => {
18639
20246
  panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
18640
20247
  applyPanelLayout(true);
18641
20248
  });
20249
+ $("#ai-semantic-search-toggle").addEventListener("click", () => {
20250
+ setAiSemanticSearchVisible($("#ai-semantic-search-panel").classList.contains("hidden"));
20251
+ });
20252
+ $("#ai-semantic-search-close").addEventListener("click", () => setAiSemanticSearchVisible(false));
20253
+ $("#ai-semantic-search-run").addEventListener("click", () => { void runAiSemanticSearch(); });
20254
+ $("#ai-semantic-query").addEventListener("keydown", (event) => {
20255
+ if (event.key !== "Enter" || event.shiftKey) return;
20256
+ event.preventDefault();
20257
+ void runAiSemanticSearch();
20258
+ });
20259
+ $("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
18642
20260
  setupPanelResize($("#left-panel-resize"), "left");
18643
20261
  setupPanelResize($("#ai-panel-resize"), "ai");
18644
20262
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
@@ -19049,6 +20667,11 @@ document.addEventListener("keydown", (event) => {
19049
20667
  return;
19050
20668
  }
19051
20669
  if (event.key === "Escape") {
20670
+ if (!$("#ai-semantic-search-panel").classList.contains("hidden")) {
20671
+ setAiSemanticSearchVisible(false);
20672
+ $("#ai-semantic-search-toggle").focus();
20673
+ return;
20674
+ }
19052
20675
  if (!$("#ai-model-popover").classList.contains("hidden")) {
19053
20676
  setAiModelPickerVisible(false);
19054
20677
  return;
@@ -19279,6 +20902,109 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
19279
20902
  if (label) label.textContent = "导出 Markdown";
19280
20903
  }
19281
20904
  });
20905
+ // --- AI 操作审批中心与写入审批 / 提问弹窗 ---
20906
+ $("#ai-approval-center-toggle").addEventListener("click", () => {
20907
+ const dialog = $("#ai-approval-center-dialog");
20908
+ if (dialog.open) {
20909
+ dialog.close();
20910
+ return;
20911
+ }
20912
+ openAiApprovalCenter();
20913
+ });
20914
+ $("#ai-approval-center-close").addEventListener("click", () => $("#ai-approval-center-dialog").close());
20915
+ $("#ai-approval-center-dialog").addEventListener("close", () => {
20916
+ $("#ai-approval-center-toggle").setAttribute("aria-expanded", "false");
20917
+ });
20918
+ $("#ai-approval-center-dialog .ai-approval-filters").addEventListener("click", (event) => {
20919
+ const chip = event.target.closest("[data-status-filter]");
20920
+ if (!chip) return;
20921
+ aiApprovalCenterState.status = String(chip.dataset.statusFilter ?? "");
20922
+ document.querySelectorAll("#ai-approval-center-dialog [data-status-filter]").forEach((item) => {
20923
+ item.setAttribute("aria-pressed", String(item === chip));
20924
+ });
20925
+ $("#ai-approval-list-host").innerHTML = '<p class="usage-measurement-note">正在加载审批记录……</p>';
20926
+ loadAiApprovalCenterPlans().catch((error) => {
20927
+ $("#ai-approval-list-host").innerHTML = `<p class="usage-measurement-note">加载失败:${esc(error.message)}</p>`;
20928
+ });
20929
+ });
20930
+ $("#ai-approval-list-host").addEventListener("click", (event) => {
20931
+ const row = event.target.closest("[data-plan-id]");
20932
+ if (row) {
20933
+ openAiWritePlanDetail(row.dataset.planId).catch((error) => toast(`审批详情加载失败:${error.message}`, "error"));
20934
+ return;
20935
+ }
20936
+ const questionRow = event.target.closest("[data-question-id]");
20937
+ if (questionRow) openAiUserQuestionDialog(questionRow.dataset.questionId).catch((error) => toast(`提问详情加载失败:${error.message}`, "error"));
20938
+ });
20939
+ $("#ai-write-plan-close").addEventListener("click", () => $("#ai-write-plan-dialog").close());
20940
+ $("#ai-write-plan-refresh").addEventListener("click", () => {
20941
+ if (!aiWritePlanDialogPlanId) return;
20942
+ openAiWritePlanDetail(aiWritePlanDialogPlanId).catch((error) => toast(`状态刷新失败:${error.message}`, "error"));
20943
+ });
20944
+ $("#ai-write-plan-confirm").addEventListener("click", () => {
20945
+ if (!aiWritePlanDialogPlanId) return;
20946
+ decideAiWritePlan(aiWritePlanDialogPlanId, "confirm").catch((error) => toast(error.message, "error"));
20947
+ });
20948
+ $("#ai-write-plan-reject").addEventListener("click", () => {
20949
+ if (!aiWritePlanDialogPlanId) return;
20950
+ decideAiWritePlan(aiWritePlanDialogPlanId, "reject").catch((error) => toast(error.message, "error"));
20951
+ });
20952
+ $("#ai-write-plan-undo").addEventListener("click", () => {
20953
+ if (!aiWritePlanDialogPlanId) return;
20954
+ undoAiWritePlan(aiWritePlanDialogPlanId);
20955
+ });
20956
+ $("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
20957
+
20958
+ function syncAiQuestionSubmitState() {
20959
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20960
+ const customInput = $("#ai-question-custom-answer");
20961
+ const submit = $("#ai-question-submit");
20962
+ const disabled = !checked
20963
+ || checked.disabled
20964
+ || (checked.value === "custom" && !String(customInput.value).trim());
20965
+ submit.disabled = disabled;
20966
+ submit.title = disabled ? "请选择一个预设选项,或填写自定义回答后提交" : "";
20967
+ }
20968
+ $("#ai-question-form").addEventListener("change", (event) => {
20969
+ const control = event.target;
20970
+ if (control?.name !== "ai-question-choice") return;
20971
+ const isCustom = control.value === "custom";
20972
+ const customInput = $("#ai-question-custom-answer");
20973
+ if (isCustom && !control.disabled) customInput.focus();
20974
+ syncAiQuestionOptionPresentation();
20975
+ syncAiQuestionSubmitState();
20976
+ });
20977
+ $("#ai-question-custom-answer").addEventListener("input", () => {
20978
+ const customInput = $("#ai-question-custom-answer");
20979
+ syncAiQuestionAnswerCount();
20980
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20981
+ if (!checked && String(customInput.value).trim()) {
20982
+ const customRadio = document.querySelector('input[name="ai-question-choice"][value="custom"]');
20983
+ if (customRadio && !customRadio.disabled) customRadio.checked = true;
20984
+ }
20985
+ syncAiQuestionOptionPresentation();
20986
+ syncAiQuestionSubmitState();
20987
+ });
20988
+ $("#ai-question-submit").addEventListener("click", () => {
20989
+ const checked = document.querySelector('input[name="ai-question-choice"]:checked');
20990
+ if (!checked || aiQuestionDialogQuestionId == null) return;
20991
+ if (checked.value === "custom") {
20992
+ const text = String($("#ai-question-custom-answer").value).trim();
20993
+ if (!text) return;
20994
+ respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "custom", customAnswer: text }).catch((error) => toast(error.message, "error"));
20995
+ return;
20996
+ }
20997
+ const supplementalAnswer = String($("#ai-question-custom-answer").value).trim();
20998
+ respondAiUserQuestion(aiQuestionDialogQuestionId, {
20999
+ action: "option",
21000
+ selectedOption: Number(checked.value),
21001
+ ...(supplementalAnswer ? { customAnswer: supplementalAnswer } : {})
21002
+ }).catch((error) => toast(error.message, "error"));
21003
+ });
21004
+ $("#ai-question-skip").addEventListener("click", () => {
21005
+ if (aiQuestionDialogQuestionId == null) return;
21006
+ respondAiUserQuestion(aiQuestionDialogQuestionId, { action: "reject" }).catch((error) => toast(error.message, "error"));
21007
+ });
19282
21008
  $("#ai-prompt").addEventListener("keydown", (event) => {
19283
21009
  const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
19284
21010
  if (mentionMenuVisible) {
@@ -19373,9 +21099,6 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
19373
21099
  $("#reader-open-button").addEventListener("click", () => {
19374
21100
  void openReadingPreview({ restorePosition: true });
19375
21101
  });
19376
- $("#chapter-reader-button").addEventListener("click", () => {
19377
- void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
19378
- });
19379
21102
  $("#reader-close").addEventListener("click", closeReadingPreview);
19380
21103
  $("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
19381
21104
  $("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));