@musnows/scriverse 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +5 -0
  2. package/dist/ai.js +248 -250
  3. package/dist/ai.js.map +1 -1
  4. package/dist/app.js +184 -39
  5. package/dist/app.js.map +1 -1
  6. package/dist/attachment-storage.js +28 -12
  7. package/dist/attachment-storage.js.map +1 -1
  8. package/dist/backup-encryption.js +129 -0
  9. package/dist/backup-encryption.js.map +1 -0
  10. package/dist/collaboration-presence.js +200 -18
  11. package/dist/collaboration-presence.js.map +1 -1
  12. package/dist/database.js +162 -3
  13. package/dist/database.js.map +1 -1
  14. package/dist/docx-export.js +1 -1
  15. package/dist/docx-export.js.map +1 -1
  16. package/dist/domain.js +18 -0
  17. package/dist/domain.js.map +1 -1
  18. package/dist/image-metadata.js +17 -2
  19. package/dist/image-metadata.js.map +1 -1
  20. package/dist/presence-store.js +160 -0
  21. package/dist/presence-store.js.map +1 -0
  22. package/dist/public/ai-context-meter.js +27 -0
  23. package/dist/public/ai-mentions.js +14 -0
  24. package/dist/public/app.js +1017 -291
  25. package/dist/public/background-task-center.js +1 -1
  26. package/dist/public/chapter-editor-virtualization.js +52 -0
  27. package/dist/public/index.html +43 -21
  28. package/dist/public/presence-client-id.d.ts +10 -0
  29. package/dist/public/presence-client-id.js +31 -0
  30. package/dist/public/s3-backup-ui.d.ts +10 -0
  31. package/dist/public/s3-backup-ui.js +29 -0
  32. package/dist/public/setting-filters.d.ts +16 -0
  33. package/dist/public/setting-filters.js +19 -0
  34. package/dist/public/styles.css +37 -7
  35. package/dist/s3-backup.js +197 -12
  36. package/dist/s3-backup.js.map +1 -1
  37. package/dist/security.js +82 -20
  38. package/dist/security.js.map +1 -1
  39. package/dist/server-runtime.js +36 -18
  40. package/dist/server-runtime.js.map +1 -1
  41. package/dist/store.js +75 -17
  42. package/dist/store.js.map +1 -1
  43. package/dist/upload-limits.js +35 -0
  44. package/dist/upload-limits.js.map +1 -0
  45. package/dist/user-auth.js +11 -1
  46. package/dist/user-auth.js.map +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +3 -2
@@ -1,9 +1,10 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, renderRelationshipMindMap } from "/relationship-graph.js?v=20260809-galaxy-size-threshold-v1";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
- import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260801-context-setting-mention-v1";
4
+ import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
- import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
6
+ import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
+ import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
7
8
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
8
9
  import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
9
10
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
@@ -11,7 +12,7 @@ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.j
11
12
  import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
12
13
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
13
14
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
14
- import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260801-retain-usage-v1";
15
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
15
16
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
16
17
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
17
18
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
@@ -48,16 +49,18 @@ import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global
48
49
  import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
49
50
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
50
51
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
52
+ import { filterSettings } from "/setting-filters.js?v=20260810-setting-inline-filters-v1";
51
53
  import {
52
54
  prepareTimelineEvents,
53
55
  resolveTimelineActiveTrackId,
54
56
  normalizeTimelineSortDirection,
55
57
  timelineTrackColorIndex
56
58
  } from "/timeline-view.js?v=20260801-timeline-sort-actions-v1";
57
- import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
59
+ import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260810-analysis-task-failed-v1";
58
60
  import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
59
61
  import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
60
- import { collectS3BackupRunTransitions, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260804-s3-backup-v1";
62
+ import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
63
+ import { createPresenceClientId, stagePresenceClientIdForRelogin } from "/presence-client-id.js?v=20260810-presence-relogin-v1";
61
64
  import {
62
65
  clampCropRect,
63
66
  containImageRect,
@@ -153,16 +156,9 @@ const cachedWorkModules = new Set([
153
156
  "ai-settings"
154
157
  ]);
155
158
 
156
- function createPresenceClientId() {
157
- if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
158
- const bytes = crypto.getRandomValues(new Uint8Array(16));
159
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
160
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
161
- const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
162
- return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
163
- }
164
-
165
- const presenceClientId = createPresenceClientId();
159
+ let presenceSessionStorage = null;
160
+ try { presenceSessionStorage = window.sessionStorage; } catch { /* 浏览器禁用存储时使用页面级新标识 */ }
161
+ const presenceClientId = createPresenceClientId(presenceSessionStorage);
166
162
  const presenceHeartbeatInterval = 12_000;
167
163
  const systemBootCheckInterval = 8_000;
168
164
  let presenceParticipants = [];
@@ -198,6 +194,8 @@ let backgroundTaskCenterTaskSnapshots = new Map();
198
194
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
199
195
  let s3BackupTargets = [];
200
196
  let s3BackupRuns = [];
197
+ let s3BackupEncryption = { enabled: false, keyConfiguredAt: null };
198
+ let s3BackupEncryptionConfirmationToken = null;
201
199
  let editingS3BackupTargetId = null;
202
200
  let s3BackupPollTimer = null;
203
201
  let s3BackupPollRequest = 0;
@@ -248,18 +246,19 @@ function analysisTaskStatusLabel(status) {
248
246
  review: "已完成",
249
247
  completed: "已完成",
250
248
  partial: "部分失败",
249
+ failed: "失败",
251
250
  expired: "已过期",
252
251
  cancelled: "已取消"
253
252
  })[String(status)] ?? "未知状态";
254
253
  }
255
254
 
256
255
  function canRerunAnalysisTask(task) {
257
- return ["review", "completed", "partial", "expired", "cancelled"].includes(String(task?.status ?? ""));
256
+ return ["review", "completed", "partial", "failed", "expired", "cancelled"].includes(String(task?.status ?? ""));
258
257
  }
259
258
 
260
259
  function normalizedAnalysisTaskStatus(status) {
261
260
  const value = String(status);
262
- return ["pending", "running", "review", "completed", "partial", "expired", "cancelled"].includes(value)
261
+ return ["pending", "running", "review", "completed", "partial", "failed", "expired", "cancelled"].includes(value)
263
262
  ? value
264
263
  : "unknown";
265
264
  }
@@ -382,7 +381,43 @@ function applyWorkAccessMode() {
382
381
 
383
382
  const $ = (selector) => document.querySelector(selector);
384
383
  const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
385
- const maximumAvatarFileSize = 5 * 1024 * 1024;
384
+ const defaultImageUploadLimits = {
385
+ avatarBytes: 2 * 1024 * 1024,
386
+ coverBytes: 5 * 1024 * 1024,
387
+ attachmentBytes: 30 * 1024 * 1024
388
+ };
389
+ let imageUploadLimits = { ...defaultImageUploadLimits };
390
+
391
+ function formatUploadLimit(bytes) {
392
+ if (bytes < 1024 * 1024) return `${bytes} B`;
393
+ const megabytes = bytes / (1024 * 1024);
394
+ return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2)} MB`;
395
+ }
396
+
397
+ function applyImageUploadLimits(nextLimits) {
398
+ if (!nextLimits || typeof nextLimits !== "object") return;
399
+ const next = {
400
+ avatarBytes: Number(nextLimits.avatarBytes),
401
+ coverBytes: Number(nextLimits.coverBytes),
402
+ attachmentBytes: Number(nextLimits.attachmentBytes)
403
+ };
404
+ if (![next.avatarBytes, next.coverBytes, next.attachmentBytes].every((value) => Number.isSafeInteger(value) && value > 0)) return;
405
+ imageUploadLimits = next;
406
+ const avatarCopy = document.querySelector(".avatar-settings-copy small");
407
+ if (avatarCopy) avatarCopy.textContent = `支持 PNG、JPEG、WebP、GIF,文件不超过 ${formatUploadLimit(next.avatarBytes)}。选择后可框选正方形选区再裁剪上传。`;
408
+ }
409
+
410
+ function isGifImageFile(file) {
411
+ return file.type === "image/gif" || /\.gif$/iu.test(file.name ?? "");
412
+ }
413
+
414
+ function coverFileSizeMessage(file) {
415
+ return isGifImageFile(file) ? "封面不支持 GIF 图片" : `封面图片不能超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}`;
416
+ }
417
+
418
+ function assertAttachmentFileSize(file) {
419
+ if (file.size > imageUploadLimits.attachmentBytes) throw new Error(`图片附件不能超过 ${formatUploadLimit(imageUploadLimits.attachmentBytes)}`);
420
+ }
386
421
 
387
422
  function setAiAssistantStatus(status) {
388
423
  const failed = status === "error";
@@ -756,7 +791,7 @@ async function refreshPresence() {
756
791
  presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
757
792
  renderPresence();
758
793
  const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
759
- void handleRelationshipCollaborativeChanges(recentChanges);
794
+ void handleCollaborativeChanges(recentChanges);
760
795
  }
761
796
  } catch {
762
797
  if (state.work?.id === workId) renderPresence();
@@ -766,10 +801,9 @@ async function refreshPresence() {
766
801
  return presenceParticipants;
767
802
  }
768
803
 
769
- async function handleRelationshipCollaborativeChanges(recentChanges) {
804
+ async function handleCollaborativeChanges(recentChanges) {
770
805
  if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
771
806
  const localKey = presencePageKey(presencePageForRoute());
772
- if (!localKey.startsWith("entity-editor:relationship:")) return;
773
807
  const selfId = state.user?.userId;
774
808
  const incoming = recentChanges.filter((change) => (
775
809
  change
@@ -788,17 +822,43 @@ async function handleRelationshipCollaborativeChanges(recentChanges) {
788
822
  }
789
823
  collaborativeChangePromptOpen = true;
790
824
  try {
791
- const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新当前人物关系。请先确认本地没有需要保留的修改,再刷新页面继续查看。`, {
792
- title: "人物关系已更新",
793
- confirmLabel: "刷新页面",
825
+ const changeLabel = latest.label || "当前页面";
826
+ const deleted = latest.action === "delete";
827
+ const targetLabel = deleted ? changeLabel.replace(/编辑$/u, "") : changeLabel;
828
+ const message = deleted
829
+ ? `${latest.actorDisplayName || "协作者"}已删除当前${targetLabel}。请先确认本地没有需要保留的修改,${latest.pageDeleted ? "确认后返回对应列表。" : "再刷新页面查看最新状态。"}`
830
+ : `${latest.actorDisplayName || "协作者"}已在“${changeLabel}”保存新内容。请先确认本地没有需要保留的修改,再刷新页面继续查看。`;
831
+ const shouldReload = await confirmToast(message, {
832
+ title: deleted ? `${targetLabel}已删除` : `${changeLabel}已更新`,
833
+ confirmLabel: deleted && latest.pageDeleted ? (latest.pageKey.startsWith("editor:") ? "返回正文" : "返回列表") : "刷新页面",
794
834
  cancelLabel: "稍后处理"
795
835
  });
796
- if (shouldReload) window.location.reload();
836
+ if (shouldReload) reloadAfterCollaborativeChange(latest);
797
837
  } finally {
798
838
  collaborativeChangePromptOpen = false;
799
839
  }
800
840
  }
801
841
 
842
+ function reloadAfterCollaborativeChange(change) {
843
+ const workId = state.work?.id;
844
+ if (change?.action === "delete" && change.pageDeleted && workId) {
845
+ const entityModule = {
846
+ setting: "settings",
847
+ character: "characters",
848
+ race: "races",
849
+ organization: "organizations",
850
+ relationship: "relationships"
851
+ }[String(change.pageKey ?? "").split(":")[1] ?? ""];
852
+ const safeRoute = change.pageKey.startsWith("editor:")
853
+ ? { view: "editor", workId }
854
+ : entityModule
855
+ ? { view: "module", workId, module: entityModule }
856
+ : { view: "welcome", workId };
857
+ window.history.replaceState(null, "", serializePageRoute(safeRoute));
858
+ }
859
+ window.location.reload();
860
+ }
861
+
802
862
  function schedulePresenceHeartbeat() {
803
863
  if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
804
864
  presenceHeartbeatQueued = setTimeout(() => {
@@ -942,6 +1002,9 @@ function setupPanelResize(handle, side) {
942
1002
  }
943
1003
 
944
1004
  let chapterLineNumberFrame = null;
1005
+ let chapterLineNumberTimer = null;
1006
+ let chapterLineLayout = null;
1007
+ let chapterLineVirtualWindow = null;
945
1008
  let chapterLineSelection = null;
946
1009
  let chapterLineDrag = null;
947
1010
  let chapterWhitespaceVisible = true;
@@ -951,8 +1014,10 @@ let chapterSaveGuardInFlight = null;
951
1014
  let lastSavedChapterSnapshot = null;
952
1015
  let moduleNavExpanded = false;
953
1016
  const chapterAutoSaveDelay = 800;
1017
+ const chapterLineInputRenderDelay = 32;
954
1018
  let aiMentionMatch = null;
955
1019
  let aiMentionRange = null;
1020
+ let aiMentionActiveIndex = -1;
956
1021
  let settingsReturnContext = null;
957
1022
  let entityEditorType = null;
958
1023
  let entityEditorDirty = false;
@@ -976,8 +1041,23 @@ const moduleListPages = {
976
1041
  };
977
1042
  const characterFilters = { raceIds: [], organizationIds: [] };
978
1043
  let characterFiltersPanelOpen = false;
1044
+ const settingFilters = { keyword: "", category: "", lockState: "all" };
1045
+ let settingFiltersPanelOpen = false;
979
1046
  const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
980
1047
  let relationshipFiltersPanelOpen = false;
1048
+
1049
+ function createEditorDirtyTracker(initialSnapshot = "") {
1050
+ let savedSnapshot = String(initialSnapshot);
1051
+ return {
1052
+ markSaved(snapshot) {
1053
+ savedSnapshot = String(snapshot);
1054
+ },
1055
+ isDirty(snapshot) {
1056
+ return String(snapshot) !== savedSnapshot;
1057
+ }
1058
+ };
1059
+ }
1060
+
981
1061
  let settingEditorItem = null;
982
1062
  let characterEditorItem = null;
983
1063
  let knowledgeEditorItem = null;
@@ -996,6 +1076,8 @@ let characterSectionEditorDirty = false;
996
1076
  let settingEditorVditor = null;
997
1077
  let knowledgeSectionVditor = null;
998
1078
  let characterSectionVditor = null;
1079
+ const settingEditorDirtyTracker = createEditorDirtyTracker();
1080
+ const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
999
1081
  let formDialogVditors = [];
1000
1082
  let entityHistoryContext = null;
1001
1083
  let moduleContentInteractionsBound = false;
@@ -1058,7 +1140,15 @@ async function confirmEntityEditorDiscard(message) {
1058
1140
  });
1059
1141
  }
1060
1142
 
1143
+ function isEntityEditorSaving() {
1144
+ return Boolean($("#entity-editor-view").querySelector('[aria-busy="true"]'));
1145
+ }
1146
+
1061
1147
  async function closeEntityEditor({ force = false } = {}) {
1148
+ if (isEntityEditorSaving()) {
1149
+ toast("正在保存,请稍候");
1150
+ return false;
1151
+ }
1062
1152
  if (!$("#character-section-editor-view").classList.contains("hidden")) return closeCharacterSectionEditor({ force });
1063
1153
  if (!$("#knowledge-section-editor-view").classList.contains("hidden")) return closeKnowledgeSectionEditor({ force });
1064
1154
  if (!force && !(await confirmEntityEditorDiscard())) return false;
@@ -1106,6 +1196,13 @@ function syncChapterLineNumberScroll() {
1106
1196
  whitespace.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
1107
1197
  whitespace.dataset.scrollTop = String(input.scrollTop);
1108
1198
  }
1199
+ if (!chapterLineVirtualWindow) return;
1200
+ const buffer = input.clientHeight * 0.35;
1201
+ const viewportBottom = input.scrollTop + input.clientHeight;
1202
+ const needsPreviousLines = chapterLineVirtualWindow.start > 0 && input.scrollTop < chapterLineVirtualWindow.top + buffer;
1203
+ const needsNextLines = chapterLineVirtualWindow.end < chapterLineVirtualWindow.lineCount
1204
+ && viewportBottom > chapterLineVirtualWindow.bottom - buffer;
1205
+ if (needsPreviousLines || needsNextLines) scheduleChapterLineNumbers();
1109
1206
  }
1110
1207
 
1111
1208
  function syncChapterWhitespaceControls() {
@@ -1121,7 +1218,7 @@ function toggleChapterWhitespaceVisibility() {
1121
1218
  scheduleChapterLineNumbers();
1122
1219
  }
1123
1220
 
1124
- function renderChapterWhitespaceMarkers(input, style) {
1221
+ function renderChapterWhitespaceMarkers(input, style, layout, lineWindow, getLineBounds, totalHeight) {
1125
1222
  const overlay = $("#chapter-whitespace-overlay");
1126
1223
  const inner = $("#chapter-whitespace-inner");
1127
1224
  syncChapterWhitespaceControls();
@@ -1129,10 +1226,17 @@ function renderChapterWhitespaceMarkers(input, style) {
1129
1226
  overlay.classList.toggle("is-visible", chapterWhitespaceVisible);
1130
1227
  if (!chapterWhitespaceVisible) {
1131
1228
  inner.replaceChildren();
1229
+ delete inner.dataset.virtualStart;
1230
+ delete inner.dataset.virtualEnd;
1231
+ delete inner.dataset.renderedLineCount;
1132
1232
  return;
1133
1233
  }
1234
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1235
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1236
+ const firstLineTop = getLineBounds(lineWindow.start).top;
1134
1237
  Object.assign(inner.style, {
1135
1238
  width: `${input.clientWidth}px`,
1239
+ height: `${Math.max(input.clientHeight, totalHeight + paddingTop + paddingBottom)}px`,
1136
1240
  fontFamily: style.fontFamily,
1137
1241
  fontSize: style.fontSize,
1138
1242
  fontWeight: style.fontWeight,
@@ -1141,11 +1245,14 @@ function renderChapterWhitespaceMarkers(input, style) {
1141
1245
  letterSpacing: style.letterSpacing,
1142
1246
  tabSize: style.tabSize,
1143
1247
  padding: style.padding,
1248
+ paddingTop: `${paddingTop + firstLineTop}px`,
1249
+ whiteSpace: style.whiteSpace,
1144
1250
  overflowWrap: style.overflowWrap,
1145
1251
  wordBreak: style.wordBreak
1146
1252
  });
1147
1253
  const fragment = document.createDocumentFragment();
1148
- for (const token of tokenizeVisibleSpaces(input.value.replace(/\r\n?/gu, "\n"))) {
1254
+ const visibleText = layout.lines.slice(lineWindow.start, lineWindow.end).join("\n");
1255
+ for (const token of tokenizeVisibleSpaces(visibleText)) {
1149
1256
  if (token.type === "text") {
1150
1257
  fragment.append(document.createTextNode(token.text));
1151
1258
  continue;
@@ -1157,20 +1264,12 @@ function renderChapterWhitespaceMarkers(input, style) {
1157
1264
  fragment.append(marker);
1158
1265
  }
1159
1266
  inner.replaceChildren(fragment);
1267
+ inner.dataset.virtualStart = String(lineWindow.start);
1268
+ inner.dataset.virtualEnd = String(lineWindow.end - 1);
1269
+ inner.dataset.renderedLineCount = String(lineWindow.end - lineWindow.start);
1160
1270
  }
1161
1271
 
1162
- function renderChapterLineNumbers() {
1163
- const input = $("#chapter-content");
1164
- const inner = $("#chapter-line-numbers-inner");
1165
- const measure = $("#chapter-line-measure");
1166
- if (!input || !inner || !measure || input.clientWidth === 0) return;
1167
- const style = getComputedStyle(input);
1168
- const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
1169
- const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
1170
- const numberStyle = getComputedStyle($("#chapter-line-numbers"));
1171
- const numberLineHeight = parseFloat(numberStyle.lineHeight) || parseFloat(numberStyle.fontSize) * 1.2;
1172
- inner.style.top = `${calculateLineNumberTop(parseFloat(style.paddingTop), lineHeight, numberLineHeight)}px`;
1173
- const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
1272
+ function prepareChapterLineLayout(input, measure, style, contentWidth) {
1174
1273
  Object.assign(measure.style, {
1175
1274
  width: `${contentWidth}px`,
1176
1275
  fontFamily: style.fontFamily,
@@ -1179,18 +1278,110 @@ function renderChapterLineNumbers() {
1179
1278
  fontStyle: style.fontStyle,
1180
1279
  lineHeight: style.lineHeight,
1181
1280
  letterSpacing: style.letterSpacing,
1182
- tabSize: style.tabSize
1183
- });
1184
- const lines = input.value.replace(/\r\n?/gu, "\n").split("\n");
1185
- const measureRows = lines.map((line) => {
1186
- const row = document.createElement("div");
1187
- row.textContent = line || "\u200b";
1188
- return row;
1281
+ tabSize: style.tabSize,
1282
+ overflowWrap: style.overflowWrap,
1283
+ wordBreak: style.wordBreak
1189
1284
  });
1190
- measure.replaceChildren(...measureRows);
1285
+ const value = input.value.replace(/\r\n?/gu, "\n");
1286
+ const styleKey = JSON.stringify([
1287
+ contentWidth,
1288
+ style.fontFamily,
1289
+ style.fontSize,
1290
+ style.fontWeight,
1291
+ style.fontStyle,
1292
+ style.lineHeight,
1293
+ style.letterSpacing,
1294
+ style.tabSize,
1295
+ style.overflowWrap,
1296
+ style.wordBreak
1297
+ ]);
1298
+ if (!chapterLineLayout || chapterLineLayout.value !== value) {
1299
+ const mirror = buildChapterLineMirror(value);
1300
+ measure.textContent = mirror.text;
1301
+ chapterLineLayout = { ...mirror, value, styleKey, bounds: new Map() };
1302
+ } else if (chapterLineLayout.styleKey !== styleKey) {
1303
+ chapterLineLayout.styleKey = styleKey;
1304
+ chapterLineLayout.bounds.clear();
1305
+ }
1306
+ return chapterLineLayout;
1307
+ }
1308
+
1309
+ function createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight = null) {
1310
+ const textNode = measure.firstChild;
1191
1311
  const measureRect = measure.getBoundingClientRect();
1312
+ const contentHeight = Number.isFinite(targetHeight) && targetHeight > 0 ? targetHeight : measureRect.height;
1313
+ const geometryScale = measureRect.height > 0 ? contentHeight / measureRect.height : 1;
1314
+ const geometryKey = `${measureRect.height}:${contentHeight}:${lineHeight}`;
1315
+ if (layout.geometryKey !== geometryKey) {
1316
+ layout.geometryKey = geometryKey;
1317
+ layout.bounds.clear();
1318
+ }
1319
+ return {
1320
+ measureHeight: contentHeight,
1321
+ getLineBounds(index) {
1322
+ const cached = layout.bounds.get(index);
1323
+ if (cached) return cached;
1324
+ const range = document.createRange();
1325
+ const start = layout.offsets[index];
1326
+ range.setStart(textNode, start);
1327
+ range.setEnd(textNode, start + layout.lines[index].length + 1);
1328
+ const rects = [...range.getClientRects()].filter((rect) => rect.height > 0);
1329
+ let top;
1330
+ let bottom;
1331
+ if (rects.length > 0) {
1332
+ const lineBoxes = rects.map((rect) => {
1333
+ const height = Math.max(lineHeight, rect.height);
1334
+ const leading = Math.max(0, (lineHeight - rect.height) / 2);
1335
+ const boxTop = (rect.top - measureRect.top - leading) * geometryScale;
1336
+ return { top: boxTop, bottom: boxTop + height * geometryScale };
1337
+ });
1338
+ top = Math.max(0, Math.min(...lineBoxes.map((box) => box.top)));
1339
+ bottom = Math.max(...lineBoxes.map((box) => box.bottom));
1340
+ } else {
1341
+ const scaledLineHeight = lineHeight * geometryScale;
1342
+ const availableHeight = Math.max(0, contentHeight - scaledLineHeight);
1343
+ top = layout.lines.length > 1 ? availableHeight * index / (layout.lines.length - 1) : 0;
1344
+ bottom = top + scaledLineHeight;
1345
+ }
1346
+ const bounds = { top, bottom: Math.max(top + lineHeight * geometryScale, bottom) };
1347
+ layout.bounds.set(index, bounds);
1348
+ return bounds;
1349
+ }
1350
+ };
1351
+ }
1352
+
1353
+ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1354
+ const input = $("#chapter-content");
1355
+ const inner = $("#chapter-line-numbers-inner");
1356
+ const measure = $("#chapter-line-measure");
1357
+ if (!input || !inner || !measure || input.clientWidth === 0) return;
1358
+ const style = getComputedStyle(input);
1359
+ const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
1360
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
1361
+ const numberStyle = getComputedStyle($("#chapter-line-numbers"));
1362
+ const numberLineHeight = parseFloat(numberStyle.lineHeight) || parseFloat(numberStyle.fontSize) * 1.2;
1363
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1364
+ inner.style.top = `${calculateLineNumberTop(paddingTop, lineHeight, numberLineHeight)}px`;
1365
+ const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
1366
+ const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
1367
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
1368
+ const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
1369
+ const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
1370
+ const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
1371
+ if (Number.isInteger(targetLineIndex)) {
1372
+ const safeTarget = Math.max(0, Math.min(targetLineIndex, layout.lines.length - 1));
1373
+ input.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - input.clientHeight / 3);
1374
+ }
1375
+ const viewportTop = Math.max(0, input.scrollTop - paddingTop);
1376
+ const overscan = Math.max(input.clientHeight, lineHeight * 8);
1377
+ const lineWindow = findChapterLineWindow(
1378
+ layout.lines.length,
1379
+ getLineBounds,
1380
+ viewportTop - overscan,
1381
+ viewportTop + input.clientHeight + overscan
1382
+ );
1192
1383
  const numbers = document.createDocumentFragment();
1193
- measureRows.forEach((row, index) => {
1384
+ for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
1194
1385
  const number = document.createElement("button");
1195
1386
  number.type = "button";
1196
1387
  number.className = "chapter-line-number";
@@ -1203,22 +1394,32 @@ function renderChapterLineNumbers() {
1203
1394
  number.classList.add("is-line-selected");
1204
1395
  number.setAttribute("aria-pressed", "true");
1205
1396
  }
1206
- const rowRect = row.getBoundingClientRect();
1207
- const rowHeight = calculateLineNumberRowHeight(lineHeight, rowRect.height);
1208
- number.style.top = `${calculateLineNumberRowTop(measureRect.top, rowRect.top)}px`;
1209
- number.style.height = `${rowHeight}px`;
1397
+ const bounds = getLineBounds(index);
1398
+ number.style.top = `${bounds.top}px`;
1399
+ number.style.height = `${bounds.bottom - bounds.top}px`;
1210
1400
  number.style.paddingTop = `${numberTextOffset}px`;
1211
1401
  numbers.append(number);
1212
- });
1402
+ }
1213
1403
  inner.replaceChildren(numbers);
1214
- inner.style.height = `${measureRect.height}px`;
1215
- inner.dataset.lineCount = String(lines.length);
1216
- measure.replaceChildren();
1217
- renderChapterWhitespaceMarkers(input, style);
1404
+ inner.style.height = `${measureHeight}px`;
1405
+ inner.dataset.lineCount = String(layout.lines.length);
1406
+ inner.dataset.virtualStart = String(lineWindow.start);
1407
+ inner.dataset.virtualEnd = String(lineWindow.end - 1);
1408
+ inner.dataset.renderedLineCount = String(lineWindow.end - lineWindow.start);
1409
+ const firstBounds = getLineBounds(lineWindow.start);
1410
+ const lastBounds = getLineBounds(lineWindow.end - 1);
1411
+ chapterLineVirtualWindow = {
1412
+ start: lineWindow.start,
1413
+ end: lineWindow.end,
1414
+ lineCount: layout.lines.length,
1415
+ top: firstBounds.top + paddingTop,
1416
+ bottom: lastBounds.bottom + paddingTop
1417
+ };
1418
+ renderChapterWhitespaceMarkers(input, style, layout, lineWindow, getLineBounds, measureHeight);
1218
1419
  syncChapterLineNumberScroll();
1219
1420
  }
1220
1421
 
1221
- function scheduleChapterLineNumbers() {
1422
+ function requestChapterLineNumberFrame() {
1222
1423
  if (chapterLineNumberFrame !== null) return;
1223
1424
  chapterLineNumberFrame = requestAnimationFrame(() => {
1224
1425
  chapterLineNumberFrame = null;
@@ -1226,6 +1427,23 @@ function scheduleChapterLineNumbers() {
1226
1427
  });
1227
1428
  }
1228
1429
 
1430
+ function scheduleChapterLineNumbers(delay = 0) {
1431
+ const wait = typeof delay === "number" && Number.isFinite(delay) ? Math.max(0, delay) : 0;
1432
+ if (wait === 0) {
1433
+ if (chapterLineNumberTimer !== null) {
1434
+ clearTimeout(chapterLineNumberTimer);
1435
+ chapterLineNumberTimer = null;
1436
+ }
1437
+ requestChapterLineNumberFrame();
1438
+ return;
1439
+ }
1440
+ if (chapterLineNumberFrame !== null || chapterLineNumberTimer !== null) return;
1441
+ chapterLineNumberTimer = setTimeout(() => {
1442
+ chapterLineNumberTimer = null;
1443
+ requestChapterLineNumberFrame();
1444
+ }, wait);
1445
+ }
1446
+
1229
1447
  function collapseChapterInputBlankLines(input) {
1230
1448
  const value = input.value;
1231
1449
  const normalized = collapseExcessBlankLines(value);
@@ -1242,10 +1460,10 @@ function collapseChapterInputBlankLines(input) {
1242
1460
  function lineIndexAtPointer(clientY) {
1243
1461
  const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
1244
1462
  if (!rows.length) return 0;
1245
- for (let index = 0; index < rows.length; index += 1) {
1246
- if (clientY < rows[index].getBoundingClientRect().bottom) return index;
1463
+ for (const row of rows) {
1464
+ if (clientY < row.getBoundingClientRect().bottom) return Number(row.dataset.lineIndex);
1247
1465
  }
1248
- return rows.length - 1;
1466
+ return Number(rows[rows.length - 1].dataset.lineIndex);
1249
1467
  }
1250
1468
 
1251
1469
  function paintChapterLineSelection(anchor, focus) {
@@ -1835,7 +2053,10 @@ async function ensureAiConversationsLoaded() {
1835
2053
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
1836
2054
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
1837
2055
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
1838
- const conversation = await api(`/api/ai-conversations/${conversationId}?${parameters}`);
2056
+ const [conversation] = await Promise.all([
2057
+ api(`/api/ai-conversations/${conversationId}?${parameters}`),
2058
+ ensureAiReferencesLoaded()
2059
+ ]);
1839
2060
  upsertAiConversationSummary(conversation);
1840
2061
  state.aiConversationId = conversation.id;
1841
2062
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
@@ -1958,7 +2179,6 @@ function applyAiRoleplayCharacter(character) {
1958
2179
  renderAiRoleplayCharacterSelect();
1959
2180
  syncAiTaskOptions();
1960
2181
  renderAiQuickActions();
1961
- resetAiContextMeter();
1962
2182
  }
1963
2183
 
1964
2184
  function refreshAiMessageRoleLabels() {
@@ -1976,6 +2196,7 @@ async function updateAiRoleplayCharacter(characterId) {
1976
2196
  upsertAiConversationSummary(conversation);
1977
2197
  applyAiConversationTaskType(conversation.taskType);
1978
2198
  applyAiRoleplayCharacter(conversation.roleplayCharacter);
2199
+ resetAiContextMeter();
1979
2200
  if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
1980
2201
  else resetAiFeed();
1981
2202
  toast(conversation.roleplayCharacter
@@ -2063,7 +2284,46 @@ function aiPromptTextBeforeCursor() {
2063
2284
  function hideAiMentionMenu() {
2064
2285
  aiMentionMatch = null;
2065
2286
  aiMentionRange = null;
2066
- $("#ai-mention-menu").classList.add("hidden");
2287
+ aiMentionActiveIndex = -1;
2288
+ const prompt = $("#ai-prompt");
2289
+ const menu = $("#ai-mention-menu");
2290
+ prompt.setAttribute("aria-expanded", "false");
2291
+ prompt.removeAttribute("aria-activedescendant");
2292
+ menu.querySelectorAll("[role=option]").forEach((option) => {
2293
+ option.classList.remove("is-active");
2294
+ option.setAttribute("aria-selected", "false");
2295
+ });
2296
+ menu.classList.add("hidden");
2297
+ }
2298
+
2299
+ function setAiMentionActiveOption(nextIndex) {
2300
+ const prompt = $("#ai-prompt");
2301
+ const options = [...$("#ai-mention-menu").querySelectorAll("[role=option]")];
2302
+ if (!options.length) {
2303
+ aiMentionActiveIndex = -1;
2304
+ prompt.removeAttribute("aria-activedescendant");
2305
+ return null;
2306
+ }
2307
+ aiMentionActiveIndex = (nextIndex + options.length) % options.length;
2308
+ let activeOption = null;
2309
+ options.forEach((option, index) => {
2310
+ const active = index === aiMentionActiveIndex;
2311
+ option.classList.toggle("is-active", active);
2312
+ option.setAttribute("aria-selected", String(active));
2313
+ if (active) activeOption = option;
2314
+ });
2315
+ prompt.setAttribute("aria-activedescendant", activeOption.id);
2316
+ activeOption.scrollIntoView({ block: "nearest" });
2317
+ return activeOption;
2318
+ }
2319
+
2320
+ function moveAiMentionActiveOption(direction) {
2321
+ const optionCount = $("#ai-mention-menu").querySelectorAll("[role=option]").length;
2322
+ if (!optionCount) return null;
2323
+ const nextIndex = aiMentionActiveIndex < 0
2324
+ ? (direction > 0 ? 0 : optionCount - 1)
2325
+ : aiMentionActiveIndex + direction;
2326
+ return setAiMentionActiveOption(nextIndex);
2067
2327
  }
2068
2328
 
2069
2329
  function syncAiReferencesWithPrompt() {
@@ -2092,10 +2352,13 @@ function updateAiMentionMenu() {
2092
2352
  }))) ?? [];
2093
2353
  const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query)
2094
2354
  .filter((item) => item.kind !== "context-settings" || $("#ai-task").value !== "roleplay");
2355
+ aiMentionActiveIndex = -1;
2356
+ prompt.removeAttribute("aria-activedescendant");
2095
2357
  menu.innerHTML = options.length
2096
- ? options.map((item) => `<button class="ai-mention-option" type="button" role="option" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
2358
+ ? options.map((item, index) => `<button id="ai-mention-option-${index}" class="ai-mention-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
2097
2359
  : '<p class="ai-mention-empty">没有匹配的角色、设定、章节或上下文能力</p>';
2098
2360
  menu.classList.remove("hidden");
2361
+ prompt.setAttribute("aria-expanded", "true");
2099
2362
  }
2100
2363
 
2101
2364
  function selectAiMention(button) {
@@ -2316,7 +2579,7 @@ function clearChapterLineSelection() {
2316
2579
  }
2317
2580
 
2318
2581
  const typographyStorageKey = "ai-novel-typography-v1";
2319
- const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 14, aiFontSize: 14, density: "balanced" });
2582
+ const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 14, aiFontSize: 12, density: "balanced" });
2320
2583
  const cjkFontStacks = {
2321
2584
  system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
2322
2585
  pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
@@ -2486,6 +2749,8 @@ function optimisticVersionForPath(path) {
2486
2749
  if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
2487
2750
  if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
2488
2751
  if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
2752
+ if (resourceMatch[1] === "settings" && settingEditorItem?.id === resourceId) return settingEditorItem.versionNo;
2753
+ if (["races", "organizations"].includes(resourceMatch[1]) && knowledgeEditorItem?.id === resourceId) return knowledgeEditorItem.versionNo;
2489
2754
  return find(collection, resourceId);
2490
2755
  }
2491
2756
  const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
@@ -2558,6 +2823,7 @@ async function api(path, options = {}) {
2558
2823
  }
2559
2824
  const payload = await response.json();
2560
2825
  if (path === "/api/health") {
2826
+ applyImageUploadLimits(payload.data?.uploadLimits);
2561
2827
  updateSystemHealth({
2562
2828
  status: payload.data?.status === "ok" ? "ready" : "degraded",
2563
2829
  version: payload.data?.version
@@ -3063,6 +3329,51 @@ function toast(message, type = "info") {
3063
3329
  }, 3600);
3064
3330
  }
3065
3331
 
3332
+ async function runEntityEditorSave({ busyTarget, button, prepare, save }) {
3333
+ if (button.disabled) return null;
3334
+ const initialLabel = button.textContent;
3335
+ const initialInert = busyTarget.inert;
3336
+ const activeElement = document.activeElement;
3337
+ let focusTarget = busyTarget.contains(activeElement) ? activeElement : null;
3338
+ let finalLabel = initialLabel;
3339
+ button.disabled = true;
3340
+ try {
3341
+ // prepare 会在首次 await 前同步采集快照;同一事件循环内随即锁定,避免新的输入插入。
3342
+ const preparation = prepare();
3343
+ if (busyTarget.contains(document.activeElement)) focusTarget = document.activeElement;
3344
+ busyTarget.inert = true;
3345
+ busyTarget.setAttribute("aria-busy", "true");
3346
+ const prepared = await preparation;
3347
+ if (prepared === null) return null;
3348
+ button.textContent = "保存中…";
3349
+ toast("正在保存…");
3350
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
3351
+ const result = await save(prepared);
3352
+ finalLabel = result?.buttonLabel ?? finalLabel;
3353
+ toast(result?.message ?? "保存成功");
3354
+ return result;
3355
+ } catch (error) {
3356
+ toast(error instanceof Error ? error.message : "保存失败,请重试", "error");
3357
+ return null;
3358
+ } finally {
3359
+ busyTarget.removeAttribute("aria-busy");
3360
+ busyTarget.inert = initialInert;
3361
+ button.disabled = false;
3362
+ button.textContent = finalLabel;
3363
+ if (!busyTarget.contains(document.activeElement) && focusTarget?.isConnected) {
3364
+ focusTarget.focus({ preventScroll: true });
3365
+ }
3366
+ }
3367
+ }
3368
+
3369
+ function upsertEntityCollection(items, saved) {
3370
+ const nextItems = Array.isArray(items) ? [...items] : [];
3371
+ const index = nextItems.findIndex((item) => item.id === saved.id);
3372
+ if (index >= 0) nextItems[index] = saved;
3373
+ else nextItems.push(saved);
3374
+ return nextItems;
3375
+ }
3376
+
3066
3377
  function persistentToast(message, type = "info") {
3067
3378
  const region = $("#toast-region");
3068
3379
  const element = document.createElement("div");
@@ -3079,7 +3390,26 @@ function persistentToast(message, type = "info") {
3079
3390
  };
3080
3391
  }
3081
3392
 
3393
+ function restoreToastFocus(previousFocus) {
3394
+ if (
3395
+ previousFocus instanceof HTMLElement
3396
+ && previousFocus.isConnected
3397
+ && !previousFocus.matches(":disabled")
3398
+ && previousFocus.getClientRects().length > 0
3399
+ ) {
3400
+ previousFocus.focus({ preventScroll: true });
3401
+ if (document.activeElement === previousFocus) return;
3402
+ }
3403
+ const body = document.body;
3404
+ const previousTabIndex = body.getAttribute("tabindex");
3405
+ body.setAttribute("tabindex", "-1");
3406
+ body.focus({ preventScroll: true });
3407
+ if (previousTabIndex === null) body.removeAttribute("tabindex");
3408
+ else body.setAttribute("tabindex", previousTabIndex);
3409
+ }
3410
+
3082
3411
  function confirmToast(message, { title = "请再次确认", confirmLabel = "确认", cancelLabel = "取消" } = {}) {
3412
+ const previousFocus = document.activeElement;
3083
3413
  const region = $("#toast-region");
3084
3414
  const element = document.createElement("section");
3085
3415
  element.className = "toast toast-confirmation";
@@ -3108,6 +3438,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
3108
3438
  const finish = (confirmed) => {
3109
3439
  element.remove();
3110
3440
  if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
3441
+ restoreToastFocus(previousFocus);
3111
3442
  resolve(confirmed);
3112
3443
  };
3113
3444
  cancel.addEventListener("click", () => finish(false), { once: true });
@@ -3911,6 +4242,17 @@ function s3BackupTargetFailed(target) {
3911
4242
  return Number.isFinite(failedAt) && (!Number.isFinite(succeededAt) || failedAt > succeededAt);
3912
4243
  }
3913
4244
 
4245
+ function renderS3BackupEncryption() {
4246
+ const presentation = s3BackupEncryptionPresentation(s3BackupEncryption);
4247
+ const toggle = $("#s3-backup-encryption-toggle");
4248
+ toggle.checked = s3BackupEncryption.enabled;
4249
+ $("#s3-backup-encryption-description").textContent = presentation.description;
4250
+ const status = $("#s3-backup-encryption-status");
4251
+ status.className = `s3-backup-status ${presentation.statusClass}`.trim();
4252
+ status.textContent = presentation.label;
4253
+ $("#s3-backup-encryption-warning").classList.toggle("hidden", !presentation.showPrivateBucketWarning);
4254
+ }
4255
+
3914
4256
  function renderS3BackupTargets() {
3915
4257
  const enabledCount = s3BackupTargets.filter((target) => target.enabled).length;
3916
4258
  $("#s3-backup-summary").textContent = s3BackupTargets.length
@@ -3983,12 +4325,15 @@ async function loadS3BackupData({ loading = false } = {}) {
3983
4325
  $("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">正在读取备份目标……</p>';
3984
4326
  $("#s3-backup-runs").innerHTML = '<p class="s3-backup-empty">正在读取运行记录……</p>';
3985
4327
  }
3986
- const [targets, runs] = await Promise.all([
4328
+ const [targets, runs, encryption] = await Promise.all([
3987
4329
  api("/api/platform/backups/targets"),
3988
- api("/api/platform/backups/runs?limit=30")
4330
+ api("/api/platform/backups/runs?limit=30"),
4331
+ api("/api/platform/backups/encryption")
3989
4332
  ]);
3990
4333
  s3BackupTargets = targets;
3991
4334
  s3BackupRuns = runs.items;
4335
+ s3BackupEncryption = encryption;
4336
+ renderS3BackupEncryption();
3992
4337
  renderS3BackupTargets();
3993
4338
  renderS3BackupRuns();
3994
4339
  }
@@ -3999,12 +4344,106 @@ async function openS3BackupDialog() {
3999
4344
  if (!dialog.open) dialog.showModal();
4000
4345
  try {
4001
4346
  await loadS3BackupData({ loading: true });
4347
+ if (!s3BackupEncryption.enabled) {
4348
+ toast("未开启备份加密,请将 S3 桶设置为私有桶,避免数据泄露", "warning");
4349
+ }
4002
4350
  } catch (error) {
4003
4351
  $("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">备份配置加载失败,请稍后刷新。</p>';
4004
4352
  toast(error.message, "error");
4005
4353
  }
4006
4354
  }
4007
4355
 
4356
+ function showS3BackupEncryptionKey(key, confirmationToken) {
4357
+ s3BackupEncryptionConfirmationToken = confirmationToken;
4358
+ $("#s3-backup-key-value").value = key;
4359
+ const dialog = $("#s3-backup-key-dialog");
4360
+ if (!dialog.open) dialog.showModal();
4361
+ queueMicrotask(() => $("#s3-backup-key-copy").focus());
4362
+ }
4363
+
4364
+ async function changeS3BackupEncryption(event) {
4365
+ const toggle = event.currentTarget;
4366
+ const enabled = toggle.checked;
4367
+ const restoreToggleFocus = document.activeElement === toggle;
4368
+ toggle.disabled = true;
4369
+ try {
4370
+ const result = await api("/api/platform/backups/encryption", {
4371
+ method: "POST",
4372
+ body: { enabled }
4373
+ });
4374
+ s3BackupEncryption = {
4375
+ enabled: result.enabled === true,
4376
+ keyConfiguredAt: result.keyConfiguredAt ?? s3BackupEncryption.keyConfiguredAt
4377
+ };
4378
+ renderS3BackupEncryption();
4379
+ if (typeof result.key === "string" && result.key && typeof result.confirmationToken === "string" && result.confirmationToken) {
4380
+ showS3BackupEncryptionKey(result.key, result.confirmationToken);
4381
+ } else {
4382
+ toast(enabled ? "S3 备份加密已开启" : "S3 备份加密已关闭;历史加密备份仍可使用原密钥恢复");
4383
+ }
4384
+ } catch (error) {
4385
+ renderS3BackupEncryption();
4386
+ toast(error.message, "error");
4387
+ } finally {
4388
+ toggle.disabled = false;
4389
+ if (restoreToggleFocus && !$("#s3-backup-key-dialog").open) toggle.focus();
4390
+ }
4391
+ }
4392
+
4393
+ async function copyS3BackupEncryptionKey() {
4394
+ const field = $("#s3-backup-key-value");
4395
+ try {
4396
+ await navigator.clipboard.writeText(field.value);
4397
+ toast("备份加密密钥已复制");
4398
+ } catch {
4399
+ field.focus();
4400
+ field.select();
4401
+ toast("无法自动复制,请手动复制密钥", "error");
4402
+ }
4403
+ }
4404
+
4405
+ function downloadS3BackupEncryptionKey() {
4406
+ const value = $("#s3-backup-key-value").value;
4407
+ const url = URL.createObjectURL(new Blob([s3BackupEncryptionKeyFile(value)], { type: "text/plain;charset=utf-8" }));
4408
+ const anchor = document.createElement("a");
4409
+ anchor.href = url;
4410
+ anchor.download = "scriverse-s3-backup-key.txt";
4411
+ document.body.append(anchor);
4412
+ anchor.click();
4413
+ anchor.remove();
4414
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
4415
+ toast("备份加密密钥文件已下载");
4416
+ }
4417
+
4418
+ async function confirmS3BackupEncryptionKeySaved() {
4419
+ const confirmationToken = s3BackupEncryptionConfirmationToken;
4420
+ if (!confirmationToken) return toast("备份加密确认已失效,请刷新后重新开启", "error");
4421
+ const button = $("#s3-backup-key-confirm");
4422
+ button.disabled = true;
4423
+ button.textContent = "正在开启";
4424
+ try {
4425
+ const result = await api("/api/platform/backups/encryption/confirm", {
4426
+ method: "POST",
4427
+ body: { confirmationToken }
4428
+ });
4429
+ s3BackupEncryption = {
4430
+ enabled: result.enabled === true,
4431
+ keyConfiguredAt: result.keyConfiguredAt ?? null
4432
+ };
4433
+ renderS3BackupEncryption();
4434
+ s3BackupEncryptionConfirmationToken = null;
4435
+ $("#s3-backup-key-value").value = "";
4436
+ $("#s3-backup-key-dialog").close();
4437
+ $("#s3-backup-encryption-toggle").focus();
4438
+ toast("S3 备份加密已开启,请妥善保管密钥");
4439
+ } catch (error) {
4440
+ toast(error.message, "error");
4441
+ } finally {
4442
+ button.disabled = false;
4443
+ button.textContent = "我已保存";
4444
+ }
4445
+ }
4446
+
4008
4447
  function updateS3BackupRootPreview() {
4009
4448
  $("#s3-backup-root-preview").textContent = s3BackupRootPrefix($("#s3-backup-base-path").value);
4010
4449
  }
@@ -4476,10 +4915,8 @@ function revealChapterSearchLines(startLine, endLine) {
4476
4915
  input.setSelectionRange(selection.startOffset, selection.startOffset + selection.text.length);
4477
4916
  scheduleChapterLineNumbers();
4478
4917
  requestAnimationFrame(() => {
4918
+ renderChapterLineNumbers({ targetLineIndex: selection.safeStart });
4479
4919
  paintChapterLineSelection(selection.safeStart, selection.safeEnd);
4480
- const row = $("#chapter-line-numbers-inner").querySelector(`[data-line-index="${selection.safeStart}"]`);
4481
- if (row) input.scrollTop = Math.max(0, row.offsetTop - input.clientHeight / 3);
4482
- syncChapterLineNumberScroll();
4483
4920
  });
4484
4921
  }
4485
4922
 
@@ -4685,6 +5122,10 @@ function resetWorkScopedUiCaches() {
4685
5122
  draftTypeFilter = "all";
4686
5123
  draftBindingFilters = [];
4687
5124
  draftFiltersPanelOpen = false;
5125
+ settingFilters.keyword = "";
5126
+ settingFilters.category = "";
5127
+ settingFilters.lockState = "all";
5128
+ settingFiltersPanelOpen = false;
4688
5129
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
4689
5130
  relationshipFilters.fromCharacterIds = [];
4690
5131
  relationshipFilters.toCharacterIds = [];
@@ -5579,6 +6020,17 @@ function bindRaceTreeExpandToggle() {
5579
6020
  });
5580
6021
  }
5581
6022
 
6023
+ function mountSettingFilterToggle() {
6024
+ $("#module-header-actions").querySelector('[data-module-header-action="setting-filter-toggle"]')?.remove();
6025
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="setting-filter-toggle" aria-label="筛选设定" aria-controls="setting-filter-panel" aria-expanded="${settingFiltersPanelOpen}" title="筛选设定"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
6026
+ const toggle = $("#module-header-actions").querySelector('[data-module-header-action="setting-filter-toggle"]');
6027
+ toggle?.addEventListener("click", () => {
6028
+ settingFiltersPanelOpen = !settingFiltersPanelOpen;
6029
+ $("#setting-filter-panel")?.classList.toggle("hidden", !settingFiltersPanelOpen);
6030
+ toggle.setAttribute("aria-expanded", String(settingFiltersPanelOpen));
6031
+ });
6032
+ }
6033
+
5582
6034
  function mountCharacterFilterToggle() {
5583
6035
  $("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
5584
6036
  $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="character-filter-toggle" aria-label="筛选角色" aria-controls="character-filter-panel" aria-expanded="${characterFiltersPanelOpen}" title="筛选角色"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
@@ -5936,19 +6388,76 @@ function entityLifecycleBadge(active, label) {
5936
6388
  async function renderSettings(page = moduleListPages.settings) {
5937
6389
  const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
5938
6390
  state.settings = records;
5939
- mountModuleCount(records.length);
5940
- const pageResult = paginateModuleItems(records, page, "settings");
5941
- moduleListPages.settings = pageResult.page;
5942
- const layout = readModuleLayout();
5943
- if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
5944
- $("#module-content").innerHTML = records.length
5945
- ? `${layout === "rows" ? renderSettingRows(pageResult.items) : renderSettingCards(pageResult.items)}${renderModulePagination(pageResult, "settings", "设定库")}`
5946
- : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
5947
- bindModuleLayoutToggle(() => renderSettings(pageResult.page));
5948
- bindModulePagination("settings", renderSettings);
5949
- const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
5950
- $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
5951
- bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
6391
+ const categories = [...new Set([
6392
+ ...records.map((item) => String(item.category ?? "").trim()).filter(Boolean),
6393
+ ...(settingFilters.category ? [settingFilters.category] : [])
6394
+ ])].sort((left, right) => left.localeCompare(right, "zh-CN"));
6395
+ const hasSettingFilters = () => Boolean(settingFilters.keyword.trim() || settingFilters.category || settingFilters.lockState !== "all");
6396
+ const filterToolbar = `<section id="setting-filter-panel" class="character-filter-toolbar setting-filter-toolbar${settingFiltersPanelOpen ? "" : " hidden"}" aria-label="设定筛选">
6397
+ <label class="setting-filter-field" for="setting-keyword-filter"><span>按关键词搜索</span><input id="setting-keyword-filter" type="search" value="${esc(settingFilters.keyword)}" placeholder="搜索标题或内容" aria-label="按关键词搜索设定" autocomplete="off"></label>
6398
+ <label class="setting-filter-field" for="setting-category-filter"><span>按分类筛选</span><select id="setting-category-filter" aria-label="按分类筛选设定"><option value="">全部分类</option>${categories.map((category) => `<option value="${esc(category)}" ${settingFilters.category === category ? "selected" : ""}>${esc(category)}</option>`).join("")}</select></label>
6399
+ <label class="setting-filter-field" for="setting-lock-filter"><span>按锁定状态筛选</span><select id="setting-lock-filter" aria-label="按锁定状态筛选设定"><option value="all" ${settingFilters.lockState === "all" ? "selected" : ""}>全部锁定状态</option><option value="locked" ${settingFilters.lockState === "locked" ? "selected" : ""}>仅已锁定</option><option value="unlocked" ${settingFilters.lockState === "unlocked" ? "selected" : ""}>仅未锁定</option></select></label>
6400
+ <div class="character-filter-toolbar-actions"><span id="setting-filter-result-count" class="character-filter-result-count${hasSettingFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-setting-filters" class="ghost-button" type="button" ${hasSettingFilters() ? "" : "disabled"}>重置筛选</button></div>
6401
+ </section><div id="setting-filter-results"></div>`;
6402
+ $("#module-content").innerHTML = filterToolbar;
6403
+ mountSettingFilterToggle();
6404
+
6405
+ const renderSettingResults = (requestedPage = moduleListPages.settings) => {
6406
+ const filteredRecords = filterSettings(state.settings, settingFilters);
6407
+ const pageResult = paginateModuleItems(filteredRecords, requestedPage, "settings");
6408
+ moduleListPages.settings = pageResult.page;
6409
+ mountModuleCount(filteredRecords.length);
6410
+ const layout = readModuleLayout();
6411
+ if (filteredRecords.length) mountModuleLayoutToggle(layout, "设定列表样式");
6412
+ else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
6413
+ $("#setting-filter-results").innerHTML = filteredRecords.length
6414
+ ? `${layout === "rows" ? renderSettingRows(pageResult.items) : renderSettingCards(pageResult.items)}${renderModulePagination(pageResult, "settings", "设定库")}`
6415
+ : records.length
6416
+ ? emptyModule("没有符合筛选条件的设定", "可以调整关键词、分类、锁定状态或重置筛选。")
6417
+ : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
6418
+ const filtersActive = hasSettingFilters();
6419
+ const resultCount = $("#setting-filter-result-count");
6420
+ resultCount.textContent = filtersActive ? `筛选后剩余 ${filteredRecords.length} 条设定` : "";
6421
+ resultCount.classList.toggle("hidden", !filtersActive);
6422
+ $("#clear-setting-filters").disabled = !filtersActive;
6423
+ bindModuleLayoutToggle(() => renderSettingResults(pageResult.page));
6424
+ bindModulePagination("settings", renderSettingResults);
6425
+ const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
6426
+ $("#setting-filter-results").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
6427
+ bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
6428
+ };
6429
+
6430
+ $("#setting-keyword-filter").addEventListener("input", (event) => {
6431
+ settingFilters.keyword = event.currentTarget.value;
6432
+ settingFiltersPanelOpen = true;
6433
+ moduleListPages.settings = 1;
6434
+ renderSettingResults(1);
6435
+ });
6436
+ $("#setting-category-filter").addEventListener("change", (event) => {
6437
+ settingFilters.category = event.currentTarget.value;
6438
+ settingFiltersPanelOpen = true;
6439
+ moduleListPages.settings = 1;
6440
+ renderSettingResults(1);
6441
+ });
6442
+ $("#setting-lock-filter").addEventListener("change", (event) => {
6443
+ settingFilters.lockState = ["locked", "unlocked"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
6444
+ settingFiltersPanelOpen = true;
6445
+ moduleListPages.settings = 1;
6446
+ renderSettingResults(1);
6447
+ });
6448
+ $("#clear-setting-filters").addEventListener("click", () => {
6449
+ settingFilters.keyword = "";
6450
+ settingFilters.category = "";
6451
+ settingFilters.lockState = "all";
6452
+ $("#setting-keyword-filter").value = "";
6453
+ $("#setting-category-filter").value = "";
6454
+ $("#setting-lock-filter").value = "all";
6455
+ settingFiltersPanelOpen = true;
6456
+ moduleListPages.settings = 1;
6457
+ renderSettingResults(1);
6458
+ $("#setting-keyword-filter").focus();
6459
+ });
6460
+ renderSettingResults(page);
5952
6461
  }
5953
6462
 
5954
6463
  async function renderCharacters(page = characterListPage) {
@@ -6530,8 +7039,14 @@ async function renderReviews(page = moduleListPages.reviews) {
6530
7039
  bindModulePagination("reviews", renderReviews);
6531
7040
  bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
6532
7041
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
6533
- await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
6534
- await renderReviews(pageResult.page);
7042
+ button.disabled = true;
7043
+ try {
7044
+ await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
7045
+ await renderReviews(pageResult.page);
7046
+ } catch (error) {
7047
+ toast(error.message, "error");
7048
+ button.disabled = false;
7049
+ }
6535
7050
  }));
6536
7051
  $("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
6537
7052
  const target = characterById.get(button.dataset.mergeTarget);
@@ -7570,6 +8085,7 @@ function backgroundProductUpdateMarkup() {
7570
8085
 
7571
8086
  function backgroundTaskTransitionMessage(transition) {
7572
8087
  const label = analysisTaskTypeLabel(transition.task.taskType);
8088
+ if (transition.status === "failed") return { message: `${label}失败,请打开任务详情查看`, type: "error" };
7573
8089
  if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
7574
8090
  if (transition.status === "expired") return { message: `${label}已过期,正文可能已发生变化`, type: "error" };
7575
8091
  if (transition.status === "cancelled") return { message: `${label}已取消`, type: "info" };
@@ -8350,8 +8866,10 @@ function renderAiContextDistribution(usage) {
8350
8866
  popover.dataset.hasUsage = String(Boolean(usage));
8351
8867
  }
8352
8868
 
8353
- function setAiContextMeter(usage) {
8354
- const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
8869
+ function setAiContextMeter(usage, allowShrink = true) {
8870
+ const displayUsage = allowShrink
8871
+ ? resolveAiContextUsage(latestAiContextUsage, usage)
8872
+ : mergeAiContextUsage(latestAiContextUsage, usage, false);
8355
8873
  latestAiContextUsage = displayUsage;
8356
8874
  const meter = $("#ai-context-meter");
8357
8875
  const value = meter.querySelector("b");
@@ -8371,7 +8889,9 @@ function setAiContextMeter(usage) {
8371
8889
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
8372
8890
  meter.classList.toggle("is-danger", percent >= 90);
8373
8891
  meter.style.setProperty("--context-usage", String(percent));
8374
- value.textContent = `${percent}%`;
8892
+ value.textContent = Number(displayUsage.inputTokens) > 0
8893
+ ? formatAiContextUsagePercent(displayUsage.inputTokens, displayUsage.contextWindow)
8894
+ : `${percent}%`;
8375
8895
  const tooltip = formatAiContextUsageTooltip(displayUsage);
8376
8896
  meter.dataset.tooltip = tooltip;
8377
8897
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
@@ -8706,7 +9226,7 @@ function workCoverFieldHtml(work) {
8706
9226
  return `<section class="work-cover-field" aria-labelledby="work-cover-title">
8707
9227
  <div class="work-cover-copy">
8708
9228
  <strong id="work-cover-title">封面</strong>
8709
- <small>用于书架展示。支持 PNG、JPEG、WebP。</small>
9229
+ <small>用于书架展示。支持 PNG、JPEG、WebP;文件不超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}。</small>
8710
9230
  </div>
8711
9231
  <div class="work-cover-preview ${work.coverUrl ? "has-cover" : ""}" aria-hidden="${work.coverUrl ? "false" : "true"}">
8712
9232
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面预览">` : "<span>暂无封面</span>"}
@@ -8910,42 +9430,65 @@ async function deleteVolume(item) {
8910
9430
  }
8911
9431
  }
8912
9432
 
9433
+ function syncSettingEditorChrome(viewOnly) {
9434
+ const item = settingEditorItem;
9435
+ $("#setting-editor-eyebrow").textContent = viewOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
9436
+ $("#setting-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
9437
+ $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
9438
+ $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
9439
+ const showManagementActions = Boolean(item && !viewOnly);
9440
+ $("#setting-editor-confirm").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
9441
+ $("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
9442
+ $("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
9443
+ $("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
9444
+ }
9445
+
9446
+ function settingEditorSnapshot(markdown = null) {
9447
+ const content = markdown ?? settingEditorVditor?.getValue() ?? $("#setting-editor-body").value;
9448
+ return JSON.stringify({
9449
+ title: $("#setting-editor-name").value,
9450
+ category: $("#setting-editor-category").value,
9451
+ content,
9452
+ locked: $("#setting-editor-locked").checked
9453
+ });
9454
+ }
9455
+
9456
+ function syncSettingEditorDirty(markdown = null) {
9457
+ const currentMarkdown = settingEditorVditor?.getValue() ?? markdown ?? $("#setting-editor-body").value;
9458
+ $("#setting-editor-body").value = currentMarkdown;
9459
+ entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
9460
+ }
9461
+
8913
9462
  function openSettingEditor(item = null, { readOnly = false } = {}) {
8914
9463
  entityEditorReadOnly = readOnly;
8915
9464
  destroyVditorEditor(settingEditorVditor);
8916
9465
  settingEditorVditor = null;
8917
9466
  settingEditorItem = item;
8918
- $("#setting-editor-eyebrow").textContent = item ? "编辑设定" : "新建设定";
8919
9467
  $("#setting-editor-name").value = item?.title ?? "";
8920
9468
  $("#setting-editor-category").value = item?.category ?? "世界规则";
8921
9469
  $("#setting-editor-locked").checked = Boolean(item?.locked);
8922
9470
  $("#setting-editor-body").value = item?.content ?? "";
8923
- $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
8924
9471
  const viewOnly = readOnly || !canEditModule("settings");
8925
9472
  $("#setting-editor-form").classList.toggle("is-read-only", viewOnly);
8926
- $("#setting-editor-eyebrow").textContent = readOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
8927
9473
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
8928
9474
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
8929
- $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
8930
9475
  const editButton = $("#setting-editor-edit");
8931
9476
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
8932
- editButton.onclick = () => openSettingEditor(item);
8933
- const showManagementActions = Boolean(item && !viewOnly);
9477
+ editButton.onclick = () => openSettingEditor(settingEditorItem);
8934
9478
  const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
8935
- $("#setting-editor-confirm").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
8936
- $("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
8937
- $("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
8938
- $("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
9479
+ syncSettingEditorChrome(viewOnly);
8939
9480
  $("#setting-editor-history").onclick = async () => {
8940
- if (!item) return;
9481
+ const currentItem = settingEditorItem;
9482
+ if (!currentItem) return;
8941
9483
  if (!(await closeEntityEditor())) return;
8942
- openEntityHistory("setting", item.id, item.title, async () => { await renderSettings(); await loadAiReferences(); });
9484
+ openEntityHistory("setting", currentItem.id, currentItem.title, async () => { await renderSettings(); await loadAiReferences(); });
8943
9485
  };
8944
9486
  $("#setting-editor-delete").onclick = () => {
8945
- if (!item) return;
9487
+ const currentItem = settingEditorItem;
9488
+ if (!currentItem) return;
8946
9489
  void deleteManagedEntity({
8947
9490
  typeLabel: "设定",
8948
- item,
9491
+ item: currentItem,
8949
9492
  endpoint: (setting) => `/api/settings/${encodeURIComponent(setting.id)}`,
8950
9493
  refresh: () => closeEntityEditor({ force: true }),
8951
9494
  warning: "这条设定及其版本历史会被删除。"
@@ -8953,11 +9496,12 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
8953
9496
  };
8954
9497
  statusButtons.forEach((button) => {
8955
9498
  button.onclick = async () => {
8956
- if (!item) return;
9499
+ const currentItem = settingEditorItem;
9500
+ if (!currentItem) return;
8957
9501
  button.disabled = true;
8958
9502
  const status = button.id === "setting-editor-confirm" ? "confirmed" : "deprecated";
8959
9503
  try {
8960
- await api(`/api/settings/${item.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
9504
+ await api(`/api/settings/${currentItem.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
8961
9505
  await closeEntityEditor({ force: true });
8962
9506
  await loadAiReferences();
8963
9507
  toast(status === "confirmed" ? "设定候选已确认" : "设定候选已弃用");
@@ -8971,54 +9515,65 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
8971
9515
  $("#setting-editor-form").onsubmit = async (event) => {
8972
9516
  event.preventDefault();
8973
9517
  if (readOnly || !canEditModule("settings")) return;
8974
- const form = new FormData(event.currentTarget);
8975
9518
  const submit = $("#setting-editor-submit");
8976
- submit.disabled = true;
8977
- try {
8978
- const title = String(form.get("title") ?? "").trim();
8979
- if (!title) {
8980
- toast("请填写设定标题", "error");
8981
- $("#setting-editor-name").focus();
8982
- return;
8983
- }
8984
- const content = String(form.get("content") ?? "");
8985
- if (!content.trim()) {
8986
- toast("请填写设定正文", "error");
8987
- $("#setting-editor-body").focus();
8988
- return;
9519
+ await runEntityEditorSave({
9520
+ busyTarget: event.currentTarget,
9521
+ button: submit,
9522
+ prepare: async () => {
9523
+ const form = new FormData(event.currentTarget);
9524
+ const title = String(form.get("title") ?? "").trim();
9525
+ if (!title) {
9526
+ toast("请填写设定标题", "error");
9527
+ $("#setting-editor-name").focus();
9528
+ return null;
9529
+ }
9530
+ const content = String(form.get("content") ?? "");
9531
+ if (!content.trim()) {
9532
+ toast("请填写设定正文", "error");
9533
+ $("#setting-editor-body").focus();
9534
+ return null;
9535
+ }
9536
+ const currentItem = settingEditorItem;
9537
+ const changeNote = currentItem ? await inputToast("简要说明这次修改,便于以后查看版本历史。可以留空。", {
9538
+ title: "填写版本说明",
9539
+ inputLabel: "版本说明",
9540
+ placeholder: "例如:补充纪元历法限制",
9541
+ confirmLabel: "保存新版本"
9542
+ }) : "";
9543
+ if (changeNote === null || !(await confirmConcurrentSave())) return null;
9544
+ const locked = form.get("locked") === "on";
9545
+ return {
9546
+ currentItem,
9547
+ body: {
9548
+ title,
9549
+ category: String(form.get("category") ?? "世界规则"),
9550
+ content,
9551
+ locked,
9552
+ status: locked ? "confirmed" : (currentItem?.status ?? "draft"),
9553
+ ...(currentItem ? { changeNote } : {})
9554
+ }
9555
+ };
9556
+ },
9557
+ save: async ({ currentItem, body }) => {
9558
+ const saved = await api(currentItem ? `/api/settings/${currentItem.id}` : `/api/works/${state.work.id}/settings`, { method: currentItem ? "PATCH" : "POST", body });
9559
+ await cleanupPendingMarkdownAttachments(body.content);
9560
+ settingEditorItem = saved;
9561
+ state.settings = upsertEntityCollection(state.settings, saved);
9562
+ syncSettingEditorChrome(false);
9563
+ settingEditorDirtyTracker.markSaved(settingEditorSnapshot(body.content));
9564
+ entityEditorDirty = false;
9565
+ replacePageRoute(currentPageRoute());
9566
+ return {
9567
+ buttonLabel: "保存新版本",
9568
+ message: currentItem ? `设定已保存为 v${saved.versionNo}` : `设定已创建为 v${saved.versionNo}`
9569
+ };
8989
9570
  }
8990
- const changeNote = item ? await inputToast("简要说明这次修改,便于以后查看版本历史。可以留空。", {
8991
- title: "填写版本说明",
8992
- inputLabel: "版本说明",
8993
- placeholder: "例如:补充纪元历法限制",
8994
- confirmLabel: "保存新版本"
8995
- }) : "";
8996
- if (changeNote === null) return;
8997
- const locked = form.get("locked") === "on";
8998
- const body = {
8999
- title,
9000
- category: String(form.get("category") ?? "世界规则"),
9001
- content,
9002
- locked,
9003
- status: locked ? "confirmed" : (item?.status ?? "draft"),
9004
- ...(item ? { changeNote } : {})
9005
- };
9006
- if (!(await confirmConcurrentSave())) return;
9007
- await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
9008
- await cleanupPendingMarkdownAttachments(body.content);
9009
- entityEditorDirty = false;
9010
- await loadAiReferences();
9011
- await closeEntityEditor({ force: true });
9012
- toast(item ? "设定新版本已保存" : "设定已创建");
9013
- } catch (error) {
9014
- toast(error.message, "error");
9015
- } finally {
9016
- submit.disabled = false;
9017
- }
9571
+ });
9018
9572
  };
9019
9573
  showEntityEditorPage("setting", { readOnly });
9574
+ settingEditorDirtyTracker.markSaved(settingEditorSnapshot(item?.content ?? ""));
9020
9575
  settingEditorVditor = createVditorEditor($("#setting-editor-markdown"), item?.content ?? "", {
9021
- onInput: (markdown) => { $("#setting-editor-body").value = markdown; markEntityEditorDirty(); },
9576
+ onInput: (markdown) => syncSettingEditorDirty(markdown),
9022
9577
  readOnly: viewOnly
9023
9578
  });
9024
9579
  (readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
@@ -9172,6 +9727,7 @@ function markdownImageLabel(file, fallback = "图片附件") {
9172
9727
  }
9173
9728
 
9174
9729
  async function uploadMarkdownAttachment(file, module = "settings") {
9730
+ assertAttachmentFileSize(file);
9175
9731
  const body = new FormData();
9176
9732
  body.append("file", file);
9177
9733
  const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
@@ -9479,6 +10035,7 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
9479
10035
  }
9480
10036
 
9481
10037
  async function uploadCharacterSectionAttachment(file) {
10038
+ assertAttachmentFileSize(file);
9482
10039
  const body = new FormData();
9483
10040
  body.append("file", file);
9484
10041
  const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
@@ -9490,6 +10047,10 @@ async function uploadCharacterSectionAttachment(file) {
9490
10047
  }
9491
10048
 
9492
10049
  async function closeCharacterSectionEditor({ force = false } = {}) {
10050
+ if (isEntityEditorSaving()) {
10051
+ toast("正在保存,请稍候");
10052
+ return false;
10053
+ }
9493
10054
  if (!force && characterSectionEditorDirty && !(await confirmToast(
9494
10055
  "当前 Markdown 章节有未保存修改,返回人物档案将丢弃这些修改。是否继续?",
9495
10056
  { title: "放弃未保存修改", confirmLabel: "放弃并继续", cancelLabel: "继续编辑" }
@@ -9522,6 +10083,10 @@ function knowledgeSectionEditorHtml(section = null) {
9522
10083
  }
9523
10084
 
9524
10085
  async function closeKnowledgeSectionEditor({ force = false } = {}) {
10086
+ if (isEntityEditorSaving()) {
10087
+ toast("正在保存,请稍候");
10088
+ return false;
10089
+ }
9525
10090
  if (!force && knowledgeSectionEditorDirty && !(await confirmToast(
9526
10091
  "当前 Markdown 设定有未保存修改,返回设定列表将丢弃这些修改。是否继续?",
9527
10092
  { title: "放弃未保存修改", confirmLabel: "放弃并继续", cancelLabel: "继续编辑" }
@@ -9540,6 +10105,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
9540
10105
 
9541
10106
  async function openKnowledgeSectionEditor(index = null) {
9542
10107
  if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
10108
+ const label = knowledgeEditorKind === "race" ? "种族" : "组织";
9543
10109
  destroyVditorEditor(knowledgeSectionVditor);
9544
10110
  knowledgeSectionVditor = null;
9545
10111
  const section = Number.isInteger(index) ? knowledgeEditorSections[index] : null;
@@ -9562,21 +10128,45 @@ async function openKnowledgeSectionEditor(index = null) {
9562
10128
  host.querySelector("[data-knowledge-section-edit-cancel]").addEventListener("click", () => void closeKnowledgeSectionEditor());
9563
10129
  host.querySelector("[data-knowledge-section-edit-save]").addEventListener("click", async (event) => {
9564
10130
  const button = event.currentTarget;
9565
- const wasEditing = knowledgeSectionEditorIndex !== null;
9566
- const title = titleInput.value.trim();
9567
- if (!title) {
9568
- toast("请填写设定标题", "error");
9569
- titleInput.focus();
9570
- return;
9571
- }
9572
- button.disabled = true;
9573
- const nextSection = { title, contentMarkdown: knowledgeSectionVditor?.getValue() ?? "", summary: String(section?.summary ?? ""), sortOrder: knowledgeSectionEditorIndex ?? knowledgeEditorSections.length };
9574
- if (knowledgeSectionEditorIndex === null) knowledgeEditorSections.push(nextSection);
9575
- else knowledgeEditorSections[knowledgeSectionEditorIndex] = nextSection;
9576
- knowledgeEditorSections.forEach((item, sortOrder) => { item.sortOrder = sortOrder; });
9577
- markEntityEditorDirty();
9578
- await closeKnowledgeSectionEditor({ force: true });
9579
- toast(wasEditing ? "设定已更新" : "设定已添加");
10131
+ await runEntityEditorSave({
10132
+ busyTarget: $("#knowledge-section-editor-view"),
10133
+ button,
10134
+ prepare: async () => {
10135
+ const title = titleInput.value.trim();
10136
+ if (!title) {
10137
+ toast("请填写设定标题", "error");
10138
+ titleInput.focus();
10139
+ return null;
10140
+ }
10141
+ return {
10142
+ title,
10143
+ contentMarkdown: knowledgeSectionVditor?.getValue() ?? "",
10144
+ targetIndex: knowledgeSectionEditorIndex
10145
+ };
10146
+ },
10147
+ save: async ({ title, contentMarkdown, targetIndex }) => {
10148
+ const currentSection = targetIndex === null ? null : knowledgeEditorSections[targetIndex];
10149
+ const nextSection = {
10150
+ title,
10151
+ contentMarkdown,
10152
+ summary: String(currentSection?.summary ?? ""),
10153
+ sortOrder: targetIndex ?? knowledgeEditorSections.length
10154
+ };
10155
+ if (targetIndex === null) {
10156
+ knowledgeEditorSections.push(nextSection);
10157
+ knowledgeSectionEditorIndex = knowledgeEditorSections.length - 1;
10158
+ } else {
10159
+ knowledgeEditorSections[targetIndex] = nextSection;
10160
+ }
10161
+ knowledgeEditorSections.forEach((item, sortOrder) => { item.sortOrder = sortOrder; });
10162
+ knowledgeSectionEditorDirty = false;
10163
+ markEntityEditorDirty();
10164
+ return {
10165
+ buttonLabel: "保存设定",
10166
+ message: `Markdown 设定保存成功,保存${label}档案后生效`
10167
+ };
10168
+ }
10169
+ });
9580
10170
  });
9581
10171
  titleInput.focus();
9582
10172
  }
@@ -9585,7 +10175,7 @@ function characterSectionEditorHtml(section = null) {
9585
10175
  const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
9586
10176
  return `<div class="character-section-editor-shell">
9587
10177
  <header class="character-section-editor-header">
9588
- <div><span class="eyebrow">人物 Markdown 档案</span><input id="character-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建档案章节" aria-label="章节标题" required></div>
10178
+ <div><span id="character-section-editor-version" class="eyebrow">人物 Markdown 档案${section ? ` · v${esc(section.versionNo)}` : ""}</span><input id="character-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建档案章节" aria-label="章节标题" required></div>
9589
10179
  <button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
9590
10180
  </header>
9591
10181
  <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
@@ -9602,19 +10192,45 @@ function characterSectionEditorHtml(section = null) {
9602
10192
  </div>`;
9603
10193
  }
9604
10194
 
10195
+ function upsertCharacterEditorSection(saved) {
10196
+ const index = characterEditorSections.findIndex((item) => item.id === saved.id);
10197
+ if (index >= 0) characterEditorSections[index] = saved;
10198
+ else characterEditorSections.push(saved);
10199
+ characterEditorSections.sort((left, right) => Number(left.sortOrder ?? 0) - Number(right.sortOrder ?? 0));
10200
+ renderCharacterMarkdownSections();
10201
+ }
10202
+
10203
+ function characterSectionEditorSnapshot(markdown = null) {
10204
+ const contentMarkdown = markdown ?? characterSectionVditor?.getValue() ?? "";
10205
+ return JSON.stringify({
10206
+ sectionType: $("#character-section-type").value,
10207
+ title: $("#character-section-title").value,
10208
+ summary: $("#character-section-summary").value,
10209
+ contentMarkdown,
10210
+ changeNote: $("#character-section-change-note").value
10211
+ });
10212
+ }
10213
+
10214
+ function syncCharacterSectionEditorDirty(markdown = null) {
10215
+ const currentMarkdown = characterSectionVditor?.getValue() ?? markdown ?? "";
10216
+ characterSectionEditorDirty = characterSectionEditorDirtyTracker.isDirty(characterSectionEditorSnapshot(currentMarkdown));
10217
+ }
10218
+
9605
10219
  async function openCharacterSectionEditor(section = null) {
9606
10220
  await discardPendingCharacterAttachments();
9607
10221
  destroyVditorEditor(characterSectionVditor);
9608
10222
  characterSectionVditor = null;
10223
+ let currentSection = section;
9609
10224
  const host = $("#character-section-editor-host");
9610
- host.innerHTML = characterSectionEditorHtml(section);
10225
+ host.innerHTML = characterSectionEditorHtml(currentSection);
10226
+ characterSectionEditorDirtyTracker.markSaved(characterSectionEditorSnapshot(currentSection?.contentMarkdown ?? ""));
9611
10227
  characterSectionEditorDirty = false;
9612
10228
  $("#character-editor-form").classList.add("hidden");
9613
10229
  $("#character-section-editor-view").classList.remove("hidden");
9614
- host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
9615
- characterSectionVditor = createVditorEditor($("#character-section-markdown"), section?.contentMarkdown ?? "", {
10230
+ host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => syncCharacterSectionEditorDirty()));
10231
+ characterSectionVditor = createVditorEditor($("#character-section-markdown"), currentSection?.contentMarkdown ?? "", {
9616
10232
  uploadAttachment: uploadCharacterSectionAttachment,
9617
- onInput: () => { characterSectionEditorDirty = true; },
10233
+ onInput: (markdown) => syncCharacterSectionEditorDirty(markdown),
9618
10234
  placeholder: "从这里开始写人物章节…",
9619
10235
  width: "100%"
9620
10236
  });
@@ -9622,43 +10238,50 @@ async function openCharacterSectionEditor(section = null) {
9622
10238
  host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
9623
10239
  host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
9624
10240
  const button = event.currentTarget;
9625
- const title = $("#character-section-title").value.trim();
9626
- if (!title) {
9627
- toast("请填写章节标题", "error");
9628
- $("#character-section-title").focus();
9629
- return;
9630
- }
9631
- button.disabled = true;
9632
- const contentMarkdown = characterSectionVditor?.getValue() ?? "";
9633
- try {
9634
- if (!(await confirmConcurrentSave())) {
9635
- button.disabled = false;
9636
- return;
9637
- }
9638
- const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
9639
- method: section ? "PATCH" : "POST",
9640
- body: {
9641
- sectionType: $("#character-section-type").value,
9642
- title,
9643
- summary: $("#character-section-summary").value.trim(),
9644
- contentMarkdown,
9645
- ...(section ? { changeNote: $("#character-section-change-note").value.trim() } : {})
10241
+ await runEntityEditorSave({
10242
+ busyTarget: $("#character-section-editor-view"),
10243
+ button,
10244
+ prepare: async () => {
10245
+ const title = $("#character-section-title").value.trim();
10246
+ if (!title) {
10247
+ toast("请填写章节标题", "error");
10248
+ $("#character-section-title").focus();
10249
+ return null;
9646
10250
  }
9647
- });
9648
- const referenced = new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]+)/gu)].map((match) => String(match[1])));
9649
- const unused = characterSectionPendingAttachments.filter((attachmentId) => !referenced.has(attachmentId));
9650
- characterSectionPendingAttachments = [];
9651
- await Promise.all(unused.map((attachmentId) => api(`/api/attachments/${attachmentId}`, { method: "DELETE" }).catch(() => null)));
9652
- characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
9653
- renderCharacterMarkdownSections();
9654
- await Promise.all([renderCharacters(), loadAiReferences()]);
9655
- characterSectionEditorDirty = false;
9656
- await closeCharacterSectionEditor({ force: true });
9657
- toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
9658
- } catch (error) {
9659
- toast(error.message, "error");
9660
- button.disabled = false;
9661
- }
10251
+ if (!(await confirmConcurrentSave())) return null;
10252
+ return {
10253
+ title,
10254
+ contentMarkdown: characterSectionVditor?.getValue() ?? "",
10255
+ editingSection: currentSection
10256
+ };
10257
+ },
10258
+ save: async ({ title, contentMarkdown, editingSection }) => {
10259
+ const saved = await api(editingSection ? `/api/character-sections/${editingSection.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
10260
+ method: editingSection ? "PATCH" : "POST",
10261
+ body: {
10262
+ sectionType: $("#character-section-type").value,
10263
+ title,
10264
+ summary: $("#character-section-summary").value.trim(),
10265
+ contentMarkdown,
10266
+ ...(editingSection ? { changeNote: $("#character-section-change-note").value.trim() } : {})
10267
+ }
10268
+ });
10269
+ const referenced = new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]+)/gu)].map((match) => String(match[1])));
10270
+ const unused = characterSectionPendingAttachments.filter((attachmentId) => !referenced.has(attachmentId));
10271
+ characterSectionPendingAttachments = [];
10272
+ await Promise.all(unused.map((attachmentId) => api(`/api/attachments/${attachmentId}`, { method: "DELETE" }).catch(() => null)));
10273
+ currentSection = saved;
10274
+ upsertCharacterEditorSection(saved);
10275
+ $("#character-section-change-note").value = "";
10276
+ $("#character-section-editor-version").textContent = `人物 Markdown 档案 · v${saved.versionNo}`;
10277
+ characterSectionEditorDirtyTracker.markSaved(characterSectionEditorSnapshot(contentMarkdown));
10278
+ characterSectionEditorDirty = false;
10279
+ return {
10280
+ buttonLabel: "保存章节版本",
10281
+ message: editingSection ? `“${saved.title}”已保存为 v${saved.versionNo}` : `“${saved.title}”已创建为 v${saved.versionNo}`
10282
+ };
10283
+ }
10284
+ });
9662
10285
  });
9663
10286
  }
9664
10287
 
@@ -9918,25 +10541,26 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9918
10541
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
9919
10542
  const characterMergeButton = $("#character-merge-button");
9920
10543
  const characterDeleteButton = $("#character-delete-button");
9921
- const canManageCharacter = Boolean(item && !readOnly && canEditModule("characters"));
10544
+ const canManageCharacter = Boolean(characterEditorItem && !readOnly && canEditModule("characters"));
9922
10545
  characterMergeButton.classList.toggle("hidden", !canManageCharacter);
9923
10546
  characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
9924
10547
  characterMergeButton.onclick = async () => {
9925
- if (!item) return;
10548
+ const currentItem = characterEditorItem;
10549
+ if (!currentItem) return;
9926
10550
  const workId = state.work?.id;
9927
10551
  if (!workId) return;
9928
10552
  try {
9929
10553
  const candidates = await moduleApiAllPages("characters", `/api/works/${workId}/characters`);
9930
- if (state.work?.id !== workId || characterEditorItem?.id !== item.id) return;
10554
+ if (state.work?.id !== workId || characterEditorItem?.id !== currentItem.id) return;
9931
10555
  state.characters = candidates;
9932
10556
  if (candidates.length < 2) return toast("至少需要两个角色才能合并", "error");
9933
10557
  await closeEntityEditor({ force: true });
9934
10558
  openEntityMergeDialog({
9935
10559
  typeLabel: "角色",
9936
- source: item,
10560
+ source: currentItem,
9937
10561
  candidates,
9938
10562
  endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
9939
- body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
10563
+ body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: currentItem.versionNo }),
9940
10564
  refresh: renderCharacters,
9941
10565
  impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
9942
10566
  });
@@ -9945,10 +10569,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9945
10569
  }
9946
10570
  };
9947
10571
  characterDeleteButton.onclick = () => {
9948
- if (!item) return;
10572
+ const currentItem = characterEditorItem;
10573
+ if (!currentItem) return;
9949
10574
  void deleteManagedEntity({
9950
10575
  typeLabel: "角色",
9951
- item,
10576
+ item: currentItem,
9952
10577
  endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
9953
10578
  refresh: () => closeEntityEditor({ force: true }),
9954
10579
  warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
@@ -9967,7 +10592,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9967
10592
  $("#character-editor-submit").classList.toggle("hidden", viewOnly);
9968
10593
  const editButton = $("#character-editor-edit");
9969
10594
  editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
9970
- editButton.onclick = () => void openCharacterEditor(item);
10595
+ editButton.onclick = () => void openCharacterEditor(characterEditorItem);
9971
10596
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
9972
10597
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
9973
10598
  });
@@ -9979,24 +10604,46 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
9979
10604
  event.preventDefault();
9980
10605
  if (readOnly || !canEditModule("characters")) return;
9981
10606
  const submit = $("#character-editor-submit");
9982
- submit.disabled = true;
9983
- try {
9984
- const body = collectCharacterBody(new FormData(form));
9985
- if (!body.name) throw new Error("请填写角色标准名");
9986
- const wasEditing = Boolean(characterEditorItem);
9987
- const previousVersion = characterEditorItem?.versionNo;
9988
- if (!wasEditing) delete body.changeNote;
9989
- if (!(await confirmConcurrentSave())) return;
9990
- const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
9991
- entityEditorDirty = false;
9992
- await loadAiReferences();
9993
- await closeEntityEditor({ force: true });
9994
- toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
9995
- } catch (error) {
9996
- toast(error.message, "error");
9997
- } finally {
9998
- submit.disabled = false;
9999
- }
10607
+ await runEntityEditorSave({
10608
+ busyTarget: form,
10609
+ button: submit,
10610
+ prepare: async () => {
10611
+ const body = collectCharacterBody(new FormData(form));
10612
+ if (!body.name) {
10613
+ toast("请填写角色标准名", "error");
10614
+ form.querySelector("[name='name']")?.focus();
10615
+ return null;
10616
+ }
10617
+ const currentItem = characterEditorItem;
10618
+ if (!currentItem) delete body.changeNote;
10619
+ if (!(await confirmConcurrentSave())) return null;
10620
+ return { body, currentItem, previousVersion: currentItem?.versionNo };
10621
+ },
10622
+ save: async ({ body, currentItem, previousVersion }) => {
10623
+ const saved = await api(currentItem ? `/api/characters/${currentItem.id}` : `/api/works/${state.work.id}/characters`, { method: currentItem ? "PATCH" : "POST", body });
10624
+ characterEditorItem = saved;
10625
+ state.characters = upsertEntityCollection(state.characters, saved);
10626
+ entityEditorDirty = false;
10627
+ $("#character-editor-title").textContent = saved.name;
10628
+ $("#character-editor-version").textContent = `v${saved.versionNo}`;
10629
+ $("#character-change-note").value = "";
10630
+ $("#character-history-button").disabled = false;
10631
+ $("#character-history-button").title = "查看、比较和回滚历史版本";
10632
+ characterMergeButton.classList.remove("hidden");
10633
+ characterDeleteButton.classList.remove("hidden");
10634
+ relationshipTab.disabled = !canReadModule("relationships");
10635
+ relationshipTab.title = canReadModule("relationships") ? "查看和编辑人物关系" : "当前账户没有关系模块读取权限";
10636
+ renderCharacterMarkdownSections();
10637
+ renderAiRoleplayCharacterSelect();
10638
+ replacePageRoute(currentPageRoute());
10639
+ const message = !currentItem
10640
+ ? `人物档案已创建为 v${saved.versionNo}`
10641
+ : saved.versionNo === previousVersion
10642
+ ? `人物档案没有变更,当前为 v${saved.versionNo}`
10643
+ : `人物档案已保存为 v${saved.versionNo}`;
10644
+ return { buttonLabel: "保存新版本", message };
10645
+ }
10646
+ });
10000
10647
  };
10001
10648
  showEntityEditorPage("character", { readOnly });
10002
10649
  if (item) {
@@ -10031,7 +10678,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
10031
10678
  + field("description", `${label}简介`, "textarea", item?.description, []);
10032
10679
  const memberField = memberOptions.length
10033
10680
  ? field("memberIds", isRace ? "属于该种族的角色(可多选)" : "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions)
10034
- : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${readOnly ? "该档案尚未绑定角色。" : "当前还没有可绑定的角色。"}</span></div>`;
10681
+ : `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${entityEditorReadOnly ? "该档案尚未绑定角色。" : "当前还没有可绑定的角色。"}</span></div>`;
10035
10682
  $("#knowledge-editor-fields").innerHTML = knowledgeEditorSection("basic", "基础资料", isRace ? "先定义名称、层级和简介,再补充共同设定。" : "先定义组织名称和简介,再补充完整的组织设定。", basicFields)
10036
10683
  + knowledgeEditorSection("settings", title, "", '<div id="knowledge-markdown-sections" class="knowledge-markdown-sections"></div>')
10037
10684
  + knowledgeEditorSection("members", isRace ? "种族成员" : "组织成员", "成员关系会同步到角色档案中。", memberField);
@@ -10056,6 +10703,8 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
10056
10703
  const isRace = kind === "race";
10057
10704
  const module = isRace ? "races" : "organizations";
10058
10705
  const label = isRace ? "种族" : "组织";
10706
+ const viewOnly = readOnly || !canEditModule(module);
10707
+ entityEditorReadOnly = viewOnly;
10059
10708
  const parentOptions = isRace
10060
10709
  ? [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
10061
10710
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -10066,32 +10715,39 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
10066
10715
  knowledgeEditorSections = normalizeKnowledgeEditorSections(item);
10067
10716
  knowledgeSectionEditorIndex = null;
10068
10717
  knowledgeSectionEditorDirty = false;
10069
- $("#knowledge-editor-eyebrow").textContent = item ? `${label}档案` : `建立${label}档案`;
10070
- $("#knowledge-editor-title").textContent = item?.name || `新建${label}`;
10071
- $("#knowledge-editor-version").textContent = item ? `v${item.versionNo ?? 1}` : "新档案";
10072
10718
  $("#knowledge-editor-header-note").textContent = isRace ? "层级、共同设定与角色归属" : "简介、组织设定与成员归属";
10073
- $("#knowledge-editor-footer-note").textContent = `保存${label}档案后返回${isRace ? "种族" : "组织"}列表。`;
10074
- $("#knowledge-editor-submit").textContent = item ? `保存${label}档案` : `创建${label}档案`;
10075
10719
  const historyButton = $("#knowledge-editor-history");
10076
10720
  const mergeButton = $("#knowledge-editor-merge");
10077
10721
  const deleteButton = $("#knowledge-editor-delete");
10078
10722
  const refresh = isRace ? renderRaces : renderOrganizations;
10079
10723
  const candidates = isRace ? state.races : state.organizations;
10080
10724
  const typeLabel = label;
10081
- historyButton.classList.toggle("hidden", !item);
10082
- mergeButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module) || candidates.length < 2);
10083
- deleteButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module));
10725
+ const syncKnowledgeEditorChrome = () => {
10726
+ const currentItem = knowledgeEditorItem;
10727
+ $("#knowledge-editor-eyebrow").textContent = viewOnly ? `阅读${label}档案` : currentItem ? `${label}档案` : `建立${label}档案`;
10728
+ $("#knowledge-editor-title").textContent = currentItem?.name || `新建${label}`;
10729
+ $("#knowledge-editor-version").textContent = currentItem ? `v${currentItem.versionNo ?? 1}` : "新档案";
10730
+ $("#knowledge-editor-footer-note").textContent = `保存${label}档案后继续留在当前页面,可使用“返回列表”离开。`;
10731
+ $("#knowledge-editor-submit").textContent = currentItem ? `保存${label}档案` : `创建${label}档案`;
10732
+ $("#knowledge-editor-submit").classList.toggle("hidden", viewOnly);
10733
+ const hasMergeCandidate = candidates.some((candidate) => candidate.id !== currentItem?.id);
10734
+ historyButton.classList.toggle("hidden", !currentItem);
10735
+ mergeButton.classList.toggle("hidden", !currentItem || viewOnly || !hasMergeCandidate);
10736
+ deleteButton.classList.toggle("hidden", !currentItem || viewOnly);
10737
+ };
10084
10738
  historyButton.onclick = async () => {
10085
- if (!item) return;
10739
+ const currentItem = knowledgeEditorItem;
10740
+ if (!currentItem) return;
10086
10741
  if (!(await closeEntityEditor())) return;
10087
- openEntityHistory(kind, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
10742
+ openEntityHistory(kind, currentItem.id, currentItem.name, async () => { await refresh(); await loadAiReferences(); });
10088
10743
  };
10089
10744
  mergeButton.onclick = async () => {
10090
- if (!item) return;
10745
+ const currentItem = knowledgeEditorItem;
10746
+ if (!currentItem) return;
10091
10747
  await closeEntityEditor({ force: true });
10092
10748
  openEntityMergeDialog({
10093
10749
  typeLabel,
10094
- source: item,
10750
+ source: currentItem,
10095
10751
  candidates,
10096
10752
  endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}/merge`,
10097
10753
  body: (target) => isRace ? { targetRaceId: target.id } : { targetOrganizationId: target.id },
@@ -10100,61 +10756,73 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
10100
10756
  });
10101
10757
  };
10102
10758
  deleteButton.onclick = () => {
10103
- if (!item) return;
10759
+ const currentItem = knowledgeEditorItem;
10760
+ if (!currentItem) return;
10104
10761
  void deleteManagedEntity({
10105
10762
  typeLabel,
10106
- item,
10763
+ item: currentItem,
10107
10764
  endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}`,
10108
10765
  refresh: () => closeEntityEditor({ force: true }),
10109
10766
  warning: isRace ? "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。" : "角色与该组织的成员关系会一并移除。"
10110
10767
  });
10111
10768
  };
10112
10769
  renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
10113
- const viewOnly = readOnly || !canEditModule(module);
10114
10770
  if (viewOnly) {
10115
- $("#knowledge-editor-eyebrow").textContent = readOnly ? `阅读${label}档案` : `${label}档案`;
10116
10771
  $("#knowledge-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
10117
10772
  $("#knowledge-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
10118
10773
  $("#knowledge-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
10119
10774
  }
10120
- $("#knowledge-editor-submit").classList.toggle("hidden", viewOnly);
10775
+ syncKnowledgeEditorChrome();
10121
10776
  const editButton = $("#knowledge-editor-edit");
10122
10777
  editButton.textContent = `编辑${label}`;
10123
10778
  editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
10124
- editButton.onclick = () => void openKnowledgeEditor(kind, item);
10779
+ editButton.onclick = () => void openKnowledgeEditor(kind, knowledgeEditorItem);
10125
10780
  const form = $("#knowledge-editor-form");
10126
10781
  form.onsubmit = async (event) => {
10127
10782
  event.preventDefault();
10128
10783
  if (readOnly || !canEditModule(module)) return;
10129
10784
  const submit = $("#knowledge-editor-submit");
10130
- submit.disabled = true;
10131
- try {
10132
- const data = new FormData(form);
10133
- const name = String(data.get("name") ?? "").trim();
10134
- if (!name) throw new Error(`请填写${label}名称`);
10135
- const settingsSections = knowledgeEditorSections
10136
- .map((section, index) => ({ title: String(section.title ?? "").trim(), contentMarkdown: String(section.contentMarkdown ?? ""), summary: String(section.summary ?? "").trim(), sortOrder: index }))
10137
- .filter((section) => section.title || section.contentMarkdown.trim());
10138
- const untitled = settingsSections.findIndex((section) => !section.title);
10139
- if (untitled >= 0) throw new Error(`请填写第 ${untitled + 1} 条 Markdown 设定的标题`);
10140
- const settingsMarkdown = settingsSections.map((section) => section.contentMarkdown).join("\n\n");
10141
- const body = isRace
10142
- ? { name, isExtinct: data.has("isExtinct"), parentRaceId: data.get("parentRaceId") || null, description: data.get("description"), settingsMarkdown, settingsSections }
10143
- : { name, isDissolved: data.has("isDissolved"), description: data.get("description"), settingsMarkdown, settingsSections };
10144
- if (canReadModule("characters")) body.memberIds = data.getAll("memberIds").map(String);
10145
- const wasEditing = Boolean(knowledgeEditorItem);
10146
- if (!(await confirmConcurrentSave())) return;
10147
- await api(wasEditing ? `/api/${module}/${knowledgeEditorItem.id}` : `/api/works/${state.work.id}/${module}`, { method: wasEditing ? "PATCH" : "POST", body });
10148
- await cleanupPendingMarkdownAttachments(settingsMarkdown);
10149
- entityEditorDirty = false;
10150
- await loadAiReferences();
10151
- await closeEntityEditor({ force: true });
10152
- toast(wasEditing ? `${label}档案已保存` : `${label}档案已创建`);
10153
- } catch (error) {
10154
- toast(error.message, "error");
10155
- } finally {
10156
- submit.disabled = false;
10157
- }
10785
+ await runEntityEditorSave({
10786
+ busyTarget: form,
10787
+ button: submit,
10788
+ prepare: async () => {
10789
+ const data = new FormData(form);
10790
+ const name = String(data.get("name") ?? "").trim();
10791
+ if (!name) {
10792
+ toast(`请填写${label}名称`, "error");
10793
+ form.querySelector("[name='name']")?.focus();
10794
+ return null;
10795
+ }
10796
+ const settingsSections = knowledgeEditorSections
10797
+ .map((section, index) => ({ title: String(section.title ?? "").trim(), contentMarkdown: String(section.contentMarkdown ?? ""), summary: String(section.summary ?? "").trim(), sortOrder: index }))
10798
+ .filter((section) => section.title || section.contentMarkdown.trim());
10799
+ const untitled = settingsSections.findIndex((section) => !section.title);
10800
+ if (untitled >= 0) {
10801
+ toast(`请填写第 ${untitled + 1} 条 Markdown 设定的标题`, "error");
10802
+ return null;
10803
+ }
10804
+ const settingsMarkdown = settingsSections.map((section) => section.contentMarkdown).join("\n\n");
10805
+ const body = isRace
10806
+ ? { name, isExtinct: data.has("isExtinct"), parentRaceId: data.get("parentRaceId") || null, description: data.get("description"), settingsMarkdown, settingsSections }
10807
+ : { name, isDissolved: data.has("isDissolved"), description: data.get("description"), settingsMarkdown, settingsSections };
10808
+ if (canReadModule("characters")) body.memberIds = data.getAll("memberIds").map(String);
10809
+ const currentItem = knowledgeEditorItem;
10810
+ if (!(await confirmConcurrentSave())) return null;
10811
+ return { body, currentItem, settingsMarkdown };
10812
+ },
10813
+ save: async ({ body, currentItem, settingsMarkdown }) => {
10814
+ const saved = await api(currentItem ? `/api/${module}/${currentItem.id}` : `/api/works/${state.work.id}/${module}`, { method: currentItem ? "PATCH" : "POST", body });
10815
+ await cleanupPendingMarkdownAttachments(settingsMarkdown);
10816
+ knowledgeEditorItem = saved;
10817
+ entityEditorDirty = false;
10818
+ syncKnowledgeEditorChrome();
10819
+ replacePageRoute(currentPageRoute());
10820
+ return {
10821
+ buttonLabel: `保存${label}档案`,
10822
+ message: currentItem ? `${label}档案已保存为 v${saved.versionNo}` : `${label}档案已创建为 v${saved.versionNo}`
10823
+ };
10824
+ }
10825
+ });
10158
10826
  };
10159
10827
  showEntityEditorPage(kind, { readOnly });
10160
10828
  (readOnly ? $("#knowledge-editor-close") : $("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea"))?.focus();
@@ -10924,7 +11592,7 @@ async function sendAi() {
10924
11592
  || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
10925
11593
  || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
10926
11594
  if (suggestionFailed) setAiAssistantStatus("error");
10927
- setAiContextMeter(suggestion.contextUsage);
11595
+ setAiContextMeter(suggestion.contextUsage, false);
10928
11596
  assistantContent = suggestion.content;
10929
11597
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
10930
11598
  }
@@ -10998,6 +11666,7 @@ async function streamChat(body) {
10998
11666
  let conversationTitle = null;
10999
11667
  let persistedUserMessage = null;
11000
11668
  let contextAction = "ready";
11669
+ let streamContextCompacted = false;
11001
11670
  let finalAnswerStarted = false;
11002
11671
  const processStartedAt = Date.now();
11003
11672
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -11049,7 +11718,7 @@ async function streamChat(body) {
11049
11718
  const payload = JSON.parse(dataLines.join("\n"));
11050
11719
  if (eventName === "context") {
11051
11720
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
11052
- setAiContextMeter(payload.usage);
11721
+ if (!state.aiPromptSent) setAiContextMeter(payload.usage);
11053
11722
  if (payload.conversation?.id) {
11054
11723
  state.aiConversationId = payload.conversation.id;
11055
11724
  upsertAiConversationSummary(payload.conversation);
@@ -11072,7 +11741,7 @@ async function streamChat(body) {
11072
11741
  syncAiTaskOptions();
11073
11742
  renderAiRoleplayCharacterSelect();
11074
11743
  renderAiQuickActions();
11075
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
11744
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id);
11076
11745
  clearAiPromptComposer();
11077
11746
  mountAssistantMessage();
11078
11747
  }
@@ -11116,6 +11785,7 @@ async function streamChat(body) {
11116
11785
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
11117
11786
  scrollAiFeedToBottom();
11118
11787
  } else if (eventName === "context_compacted") {
11788
+ streamContextCompacted = true;
11119
11789
  setAiContextMeter(payload.contextUsage);
11120
11790
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
11121
11791
  } else if (eventName === "complete") {
@@ -11123,7 +11793,8 @@ async function streamChat(body) {
11123
11793
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
11124
11794
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
11125
11795
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
11126
- setAiContextMeter(payload.contextUsage);
11796
+ const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
11797
+ setAiContextMeter(payload.contextUsage, announcedCompaction);
11127
11798
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
11128
11799
  message.classList.remove("is-streaming");
11129
11800
  content.setAttribute("aria-busy", "false");
@@ -11188,6 +11859,24 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
11188
11859
  failureBadge.setAttribute("aria-label", "消息状态:失败");
11189
11860
  heading.firstElementChild?.append(failureBadge);
11190
11861
  }
11862
+ const mentionNames = role === "user"
11863
+ ? userMessageMentionNames(metadata?.mentionCharacterIds, state.characters)
11864
+ : [];
11865
+ if (mentionNames.length) {
11866
+ const references = document.createElement("div");
11867
+ references.className = "user-message-mentions";
11868
+ const label = document.createElement("span");
11869
+ label.className = "user-message-mentions-label";
11870
+ label.textContent = "引用角色";
11871
+ references.append(label);
11872
+ for (const name of mentionNames) {
11873
+ const reference = document.createElement("span");
11874
+ reference.className = "user-message-mention";
11875
+ reference.textContent = name;
11876
+ references.append(reference);
11877
+ }
11878
+ message.append(references);
11879
+ }
11191
11880
  if (citations.length) {
11192
11881
  const references = document.createElement("div");
11193
11882
  references.className = "message-citations";
@@ -11827,8 +12516,8 @@ function exportAvatarCropBlob() {
11827
12516
  $("#avatar-file").addEventListener("change", async (event) => {
11828
12517
  const file = event.target.files[0];
11829
12518
  if (!file) return;
11830
- if (file.size > maximumAvatarFileSize) {
11831
- toast("头像文件不能超过 5 MB", "error");
12519
+ if (file.size > imageUploadLimits.avatarBytes) {
12520
+ toast(`头像图片不能超过 ${formatUploadLimit(imageUploadLimits.avatarBytes)}`, "error");
11832
12521
  event.target.value = "";
11833
12522
  return;
11834
12523
  }
@@ -12081,6 +12770,7 @@ $("#login-form").addEventListener("submit", async (event) => {
12081
12770
  captchaAnswer: form.get("captchaAnswer")
12082
12771
  }
12083
12772
  });
12773
+ if (systemRestartDetected) stagePresenceClientIdForRelogin(presenceSessionStorage, presenceClientId);
12084
12774
  window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
12085
12775
  window.location.reload();
12086
12776
  } catch (error) {
@@ -12171,6 +12861,15 @@ $("#platform-ui-settings-return").addEventListener("click", () => returnToSettin
12171
12861
  $("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
12172
12862
  $("#s3-backup-close").addEventListener("click", () => $("#s3-backup-dialog").close());
12173
12863
  $("#s3-backup-settings-return").addEventListener("click", () => returnToSettingsHub("#s3-backup-button", "#s3-backup-dialog").catch((error) => toast(error.message, "error")));
12864
+ $("#s3-backup-encryption-toggle").addEventListener("change", (event) => void changeS3BackupEncryption(event));
12865
+ $("#s3-backup-key-copy").addEventListener("click", () => void copyS3BackupEncryptionKey());
12866
+ $("#s3-backup-key-download").addEventListener("click", downloadS3BackupEncryptionKey);
12867
+ $("#s3-backup-key-confirm").addEventListener("click", () => void confirmS3BackupEncryptionKeySaved());
12868
+ $("#s3-backup-key-dialog").addEventListener("cancel", (event) => event.preventDefault());
12869
+ $("#s3-backup-key-dialog").addEventListener("close", () => {
12870
+ s3BackupEncryptionConfirmationToken = null;
12871
+ $("#s3-backup-key-value").value = "";
12872
+ });
12174
12873
  $("#s3-backup-add").addEventListener("click", () => openS3BackupTargetDialog());
12175
12874
  $("#s3-backup-run-all").addEventListener("click", (event) => void queueS3BackupRuns(null, event.currentTarget));
12176
12875
  $("#s3-backup-refresh").addEventListener("click", async (event) => {
@@ -12308,8 +13007,8 @@ $("#character-editor-close").addEventListener("click", () => { void closeEntityE
12308
13007
  $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
12309
13008
  $("#knowledge-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
12310
13009
  $("#knowledge-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
12311
- $("#setting-editor-form").addEventListener("input", markEntityEditorDirty);
12312
- $("#setting-editor-form").addEventListener("change", markEntityEditorDirty);
13010
+ $("#setting-editor-form").addEventListener("input", () => syncSettingEditorDirty());
13011
+ $("#setting-editor-form").addEventListener("change", () => syncSettingEditorDirty());
12313
13012
  $("#character-editor-form").addEventListener("input", markEntityEditorDirty);
12314
13013
  $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
12315
13014
  $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
@@ -12357,7 +13056,7 @@ $("#chapter-content").addEventListener("input", (event) => {
12357
13056
  updateChapterStats();
12358
13057
  scheduleChapterAutoSave();
12359
13058
  clearChapterLineSelection();
12360
- scheduleChapterLineNumbers();
13059
+ scheduleChapterLineNumbers(chapterLineInputRenderDelay);
12361
13060
  setAiContextMeter(null);
12362
13061
  });
12363
13062
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
@@ -12587,6 +13286,18 @@ $("#cover-file").addEventListener("change", async (event) => {
12587
13286
  const file = event.target.files[0];
12588
13287
  const workId = state.pendingCoverWorkId;
12589
13288
  if (!file || !workId) return;
13289
+ if (isGifImageFile(file)) {
13290
+ toast("封面不支持 GIF 图片", "error");
13291
+ state.pendingCoverWorkId = null;
13292
+ event.target.value = "";
13293
+ return;
13294
+ }
13295
+ if (file.size > imageUploadLimits.coverBytes) {
13296
+ toast(coverFileSizeMessage(file), "error");
13297
+ state.pendingCoverWorkId = null;
13298
+ event.target.value = "";
13299
+ return;
13300
+ }
12590
13301
  const body = new FormData();
12591
13302
  body.append("file", file);
12592
13303
  try {
@@ -12782,10 +13493,25 @@ $("#ai-history-dialog").addEventListener("close", () => {
12782
13493
  $("#ai-history-toggle").setAttribute("aria-expanded", "false");
12783
13494
  });
12784
13495
  $("#ai-prompt").addEventListener("keydown", (event) => {
12785
- if (event.key === "Escape" && !$("#ai-mention-menu").classList.contains("hidden")) {
12786
- event.preventDefault();
12787
- hideAiMentionMenu();
12788
- return;
13496
+ const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
13497
+ if (mentionMenuVisible) {
13498
+ if (!event.isComposing && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
13499
+ if (moveAiMentionActiveOption(event.key === "ArrowDown" ? 1 : -1)) event.preventDefault();
13500
+ return;
13501
+ }
13502
+ if (shouldSendAiPrompt(event)) {
13503
+ const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
13504
+ if (activeOption) {
13505
+ event.preventDefault();
13506
+ selectAiMention(activeOption);
13507
+ return;
13508
+ }
13509
+ }
13510
+ if (event.key === "Escape") {
13511
+ event.preventDefault();
13512
+ hideAiMentionMenu();
13513
+ return;
13514
+ }
12789
13515
  }
12790
13516
  if (shouldSendAiPrompt(event)) {
12791
13517
  event.preventDefault();