@musnows/scriverse 0.7.13 → 0.8.1

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 (52) 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 +547 -119
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +80 -22
  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 +1305 -298
  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 +67 -26
  32. package/dist/public/model-config.d.ts +3 -1
  33. package/dist/public/model-config.js +13 -0
  34. package/dist/public/relationship-filters.d.ts +5 -0
  35. package/dist/public/relationship-filters.js +31 -0
  36. package/dist/public/relationship-graph.js +289 -58
  37. package/dist/public/stream-typewriter.d.ts +1 -0
  38. package/dist/public/stream-typewriter.js +12 -0
  39. package/dist/public/styles.css +185 -41
  40. package/dist/public/work-permissions.d.ts +1 -1
  41. package/dist/public/work-permissions.js +13 -1
  42. package/dist/server-runtime.js +6 -0
  43. package/dist/server-runtime.js.map +1 -1
  44. package/dist/store.js +127 -44
  45. package/dist/store.js.map +1 -1
  46. package/dist/user-auth.js +34 -7
  47. package/dist/user-auth.js.map +1 -1
  48. package/dist/version.js +6 -1
  49. package/dist/version.js.map +1 -1
  50. package/dist/work-permissions.js +13 -2
  51. package/dist/work-permissions.js.map +1 -1
  52. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, renderRelationshipMindMap } from "/relationship-graph.js?v=20260809-galaxy-size-threshold-v1";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
- import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260812-ai-request-snapshot-v1";
6
+ import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.js?v=20260816-ai-chat-switcher-v2";
7
+ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
7
8
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
8
9
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
9
10
  import {
@@ -15,11 +16,11 @@ import {
15
16
  visibleForeshadowReminders
16
17
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
17
18
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
18
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
19
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260816-extended-thinking-effort-v1";
19
20
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
20
21
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
21
22
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
22
- import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260815-ai-stream-typewriter-v4";
23
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
23
24
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
24
25
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
25
26
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -27,9 +28,10 @@ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContex
27
28
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
28
29
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
29
30
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
31
+ import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
30
32
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
31
33
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
32
- import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
34
+ import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
33
35
  import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
34
36
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
35
37
  import {
@@ -49,8 +51,9 @@ import {
49
51
  settingStatusLabel,
50
52
  taskScopeLabel,
51
53
  timelineStatusLabel,
54
+ characterGenderLabel,
52
55
  characterStateFieldLabel
53
- } from "/display-labels.js?v=20260809-global-replace-v1";
56
+ } from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1";
54
57
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
55
58
  import {
56
59
  READING_PREFERENCES_STORAGE_KEY,
@@ -70,12 +73,12 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
70
73
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
71
74
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
72
75
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
73
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
76
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260818-annotation-permissions-v1";
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",
@@ -211,6 +230,8 @@ let systemBootCheckTimer = null;
211
230
  let systemBootCheckPromise = null;
212
231
  let systemRestartDetected = false;
213
232
  let chapterAnnotations = [];
233
+ let chapterAnnotationCounts = new Map();
234
+ let chapterAnnotationsLineIndex = null;
214
235
  let workAuditRecords = [];
215
236
  let workAuditNextPage = null;
216
237
  const chapterBatchSelectedIds = new Set();
@@ -240,6 +261,7 @@ let backgroundTaskCenterRequest = 0;
240
261
  let backgroundTaskCenterWorkId = null;
241
262
  let backgroundTaskCenterTasksInitialized = false;
242
263
  let backgroundTaskCenterTaskSnapshots = new Map();
264
+ let backgroundTaskCenterExpiredNoticeTimes = new Map();
243
265
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
244
266
  let s3BackupTargets = [];
245
267
  let s3BackupRuns = [];
@@ -356,6 +378,13 @@ function canEditProse(work = state.work) {
356
378
  return canWriteUiModule(work, "editor");
357
379
  }
358
380
 
381
+ function canManageChapterAnnotation(annotation, work = state.work) {
382
+ if (!work) return false;
383
+ if (annotation.kind !== "todo") return canEditProse(work);
384
+ if (["admin", "owner"].includes(String(work.accessRole))) return true;
385
+ return annotation.createdByUserId === state.user?.userId && canWritePermissionModule(work, "todos");
386
+ }
387
+
359
388
  function canReplaceProse(work = state.work) {
360
389
  return WORK_PERMISSION_MODULES
361
390
  .filter((item) => item.id !== "ai-settings")
@@ -409,6 +438,7 @@ function applyWorkAccessMode() {
409
438
  $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
410
439
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
411
440
  $("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
441
+ $("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
412
442
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
413
443
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
414
444
  $("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -519,10 +549,30 @@ function setAiAssistantStatus(status) {
519
549
  dot.title = label;
520
550
  }
521
551
 
552
+ function activeAiChatTab() {
553
+ return aiChatTabManager.active();
554
+ }
555
+
556
+ function aiChatTabForRequest(request) {
557
+ return request?.tabId ? aiChatTabManager.get(request.tabId) : null;
558
+ }
559
+
560
+ function isActiveAiChatTab(tab) {
561
+ return Boolean(tab && activeAiChatTab()?.id === tab.id);
562
+ }
563
+
564
+ function setAiChatTabStatus(tab, status) {
565
+ if (!tab) return;
566
+ tab.status = status;
567
+ if (isActiveAiChatTab(tab)) setAiAssistantStatus(status);
568
+ renderAiChatTabs();
569
+ }
570
+
522
571
  function aiRequestTargetsCurrentState(request) {
523
- return aiRequestManager.isCurrent(request) && aiRequestTargetsState(request, {
572
+ const tab = aiChatTabForRequest(request);
573
+ return aiRequestManager.isCurrent(request) && Boolean(tab) && aiRequestTargetsState(request, {
524
574
  workId: state.work?.id,
525
- conversationId: state.aiConversationId
575
+ conversationId: tab.conversationId
526
576
  });
527
577
  }
528
578
 
@@ -532,41 +582,56 @@ function assertAiRequestCurrent(request) {
532
582
  }
533
583
 
534
584
  function aiInteractionBusy() {
535
- return aiRequestManager.hasActive() || aiConversationNavigationPending !== null;
585
+ return aiRequestManager.hasActive(activeAiChatTab()?.id) || aiConversationNavigationPending !== null;
586
+ }
587
+
588
+ function aiSendButtonIconMarkup(stateName) {
589
+ return stateName === "stop"
590
+ ? '<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>'
591
+ : '<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
592
  }
537
593
 
538
594
  function syncAiRequestControls() {
539
- const sending = aiRequestManager.hasActive();
595
+ const sending = aiRequestManager.hasActive(activeAiChatTab()?.id);
540
596
  const switching = aiConversationNavigationPending !== null;
541
597
  const button = $("#ai-send");
542
- button.disabled = sending || switching;
543
- button.textContent = sending ? "发送中" : switching ? "切换中" : "发送";
598
+ const stateName = sending ? "stop" : switching ? "switching" : "send";
599
+ const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
600
+ button.disabled = switching;
601
+ button.dataset.state = stateName;
602
+ button.classList.toggle("is-stop", sending);
603
+ button.setAttribute("aria-label", label);
604
+ button.title = label;
605
+ button.innerHTML = aiSendButtonIconMarkup(stateName);
544
606
  syncAiTaskOptions();
545
607
  renderAiRoleplayCharacterSelect();
546
608
  }
547
609
 
548
610
  function cancelActiveAiRequest(reason) {
549
- const cancelled = aiRequestManager.cancel(reason);
611
+ const cancelled = aiRequestManager.cancel(reason, activeAiChatTab()?.id);
550
612
  syncAiRequestControls();
551
613
  return cancelled;
552
614
  }
553
615
 
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
- );
616
+ function activateAiSendControl() {
617
+ const tab = activeAiChatTab();
618
+ if (aiRequestManager.hasActive(tab?.id)) {
619
+ if (cancelActiveAiRequest("用户已终止当前回复")) {
620
+ toast("已终止当前回复,可以重新发送");
621
+ $("#ai-prompt").focus();
622
+ }
623
+ return;
562
624
  }
625
+ void sendAi();
626
+ }
627
+
628
+ function beginAiConversationNavigation(reason, action = "切换会话") {
563
629
  aiConversationNavigationGeneration += 1;
564
630
  aiConversationNavigationPending = aiConversationNavigationGeneration;
565
631
  const navigation = Object.freeze({
566
632
  generation: aiConversationNavigationGeneration,
567
633
  workId: String(state.work?.id ?? "")
568
634
  });
569
- cancelActiveAiRequest(reason);
570
635
  return navigation;
571
636
  }
572
637
 
@@ -584,11 +649,12 @@ function finishAiConversationNavigation(navigation) {
584
649
 
585
650
  function invalidateAiConversationNavigation(reason, action = "切换作品") {
586
651
  if (aiRequestManager.hasActive()) {
587
- toast(`当前 turn 尚未结束,${action}会中断生成;已收到的内容会保留在历史记录中`, "warning");
652
+ toast(`仍有 Agent turn 尚未结束,${action}会中断全部生成;已收到的内容会保留在历史记录中`, "warning");
588
653
  }
589
654
  aiConversationNavigationGeneration += 1;
590
655
  aiConversationNavigationPending = null;
591
- cancelActiveAiRequest(reason);
656
+ aiRequestManager.cancelAll(reason);
657
+ syncAiRequestControls();
592
658
  }
593
659
 
594
660
  function aiAssistantRequestId(request) {
@@ -665,6 +731,8 @@ let workSelectionRequestGeneration = 0;
665
731
  let chapterSelectionRequestGeneration = 0;
666
732
  let aiConversationNavigationGeneration = 0;
667
733
  let aiConversationNavigationPending = null;
734
+ let aiChatTabLimit = 5;
735
+ let aiConversationWorkspaceOpen = false;
668
736
  const loadedVolumeChapterIds = new Set();
669
737
  const volumeChapterLoadingIds = new Set();
670
738
  const volumeChapterRequests = new Map();
@@ -873,6 +941,7 @@ function replacePageRoute(route) {
873
941
  const hash = serializePageRoute(route);
874
942
  if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
875
943
  schedulePresenceHeartbeat();
944
+ syncTopSearchButton();
876
945
  }
877
946
 
878
947
  function presencePageForRoute(route = currentPageRoute()) {
@@ -1084,6 +1153,149 @@ function currentPageRoute() {
1084
1153
  return { view: "welcome", workId };
1085
1154
  }
1086
1155
 
1156
+ function isChapterSearchContext() {
1157
+ return Boolean(state.work && state.chapter && !$("#editor-view").classList.contains("hidden"));
1158
+ }
1159
+
1160
+ function syncTopSearchButton() {
1161
+ const button = $("#top-search-button");
1162
+ const chapterContext = isChapterSearchContext();
1163
+ button.classList.toggle("hidden", chapterContext);
1164
+ button.disabled = !state.work || chapterContext || !canReadAggregateContent();
1165
+ if (!chapterContext) closeChapterSearchPanel({ restoreFocus: false });
1166
+ }
1167
+
1168
+ function chapterSearchMatches() {
1169
+ return findTextMatches($("#chapter-content").value, $("#chapter-search-query").value);
1170
+ }
1171
+
1172
+ function renderChapterSearchStatus() {
1173
+ const query = $("#chapter-search-query").value;
1174
+ const matches = chapterSearchMatches();
1175
+ const status = $("#chapter-search-status");
1176
+ const readOnly = !canEditProse() || chapterEditorReadOnly;
1177
+ const replaceButtons = [$("#chapter-search-replace"), $("#chapter-search-replace-all")];
1178
+ replaceButtons.forEach((button) => { button.disabled = readOnly || matches.length === 0; });
1179
+ $("#chapter-search-previous").disabled = matches.length === 0;
1180
+ $("#chapter-search-next").disabled = matches.length === 0;
1181
+ if (!query) {
1182
+ status.textContent = "输入关键词后开始搜索";
1183
+ return;
1184
+ }
1185
+ if (!matches.length) {
1186
+ status.textContent = "本章未找到匹配内容";
1187
+ return;
1188
+ }
1189
+ const current = chapterSearchMatchIndex >= 0 ? chapterSearchMatchIndex + 1 : 0;
1190
+ status.textContent = current ? `第 ${current} / ${matches.length} 处` : `找到 ${matches.length} 处`;
1191
+ }
1192
+
1193
+ function syncChapterSearchControls() {
1194
+ if (!$("#chapter-search-panel")) return;
1195
+ const matches = chapterSearchMatches();
1196
+ if (chapterSearchMatchIndex >= matches.length) chapterSearchMatchIndex = matches.length - 1;
1197
+ renderChapterSearchStatus();
1198
+ }
1199
+
1200
+ function selectChapterSearchMatch(index) {
1201
+ const matches = chapterSearchMatches();
1202
+ if (!matches.length) {
1203
+ chapterSearchMatchIndex = -1;
1204
+ renderChapterSearchStatus();
1205
+ return;
1206
+ }
1207
+ chapterSearchMatchIndex = (index + matches.length) % matches.length;
1208
+ const input = $("#chapter-content");
1209
+ const start = matches[chapterSearchMatchIndex];
1210
+ input.focus({ preventScroll: true });
1211
+ input.setSelectionRange(start, start + $("#chapter-search-query").value.length);
1212
+ renderChapterSearchStatus();
1213
+ }
1214
+
1215
+ function moveChapterSearchMatch(direction) {
1216
+ const matches = chapterSearchMatches();
1217
+ if (!matches.length) {
1218
+ renderChapterSearchStatus();
1219
+ return;
1220
+ }
1221
+ const nextIndex = chapterSearchMatchIndex < 0
1222
+ ? direction > 0 ? 0 : matches.length - 1
1223
+ : chapterSearchMatchIndex + direction;
1224
+ selectChapterSearchMatch(nextIndex);
1225
+ }
1226
+
1227
+ function openChapterSearchPanel() {
1228
+ if (!isChapterSearchContext()) return false;
1229
+ const panel = $("#chapter-search-panel");
1230
+ panel.classList.remove("hidden");
1231
+ panel.setAttribute("aria-hidden", "false");
1232
+ syncChapterSearchControls();
1233
+ queueMicrotask(() => {
1234
+ const input = $("#chapter-search-query");
1235
+ input.focus();
1236
+ if (input.value) input.select();
1237
+ });
1238
+ return true;
1239
+ }
1240
+
1241
+ function closeChapterSearchPanel({ restoreFocus = true } = {}) {
1242
+ const panel = $("#chapter-search-panel");
1243
+ if (!panel || panel.classList.contains("hidden")) return;
1244
+ panel.classList.add("hidden");
1245
+ panel.setAttribute("aria-hidden", "true");
1246
+ chapterSearchMatchIndex = -1;
1247
+ if (restoreFocus && isChapterSearchContext()) $("#chapter-content").focus({ preventScroll: true });
1248
+ }
1249
+
1250
+ function resetChapterSearchPanel() {
1251
+ closeChapterSearchPanel({ restoreFocus: false });
1252
+ $("#chapter-search-query").value = "";
1253
+ $("#chapter-replace-query").value = "";
1254
+ chapterSearchMatchIndex = -1;
1255
+ renderChapterSearchStatus();
1256
+ }
1257
+
1258
+ function replaceCurrentChapterSearchMatch() {
1259
+ if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
1260
+ toast("请先进入编辑模式", "error");
1261
+ return;
1262
+ }
1263
+ const query = $("#chapter-search-query").value;
1264
+ const matches = chapterSearchMatches();
1265
+ if (!query || !matches.length) return renderChapterSearchStatus();
1266
+ const index = chapterSearchMatchIndex < 0 ? 0 : chapterSearchMatchIndex;
1267
+ const start = matches[index];
1268
+ const input = $("#chapter-content");
1269
+ const replacement = $("#chapter-replace-query").value;
1270
+ input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
1271
+ chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
1272
+ updateChapterStats();
1273
+ clearChapterLineSelection();
1274
+ scheduleChapterLineNumbers();
1275
+ scheduleChapterAutoSave(120);
1276
+ syncChapterSearchControls();
1277
+ toast("已替换本章 1 处,等待自动保存");
1278
+ }
1279
+
1280
+ function replaceAllChapterSearchMatches() {
1281
+ if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
1282
+ toast("请先进入编辑模式", "error");
1283
+ return;
1284
+ }
1285
+ const query = $("#chapter-search-query").value;
1286
+ const input = $("#chapter-content");
1287
+ const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
1288
+ if (!result.matches) return renderChapterSearchStatus();
1289
+ input.value = result.content;
1290
+ chapterSearchMatchIndex = -1;
1291
+ updateChapterStats();
1292
+ clearChapterLineSelection();
1293
+ scheduleChapterLineNumbers();
1294
+ scheduleChapterAutoSave(120);
1295
+ syncChapterSearchControls();
1296
+ toast(`已替换本章 ${result.matches} 处,等待自动保存`);
1297
+ }
1298
+
1087
1299
  function loadPanelLayout() {
1088
1300
  try {
1089
1301
  const stored = JSON.parse(localStorage.getItem(panelLayoutStorageKey) ?? "{}");
@@ -1131,9 +1343,9 @@ function applyPanelLayout(persist = false) {
1131
1343
  ? (panelLayout.leftCollapsed ? "打开作品模块" : "关闭作品模块")
1132
1344
  : (panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏"));
1133
1345
  $("#mobile-module-tab").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
1134
- $("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
1346
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
1135
1347
  $("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
1136
- $("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
1348
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
1137
1349
  syncMobileAiPanelSafeTop();
1138
1350
  scheduleChapterLineNumbers();
1139
1351
  if (persist) {
@@ -1142,7 +1354,11 @@ function applyPanelLayout(persist = false) {
1142
1354
  }
1143
1355
 
1144
1356
  function ensureAiPanelExpanded() {
1145
- if (!panelLayout.aiCollapsed) return;
1357
+ if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1358
+ if (isMobileViewport()) {
1359
+ setAiConversationWorkspaceVisible(true);
1360
+ return;
1361
+ }
1146
1362
  panelLayout.aiCollapsed = false;
1147
1363
  applyPanelLayout(true);
1148
1364
  }
@@ -1193,6 +1409,7 @@ let chapterAutoSaveTimer = null;
1193
1409
  let chapterSaveInFlight = null;
1194
1410
  let chapterSaveGuardInFlight = null;
1195
1411
  let lastSavedChapterSnapshot = null;
1412
+ let chapterSearchMatchIndex = -1;
1196
1413
  let chapterSelectionRequestId = 0;
1197
1414
  let chapterForeshadowReminderRequestId = 0;
1198
1415
  let chapterForeshadowReminders = [];
@@ -1234,7 +1451,9 @@ const moduleListPages = {
1234
1451
  comments: 1,
1235
1452
  reviews: 1
1236
1453
  };
1237
- const characterFilters = { raceIds: [], organizationIds: [] };
1454
+ const characterFilters = { raceIds: [], organizationIds: [], genderValues: [], deathState: "all" };
1455
+ const CHARACTER_GENDER_OPTIONS = [["unknown", "未知"], ["male", "男 / 雄"], ["female", "女 / 雌"], ["none", "无性别"]];
1456
+ const CHARACTER_DEATH_STATE_OPTIONS = [["all", "全部状态"], ["alive", "未死亡"], ["dead", "已死亡"]];
1238
1457
  let characterFiltersPanelOpen = false;
1239
1458
  const settingFilters = { keyword: "", category: "", lockState: "all" };
1240
1459
  let settingFiltersPanelOpen = false;
@@ -1291,8 +1510,9 @@ function applyChapterEditorMode() {
1291
1510
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
1292
1511
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
1293
1512
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1294
- $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1513
+ $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
1295
1514
  $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1515
+ syncChapterSearchControls();
1296
1516
  if (viewOnly) cancelChapterAutoSave();
1297
1517
  }
1298
1518
 
@@ -1582,22 +1802,36 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1582
1802
  );
1583
1803
  const numbers = document.createDocumentFragment();
1584
1804
  for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
1585
- const number = document.createElement("button");
1586
- number.type = "button";
1805
+ const number = document.createElement("div");
1587
1806
  number.className = "chapter-line-number";
1588
- number.textContent = String(index + 1);
1589
1807
  number.dataset.lineIndex = String(index);
1590
- number.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1591
- number.tabIndex = -1;
1808
+ const lineSelect = document.createElement("button");
1809
+ lineSelect.type = "button";
1810
+ lineSelect.className = "chapter-line-number-select";
1811
+ lineSelect.textContent = String(index + 1);
1812
+ lineSelect.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1813
+ lineSelect.tabIndex = -1;
1814
+ number.append(lineSelect);
1815
+ const annotationCount = chapterAnnotationCounts.get(index + 1) ?? 0;
1816
+ if (annotationCount > 0) {
1817
+ const bubble = document.createElement("button");
1818
+ bubble.type = "button";
1819
+ bubble.className = "chapter-line-annotation-count";
1820
+ bubble.dataset.lineIndex = String(index);
1821
+ bubble.dataset.lineAnnotationCount = String(annotationCount);
1822
+ bubble.textContent = String(annotationCount);
1823
+ bubble.setAttribute("aria-label", `查看第 ${index + 1} 行的 ${annotationCount} 条评论和待办`);
1824
+ number.append(bubble);
1825
+ }
1592
1826
  const selected = chapterLineSelection && index >= chapterLineSelection.start && index <= chapterLineSelection.end;
1593
1827
  if (selected) {
1594
1828
  number.classList.add("is-line-selected");
1595
- number.setAttribute("aria-pressed", "true");
1829
+ lineSelect.setAttribute("aria-pressed", "true");
1596
1830
  }
1597
1831
  const bounds = getLineBounds(index);
1598
1832
  number.style.top = `${bounds.top}px`;
1599
1833
  number.style.height = `${bounds.bottom - bounds.top}px`;
1600
- number.style.paddingTop = `${numberTextOffset}px`;
1834
+ lineSelect.style.paddingTop = `${numberTextOffset}px`;
1601
1835
  numbers.append(number);
1602
1836
  }
1603
1837
  inner.replaceChildren(numbers);
@@ -1673,7 +1907,7 @@ function paintChapterLineSelection(anchor, focus) {
1673
1907
  $("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number").forEach((row) => {
1674
1908
  const selected = Number(row.dataset.lineIndex) >= start && Number(row.dataset.lineIndex) <= end;
1675
1909
  row.classList.toggle("is-line-selected", selected);
1676
- row.setAttribute("aria-pressed", String(selected));
1910
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", String(selected));
1677
1911
  });
1678
1912
  }
1679
1913
 
@@ -1781,9 +2015,346 @@ function renderAiQuickActions() {
1781
2015
  quickActions.setAttribute("aria-hidden", String(!visible));
1782
2016
  }
1783
2017
 
1784
- function attachMessageHeading(message, label, createdAt = new Date().toISOString()) {
2018
+ function createAiChatFeed() {
2019
+ const feed = document.createElement("div");
2020
+ feed.className = "ai-feed hidden";
2021
+ feed.setAttribute("role", "tabpanel");
2022
+ $("#ai-chat-panels").append(feed);
2023
+ return feed;
2024
+ }
2025
+
2026
+ function createAiChatTabState(input = {}) {
2027
+ const existing = input.conversationId ? aiChatTabManager.findByConversation(input.conversationId) : null;
2028
+ if (existing) {
2029
+ aiChatTabManager.activate(existing.id);
2030
+ return existing;
2031
+ }
2032
+ const feed = input.feed ?? createAiChatFeed();
2033
+ const tab = aiChatTabManager.open({
2034
+ workId: String(state.work?.id ?? ""),
2035
+ conversationId: input.conversationId ?? null,
2036
+ title: input.title ?? "新对话",
2037
+ modelId: input.modelId ?? null,
2038
+ selectedModelId: input.selectedModelId ?? null,
2039
+ promptSent: input.promptSent === true,
2040
+ taskType: input.taskType ?? "chat",
2041
+ contextScope: input.contextScope ?? { type: "none" },
2042
+ roleplayCharacter: input.roleplayCharacter ?? null,
2043
+ citations: input.citations ?? [],
2044
+ references: input.references ?? [],
2045
+ composer: input.composer ?? { text: "", citations: [], references: [] },
2046
+ contextUsage: input.contextUsage ?? null,
2047
+ contextWarning: input.contextWarning === true,
2048
+ lastMessageAt: input.lastMessageAt ?? null,
2049
+ status: input.status ?? "ready",
2050
+ unread: input.unread === true,
2051
+ feed
2052
+ });
2053
+ feed.dataset.aiTabId = tab.id;
2054
+ feed.setAttribute("aria-labelledby", `ai-chat-tab-${tab.id}`);
2055
+ return tab;
2056
+ }
2057
+
2058
+ function persistActiveAiChatTab() {
2059
+ const tab = activeAiChatTab();
2060
+ if (!tab) return null;
2061
+ tab.workId = String(state.work?.id ?? "");
2062
+ tab.conversationId = state.aiConversationId;
2063
+ tab.title = $("#ai-conversation-title").textContent || tab.title || "新对话";
2064
+ tab.modelId = state.aiConversationModelId;
2065
+ tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
2066
+ tab.promptSent = state.aiPromptSent;
2067
+ tab.taskType = state.aiTaskType;
2068
+ tab.contextScope = { ...state.aiContextScope };
2069
+ tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2070
+ setAiChatTabComposerSnapshot(tab, captureAiPromptComposer());
2071
+ tab.contextUsage = latestAiContextUsage;
2072
+ tab.contextWarning = !$("#ai-context-warning").classList.contains("hidden");
2073
+ tab.lastMessageAt = state.aiLastMessageAt;
2074
+ return tab;
2075
+ }
2076
+
2077
+ function setAiChatTabComposerSnapshot(tab, snapshot) {
2078
+ if (!tab) return;
2079
+ tab.citations = snapshot.citations.map((citation) => ({ ...citation }));
2080
+ tab.references = snapshot.references.map((reference) => ({ ...reference }));
2081
+ tab.composer = {
2082
+ text: snapshot.text,
2083
+ citations: tab.citations.map((citation) => ({ ...citation })),
2084
+ references: tab.references.map((reference) => ({ ...reference }))
2085
+ };
2086
+ }
2087
+
2088
+ function clearAiChatTabComposer(tab) {
2089
+ setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [] });
2090
+ }
2091
+
2092
+ function applyAiChatTabState(tab) {
2093
+ state.aiConversationId = tab.conversationId;
2094
+ state.aiConversationModelId = tab.modelId;
2095
+ state.aiPromptSent = tab.promptSent;
2096
+ state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
2097
+ state.aiReferences = tab.references.map((reference) => ({ ...reference }));
2098
+ state.aiLastMessageAt = tab.lastMessageAt;
2099
+ $("#ai-conversation-title").textContent = tab.title || "新对话";
2100
+ applyAiConversationTaskType(tab.taskType);
2101
+ applyAiConversationContextScope(tab.contextScope);
2102
+ applyAiRoleplayCharacter(tab.roleplayCharacter);
2103
+ const selectedModelId = tab.modelId ?? tab.selectedModelId;
2104
+ if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
2105
+ setAiPromptText(tab.composer.text);
2106
+ renderAiCitations();
2107
+ renderAiReferences();
2108
+ latestAiContextUsage = null;
2109
+ setAiContextMeter(tab.contextUsage);
2110
+ if (tab.contextWarning) showAiContextWarning();
2111
+ else hideAiContextWarning();
2112
+ setAiAssistantStatus(tab.status);
2113
+ renderAiQuickActions();
2114
+ renderAiConversationHistory();
2115
+ syncAiRequestControls();
2116
+ }
2117
+
2118
+ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
2119
+ return Boolean(tab
2120
+ && !tab.conversationId
2121
+ && !tab.promptSent
2122
+ && !aiRequestManager.hasActive(tab.id)
2123
+ && !(tab.composer?.text || "").trim()
2124
+ && !(tab.composer?.citations?.length)
2125
+ && !(tab.composer?.references?.length));
2126
+ }
2127
+
2128
+ function aiChatTabOpeningSlot() {
2129
+ const active = activeAiChatTab();
2130
+ if (aiChatTabLimit === 1) return { allowed: true, tab: active, replacing: true };
2131
+ if (aiChatTabIsReplaceable(active)) return { allowed: true, tab: active, replacing: true };
2132
+ if (aiChatTabManager.list().length < aiChatTabLimit) return { allowed: true, tab: null, replacing: false };
2133
+ return { allowed: false, tab: null, replacing: false };
2134
+ }
2135
+
2136
+ function interruptAiChatTabForReplacement(slot) {
2137
+ if (!slot?.replacing || !slot.tab) return;
2138
+ if (aiRequestManager.cancel("已切换到其他对话", slot.tab.id)) {
2139
+ toast("当前对话仍在生成,切换后已中断该 turn;已收到的内容仍保留在历史记录中", "warning");
2140
+ }
2141
+ }
2142
+
2143
+ function reportAiChatTabLimit() {
2144
+ toast(`最多同时打开 ${aiChatTabLimit} 个对话,请先关闭一个再继续`, "warning");
2145
+ }
2146
+
2147
+ function aiChatTabStatusLabel(tab, selected) {
2148
+ if (tab.status === "streaming") return "正在生成";
2149
+ if (tab.status === "error") return "执行失败";
2150
+ if (tab.unread) return "已完成 · 未读";
2151
+ return selected ? "当前对话" : "空闲";
2152
+ }
2153
+
2154
+ function renderAiConversationSwitcher() {
2155
+ const button = $("#ai-conversation-switcher");
2156
+ const list = $("#ai-open-conversation-list");
2157
+ if (!button || !list) return;
2158
+ const tabs = aiChatTabManager.list();
2159
+ const active = activeAiChatTab();
2160
+ const multipleEnabled = aiChatTabLimit > 1;
2161
+ $("#ai-conversation-title").textContent = active?.title || "新对话";
2162
+ $("#ai-open-conversation-count").textContent = String(tabs.length);
2163
+ $("#ai-conversation-switcher-limit").textContent = `${tabs.length} / ${aiChatTabLimit}`;
2164
+ button.disabled = !multipleEnabled;
2165
+ button.setAttribute("aria-disabled", String(!multipleEnabled));
2166
+ button.title = multipleEnabled ? "切换打开的对话" : "多会话已通过环境变量关闭";
2167
+ button.classList.toggle("has-background-activity", tabs.some((tab) => tab.id !== active?.id && (tab.status === "streaming" || tab.unread)));
2168
+ $("#ai-workspace-open").classList.toggle("hidden", !multipleEnabled);
2169
+ list.replaceChildren();
2170
+ for (const tab of tabs) {
2171
+ const selected = tab.id === active?.id;
2172
+ const row = document.createElement("div");
2173
+ row.className = `ai-open-conversation-row${selected ? " is-active" : ""}`;
2174
+ row.setAttribute("role", "listitem");
2175
+ const select = document.createElement("button");
2176
+ select.type = "button";
2177
+ select.className = "ai-open-conversation-select";
2178
+ select.dataset.aiSwitcherTabId = tab.id;
2179
+ select.setAttribute("aria-current", selected ? "true" : "false");
2180
+ const status = document.createElement("span");
2181
+ status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
2182
+ status.setAttribute("aria-hidden", "true");
2183
+ const copy = document.createElement("span");
2184
+ copy.className = "ai-open-conversation-copy";
2185
+ const title = document.createElement("strong");
2186
+ title.textContent = tab.title || "新对话";
2187
+ const meta = document.createElement("small");
2188
+ meta.textContent = aiChatTabStatusLabel(tab, selected);
2189
+ copy.append(title, meta);
2190
+ select.append(status, copy);
2191
+ select.addEventListener("click", () => {
2192
+ activateAiChatTab(tab.id);
2193
+ setAiConversationSwitcherVisible(false);
2194
+ });
2195
+ const close = document.createElement("button");
2196
+ close.type = "button";
2197
+ close.className = "ai-open-conversation-close";
2198
+ close.setAttribute("aria-label", `关闭对话“${tab.title || "新对话"}”`);
2199
+ close.textContent = "×";
2200
+ close.classList.toggle("hidden", tabs.length === 1);
2201
+ close.addEventListener("click", () => closeAiChatTab(tab.id));
2202
+ row.append(select, close);
2203
+ list.append(row);
2204
+ }
2205
+ }
2206
+
2207
+ function setAiConversationSwitcherVisible(visible) {
2208
+ const menu = $("#ai-conversation-switcher-menu");
2209
+ const button = $("#ai-conversation-switcher");
2210
+ if (!menu || !button) return;
2211
+ const nextVisible = Boolean(visible && aiChatTabLimit > 1);
2212
+ menu.classList.toggle("hidden", !nextVisible);
2213
+ button.setAttribute("aria-expanded", String(nextVisible));
2214
+ if (nextVisible) renderAiConversationSwitcher();
2215
+ }
2216
+
2217
+ function setAiConversationWorkspaceVisible(visible) {
2218
+ const mobileWorkspace = isMobileViewport();
2219
+ aiConversationWorkspaceOpen = Boolean(visible);
2220
+ panelLayout.aiCollapsed = aiConversationWorkspaceOpen ? false : mobileWorkspace;
2221
+ $("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
2222
+ $(".ai-panel").classList.toggle("is-conversation-workspace", aiConversationWorkspaceOpen);
2223
+ $("#ai-assistant-entry").classList.toggle("active", aiConversationWorkspaceOpen);
2224
+ $("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
2225
+ applyPanelLayout(true);
2226
+ $("#ai-chat-tabs").classList.add("hidden");
2227
+ $("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
2228
+ $("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
2229
+ setAiConversationSwitcherVisible(false);
2230
+ renderAiChatTabs();
2231
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
2232
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
2233
+ if (aiConversationWorkspaceOpen) {
2234
+ window.requestAnimationFrame(() => $("#ai-prompt").focus({ preventScroll: true }));
2235
+ }
2236
+ }
2237
+
2238
+ function applyAiChatTabLimit(value) {
2239
+ aiChatTabLimit = normalizeAiChatTabLimit(value);
2240
+ const singleSession = aiChatTabLimit === 1;
2241
+ $(".ai-panel").classList.toggle("is-single-conversation", singleSession);
2242
+ if (singleSession) {
2243
+ setAiConversationSwitcherVisible(false);
2244
+ }
2245
+ renderAiChatTabs();
2246
+ }
2247
+
2248
+ function renderAiChatTabs() {
2249
+ const host = $("#ai-chat-tabs");
2250
+ if (!host) return;
2251
+ const tabs = aiChatTabManager.list();
2252
+ const active = activeAiChatTab();
2253
+ host.replaceChildren();
2254
+ for (const tab of tabs) {
2255
+ const item = document.createElement("div");
2256
+ item.className = "ai-chat-tab";
2257
+ item.setAttribute("role", "presentation");
2258
+ const button = document.createElement("button");
2259
+ const selected = tab.id === active?.id;
2260
+ button.type = "button";
2261
+ button.id = `ai-chat-tab-${tab.id}`;
2262
+ button.className = "ai-chat-tab-select";
2263
+ button.dataset.aiTabId = tab.id;
2264
+ button.setAttribute("role", "tab");
2265
+ button.setAttribute("aria-selected", String(selected));
2266
+ button.setAttribute("aria-controls", tab.feed.id || `ai-chat-panel-${tab.id}`);
2267
+ button.tabIndex = selected ? 0 : -1;
2268
+ button.title = tab.title || "新对话";
2269
+ const status = document.createElement("span");
2270
+ status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
2271
+ status.setAttribute("aria-hidden", "true");
2272
+ const title = document.createElement("span");
2273
+ title.textContent = tab.title || "新对话";
2274
+ button.append(status, title);
2275
+ button.addEventListener("click", () => activateAiChatTab(tab.id));
2276
+ button.addEventListener("keydown", (event) => {
2277
+ const index = tabs.findIndex((candidate) => candidate.id === tab.id);
2278
+ const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
2279
+ if (!offset) return;
2280
+ event.preventDefault();
2281
+ const target = tabs[(index + offset + tabs.length) % tabs.length];
2282
+ activateAiChatTab(target.id);
2283
+ $("#ai-conversation-switcher").focus();
2284
+ });
2285
+ const close = document.createElement("button");
2286
+ close.type = "button";
2287
+ close.className = "ai-chat-tab-close";
2288
+ close.dataset.aiTabClose = tab.id;
2289
+ close.setAttribute("aria-label", `关闭对话页签“${tab.title || "新对话"}”`);
2290
+ close.title = "关闭页签";
2291
+ close.textContent = "×";
2292
+ close.classList.toggle("hidden", tabs.length === 1);
2293
+ close.addEventListener("click", () => closeAiChatTab(tab.id));
2294
+ item.append(button, close);
2295
+ host.append(item);
2296
+ }
2297
+ renderAiConversationSwitcher();
2298
+ }
2299
+
2300
+ function activateAiChatTab(tabId, { persistCurrent = true, force = false } = {}) {
2301
+ const current = activeAiChatTab();
2302
+ if (!force && current?.id === tabId) return current;
2303
+ if (persistCurrent) persistActiveAiChatTab();
2304
+ const tab = aiChatTabManager.activate(tabId);
2305
+ if (!tab) return null;
2306
+ tab.unread = false;
2307
+ for (const feed of $("#ai-chat-panels").querySelectorAll(".ai-feed")) {
2308
+ feed.classList.toggle("hidden", feed !== tab.feed);
2309
+ feed.id = feed === tab.feed ? "ai-feed" : `ai-chat-panel-${feed.dataset.aiTabId}`;
2310
+ }
2311
+ tab.feed.classList.remove("hidden");
2312
+ applyAiChatTabState(tab);
2313
+ renderAiChatTabs();
2314
+ return tab;
2315
+ }
2316
+
2317
+ function closeAiChatTab(tabId) {
2318
+ const tab = aiChatTabManager.get(tabId);
2319
+ if (!tab) return;
2320
+ const wasActive = isActiveAiChatTab(tab);
2321
+ if (wasActive) persistActiveAiChatTab();
2322
+ if (aiRequestManager.cancel("对话页签已关闭", tab.id)) {
2323
+ toast(`“${tab.title || "新对话"}”仍在生成,关闭页签已中断该 turn;其他页签不受影响`, "warning");
2324
+ }
2325
+ const { active } = aiChatTabManager.close(tab.id);
2326
+ tab.feed.remove();
2327
+ if (!wasActive) {
2328
+ renderAiChatTabs();
2329
+ return;
2330
+ }
2331
+ const next = active ?? createAiChatTabState();
2332
+ activateAiChatTab(next.id, { persistCurrent: false, force: true });
2333
+ }
2334
+
2335
+ function resetAiChatTabs() {
2336
+ aiRequestManager.cancelAll("已重置 Agent 对话页签");
2337
+ setAiConversationSwitcherVisible(false);
2338
+ setAiConversationWorkspaceVisible(false);
2339
+ const panels = $("#ai-chat-panels");
2340
+ panels.replaceChildren();
2341
+ aiChatTabManager.reset();
2342
+ const tab = createAiChatTabState();
2343
+ resetAiFeed(tab.feed, tab.roleplayCharacter);
2344
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2345
+ }
2346
+
2347
+ function initializeAiChatTabs() {
2348
+ const feed = $("#ai-feed");
2349
+ aiChatTabManager.reset();
2350
+ const tab = createAiChatTabState({ feed });
2351
+ feed.dataset.aiTabId = tab.id;
2352
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2353
+ }
2354
+
2355
+ function attachMessageHeading(message, label, createdAt = new Date().toISOString(), tab = activeAiChatTab()) {
1785
2356
  const timestamp = createdAt || new Date().toISOString();
1786
- const previousCreatedAt = state.aiLastMessageAt;
2357
+ const previousCreatedAt = tab?.lastMessageAt ?? state.aiLastMessageAt;
1787
2358
  const heading = document.createElement("span");
1788
2359
  heading.className = "message-heading";
1789
2360
  const role = document.createElement("span");
@@ -1795,7 +2366,8 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1795
2366
  message.prepend(heading);
1796
2367
  message.dataset.createdAt = timestamp;
1797
2368
  message.dataset.previousCreatedAt = previousCreatedAt ?? "";
1798
- state.aiLastMessageAt = timestamp;
2369
+ if (tab) tab.lastMessageAt = timestamp;
2370
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = timestamp;
1799
2371
  return heading;
1800
2372
  }
1801
2373
 
@@ -1806,19 +2378,25 @@ function updateMessageCreatedAt(message, createdAt) {
1806
2378
  time.dateTime = createdAt;
1807
2379
  time.textContent = formatAiMessageTime(createdAt);
1808
2380
  message.dataset.createdAt = createdAt;
1809
- if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
2381
+ const tab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2382
+ if (message === tab?.feed.lastElementChild) {
2383
+ tab.lastMessageAt = createdAt;
2384
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = createdAt;
2385
+ }
1810
2386
  }
1811
2387
 
1812
- function resetAiFeed() {
1813
- state.aiLastMessageAt = null;
1814
- const roleplayName = state.aiRoleplayCharacter?.name;
1815
- $("#ai-feed").innerHTML = roleplayName
2388
+ function resetAiFeed(feed = $("#ai-feed"), roleplayCharacter = state.aiRoleplayCharacter) {
2389
+ const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2390
+ if (tab) tab.lastMessageAt = null;
2391
+ if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
2392
+ const roleplayName = roleplayCharacter?.name;
2393
+ feed.innerHTML = roleplayName
1816
2394
  ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
1817
2395
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1818
2396
  }
1819
2397
 
1820
- function aiAssistantLabel(suffix = "") {
1821
- const name = state.aiRoleplayCharacter?.name || "助手";
2398
+ function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
2399
+ const name = roleplayCharacter?.name || "助手";
1822
2400
  return suffix ? `${name} · ${suffix}` : name;
1823
2401
  }
1824
2402
 
@@ -1834,26 +2412,24 @@ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "
1834
2412
  return divider;
1835
2413
  }
1836
2414
 
1837
- function appendAiContextCompactionDivider(kind, before = null) {
2415
+ function appendAiContextCompactionDivider(kind, before = null, feed = $("#ai-feed")) {
1838
2416
  const divider = createAiContextCompactionDivider({ kind });
1839
- const feed = $("#ai-feed");
1840
2417
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1841
2418
  else feed.append(divider);
1842
- scrollAiFeedToBottom();
2419
+ scrollAiFeedToBottom(feed);
1843
2420
  return divider;
1844
2421
  }
1845
2422
 
1846
- function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
1847
- const feed = $("#ai-feed");
2423
+ function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null, feed = $("#ai-feed"), conversation = null) {
1848
2424
  feed.querySelector('[data-context-compaction="conversation"]')?.remove();
1849
2425
  const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
1850
2426
  if (compactedCount === 0) return null;
1851
2427
  const messages = [...feed.querySelectorAll("[data-message-id]")];
1852
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
2428
+ const current = conversation ?? state.aiConversations.find((item) => item.id === state.aiConversationId);
1853
2429
  const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
1854
2430
  const loadedOffset = Math.max(0, totalCount - messages.length);
1855
2431
  const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
1856
- return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
2432
+ return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
1857
2433
  }
1858
2434
 
1859
2435
  function renderMessageCardActions(message) {
@@ -1864,16 +2440,18 @@ function renderMessageCardActions(message) {
1864
2440
  message.append(actions);
1865
2441
  }
1866
2442
  actions.replaceChildren();
1867
- if (Object.hasOwn(message.dataset, "rawMarkdown")) {
2443
+ const hasCopyValue = Object.hasOwn(message.dataset, "rawMarkdown") || Object.hasOwn(message.dataset, "copyText");
2444
+ if (hasCopyValue) {
1868
2445
  const copy = document.createElement("button");
1869
2446
  copy.type = "button";
1870
2447
  copy.className = "message-copy-button";
1871
- copy.setAttribute("aria-label", "复制 AI 原始 Markdown");
2448
+ const isUserMessage = message.classList.contains("user-message");
2449
+ copy.setAttribute("aria-label", isUserMessage ? "复制用户指令" : "复制 AI 回复");
1872
2450
  copy.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>复制</span>';
1873
2451
  const copyLabel = copy.querySelector("span");
1874
2452
  copy.addEventListener("click", async () => {
1875
2453
  try {
1876
- await copyAiRawMarkdown(message.dataset.rawMarkdown);
2454
+ await copyAiRawMarkdown(message.dataset.rawMarkdown ?? message.dataset.copyText ?? "");
1877
2455
  copyLabel.textContent = "已复制";
1878
2456
  window.setTimeout(() => { copyLabel.textContent = "复制"; }, 1200);
1879
2457
  } catch (error) {
@@ -1890,9 +2468,16 @@ function renderMessageCardActions(message) {
1890
2468
  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
2469
  const forkRequestId = createAiIdempotencyKey();
1892
2470
  fork.addEventListener("click", async () => {
2471
+ const openingSlot = aiChatTabOpeningSlot();
2472
+ if (!openingSlot.allowed) {
2473
+ reportAiChatTabLimit();
2474
+ return;
2475
+ }
1893
2476
  fork.disabled = true;
1894
2477
  try {
1895
- const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, {
2478
+ const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2479
+ if (!sourceTab?.conversationId) throw new Error("无法确定消息所属对话");
2480
+ const conversation = await api(`/api/ai-conversations/${sourceTab.conversationId}/fork`, {
1896
2481
  method: "POST",
1897
2482
  body: { messageId: message.dataset.messageId, requestId: forkRequestId }
1898
2483
  });
@@ -1916,6 +2501,11 @@ function attachAssistantCopyAction(message, rawMarkdown) {
1916
2501
  renderMessageCardActions(message);
1917
2502
  }
1918
2503
 
2504
+ function attachUserCopyAction(message, text) {
2505
+ message.dataset.copyText = String(text ?? "");
2506
+ renderMessageCardActions(message);
2507
+ }
2508
+
1919
2509
  function attachMessageIdentity(message, messageId) {
1920
2510
  if (!messageId) return;
1921
2511
  message.dataset.messageId = messageId;
@@ -1931,7 +2521,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1931
2521
  search_drafts: "搜索想法",
1932
2522
  recall_self: "回忆自身",
1933
2523
  image: "读取设定图片",
1934
- recall_relationship: "回忆人物关系"
2524
+ recall_relationship: "回忆人物关系",
2525
+ calculate_time: "计算日期"
1935
2526
  };
1936
2527
 
1937
2528
  const AI_TOOL_DESCRIPTIONS = {
@@ -1943,10 +2534,11 @@ const AI_TOOL_DESCRIPTIONS = {
1943
2534
  search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1944
2535
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
1945
2536
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
1946
- recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。"
2537
+ recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2538
+ calculate_time: "计算日期差值,或从起始日期推算目标日期。"
1947
2539
  };
1948
2540
 
1949
- let aiFeedScrollFrame = null;
2541
+ const aiFeedScrollFrames = new WeakMap();
1950
2542
  let markdownTableMenuTarget = null;
1951
2543
  let markdownTableMenuTrigger = null;
1952
2544
 
@@ -1987,14 +2579,15 @@ function openMarkdownTableMenu(header, clientX, clientY) {
1987
2579
  toggle.focus();
1988
2580
  }
1989
2581
 
1990
- function scrollAiFeedToBottom() {
1991
- const feed = $("#ai-feed");
2582
+ function scrollAiFeedToBottom(feed = $("#ai-feed")) {
1992
2583
  feed.scrollTop = feed.scrollHeight;
1993
- if (aiFeedScrollFrame !== null) window.cancelAnimationFrame(aiFeedScrollFrame);
1994
- aiFeedScrollFrame = window.requestAnimationFrame(() => {
2584
+ const currentFrame = aiFeedScrollFrames.get(feed);
2585
+ if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
2586
+ const nextFrame = window.requestAnimationFrame(() => {
1995
2587
  feed.scrollTop = feed.scrollHeight;
1996
- aiFeedScrollFrame = null;
2588
+ aiFeedScrollFrames.delete(feed);
1997
2589
  });
2590
+ aiFeedScrollFrames.set(feed, nextFrame);
1998
2591
  }
1999
2592
 
2000
2593
  function formatAiToolCallTime(value) {
@@ -2012,9 +2605,14 @@ function formatAiToolCallTime(value) {
2012
2605
  }).format(date);
2013
2606
  }
2014
2607
 
2608
+ 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>';
2609
+ 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>';
2610
+
2015
2611
  function setAiToolCallCopyButtonState(button, copied) {
2016
2612
  const label = button.dataset.copyLabel ?? "代码块";
2017
- button.dataset.copyState = copied ? "copied" : "idle";
2613
+ const nextState = copied ? "copied" : "idle";
2614
+ if (button.dataset.copyState !== nextState) button.innerHTML = copied ? AI_TOOL_CALL_COPIED_ICON : AI_TOOL_CALL_COPY_ICON;
2615
+ button.dataset.copyState = nextState;
2018
2616
  button.classList.toggle("is-copied", copied);
2019
2617
  button.setAttribute("aria-label", copied ? `${label}已复制` : `复制${label}`);
2020
2618
  button.title = copied ? "已复制" : `复制${label}`;
@@ -2233,23 +2831,15 @@ async function downloadAiConversation(conversation) {
2233
2831
  window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
2234
2832
  }
2235
2833
 
2834
+ async function copyAiConversationSessionId(conversation) {
2835
+ const sessionId = typeof conversation?.id === "string" ? conversation.id.trim() : "";
2836
+ if (!sessionId) throw new Error("无法确定对话 Session ID");
2837
+ await copyAiRawMarkdown(sessionId);
2838
+ }
2839
+
2236
2840
  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();
2841
+ const tab = activeAiChatTab();
2842
+ if (tab) closeAiChatTab(tab.id);
2253
2843
  }
2254
2844
 
2255
2845
  async function favoriteAiConversation(conversation) {
@@ -2272,7 +2862,11 @@ async function deleteAiConversation(conversation) {
2272
2862
  })) return;
2273
2863
  await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}`, { method: "DELETE" });
2274
2864
  state.aiConversations = state.aiConversations.filter((item) => item.id !== conversation.id);
2275
- if (state.aiConversationId === conversation.id) resetAiConversationAfterDelete();
2865
+ const tab = aiChatTabManager.findByConversation(conversation.id);
2866
+ if (tab) {
2867
+ if (isActiveAiChatTab(tab)) resetAiConversationAfterDelete();
2868
+ else closeAiChatTab(tab.id);
2869
+ }
2276
2870
  renderAiConversationHistory();
2277
2871
  toast("对话已清理");
2278
2872
  }
@@ -2408,8 +3002,11 @@ function applyAiConversationTitle(title, conversationId = state.aiConversationId
2408
3002
  if (!title || !conversationId) return;
2409
3003
  const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
2410
3004
  if (current) current.title = title;
3005
+ const tab = aiChatTabManager.findByConversation(conversationId);
3006
+ if (tab) tab.title = title;
2411
3007
  if (state.aiConversationId === conversationId) $("#ai-conversation-title").textContent = title;
2412
3008
  renderAiConversationHistory();
3009
+ renderAiChatTabs();
2413
3010
  }
2414
3011
 
2415
3012
  function applyAiConversations(pageResult) {
@@ -2453,6 +3050,21 @@ async function ensureAiConversationsLoaded() {
2453
3050
 
2454
3051
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2455
3052
  if (!state.work) return null;
3053
+ const existingTab = aiChatTabManager.findByConversation(conversationId);
3054
+ const existingMessage = focusMessageId
3055
+ ? existingTab?.feed.querySelector(`[data-message-id="${CSS.escape(String(focusMessageId))}"]`)
3056
+ : null;
3057
+ if (existingTab && (!focusMessageId || existingMessage || aiRequestManager.hasActive(existingTab.id))) {
3058
+ activateAiChatTab(existingTab.id);
3059
+ if (focusMessageId && existingMessage) focusAiConversationMessage(focusMessageId);
3060
+ if (hideHistory) setAiHistoryVisible(false);
3061
+ return state.aiConversations.find((conversation) => conversation.id === conversationId) ?? existingTab;
3062
+ }
3063
+ let openingSlot = existingTab ? { allowed: true, tab: existingTab, replacing: false } : aiChatTabOpeningSlot();
3064
+ if (!openingSlot.allowed) {
3065
+ reportAiChatTabLimit();
3066
+ return null;
3067
+ }
2456
3068
  const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
2457
3069
  try {
2458
3070
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
@@ -2464,28 +3076,18 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
2464
3076
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2465
3077
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
2466
3078
  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);
3079
+ persistActiveAiChatTab();
3080
+ if (!existingTab) openingSlot = aiChatTabOpeningSlot();
3081
+ if (!openingSlot.allowed) {
3082
+ reportAiChatTabLimit();
3083
+ return null;
2479
3084
  }
2480
- state.aiCitations = [];
2481
- state.aiReferences = [];
2482
- setAiPromptText("");
2483
- renderAiCitations();
2484
- renderAiReferences();
2485
- renderAiQuickActions();
2486
- renderAiConversationHistory();
2487
- if (conversation.contextWarningPending) showAiContextWarning();
2488
- else hideAiContextWarning();
3085
+ interruptAiChatTabForReplacement(openingSlot);
3086
+ let tab = existingTab ?? openingSlot.tab;
3087
+ if (!tab) tab = createAiChatTabState();
3088
+ applyConversationToAiChatTab(tab, conversation);
3089
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3090
+ if (focusMessageId) window.requestAnimationFrame(() => focusAiConversationMessage(focusMessageId));
2489
3091
  if (hideHistory) setAiHistoryVisible(false);
2490
3092
  return conversation;
2491
3093
  } catch (error) {
@@ -2505,30 +3107,60 @@ function focusAiConversationMessage(messageId) {
2505
3107
  window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
2506
3108
  }
2507
3109
 
2508
- function applyNewAiConversation(conversation) {
3110
+ function applyConversationToAiChatTab(tab, conversation) {
3111
+ tab.workId = String(conversation.workId ?? state.work?.id ?? "");
3112
+ tab.conversationId = conversation.id;
3113
+ tab.title = conversation.title || "新对话";
3114
+ tab.modelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
3115
+ tab.selectedModelId = tab.modelId ?? tab.selectedModelId;
3116
+ tab.promptSent = Array.isArray(conversation.messages) && conversation.messages.some((message) => message.role === "user");
3117
+ tab.taskType = conversation.taskType ?? "chat";
3118
+ tab.contextScope = conversation.contextScope ?? { type: "none" };
3119
+ tab.roleplayCharacter = conversation.roleplayCharacter ?? null;
3120
+ tab.citations = [];
3121
+ tab.references = [];
3122
+ tab.composer = { text: "", citations: [], references: [] };
3123
+ tab.contextUsage = null;
3124
+ tab.contextWarning = conversation.contextWarningPending === true;
3125
+ resetAiFeed(tab.feed, tab.roleplayCharacter);
3126
+ for (const message of conversation.messages ?? []) {
3127
+ appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id, { tab });
3128
+ }
3129
+ if (conversation.hasCompactedSummary) {
3130
+ renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount, tab.feed, conversation);
3131
+ }
3132
+ renderAiChatTabs();
3133
+ }
3134
+
3135
+ function applyNewAiConversation(conversation, tab = activeAiChatTab()) {
2509
3136
  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();
3137
+ applyConversationToAiChatTab(tab, { ...conversation, messages: [] });
3138
+ if (isActiveAiChatTab(tab)) applyAiChatTabState(tab);
2521
3139
  setAiHistoryVisible(false);
2522
3140
  }
2523
3141
 
2524
3142
  async function createNewAiConversation(taskType = "chat") {
2525
3143
  if (!state.work) return null;
3144
+ let openingSlot = aiChatTabOpeningSlot();
3145
+ if (!openingSlot.allowed) {
3146
+ reportAiChatTabLimit();
3147
+ return null;
3148
+ }
2526
3149
  const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
2527
3150
  try {
2528
3151
  const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2529
3152
  if (!isAiConversationNavigationCurrent(navigation)) return null;
2530
3153
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("新对话不属于当前作品");
2531
- applyNewAiConversation(conversation);
3154
+ persistActiveAiChatTab();
3155
+ openingSlot = aiChatTabOpeningSlot();
3156
+ if (!openingSlot.allowed) {
3157
+ reportAiChatTabLimit();
3158
+ return null;
3159
+ }
3160
+ interruptAiChatTabForReplacement(openingSlot);
3161
+ const tab = openingSlot.tab ?? createAiChatTabState();
3162
+ applyNewAiConversation(conversation, tab);
3163
+ activateAiChatTab(tab.id, { persistCurrent: false, force: true });
2532
3164
  return conversation;
2533
3165
  } catch (error) {
2534
3166
  if (!isAiConversationNavigationCurrent(navigation)) return null;
@@ -2610,6 +3242,8 @@ function syncAiTaskOptions() {
2610
3242
  function applyAiConversationTaskType(taskType) {
2611
3243
  const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
2612
3244
  state.aiTaskType = normalizedTaskType;
3245
+ const tab = activeAiChatTab();
3246
+ if (tab) tab.taskType = normalizedTaskType;
2613
3247
  $("#ai-task").value = normalizedTaskType;
2614
3248
  $("#ai-task").dataset.previousValue = normalizedTaskType;
2615
3249
  syncAiTaskOptions();
@@ -2618,6 +3252,8 @@ function applyAiConversationTaskType(taskType) {
2618
3252
  function applyAiConversationContextScope(scope) {
2619
3253
  const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
2620
3254
  state.aiContextScope = normalizedScope;
3255
+ const tab = activeAiChatTab();
3256
+ if (tab) tab.contextScope = { ...normalizedScope };
2621
3257
  $("#ai-scope").value = normalizedScope.type === "book" ? "book"
2622
3258
  : normalizedScope.type === "volume" ? "volume"
2623
3259
  : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
@@ -2628,6 +3264,8 @@ function applyAiConversationContextScope(scope) {
2628
3264
 
2629
3265
  function applyAiRoleplayCharacter(character) {
2630
3266
  state.aiRoleplayCharacter = character?.id ? character : null;
3267
+ const tab = activeAiChatTab();
3268
+ if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
2631
3269
  const active = Boolean(state.aiRoleplayCharacter);
2632
3270
  if (active) $("#ai-scope").value = "none";
2633
3271
  $(".ai-panel").classList.toggle("is-roleplaying", active);
@@ -2693,6 +3331,8 @@ async function persistAiConversationContextScope(scope) {
2693
3331
 
2694
3332
  async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2695
3333
  let request = assertAiRequestCurrent(requestHolder.snapshot);
3334
+ const tab = aiChatTabForRequest(request);
3335
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
2696
3336
  if (!request.conversationId) {
2697
3337
  const conversation = await api(`/api/works/${encodeURIComponent(request.workId)}/ai-conversations`, {
2698
3338
  method: "POST",
@@ -2701,7 +3341,7 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2701
3341
  });
2702
3342
  assertAiRequestCurrent(request);
2703
3343
  if (String(conversation.workId ?? "") !== request.workId) throw new Error("新对话不属于请求作品");
2704
- applyNewAiConversation(conversation);
3344
+ applyNewAiConversation(conversation, tab);
2705
3345
  requestHolder.snapshot = aiRequestManager.bind(request, { conversationId: conversation.id });
2706
3346
  request = requestHolder.snapshot;
2707
3347
  }
@@ -2712,8 +3352,12 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2712
3352
  });
2713
3353
  assertAiRequestCurrent(request);
2714
3354
  upsertAiConversationSummary(taskConversation);
2715
- applyAiConversationTaskType(taskConversation.taskType);
2716
- applyAiRoleplayCharacter(taskConversation.roleplayCharacter);
3355
+ tab.taskType = taskConversation.taskType;
3356
+ tab.roleplayCharacter = taskConversation.roleplayCharacter ?? null;
3357
+ if (isActiveAiChatTab(tab)) {
3358
+ applyAiConversationTaskType(tab.taskType);
3359
+ applyAiRoleplayCharacter(tab.roleplayCharacter);
3360
+ }
2717
3361
 
2718
3362
  const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
2719
3363
  method: "PATCH",
@@ -2722,7 +3366,8 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2722
3366
  });
2723
3367
  assertAiRequestCurrent(request);
2724
3368
  upsertAiConversationSummary(scopedConversation);
2725
- applyAiConversationContextScope(scopedConversation.contextScope);
3369
+ tab.contextScope = scopedConversation.contextScope;
3370
+ if (isActiveAiChatTab(tab)) applyAiConversationContextScope(tab.contextScope);
2726
3371
  return request;
2727
3372
  }
2728
3373
 
@@ -2746,13 +3391,29 @@ async function persistAiRequestInterruption(request, interruption = null) {
2746
3391
  const partialContent = typeof interruption?.content === "string" && interruption.content.trim()
2747
3392
  ? interruption.content
2748
3393
  : null;
3394
+ const cancellationMessage = request.signal.reason instanceof Error
3395
+ ? request.signal.reason.message
3396
+ : "AI 请求已取消";
3397
+ const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
3398
+ const metadata = cancelledByClient
3399
+ ? {
3400
+ ...(partialContent ? interruption.metadata : {}),
3401
+ interrupted: true,
3402
+ interruptionCode: "AI_REQUEST_CANCELLED",
3403
+ interruptionMessage: cancellationMessage.slice(0, 500)
3404
+ }
3405
+ : interruption?.metadata ?? {
3406
+ interrupted: true,
3407
+ interruptionCode: "AI_REQUEST_CANCELLED",
3408
+ interruptionMessage: cancellationMessage.slice(0, 500)
3409
+ };
2749
3410
  try {
2750
3411
  const message = await persistAiConversationMessage(
2751
3412
  request.conversationId,
2752
3413
  "assistant",
2753
- partialContent ?? "调用失败:生成已取消,尚未收到可保留的回复内容。",
3414
+ partialContent ?? "生成已终止,尚未收到可保留的回复内容。",
2754
3415
  [],
2755
- partialContent ? interruption.metadata : {},
3416
+ metadata,
2756
3417
  { requestId: aiAssistantRequestId(request) }
2757
3418
  );
2758
3419
  updateAiConversationSummaryFromMessage(message);
@@ -2951,7 +3612,8 @@ function addSelectedLinesAsCitation() {
2951
3612
  }
2952
3613
 
2953
3614
  async function createSelectedLineAnnotation(kind) {
2954
- if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
3615
+ const permissionModule = kind === "todo" ? "todos" : "comments";
3616
+ if (!state.chapter || !chapterLineSelection || !canWritePermissionModule(state.work, permissionModule)) return;
2955
3617
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
2956
3618
  closeLineCitationMenu();
2957
3619
  const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
@@ -2967,7 +3629,8 @@ async function createSelectedLineAnnotation(kind) {
2967
3629
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
2968
3630
  });
2969
3631
  toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
2970
- await openChapterAnnotationsDialog();
3632
+ await loadChapterAnnotationCounts();
3633
+ await openChapterAnnotationsDialog(selection.safeStart);
2971
3634
  } catch (error) {
2972
3635
  toast(error.message, "error");
2973
3636
  }
@@ -2995,7 +3658,7 @@ function chapterAnnotationCard(annotation, { showSource = false } = {}) {
2995
3658
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
2996
3659
  <p data-annotation-content>${esc(annotation.note)}</p>
2997
3660
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
2998
- <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
3661
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canManageChapterAnnotation(annotation) ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
2999
3662
  </article>`;
3000
3663
  }
3001
3664
 
@@ -3055,9 +3718,12 @@ function renderChapterAnnotations() {
3055
3718
  const host = $("#chapter-annotations-list");
3056
3719
  host.innerHTML = chapterAnnotations.length
3057
3720
  ? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
3058
- : '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
3721
+ : `<p class="entity-history-empty">${chapterAnnotationsLineIndex === null ? "本章还没有评论。在正文行上点击右键即可添加。" : `第 ${chapterAnnotationsLineIndex + 1} 行还没有评论或待办。`}</p>`;
3059
3722
  bindChapterAnnotationCards(host, chapterAnnotations, {
3060
- refresh: loadChapterAnnotations,
3723
+ refresh: async () => {
3724
+ await loadChapterAnnotationCounts();
3725
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3726
+ },
3061
3727
  locate: (annotation) => {
3062
3728
  $("#chapter-annotations-dialog").close();
3063
3729
  paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
@@ -3067,19 +3733,43 @@ function renderChapterAnnotations() {
3067
3733
  });
3068
3734
  }
3069
3735
 
3070
- async function loadChapterAnnotations() {
3736
+ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
3737
+ if (!chapterId || !canReadModule("comments") && !canReadModule("todos")) {
3738
+ chapterAnnotationCounts = new Map();
3739
+ scheduleChapterLineNumbers();
3740
+ return;
3741
+ }
3742
+ const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
3743
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3744
+ chapterAnnotationCounts = new Map(
3745
+ (Array.isArray(counts) ? counts : [])
3746
+ .map((item) => [Number(item.line), Number(item.count)])
3747
+ .filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
3748
+ );
3749
+ scheduleChapterLineNumbers();
3750
+ }
3751
+
3752
+ async function loadChapterAnnotations(lineIndex = chapterAnnotationsLineIndex) {
3071
3753
  if (!state.chapter) return;
3072
- chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
3754
+ const chapterId = state.chapter.id;
3755
+ const line = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex + 1 : null;
3756
+ const query = line === null ? "" : `?line=${encodeURIComponent(line)}`;
3757
+ const annotations = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotations${query}`);
3758
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3759
+ chapterAnnotationsLineIndex = line === null ? null : line - 1;
3760
+ chapterAnnotations = annotations;
3073
3761
  renderChapterAnnotations();
3074
3762
  }
3075
3763
 
3076
- async function openChapterAnnotationsDialog() {
3764
+ async function openChapterAnnotationsDialog(lineIndex = null) {
3077
3765
  if (!state.chapter) return;
3078
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
3766
+ chapterAnnotationsLineIndex = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex : null;
3767
+ const lineLabel = chapterAnnotationsLineIndex === null ? "" : `第 ${chapterAnnotationsLineIndex + 1} 行的`;
3768
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》${lineLabel}正文评论与待办`;
3079
3769
  $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
3080
3770
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
3081
3771
  try {
3082
- await loadChapterAnnotations();
3772
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3083
3773
  } catch (error) {
3084
3774
  $("#chapter-annotations-dialog").close();
3085
3775
  toast(error.message, "error");
@@ -3097,8 +3787,8 @@ function showLineCitationMenu(event, lineIndex) {
3097
3787
  selectChapterLines(lineIndex, lineIndex);
3098
3788
  }
3099
3789
  const menu = $("#line-citation-menu");
3100
- $("#add-line-annotation").classList.toggle("hidden", !canEditProse());
3101
- $("#add-line-todo").classList.toggle("hidden", !canEditProse());
3790
+ $("#add-line-annotation").classList.toggle("hidden", !canWritePermissionModule(state.work, "comments"));
3791
+ $("#add-line-todo").classList.toggle("hidden", !canWritePermissionModule(state.work, "todos"));
3102
3792
  const { start, end } = chapterLineSelection;
3103
3793
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
3104
3794
  menu.classList.remove("hidden");
@@ -3111,7 +3801,7 @@ function clearChapterLineSelection() {
3111
3801
  chapterLineSelection = null;
3112
3802
  $("#chapter-line-numbers-inner")?.querySelectorAll(".is-line-selected").forEach((row) => {
3113
3803
  row.classList.remove("is-line-selected");
3114
- row.setAttribute("aria-pressed", "false");
3804
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", "false");
3115
3805
  });
3116
3806
  }
3117
3807
 
@@ -3361,6 +4051,7 @@ async function api(path, options = {}) {
3361
4051
  const payload = await response.json();
3362
4052
  if (path === "/api/health") {
3363
4053
  applyImageUploadLimits(payload.data?.uploadLimits);
4054
+ applyAiChatTabLimit(payload.data?.aiChatTabLimit);
3364
4055
  updateSystemHealth({
3365
4056
  status: payload.data?.status === "ok" ? "ready" : "degraded",
3366
4057
  version: payload.data?.version
@@ -3447,6 +4138,7 @@ function aiStreamInterruptionLabel(code) {
3447
4138
  if (code === "AI_STREAM_NETWORK_ERROR") return "网络中断";
3448
4139
  if (code === "AI_STREAM_UPSTREAM_CLOSED") return "流被关闭";
3449
4140
  if (code === "AI_STREAM_REQUEST_CANCELLED") return "请求已取消";
4141
+ if (code === "AI_REQUEST_CANCELLED") return "已终止";
3450
4142
  return "生成中断";
3451
4143
  }
3452
4144
 
@@ -3578,8 +4270,9 @@ function invalidateModuleRequestsAfterMutation(path, method) {
3578
4270
 
3579
4271
  function applyProductHealthMetadata(health) {
3580
4272
  const version = String(health?.version ?? "").trim();
4273
+ const versionLabel = String(health?.versionLabel ?? "").trim();
3581
4274
  document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
3582
- element.textContent = version ? `v${version}` : "v—";
4275
+ element.textContent = versionLabel || (version ? `v${version}` : "v—");
3583
4276
  });
3584
4277
  document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
3585
4278
  element.classList.toggle("hidden", health?.development !== true);
@@ -4286,7 +4979,6 @@ async function persistChapter({ automatic = false } = {}) {
4286
4979
  const currentDraft = chapterDraftSnapshot();
4287
4980
  if (sameChapterSnapshot(currentDraft, draft)) {
4288
4981
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
4289
- if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
4290
4982
  } else {
4291
4983
  scheduleChapterAutoSave(250);
4292
4984
  }
@@ -5233,7 +5925,7 @@ function renderMemberPermissionGrid(value) {
5233
5925
  <select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
5234
5926
  <option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
5235
5927
  <option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
5236
- <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
5928
+ <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>${["comments", "todos"].includes(item.id) ? "可添加" : "可编辑"}</option>
5237
5929
  </select>
5238
5930
  </label>`).join("");
5239
5931
  }
@@ -5359,12 +6051,29 @@ function syncGlobalReplaceScopeOptions() {
5359
6051
  const selected = options.find((option) => option.checked && !option.disabled) ?? options.find((option) => !option.disabled);
5360
6052
  options.forEach((option) => { option.checked = option === selected; });
5361
6053
  const scope = selected?.value ?? "";
6054
+ const volumeSelect = $("#replace-volume");
6055
+ const volumeEnabled = scope === "prose" || scope === "prose-and-settings";
6056
+ volumeSelect.disabled = !volumeEnabled || !canEditProse();
6057
+ $("#replace-volume-description").textContent = scope === "settings"
6058
+ ? "当前选择了设定库,分卷筛选不会生效。"
6059
+ : "选择分卷后,只替换该分卷的章节正文;设定库不按分卷筛选。";
5362
6060
  $("#replace-submit").disabled = !scope || !canGlobalReplaceScope(scope);
5363
6061
  $("#replace-permission-note").textContent = scope
5364
6062
  ? "替换完成后,命中的章节和设定会分别生成新的版本历史。"
5365
6063
  : "当前账户没有可写入的正文或设定库权限。";
5366
6064
  }
5367
6065
 
6066
+ function renderGlobalReplaceVolumeOptions() {
6067
+ const select = $("#replace-volume");
6068
+ const currentValue = select.value;
6069
+ const volumes = Array.isArray(state.work?.volumes) ? state.work.volumes : [];
6070
+ select.innerHTML = [
6071
+ '<option value="">全部分卷(整部作品正文)</option>',
6072
+ ...volumes.map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`)
6073
+ ].join("");
6074
+ select.value = volumes.some((volume) => String(volume.id) === currentValue) ? currentValue : "";
6075
+ }
6076
+
5368
6077
  function openGlobalReplaceDialog() {
5369
6078
  if (!state.work) {
5370
6079
  toast("请先打开一部作品", "error");
@@ -5376,6 +6085,7 @@ function openGlobalReplaceDialog() {
5376
6085
  }
5377
6086
  if ($("#search-dialog").open) $("#search-dialog").close();
5378
6087
  $("#replace-form").reset();
6088
+ renderGlobalReplaceVolumeOptions();
5379
6089
  syncGlobalReplaceScopeOptions();
5380
6090
  $("#replace-dialog").showModal();
5381
6091
  queueMicrotask(() => $("#replace-find").focus());
@@ -5463,6 +6173,7 @@ async function submitGlobalReplace(event) {
5463
6173
  const find = $("#replace-find").value;
5464
6174
  const replacement = $("#replace-with").value;
5465
6175
  const scope = $("#replace-form").querySelector('input[name="replaceScope"]:checked')?.value ?? "prose";
6176
+ const volumeId = scope === "settings" ? null : ($("#replace-volume").value || null);
5466
6177
  if (!find.trim()) {
5467
6178
  toast("请输入要查找的内容", "error");
5468
6179
  $("#replace-find").focus();
@@ -5485,8 +6196,14 @@ async function submitGlobalReplace(event) {
5485
6196
  return;
5486
6197
  }
5487
6198
  const scopeLabel = globalReplaceScopeLabels[scope] ?? "正文";
6199
+ const selectedVolume = volumeId
6200
+ ? state.work.volumes.find((volume) => String(volume.id) === volumeId)
6201
+ : null;
6202
+ const targetLabel = selectedVolume
6203
+ ? `${scopeLabel}(正文仅限“${selectedVolume.title}”分卷${scope === "prose-and-settings" ? ",设定库仍为全部设定库" : ""})`
6204
+ : scopeLabel;
5488
6205
  const replacementLabel = replacement ? `“${replacement}”` : "空内容";
5489
- const confirmed = await confirmToast(`将把《${state.work.title}》的${scopeLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
6206
+ const confirmed = await confirmToast(`将把《${state.work.title}》的${targetLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
5490
6207
  title: "确认全局替换",
5491
6208
  confirmLabel: "确认替换"
5492
6209
  });
@@ -5511,7 +6228,7 @@ async function submitGlobalReplace(event) {
5511
6228
  try {
5512
6229
  const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
5513
6230
  method: "POST",
5514
- body: { find, replacement, scope },
6231
+ body: { find, replacement, scope, volumeId },
5515
6232
  skipOptimisticVersion: true
5516
6233
  });
5517
6234
  $("#replace-dialog").close();
@@ -5806,6 +6523,7 @@ function resetWorkScopedUiCaches() {
5806
6523
  state.characters = [];
5807
6524
  state.settings = [];
5808
6525
  state.races = [];
6526
+ state.organizations = [];
5809
6527
  characterListPage = 1;
5810
6528
  draftTypeFilter = "all";
5811
6529
  draftBindingFilters = [];
@@ -5833,23 +6551,9 @@ function resetWorkScopedUiCaches() {
5833
6551
  volumeChapterRequests.clear();
5834
6552
  state.collapsedRaceIds.clear();
5835
6553
  lastSavedChapterSnapshot = null;
5836
- state.aiCitations = [];
5837
- state.aiReferences = [];
5838
- state.aiPromptSent = false;
5839
- state.aiConversationId = null;
5840
- state.aiConversationModelId = null;
5841
6554
  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 = "新对话";
6555
+ resetAiChatTabs();
5851
6556
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
5852
- resetAiContextMeter();
5853
6557
  renderAiConversationHistory();
5854
6558
  }
5855
6559
 
@@ -5994,7 +6698,7 @@ function renderTree() {
5994
6698
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
5995
6699
  <div class="volume-title">
5996
6700
  <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>` : ""}
6701
+ ${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
6702
  ${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
6703
  </div>
6000
6704
  <div class="volume-chapters">
@@ -6506,6 +7210,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6506
7210
  if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
6507
7211
  return false;
6508
7212
  }
7213
+ resetChapterSearchPanel();
6509
7214
  dismissDeleteToasts();
6510
7215
  if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6511
7216
  cancelChapterAutoSave();
@@ -6535,8 +7240,12 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6535
7240
  const normalizedContent = normalizeParagraphSpacing(state.chapter.content);
6536
7241
  const spacingChanged = normalizedContent !== state.chapter.content;
6537
7242
  $("#chapter-content").value = normalizedContent;
7243
+ chapterAnnotationCounts = new Map();
6538
7244
  clearChapterLineSelection();
6539
7245
  scheduleChapterLineNumbers();
7246
+ void loadChapterAnnotationCounts(state.chapter.id).catch((error) => {
7247
+ if (String(state.chapter?.id ?? "") === String(chapterId)) toast("正文评论数量读取失败,请稍后重试", "error");
7248
+ });
6540
7249
  dismissChapterInsightToast();
6541
7250
  updateChapterStats();
6542
7251
  if (!canEditProse()) setSaveState("正文只读");
@@ -7021,22 +7730,15 @@ function handleReadingKeyboard(event) {
7021
7730
  }
7022
7731
  }
7023
7732
 
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
7733
  async function saveChapter() {
7039
- return persistChapter({ automatic: false });
7734
+ const dismissSavingToast = persistentToast("正在保存中");
7735
+ try {
7736
+ const saved = await persistChapter({ automatic: false });
7737
+ if (saved) toast(`保存成功(正文 v${saved.versionNo})`);
7738
+ return saved;
7739
+ } finally {
7740
+ dismissSavingToast();
7741
+ }
7040
7742
  }
7041
7743
 
7042
7744
  function tidyChapterBlankLines() {
@@ -7067,7 +7769,7 @@ function showWelcome(hasWork = false) {
7067
7769
  }
7068
7770
 
7069
7771
  function markActiveModule(module) {
7070
- $("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module));
7772
+ $("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module || (button.id === "ai-assistant-entry" && aiConversationWorkspaceOpen)));
7071
7773
  }
7072
7774
 
7073
7775
  const moduleMeta = {
@@ -7079,7 +7781,7 @@ const moduleMeta = {
7079
7781
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
7080
7782
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
7081
7783
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
7082
- comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7784
+ comments: ["正文协作", "正文评论与待办", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7083
7785
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
7084
7786
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
7085
7787
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -7865,7 +8567,10 @@ async function renderSettings(page = moduleListPages.settings) {
7865
8567
  }
7866
8568
 
7867
8569
  async function renderCharacters(page = characterListPage) {
7868
- const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
8570
+ const hasCharacterFilters = characterFilters.raceIds.length > 0
8571
+ || characterFilters.organizationIds.length > 0
8572
+ || characterFilters.genderValues.length > 0
8573
+ || characterFilters.deathState !== "all";
7869
8574
  const pageSize = pageSizeFor("characters");
7870
8575
  const [characterSource, races, organizations] = await Promise.all([
7871
8576
  hasCharacterFilters
@@ -7893,6 +8598,7 @@ async function renderCharacters(page = characterListPage) {
7893
8598
  <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
8599
  <div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
7895
8600
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
8601
+ <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
7896
8602
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
7897
8603
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
7898
8604
  ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
@@ -7906,6 +8612,7 @@ async function renderCharacters(page = characterListPage) {
7906
8612
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
7907
8613
  const meta = [
7908
8614
  item.code ? `编号 ${item.code}` : "",
8615
+ `性别 ${characterGenderLabel(item.gender)}`,
7909
8616
  item.species ? (racePathLabel(item.race) || item.species) : "",
7910
8617
  ...(item.aliases ?? []).slice(0, 3),
7911
8618
  (item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
@@ -7927,16 +8634,25 @@ async function renderCharacters(page = characterListPage) {
7927
8634
  : "";
7928
8635
  const selectedRaceIds = new Set(characterFilters.raceIds);
7929
8636
  const selectedOrganizationIds = new Set(characterFilters.organizationIds);
8637
+ const selectedGenderValues = new Set(characterFilters.genderValues);
7930
8638
  const selectedRaceNames = races.filter((race) => selectedRaceIds.has(String(race.id))).map((race) => racePathLabel(race) || race.name);
7931
8639
  const selectedOrganizationNames = organizations.filter((organization) => selectedOrganizationIds.has(String(organization.id))).map((organization) => organization.name);
8640
+ const selectedGenderNames = CHARACTER_GENDER_OPTIONS
8641
+ .filter(([value]) => selectedGenderValues.has(value))
8642
+ .map(([, label]) => label);
8643
+ const selectedDeathStateLabel = CHARACTER_DEATH_STATE_OPTIONS.find(([value]) => value === characterFilters.deathState)?.[1] ?? "全部状态";
7932
8644
  const filterOptionList = (items, selectedIds, valueKey = "id") => items.map((item) => {
7933
8645
  const value = String(item[valueKey]);
7934
8646
  const label = valueKey === "id" ? (racePathLabel(item) || item.name) : item.name;
7935
8647
  return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
7936
8648
  }).join("");
7937
- const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
8649
+ 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("");
8650
+ 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("");
8651
+ const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar character-filter-toolbar-with-extra-filters${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
7938
8652
  <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
8653
  <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>
8654
+ <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>
8655
+ <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
8656
  <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
8657
  </section>`;
7942
8658
  mountCharacterFilterToggle();
@@ -7958,10 +8674,24 @@ async function renderCharacters(page = characterListPage) {
7958
8674
  characterListPage = 1;
7959
8675
  await renderCharacters(1);
7960
8676
  });
8677
+ $("#character-gender-filter").addEventListener("change", async () => {
8678
+ characterFiltersPanelOpen = true;
8679
+ characterFilters.genderValues = readSelectedValues("#character-gender-filter");
8680
+ characterListPage = 1;
8681
+ await renderCharacters(1);
8682
+ });
8683
+ $("#character-death-filter").addEventListener("change", async (event) => {
8684
+ characterFiltersPanelOpen = true;
8685
+ characterFilters.deathState = CHARACTER_DEATH_STATE_OPTIONS.some(([value]) => value === event.target.value) ? event.target.value : "all";
8686
+ characterListPage = 1;
8687
+ await renderCharacters(1);
8688
+ });
7961
8689
  $("#clear-character-filters")?.addEventListener("click", async () => {
7962
8690
  characterFiltersPanelOpen = true;
7963
8691
  characterFilters.raceIds = [];
7964
8692
  characterFilters.organizationIds = [];
8693
+ characterFilters.genderValues = [];
8694
+ characterFilters.deathState = "all";
7965
8695
  characterListPage = 1;
7966
8696
  await renderCharacters(1);
7967
8697
  });
@@ -8556,9 +9286,9 @@ async function renderRelationships(page = moduleListPages.relationships) {
8556
9286
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
8557
9287
  const graph = buildRelationshipGraph(state.characters, relationships);
8558
9288
  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) => `
9289
+ 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
9290
  <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>';
9291
+ <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
9292
  $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
8563
9293
  bindModulePagination("relationships", renderRelationships);
8564
9294
  const openGalaxy = () => {
@@ -8566,6 +9296,8 @@ async function renderRelationships(page = moduleListPages.relationships) {
8566
9296
  const galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, {
8567
9297
  workId: state.work.id,
8568
9298
  frameRate: state.uiSettings.galaxyFrameRate,
9299
+ motionMode: storedGalaxyMotionMode(),
9300
+ onMotionModeChange: persistGalaxyMotionMode,
8569
9301
  onClose: () => {
8570
9302
  if (state.galaxy === galaxy) state.galaxy = null;
8571
9303
  }
@@ -8619,8 +9351,8 @@ async function renderWorkChapterComments(page = moduleListPages.comments) {
8619
9351
  moduleListPages.comments = pageResult.page;
8620
9352
  mountModuleCount(total);
8621
9353
  $("#module-content").innerHTML = result.items.length
8622
- ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
8623
- : emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
9354
+ ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
9355
+ : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
8624
9356
  bindModulePagination("comments", renderWorkChapterComments);
8625
9357
  bindChapterAnnotationCards($("#module-content"), result.items, {
8626
9358
  refresh: () => renderWorkChapterComments(pageResult.page),
@@ -8742,7 +9474,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8742
9474
  moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
8743
9475
  canReadModule("ai-settings")
8744
9476
  ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
8745
- : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
9477
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunStabilityDelayMinutes: 2, autoRunPaused: false })
8746
9478
  ]);
8747
9479
  if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
8748
9480
  taskListPage = taskPage.page;
@@ -8779,6 +9511,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8779
9511
  <div class="task-auto-run-copy">
8780
9512
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
8781
9513
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
9514
+ <small>正文停止编辑后,等待稳定窗口结束才会创建章节理解任务。</small>
8782
9515
  <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
8783
9516
  </div>
8784
9517
  <div class="task-auto-run-actions">
@@ -8790,11 +9523,13 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8790
9523
  <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
9524
  <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
9525
  <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>
9526
+ <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
9527
  ${autoRunEditing
8794
9528
  ? `<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
9529
  : ""}
8796
9530
  </div>
8797
9531
  <p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
9532
+ <p id="task-auto-run-stability-help" class="task-auto-run-help">正文连续 ${esc(String(settings.autoRunStabilityDelayMinutes ?? 2))} 分钟没有编辑,才会创建章节理解任务;最低 1 分钟。</p>
8798
9533
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
8799
9534
  ${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
9535
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
@@ -8848,7 +9583,8 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
8848
9583
  autoRunEnabled: $("#task-auto-run-enabled").checked,
8849
9584
  autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
8850
9585
  autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
8851
- autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value)
9586
+ autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value),
9587
+ autoRunStabilityDelayMinutes: Number($("#task-auto-run-stability-delay").value)
8852
9588
  }
8853
9589
  });
8854
9590
  toast(updated.autoRunEnabled
@@ -9771,7 +10507,7 @@ function renderProviderCards(providers, models) {
9771
10507
  : "";
9772
10508
  return `
9773
10509
  <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>
10510
+ ${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
10511
  <div class="provider-models">${providerModels.map((model) => {
9776
10512
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
9777
10513
  const modelStatus = !model.enabled
@@ -9781,7 +10517,8 @@ function renderProviderCards(providers, models) {
9781
10517
  : "";
9782
10518
  const capability = model.multimodalEnabled ? " · 多模态" : "";
9783
10519
  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>`;
10520
+ const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
10521
+ 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
10522
  }).join("")}</div>
9786
10523
  <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
10524
  }).join("")}</div>`
@@ -10106,7 +10843,12 @@ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
10106
10843
  backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
10107
10844
  backgroundTaskCenterSnapshot.taskPage = taskResult.value;
10108
10845
  if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
10109
- for (const transition of transitionResult.transitions) {
10846
+ const announcementResult = filterBackgroundTaskTransitionsForAnnouncement(
10847
+ transitionResult.transitions,
10848
+ backgroundTaskCenterExpiredNoticeTimes
10849
+ );
10850
+ backgroundTaskCenterExpiredNoticeTimes = announcementResult.noticeTimes;
10851
+ for (const transition of announcementResult.transitions) {
10110
10852
  const notification = backgroundTaskTransitionMessage(transition);
10111
10853
  toast(notification.message, notification.type);
10112
10854
  }
@@ -10134,6 +10876,7 @@ function stopBackgroundTaskCenter() {
10134
10876
  backgroundTaskCenterWorkId = null;
10135
10877
  backgroundTaskCenterTasksInitialized = false;
10136
10878
  backgroundTaskCenterTaskSnapshots = new Map();
10879
+ backgroundTaskCenterExpiredNoticeTimes = new Map();
10137
10880
  backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
10138
10881
  const dialog = $("#background-task-dialog");
10139
10882
  if (dialog?.open) dialog.close();
@@ -10347,7 +11090,7 @@ async function renderBookAiSettings() {
10347
11090
  const host = $("#module-content");
10348
11091
  const workId = String(state.work.id);
10349
11092
  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"]);
11093
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
10351
11094
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
10352
11095
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
10353
11096
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
@@ -10380,7 +11123,7 @@ async function renderBookAiSettings() {
10380
11123
  );
10381
11124
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
10382
11125
  "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>`
11126
+ `<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
11127
  );
10385
11128
  if (!canEditModule("ai-settings")) {
10386
11129
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -10772,6 +11515,18 @@ function resetAiContextMeter() {
10772
11515
  setAiContextMeter(null);
10773
11516
  }
10774
11517
 
11518
+ function setAiChatTabContextUsage(tab, usage, allowShrink = true) {
11519
+ if (!tab) return;
11520
+ if (isActiveAiChatTab(tab)) {
11521
+ setAiContextMeter(usage, allowShrink);
11522
+ tab.contextUsage = latestAiContextUsage;
11523
+ return;
11524
+ }
11525
+ tab.contextUsage = allowShrink
11526
+ ? resolveAiContextUsage(tab.contextUsage, usage)
11527
+ : mergeAiContextUsage(tab.contextUsage, usage, false);
11528
+ }
11529
+
10775
11530
  function setAiContextDistributionVisible(visible) {
10776
11531
  const meter = $("#ai-context-meter");
10777
11532
  const popover = $("#ai-context-popover");
@@ -10790,6 +11545,14 @@ function hideAiContextWarning() {
10790
11545
  $("#ai-context-warning").classList.add("hidden");
10791
11546
  }
10792
11547
 
11548
+ function setAiChatTabContextWarning(tab, visible) {
11549
+ if (!tab) return;
11550
+ tab.contextWarning = visible;
11551
+ if (!isActiveAiChatTab(tab)) return;
11552
+ if (visible) showAiContextWarning();
11553
+ else hideAiContextWarning();
11554
+ }
11555
+
10793
11556
  function setAiContextWarningActionsDisabled(disabled) {
10794
11557
  for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
10795
11558
  }
@@ -10798,13 +11561,17 @@ async function loadAiReferences() {
10798
11561
  const workId = state.work?.id;
10799
11562
  if (!workId) return;
10800
11563
  const generation = workScopedUiGeneration;
10801
- const [characters, settings] = await Promise.all([
11564
+ const [characters, settings, races, organizations] = await Promise.all([
10802
11565
  canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
10803
- canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([])
11566
+ canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([]),
11567
+ canReadModule("races") ? api(`/api/works/${workId}/races`) : Promise.resolve([]),
11568
+ canReadModule("organizations") ? apiAllPages(`/api/works/${workId}/organizations`) : Promise.resolve([])
10804
11569
  ]);
10805
11570
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
10806
11571
  state.characters = characters;
10807
11572
  state.settings = settings;
11573
+ state.races = races;
11574
+ state.organizations = organizations;
10808
11575
  renderAiRoleplayCharacterSelect();
10809
11576
  loadedAiReferencesWorkId = workId;
10810
11577
  }
@@ -11640,8 +12407,11 @@ function renderCharacterEditorRelationships() {
11640
12407
  const category = relationshipCategoryLabel(relationship.category);
11641
12408
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
11642
12409
  const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
12410
+ const actions = !entityEditorReadOnly && canEditModule("relationships")
12411
+ ? `<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>`
12412
+ : "";
11643
12413
  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>
12414
+ <div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${actions}</div>
11645
12415
  <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
12416
  </article>`;
11647
12417
  }).join("");
@@ -11650,6 +12420,14 @@ function renderCharacterEditorRelationships() {
11650
12420
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
11651
12421
  if (relationship) void openRelationshipDialog(relationship, { characterId });
11652
12422
  }));
12423
+ host.querySelectorAll("[data-character-relationship-delete]").forEach((button) => button.addEventListener("click", () => {
12424
+ const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipDelete);
12425
+ if (!relationship) return;
12426
+ button.disabled = true;
12427
+ void deleteRelationshipRecord(relationship, characterId).finally(() => {
12428
+ if (button.isConnected) button.disabled = false;
12429
+ });
12430
+ }));
11653
12431
  host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
11654
12432
  }
11655
12433
 
@@ -11668,7 +12446,7 @@ async function loadCharacterEditorRelationships(characterId, { refresh = false }
11668
12446
  ]);
11669
12447
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
11670
12448
  state.characters = characters;
11671
- characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
12449
+ characterEditorRelationships = sortCharacterRelationships(relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId), characterId);
11672
12450
  characterEditorRelationshipsLoaded = true;
11673
12451
  loaded = true;
11674
12452
  } catch (error) {
@@ -11692,6 +12470,21 @@ async function refreshRelationshipSurfaces(characterId = null) {
11692
12470
  await Promise.all(tasks);
11693
12471
  }
11694
12472
 
12473
+ async function deleteRelationshipRecord(item, characterId = null) {
12474
+ const relationshipName = `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}`;
12475
+ if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return false;
12476
+ if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return false;
12477
+ try {
12478
+ await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
12479
+ await Promise.all([refreshRelationshipSurfaces(characterId), loadAiReferences()]);
12480
+ deleteToast(`已删除人物关系“${relationshipName}”`);
12481
+ return true;
12482
+ } catch (error) {
12483
+ toast(error.message, "error");
12484
+ return false;
12485
+ }
12486
+ }
12487
+
11695
12488
  const characterSectionTypeLabels = {
11696
12489
  overview: "基本档案",
11697
12490
  appearance: "外貌与生理",
@@ -12426,6 +13219,7 @@ function renderCharacterEditorFields(item) {
12426
13219
  $("#character-editor-fields").innerHTML = [
12427
13220
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
12428
13221
  field("name", "标准名", "text", item?.name) +
13222
+ field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
12429
13223
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
12430
13224
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
12431
13225
  (!canReadModule("races")
@@ -12479,6 +13273,7 @@ function collectCharacterBody(form) {
12479
13273
  delete profile.sections;
12480
13274
  const body = {
12481
13275
  name: String(form.get("name") ?? "").trim(),
13276
+ gender: String(form.get("gender") ?? "unknown"),
12482
13277
  isDead: form.has("isDead"),
12483
13278
  code: String(form.get("code") ?? "").trim(),
12484
13279
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
@@ -13083,7 +13878,6 @@ async function openRelationshipDialog(item, options = {}) {
13083
13878
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
13084
13879
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
13085
13880
  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
13881
  const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
13088
13882
  <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
13089
13883
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
@@ -13101,16 +13895,7 @@ async function openRelationshipDialog(item, options = {}) {
13101
13895
  dialog.showModal();
13102
13896
  };
13103
13897
  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
- }
13898
+ if (!await deleteRelationshipRecord(item, options.characterId ?? null)) return reopenDialog();
13114
13899
  });
13115
13900
  }
13116
13901
 
@@ -13161,17 +13946,23 @@ async function openTaskDialog() {
13161
13946
  group.push(item);
13162
13947
  groups.set(groupKey, { id: groupId, title: groupTitle, options: group });
13163
13948
  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)}">
13949
+ }, new Map())].map(([, group]) => {
13950
+ const groupOptionsId = group.id ? `task-scope-volume-options-${group.id}` : "task-scope-volume-options-unassigned";
13951
+ return `<section class="task-scope-volume-group" data-task-scope-group data-task-scope-volume-id="${esc(group.id)}">
13165
13952
  <header>
13166
13953
  ${group.id ? `<label class="task-scope-volume-option">
13167
13954
  <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
13955
  <span class="task-scope-checkbox" aria-hidden="true"></span>
13169
13956
  <span class="task-scope-volume-copy"><strong>${esc(group.title)}</strong><small>勾选以全选本卷章节</small></span>
13170
13957
  </label>` : `<strong>${esc(group.title)}</strong>`}
13171
- <span>${group.options.length} 章</span>
13958
+ <span class="task-scope-volume-count">${group.options.length} 章</span>
13959
+ ${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
13960
  </header>
13173
- ${group.options.map(renderOption).join("")}
13174
- </section>`).join("");
13961
+ <div id="${esc(groupOptionsId)}" class="task-scope-volume-options">
13962
+ ${group.options.map(renderOption).join("")}
13963
+ </div>
13964
+ </section>`;
13965
+ }).join("");
13175
13966
  return `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13176
13967
  <div class="task-scope-field-heading"><span id="task-${kind}-label">${esc(label)}(可多选)</span><small>从左侧选择,右侧确认已选内容</small></div>
13177
13968
  <div class="task-scope-picker">
@@ -13262,7 +14053,8 @@ async function openTaskDialog() {
13262
14053
  const buildRelationshipScope = (form) => {
13263
14054
  const taskType = String(form.get("taskType"));
13264
14055
  const scopeType = String(form.get("scopeType"));
13265
- const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
14056
+ const includeAllSettings = taskType === "relationship-analysis"
14057
+ && ["chapter-with-settings", "book-with-settings"].includes(scopeType);
13266
14058
  const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
13267
14059
  const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
13268
14060
  const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
@@ -13273,11 +14065,12 @@ async function openTaskDialog() {
13273
14065
  const chapterScope = {
13274
14066
  type: "chapter",
13275
14067
  ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13276
- ...(chapterIds.length ? { chapterIds } : {})
14068
+ ...(chapterIds.length ? { chapterIds } : {}),
14069
+ ...(scopeType === "chapter-with-settings" ? { includeAllSettings: true } : {})
13277
14070
  };
13278
14071
  const scope = settingsOnly
13279
14072
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13280
- : scopeType === "book" || includeAllSettings
14073
+ : scopeType === "book" || scopeType === "book-with-settings"
13281
14074
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13282
14075
  : { ...chapterScope, ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13283
14076
  return scope;
@@ -13318,7 +14111,7 @@ async function openTaskDialog() {
13318
14111
  }
13319
14112
  taskListPage = 1;
13320
14113
  toast("分析任务已创建,已进入任务队列");
13321
- void refreshAnalysisTaskViewsAfterCreate(workId);
14114
+ await refreshAnalysisTaskViewsAfterCreate(workId);
13322
14115
  }, "AI 分析", {
13323
14116
  large: true,
13324
14117
  submitLabel: "创建任务",
@@ -13347,6 +14140,9 @@ async function openTaskDialog() {
13347
14140
  const scopeVolumeInputs = [
13348
14141
  ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
13349
14142
  ];
14143
+ const scopeVolumeCollapseButtons = [
14144
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-toggle]")
14145
+ ];
13350
14146
  const scopeSummaries = {
13351
14147
  chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
13352
14148
  };
@@ -13393,6 +14189,9 @@ async function openTaskDialog() {
13393
14189
  const allSettingsOption = document.createElement("option");
13394
14190
  allSettingsOption.value = "book-with-settings";
13395
14191
  allSettingsOption.textContent = "全书 + 设定集";
14192
+ const chapterSettingsOption = document.createElement("option");
14193
+ chapterSettingsOption.value = "chapter-with-settings";
14194
+ chapterSettingsOption.textContent = "指定章节 + 设定集";
13396
14195
  const settingsOnlyOption = document.createElement("option");
13397
14196
  settingsOnlyOption.value = "settings";
13398
14197
  settingsOnlyOption.textContent = "仅设定集";
@@ -13405,6 +14204,13 @@ async function openTaskDialog() {
13405
14204
  scopeSearches[kind].focus({ preventScroll: true });
13406
14205
  }
13407
14206
  };
14207
+ const setTaskScopeVolumeCollapsed = (button, collapsed) => {
14208
+ const group = button.closest("[data-task-scope-group]");
14209
+ if (!group) return;
14210
+ group.classList.toggle("is-collapsed", collapsed);
14211
+ button.setAttribute("aria-expanded", String(!collapsed));
14212
+ button.setAttribute("aria-label", `${collapsed ? "展开" : "折叠"}${button.dataset.taskScopeVolumeTitle ?? ""}章节`);
14213
+ };
13408
14214
  const syncTaskScopeVolumeInputs = () => {
13409
14215
  for (const volumeInput of scopeVolumeInputs) {
13410
14216
  const group = volumeInput.closest("[data-task-scope-group]");
@@ -13442,7 +14248,12 @@ async function openTaskDialog() {
13442
14248
  if (visible) visibleCount += 1;
13443
14249
  }
13444
14250
  scopeFields[kind].querySelectorAll("[data-task-scope-group]").forEach((group) => {
13445
- group.classList.toggle("hidden", !group.querySelector(".task-scope-option:not(.hidden)"));
14251
+ const hasVisibleOptions = Boolean(group.querySelector(".task-scope-option:not(.hidden)"));
14252
+ group.classList.toggle("hidden", !hasVisibleOptions);
14253
+ if (query && hasVisibleOptions) {
14254
+ const collapseButton = group.querySelector("[data-task-scope-volume-toggle]");
14255
+ if (collapseButton) setTaskScopeVolumeCollapsed(collapseButton, false);
14256
+ }
13446
14257
  });
13447
14258
  scopeAvailableCounts[kind].textContent = query
13448
14259
  ? `${visibleCount} / ${scopeInputs[kind].length} 章`
@@ -13451,7 +14262,7 @@ async function openTaskDialog() {
13451
14262
  };
13452
14263
  const syncChapterField = () => {
13453
14264
  const kind = "chapter";
13454
- const enabled = scopeTypeSelect.value === "chapter";
14265
+ const enabled = ["chapter", "chapter-with-settings"].includes(scopeTypeSelect.value);
13455
14266
  scopeFields[kind].classList.toggle("is-disabled", !enabled);
13456
14267
  scopeFields[kind].classList.toggle("hidden", !enabled);
13457
14268
  scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
@@ -13525,8 +14336,14 @@ async function openTaskDialog() {
13525
14336
  };
13526
14337
  const syncRelationshipOptions = () => {
13527
14338
  const enabled = taskTypeSelect.value === "relationship-analysis";
14339
+ const bookScopeOption = scopeTypeSelect.querySelector('option[value="book"]');
14340
+ if (enabled && !chapterSettingsOption.isConnected) bookScopeOption.before(chapterSettingsOption);
13528
14341
  if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
13529
14342
  if (enabled && !settingsOnlyOption.isConnected) scopeTypeSelect.append(settingsOnlyOption);
14343
+ if (!enabled && chapterSettingsOption.isConnected) {
14344
+ if (scopeTypeSelect.value === chapterSettingsOption.value) scopeTypeSelect.value = "chapter";
14345
+ chapterSettingsOption.remove();
14346
+ }
13530
14347
  if (!enabled && allSettingsOption.isConnected) {
13531
14348
  if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
13532
14349
  allSettingsOption.remove();
@@ -13644,6 +14461,11 @@ async function openTaskDialog() {
13644
14461
  clearContextWarning();
13645
14462
  invalidateRelationshipSourcePreview();
13646
14463
  });
14464
+ for (const collapseButton of scopeVolumeCollapseButtons) collapseButton.addEventListener("click", (event) => {
14465
+ event.preventDefault();
14466
+ event.stopPropagation();
14467
+ setTaskScopeVolumeCollapsed(collapseButton, collapseButton.getAttribute("aria-expanded") === "true");
14468
+ });
13647
14469
  $("#dynamic-form").onclick = (event) => {
13648
14470
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13649
14471
  for (const kind of ["chapter"]) {
@@ -13711,12 +14533,15 @@ function openProviderDialog(item) {
13711
14533
  return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
13712
14534
  };
13713
14535
  const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
14536
+ const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens" && protocol !== "anthropic-messages";
14537
+ 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
14538
  openDialog(
13715
14539
  item ? "编辑 AI 供应商" : "新建 AI 供应商",
13716
14540
  field("name", "显示名称", "text", item?.name)
13717
14541
  + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
13718
14542
  + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
13719
14543
  + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
14544
+ + maxTokensParameterField
13720
14545
  + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
13721
14546
  + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
13722
14547
  + field("note", "用途备注", "textarea", item?.note)
@@ -13726,6 +14551,7 @@ function openProviderDialog(item) {
13726
14551
  name: form.get("name"),
13727
14552
  protocol: form.get("protocol"),
13728
14553
  baseUrl: form.get("baseUrl"),
14554
+ maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
13729
14555
  concurrencyLimit: Number(form.get("concurrencyLimit")),
13730
14556
  rpmLimit: Number(form.get("rpmLimit")),
13731
14557
  note: form.get("note"),
@@ -13742,12 +14568,23 @@ function openProviderDialog(item) {
13742
14568
  const protocolSelect = $("#dialog-fields select[name='protocol']");
13743
14569
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
13744
14570
  const credentialHost = $("#dialog-fields [data-provider-credential-field]");
14571
+ const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
14572
+ const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
14573
+ const syncMaxTokensParameter = () => {
14574
+ const anthropic = protocolSelect.value === "anthropic-messages";
14575
+ maxTokensParameterFieldElement.hidden = anthropic;
14576
+ maxTokensParameterFieldElement.classList.toggle("hidden", anthropic);
14577
+ maxTokensParameterInput.disabled = anthropic;
14578
+ if (anthropic) maxTokensParameterInput.checked = false;
14579
+ };
13745
14580
  const syncProviderCredentialField = () => {
13746
14581
  const nextProtocol = protocolSelect.value;
13747
14582
  credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
13748
14583
  if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
14584
+ syncMaxTokensParameter();
13749
14585
  };
13750
14586
  protocolSelect.addEventListener("change", syncProviderCredentialField);
14587
+ syncMaxTokensParameter();
13751
14588
  }
13752
14589
 
13753
14590
  function openModelDialog(providerId, item = null, provider = null) {
@@ -13757,16 +14594,16 @@ function openModelDialog(providerId, item = null, provider = null) {
13757
14594
  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
14595
  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
14596
  const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
13760
- ? "使用当前已保存的模型标识符和供应商凭据,并发送一张测试图片验证图片请求。"
13761
- : "使用当前已保存的模型标识符和供应商凭据发起最小请求。";
14597
+ ? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
14598
+ : "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
13762
14599
  const connectionTest = item && item.providerStatus === "enabled"
13763
14600
  ? `<section class="model-connection-test">
13764
14601
  <div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
13765
14602
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
13766
14603
  </section>`
13767
14604
  : "";
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);
14605
+ 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) => {
14606
+ 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
14607
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
13771
14608
  await renderPlatformAiConfig();
13772
14609
  await loadModels();
@@ -13777,6 +14614,8 @@ function openModelDialog(providerId, item = null, provider = null) {
13777
14614
  const contextWindowHint = $("#model-context-window-hint");
13778
14615
  const temperatureInput = $("#dialog-fields input[name='temperature']");
13779
14616
  const temperatureHint = $("#model-temperature-hint");
14617
+ const thinkingEnabledInput = $("#dialog-fields input[name='thinkingEnabled']");
14618
+ const thinkingEffortSelect = $("#dialog-fields select[name='thinkingEffort']");
13780
14619
  const multimodalInput = $("#model-multimodal-enabled");
13781
14620
  const imageDefaultField = $("#model-image-tool-default-field");
13782
14621
  const imageDefaultInput = $("#model-image-tool-default");
@@ -13796,11 +14635,15 @@ function openModelDialog(providerId, item = null, provider = null) {
13796
14635
  const isKimi = isKimiModelId(modelIdInput.value);
13797
14636
  temperatureHint.hidden = !isKimi;
13798
14637
  };
14638
+ const syncThinkingEffort = () => {
14639
+ thinkingEffortSelect.disabled = !thinkingEnabledInput.checked;
14640
+ };
13799
14641
  modelIdInput.addEventListener("input", () => {
13800
14642
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
13801
14643
  syncKimiTemperature();
13802
14644
  });
13803
14645
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
14646
+ thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
13804
14647
  multimodalInput?.addEventListener("change", syncMultimodalFields);
13805
14648
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
13806
14649
  const button = event.currentTarget;
@@ -13823,6 +14666,7 @@ function openModelDialog(providerId, item = null, provider = null) {
13823
14666
  });
13824
14667
  syncModelContextWindowGuidance();
13825
14668
  syncKimiTemperature();
14669
+ syncThinkingEffort();
13826
14670
  syncMultimodalFields();
13827
14671
  }
13828
14672
 
@@ -13832,6 +14676,9 @@ async function sendAi() {
13832
14676
 
13833
14677
  async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13834
14678
  if (!state.work) return toast("请先选择作品", "error");
14679
+ const tab = activeAiChatTab();
14680
+ if (!tab) return toast("Agent 对话页签尚未就绪", "error");
14681
+ if (aiRequestManager.hasActive(tab.id)) return;
13835
14682
  const composerSnapshot = captureAiPromptComposer();
13836
14683
  const instruction = composerSnapshot.text.trim();
13837
14684
  if (!instruction) return toast("请输入指令", "error");
@@ -13841,31 +14688,38 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13841
14688
  const { taskType, scope, selection } = requestScope;
13842
14689
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
13843
14690
  const citations = composerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
14691
+ const selectedTaskType = $("#ai-task").value;
14692
+ persistActiveAiChatTab();
14693
+ tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
13844
14694
  const requestHolder = {
13845
14695
  snapshot: aiRequestManager.begin({
14696
+ tabId: tab.id,
13846
14697
  workId: state.work.id,
13847
14698
  conversationId: state.aiConversationId
13848
14699
  })
13849
14700
  };
14701
+ setAiChatTabStatus(tab, "streaming");
13850
14702
  syncAiRequestControls();
13851
14703
  try {
13852
14704
  try {
13853
14705
  await ensureAiModelsLoaded();
14706
+ await ensureAiReferencesLoaded();
13854
14707
  } catch (error) {
13855
14708
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13856
- setAiAssistantStatus("error");
14709
+ setAiChatTabStatus(tab, "error");
13857
14710
  return toast(`创作助手加载失败:${error.message}`, "error");
13858
14711
  }
13859
14712
  assertAiRequestCurrent(requestHolder.snapshot);
13860
- const modelId = $("#ai-model").value;
14713
+ const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
13861
14714
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
14715
+ tab.selectedModelId = modelId;
13862
14716
  try {
13863
- await prepareAiRequestConversation(requestHolder, $("#ai-task").value, requestScope.conversationScope);
14717
+ await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
13864
14718
  } catch (error) {
13865
14719
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13866
14720
  return toast(`对话配置锁定失败:${error.message}`, "error");
13867
14721
  }
13868
- setAiAssistantStatus("ready");
14722
+ setAiChatTabStatus(tab, "streaming");
13869
14723
  if (taskType !== "chat") {
13870
14724
  try {
13871
14725
  const request = assertAiRequestCurrent(requestHolder.snapshot);
@@ -13880,16 +14734,22 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13880
14734
  assertAiRequestCurrent(request);
13881
14735
  updateAiConversationSummaryFromMessage(persistedUserMessage);
13882
14736
  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();
14737
+ tab.modelId = modelId;
14738
+ tab.selectedModelId = modelId;
14739
+ tab.promptSent = true;
14740
+ clearAiChatTabComposer(tab);
14741
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
14742
+ if (isActiveAiChatTab(tab)) {
14743
+ state.aiConversationModelId = modelId;
14744
+ state.aiPromptSent = true;
14745
+ syncAiTaskOptions();
14746
+ renderAiRoleplayCharacterSelect();
14747
+ renderAiQuickActions();
14748
+ clearAiPromptComposer();
14749
+ }
13890
14750
  } catch (error) {
13891
14751
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13892
- setAiAssistantStatus("error");
14752
+ setAiChatTabStatus(tab, "error");
13893
14753
  return toast(`对话记录创建失败:${error.message}`, "error");
13894
14754
  }
13895
14755
  }
@@ -13925,8 +14785,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13925
14785
  const suggestionFailed = suggestion.guard?.status === "failed"
13926
14786
  || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
13927
14787
  || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
13928
- if (suggestionFailed) setAiAssistantStatus("error");
13929
- setAiContextMeter(suggestion.contextUsage, false);
14788
+ if (suggestionFailed) setAiChatTabStatus(tab, "error");
14789
+ tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
14790
+ if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
13930
14791
  assistantContent = suggestion.content;
13931
14792
  assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
13932
14793
  }
@@ -13955,36 +14816,50 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13955
14816
  if (assistantMessage) {
13956
14817
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
13957
14818
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
13958
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
14819
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
13959
14820
  }
13960
14821
  } catch (error) {
13961
14822
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13962
- setAiAssistantStatus("error");
13963
- if (suggestion) appendSuggestion(suggestion);
14823
+ setAiChatTabStatus(tab, "error");
14824
+ if (suggestion) appendSuggestion(suggestion, null, null, { tab });
13964
14825
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
13965
14826
  }
13966
14827
  } catch (error) {
13967
14828
  const request = requestHolder.snapshot;
13968
14829
  if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
13969
- await persistAiRequestInterruption(request, error?.streamInterruption);
14830
+ const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
14831
+ const userStoppedCurrentReply = request.signal.reason instanceof Error
14832
+ && request.signal.reason.message === "用户已终止当前回复";
14833
+ if (userStoppedCurrentReply && persistedInterruption && isActiveAiChatTab(tab)) {
14834
+ appendMessage(
14835
+ "assistant",
14836
+ persistedInterruption.content,
14837
+ [],
14838
+ persistedInterruption.createdAt,
14839
+ persistedInterruption.metadata,
14840
+ persistedInterruption.id,
14841
+ { tab }
14842
+ );
14843
+ }
13970
14844
  return;
13971
14845
  }
13972
14846
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
13973
- restoreAiPromptComposer(composerSnapshot);
14847
+ setAiChatTabComposerSnapshot(tab, composerSnapshot);
14848
+ if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
13974
14849
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13975
- $("#ai-prompt").focus();
14850
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13976
14851
  return;
13977
14852
  }
13978
14853
  if (error?.code === "AI_IDEMPOTENT_REQUEST_IN_PROGRESS") {
13979
14854
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13980
- $("#ai-prompt").focus();
14855
+ if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
13981
14856
  return;
13982
14857
  }
13983
14858
  if (["IDEMPOTENCY_KEY_REUSED", "AI_IDEMPOTENT_REQUEST_TERMINAL"].includes(error?.code)) {
13984
14859
  toast(error.message, "error");
13985
14860
  return;
13986
14861
  }
13987
- setAiAssistantStatus("error");
14862
+ setAiChatTabStatus(tab, "error");
13988
14863
  const interruption = error?.streamInterruption;
13989
14864
  if (interruption?.content) {
13990
14865
  try {
@@ -14031,15 +14906,21 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14031
14906
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
14032
14907
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
14033
14908
  if (aiRequestTargetsCurrentState(request)) {
14034
- appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
14909
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id, { tab });
14035
14910
  }
14036
14911
  } finally {
14037
14912
  aiRequestManager.finish(requestHolder.snapshot);
14038
- syncAiRequestControls();
14913
+ if (!isActiveAiChatTab(tab) && aiChatTabManager.get(tab.id)) tab.unread = true;
14914
+ if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
14915
+ else renderAiChatTabs();
14916
+ if (isActiveAiChatTab(tab)) syncAiRequestControls();
14039
14917
  }
14040
14918
  }
14041
14919
 
14042
14920
  async function streamChat(requestHolder, body, idempotencyKey) {
14921
+ const tab = aiChatTabForRequest(requestHolder.snapshot);
14922
+ if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
14923
+ const feed = tab.feed;
14043
14924
  const message = document.createElement("div");
14044
14925
  message.className = "assistant-message is-streaming";
14045
14926
  message.dataset.testid = "ai-stream-message";
@@ -14051,10 +14932,10 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14051
14932
  const mountAssistantMessage = () => {
14052
14933
  if (messageMounted) return true;
14053
14934
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
14054
- attachMessageHeading(message, aiAssistantLabel("正在生成"));
14055
- $("#ai-feed").append(message);
14935
+ attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
14936
+ feed.append(message);
14056
14937
  messageMounted = true;
14057
- scrollAiFeedToBottom();
14938
+ scrollAiFeedToBottom(feed);
14058
14939
  return true;
14059
14940
  };
14060
14941
  const typewriter = createStreamTypewriter({
@@ -14066,10 +14947,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14066
14947
  meta.textContent = progress.pendingCharacters > 0
14067
14948
  ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
14068
14949
  : `正在生成 · ${progress.visibleCharacters} 字`;
14069
- scrollAiFeedToBottom();
14950
+ scrollAiFeedToBottom(feed);
14070
14951
  }
14071
14952
  });
14072
14953
  let streamedText = "";
14954
+ let streamedPendingText = "";
14073
14955
  let generatedMetadata = {};
14074
14956
  let toolCalls = [];
14075
14957
  let processSteps = [];
@@ -14080,7 +14962,6 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14080
14962
  let contextAction = "ready";
14081
14963
  let warningOnly = false;
14082
14964
  let streamContextCompacted = false;
14083
- let finalAnswerStarted = false;
14084
14965
  const processStartedAt = Date.now();
14085
14966
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
14086
14967
  const processStepTypewriters = new Map();
@@ -14098,8 +14979,8 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14098
14979
  onRender: (text) => {
14099
14980
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
14100
14981
  processStepVisibleContents.set(step, text);
14101
- renderStreamingProcessSteps(finalAnswerStarted);
14102
- scrollAiFeedToBottom();
14982
+ renderStreamingProcessSteps(false);
14983
+ scrollAiFeedToBottom(feed);
14103
14984
  }
14104
14985
  });
14105
14986
  processStepTypewriters.set(step, typewriter);
@@ -14127,7 +15008,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14127
15008
  assertAiRequestCurrent(requestHolder.snapshot);
14128
15009
  if (eventName === "context") {
14129
15010
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
14130
- if (!state.aiPromptSent) setAiContextMeter(payload.usage);
15011
+ if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
14131
15012
  if (payload.conversation?.id) {
14132
15013
  if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
14133
15014
  throw new Error("流式上下文返回了其他对话");
@@ -14135,11 +15016,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14135
15016
  upsertAiConversationSummary(payload.conversation);
14136
15017
  applyAiConversationTitle(payload.conversation.title, requestHolder.snapshot.conversationId);
14137
15018
  }
14138
- if (contextAction === "warn") showAiContextWarning(payload.usage);
15019
+ if (contextAction === "warn") setAiChatTabContextWarning(tab, true);
14139
15020
  else {
14140
- hideAiContextWarning();
15021
+ setAiChatTabContextWarning(tab, false);
14141
15022
  if (contextAction === "compacted") {
14142
- renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
15023
+ renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount, feed, payload.conversation);
14143
15024
  toast("已自动整理较早对话为长期记忆并继续发送");
14144
15025
  }
14145
15026
  }
@@ -14154,29 +15035,38 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14154
15035
  const lockedModelId = typeof persistedUserMessage.metadata?.modelId === "string"
14155
15036
  ? persistedUserMessage.metadata.modelId
14156
15037
  : 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();
15038
+ tab.modelId = lockedModelId;
15039
+ tab.selectedModelId = lockedModelId;
15040
+ tab.promptSent = true;
15041
+ clearAiChatTabComposer(tab);
15042
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
15043
+ if (isActiveAiChatTab(tab)) {
15044
+ state.aiConversationModelId = lockedModelId;
15045
+ state.aiPromptSent = true;
15046
+ syncAiTaskOptions();
15047
+ renderAiRoleplayCharacterSelect();
15048
+ renderAiQuickActions();
15049
+ clearAiPromptComposer();
15050
+ }
14164
15051
  mountAssistantMessage();
14165
15052
  }
14166
15053
  } else if (eventName === "delta") {
14167
15054
  mountAssistantMessage();
14168
- const firstFinalDelta = streamedText.length === 0;
14169
15055
  const delta = typeof payload.delta === "string" ? payload.delta : "";
14170
15056
  streamedText += delta;
14171
- if (streamedText.length > 0) finalAnswerStarted = true;
15057
+ streamedPendingText += delta;
14172
15058
  typewriter.append(delta);
14173
- if (firstFinalDelta && processSteps.length) renderStreamingProcessSteps(true, elapsedProcessTime());
14174
15059
  meta.textContent = "正在生成回复……";
14175
15060
  } else if (eventName === "process_step") {
14176
15061
  mountAssistantMessage();
14177
15062
  const step = { ...payload };
14178
15063
  const append = step.append === true;
14179
15064
  delete step.append;
15065
+ if (step.type === "intermediate" && typeof step.content === "string" && streamedPendingText.endsWith(step.content)) {
15066
+ streamedPendingText = streamedPendingText.slice(0, -step.content.length);
15067
+ streamedText = streamedText.slice(0, -step.content.length);
15068
+ typewriter.replace(streamedText);
15069
+ }
14180
15070
  const existing = append ? processSteps.find((item) => item.id === step.id && item.type === step.type) : null;
14181
15071
  if (existing && typeof step.content === "string") existing.content += step.content;
14182
15072
  else processSteps.push(step);
@@ -14184,32 +15074,32 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14184
15074
  if (typeof step.content === "string" && step.content.length > 0 && step.type === "thinking") {
14185
15075
  processStepTypewriter(targetStep).append(step.content);
14186
15076
  }
14187
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15077
+ renderStreamingProcessSteps(false, elapsedProcessTime());
14188
15078
  meta.textContent = step.type === "thinking"
14189
15079
  ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
14190
15080
  : step.type === "context_compaction"
14191
15081
  ? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
14192
15082
  : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
14193
- scrollAiFeedToBottom();
15083
+ scrollAiFeedToBottom(feed);
14194
15084
  } else if (eventName === "tool_call") {
14195
15085
  mountAssistantMessage();
14196
15086
  const toolCall = { ...payload };
14197
15087
  const round = toolCall.round;
14198
15088
  delete toolCall.round;
14199
- if (toolCall.status === "failed") setAiAssistantStatus("error");
15089
+ if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
14200
15090
  toolCalls.push(toolCall);
14201
15091
  processSteps.push(aiToolProcessStep(toolCall, round));
14202
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15092
+ renderStreamingProcessSteps(false, elapsedProcessTime());
14203
15093
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
14204
- scrollAiFeedToBottom();
15094
+ scrollAiFeedToBottom(feed);
14205
15095
  } else if (eventName === "context_compacted") {
14206
15096
  streamContextCompacted = true;
14207
- setAiContextMeter(payload.contextUsage);
15097
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14208
15098
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
14209
15099
  } else if (eventName === "complete") {
14210
15100
  if (payload.warningOnly === true) {
14211
15101
  warningOnly = true;
14212
- setAiContextMeter(payload.contextUsage);
15102
+ setAiChatTabContextUsage(tab, payload.contextUsage);
14213
15103
  return;
14214
15104
  }
14215
15105
  mountAssistantMessage();
@@ -14217,7 +15107,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14217
15107
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
14218
15108
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
14219
15109
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
14220
- setAiContextMeter(payload.contextUsage, announcedCompaction);
15110
+ setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
14221
15111
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
14222
15112
  assertAiRequestCurrent(requestHolder.snapshot);
14223
15113
  message.classList.remove("is-streaming");
@@ -14232,11 +15122,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14232
15122
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14233
15123
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
14234
15124
  attachAssistantCopyAction(message, streamedText);
14235
- scrollAiFeedToBottom();
15125
+ scrollAiFeedToBottom(feed);
14236
15126
  } else if (eventName === "request_status") {
14237
15127
  streamError = createClientError(payload, "AI 请求状态不可重放", response.status);
14238
15128
  } else if (eventName === "error") {
14239
- setAiAssistantStatus("error");
15129
+ setAiChatTabStatus(tab, "error");
14240
15130
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
14241
15131
  }
14242
15132
  };
@@ -14279,7 +15169,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14279
15169
  message.dataset.status = "interrupted";
14280
15170
  content.setAttribute("aria-busy", "false");
14281
15171
  const headingRole = message.querySelector(".message-heading > span");
14282
- headingRole.textContent = aiAssistantLabel(interruptionLabel);
15172
+ headingRole.textContent = aiAssistantLabel(interruptionLabel, tab.roleplayCharacter);
14283
15173
  const interruptionBadge = document.createElement("strong");
14284
15174
  interruptionBadge.className = "ai-message-status is-error";
14285
15175
  interruptionBadge.textContent = "中断";
@@ -14288,12 +15178,14 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14288
15178
  renderStreamingProcessSteps(true, elapsedProcessTime());
14289
15179
  meta.textContent = formatAiStreamInterruptionMeta(interruptionCode, streamedText.length);
14290
15180
  if (streamedText) attachAssistantCopyAction(message, streamedText);
14291
- scrollAiFeedToBottom();
15181
+ scrollAiFeedToBottom(feed);
14292
15182
  throw streamFailure;
14293
15183
  }
14294
15184
  }
14295
15185
 
14296
- function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
15186
+ function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
15187
+ const tab = options.tab ?? activeAiChatTab();
15188
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
14297
15189
  const message = document.createElement("div");
14298
15190
  const isFailure = role === "assistant" && text.startsWith("调用失败:");
14299
15191
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
@@ -14309,8 +15201,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14309
15201
  });
14310
15202
  const heading = attachMessageHeading(
14311
15203
  message,
14312
- role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : ""),
14313
- createdAt ?? undefined
15204
+ role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
15205
+ createdAt ?? undefined,
15206
+ tab
14314
15207
  );
14315
15208
  if (isFailure || isInterrupted) {
14316
15209
  message.dataset.status = isInterrupted ? "interrupted" : "failed";
@@ -14320,20 +15213,27 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14320
15213
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
14321
15214
  heading.firstElementChild?.append(failureBadge);
14322
15215
  }
14323
- const mentionNames = role === "user"
14324
- ? userMessageMentionNames(metadata?.mentionCharacterIds, state.characters)
15216
+ const mentionGroups = role === "user"
15217
+ ? [
15218
+ ["角色", metadata?.mentionCharacterIds, state.characters],
15219
+ ["种族", metadata?.mentionRaceIds, state.races],
15220
+ ["组织", metadata?.mentionOrganizationIds, state.organizations]
15221
+ ]
15222
+ .flatMap(([kind, ids, items]) => userMessageMentionNames(ids, items).map((name) => ({ kind, name })))
14325
15223
  : [];
14326
- if (mentionNames.length) {
15224
+ if (mentionGroups.length) {
14327
15225
  const references = document.createElement("div");
14328
15226
  references.className = "user-message-mentions";
14329
15227
  const label = document.createElement("span");
14330
15228
  label.className = "user-message-mentions-label";
14331
- label.textContent = "引用角色";
15229
+ label.textContent = "引用";
14332
15230
  references.append(label);
14333
- for (const name of mentionNames) {
15231
+ for (const { kind, name } of mentionGroups) {
14334
15232
  const reference = document.createElement("span");
14335
15233
  reference.className = "user-message-mention";
14336
15234
  reference.textContent = name;
15235
+ reference.title = `${kind}:${name}`;
15236
+ reference.setAttribute("aria-label", `${kind}:${name}`);
14337
15237
  references.append(reference);
14338
15238
  }
14339
15239
  message.append(references);
@@ -14359,8 +15259,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14359
15259
  if (role === "assistant") {
14360
15260
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
14361
15261
  }
15262
+ if (role === "user") attachUserCopyAction(message, text);
14362
15263
  if (role === "assistant" && !text.startsWith("调用失败:")) {
14363
- const selectedModel = state.models.find((model) => model.id === $("#ai-model").value) ?? state.models[0];
15264
+ const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15265
+ const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
14364
15266
  const modelDisplayName = metadata?.modelDisplayName || selectedModel?.displayName || "模型";
14365
15267
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
14366
15268
  const meta = document.createElement("div");
@@ -14372,18 +15274,20 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
14372
15274
  attachAssistantCopyAction(message, text);
14373
15275
  }
14374
15276
  attachMessageIdentity(message, messageId);
14375
- $("#ai-feed").append(message);
14376
- scrollAiFeedToBottom();
15277
+ feed.append(message);
15278
+ scrollAiFeedToBottom(feed);
14377
15279
  }
14378
15280
 
14379
- function appendSuggestion(suggestion, createdAt = null, messageId = null) {
15281
+ function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
15282
+ const tab = options.tab ?? activeAiChatTab();
15283
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
14380
15284
  const message = document.createElement("div");
14381
15285
  message.className = "assistant-message";
14382
15286
  const applicable = suggestion.action !== "note";
14383
15287
  const guard = suggestion.guard;
14384
15288
  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
15289
  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);
15290
+ attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
14387
15291
  attachAssistantCopyAction(message, suggestion.content);
14388
15292
  attachMessageIdentity(message, messageId);
14389
15293
  if (applicable) {
@@ -14406,8 +15310,8 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
14406
15310
  message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
14407
15311
  });
14408
15312
  }
14409
- $("#ai-feed").append(message);
14410
- scrollAiFeedToBottom();
15313
+ feed.append(message);
15314
+ scrollAiFeedToBottom(feed);
14411
15315
  return message;
14412
15316
  }
14413
15317
 
@@ -15696,6 +16600,20 @@ $("#shelf-new-work").addEventListener("click", openWorkDialog);
15696
16600
  $("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
15697
16601
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
15698
16602
  $("#save-button").addEventListener("click", saveChapter);
16603
+ $("#chapter-search-close").addEventListener("click", () => closeChapterSearchPanel());
16604
+ $("#chapter-search-query").addEventListener("input", () => {
16605
+ chapterSearchMatchIndex = -1;
16606
+ renderChapterSearchStatus();
16607
+ });
16608
+ $("#chapter-search-query").addEventListener("keydown", (event) => {
16609
+ if (event.key !== "Enter") return;
16610
+ event.preventDefault();
16611
+ moveChapterSearchMatch(event.shiftKey ? -1 : 1);
16612
+ });
16613
+ $("#chapter-search-previous").addEventListener("click", () => moveChapterSearchMatch(-1));
16614
+ $("#chapter-search-next").addEventListener("click", () => moveChapterSearchMatch(1));
16615
+ $("#chapter-search-replace").addEventListener("click", replaceCurrentChapterSearchMatch);
16616
+ $("#chapter-search-replace-all").addEventListener("click", replaceAllChapterSearchMatches);
15699
16617
  $("#chapter-foreshadow-reminder-previous").addEventListener("click", () => {
15700
16618
  showChapterForeshadowReminderAt(chapterForeshadowReminderIndex - 1);
15701
16619
  });
@@ -15816,6 +16734,7 @@ $("#chapter-content").addEventListener("contextmenu", (event) => {
15816
16734
  showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
15817
16735
  });
15818
16736
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
16737
+ if (event.target.closest(".chapter-line-annotation-count")) return;
15819
16738
  const row = event.target.closest(".chapter-line-number");
15820
16739
  if (!row || event.button !== 0) return;
15821
16740
  event.preventDefault();
@@ -15838,7 +16757,23 @@ const finishChapterLineDrag = (event) => {
15838
16757
  };
15839
16758
  $("#chapter-line-numbers-inner").addEventListener("pointerup", finishChapterLineDrag);
15840
16759
  $("#chapter-line-numbers-inner").addEventListener("pointercancel", finishChapterLineDrag);
16760
+ $("#chapter-line-numbers-inner").addEventListener("click", (event) => {
16761
+ const bubble = event.target.closest(".chapter-line-annotation-count");
16762
+ if (bubble) {
16763
+ event.preventDefault();
16764
+ event.stopPropagation();
16765
+ openChapterAnnotationsDialog(Number(bubble.dataset.lineIndex)).catch((error) => toast(error.message, "error"));
16766
+ return;
16767
+ }
16768
+ const lineSelect = event.target.closest(".chapter-line-number-select");
16769
+ if (!lineSelect) return;
16770
+ const lineIndex = Number(lineSelect.closest(".chapter-line-number")?.dataset.lineIndex);
16771
+ if (!Number.isInteger(lineIndex)) return;
16772
+ paintChapterLineSelection(lineIndex, lineIndex);
16773
+ selectChapterLines(lineIndex, lineIndex);
16774
+ });
15841
16775
  $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
16776
+ if (event.target.closest(".chapter-line-annotation-count")) return;
15842
16777
  const row = event.target.closest(".chapter-line-number");
15843
16778
  if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
15844
16779
  });
@@ -15848,7 +16783,10 @@ $("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation
15848
16783
  $("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
15849
16784
  $("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
15850
16785
  $("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
15851
- $("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
16786
+ $("#chapter-annotations-refresh").addEventListener("click", () => Promise.all([
16787
+ loadChapterAnnotationCounts(),
16788
+ loadChapterAnnotations(chapterAnnotationsLineIndex)
16789
+ ]).catch((error) => toast(error.message, "error")));
15852
16790
  $("#left-panel-toggle").addEventListener("click", () => {
15853
16791
  panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
15854
16792
  applyPanelLayout(true);
@@ -15862,6 +16800,10 @@ $("#mobile-panel-backdrop").addEventListener("click", () => {
15862
16800
  applyPanelLayout(true);
15863
16801
  });
15864
16802
  $("#ai-panel-toggle").addEventListener("click", () => {
16803
+ if (aiConversationWorkspaceOpen) {
16804
+ setAiConversationWorkspaceVisible(false);
16805
+ return;
16806
+ }
15865
16807
  panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
15866
16808
  applyPanelLayout(true);
15867
16809
  });
@@ -15874,6 +16816,19 @@ window.addEventListener("resize", () => {
15874
16816
  applyPanelLayout();
15875
16817
  scheduleChapterLineNumbers();
15876
16818
  });
16819
+ $("#ai-assistant-entry").addEventListener("click", () => {
16820
+ if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) {
16821
+ return toast("当前账户没有创作助手读取权限", "error");
16822
+ }
16823
+ setModuleNavExpanded(false);
16824
+ if (isMobileViewport()) {
16825
+ panelLayout.leftCollapsed = true;
16826
+ applyPanelLayout(true);
16827
+ setAiConversationWorkspaceVisible(true);
16828
+ return;
16829
+ }
16830
+ setAiConversationWorkspaceVisible(true);
16831
+ });
15877
16832
  $("#module-nav").addEventListener("click", (event) => {
15878
16833
  const button = event.target.closest("button");
15879
16834
  if (!button || button.id === "module-more-button") return;
@@ -16139,6 +17094,7 @@ document.addEventListener("pointerdown", (event) => {
16139
17094
  closeManuscriptExportMenu(true);
16140
17095
  }
16141
17096
  if (!event.target.closest("#ai-history-action-menu") && !event.target.closest(".ai-history-more")) closeAiHistoryActionMenu();
17097
+ if (!event.target.closest("#ai-conversation-switcher-menu") && !event.target.closest("#ai-conversation-switcher")) setAiConversationSwitcherVisible(false);
16142
17098
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
16143
17099
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
16144
17100
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -16158,7 +17114,20 @@ document.addEventListener("keydown", (event) => {
16158
17114
  return;
16159
17115
  }
16160
17116
  if (event.key === "Escape") {
17117
+ if (!$("#chapter-search-panel").classList.contains("hidden")) {
17118
+ closeChapterSearchPanel();
17119
+ return;
17120
+ }
16161
17121
  if (!$("#ai-history-action-menu").classList.contains("hidden")) return;
17122
+ if (!$("#ai-conversation-switcher-menu").classList.contains("hidden")) {
17123
+ setAiConversationSwitcherVisible(false);
17124
+ $("#ai-conversation-switcher").focus();
17125
+ return;
17126
+ }
17127
+ if (aiConversationWorkspaceOpen) {
17128
+ setAiConversationWorkspaceVisible(false);
17129
+ return;
17130
+ }
16162
17131
  closeChapterTypeMenu();
16163
17132
  closeLineCitationMenu();
16164
17133
  closeMarkdownTableMenu(true);
@@ -16173,14 +17142,37 @@ document.addEventListener("keydown", (event) => {
16173
17142
  event.preventDefault();
16174
17143
  event.stopPropagation();
16175
17144
  if (event.repeat) return;
17145
+ if (isChapterSearchContext()) {
17146
+ openChapterSearchPanel();
17147
+ return;
17148
+ }
16176
17149
  openSearchDialog().catch((error) => toast(error.message, "error"));
16177
17150
  }, { capture: true });
16178
17151
  $("#ai-context-meter").addEventListener("click", () => {
16179
17152
  setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
16180
17153
  });
16181
17154
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
16182
- $("#ai-send").addEventListener("click", sendAi);
17155
+ $("#ai-send").addEventListener("click", activateAiSendControl);
17156
+ $("#ai-conversation-switcher").addEventListener("click", () => {
17157
+ setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
17158
+ });
17159
+ $("#ai-conversation-switcher-close").addEventListener("click", () => {
17160
+ setAiConversationSwitcherVisible(false);
17161
+ $("#ai-conversation-switcher").focus();
17162
+ });
17163
+ $("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
17164
+ $("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
17165
+ $("#ai-switcher-history").addEventListener("click", async () => {
17166
+ setAiConversationSwitcherVisible(false);
17167
+ try {
17168
+ await ensureAiConversationsLoaded();
17169
+ setAiHistoryVisible(true);
17170
+ } catch (error) {
17171
+ toast(`对话历史加载失败:${error.message}`, "error");
17172
+ }
17173
+ });
16183
17174
  $("#ai-new-conversation").addEventListener("click", async () => {
17175
+ setAiConversationSwitcherVisible(false);
16184
17176
  const button = $("#ai-new-conversation");
16185
17177
  button.disabled = true;
16186
17178
  try {
@@ -16244,6 +17236,7 @@ $("#ai-context-dismiss").addEventListener("click", async () => {
16244
17236
  }
16245
17237
  });
16246
17238
  $("#ai-history-toggle").addEventListener("click", async () => {
17239
+ setAiConversationSwitcherVisible(false);
16247
17240
  if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
16248
17241
  try {
16249
17242
  await ensureAiConversationsLoaded();
@@ -16304,6 +17297,20 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
16304
17297
  }
16305
17298
  return;
16306
17299
  }
17300
+ if (action === "copy-session-id") {
17301
+ option.disabled = true;
17302
+ try {
17303
+ await copyAiConversationSessionId(conversation);
17304
+ closeAiHistoryActionMenu(true);
17305
+ toast("对话 Session ID 已复制");
17306
+ } catch (error) {
17307
+ toast(`Session ID 复制失败:${error.message}`, "error");
17308
+ option.focus();
17309
+ } finally {
17310
+ option.disabled = false;
17311
+ }
17312
+ return;
17313
+ }
16307
17314
  if (action === "delete") {
16308
17315
  option.disabled = true;
16309
17316
  try {
@@ -16356,7 +17363,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
16356
17363
  }
16357
17364
  if (shouldSendAiPrompt(event)) {
16358
17365
  event.preventDefault();
16359
- if (!$("#ai-send").disabled) void sendAi();
17366
+ if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
16360
17367
  }
16361
17368
  });
16362
17369
  $(".quick-actions").addEventListener("click", (event) => {
@@ -16452,7 +17459,6 @@ $("#reader-font-size").addEventListener("change", (event) => updateReadingPrefer
16452
17459
  $("#reader-line-height").addEventListener("change", (event) => updateReadingPreference({ lineHeight: Number(event.currentTarget.value) }));
16453
17460
  $("#reader-theme").addEventListener("change", (event) => updateReadingPreference({ theme: event.currentTarget.value }));
16454
17461
  $("#reader-view").addEventListener("keydown", handleReadingKeyboard);
16455
- $("#reader-viewport").addEventListener("wheel", handleReadingWheel, { passive: false });
16456
17462
  $("#reader-viewport").addEventListener("scroll", () => {
16457
17463
  if (readingPreferences.mode === "scroll") scheduleReadingPositionSave();
16458
17464
  }, { passive: true });
@@ -16469,7 +17475,7 @@ window.addEventListener("pagehide", dismissDeleteToasts);
16469
17475
  window.addEventListener("beforeunload", (event) => {
16470
17476
  if (hasUnsavedEditorChanges()) event.preventDefault();
16471
17477
  if (aiRequestManager.hasActive()) {
16472
- toast("当前 turn 尚未结束,刷新会中断生成;已收到的内容会保留在历史记录中", "warning");
17478
+ toast("仍有 Agent turn 尚未结束,刷新会中断全部生成;已收到的内容会保留在历史记录中", "warning");
16473
17479
  }
16474
17480
  });
16475
17481
  window.addEventListener("online", () => {
@@ -16486,6 +17492,7 @@ window.addEventListener("resize", () => {
16486
17492
  });
16487
17493
  });
16488
17494
 
17495
+ initializeAiChatTabs();
16489
17496
  initializePage().catch((error) => {
16490
17497
  restoringPageRoute = false;
16491
17498
  if (state.user) {