@musnows/scriverse 0.7.12 → 0.8.0

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 (46) hide show
  1. package/README.en.md +4 -0
  2. package/README.md +5 -0
  3. package/dist/ai-chat-tab-limit.js +14 -0
  4. package/dist/ai-chat-tab-limit.js.map +1 -0
  5. package/dist/ai-protocol.js +10 -2
  6. package/dist/ai-protocol.js.map +1 -1
  7. package/dist/ai-retry.js +52 -0
  8. package/dist/ai-retry.js.map +1 -0
  9. package/dist/ai.js +611 -152
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +41 -12
  12. package/dist/app.js.map +1 -1
  13. package/dist/cli-contract.js +2 -1
  14. package/dist/cli-contract.js.map +1 -1
  15. package/dist/database.js +127 -2
  16. package/dist/database.js.map +1 -1
  17. package/dist/domain.js +1 -0
  18. package/dist/domain.js.map +1 -1
  19. package/dist/public/ai-chat-tabs.js +75 -0
  20. package/dist/public/ai-request-manager.js +32 -15
  21. package/dist/public/app.js +1340 -315
  22. package/dist/public/background-task-center.d.ts +9 -0
  23. package/dist/public/background-task-center.js +18 -0
  24. package/dist/public/chapter-search.d.ts +6 -0
  25. package/dist/public/chapter-search.js +23 -0
  26. package/dist/public/character-filters.d.ts +3 -1
  27. package/dist/public/character-filters.js +17 -2
  28. package/dist/public/character-version.js +1 -0
  29. package/dist/public/display-labels.d.ts +1 -0
  30. package/dist/public/display-labels.js +5 -1
  31. package/dist/public/index.html +68 -27
  32. package/dist/public/model-config.d.ts +3 -1
  33. package/dist/public/model-config.js +13 -0
  34. package/dist/public/relationship-filters.d.ts +5 -0
  35. package/dist/public/relationship-filters.js +31 -0
  36. package/dist/public/relationship-graph.js +289 -58
  37. package/dist/public/stream-typewriter.d.ts +12 -1
  38. package/dist/public/stream-typewriter.js +65 -5
  39. package/dist/public/styles.css +239 -45
  40. package/dist/server-runtime.js +6 -0
  41. package/dist/server-runtime.js.map +1 -1
  42. package/dist/store.js +264 -65
  43. package/dist/store.js.map +1 -1
  44. package/dist/version.js +6 -1
  45. package/dist/version.js.map +1 -1
  46. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, renderRelationshipMindMap } from "/relationship-graph.js?v=20260809-galaxy-size-threshold-v1";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3";
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
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 { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260812-ai-request-snapshot-v1";
6
+ import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.js?v=20260816-ai-chat-switcher-v2";
7
+ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
7
8
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
8
9
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
9
10
  import {
@@ -15,11 +16,11 @@ import {
15
16
  visibleForeshadowReminders
16
17
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
17
18
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
18
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
19
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260816-extended-thinking-effort-v1";
19
20
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
20
21
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
21
22
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
22
- import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
23
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260815-ai-stream-typewriter-v4";
23
24
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
24
25
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
25
26
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -27,9 +28,10 @@ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContex
27
28
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
28
29
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
29
30
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
31
+ import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
30
32
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
31
33
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
32
- import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
34
+ import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
33
35
  import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
34
36
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
35
37
  import {
@@ -49,8 +51,9 @@ import {
49
51
  settingStatusLabel,
50
52
  taskScopeLabel,
51
53
  timelineStatusLabel,
54
+ characterGenderLabel,
52
55
  characterStateFieldLabel
53
- } from "/display-labels.js?v=20260809-global-replace-v1";
56
+ } from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1";
54
57
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
55
58
  import {
56
59
  READING_PREFERENCES_STORAGE_KEY,
@@ -74,8 +77,8 @@ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canW
74
77
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
75
78
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
76
79
  import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
77
- import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
78
- import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
80
+ import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260817-character-filter-state-v1";
81
+ import { filterRelationships, sortCharacterRelationships } from "/relationship-filters.js?v=20260818-character-relationship-group-v1";
79
82
  import { filterSettings } from "/setting-filters.js?v=20260810-setting-inline-filters-v1";
80
83
  import {
81
84
  prepareTimelineEvents,
@@ -88,7 +91,7 @@ import {
88
91
  outlineBoardRequestPath,
89
92
  outlineBoardUnresolvedCount
90
93
  } from "/outline-board.js?v=20260813-outline-board-page-v1";
91
- import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260810-analysis-task-failed-v1";
94
+ import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions, filterBackgroundTaskTransitionsForAnnouncement } from "/background-task-center.js?v=20260817-analysis-task-expired-toast-v1";
92
95
  import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
93
96
  import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
94
97
  import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
@@ -120,6 +123,21 @@ const defaultPageSizes = Object.freeze({
120
123
  analysisTasks: 30,
121
124
  fileVersions: 30
122
125
  });
126
+ const GALAXY_MOTION_MODE_STORAGE_KEY = "scriverse.galaxy-motion-mode.v1";
127
+
128
+ function storedGalaxyMotionMode() {
129
+ try {
130
+ return normalizeGalaxyMotionMode(localStorage.getItem(GALAXY_MOTION_MODE_STORAGE_KEY));
131
+ } catch {
132
+ return "auto";
133
+ }
134
+ }
135
+
136
+ function persistGalaxyMotionMode(mode) {
137
+ const normalized = normalizeGalaxyMotionMode(mode);
138
+ try { localStorage.setItem(GALAXY_MOTION_MODE_STORAGE_KEY, normalized); } catch { /* 浏览器禁用存储时仅保留本次选择 */ }
139
+ return normalized;
140
+ }
123
141
 
124
142
  function normalizePageSize(value, fallback = 30) {
125
143
  const candidate = Number(value);
@@ -177,6 +195,7 @@ const state = {
177
195
  };
178
196
 
179
197
  const aiRequestManager = createAiRequestManager();
198
+ const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
180
199
  const moduleRequestCache = createModuleRequestCache();
181
200
  const cachedWorkModules = new Set([
182
201
  "drafts",
@@ -240,6 +259,7 @@ let backgroundTaskCenterRequest = 0;
240
259
  let backgroundTaskCenterWorkId = null;
241
260
  let backgroundTaskCenterTasksInitialized = false;
242
261
  let backgroundTaskCenterTaskSnapshots = new Map();
262
+ let backgroundTaskCenterExpiredNoticeTimes = new Map();
243
263
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
244
264
  let s3BackupTargets = [];
245
265
  let s3BackupRuns = [];
@@ -409,6 +429,7 @@ function applyWorkAccessMode() {
409
429
  $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
410
430
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
411
431
  $("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
432
+ $("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
412
433
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
413
434
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
414
435
  $("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -519,10 +540,30 @@ function setAiAssistantStatus(status) {
519
540
  dot.title = label;
520
541
  }
521
542
 
543
+ function activeAiChatTab() {
544
+ return aiChatTabManager.active();
545
+ }
546
+
547
+ function aiChatTabForRequest(request) {
548
+ return request?.tabId ? aiChatTabManager.get(request.tabId) : null;
549
+ }
550
+
551
+ function isActiveAiChatTab(tab) {
552
+ return Boolean(tab && activeAiChatTab()?.id === tab.id);
553
+ }
554
+
555
+ function setAiChatTabStatus(tab, status) {
556
+ if (!tab) return;
557
+ tab.status = status;
558
+ if (isActiveAiChatTab(tab)) setAiAssistantStatus(status);
559
+ renderAiChatTabs();
560
+ }
561
+
522
562
  function aiRequestTargetsCurrentState(request) {
523
- return aiRequestManager.isCurrent(request) && aiRequestTargetsState(request, {
563
+ const tab = aiChatTabForRequest(request);
564
+ return aiRequestManager.isCurrent(request) && Boolean(tab) && aiRequestTargetsState(request, {
524
565
  workId: state.work?.id,
525
- conversationId: state.aiConversationId
566
+ conversationId: tab.conversationId
526
567
  });
527
568
  }
528
569
 
@@ -532,33 +573,56 @@ function assertAiRequestCurrent(request) {
532
573
  }
533
574
 
534
575
  function aiInteractionBusy() {
535
- return aiRequestManager.hasActive() || aiConversationNavigationPending !== null;
576
+ return aiRequestManager.hasActive(activeAiChatTab()?.id) || aiConversationNavigationPending !== null;
577
+ }
578
+
579
+ function aiSendButtonIconMarkup(stateName) {
580
+ return stateName === "stop"
581
+ ? '<svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="6" y="6" width="12" height="12" rx="1.5"></rect></svg>'
582
+ : '<svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 3 9.5 14.5M21 3l-7 18-4.5-6.5L3 10l18-7Z"></path></svg>';
536
583
  }
537
584
 
538
585
  function syncAiRequestControls() {
539
- const sending = aiRequestManager.hasActive();
586
+ const sending = aiRequestManager.hasActive(activeAiChatTab()?.id);
540
587
  const switching = aiConversationNavigationPending !== null;
541
588
  const button = $("#ai-send");
542
- button.disabled = sending || switching;
543
- button.textContent = sending ? "发送中" : switching ? "切换中" : "发送";
589
+ const stateName = sending ? "stop" : switching ? "switching" : "send";
590
+ const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
591
+ button.disabled = switching;
592
+ button.dataset.state = stateName;
593
+ button.classList.toggle("is-stop", sending);
594
+ button.setAttribute("aria-label", label);
595
+ button.title = label;
596
+ button.innerHTML = aiSendButtonIconMarkup(stateName);
544
597
  syncAiTaskOptions();
545
598
  renderAiRoleplayCharacterSelect();
546
599
  }
547
600
 
548
601
  function cancelActiveAiRequest(reason) {
549
- const cancelled = aiRequestManager.cancel(reason);
602
+ const cancelled = aiRequestManager.cancel(reason, activeAiChatTab()?.id);
550
603
  syncAiRequestControls();
551
604
  return cancelled;
552
605
  }
553
606
 
554
- function beginAiConversationNavigation(reason) {
607
+ function activateAiSendControl() {
608
+ const tab = activeAiChatTab();
609
+ if (aiRequestManager.hasActive(tab?.id)) {
610
+ if (cancelActiveAiRequest("用户已终止当前回复")) {
611
+ toast("已终止当前回复,可以重新发送");
612
+ $("#ai-prompt").focus();
613
+ }
614
+ return;
615
+ }
616
+ void sendAi();
617
+ }
618
+
619
+ function beginAiConversationNavigation(reason, action = "切换会话") {
555
620
  aiConversationNavigationGeneration += 1;
556
621
  aiConversationNavigationPending = aiConversationNavigationGeneration;
557
622
  const navigation = Object.freeze({
558
623
  generation: aiConversationNavigationGeneration,
559
624
  workId: String(state.work?.id ?? "")
560
625
  });
561
- cancelActiveAiRequest(reason);
562
626
  return navigation;
563
627
  }
564
628
 
@@ -574,10 +638,14 @@ function finishAiConversationNavigation(navigation) {
574
638
  return true;
575
639
  }
576
640
 
577
- function invalidateAiConversationNavigation(reason) {
641
+ function invalidateAiConversationNavigation(reason, action = "切换作品") {
642
+ if (aiRequestManager.hasActive()) {
643
+ toast(`仍有 Agent turn 尚未结束,${action}会中断全部生成;已收到的内容会保留在历史记录中`, "warning");
644
+ }
578
645
  aiConversationNavigationGeneration += 1;
579
646
  aiConversationNavigationPending = null;
580
- cancelActiveAiRequest(reason);
647
+ aiRequestManager.cancelAll(reason);
648
+ syncAiRequestControls();
581
649
  }
582
650
 
583
651
  function aiAssistantRequestId(request) {
@@ -654,6 +722,8 @@ let workSelectionRequestGeneration = 0;
654
722
  let chapterSelectionRequestGeneration = 0;
655
723
  let aiConversationNavigationGeneration = 0;
656
724
  let aiConversationNavigationPending = null;
725
+ let aiChatTabLimit = 5;
726
+ let aiConversationWorkspaceOpen = false;
657
727
  const loadedVolumeChapterIds = new Set();
658
728
  const volumeChapterLoadingIds = new Set();
659
729
  const volumeChapterRequests = new Map();
@@ -862,6 +932,7 @@ function replacePageRoute(route) {
862
932
  const hash = serializePageRoute(route);
863
933
  if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
864
934
  schedulePresenceHeartbeat();
935
+ syncTopSearchButton();
865
936
  }
866
937
 
867
938
  function presencePageForRoute(route = currentPageRoute()) {
@@ -1073,6 +1144,149 @@ function currentPageRoute() {
1073
1144
  return { view: "welcome", workId };
1074
1145
  }
1075
1146
 
1147
+ function isChapterSearchContext() {
1148
+ return Boolean(state.work && state.chapter && !$("#editor-view").classList.contains("hidden"));
1149
+ }
1150
+
1151
+ function syncTopSearchButton() {
1152
+ const button = $("#top-search-button");
1153
+ const chapterContext = isChapterSearchContext();
1154
+ button.classList.toggle("hidden", chapterContext);
1155
+ button.disabled = !state.work || chapterContext || !canReadAggregateContent();
1156
+ if (!chapterContext) closeChapterSearchPanel({ restoreFocus: false });
1157
+ }
1158
+
1159
+ function chapterSearchMatches() {
1160
+ return findTextMatches($("#chapter-content").value, $("#chapter-search-query").value);
1161
+ }
1162
+
1163
+ function renderChapterSearchStatus() {
1164
+ const query = $("#chapter-search-query").value;
1165
+ const matches = chapterSearchMatches();
1166
+ const status = $("#chapter-search-status");
1167
+ const readOnly = !canEditProse() || chapterEditorReadOnly;
1168
+ const replaceButtons = [$("#chapter-search-replace"), $("#chapter-search-replace-all")];
1169
+ replaceButtons.forEach((button) => { button.disabled = readOnly || matches.length === 0; });
1170
+ $("#chapter-search-previous").disabled = matches.length === 0;
1171
+ $("#chapter-search-next").disabled = matches.length === 0;
1172
+ if (!query) {
1173
+ status.textContent = "输入关键词后开始搜索";
1174
+ return;
1175
+ }
1176
+ if (!matches.length) {
1177
+ status.textContent = "本章未找到匹配内容";
1178
+ return;
1179
+ }
1180
+ const current = chapterSearchMatchIndex >= 0 ? chapterSearchMatchIndex + 1 : 0;
1181
+ status.textContent = current ? `第 ${current} / ${matches.length} 处` : `找到 ${matches.length} 处`;
1182
+ }
1183
+
1184
+ function syncChapterSearchControls() {
1185
+ if (!$("#chapter-search-panel")) return;
1186
+ const matches = chapterSearchMatches();
1187
+ if (chapterSearchMatchIndex >= matches.length) chapterSearchMatchIndex = matches.length - 1;
1188
+ renderChapterSearchStatus();
1189
+ }
1190
+
1191
+ function selectChapterSearchMatch(index) {
1192
+ const matches = chapterSearchMatches();
1193
+ if (!matches.length) {
1194
+ chapterSearchMatchIndex = -1;
1195
+ renderChapterSearchStatus();
1196
+ return;
1197
+ }
1198
+ chapterSearchMatchIndex = (index + matches.length) % matches.length;
1199
+ const input = $("#chapter-content");
1200
+ const start = matches[chapterSearchMatchIndex];
1201
+ input.focus({ preventScroll: true });
1202
+ input.setSelectionRange(start, start + $("#chapter-search-query").value.length);
1203
+ renderChapterSearchStatus();
1204
+ }
1205
+
1206
+ function moveChapterSearchMatch(direction) {
1207
+ const matches = chapterSearchMatches();
1208
+ if (!matches.length) {
1209
+ renderChapterSearchStatus();
1210
+ return;
1211
+ }
1212
+ const nextIndex = chapterSearchMatchIndex < 0
1213
+ ? direction > 0 ? 0 : matches.length - 1
1214
+ : chapterSearchMatchIndex + direction;
1215
+ selectChapterSearchMatch(nextIndex);
1216
+ }
1217
+
1218
+ function openChapterSearchPanel() {
1219
+ if (!isChapterSearchContext()) return false;
1220
+ const panel = $("#chapter-search-panel");
1221
+ panel.classList.remove("hidden");
1222
+ panel.setAttribute("aria-hidden", "false");
1223
+ syncChapterSearchControls();
1224
+ queueMicrotask(() => {
1225
+ const input = $("#chapter-search-query");
1226
+ input.focus();
1227
+ if (input.value) input.select();
1228
+ });
1229
+ return true;
1230
+ }
1231
+
1232
+ function closeChapterSearchPanel({ restoreFocus = true } = {}) {
1233
+ const panel = $("#chapter-search-panel");
1234
+ if (!panel || panel.classList.contains("hidden")) return;
1235
+ panel.classList.add("hidden");
1236
+ panel.setAttribute("aria-hidden", "true");
1237
+ chapterSearchMatchIndex = -1;
1238
+ if (restoreFocus && isChapterSearchContext()) $("#chapter-content").focus({ preventScroll: true });
1239
+ }
1240
+
1241
+ function resetChapterSearchPanel() {
1242
+ closeChapterSearchPanel({ restoreFocus: false });
1243
+ $("#chapter-search-query").value = "";
1244
+ $("#chapter-replace-query").value = "";
1245
+ chapterSearchMatchIndex = -1;
1246
+ renderChapterSearchStatus();
1247
+ }
1248
+
1249
+ function replaceCurrentChapterSearchMatch() {
1250
+ if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
1251
+ toast("请先进入编辑模式", "error");
1252
+ return;
1253
+ }
1254
+ const query = $("#chapter-search-query").value;
1255
+ const matches = chapterSearchMatches();
1256
+ if (!query || !matches.length) return renderChapterSearchStatus();
1257
+ const index = chapterSearchMatchIndex < 0 ? 0 : chapterSearchMatchIndex;
1258
+ const start = matches[index];
1259
+ const input = $("#chapter-content");
1260
+ const replacement = $("#chapter-replace-query").value;
1261
+ input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
1262
+ chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
1263
+ updateChapterStats();
1264
+ clearChapterLineSelection();
1265
+ scheduleChapterLineNumbers();
1266
+ scheduleChapterAutoSave(120);
1267
+ syncChapterSearchControls();
1268
+ toast("已替换本章 1 处,等待自动保存");
1269
+ }
1270
+
1271
+ function replaceAllChapterSearchMatches() {
1272
+ if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
1273
+ toast("请先进入编辑模式", "error");
1274
+ return;
1275
+ }
1276
+ const query = $("#chapter-search-query").value;
1277
+ const input = $("#chapter-content");
1278
+ const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
1279
+ if (!result.matches) return renderChapterSearchStatus();
1280
+ input.value = result.content;
1281
+ chapterSearchMatchIndex = -1;
1282
+ updateChapterStats();
1283
+ clearChapterLineSelection();
1284
+ scheduleChapterLineNumbers();
1285
+ scheduleChapterAutoSave(120);
1286
+ syncChapterSearchControls();
1287
+ toast(`已替换本章 ${result.matches} 处,等待自动保存`);
1288
+ }
1289
+
1076
1290
  function loadPanelLayout() {
1077
1291
  try {
1078
1292
  const stored = JSON.parse(localStorage.getItem(panelLayoutStorageKey) ?? "{}");
@@ -1131,9 +1345,8 @@ function applyPanelLayout(persist = false) {
1131
1345
  }
1132
1346
 
1133
1347
  function ensureAiPanelExpanded() {
1134
- if (!panelLayout.aiCollapsed) return;
1135
- panelLayout.aiCollapsed = false;
1136
- applyPanelLayout(true);
1348
+ if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1349
+ setAiConversationWorkspaceVisible(true);
1137
1350
  }
1138
1351
 
1139
1352
  function setupPanelResize(handle, side) {
@@ -1182,6 +1395,7 @@ let chapterAutoSaveTimer = null;
1182
1395
  let chapterSaveInFlight = null;
1183
1396
  let chapterSaveGuardInFlight = null;
1184
1397
  let lastSavedChapterSnapshot = null;
1398
+ let chapterSearchMatchIndex = -1;
1185
1399
  let chapterSelectionRequestId = 0;
1186
1400
  let chapterForeshadowReminderRequestId = 0;
1187
1401
  let chapterForeshadowReminders = [];
@@ -1223,7 +1437,9 @@ const moduleListPages = {
1223
1437
  comments: 1,
1224
1438
  reviews: 1
1225
1439
  };
1226
- const characterFilters = { raceIds: [], organizationIds: [] };
1440
+ const characterFilters = { raceIds: [], organizationIds: [], genderValues: [], deathState: "all" };
1441
+ const CHARACTER_GENDER_OPTIONS = [["unknown", "未知"], ["male", "男 / 雄"], ["female", "女 / 雌"], ["none", "无性别"]];
1442
+ const CHARACTER_DEATH_STATE_OPTIONS = [["all", "全部状态"], ["alive", "未死亡"], ["dead", "已死亡"]];
1227
1443
  let characterFiltersPanelOpen = false;
1228
1444
  const settingFilters = { keyword: "", category: "", lockState: "all" };
1229
1445
  let settingFiltersPanelOpen = false;
@@ -1282,6 +1498,7 @@ function applyChapterEditorMode() {
1282
1498
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1283
1499
  $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1284
1500
  $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1501
+ syncChapterSearchControls();
1285
1502
  if (viewOnly) cancelChapterAutoSave();
1286
1503
  }
1287
1504
 
@@ -1770,9 +1987,345 @@ function renderAiQuickActions() {
1770
1987
  quickActions.setAttribute("aria-hidden", String(!visible));
1771
1988
  }
1772
1989
 
1773
- function attachMessageHeading(message, label, createdAt = new Date().toISOString()) {
1990
+ function createAiChatFeed() {
1991
+ const feed = document.createElement("div");
1992
+ feed.className = "ai-feed hidden";
1993
+ feed.setAttribute("role", "tabpanel");
1994
+ $("#ai-chat-panels").append(feed);
1995
+ return feed;
1996
+ }
1997
+
1998
+ function createAiChatTabState(input = {}) {
1999
+ const existing = input.conversationId ? aiChatTabManager.findByConversation(input.conversationId) : null;
2000
+ if (existing) {
2001
+ aiChatTabManager.activate(existing.id);
2002
+ return existing;
2003
+ }
2004
+ const feed = input.feed ?? createAiChatFeed();
2005
+ const tab = aiChatTabManager.open({
2006
+ workId: String(state.work?.id ?? ""),
2007
+ conversationId: input.conversationId ?? null,
2008
+ title: input.title ?? "新对话",
2009
+ modelId: input.modelId ?? null,
2010
+ selectedModelId: input.selectedModelId ?? null,
2011
+ promptSent: input.promptSent === true,
2012
+ taskType: input.taskType ?? "chat",
2013
+ contextScope: input.contextScope ?? { type: "none" },
2014
+ roleplayCharacter: input.roleplayCharacter ?? null,
2015
+ citations: input.citations ?? [],
2016
+ references: input.references ?? [],
2017
+ composer: input.composer ?? { text: "", citations: [], references: [] },
2018
+ contextUsage: input.contextUsage ?? null,
2019
+ contextWarning: input.contextWarning === true,
2020
+ lastMessageAt: input.lastMessageAt ?? null,
2021
+ status: input.status ?? "ready",
2022
+ unread: input.unread === true,
2023
+ feed
2024
+ });
2025
+ feed.dataset.aiTabId = tab.id;
2026
+ feed.setAttribute("aria-labelledby", `ai-chat-tab-${tab.id}`);
2027
+ return tab;
2028
+ }
2029
+
2030
+ function persistActiveAiChatTab() {
2031
+ const tab = activeAiChatTab();
2032
+ if (!tab) return null;
2033
+ tab.workId = String(state.work?.id ?? "");
2034
+ tab.conversationId = state.aiConversationId;
2035
+ tab.title = $("#ai-conversation-title").textContent || tab.title || "新对话";
2036
+ tab.modelId = state.aiConversationModelId;
2037
+ tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
2038
+ tab.promptSent = state.aiPromptSent;
2039
+ tab.taskType = state.aiTaskType;
2040
+ tab.contextScope = { ...state.aiContextScope };
2041
+ tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2042
+ setAiChatTabComposerSnapshot(tab, captureAiPromptComposer());
2043
+ tab.contextUsage = latestAiContextUsage;
2044
+ tab.contextWarning = !$("#ai-context-warning").classList.contains("hidden");
2045
+ tab.lastMessageAt = state.aiLastMessageAt;
2046
+ return tab;
2047
+ }
2048
+
2049
+ function setAiChatTabComposerSnapshot(tab, snapshot) {
2050
+ if (!tab) return;
2051
+ tab.citations = snapshot.citations.map((citation) => ({ ...citation }));
2052
+ tab.references = snapshot.references.map((reference) => ({ ...reference }));
2053
+ tab.composer = {
2054
+ text: snapshot.text,
2055
+ citations: tab.citations.map((citation) => ({ ...citation })),
2056
+ references: tab.references.map((reference) => ({ ...reference }))
2057
+ };
2058
+ }
2059
+
2060
+ function clearAiChatTabComposer(tab) {
2061
+ setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [] });
2062
+ }
2063
+
2064
+ function applyAiChatTabState(tab) {
2065
+ state.aiConversationId = tab.conversationId;
2066
+ state.aiConversationModelId = tab.modelId;
2067
+ state.aiPromptSent = tab.promptSent;
2068
+ state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
2069
+ state.aiReferences = tab.references.map((reference) => ({ ...reference }));
2070
+ state.aiLastMessageAt = tab.lastMessageAt;
2071
+ $("#ai-conversation-title").textContent = tab.title || "新对话";
2072
+ applyAiConversationTaskType(tab.taskType);
2073
+ applyAiConversationContextScope(tab.contextScope);
2074
+ applyAiRoleplayCharacter(tab.roleplayCharacter);
2075
+ const selectedModelId = tab.modelId ?? tab.selectedModelId;
2076
+ if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2077
+ setAiPromptText(tab.composer.text);
2078
+ renderAiCitations();
2079
+ renderAiReferences();
2080
+ latestAiContextUsage = null;
2081
+ setAiContextMeter(tab.contextUsage);
2082
+ if (tab.contextWarning) showAiContextWarning();
2083
+ else hideAiContextWarning();
2084
+ setAiAssistantStatus(tab.status);
2085
+ renderAiQuickActions();
2086
+ renderAiConversationHistory();
2087
+ syncAiRequestControls();
2088
+ }
2089
+
2090
+ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
2091
+ return Boolean(tab
2092
+ && !tab.conversationId
2093
+ && !tab.promptSent
2094
+ && !aiRequestManager.hasActive(tab.id)
2095
+ && !(tab.composer?.text || "").trim()
2096
+ && !(tab.composer?.citations?.length)
2097
+ && !(tab.composer?.references?.length));
2098
+ }
2099
+
2100
+ function aiChatTabOpeningSlot() {
2101
+ const active = activeAiChatTab();
2102
+ if (aiChatTabLimit === 1) return { allowed: true, tab: active, replacing: true };
2103
+ if (aiChatTabIsReplaceable(active)) return { allowed: true, tab: active, replacing: true };
2104
+ if (aiChatTabManager.list().length < aiChatTabLimit) return { allowed: true, tab: null, replacing: false };
2105
+ return { allowed: false, tab: null, replacing: false };
2106
+ }
2107
+
2108
+ function interruptAiChatTabForReplacement(slot) {
2109
+ if (!slot?.replacing || !slot.tab) return;
2110
+ if (aiRequestManager.cancel("已切换到其他对话", slot.tab.id)) {
2111
+ toast("当前对话仍在生成,切换后已中断该 turn;已收到的内容仍保留在历史记录中", "warning");
2112
+ }
2113
+ }
2114
+
2115
+ function reportAiChatTabLimit() {
2116
+ toast(`最多同时打开 ${aiChatTabLimit} 个对话,请先关闭一个再继续`, "warning");
2117
+ }
2118
+
2119
+ function aiChatTabStatusLabel(tab, selected) {
2120
+ if (tab.status === "streaming") return "正在生成";
2121
+ if (tab.status === "error") return "执行失败";
2122
+ if (tab.unread) return "已完成 · 未读";
2123
+ return selected ? "当前对话" : "空闲";
2124
+ }
2125
+
2126
+ function renderAiConversationSwitcher() {
2127
+ const button = $("#ai-conversation-switcher");
2128
+ const list = $("#ai-open-conversation-list");
2129
+ if (!button || !list) return;
2130
+ const tabs = aiChatTabManager.list();
2131
+ const active = activeAiChatTab();
2132
+ const multipleEnabled = aiChatTabLimit > 1;
2133
+ $("#ai-conversation-title").textContent = active?.title || "新对话";
2134
+ $("#ai-open-conversation-count").textContent = String(tabs.length);
2135
+ $("#ai-conversation-switcher-limit").textContent = `${tabs.length} / ${aiChatTabLimit}`;
2136
+ button.disabled = !multipleEnabled;
2137
+ button.setAttribute("aria-disabled", String(!multipleEnabled));
2138
+ button.title = multipleEnabled ? "切换打开的对话" : "多会话已通过环境变量关闭";
2139
+ button.classList.toggle("has-background-activity", tabs.some((tab) => tab.id !== active?.id && (tab.status === "streaming" || tab.unread)));
2140
+ $("#ai-workspace-open").classList.toggle("hidden", !multipleEnabled);
2141
+ list.replaceChildren();
2142
+ for (const tab of tabs) {
2143
+ const selected = tab.id === active?.id;
2144
+ const row = document.createElement("div");
2145
+ row.className = `ai-open-conversation-row${selected ? " is-active" : ""}`;
2146
+ row.setAttribute("role", "listitem");
2147
+ const select = document.createElement("button");
2148
+ select.type = "button";
2149
+ select.className = "ai-open-conversation-select";
2150
+ select.dataset.aiSwitcherTabId = tab.id;
2151
+ select.setAttribute("aria-current", selected ? "true" : "false");
2152
+ const status = document.createElement("span");
2153
+ status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
2154
+ status.setAttribute("aria-hidden", "true");
2155
+ const copy = document.createElement("span");
2156
+ copy.className = "ai-open-conversation-copy";
2157
+ const title = document.createElement("strong");
2158
+ title.textContent = tab.title || "新对话";
2159
+ const meta = document.createElement("small");
2160
+ meta.textContent = aiChatTabStatusLabel(tab, selected);
2161
+ copy.append(title, meta);
2162
+ select.append(status, copy);
2163
+ select.addEventListener("click", () => {
2164
+ activateAiChatTab(tab.id);
2165
+ setAiConversationSwitcherVisible(false);
2166
+ });
2167
+ const close = document.createElement("button");
2168
+ close.type = "button";
2169
+ close.className = "ai-open-conversation-close";
2170
+ close.setAttribute("aria-label", `关闭对话“${tab.title || "新对话"}”`);
2171
+ close.textContent = "×";
2172
+ close.classList.toggle("hidden", tabs.length === 1);
2173
+ close.addEventListener("click", () => closeAiChatTab(tab.id));
2174
+ row.append(select, close);
2175
+ list.append(row);
2176
+ }
2177
+ }
2178
+
2179
+ function setAiConversationSwitcherVisible(visible) {
2180
+ const menu = $("#ai-conversation-switcher-menu");
2181
+ const button = $("#ai-conversation-switcher");
2182
+ if (!menu || !button) return;
2183
+ const nextVisible = Boolean(visible && aiChatTabLimit > 1);
2184
+ menu.classList.toggle("hidden", !nextVisible);
2185
+ button.setAttribute("aria-expanded", String(nextVisible));
2186
+ if (nextVisible) renderAiConversationSwitcher();
2187
+ }
2188
+
2189
+ function setAiConversationWorkspaceVisible(visible) {
2190
+ aiConversationWorkspaceOpen = Boolean(visible);
2191
+ if (aiConversationWorkspaceOpen) {
2192
+ panelLayout.aiCollapsed = false;
2193
+ $("#app").classList.remove("ai-panel-collapsed");
2194
+ }
2195
+ $("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
2196
+ $(".ai-panel").classList.toggle("is-conversation-workspace", aiConversationWorkspaceOpen);
2197
+ $("#ai-assistant-entry").classList.toggle("active", aiConversationWorkspaceOpen);
2198
+ $("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
2199
+ $("#ai-chat-tabs").classList.add("hidden");
2200
+ $("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
2201
+ $("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
2202
+ setAiConversationSwitcherVisible(false);
2203
+ renderAiChatTabs();
2204
+ if (aiConversationWorkspaceOpen) {
2205
+ window.requestAnimationFrame(() => $("#ai-prompt").focus({ preventScroll: true }));
2206
+ }
2207
+ }
2208
+
2209
+ function applyAiChatTabLimit(value) {
2210
+ aiChatTabLimit = normalizeAiChatTabLimit(value);
2211
+ const singleSession = aiChatTabLimit === 1;
2212
+ $(".ai-panel").classList.toggle("is-single-conversation", singleSession);
2213
+ if (singleSession) {
2214
+ setAiConversationSwitcherVisible(false);
2215
+ }
2216
+ renderAiChatTabs();
2217
+ }
2218
+
2219
+ function renderAiChatTabs() {
2220
+ const host = $("#ai-chat-tabs");
2221
+ if (!host) return;
2222
+ const tabs = aiChatTabManager.list();
2223
+ const active = activeAiChatTab();
2224
+ host.replaceChildren();
2225
+ for (const tab of tabs) {
2226
+ const item = document.createElement("div");
2227
+ item.className = "ai-chat-tab";
2228
+ item.setAttribute("role", "presentation");
2229
+ const button = document.createElement("button");
2230
+ const selected = tab.id === active?.id;
2231
+ button.type = "button";
2232
+ button.id = `ai-chat-tab-${tab.id}`;
2233
+ button.className = "ai-chat-tab-select";
2234
+ button.dataset.aiTabId = tab.id;
2235
+ button.setAttribute("role", "tab");
2236
+ button.setAttribute("aria-selected", String(selected));
2237
+ button.setAttribute("aria-controls", tab.feed.id || `ai-chat-panel-${tab.id}`);
2238
+ button.tabIndex = selected ? 0 : -1;
2239
+ button.title = tab.title || "新对话";
2240
+ const status = document.createElement("span");
2241
+ status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
2242
+ status.setAttribute("aria-hidden", "true");
2243
+ const title = document.createElement("span");
2244
+ title.textContent = tab.title || "新对话";
2245
+ button.append(status, title);
2246
+ button.addEventListener("click", () => activateAiChatTab(tab.id));
2247
+ button.addEventListener("keydown", (event) => {
2248
+ const index = tabs.findIndex((candidate) => candidate.id === tab.id);
2249
+ const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
2250
+ if (!offset) return;
2251
+ event.preventDefault();
2252
+ const target = tabs[(index + offset + tabs.length) % tabs.length];
2253
+ activateAiChatTab(target.id);
2254
+ $("#ai-conversation-switcher").focus();
2255
+ });
2256
+ const close = document.createElement("button");
2257
+ close.type = "button";
2258
+ close.className = "ai-chat-tab-close";
2259
+ close.dataset.aiTabClose = tab.id;
2260
+ close.setAttribute("aria-label", `关闭对话页签“${tab.title || "新对话"}”`);
2261
+ close.title = "关闭页签";
2262
+ close.textContent = "×";
2263
+ close.classList.toggle("hidden", tabs.length === 1);
2264
+ close.addEventListener("click", () => closeAiChatTab(tab.id));
2265
+ item.append(button, close);
2266
+ host.append(item);
2267
+ }
2268
+ renderAiConversationSwitcher();
2269
+ }
2270
+
2271
+ function activateAiChatTab(tabId, { persistCurrent = true, force = false } = {}) {
2272
+ const current = activeAiChatTab();
2273
+ if (!force && current?.id === tabId) return current;
2274
+ if (persistCurrent) persistActiveAiChatTab();
2275
+ const tab = aiChatTabManager.activate(tabId);
2276
+ if (!tab) return null;
2277
+ tab.unread = false;
2278
+ for (const feed of $("#ai-chat-panels").querySelectorAll(".ai-feed")) {
2279
+ feed.classList.toggle("hidden", feed !== tab.feed);
2280
+ feed.id = feed === tab.feed ? "ai-feed" : `ai-chat-panel-${feed.dataset.aiTabId}`;
2281
+ }
2282
+ tab.feed.classList.remove("hidden");
2283
+ applyAiChatTabState(tab);
2284
+ renderAiChatTabs();
2285
+ return tab;
2286
+ }
2287
+
2288
+ function closeAiChatTab(tabId) {
2289
+ const tab = aiChatTabManager.get(tabId);
2290
+ if (!tab) return;
2291
+ const wasActive = isActiveAiChatTab(tab);
2292
+ if (wasActive) persistActiveAiChatTab();
2293
+ if (aiRequestManager.cancel("对话页签已关闭", tab.id)) {
2294
+ toast(`“${tab.title || "新对话"}”仍在生成,关闭页签已中断该 turn;其他页签不受影响`, "warning");
2295
+ }
2296
+ const { active } = aiChatTabManager.close(tab.id);
2297
+ tab.feed.remove();
2298
+ if (!wasActive) {
2299
+ renderAiChatTabs();
2300
+ return;
2301
+ }
2302
+ const next = active ?? createAiChatTabState();
2303
+ activateAiChatTab(next.id, { persistCurrent: false, force: true });
2304
+ }
2305
+
2306
+ function resetAiChatTabs() {
2307
+ aiRequestManager.cancelAll("已重置 Agent 对话页签");
2308
+ setAiConversationSwitcherVisible(false);
2309
+ setAiConversationWorkspaceVisible(false);
2310
+ const panels = $("#ai-chat-panels");
2311
+ panels.replaceChildren();
2312
+ aiChatTabManager.reset();
2313
+ const tab = createAiChatTabState();
2314
+ resetAiFeed(tab.feed, tab.roleplayCharacter);
2315
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2316
+ }
2317
+
2318
+ function initializeAiChatTabs() {
2319
+ const feed = $("#ai-feed");
2320
+ aiChatTabManager.reset();
2321
+ const tab = createAiChatTabState({ feed });
2322
+ feed.dataset.aiTabId = tab.id;
2323
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2324
+ }
2325
+
2326
+ function attachMessageHeading(message, label, createdAt = new Date().toISOString(), tab = activeAiChatTab()) {
1774
2327
  const timestamp = createdAt || new Date().toISOString();
1775
- const previousCreatedAt = state.aiLastMessageAt;
2328
+ const previousCreatedAt = tab?.lastMessageAt ?? state.aiLastMessageAt;
1776
2329
  const heading = document.createElement("span");
1777
2330
  heading.className = "message-heading";
1778
2331
  const role = document.createElement("span");
@@ -1784,7 +2337,8 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1784
2337
  message.prepend(heading);
1785
2338
  message.dataset.createdAt = timestamp;
1786
2339
  message.dataset.previousCreatedAt = previousCreatedAt ?? "";
1787
- state.aiLastMessageAt = timestamp;
2340
+ if (tab) tab.lastMessageAt = timestamp;
2341
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = timestamp;
1788
2342
  return heading;
1789
2343
  }
1790
2344
 
@@ -1795,19 +2349,25 @@ function updateMessageCreatedAt(message, createdAt) {
1795
2349
  time.dateTime = createdAt;
1796
2350
  time.textContent = formatAiMessageTime(createdAt);
1797
2351
  message.dataset.createdAt = createdAt;
1798
- if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
2352
+ const tab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2353
+ if (message === tab?.feed.lastElementChild) {
2354
+ tab.lastMessageAt = createdAt;
2355
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = createdAt;
2356
+ }
1799
2357
  }
1800
2358
 
1801
- function resetAiFeed() {
1802
- state.aiLastMessageAt = null;
1803
- const roleplayName = state.aiRoleplayCharacter?.name;
1804
- $("#ai-feed").innerHTML = roleplayName
2359
+ function resetAiFeed(feed = $("#ai-feed"), roleplayCharacter = state.aiRoleplayCharacter) {
2360
+ const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2361
+ if (tab) tab.lastMessageAt = null;
2362
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
2363
+ const roleplayName = roleplayCharacter?.name;
2364
+ feed.innerHTML = roleplayName
1805
2365
  ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
1806
2366
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1807
2367
  }
1808
2368
 
1809
- function aiAssistantLabel(suffix = "") {
1810
- const name = state.aiRoleplayCharacter?.name || "助手";
2369
+ function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
2370
+ const name = roleplayCharacter?.name || "助手";
1811
2371
  return suffix ? `${name} · ${suffix}` : name;
1812
2372
  }
1813
2373
 
@@ -1823,26 +2383,24 @@ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "
1823
2383
  return divider;
1824
2384
  }
1825
2385
 
1826
- function appendAiContextCompactionDivider(kind, before = null) {
2386
+ function appendAiContextCompactionDivider(kind, before = null, feed = $("#ai-feed")) {
1827
2387
  const divider = createAiContextCompactionDivider({ kind });
1828
- const feed = $("#ai-feed");
1829
2388
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1830
2389
  else feed.append(divider);
1831
- scrollAiFeedToBottom();
2390
+ scrollAiFeedToBottom(feed);
1832
2391
  return divider;
1833
2392
  }
1834
2393
 
1835
- function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
1836
- const feed = $("#ai-feed");
2394
+ function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null, feed = $("#ai-feed"), conversation = null) {
1837
2395
  feed.querySelector('[data-context-compaction="conversation"]')?.remove();
1838
2396
  const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
1839
2397
  if (compactedCount === 0) return null;
1840
2398
  const messages = [...feed.querySelectorAll("[data-message-id]")];
1841
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
2399
+ const current = conversation ?? state.aiConversations.find((item) => item.id === state.aiConversationId);
1842
2400
  const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
1843
2401
  const loadedOffset = Math.max(0, totalCount - messages.length);
1844
2402
  const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
1845
- return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
2403
+ return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
1846
2404
  }
1847
2405
 
1848
2406
  function renderMessageCardActions(message) {
@@ -1871,7 +2429,7 @@ function renderMessageCardActions(message) {
1871
2429
  });
1872
2430
  actions.append(copy);
1873
2431
  }
1874
- if (message.dataset.messageId) {
2432
+ if (message.dataset.messageId && message.classList.contains("assistant-message")) {
1875
2433
  const fork = document.createElement("button");
1876
2434
  fork.type = "button";
1877
2435
  fork.className = "message-fork-button";
@@ -1879,9 +2437,16 @@ function renderMessageCardActions(message) {
1879
2437
  fork.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V7M12 13v4"/></svg><span>从此续写</span>';
1880
2438
  const forkRequestId = createAiIdempotencyKey();
1881
2439
  fork.addEventListener("click", async () => {
2440
+ const openingSlot = aiChatTabOpeningSlot();
2441
+ if (!openingSlot.allowed) {
2442
+ reportAiChatTabLimit();
2443
+ return;
2444
+ }
1882
2445
  fork.disabled = true;
1883
2446
  try {
1884
- const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, {
2447
+ const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2448
+ if (!sourceTab?.conversationId) throw new Error("无法确定消息所属对话");
2449
+ const conversation = await api(`/api/ai-conversations/${sourceTab.conversationId}/fork`, {
1885
2450
  method: "POST",
1886
2451
  body: { messageId: message.dataset.messageId, requestId: forkRequestId }
1887
2452
  });
@@ -1920,7 +2485,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1920
2485
  search_drafts: "搜索想法",
1921
2486
  recall_self: "回忆自身",
1922
2487
  image: "读取设定图片",
1923
- recall_relationship: "回忆人物关系"
2488
+ recall_relationship: "回忆人物关系",
2489
+ calculate_time: "计算日期"
1924
2490
  };
1925
2491
 
1926
2492
  const AI_TOOL_DESCRIPTIONS = {
@@ -1932,10 +2498,11 @@ const AI_TOOL_DESCRIPTIONS = {
1932
2498
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1933
2499
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
1934
2500
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
1935
- recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。"
2501
+ recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2502
+ calculate_time: "计算日期差值,或从起始日期推算目标日期。"
1936
2503
  };
1937
2504
 
1938
- let aiFeedScrollFrame = null;
2505
+ const aiFeedScrollFrames = new WeakMap();
1939
2506
  let markdownTableMenuTarget = null;
1940
2507
  let markdownTableMenuTrigger = null;
1941
2508
 
@@ -1976,14 +2543,15 @@ function openMarkdownTableMenu(header, clientX, clientY) {
1976
2543
  toggle.focus();
1977
2544
  }
1978
2545
 
1979
- function scrollAiFeedToBottom() {
1980
- const feed = $("#ai-feed");
2546
+ function scrollAiFeedToBottom(feed = $("#ai-feed")) {
1981
2547
  feed.scrollTop = feed.scrollHeight;
1982
- if (aiFeedScrollFrame !== null) window.cancelAnimationFrame(aiFeedScrollFrame);
1983
- aiFeedScrollFrame = window.requestAnimationFrame(() => {
2548
+ const currentFrame = aiFeedScrollFrames.get(feed);
2549
+ if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
2550
+ const nextFrame = window.requestAnimationFrame(() => {
1984
2551
  feed.scrollTop = feed.scrollHeight;
1985
- aiFeedScrollFrame = null;
2552
+ aiFeedScrollFrames.delete(feed);
1986
2553
  });
2554
+ aiFeedScrollFrames.set(feed, nextFrame);
1987
2555
  }
1988
2556
 
1989
2557
  function formatAiToolCallTime(value) {
@@ -2001,6 +2569,34 @@ function formatAiToolCallTime(value) {
2001
2569
  }).format(date);
2002
2570
  }
2003
2571
 
2572
+ const AI_TOOL_CALL_COPY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg>';
2573
+ const AI_TOOL_CALL_COPIED_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12 4 4L19 6"></path></svg>';
2574
+
2575
+ function setAiToolCallCopyButtonState(button, copied) {
2576
+ const label = button.dataset.copyLabel ?? "代码块";
2577
+ const nextState = copied ? "copied" : "idle";
2578
+ if (button.dataset.copyState !== nextState) button.innerHTML = copied ? AI_TOOL_CALL_COPIED_ICON : AI_TOOL_CALL_COPY_ICON;
2579
+ button.dataset.copyState = nextState;
2580
+ button.classList.toggle("is-copied", copied);
2581
+ button.setAttribute("aria-label", copied ? `${label}已复制` : `复制${label}`);
2582
+ button.title = copied ? "已复制" : `复制${label}`;
2583
+ }
2584
+
2585
+ async function copyAiToolCallCode(button) {
2586
+ const targetId = button.dataset.copyTarget;
2587
+ const target = targetId ? document.getElementById(targetId) : null;
2588
+ if (!target) return;
2589
+ try {
2590
+ await copyAiRawMarkdown(target.textContent);
2591
+ setAiToolCallCopyButtonState(button, true);
2592
+ window.setTimeout(() => {
2593
+ if (button.isConnected && button.dataset.copyState === "copied") setAiToolCallCopyButtonState(button, false);
2594
+ }, 1200);
2595
+ } catch (error) {
2596
+ toast(error.message, "error");
2597
+ }
2598
+ }
2599
+
2004
2600
  function openAiToolCallDetail(toolCall) {
2005
2601
  const name = String(toolCall?.name ?? "unknown");
2006
2602
  const status = toolCall?.status === "failed" ? "调用失败" : "调用成功";
@@ -2017,6 +2613,7 @@ function openAiToolCallDetail(toolCall) {
2017
2613
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
2018
2614
  $("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
2019
2615
  $("#ai-tool-call-result").textContent = resultDetails.text;
2616
+ document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => setAiToolCallCopyButtonState(button, false));
2020
2617
  $("#ai-tool-call-dialog").showModal();
2021
2618
  }
2022
2619
 
@@ -2198,23 +2795,15 @@ async function downloadAiConversation(conversation) {
2198
2795
  window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
2199
2796
  }
2200
2797
 
2798
+ async function copyAiConversationSessionId(conversation) {
2799
+ const sessionId = typeof conversation?.id === "string" ? conversation.id.trim() : "";
2800
+ if (!sessionId) throw new Error("无法确定对话 Session ID");
2801
+ await copyAiRawMarkdown(sessionId);
2802
+ }
2803
+
2201
2804
  function resetAiConversationAfterDelete() {
2202
- state.aiConversationId = null;
2203
- state.aiConversationModelId = null;
2204
- state.aiPromptSent = false;
2205
- state.aiCitations = [];
2206
- state.aiReferences = [];
2207
- applyAiConversationTaskType("chat");
2208
- applyAiConversationContextScope({ type: "none" });
2209
- applyAiRoleplayCharacter(null);
2210
- $("#ai-conversation-title").textContent = "新对话";
2211
- resetAiFeed();
2212
- hideAiContextWarning();
2213
- resetAiContextMeter();
2214
- setAiPromptText("");
2215
- renderAiCitations();
2216
- renderAiReferences();
2217
- renderAiQuickActions();
2805
+ const tab = activeAiChatTab();
2806
+ if (tab) closeAiChatTab(tab.id);
2218
2807
  }
2219
2808
 
2220
2809
  async function favoriteAiConversation(conversation) {
@@ -2237,7 +2826,11 @@ async function deleteAiConversation(conversation) {
2237
2826
  })) return;
2238
2827
  await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}`, { method: "DELETE" });
2239
2828
  state.aiConversations = state.aiConversations.filter((item) => item.id !== conversation.id);
2240
- if (state.aiConversationId === conversation.id) resetAiConversationAfterDelete();
2829
+ const tab = aiChatTabManager.findByConversation(conversation.id);
2830
+ if (tab) {
2831
+ if (isActiveAiChatTab(tab)) resetAiConversationAfterDelete();
2832
+ else closeAiChatTab(tab.id);
2833
+ }
2241
2834
  renderAiConversationHistory();
2242
2835
  toast("对话已清理");
2243
2836
  }
@@ -2373,8 +2966,11 @@ function applyAiConversationTitle(title, conversationId = state.aiConversationId
2373
2966
  if (!title || !conversationId) return;
2374
2967
  const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
2375
2968
  if (current) current.title = title;
2969
+ const tab = aiChatTabManager.findByConversation(conversationId);
2970
+ if (tab) tab.title = title;
2376
2971
  if (state.aiConversationId === conversationId) $("#ai-conversation-title").textContent = title;
2377
2972
  renderAiConversationHistory();
2973
+ renderAiChatTabs();
2378
2974
  }
2379
2975
 
2380
2976
  function applyAiConversations(pageResult) {
@@ -2418,7 +3014,22 @@ async function ensureAiConversationsLoaded() {
2418
3014
 
2419
3015
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2420
3016
  if (!state.work) return null;
2421
- const navigation = beginAiConversationNavigation("已切换 AI 对话");
3017
+ const existingTab = aiChatTabManager.findByConversation(conversationId);
3018
+ const existingMessage = focusMessageId
3019
+ ? existingTab?.feed.querySelector(`[data-message-id="${CSS.escape(String(focusMessageId))}"]`)
3020
+ : null;
3021
+ if (existingTab && (!focusMessageId || existingMessage || aiRequestManager.hasActive(existingTab.id))) {
3022
+ activateAiChatTab(existingTab.id);
3023
+ if (focusMessageId && existingMessage) focusAiConversationMessage(focusMessageId);
3024
+ if (hideHistory) setAiHistoryVisible(false);
3025
+ return state.aiConversations.find((conversation) => conversation.id === conversationId) ?? existingTab;
3026
+ }
3027
+ let openingSlot = existingTab ? { allowed: true, tab: existingTab, replacing: false } : aiChatTabOpeningSlot();
3028
+ if (!openingSlot.allowed) {
3029
+ reportAiChatTabLimit();
3030
+ return null;
3031
+ }
3032
+ const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
2422
3033
  try {
2423
3034
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
2424
3035
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
@@ -2429,28 +3040,18 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
2429
3040
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2430
3041
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
2431
3042
  upsertAiConversationSummary(conversation);
2432
- state.aiConversationId = conversation.id;
2433
- state.aiConversationModelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
2434
- state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
2435
- applyAiConversationTaskType(conversation.taskType);
2436
- applyAiConversationContextScope(conversation.contextScope);
2437
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
2438
- resetAiContextMeter();
2439
- $("#ai-conversation-title").textContent = conversation.title;
2440
- resetAiFeed();
2441
- for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
2442
- if (conversation.hasCompactedSummary) {
2443
- renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
2444
- }
2445
- state.aiCitations = [];
2446
- state.aiReferences = [];
2447
- setAiPromptText("");
2448
- renderAiCitations();
2449
- renderAiReferences();
2450
- renderAiQuickActions();
2451
- renderAiConversationHistory();
2452
- if (conversation.contextWarningPending) showAiContextWarning();
2453
- else hideAiContextWarning();
3043
+ persistActiveAiChatTab();
3044
+ if (!existingTab) openingSlot = aiChatTabOpeningSlot();
3045
+ if (!openingSlot.allowed) {
3046
+ reportAiChatTabLimit();
3047
+ return null;
3048
+ }
3049
+ interruptAiChatTabForReplacement(openingSlot);
3050
+ let tab = existingTab ?? openingSlot.tab;
3051
+ if (!tab) tab = createAiChatTabState();
3052
+ applyConversationToAiChatTab(tab, conversation);
3053
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3054
+ if (focusMessageId) window.requestAnimationFrame(() => focusAiConversationMessage(focusMessageId));
2454
3055
  if (hideHistory) setAiHistoryVisible(false);
2455
3056
  return conversation;
2456
3057
  } catch (error) {
@@ -2470,30 +3071,60 @@ function focusAiConversationMessage(messageId) {
2470
3071
  window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
2471
3072
  }
2472
3073
 
2473
- function applyNewAiConversation(conversation) {
3074
+ function applyConversationToAiChatTab(tab, conversation) {
3075
+ tab.workId = String(conversation.workId ?? state.work?.id ?? "");
3076
+ tab.conversationId = conversation.id;
3077
+ tab.title = conversation.title || "新对话";
3078
+ tab.modelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
3079
+ tab.selectedModelId = tab.modelId ?? tab.selectedModelId;
3080
+ tab.promptSent = Array.isArray(conversation.messages) && conversation.messages.some((message) => message.role === "user");
3081
+ tab.taskType = conversation.taskType ?? "chat";
3082
+ tab.contextScope = conversation.contextScope ?? { type: "none" };
3083
+ tab.roleplayCharacter = conversation.roleplayCharacter ?? null;
3084
+ tab.citations = [];
3085
+ tab.references = [];
3086
+ tab.composer = { text: "", citations: [], references: [] };
3087
+ tab.contextUsage = null;
3088
+ tab.contextWarning = conversation.contextWarningPending === true;
3089
+ resetAiFeed(tab.feed, tab.roleplayCharacter);
3090
+ for (const message of conversation.messages ?? []) {
3091
+ appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id, { tab });
3092
+ }
3093
+ if (conversation.hasCompactedSummary) {
3094
+ renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount, tab.feed, conversation);
3095
+ }
3096
+ renderAiChatTabs();
3097
+ }
3098
+
3099
+ function applyNewAiConversation(conversation, tab = activeAiChatTab()) {
2474
3100
  upsertAiConversationSummary(conversation);
2475
- state.aiConversationId = conversation.id;
2476
- state.aiConversationModelId = null;
2477
- state.aiPromptSent = false;
2478
- applyAiConversationTaskType(conversation.taskType);
2479
- applyAiConversationContextScope(conversation.contextScope);
2480
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
2481
- $("#ai-conversation-title").textContent = conversation.title;
2482
- resetAiFeed();
2483
- hideAiContextWarning();
2484
- resetAiContextMeter();
2485
- renderAiQuickActions();
3101
+ applyConversationToAiChatTab(tab, { ...conversation, messages: [] });
3102
+ if (isActiveAiChatTab(tab)) applyAiChatTabState(tab);
2486
3103
  setAiHistoryVisible(false);
2487
3104
  }
2488
3105
 
2489
3106
  async function createNewAiConversation(taskType = "chat") {
2490
3107
  if (!state.work) return null;
2491
- const navigation = beginAiConversationNavigation("已新建 AI 对话");
3108
+ let openingSlot = aiChatTabOpeningSlot();
3109
+ if (!openingSlot.allowed) {
3110
+ reportAiChatTabLimit();
3111
+ return null;
3112
+ }
3113
+ const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
2492
3114
  try {
2493
3115
  const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2494
3116
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2495
3117
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("新对话不属于当前作品");
2496
- applyNewAiConversation(conversation);
3118
+ persistActiveAiChatTab();
3119
+ openingSlot = aiChatTabOpeningSlot();
3120
+ if (!openingSlot.allowed) {
3121
+ reportAiChatTabLimit();
3122
+ return null;
3123
+ }
3124
+ interruptAiChatTabForReplacement(openingSlot);
3125
+ const tab = openingSlot.tab ?? createAiChatTabState();
3126
+ applyNewAiConversation(conversation, tab);
3127
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2497
3128
  return conversation;
2498
3129
  } catch (error) {
2499
3130
  if (!isAiConversationNavigationCurrent(navigation)) return null;
@@ -2575,6 +3206,8 @@ function syncAiTaskOptions() {
2575
3206
  function applyAiConversationTaskType(taskType) {
2576
3207
  const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
2577
3208
  state.aiTaskType = normalizedTaskType;
3209
+ const tab = activeAiChatTab();
3210
+ if (tab) tab.taskType = normalizedTaskType;
2578
3211
  $("#ai-task").value = normalizedTaskType;
2579
3212
  $("#ai-task").dataset.previousValue = normalizedTaskType;
2580
3213
  syncAiTaskOptions();
@@ -2583,6 +3216,8 @@ function applyAiConversationTaskType(taskType) {
2583
3216
  function applyAiConversationContextScope(scope) {
2584
3217
  const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
2585
3218
  state.aiContextScope = normalizedScope;
3219
+ const tab = activeAiChatTab();
3220
+ if (tab) tab.contextScope = { ...normalizedScope };
2586
3221
  $("#ai-scope").value = normalizedScope.type === "book" ? "book"
2587
3222
  : normalizedScope.type === "volume" ? "volume"
2588
3223
  : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
@@ -2593,6 +3228,8 @@ function applyAiConversationContextScope(scope) {
2593
3228
 
2594
3229
  function applyAiRoleplayCharacter(character) {
2595
3230
  state.aiRoleplayCharacter = character?.id ? character : null;
3231
+ const tab = activeAiChatTab();
3232
+ if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2596
3233
  const active = Boolean(state.aiRoleplayCharacter);
2597
3234
  if (active) $("#ai-scope").value = "none";
2598
3235
  $(".ai-panel").classList.toggle("is-roleplaying", active);
@@ -2658,6 +3295,8 @@ async function persistAiConversationContextScope(scope) {
2658
3295
 
2659
3296
  async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2660
3297
  let request = assertAiRequestCurrent(requestHolder.snapshot);
3298
+ const tab = aiChatTabForRequest(request);
3299
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
2661
3300
  if (!request.conversationId) {
2662
3301
  const conversation = await api(`/api/works/${encodeURIComponent(request.workId)}/ai-conversations`, {
2663
3302
  method: "POST",
@@ -2666,7 +3305,7 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2666
3305
  });
2667
3306
  assertAiRequestCurrent(request);
2668
3307
  if (String(conversation.workId ?? "") !== request.workId) throw new Error("新对话不属于请求作品");
2669
- applyNewAiConversation(conversation);
3308
+ applyNewAiConversation(conversation, tab);
2670
3309
  requestHolder.snapshot = aiRequestManager.bind(request, { conversationId: conversation.id });
2671
3310
  request = requestHolder.snapshot;
2672
3311
  }
@@ -2677,8 +3316,12 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2677
3316
  });
2678
3317
  assertAiRequestCurrent(request);
2679
3318
  upsertAiConversationSummary(taskConversation);
2680
- applyAiConversationTaskType(taskConversation.taskType);
2681
- applyAiRoleplayCharacter(taskConversation.roleplayCharacter);
3319
+ tab.taskType = taskConversation.taskType;
3320
+ tab.roleplayCharacter = taskConversation.roleplayCharacter ?? null;
3321
+ if (isActiveAiChatTab(tab)) {
3322
+ applyAiConversationTaskType(tab.taskType);
3323
+ applyAiRoleplayCharacter(tab.roleplayCharacter);
3324
+ }
2682
3325
 
2683
3326
  const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
2684
3327
  method: "PATCH",
@@ -2687,7 +3330,8 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2687
3330
  });
2688
3331
  assertAiRequestCurrent(request);
2689
3332
  upsertAiConversationSummary(scopedConversation);
2690
- applyAiConversationContextScope(scopedConversation.contextScope);
3333
+ tab.contextScope = scopedConversation.contextScope;
3334
+ if (isActiveAiChatTab(tab)) applyAiConversationContextScope(tab.contextScope);
2691
3335
  return request;
2692
3336
  }
2693
3337
 
@@ -2711,13 +3355,29 @@ async function persistAiRequestInterruption(request, interruption = null) {
2711
3355
  const partialContent = typeof interruption?.content === "string" && interruption.content.trim()
2712
3356
  ? interruption.content
2713
3357
  : null;
3358
+ const cancellationMessage = request.signal.reason instanceof Error
3359
+ ? request.signal.reason.message
3360
+ : "AI 请求已取消";
3361
+ const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
3362
+ const metadata = cancelledByClient
3363
+ ? {
3364
+ ...(partialContent ? interruption.metadata : {}),
3365
+ interrupted: true,
3366
+ interruptionCode: "AI_REQUEST_CANCELLED",
3367
+ interruptionMessage: cancellationMessage.slice(0, 500)
3368
+ }
3369
+ : interruption?.metadata ?? {
3370
+ interrupted: true,
3371
+ interruptionCode: "AI_REQUEST_CANCELLED",
3372
+ interruptionMessage: cancellationMessage.slice(0, 500)
3373
+ };
2714
3374
  try {
2715
3375
  const message = await persistAiConversationMessage(
2716
3376
  request.conversationId,
2717
3377
  "assistant",
2718
- partialContent ?? "调用失败:生成已取消,尚未收到可保留的回复内容。",
3378
+ partialContent ?? "生成已终止,尚未收到可保留的回复内容。",
2719
3379
  [],
2720
- partialContent ? interruption.metadata : {},
3380
+ metadata,
2721
3381
  { requestId: aiAssistantRequestId(request) }
2722
3382
  );
2723
3383
  updateAiConversationSummaryFromMessage(message);
@@ -3326,6 +3986,7 @@ async function api(path, options = {}) {
3326
3986
  const payload = await response.json();
3327
3987
  if (path === "/api/health") {
3328
3988
  applyImageUploadLimits(payload.data?.uploadLimits);
3989
+ applyAiChatTabLimit(payload.data?.aiChatTabLimit);
3329
3990
  updateSystemHealth({
3330
3991
  status: payload.data?.status === "ok" ? "ready" : "degraded",
3331
3992
  version: payload.data?.version
@@ -3412,6 +4073,7 @@ function aiStreamInterruptionLabel(code) {
3412
4073
  if (code === "AI_STREAM_NETWORK_ERROR") return "网络中断";
3413
4074
  if (code === "AI_STREAM_UPSTREAM_CLOSED") return "流被关闭";
3414
4075
  if (code === "AI_STREAM_REQUEST_CANCELLED") return "请求已取消";
4076
+ if (code === "AI_REQUEST_CANCELLED") return "已终止";
3415
4077
  return "生成中断";
3416
4078
  }
3417
4079
 
@@ -3543,8 +4205,9 @@ function invalidateModuleRequestsAfterMutation(path, method) {
3543
4205
 
3544
4206
  function applyProductHealthMetadata(health) {
3545
4207
  const version = String(health?.version ?? "").trim();
4208
+ const versionLabel = String(health?.versionLabel ?? "").trim();
3546
4209
  document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
3547
- element.textContent = version ? `v${version}` : "v—";
4210
+ element.textContent = versionLabel || (version ? `v${version}` : "v—");
3548
4211
  });
3549
4212
  document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
3550
4213
  element.classList.toggle("hidden", health?.development !== true);
@@ -3898,11 +4561,11 @@ function dismissChapterInsightToast() {
3898
4561
  }
3899
4562
  }
3900
4563
 
3901
- function toast(message, type = "info") {
4564
+ function toast(message, type = "info", extraClass = "") {
3902
4565
  if (systemRestartDetected || (!state.user && document.documentElement.classList.contains("login-route"))) return;
3903
4566
  const region = $("#toast-region");
3904
4567
  const element = document.createElement("div");
3905
- element.className = `toast ${type}`;
4568
+ element.className = `toast ${type}${extraClass ? ` ${extraClass}` : ""}`;
3906
4569
  element.setAttribute("role", type === "error" ? "alert" : "status");
3907
4570
  element.setAttribute("aria-atomic", "true");
3908
4571
  element.textContent = message;
@@ -4251,7 +4914,6 @@ async function persistChapter({ automatic = false } = {}) {
4251
4914
  const currentDraft = chapterDraftSnapshot();
4252
4915
  if (sameChapterSnapshot(currentDraft, draft)) {
4253
4916
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
4254
- if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
4255
4917
  } else {
4256
4918
  scheduleChapterAutoSave(250);
4257
4919
  }
@@ -5324,12 +5986,29 @@ function syncGlobalReplaceScopeOptions() {
5324
5986
  const selected = options.find((option) => option.checked && !option.disabled) ?? options.find((option) => !option.disabled);
5325
5987
  options.forEach((option) => { option.checked = option === selected; });
5326
5988
  const scope = selected?.value ?? "";
5989
+ const volumeSelect = $("#replace-volume");
5990
+ const volumeEnabled = scope === "prose" || scope === "prose-and-settings";
5991
+ volumeSelect.disabled = !volumeEnabled || !canEditProse();
5992
+ $("#replace-volume-description").textContent = scope === "settings"
5993
+ ? "当前选择了设定库,分卷筛选不会生效。"
5994
+ : "选择分卷后,只替换该分卷的章节正文;设定库不按分卷筛选。";
5327
5995
  $("#replace-submit").disabled = !scope || !canGlobalReplaceScope(scope);
5328
5996
  $("#replace-permission-note").textContent = scope
5329
5997
  ? "替换完成后,命中的章节和设定会分别生成新的版本历史。"
5330
5998
  : "当前账户没有可写入的正文或设定库权限。";
5331
5999
  }
5332
6000
 
6001
+ function renderGlobalReplaceVolumeOptions() {
6002
+ const select = $("#replace-volume");
6003
+ const currentValue = select.value;
6004
+ const volumes = Array.isArray(state.work?.volumes) ? state.work.volumes : [];
6005
+ select.innerHTML = [
6006
+ '<option value="">全部分卷(整部作品正文)</option>',
6007
+ ...volumes.map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`)
6008
+ ].join("");
6009
+ select.value = volumes.some((volume) => String(volume.id) === currentValue) ? currentValue : "";
6010
+ }
6011
+
5333
6012
  function openGlobalReplaceDialog() {
5334
6013
  if (!state.work) {
5335
6014
  toast("请先打开一部作品", "error");
@@ -5341,6 +6020,7 @@ function openGlobalReplaceDialog() {
5341
6020
  }
5342
6021
  if ($("#search-dialog").open) $("#search-dialog").close();
5343
6022
  $("#replace-form").reset();
6023
+ renderGlobalReplaceVolumeOptions();
5344
6024
  syncGlobalReplaceScopeOptions();
5345
6025
  $("#replace-dialog").showModal();
5346
6026
  queueMicrotask(() => $("#replace-find").focus());
@@ -5428,6 +6108,7 @@ async function submitGlobalReplace(event) {
5428
6108
  const find = $("#replace-find").value;
5429
6109
  const replacement = $("#replace-with").value;
5430
6110
  const scope = $("#replace-form").querySelector('input[name="replaceScope"]:checked')?.value ?? "prose";
6111
+ const volumeId = scope === "settings" ? null : ($("#replace-volume").value || null);
5431
6112
  if (!find.trim()) {
5432
6113
  toast("请输入要查找的内容", "error");
5433
6114
  $("#replace-find").focus();
@@ -5450,8 +6131,14 @@ async function submitGlobalReplace(event) {
5450
6131
  return;
5451
6132
  }
5452
6133
  const scopeLabel = globalReplaceScopeLabels[scope] ?? "正文";
6134
+ const selectedVolume = volumeId
6135
+ ? state.work.volumes.find((volume) => String(volume.id) === volumeId)
6136
+ : null;
6137
+ const targetLabel = selectedVolume
6138
+ ? `${scopeLabel}(正文仅限“${selectedVolume.title}”分卷${scope === "prose-and-settings" ? ",设定库仍为全部设定库" : ""})`
6139
+ : scopeLabel;
5453
6140
  const replacementLabel = replacement ? `“${replacement}”` : "空内容";
5454
- const confirmed = await confirmToast(`将把《${state.work.title}》的${scopeLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
6141
+ const confirmed = await confirmToast(`将把《${state.work.title}》的${targetLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
5455
6142
  title: "确认全局替换",
5456
6143
  confirmLabel: "确认替换"
5457
6144
  });
@@ -5476,7 +6163,7 @@ async function submitGlobalReplace(event) {
5476
6163
  try {
5477
6164
  const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
5478
6165
  method: "POST",
5479
- body: { find, replacement, scope },
6166
+ body: { find, replacement, scope, volumeId },
5480
6167
  skipOptimisticVersion: true
5481
6168
  });
5482
6169
  $("#replace-dialog").close();
@@ -5798,23 +6485,9 @@ function resetWorkScopedUiCaches() {
5798
6485
  volumeChapterRequests.clear();
5799
6486
  state.collapsedRaceIds.clear();
5800
6487
  lastSavedChapterSnapshot = null;
5801
- state.aiCitations = [];
5802
- state.aiReferences = [];
5803
- state.aiPromptSent = false;
5804
- state.aiConversationId = null;
5805
- state.aiConversationModelId = null;
5806
6488
  state.aiConversations = [];
5807
- state.aiRoleplayCharacter = null;
5808
- renderAiCitations();
5809
- renderAiReferences();
5810
- renderAiQuickActions();
5811
- resetAiFeed();
5812
- applyAiConversationTaskType("chat");
5813
- applyAiConversationContextScope({ type: "none" });
5814
- applyAiRoleplayCharacter(null);
5815
- $("#ai-conversation-title").textContent = "新对话";
6489
+ resetAiChatTabs();
5816
6490
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
5817
- resetAiContextMeter();
5818
6491
  renderAiConversationHistory();
5819
6492
  }
5820
6493
 
@@ -5959,7 +6632,7 @@ function renderTree() {
5959
6632
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
5960
6633
  <div class="volume-title">
5961
6634
  <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
5962
- ${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情">详情</button>` : ""}
6635
+ ${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情" title="打开“${esc(volume.title)}”分卷详情"><svg class="volume-detail-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 10.5v5.5M12 7.5h.01"></path></svg></button>` : ""}
5963
6636
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
5964
6637
  </div>
5965
6638
  <div class="volume-chapters">
@@ -6471,6 +7144,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6471
7144
  if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
6472
7145
  return false;
6473
7146
  }
7147
+ resetChapterSearchPanel();
6474
7148
  dismissDeleteToasts();
6475
7149
  if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6476
7150
  cancelChapterAutoSave();
@@ -6986,22 +7660,15 @@ function handleReadingKeyboard(event) {
6986
7660
  }
6987
7661
  }
6988
7662
 
6989
- function handleReadingWheel(event) {
6990
- if (readingPreferences.mode !== "scroll" || readingLoading || Math.abs(event.deltaY) < 2) return;
6991
- const viewport = $("#reader-viewport");
6992
- const atEnd = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 2;
6993
- const atStart = viewport.scrollTop <= 2;
6994
- if (event.deltaY > 0 && atEnd) {
6995
- event.preventDefault();
6996
- void navigateReadingChapter(1, { continueFromBoundary: true });
6997
- } else if (event.deltaY < 0 && atStart) {
6998
- event.preventDefault();
6999
- void navigateReadingChapter(-1, { continueFromBoundary: true });
7000
- }
7001
- }
7002
-
7003
7663
  async function saveChapter() {
7004
- return persistChapter({ automatic: false });
7664
+ const dismissSavingToast = persistentToast("正在保存中");
7665
+ try {
7666
+ const saved = await persistChapter({ automatic: false });
7667
+ if (saved) toast(`保存成功(正文 v${saved.versionNo})`);
7668
+ return saved;
7669
+ } finally {
7670
+ dismissSavingToast();
7671
+ }
7005
7672
  }
7006
7673
 
7007
7674
  function tidyChapterBlankLines() {
@@ -7032,7 +7699,7 @@ function showWelcome(hasWork = false) {
7032
7699
  }
7033
7700
 
7034
7701
  function markActiveModule(module) {
7035
- $("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module));
7702
+ $("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module || (button.id === "ai-assistant-entry" && aiConversationWorkspaceOpen)));
7036
7703
  }
7037
7704
 
7038
7705
  const moduleMeta = {
@@ -7830,7 +8497,10 @@ async function renderSettings(page = moduleListPages.settings) {
7830
8497
  }
7831
8498
 
7832
8499
  async function renderCharacters(page = characterListPage) {
7833
- const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
8500
+ const hasCharacterFilters = characterFilters.raceIds.length > 0
8501
+ || characterFilters.organizationIds.length > 0
8502
+ || characterFilters.genderValues.length > 0
8503
+ || characterFilters.deathState !== "all";
7834
8504
  const pageSize = pageSizeFor("characters");
7835
8505
  const [characterSource, races, organizations] = await Promise.all([
7836
8506
  hasCharacterFilters
@@ -7858,6 +8528,7 @@ async function renderCharacters(page = characterListPage) {
7858
8528
  <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
7859
8529
  <div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
7860
8530
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
8531
+ <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
7861
8532
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
7862
8533
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
7863
8534
  ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
@@ -7871,6 +8542,7 @@ async function renderCharacters(page = characterListPage) {
7871
8542
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
7872
8543
  const meta = [
7873
8544
  item.code ? `编号 ${item.code}` : "",
8545
+ `性别 ${characterGenderLabel(item.gender)}`,
7874
8546
  item.species ? (racePathLabel(item.race) || item.species) : "",
7875
8547
  ...(item.aliases ?? []).slice(0, 3),
7876
8548
  (item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
@@ -7892,16 +8564,25 @@ async function renderCharacters(page = characterListPage) {
7892
8564
  : "";
7893
8565
  const selectedRaceIds = new Set(characterFilters.raceIds);
7894
8566
  const selectedOrganizationIds = new Set(characterFilters.organizationIds);
8567
+ const selectedGenderValues = new Set(characterFilters.genderValues);
7895
8568
  const selectedRaceNames = races.filter((race) => selectedRaceIds.has(String(race.id))).map((race) => racePathLabel(race) || race.name);
7896
8569
  const selectedOrganizationNames = organizations.filter((organization) => selectedOrganizationIds.has(String(organization.id))).map((organization) => organization.name);
8570
+ const selectedGenderNames = CHARACTER_GENDER_OPTIONS
8571
+ .filter(([value]) => selectedGenderValues.has(value))
8572
+ .map(([, label]) => label);
8573
+ const selectedDeathStateLabel = CHARACTER_DEATH_STATE_OPTIONS.find(([value]) => value === characterFilters.deathState)?.[1] ?? "全部状态";
7897
8574
  const filterOptionList = (items, selectedIds, valueKey = "id") => items.map((item) => {
7898
8575
  const value = String(item[valueKey]);
7899
8576
  const label = valueKey === "id" ? (racePathLabel(item) || item.name) : item.name;
7900
8577
  return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
7901
8578
  }).join("");
7902
- const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
8579
+ const genderFilterOptions = CHARACTER_GENDER_OPTIONS.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedGenderValues.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("");
8580
+ const deathStateFilterOptions = CHARACTER_DEATH_STATE_OPTIONS.map(([value, label]) => `<label class="character-filter-option"><input type="radio" name="character-death-state" value="${esc(value)}" ${characterFilters.deathState === value ? "checked" : ""}><span>${esc(label)}</span></label>`).join("");
8581
+ const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar character-filter-toolbar-with-extra-filters${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
7903
8582
  <details class="character-filter-dropdown"><summary><span>按种族筛选</span><strong>${selectedRaceNames.length ? `已选 ${selectedRaceNames.length} 项` : "全部种族"}</strong></summary><div id="character-race-filter" class="character-filter-options">${filterOptionList(orderRaceFilterOptions(races), selectedRaceIds)}</div></details>
7904
8583
  <details class="character-filter-dropdown"><summary><span>按组织筛选</span><strong>${selectedOrganizationNames.length ? `已选 ${selectedOrganizationNames.length} 项` : "全部组织"}</strong></summary><div id="character-organization-filter" class="character-filter-options">${filterOptionList(organizations, selectedOrganizationIds, "id")}</div></details>
8584
+ <details class="character-filter-dropdown"><summary><span>按性别筛选</span><strong>${selectedGenderNames.length ? `已选 ${selectedGenderNames.length} 项` : "全部性别"}</strong></summary><div id="character-gender-filter" class="character-filter-options">${genderFilterOptions}</div></details>
8585
+ <details class="character-filter-dropdown"><summary><span>按死亡状态筛选</span><strong>${esc(selectedDeathStateLabel)}</strong></summary><div id="character-death-filter" class="character-filter-options">${deathStateFilterOptions}</div></details>
7905
8586
  <div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
7906
8587
  </section>`;
7907
8588
  mountCharacterFilterToggle();
@@ -7923,10 +8604,24 @@ async function renderCharacters(page = characterListPage) {
7923
8604
  characterListPage = 1;
7924
8605
  await renderCharacters(1);
7925
8606
  });
8607
+ $("#character-gender-filter").addEventListener("change", async () => {
8608
+ characterFiltersPanelOpen = true;
8609
+ characterFilters.genderValues = readSelectedValues("#character-gender-filter");
8610
+ characterListPage = 1;
8611
+ await renderCharacters(1);
8612
+ });
8613
+ $("#character-death-filter").addEventListener("change", async (event) => {
8614
+ characterFiltersPanelOpen = true;
8615
+ characterFilters.deathState = CHARACTER_DEATH_STATE_OPTIONS.some(([value]) => value === event.target.value) ? event.target.value : "all";
8616
+ characterListPage = 1;
8617
+ await renderCharacters(1);
8618
+ });
7926
8619
  $("#clear-character-filters")?.addEventListener("click", async () => {
7927
8620
  characterFiltersPanelOpen = true;
7928
8621
  characterFilters.raceIds = [];
7929
8622
  characterFilters.organizationIds = [];
8623
+ characterFilters.genderValues = [];
8624
+ characterFilters.deathState = "all";
7930
8625
  characterListPage = 1;
7931
8626
  await renderCharacters(1);
7932
8627
  });
@@ -8521,9 +9216,9 @@ async function renderRelationships(page = moduleListPages.relationships) {
8521
9216
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
8522
9217
  const graph = buildRelationshipGraph(state.characters, relationships);
8523
9218
  state.relationshipGraph = graph;
8524
- const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
9219
+ const relationshipList = filteredRelationships.length ? `<div class="relationship-table-wrapper"><table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
8525
9220
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
8526
- <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
9221
+ <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table></div>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
8527
9222
  $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
8528
9223
  bindModulePagination("relationships", renderRelationships);
8529
9224
  const openGalaxy = () => {
@@ -8531,6 +9226,8 @@ async function renderRelationships(page = moduleListPages.relationships) {
8531
9226
  const galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, {
8532
9227
  workId: state.work.id,
8533
9228
  frameRate: state.uiSettings.galaxyFrameRate,
9229
+ motionMode: storedGalaxyMotionMode(),
9230
+ onMotionModeChange: persistGalaxyMotionMode,
8534
9231
  onClose: () => {
8535
9232
  if (state.galaxy === galaxy) state.galaxy = null;
8536
9233
  }
@@ -8707,7 +9404,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8707
9404
  moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
8708
9405
  canReadModule("ai-settings")
8709
9406
  ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
8710
- : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
9407
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunStabilityDelayMinutes: 2, autoRunPaused: false })
8711
9408
  ]);
8712
9409
  if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
8713
9410
  taskListPage = taskPage.page;
@@ -8744,6 +9441,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8744
9441
  <div class="task-auto-run-copy">
8745
9442
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
8746
9443
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
9444
+ <small>正文停止编辑后,等待稳定窗口结束才会创建章节理解任务。</small>
8747
9445
  <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
8748
9446
  </div>
8749
9447
  <div class="task-auto-run-actions">
@@ -8755,11 +9453,13 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8755
9453
  <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
8756
9454
  <label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-daily-help"></label>
8757
9455
  <label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
9456
+ <label>停止编辑后创建任务<input id="task-auto-run-stability-delay" type="number" min="1" max="120" value="${esc(String(settings.autoRunStabilityDelayMinutes ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-stability-help"></label>
8758
9457
  ${autoRunEditing
8759
9458
  ? `<button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button><button id="task-auto-run-pause" class="ghost-button" type="button">${autoRunPauseLabel}</button>`
8760
9459
  : ""}
8761
9460
  </div>
8762
9461
  <p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
9462
+ <p id="task-auto-run-stability-help" class="task-auto-run-help">正文连续 ${esc(String(settings.autoRunStabilityDelayMinutes ?? 2))} 分钟没有编辑,才会创建章节理解任务;最低 1 分钟。</p>
8763
9463
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
8764
9464
  ${autoRunPaused ? `<p class="task-auto-run-alert" role="status"><strong>自动执行已暂停</strong><span>${esc(settings.autoRunPauseReason || "需要人工确认后恢复")}</span>${settings.autoRunResumeAt ? `<small>预计 ${esc(formatDateTime(settings.autoRunResumeAt))} 自动恢复</small>` : ""}</p>` : ""}
8765
9465
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
@@ -8813,7 +9513,8 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8813
9513
  autoRunEnabled: $("#task-auto-run-enabled").checked,
8814
9514
  autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
8815
9515
  autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
8816
- autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value)
9516
+ autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value),
9517
+ autoRunStabilityDelayMinutes: Number($("#task-auto-run-stability-delay").value)
8817
9518
  }
8818
9519
  });
8819
9520
  toast(updated.autoRunEnabled
@@ -9320,7 +10021,8 @@ function renderCharacterExtractionEditor(preview) {
9320
10021
  if (!items.length) return '<p class="character-extraction-empty">没有可应用的角色候选。</p>';
9321
10022
  return `<div class="character-extraction-editor" data-character-extraction-editor data-preview-token="${esc(preview.previewToken)}">
9322
10023
  <div class="character-extraction-policy" role="note"><strong>合并规则</strong><span>优先使用任务保存的稳定角色引用,其次使用当前标准名和别名匹配。合并只追加无冲突别名、空缺身份、种族和首次登场;已有非空字段保持不变。</span></div>
9323
- <div class="character-extraction-editor-toolbar"><span data-character-extraction-selected-count>已选择 0 项</span><button class="ghost-button" type="button" data-character-extraction-select-all>全选可处理项</button><button class="ghost-button" type="button" data-character-extraction-clear>全部跳过</button></div>
10024
+ <div class="character-extraction-editor-toolbar"><button class="primary-button character-extraction-apply-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><span data-character-extraction-selected-count>已选择 0 项</span><button class="ghost-button" type="button" data-character-extraction-select-all>全选可处理项</button><button class="ghost-button" type="button" data-character-extraction-clear>全部跳过</button></div>
10025
+ <p class="character-extraction-apply-note">确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</p>
9324
10026
  <div class="character-extraction-candidate-list">${items.map((item, index) => {
9325
10027
  const matches = Array.isArray(item.matchCandidates) ? item.matchCandidates : [];
9326
10028
  const suggestedAction = item.suggestedAction === "merge" && matches.length ? "merge" : item.suggestedAction === "skip" ? "skip" : "create";
@@ -9351,7 +10053,6 @@ function renderCharacterExtractionEditor(preview) {
9351
10053
  </article>`;
9352
10054
  }).join("")}</div>
9353
10055
  <p class="character-extraction-apply-error hidden" data-character-extraction-error role="alert"></p>
9354
- <div class="character-extraction-apply-actions"><button class="primary-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><small>确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</small></div>
9355
10056
  </div>`;
9356
10057
  }
9357
10058
 
@@ -9736,7 +10437,7 @@ function renderProviderCards(providers, models) {
9736
10437
  : "";
9737
10438
  return `
9738
10439
  <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
9739
- ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
10440
+ ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
9740
10441
  <div class="provider-models">${providerModels.map((model) => {
9741
10442
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
9742
10443
  const modelStatus = !model.enabled
@@ -9746,7 +10447,8 @@ function renderProviderCards(providers, models) {
9746
10447
  : "";
9747
10448
  const capability = model.multimodalEnabled ? " · 多模态" : "";
9748
10449
  const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
9749
- return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
10450
+ const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
10451
+ return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
9750
10452
  }).join("")}</div>
9751
10453
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
9752
10454
  }).join("")}</div>`
@@ -10071,7 +10773,12 @@ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
10071
10773
  backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
10072
10774
  backgroundTaskCenterSnapshot.taskPage = taskResult.value;
10073
10775
  if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
10074
- for (const transition of transitionResult.transitions) {
10776
+ const announcementResult = filterBackgroundTaskTransitionsForAnnouncement(
10777
+ transitionResult.transitions,
10778
+ backgroundTaskCenterExpiredNoticeTimes
10779
+ );
10780
+ backgroundTaskCenterExpiredNoticeTimes = announcementResult.noticeTimes;
10781
+ for (const transition of announcementResult.transitions) {
10075
10782
  const notification = backgroundTaskTransitionMessage(transition);
10076
10783
  toast(notification.message, notification.type);
10077
10784
  }
@@ -10099,6 +10806,7 @@ function stopBackgroundTaskCenter() {
10099
10806
  backgroundTaskCenterWorkId = null;
10100
10807
  backgroundTaskCenterTasksInitialized = false;
10101
10808
  backgroundTaskCenterTaskSnapshots = new Map();
10809
+ backgroundTaskCenterExpiredNoticeTimes = new Map();
10102
10810
  backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
10103
10811
  const dialog = $("#background-task-dialog");
10104
10812
  if (dialog?.open) dialog.close();
@@ -10312,7 +11020,7 @@ async function renderBookAiSettings() {
10312
11020
  const host = $("#module-content");
10313
11021
  const workId = String(state.work.id);
10314
11022
  const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
10315
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
11023
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
10316
11024
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
10317
11025
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
10318
11026
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
@@ -10345,7 +11053,7 @@ async function renderBookAiSettings() {
10345
11053
  );
10346
11054
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
10347
11055
  "beforeend",
10348
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label>`
11056
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算日期差值,或从起始日期推算目标日期,不读取作品内容。</small></span></label>`
10349
11057
  );
10350
11058
  if (!canEditModule("ai-settings")) {
10351
11059
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -10737,6 +11445,18 @@ function resetAiContextMeter() {
10737
11445
  setAiContextMeter(null);
10738
11446
  }
10739
11447
 
11448
+ function setAiChatTabContextUsage(tab, usage, allowShrink = true) {
11449
+ if (!tab) return;
11450
+ if (isActiveAiChatTab(tab)) {
11451
+ setAiContextMeter(usage, allowShrink);
11452
+ tab.contextUsage = latestAiContextUsage;
11453
+ return;
11454
+ }
11455
+ tab.contextUsage = allowShrink
11456
+ ? resolveAiContextUsage(tab.contextUsage, usage)
11457
+ : mergeAiContextUsage(tab.contextUsage, usage, false);
11458
+ }
11459
+
10740
11460
  function setAiContextDistributionVisible(visible) {
10741
11461
  const meter = $("#ai-context-meter");
10742
11462
  const popover = $("#ai-context-popover");
@@ -10755,6 +11475,14 @@ function hideAiContextWarning() {
10755
11475
  $("#ai-context-warning").classList.add("hidden");
10756
11476
  }
10757
11477
 
11478
+ function setAiChatTabContextWarning(tab, visible) {
11479
+ if (!tab) return;
11480
+ tab.contextWarning = visible;
11481
+ if (!isActiveAiChatTab(tab)) return;
11482
+ if (visible) showAiContextWarning();
11483
+ else hideAiContextWarning();
11484
+ }
11485
+
10758
11486
  function setAiContextWarningActionsDisabled(disabled) {
10759
11487
  for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
10760
11488
  }
@@ -11605,8 +12333,11 @@ function renderCharacterEditorRelationships() {
11605
12333
  const category = relationshipCategoryLabel(relationship.category);
11606
12334
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
11607
12335
  const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
12336
+ const actions = !entityEditorReadOnly && canEditModule("relationships")
12337
+ ? `<div class="character-relationship-actions"><button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button><button class="danger-button" type="button" data-character-relationship-delete="${esc(relationship.id)}">删除关系</button></div>`
12338
+ : "";
11608
12339
  return `<article class="character-relationship-row">
11609
- <div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${!entityEditorReadOnly && canEditModule("relationships") ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
12340
+ <div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${actions}</div>
11610
12341
  <div class="character-relationship-keywords"><small>关系关键词</small><div>${keywords.map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || '<span class="character-relationship-empty-keywords">未填写关键词</span>'}</div></div>
11611
12342
  </article>`;
11612
12343
  }).join("");
@@ -11615,6 +12346,14 @@ function renderCharacterEditorRelationships() {
11615
12346
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
11616
12347
  if (relationship) void openRelationshipDialog(relationship, { characterId });
11617
12348
  }));
12349
+ host.querySelectorAll("[data-character-relationship-delete]").forEach((button) => button.addEventListener("click", () => {
12350
+ const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipDelete);
12351
+ if (!relationship) return;
12352
+ button.disabled = true;
12353
+ void deleteRelationshipRecord(relationship, characterId).finally(() => {
12354
+ if (button.isConnected) button.disabled = false;
12355
+ });
12356
+ }));
11618
12357
  host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
11619
12358
  }
11620
12359
 
@@ -11633,7 +12372,7 @@ async function loadCharacterEditorRelationships(characterId, { refresh = false }
11633
12372
  ]);
11634
12373
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
11635
12374
  state.characters = characters;
11636
- characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
12375
+ characterEditorRelationships = sortCharacterRelationships(relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId), characterId);
11637
12376
  characterEditorRelationshipsLoaded = true;
11638
12377
  loaded = true;
11639
12378
  } catch (error) {
@@ -11657,6 +12396,21 @@ async function refreshRelationshipSurfaces(characterId = null) {
11657
12396
  await Promise.all(tasks);
11658
12397
  }
11659
12398
 
12399
+ async function deleteRelationshipRecord(item, characterId = null) {
12400
+ const relationshipName = `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}`;
12401
+ if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return false;
12402
+ if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return false;
12403
+ try {
12404
+ await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
12405
+ await Promise.all([refreshRelationshipSurfaces(characterId), loadAiReferences()]);
12406
+ deleteToast(`已删除人物关系“${relationshipName}”`);
12407
+ return true;
12408
+ } catch (error) {
12409
+ toast(error.message, "error");
12410
+ return false;
12411
+ }
12412
+ }
12413
+
11660
12414
  const characterSectionTypeLabels = {
11661
12415
  overview: "基本档案",
11662
12416
  appearance: "外貌与生理",
@@ -12391,6 +13145,7 @@ function renderCharacterEditorFields(item) {
12391
13145
  $("#character-editor-fields").innerHTML = [
12392
13146
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
12393
13147
  field("name", "标准名", "text", item?.name) +
13148
+ field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
12394
13149
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
12395
13150
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
12396
13151
  (!canReadModule("races")
@@ -12444,6 +13199,7 @@ function collectCharacterBody(form) {
12444
13199
  delete profile.sections;
12445
13200
  const body = {
12446
13201
  name: String(form.get("name") ?? "").trim(),
13202
+ gender: String(form.get("gender") ?? "unknown"),
12447
13203
  isDead: form.has("isDead"),
12448
13204
  code: String(form.get("code") ?? "").trim(),
12449
13205
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
@@ -13048,7 +13804,6 @@ async function openRelationshipDialog(item, options = {}) {
13048
13804
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
13049
13805
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
13050
13806
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
13051
- const relationshipName = item ? `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}` : "";
13052
13807
  const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
13053
13808
  <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
13054
13809
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
@@ -13066,16 +13821,7 @@ async function openRelationshipDialog(item, options = {}) {
13066
13821
  dialog.showModal();
13067
13822
  };
13068
13823
  dialog.close();
13069
- if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return reopenDialog();
13070
- if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return reopenDialog();
13071
- try {
13072
- await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
13073
- await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
13074
- deleteToast(`已删除人物关系“${relationshipName}”`);
13075
- } catch (error) {
13076
- reopenDialog();
13077
- toast(error.message, "error");
13078
- }
13824
+ if (!await deleteRelationshipRecord(item, options.characterId ?? null)) return reopenDialog();
13079
13825
  });
13080
13826
  }
13081
13827
 
@@ -13087,11 +13833,12 @@ function openReviewDialog() {
13087
13833
  }
13088
13834
 
13089
13835
  async function openTaskDialog() {
13090
- const volumeOptions = state.work.volumes.map((volume) => ({ id: String(volume.id), title: String(volume.title) }));
13091
13836
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
13092
13837
  id: String(chapter.id),
13093
13838
  title: String(chapter.title),
13094
- volumeTitle: String(volume.title)
13839
+ volumeId: String(volume.id),
13840
+ volumeTitle: String(volume.title),
13841
+ chapterType: String(chapter.chapterType || "正文")
13095
13842
  })));
13096
13843
  let relationshipCharacters = [];
13097
13844
  let taskModels = [];
@@ -13108,23 +13855,73 @@ async function openTaskDialog() {
13108
13855
  toast(`分析任务配置加载失败:${error.message}`, "error");
13109
13856
  return;
13110
13857
  }
13111
- const taskScopePicker = (kind, label, options, emptyLabel) => `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13112
- <span id="task-${kind}-label">${esc(label)}(可多选)</span>
13113
- <div class="task-scope-picker">
13114
- <button class="task-scope-trigger" type="button" data-task-scope-trigger="${kind}" aria-expanded="false" aria-controls="task-${kind}-bubble" aria-labelledby="task-${kind}-label task-${kind}-summary" disabled>
13115
- <span id="task-${kind}-summary">${esc(emptyLabel)}</span>
13116
- <span class="task-scope-trigger-meta"><span data-task-scope-count="${kind}">未选择</span><span class="task-scope-chevron" aria-hidden="true">⌄</span></span>
13117
- </button>
13118
- <div id="task-${kind}-bubble" class="task-scope-bubble hidden" data-task-scope-bubble="${kind}">
13119
- <label class="task-scope-search">筛选${esc(label)}<input type="search" data-task-scope-search="${kind}" placeholder="输入名称" autocomplete="off"></label>
13120
- <div class="task-scope-bubble-meta"><span>共 ${options.length} ${kind === "chapter" ? "章" : "卷"}</span><button type="button" data-task-scope-clear="${kind}" disabled>清空选择</button></div>
13121
- <div class="task-scope-options" role="group" aria-labelledby="task-${kind}-label">
13122
- ${options.map((item) => `<label class="task-scope-option" data-task-scope-search-name="${esc(`${item.title} ${item.volumeTitle ?? ""}`.toLocaleLowerCase())}"><input type="checkbox" name="${kind === "chapter" ? "chapterIds" : "volumeIds"}" value="${esc(item.id)}" data-task-scope-input="${kind}"><span><strong>${esc(item.title)}</strong>${item.volumeTitle ? `<small>${esc(item.volumeTitle)}</small>` : ""}</span></label>`).join("")}
13858
+ const taskScopePicker = (options, emptyLabel) => {
13859
+ const kind = "chapter";
13860
+ const label = "章节";
13861
+ const inputName = "chapterIds";
13862
+ const renderOption = (item) => `<label class="task-scope-option" data-task-scope-search-name="${esc(`${item.title} ${item.volumeTitle ?? ""}`.toLocaleLowerCase())}">
13863
+ <input type="checkbox" name="${inputName}" value="${esc(item.id)}" data-task-scope-input="${kind}" data-task-scope-title="${esc(item.title)}" data-task-scope-subtitle="${esc(item.volumeTitle ?? "")}">
13864
+ <span class="task-scope-checkbox" aria-hidden="true"></span>
13865
+ <span class="task-scope-option-copy"><strong>${esc(item.title)}</strong>${item.volumeTitle ? `<small>${esc(item.volumeTitle)}${item.chapterType && item.chapterType !== "正文" ? ` · ${esc(item.chapterType)}` : ""}</small>` : ""}</span>
13866
+ </label>`;
13867
+ const optionMarkup = [...options.reduce((groups, item) => {
13868
+ const groupId = String(item.volumeId || "");
13869
+ const groupTitle = String(item.volumeTitle || "未分卷章节");
13870
+ const groupKey = `${groupId}:${groupTitle}`;
13871
+ const group = groups.get(groupKey)?.options ?? [];
13872
+ group.push(item);
13873
+ groups.set(groupKey, { id: groupId, title: groupTitle, options: group });
13874
+ return groups;
13875
+ }, new Map())].map(([, group]) => {
13876
+ const groupOptionsId = group.id ? `task-scope-volume-options-${group.id}` : "task-scope-volume-options-unassigned";
13877
+ return `<section class="task-scope-volume-group" data-task-scope-group data-task-scope-volume-id="${esc(group.id)}">
13878
+ <header>
13879
+ ${group.id ? `<label class="task-scope-volume-option">
13880
+ <input type="checkbox" data-task-scope-volume-input="${esc(group.id)}" data-task-scope-volume-title="${esc(group.title)}" aria-label="全选${esc(group.title)}章节">
13881
+ <span class="task-scope-checkbox" aria-hidden="true"></span>
13882
+ <span class="task-scope-volume-copy"><strong>${esc(group.title)}</strong><small>勾选以全选本卷章节</small></span>
13883
+ </label>` : `<strong>${esc(group.title)}</strong>`}
13884
+ <span class="task-scope-volume-count">${group.options.length} 章</span>
13885
+ ${group.id ? `<button type="button" class="task-scope-volume-collapse" data-task-scope-volume-toggle="${esc(group.id)}" data-task-scope-volume-title="${esc(group.title)}" aria-expanded="true" aria-controls="${esc(groupOptionsId)}" aria-label="折叠${esc(group.title)}章节"><span aria-hidden="true">⌃</span></button>` : ""}
13886
+ </header>
13887
+ <div id="${esc(groupOptionsId)}" class="task-scope-volume-options">
13888
+ ${group.options.map(renderOption).join("")}
13123
13889
  </div>
13124
- <p class="task-scope-empty hidden" data-task-scope-empty="${kind}">没有匹配的${esc(label)}</p>
13890
+ </section>`;
13891
+ }).join("");
13892
+ return `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13893
+ <div class="task-scope-field-heading"><span id="task-${kind}-label">${esc(label)}(可多选)</span><small>从左侧选择,右侧确认已选内容</small></div>
13894
+ <div class="task-scope-picker">
13895
+ <button class="task-scope-trigger" type="button" data-task-scope-trigger="${kind}" aria-expanded="false" aria-controls="task-${kind}-bubble" aria-labelledby="task-${kind}-label task-${kind}-summary" disabled>
13896
+ <span id="task-${kind}-summary">${esc(emptyLabel)}</span>
13897
+ <span class="task-scope-trigger-meta"><span data-task-scope-count="${kind}">未选择</span><span class="task-scope-chevron" aria-hidden="true">⌄</span></span>
13898
+ </button>
13899
+ <section id="task-${kind}-bubble" class="task-scope-panel hidden" data-task-scope-bubble="${kind}" aria-hidden="true" aria-labelledby="task-${kind}-label">
13900
+ <header class="task-scope-panel-header">
13901
+ <div><strong>选择${esc(label)}</strong><small>支持搜索和按分卷浏览,右侧会实时汇总。</small></div>
13902
+ <button type="button" class="task-scope-clear" data-task-scope-clear="${kind}" disabled>清空已选</button>
13903
+ </header>
13904
+ <div class="task-scope-author-note-banner" role="note">
13905
+ <span class="task-scope-author-note-banner-icon" aria-hidden="true">i</span>
13906
+ <div><strong>范围提示</strong><span>标记为“作者的话”的章节不会纳入 AI 分析输入;即使被选中,也只会保留在作品目录中。</span></div>
13907
+ </div>
13908
+ <div class="task-scope-panel-grid">
13909
+ <section class="task-scope-available" aria-label="待选${esc(label)}">
13910
+ <header class="task-scope-panel-section-header"><strong>待选章节</strong><span data-task-scope-available-count="${kind}">${options.length} 章</span></header>
13911
+ <label class="task-scope-search"><span>筛选${esc(label)}</span><input type="search" data-task-scope-search="${kind}" placeholder="输入名称" autocomplete="off"></label>
13912
+ <div class="task-scope-options" role="group" aria-labelledby="task-${kind}-label">${optionMarkup}</div>
13913
+ <p class="task-scope-empty hidden" data-task-scope-empty="${kind}">没有匹配的${esc(label)}</p>
13914
+ </section>
13915
+ <aside class="task-scope-selected" aria-label="已选章节汇总">
13916
+ <header class="task-scope-panel-section-header"><strong>已选汇总</strong><span data-task-scope-summary-count="${kind}">0 章</span></header>
13917
+ <p class="task-scope-selected-note">创建任务时会分析这里列出的章节;标记为“作者的话”的章节除外。</p>
13918
+ <div class="task-scope-selected-list" data-task-scope-selected-list="${kind}" role="list" aria-live="polite"><p class="task-scope-selected-empty">暂未选择章节</p></div>
13919
+ </aside>
13920
+ </div>
13921
+ </section>
13125
13922
  </div>
13126
- </div>
13127
- </div>`;
13923
+ </div>`;
13924
+ };
13128
13925
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
13129
13926
  const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
13130
13927
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
@@ -13152,8 +13949,7 @@ async function openTaskDialog() {
13152
13949
  <option value="" ${availableTaskModels.some((model) => model.id === defaultModelId) ? "" : "selected"} disabled>${availableTaskModels.length ? "请选择模型" : "没有可用模型"}</option>
13153
13950
  ${availableTaskModels.map((model) => `<option value="${esc(model.id)}" ${model.id === defaultModelId ? "selected" : ""}>${esc(modelOptionLabel(model))}</option>`).join("")}
13154
13951
  </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small><p class="analysis-context-warning hidden" data-analysis-context-warning role="alert"></p></label>`;
13155
- const chapterField = taskScopePicker("chapter", "章节", chapterOptions, "选择需要分析的章节");
13156
- const volumeField = taskScopePicker("volume", "分卷", volumeOptions, "选择需要分析的分卷");
13952
+ const chapterField = taskScopePicker(chapterOptions, "选择需要分析的章节");
13157
13953
  const relationshipFields = `<div class="relationship-analysis-options hidden">
13158
13954
  ${relationshipCharacterPicker}
13159
13955
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
@@ -13183,7 +13979,8 @@ async function openTaskDialog() {
13183
13979
  const buildRelationshipScope = (form) => {
13184
13980
  const taskType = String(form.get("taskType"));
13185
13981
  const scopeType = String(form.get("scopeType"));
13186
- const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
13982
+ const includeAllSettings = taskType === "relationship-analysis"
13983
+ && ["chapter-with-settings", "book-with-settings"].includes(scopeType);
13187
13984
  const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
13188
13985
  const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
13189
13986
  const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
@@ -13191,42 +13988,34 @@ async function openTaskDialog() {
13191
13988
  const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
13192
13989
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
13193
13990
  const chapterIds = form.getAll("chapterIds").map(String).filter(Boolean);
13194
- const volumeIds = form.getAll("volumeIds").map(String).filter(Boolean);
13195
13991
  const chapterScope = {
13196
13992
  type: "chapter",
13197
13993
  ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13198
- ...(chapterIds.length ? { chapterIds } : {})
13199
- };
13200
- const volumeScope = {
13201
- type: "volume",
13202
- ...(volumeIds[0] ? { volumeId: volumeIds[0] } : {}),
13203
- ...(volumeIds.length ? { volumeIds } : {})
13994
+ ...(chapterIds.length ? { chapterIds } : {}),
13995
+ ...(scopeType === "chapter-with-settings" ? { includeAllSettings: true } : {})
13204
13996
  };
13205
13997
  const scope = settingsOnly
13206
13998
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13207
- : scopeType === "book" || includeAllSettings
13999
+ : scopeType === "book" || scopeType === "book-with-settings"
13208
14000
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13209
- : { ...(scopeType === "volume" ? volumeScope : chapterScope), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
14001
+ : { ...chapterScope, ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13210
14002
  return scope;
13211
14003
  };
13212
14004
  const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
13213
14005
  type: scope.type,
13214
14006
  chapterId: scope.chapterId ?? null,
13215
14007
  chapterIds: scope.chapterIds ?? [],
13216
- volumeId: scope.volumeId ?? null,
13217
- volumeIds: scope.volumeIds ?? [],
13218
14008
  includeAllSettings: scope.includeAllSettings === true,
13219
14009
  characterIds: scope.characterIds ?? [],
13220
14010
  preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
13221
14011
  modelId
13222
14012
  });
13223
- openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["volume", "指定分卷"], ["book", "全书"]]) + chapterField + volumeField + relationshipFields, async (form) => {
14013
+ openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
13224
14014
  const workId = state.work.id;
13225
14015
  const taskType = String(form.get("taskType"));
13226
14016
  const modelId = String(form.get("modelId"));
13227
14017
  const scope = buildRelationshipScope(form);
13228
14018
  if (scope.type === "chapter" && !scope.chapterIds?.length) return toast("请先选择至少一个章节", "error");
13229
- if (scope.type === "volume" && !scope.volumeIds?.length) return toast("请先选择至少一个分卷", "error");
13230
14019
  if (taskType === "relationship-analysis" && relationshipSourcePreview
13231
14020
  && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
13232
14021
  scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
@@ -13248,8 +14037,9 @@ async function openTaskDialog() {
13248
14037
  }
13249
14038
  taskListPage = 1;
13250
14039
  toast("分析任务已创建,已进入任务队列");
13251
- void refreshAnalysisTaskViewsAfterCreate(workId);
14040
+ await refreshAnalysisTaskViewsAfterCreate(workId);
13252
14041
  }, "AI 分析", {
14042
+ large: true,
13253
14043
  submitLabel: "创建任务",
13254
14044
  pendingLabel: "创建中…",
13255
14045
  pendingMessage: "正在创建分析任务,请稍候",
@@ -13259,40 +14049,46 @@ async function openTaskDialog() {
13259
14049
  const taskModelSelect = $("#dialog-fields").querySelector('select[name="modelId"]');
13260
14050
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
13261
14051
  const scopeFields = {
13262
- chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]'),
13263
- volume: $("#dialog-fields").querySelector('[data-task-scope-field="volume"]')
14052
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]')
13264
14053
  };
13265
14054
  const scopeTriggers = {
13266
- chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]'),
13267
- volume: $("#dialog-fields").querySelector('[data-task-scope-trigger="volume"]')
14055
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]')
13268
14056
  };
13269
14057
  const scopeBubbles = {
13270
- chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]'),
13271
- volume: $("#dialog-fields").querySelector('[data-task-scope-bubble="volume"]')
14058
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]')
13272
14059
  };
13273
14060
  const scopeSearches = {
13274
- chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]'),
13275
- volume: $("#dialog-fields").querySelector('[data-task-scope-search="volume"]')
14061
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]')
13276
14062
  };
13277
14063
  const scopeInputs = {
13278
- chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')],
13279
- volume: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="volume"]')]
14064
+ chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')]
13280
14065
  };
14066
+ const scopeVolumeInputs = [
14067
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
14068
+ ];
14069
+ const scopeVolumeCollapseButtons = [
14070
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-toggle]")
14071
+ ];
13281
14072
  const scopeSummaries = {
13282
- chapter: $("#dialog-fields").querySelector("#task-chapter-summary"),
13283
- volume: $("#dialog-fields").querySelector("#task-volume-summary")
14073
+ chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
13284
14074
  };
13285
14075
  const scopeCounts = {
13286
- chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]'),
13287
- volume: $("#dialog-fields").querySelector('[data-task-scope-count="volume"]')
14076
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]')
13288
14077
  };
13289
14078
  const scopeClearButtons = {
13290
- chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]'),
13291
- volume: $("#dialog-fields").querySelector('[data-task-scope-clear="volume"]')
14079
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]')
13292
14080
  };
13293
14081
  const scopeEmptyMessages = {
13294
- chapter: $("#dialog-fields").querySelector('[data-task-scope-empty="chapter"]'),
13295
- volume: $("#dialog-fields").querySelector('[data-task-scope-empty="volume"]')
14082
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-empty="chapter"]')
14083
+ };
14084
+ const scopeSelectedLists = {
14085
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-selected-list="chapter"]')
14086
+ };
14087
+ const scopeSummaryCounts = {
14088
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-summary-count="chapter"]')
14089
+ };
14090
+ const scopeAvailableCounts = {
14091
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-available-count="chapter"]')
13296
14092
  };
13297
14093
  const description = $("#analysis-type-description");
13298
14094
  const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
@@ -13319,24 +14115,54 @@ async function openTaskDialog() {
13319
14115
  const allSettingsOption = document.createElement("option");
13320
14116
  allSettingsOption.value = "book-with-settings";
13321
14117
  allSettingsOption.textContent = "全书 + 设定集";
14118
+ const chapterSettingsOption = document.createElement("option");
14119
+ chapterSettingsOption.value = "chapter-with-settings";
14120
+ chapterSettingsOption.textContent = "指定章节 + 设定集";
13322
14121
  const settingsOnlyOption = document.createElement("option");
13323
14122
  settingsOnlyOption.value = "settings";
13324
14123
  settingsOnlyOption.textContent = "仅设定集";
13325
14124
  const setTaskScopeBubbleOpen = (kind, open) => {
13326
14125
  scopeBubbles[kind].classList.toggle("hidden", !open);
13327
14126
  scopeTriggers[kind].setAttribute("aria-expanded", String(open));
13328
- if (open) scopeSearches[kind].focus();
14127
+ scopeBubbles[kind].setAttribute("aria-hidden", String(!open));
14128
+ if (open) {
14129
+ scopeBubbles[kind].scrollIntoView({ block: "start" });
14130
+ scopeSearches[kind].focus({ preventScroll: true });
14131
+ }
14132
+ };
14133
+ const setTaskScopeVolumeCollapsed = (button, collapsed) => {
14134
+ const group = button.closest("[data-task-scope-group]");
14135
+ if (!group) return;
14136
+ group.classList.toggle("is-collapsed", collapsed);
14137
+ button.setAttribute("aria-expanded", String(!collapsed));
14138
+ button.setAttribute("aria-label", `${collapsed ? "展开" : "折叠"}${button.dataset.taskScopeVolumeTitle ?? ""}章节`);
14139
+ };
14140
+ const syncTaskScopeVolumeInputs = () => {
14141
+ for (const volumeInput of scopeVolumeInputs) {
14142
+ const group = volumeInput.closest("[data-task-scope-group]");
14143
+ const chapters = [...group.querySelectorAll('[data-task-scope-input="chapter"]')];
14144
+ const selectedCount = chapters.filter((input) => input.checked).length;
14145
+ volumeInput.checked = chapters.length > 0 && selectedCount === chapters.length;
14146
+ volumeInput.indeterminate = selectedCount > 0 && selectedCount < chapters.length;
14147
+ }
13329
14148
  };
13330
14149
  const syncTaskScopePicker = (kind) => {
13331
14150
  const selected = scopeInputs[kind].filter((input) => input.checked);
13332
- const selectedNames = selected.map((input) => input.closest(".task-scope-option")?.querySelector("strong")?.textContent ?? "");
13333
- const label = kind === "chapter" ? "选择需要分析的章节" : "选择需要分析的分卷";
14151
+ syncTaskScopeVolumeInputs();
14152
+ const selectedNames = selected.map((input) => input.dataset.taskScopeTitle ?? "");
13334
14153
  scopeSummaries[kind].textContent = selectedNames.length
13335
- ? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` ${selectedNames.length} 个` : ""}`
13336
- : label;
14154
+ ? selectedNames.length === 1 ? selectedNames[0] : `已选择 ${selectedNames.length} 个章节`
14155
+ : "选择需要分析的章节";
13337
14156
  scopeCounts[kind].textContent = selected.length ? `已选 ${selected.length}` : "未选择";
14157
+ scopeSummaryCounts[kind].textContent = `${selected.length} 章`;
13338
14158
  scopeClearButtons[kind].disabled = selected.length === 0;
13339
- scopeTriggers[kind].setAttribute("aria-label", `筛选${kind === "chapter" ? "章节" : "分卷"},已选择 ${selected.length} 个`);
14159
+ scopeTriggers[kind].setAttribute("aria-label", `筛选章节,已选择 ${selected.length} 个`);
14160
+ scopeSelectedLists[kind].innerHTML = selected.length
14161
+ ? selected.map((input) => `<div class="task-scope-selected-item" role="listitem">
14162
+ <span class="task-scope-selected-copy"><strong>${esc(input.dataset.taskScopeTitle ?? "")}</strong>${input.dataset.taskScopeSubtitle ? `<small>${esc(input.dataset.taskScopeSubtitle)}</small>` : ""}</span>
14163
+ <button type="button" data-task-scope-remove="${kind}" data-task-scope-remove-id="${esc(input.value)}" aria-label="移除${esc(input.dataset.taskScopeTitle ?? "")}">×</button>
14164
+ </div>`).join("")
14165
+ : `<p class="task-scope-selected-empty">暂未选择章节</p>`;
13340
14166
  };
13341
14167
  const filterTaskScopeOptions = (kind) => {
13342
14168
  const query = scopeSearches[kind].value.trim().toLocaleLowerCase();
@@ -13347,21 +14173,31 @@ async function openTaskDialog() {
13347
14173
  option.classList.toggle("hidden", !visible);
13348
14174
  if (visible) visibleCount += 1;
13349
14175
  }
14176
+ scopeFields[kind].querySelectorAll("[data-task-scope-group]").forEach((group) => {
14177
+ const hasVisibleOptions = Boolean(group.querySelector(".task-scope-option:not(.hidden)"));
14178
+ group.classList.toggle("hidden", !hasVisibleOptions);
14179
+ if (query && hasVisibleOptions) {
14180
+ const collapseButton = group.querySelector("[data-task-scope-volume-toggle]");
14181
+ if (collapseButton) setTaskScopeVolumeCollapsed(collapseButton, false);
14182
+ }
14183
+ });
14184
+ scopeAvailableCounts[kind].textContent = query
14185
+ ? `${visibleCount} / ${scopeInputs[kind].length} 章`
14186
+ : `${scopeInputs[kind].length} 章`;
13350
14187
  scopeEmptyMessages[kind].classList.toggle("hidden", visibleCount > 0);
13351
14188
  };
13352
14189
  const syncChapterField = () => {
13353
- const activeKind = ["chapter", "volume"].includes(scopeTypeSelect.value) ? scopeTypeSelect.value : null;
13354
- for (const kind of ["chapter", "volume"]) {
13355
- const enabled = kind === activeKind;
13356
- scopeFields[kind].classList.toggle("is-disabled", !enabled);
13357
- scopeFields[kind].classList.toggle("hidden", !enabled);
13358
- scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
13359
- scopeTriggers[kind].disabled = !enabled;
13360
- for (const input of scopeInputs[kind]) input.disabled = !enabled;
13361
- if (!enabled) setTaskScopeBubbleOpen(kind, false);
13362
- syncTaskScopePicker(kind);
13363
- filterTaskScopeOptions(kind);
13364
- }
14190
+ const kind = "chapter";
14191
+ const enabled = ["chapter", "chapter-with-settings"].includes(scopeTypeSelect.value);
14192
+ scopeFields[kind].classList.toggle("is-disabled", !enabled);
14193
+ scopeFields[kind].classList.toggle("hidden", !enabled);
14194
+ scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
14195
+ scopeTriggers[kind].disabled = !enabled;
14196
+ for (const input of scopeInputs[kind]) input.disabled = !enabled;
14197
+ for (const input of scopeVolumeInputs) input.disabled = !enabled;
14198
+ if (!enabled) setTaskScopeBubbleOpen(kind, false);
14199
+ syncTaskScopePicker(kind);
14200
+ filterTaskScopeOptions(kind);
13365
14201
  };
13366
14202
  const setRelationshipCharacterBubbleOpen = (open) => {
13367
14203
  relationshipCharacterBubble.classList.toggle("hidden", !open);
@@ -13426,8 +14262,14 @@ async function openTaskDialog() {
13426
14262
  };
13427
14263
  const syncRelationshipOptions = () => {
13428
14264
  const enabled = taskTypeSelect.value === "relationship-analysis";
14265
+ const bookScopeOption = scopeTypeSelect.querySelector('option[value="book"]');
14266
+ if (enabled && !chapterSettingsOption.isConnected) bookScopeOption.before(chapterSettingsOption);
13429
14267
  if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
13430
14268
  if (enabled && !settingsOnlyOption.isConnected) scopeTypeSelect.append(settingsOnlyOption);
14269
+ if (!enabled && chapterSettingsOption.isConnected) {
14270
+ if (scopeTypeSelect.value === chapterSettingsOption.value) scopeTypeSelect.value = "chapter";
14271
+ chapterSettingsOption.remove();
14272
+ }
13431
14273
  if (!enabled && allSettingsOption.isConnected) {
13432
14274
  if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
13433
14275
  allSettingsOption.remove();
@@ -13511,7 +14353,7 @@ async function openTaskDialog() {
13511
14353
  syncRelationshipOptions();
13512
14354
  }
13513
14355
  });
13514
- for (const kind of ["chapter", "volume"]) {
14356
+ for (const kind of ["chapter"]) {
13515
14357
  scopeTriggers[kind].addEventListener("click", () => {
13516
14358
  setTaskScopeBubbleOpen(kind, scopeTriggers[kind].getAttribute("aria-expanded") !== "true");
13517
14359
  });
@@ -13527,16 +14369,38 @@ async function openTaskDialog() {
13527
14369
  clearContextWarning();
13528
14370
  invalidateRelationshipSourcePreview();
13529
14371
  });
14372
+ scopeSelectedLists[kind].addEventListener("click", (event) => {
14373
+ const removeButton = event.target.closest("[data-task-scope-remove]");
14374
+ if (!removeButton) return;
14375
+ const input = scopeInputs[kind].find((candidate) => candidate.value === removeButton.dataset.taskScopeRemoveId);
14376
+ if (!input) return;
14377
+ input.checked = false;
14378
+ syncTaskScopePicker(kind);
14379
+ clearContextWarning();
14380
+ invalidateRelationshipSourcePreview();
14381
+ });
13530
14382
  }
14383
+ for (const volumeInput of scopeVolumeInputs) volumeInput.addEventListener("change", () => {
14384
+ const group = volumeInput.closest("[data-task-scope-group]");
14385
+ for (const chapterInput of group.querySelectorAll('[data-task-scope-input="chapter"]')) chapterInput.checked = volumeInput.checked;
14386
+ syncTaskScopePicker("chapter");
14387
+ clearContextWarning();
14388
+ invalidateRelationshipSourcePreview();
14389
+ });
14390
+ for (const collapseButton of scopeVolumeCollapseButtons) collapseButton.addEventListener("click", (event) => {
14391
+ event.preventDefault();
14392
+ event.stopPropagation();
14393
+ setTaskScopeVolumeCollapsed(collapseButton, collapseButton.getAttribute("aria-expanded") === "true");
14394
+ });
13531
14395
  $("#dynamic-form").onclick = (event) => {
13532
14396
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13533
- for (const kind of ["chapter", "volume"]) {
14397
+ for (const kind of ["chapter"]) {
13534
14398
  if (!scopeFields[kind].contains(event.target)) setTaskScopeBubbleOpen(kind, false);
13535
14399
  }
13536
14400
  };
13537
14401
  $("#dynamic-form").onkeydown = (event) => {
13538
14402
  if (event.key !== "Escape") return;
13539
- const openScope = ["chapter", "volume"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
14403
+ const openScope = ["chapter"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
13540
14404
  if (openScope) {
13541
14405
  event.preventDefault();
13542
14406
  setTaskScopeBubbleOpen(openScope, false);
@@ -13595,12 +14459,15 @@ function openProviderDialog(item) {
13595
14459
  return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
13596
14460
  };
13597
14461
  const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
14462
+ const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens" && protocol !== "anthropic-messages";
14463
+ const maxTokensParameterField = `<div class="form-field provider-max-tokens-parameter-field" data-provider-max-tokens-parameter-field><span>最大输出令牌参数</span><label class="checkbox-field"><input name="useMaxCompletionTokens" type="checkbox" ${useMaxCompletionTokens ? "checked" : ""} aria-describedby="provider-max-tokens-parameter-hint"><span>使用 max_completion_tokens</span></label><small id="provider-max-tokens-parameter-hint" data-provider-max-tokens-parameter-hint>默认使用 max_tokens;OpenAI 新版模型可按需切换。</small></div>`;
13598
14464
  openDialog(
13599
14465
  item ? "编辑 AI 供应商" : "新建 AI 供应商",
13600
14466
  field("name", "显示名称", "text", item?.name)
13601
14467
  + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
13602
14468
  + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
13603
14469
  + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
14470
+ + maxTokensParameterField
13604
14471
  + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
13605
14472
  + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
13606
14473
  + field("note", "用途备注", "textarea", item?.note)
@@ -13610,6 +14477,7 @@ function openProviderDialog(item) {
13610
14477
  name: form.get("name"),
13611
14478
  protocol: form.get("protocol"),
13612
14479
  baseUrl: form.get("baseUrl"),
14480
+ maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
13613
14481
  concurrencyLimit: Number(form.get("concurrencyLimit")),
13614
14482
  rpmLimit: Number(form.get("rpmLimit")),
13615
14483
  note: form.get("note"),
@@ -13626,12 +14494,23 @@ function openProviderDialog(item) {
13626
14494
  const protocolSelect = $("#dialog-fields select[name='protocol']");
13627
14495
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
13628
14496
  const credentialHost = $("#dialog-fields [data-provider-credential-field]");
14497
+ const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
14498
+ const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
14499
+ const syncMaxTokensParameter = () => {
14500
+ const anthropic = protocolSelect.value === "anthropic-messages";
14501
+ maxTokensParameterFieldElement.hidden = anthropic;
14502
+ maxTokensParameterFieldElement.classList.toggle("hidden", anthropic);
14503
+ maxTokensParameterInput.disabled = anthropic;
14504
+ if (anthropic) maxTokensParameterInput.checked = false;
14505
+ };
13629
14506
  const syncProviderCredentialField = () => {
13630
14507
  const nextProtocol = protocolSelect.value;
13631
14508
  credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
13632
14509
  if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
14510
+ syncMaxTokensParameter();
13633
14511
  };
13634
14512
  protocolSelect.addEventListener("change", syncProviderCredentialField);
14513
+ syncMaxTokensParameter();
13635
14514
  }
13636
14515
 
13637
14516
  function openModelDialog(providerId, item = null, provider = null) {
@@ -13641,16 +14520,16 @@ function openModelDialog(providerId, item = null, provider = null) {
13641
14520
  const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
13642
14521
  const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
13643
14522
  const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
13644
- ? "使用当前已保存的模型标识符和供应商凭据,并发送一张测试图片验证图片请求。"
13645
- : "使用当前已保存的模型标识符和供应商凭据发起最小请求。";
14523
+ ? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
14524
+ : "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
13646
14525
  const connectionTest = item && item.providerStatus === "enabled"
13647
14526
  ? `<section class="model-connection-test">
13648
14527
  <div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
13649
14528
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
13650
14529
  </section>`
13651
14530
  : "";
13652
- openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
13653
- const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
14531
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
14532
+ const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
13654
14533
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
13655
14534
  await renderPlatformAiConfig();
13656
14535
  await loadModels();
@@ -13661,6 +14540,8 @@ function openModelDialog(providerId, item = null, provider = null) {
13661
14540
  const contextWindowHint = $("#model-context-window-hint");
13662
14541
  const temperatureInput = $("#dialog-fields input[name='temperature']");
13663
14542
  const temperatureHint = $("#model-temperature-hint");
14543
+ const thinkingEnabledInput = $("#dialog-fields input[name='thinkingEnabled']");
14544
+ const thinkingEffortSelect = $("#dialog-fields select[name='thinkingEffort']");
13664
14545
  const multimodalInput = $("#model-multimodal-enabled");
13665
14546
  const imageDefaultField = $("#model-image-tool-default-field");
13666
14547
  const imageDefaultInput = $("#model-image-tool-default");
@@ -13680,11 +14561,15 @@ function openModelDialog(providerId, item = null, provider = null) {
13680
14561
  const isKimi = isKimiModelId(modelIdInput.value);
13681
14562
  temperatureHint.hidden = !isKimi;
13682
14563
  };
14564
+ const syncThinkingEffort = () => {
14565
+ thinkingEffortSelect.disabled = !thinkingEnabledInput.checked;
14566
+ };
13683
14567
  modelIdInput.addEventListener("input", () => {
13684
14568
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
13685
14569
  syncKimiTemperature();
13686
14570
  });
13687
14571
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
14572
+ thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
13688
14573
  multimodalInput?.addEventListener("change", syncMultimodalFields);
13689
14574
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
13690
14575
  const button = event.currentTarget;
@@ -13707,6 +14592,7 @@ function openModelDialog(providerId, item = null, provider = null) {
13707
14592
  });
13708
14593
  syncModelContextWindowGuidance();
13709
14594
  syncKimiTemperature();
14595
+ syncThinkingEffort();
13710
14596
  syncMultimodalFields();
13711
14597
  }
13712
14598
 
@@ -13716,6 +14602,9 @@ async function sendAi() {
13716
14602
 
13717
14603
  async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13718
14604
  if (!state.work) return toast("请先选择作品", "error");
14605
+ const tab = activeAiChatTab();
14606
+ if (!tab) return toast("Agent 对话页签尚未就绪", "error");
14607
+ if (aiRequestManager.hasActive(tab.id)) return;
13719
14608
  const composerSnapshot = captureAiPromptComposer();
13720
14609
  const instruction = composerSnapshot.text.trim();
13721
14610
  if (!instruction) return toast("请输入指令", "error");
@@ -13725,31 +14614,37 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13725
14614
  const { taskType, scope, selection } = requestScope;
13726
14615
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
13727
14616
  const citations = composerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
14617
+ const selectedTaskType = $("#ai-task").value;
14618
+ persistActiveAiChatTab();
14619
+ tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
13728
14620
  const requestHolder = {
13729
14621
  snapshot: aiRequestManager.begin({
14622
+ tabId: tab.id,
13730
14623
  workId: state.work.id,
13731
14624
  conversationId: state.aiConversationId
13732
14625
  })
13733
14626
  };
14627
+ setAiChatTabStatus(tab, "streaming");
13734
14628
  syncAiRequestControls();
13735
14629
  try {
13736
14630
  try {
13737
14631
  await ensureAiModelsLoaded();
13738
14632
  } catch (error) {
13739
14633
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13740
- setAiAssistantStatus("error");
14634
+ setAiChatTabStatus(tab, "error");
13741
14635
  return toast(`创作助手加载失败:${error.message}`, "error");
13742
14636
  }
13743
14637
  assertAiRequestCurrent(requestHolder.snapshot);
13744
- const modelId = $("#ai-model").value;
14638
+ const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
13745
14639
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
14640
+ tab.selectedModelId = modelId;
13746
14641
  try {
13747
- await prepareAiRequestConversation(requestHolder, $("#ai-task").value, requestScope.conversationScope);
14642
+ await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
13748
14643
  } catch (error) {
13749
14644
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13750
14645
  return toast(`对话配置锁定失败:${error.message}`, "error");
13751
14646
  }
13752
- setAiAssistantStatus("ready");
14647
+ setAiChatTabStatus(tab, "streaming");
13753
14648
  if (taskType !== "chat") {
13754
14649
  try {
13755
14650
  const request = assertAiRequestCurrent(requestHolder.snapshot);
@@ -13764,16 +14659,22 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13764
14659
  assertAiRequestCurrent(request);
13765
14660
  updateAiConversationSummaryFromMessage(persistedUserMessage);
13766
14661
  requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
13767
- state.aiConversationModelId = modelId;
13768
- state.aiPromptSent = true;
13769
- syncAiTaskOptions();
13770
- renderAiRoleplayCharacterSelect();
13771
- renderAiQuickActions();
13772
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
13773
- clearAiPromptComposer();
14662
+ tab.modelId = modelId;
14663
+ tab.selectedModelId = modelId;
14664
+ tab.promptSent = true;
14665
+ clearAiChatTabComposer(tab);
14666
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
14667
+ if (isActiveAiChatTab(tab)) {
14668
+ state.aiConversationModelId = modelId;
14669
+ state.aiPromptSent = true;
14670
+ syncAiTaskOptions();
14671
+ renderAiRoleplayCharacterSelect();
14672
+ renderAiQuickActions();
14673
+ clearAiPromptComposer();
14674
+ }
13774
14675
  } catch (error) {
13775
14676
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13776
- setAiAssistantStatus("error");
14677
+ setAiChatTabStatus(tab, "error");
13777
14678
  return toast(`对话记录创建失败:${error.message}`, "error");
13778
14679
  }
13779
14680
  }
@@ -13809,8 +14710,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13809
14710
  const suggestionFailed = suggestion.guard?.status === "failed"
13810
14711
  || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
13811
14712
  || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
13812
- if (suggestionFailed) setAiAssistantStatus("error");
13813
- setAiContextMeter(suggestion.contextUsage, false);
14713
+ if (suggestionFailed) setAiChatTabStatus(tab, "error");
14714
+ tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
14715
+ if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
13814
14716
  assistantContent = suggestion.content;
13815
14717
  assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
13816
14718
  }
@@ -13839,36 +14741,50 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13839
14741
  if (assistantMessage) {
13840
14742
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
13841
14743
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
13842
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
14744
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
13843
14745
  }
13844
14746
  } catch (error) {
13845
14747
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13846
- setAiAssistantStatus("error");
13847
- if (suggestion) appendSuggestion(suggestion);
14748
+ setAiChatTabStatus(tab, "error");
14749
+ if (suggestion) appendSuggestion(suggestion, null, null, { tab });
13848
14750
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
13849
14751
  }
13850
14752
  } catch (error) {
13851
14753
  const request = requestHolder.snapshot;
13852
14754
  if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
13853
- await persistAiRequestInterruption(request, error?.streamInterruption);
14755
+ const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
14756
+ const userStoppedCurrentReply = request.signal.reason instanceof Error
14757
+ && request.signal.reason.message === "用户已终止当前回复";
14758
+ if (userStoppedCurrentReply && persistedInterruption && isActiveAiChatTab(tab)) {
14759
+ appendMessage(
14760
+ "assistant",
14761
+ persistedInterruption.content,
14762
+ [],
14763
+ persistedInterruption.createdAt,
14764
+ persistedInterruption.metadata,
14765
+ persistedInterruption.id,
14766
+ { tab }
14767
+ );
14768
+ }
13854
14769
  return;
13855
14770
  }
13856
14771
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
13857
- restoreAiPromptComposer(composerSnapshot);
14772
+ setAiChatTabComposerSnapshot(tab, composerSnapshot);
14773
+ if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
13858
14774
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13859
- $("#ai-prompt").focus();
14775
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13860
14776
  return;
13861
14777
  }
13862
14778
  if (error?.code === "AI_IDEMPOTENT_REQUEST_IN_PROGRESS") {
13863
14779
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13864
- $("#ai-prompt").focus();
14780
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13865
14781
  return;
13866
14782
  }
13867
14783
  if (["IDEMPOTENCY_KEY_REUSED", "AI_IDEMPOTENT_REQUEST_TERMINAL"].includes(error?.code)) {
13868
14784
  toast(error.message, "error");
13869
14785
  return;
13870
14786
  }
13871
- setAiAssistantStatus("error");
14787
+ setAiChatTabStatus(tab, "error");
13872
14788
  const interruption = error?.streamInterruption;
13873
14789
  if (interruption?.content) {
13874
14790
  try {
@@ -13915,32 +14831,40 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13915
14831
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
13916
14832
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
13917
14833
  if (aiRequestTargetsCurrentState(request)) {
13918
- appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
14834
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id, { tab });
13919
14835
  }
13920
14836
  } finally {
13921
14837
  aiRequestManager.finish(requestHolder.snapshot);
13922
- syncAiRequestControls();
14838
+ if (!isActiveAiChatTab(tab) && aiChatTabManager.get(tab.id)) tab.unread = true;
14839
+ if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
14840
+ else renderAiChatTabs();
14841
+ if (isActiveAiChatTab(tab)) syncAiRequestControls();
13923
14842
  }
13924
14843
  }
13925
14844
 
13926
14845
  async function streamChat(requestHolder, body, idempotencyKey) {
14846
+ const tab = aiChatTabForRequest(requestHolder.snapshot);
14847
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
14848
+ const feed = tab.feed;
13927
14849
  const message = document.createElement("div");
13928
14850
  message.className = "assistant-message is-streaming";
13929
14851
  message.dataset.testid = "ai-stream-message";
13930
14852
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
13931
14853
  const content = message.querySelector(".message-body");
13932
14854
  const meta = message.querySelector(".message-meta");
14855
+ const streamSpeedController = createStreamTypewriterSpeedController();
13933
14856
  let messageMounted = false;
13934
14857
  const mountAssistantMessage = () => {
13935
14858
  if (messageMounted) return true;
13936
14859
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
13937
- attachMessageHeading(message, aiAssistantLabel("正在生成"));
13938
- $("#ai-feed").append(message);
14860
+ attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
14861
+ feed.append(message);
13939
14862
  messageMounted = true;
13940
- scrollAiFeedToBottom();
14863
+ scrollAiFeedToBottom(feed);
13941
14864
  return true;
13942
14865
  };
13943
14866
  const typewriter = createStreamTypewriter({
14867
+ speedController: streamSpeedController,
13944
14868
  onRender: (text, progress) => {
13945
14869
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
13946
14870
  content.innerHTML = renderMarkdown(text);
@@ -13948,7 +14872,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13948
14872
  meta.textContent = progress.pendingCharacters > 0
13949
14873
  ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
13950
14874
  : `正在生成 · ${progress.visibleCharacters} 字`;
13951
- scrollAiFeedToBottom();
14875
+ scrollAiFeedToBottom(feed);
13952
14876
  }
13953
14877
  });
13954
14878
  let streamedText = "";
@@ -13976,11 +14900,12 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13976
14900
  if (existing) return existing;
13977
14901
  processStepVisibleContents.set(step, "");
13978
14902
  const typewriter = createStreamTypewriter({
14903
+ speedController: streamSpeedController,
13979
14904
  onRender: (text) => {
13980
14905
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
13981
14906
  processStepVisibleContents.set(step, text);
13982
14907
  renderStreamingProcessSteps(finalAnswerStarted);
13983
- scrollAiFeedToBottom();
14908
+ scrollAiFeedToBottom(feed);
13984
14909
  }
13985
14910
  });
13986
14911
  processStepTypewriters.set(step, typewriter);
@@ -14008,7 +14933,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14008
14933
  assertAiRequestCurrent(requestHolder.snapshot);
14009
14934
  if (eventName === "context") {
14010
14935
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
14011
- if (!state.aiPromptSent) setAiContextMeter(payload.usage);
14936
+ if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
14012
14937
  if (payload.conversation?.id) {
14013
14938
  if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
14014
14939
  throw new Error("流式上下文返回了其他对话");
@@ -14016,11 +14941,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14016
14941
  upsertAiConversationSummary(payload.conversation);
14017
14942
  applyAiConversationTitle(payload.conversation.title, requestHolder.snapshot.conversationId);
14018
14943
  }
14019
- if (contextAction === "warn") showAiContextWarning(payload.usage);
14944
+ if (contextAction === "warn") setAiChatTabContextWarning(tab, true);
14020
14945
  else {
14021
- hideAiContextWarning();
14946
+ setAiChatTabContextWarning(tab, false);
14022
14947
  if (contextAction === "compacted") {
14023
- renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
14948
+ renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount, feed, payload.conversation);
14024
14949
  toast("已自动整理较早对话为长期记忆并继续发送");
14025
14950
  }
14026
14951
  }
@@ -14035,13 +14960,19 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14035
14960
  const lockedModelId = typeof persistedUserMessage.metadata?.modelId === "string"
14036
14961
  ? persistedUserMessage.metadata.modelId
14037
14962
  : typeof body.modelId === "string" ? body.modelId : null;
14038
- state.aiConversationModelId = lockedModelId;
14039
- state.aiPromptSent = true;
14040
- syncAiTaskOptions();
14041
- renderAiRoleplayCharacterSelect();
14042
- renderAiQuickActions();
14043
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id);
14044
- clearAiPromptComposer();
14963
+ tab.modelId = lockedModelId;
14964
+ tab.selectedModelId = lockedModelId;
14965
+ tab.promptSent = true;
14966
+ clearAiChatTabComposer(tab);
14967
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
14968
+ if (isActiveAiChatTab(tab)) {
14969
+ state.aiConversationModelId = lockedModelId;
14970
+ state.aiPromptSent = true;
14971
+ syncAiTaskOptions();
14972
+ renderAiRoleplayCharacterSelect();
14973
+ renderAiQuickActions();
14974
+ clearAiPromptComposer();
14975
+ }
14045
14976
  mountAssistantMessage();
14046
14977
  }
14047
14978
  } else if (eventName === "delta") {
@@ -14071,26 +15002,26 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14071
15002
  : step.type === "context_compaction"
14072
15003
  ? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
14073
15004
  : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
14074
- scrollAiFeedToBottom();
15005
+ scrollAiFeedToBottom(feed);
14075
15006
  } else if (eventName === "tool_call") {
14076
15007
  mountAssistantMessage();
14077
15008
  const toolCall = { ...payload };
14078
15009
  const round = toolCall.round;
14079
15010
  delete toolCall.round;
14080
- if (toolCall.status === "failed") setAiAssistantStatus("error");
15011
+ if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
14081
15012
  toolCalls.push(toolCall);
14082
15013
  processSteps.push(aiToolProcessStep(toolCall, round));
14083
15014
  renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
14084
15015
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
14085
- scrollAiFeedToBottom();
15016
+ scrollAiFeedToBottom(feed);
14086
15017
  } else if (eventName === "context_compacted") {
14087
15018
  streamContextCompacted = true;
14088
- setAiContextMeter(payload.contextUsage);
15019
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14089
15020
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
14090
15021
  } else if (eventName === "complete") {
14091
15022
  if (payload.warningOnly === true) {
14092
15023
  warningOnly = true;
14093
- setAiContextMeter(payload.contextUsage);
15024
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14094
15025
  return;
14095
15026
  }
14096
15027
  mountAssistantMessage();
@@ -14098,7 +15029,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14098
15029
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
14099
15030
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
14100
15031
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
14101
- setAiContextMeter(payload.contextUsage, announcedCompaction);
15032
+ setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
14102
15033
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
14103
15034
  assertAiRequestCurrent(requestHolder.snapshot);
14104
15035
  message.classList.remove("is-streaming");
@@ -14113,11 +15044,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14113
15044
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14114
15045
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
14115
15046
  attachAssistantCopyAction(message, streamedText);
14116
- scrollAiFeedToBottom();
15047
+ scrollAiFeedToBottom(feed);
14117
15048
  } else if (eventName === "request_status") {
14118
15049
  streamError = createClientError(payload, "AI 请求状态不可重放", response.status);
14119
15050
  } else if (eventName === "error") {
14120
- setAiAssistantStatus("error");
15051
+ setAiChatTabStatus(tab, "error");
14121
15052
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
14122
15053
  }
14123
15054
  };
@@ -14160,7 +15091,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14160
15091
  message.dataset.status = "interrupted";
14161
15092
  content.setAttribute("aria-busy", "false");
14162
15093
  const headingRole = message.querySelector(".message-heading > span");
14163
- headingRole.textContent = aiAssistantLabel(interruptionLabel);
15094
+ headingRole.textContent = aiAssistantLabel(interruptionLabel, tab.roleplayCharacter);
14164
15095
  const interruptionBadge = document.createElement("strong");
14165
15096
  interruptionBadge.className = "ai-message-status is-error";
14166
15097
  interruptionBadge.textContent = "中断";
@@ -14169,12 +15100,14 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14169
15100
  renderStreamingProcessSteps(true, elapsedProcessTime());
14170
15101
  meta.textContent = formatAiStreamInterruptionMeta(interruptionCode, streamedText.length);
14171
15102
  if (streamedText) attachAssistantCopyAction(message, streamedText);
14172
- scrollAiFeedToBottom();
15103
+ scrollAiFeedToBottom(feed);
14173
15104
  throw streamFailure;
14174
15105
  }
14175
15106
  }
14176
15107
 
14177
- function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
15108
+ function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
15109
+ const tab = options.tab ?? activeAiChatTab();
15110
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
14178
15111
  const message = document.createElement("div");
14179
15112
  const isFailure = role === "assistant" && text.startsWith("调用失败:");
14180
15113
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
@@ -14190,8 +15123,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14190
15123
  });
14191
15124
  const heading = attachMessageHeading(
14192
15125
  message,
14193
- role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : ""),
14194
- createdAt ?? undefined
15126
+ role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15127
+ createdAt ?? undefined,
15128
+ tab
14195
15129
  );
14196
15130
  if (isFailure || isInterrupted) {
14197
15131
  message.dataset.status = isInterrupted ? "interrupted" : "failed";
@@ -14241,7 +15175,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14241
15175
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14242
15176
  }
14243
15177
  if (role === "assistant" && !text.startsWith("调用失败:")) {
14244
- const selectedModel = state.models.find((model) => model.id === $("#ai-model").value) ?? state.models[0];
15178
+ const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15179
+ const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
14245
15180
  const modelDisplayName = metadata?.modelDisplayName || selectedModel?.displayName || "模型";
14246
15181
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
14247
15182
  const meta = document.createElement("div");
@@ -14253,18 +15188,20 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14253
15188
  attachAssistantCopyAction(message, text);
14254
15189
  }
14255
15190
  attachMessageIdentity(message, messageId);
14256
- $("#ai-feed").append(message);
14257
- scrollAiFeedToBottom();
15191
+ feed.append(message);
15192
+ scrollAiFeedToBottom(feed);
14258
15193
  }
14259
15194
 
14260
- function appendSuggestion(suggestion, createdAt = null, messageId = null) {
15195
+ function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
15196
+ const tab = options.tab ?? activeAiChatTab();
15197
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
14261
15198
  const message = document.createElement("div");
14262
15199
  message.className = "assistant-message";
14263
15200
  const applicable = suggestion.action !== "note";
14264
15201
  const guard = suggestion.guard;
14265
15202
  const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
14266
15203
  message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`, suggestion.processDurationMs))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
14267
- attachMessageHeading(message, "助手建议", createdAt ?? undefined);
15204
+ attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
14268
15205
  attachAssistantCopyAction(message, suggestion.content);
14269
15206
  attachMessageIdentity(message, messageId);
14270
15207
  if (applicable) {
@@ -14287,8 +15224,8 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
14287
15224
  message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
14288
15225
  });
14289
15226
  }
14290
- $("#ai-feed").append(message);
14291
- scrollAiFeedToBottom();
15227
+ feed.append(message);
15228
+ scrollAiFeedToBottom(feed);
14292
15229
  return message;
14293
15230
  }
14294
15231
 
@@ -15577,6 +16514,20 @@ $("#shelf-new-work").addEventListener("click", openWorkDialog);
15577
16514
  $("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
15578
16515
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
15579
16516
  $("#save-button").addEventListener("click", saveChapter);
16517
+ $("#chapter-search-close").addEventListener("click", () => closeChapterSearchPanel());
16518
+ $("#chapter-search-query").addEventListener("input", () => {
16519
+ chapterSearchMatchIndex = -1;
16520
+ renderChapterSearchStatus();
16521
+ });
16522
+ $("#chapter-search-query").addEventListener("keydown", (event) => {
16523
+ if (event.key !== "Enter") return;
16524
+ event.preventDefault();
16525
+ moveChapterSearchMatch(event.shiftKey ? -1 : 1);
16526
+ });
16527
+ $("#chapter-search-previous").addEventListener("click", () => moveChapterSearchMatch(-1));
16528
+ $("#chapter-search-next").addEventListener("click", () => moveChapterSearchMatch(1));
16529
+ $("#chapter-search-replace").addEventListener("click", replaceCurrentChapterSearchMatch);
16530
+ $("#chapter-search-replace-all").addEventListener("click", replaceAllChapterSearchMatches);
15580
16531
  $("#chapter-foreshadow-reminder-previous").addEventListener("click", () => {
15581
16532
  showChapterForeshadowReminderAt(chapterForeshadowReminderIndex - 1);
15582
16533
  });
@@ -15629,6 +16580,10 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
15629
16580
  $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
15630
16581
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
15631
16582
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
16583
+ document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => {
16584
+ setAiToolCallCopyButtonState(button, false);
16585
+ button.addEventListener("click", () => void copyAiToolCallCode(button));
16586
+ });
15632
16587
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
15633
16588
  $("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
15634
16589
  $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
@@ -15739,6 +16694,10 @@ $("#mobile-panel-backdrop").addEventListener("click", () => {
15739
16694
  applyPanelLayout(true);
15740
16695
  });
15741
16696
  $("#ai-panel-toggle").addEventListener("click", () => {
16697
+ if (aiConversationWorkspaceOpen) {
16698
+ setAiConversationWorkspaceVisible(false);
16699
+ return;
16700
+ }
15742
16701
  panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
15743
16702
  applyPanelLayout(true);
15744
16703
  });
@@ -15751,6 +16710,17 @@ window.addEventListener("resize", () => {
15751
16710
  applyPanelLayout();
15752
16711
  scheduleChapterLineNumbers();
15753
16712
  });
16713
+ $("#ai-assistant-entry").addEventListener("click", () => {
16714
+ if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) {
16715
+ return toast("当前账户没有创作助手读取权限", "error");
16716
+ }
16717
+ setModuleNavExpanded(false);
16718
+ if (isMobileViewport()) {
16719
+ panelLayout.leftCollapsed = true;
16720
+ applyPanelLayout(true);
16721
+ }
16722
+ setAiConversationWorkspaceVisible(true);
16723
+ });
15754
16724
  $("#module-nav").addEventListener("click", (event) => {
15755
16725
  const button = event.target.closest("button");
15756
16726
  if (!button || button.id === "module-more-button") return;
@@ -16016,6 +16986,7 @@ document.addEventListener("pointerdown", (event) => {
16016
16986
  closeManuscriptExportMenu(true);
16017
16987
  }
16018
16988
  if (!event.target.closest("#ai-history-action-menu") && !event.target.closest(".ai-history-more")) closeAiHistoryActionMenu();
16989
+ if (!event.target.closest("#ai-conversation-switcher-menu") && !event.target.closest("#ai-conversation-switcher")) setAiConversationSwitcherVisible(false);
16019
16990
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
16020
16991
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
16021
16992
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -16035,7 +17006,20 @@ document.addEventListener("keydown", (event) => {
16035
17006
  return;
16036
17007
  }
16037
17008
  if (event.key === "Escape") {
17009
+ if (!$("#chapter-search-panel").classList.contains("hidden")) {
17010
+ closeChapterSearchPanel();
17011
+ return;
17012
+ }
16038
17013
  if (!$("#ai-history-action-menu").classList.contains("hidden")) return;
17014
+ if (!$("#ai-conversation-switcher-menu").classList.contains("hidden")) {
17015
+ setAiConversationSwitcherVisible(false);
17016
+ $("#ai-conversation-switcher").focus();
17017
+ return;
17018
+ }
17019
+ if (aiConversationWorkspaceOpen) {
17020
+ setAiConversationWorkspaceVisible(false);
17021
+ return;
17022
+ }
16039
17023
  closeChapterTypeMenu();
16040
17024
  closeLineCitationMenu();
16041
17025
  closeMarkdownTableMenu(true);
@@ -16050,14 +17034,37 @@ document.addEventListener("keydown", (event) => {
16050
17034
  event.preventDefault();
16051
17035
  event.stopPropagation();
16052
17036
  if (event.repeat) return;
17037
+ if (isChapterSearchContext()) {
17038
+ openChapterSearchPanel();
17039
+ return;
17040
+ }
16053
17041
  openSearchDialog().catch((error) => toast(error.message, "error"));
16054
17042
  }, { capture: true });
16055
17043
  $("#ai-context-meter").addEventListener("click", () => {
16056
17044
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
16057
17045
  });
16058
17046
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
16059
- $("#ai-send").addEventListener("click", sendAi);
17047
+ $("#ai-send").addEventListener("click", activateAiSendControl);
17048
+ $("#ai-conversation-switcher").addEventListener("click", () => {
17049
+ setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
17050
+ });
17051
+ $("#ai-conversation-switcher-close").addEventListener("click", () => {
17052
+ setAiConversationSwitcherVisible(false);
17053
+ $("#ai-conversation-switcher").focus();
17054
+ });
17055
+ $("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
17056
+ $("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
17057
+ $("#ai-switcher-history").addEventListener("click", async () => {
17058
+ setAiConversationSwitcherVisible(false);
17059
+ try {
17060
+ await ensureAiConversationsLoaded();
17061
+ setAiHistoryVisible(true);
17062
+ } catch (error) {
17063
+ toast(`对话历史加载失败:${error.message}`, "error");
17064
+ }
17065
+ });
16060
17066
  $("#ai-new-conversation").addEventListener("click", async () => {
17067
+ setAiConversationSwitcherVisible(false);
16061
17068
  const button = $("#ai-new-conversation");
16062
17069
  button.disabled = true;
16063
17070
  try {
@@ -16121,6 +17128,7 @@ $("#ai-context-dismiss").addEventListener("click", async () => {
16121
17128
  }
16122
17129
  });
16123
17130
  $("#ai-history-toggle").addEventListener("click", async () => {
17131
+ setAiConversationSwitcherVisible(false);
16124
17132
  if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
16125
17133
  try {
16126
17134
  await ensureAiConversationsLoaded();
@@ -16181,6 +17189,20 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
16181
17189
  }
16182
17190
  return;
16183
17191
  }
17192
+ if (action === "copy-session-id") {
17193
+ option.disabled = true;
17194
+ try {
17195
+ await copyAiConversationSessionId(conversation);
17196
+ closeAiHistoryActionMenu(true);
17197
+ toast("对话 Session ID 已复制");
17198
+ } catch (error) {
17199
+ toast(`Session ID 复制失败:${error.message}`, "error");
17200
+ option.focus();
17201
+ } finally {
17202
+ option.disabled = false;
17203
+ }
17204
+ return;
17205
+ }
16184
17206
  if (action === "delete") {
16185
17207
  option.disabled = true;
16186
17208
  try {
@@ -16233,7 +17255,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
16233
17255
  }
16234
17256
  if (shouldSendAiPrompt(event)) {
16235
17257
  event.preventDefault();
16236
- if (!$("#ai-send").disabled) void sendAi();
17258
+ if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
16237
17259
  }
16238
17260
  });
16239
17261
  $(".quick-actions").addEventListener("click", (event) => {
@@ -16329,7 +17351,6 @@ $("#reader-font-size").addEventListener("change", (event) => updateReadingPrefer
16329
17351
  $("#reader-line-height").addEventListener("change", (event) => updateReadingPreference({ lineHeight: Number(event.currentTarget.value) }));
16330
17352
  $("#reader-theme").addEventListener("change", (event) => updateReadingPreference({ theme: event.currentTarget.value }));
16331
17353
  $("#reader-view").addEventListener("keydown", handleReadingKeyboard);
16332
- $("#reader-viewport").addEventListener("wheel", handleReadingWheel, { passive: false });
16333
17354
  $("#reader-viewport").addEventListener("scroll", () => {
16334
17355
  if (readingPreferences.mode === "scroll") scheduleReadingPositionSave();
16335
17356
  }, { passive: true });
@@ -16345,6 +17366,9 @@ document.addEventListener("click", (event) => {
16345
17366
  window.addEventListener("pagehide", dismissDeleteToasts);
16346
17367
  window.addEventListener("beforeunload", (event) => {
16347
17368
  if (hasUnsavedEditorChanges()) event.preventDefault();
17369
+ if (aiRequestManager.hasActive()) {
17370
+ toast("仍有 Agent turn 尚未结束,刷新会中断全部生成;已收到的内容会保留在历史记录中", "warning");
17371
+ }
16348
17372
  });
16349
17373
  window.addEventListener("online", () => {
16350
17374
  updateSystemHealth({ status: "checking" });
@@ -16360,6 +17384,7 @@ window.addEventListener("resize", () => {
16360
17384
  });
16361
17385
  });
16362
17386
 
17387
+ initializeAiChatTabs();
16363
17388
  initializePage().catch((error) => {
16364
17389
  restoringPageRoute = false;
16365
17390
  if (state.user) {