@musnows/scriverse 0.7.13 → 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 (44) 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 +525 -117
  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 +1151 -252
  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 +66 -25
  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/styles.css +170 -32
  38. package/dist/server-runtime.js +6 -0
  39. package/dist/server-runtime.js.map +1 -1
  40. package/dist/store.js +77 -34
  41. package/dist/store.js.map +1 -1
  42. package/dist/version.js +6 -1
  43. package/dist/version.js.map +1 -1
  44. 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,7 +16,7 @@ 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";
@@ -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,41 +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, action = "切换会话") {
555
- if (aiRequestManager.hasActive()) {
556
- const isNewConversation = action === "新建会话";
557
- toast(
558
- `当前 turn 尚未结束,${action}会中断生成;${isNewConversation ? "\n" : ""}已收到的内容会保留在历史记录中`,
559
- "warning",
560
- isNewConversation ? "ai-new-conversation-toast" : ""
561
- );
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;
562
615
  }
616
+ void sendAi();
617
+ }
618
+
619
+ function beginAiConversationNavigation(reason, action = "切换会话") {
563
620
  aiConversationNavigationGeneration += 1;
564
621
  aiConversationNavigationPending = aiConversationNavigationGeneration;
565
622
  const navigation = Object.freeze({
566
623
  generation: aiConversationNavigationGeneration,
567
624
  workId: String(state.work?.id ?? "")
568
625
  });
569
- cancelActiveAiRequest(reason);
570
626
  return navigation;
571
627
  }
572
628
 
@@ -584,11 +640,12 @@ function finishAiConversationNavigation(navigation) {
584
640
 
585
641
  function invalidateAiConversationNavigation(reason, action = "切换作品") {
586
642
  if (aiRequestManager.hasActive()) {
587
- toast(`当前 turn 尚未结束,${action}会中断生成;已收到的内容会保留在历史记录中`, "warning");
643
+ toast(`仍有 Agent turn 尚未结束,${action}会中断全部生成;已收到的内容会保留在历史记录中`, "warning");
588
644
  }
589
645
  aiConversationNavigationGeneration += 1;
590
646
  aiConversationNavigationPending = null;
591
- cancelActiveAiRequest(reason);
647
+ aiRequestManager.cancelAll(reason);
648
+ syncAiRequestControls();
592
649
  }
593
650
 
594
651
  function aiAssistantRequestId(request) {
@@ -665,6 +722,8 @@ let workSelectionRequestGeneration = 0;
665
722
  let chapterSelectionRequestGeneration = 0;
666
723
  let aiConversationNavigationGeneration = 0;
667
724
  let aiConversationNavigationPending = null;
725
+ let aiChatTabLimit = 5;
726
+ let aiConversationWorkspaceOpen = false;
668
727
  const loadedVolumeChapterIds = new Set();
669
728
  const volumeChapterLoadingIds = new Set();
670
729
  const volumeChapterRequests = new Map();
@@ -873,6 +932,7 @@ function replacePageRoute(route) {
873
932
  const hash = serializePageRoute(route);
874
933
  if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
875
934
  schedulePresenceHeartbeat();
935
+ syncTopSearchButton();
876
936
  }
877
937
 
878
938
  function presencePageForRoute(route = currentPageRoute()) {
@@ -1084,6 +1144,149 @@ function currentPageRoute() {
1084
1144
  return { view: "welcome", workId };
1085
1145
  }
1086
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
+
1087
1290
  function loadPanelLayout() {
1088
1291
  try {
1089
1292
  const stored = JSON.parse(localStorage.getItem(panelLayoutStorageKey) ?? "{}");
@@ -1142,9 +1345,8 @@ function applyPanelLayout(persist = false) {
1142
1345
  }
1143
1346
 
1144
1347
  function ensureAiPanelExpanded() {
1145
- if (!panelLayout.aiCollapsed) return;
1146
- panelLayout.aiCollapsed = false;
1147
- applyPanelLayout(true);
1348
+ if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1349
+ setAiConversationWorkspaceVisible(true);
1148
1350
  }
1149
1351
 
1150
1352
  function setupPanelResize(handle, side) {
@@ -1193,6 +1395,7 @@ let chapterAutoSaveTimer = null;
1193
1395
  let chapterSaveInFlight = null;
1194
1396
  let chapterSaveGuardInFlight = null;
1195
1397
  let lastSavedChapterSnapshot = null;
1398
+ let chapterSearchMatchIndex = -1;
1196
1399
  let chapterSelectionRequestId = 0;
1197
1400
  let chapterForeshadowReminderRequestId = 0;
1198
1401
  let chapterForeshadowReminders = [];
@@ -1234,7 +1437,9 @@ const moduleListPages = {
1234
1437
  comments: 1,
1235
1438
  reviews: 1
1236
1439
  };
1237
- 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", "已死亡"]];
1238
1443
  let characterFiltersPanelOpen = false;
1239
1444
  const settingFilters = { keyword: "", category: "", lockState: "all" };
1240
1445
  let settingFiltersPanelOpen = false;
@@ -1293,6 +1498,7 @@ function applyChapterEditorMode() {
1293
1498
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1294
1499
  $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1295
1500
  $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1501
+ syncChapterSearchControls();
1296
1502
  if (viewOnly) cancelChapterAutoSave();
1297
1503
  }
1298
1504
 
@@ -1781,9 +1987,345 @@ function renderAiQuickActions() {
1781
1987
  quickActions.setAttribute("aria-hidden", String(!visible));
1782
1988
  }
1783
1989
 
1784
- 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()) {
1785
2327
  const timestamp = createdAt || new Date().toISOString();
1786
- const previousCreatedAt = state.aiLastMessageAt;
2328
+ const previousCreatedAt = tab?.lastMessageAt ?? state.aiLastMessageAt;
1787
2329
  const heading = document.createElement("span");
1788
2330
  heading.className = "message-heading";
1789
2331
  const role = document.createElement("span");
@@ -1795,7 +2337,8 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1795
2337
  message.prepend(heading);
1796
2338
  message.dataset.createdAt = timestamp;
1797
2339
  message.dataset.previousCreatedAt = previousCreatedAt ?? "";
1798
- state.aiLastMessageAt = timestamp;
2340
+ if (tab) tab.lastMessageAt = timestamp;
2341
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = timestamp;
1799
2342
  return heading;
1800
2343
  }
1801
2344
 
@@ -1806,19 +2349,25 @@ function updateMessageCreatedAt(message, createdAt) {
1806
2349
  time.dateTime = createdAt;
1807
2350
  time.textContent = formatAiMessageTime(createdAt);
1808
2351
  message.dataset.createdAt = createdAt;
1809
- 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
+ }
1810
2357
  }
1811
2358
 
1812
- function resetAiFeed() {
1813
- state.aiLastMessageAt = null;
1814
- const roleplayName = state.aiRoleplayCharacter?.name;
1815
- $("#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
1816
2365
  ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
1817
2366
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1818
2367
  }
1819
2368
 
1820
- function aiAssistantLabel(suffix = "") {
1821
- const name = state.aiRoleplayCharacter?.name || "助手";
2369
+ function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
2370
+ const name = roleplayCharacter?.name || "助手";
1822
2371
  return suffix ? `${name} · ${suffix}` : name;
1823
2372
  }
1824
2373
 
@@ -1834,26 +2383,24 @@ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "
1834
2383
  return divider;
1835
2384
  }
1836
2385
 
1837
- function appendAiContextCompactionDivider(kind, before = null) {
2386
+ function appendAiContextCompactionDivider(kind, before = null, feed = $("#ai-feed")) {
1838
2387
  const divider = createAiContextCompactionDivider({ kind });
1839
- const feed = $("#ai-feed");
1840
2388
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1841
2389
  else feed.append(divider);
1842
- scrollAiFeedToBottom();
2390
+ scrollAiFeedToBottom(feed);
1843
2391
  return divider;
1844
2392
  }
1845
2393
 
1846
- function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
1847
- const feed = $("#ai-feed");
2394
+ function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null, feed = $("#ai-feed"), conversation = null) {
1848
2395
  feed.querySelector('[data-context-compaction="conversation"]')?.remove();
1849
2396
  const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
1850
2397
  if (compactedCount === 0) return null;
1851
2398
  const messages = [...feed.querySelectorAll("[data-message-id]")];
1852
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
2399
+ const current = conversation ?? state.aiConversations.find((item) => item.id === state.aiConversationId);
1853
2400
  const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
1854
2401
  const loadedOffset = Math.max(0, totalCount - messages.length);
1855
2402
  const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
1856
- return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
2403
+ return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
1857
2404
  }
1858
2405
 
1859
2406
  function renderMessageCardActions(message) {
@@ -1890,9 +2437,16 @@ function renderMessageCardActions(message) {
1890
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>';
1891
2438
  const forkRequestId = createAiIdempotencyKey();
1892
2439
  fork.addEventListener("click", async () => {
2440
+ const openingSlot = aiChatTabOpeningSlot();
2441
+ if (!openingSlot.allowed) {
2442
+ reportAiChatTabLimit();
2443
+ return;
2444
+ }
1893
2445
  fork.disabled = true;
1894
2446
  try {
1895
- 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`, {
1896
2450
  method: "POST",
1897
2451
  body: { messageId: message.dataset.messageId, requestId: forkRequestId }
1898
2452
  });
@@ -1931,7 +2485,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1931
2485
  search_drafts: "搜索想法",
1932
2486
  recall_self: "回忆自身",
1933
2487
  image: "读取设定图片",
1934
- recall_relationship: "回忆人物关系"
2488
+ recall_relationship: "回忆人物关系",
2489
+ calculate_time: "计算日期"
1935
2490
  };
1936
2491
 
1937
2492
  const AI_TOOL_DESCRIPTIONS = {
@@ -1943,10 +2498,11 @@ const AI_TOOL_DESCRIPTIONS = {
1943
2498
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1944
2499
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
1945
2500
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
1946
- recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。"
2501
+ recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2502
+ calculate_time: "计算日期差值,或从起始日期推算目标日期。"
1947
2503
  };
1948
2504
 
1949
- let aiFeedScrollFrame = null;
2505
+ const aiFeedScrollFrames = new WeakMap();
1950
2506
  let markdownTableMenuTarget = null;
1951
2507
  let markdownTableMenuTrigger = null;
1952
2508
 
@@ -1987,14 +2543,15 @@ function openMarkdownTableMenu(header, clientX, clientY) {
1987
2543
  toggle.focus();
1988
2544
  }
1989
2545
 
1990
- function scrollAiFeedToBottom() {
1991
- const feed = $("#ai-feed");
2546
+ function scrollAiFeedToBottom(feed = $("#ai-feed")) {
1992
2547
  feed.scrollTop = feed.scrollHeight;
1993
- if (aiFeedScrollFrame !== null) window.cancelAnimationFrame(aiFeedScrollFrame);
1994
- aiFeedScrollFrame = window.requestAnimationFrame(() => {
2548
+ const currentFrame = aiFeedScrollFrames.get(feed);
2549
+ if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
2550
+ const nextFrame = window.requestAnimationFrame(() => {
1995
2551
  feed.scrollTop = feed.scrollHeight;
1996
- aiFeedScrollFrame = null;
2552
+ aiFeedScrollFrames.delete(feed);
1997
2553
  });
2554
+ aiFeedScrollFrames.set(feed, nextFrame);
1998
2555
  }
1999
2556
 
2000
2557
  function formatAiToolCallTime(value) {
@@ -2012,9 +2569,14 @@ function formatAiToolCallTime(value) {
2012
2569
  }).format(date);
2013
2570
  }
2014
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
+
2015
2575
  function setAiToolCallCopyButtonState(button, copied) {
2016
2576
  const label = button.dataset.copyLabel ?? "代码块";
2017
- button.dataset.copyState = copied ? "copied" : "idle";
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;
2018
2580
  button.classList.toggle("is-copied", copied);
2019
2581
  button.setAttribute("aria-label", copied ? `${label}已复制` : `复制${label}`);
2020
2582
  button.title = copied ? "已复制" : `复制${label}`;
@@ -2233,23 +2795,15 @@ async function downloadAiConversation(conversation) {
2233
2795
  window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
2234
2796
  }
2235
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
+
2236
2804
  function resetAiConversationAfterDelete() {
2237
- state.aiConversationId = null;
2238
- state.aiConversationModelId = null;
2239
- state.aiPromptSent = false;
2240
- state.aiCitations = [];
2241
- state.aiReferences = [];
2242
- applyAiConversationTaskType("chat");
2243
- applyAiConversationContextScope({ type: "none" });
2244
- applyAiRoleplayCharacter(null);
2245
- $("#ai-conversation-title").textContent = "新对话";
2246
- resetAiFeed();
2247
- hideAiContextWarning();
2248
- resetAiContextMeter();
2249
- setAiPromptText("");
2250
- renderAiCitations();
2251
- renderAiReferences();
2252
- renderAiQuickActions();
2805
+ const tab = activeAiChatTab();
2806
+ if (tab) closeAiChatTab(tab.id);
2253
2807
  }
2254
2808
 
2255
2809
  async function favoriteAiConversation(conversation) {
@@ -2272,7 +2826,11 @@ async function deleteAiConversation(conversation) {
2272
2826
  })) return;
2273
2827
  await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}`, { method: "DELETE" });
2274
2828
  state.aiConversations = state.aiConversations.filter((item) => item.id !== conversation.id);
2275
- 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
+ }
2276
2834
  renderAiConversationHistory();
2277
2835
  toast("对话已清理");
2278
2836
  }
@@ -2408,8 +2966,11 @@ function applyAiConversationTitle(title, conversationId = state.aiConversationId
2408
2966
  if (!title || !conversationId) return;
2409
2967
  const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
2410
2968
  if (current) current.title = title;
2969
+ const tab = aiChatTabManager.findByConversation(conversationId);
2970
+ if (tab) tab.title = title;
2411
2971
  if (state.aiConversationId === conversationId) $("#ai-conversation-title").textContent = title;
2412
2972
  renderAiConversationHistory();
2973
+ renderAiChatTabs();
2413
2974
  }
2414
2975
 
2415
2976
  function applyAiConversations(pageResult) {
@@ -2453,6 +3014,21 @@ async function ensureAiConversationsLoaded() {
2453
3014
 
2454
3015
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2455
3016
  if (!state.work) return null;
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
+ }
2456
3032
  const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
2457
3033
  try {
2458
3034
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
@@ -2464,28 +3040,18 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
2464
3040
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2465
3041
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
2466
3042
  upsertAiConversationSummary(conversation);
2467
- state.aiConversationId = conversation.id;
2468
- state.aiConversationModelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
2469
- state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
2470
- applyAiConversationTaskType(conversation.taskType);
2471
- applyAiConversationContextScope(conversation.contextScope);
2472
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
2473
- resetAiContextMeter();
2474
- $("#ai-conversation-title").textContent = conversation.title;
2475
- resetAiFeed();
2476
- for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
2477
- if (conversation.hasCompactedSummary) {
2478
- renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
3043
+ persistActiveAiChatTab();
3044
+ if (!existingTab) openingSlot = aiChatTabOpeningSlot();
3045
+ if (!openingSlot.allowed) {
3046
+ reportAiChatTabLimit();
3047
+ return null;
2479
3048
  }
2480
- state.aiCitations = [];
2481
- state.aiReferences = [];
2482
- setAiPromptText("");
2483
- renderAiCitations();
2484
- renderAiReferences();
2485
- renderAiQuickActions();
2486
- renderAiConversationHistory();
2487
- if (conversation.contextWarningPending) showAiContextWarning();
2488
- else hideAiContextWarning();
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));
2489
3055
  if (hideHistory) setAiHistoryVisible(false);
2490
3056
  return conversation;
2491
3057
  } catch (error) {
@@ -2505,30 +3071,60 @@ function focusAiConversationMessage(messageId) {
2505
3071
  window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
2506
3072
  }
2507
3073
 
2508
- 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()) {
2509
3100
  upsertAiConversationSummary(conversation);
2510
- state.aiConversationId = conversation.id;
2511
- state.aiConversationModelId = null;
2512
- state.aiPromptSent = false;
2513
- applyAiConversationTaskType(conversation.taskType);
2514
- applyAiConversationContextScope(conversation.contextScope);
2515
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
2516
- $("#ai-conversation-title").textContent = conversation.title;
2517
- resetAiFeed();
2518
- hideAiContextWarning();
2519
- resetAiContextMeter();
2520
- renderAiQuickActions();
3101
+ applyConversationToAiChatTab(tab, { ...conversation, messages: [] });
3102
+ if (isActiveAiChatTab(tab)) applyAiChatTabState(tab);
2521
3103
  setAiHistoryVisible(false);
2522
3104
  }
2523
3105
 
2524
3106
  async function createNewAiConversation(taskType = "chat") {
2525
3107
  if (!state.work) return null;
3108
+ let openingSlot = aiChatTabOpeningSlot();
3109
+ if (!openingSlot.allowed) {
3110
+ reportAiChatTabLimit();
3111
+ return null;
3112
+ }
2526
3113
  const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
2527
3114
  try {
2528
3115
  const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2529
3116
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2530
3117
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("新对话不属于当前作品");
2531
- 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 });
2532
3128
  return conversation;
2533
3129
  } catch (error) {
2534
3130
  if (!isAiConversationNavigationCurrent(navigation)) return null;
@@ -2610,6 +3206,8 @@ function syncAiTaskOptions() {
2610
3206
  function applyAiConversationTaskType(taskType) {
2611
3207
  const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
2612
3208
  state.aiTaskType = normalizedTaskType;
3209
+ const tab = activeAiChatTab();
3210
+ if (tab) tab.taskType = normalizedTaskType;
2613
3211
  $("#ai-task").value = normalizedTaskType;
2614
3212
  $("#ai-task").dataset.previousValue = normalizedTaskType;
2615
3213
  syncAiTaskOptions();
@@ -2618,6 +3216,8 @@ function applyAiConversationTaskType(taskType) {
2618
3216
  function applyAiConversationContextScope(scope) {
2619
3217
  const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
2620
3218
  state.aiContextScope = normalizedScope;
3219
+ const tab = activeAiChatTab();
3220
+ if (tab) tab.contextScope = { ...normalizedScope };
2621
3221
  $("#ai-scope").value = normalizedScope.type === "book" ? "book"
2622
3222
  : normalizedScope.type === "volume" ? "volume"
2623
3223
  : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
@@ -2628,6 +3228,8 @@ function applyAiConversationContextScope(scope) {
2628
3228
 
2629
3229
  function applyAiRoleplayCharacter(character) {
2630
3230
  state.aiRoleplayCharacter = character?.id ? character : null;
3231
+ const tab = activeAiChatTab();
3232
+ if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2631
3233
  const active = Boolean(state.aiRoleplayCharacter);
2632
3234
  if (active) $("#ai-scope").value = "none";
2633
3235
  $(".ai-panel").classList.toggle("is-roleplaying", active);
@@ -2693,6 +3295,8 @@ async function persistAiConversationContextScope(scope) {
2693
3295
 
2694
3296
  async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2695
3297
  let request = assertAiRequestCurrent(requestHolder.snapshot);
3298
+ const tab = aiChatTabForRequest(request);
3299
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
2696
3300
  if (!request.conversationId) {
2697
3301
  const conversation = await api(`/api/works/${encodeURIComponent(request.workId)}/ai-conversations`, {
2698
3302
  method: "POST",
@@ -2701,7 +3305,7 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2701
3305
  });
2702
3306
  assertAiRequestCurrent(request);
2703
3307
  if (String(conversation.workId ?? "") !== request.workId) throw new Error("新对话不属于请求作品");
2704
- applyNewAiConversation(conversation);
3308
+ applyNewAiConversation(conversation, tab);
2705
3309
  requestHolder.snapshot = aiRequestManager.bind(request, { conversationId: conversation.id });
2706
3310
  request = requestHolder.snapshot;
2707
3311
  }
@@ -2712,8 +3316,12 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2712
3316
  });
2713
3317
  assertAiRequestCurrent(request);
2714
3318
  upsertAiConversationSummary(taskConversation);
2715
- applyAiConversationTaskType(taskConversation.taskType);
2716
- 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
+ }
2717
3325
 
2718
3326
  const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
2719
3327
  method: "PATCH",
@@ -2722,7 +3330,8 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2722
3330
  });
2723
3331
  assertAiRequestCurrent(request);
2724
3332
  upsertAiConversationSummary(scopedConversation);
2725
- applyAiConversationContextScope(scopedConversation.contextScope);
3333
+ tab.contextScope = scopedConversation.contextScope;
3334
+ if (isActiveAiChatTab(tab)) applyAiConversationContextScope(tab.contextScope);
2726
3335
  return request;
2727
3336
  }
2728
3337
 
@@ -2746,13 +3355,29 @@ async function persistAiRequestInterruption(request, interruption = null) {
2746
3355
  const partialContent = typeof interruption?.content === "string" && interruption.content.trim()
2747
3356
  ? interruption.content
2748
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
+ };
2749
3374
  try {
2750
3375
  const message = await persistAiConversationMessage(
2751
3376
  request.conversationId,
2752
3377
  "assistant",
2753
- partialContent ?? "调用失败:生成已取消,尚未收到可保留的回复内容。",
3378
+ partialContent ?? "生成已终止,尚未收到可保留的回复内容。",
2754
3379
  [],
2755
- partialContent ? interruption.metadata : {},
3380
+ metadata,
2756
3381
  { requestId: aiAssistantRequestId(request) }
2757
3382
  );
2758
3383
  updateAiConversationSummaryFromMessage(message);
@@ -3361,6 +3986,7 @@ async function api(path, options = {}) {
3361
3986
  const payload = await response.json();
3362
3987
  if (path === "/api/health") {
3363
3988
  applyImageUploadLimits(payload.data?.uploadLimits);
3989
+ applyAiChatTabLimit(payload.data?.aiChatTabLimit);
3364
3990
  updateSystemHealth({
3365
3991
  status: payload.data?.status === "ok" ? "ready" : "degraded",
3366
3992
  version: payload.data?.version
@@ -3447,6 +4073,7 @@ function aiStreamInterruptionLabel(code) {
3447
4073
  if (code === "AI_STREAM_NETWORK_ERROR") return "网络中断";
3448
4074
  if (code === "AI_STREAM_UPSTREAM_CLOSED") return "流被关闭";
3449
4075
  if (code === "AI_STREAM_REQUEST_CANCELLED") return "请求已取消";
4076
+ if (code === "AI_REQUEST_CANCELLED") return "已终止";
3450
4077
  return "生成中断";
3451
4078
  }
3452
4079
 
@@ -3578,8 +4205,9 @@ function invalidateModuleRequestsAfterMutation(path, method) {
3578
4205
 
3579
4206
  function applyProductHealthMetadata(health) {
3580
4207
  const version = String(health?.version ?? "").trim();
4208
+ const versionLabel = String(health?.versionLabel ?? "").trim();
3581
4209
  document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
3582
- element.textContent = version ? `v${version}` : "v—";
4210
+ element.textContent = versionLabel || (version ? `v${version}` : "v—");
3583
4211
  });
3584
4212
  document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
3585
4213
  element.classList.toggle("hidden", health?.development !== true);
@@ -4286,7 +4914,6 @@ async function persistChapter({ automatic = false } = {}) {
4286
4914
  const currentDraft = chapterDraftSnapshot();
4287
4915
  if (sameChapterSnapshot(currentDraft, draft)) {
4288
4916
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
4289
- if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
4290
4917
  } else {
4291
4918
  scheduleChapterAutoSave(250);
4292
4919
  }
@@ -5359,12 +5986,29 @@ function syncGlobalReplaceScopeOptions() {
5359
5986
  const selected = options.find((option) => option.checked && !option.disabled) ?? options.find((option) => !option.disabled);
5360
5987
  options.forEach((option) => { option.checked = option === selected; });
5361
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
+ : "选择分卷后,只替换该分卷的章节正文;设定库不按分卷筛选。";
5362
5995
  $("#replace-submit").disabled = !scope || !canGlobalReplaceScope(scope);
5363
5996
  $("#replace-permission-note").textContent = scope
5364
5997
  ? "替换完成后,命中的章节和设定会分别生成新的版本历史。"
5365
5998
  : "当前账户没有可写入的正文或设定库权限。";
5366
5999
  }
5367
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
+
5368
6012
  function openGlobalReplaceDialog() {
5369
6013
  if (!state.work) {
5370
6014
  toast("请先打开一部作品", "error");
@@ -5376,6 +6020,7 @@ function openGlobalReplaceDialog() {
5376
6020
  }
5377
6021
  if ($("#search-dialog").open) $("#search-dialog").close();
5378
6022
  $("#replace-form").reset();
6023
+ renderGlobalReplaceVolumeOptions();
5379
6024
  syncGlobalReplaceScopeOptions();
5380
6025
  $("#replace-dialog").showModal();
5381
6026
  queueMicrotask(() => $("#replace-find").focus());
@@ -5463,6 +6108,7 @@ async function submitGlobalReplace(event) {
5463
6108
  const find = $("#replace-find").value;
5464
6109
  const replacement = $("#replace-with").value;
5465
6110
  const scope = $("#replace-form").querySelector('input[name="replaceScope"]:checked')?.value ?? "prose";
6111
+ const volumeId = scope === "settings" ? null : ($("#replace-volume").value || null);
5466
6112
  if (!find.trim()) {
5467
6113
  toast("请输入要查找的内容", "error");
5468
6114
  $("#replace-find").focus();
@@ -5485,8 +6131,14 @@ async function submitGlobalReplace(event) {
5485
6131
  return;
5486
6132
  }
5487
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;
5488
6140
  const replacementLabel = replacement ? `“${replacement}”` : "空内容";
5489
- const confirmed = await confirmToast(`将把《${state.work.title}》的${scopeLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
6141
+ const confirmed = await confirmToast(`将把《${state.work.title}》的${targetLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
5490
6142
  title: "确认全局替换",
5491
6143
  confirmLabel: "确认替换"
5492
6144
  });
@@ -5511,7 +6163,7 @@ async function submitGlobalReplace(event) {
5511
6163
  try {
5512
6164
  const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
5513
6165
  method: "POST",
5514
- body: { find, replacement, scope },
6166
+ body: { find, replacement, scope, volumeId },
5515
6167
  skipOptimisticVersion: true
5516
6168
  });
5517
6169
  $("#replace-dialog").close();
@@ -5833,23 +6485,9 @@ function resetWorkScopedUiCaches() {
5833
6485
  volumeChapterRequests.clear();
5834
6486
  state.collapsedRaceIds.clear();
5835
6487
  lastSavedChapterSnapshot = null;
5836
- state.aiCitations = [];
5837
- state.aiReferences = [];
5838
- state.aiPromptSent = false;
5839
- state.aiConversationId = null;
5840
- state.aiConversationModelId = null;
5841
6488
  state.aiConversations = [];
5842
- state.aiRoleplayCharacter = null;
5843
- renderAiCitations();
5844
- renderAiReferences();
5845
- renderAiQuickActions();
5846
- resetAiFeed();
5847
- applyAiConversationTaskType("chat");
5848
- applyAiConversationContextScope({ type: "none" });
5849
- applyAiRoleplayCharacter(null);
5850
- $("#ai-conversation-title").textContent = "新对话";
6489
+ resetAiChatTabs();
5851
6490
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
5852
- resetAiContextMeter();
5853
6491
  renderAiConversationHistory();
5854
6492
  }
5855
6493
 
@@ -5994,7 +6632,7 @@ function renderTree() {
5994
6632
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
5995
6633
  <div class="volume-title">
5996
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>
5997
- ${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>` : ""}
5998
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>` : ""}
5999
6637
  </div>
6000
6638
  <div class="volume-chapters">
@@ -6506,6 +7144,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6506
7144
  if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
6507
7145
  return false;
6508
7146
  }
7147
+ resetChapterSearchPanel();
6509
7148
  dismissDeleteToasts();
6510
7149
  if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6511
7150
  cancelChapterAutoSave();
@@ -7021,22 +7660,15 @@ function handleReadingKeyboard(event) {
7021
7660
  }
7022
7661
  }
7023
7662
 
7024
- function handleReadingWheel(event) {
7025
- if (readingPreferences.mode !== "scroll" || readingLoading || Math.abs(event.deltaY) < 2) return;
7026
- const viewport = $("#reader-viewport");
7027
- const atEnd = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 2;
7028
- const atStart = viewport.scrollTop <= 2;
7029
- if (event.deltaY > 0 && atEnd) {
7030
- event.preventDefault();
7031
- void navigateReadingChapter(1, { continueFromBoundary: true });
7032
- } else if (event.deltaY < 0 && atStart) {
7033
- event.preventDefault();
7034
- void navigateReadingChapter(-1, { continueFromBoundary: true });
7035
- }
7036
- }
7037
-
7038
7663
  async function saveChapter() {
7039
- 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
+ }
7040
7672
  }
7041
7673
 
7042
7674
  function tidyChapterBlankLines() {
@@ -7067,7 +7699,7 @@ function showWelcome(hasWork = false) {
7067
7699
  }
7068
7700
 
7069
7701
  function markActiveModule(module) {
7070
- $("#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)));
7071
7703
  }
7072
7704
 
7073
7705
  const moduleMeta = {
@@ -7865,7 +8497,10 @@ async function renderSettings(page = moduleListPages.settings) {
7865
8497
  }
7866
8498
 
7867
8499
  async function renderCharacters(page = characterListPage) {
7868
- 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";
7869
8504
  const pageSize = pageSizeFor("characters");
7870
8505
  const [characterSource, races, organizations] = await Promise.all([
7871
8506
  hasCharacterFilters
@@ -7893,6 +8528,7 @@ async function renderCharacters(page = characterListPage) {
7893
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}”`)}
7894
8529
  <div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
7895
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>
7896
8532
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
7897
8533
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
7898
8534
  ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
@@ -7906,6 +8542,7 @@ async function renderCharacters(page = characterListPage) {
7906
8542
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
7907
8543
  const meta = [
7908
8544
  item.code ? `编号 ${item.code}` : "",
8545
+ `性别 ${characterGenderLabel(item.gender)}`,
7909
8546
  item.species ? (racePathLabel(item.race) || item.species) : "",
7910
8547
  ...(item.aliases ?? []).slice(0, 3),
7911
8548
  (item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
@@ -7927,16 +8564,25 @@ async function renderCharacters(page = characterListPage) {
7927
8564
  : "";
7928
8565
  const selectedRaceIds = new Set(characterFilters.raceIds);
7929
8566
  const selectedOrganizationIds = new Set(characterFilters.organizationIds);
8567
+ const selectedGenderValues = new Set(characterFilters.genderValues);
7930
8568
  const selectedRaceNames = races.filter((race) => selectedRaceIds.has(String(race.id))).map((race) => racePathLabel(race) || race.name);
7931
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] ?? "全部状态";
7932
8574
  const filterOptionList = (items, selectedIds, valueKey = "id") => items.map((item) => {
7933
8575
  const value = String(item[valueKey]);
7934
8576
  const label = valueKey === "id" ? (racePathLabel(item) || item.name) : item.name;
7935
8577
  return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
7936
8578
  }).join("");
7937
- 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="角色筛选">
7938
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>
7939
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>
7940
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>
7941
8587
  </section>`;
7942
8588
  mountCharacterFilterToggle();
@@ -7958,10 +8604,24 @@ async function renderCharacters(page = characterListPage) {
7958
8604
  characterListPage = 1;
7959
8605
  await renderCharacters(1);
7960
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
+ });
7961
8619
  $("#clear-character-filters")?.addEventListener("click", async () => {
7962
8620
  characterFiltersPanelOpen = true;
7963
8621
  characterFilters.raceIds = [];
7964
8622
  characterFilters.organizationIds = [];
8623
+ characterFilters.genderValues = [];
8624
+ characterFilters.deathState = "all";
7965
8625
  characterListPage = 1;
7966
8626
  await renderCharacters(1);
7967
8627
  });
@@ -8556,9 +9216,9 @@ async function renderRelationships(page = moduleListPages.relationships) {
8556
9216
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
8557
9217
  const graph = buildRelationshipGraph(state.characters, relationships);
8558
9218
  state.relationshipGraph = graph;
8559
- 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) => `
8560
9220
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
8561
- <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>';
8562
9222
  $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
8563
9223
  bindModulePagination("relationships", renderRelationships);
8564
9224
  const openGalaxy = () => {
@@ -8566,6 +9226,8 @@ async function renderRelationships(page = moduleListPages.relationships) {
8566
9226
  const galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, {
8567
9227
  workId: state.work.id,
8568
9228
  frameRate: state.uiSettings.galaxyFrameRate,
9229
+ motionMode: storedGalaxyMotionMode(),
9230
+ onMotionModeChange: persistGalaxyMotionMode,
8569
9231
  onClose: () => {
8570
9232
  if (state.galaxy === galaxy) state.galaxy = null;
8571
9233
  }
@@ -8742,7 +9404,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8742
9404
  moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
8743
9405
  canReadModule("ai-settings")
8744
9406
  ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
8745
- : 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 })
8746
9408
  ]);
8747
9409
  if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
8748
9410
  taskListPage = taskPage.page;
@@ -8779,6 +9441,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8779
9441
  <div class="task-auto-run-copy">
8780
9442
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
8781
9443
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
9444
+ <small>正文停止编辑后,等待稳定窗口结束才会创建章节理解任务。</small>
8782
9445
  <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
8783
9446
  </div>
8784
9447
  <div class="task-auto-run-actions">
@@ -8790,11 +9453,13 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8790
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>
8791
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>
8792
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>
8793
9457
  ${autoRunEditing
8794
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>`
8795
9459
  : ""}
8796
9460
  </div>
8797
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>
8798
9463
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
8799
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>` : ""}
8800
9465
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
@@ -8848,7 +9513,8 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8848
9513
  autoRunEnabled: $("#task-auto-run-enabled").checked,
8849
9514
  autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
8850
9515
  autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
8851
- 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)
8852
9518
  }
8853
9519
  });
8854
9520
  toast(updated.autoRunEnabled
@@ -9771,7 +10437,7 @@ function renderProviderCards(providers, models) {
9771
10437
  : "";
9772
10438
  return `
9773
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>
9774
- ${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>
9775
10441
  <div class="provider-models">${providerModels.map((model) => {
9776
10442
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
9777
10443
  const modelStatus = !model.enabled
@@ -9781,7 +10447,8 @@ function renderProviderCards(providers, models) {
9781
10447
  : "";
9782
10448
  const capability = model.multimodalEnabled ? " · 多模态" : "";
9783
10449
  const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
9784
- 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>`;
9785
10452
  }).join("")}</div>
9786
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>`;
9787
10454
  }).join("")}</div>`
@@ -10106,7 +10773,12 @@ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
10106
10773
  backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
10107
10774
  backgroundTaskCenterSnapshot.taskPage = taskResult.value;
10108
10775
  if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
10109
- 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) {
10110
10782
  const notification = backgroundTaskTransitionMessage(transition);
10111
10783
  toast(notification.message, notification.type);
10112
10784
  }
@@ -10134,6 +10806,7 @@ function stopBackgroundTaskCenter() {
10134
10806
  backgroundTaskCenterWorkId = null;
10135
10807
  backgroundTaskCenterTasksInitialized = false;
10136
10808
  backgroundTaskCenterTaskSnapshots = new Map();
10809
+ backgroundTaskCenterExpiredNoticeTimes = new Map();
10137
10810
  backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
10138
10811
  const dialog = $("#background-task-dialog");
10139
10812
  if (dialog?.open) dialog.close();
@@ -10347,7 +11020,7 @@ async function renderBookAiSettings() {
10347
11020
  const host = $("#module-content");
10348
11021
  const workId = String(state.work.id);
10349
11022
  const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
10350
- 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"]);
10351
11024
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
10352
11025
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
10353
11026
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
@@ -10380,7 +11053,7 @@ async function renderBookAiSettings() {
10380
11053
  );
10381
11054
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
10382
11055
  "beforeend",
10383
- `<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>`
10384
11057
  );
10385
11058
  if (!canEditModule("ai-settings")) {
10386
11059
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -10772,6 +11445,18 @@ function resetAiContextMeter() {
10772
11445
  setAiContextMeter(null);
10773
11446
  }
10774
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
+
10775
11460
  function setAiContextDistributionVisible(visible) {
10776
11461
  const meter = $("#ai-context-meter");
10777
11462
  const popover = $("#ai-context-popover");
@@ -10790,6 +11475,14 @@ function hideAiContextWarning() {
10790
11475
  $("#ai-context-warning").classList.add("hidden");
10791
11476
  }
10792
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
+
10793
11486
  function setAiContextWarningActionsDisabled(disabled) {
10794
11487
  for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
10795
11488
  }
@@ -11640,8 +12333,11 @@ function renderCharacterEditorRelationships() {
11640
12333
  const category = relationshipCategoryLabel(relationship.category);
11641
12334
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
11642
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
+ : "";
11643
12339
  return `<article class="character-relationship-row">
11644
- <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>
11645
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>
11646
12342
  </article>`;
11647
12343
  }).join("");
@@ -11650,6 +12346,14 @@ function renderCharacterEditorRelationships() {
11650
12346
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
11651
12347
  if (relationship) void openRelationshipDialog(relationship, { characterId });
11652
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
+ }));
11653
12357
  host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
11654
12358
  }
11655
12359
 
@@ -11668,7 +12372,7 @@ async function loadCharacterEditorRelationships(characterId, { refresh = false }
11668
12372
  ]);
11669
12373
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
11670
12374
  state.characters = characters;
11671
- characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
12375
+ characterEditorRelationships = sortCharacterRelationships(relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId), characterId);
11672
12376
  characterEditorRelationshipsLoaded = true;
11673
12377
  loaded = true;
11674
12378
  } catch (error) {
@@ -11692,6 +12396,21 @@ async function refreshRelationshipSurfaces(characterId = null) {
11692
12396
  await Promise.all(tasks);
11693
12397
  }
11694
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
+
11695
12414
  const characterSectionTypeLabels = {
11696
12415
  overview: "基本档案",
11697
12416
  appearance: "外貌与生理",
@@ -12426,6 +13145,7 @@ function renderCharacterEditorFields(item) {
12426
13145
  $("#character-editor-fields").innerHTML = [
12427
13146
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
12428
13147
  field("name", "标准名", "text", item?.name) +
13148
+ field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
12429
13149
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
12430
13150
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
12431
13151
  (!canReadModule("races")
@@ -12479,6 +13199,7 @@ function collectCharacterBody(form) {
12479
13199
  delete profile.sections;
12480
13200
  const body = {
12481
13201
  name: String(form.get("name") ?? "").trim(),
13202
+ gender: String(form.get("gender") ?? "unknown"),
12482
13203
  isDead: form.has("isDead"),
12483
13204
  code: String(form.get("code") ?? "").trim(),
12484
13205
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
@@ -13083,7 +13804,6 @@ async function openRelationshipDialog(item, options = {}) {
13083
13804
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
13084
13805
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
13085
13806
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
13086
- const relationshipName = item ? `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}` : "";
13087
13807
  const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
13088
13808
  <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
13089
13809
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
@@ -13101,16 +13821,7 @@ async function openRelationshipDialog(item, options = {}) {
13101
13821
  dialog.showModal();
13102
13822
  };
13103
13823
  dialog.close();
13104
- if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return reopenDialog();
13105
- if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return reopenDialog();
13106
- try {
13107
- await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
13108
- await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
13109
- deleteToast(`已删除人物关系“${relationshipName}”`);
13110
- } catch (error) {
13111
- reopenDialog();
13112
- toast(error.message, "error");
13113
- }
13824
+ if (!await deleteRelationshipRecord(item, options.characterId ?? null)) return reopenDialog();
13114
13825
  });
13115
13826
  }
13116
13827
 
@@ -13161,17 +13872,23 @@ async function openTaskDialog() {
13161
13872
  group.push(item);
13162
13873
  groups.set(groupKey, { id: groupId, title: groupTitle, options: group });
13163
13874
  return groups;
13164
- }, new Map())].map(([, group]) => `<section class="task-scope-volume-group" data-task-scope-group data-task-scope-volume-id="${esc(group.id)}">
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)}">
13165
13878
  <header>
13166
13879
  ${group.id ? `<label class="task-scope-volume-option">
13167
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)}章节">
13168
13881
  <span class="task-scope-checkbox" aria-hidden="true"></span>
13169
13882
  <span class="task-scope-volume-copy"><strong>${esc(group.title)}</strong><small>勾选以全选本卷章节</small></span>
13170
13883
  </label>` : `<strong>${esc(group.title)}</strong>`}
13171
- <span>${group.options.length} 章</span>
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>` : ""}
13172
13886
  </header>
13173
- ${group.options.map(renderOption).join("")}
13174
- </section>`).join("");
13887
+ <div id="${esc(groupOptionsId)}" class="task-scope-volume-options">
13888
+ ${group.options.map(renderOption).join("")}
13889
+ </div>
13890
+ </section>`;
13891
+ }).join("");
13175
13892
  return `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13176
13893
  <div class="task-scope-field-heading"><span id="task-${kind}-label">${esc(label)}(可多选)</span><small>从左侧选择,右侧确认已选内容</small></div>
13177
13894
  <div class="task-scope-picker">
@@ -13262,7 +13979,8 @@ async function openTaskDialog() {
13262
13979
  const buildRelationshipScope = (form) => {
13263
13980
  const taskType = String(form.get("taskType"));
13264
13981
  const scopeType = String(form.get("scopeType"));
13265
- const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
13982
+ const includeAllSettings = taskType === "relationship-analysis"
13983
+ && ["chapter-with-settings", "book-with-settings"].includes(scopeType);
13266
13984
  const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
13267
13985
  const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
13268
13986
  const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
@@ -13273,11 +13991,12 @@ async function openTaskDialog() {
13273
13991
  const chapterScope = {
13274
13992
  type: "chapter",
13275
13993
  ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13276
- ...(chapterIds.length ? { chapterIds } : {})
13994
+ ...(chapterIds.length ? { chapterIds } : {}),
13995
+ ...(scopeType === "chapter-with-settings" ? { includeAllSettings: true } : {})
13277
13996
  };
13278
13997
  const scope = settingsOnly
13279
13998
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13280
- : scopeType === "book" || includeAllSettings
13999
+ : scopeType === "book" || scopeType === "book-with-settings"
13281
14000
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13282
14001
  : { ...chapterScope, ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13283
14002
  return scope;
@@ -13318,7 +14037,7 @@ async function openTaskDialog() {
13318
14037
  }
13319
14038
  taskListPage = 1;
13320
14039
  toast("分析任务已创建,已进入任务队列");
13321
- void refreshAnalysisTaskViewsAfterCreate(workId);
14040
+ await refreshAnalysisTaskViewsAfterCreate(workId);
13322
14041
  }, "AI 分析", {
13323
14042
  large: true,
13324
14043
  submitLabel: "创建任务",
@@ -13347,6 +14066,9 @@ async function openTaskDialog() {
13347
14066
  const scopeVolumeInputs = [
13348
14067
  ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
13349
14068
  ];
14069
+ const scopeVolumeCollapseButtons = [
14070
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-toggle]")
14071
+ ];
13350
14072
  const scopeSummaries = {
13351
14073
  chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
13352
14074
  };
@@ -13393,6 +14115,9 @@ async function openTaskDialog() {
13393
14115
  const allSettingsOption = document.createElement("option");
13394
14116
  allSettingsOption.value = "book-with-settings";
13395
14117
  allSettingsOption.textContent = "全书 + 设定集";
14118
+ const chapterSettingsOption = document.createElement("option");
14119
+ chapterSettingsOption.value = "chapter-with-settings";
14120
+ chapterSettingsOption.textContent = "指定章节 + 设定集";
13396
14121
  const settingsOnlyOption = document.createElement("option");
13397
14122
  settingsOnlyOption.value = "settings";
13398
14123
  settingsOnlyOption.textContent = "仅设定集";
@@ -13405,6 +14130,13 @@ async function openTaskDialog() {
13405
14130
  scopeSearches[kind].focus({ preventScroll: true });
13406
14131
  }
13407
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
+ };
13408
14140
  const syncTaskScopeVolumeInputs = () => {
13409
14141
  for (const volumeInput of scopeVolumeInputs) {
13410
14142
  const group = volumeInput.closest("[data-task-scope-group]");
@@ -13442,7 +14174,12 @@ async function openTaskDialog() {
13442
14174
  if (visible) visibleCount += 1;
13443
14175
  }
13444
14176
  scopeFields[kind].querySelectorAll("[data-task-scope-group]").forEach((group) => {
13445
- group.classList.toggle("hidden", !group.querySelector(".task-scope-option:not(.hidden)"));
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
+ }
13446
14183
  });
13447
14184
  scopeAvailableCounts[kind].textContent = query
13448
14185
  ? `${visibleCount} / ${scopeInputs[kind].length} 章`
@@ -13451,7 +14188,7 @@ async function openTaskDialog() {
13451
14188
  };
13452
14189
  const syncChapterField = () => {
13453
14190
  const kind = "chapter";
13454
- const enabled = scopeTypeSelect.value === "chapter";
14191
+ const enabled = ["chapter", "chapter-with-settings"].includes(scopeTypeSelect.value);
13455
14192
  scopeFields[kind].classList.toggle("is-disabled", !enabled);
13456
14193
  scopeFields[kind].classList.toggle("hidden", !enabled);
13457
14194
  scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
@@ -13525,8 +14262,14 @@ async function openTaskDialog() {
13525
14262
  };
13526
14263
  const syncRelationshipOptions = () => {
13527
14264
  const enabled = taskTypeSelect.value === "relationship-analysis";
14265
+ const bookScopeOption = scopeTypeSelect.querySelector('option[value="book"]');
14266
+ if (enabled && !chapterSettingsOption.isConnected) bookScopeOption.before(chapterSettingsOption);
13528
14267
  if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
13529
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
+ }
13530
14273
  if (!enabled && allSettingsOption.isConnected) {
13531
14274
  if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
13532
14275
  allSettingsOption.remove();
@@ -13644,6 +14387,11 @@ async function openTaskDialog() {
13644
14387
  clearContextWarning();
13645
14388
  invalidateRelationshipSourcePreview();
13646
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
+ });
13647
14395
  $("#dynamic-form").onclick = (event) => {
13648
14396
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13649
14397
  for (const kind of ["chapter"]) {
@@ -13711,12 +14459,15 @@ function openProviderDialog(item) {
13711
14459
  return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
13712
14460
  };
13713
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>`;
13714
14464
  openDialog(
13715
14465
  item ? "编辑 AI 供应商" : "新建 AI 供应商",
13716
14466
  field("name", "显示名称", "text", item?.name)
13717
14467
  + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
13718
14468
  + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
13719
14469
  + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
14470
+ + maxTokensParameterField
13720
14471
  + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
13721
14472
  + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
13722
14473
  + field("note", "用途备注", "textarea", item?.note)
@@ -13726,6 +14477,7 @@ function openProviderDialog(item) {
13726
14477
  name: form.get("name"),
13727
14478
  protocol: form.get("protocol"),
13728
14479
  baseUrl: form.get("baseUrl"),
14480
+ maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
13729
14481
  concurrencyLimit: Number(form.get("concurrencyLimit")),
13730
14482
  rpmLimit: Number(form.get("rpmLimit")),
13731
14483
  note: form.get("note"),
@@ -13742,12 +14494,23 @@ function openProviderDialog(item) {
13742
14494
  const protocolSelect = $("#dialog-fields select[name='protocol']");
13743
14495
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
13744
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
+ };
13745
14506
  const syncProviderCredentialField = () => {
13746
14507
  const nextProtocol = protocolSelect.value;
13747
14508
  credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
13748
14509
  if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
14510
+ syncMaxTokensParameter();
13749
14511
  };
13750
14512
  protocolSelect.addEventListener("change", syncProviderCredentialField);
14513
+ syncMaxTokensParameter();
13751
14514
  }
13752
14515
 
13753
14516
  function openModelDialog(providerId, item = null, provider = null) {
@@ -13757,16 +14520,16 @@ function openModelDialog(providerId, item = null, provider = null) {
13757
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>`;
13758
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>`;
13759
14522
  const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
13760
- ? "使用当前已保存的模型标识符和供应商凭据,并发送一张测试图片验证图片请求。"
13761
- : "使用当前已保存的模型标识符和供应商凭据发起最小请求。";
14523
+ ? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
14524
+ : "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
13762
14525
  const connectionTest = item && item.providerStatus === "enabled"
13763
14526
  ? `<section class="model-connection-test">
13764
14527
  <div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
13765
14528
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
13766
14529
  </section>`
13767
14530
  : "";
13768
- 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) => {
13769
- 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);
13770
14533
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
13771
14534
  await renderPlatformAiConfig();
13772
14535
  await loadModels();
@@ -13777,6 +14540,8 @@ function openModelDialog(providerId, item = null, provider = null) {
13777
14540
  const contextWindowHint = $("#model-context-window-hint");
13778
14541
  const temperatureInput = $("#dialog-fields input[name='temperature']");
13779
14542
  const temperatureHint = $("#model-temperature-hint");
14543
+ const thinkingEnabledInput = $("#dialog-fields input[name='thinkingEnabled']");
14544
+ const thinkingEffortSelect = $("#dialog-fields select[name='thinkingEffort']");
13780
14545
  const multimodalInput = $("#model-multimodal-enabled");
13781
14546
  const imageDefaultField = $("#model-image-tool-default-field");
13782
14547
  const imageDefaultInput = $("#model-image-tool-default");
@@ -13796,11 +14561,15 @@ function openModelDialog(providerId, item = null, provider = null) {
13796
14561
  const isKimi = isKimiModelId(modelIdInput.value);
13797
14562
  temperatureHint.hidden = !isKimi;
13798
14563
  };
14564
+ const syncThinkingEffort = () => {
14565
+ thinkingEffortSelect.disabled = !thinkingEnabledInput.checked;
14566
+ };
13799
14567
  modelIdInput.addEventListener("input", () => {
13800
14568
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
13801
14569
  syncKimiTemperature();
13802
14570
  });
13803
14571
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
14572
+ thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
13804
14573
  multimodalInput?.addEventListener("change", syncMultimodalFields);
13805
14574
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
13806
14575
  const button = event.currentTarget;
@@ -13823,6 +14592,7 @@ function openModelDialog(providerId, item = null, provider = null) {
13823
14592
  });
13824
14593
  syncModelContextWindowGuidance();
13825
14594
  syncKimiTemperature();
14595
+ syncThinkingEffort();
13826
14596
  syncMultimodalFields();
13827
14597
  }
13828
14598
 
@@ -13832,6 +14602,9 @@ async function sendAi() {
13832
14602
 
13833
14603
  async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13834
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;
13835
14608
  const composerSnapshot = captureAiPromptComposer();
13836
14609
  const instruction = composerSnapshot.text.trim();
13837
14610
  if (!instruction) return toast("请输入指令", "error");
@@ -13841,31 +14614,37 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13841
14614
  const { taskType, scope, selection } = requestScope;
13842
14615
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
13843
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;
13844
14620
  const requestHolder = {
13845
14621
  snapshot: aiRequestManager.begin({
14622
+ tabId: tab.id,
13846
14623
  workId: state.work.id,
13847
14624
  conversationId: state.aiConversationId
13848
14625
  })
13849
14626
  };
14627
+ setAiChatTabStatus(tab, "streaming");
13850
14628
  syncAiRequestControls();
13851
14629
  try {
13852
14630
  try {
13853
14631
  await ensureAiModelsLoaded();
13854
14632
  } catch (error) {
13855
14633
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13856
- setAiAssistantStatus("error");
14634
+ setAiChatTabStatus(tab, "error");
13857
14635
  return toast(`创作助手加载失败:${error.message}`, "error");
13858
14636
  }
13859
14637
  assertAiRequestCurrent(requestHolder.snapshot);
13860
- const modelId = $("#ai-model").value;
14638
+ const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
13861
14639
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
14640
+ tab.selectedModelId = modelId;
13862
14641
  try {
13863
- await prepareAiRequestConversation(requestHolder, $("#ai-task").value, requestScope.conversationScope);
14642
+ await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
13864
14643
  } catch (error) {
13865
14644
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13866
14645
  return toast(`对话配置锁定失败:${error.message}`, "error");
13867
14646
  }
13868
- setAiAssistantStatus("ready");
14647
+ setAiChatTabStatus(tab, "streaming");
13869
14648
  if (taskType !== "chat") {
13870
14649
  try {
13871
14650
  const request = assertAiRequestCurrent(requestHolder.snapshot);
@@ -13880,16 +14659,22 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13880
14659
  assertAiRequestCurrent(request);
13881
14660
  updateAiConversationSummaryFromMessage(persistedUserMessage);
13882
14661
  requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
13883
- state.aiConversationModelId = modelId;
13884
- state.aiPromptSent = true;
13885
- syncAiTaskOptions();
13886
- renderAiRoleplayCharacterSelect();
13887
- renderAiQuickActions();
13888
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
13889
- 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
+ }
13890
14675
  } catch (error) {
13891
14676
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13892
- setAiAssistantStatus("error");
14677
+ setAiChatTabStatus(tab, "error");
13893
14678
  return toast(`对话记录创建失败:${error.message}`, "error");
13894
14679
  }
13895
14680
  }
@@ -13925,8 +14710,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13925
14710
  const suggestionFailed = suggestion.guard?.status === "failed"
13926
14711
  || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
13927
14712
  || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
13928
- if (suggestionFailed) setAiAssistantStatus("error");
13929
- 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);
13930
14716
  assistantContent = suggestion.content;
13931
14717
  assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
13932
14718
  }
@@ -13955,36 +14741,50 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13955
14741
  if (assistantMessage) {
13956
14742
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
13957
14743
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
13958
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
14744
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
13959
14745
  }
13960
14746
  } catch (error) {
13961
14747
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13962
- setAiAssistantStatus("error");
13963
- if (suggestion) appendSuggestion(suggestion);
14748
+ setAiChatTabStatus(tab, "error");
14749
+ if (suggestion) appendSuggestion(suggestion, null, null, { tab });
13964
14750
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
13965
14751
  }
13966
14752
  } catch (error) {
13967
14753
  const request = requestHolder.snapshot;
13968
14754
  if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
13969
- 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
+ }
13970
14769
  return;
13971
14770
  }
13972
14771
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
13973
- restoreAiPromptComposer(composerSnapshot);
14772
+ setAiChatTabComposerSnapshot(tab, composerSnapshot);
14773
+ if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
13974
14774
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13975
- $("#ai-prompt").focus();
14775
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13976
14776
  return;
13977
14777
  }
13978
14778
  if (error?.code === "AI_IDEMPOTENT_REQUEST_IN_PROGRESS") {
13979
14779
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13980
- $("#ai-prompt").focus();
14780
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13981
14781
  return;
13982
14782
  }
13983
14783
  if (["IDEMPOTENCY_KEY_REUSED", "AI_IDEMPOTENT_REQUEST_TERMINAL"].includes(error?.code)) {
13984
14784
  toast(error.message, "error");
13985
14785
  return;
13986
14786
  }
13987
- setAiAssistantStatus("error");
14787
+ setAiChatTabStatus(tab, "error");
13988
14788
  const interruption = error?.streamInterruption;
13989
14789
  if (interruption?.content) {
13990
14790
  try {
@@ -14031,15 +14831,21 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14031
14831
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
14032
14832
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
14033
14833
  if (aiRequestTargetsCurrentState(request)) {
14034
- appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
14834
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id, { tab });
14035
14835
  }
14036
14836
  } finally {
14037
14837
  aiRequestManager.finish(requestHolder.snapshot);
14038
- 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();
14039
14842
  }
14040
14843
  }
14041
14844
 
14042
14845
  async function streamChat(requestHolder, body, idempotencyKey) {
14846
+ const tab = aiChatTabForRequest(requestHolder.snapshot);
14847
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
14848
+ const feed = tab.feed;
14043
14849
  const message = document.createElement("div");
14044
14850
  message.className = "assistant-message is-streaming";
14045
14851
  message.dataset.testid = "ai-stream-message";
@@ -14051,10 +14857,10 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14051
14857
  const mountAssistantMessage = () => {
14052
14858
  if (messageMounted) return true;
14053
14859
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
14054
- attachMessageHeading(message, aiAssistantLabel("正在生成"));
14055
- $("#ai-feed").append(message);
14860
+ attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
14861
+ feed.append(message);
14056
14862
  messageMounted = true;
14057
- scrollAiFeedToBottom();
14863
+ scrollAiFeedToBottom(feed);
14058
14864
  return true;
14059
14865
  };
14060
14866
  const typewriter = createStreamTypewriter({
@@ -14066,7 +14872,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14066
14872
  meta.textContent = progress.pendingCharacters > 0
14067
14873
  ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
14068
14874
  : `正在生成 · ${progress.visibleCharacters} 字`;
14069
- scrollAiFeedToBottom();
14875
+ scrollAiFeedToBottom(feed);
14070
14876
  }
14071
14877
  });
14072
14878
  let streamedText = "";
@@ -14099,7 +14905,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14099
14905
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
14100
14906
  processStepVisibleContents.set(step, text);
14101
14907
  renderStreamingProcessSteps(finalAnswerStarted);
14102
- scrollAiFeedToBottom();
14908
+ scrollAiFeedToBottom(feed);
14103
14909
  }
14104
14910
  });
14105
14911
  processStepTypewriters.set(step, typewriter);
@@ -14127,7 +14933,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14127
14933
  assertAiRequestCurrent(requestHolder.snapshot);
14128
14934
  if (eventName === "context") {
14129
14935
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
14130
- if (!state.aiPromptSent) setAiContextMeter(payload.usage);
14936
+ if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
14131
14937
  if (payload.conversation?.id) {
14132
14938
  if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
14133
14939
  throw new Error("流式上下文返回了其他对话");
@@ -14135,11 +14941,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14135
14941
  upsertAiConversationSummary(payload.conversation);
14136
14942
  applyAiConversationTitle(payload.conversation.title, requestHolder.snapshot.conversationId);
14137
14943
  }
14138
- if (contextAction === "warn") showAiContextWarning(payload.usage);
14944
+ if (contextAction === "warn") setAiChatTabContextWarning(tab, true);
14139
14945
  else {
14140
- hideAiContextWarning();
14946
+ setAiChatTabContextWarning(tab, false);
14141
14947
  if (contextAction === "compacted") {
14142
- renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
14948
+ renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount, feed, payload.conversation);
14143
14949
  toast("已自动整理较早对话为长期记忆并继续发送");
14144
14950
  }
14145
14951
  }
@@ -14154,13 +14960,19 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14154
14960
  const lockedModelId = typeof persistedUserMessage.metadata?.modelId === "string"
14155
14961
  ? persistedUserMessage.metadata.modelId
14156
14962
  : typeof body.modelId === "string" ? body.modelId : null;
14157
- state.aiConversationModelId = lockedModelId;
14158
- state.aiPromptSent = true;
14159
- syncAiTaskOptions();
14160
- renderAiRoleplayCharacterSelect();
14161
- renderAiQuickActions();
14162
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id);
14163
- 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
+ }
14164
14976
  mountAssistantMessage();
14165
14977
  }
14166
14978
  } else if (eventName === "delta") {
@@ -14190,26 +15002,26 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14190
15002
  : step.type === "context_compaction"
14191
15003
  ? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
14192
15004
  : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
14193
- scrollAiFeedToBottom();
15005
+ scrollAiFeedToBottom(feed);
14194
15006
  } else if (eventName === "tool_call") {
14195
15007
  mountAssistantMessage();
14196
15008
  const toolCall = { ...payload };
14197
15009
  const round = toolCall.round;
14198
15010
  delete toolCall.round;
14199
- if (toolCall.status === "failed") setAiAssistantStatus("error");
15011
+ if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
14200
15012
  toolCalls.push(toolCall);
14201
15013
  processSteps.push(aiToolProcessStep(toolCall, round));
14202
15014
  renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
14203
15015
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
14204
- scrollAiFeedToBottom();
15016
+ scrollAiFeedToBottom(feed);
14205
15017
  } else if (eventName === "context_compacted") {
14206
15018
  streamContextCompacted = true;
14207
- setAiContextMeter(payload.contextUsage);
15019
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14208
15020
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
14209
15021
  } else if (eventName === "complete") {
14210
15022
  if (payload.warningOnly === true) {
14211
15023
  warningOnly = true;
14212
- setAiContextMeter(payload.contextUsage);
15024
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14213
15025
  return;
14214
15026
  }
14215
15027
  mountAssistantMessage();
@@ -14217,7 +15029,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14217
15029
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
14218
15030
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
14219
15031
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
14220
- setAiContextMeter(payload.contextUsage, announcedCompaction);
15032
+ setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
14221
15033
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
14222
15034
  assertAiRequestCurrent(requestHolder.snapshot);
14223
15035
  message.classList.remove("is-streaming");
@@ -14232,11 +15044,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14232
15044
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14233
15045
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
14234
15046
  attachAssistantCopyAction(message, streamedText);
14235
- scrollAiFeedToBottom();
15047
+ scrollAiFeedToBottom(feed);
14236
15048
  } else if (eventName === "request_status") {
14237
15049
  streamError = createClientError(payload, "AI 请求状态不可重放", response.status);
14238
15050
  } else if (eventName === "error") {
14239
- setAiAssistantStatus("error");
15051
+ setAiChatTabStatus(tab, "error");
14240
15052
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
14241
15053
  }
14242
15054
  };
@@ -14279,7 +15091,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14279
15091
  message.dataset.status = "interrupted";
14280
15092
  content.setAttribute("aria-busy", "false");
14281
15093
  const headingRole = message.querySelector(".message-heading > span");
14282
- headingRole.textContent = aiAssistantLabel(interruptionLabel);
15094
+ headingRole.textContent = aiAssistantLabel(interruptionLabel, tab.roleplayCharacter);
14283
15095
  const interruptionBadge = document.createElement("strong");
14284
15096
  interruptionBadge.className = "ai-message-status is-error";
14285
15097
  interruptionBadge.textContent = "中断";
@@ -14288,12 +15100,14 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14288
15100
  renderStreamingProcessSteps(true, elapsedProcessTime());
14289
15101
  meta.textContent = formatAiStreamInterruptionMeta(interruptionCode, streamedText.length);
14290
15102
  if (streamedText) attachAssistantCopyAction(message, streamedText);
14291
- scrollAiFeedToBottom();
15103
+ scrollAiFeedToBottom(feed);
14292
15104
  throw streamFailure;
14293
15105
  }
14294
15106
  }
14295
15107
 
14296
- 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");
14297
15111
  const message = document.createElement("div");
14298
15112
  const isFailure = role === "assistant" && text.startsWith("调用失败:");
14299
15113
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
@@ -14309,8 +15123,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14309
15123
  });
14310
15124
  const heading = attachMessageHeading(
14311
15125
  message,
14312
- role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : ""),
14313
- createdAt ?? undefined
15126
+ role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15127
+ createdAt ?? undefined,
15128
+ tab
14314
15129
  );
14315
15130
  if (isFailure || isInterrupted) {
14316
15131
  message.dataset.status = isInterrupted ? "interrupted" : "failed";
@@ -14360,7 +15175,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14360
15175
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14361
15176
  }
14362
15177
  if (role === "assistant" && !text.startsWith("调用失败:")) {
14363
- 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];
14364
15180
  const modelDisplayName = metadata?.modelDisplayName || selectedModel?.displayName || "模型";
14365
15181
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
14366
15182
  const meta = document.createElement("div");
@@ -14372,18 +15188,20 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14372
15188
  attachAssistantCopyAction(message, text);
14373
15189
  }
14374
15190
  attachMessageIdentity(message, messageId);
14375
- $("#ai-feed").append(message);
14376
- scrollAiFeedToBottom();
15191
+ feed.append(message);
15192
+ scrollAiFeedToBottom(feed);
14377
15193
  }
14378
15194
 
14379
- 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");
14380
15198
  const message = document.createElement("div");
14381
15199
  message.className = "assistant-message";
14382
15200
  const applicable = suggestion.action !== "note";
14383
15201
  const guard = suggestion.guard;
14384
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>` : "";
14385
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>' : ""}`;
14386
- attachMessageHeading(message, "助手建议", createdAt ?? undefined);
15204
+ attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
14387
15205
  attachAssistantCopyAction(message, suggestion.content);
14388
15206
  attachMessageIdentity(message, messageId);
14389
15207
  if (applicable) {
@@ -14406,8 +15224,8 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
14406
15224
  message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
14407
15225
  });
14408
15226
  }
14409
- $("#ai-feed").append(message);
14410
- scrollAiFeedToBottom();
15227
+ feed.append(message);
15228
+ scrollAiFeedToBottom(feed);
14411
15229
  return message;
14412
15230
  }
14413
15231
 
@@ -15696,6 +16514,20 @@ $("#shelf-new-work").addEventListener("click", openWorkDialog);
15696
16514
  $("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
15697
16515
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
15698
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);
15699
16531
  $("#chapter-foreshadow-reminder-previous").addEventListener("click", () => {
15700
16532
  showChapterForeshadowReminderAt(chapterForeshadowReminderIndex - 1);
15701
16533
  });
@@ -15862,6 +16694,10 @@ $("#mobile-panel-backdrop").addEventListener("click", () => {
15862
16694
  applyPanelLayout(true);
15863
16695
  });
15864
16696
  $("#ai-panel-toggle").addEventListener("click", () => {
16697
+ if (aiConversationWorkspaceOpen) {
16698
+ setAiConversationWorkspaceVisible(false);
16699
+ return;
16700
+ }
15865
16701
  panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
15866
16702
  applyPanelLayout(true);
15867
16703
  });
@@ -15874,6 +16710,17 @@ window.addEventListener("resize", () => {
15874
16710
  applyPanelLayout();
15875
16711
  scheduleChapterLineNumbers();
15876
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
+ });
15877
16724
  $("#module-nav").addEventListener("click", (event) => {
15878
16725
  const button = event.target.closest("button");
15879
16726
  if (!button || button.id === "module-more-button") return;
@@ -16139,6 +16986,7 @@ document.addEventListener("pointerdown", (event) => {
16139
16986
  closeManuscriptExportMenu(true);
16140
16987
  }
16141
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);
16142
16990
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
16143
16991
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
16144
16992
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -16158,7 +17006,20 @@ document.addEventListener("keydown", (event) => {
16158
17006
  return;
16159
17007
  }
16160
17008
  if (event.key === "Escape") {
17009
+ if (!$("#chapter-search-panel").classList.contains("hidden")) {
17010
+ closeChapterSearchPanel();
17011
+ return;
17012
+ }
16161
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
+ }
16162
17023
  closeChapterTypeMenu();
16163
17024
  closeLineCitationMenu();
16164
17025
  closeMarkdownTableMenu(true);
@@ -16173,14 +17034,37 @@ document.addEventListener("keydown", (event) => {
16173
17034
  event.preventDefault();
16174
17035
  event.stopPropagation();
16175
17036
  if (event.repeat) return;
17037
+ if (isChapterSearchContext()) {
17038
+ openChapterSearchPanel();
17039
+ return;
17040
+ }
16176
17041
  openSearchDialog().catch((error) => toast(error.message, "error"));
16177
17042
  }, { capture: true });
16178
17043
  $("#ai-context-meter").addEventListener("click", () => {
16179
17044
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
16180
17045
  });
16181
17046
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
16182
- $("#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
+ });
16183
17066
  $("#ai-new-conversation").addEventListener("click", async () => {
17067
+ setAiConversationSwitcherVisible(false);
16184
17068
  const button = $("#ai-new-conversation");
16185
17069
  button.disabled = true;
16186
17070
  try {
@@ -16244,6 +17128,7 @@ $("#ai-context-dismiss").addEventListener("click", async () => {
16244
17128
  }
16245
17129
  });
16246
17130
  $("#ai-history-toggle").addEventListener("click", async () => {
17131
+ setAiConversationSwitcherVisible(false);
16247
17132
  if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
16248
17133
  try {
16249
17134
  await ensureAiConversationsLoaded();
@@ -16304,6 +17189,20 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
16304
17189
  }
16305
17190
  return;
16306
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
+ }
16307
17206
  if (action === "delete") {
16308
17207
  option.disabled = true;
16309
17208
  try {
@@ -16356,7 +17255,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
16356
17255
  }
16357
17256
  if (shouldSendAiPrompt(event)) {
16358
17257
  event.preventDefault();
16359
- if (!$("#ai-send").disabled) void sendAi();
17258
+ if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
16360
17259
  }
16361
17260
  });
16362
17261
  $(".quick-actions").addEventListener("click", (event) => {
@@ -16452,7 +17351,6 @@ $("#reader-font-size").addEventListener("change", (event) => updateReadingPrefer
16452
17351
  $("#reader-line-height").addEventListener("change", (event) => updateReadingPreference({ lineHeight: Number(event.currentTarget.value) }));
16453
17352
  $("#reader-theme").addEventListener("change", (event) => updateReadingPreference({ theme: event.currentTarget.value }));
16454
17353
  $("#reader-view").addEventListener("keydown", handleReadingKeyboard);
16455
- $("#reader-viewport").addEventListener("wheel", handleReadingWheel, { passive: false });
16456
17354
  $("#reader-viewport").addEventListener("scroll", () => {
16457
17355
  if (readingPreferences.mode === "scroll") scheduleReadingPositionSave();
16458
17356
  }, { passive: true });
@@ -16469,7 +17367,7 @@ window.addEventListener("pagehide", dismissDeleteToasts);
16469
17367
  window.addEventListener("beforeunload", (event) => {
16470
17368
  if (hasUnsavedEditorChanges()) event.preventDefault();
16471
17369
  if (aiRequestManager.hasActive()) {
16472
- toast("当前 turn 尚未结束,刷新会中断生成;已收到的内容会保留在历史记录中", "warning");
17370
+ toast("仍有 Agent turn 尚未结束,刷新会中断全部生成;已收到的内容会保留在历史记录中", "warning");
16473
17371
  }
16474
17372
  });
16475
17373
  window.addEventListener("online", () => {
@@ -16486,6 +17384,7 @@ window.addEventListener("resize", () => {
16486
17384
  });
16487
17385
  });
16488
17386
 
17387
+ initializeAiChatTabs();
16489
17388
  initializePage().catch((error) => {
16490
17389
  restoringPageRoute = false;
16491
17390
  if (state.user) {