@musnows/scriverse 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineN
7
7
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
8
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
9
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
10
+ import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
10
11
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
11
12
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
12
13
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
@@ -21,6 +22,7 @@ import {
21
22
  occurrenceRoleLabel,
22
23
  outlineStatusLabel,
23
24
  providerConnectionLabel,
25
+ providerProtocolLabel,
24
26
  providerStatusLabel,
25
27
  relationshipCategoryLabel,
26
28
  relationshipConfirmationLabel,
@@ -31,11 +33,11 @@ import {
31
33
  taskScopeLabel,
32
34
  timelineStatusLabel,
33
35
  characterStateFieldLabel
34
- } from "/display-labels.js?v=20260726-character-name-variant";
35
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
36
+ } from "/display-labels.js?v=20260726-anthropic-messages-v2";
37
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
36
38
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
37
39
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
38
- import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260726-race-filter-order";
40
+ import { eligibleRaceParents, orderRaceFilterOptions, paginateRaceForest, racePathLabel } from "/race-hierarchy.js?v=20260727-race-tree-pagination-v1";
39
41
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
40
42
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
41
43
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
@@ -43,6 +45,7 @@ import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global
43
45
  import { resolveGlobalSearchTarget } from "/global-search.js?v=20260726-search-result-details";
44
46
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
45
47
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
48
+ import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
46
49
  import {
47
50
  clampCropRect,
48
51
  containImageRect,
@@ -55,7 +58,14 @@ import {
55
58
  } from "/avatar-crop.js?v=20260725-avatar-crop";
56
59
 
57
60
  const defaultPageSizes = Object.freeze({
61
+ settings: 30,
58
62
  characters: 30,
63
+ races: 30,
64
+ organizations: 30,
65
+ timeline: 30,
66
+ outlines: 30,
67
+ relationships: 30,
68
+ reviews: 30,
59
69
  analysisTasks: 30,
60
70
  fileVersions: 30
61
71
  });
@@ -121,11 +131,18 @@ let presenceHeartbeatQueued = null;
121
131
  let presenceHeartbeatRequest = 0;
122
132
  const acknowledgedCollaborativeChangeIds = new Set();
123
133
  let collaborativeChangePromptOpen = false;
124
- let peerPageStale = false;
134
+ let relationshipPresenceId = null;
125
135
  let collaborationAutoSaveDisabled = false;
126
136
 
127
137
  let timelineMultiSelectEnabled = false;
128
138
  let taskProgressRefreshTimer = null;
139
+ let relationshipSearchIndexRefreshTimer = null;
140
+ let backgroundTaskCenterTimer = null;
141
+ let backgroundTaskCenterRequest = 0;
142
+ let backgroundTaskCenterWorkId = null;
143
+ let backgroundTaskCenterTasksInitialized = false;
144
+ let backgroundTaskCenterTaskSnapshots = new Map();
145
+ let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
129
146
  const taskProgressRefreshInterval = 2_500;
130
147
  const taskStatusSnapshots = new Map();
131
148
 
@@ -167,6 +184,10 @@ function analysisTaskStatusLabel(status) {
167
184
  })[String(status)] ?? "未知状态";
168
185
  }
169
186
 
187
+ function canRerunAnalysisTask(task) {
188
+ return ["review", "completed", "partial", "expired", "cancelled"].includes(String(task?.status ?? ""));
189
+ }
190
+
170
191
  function normalizedAnalysisTaskStatus(status) {
171
192
  const value = String(status);
172
193
  return ["pending", "running", "review", "completed", "partial", "expired", "cancelled"].includes(value)
@@ -264,6 +285,7 @@ function applyWorkAccessMode() {
264
285
  $("#ai-prompt").readOnly = aiReadOnly;
265
286
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
266
287
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
288
+ updateBackgroundTaskCenterVisibility();
267
289
  if (proseReadOnly) {
268
290
  chapterEditorReadOnly = true;
269
291
  cancelChapterAutoSave();
@@ -337,6 +359,7 @@ let workScopedUiGeneration = 0;
337
359
  let importHistoryRecords = [];
338
360
  let importHistoryNextPage = null;
339
361
  let importHistoryRequestId = 0;
362
+ let chapterInsightRequestId = 0;
340
363
 
341
364
  const shelfOnboardingSteps = [
342
365
  { selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
@@ -537,11 +560,12 @@ function replacePageRoute(route) {
537
560
  }
538
561
 
539
562
  function presencePageForRoute(route = currentPageRoute()) {
540
- if (!state.work || route.view === "shelf" || route.view === "platform-ai") return null;
563
+ if (!state.work || route.view === "shelf" || route.view === "platform-ai" || route.view === "platform-usage") return null;
564
+ if (relationshipPresenceId) return { kind: "entity-editor", module: "relationship", resourceId: relationshipPresenceId };
541
565
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
542
566
  if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
543
567
  if (route.view === "module") return { kind: "module", module: route.module };
544
- if (route.view === "settings" || route.view === "platform-ai") return { kind: "settings" };
568
+ if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage") return { kind: "settings" };
545
569
  return { kind: "welcome" };
546
570
  }
547
571
 
@@ -627,7 +651,7 @@ async function refreshPresence() {
627
651
  presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
628
652
  renderPresence();
629
653
  const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
630
- void handleCollaborativeChanges(recentChanges);
654
+ void handleRelationshipCollaborativeChanges(recentChanges);
631
655
  }
632
656
  } catch {
633
657
  if (state.work?.id === workId) renderPresence();
@@ -637,14 +661,10 @@ async function refreshPresence() {
637
661
  return presenceParticipants;
638
662
  }
639
663
 
640
- function localEditingDirty() {
641
- return Boolean(state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty);
642
- }
643
-
644
- async function handleCollaborativeChanges(recentChanges) {
664
+ async function handleRelationshipCollaborativeChanges(recentChanges) {
645
665
  if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
646
666
  const localKey = presencePageKey(presencePageForRoute());
647
- if (!localKey) return;
667
+ if (!localKey.startsWith("entity-editor:relationship:")) return;
648
668
  const selfId = state.user?.userId;
649
669
  const incoming = recentChanges.filter((change) => (
650
670
  change
@@ -661,14 +681,10 @@ async function handleCollaborativeChanges(recentChanges) {
661
681
  const oldest = acknowledgedCollaborativeChangeIds.values().next().value;
662
682
  acknowledgedCollaborativeChangeIds.delete(oldest);
663
683
  }
664
- peerPageStale = true;
665
684
  collaborativeChangePromptOpen = true;
666
- const dirtyHint = localEditingDirty()
667
- ? "你当前页面还有未保存修改。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。"
668
- : "请先确认本地没有需要保留的草稿;如有,请复制保存到别处(本页无法代为另存),再刷新页面后继续编辑。";
669
685
  try {
670
- const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新「${latest.label || "当前页面"}」。${dirtyHint}`, {
671
- title: "协作者已更新",
686
+ const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新当前人物关系。请先确认本地没有需要保留的修改,再刷新页面继续查看。`, {
687
+ title: "人物关系已更新",
672
688
  confirmLabel: "刷新页面",
673
689
  cancelLabel: "稍后处理"
674
690
  });
@@ -678,17 +694,6 @@ async function handleCollaborativeChanges(recentChanges) {
678
694
  }
679
695
  }
680
696
 
681
- async function confirmPeerStaleSave() {
682
- if (!peerPageStale) return true;
683
- const confirmed = await confirmToast("协作者已更新当前页面。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。继续保存可能覆盖对方修改或触发冲突。", {
684
- title: "页面内容已过期",
685
- confirmLabel: "仍然保存",
686
- cancelLabel: "暂不保存"
687
- });
688
- if (confirmed) peerPageStale = false;
689
- return confirmed;
690
- }
691
-
692
697
  function schedulePresenceHeartbeat() {
693
698
  if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
694
699
  presenceHeartbeatQueued = setTimeout(() => {
@@ -697,9 +702,15 @@ function schedulePresenceHeartbeat() {
697
702
  }, 80);
698
703
  }
699
704
 
705
+ function setRelationshipPresence(relationshipId) {
706
+ const nextId = relationshipId ? String(relationshipId) : null;
707
+ if (relationshipPresenceId === nextId) return;
708
+ relationshipPresenceId = nextId;
709
+ void refreshPresence();
710
+ }
711
+
700
712
  async function confirmConcurrentSave() {
701
713
  await refreshPresence();
702
- if (!(await confirmPeerStaleSave())) return false;
703
714
  const localKey = presencePageKey(presencePageForRoute());
704
715
  const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
705
716
  if (!peers.length) return true;
@@ -719,6 +730,7 @@ function currentPageRoute() {
719
730
  }
720
731
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
721
732
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
733
+ if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
722
734
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
723
735
  if (!workId) return { view: "shelf" };
724
736
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
@@ -842,6 +854,16 @@ let entityEditorReadOnly = false;
842
854
  let chapterEditorReadOnly = true;
843
855
  let characterListPage = 1;
844
856
  let taskListPage = 1;
857
+ const moduleListPages = {
858
+ settings: 1,
859
+ races: 1,
860
+ organizations: 1,
861
+ timeline: 1,
862
+ outlinePlans: 1,
863
+ foreshadows: 1,
864
+ relationships: 1,
865
+ reviews: 1
866
+ };
845
867
  const characterFilters = { raceIds: [], organizationIds: [] };
846
868
  let characterFiltersPanelOpen = false;
847
869
  const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
@@ -2076,6 +2098,40 @@ function pageSizeFor(module) {
2076
2098
  return normalizePageSize(state.uiSettings.pageSizes[module], defaultPageSizes[module] ?? 30);
2077
2099
  }
2078
2100
 
2101
+ function paginateModuleItems(items, page, sizeKey) {
2102
+ const limit = pageSizeFor(sizeKey);
2103
+ const total = items.length;
2104
+ const pageCount = Math.max(1, Math.ceil(total / limit));
2105
+ const safePage = Math.min(Math.max(1, Number(page) || 1), pageCount);
2106
+ const start = (safePage - 1) * limit;
2107
+ return {
2108
+ items: items.slice(start, start + limit),
2109
+ page: safePage,
2110
+ limit,
2111
+ total,
2112
+ pageCount,
2113
+ hasMore: safePage < pageCount,
2114
+ nextPage: safePage < pageCount ? safePage + 1 : null
2115
+ };
2116
+ }
2117
+
2118
+ function renderModulePagination(pageResult, pageKey, label) {
2119
+ if (pageResult.pageCount <= 1) return "";
2120
+ return `<nav class="module-pagination" aria-label="${esc(label)}分页">
2121
+ <button type="button" data-module-page-key="${esc(pageKey)}" data-module-page="${pageResult.page - 1}" ${pageResult.page <= 1 ? "disabled" : ""}>上一页</button>
2122
+ <span>第 ${pageResult.page}/${pageResult.pageCount} 页 · 本页 ${pageResult.itemCount ?? pageResult.items.length} 条 · 共 ${pageResult.total} 条</span>
2123
+ <button type="button" data-module-page-key="${esc(pageKey)}" data-module-page="${pageResult.nextPage ?? pageResult.page + 1}" ${pageResult.hasMore ? "" : "disabled"}>下一页</button>
2124
+ </nav>`;
2125
+ }
2126
+
2127
+ function bindModulePagination(pageKey, renderPage) {
2128
+ $("#module-content").querySelectorAll(`[data-module-page-key="${CSS.escape(pageKey)}"]`).forEach((button) => button.addEventListener("click", async () => {
2129
+ if (button.disabled) return;
2130
+ $("#module-content").querySelectorAll(`[data-module-page-key="${CSS.escape(pageKey)}"]`).forEach((control) => { control.disabled = true; });
2131
+ await renderPage(Number(button.dataset.modulePage));
2132
+ }));
2133
+ }
2134
+
2079
2135
  async function loadPlatformUiSettings() {
2080
2136
  try {
2081
2137
  applyPlatformUiSettings(await api("/api/ui-settings"));
@@ -2109,6 +2165,16 @@ function raiseToastRegion() {
2109
2165
  region.showPopover();
2110
2166
  }
2111
2167
 
2168
+ function dismissChapterInsightToast() {
2169
+ chapterInsightRequestId += 1;
2170
+ const region = $("#toast-region");
2171
+ region.querySelector(".chapter-insight-toast")?.remove();
2172
+ $("#insight-button").setAttribute("aria-expanded", "false");
2173
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
2174
+ region.hidePopover();
2175
+ }
2176
+ }
2177
+
2112
2178
  function toast(message, type = "info") {
2113
2179
  const region = $("#toast-region");
2114
2180
  const element = document.createElement("div");
@@ -2466,6 +2532,11 @@ async function initializePage() {
2466
2532
  if (route.view === "platform-ai") {
2467
2533
  await showPlatformAi();
2468
2534
  settingsReturnContext = restoredSettingsReturnContext(route);
2535
+ return;
2536
+ }
2537
+ if (route.view === "platform-usage") {
2538
+ await showPlatformUsage();
2539
+ settingsReturnContext = restoredSettingsReturnContext(route);
2469
2540
  }
2470
2541
  } finally {
2471
2542
  document.body.classList.remove("auth-pending");
@@ -2476,12 +2547,15 @@ async function initializePage() {
2476
2547
  }
2477
2548
 
2478
2549
  function showShelf() {
2550
+ stopBackgroundTaskCenter();
2551
+ dismissChapterInsightToast();
2479
2552
  state.dirty = false;
2480
2553
  settingsReturnContext = null;
2481
2554
  updateDocumentTitle();
2482
2555
  $("#app").classList.add("shelf-mode");
2483
2556
  $("#shelf-view").classList.remove("hidden");
2484
2557
  $("#platform-ai-view").classList.add("hidden");
2558
+ $("#platform-usage-view").classList.add("hidden");
2485
2559
  $("#settings-hub-view").classList.add("hidden");
2486
2560
  $("#welcome-view").classList.add("hidden");
2487
2561
  $("#editor-view").classList.add("hidden");
@@ -2508,6 +2582,7 @@ function renderSettingsHub() {
2508
2582
  const canReadAggregate = hasWork && canReadAggregateContent();
2509
2583
  const isAdmin = state.user?.role === "admin";
2510
2584
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
2585
+ $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
2511
2586
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
2512
2587
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
2513
2588
  $("#collaboration-button").disabled = !canManageWork;
@@ -2569,7 +2644,14 @@ async function openPlatformUiSettingsDialog() {
2569
2644
  const settings = await api("/api/platform/ui-settings");
2570
2645
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2571
2646
  const pageSizes = normalizePageSizes(settings.pageSizes);
2647
+ $("#page-size-settings").value = String(pageSizes.settings);
2572
2648
  $("#page-size-characters").value = String(pageSizes.characters);
2649
+ $("#page-size-races").value = String(pageSizes.races);
2650
+ $("#page-size-organizations").value = String(pageSizes.organizations);
2651
+ $("#page-size-timeline").value = String(pageSizes.timeline);
2652
+ $("#page-size-outlines").value = String(pageSizes.outlines);
2653
+ $("#page-size-relationships").value = String(pageSizes.relationships);
2654
+ $("#page-size-reviews").value = String(pageSizes.reviews);
2573
2655
  $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
2574
2656
  $("#page-size-file-versions").value = String(pageSizes.fileVersions);
2575
2657
  $("#platform-ui-settings-dialog").showModal();
@@ -2726,7 +2808,9 @@ async function openSearchResult(result) {
2726
2808
  const target = resolveGlobalSearchTarget(result);
2727
2809
  if (!target) throw new Error("无法打开该搜索结果");
2728
2810
  $("#search-dialog").close();
2729
- const inSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
2811
+ const inSettings = !$("#settings-hub-view").classList.contains("hidden")
2812
+ || !$("#platform-ai-view").classList.contains("hidden")
2813
+ || !$("#platform-usage-view").classList.contains("hidden");
2730
2814
  if (inSettings) await returnFromSettings();
2731
2815
  if (target.kind === "chapter") {
2732
2816
  await selectChapter(target.id);
@@ -2742,16 +2826,20 @@ async function openSearchResult(result) {
2742
2826
  }
2743
2827
 
2744
2828
  async function showSettingsHub() {
2745
- const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
2829
+ const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
2830
+ || !$("#platform-ai-view").classList.contains("hidden")
2831
+ || !$("#platform-usage-view").classList.contains("hidden");
2746
2832
  if (!alreadyInSettings) {
2747
2833
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
2748
2834
  settingsReturnContext = captureSettingsReturnContext();
2749
2835
  state.dirty = false;
2750
2836
  }
2837
+ dismissChapterInsightToast();
2751
2838
  updateDocumentTitle(state.work);
2752
2839
  $("#app").classList.add("shelf-mode");
2753
2840
  $("#shelf-view").classList.add("hidden");
2754
2841
  $("#platform-ai-view").classList.add("hidden");
2842
+ $("#platform-usage-view").classList.add("hidden");
2755
2843
  $("#settings-hub-view").classList.remove("hidden");
2756
2844
  $("#welcome-view").classList.add("hidden");
2757
2845
  $("#editor-view").classList.add("hidden");
@@ -2777,6 +2865,7 @@ async function returnFromSettings() {
2777
2865
  $("#settings-button").removeAttribute("aria-current");
2778
2866
  $("#settings-hub-view").classList.add("hidden");
2779
2867
  $("#platform-ai-view").classList.add("hidden");
2868
+ $("#platform-usage-view").classList.add("hidden");
2780
2869
  if (context.view === "shelf" || !state.work) return showShelf();
2781
2870
  $("#app").classList.remove("shelf-mode");
2782
2871
  $("#shelf-view").classList.add("hidden");
@@ -2790,10 +2879,12 @@ async function returnFromSettings() {
2790
2879
  async function showPlatformAi() {
2791
2880
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?"))) return false;
2792
2881
  state.dirty = false;
2882
+ dismissChapterInsightToast();
2793
2883
  updateDocumentTitle();
2794
2884
  $("#app").classList.add("shelf-mode");
2795
2885
  $("#shelf-view").classList.add("hidden");
2796
2886
  $("#platform-ai-view").classList.remove("hidden");
2887
+ $("#platform-usage-view").classList.add("hidden");
2797
2888
  $("#settings-hub-view").classList.add("hidden");
2798
2889
  $("#welcome-view").classList.add("hidden");
2799
2890
  $("#editor-view").classList.add("hidden");
@@ -2806,6 +2897,26 @@ async function showPlatformAi() {
2806
2897
  return true;
2807
2898
  }
2808
2899
 
2900
+ async function showPlatformUsage() {
2901
+ if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 Token 用量面板将放弃本地修改。是否继续?"))) return false;
2902
+ state.dirty = false;
2903
+ updateDocumentTitle();
2904
+ $("#app").classList.add("shelf-mode");
2905
+ $("#shelf-view").classList.add("hidden");
2906
+ $("#platform-ai-view").classList.add("hidden");
2907
+ $("#platform-usage-view").classList.remove("hidden");
2908
+ $("#settings-hub-view").classList.add("hidden");
2909
+ $("#welcome-view").classList.add("hidden");
2910
+ $("#editor-view").classList.add("hidden");
2911
+ $("#module-view").classList.add("hidden");
2912
+ $("#work-meta").textContent = "Token 用量";
2913
+ $("#settings-button").setAttribute("aria-current", "page");
2914
+ setSaveState("Token 用量");
2915
+ await renderPlatformTokenUsage();
2916
+ replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
2917
+ return true;
2918
+ }
2919
+
2809
2920
  function renderShelf() {
2810
2921
  const shelf = $("#book-shelf");
2811
2922
  shelf.innerHTML = `${state.works.map((work) => `
@@ -2829,6 +2940,7 @@ function renderShelf() {
2829
2940
  }
2830
2941
 
2831
2942
  function resetWorkScopedUiCaches() {
2943
+ stopBackgroundTaskCenter();
2832
2944
  workScopedUiGeneration += 1;
2833
2945
  loadedAiModelsWorkId = null;
2834
2946
  loadedAiReferencesWorkId = null;
@@ -2843,6 +2955,7 @@ function resetWorkScopedUiCaches() {
2843
2955
  state.characters = [];
2844
2956
  state.settings = [];
2845
2957
  characterListPage = 1;
2958
+ Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
2846
2959
  relationshipFilters.fromCharacterIds = [];
2847
2960
  relationshipFilters.toCharacterIds = [];
2848
2961
  taskListPage = 1;
@@ -2876,6 +2989,7 @@ async function selectWork(workId, preferredChapterId = null) {
2876
2989
  $("#app").classList.remove("shelf-mode");
2877
2990
  $("#shelf-view").classList.add("hidden");
2878
2991
  $("#platform-ai-view").classList.add("hidden");
2992
+ $("#platform-usage-view").classList.add("hidden");
2879
2993
  $("#settings-hub-view").classList.add("hidden");
2880
2994
  $("#settings-button").removeAttribute("aria-current");
2881
2995
  settingsReturnContext = null;
@@ -2884,6 +2998,7 @@ async function selectWork(workId, preferredChapterId = null) {
2884
2998
  chapterEditorReadOnly = true;
2885
2999
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2886
3000
  applyWorkAccessMode();
3001
+ startBackgroundTaskCenter(nextWork.id);
2887
3002
  updateDocumentTitle(state.work);
2888
3003
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2889
3004
  $("#top-search-button").disabled = !canReadAggregateContent();
@@ -2992,7 +3107,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
2992
3107
  $("#chapter-content").value = normalizedContent;
2993
3108
  clearChapterLineSelection();
2994
3109
  scheduleChapterLineNumbers();
2995
- $("#chapter-insight").classList.add("hidden");
3110
+ dismissChapterInsightToast();
2996
3111
  updateChapterStats();
2997
3112
  if (!canEditProse()) setSaveState("正文只读");
2998
3113
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
@@ -3027,6 +3142,7 @@ function tidyChapterBlankLines() {
3027
3142
  }
3028
3143
 
3029
3144
  function showWelcome(hasWork = false) {
3145
+ dismissChapterInsightToast();
3030
3146
  $("#editor-view").classList.add("hidden");
3031
3147
  $("#module-view").classList.add("hidden");
3032
3148
  $("#welcome-view").classList.remove("hidden");
@@ -3077,6 +3193,7 @@ async function showModule(module) {
3077
3193
  }
3078
3194
  return;
3079
3195
  }
3196
+ dismissChapterInsightToast();
3080
3197
  $("#welcome-view").classList.add("hidden");
3081
3198
  $("#editor-view").classList.add("hidden");
3082
3199
  $("#module-view").classList.remove("hidden");
@@ -3507,19 +3624,22 @@ function renderSettingRows(records) {
3507
3624
  }).join("")}</div>`;
3508
3625
  }
3509
3626
 
3510
- async function renderSettings() {
3627
+ async function renderSettings(page = moduleListPages.settings) {
3511
3628
  const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
3512
3629
  state.settings = records;
3513
3630
  mountModuleCount(records.length);
3631
+ const pageResult = paginateModuleItems(records, page, "settings");
3632
+ moduleListPages.settings = pageResult.page;
3514
3633
  const layout = readModuleLayout();
3515
3634
  if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
3516
3635
  $("#module-content").innerHTML = records.length
3517
- ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
3636
+ ? `${layout === "rows" ? renderSettingRows(pageResult.items) : renderSettingCards(pageResult.items)}${renderModulePagination(pageResult, "settings", "设定库")}`
3518
3637
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
3519
- bindModuleLayoutToggle(renderSettings);
3638
+ bindModuleLayoutToggle(() => renderSettings(pageResult.page));
3639
+ bindModulePagination("settings", renderSettings);
3520
3640
  const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
3521
3641
  $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
3522
- bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
3642
+ bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
3523
3643
  }
3524
3644
 
3525
3645
  async function renderCharacters(page = characterListPage) {
@@ -3535,14 +3655,15 @@ async function renderCharacters(page = characterListPage) {
3535
3655
  : characterSource;
3536
3656
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3537
3657
  characterListPage = characterPage.page;
3538
- [state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
3658
+ const pageCharacters = characterPage.items;
3659
+ [state.races, state.organizations] = [races, organizations];
3539
3660
  mountModuleCount(characterPage.total);
3540
3661
  const layout = readModuleLayout();
3541
3662
  const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
3542
3663
  const characterLockBadge = (item) => item.lockedFields.length
3543
3664
  ? `<span class="character-lock-badge" aria-label="${item.lockedFields.length} 个锁定字段" title="锁定字段:${esc(item.lockedFields.join("、"))}"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg><span>${item.lockedFields.length}</span></span>`
3544
3665
  : "";
3545
- const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
3666
+ const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
3546
3667
  const details = normalizeCharacterDetails(item.attributes?.details);
3547
3668
  return `
3548
3669
  <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
@@ -3557,7 +3678,7 @@ async function renderCharacters(page = characterListPage) {
3557
3678
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
3558
3679
  </article>`;
3559
3680
  }).join("")}</div>`;
3560
- const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
3681
+ const characterRows = () => `<div class="module-row-list">${pageCharacters.map((item) => {
3561
3682
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
3562
3683
  const meta = [
3563
3684
  item.code ? `编号 ${item.code}` : "",
@@ -3573,10 +3694,10 @@ async function renderCharacters(page = characterListPage) {
3573
3694
  <div class="card-actions">${characterActions(item)}</div>
3574
3695
  </article>`;
3575
3696
  }).join("")}</div>`;
3576
- const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
3697
+ const pagination = pageCharacters.length && (characterPage.page > 1 || characterPage.hasMore)
3577
3698
  ? `<nav class="module-pagination" aria-label="角色列表分页">
3578
3699
  <button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
3579
- <span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${state.characters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
3700
+ <span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${pageCharacters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
3580
3701
  <button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
3581
3702
  </nav>`
3582
3703
  : "";
@@ -3595,8 +3716,8 @@ async function renderCharacters(page = characterListPage) {
3595
3716
  <div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
3596
3717
  </section>`;
3597
3718
  mountCharacterFilterToggle();
3598
- if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
3599
- $("#module-content").innerHTML = filterToolbar + (state.characters.length
3719
+ if (pageCharacters.length) mountModuleLayoutToggle(layout, "角色列表样式");
3720
+ $("#module-content").innerHTML = filterToolbar + (pageCharacters.length
3600
3721
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
3601
3722
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
3602
3723
  bindModuleLayoutToggle(renderCharacters);
@@ -3625,14 +3746,18 @@ async function renderCharacters(page = characterListPage) {
3625
3746
  $("#module-content").querySelectorAll("[data-character-page]").forEach((control) => { control.disabled = true; });
3626
3747
  await renderCharacters(Number(button.dataset.characterPage));
3627
3748
  }));
3628
- bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
3629
- $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
3749
+ bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(pageCharacters.find((item) => item.id === id), { readOnly: true }));
3750
+ $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(pageCharacters.find((item) => item.id === button.dataset.editCharacter))));
3630
3751
  }
3631
3752
 
3632
- async function renderRaces() {
3753
+ async function renderRaces(page = moduleListPages.races) {
3633
3754
  state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
3634
3755
  mountModuleCount(state.races.length);
3635
3756
  const layout = readModuleLayout();
3757
+ const pageResult = layout === "rows"
3758
+ ? paginateModuleItems(state.races, page, "races")
3759
+ : paginateRaceForest(state.races, page, pageSizeFor("races"));
3760
+ moduleListPages.races = pageResult.page;
3636
3761
  const canEditRaces = canEditModule("races");
3637
3762
  const raceActions = (item) => canEditRaces
3638
3763
  ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
@@ -3653,7 +3778,7 @@ async function renderRaces() {
3653
3778
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
3654
3779
  </div>
3655
3780
  </details>`;
3656
- const raceRows = () => `<div class="module-row-list">${state.races.map((item) => {
3781
+ const raceRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
3657
3782
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
3658
3783
  const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
3659
3784
  return `
@@ -3667,22 +3792,25 @@ async function renderRaces() {
3667
3792
  if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
3668
3793
  if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
3669
3794
  $("#module-content").innerHTML = state.races.length
3670
- ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
3795
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${pageResult.items.map(renderRaceNode).join("")}</section>`}${renderModulePagination(pageResult, "races", "种族列表")}`
3671
3796
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
3672
- bindModuleLayoutToggle(renderRaces);
3797
+ bindModuleLayoutToggle(() => renderRaces(pageResult.page));
3798
+ bindModulePagination("races", renderRaces);
3673
3799
  bindRaceTreeExpandToggle();
3674
3800
  bindRaceTreeNodeToggles();
3675
3801
  const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
3676
3802
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
3677
- bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
3803
+ bindEntityHistoryButtons(async () => { await renderRaces(pageResult.page); await loadAiReferences(); });
3678
3804
  }
3679
3805
 
3680
- async function renderOrganizations() {
3806
+ async function renderOrganizations(page = moduleListPages.organizations) {
3681
3807
  [state.organizations, state.characters] = await Promise.all([
3682
3808
  apiAllPages(`/api/works/${state.work.id}/organizations`),
3683
3809
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
3684
3810
  ]);
3685
3811
  mountModuleCount(state.organizations.length);
3812
+ const pageResult = paginateModuleItems(state.organizations, page, "organizations");
3813
+ moduleListPages.organizations = pageResult.page;
3686
3814
  const layout = readModuleLayout();
3687
3815
  const canEditOrganizations = canEditModule("organizations");
3688
3816
  const organizationActions = (item) => canEditOrganizations
@@ -3691,14 +3819,14 @@ async function renderOrganizations() {
3691
3819
  const organizationCardActions = (item) => canEditOrganizations
3692
3820
  ? organizationActions(item)
3693
3821
  : `<div class="card-actions">${organizationActions(item)}</div>`;
3694
- const organizationCards = () => `<div class="card-grid organization-grid">${state.organizations.map((item) => `
3822
+ const organizationCards = () => `<div class="card-grid organization-grid">${pageResult.items.map((item) => `
3695
3823
  <article class="record-card organization-card preview-record-card${canEditOrganizations ? " has-card-edit" : ""}" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}"><small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
3696
3824
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
3697
3825
  <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
3698
3826
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3699
3827
  ${organizationCardActions(item)}
3700
3828
  </article>`).join("")}</div>`;
3701
- const organizationRows = () => `<div class="module-row-list">${state.organizations.map((item) => {
3829
+ const organizationRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
3702
3830
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
3703
3831
  const members = item.members.length ? item.members.map((member) => member.name).join("、") : "暂无绑定角色";
3704
3832
  return `
@@ -3711,12 +3839,13 @@ async function renderOrganizations() {
3711
3839
  }).join("")}</div>`;
3712
3840
  if (state.organizations.length) mountModuleLayoutToggle(layout, "组织列表样式");
3713
3841
  $("#module-content").innerHTML = state.organizations.length
3714
- ? `${layout === "rows" ? organizationRows() : organizationCards()}`
3842
+ ? `${layout === "rows" ? organizationRows() : organizationCards()}${renderModulePagination(pageResult, "organizations", "组织列表")}`
3715
3843
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
3716
- bindModuleLayoutToggle(renderOrganizations);
3844
+ bindModuleLayoutToggle(() => renderOrganizations(pageResult.page));
3845
+ bindModulePagination("organizations", renderOrganizations);
3717
3846
  const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
3718
3847
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
3719
- bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
3848
+ bindEntityHistoryButtons(async () => { await renderOrganizations(pageResult.page); await loadAiReferences(); });
3720
3849
  }
3721
3850
 
3722
3851
  function updateTimelineMultiSelectControls() {
@@ -3741,12 +3870,14 @@ function setTimelineMultiSelectMode(enabled) {
3741
3870
  updateTimelineMultiSelectControls();
3742
3871
  }
3743
3872
 
3744
- async function renderTimeline() {
3873
+ async function renderTimeline(page = moduleListPages.timeline) {
3745
3874
  const [events, tracks] = await Promise.all([
3746
3875
  apiAllPages(`/api/works/${state.work.id}/timeline`),
3747
3876
  apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
3748
3877
  ]);
3749
3878
  mountModuleCount(events.length);
3879
+ const pageResult = paginateModuleItems(events, page, "timeline");
3880
+ moduleListPages.timeline = pageResult.page;
3750
3881
  timelineMultiSelectEnabled = false;
3751
3882
  $("#timeline-tools")?.remove();
3752
3883
  $("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
@@ -3754,9 +3885,10 @@ async function renderTimeline() {
3754
3885
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
3755
3886
  const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(timelineStatusLabel(item.status))}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
3756
3887
  $("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
3757
- const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
3888
+ const laneEvents = pageResult.items.filter((item) => (item.trackId ?? "") === track.id);
3758
3889
  return `<section class="timeline-lane" data-track-id="${esc(track.id)}"><header><div><small>${laneEvents.length} 个节点</small><h3>${esc(track.name)}</h3></div>${track.id ? `<div class="timeline-track-actions"><button class="timeline-track-menu" data-edit-timeline-track="${esc(track.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(track.id)}" data-entity-title="${esc(track.name)}" type="button">历史</button></div>` : ""}</header><p class="timeline-track-description">${esc(track.description || "暂无说明")}</p><div class="timeline-lane-events">${laneEvents.map(eventCard).join("") || '<div class="timeline-lane-empty">还没有时间节点</div>'}</div><button class="timeline-add-event" data-add-event-track="${esc(track.id)}" type="button">添加事件</button></section>`;
3759
- }).join("")}</div>`;
3890
+ }).join("")}</div>${renderModulePagination(pageResult, "timeline", "时间线事件")}`;
3891
+ bindModulePagination("timeline", renderTimeline);
3760
3892
  $("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
3761
3893
  $("#timeline-multi-select-toggle").addEventListener("click", () => setTimelineMultiSelectMode(!timelineMultiSelectEnabled));
3762
3894
  $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => input.addEventListener("change", updateTimelineMultiSelectControls));
@@ -3765,7 +3897,7 @@ async function renderTimeline() {
3765
3897
  $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
3766
3898
  $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
3767
3899
  $("#module-content").querySelectorAll("[data-split-event]").forEach((button) => button.addEventListener("click", () => openTimelineSplitDialog(events.find((item) => item.id === button.dataset.splitEvent))));
3768
- bindEntityHistoryButtons(renderTimeline);
3900
+ bindEntityHistoryButtons(() => renderTimeline(pageResult.page));
3769
3901
  $("#merge-events")?.addEventListener("click", () => {
3770
3902
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
3771
3903
  if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
@@ -3776,30 +3908,34 @@ async function renderTimeline() {
3776
3908
  description: form.get("description") || undefined,
3777
3909
  expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
3778
3910
  } });
3779
- await renderTimeline();
3911
+ await renderTimeline(pageResult.page);
3780
3912
  }, "保留参与者与证据");
3781
3913
  });
3782
3914
  }
3783
3915
 
3784
- async function renderOutlines() {
3916
+ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
3785
3917
  const currentChapterId = state.chapter?.id;
3786
3918
  const [outlines, foreshadows] = await Promise.all([
3787
3919
  apiAllPages(`/api/works/${state.work.id}/outlines`),
3788
3920
  apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
3789
3921
  ]);
3790
3922
  mountModuleCount(outlines.length + foreshadows.length);
3923
+ const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
3924
+ const foreshadowPageResult = paginateModuleItems(foreshadows, foreshadowPage, "outlines");
3925
+ moduleListPages.outlinePlans = outlinePageResult.page;
3926
+ moduleListPages.foreshadows = foreshadowPageResult.page;
3791
3927
  const layout = readModuleLayout();
3792
3928
  const unresolved = foreshadows.filter((item) => item.unresolved);
3793
3929
  const overdue = unresolved.filter((item) => item.overdue);
3794
3930
  const foreshadowActions = (item) => `<button data-edit-foreshadow="${esc(item.id)}">编辑伏笔</button><button data-entity-history="foreshadow" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button>`;
3795
- const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
3931
+ const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadowPageResult.items.map((item) => `
3796
3932
  <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
3797
3933
  <small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
3798
3934
  <h3>${esc(item.title)}</h3><p>${esc(item.description || "暂无说明")}</p>
3799
3935
  <div class="foreshadow-links">${item.occurrences.length ? item.occurrences.map((link) => `<span class="pill">${esc(occurrenceRoleLabel(link.role))} · ${esc(link.volumeTitle)} / ${esc(link.chapterTitle)}</span>`).join("") : '<span class="pill">尚未关联章节</span>'}</div>
3800
3936
  <div class="card-actions">${foreshadowActions(item)}</div>
3801
3937
  </article>`).join("")}</div>`;
3802
- const foreshadowRows = () => `<div class="module-row-list">${foreshadows.map((item) => {
3938
+ const foreshadowRows = () => `<div class="module-row-list">${foreshadowPageResult.items.map((item) => {
3803
3939
  const preview = moduleRowPreview(item.description || "暂无说明");
3804
3940
  const links = item.occurrences.length
3805
3941
  ? item.occurrences.map((link) => `${occurrenceRoleLabel(link.role)} · ${link.volumeTitle} / ${link.chapterTitle}`).join(";")
@@ -3813,28 +3949,32 @@ async function renderOutlines() {
3813
3949
  </article>`;
3814
3950
  }).join("")}</div>`;
3815
3951
  const foreshadowHtml = foreshadows.length
3816
- ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
3952
+ ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}${renderModulePagination(foreshadowPageResult, "foreshadows", "伏笔列表")}`
3817
3953
  : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
3818
3954
  if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
3819
- const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
3955
+ const outlineHtml = outlines.length ? `<div class="outline-list">${outlinePageResult.items.map((item) => `
3820
3956
  <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
3821
3957
  <div><small>${esc(item.volumeTitle)} · ${esc(outlineStatusLabel(item.status))}</small><h3>${esc(item.chapterTitle)}</h3></div>
3822
3958
  <div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
3823
3959
  <div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
3824
3960
  <div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
3825
3961
  <div class="outline-actions">${item.unresolvedForeshadowCount ? `<span>${item.unresolvedForeshadowCount} 个未回收伏笔</span>` : ""}<button data-edit-outline="${esc(item.chapterId)}">编辑</button><button data-entity-history="chapter-outline" data-entity-id="${esc(item.chapterId)}" data-entity-title="${esc(item.chapterTitle)}">版本历史</button></div>
3826
- </article>`).join("")}</div>` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
3962
+ </article>`).join("")}</div>${renderModulePagination(outlinePageResult, "outlinePlans", "章节规划")}` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
3827
3963
  $("#module-content").innerHTML = `<div class="outline-summary"><article><strong>${outlines.length}</strong><span>章节规划</span></article><article><strong>${unresolved.length}</strong><span>未回收伏笔</span></article><article class="${overdue.length ? "danger-text" : ""}"><strong>${overdue.length}</strong><span>已逾期</span></article></div><section class="planning-section"><div class="section-title"><div><span class="eyebrow">伏笔追踪</span><h2>尚未回收与历史伏笔</h2></div></div>${foreshadowHtml}</section><section class="planning-section"><div class="section-title"><div><span class="eyebrow">逐章规划</span><h2>章节目标、冲突与转折</h2></div></div>${outlineHtml}</section>`;
3828
- bindModuleLayoutToggle(renderOutlines);
3964
+ bindModuleLayoutToggle(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
3965
+ bindModulePagination("foreshadows", (page) => renderOutlines(outlinePageResult.page, page));
3966
+ bindModulePagination("outlinePlans", (page) => renderOutlines(page, foreshadowPageResult.page));
3829
3967
  $("#module-content").querySelectorAll("[data-edit-outline]").forEach((button) => button.addEventListener("click", () => openOutlineDialog(outlines.find((item) => item.chapterId === button.dataset.editOutline))));
3830
3968
  $("#module-content").querySelectorAll("[data-edit-foreshadow]").forEach((button) => button.addEventListener("click", () => openForeshadowDialog(foreshadows.find((item) => item.id === button.dataset.editForeshadow))));
3831
- bindEntityHistoryButtons(renderOutlines);
3969
+ bindEntityHistoryButtons(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
3832
3970
  }
3833
3971
 
3834
- async function renderRelationships() {
3972
+ async function renderRelationships(page = moduleListPages.relationships) {
3835
3973
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3836
3974
  const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
3837
3975
  const filteredRelationships = filterRelationships(relationships, relationshipFilters);
3976
+ const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
3977
+ moduleListPages.relationships = pageResult.page;
3838
3978
  const hasRelationshipFilters = relationshipFilters.fromCharacterIds.length > 0 || relationshipFilters.toCharacterIds.length > 0;
3839
3979
  const canEditRelationships = canEditModule("relationships");
3840
3980
  mountModuleCount(filteredRelationships.length);
@@ -3858,10 +3998,11 @@ async function renderRelationships() {
3858
3998
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
3859
3999
  const graph = buildRelationshipGraph(state.characters, relationships);
3860
4000
  state.relationshipGraph = graph;
3861
- const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${filteredRelationships.map((item) => `
4001
+ const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
3862
4002
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3863
4003
  <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
3864
- $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}`;
4004
+ $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
4005
+ bindModulePagination("relationships", renderRelationships);
3865
4006
  const openGalaxy = () => {
3866
4007
  state.galaxy?.destroy();
3867
4008
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3881,24 +4022,24 @@ async function renderRelationships() {
3881
4022
  $("#relationship-from-character-filter").addEventListener("change", async () => {
3882
4023
  relationshipFiltersPanelOpen = true;
3883
4024
  relationshipFilters.fromCharacterIds = readSelectedValues("#relationship-from-character-filter");
3884
- await renderRelationships();
4025
+ await renderRelationships(1);
3885
4026
  });
3886
4027
  $("#relationship-to-character-filter").addEventListener("change", async () => {
3887
4028
  relationshipFiltersPanelOpen = true;
3888
4029
  relationshipFilters.toCharacterIds = readSelectedValues("#relationship-to-character-filter");
3889
- await renderRelationships();
4030
+ await renderRelationships(1);
3890
4031
  });
3891
4032
  $("#clear-relationship-filters")?.addEventListener("click", async () => {
3892
4033
  relationshipFiltersPanelOpen = true;
3893
4034
  relationshipFilters.fromCharacterIds = [];
3894
4035
  relationshipFilters.toCharacterIds = [];
3895
- await renderRelationships();
4036
+ await renderRelationships(1);
3896
4037
  });
3897
4038
  $("#module-content").querySelectorAll("[data-edit-relationship]").forEach((button) => button.addEventListener("click", () => openRelationshipDialog(filteredRelationships.find((item) => item.id === button.dataset.editRelationship))));
3898
- bindEntityHistoryButtons(async () => { await renderRelationships(); await loadAiReferences(); });
4039
+ bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
3899
4040
  }
3900
4041
 
3901
- async function renderReviews() {
4042
+ async function renderReviews(page = moduleListPages.reviews) {
3902
4043
  const canReadCharacters = canReadModule("characters");
3903
4044
  const canResolveReview = canEditModule("reviews");
3904
4045
  const canMergeCharacters = canResolveReview
@@ -3908,6 +4049,8 @@ async function renderReviews() {
3908
4049
  canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
3909
4050
  ]);
3910
4051
  mountModuleCount(reviews.length);
4052
+ const pageResult = paginateModuleItems(reviews, page, "reviews");
4053
+ moduleListPages.reviews = pageResult.page;
3911
4054
  const characterById = new Map(characters.map((character) => [character.id, character]));
3912
4055
  const duplicateCard = (item) => {
3913
4056
  const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
@@ -3945,13 +4088,14 @@ async function renderReviews() {
3945
4088
  };
3946
4089
  if (reviews.length) mountModuleLayoutToggle(layout, "审核列表样式");
3947
4090
  $("#module-content").innerHTML = reviews.length
3948
- ? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
4091
+ ? `${layout === "rows" ? `<div class="module-row-list">${pageResult.items.map(reviewRow).join("")}</div>` : `<div class="card-grid">${pageResult.items.map(reviewCard).join("")}</div>`}${renderModulePagination(pageResult, "reviews", "审核列表")}`
3949
4092
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
3950
- bindModuleLayoutToggle(renderReviews);
4093
+ bindModuleLayoutToggle(() => renderReviews(pageResult.page));
4094
+ bindModulePagination("reviews", renderReviews);
3951
4095
  bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
3952
4096
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
3953
4097
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
3954
- await renderReviews();
4098
+ await renderReviews(pageResult.page);
3955
4099
  }));
3956
4100
  $("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
3957
4101
  const target = characterById.get(button.dataset.mergeTarget);
@@ -3970,7 +4114,7 @@ async function renderReviews() {
3970
4114
  expectedSourceVersionNo: Number(button.dataset.sourceVersion)
3971
4115
  } });
3972
4116
  toast(`已将“${source.name}”合并到“${target.name}”`);
3973
- await renderReviews();
4117
+ await renderReviews(pageResult.page);
3974
4118
  await loadAiReferences();
3975
4119
  } catch (error) {
3976
4120
  toast(error.message, "error");
@@ -3982,7 +4126,7 @@ async function renderReviews() {
3982
4126
  try {
3983
4127
  await api(`/api/reviews/${button.dataset.keepCharactersSeparate}/character-resolution`, { method: "POST", body: { action: "keep-separate" } });
3984
4128
  toast("已确认这两个档案属于不同角色");
3985
- await renderReviews();
4129
+ await renderReviews(pageResult.page);
3986
4130
  } catch (error) {
3987
4131
  toast(error.message, "error");
3988
4132
  button.disabled = false;
@@ -4096,6 +4240,7 @@ async function renderTasks(page = taskListPage) {
4096
4240
  try {
4097
4241
  const result = await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4098
4242
  toast(`已开始下一轮,队列中还有 ${result.pendingCount} 个待执行任务`);
4243
+ await refreshBackgroundTaskCenter({ announce: false });
4099
4244
  await renderTasks();
4100
4245
  } catch (error) {
4101
4246
  toast(error.message, "error");
@@ -4134,6 +4279,7 @@ async function renderTasks(page = taskListPage) {
4134
4279
  scheduleTaskProgressRefresh(workId, 1);
4135
4280
  const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: {} });
4136
4281
  toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
4282
+ await refreshBackgroundTaskCenter({ announce: false });
4137
4283
  if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
4138
4284
  } catch (error) {
4139
4285
  toast(error.message, "error");
@@ -4145,6 +4291,7 @@ async function renderTasks(page = taskListPage) {
4145
4291
  try {
4146
4292
  await api(`/api/tasks/${button.dataset.cancelTask}/cancel`, { method: "POST", body: {} });
4147
4293
  toast("分析任务已取消");
4294
+ await refreshBackgroundTaskCenter({ announce: false });
4148
4295
  if (state.module === "tasks") await renderTasks();
4149
4296
  } catch (error) {
4150
4297
  toast(error.message, "error");
@@ -4154,6 +4301,23 @@ async function renderTasks(page = taskListPage) {
4154
4301
  scheduleTaskProgressRefresh(state.work.id, runningCount);
4155
4302
  }
4156
4303
 
4304
+ async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
4305
+ const workId = state.work?.id;
4306
+ button.disabled = true;
4307
+ const originalLabel = button.textContent;
4308
+ button.textContent = "正在创建";
4309
+ try {
4310
+ const rerun = await api(`/api/tasks/${encodeURIComponent(taskId)}/rerun`, { method: "POST", body: {} });
4311
+ if (closeDetail) $("#form-dialog").close();
4312
+ toast(`已按原配置创建新任务 ${rerun.id}`);
4313
+ if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
4314
+ } catch (error) {
4315
+ toast(error.message, "error");
4316
+ button.disabled = false;
4317
+ button.textContent = originalLabel;
4318
+ }
4319
+ }
4320
+
4157
4321
  function stopTaskProgressRefresh() {
4158
4322
  if (taskProgressRefreshTimer === null) return;
4159
4323
  window.clearTimeout(taskProgressRefreshTimer);
@@ -4372,6 +4536,30 @@ function renderTaskResultItem(item) {
4372
4536
  </article>`;
4373
4537
  }
4374
4538
 
4539
+ function renderRelationshipChangePreview(task, result) {
4540
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
4541
+ ? result.relationshipChangePreview
4542
+ : null;
4543
+ if (!preview) return "";
4544
+ const totalCount = Number(preview.totalCount ?? 0);
4545
+ const counts = `新增 ${Number(preview.createdCount ?? 0)} 项 · 更新 ${Number(preview.updatedCount ?? 0)} 项 · 删除 ${Number(preview.deletedCount ?? 0)} 项`;
4546
+ if (preview.status === "pending") {
4547
+ return `<section class="relationship-change-preview is-pending" aria-label="人物关系变更待确认">
4548
+ <div><small>写入前预览</small><h4>人物关系库尚未修改</h4><p>${esc(counts)}。请先核对下方关系和原文证据,再决定是否应用。</p></div>
4549
+ ${Number(preview.deletedCount ?? 0) > 0 ? `<p class="relationship-change-preview-warning">确认应用后将删除 ${Number(preview.deletedCount)} 条已有关系;若资料已被他人修改,系统会阻止过期预览覆盖新版本。</p>` : ""}
4550
+ <div class="relationship-change-preview-actions">
4551
+ ${totalCount > 0 && canEditModule("relationships") ? `<button class="primary-button" type="button" data-apply-relationship-changes="${esc(task.id)}">确认并应用 ${totalCount} 项变更</button>` : ""}
4552
+ <button class="ghost-button" type="button" data-discard-relationship-changes="${esc(task.id)}">${totalCount > 0 ? "放弃本次变更" : "结束无变更预览"}</button>
4553
+ ${!canEditModule("relationships") && totalCount > 0 ? "<small>当前账号没有编辑人物关系的权限,只能查看或放弃本次变更。</small>" : ""}
4554
+ </div>
4555
+ </section>`;
4556
+ }
4557
+ const applied = preview.status === "applied";
4558
+ return `<section class="relationship-change-preview ${applied ? "is-applied" : "is-discarded"}" aria-label="人物关系变更状态">
4559
+ <div><small>写入前预览</small><h4>${applied ? "变更已应用" : "本次变更已放弃"}</h4><p>${esc(counts)}。${applied ? "人物关系库已按确认结果更新。" : "人物关系库未因本次预览发生修改。"}</p></div>
4560
+ </section>`;
4561
+ }
4562
+
4375
4563
  function renderTaskResult(task) {
4376
4564
  const result = task.resultSummary && typeof task.resultSummary === "object" ? task.resultSummary : {};
4377
4565
  const metrics = Array.isArray(result.metrics) ? result.metrics : [];
@@ -4384,6 +4572,7 @@ function renderTaskResult(task) {
4384
4572
  <p class="task-result-summary">${esc(result.summary || "任务尚未产生分析结果。")}</p>
4385
4573
  ${result.restricted ? '<p class="task-result-warning">部分结果因当前账号权限受限而隐藏。</p>' : ""}
4386
4574
  </section>
4575
+ ${renderRelationshipChangePreview(task, result)}
4387
4576
  <section class="task-result-section">
4388
4577
  <h4>结果保存位置</h4>
4389
4578
  <p><strong>作品</strong> ${esc(state.work?.title || "当前作品")}</p>
@@ -4425,6 +4614,111 @@ function bindTaskResultActions(container) {
4425
4614
  button.textContent = "重新加载完整 JSON";
4426
4615
  }
4427
4616
  }));
4617
+ container.querySelectorAll("[data-apply-relationship-changes], [data-discard-relationship-changes]").forEach((button) => button.addEventListener("click", async () => {
4618
+ if (button.disabled) return;
4619
+ const taskId = button.dataset.applyRelationshipChanges || button.dataset.discardRelationshipChanges;
4620
+ const action = button.dataset.applyRelationshipChanges ? "apply" : "discard";
4621
+ const controls = button.closest(".relationship-change-preview")?.querySelectorAll("button") ?? [];
4622
+ controls.forEach((control) => { control.disabled = true; });
4623
+ button.textContent = action === "apply" ? "正在应用变更" : "正在放弃变更";
4624
+ try {
4625
+ await api(`/api/tasks/${encodeURIComponent(taskId)}/relationship-changes/${action}`, { method: "POST", body: {} });
4626
+ $("#form-dialog").close();
4627
+ toast(action === "apply" ? "人物关系变更已应用" : "本次人物关系变更已放弃");
4628
+ if (state.module === "tasks") await renderTasks();
4629
+ } catch (error) {
4630
+ toast(error.message, "error");
4631
+ controls.forEach((control) => { control.disabled = false; });
4632
+ button.textContent = action === "apply" ? "重新确认并应用" : "重新放弃本次变更";
4633
+ }
4634
+ }));
4635
+ }
4636
+
4637
+ function relationshipIdentityRepairFailure(task) {
4638
+ if (task?.taskType !== "relationship-analysis" || !Array.isArray(task.failures)) return null;
4639
+ return task.failures.find((failure) =>
4640
+ failure?.code === "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED"
4641
+ && failure.details
4642
+ && typeof failure.details === "object"
4643
+ && typeof failure.details.characterId === "string"
4644
+ ) ?? null;
4645
+ }
4646
+
4647
+ function relationshipIdentityRepairMetrics(details) {
4648
+ const metrics = [];
4649
+ if (Number.isFinite(Number(details.candidateCount))) {
4650
+ metrics.push(["疑似来源", `${Number(details.candidateCount)} / 上限 ${Number(details.maximum)}`]);
4651
+ }
4652
+ if (Number.isFinite(Number(details.fuzzyReferenceCount))) {
4653
+ metrics.push(["名称与别名", `${Number(details.fuzzyReferenceCount)} / 上限 ${Number(details.maximumFuzzyReferences)}`]);
4654
+ }
4655
+ if (Number.isFinite(Number(details.scannedCharacters))) {
4656
+ metrics.push(["待核对文本", `${Number(details.scannedCharacters).toLocaleString("zh-CN")} / 上限 ${Number(details.maximumScannedCharacters).toLocaleString("zh-CN")} 字符`]);
4657
+ }
4658
+ if (Number.isFinite(Number(details.fuzzyMatchCount))) {
4659
+ metrics.push(["疑似写法", `${Number(details.fuzzyMatchCount)} / 上限 ${Number(details.maximumFuzzyMatches)}`]);
4660
+ }
4661
+ metrics.push(["身份锚点", `${Number(details.identityAnchorCount ?? 0)} 个`]);
4662
+ return metrics;
4663
+ }
4664
+
4665
+ async function openRelationshipIdentityRepairDialog(task, failure) {
4666
+ const details = failure.details && typeof failure.details === "object" ? failure.details : {};
4667
+ const character = await api(`/api/characters/${encodeURIComponent(details.characterId)}`);
4668
+ const anchors = [
4669
+ character.code,
4670
+ character.attributes?.identity,
4671
+ character.race?.name || character.species,
4672
+ ...(Array.isArray(character.organizations) ? character.organizations.map((organization) => organization.name) : [])
4673
+ ].map((value) => String(value ?? "").trim()).filter(Boolean);
4674
+ const metrics = relationshipIdentityRepairMetrics(details);
4675
+ const metricHtml = metrics.map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`).join("");
4676
+ const anchorHtml = anchors.length
4677
+ ? anchors.map((anchor) => `<span>${esc(anchor)}</span>`).join("")
4678
+ : "<em>尚未登记可用于消歧的身份锚点</em>";
4679
+ openDialog("修复人物身份资料",
4680
+ `<section class="relationship-identity-repair-summary">
4681
+ <div><span>匹配诊断</span><h3>${esc(character.name)}</h3><p>${esc(failure.message)}</p></div>
4682
+ <dl>${metricHtml}</dl>
4683
+ <div class="relationship-identity-anchor-list"><strong>当前身份锚点</strong><div>${anchorHtml}</div></div>
4684
+ </section>
4685
+ <p class="relationship-identity-repair-guidance">把原文中确实指向该人物的稳定称呼登记为别名;编号和身份定位应填写会在正文或设定中共同出现的辨识词。保存后,别名命中会转为精确匹配,身份锚点可收窄过多的拼音疑似来源。</p>
4686
+ <label>角色标准名<input type="text" value="${esc(character.name)}" readonly></label>
4687
+ ${field("aliases", "确认别名", "item-list", character.aliases ?? [])}
4688
+ ${field("code", "人物编号或代号", "text", character.code)}
4689
+ ${field("identity", "身份与定位", "text", character.attributes?.identity)}
4690
+ <button class="ghost-button relationship-identity-full-profile" type="button" data-open-identity-character-profile>打开完整人物档案</button>`,
4691
+ async (form) => {
4692
+ const aliases = form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean);
4693
+ const code = String(form.get("code") ?? "").trim();
4694
+ const identity = String(form.get("identity") ?? "").trim();
4695
+ const saved = await api(`/api/characters/${encodeURIComponent(character.id)}`, {
4696
+ method: "PATCH",
4697
+ body: {
4698
+ aliases,
4699
+ code,
4700
+ attributes: { ...(character.attributes ?? {}), identity },
4701
+ expectedVersionNo: character.versionNo,
4702
+ changeNote: `修复人物关系来源匹配:${failure.message}`.slice(0, 500)
4703
+ }
4704
+ });
4705
+ await loadAiReferences();
4706
+ if (state.module === "tasks") await renderTasks(taskListPage);
4707
+ toast(`“${saved.name}”的身份资料已保存为 v${saved.versionNo},可重新运行原分析配置`);
4708
+ },
4709
+ "人物关系匹配修复",
4710
+ {
4711
+ submitLabel: "保存身份修复",
4712
+ pendingLabel: "保存中…",
4713
+ pendingMessage: "正在保存人物身份资料",
4714
+ errorPrefix: "身份修复失败:",
4715
+ wide: true
4716
+ });
4717
+ $("#dialog-fields").querySelector("[data-open-identity-character-profile]")?.addEventListener("click", async () => {
4718
+ $("#form-dialog").close();
4719
+ await openCharacterEditor(character);
4720
+ activateCharacterEditorTab("profile");
4721
+ });
4428
4722
  }
4429
4723
 
4430
4724
  function openTaskDetailDialog(task, trace) {
@@ -4453,6 +4747,15 @@ function openTaskDetailDialog(task, trace) {
4453
4747
  const failureHtml = failures.length
4454
4748
  ? `<ul>${failures.map((item) => `<li>${esc(item.message || JSON.stringify(item))}</li>`).join("")}</ul>`
4455
4749
  : "<p>无</p>";
4750
+ const identityRepairFailure = relationshipIdentityRepairFailure(task);
4751
+ const identityRepairHtml = identityRepairFailure
4752
+ ? `<section class="relationship-identity-repair-entry">
4753
+ <div><strong>可修复的人物身份匹配问题</strong><p>系统已定位到触发来源超限的角色。补充准确别名或身份锚点后,再按原配置重试。</p></div>
4754
+ ${canEditModule("characters")
4755
+ ? '<button class="primary-button" type="button" data-task-identity-repair>修复人物身份资料</button>'
4756
+ : "<small>当前账户没有人物模块编辑权限。</small>"}
4757
+ </section>`
4758
+ : "";
4456
4759
  const resultPreview = renderTaskResult(task);
4457
4760
  openDialog("任务详情",
4458
4761
  `<div class="task-detail">
@@ -4463,8 +4766,9 @@ function openTaskDetailDialog(task, trace) {
4463
4766
  <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
4464
4767
  <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
4465
4768
  <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
4466
- <div><strong>失败信息</strong>${failureHtml}</div>
4769
+ <div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
4467
4770
  <div><strong>结果摘要</strong>${resultPreview}</div>
4771
+ ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
4468
4772
  </section>
4469
4773
  ${renderTaskTraceVisualization(trace, task.id)}
4470
4774
  </div>`,
@@ -4473,15 +4777,28 @@ function openTaskDetailDialog(task, trace) {
4473
4777
  { submitLabel: "关闭", wide: true, trace: true });
4474
4778
  bindTaskTraceCallActions($("#dialog-fields"));
4475
4779
  bindTaskResultActions($("#dialog-fields"));
4780
+ $("#dialog-fields").querySelector("[data-rerun-task-detail]")?.addEventListener("click", async (event) => {
4781
+ await rerunAnalysisTask(event.currentTarget.dataset.rerunTaskDetail, event.currentTarget, { closeDetail: true });
4782
+ });
4783
+ $("#dialog-fields").querySelector("[data-task-identity-repair]")?.addEventListener("click", async (event) => {
4784
+ const button = event.currentTarget;
4785
+ button.disabled = true;
4786
+ $("#form-dialog").close();
4787
+ try {
4788
+ await openRelationshipIdentityRepairDialog(task, identityRepairFailure);
4789
+ } catch (error) {
4790
+ toast(`身份修复向导加载失败:${error.message}`, "error");
4791
+ }
4792
+ });
4476
4793
  }
4477
4794
 
4478
4795
  function renderProviderCards(providers, models) {
4479
4796
  return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
4480
- <article class="record-card provider-card"><small>平台级 · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
4797
+ <article class="record-card provider-card"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
4481
4798
  <p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
4482
4799
  <div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>`).join("")}</div>
4483
4800
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}">测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`).join("")}</div>`
4484
- : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 兼容接口地址和密钥,测试成功后再添加模型。");
4801
+ : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
4485
4802
  }
4486
4803
 
4487
4804
  function bindPlatformProviderActions(host, providers, models) {
@@ -4517,6 +4834,266 @@ function renderTaskDefaults(models, providers, taskDefaults) {
4517
4834
  </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
4518
4835
  }
4519
4836
 
4837
+ const relationshipIndexStatusLabels = Object.freeze({
4838
+ queued: "等待同步",
4839
+ building: "正在同步",
4840
+ ready: "已索引",
4841
+ failed: "同步失败"
4842
+ });
4843
+
4844
+ const relationshipIndexSourceLabels = Object.freeze({
4845
+ work: "作品资料",
4846
+ chapter: "正文",
4847
+ setting: "设定",
4848
+ character: "人物",
4849
+ race: "种族",
4850
+ organization: "组织",
4851
+ "timeline-track": "时间轴",
4852
+ "timeline-event": "时间线事件",
4853
+ relationship: "人物关系",
4854
+ "chapter-outline": "章节大纲",
4855
+ foreshadow: "伏笔",
4856
+ review: "审核项"
4857
+ });
4858
+
4859
+ function relationshipIndexStatusMarkup(status) {
4860
+ const queuedSources = Array.isArray(status.queuedSources) ? status.queuedSources : [];
4861
+ const statusName = relationshipIndexStatusLabels[status.status] ?? "状态未知";
4862
+ const queueMarkup = queuedSources.length
4863
+ ? queuedSources.map((item) => `<span class="relationship-index-queue-chip"><span>${esc(relationshipIndexSourceLabels[item.sourceType] ?? item.sourceType)}</span><strong>${esc(String(item.count))}</strong></span>`).join("")
4864
+ : '<span class="relationship-index-queue-empty">队列为空</span>';
4865
+ const updatedAt = formatDateTime(status.updatedAt) || "尚未构建";
4866
+ const errorMarkup = status.error
4867
+ ? `<p class="relationship-index-error">${esc(status.error)}</p>`
4868
+ : "";
4869
+ return `<div class="relationship-index-summary">
4870
+ <div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>索引代次 <strong>${esc(String(status.generation ?? 0))}</strong></span><span>最后更新 <strong>${esc(updatedAt)}</strong></span></div>
4871
+ <dl class="relationship-index-metrics"><div><dt>待同步任务</dt><dd>${esc(String(status.queuedSourceCount ?? 0))}</dd></div><div><dt>已索引正文段落</dt><dd>${esc(String(status.indexedParagraphCount ?? 0))}</dd></div><div><dt>已索引设定来源</dt><dd>${esc(String(status.indexedSourceCount ?? 0))}</dd></div></dl>
4872
+ <div class="relationship-index-queue"><div class="relationship-index-queue-heading"><strong>增量任务队列</strong><span>仅包含新增、修改或删除后待同步的来源</span></div><div class="relationship-index-queue-list">${queueMarkup}</div></div>
4873
+ ${errorMarkup}
4874
+ </div>`;
4875
+ }
4876
+
4877
+ function updateBackgroundTaskCenterVisibility() {
4878
+ const button = $("#background-task-button");
4879
+ if (!button) return;
4880
+ const visible = Boolean(state.work)
4881
+ && (canReadModule("tasks") || canReadModule("ai-settings"));
4882
+ button.classList.toggle("hidden", !visible);
4883
+ if (!visible) {
4884
+ $("#background-task-count")?.classList.add("hidden");
4885
+ return;
4886
+ }
4887
+ const activityCount = backgroundTaskActivityCount(
4888
+ backgroundTaskCenterSnapshot.taskPage,
4889
+ backgroundTaskCenterSnapshot.relationshipIndex
4890
+ );
4891
+ const badge = $("#background-task-count");
4892
+ badge.textContent = activityCount > 99 ? "99+" : String(activityCount);
4893
+ badge.classList.toggle("hidden", activityCount === 0);
4894
+ button.classList.toggle("is-active", activityCount > 0);
4895
+ button.setAttribute("aria-label", activityCount > 0 ? `后台任务中心,${activityCount} 项进行中` : "后台任务中心");
4896
+ button.setAttribute("title", activityCount > 0 ? `后台任务中心 · ${activityCount} 项进行中` : "后台任务中心");
4897
+ }
4898
+
4899
+ function backgroundTaskTransitionMessage(transition) {
4900
+ const label = analysisTaskTypeLabel(transition.task.taskType);
4901
+ if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
4902
+ if (transition.status === "expired") return { message: `${label}已过期,正文可能已发生变化`, type: "error" };
4903
+ if (transition.status === "cancelled") return { message: `${label}已取消`, type: "info" };
4904
+ return { message: `${label}已完成`, type: "info" };
4905
+ }
4906
+
4907
+ function backgroundTaskListMarkup(taskPage, error) {
4908
+ if (!canReadModule("tasks")) {
4909
+ return '<section class="background-task-section"><div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>当前账户无权查看此模块</small></div></div></section>';
4910
+ }
4911
+ if (!taskPage) {
4912
+ return `<section class="background-task-section"><div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>${esc(error || "正在读取任务队列")}</small></div></div></section>`;
4913
+ }
4914
+ const tasks = Array.isArray(taskPage.items) ? taskPage.items.slice(0, 10) : [];
4915
+ const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
4916
+ const runningCount = Number(taskPage.stats?.runningCount ?? 0);
4917
+ return `<section class="background-task-section">
4918
+ <div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>待执行 ${pendingCount} 个 · 运行中 ${runningCount} 个 · 共 ${Number(taskPage.stats?.total ?? taskPage.total ?? tasks.length)} 个</small></div></div>
4919
+ ${error ? `<p class="background-task-error">${esc(error)}</p>` : ""}
4920
+ ${tasks.length ? `<div class="background-task-list">${tasks.map((item) => {
4921
+ const status = normalizedAnalysisTaskStatus(item.status);
4922
+ return `<article class="background-task-row">
4923
+ <span class="task-status-badge is-${esc(status)}"><span class="task-status-indicator" aria-hidden="true"></span><span>${esc(analysisTaskStatusLabel(item.status))}</span></span>
4924
+ <div><strong>${esc(analysisTaskTypeLabel(item.taskType))}</strong><small>${esc(item.scopeSummary || "未指定范围")} · ${esc(item.model?.displayName || "默认模型")}</small></div>
4925
+ <span class="background-task-progress">${analysisTaskProgressValue(item.progress)}%</span>
4926
+ <button class="ghost-button" type="button" data-background-task-detail="${esc(item.id)}">详情</button>
4927
+ </article>`;
4928
+ }).join("")}</div>` : '<p class="background-task-empty">还没有 AI 分析任务。</p>'}
4929
+ </section>`;
4930
+ }
4931
+
4932
+ function backgroundIndexMarkup(relationshipIndex, error) {
4933
+ if (!canReadModule("ai-settings")) {
4934
+ return '<section class="background-task-section"><div class="background-task-section-heading"><div><strong>人物关系拼音索引</strong><small>当前账户无权查看此模块</small></div></div></section>';
4935
+ }
4936
+ if (!relationshipIndex) {
4937
+ return `<section class="background-task-section"><div class="background-task-section-heading"><div><strong>人物关系拼音索引</strong><small>${esc(error || "正在读取索引队列")}</small></div></div></section>`;
4938
+ }
4939
+ const editable = canEditModule("ai-settings");
4940
+ return `<section class="background-task-section">
4941
+ <div class="background-task-section-heading">
4942
+ <div><strong>人物关系拼音索引</strong><small>可在任何模块查看增量同步状态</small></div>
4943
+ <div class="background-index-actions ${editable ? "" : "hidden"}">
4944
+ <button class="primary-button" type="button" data-background-index-action="sync">同步增量队列</button>
4945
+ <button class="ghost-button" type="button" data-background-index-action="rebuild">完整重建</button>
4946
+ </div>
4947
+ </div>
4948
+ ${error ? `<p class="background-task-error">${esc(error)}</p>` : ""}
4949
+ ${relationshipIndexStatusMarkup(relationshipIndex)}
4950
+ </section>`;
4951
+ }
4952
+
4953
+ function renderBackgroundTaskCenter() {
4954
+ updateBackgroundTaskCenterVisibility();
4955
+ const content = $("#background-task-content");
4956
+ if (!content) return;
4957
+ content.innerHTML = `${backgroundTaskListMarkup(
4958
+ backgroundTaskCenterSnapshot.taskPage,
4959
+ backgroundTaskCenterSnapshot.errors.tasks
4960
+ )}${backgroundIndexMarkup(
4961
+ backgroundTaskCenterSnapshot.relationshipIndex,
4962
+ backgroundTaskCenterSnapshot.errors.index
4963
+ )}`;
4964
+ $("#background-task-dialog-meta").textContent = state.work
4965
+ ? `《${state.work.title}》的分析任务与索引队列`
4966
+ : "当前作品的分析任务与索引队列";
4967
+ $("#background-task-open-analysis").classList.toggle("hidden", !canReadModule("tasks"));
4968
+ content.querySelectorAll("[data-background-task-detail]").forEach((button) => button.addEventListener("click", async () => {
4969
+ button.disabled = true;
4970
+ try {
4971
+ const taskId = encodeURIComponent(button.dataset.backgroundTaskDetail);
4972
+ const [task, trace] = await Promise.all([
4973
+ api(`/api/tasks/${taskId}/detail`),
4974
+ api(`/api/tasks/${taskId}/trace`).catch((error) => {
4975
+ if (error.code === "WORK_MODULE_READ_DENIED") return { restricted: true, captured: false, calls: [] };
4976
+ throw error;
4977
+ })
4978
+ ]);
4979
+ $("#background-task-dialog").close();
4980
+ openTaskDetailDialog(task, trace);
4981
+ } catch (error) {
4982
+ toast(error.message, "error");
4983
+ button.disabled = false;
4984
+ }
4985
+ }));
4986
+ content.querySelectorAll("[data-background-index-action]").forEach((button) => button.addEventListener("click", async () => {
4987
+ if (!backgroundTaskCenterWorkId) return;
4988
+ button.disabled = true;
4989
+ const action = button.dataset.backgroundIndexAction;
4990
+ try {
4991
+ const status = await api(`/api/works/${encodeURIComponent(backgroundTaskCenterWorkId)}/ai-settings/relationship-search-index/${action}`, {
4992
+ method: "POST"
4993
+ });
4994
+ backgroundTaskCenterSnapshot.relationshipIndex = status;
4995
+ renderBackgroundTaskCenter();
4996
+ toast(action === "rebuild"
4997
+ ? `已将全部来源加入索引队列,共 ${status.queuedSourceCount} 项`
4998
+ : Number(status.queuedSourceCount) > 0
4999
+ ? `开始同步 ${status.queuedSourceCount} 项增量任务`
5000
+ : "增量任务队列为空,索引已是最新状态");
5001
+ scheduleBackgroundTaskCenterRefresh();
5002
+ } catch (error) {
5003
+ toast(error.message, "error");
5004
+ button.disabled = false;
5005
+ }
5006
+ }));
5007
+ }
5008
+
5009
+ function scheduleBackgroundTaskCenterRefresh() {
5010
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5011
+ backgroundTaskCenterTimer = null;
5012
+ if (!backgroundTaskCenterWorkId) return;
5013
+ const activityCount = backgroundTaskActivityCount(
5014
+ backgroundTaskCenterSnapshot.taskPage,
5015
+ backgroundTaskCenterSnapshot.relationshipIndex
5016
+ );
5017
+ const delay = backgroundTaskPollDelay(activityCount, Boolean($("#background-task-dialog")?.open));
5018
+ backgroundTaskCenterTimer = window.setTimeout(() => {
5019
+ backgroundTaskCenterTimer = null;
5020
+ void refreshBackgroundTaskCenter();
5021
+ }, delay);
5022
+ }
5023
+
5024
+ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
5025
+ const workId = backgroundTaskCenterWorkId;
5026
+ if (!workId || state.work?.id !== workId) return;
5027
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5028
+ backgroundTaskCenterTimer = null;
5029
+ const requestId = ++backgroundTaskCenterRequest;
5030
+ const readTasks = canReadModule("tasks");
5031
+ const readIndex = canReadModule("ai-settings");
5032
+ const [taskResult, indexResult] = await Promise.all([
5033
+ readTasks
5034
+ ? api(`/api/works/${encodeURIComponent(workId)}/tasks?page=1&limit=30`)
5035
+ .then((value) => ({ value }))
5036
+ .catch((error) => ({ error }))
5037
+ : Promise.resolve(null),
5038
+ readIndex
5039
+ ? api(`/api/works/${encodeURIComponent(workId)}/ai-settings/relationship-search-index`)
5040
+ .then((value) => ({ value }))
5041
+ .catch((error) => ({ error }))
5042
+ : Promise.resolve(null)
5043
+ ]);
5044
+ if (requestId !== backgroundTaskCenterRequest || backgroundTaskCenterWorkId !== workId || state.work?.id !== workId) return;
5045
+ const errors = {};
5046
+ if (taskResult?.value) {
5047
+ const transitionResult = collectBackgroundTaskTransitions(
5048
+ backgroundTaskCenterTaskSnapshots,
5049
+ taskResult.value.items,
5050
+ backgroundTaskCenterTasksInitialized
5051
+ );
5052
+ backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
5053
+ backgroundTaskCenterSnapshot.taskPage = taskResult.value;
5054
+ if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
5055
+ for (const transition of transitionResult.transitions) {
5056
+ const notification = backgroundTaskTransitionMessage(transition);
5057
+ toast(notification.message, notification.type);
5058
+ }
5059
+ }
5060
+ backgroundTaskCenterTasksInitialized = true;
5061
+ } else if (taskResult?.error) {
5062
+ errors.tasks = taskResult.error.message;
5063
+ console.error("Failed to refresh background analysis tasks", taskResult.error);
5064
+ }
5065
+ if (indexResult?.value) {
5066
+ backgroundTaskCenterSnapshot.relationshipIndex = indexResult.value;
5067
+ } else if (indexResult?.error) {
5068
+ errors.index = indexResult.error.message;
5069
+ console.error("Failed to refresh relationship search index status", indexResult.error);
5070
+ }
5071
+ backgroundTaskCenterSnapshot.errors = errors;
5072
+ renderBackgroundTaskCenter();
5073
+ scheduleBackgroundTaskCenterRefresh();
5074
+ }
5075
+
5076
+ function stopBackgroundTaskCenter() {
5077
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5078
+ backgroundTaskCenterTimer = null;
5079
+ backgroundTaskCenterRequest += 1;
5080
+ backgroundTaskCenterWorkId = null;
5081
+ backgroundTaskCenterTasksInitialized = false;
5082
+ backgroundTaskCenterTaskSnapshots = new Map();
5083
+ backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
5084
+ const dialog = $("#background-task-dialog");
5085
+ if (dialog?.open) dialog.close();
5086
+ $("#background-task-button")?.classList.add("hidden");
5087
+ $("#background-task-count")?.classList.add("hidden");
5088
+ }
5089
+
5090
+ function startBackgroundTaskCenter(workId) {
5091
+ stopBackgroundTaskCenter();
5092
+ backgroundTaskCenterWorkId = String(workId);
5093
+ renderBackgroundTaskCenter();
5094
+ void refreshBackgroundTaskCenter();
5095
+ }
5096
+
4520
5097
  async function renderPlatformAiConfig() {
4521
5098
  const [providers, models, settings] = await Promise.all([
4522
5099
  api("/api/platform/ai/providers"),
@@ -4540,16 +5117,116 @@ async function renderPlatformAiConfig() {
4540
5117
  bindPlatformProviderActions(host, providers, models);
4541
5118
  }
4542
5119
 
5120
+ function tokenUsageDateLabel(date) {
5121
+ return new Intl.DateTimeFormat("zh-CN", {
5122
+ year: "numeric",
5123
+ month: "short",
5124
+ day: "numeric",
5125
+ weekday: "short"
5126
+ }).format(new Date(`${date}T00:00:00`));
5127
+ }
5128
+
5129
+ function tokenUsageCalendarMarkup(daily) {
5130
+ const calendar = buildUsageCalendar(daily);
5131
+ const cells = calendar.cells.map((cell) => {
5132
+ const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
5133
+ return `<span class="usage-calendar-cell${cell.future ? " is-future" : ""}" data-level="${cell.level}" role="gridcell" aria-label="${esc(label)}" title="${esc(label)}" ${cell.future ? 'aria-disabled="true"' : 'tabindex="0"'}></span>`;
5134
+ }).join("");
5135
+ const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
5136
+ return `<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
5137
+ <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
5138
+ <div class="usage-calendar-months" aria-hidden="true">${months}</div>
5139
+ <div class="usage-calendar-body">
5140
+ <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
5141
+ <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
5142
+ </div>
5143
+ </div>
5144
+ </div>
5145
+ <div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
5146
+ }
5147
+
5148
+ function scrollUsageCalendarsToLatest(root) {
5149
+ window.requestAnimationFrame(() => {
5150
+ root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
5151
+ calendar.scrollLeft = calendar.scrollWidth;
5152
+ });
5153
+ });
5154
+ }
5155
+
5156
+ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false } = {}) {
5157
+ const summary = usage?.summary ?? {};
5158
+ const totalTokens = Number(summary.totalTokens) || 0;
5159
+ const exactTotal = totalTokens.toLocaleString("zh-CN");
5160
+ const estimatedRequests = Number(summary.estimatedRequestCount) || 0;
5161
+ const requestCount = Number(summary.requestCount) || 0;
5162
+ const cachedInputTokens = Number(summary.cachedInputTokens) || 0;
5163
+ const cacheEligibleInputTokens = Number(summary.cacheEligibleInputTokens) || 0;
5164
+ const cacheDescription = summary.cacheHitRate === null || summary.cacheHitRate === undefined
5165
+ ? "供应商尚未返回可计算的缓存明细"
5166
+ : `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
5167
+ const estimateNote = estimatedRequests > 0
5168
+ ? `其中 ${estimatedRequests.toLocaleString("zh-CN")} 次调用包含历史或供应商缺失用量时的估算。`
5169
+ : "全部用量均来自供应商返回的 Token 统计。";
5170
+ const works = Array.isArray(usage?.works) ? usage.works : [];
5171
+ const workRows = works.map((work) => `<tr>
5172
+ <th scope="row">${esc(work.workTitle)}</th>
5173
+ <td title="${esc(Number(work.totalTokens || 0).toLocaleString("zh-CN"))} Token">${esc(formatTokenCount(work.totalTokens))}</td>
5174
+ <td>${esc(formatTokenCount(work.inputTokens))}</td>
5175
+ <td>${esc(formatTokenCount(work.outputTokens))}</td>
5176
+ <td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
5177
+ <td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
5178
+ </tr>`).join("");
5179
+ return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
5180
+ <div class="config-section-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div></div>
5181
+ <div class="usage-stat-grid">
5182
+ <article class="usage-stat is-primary"><span>总消耗</span><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
5183
+ <article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small>${Number(summary.inputTokens || 0).toLocaleString("zh-CN")}</small></article>
5184
+ <article class="usage-stat"><span>输出 Token</span><strong>${esc(formatTokenCount(summary.outputTokens))}</strong><small>${Number(summary.outputTokens || 0).toLocaleString("zh-CN")}</small></article>
5185
+ <article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
5186
+ </div>
5187
+ <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)}</p>
5188
+ <section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
5189
+ <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
5190
+ ${tokenUsageCalendarMarkup(usage?.daily)}
5191
+ </section>
5192
+ ${showWorks ? `<section class="usage-work-section" aria-labelledby="usage-work-title"><header><div><h3 id="usage-work-title">各作品用量</h3><p>按 Token 总消耗从高到低排列,包含尚未使用 AI 的作品。</p></div></header><div class="usage-work-table-scroll"><table class="usage-work-table"><thead><tr><th>作品</th><th>总消耗</th><th>输入</th><th>输出</th><th>缓存命中率</th><th>调用</th></tr></thead><tbody>${workRows || '<tr><td colspan="6">还没有作品用量记录。</td></tr>'}</tbody></table></div></section>` : ""}
5193
+ </section>`;
5194
+ }
5195
+
5196
+ async function renderPlatformTokenUsage() {
5197
+ const host = $("#platform-usage-content");
5198
+ host.innerHTML = '<div class="empty-state">正在汇总 Token 用量……</div>';
5199
+ const timezoneOffset = -new Date().getTimezoneOffset();
5200
+ const usage = await api(`/api/platform/ai/usage?timezoneOffset=${timezoneOffset}`);
5201
+ host.innerHTML = tokenUsageOverviewMarkup(usage, {
5202
+ title: "项目累计用量",
5203
+ description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
5204
+ showWorks: true
5205
+ });
5206
+ scrollUsageCalendarsToLatest(host);
5207
+ }
5208
+
4543
5209
  async function renderBookAiSettings() {
4544
- const [settings, providers, models, taskDefaults] = await Promise.all([
5210
+ if (relationshipSearchIndexRefreshTimer) {
5211
+ clearTimeout(relationshipSearchIndexRefreshTimer);
5212
+ relationshipSearchIndexRefreshTimer = null;
5213
+ }
5214
+ const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
4545
5215
  api(`/api/works/${state.work.id}/ai-settings`),
4546
5216
  api("/api/platform/ai/providers"),
4547
5217
  api(`/api/works/${state.work.id}/models`),
4548
- api(`/api/works/${state.work.id}/task-defaults`)
5218
+ api(`/api/works/${state.work.id}/task-defaults`),
5219
+ api(`/api/works/${state.work.id}/ai-settings/relationship-search-index`),
5220
+ api(`/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
4549
5221
  ]);
4550
5222
  const host = $("#module-content");
5223
+ const workId = String(state.work.id);
4551
5224
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
4552
- host.innerHTML = `<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 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>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</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>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
5225
+ host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5226
+ title: "本书 Token 用量",
5227
+ description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
5228
+ })}</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>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</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>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
5229
+ scrollUsageCalendarsToLatest(host);
4553
5230
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
4554
5231
  "beforebegin",
4555
5232
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
@@ -4562,6 +5239,33 @@ async function renderBookAiSettings() {
4562
5239
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
4563
5240
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
4564
5241
  }
5242
+ const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
5243
+ && String(state.work?.id ?? "") === workId
5244
+ && Boolean($("#relationship-search-index-status"));
5245
+ const updateRelationshipIndexStatus = (status) => {
5246
+ const statusHost = $("#relationship-search-index-status");
5247
+ if (!statusHost) return;
5248
+ statusHost.innerHTML = relationshipIndexStatusMarkup(status);
5249
+ if (relationshipSearchIndexRefreshTimer) clearTimeout(relationshipSearchIndexRefreshTimer);
5250
+ relationshipSearchIndexRefreshTimer = null;
5251
+ if (!["queued", "building"].includes(String(status.status))) return;
5252
+ relationshipSearchIndexRefreshTimer = setTimeout(async () => {
5253
+ relationshipSearchIndexRefreshTimer = null;
5254
+ if (!isCurrentRelationshipIndexPanel()) return;
5255
+ try {
5256
+ const nextStatus = await api(`/api/works/${workId}/ai-settings/relationship-search-index`);
5257
+ if (isCurrentRelationshipIndexPanel()) updateRelationshipIndexStatus(nextStatus);
5258
+ } catch {
5259
+ // 后台轮询失败时保留当前状态,用户仍可手动刷新。
5260
+ }
5261
+ }, 1_200);
5262
+ };
5263
+ const refreshRelationshipIndexStatus = async () => {
5264
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index`);
5265
+ updateRelationshipIndexStatus(status);
5266
+ return status;
5267
+ };
5268
+ updateRelationshipIndexStatus(relationshipIndex);
4565
5269
  $("#save-work-system-prompt").addEventListener("click", async () => {
4566
5270
  const button = $("#save-work-system-prompt");
4567
5271
  button.disabled = true;
@@ -4575,6 +5279,46 @@ async function renderBookAiSettings() {
4575
5279
  button.disabled = false;
4576
5280
  }
4577
5281
  });
5282
+ $("#sync-relationship-search-index").addEventListener("click", async () => {
5283
+ const button = $("#sync-relationship-search-index");
5284
+ button.disabled = true;
5285
+ try {
5286
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index/sync`, { method: "POST" });
5287
+ updateRelationshipIndexStatus(status);
5288
+ toast(Number(status.queuedSourceCount) > 0
5289
+ ? `开始同步 ${status.queuedSourceCount} 项增量任务`
5290
+ : "增量任务队列为空,索引已是最新状态");
5291
+ } catch (error) {
5292
+ toast(error.message, "error");
5293
+ } finally {
5294
+ button.disabled = false;
5295
+ }
5296
+ });
5297
+ $("#refresh-relationship-search-index").addEventListener("click", async () => {
5298
+ const button = $("#refresh-relationship-search-index");
5299
+ button.disabled = true;
5300
+ try {
5301
+ await refreshRelationshipIndexStatus();
5302
+ toast("索引状态已刷新", "info");
5303
+ } catch (error) {
5304
+ toast(error.message, "error");
5305
+ } finally {
5306
+ button.disabled = false;
5307
+ }
5308
+ });
5309
+ $("#rebuild-relationship-search-index").addEventListener("click", async () => {
5310
+ const button = $("#rebuild-relationship-search-index");
5311
+ button.disabled = true;
5312
+ try {
5313
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index/rebuild`, { method: "POST" });
5314
+ updateRelationshipIndexStatus(status);
5315
+ toast(`已将全部来源加入索引队列,共 ${status.queuedSourceCount} 项`);
5316
+ } catch (error) {
5317
+ toast(error.message, "error");
5318
+ } finally {
5319
+ button.disabled = false;
5320
+ }
5321
+ });
4578
5322
  $("#save-book-summary-context-percent").addEventListener("click", async () => {
4579
5323
  const button = $("#save-book-summary-context-percent");
4580
5324
  button.disabled = true;
@@ -6039,9 +6783,10 @@ async function showCharacterHistory() {
6039
6783
 
6040
6784
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
6041
6785
  entityEditorReadOnly = readOnly;
6042
- [state.races, state.organizations] = await Promise.all([
6786
+ [state.races, state.organizations, state.characters] = await Promise.all([
6043
6787
  canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
6044
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
6788
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([]),
6789
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
6045
6790
  ]);
6046
6791
  characterEditorItem = item ?? null;
6047
6792
  characterEditorVersions = [];
@@ -6390,6 +7135,7 @@ async function openRelationshipDialog(item, options = {}) {
6390
7135
  <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
6391
7136
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
6392
7137
  </section>` : "";
7138
+ setRelationshipPresence(item?.id ?? null);
6393
7139
  openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false) + management, async (form) => {
6394
7140
  const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
6395
7141
  await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) } });
@@ -6397,15 +7143,19 @@ async function openRelationshipDialog(item, options = {}) {
6397
7143
  }, item ? "关系档案" : "人工确认关系");
6398
7144
  $("#dialog-fields").querySelector("[data-dialog-relationship-delete]")?.addEventListener("click", async () => {
6399
7145
  const dialog = $("#form-dialog");
7146
+ const reopenDialog = () => {
7147
+ setRelationshipPresence(item.id);
7148
+ dialog.showModal();
7149
+ };
6400
7150
  dialog.close();
6401
- if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return dialog.showModal();
6402
- if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return dialog.showModal();
7151
+ if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return reopenDialog();
7152
+ if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return reopenDialog();
6403
7153
  try {
6404
7154
  await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
6405
7155
  await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
6406
7156
  toast(`已删除人物关系“${relationshipName}”`);
6407
7157
  } catch (error) {
6408
- dialog.showModal();
7158
+ reopenDialog();
6409
7159
  toast(error.message, "error");
6410
7160
  }
6411
7161
  });
@@ -6470,30 +7220,76 @@ async function openTaskDialog() {
6470
7220
  const relationshipFields = `<div class="relationship-analysis-options hidden">
6471
7221
  ${relationshipCharacterPicker}
6472
7222
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
6473
- <div class="relationship-overwrite-card hidden">
7223
+ <div class="relationship-overwrite-card relationship-prefilter-card">
7224
+ <label class="checkbox-field"><input name="preFilterRelationshipSources" type="checkbox" checked disabled><span>分析前按人物名称和拼音过滤来源</span></label>
7225
+ <p>仅对定向人物分析生效。取消勾选后,将跳过前置过滤,无差别发送所选范围内的全部章节和设定来源。</p>
7226
+ </div>
7227
+ <div class="relationship-overwrite-card relationship-change-preview-card">
7228
+ <label class="checkbox-field"><input name="previewRelationshipChanges" type="checkbox" checked><span>分析完成后先预览关系变更</span></label>
7229
+ <p>建议保持开启。分析只生成新增、更新和删除清单,确认应用前不会修改人物关系库;取消勾选则沿用直接写入逻辑。</p>
7230
+ </div>
7231
+ <section class="relationship-source-preview-card" aria-labelledby="relationship-source-preview-title">
7232
+ <div class="relationship-source-preview-header">
7233
+ <div><strong id="relationship-source-preview-title">发送来源预检</strong><p>创建任务前查看将发送的章节与设定,并可取消误命中的来源。疑似拼音写法可能调用当前任务模型确认身份。</p></div>
7234
+ <button type="button" class="ghost-button" data-relationship-source-preview disabled>预览来源</button>
7235
+ </div>
7236
+ <div class="relationship-source-preview-content" data-relationship-source-preview-content role="status" aria-live="polite">选择至少一个被分析角色后即可预览。</div>
7237
+ </section>
7238
+ <div class="relationship-overwrite-card relationship-existing-overwrite-card hidden">
6474
7239
  <label class="checkbox-field"><input name="replaceExistingRelationships" type="checkbox" disabled><span>用本次结果覆盖所选角色的已有关系</span></label>
6475
7240
  <p>勾选后,任务成功时会先删除所有涉及所选角色的旧关系,再写入本次分析结果;不勾选则只追加新关系。</p>
6476
7241
  </div>
6477
7242
  <label>额外分析提示<textarea name="additionalPrompt" maxlength="10000" placeholder="例如:重点识别权力继承、师承变化或隐秘亲缘关系"></textarea><small>将同时追加到证据收集和全局关系归纳提示词,仅影响本次任务。</small></label>
6478
7243
  </div>`;
6479
- openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
7244
+ let relationshipSourcePreview = null;
7245
+ let relationshipSourcePreviewConfigKey = "";
7246
+ const buildRelationshipScope = (form) => {
6480
7247
  const taskType = String(form.get("taskType"));
6481
- const modelId = String(form.get("modelId"));
6482
7248
  const scopeType = String(form.get("scopeType"));
6483
7249
  const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
6484
7250
  const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
6485
7251
  const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
6486
7252
  const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
7253
+ const preFilterRelationshipSources = characterIds.length > 0 && form.get("preFilterRelationshipSources") === "on";
7254
+ const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
6487
7255
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
6488
7256
  const scope = settingsOnly
6489
- ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
7257
+ ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6490
7258
  : taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
6491
- ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6492
- : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
7259
+ ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
7260
+ : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
7261
+ return scope;
7262
+ };
7263
+ const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
7264
+ type: scope.type,
7265
+ chapterId: scope.chapterId ?? null,
7266
+ includeAllSettings: scope.includeAllSettings === true,
7267
+ characterIds: scope.characterIds ?? [],
7268
+ preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
7269
+ modelId
7270
+ });
7271
+ openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
7272
+ const taskType = String(form.get("taskType"));
7273
+ const modelId = String(form.get("modelId"));
7274
+ const scope = buildRelationshipScope(form);
7275
+ if (taskType === "relationship-analysis" && relationshipSourcePreview
7276
+ && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
7277
+ scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
7278
+ .map((input) => ({
7279
+ sourceType: input.dataset.sourceType,
7280
+ sourceId: input.dataset.sourceId,
7281
+ sourceVersion: input.dataset.sourceVersion
7282
+ }));
7283
+ }
6493
7284
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
7285
+ await refreshBackgroundTaskCenter({ announce: false });
6494
7286
  taskListPage = 1;
6495
- toast("分析任务已创建,已进入任务队列");
6496
- void renderTasks(1).catch((error) => toast(`任务已创建,但列表刷新失败:${error.message}`, "error"));
7287
+ try {
7288
+ await renderTasks(1);
7289
+ toast("分析任务已创建,已进入任务队列");
7290
+ } catch (error) {
7291
+ toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
7292
+ }
6497
7293
  }, "AI 分析", {
6498
7294
  submitLabel: "创建任务",
6499
7295
  pendingLabel: "创建中…",
@@ -6517,8 +7313,11 @@ async function openTaskDialog() {
6517
7313
  const relationshipCharacterCount = relationshipOptions.querySelector("[data-relationship-character-count]");
6518
7314
  const relationshipCharacterClear = relationshipOptions.querySelector("[data-relationship-character-clear]");
6519
7315
  const relationshipCharacterEmpty = relationshipOptions.querySelector("[data-relationship-character-empty]");
7316
+ const preFilterRelationships = relationshipOptions.querySelector('input[name="preFilterRelationshipSources"]');
7317
+ const relationshipSourcePreviewButton = relationshipOptions.querySelector("[data-relationship-source-preview]");
7318
+ const relationshipSourcePreviewContent = relationshipOptions.querySelector("[data-relationship-source-preview-content]");
6520
7319
  const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
6521
- const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-overwrite-card");
7320
+ const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-existing-overwrite-card");
6522
7321
  const allSettingsOption = document.createElement("option");
6523
7322
  allSettingsOption.value = "book-with-settings";
6524
7323
  allSettingsOption.textContent = "全书 + 设定集";
@@ -6546,6 +7345,41 @@ async function openTaskDialog() {
6546
7345
  relationshipCharacterClear.disabled = selected.length === 0;
6547
7346
  relationshipCharacterTrigger.setAttribute("aria-label", `筛选被分析角色,已选择 ${selected.length} 个`);
6548
7347
  };
7348
+ const invalidateRelationshipSourcePreview = () => {
7349
+ relationshipSourcePreview = null;
7350
+ relationshipSourcePreviewConfigKey = "";
7351
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7352
+ relationshipSourcePreviewContent.textContent = relationshipCharacterInputs.some((input) => input.checked)
7353
+ ? "分析配置已变化,请重新预览将发送的来源。"
7354
+ : "选择至少一个被分析角色后即可预览。";
7355
+ };
7356
+ const relationshipSourceTypeLabel = (sourceType) => ({
7357
+ chapter: "章节",
7358
+ work: "作品资料",
7359
+ setting: "设定",
7360
+ character: "人物档案",
7361
+ race: "种族",
7362
+ organization: "组织",
7363
+ "timeline-track": "时间轴",
7364
+ "timeline-event": "时间线事件",
7365
+ relationship: "已有关系",
7366
+ "chapter-outline": "章节大纲",
7367
+ foreshadow: "伏笔",
7368
+ review: "审核项"
7369
+ }[sourceType] ?? sourceType);
7370
+ const renderRelationshipSourcePreview = (preview) => {
7371
+ const summary = `<div class="relationship-source-preview-summary"><strong>${preview.sourceCount} 条来源</strong><span>${preview.chapterCount} 章</span><span>${preview.settingCount} 条设定资料</span><span>约 ${Number(preview.totalCharacters).toLocaleString("zh-CN")} 字符</span><span>预计 ${preview.estimatedBatchCount} 个批次</span></div>`;
7372
+ if (!preview.sources.length) {
7373
+ relationshipSourcePreviewContent.innerHTML = `${summary}<p class="relationship-source-preview-empty">当前配置没有命中任何来源。仍可创建任务,但任务不会向模型发送正文或设定。</p>`;
7374
+ return;
7375
+ }
7376
+ const rows = preview.sources.map((source) => `<label class="relationship-source-preview-row">
7377
+ <input type="checkbox" data-relationship-source-selected data-source-type="${esc(source.sourceType)}" data-source-id="${esc(source.sourceId)}" data-source-version="${esc(source.version)}" checked>
7378
+ <span class="relationship-source-preview-main"><strong>${esc(source.title)}</strong><small>${esc(relationshipSourceTypeLabel(source.sourceType))} · ${Number(source.characterCount).toLocaleString("zh-CN")} 字符</small></span>
7379
+ <span class="relationship-source-preview-match ${source.matchType === "fuzzy" ? "is-fuzzy" : ""}">${source.matchType === "exact" ? "名称命中" : source.matchType === "fuzzy" ? "疑似写法确认" : "范围内来源"}</span>
7380
+ </label>`).join("");
7381
+ relationshipSourcePreviewContent.innerHTML = `${summary}<div class="relationship-source-preview-list" role="group" aria-label="选择要发送的来源">${rows}</div>`;
7382
+ };
6549
7383
  const filterRelationshipCharacters = () => {
6550
7384
  const query = relationshipCharacterSearch.value.trim().toLocaleLowerCase();
6551
7385
  let visibleCount = 0;
@@ -6576,6 +7410,8 @@ async function openTaskDialog() {
6576
7410
  for (const input of relationshipCharacterInputs) input.disabled = !enabled;
6577
7411
  if (!enabled) setRelationshipCharacterBubbleOpen(false);
6578
7412
  const hasSelectedCharacters = enabled && relationshipCharacterInputs.some((input) => input.checked);
7413
+ preFilterRelationships.disabled = !hasSelectedCharacters;
7414
+ relationshipSourcePreviewButton.disabled = !hasSelectedCharacters || !taskModelSelect.value;
6579
7415
  replaceRelationships.disabled = !hasSelectedCharacters;
6580
7416
  relationshipOverwriteCard.classList.toggle("hidden", !hasSelectedCharacters);
6581
7417
  if (!hasSelectedCharacters) replaceRelationships.checked = false;
@@ -6590,17 +7426,56 @@ async function openTaskDialog() {
6590
7426
  taskTypeSelect.addEventListener("change", () => {
6591
7427
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
6592
7428
  syncTaskModelDefault();
7429
+ invalidateRelationshipSourcePreview();
7430
+ syncRelationshipOptions();
7431
+ });
7432
+ taskModelSelect.addEventListener("change", () => {
7433
+ invalidateRelationshipSourcePreview();
6593
7434
  syncRelationshipOptions();
6594
7435
  });
6595
7436
  relationshipCharacterTrigger.addEventListener("click", () => {
6596
7437
  setRelationshipCharacterBubbleOpen(relationshipCharacterTrigger.getAttribute("aria-expanded") !== "true");
6597
7438
  });
6598
7439
  relationshipCharacterSearch.addEventListener("input", filterRelationshipCharacters);
6599
- for (const input of relationshipCharacterInputs) input.addEventListener("change", syncRelationshipOptions);
7440
+ for (const input of relationshipCharacterInputs) input.addEventListener("change", () => {
7441
+ invalidateRelationshipSourcePreview();
7442
+ syncRelationshipOptions();
7443
+ });
6600
7444
  relationshipCharacterClear.addEventListener("click", () => {
6601
7445
  for (const input of relationshipCharacterInputs) input.checked = false;
7446
+ invalidateRelationshipSourcePreview();
6602
7447
  syncRelationshipOptions();
6603
7448
  });
7449
+ preFilterRelationships.addEventListener("change", invalidateRelationshipSourcePreview);
7450
+ relationshipSourcePreviewButton.addEventListener("click", async () => {
7451
+ const form = new FormData($("#dynamic-form"));
7452
+ const scope = buildRelationshipScope(form);
7453
+ const modelId = String(form.get("modelId") ?? "");
7454
+ if (!scope.characterIds?.length) return toast("请先选择至少一个被分析角色", "error");
7455
+ relationshipSourcePreviewButton.disabled = true;
7456
+ relationshipSourcePreviewButton.textContent = "预检中…";
7457
+ relationshipSourcePreviewContent.classList.add("is-loading");
7458
+ relationshipSourcePreviewContent.textContent = "正在构建索引并核对来源,请稍候。";
7459
+ try {
7460
+ const preview = await api(`/api/works/${state.work.id}/tasks/relationship-source-preview`, {
7461
+ method: "POST",
7462
+ body: { scope, modelId }
7463
+ });
7464
+ relationshipSourcePreview = preview;
7465
+ relationshipSourcePreviewConfigKey = relationshipPreviewKey(scope, modelId);
7466
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7467
+ renderRelationshipSourcePreview(preview);
7468
+ } catch (error) {
7469
+ relationshipSourcePreview = null;
7470
+ relationshipSourcePreviewConfigKey = "";
7471
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7472
+ relationshipSourcePreviewContent.textContent = `来源预检失败:${error.message}`;
7473
+ toast(`来源预检失败:${error.message}`, "error");
7474
+ } finally {
7475
+ relationshipSourcePreviewButton.textContent = "重新预览";
7476
+ syncRelationshipOptions();
7477
+ }
7478
+ });
6604
7479
  $("#dynamic-form").onclick = (event) => {
6605
7480
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
6606
7481
  };
@@ -6610,18 +7485,33 @@ async function openTaskDialog() {
6610
7485
  setRelationshipCharacterBubbleOpen(false);
6611
7486
  relationshipCharacterTrigger.focus();
6612
7487
  };
6613
- scopeTypeSelect.addEventListener("change", syncChapterField);
7488
+ scopeTypeSelect.addEventListener("change", () => {
7489
+ invalidateRelationshipSourcePreview();
7490
+ syncChapterField();
7491
+ });
7492
+ chapterSelect.addEventListener("change", invalidateRelationshipSourcePreview);
6614
7493
  syncRelationshipOptions();
6615
7494
  }
6616
7495
 
6617
7496
  function openProviderDialog(item) {
6618
- openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("baseUrl", "OpenAI 兼容接口地址", "url", item?.baseUrl ?? "https://api.openai.com/v1") + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出令牌数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
6619
- const body = { name: form.get("name"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
7497
+ const protocol = item?.protocol ?? "openai-chat-completions";
7498
+ const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
7499
+ openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出令牌数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
7500
+ const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
6620
7501
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
6621
7502
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
6622
7503
  await renderPlatformAiConfig();
6623
7504
  await loadModels();
6624
- }, item ? "限流与凭据" : "OpenAI 兼容协议");
7505
+ }, item ? "协议、限流与凭据" : "OpenAI / Anthropic 兼容协议");
7506
+ if (!item) {
7507
+ const protocolSelect = $("#dialog-fields select[name='protocol']");
7508
+ const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
7509
+ protocolSelect.addEventListener("change", () => {
7510
+ baseUrlInput.value = protocolSelect.value === "anthropic-messages"
7511
+ ? "https://api.anthropic.com"
7512
+ : "https://api.openai.com/v1";
7513
+ });
7514
+ }
6625
7515
  }
6626
7516
 
6627
7517
  function openModelDialog(providerId, item = null) {
@@ -7037,17 +7927,72 @@ async function openImportHistory() {
7037
7927
 
7038
7928
  async function showChapterInsight() {
7039
7929
  if (!state.chapter) return;
7040
- const panel = $("#chapter-insight");
7041
- const insights = await api(`/api/chapters/${state.chapter.id}/insights`);
7930
+ const chapterId = state.chapter.id;
7931
+ dismissChapterInsightToast();
7932
+ const requestId = ++chapterInsightRequestId;
7933
+ const insights = await api(`/api/chapters/${chapterId}/insights`);
7934
+ if (requestId !== chapterInsightRequestId || state.chapter?.id !== chapterId || $("#editor-view").classList.contains("hidden")) return;
7042
7935
  const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
7043
- panel.classList.remove("hidden");
7936
+ const region = $("#toast-region");
7937
+ const element = document.createElement("section");
7938
+ element.id = "chapter-insight-toast";
7939
+ element.className = "toast chapter-insight-toast";
7940
+ element.setAttribute("role", "region");
7941
+ element.setAttribute("aria-label", "章节概览");
7942
+ const header = document.createElement("header");
7943
+ header.className = "chapter-insight-toast-header";
7944
+ const heading = document.createElement("div");
7945
+ heading.className = "chapter-insight-toast-heading";
7946
+ const title = document.createElement("strong");
7947
+ title.textContent = "章节概览";
7948
+ heading.append(title);
7949
+ if (insight && insight.chapterVersion !== state.chapter.versionNo) {
7950
+ const stale = document.createElement("span");
7951
+ stale.textContent = `基于旧版本 v${insight.chapterVersion}`;
7952
+ heading.append(stale);
7953
+ }
7954
+ const close = document.createElement("button");
7955
+ close.className = "ghost-button chapter-insight-toast-close";
7956
+ close.type = "button";
7957
+ close.textContent = "关闭";
7958
+ close.setAttribute("aria-label", "关闭章节概览");
7959
+ header.append(heading, close);
7960
+ const body = document.createElement("div");
7961
+ body.className = "chapter-insight-toast-body";
7044
7962
  if (!insight) {
7045
- panel.innerHTML = "<strong>尚无章节概览</strong>请在“AI 分析”中运行章节理解,完成后可在此查看结果。";
7046
- return;
7963
+ const empty = document.createElement("p");
7964
+ empty.className = "chapter-insight-toast-empty";
7965
+ empty.textContent = "尚无章节概览。请在“AI 分析”中运行章节理解,完成后可在此查看结果。";
7966
+ body.append(empty);
7967
+ } else {
7968
+ const summary = document.createElement("p");
7969
+ summary.textContent = insight.summary || "暂无梗概";
7970
+ body.append(summary);
7971
+ const eventNames = insight.events.map((event) => typeof event === "string" ? event : (event.name ?? event.description ?? "未命名事件"));
7972
+ const uncertainties = insight.uncertainties.map((item) => typeof item === "string" ? item : JSON.stringify(item));
7973
+ [["事件", eventNames], ["待确认", uncertainties]].forEach(([label, items]) => {
7974
+ if (!items.length) return;
7975
+ const detail = document.createElement("div");
7976
+ detail.className = "chapter-insight-toast-detail";
7977
+ const detailLabel = document.createElement("strong");
7978
+ detailLabel.textContent = label;
7979
+ const detailContent = document.createElement("p");
7980
+ detailContent.textContent = items.join(";");
7981
+ detail.append(detailLabel, detailContent);
7982
+ body.append(detail);
7983
+ });
7047
7984
  }
7048
- const eventNames = insight.events.map((event) => typeof event === "string" ? event : (event.name ?? event.description ?? "未命名事件"));
7049
- const stale = insight.chapterVersion !== state.chapter.versionNo ? `;基于旧版本 v${insight.chapterVersion}` : "";
7050
- panel.innerHTML = `<strong>章节概览${esc(stale)}</strong>${esc(insight.summary || "暂无梗概")}${eventNames.length ? `<br><strong>事件</strong>${esc(eventNames.join(";"))}` : ""}${insight.uncertainties.length ? `<br><strong>待确认</strong>${esc(insight.uncertainties.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join(";"))}` : ""}`;
7985
+ element.append(header, body);
7986
+ close.addEventListener("click", dismissChapterInsightToast, { once: true });
7987
+ element.addEventListener("keydown", (event) => {
7988
+ if (event.key !== "Escape") return;
7989
+ event.preventDefault();
7990
+ dismissChapterInsightToast();
7991
+ $("#insight-button").focus();
7992
+ });
7993
+ region.append(element);
7994
+ $("#insight-button").setAttribute("aria-expanded", "true");
7995
+ raiseToastRegion();
7051
7996
  }
7052
7997
 
7053
7998
  $("#home-button").addEventListener("click", async () => {
@@ -7594,6 +8539,20 @@ $("#register-form").addEventListener("submit", async (event) => {
7594
8539
  $("#settings-return").addEventListener("click", () => returnFromSettings().catch((error) => toast(error.message, "error")));
7595
8540
  $("#platform-ai-button").addEventListener("click", () => showPlatformAi().catch((error) => toast(error.message, "error")));
7596
8541
  $("#platform-ai-return").addEventListener("click", () => returnToSettingsHub("#platform-ai-button").catch((error) => toast(error.message, "error")));
8542
+ $("#platform-usage-button").addEventListener("click", () => showPlatformUsage().catch((error) => toast(error.message, "error")));
8543
+ $("#platform-usage-return").addEventListener("click", () => returnToSettingsHub("#platform-usage-button").catch((error) => toast(error.message, "error")));
8544
+ $("#platform-usage-refresh").addEventListener("click", async () => {
8545
+ const button = $("#platform-usage-refresh");
8546
+ button.disabled = true;
8547
+ try {
8548
+ await renderPlatformTokenUsage();
8549
+ toast("Token 用量已刷新");
8550
+ } catch (error) {
8551
+ toast(error.message, "error");
8552
+ } finally {
8553
+ button.disabled = false;
8554
+ }
8555
+ });
7597
8556
  $("#user-management-button").addEventListener("click", openUsersDialog);
7598
8557
  $("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
7599
8558
  $("#collaboration-button").addEventListener("click", () => openMembersDialog());
@@ -7617,7 +8576,14 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
7617
8576
  body: {
7618
8577
  toastPosition: $("#toast-position").value,
7619
8578
  pageSizes: {
8579
+ settings: Number($("#page-size-settings").value),
7620
8580
  characters: Number($("#page-size-characters").value),
8581
+ races: Number($("#page-size-races").value),
8582
+ organizations: Number($("#page-size-organizations").value),
8583
+ timeline: Number($("#page-size-timeline").value),
8584
+ outlines: Number($("#page-size-outlines").value),
8585
+ relationships: Number($("#page-size-relationships").value),
8586
+ reviews: Number($("#page-size-reviews").value),
7621
8587
  analysisTasks: Number($("#page-size-analysis-tasks").value),
7622
8588
  fileVersions: Number($("#page-size-file-versions").value)
7623
8589
  }
@@ -7626,6 +8592,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
7626
8592
  applyPlatformUiSettings(settings);
7627
8593
  characterListPage = 1;
7628
8594
  taskListPage = 1;
8595
+ Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
7629
8596
  $("#platform-ui-settings-dialog").close();
7630
8597
  toast("界面与分页设置已保存");
7631
8598
  } catch (error) {
@@ -7641,6 +8608,9 @@ $("#members-dialog").addEventListener("close", () => {
7641
8608
  memberDialogMembers = [];
7642
8609
  memberDialogDirectory = [];
7643
8610
  });
8611
+ $("#form-dialog").addEventListener("close", () => {
8612
+ if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
8613
+ });
7644
8614
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
7645
8615
  $("#member-permission-form").querySelectorAll("[data-permission-preset]").forEach((button) => button.addEventListener("click", () => {
7646
8616
  $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
@@ -8047,6 +9017,26 @@ $(".quick-actions").addEventListener("click", (event) => {
8047
9017
  $("#top-search-button").addEventListener("click", () => {
8048
9018
  openSearchDialog().catch((error) => toast(error.message, "error"));
8049
9019
  });
9020
+ $("#background-task-button").addEventListener("click", () => {
9021
+ renderBackgroundTaskCenter();
9022
+ const dialog = $("#background-task-dialog");
9023
+ if (!dialog.open) dialog.showModal();
9024
+ void refreshBackgroundTaskCenter();
9025
+ });
9026
+ $("#background-task-dialog").addEventListener("close", scheduleBackgroundTaskCenterRefresh);
9027
+ $("#background-task-refresh").addEventListener("click", async () => {
9028
+ const button = $("#background-task-refresh");
9029
+ button.disabled = true;
9030
+ try {
9031
+ await refreshBackgroundTaskCenter();
9032
+ } finally {
9033
+ button.disabled = false;
9034
+ }
9035
+ });
9036
+ $("#background-task-open-analysis").addEventListener("click", () => {
9037
+ $("#background-task-dialog").close();
9038
+ showModule("tasks").catch((error) => toast(error.message, "error"));
9039
+ });
8050
9040
  $("#search-dialog-close").addEventListener("click", () => $("#search-dialog").close());
8051
9041
  $("#search-form").addEventListener("submit", async (event) => {
8052
9042
  event.preventDefault();