@musnows/scriverse 0.7.13 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -0
- package/README.md +5 -0
- package/dist/ai-chat-tab-limit.js +14 -0
- package/dist/ai-chat-tab-limit.js.map +1 -0
- package/dist/ai-protocol.js +10 -2
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +52 -0
- package/dist/ai-retry.js.map +1 -0
- package/dist/ai.js +525 -117
- package/dist/ai.js.map +1 -1
- package/dist/app.js +41 -12
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +2 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +127 -2
- package/dist/database.js.map +1 -1
- package/dist/domain.js +1 -0
- package/dist/domain.js.map +1 -1
- package/dist/public/ai-chat-tabs.js +75 -0
- package/dist/public/ai-request-manager.js +32 -15
- package/dist/public/app.js +1151 -252
- package/dist/public/background-task-center.d.ts +9 -0
- package/dist/public/background-task-center.js +18 -0
- package/dist/public/chapter-search.d.ts +6 -0
- package/dist/public/chapter-search.js +23 -0
- package/dist/public/character-filters.d.ts +3 -1
- package/dist/public/character-filters.js +17 -2
- package/dist/public/character-version.js +1 -0
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +5 -1
- package/dist/public/index.html +66 -25
- package/dist/public/model-config.d.ts +3 -1
- package/dist/public/model-config.js +13 -0
- package/dist/public/relationship-filters.d.ts +5 -0
- package/dist/public/relationship-filters.js +31 -0
- package/dist/public/relationship-graph.js +289 -58
- package/dist/public/styles.css +170 -32
- package/dist/server-runtime.js +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +77 -34
- package/dist/store.js.map +1 -1
- package/dist/version.js +6 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, renderRelationshipMindMap } from "/relationship-graph.js?v=
|
|
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 {
|
|
6
|
+
import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.js?v=20260816-ai-chat-switcher-v2";
|
|
7
|
+
import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
|
|
7
8
|
import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
8
9
|
import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
|
|
9
10
|
import {
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
visibleForeshadowReminders
|
|
16
17
|
} from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
|
|
17
18
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
18
|
-
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=
|
|
19
|
+
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260816-extended-thinking-effort-v1";
|
|
19
20
|
import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
|
|
20
21
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
21
22
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
|
|
@@ -27,9 +28,10 @@ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContex
|
|
|
27
28
|
import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
|
|
28
29
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
29
30
|
import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
|
|
31
|
+
import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
|
|
30
32
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
31
33
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
32
|
-
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=
|
|
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=
|
|
56
|
+
} from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1";
|
|
54
57
|
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
|
|
55
58
|
import {
|
|
56
59
|
READING_PREFERENCES_STORAGE_KEY,
|
|
@@ -74,8 +77,8 @@ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canW
|
|
|
74
77
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
75
78
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
76
79
|
import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
|
|
77
|
-
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=
|
|
78
|
-
import { filterRelationships } from "/relationship-filters.js?v=
|
|
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=
|
|
94
|
+
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions, filterBackgroundTaskTransitionsForAnnouncement } from "/background-task-center.js?v=20260817-analysis-task-expired-toast-v1";
|
|
92
95
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
93
96
|
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
94
97
|
import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
|
|
@@ -120,6 +123,21 @@ const defaultPageSizes = Object.freeze({
|
|
|
120
123
|
analysisTasks: 30,
|
|
121
124
|
fileVersions: 30
|
|
122
125
|
});
|
|
126
|
+
const GALAXY_MOTION_MODE_STORAGE_KEY = "scriverse.galaxy-motion-mode.v1";
|
|
127
|
+
|
|
128
|
+
function storedGalaxyMotionMode() {
|
|
129
|
+
try {
|
|
130
|
+
return normalizeGalaxyMotionMode(localStorage.getItem(GALAXY_MOTION_MODE_STORAGE_KEY));
|
|
131
|
+
} catch {
|
|
132
|
+
return "auto";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function persistGalaxyMotionMode(mode) {
|
|
137
|
+
const normalized = normalizeGalaxyMotionMode(mode);
|
|
138
|
+
try { localStorage.setItem(GALAXY_MOTION_MODE_STORAGE_KEY, normalized); } catch { /* 浏览器禁用存储时仅保留本次选择 */ }
|
|
139
|
+
return normalized;
|
|
140
|
+
}
|
|
123
141
|
|
|
124
142
|
function normalizePageSize(value, fallback = 30) {
|
|
125
143
|
const candidate = Number(value);
|
|
@@ -177,6 +195,7 @@ const state = {
|
|
|
177
195
|
};
|
|
178
196
|
|
|
179
197
|
const aiRequestManager = createAiRequestManager();
|
|
198
|
+
const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
|
|
180
199
|
const moduleRequestCache = createModuleRequestCache();
|
|
181
200
|
const cachedWorkModules = new Set([
|
|
182
201
|
"drafts",
|
|
@@ -240,6 +259,7 @@ let backgroundTaskCenterRequest = 0;
|
|
|
240
259
|
let backgroundTaskCenterWorkId = null;
|
|
241
260
|
let backgroundTaskCenterTasksInitialized = false;
|
|
242
261
|
let backgroundTaskCenterTaskSnapshots = new Map();
|
|
262
|
+
let backgroundTaskCenterExpiredNoticeTimes = new Map();
|
|
243
263
|
let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
244
264
|
let s3BackupTargets = [];
|
|
245
265
|
let s3BackupRuns = [];
|
|
@@ -409,6 +429,7 @@ function applyWorkAccessMode() {
|
|
|
409
429
|
$("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
|
|
410
430
|
$("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
|
|
411
431
|
$("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
|
|
432
|
+
$("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
|
|
412
433
|
$("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
413
434
|
$("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
414
435
|
$("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
@@ -519,10 +540,30 @@ function setAiAssistantStatus(status) {
|
|
|
519
540
|
dot.title = label;
|
|
520
541
|
}
|
|
521
542
|
|
|
543
|
+
function activeAiChatTab() {
|
|
544
|
+
return aiChatTabManager.active();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function aiChatTabForRequest(request) {
|
|
548
|
+
return request?.tabId ? aiChatTabManager.get(request.tabId) : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isActiveAiChatTab(tab) {
|
|
552
|
+
return Boolean(tab && activeAiChatTab()?.id === tab.id);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function setAiChatTabStatus(tab, status) {
|
|
556
|
+
if (!tab) return;
|
|
557
|
+
tab.status = status;
|
|
558
|
+
if (isActiveAiChatTab(tab)) setAiAssistantStatus(status);
|
|
559
|
+
renderAiChatTabs();
|
|
560
|
+
}
|
|
561
|
+
|
|
522
562
|
function aiRequestTargetsCurrentState(request) {
|
|
523
|
-
|
|
563
|
+
const tab = aiChatTabForRequest(request);
|
|
564
|
+
return aiRequestManager.isCurrent(request) && Boolean(tab) && aiRequestTargetsState(request, {
|
|
524
565
|
workId: state.work?.id,
|
|
525
|
-
conversationId:
|
|
566
|
+
conversationId: tab.conversationId
|
|
526
567
|
});
|
|
527
568
|
}
|
|
528
569
|
|
|
@@ -532,41 +573,56 @@ function assertAiRequestCurrent(request) {
|
|
|
532
573
|
}
|
|
533
574
|
|
|
534
575
|
function aiInteractionBusy() {
|
|
535
|
-
return aiRequestManager.hasActive() || aiConversationNavigationPending !== null;
|
|
576
|
+
return aiRequestManager.hasActive(activeAiChatTab()?.id) || aiConversationNavigationPending !== null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function aiSendButtonIconMarkup(stateName) {
|
|
580
|
+
return stateName === "stop"
|
|
581
|
+
? '<svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="6" y="6" width="12" height="12" rx="1.5"></rect></svg>'
|
|
582
|
+
: '<svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 3 9.5 14.5M21 3l-7 18-4.5-6.5L3 10l18-7Z"></path></svg>';
|
|
536
583
|
}
|
|
537
584
|
|
|
538
585
|
function syncAiRequestControls() {
|
|
539
|
-
const sending = aiRequestManager.hasActive();
|
|
586
|
+
const sending = aiRequestManager.hasActive(activeAiChatTab()?.id);
|
|
540
587
|
const switching = aiConversationNavigationPending !== null;
|
|
541
588
|
const button = $("#ai-send");
|
|
542
|
-
|
|
543
|
-
|
|
589
|
+
const stateName = sending ? "stop" : switching ? "switching" : "send";
|
|
590
|
+
const label = sending ? "终止当前回复" : switching ? "正在切换对话" : "发送消息";
|
|
591
|
+
button.disabled = switching;
|
|
592
|
+
button.dataset.state = stateName;
|
|
593
|
+
button.classList.toggle("is-stop", sending);
|
|
594
|
+
button.setAttribute("aria-label", label);
|
|
595
|
+
button.title = label;
|
|
596
|
+
button.innerHTML = aiSendButtonIconMarkup(stateName);
|
|
544
597
|
syncAiTaskOptions();
|
|
545
598
|
renderAiRoleplayCharacterSelect();
|
|
546
599
|
}
|
|
547
600
|
|
|
548
601
|
function cancelActiveAiRequest(reason) {
|
|
549
|
-
const cancelled = aiRequestManager.cancel(reason);
|
|
602
|
+
const cancelled = aiRequestManager.cancel(reason, activeAiChatTab()?.id);
|
|
550
603
|
syncAiRequestControls();
|
|
551
604
|
return cancelled;
|
|
552
605
|
}
|
|
553
606
|
|
|
554
|
-
function
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
"
|
|
560
|
-
|
|
561
|
-
|
|
607
|
+
function activateAiSendControl() {
|
|
608
|
+
const tab = activeAiChatTab();
|
|
609
|
+
if (aiRequestManager.hasActive(tab?.id)) {
|
|
610
|
+
if (cancelActiveAiRequest("用户已终止当前回复")) {
|
|
611
|
+
toast("已终止当前回复,可以重新发送");
|
|
612
|
+
$("#ai-prompt").focus();
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
562
615
|
}
|
|
616
|
+
void sendAi();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function beginAiConversationNavigation(reason, action = "切换会话") {
|
|
563
620
|
aiConversationNavigationGeneration += 1;
|
|
564
621
|
aiConversationNavigationPending = aiConversationNavigationGeneration;
|
|
565
622
|
const navigation = Object.freeze({
|
|
566
623
|
generation: aiConversationNavigationGeneration,
|
|
567
624
|
workId: String(state.work?.id ?? "")
|
|
568
625
|
});
|
|
569
|
-
cancelActiveAiRequest(reason);
|
|
570
626
|
return navigation;
|
|
571
627
|
}
|
|
572
628
|
|
|
@@ -584,11 +640,12 @@ function finishAiConversationNavigation(navigation) {
|
|
|
584
640
|
|
|
585
641
|
function invalidateAiConversationNavigation(reason, action = "切换作品") {
|
|
586
642
|
if (aiRequestManager.hasActive()) {
|
|
587
|
-
toast(
|
|
643
|
+
toast(`仍有 Agent turn 尚未结束,${action}会中断全部生成;已收到的内容会保留在历史记录中`, "warning");
|
|
588
644
|
}
|
|
589
645
|
aiConversationNavigationGeneration += 1;
|
|
590
646
|
aiConversationNavigationPending = null;
|
|
591
|
-
|
|
647
|
+
aiRequestManager.cancelAll(reason);
|
|
648
|
+
syncAiRequestControls();
|
|
592
649
|
}
|
|
593
650
|
|
|
594
651
|
function aiAssistantRequestId(request) {
|
|
@@ -665,6 +722,8 @@ let workSelectionRequestGeneration = 0;
|
|
|
665
722
|
let chapterSelectionRequestGeneration = 0;
|
|
666
723
|
let aiConversationNavigationGeneration = 0;
|
|
667
724
|
let aiConversationNavigationPending = null;
|
|
725
|
+
let aiChatTabLimit = 5;
|
|
726
|
+
let aiConversationWorkspaceOpen = false;
|
|
668
727
|
const loadedVolumeChapterIds = new Set();
|
|
669
728
|
const volumeChapterLoadingIds = new Set();
|
|
670
729
|
const volumeChapterRequests = new Map();
|
|
@@ -873,6 +932,7 @@ function replacePageRoute(route) {
|
|
|
873
932
|
const hash = serializePageRoute(route);
|
|
874
933
|
if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
|
|
875
934
|
schedulePresenceHeartbeat();
|
|
935
|
+
syncTopSearchButton();
|
|
876
936
|
}
|
|
877
937
|
|
|
878
938
|
function presencePageForRoute(route = currentPageRoute()) {
|
|
@@ -1084,6 +1144,149 @@ function currentPageRoute() {
|
|
|
1084
1144
|
return { view: "welcome", workId };
|
|
1085
1145
|
}
|
|
1086
1146
|
|
|
1147
|
+
function isChapterSearchContext() {
|
|
1148
|
+
return Boolean(state.work && state.chapter && !$("#editor-view").classList.contains("hidden"));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function syncTopSearchButton() {
|
|
1152
|
+
const button = $("#top-search-button");
|
|
1153
|
+
const chapterContext = isChapterSearchContext();
|
|
1154
|
+
button.classList.toggle("hidden", chapterContext);
|
|
1155
|
+
button.disabled = !state.work || chapterContext || !canReadAggregateContent();
|
|
1156
|
+
if (!chapterContext) closeChapterSearchPanel({ restoreFocus: false });
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function chapterSearchMatches() {
|
|
1160
|
+
return findTextMatches($("#chapter-content").value, $("#chapter-search-query").value);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function renderChapterSearchStatus() {
|
|
1164
|
+
const query = $("#chapter-search-query").value;
|
|
1165
|
+
const matches = chapterSearchMatches();
|
|
1166
|
+
const status = $("#chapter-search-status");
|
|
1167
|
+
const readOnly = !canEditProse() || chapterEditorReadOnly;
|
|
1168
|
+
const replaceButtons = [$("#chapter-search-replace"), $("#chapter-search-replace-all")];
|
|
1169
|
+
replaceButtons.forEach((button) => { button.disabled = readOnly || matches.length === 0; });
|
|
1170
|
+
$("#chapter-search-previous").disabled = matches.length === 0;
|
|
1171
|
+
$("#chapter-search-next").disabled = matches.length === 0;
|
|
1172
|
+
if (!query) {
|
|
1173
|
+
status.textContent = "输入关键词后开始搜索";
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (!matches.length) {
|
|
1177
|
+
status.textContent = "本章未找到匹配内容";
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const current = chapterSearchMatchIndex >= 0 ? chapterSearchMatchIndex + 1 : 0;
|
|
1181
|
+
status.textContent = current ? `第 ${current} / ${matches.length} 处` : `找到 ${matches.length} 处`;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function syncChapterSearchControls() {
|
|
1185
|
+
if (!$("#chapter-search-panel")) return;
|
|
1186
|
+
const matches = chapterSearchMatches();
|
|
1187
|
+
if (chapterSearchMatchIndex >= matches.length) chapterSearchMatchIndex = matches.length - 1;
|
|
1188
|
+
renderChapterSearchStatus();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function selectChapterSearchMatch(index) {
|
|
1192
|
+
const matches = chapterSearchMatches();
|
|
1193
|
+
if (!matches.length) {
|
|
1194
|
+
chapterSearchMatchIndex = -1;
|
|
1195
|
+
renderChapterSearchStatus();
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
chapterSearchMatchIndex = (index + matches.length) % matches.length;
|
|
1199
|
+
const input = $("#chapter-content");
|
|
1200
|
+
const start = matches[chapterSearchMatchIndex];
|
|
1201
|
+
input.focus({ preventScroll: true });
|
|
1202
|
+
input.setSelectionRange(start, start + $("#chapter-search-query").value.length);
|
|
1203
|
+
renderChapterSearchStatus();
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function moveChapterSearchMatch(direction) {
|
|
1207
|
+
const matches = chapterSearchMatches();
|
|
1208
|
+
if (!matches.length) {
|
|
1209
|
+
renderChapterSearchStatus();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const nextIndex = chapterSearchMatchIndex < 0
|
|
1213
|
+
? direction > 0 ? 0 : matches.length - 1
|
|
1214
|
+
: chapterSearchMatchIndex + direction;
|
|
1215
|
+
selectChapterSearchMatch(nextIndex);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function openChapterSearchPanel() {
|
|
1219
|
+
if (!isChapterSearchContext()) return false;
|
|
1220
|
+
const panel = $("#chapter-search-panel");
|
|
1221
|
+
panel.classList.remove("hidden");
|
|
1222
|
+
panel.setAttribute("aria-hidden", "false");
|
|
1223
|
+
syncChapterSearchControls();
|
|
1224
|
+
queueMicrotask(() => {
|
|
1225
|
+
const input = $("#chapter-search-query");
|
|
1226
|
+
input.focus();
|
|
1227
|
+
if (input.value) input.select();
|
|
1228
|
+
});
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function closeChapterSearchPanel({ restoreFocus = true } = {}) {
|
|
1233
|
+
const panel = $("#chapter-search-panel");
|
|
1234
|
+
if (!panel || panel.classList.contains("hidden")) return;
|
|
1235
|
+
panel.classList.add("hidden");
|
|
1236
|
+
panel.setAttribute("aria-hidden", "true");
|
|
1237
|
+
chapterSearchMatchIndex = -1;
|
|
1238
|
+
if (restoreFocus && isChapterSearchContext()) $("#chapter-content").focus({ preventScroll: true });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function resetChapterSearchPanel() {
|
|
1242
|
+
closeChapterSearchPanel({ restoreFocus: false });
|
|
1243
|
+
$("#chapter-search-query").value = "";
|
|
1244
|
+
$("#chapter-replace-query").value = "";
|
|
1245
|
+
chapterSearchMatchIndex = -1;
|
|
1246
|
+
renderChapterSearchStatus();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function replaceCurrentChapterSearchMatch() {
|
|
1250
|
+
if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
|
|
1251
|
+
toast("请先进入编辑模式", "error");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const query = $("#chapter-search-query").value;
|
|
1255
|
+
const matches = chapterSearchMatches();
|
|
1256
|
+
if (!query || !matches.length) return renderChapterSearchStatus();
|
|
1257
|
+
const index = chapterSearchMatchIndex < 0 ? 0 : chapterSearchMatchIndex;
|
|
1258
|
+
const start = matches[index];
|
|
1259
|
+
const input = $("#chapter-content");
|
|
1260
|
+
const replacement = $("#chapter-replace-query").value;
|
|
1261
|
+
input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
|
|
1262
|
+
chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
|
|
1263
|
+
updateChapterStats();
|
|
1264
|
+
clearChapterLineSelection();
|
|
1265
|
+
scheduleChapterLineNumbers();
|
|
1266
|
+
scheduleChapterAutoSave(120);
|
|
1267
|
+
syncChapterSearchControls();
|
|
1268
|
+
toast("已替换本章 1 处,等待自动保存");
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function replaceAllChapterSearchMatches() {
|
|
1272
|
+
if (!isChapterSearchContext() || !canEditProse() || chapterEditorReadOnly) {
|
|
1273
|
+
toast("请先进入编辑模式", "error");
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const query = $("#chapter-search-query").value;
|
|
1277
|
+
const input = $("#chapter-content");
|
|
1278
|
+
const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
|
|
1279
|
+
if (!result.matches) return renderChapterSearchStatus();
|
|
1280
|
+
input.value = result.content;
|
|
1281
|
+
chapterSearchMatchIndex = -1;
|
|
1282
|
+
updateChapterStats();
|
|
1283
|
+
clearChapterLineSelection();
|
|
1284
|
+
scheduleChapterLineNumbers();
|
|
1285
|
+
scheduleChapterAutoSave(120);
|
|
1286
|
+
syncChapterSearchControls();
|
|
1287
|
+
toast(`已替换本章 ${result.matches} 处,等待自动保存`);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1087
1290
|
function loadPanelLayout() {
|
|
1088
1291
|
try {
|
|
1089
1292
|
const stored = JSON.parse(localStorage.getItem(panelLayoutStorageKey) ?? "{}");
|
|
@@ -1142,9 +1345,8 @@ function applyPanelLayout(persist = false) {
|
|
|
1142
1345
|
}
|
|
1143
1346
|
|
|
1144
1347
|
function ensureAiPanelExpanded() {
|
|
1145
|
-
if (!
|
|
1146
|
-
|
|
1147
|
-
applyPanelLayout(true);
|
|
1348
|
+
if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
|
|
1349
|
+
setAiConversationWorkspaceVisible(true);
|
|
1148
1350
|
}
|
|
1149
1351
|
|
|
1150
1352
|
function setupPanelResize(handle, side) {
|
|
@@ -1193,6 +1395,7 @@ let chapterAutoSaveTimer = null;
|
|
|
1193
1395
|
let chapterSaveInFlight = null;
|
|
1194
1396
|
let chapterSaveGuardInFlight = null;
|
|
1195
1397
|
let lastSavedChapterSnapshot = null;
|
|
1398
|
+
let chapterSearchMatchIndex = -1;
|
|
1196
1399
|
let chapterSelectionRequestId = 0;
|
|
1197
1400
|
let chapterForeshadowReminderRequestId = 0;
|
|
1198
1401
|
let chapterForeshadowReminders = [];
|
|
@@ -1234,7 +1437,9 @@ const moduleListPages = {
|
|
|
1234
1437
|
comments: 1,
|
|
1235
1438
|
reviews: 1
|
|
1236
1439
|
};
|
|
1237
|
-
const characterFilters = { raceIds: [], organizationIds: [] };
|
|
1440
|
+
const characterFilters = { raceIds: [], organizationIds: [], genderValues: [], deathState: "all" };
|
|
1441
|
+
const CHARACTER_GENDER_OPTIONS = [["unknown", "未知"], ["male", "男 / 雄"], ["female", "女 / 雌"], ["none", "无性别"]];
|
|
1442
|
+
const CHARACTER_DEATH_STATE_OPTIONS = [["all", "全部状态"], ["alive", "未死亡"], ["dead", "已死亡"]];
|
|
1238
1443
|
let characterFiltersPanelOpen = false;
|
|
1239
1444
|
const settingFilters = { keyword: "", category: "", lockState: "all" };
|
|
1240
1445
|
let settingFiltersPanelOpen = false;
|
|
@@ -1293,6 +1498,7 @@ function applyChapterEditorMode() {
|
|
|
1293
1498
|
$("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
|
|
1294
1499
|
$("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
|
|
1295
1500
|
$("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
|
|
1501
|
+
syncChapterSearchControls();
|
|
1296
1502
|
if (viewOnly) cancelChapterAutoSave();
|
|
1297
1503
|
}
|
|
1298
1504
|
|
|
@@ -1781,9 +1987,345 @@ function renderAiQuickActions() {
|
|
|
1781
1987
|
quickActions.setAttribute("aria-hidden", String(!visible));
|
|
1782
1988
|
}
|
|
1783
1989
|
|
|
1784
|
-
function
|
|
1990
|
+
function createAiChatFeed() {
|
|
1991
|
+
const feed = document.createElement("div");
|
|
1992
|
+
feed.className = "ai-feed hidden";
|
|
1993
|
+
feed.setAttribute("role", "tabpanel");
|
|
1994
|
+
$("#ai-chat-panels").append(feed);
|
|
1995
|
+
return feed;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function createAiChatTabState(input = {}) {
|
|
1999
|
+
const existing = input.conversationId ? aiChatTabManager.findByConversation(input.conversationId) : null;
|
|
2000
|
+
if (existing) {
|
|
2001
|
+
aiChatTabManager.activate(existing.id);
|
|
2002
|
+
return existing;
|
|
2003
|
+
}
|
|
2004
|
+
const feed = input.feed ?? createAiChatFeed();
|
|
2005
|
+
const tab = aiChatTabManager.open({
|
|
2006
|
+
workId: String(state.work?.id ?? ""),
|
|
2007
|
+
conversationId: input.conversationId ?? null,
|
|
2008
|
+
title: input.title ?? "新对话",
|
|
2009
|
+
modelId: input.modelId ?? null,
|
|
2010
|
+
selectedModelId: input.selectedModelId ?? null,
|
|
2011
|
+
promptSent: input.promptSent === true,
|
|
2012
|
+
taskType: input.taskType ?? "chat",
|
|
2013
|
+
contextScope: input.contextScope ?? { type: "none" },
|
|
2014
|
+
roleplayCharacter: input.roleplayCharacter ?? null,
|
|
2015
|
+
citations: input.citations ?? [],
|
|
2016
|
+
references: input.references ?? [],
|
|
2017
|
+
composer: input.composer ?? { text: "", citations: [], references: [] },
|
|
2018
|
+
contextUsage: input.contextUsage ?? null,
|
|
2019
|
+
contextWarning: input.contextWarning === true,
|
|
2020
|
+
lastMessageAt: input.lastMessageAt ?? null,
|
|
2021
|
+
status: input.status ?? "ready",
|
|
2022
|
+
unread: input.unread === true,
|
|
2023
|
+
feed
|
|
2024
|
+
});
|
|
2025
|
+
feed.dataset.aiTabId = tab.id;
|
|
2026
|
+
feed.setAttribute("aria-labelledby", `ai-chat-tab-${tab.id}`);
|
|
2027
|
+
return tab;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function persistActiveAiChatTab() {
|
|
2031
|
+
const tab = activeAiChatTab();
|
|
2032
|
+
if (!tab) return null;
|
|
2033
|
+
tab.workId = String(state.work?.id ?? "");
|
|
2034
|
+
tab.conversationId = state.aiConversationId;
|
|
2035
|
+
tab.title = $("#ai-conversation-title").textContent || tab.title || "新对话";
|
|
2036
|
+
tab.modelId = state.aiConversationModelId;
|
|
2037
|
+
tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
|
|
2038
|
+
tab.promptSent = state.aiPromptSent;
|
|
2039
|
+
tab.taskType = state.aiTaskType;
|
|
2040
|
+
tab.contextScope = { ...state.aiContextScope };
|
|
2041
|
+
tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
|
|
2042
|
+
setAiChatTabComposerSnapshot(tab, captureAiPromptComposer());
|
|
2043
|
+
tab.contextUsage = latestAiContextUsage;
|
|
2044
|
+
tab.contextWarning = !$("#ai-context-warning").classList.contains("hidden");
|
|
2045
|
+
tab.lastMessageAt = state.aiLastMessageAt;
|
|
2046
|
+
return tab;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function setAiChatTabComposerSnapshot(tab, snapshot) {
|
|
2050
|
+
if (!tab) return;
|
|
2051
|
+
tab.citations = snapshot.citations.map((citation) => ({ ...citation }));
|
|
2052
|
+
tab.references = snapshot.references.map((reference) => ({ ...reference }));
|
|
2053
|
+
tab.composer = {
|
|
2054
|
+
text: snapshot.text,
|
|
2055
|
+
citations: tab.citations.map((citation) => ({ ...citation })),
|
|
2056
|
+
references: tab.references.map((reference) => ({ ...reference }))
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function clearAiChatTabComposer(tab) {
|
|
2061
|
+
setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [] });
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function applyAiChatTabState(tab) {
|
|
2065
|
+
state.aiConversationId = tab.conversationId;
|
|
2066
|
+
state.aiConversationModelId = tab.modelId;
|
|
2067
|
+
state.aiPromptSent = tab.promptSent;
|
|
2068
|
+
state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
|
|
2069
|
+
state.aiReferences = tab.references.map((reference) => ({ ...reference }));
|
|
2070
|
+
state.aiLastMessageAt = tab.lastMessageAt;
|
|
2071
|
+
$("#ai-conversation-title").textContent = tab.title || "新对话";
|
|
2072
|
+
applyAiConversationTaskType(tab.taskType);
|
|
2073
|
+
applyAiConversationContextScope(tab.contextScope);
|
|
2074
|
+
applyAiRoleplayCharacter(tab.roleplayCharacter);
|
|
2075
|
+
const selectedModelId = tab.modelId ?? tab.selectedModelId;
|
|
2076
|
+
if (selectedModelId && state.models.some((model) => model.id === selectedModelId)) $("#ai-model").value = selectedModelId;
|
|
2077
|
+
setAiPromptText(tab.composer.text);
|
|
2078
|
+
renderAiCitations();
|
|
2079
|
+
renderAiReferences();
|
|
2080
|
+
latestAiContextUsage = null;
|
|
2081
|
+
setAiContextMeter(tab.contextUsage);
|
|
2082
|
+
if (tab.contextWarning) showAiContextWarning();
|
|
2083
|
+
else hideAiContextWarning();
|
|
2084
|
+
setAiAssistantStatus(tab.status);
|
|
2085
|
+
renderAiQuickActions();
|
|
2086
|
+
renderAiConversationHistory();
|
|
2087
|
+
syncAiRequestControls();
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
|
|
2091
|
+
return Boolean(tab
|
|
2092
|
+
&& !tab.conversationId
|
|
2093
|
+
&& !tab.promptSent
|
|
2094
|
+
&& !aiRequestManager.hasActive(tab.id)
|
|
2095
|
+
&& !(tab.composer?.text || "").trim()
|
|
2096
|
+
&& !(tab.composer?.citations?.length)
|
|
2097
|
+
&& !(tab.composer?.references?.length));
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function aiChatTabOpeningSlot() {
|
|
2101
|
+
const active = activeAiChatTab();
|
|
2102
|
+
if (aiChatTabLimit === 1) return { allowed: true, tab: active, replacing: true };
|
|
2103
|
+
if (aiChatTabIsReplaceable(active)) return { allowed: true, tab: active, replacing: true };
|
|
2104
|
+
if (aiChatTabManager.list().length < aiChatTabLimit) return { allowed: true, tab: null, replacing: false };
|
|
2105
|
+
return { allowed: false, tab: null, replacing: false };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function interruptAiChatTabForReplacement(slot) {
|
|
2109
|
+
if (!slot?.replacing || !slot.tab) return;
|
|
2110
|
+
if (aiRequestManager.cancel("已切换到其他对话", slot.tab.id)) {
|
|
2111
|
+
toast("当前对话仍在生成,切换后已中断该 turn;已收到的内容仍保留在历史记录中", "warning");
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function reportAiChatTabLimit() {
|
|
2116
|
+
toast(`最多同时打开 ${aiChatTabLimit} 个对话,请先关闭一个再继续`, "warning");
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function aiChatTabStatusLabel(tab, selected) {
|
|
2120
|
+
if (tab.status === "streaming") return "正在生成";
|
|
2121
|
+
if (tab.status === "error") return "执行失败";
|
|
2122
|
+
if (tab.unread) return "已完成 · 未读";
|
|
2123
|
+
return selected ? "当前对话" : "空闲";
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function renderAiConversationSwitcher() {
|
|
2127
|
+
const button = $("#ai-conversation-switcher");
|
|
2128
|
+
const list = $("#ai-open-conversation-list");
|
|
2129
|
+
if (!button || !list) return;
|
|
2130
|
+
const tabs = aiChatTabManager.list();
|
|
2131
|
+
const active = activeAiChatTab();
|
|
2132
|
+
const multipleEnabled = aiChatTabLimit > 1;
|
|
2133
|
+
$("#ai-conversation-title").textContent = active?.title || "新对话";
|
|
2134
|
+
$("#ai-open-conversation-count").textContent = String(tabs.length);
|
|
2135
|
+
$("#ai-conversation-switcher-limit").textContent = `${tabs.length} / ${aiChatTabLimit}`;
|
|
2136
|
+
button.disabled = !multipleEnabled;
|
|
2137
|
+
button.setAttribute("aria-disabled", String(!multipleEnabled));
|
|
2138
|
+
button.title = multipleEnabled ? "切换打开的对话" : "多会话已通过环境变量关闭";
|
|
2139
|
+
button.classList.toggle("has-background-activity", tabs.some((tab) => tab.id !== active?.id && (tab.status === "streaming" || tab.unread)));
|
|
2140
|
+
$("#ai-workspace-open").classList.toggle("hidden", !multipleEnabled);
|
|
2141
|
+
list.replaceChildren();
|
|
2142
|
+
for (const tab of tabs) {
|
|
2143
|
+
const selected = tab.id === active?.id;
|
|
2144
|
+
const row = document.createElement("div");
|
|
2145
|
+
row.className = `ai-open-conversation-row${selected ? " is-active" : ""}`;
|
|
2146
|
+
row.setAttribute("role", "listitem");
|
|
2147
|
+
const select = document.createElement("button");
|
|
2148
|
+
select.type = "button";
|
|
2149
|
+
select.className = "ai-open-conversation-select";
|
|
2150
|
+
select.dataset.aiSwitcherTabId = tab.id;
|
|
2151
|
+
select.setAttribute("aria-current", selected ? "true" : "false");
|
|
2152
|
+
const status = document.createElement("span");
|
|
2153
|
+
status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
|
|
2154
|
+
status.setAttribute("aria-hidden", "true");
|
|
2155
|
+
const copy = document.createElement("span");
|
|
2156
|
+
copy.className = "ai-open-conversation-copy";
|
|
2157
|
+
const title = document.createElement("strong");
|
|
2158
|
+
title.textContent = tab.title || "新对话";
|
|
2159
|
+
const meta = document.createElement("small");
|
|
2160
|
+
meta.textContent = aiChatTabStatusLabel(tab, selected);
|
|
2161
|
+
copy.append(title, meta);
|
|
2162
|
+
select.append(status, copy);
|
|
2163
|
+
select.addEventListener("click", () => {
|
|
2164
|
+
activateAiChatTab(tab.id);
|
|
2165
|
+
setAiConversationSwitcherVisible(false);
|
|
2166
|
+
});
|
|
2167
|
+
const close = document.createElement("button");
|
|
2168
|
+
close.type = "button";
|
|
2169
|
+
close.className = "ai-open-conversation-close";
|
|
2170
|
+
close.setAttribute("aria-label", `关闭对话“${tab.title || "新对话"}”`);
|
|
2171
|
+
close.textContent = "×";
|
|
2172
|
+
close.classList.toggle("hidden", tabs.length === 1);
|
|
2173
|
+
close.addEventListener("click", () => closeAiChatTab(tab.id));
|
|
2174
|
+
row.append(select, close);
|
|
2175
|
+
list.append(row);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function setAiConversationSwitcherVisible(visible) {
|
|
2180
|
+
const menu = $("#ai-conversation-switcher-menu");
|
|
2181
|
+
const button = $("#ai-conversation-switcher");
|
|
2182
|
+
if (!menu || !button) return;
|
|
2183
|
+
const nextVisible = Boolean(visible && aiChatTabLimit > 1);
|
|
2184
|
+
menu.classList.toggle("hidden", !nextVisible);
|
|
2185
|
+
button.setAttribute("aria-expanded", String(nextVisible));
|
|
2186
|
+
if (nextVisible) renderAiConversationSwitcher();
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function setAiConversationWorkspaceVisible(visible) {
|
|
2190
|
+
aiConversationWorkspaceOpen = Boolean(visible);
|
|
2191
|
+
if (aiConversationWorkspaceOpen) {
|
|
2192
|
+
panelLayout.aiCollapsed = false;
|
|
2193
|
+
$("#app").classList.remove("ai-panel-collapsed");
|
|
2194
|
+
}
|
|
2195
|
+
$("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
|
|
2196
|
+
$(".ai-panel").classList.toggle("is-conversation-workspace", aiConversationWorkspaceOpen);
|
|
2197
|
+
$("#ai-assistant-entry").classList.toggle("active", aiConversationWorkspaceOpen);
|
|
2198
|
+
$("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
|
|
2199
|
+
$("#ai-chat-tabs").classList.add("hidden");
|
|
2200
|
+
$("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
|
|
2201
|
+
$("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
|
|
2202
|
+
setAiConversationSwitcherVisible(false);
|
|
2203
|
+
renderAiChatTabs();
|
|
2204
|
+
if (aiConversationWorkspaceOpen) {
|
|
2205
|
+
window.requestAnimationFrame(() => $("#ai-prompt").focus({ preventScroll: true }));
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function applyAiChatTabLimit(value) {
|
|
2210
|
+
aiChatTabLimit = normalizeAiChatTabLimit(value);
|
|
2211
|
+
const singleSession = aiChatTabLimit === 1;
|
|
2212
|
+
$(".ai-panel").classList.toggle("is-single-conversation", singleSession);
|
|
2213
|
+
if (singleSession) {
|
|
2214
|
+
setAiConversationSwitcherVisible(false);
|
|
2215
|
+
}
|
|
2216
|
+
renderAiChatTabs();
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function renderAiChatTabs() {
|
|
2220
|
+
const host = $("#ai-chat-tabs");
|
|
2221
|
+
if (!host) return;
|
|
2222
|
+
const tabs = aiChatTabManager.list();
|
|
2223
|
+
const active = activeAiChatTab();
|
|
2224
|
+
host.replaceChildren();
|
|
2225
|
+
for (const tab of tabs) {
|
|
2226
|
+
const item = document.createElement("div");
|
|
2227
|
+
item.className = "ai-chat-tab";
|
|
2228
|
+
item.setAttribute("role", "presentation");
|
|
2229
|
+
const button = document.createElement("button");
|
|
2230
|
+
const selected = tab.id === active?.id;
|
|
2231
|
+
button.type = "button";
|
|
2232
|
+
button.id = `ai-chat-tab-${tab.id}`;
|
|
2233
|
+
button.className = "ai-chat-tab-select";
|
|
2234
|
+
button.dataset.aiTabId = tab.id;
|
|
2235
|
+
button.setAttribute("role", "tab");
|
|
2236
|
+
button.setAttribute("aria-selected", String(selected));
|
|
2237
|
+
button.setAttribute("aria-controls", tab.feed.id || `ai-chat-panel-${tab.id}`);
|
|
2238
|
+
button.tabIndex = selected ? 0 : -1;
|
|
2239
|
+
button.title = tab.title || "新对话";
|
|
2240
|
+
const status = document.createElement("span");
|
|
2241
|
+
status.className = `ai-chat-tab-status${tab.status === "streaming" ? " is-streaming" : tab.status === "error" ? " is-error" : tab.unread ? " is-unread" : ""}`;
|
|
2242
|
+
status.setAttribute("aria-hidden", "true");
|
|
2243
|
+
const title = document.createElement("span");
|
|
2244
|
+
title.textContent = tab.title || "新对话";
|
|
2245
|
+
button.append(status, title);
|
|
2246
|
+
button.addEventListener("click", () => activateAiChatTab(tab.id));
|
|
2247
|
+
button.addEventListener("keydown", (event) => {
|
|
2248
|
+
const index = tabs.findIndex((candidate) => candidate.id === tab.id);
|
|
2249
|
+
const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
|
|
2250
|
+
if (!offset) return;
|
|
2251
|
+
event.preventDefault();
|
|
2252
|
+
const target = tabs[(index + offset + tabs.length) % tabs.length];
|
|
2253
|
+
activateAiChatTab(target.id);
|
|
2254
|
+
$("#ai-conversation-switcher").focus();
|
|
2255
|
+
});
|
|
2256
|
+
const close = document.createElement("button");
|
|
2257
|
+
close.type = "button";
|
|
2258
|
+
close.className = "ai-chat-tab-close";
|
|
2259
|
+
close.dataset.aiTabClose = tab.id;
|
|
2260
|
+
close.setAttribute("aria-label", `关闭对话页签“${tab.title || "新对话"}”`);
|
|
2261
|
+
close.title = "关闭页签";
|
|
2262
|
+
close.textContent = "×";
|
|
2263
|
+
close.classList.toggle("hidden", tabs.length === 1);
|
|
2264
|
+
close.addEventListener("click", () => closeAiChatTab(tab.id));
|
|
2265
|
+
item.append(button, close);
|
|
2266
|
+
host.append(item);
|
|
2267
|
+
}
|
|
2268
|
+
renderAiConversationSwitcher();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function activateAiChatTab(tabId, { persistCurrent = true, force = false } = {}) {
|
|
2272
|
+
const current = activeAiChatTab();
|
|
2273
|
+
if (!force && current?.id === tabId) return current;
|
|
2274
|
+
if (persistCurrent) persistActiveAiChatTab();
|
|
2275
|
+
const tab = aiChatTabManager.activate(tabId);
|
|
2276
|
+
if (!tab) return null;
|
|
2277
|
+
tab.unread = false;
|
|
2278
|
+
for (const feed of $("#ai-chat-panels").querySelectorAll(".ai-feed")) {
|
|
2279
|
+
feed.classList.toggle("hidden", feed !== tab.feed);
|
|
2280
|
+
feed.id = feed === tab.feed ? "ai-feed" : `ai-chat-panel-${feed.dataset.aiTabId}`;
|
|
2281
|
+
}
|
|
2282
|
+
tab.feed.classList.remove("hidden");
|
|
2283
|
+
applyAiChatTabState(tab);
|
|
2284
|
+
renderAiChatTabs();
|
|
2285
|
+
return tab;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function closeAiChatTab(tabId) {
|
|
2289
|
+
const tab = aiChatTabManager.get(tabId);
|
|
2290
|
+
if (!tab) return;
|
|
2291
|
+
const wasActive = isActiveAiChatTab(tab);
|
|
2292
|
+
if (wasActive) persistActiveAiChatTab();
|
|
2293
|
+
if (aiRequestManager.cancel("对话页签已关闭", tab.id)) {
|
|
2294
|
+
toast(`“${tab.title || "新对话"}”仍在生成,关闭页签已中断该 turn;其他页签不受影响`, "warning");
|
|
2295
|
+
}
|
|
2296
|
+
const { active } = aiChatTabManager.close(tab.id);
|
|
2297
|
+
tab.feed.remove();
|
|
2298
|
+
if (!wasActive) {
|
|
2299
|
+
renderAiChatTabs();
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
const next = active ?? createAiChatTabState();
|
|
2303
|
+
activateAiChatTab(next.id, { persistCurrent: false, force: true });
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function resetAiChatTabs() {
|
|
2307
|
+
aiRequestManager.cancelAll("已重置 Agent 对话页签");
|
|
2308
|
+
setAiConversationSwitcherVisible(false);
|
|
2309
|
+
setAiConversationWorkspaceVisible(false);
|
|
2310
|
+
const panels = $("#ai-chat-panels");
|
|
2311
|
+
panels.replaceChildren();
|
|
2312
|
+
aiChatTabManager.reset();
|
|
2313
|
+
const tab = createAiChatTabState();
|
|
2314
|
+
resetAiFeed(tab.feed, tab.roleplayCharacter);
|
|
2315
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function initializeAiChatTabs() {
|
|
2319
|
+
const feed = $("#ai-feed");
|
|
2320
|
+
aiChatTabManager.reset();
|
|
2321
|
+
const tab = createAiChatTabState({ feed });
|
|
2322
|
+
feed.dataset.aiTabId = tab.id;
|
|
2323
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function attachMessageHeading(message, label, createdAt = new Date().toISOString(), tab = activeAiChatTab()) {
|
|
1785
2327
|
const timestamp = createdAt || new Date().toISOString();
|
|
1786
|
-
const previousCreatedAt = state.aiLastMessageAt;
|
|
2328
|
+
const previousCreatedAt = tab?.lastMessageAt ?? state.aiLastMessageAt;
|
|
1787
2329
|
const heading = document.createElement("span");
|
|
1788
2330
|
heading.className = "message-heading";
|
|
1789
2331
|
const role = document.createElement("span");
|
|
@@ -1795,7 +2337,8 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
|
|
|
1795
2337
|
message.prepend(heading);
|
|
1796
2338
|
message.dataset.createdAt = timestamp;
|
|
1797
2339
|
message.dataset.previousCreatedAt = previousCreatedAt ?? "";
|
|
1798
|
-
|
|
2340
|
+
if (tab) tab.lastMessageAt = timestamp;
|
|
2341
|
+
if (isActiveAiChatTab(tab)) state.aiLastMessageAt = timestamp;
|
|
1799
2342
|
return heading;
|
|
1800
2343
|
}
|
|
1801
2344
|
|
|
@@ -1806,19 +2349,25 @@ function updateMessageCreatedAt(message, createdAt) {
|
|
|
1806
2349
|
time.dateTime = createdAt;
|
|
1807
2350
|
time.textContent = formatAiMessageTime(createdAt);
|
|
1808
2351
|
message.dataset.createdAt = createdAt;
|
|
1809
|
-
|
|
2352
|
+
const tab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
|
|
2353
|
+
if (message === tab?.feed.lastElementChild) {
|
|
2354
|
+
tab.lastMessageAt = createdAt;
|
|
2355
|
+
if (isActiveAiChatTab(tab)) state.aiLastMessageAt = createdAt;
|
|
2356
|
+
}
|
|
1810
2357
|
}
|
|
1811
2358
|
|
|
1812
|
-
function resetAiFeed() {
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
2359
|
+
function resetAiFeed(feed = $("#ai-feed"), roleplayCharacter = state.aiRoleplayCharacter) {
|
|
2360
|
+
const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
|
|
2361
|
+
if (tab) tab.lastMessageAt = null;
|
|
2362
|
+
if (isActiveAiChatTab(tab)) state.aiLastMessageAt = null;
|
|
2363
|
+
const roleplayName = roleplayCharacter?.name;
|
|
2364
|
+
feed.innerHTML = roleplayName
|
|
1816
2365
|
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
|
|
1817
2366
|
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
|
|
1818
2367
|
}
|
|
1819
2368
|
|
|
1820
|
-
function aiAssistantLabel(suffix = "") {
|
|
1821
|
-
const name =
|
|
2369
|
+
function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
|
|
2370
|
+
const name = roleplayCharacter?.name || "助手";
|
|
1822
2371
|
return suffix ? `${name} · ${suffix}` : name;
|
|
1823
2372
|
}
|
|
1824
2373
|
|
|
@@ -1834,26 +2383,24 @@ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "
|
|
|
1834
2383
|
return divider;
|
|
1835
2384
|
}
|
|
1836
2385
|
|
|
1837
|
-
function appendAiContextCompactionDivider(kind, before = null) {
|
|
2386
|
+
function appendAiContextCompactionDivider(kind, before = null, feed = $("#ai-feed")) {
|
|
1838
2387
|
const divider = createAiContextCompactionDivider({ kind });
|
|
1839
|
-
const feed = $("#ai-feed");
|
|
1840
2388
|
if (before?.parentElement === feed) feed.insertBefore(divider, before);
|
|
1841
2389
|
else feed.append(divider);
|
|
1842
|
-
scrollAiFeedToBottom();
|
|
2390
|
+
scrollAiFeedToBottom(feed);
|
|
1843
2391
|
return divider;
|
|
1844
2392
|
}
|
|
1845
2393
|
|
|
1846
|
-
function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
|
|
1847
|
-
const feed = $("#ai-feed");
|
|
2394
|
+
function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null, feed = $("#ai-feed"), conversation = null) {
|
|
1848
2395
|
feed.querySelector('[data-context-compaction="conversation"]')?.remove();
|
|
1849
2396
|
const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
|
|
1850
2397
|
if (compactedCount === 0) return null;
|
|
1851
2398
|
const messages = [...feed.querySelectorAll("[data-message-id]")];
|
|
1852
|
-
const current = state.aiConversations.find((
|
|
2399
|
+
const current = conversation ?? state.aiConversations.find((item) => item.id === state.aiConversationId);
|
|
1853
2400
|
const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
|
|
1854
2401
|
const loadedOffset = Math.max(0, totalCount - messages.length);
|
|
1855
2402
|
const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
|
|
1856
|
-
return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
|
|
2403
|
+
return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
|
|
1857
2404
|
}
|
|
1858
2405
|
|
|
1859
2406
|
function renderMessageCardActions(message) {
|
|
@@ -1890,9 +2437,16 @@ function renderMessageCardActions(message) {
|
|
|
1890
2437
|
fork.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V7M12 13v4"/></svg><span>从此续写</span>';
|
|
1891
2438
|
const forkRequestId = createAiIdempotencyKey();
|
|
1892
2439
|
fork.addEventListener("click", async () => {
|
|
2440
|
+
const openingSlot = aiChatTabOpeningSlot();
|
|
2441
|
+
if (!openingSlot.allowed) {
|
|
2442
|
+
reportAiChatTabLimit();
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
1893
2445
|
fork.disabled = true;
|
|
1894
2446
|
try {
|
|
1895
|
-
const
|
|
2447
|
+
const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
|
|
2448
|
+
if (!sourceTab?.conversationId) throw new Error("无法确定消息所属对话");
|
|
2449
|
+
const conversation = await api(`/api/ai-conversations/${sourceTab.conversationId}/fork`, {
|
|
1896
2450
|
method: "POST",
|
|
1897
2451
|
body: { messageId: message.dataset.messageId, requestId: forkRequestId }
|
|
1898
2452
|
});
|
|
@@ -1931,7 +2485,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1931
2485
|
search_drafts: "搜索想法",
|
|
1932
2486
|
recall_self: "回忆自身",
|
|
1933
2487
|
image: "读取设定图片",
|
|
1934
|
-
recall_relationship: "回忆人物关系"
|
|
2488
|
+
recall_relationship: "回忆人物关系",
|
|
2489
|
+
calculate_time: "计算日期"
|
|
1935
2490
|
};
|
|
1936
2491
|
|
|
1937
2492
|
const AI_TOOL_DESCRIPTIONS = {
|
|
@@ -1943,10 +2498,11 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
1943
2498
|
search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
|
|
1944
2499
|
recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
|
|
1945
2500
|
image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
|
|
1946
|
-
recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。"
|
|
2501
|
+
recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
|
|
2502
|
+
calculate_time: "计算日期差值,或从起始日期推算目标日期。"
|
|
1947
2503
|
};
|
|
1948
2504
|
|
|
1949
|
-
|
|
2505
|
+
const aiFeedScrollFrames = new WeakMap();
|
|
1950
2506
|
let markdownTableMenuTarget = null;
|
|
1951
2507
|
let markdownTableMenuTrigger = null;
|
|
1952
2508
|
|
|
@@ -1987,14 +2543,15 @@ function openMarkdownTableMenu(header, clientX, clientY) {
|
|
|
1987
2543
|
toggle.focus();
|
|
1988
2544
|
}
|
|
1989
2545
|
|
|
1990
|
-
function scrollAiFeedToBottom() {
|
|
1991
|
-
const feed = $("#ai-feed");
|
|
2546
|
+
function scrollAiFeedToBottom(feed = $("#ai-feed")) {
|
|
1992
2547
|
feed.scrollTop = feed.scrollHeight;
|
|
1993
|
-
|
|
1994
|
-
|
|
2548
|
+
const currentFrame = aiFeedScrollFrames.get(feed);
|
|
2549
|
+
if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
|
|
2550
|
+
const nextFrame = window.requestAnimationFrame(() => {
|
|
1995
2551
|
feed.scrollTop = feed.scrollHeight;
|
|
1996
|
-
|
|
2552
|
+
aiFeedScrollFrames.delete(feed);
|
|
1997
2553
|
});
|
|
2554
|
+
aiFeedScrollFrames.set(feed, nextFrame);
|
|
1998
2555
|
}
|
|
1999
2556
|
|
|
2000
2557
|
function formatAiToolCallTime(value) {
|
|
@@ -2012,9 +2569,14 @@ function formatAiToolCallTime(value) {
|
|
|
2012
2569
|
}).format(date);
|
|
2013
2570
|
}
|
|
2014
2571
|
|
|
2572
|
+
const AI_TOOL_CALL_COPY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg>';
|
|
2573
|
+
const AI_TOOL_CALL_COPIED_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12 4 4L19 6"></path></svg>';
|
|
2574
|
+
|
|
2015
2575
|
function setAiToolCallCopyButtonState(button, copied) {
|
|
2016
2576
|
const label = button.dataset.copyLabel ?? "代码块";
|
|
2017
|
-
|
|
2577
|
+
const nextState = copied ? "copied" : "idle";
|
|
2578
|
+
if (button.dataset.copyState !== nextState) button.innerHTML = copied ? AI_TOOL_CALL_COPIED_ICON : AI_TOOL_CALL_COPY_ICON;
|
|
2579
|
+
button.dataset.copyState = nextState;
|
|
2018
2580
|
button.classList.toggle("is-copied", copied);
|
|
2019
2581
|
button.setAttribute("aria-label", copied ? `${label}已复制` : `复制${label}`);
|
|
2020
2582
|
button.title = copied ? "已复制" : `复制${label}`;
|
|
@@ -2233,23 +2795,15 @@ async function downloadAiConversation(conversation) {
|
|
|
2233
2795
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
2234
2796
|
}
|
|
2235
2797
|
|
|
2798
|
+
async function copyAiConversationSessionId(conversation) {
|
|
2799
|
+
const sessionId = typeof conversation?.id === "string" ? conversation.id.trim() : "";
|
|
2800
|
+
if (!sessionId) throw new Error("无法确定对话 Session ID");
|
|
2801
|
+
await copyAiRawMarkdown(sessionId);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2236
2804
|
function resetAiConversationAfterDelete() {
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
state.aiPromptSent = false;
|
|
2240
|
-
state.aiCitations = [];
|
|
2241
|
-
state.aiReferences = [];
|
|
2242
|
-
applyAiConversationTaskType("chat");
|
|
2243
|
-
applyAiConversationContextScope({ type: "none" });
|
|
2244
|
-
applyAiRoleplayCharacter(null);
|
|
2245
|
-
$("#ai-conversation-title").textContent = "新对话";
|
|
2246
|
-
resetAiFeed();
|
|
2247
|
-
hideAiContextWarning();
|
|
2248
|
-
resetAiContextMeter();
|
|
2249
|
-
setAiPromptText("");
|
|
2250
|
-
renderAiCitations();
|
|
2251
|
-
renderAiReferences();
|
|
2252
|
-
renderAiQuickActions();
|
|
2805
|
+
const tab = activeAiChatTab();
|
|
2806
|
+
if (tab) closeAiChatTab(tab.id);
|
|
2253
2807
|
}
|
|
2254
2808
|
|
|
2255
2809
|
async function favoriteAiConversation(conversation) {
|
|
@@ -2272,7 +2826,11 @@ async function deleteAiConversation(conversation) {
|
|
|
2272
2826
|
})) return;
|
|
2273
2827
|
await api(`/api/ai-conversations/${encodeURIComponent(conversation.id)}`, { method: "DELETE" });
|
|
2274
2828
|
state.aiConversations = state.aiConversations.filter((item) => item.id !== conversation.id);
|
|
2275
|
-
|
|
2829
|
+
const tab = aiChatTabManager.findByConversation(conversation.id);
|
|
2830
|
+
if (tab) {
|
|
2831
|
+
if (isActiveAiChatTab(tab)) resetAiConversationAfterDelete();
|
|
2832
|
+
else closeAiChatTab(tab.id);
|
|
2833
|
+
}
|
|
2276
2834
|
renderAiConversationHistory();
|
|
2277
2835
|
toast("对话已清理");
|
|
2278
2836
|
}
|
|
@@ -2408,8 +2966,11 @@ function applyAiConversationTitle(title, conversationId = state.aiConversationId
|
|
|
2408
2966
|
if (!title || !conversationId) return;
|
|
2409
2967
|
const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
|
|
2410
2968
|
if (current) current.title = title;
|
|
2969
|
+
const tab = aiChatTabManager.findByConversation(conversationId);
|
|
2970
|
+
if (tab) tab.title = title;
|
|
2411
2971
|
if (state.aiConversationId === conversationId) $("#ai-conversation-title").textContent = title;
|
|
2412
2972
|
renderAiConversationHistory();
|
|
2973
|
+
renderAiChatTabs();
|
|
2413
2974
|
}
|
|
2414
2975
|
|
|
2415
2976
|
function applyAiConversations(pageResult) {
|
|
@@ -2453,6 +3014,21 @@ async function ensureAiConversationsLoaded() {
|
|
|
2453
3014
|
|
|
2454
3015
|
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
|
|
2455
3016
|
if (!state.work) return null;
|
|
3017
|
+
const existingTab = aiChatTabManager.findByConversation(conversationId);
|
|
3018
|
+
const existingMessage = focusMessageId
|
|
3019
|
+
? existingTab?.feed.querySelector(`[data-message-id="${CSS.escape(String(focusMessageId))}"]`)
|
|
3020
|
+
: null;
|
|
3021
|
+
if (existingTab && (!focusMessageId || existingMessage || aiRequestManager.hasActive(existingTab.id))) {
|
|
3022
|
+
activateAiChatTab(existingTab.id);
|
|
3023
|
+
if (focusMessageId && existingMessage) focusAiConversationMessage(focusMessageId);
|
|
3024
|
+
if (hideHistory) setAiHistoryVisible(false);
|
|
3025
|
+
return state.aiConversations.find((conversation) => conversation.id === conversationId) ?? existingTab;
|
|
3026
|
+
}
|
|
3027
|
+
let openingSlot = existingTab ? { allowed: true, tab: existingTab, replacing: false } : aiChatTabOpeningSlot();
|
|
3028
|
+
if (!openingSlot.allowed) {
|
|
3029
|
+
reportAiChatTabLimit();
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
2456
3032
|
const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
|
|
2457
3033
|
try {
|
|
2458
3034
|
const parameters = new URLSearchParams({ page: "1", limit: "100" });
|
|
@@ -2464,28 +3040,18 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
|
|
|
2464
3040
|
if (!isAiConversationNavigationCurrent(navigation)) return null;
|
|
2465
3041
|
if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
|
|
2466
3042
|
upsertAiConversationSummary(conversation);
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
2473
|
-
resetAiContextMeter();
|
|
2474
|
-
$("#ai-conversation-title").textContent = conversation.title;
|
|
2475
|
-
resetAiFeed();
|
|
2476
|
-
for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
|
|
2477
|
-
if (conversation.hasCompactedSummary) {
|
|
2478
|
-
renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
|
|
3043
|
+
persistActiveAiChatTab();
|
|
3044
|
+
if (!existingTab) openingSlot = aiChatTabOpeningSlot();
|
|
3045
|
+
if (!openingSlot.allowed) {
|
|
3046
|
+
reportAiChatTabLimit();
|
|
3047
|
+
return null;
|
|
2479
3048
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
renderAiConversationHistory();
|
|
2487
|
-
if (conversation.contextWarningPending) showAiContextWarning();
|
|
2488
|
-
else hideAiContextWarning();
|
|
3049
|
+
interruptAiChatTabForReplacement(openingSlot);
|
|
3050
|
+
let tab = existingTab ?? openingSlot.tab;
|
|
3051
|
+
if (!tab) tab = createAiChatTabState();
|
|
3052
|
+
applyConversationToAiChatTab(tab, conversation);
|
|
3053
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
3054
|
+
if (focusMessageId) window.requestAnimationFrame(() => focusAiConversationMessage(focusMessageId));
|
|
2489
3055
|
if (hideHistory) setAiHistoryVisible(false);
|
|
2490
3056
|
return conversation;
|
|
2491
3057
|
} catch (error) {
|
|
@@ -2505,30 +3071,60 @@ function focusAiConversationMessage(messageId) {
|
|
|
2505
3071
|
window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
|
|
2506
3072
|
}
|
|
2507
3073
|
|
|
2508
|
-
function
|
|
3074
|
+
function applyConversationToAiChatTab(tab, conversation) {
|
|
3075
|
+
tab.workId = String(conversation.workId ?? state.work?.id ?? "");
|
|
3076
|
+
tab.conversationId = conversation.id;
|
|
3077
|
+
tab.title = conversation.title || "新对话";
|
|
3078
|
+
tab.modelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
|
|
3079
|
+
tab.selectedModelId = tab.modelId ?? tab.selectedModelId;
|
|
3080
|
+
tab.promptSent = Array.isArray(conversation.messages) && conversation.messages.some((message) => message.role === "user");
|
|
3081
|
+
tab.taskType = conversation.taskType ?? "chat";
|
|
3082
|
+
tab.contextScope = conversation.contextScope ?? { type: "none" };
|
|
3083
|
+
tab.roleplayCharacter = conversation.roleplayCharacter ?? null;
|
|
3084
|
+
tab.citations = [];
|
|
3085
|
+
tab.references = [];
|
|
3086
|
+
tab.composer = { text: "", citations: [], references: [] };
|
|
3087
|
+
tab.contextUsage = null;
|
|
3088
|
+
tab.contextWarning = conversation.contextWarningPending === true;
|
|
3089
|
+
resetAiFeed(tab.feed, tab.roleplayCharacter);
|
|
3090
|
+
for (const message of conversation.messages ?? []) {
|
|
3091
|
+
appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id, { tab });
|
|
3092
|
+
}
|
|
3093
|
+
if (conversation.hasCompactedSummary) {
|
|
3094
|
+
renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount, tab.feed, conversation);
|
|
3095
|
+
}
|
|
3096
|
+
renderAiChatTabs();
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function applyNewAiConversation(conversation, tab = activeAiChatTab()) {
|
|
2509
3100
|
upsertAiConversationSummary(conversation);
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
state.aiPromptSent = false;
|
|
2513
|
-
applyAiConversationTaskType(conversation.taskType);
|
|
2514
|
-
applyAiConversationContextScope(conversation.contextScope);
|
|
2515
|
-
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
2516
|
-
$("#ai-conversation-title").textContent = conversation.title;
|
|
2517
|
-
resetAiFeed();
|
|
2518
|
-
hideAiContextWarning();
|
|
2519
|
-
resetAiContextMeter();
|
|
2520
|
-
renderAiQuickActions();
|
|
3101
|
+
applyConversationToAiChatTab(tab, { ...conversation, messages: [] });
|
|
3102
|
+
if (isActiveAiChatTab(tab)) applyAiChatTabState(tab);
|
|
2521
3103
|
setAiHistoryVisible(false);
|
|
2522
3104
|
}
|
|
2523
3105
|
|
|
2524
3106
|
async function createNewAiConversation(taskType = "chat") {
|
|
2525
3107
|
if (!state.work) return null;
|
|
3108
|
+
let openingSlot = aiChatTabOpeningSlot();
|
|
3109
|
+
if (!openingSlot.allowed) {
|
|
3110
|
+
reportAiChatTabLimit();
|
|
3111
|
+
return null;
|
|
3112
|
+
}
|
|
2526
3113
|
const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
|
|
2527
3114
|
try {
|
|
2528
3115
|
const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
|
|
2529
3116
|
if (!isAiConversationNavigationCurrent(navigation)) return null;
|
|
2530
3117
|
if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("新对话不属于当前作品");
|
|
2531
|
-
|
|
3118
|
+
persistActiveAiChatTab();
|
|
3119
|
+
openingSlot = aiChatTabOpeningSlot();
|
|
3120
|
+
if (!openingSlot.allowed) {
|
|
3121
|
+
reportAiChatTabLimit();
|
|
3122
|
+
return null;
|
|
3123
|
+
}
|
|
3124
|
+
interruptAiChatTabForReplacement(openingSlot);
|
|
3125
|
+
const tab = openingSlot.tab ?? createAiChatTabState();
|
|
3126
|
+
applyNewAiConversation(conversation, tab);
|
|
3127
|
+
activateAiChatTab(tab.id, { persistCurrent: false, force: true });
|
|
2532
3128
|
return conversation;
|
|
2533
3129
|
} catch (error) {
|
|
2534
3130
|
if (!isAiConversationNavigationCurrent(navigation)) return null;
|
|
@@ -2610,6 +3206,8 @@ function syncAiTaskOptions() {
|
|
|
2610
3206
|
function applyAiConversationTaskType(taskType) {
|
|
2611
3207
|
const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
|
|
2612
3208
|
state.aiTaskType = normalizedTaskType;
|
|
3209
|
+
const tab = activeAiChatTab();
|
|
3210
|
+
if (tab) tab.taskType = normalizedTaskType;
|
|
2613
3211
|
$("#ai-task").value = normalizedTaskType;
|
|
2614
3212
|
$("#ai-task").dataset.previousValue = normalizedTaskType;
|
|
2615
3213
|
syncAiTaskOptions();
|
|
@@ -2618,6 +3216,8 @@ function applyAiConversationTaskType(taskType) {
|
|
|
2618
3216
|
function applyAiConversationContextScope(scope) {
|
|
2619
3217
|
const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
|
|
2620
3218
|
state.aiContextScope = normalizedScope;
|
|
3219
|
+
const tab = activeAiChatTab();
|
|
3220
|
+
if (tab) tab.contextScope = { ...normalizedScope };
|
|
2621
3221
|
$("#ai-scope").value = normalizedScope.type === "book" ? "book"
|
|
2622
3222
|
: normalizedScope.type === "volume" ? "volume"
|
|
2623
3223
|
: normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
|
|
@@ -2628,6 +3228,8 @@ function applyAiConversationContextScope(scope) {
|
|
|
2628
3228
|
|
|
2629
3229
|
function applyAiRoleplayCharacter(character) {
|
|
2630
3230
|
state.aiRoleplayCharacter = character?.id ? character : null;
|
|
3231
|
+
const tab = activeAiChatTab();
|
|
3232
|
+
if (tab) tab.roleplayCharacter = state.aiRoleplayCharacter ? { ...state.aiRoleplayCharacter } : null;
|
|
2631
3233
|
const active = Boolean(state.aiRoleplayCharacter);
|
|
2632
3234
|
if (active) $("#ai-scope").value = "none";
|
|
2633
3235
|
$(".ai-panel").classList.toggle("is-roleplaying", active);
|
|
@@ -2693,6 +3295,8 @@ async function persistAiConversationContextScope(scope) {
|
|
|
2693
3295
|
|
|
2694
3296
|
async function prepareAiRequestConversation(requestHolder, taskType, scope) {
|
|
2695
3297
|
let request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
3298
|
+
const tab = aiChatTabForRequest(request);
|
|
3299
|
+
if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
|
|
2696
3300
|
if (!request.conversationId) {
|
|
2697
3301
|
const conversation = await api(`/api/works/${encodeURIComponent(request.workId)}/ai-conversations`, {
|
|
2698
3302
|
method: "POST",
|
|
@@ -2701,7 +3305,7 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
|
|
|
2701
3305
|
});
|
|
2702
3306
|
assertAiRequestCurrent(request);
|
|
2703
3307
|
if (String(conversation.workId ?? "") !== request.workId) throw new Error("新对话不属于请求作品");
|
|
2704
|
-
applyNewAiConversation(conversation);
|
|
3308
|
+
applyNewAiConversation(conversation, tab);
|
|
2705
3309
|
requestHolder.snapshot = aiRequestManager.bind(request, { conversationId: conversation.id });
|
|
2706
3310
|
request = requestHolder.snapshot;
|
|
2707
3311
|
}
|
|
@@ -2712,8 +3316,12 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
|
|
|
2712
3316
|
});
|
|
2713
3317
|
assertAiRequestCurrent(request);
|
|
2714
3318
|
upsertAiConversationSummary(taskConversation);
|
|
2715
|
-
|
|
2716
|
-
|
|
3319
|
+
tab.taskType = taskConversation.taskType;
|
|
3320
|
+
tab.roleplayCharacter = taskConversation.roleplayCharacter ?? null;
|
|
3321
|
+
if (isActiveAiChatTab(tab)) {
|
|
3322
|
+
applyAiConversationTaskType(tab.taskType);
|
|
3323
|
+
applyAiRoleplayCharacter(tab.roleplayCharacter);
|
|
3324
|
+
}
|
|
2717
3325
|
|
|
2718
3326
|
const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
|
|
2719
3327
|
method: "PATCH",
|
|
@@ -2722,7 +3330,8 @@ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
|
|
|
2722
3330
|
});
|
|
2723
3331
|
assertAiRequestCurrent(request);
|
|
2724
3332
|
upsertAiConversationSummary(scopedConversation);
|
|
2725
|
-
|
|
3333
|
+
tab.contextScope = scopedConversation.contextScope;
|
|
3334
|
+
if (isActiveAiChatTab(tab)) applyAiConversationContextScope(tab.contextScope);
|
|
2726
3335
|
return request;
|
|
2727
3336
|
}
|
|
2728
3337
|
|
|
@@ -2746,13 +3355,29 @@ async function persistAiRequestInterruption(request, interruption = null) {
|
|
|
2746
3355
|
const partialContent = typeof interruption?.content === "string" && interruption.content.trim()
|
|
2747
3356
|
? interruption.content
|
|
2748
3357
|
: null;
|
|
3358
|
+
const cancellationMessage = request.signal.reason instanceof Error
|
|
3359
|
+
? request.signal.reason.message
|
|
3360
|
+
: "AI 请求已取消";
|
|
3361
|
+
const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
|
|
3362
|
+
const metadata = cancelledByClient
|
|
3363
|
+
? {
|
|
3364
|
+
...(partialContent ? interruption.metadata : {}),
|
|
3365
|
+
interrupted: true,
|
|
3366
|
+
interruptionCode: "AI_REQUEST_CANCELLED",
|
|
3367
|
+
interruptionMessage: cancellationMessage.slice(0, 500)
|
|
3368
|
+
}
|
|
3369
|
+
: interruption?.metadata ?? {
|
|
3370
|
+
interrupted: true,
|
|
3371
|
+
interruptionCode: "AI_REQUEST_CANCELLED",
|
|
3372
|
+
interruptionMessage: cancellationMessage.slice(0, 500)
|
|
3373
|
+
};
|
|
2749
3374
|
try {
|
|
2750
3375
|
const message = await persistAiConversationMessage(
|
|
2751
3376
|
request.conversationId,
|
|
2752
3377
|
"assistant",
|
|
2753
|
-
partialContent ?? "
|
|
3378
|
+
partialContent ?? "生成已终止,尚未收到可保留的回复内容。",
|
|
2754
3379
|
[],
|
|
2755
|
-
|
|
3380
|
+
metadata,
|
|
2756
3381
|
{ requestId: aiAssistantRequestId(request) }
|
|
2757
3382
|
);
|
|
2758
3383
|
updateAiConversationSummaryFromMessage(message);
|
|
@@ -3361,6 +3986,7 @@ async function api(path, options = {}) {
|
|
|
3361
3986
|
const payload = await response.json();
|
|
3362
3987
|
if (path === "/api/health") {
|
|
3363
3988
|
applyImageUploadLimits(payload.data?.uploadLimits);
|
|
3989
|
+
applyAiChatTabLimit(payload.data?.aiChatTabLimit);
|
|
3364
3990
|
updateSystemHealth({
|
|
3365
3991
|
status: payload.data?.status === "ok" ? "ready" : "degraded",
|
|
3366
3992
|
version: payload.data?.version
|
|
@@ -3447,6 +4073,7 @@ function aiStreamInterruptionLabel(code) {
|
|
|
3447
4073
|
if (code === "AI_STREAM_NETWORK_ERROR") return "网络中断";
|
|
3448
4074
|
if (code === "AI_STREAM_UPSTREAM_CLOSED") return "流被关闭";
|
|
3449
4075
|
if (code === "AI_STREAM_REQUEST_CANCELLED") return "请求已取消";
|
|
4076
|
+
if (code === "AI_REQUEST_CANCELLED") return "已终止";
|
|
3450
4077
|
return "生成中断";
|
|
3451
4078
|
}
|
|
3452
4079
|
|
|
@@ -3578,8 +4205,9 @@ function invalidateModuleRequestsAfterMutation(path, method) {
|
|
|
3578
4205
|
|
|
3579
4206
|
function applyProductHealthMetadata(health) {
|
|
3580
4207
|
const version = String(health?.version ?? "").trim();
|
|
4208
|
+
const versionLabel = String(health?.versionLabel ?? "").trim();
|
|
3581
4209
|
document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
|
|
3582
|
-
element.textContent = version ? `v${version}` : "v—";
|
|
4210
|
+
element.textContent = versionLabel || (version ? `v${version}` : "v—");
|
|
3583
4211
|
});
|
|
3584
4212
|
document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
|
|
3585
4213
|
element.classList.toggle("hidden", health?.development !== true);
|
|
@@ -4286,7 +4914,6 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
4286
4914
|
const currentDraft = chapterDraftSnapshot();
|
|
4287
4915
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
4288
4916
|
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
4289
|
-
if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
|
|
4290
4917
|
} else {
|
|
4291
4918
|
scheduleChapterAutoSave(250);
|
|
4292
4919
|
}
|
|
@@ -5359,12 +5986,29 @@ function syncGlobalReplaceScopeOptions() {
|
|
|
5359
5986
|
const selected = options.find((option) => option.checked && !option.disabled) ?? options.find((option) => !option.disabled);
|
|
5360
5987
|
options.forEach((option) => { option.checked = option === selected; });
|
|
5361
5988
|
const scope = selected?.value ?? "";
|
|
5989
|
+
const volumeSelect = $("#replace-volume");
|
|
5990
|
+
const volumeEnabled = scope === "prose" || scope === "prose-and-settings";
|
|
5991
|
+
volumeSelect.disabled = !volumeEnabled || !canEditProse();
|
|
5992
|
+
$("#replace-volume-description").textContent = scope === "settings"
|
|
5993
|
+
? "当前选择了设定库,分卷筛选不会生效。"
|
|
5994
|
+
: "选择分卷后,只替换该分卷的章节正文;设定库不按分卷筛选。";
|
|
5362
5995
|
$("#replace-submit").disabled = !scope || !canGlobalReplaceScope(scope);
|
|
5363
5996
|
$("#replace-permission-note").textContent = scope
|
|
5364
5997
|
? "替换完成后,命中的章节和设定会分别生成新的版本历史。"
|
|
5365
5998
|
: "当前账户没有可写入的正文或设定库权限。";
|
|
5366
5999
|
}
|
|
5367
6000
|
|
|
6001
|
+
function renderGlobalReplaceVolumeOptions() {
|
|
6002
|
+
const select = $("#replace-volume");
|
|
6003
|
+
const currentValue = select.value;
|
|
6004
|
+
const volumes = Array.isArray(state.work?.volumes) ? state.work.volumes : [];
|
|
6005
|
+
select.innerHTML = [
|
|
6006
|
+
'<option value="">全部分卷(整部作品正文)</option>',
|
|
6007
|
+
...volumes.map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`)
|
|
6008
|
+
].join("");
|
|
6009
|
+
select.value = volumes.some((volume) => String(volume.id) === currentValue) ? currentValue : "";
|
|
6010
|
+
}
|
|
6011
|
+
|
|
5368
6012
|
function openGlobalReplaceDialog() {
|
|
5369
6013
|
if (!state.work) {
|
|
5370
6014
|
toast("请先打开一部作品", "error");
|
|
@@ -5376,6 +6020,7 @@ function openGlobalReplaceDialog() {
|
|
|
5376
6020
|
}
|
|
5377
6021
|
if ($("#search-dialog").open) $("#search-dialog").close();
|
|
5378
6022
|
$("#replace-form").reset();
|
|
6023
|
+
renderGlobalReplaceVolumeOptions();
|
|
5379
6024
|
syncGlobalReplaceScopeOptions();
|
|
5380
6025
|
$("#replace-dialog").showModal();
|
|
5381
6026
|
queueMicrotask(() => $("#replace-find").focus());
|
|
@@ -5463,6 +6108,7 @@ async function submitGlobalReplace(event) {
|
|
|
5463
6108
|
const find = $("#replace-find").value;
|
|
5464
6109
|
const replacement = $("#replace-with").value;
|
|
5465
6110
|
const scope = $("#replace-form").querySelector('input[name="replaceScope"]:checked')?.value ?? "prose";
|
|
6111
|
+
const volumeId = scope === "settings" ? null : ($("#replace-volume").value || null);
|
|
5466
6112
|
if (!find.trim()) {
|
|
5467
6113
|
toast("请输入要查找的内容", "error");
|
|
5468
6114
|
$("#replace-find").focus();
|
|
@@ -5485,8 +6131,14 @@ async function submitGlobalReplace(event) {
|
|
|
5485
6131
|
return;
|
|
5486
6132
|
}
|
|
5487
6133
|
const scopeLabel = globalReplaceScopeLabels[scope] ?? "正文";
|
|
6134
|
+
const selectedVolume = volumeId
|
|
6135
|
+
? state.work.volumes.find((volume) => String(volume.id) === volumeId)
|
|
6136
|
+
: null;
|
|
6137
|
+
const targetLabel = selectedVolume
|
|
6138
|
+
? `${scopeLabel}(正文仅限“${selectedVolume.title}”分卷${scope === "prose-and-settings" ? ",设定库仍为全部设定库" : ""})`
|
|
6139
|
+
: scopeLabel;
|
|
5488
6140
|
const replacementLabel = replacement ? `“${replacement}”` : "空内容";
|
|
5489
|
-
const confirmed = await confirmToast(`将把《${state.work.title}》的${
|
|
6141
|
+
const confirmed = await confirmToast(`将把《${state.work.title}》的${targetLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
|
|
5490
6142
|
title: "确认全局替换",
|
|
5491
6143
|
confirmLabel: "确认替换"
|
|
5492
6144
|
});
|
|
@@ -5511,7 +6163,7 @@ async function submitGlobalReplace(event) {
|
|
|
5511
6163
|
try {
|
|
5512
6164
|
const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
|
|
5513
6165
|
method: "POST",
|
|
5514
|
-
body: { find, replacement, scope },
|
|
6166
|
+
body: { find, replacement, scope, volumeId },
|
|
5515
6167
|
skipOptimisticVersion: true
|
|
5516
6168
|
});
|
|
5517
6169
|
$("#replace-dialog").close();
|
|
@@ -5833,23 +6485,9 @@ function resetWorkScopedUiCaches() {
|
|
|
5833
6485
|
volumeChapterRequests.clear();
|
|
5834
6486
|
state.collapsedRaceIds.clear();
|
|
5835
6487
|
lastSavedChapterSnapshot = null;
|
|
5836
|
-
state.aiCitations = [];
|
|
5837
|
-
state.aiReferences = [];
|
|
5838
|
-
state.aiPromptSent = false;
|
|
5839
|
-
state.aiConversationId = null;
|
|
5840
|
-
state.aiConversationModelId = null;
|
|
5841
6488
|
state.aiConversations = [];
|
|
5842
|
-
|
|
5843
|
-
renderAiCitations();
|
|
5844
|
-
renderAiReferences();
|
|
5845
|
-
renderAiQuickActions();
|
|
5846
|
-
resetAiFeed();
|
|
5847
|
-
applyAiConversationTaskType("chat");
|
|
5848
|
-
applyAiConversationContextScope({ type: "none" });
|
|
5849
|
-
applyAiRoleplayCharacter(null);
|
|
5850
|
-
$("#ai-conversation-title").textContent = "新对话";
|
|
6489
|
+
resetAiChatTabs();
|
|
5851
6490
|
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
5852
|
-
resetAiContextMeter();
|
|
5853
6491
|
renderAiConversationHistory();
|
|
5854
6492
|
}
|
|
5855
6493
|
|
|
@@ -5994,7 +6632,7 @@ function renderTree() {
|
|
|
5994
6632
|
<div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
|
|
5995
6633
|
<div class="volume-title">
|
|
5996
6634
|
<button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
|
|
5997
|
-
${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情"
|
|
6635
|
+
${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情" title="打开“${esc(volume.title)}”分卷详情"><svg class="volume-detail-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 10.5v5.5M12 7.5h.01"></path></svg></button>` : ""}
|
|
5998
6636
|
${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
|
|
5999
6637
|
</div>
|
|
6000
6638
|
<div class="volume-chapters">
|
|
@@ -6506,6 +7144,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
6506
7144
|
if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
|
|
6507
7145
|
return false;
|
|
6508
7146
|
}
|
|
7147
|
+
resetChapterSearchPanel();
|
|
6509
7148
|
dismissDeleteToasts();
|
|
6510
7149
|
if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
|
|
6511
7150
|
cancelChapterAutoSave();
|
|
@@ -7021,22 +7660,15 @@ function handleReadingKeyboard(event) {
|
|
|
7021
7660
|
}
|
|
7022
7661
|
}
|
|
7023
7662
|
|
|
7024
|
-
function handleReadingWheel(event) {
|
|
7025
|
-
if (readingPreferences.mode !== "scroll" || readingLoading || Math.abs(event.deltaY) < 2) return;
|
|
7026
|
-
const viewport = $("#reader-viewport");
|
|
7027
|
-
const atEnd = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 2;
|
|
7028
|
-
const atStart = viewport.scrollTop <= 2;
|
|
7029
|
-
if (event.deltaY > 0 && atEnd) {
|
|
7030
|
-
event.preventDefault();
|
|
7031
|
-
void navigateReadingChapter(1, { continueFromBoundary: true });
|
|
7032
|
-
} else if (event.deltaY < 0 && atStart) {
|
|
7033
|
-
event.preventDefault();
|
|
7034
|
-
void navigateReadingChapter(-1, { continueFromBoundary: true });
|
|
7035
|
-
}
|
|
7036
|
-
}
|
|
7037
|
-
|
|
7038
7663
|
async function saveChapter() {
|
|
7039
|
-
|
|
7664
|
+
const dismissSavingToast = persistentToast("正在保存中");
|
|
7665
|
+
try {
|
|
7666
|
+
const saved = await persistChapter({ automatic: false });
|
|
7667
|
+
if (saved) toast(`保存成功(正文 v${saved.versionNo})`);
|
|
7668
|
+
return saved;
|
|
7669
|
+
} finally {
|
|
7670
|
+
dismissSavingToast();
|
|
7671
|
+
}
|
|
7040
7672
|
}
|
|
7041
7673
|
|
|
7042
7674
|
function tidyChapterBlankLines() {
|
|
@@ -7067,7 +7699,7 @@ function showWelcome(hasWork = false) {
|
|
|
7067
7699
|
}
|
|
7068
7700
|
|
|
7069
7701
|
function markActiveModule(module) {
|
|
7070
|
-
$("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module));
|
|
7702
|
+
$("#module-nav").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === module || (button.id === "ai-assistant-entry" && aiConversationWorkspaceOpen)));
|
|
7071
7703
|
}
|
|
7072
7704
|
|
|
7073
7705
|
const moduleMeta = {
|
|
@@ -7865,7 +8497,10 @@ async function renderSettings(page = moduleListPages.settings) {
|
|
|
7865
8497
|
}
|
|
7866
8498
|
|
|
7867
8499
|
async function renderCharacters(page = characterListPage) {
|
|
7868
|
-
const hasCharacterFilters = characterFilters.raceIds.length > 0
|
|
8500
|
+
const hasCharacterFilters = characterFilters.raceIds.length > 0
|
|
8501
|
+
|| characterFilters.organizationIds.length > 0
|
|
8502
|
+
|| characterFilters.genderValues.length > 0
|
|
8503
|
+
|| characterFilters.deathState !== "all";
|
|
7869
8504
|
const pageSize = pageSizeFor("characters");
|
|
7870
8505
|
const [characterSource, races, organizations] = await Promise.all([
|
|
7871
8506
|
hasCharacterFilters
|
|
@@ -7893,6 +8528,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
7893
8528
|
<article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
|
|
7894
8529
|
<div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
7895
8530
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
8531
|
+
<div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
|
|
7896
8532
|
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
7897
8533
|
${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
|
|
7898
8534
|
${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
|
|
@@ -7906,6 +8542,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
7906
8542
|
const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
|
|
7907
8543
|
const meta = [
|
|
7908
8544
|
item.code ? `编号 ${item.code}` : "",
|
|
8545
|
+
`性别 ${characterGenderLabel(item.gender)}`,
|
|
7909
8546
|
item.species ? (racePathLabel(item.race) || item.species) : "",
|
|
7910
8547
|
...(item.aliases ?? []).slice(0, 3),
|
|
7911
8548
|
(item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
|
|
@@ -7927,16 +8564,25 @@ async function renderCharacters(page = characterListPage) {
|
|
|
7927
8564
|
: "";
|
|
7928
8565
|
const selectedRaceIds = new Set(characterFilters.raceIds);
|
|
7929
8566
|
const selectedOrganizationIds = new Set(characterFilters.organizationIds);
|
|
8567
|
+
const selectedGenderValues = new Set(characterFilters.genderValues);
|
|
7930
8568
|
const selectedRaceNames = races.filter((race) => selectedRaceIds.has(String(race.id))).map((race) => racePathLabel(race) || race.name);
|
|
7931
8569
|
const selectedOrganizationNames = organizations.filter((organization) => selectedOrganizationIds.has(String(organization.id))).map((organization) => organization.name);
|
|
8570
|
+
const selectedGenderNames = CHARACTER_GENDER_OPTIONS
|
|
8571
|
+
.filter(([value]) => selectedGenderValues.has(value))
|
|
8572
|
+
.map(([, label]) => label);
|
|
8573
|
+
const selectedDeathStateLabel = CHARACTER_DEATH_STATE_OPTIONS.find(([value]) => value === characterFilters.deathState)?.[1] ?? "全部状态";
|
|
7932
8574
|
const filterOptionList = (items, selectedIds, valueKey = "id") => items.map((item) => {
|
|
7933
8575
|
const value = String(item[valueKey]);
|
|
7934
8576
|
const label = valueKey === "id" ? (racePathLabel(item) || item.name) : item.name;
|
|
7935
8577
|
return `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedIds.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`;
|
|
7936
8578
|
}).join("");
|
|
7937
|
-
const
|
|
8579
|
+
const genderFilterOptions = CHARACTER_GENDER_OPTIONS.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedGenderValues.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("");
|
|
8580
|
+
const deathStateFilterOptions = CHARACTER_DEATH_STATE_OPTIONS.map(([value, label]) => `<label class="character-filter-option"><input type="radio" name="character-death-state" value="${esc(value)}" ${characterFilters.deathState === value ? "checked" : ""}><span>${esc(label)}</span></label>`).join("");
|
|
8581
|
+
const filterToolbar = `<section id="character-filter-panel" class="character-filter-toolbar character-filter-toolbar-with-extra-filters${characterFiltersPanelOpen ? "" : " hidden"}" aria-label="角色筛选">
|
|
7938
8582
|
<details class="character-filter-dropdown"><summary><span>按种族筛选</span><strong>${selectedRaceNames.length ? `已选 ${selectedRaceNames.length} 项` : "全部种族"}</strong></summary><div id="character-race-filter" class="character-filter-options">${filterOptionList(orderRaceFilterOptions(races), selectedRaceIds)}</div></details>
|
|
7939
8583
|
<details class="character-filter-dropdown"><summary><span>按组织筛选</span><strong>${selectedOrganizationNames.length ? `已选 ${selectedOrganizationNames.length} 项` : "全部组织"}</strong></summary><div id="character-organization-filter" class="character-filter-options">${filterOptionList(organizations, selectedOrganizationIds, "id")}</div></details>
|
|
8584
|
+
<details class="character-filter-dropdown"><summary><span>按性别筛选</span><strong>${selectedGenderNames.length ? `已选 ${selectedGenderNames.length} 项` : "全部性别"}</strong></summary><div id="character-gender-filter" class="character-filter-options">${genderFilterOptions}</div></details>
|
|
8585
|
+
<details class="character-filter-dropdown"><summary><span>按死亡状态筛选</span><strong>${esc(selectedDeathStateLabel)}</strong></summary><div id="character-death-filter" class="character-filter-options">${deathStateFilterOptions}</div></details>
|
|
7940
8586
|
<div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
|
|
7941
8587
|
</section>`;
|
|
7942
8588
|
mountCharacterFilterToggle();
|
|
@@ -7958,10 +8604,24 @@ async function renderCharacters(page = characterListPage) {
|
|
|
7958
8604
|
characterListPage = 1;
|
|
7959
8605
|
await renderCharacters(1);
|
|
7960
8606
|
});
|
|
8607
|
+
$("#character-gender-filter").addEventListener("change", async () => {
|
|
8608
|
+
characterFiltersPanelOpen = true;
|
|
8609
|
+
characterFilters.genderValues = readSelectedValues("#character-gender-filter");
|
|
8610
|
+
characterListPage = 1;
|
|
8611
|
+
await renderCharacters(1);
|
|
8612
|
+
});
|
|
8613
|
+
$("#character-death-filter").addEventListener("change", async (event) => {
|
|
8614
|
+
characterFiltersPanelOpen = true;
|
|
8615
|
+
characterFilters.deathState = CHARACTER_DEATH_STATE_OPTIONS.some(([value]) => value === event.target.value) ? event.target.value : "all";
|
|
8616
|
+
characterListPage = 1;
|
|
8617
|
+
await renderCharacters(1);
|
|
8618
|
+
});
|
|
7961
8619
|
$("#clear-character-filters")?.addEventListener("click", async () => {
|
|
7962
8620
|
characterFiltersPanelOpen = true;
|
|
7963
8621
|
characterFilters.raceIds = [];
|
|
7964
8622
|
characterFilters.organizationIds = [];
|
|
8623
|
+
characterFilters.genderValues = [];
|
|
8624
|
+
characterFilters.deathState = "all";
|
|
7965
8625
|
characterListPage = 1;
|
|
7966
8626
|
await renderCharacters(1);
|
|
7967
8627
|
});
|
|
@@ -8556,9 +9216,9 @@ async function renderRelationships(page = moduleListPages.relationships) {
|
|
|
8556
9216
|
if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
|
|
8557
9217
|
const graph = buildRelationshipGraph(state.characters, relationships);
|
|
8558
9218
|
state.relationshipGraph = graph;
|
|
8559
|
-
const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
|
|
9219
|
+
const relationshipList = filteredRelationships.length ? `<div class="relationship-table-wrapper"><table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
|
|
8560
9220
|
<tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
|
|
8561
|
-
<td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
|
|
9221
|
+
<td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table></div>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
|
|
8562
9222
|
$("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
|
|
8563
9223
|
bindModulePagination("relationships", renderRelationships);
|
|
8564
9224
|
const openGalaxy = () => {
|
|
@@ -8566,6 +9226,8 @@ async function renderRelationships(page = moduleListPages.relationships) {
|
|
|
8566
9226
|
const galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, {
|
|
8567
9227
|
workId: state.work.id,
|
|
8568
9228
|
frameRate: state.uiSettings.galaxyFrameRate,
|
|
9229
|
+
motionMode: storedGalaxyMotionMode(),
|
|
9230
|
+
onMotionModeChange: persistGalaxyMotionMode,
|
|
8569
9231
|
onClose: () => {
|
|
8570
9232
|
if (state.galaxy === galaxy) state.galaxy = null;
|
|
8571
9233
|
}
|
|
@@ -8742,7 +9404,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
|
|
|
8742
9404
|
moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
|
|
8743
9405
|
canReadModule("ai-settings")
|
|
8744
9406
|
? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
|
|
8745
|
-
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
|
|
9407
|
+
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunStabilityDelayMinutes: 2, autoRunPaused: false })
|
|
8746
9408
|
]);
|
|
8747
9409
|
if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
|
|
8748
9410
|
taskListPage = taskPage.page;
|
|
@@ -8779,6 +9441,7 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
|
|
|
8779
9441
|
<div class="task-auto-run-copy">
|
|
8780
9442
|
<strong id="task-auto-run-title">自动执行待分析任务</strong>
|
|
8781
9443
|
<small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
|
|
9444
|
+
<small>正文停止编辑后,等待稳定窗口结束才会创建章节理解任务。</small>
|
|
8782
9445
|
<small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
|
|
8783
9446
|
</div>
|
|
8784
9447
|
<div class="task-auto-run-actions">
|
|
@@ -8790,11 +9453,13 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
|
|
|
8790
9453
|
<label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
|
|
8791
9454
|
<label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-daily-help"></label>
|
|
8792
9455
|
<label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
|
|
9456
|
+
<label>停止编辑后创建任务<input id="task-auto-run-stability-delay" type="number" min="1" max="120" value="${esc(String(settings.autoRunStabilityDelayMinutes ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-stability-help"></label>
|
|
8793
9457
|
${autoRunEditing
|
|
8794
9458
|
? `<button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button><button id="task-auto-run-pause" class="ghost-button" type="button">${autoRunPauseLabel}</button>`
|
|
8795
9459
|
: ""}
|
|
8796
9460
|
</div>
|
|
8797
9461
|
<p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
|
|
9462
|
+
<p id="task-auto-run-stability-help" class="task-auto-run-help">正文连续 ${esc(String(settings.autoRunStabilityDelayMinutes ?? 2))} 分钟没有编辑,才会创建章节理解任务;最低 1 分钟。</p>
|
|
8798
9463
|
<p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
|
|
8799
9464
|
${autoRunPaused ? `<p class="task-auto-run-alert" role="status"><strong>自动执行已暂停</strong><span>${esc(settings.autoRunPauseReason || "需要人工确认后恢复")}</span>${settings.autoRunResumeAt ? `<small>预计 ${esc(formatDateTime(settings.autoRunResumeAt))} 自动恢复</small>` : ""}</p>` : ""}
|
|
8800
9465
|
<div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
|
|
@@ -8848,7 +9513,8 @@ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
|
|
|
8848
9513
|
autoRunEnabled: $("#task-auto-run-enabled").checked,
|
|
8849
9514
|
autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
|
|
8850
9515
|
autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
|
|
8851
|
-
autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value)
|
|
9516
|
+
autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value),
|
|
9517
|
+
autoRunStabilityDelayMinutes: Number($("#task-auto-run-stability-delay").value)
|
|
8852
9518
|
}
|
|
8853
9519
|
});
|
|
8854
9520
|
toast(updated.autoRunEnabled
|
|
@@ -9771,7 +10437,7 @@ function renderProviderCards(providers, models) {
|
|
|
9771
10437
|
: "";
|
|
9772
10438
|
return `
|
|
9773
10439
|
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
9774
|
-
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
10440
|
+
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
9775
10441
|
<div class="provider-models">${providerModels.map((model) => {
|
|
9776
10442
|
const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
9777
10443
|
const modelStatus = !model.enabled
|
|
@@ -9781,7 +10447,8 @@ function renderProviderCards(providers, models) {
|
|
|
9781
10447
|
: "";
|
|
9782
10448
|
const capability = model.multimodalEnabled ? " · 多模态" : "";
|
|
9783
10449
|
const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
|
|
9784
|
-
|
|
10450
|
+
const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
|
|
10451
|
+
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
|
|
9785
10452
|
}).join("")}</div>
|
|
9786
10453
|
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
9787
10454
|
}).join("")}</div>`
|
|
@@ -10106,7 +10773,12 @@ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
|
|
|
10106
10773
|
backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
|
|
10107
10774
|
backgroundTaskCenterSnapshot.taskPage = taskResult.value;
|
|
10108
10775
|
if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
|
|
10109
|
-
|
|
10776
|
+
const announcementResult = filterBackgroundTaskTransitionsForAnnouncement(
|
|
10777
|
+
transitionResult.transitions,
|
|
10778
|
+
backgroundTaskCenterExpiredNoticeTimes
|
|
10779
|
+
);
|
|
10780
|
+
backgroundTaskCenterExpiredNoticeTimes = announcementResult.noticeTimes;
|
|
10781
|
+
for (const transition of announcementResult.transitions) {
|
|
10110
10782
|
const notification = backgroundTaskTransitionMessage(transition);
|
|
10111
10783
|
toast(notification.message, notification.type);
|
|
10112
10784
|
}
|
|
@@ -10134,6 +10806,7 @@ function stopBackgroundTaskCenter() {
|
|
|
10134
10806
|
backgroundTaskCenterWorkId = null;
|
|
10135
10807
|
backgroundTaskCenterTasksInitialized = false;
|
|
10136
10808
|
backgroundTaskCenterTaskSnapshots = new Map();
|
|
10809
|
+
backgroundTaskCenterExpiredNoticeTimes = new Map();
|
|
10137
10810
|
backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
10138
10811
|
const dialog = $("#background-task-dialog");
|
|
10139
10812
|
if (dialog?.open) dialog.close();
|
|
@@ -10347,7 +11020,7 @@ async function renderBookAiSettings() {
|
|
|
10347
11020
|
const host = $("#module-content");
|
|
10348
11021
|
const workId = String(state.work.id);
|
|
10349
11022
|
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
10350
|
-
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
|
|
11023
|
+
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
|
|
10351
11024
|
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
10352
11025
|
const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
|
|
10353
11026
|
const quotaRemainingTokens = usage?.quota?.remainingTokens === null
|
|
@@ -10380,7 +11053,7 @@ async function renderBookAiSettings() {
|
|
|
10380
11053
|
);
|
|
10381
11054
|
host.querySelector(".ai-agent-tools").insertAdjacentHTML(
|
|
10382
11055
|
"beforeend",
|
|
10383
|
-
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label>`
|
|
11056
|
+
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算日期差值,或从起始日期推算目标日期,不读取作品内容。</small></span></label>`
|
|
10384
11057
|
);
|
|
10385
11058
|
if (!canEditModule("ai-settings")) {
|
|
10386
11059
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
@@ -10772,6 +11445,18 @@ function resetAiContextMeter() {
|
|
|
10772
11445
|
setAiContextMeter(null);
|
|
10773
11446
|
}
|
|
10774
11447
|
|
|
11448
|
+
function setAiChatTabContextUsage(tab, usage, allowShrink = true) {
|
|
11449
|
+
if (!tab) return;
|
|
11450
|
+
if (isActiveAiChatTab(tab)) {
|
|
11451
|
+
setAiContextMeter(usage, allowShrink);
|
|
11452
|
+
tab.contextUsage = latestAiContextUsage;
|
|
11453
|
+
return;
|
|
11454
|
+
}
|
|
11455
|
+
tab.contextUsage = allowShrink
|
|
11456
|
+
? resolveAiContextUsage(tab.contextUsage, usage)
|
|
11457
|
+
: mergeAiContextUsage(tab.contextUsage, usage, false);
|
|
11458
|
+
}
|
|
11459
|
+
|
|
10775
11460
|
function setAiContextDistributionVisible(visible) {
|
|
10776
11461
|
const meter = $("#ai-context-meter");
|
|
10777
11462
|
const popover = $("#ai-context-popover");
|
|
@@ -10790,6 +11475,14 @@ function hideAiContextWarning() {
|
|
|
10790
11475
|
$("#ai-context-warning").classList.add("hidden");
|
|
10791
11476
|
}
|
|
10792
11477
|
|
|
11478
|
+
function setAiChatTabContextWarning(tab, visible) {
|
|
11479
|
+
if (!tab) return;
|
|
11480
|
+
tab.contextWarning = visible;
|
|
11481
|
+
if (!isActiveAiChatTab(tab)) return;
|
|
11482
|
+
if (visible) showAiContextWarning();
|
|
11483
|
+
else hideAiContextWarning();
|
|
11484
|
+
}
|
|
11485
|
+
|
|
10793
11486
|
function setAiContextWarningActionsDisabled(disabled) {
|
|
10794
11487
|
for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
|
|
10795
11488
|
}
|
|
@@ -11640,8 +12333,11 @@ function renderCharacterEditorRelationships() {
|
|
|
11640
12333
|
const category = relationshipCategoryLabel(relationship.category);
|
|
11641
12334
|
const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
|
|
11642
12335
|
const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
|
|
12336
|
+
const actions = !entityEditorReadOnly && canEditModule("relationships")
|
|
12337
|
+
? `<div class="character-relationship-actions"><button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button><button class="danger-button" type="button" data-character-relationship-delete="${esc(relationship.id)}">删除关系</button></div>`
|
|
12338
|
+
: "";
|
|
11643
12339
|
return `<article class="character-relationship-row">
|
|
11644
|
-
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${
|
|
12340
|
+
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${actions}</div>
|
|
11645
12341
|
<div class="character-relationship-keywords"><small>关系关键词</small><div>${keywords.map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || '<span class="character-relationship-empty-keywords">未填写关键词</span>'}</div></div>
|
|
11646
12342
|
</article>`;
|
|
11647
12343
|
}).join("");
|
|
@@ -11650,6 +12346,14 @@ function renderCharacterEditorRelationships() {
|
|
|
11650
12346
|
const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
|
|
11651
12347
|
if (relationship) void openRelationshipDialog(relationship, { characterId });
|
|
11652
12348
|
}));
|
|
12349
|
+
host.querySelectorAll("[data-character-relationship-delete]").forEach((button) => button.addEventListener("click", () => {
|
|
12350
|
+
const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipDelete);
|
|
12351
|
+
if (!relationship) return;
|
|
12352
|
+
button.disabled = true;
|
|
12353
|
+
void deleteRelationshipRecord(relationship, characterId).finally(() => {
|
|
12354
|
+
if (button.isConnected) button.disabled = false;
|
|
12355
|
+
});
|
|
12356
|
+
}));
|
|
11653
12357
|
host.querySelector("[data-character-relationship-create]")?.addEventListener("click", () => void openRelationshipDialog(null, { characterId }));
|
|
11654
12358
|
}
|
|
11655
12359
|
|
|
@@ -11668,7 +12372,7 @@ async function loadCharacterEditorRelationships(characterId, { refresh = false }
|
|
|
11668
12372
|
]);
|
|
11669
12373
|
if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
|
|
11670
12374
|
state.characters = characters;
|
|
11671
|
-
characterEditorRelationships = relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
|
|
12375
|
+
characterEditorRelationships = sortCharacterRelationships(relationships.filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId), characterId);
|
|
11672
12376
|
characterEditorRelationshipsLoaded = true;
|
|
11673
12377
|
loaded = true;
|
|
11674
12378
|
} catch (error) {
|
|
@@ -11692,6 +12396,21 @@ async function refreshRelationshipSurfaces(characterId = null) {
|
|
|
11692
12396
|
await Promise.all(tasks);
|
|
11693
12397
|
}
|
|
11694
12398
|
|
|
12399
|
+
async function deleteRelationshipRecord(item, characterId = null) {
|
|
12400
|
+
const relationshipName = `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}`;
|
|
12401
|
+
if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return false;
|
|
12402
|
+
if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return false;
|
|
12403
|
+
try {
|
|
12404
|
+
await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
12405
|
+
await Promise.all([refreshRelationshipSurfaces(characterId), loadAiReferences()]);
|
|
12406
|
+
deleteToast(`已删除人物关系“${relationshipName}”`);
|
|
12407
|
+
return true;
|
|
12408
|
+
} catch (error) {
|
|
12409
|
+
toast(error.message, "error");
|
|
12410
|
+
return false;
|
|
12411
|
+
}
|
|
12412
|
+
}
|
|
12413
|
+
|
|
11695
12414
|
const characterSectionTypeLabels = {
|
|
11696
12415
|
overview: "基本档案",
|
|
11697
12416
|
appearance: "外貌与生理",
|
|
@@ -12426,6 +13145,7 @@ function renderCharacterEditorFields(item) {
|
|
|
12426
13145
|
$("#character-editor-fields").innerHTML = [
|
|
12427
13146
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
12428
13147
|
field("name", "标准名", "text", item?.name) +
|
|
13148
|
+
field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
|
|
12429
13149
|
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
12430
13150
|
field("aliases", "别名", "item-list", item?.aliases ?? []) +
|
|
12431
13151
|
(!canReadModule("races")
|
|
@@ -12479,6 +13199,7 @@ function collectCharacterBody(form) {
|
|
|
12479
13199
|
delete profile.sections;
|
|
12480
13200
|
const body = {
|
|
12481
13201
|
name: String(form.get("name") ?? "").trim(),
|
|
13202
|
+
gender: String(form.get("gender") ?? "unknown"),
|
|
12482
13203
|
isDead: form.has("isDead"),
|
|
12483
13204
|
code: String(form.get("code") ?? "").trim(),
|
|
12484
13205
|
aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
|
|
@@ -13083,7 +13804,6 @@ async function openRelationshipDialog(item, options = {}) {
|
|
|
13083
13804
|
const characterOptions = state.characters.map((item) => [item.id, item.name]);
|
|
13084
13805
|
const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
|
|
13085
13806
|
const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
|
|
13086
|
-
const relationshipName = item ? `${state.characters.find((character) => character.id === item.fromCharacterId)?.name ?? "未知角色"} / ${state.characters.find((character) => character.id === item.toCharacterId)?.name ?? "未知角色"}` : "";
|
|
13087
13807
|
const management = item && canEditModule("relationships") ? `<section class="entity-dialog-management" aria-label="人物关系操作">
|
|
13088
13808
|
<div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
|
|
13089
13809
|
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
|
|
@@ -13101,16 +13821,7 @@ async function openRelationshipDialog(item, options = {}) {
|
|
|
13101
13821
|
dialog.showModal();
|
|
13102
13822
|
};
|
|
13103
13823
|
dialog.close();
|
|
13104
|
-
if (!await
|
|
13105
|
-
if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return reopenDialog();
|
|
13106
|
-
try {
|
|
13107
|
-
await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
13108
|
-
await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
|
|
13109
|
-
deleteToast(`已删除人物关系“${relationshipName}”`);
|
|
13110
|
-
} catch (error) {
|
|
13111
|
-
reopenDialog();
|
|
13112
|
-
toast(error.message, "error");
|
|
13113
|
-
}
|
|
13824
|
+
if (!await deleteRelationshipRecord(item, options.characterId ?? null)) return reopenDialog();
|
|
13114
13825
|
});
|
|
13115
13826
|
}
|
|
13116
13827
|
|
|
@@ -13161,17 +13872,23 @@ async function openTaskDialog() {
|
|
|
13161
13872
|
group.push(item);
|
|
13162
13873
|
groups.set(groupKey, { id: groupId, title: groupTitle, options: group });
|
|
13163
13874
|
return groups;
|
|
13164
|
-
}, new Map())].map(([, group]) =>
|
|
13875
|
+
}, new Map())].map(([, group]) => {
|
|
13876
|
+
const groupOptionsId = group.id ? `task-scope-volume-options-${group.id}` : "task-scope-volume-options-unassigned";
|
|
13877
|
+
return `<section class="task-scope-volume-group" data-task-scope-group data-task-scope-volume-id="${esc(group.id)}">
|
|
13165
13878
|
<header>
|
|
13166
13879
|
${group.id ? `<label class="task-scope-volume-option">
|
|
13167
13880
|
<input type="checkbox" data-task-scope-volume-input="${esc(group.id)}" data-task-scope-volume-title="${esc(group.title)}" aria-label="全选${esc(group.title)}章节">
|
|
13168
13881
|
<span class="task-scope-checkbox" aria-hidden="true"></span>
|
|
13169
13882
|
<span class="task-scope-volume-copy"><strong>${esc(group.title)}</strong><small>勾选以全选本卷章节</small></span>
|
|
13170
13883
|
</label>` : `<strong>${esc(group.title)}</strong>`}
|
|
13171
|
-
<span>${group.options.length} 章</span>
|
|
13884
|
+
<span class="task-scope-volume-count">${group.options.length} 章</span>
|
|
13885
|
+
${group.id ? `<button type="button" class="task-scope-volume-collapse" data-task-scope-volume-toggle="${esc(group.id)}" data-task-scope-volume-title="${esc(group.title)}" aria-expanded="true" aria-controls="${esc(groupOptionsId)}" aria-label="折叠${esc(group.title)}章节"><span aria-hidden="true">⌃</span></button>` : ""}
|
|
13172
13886
|
</header>
|
|
13173
|
-
${
|
|
13174
|
-
|
|
13887
|
+
<div id="${esc(groupOptionsId)}" class="task-scope-volume-options">
|
|
13888
|
+
${group.options.map(renderOption).join("")}
|
|
13889
|
+
</div>
|
|
13890
|
+
</section>`;
|
|
13891
|
+
}).join("");
|
|
13175
13892
|
return `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
|
|
13176
13893
|
<div class="task-scope-field-heading"><span id="task-${kind}-label">${esc(label)}(可多选)</span><small>从左侧选择,右侧确认已选内容</small></div>
|
|
13177
13894
|
<div class="task-scope-picker">
|
|
@@ -13262,7 +13979,8 @@ async function openTaskDialog() {
|
|
|
13262
13979
|
const buildRelationshipScope = (form) => {
|
|
13263
13980
|
const taskType = String(form.get("taskType"));
|
|
13264
13981
|
const scopeType = String(form.get("scopeType"));
|
|
13265
|
-
const includeAllSettings = taskType === "relationship-analysis"
|
|
13982
|
+
const includeAllSettings = taskType === "relationship-analysis"
|
|
13983
|
+
&& ["chapter-with-settings", "book-with-settings"].includes(scopeType);
|
|
13266
13984
|
const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
|
|
13267
13985
|
const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
|
|
13268
13986
|
const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
|
|
@@ -13273,11 +13991,12 @@ async function openTaskDialog() {
|
|
|
13273
13991
|
const chapterScope = {
|
|
13274
13992
|
type: "chapter",
|
|
13275
13993
|
...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
|
|
13276
|
-
...(chapterIds.length ? { chapterIds } : {})
|
|
13994
|
+
...(chapterIds.length ? { chapterIds } : {}),
|
|
13995
|
+
...(scopeType === "chapter-with-settings" ? { includeAllSettings: true } : {})
|
|
13277
13996
|
};
|
|
13278
13997
|
const scope = settingsOnly
|
|
13279
13998
|
? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
|
|
13280
|
-
: scopeType === "book" ||
|
|
13999
|
+
: scopeType === "book" || scopeType === "book-with-settings"
|
|
13281
14000
|
? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
|
|
13282
14001
|
: { ...chapterScope, ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
|
|
13283
14002
|
return scope;
|
|
@@ -13318,7 +14037,7 @@ async function openTaskDialog() {
|
|
|
13318
14037
|
}
|
|
13319
14038
|
taskListPage = 1;
|
|
13320
14039
|
toast("分析任务已创建,已进入任务队列");
|
|
13321
|
-
|
|
14040
|
+
await refreshAnalysisTaskViewsAfterCreate(workId);
|
|
13322
14041
|
}, "AI 分析", {
|
|
13323
14042
|
large: true,
|
|
13324
14043
|
submitLabel: "创建任务",
|
|
@@ -13347,6 +14066,9 @@ async function openTaskDialog() {
|
|
|
13347
14066
|
const scopeVolumeInputs = [
|
|
13348
14067
|
...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
|
|
13349
14068
|
];
|
|
14069
|
+
const scopeVolumeCollapseButtons = [
|
|
14070
|
+
...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-toggle]")
|
|
14071
|
+
];
|
|
13350
14072
|
const scopeSummaries = {
|
|
13351
14073
|
chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
|
|
13352
14074
|
};
|
|
@@ -13393,6 +14115,9 @@ async function openTaskDialog() {
|
|
|
13393
14115
|
const allSettingsOption = document.createElement("option");
|
|
13394
14116
|
allSettingsOption.value = "book-with-settings";
|
|
13395
14117
|
allSettingsOption.textContent = "全书 + 设定集";
|
|
14118
|
+
const chapterSettingsOption = document.createElement("option");
|
|
14119
|
+
chapterSettingsOption.value = "chapter-with-settings";
|
|
14120
|
+
chapterSettingsOption.textContent = "指定章节 + 设定集";
|
|
13396
14121
|
const settingsOnlyOption = document.createElement("option");
|
|
13397
14122
|
settingsOnlyOption.value = "settings";
|
|
13398
14123
|
settingsOnlyOption.textContent = "仅设定集";
|
|
@@ -13405,6 +14130,13 @@ async function openTaskDialog() {
|
|
|
13405
14130
|
scopeSearches[kind].focus({ preventScroll: true });
|
|
13406
14131
|
}
|
|
13407
14132
|
};
|
|
14133
|
+
const setTaskScopeVolumeCollapsed = (button, collapsed) => {
|
|
14134
|
+
const group = button.closest("[data-task-scope-group]");
|
|
14135
|
+
if (!group) return;
|
|
14136
|
+
group.classList.toggle("is-collapsed", collapsed);
|
|
14137
|
+
button.setAttribute("aria-expanded", String(!collapsed));
|
|
14138
|
+
button.setAttribute("aria-label", `${collapsed ? "展开" : "折叠"}${button.dataset.taskScopeVolumeTitle ?? ""}章节`);
|
|
14139
|
+
};
|
|
13408
14140
|
const syncTaskScopeVolumeInputs = () => {
|
|
13409
14141
|
for (const volumeInput of scopeVolumeInputs) {
|
|
13410
14142
|
const group = volumeInput.closest("[data-task-scope-group]");
|
|
@@ -13442,7 +14174,12 @@ async function openTaskDialog() {
|
|
|
13442
14174
|
if (visible) visibleCount += 1;
|
|
13443
14175
|
}
|
|
13444
14176
|
scopeFields[kind].querySelectorAll("[data-task-scope-group]").forEach((group) => {
|
|
13445
|
-
|
|
14177
|
+
const hasVisibleOptions = Boolean(group.querySelector(".task-scope-option:not(.hidden)"));
|
|
14178
|
+
group.classList.toggle("hidden", !hasVisibleOptions);
|
|
14179
|
+
if (query && hasVisibleOptions) {
|
|
14180
|
+
const collapseButton = group.querySelector("[data-task-scope-volume-toggle]");
|
|
14181
|
+
if (collapseButton) setTaskScopeVolumeCollapsed(collapseButton, false);
|
|
14182
|
+
}
|
|
13446
14183
|
});
|
|
13447
14184
|
scopeAvailableCounts[kind].textContent = query
|
|
13448
14185
|
? `${visibleCount} / ${scopeInputs[kind].length} 章`
|
|
@@ -13451,7 +14188,7 @@ async function openTaskDialog() {
|
|
|
13451
14188
|
};
|
|
13452
14189
|
const syncChapterField = () => {
|
|
13453
14190
|
const kind = "chapter";
|
|
13454
|
-
const enabled =
|
|
14191
|
+
const enabled = ["chapter", "chapter-with-settings"].includes(scopeTypeSelect.value);
|
|
13455
14192
|
scopeFields[kind].classList.toggle("is-disabled", !enabled);
|
|
13456
14193
|
scopeFields[kind].classList.toggle("hidden", !enabled);
|
|
13457
14194
|
scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
|
|
@@ -13525,8 +14262,14 @@ async function openTaskDialog() {
|
|
|
13525
14262
|
};
|
|
13526
14263
|
const syncRelationshipOptions = () => {
|
|
13527
14264
|
const enabled = taskTypeSelect.value === "relationship-analysis";
|
|
14265
|
+
const bookScopeOption = scopeTypeSelect.querySelector('option[value="book"]');
|
|
14266
|
+
if (enabled && !chapterSettingsOption.isConnected) bookScopeOption.before(chapterSettingsOption);
|
|
13528
14267
|
if (enabled && !allSettingsOption.isConnected) scopeTypeSelect.append(allSettingsOption);
|
|
13529
14268
|
if (enabled && !settingsOnlyOption.isConnected) scopeTypeSelect.append(settingsOnlyOption);
|
|
14269
|
+
if (!enabled && chapterSettingsOption.isConnected) {
|
|
14270
|
+
if (scopeTypeSelect.value === chapterSettingsOption.value) scopeTypeSelect.value = "chapter";
|
|
14271
|
+
chapterSettingsOption.remove();
|
|
14272
|
+
}
|
|
13530
14273
|
if (!enabled && allSettingsOption.isConnected) {
|
|
13531
14274
|
if (scopeTypeSelect.value === allSettingsOption.value) scopeTypeSelect.value = "book";
|
|
13532
14275
|
allSettingsOption.remove();
|
|
@@ -13644,6 +14387,11 @@ async function openTaskDialog() {
|
|
|
13644
14387
|
clearContextWarning();
|
|
13645
14388
|
invalidateRelationshipSourcePreview();
|
|
13646
14389
|
});
|
|
14390
|
+
for (const collapseButton of scopeVolumeCollapseButtons) collapseButton.addEventListener("click", (event) => {
|
|
14391
|
+
event.preventDefault();
|
|
14392
|
+
event.stopPropagation();
|
|
14393
|
+
setTaskScopeVolumeCollapsed(collapseButton, collapseButton.getAttribute("aria-expanded") === "true");
|
|
14394
|
+
});
|
|
13647
14395
|
$("#dynamic-form").onclick = (event) => {
|
|
13648
14396
|
if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
|
|
13649
14397
|
for (const kind of ["chapter"]) {
|
|
@@ -13711,12 +14459,15 @@ function openProviderDialog(item) {
|
|
|
13711
14459
|
return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
|
|
13712
14460
|
};
|
|
13713
14461
|
const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
|
|
14462
|
+
const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens" && protocol !== "anthropic-messages";
|
|
14463
|
+
const maxTokensParameterField = `<div class="form-field provider-max-tokens-parameter-field" data-provider-max-tokens-parameter-field><span>最大输出令牌参数</span><label class="checkbox-field"><input name="useMaxCompletionTokens" type="checkbox" ${useMaxCompletionTokens ? "checked" : ""} aria-describedby="provider-max-tokens-parameter-hint"><span>使用 max_completion_tokens</span></label><small id="provider-max-tokens-parameter-hint" data-provider-max-tokens-parameter-hint>默认使用 max_tokens;OpenAI 新版模型可按需切换。</small></div>`;
|
|
13714
14464
|
openDialog(
|
|
13715
14465
|
item ? "编辑 AI 供应商" : "新建 AI 供应商",
|
|
13716
14466
|
field("name", "显示名称", "text", item?.name)
|
|
13717
14467
|
+ field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
|
|
13718
14468
|
+ field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
|
|
13719
14469
|
+ `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
|
|
14470
|
+
+ maxTokensParameterField
|
|
13720
14471
|
+ field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
|
|
13721
14472
|
+ field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
|
|
13722
14473
|
+ field("note", "用途备注", "textarea", item?.note)
|
|
@@ -13726,6 +14477,7 @@ function openProviderDialog(item) {
|
|
|
13726
14477
|
name: form.get("name"),
|
|
13727
14478
|
protocol: form.get("protocol"),
|
|
13728
14479
|
baseUrl: form.get("baseUrl"),
|
|
14480
|
+
maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
|
|
13729
14481
|
concurrencyLimit: Number(form.get("concurrencyLimit")),
|
|
13730
14482
|
rpmLimit: Number(form.get("rpmLimit")),
|
|
13731
14483
|
note: form.get("note"),
|
|
@@ -13742,12 +14494,23 @@ function openProviderDialog(item) {
|
|
|
13742
14494
|
const protocolSelect = $("#dialog-fields select[name='protocol']");
|
|
13743
14495
|
const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
|
|
13744
14496
|
const credentialHost = $("#dialog-fields [data-provider-credential-field]");
|
|
14497
|
+
const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
|
|
14498
|
+
const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
|
|
14499
|
+
const syncMaxTokensParameter = () => {
|
|
14500
|
+
const anthropic = protocolSelect.value === "anthropic-messages";
|
|
14501
|
+
maxTokensParameterFieldElement.hidden = anthropic;
|
|
14502
|
+
maxTokensParameterFieldElement.classList.toggle("hidden", anthropic);
|
|
14503
|
+
maxTokensParameterInput.disabled = anthropic;
|
|
14504
|
+
if (anthropic) maxTokensParameterInput.checked = false;
|
|
14505
|
+
};
|
|
13745
14506
|
const syncProviderCredentialField = () => {
|
|
13746
14507
|
const nextProtocol = protocolSelect.value;
|
|
13747
14508
|
credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
|
|
13748
14509
|
if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
|
|
14510
|
+
syncMaxTokensParameter();
|
|
13749
14511
|
};
|
|
13750
14512
|
protocolSelect.addEventListener("change", syncProviderCredentialField);
|
|
14513
|
+
syncMaxTokensParameter();
|
|
13751
14514
|
}
|
|
13752
14515
|
|
|
13753
14516
|
function openModelDialog(providerId, item = null, provider = null) {
|
|
@@ -13757,16 +14520,16 @@ function openModelDialog(providerId, item = null, provider = null) {
|
|
|
13757
14520
|
const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
|
|
13758
14521
|
const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
|
|
13759
14522
|
const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
|
|
13760
|
-
? "
|
|
13761
|
-
: "
|
|
14523
|
+
? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
|
|
14524
|
+
: "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
|
|
13762
14525
|
const connectionTest = item && item.providerStatus === "enabled"
|
|
13763
14526
|
? `<section class="model-connection-test">
|
|
13764
14527
|
<div><strong>模型连接测试</strong><p>${connectionTestDescription}</p></div>
|
|
13765
14528
|
<button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
|
|
13766
14529
|
</section>`
|
|
13767
14530
|
: "";
|
|
13768
|
-
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
13769
|
-
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
14531
|
+
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
14532
|
+
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
13770
14533
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
13771
14534
|
await renderPlatformAiConfig();
|
|
13772
14535
|
await loadModels();
|
|
@@ -13777,6 +14540,8 @@ function openModelDialog(providerId, item = null, provider = null) {
|
|
|
13777
14540
|
const contextWindowHint = $("#model-context-window-hint");
|
|
13778
14541
|
const temperatureInput = $("#dialog-fields input[name='temperature']");
|
|
13779
14542
|
const temperatureHint = $("#model-temperature-hint");
|
|
14543
|
+
const thinkingEnabledInput = $("#dialog-fields input[name='thinkingEnabled']");
|
|
14544
|
+
const thinkingEffortSelect = $("#dialog-fields select[name='thinkingEffort']");
|
|
13780
14545
|
const multimodalInput = $("#model-multimodal-enabled");
|
|
13781
14546
|
const imageDefaultField = $("#model-image-tool-default-field");
|
|
13782
14547
|
const imageDefaultInput = $("#model-image-tool-default");
|
|
@@ -13796,11 +14561,15 @@ function openModelDialog(providerId, item = null, provider = null) {
|
|
|
13796
14561
|
const isKimi = isKimiModelId(modelIdInput.value);
|
|
13797
14562
|
temperatureHint.hidden = !isKimi;
|
|
13798
14563
|
};
|
|
14564
|
+
const syncThinkingEffort = () => {
|
|
14565
|
+
thinkingEffortSelect.disabled = !thinkingEnabledInput.checked;
|
|
14566
|
+
};
|
|
13799
14567
|
modelIdInput.addEventListener("input", () => {
|
|
13800
14568
|
if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
|
|
13801
14569
|
syncKimiTemperature();
|
|
13802
14570
|
});
|
|
13803
14571
|
contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
|
|
14572
|
+
thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
|
|
13804
14573
|
multimodalInput?.addEventListener("change", syncMultimodalFields);
|
|
13805
14574
|
$("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
|
|
13806
14575
|
const button = event.currentTarget;
|
|
@@ -13823,6 +14592,7 @@ function openModelDialog(providerId, item = null, provider = null) {
|
|
|
13823
14592
|
});
|
|
13824
14593
|
syncModelContextWindowGuidance();
|
|
13825
14594
|
syncKimiTemperature();
|
|
14595
|
+
syncThinkingEffort();
|
|
13826
14596
|
syncMultimodalFields();
|
|
13827
14597
|
}
|
|
13828
14598
|
|
|
@@ -13832,6 +14602,9 @@ async function sendAi() {
|
|
|
13832
14602
|
|
|
13833
14603
|
async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
13834
14604
|
if (!state.work) return toast("请先选择作品", "error");
|
|
14605
|
+
const tab = activeAiChatTab();
|
|
14606
|
+
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
14607
|
+
if (aiRequestManager.hasActive(tab.id)) return;
|
|
13835
14608
|
const composerSnapshot = captureAiPromptComposer();
|
|
13836
14609
|
const instruction = composerSnapshot.text.trim();
|
|
13837
14610
|
if (!instruction) return toast("请输入指令", "error");
|
|
@@ -13841,31 +14614,37 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
13841
14614
|
const { taskType, scope, selection } = requestScope;
|
|
13842
14615
|
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
13843
14616
|
const citations = composerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
14617
|
+
const selectedTaskType = $("#ai-task").value;
|
|
14618
|
+
persistActiveAiChatTab();
|
|
14619
|
+
tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
|
|
13844
14620
|
const requestHolder = {
|
|
13845
14621
|
snapshot: aiRequestManager.begin({
|
|
14622
|
+
tabId: tab.id,
|
|
13846
14623
|
workId: state.work.id,
|
|
13847
14624
|
conversationId: state.aiConversationId
|
|
13848
14625
|
})
|
|
13849
14626
|
};
|
|
14627
|
+
setAiChatTabStatus(tab, "streaming");
|
|
13850
14628
|
syncAiRequestControls();
|
|
13851
14629
|
try {
|
|
13852
14630
|
try {
|
|
13853
14631
|
await ensureAiModelsLoaded();
|
|
13854
14632
|
} catch (error) {
|
|
13855
14633
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
13856
|
-
|
|
14634
|
+
setAiChatTabStatus(tab, "error");
|
|
13857
14635
|
return toast(`创作助手加载失败:${error.message}`, "error");
|
|
13858
14636
|
}
|
|
13859
14637
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
13860
|
-
const modelId = $("#ai-model").value;
|
|
14638
|
+
const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
|
|
13861
14639
|
if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
|
|
14640
|
+
tab.selectedModelId = modelId;
|
|
13862
14641
|
try {
|
|
13863
|
-
await prepareAiRequestConversation(requestHolder,
|
|
14642
|
+
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
13864
14643
|
} catch (error) {
|
|
13865
14644
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
13866
14645
|
return toast(`对话配置锁定失败:${error.message}`, "error");
|
|
13867
14646
|
}
|
|
13868
|
-
|
|
14647
|
+
setAiChatTabStatus(tab, "streaming");
|
|
13869
14648
|
if (taskType !== "chat") {
|
|
13870
14649
|
try {
|
|
13871
14650
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
@@ -13880,16 +14659,22 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
13880
14659
|
assertAiRequestCurrent(request);
|
|
13881
14660
|
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
13882
14661
|
requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
|
|
13883
|
-
|
|
13884
|
-
|
|
13885
|
-
|
|
13886
|
-
|
|
13887
|
-
|
|
13888
|
-
|
|
13889
|
-
|
|
14662
|
+
tab.modelId = modelId;
|
|
14663
|
+
tab.selectedModelId = modelId;
|
|
14664
|
+
tab.promptSent = true;
|
|
14665
|
+
clearAiChatTabComposer(tab);
|
|
14666
|
+
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
|
|
14667
|
+
if (isActiveAiChatTab(tab)) {
|
|
14668
|
+
state.aiConversationModelId = modelId;
|
|
14669
|
+
state.aiPromptSent = true;
|
|
14670
|
+
syncAiTaskOptions();
|
|
14671
|
+
renderAiRoleplayCharacterSelect();
|
|
14672
|
+
renderAiQuickActions();
|
|
14673
|
+
clearAiPromptComposer();
|
|
14674
|
+
}
|
|
13890
14675
|
} catch (error) {
|
|
13891
14676
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
13892
|
-
|
|
14677
|
+
setAiChatTabStatus(tab, "error");
|
|
13893
14678
|
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
13894
14679
|
}
|
|
13895
14680
|
}
|
|
@@ -13925,8 +14710,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
13925
14710
|
const suggestionFailed = suggestion.guard?.status === "failed"
|
|
13926
14711
|
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
13927
14712
|
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
13928
|
-
if (suggestionFailed)
|
|
13929
|
-
|
|
14713
|
+
if (suggestionFailed) setAiChatTabStatus(tab, "error");
|
|
14714
|
+
tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
|
|
14715
|
+
if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
|
|
13930
14716
|
assistantContent = suggestion.content;
|
|
13931
14717
|
assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
|
|
13932
14718
|
}
|
|
@@ -13955,36 +14741,50 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
13955
14741
|
if (assistantMessage) {
|
|
13956
14742
|
updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
|
|
13957
14743
|
attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
|
|
13958
|
-
} else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
|
|
14744
|
+
} else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
|
|
13959
14745
|
}
|
|
13960
14746
|
} catch (error) {
|
|
13961
14747
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
13962
|
-
|
|
13963
|
-
if (suggestion) appendSuggestion(suggestion);
|
|
14748
|
+
setAiChatTabStatus(tab, "error");
|
|
14749
|
+
if (suggestion) appendSuggestion(suggestion, null, null, { tab });
|
|
13964
14750
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
13965
14751
|
}
|
|
13966
14752
|
} catch (error) {
|
|
13967
14753
|
const request = requestHolder.snapshot;
|
|
13968
14754
|
if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
|
|
13969
|
-
await persistAiRequestInterruption(request, error?.streamInterruption);
|
|
14755
|
+
const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
|
|
14756
|
+
const userStoppedCurrentReply = request.signal.reason instanceof Error
|
|
14757
|
+
&& request.signal.reason.message === "用户已终止当前回复";
|
|
14758
|
+
if (userStoppedCurrentReply && persistedInterruption && isActiveAiChatTab(tab)) {
|
|
14759
|
+
appendMessage(
|
|
14760
|
+
"assistant",
|
|
14761
|
+
persistedInterruption.content,
|
|
14762
|
+
[],
|
|
14763
|
+
persistedInterruption.createdAt,
|
|
14764
|
+
persistedInterruption.metadata,
|
|
14765
|
+
persistedInterruption.id,
|
|
14766
|
+
{ tab }
|
|
14767
|
+
);
|
|
14768
|
+
}
|
|
13970
14769
|
return;
|
|
13971
14770
|
}
|
|
13972
14771
|
if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
|
|
13973
|
-
|
|
14772
|
+
setAiChatTabComposerSnapshot(tab, composerSnapshot);
|
|
14773
|
+
if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
|
|
13974
14774
|
toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
|
|
13975
|
-
$("#ai-prompt").focus();
|
|
14775
|
+
if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
|
|
13976
14776
|
return;
|
|
13977
14777
|
}
|
|
13978
14778
|
if (error?.code === "AI_IDEMPOTENT_REQUEST_IN_PROGRESS") {
|
|
13979
14779
|
toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
|
|
13980
|
-
$("#ai-prompt").focus();
|
|
14780
|
+
if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
|
|
13981
14781
|
return;
|
|
13982
14782
|
}
|
|
13983
14783
|
if (["IDEMPOTENCY_KEY_REUSED", "AI_IDEMPOTENT_REQUEST_TERMINAL"].includes(error?.code)) {
|
|
13984
14784
|
toast(error.message, "error");
|
|
13985
14785
|
return;
|
|
13986
14786
|
}
|
|
13987
|
-
|
|
14787
|
+
setAiChatTabStatus(tab, "error");
|
|
13988
14788
|
const interruption = error?.streamInterruption;
|
|
13989
14789
|
if (interruption?.content) {
|
|
13990
14790
|
try {
|
|
@@ -14031,15 +14831,21 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14031
14831
|
updateAiConversationSummaryFromMessage(persistedFailureMessage);
|
|
14032
14832
|
} catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
14033
14833
|
if (aiRequestTargetsCurrentState(request)) {
|
|
14034
|
-
appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
|
|
14834
|
+
appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id, { tab });
|
|
14035
14835
|
}
|
|
14036
14836
|
} finally {
|
|
14037
14837
|
aiRequestManager.finish(requestHolder.snapshot);
|
|
14038
|
-
|
|
14838
|
+
if (!isActiveAiChatTab(tab) && aiChatTabManager.get(tab.id)) tab.unread = true;
|
|
14839
|
+
if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
|
|
14840
|
+
else renderAiChatTabs();
|
|
14841
|
+
if (isActiveAiChatTab(tab)) syncAiRequestControls();
|
|
14039
14842
|
}
|
|
14040
14843
|
}
|
|
14041
14844
|
|
|
14042
14845
|
async function streamChat(requestHolder, body, idempotencyKey) {
|
|
14846
|
+
const tab = aiChatTabForRequest(requestHolder.snapshot);
|
|
14847
|
+
if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
|
|
14848
|
+
const feed = tab.feed;
|
|
14043
14849
|
const message = document.createElement("div");
|
|
14044
14850
|
message.className = "assistant-message is-streaming";
|
|
14045
14851
|
message.dataset.testid = "ai-stream-message";
|
|
@@ -14051,10 +14857,10 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14051
14857
|
const mountAssistantMessage = () => {
|
|
14052
14858
|
if (messageMounted) return true;
|
|
14053
14859
|
if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
|
|
14054
|
-
attachMessageHeading(message, aiAssistantLabel("正在生成"));
|
|
14055
|
-
|
|
14860
|
+
attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
|
|
14861
|
+
feed.append(message);
|
|
14056
14862
|
messageMounted = true;
|
|
14057
|
-
scrollAiFeedToBottom();
|
|
14863
|
+
scrollAiFeedToBottom(feed);
|
|
14058
14864
|
return true;
|
|
14059
14865
|
};
|
|
14060
14866
|
const typewriter = createStreamTypewriter({
|
|
@@ -14066,7 +14872,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14066
14872
|
meta.textContent = progress.pendingCharacters > 0
|
|
14067
14873
|
? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
|
|
14068
14874
|
: `正在生成 · ${progress.visibleCharacters} 字`;
|
|
14069
|
-
scrollAiFeedToBottom();
|
|
14875
|
+
scrollAiFeedToBottom(feed);
|
|
14070
14876
|
}
|
|
14071
14877
|
});
|
|
14072
14878
|
let streamedText = "";
|
|
@@ -14099,7 +14905,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14099
14905
|
if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
|
|
14100
14906
|
processStepVisibleContents.set(step, text);
|
|
14101
14907
|
renderStreamingProcessSteps(finalAnswerStarted);
|
|
14102
|
-
scrollAiFeedToBottom();
|
|
14908
|
+
scrollAiFeedToBottom(feed);
|
|
14103
14909
|
}
|
|
14104
14910
|
});
|
|
14105
14911
|
processStepTypewriters.set(step, typewriter);
|
|
@@ -14127,7 +14933,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14127
14933
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
14128
14934
|
if (eventName === "context") {
|
|
14129
14935
|
contextAction = typeof payload.action === "string" ? payload.action : "ready";
|
|
14130
|
-
if (!
|
|
14936
|
+
if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
|
|
14131
14937
|
if (payload.conversation?.id) {
|
|
14132
14938
|
if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
|
|
14133
14939
|
throw new Error("流式上下文返回了其他对话");
|
|
@@ -14135,11 +14941,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14135
14941
|
upsertAiConversationSummary(payload.conversation);
|
|
14136
14942
|
applyAiConversationTitle(payload.conversation.title, requestHolder.snapshot.conversationId);
|
|
14137
14943
|
}
|
|
14138
|
-
if (contextAction === "warn")
|
|
14944
|
+
if (contextAction === "warn") setAiChatTabContextWarning(tab, true);
|
|
14139
14945
|
else {
|
|
14140
|
-
|
|
14946
|
+
setAiChatTabContextWarning(tab, false);
|
|
14141
14947
|
if (contextAction === "compacted") {
|
|
14142
|
-
renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
|
|
14948
|
+
renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount, feed, payload.conversation);
|
|
14143
14949
|
toast("已自动整理较早对话为长期记忆并继续发送");
|
|
14144
14950
|
}
|
|
14145
14951
|
}
|
|
@@ -14154,13 +14960,19 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14154
14960
|
const lockedModelId = typeof persistedUserMessage.metadata?.modelId === "string"
|
|
14155
14961
|
? persistedUserMessage.metadata.modelId
|
|
14156
14962
|
: typeof body.modelId === "string" ? body.modelId : null;
|
|
14157
|
-
|
|
14158
|
-
|
|
14159
|
-
|
|
14160
|
-
|
|
14161
|
-
|
|
14162
|
-
|
|
14163
|
-
|
|
14963
|
+
tab.modelId = lockedModelId;
|
|
14964
|
+
tab.selectedModelId = lockedModelId;
|
|
14965
|
+
tab.promptSent = true;
|
|
14966
|
+
clearAiChatTabComposer(tab);
|
|
14967
|
+
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
|
|
14968
|
+
if (isActiveAiChatTab(tab)) {
|
|
14969
|
+
state.aiConversationModelId = lockedModelId;
|
|
14970
|
+
state.aiPromptSent = true;
|
|
14971
|
+
syncAiTaskOptions();
|
|
14972
|
+
renderAiRoleplayCharacterSelect();
|
|
14973
|
+
renderAiQuickActions();
|
|
14974
|
+
clearAiPromptComposer();
|
|
14975
|
+
}
|
|
14164
14976
|
mountAssistantMessage();
|
|
14165
14977
|
}
|
|
14166
14978
|
} else if (eventName === "delta") {
|
|
@@ -14190,26 +15002,26 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14190
15002
|
: step.type === "context_compaction"
|
|
14191
15003
|
? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
|
|
14192
15004
|
: `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
|
|
14193
|
-
scrollAiFeedToBottom();
|
|
15005
|
+
scrollAiFeedToBottom(feed);
|
|
14194
15006
|
} else if (eventName === "tool_call") {
|
|
14195
15007
|
mountAssistantMessage();
|
|
14196
15008
|
const toolCall = { ...payload };
|
|
14197
15009
|
const round = toolCall.round;
|
|
14198
15010
|
delete toolCall.round;
|
|
14199
|
-
if (toolCall.status === "failed")
|
|
15011
|
+
if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
|
|
14200
15012
|
toolCalls.push(toolCall);
|
|
14201
15013
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
14202
15014
|
renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
|
|
14203
15015
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
14204
|
-
scrollAiFeedToBottom();
|
|
15016
|
+
scrollAiFeedToBottom(feed);
|
|
14205
15017
|
} else if (eventName === "context_compacted") {
|
|
14206
15018
|
streamContextCompacted = true;
|
|
14207
|
-
|
|
15019
|
+
setAiChatTabContextUsage(tab, payload.contextUsage);
|
|
14208
15020
|
if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
|
|
14209
15021
|
} else if (eventName === "complete") {
|
|
14210
15022
|
if (payload.warningOnly === true) {
|
|
14211
15023
|
warningOnly = true;
|
|
14212
|
-
|
|
15024
|
+
setAiChatTabContextUsage(tab, payload.contextUsage);
|
|
14213
15025
|
return;
|
|
14214
15026
|
}
|
|
14215
15027
|
mountAssistantMessage();
|
|
@@ -14217,7 +15029,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14217
15029
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
14218
15030
|
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
14219
15031
|
const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
|
|
14220
|
-
|
|
15032
|
+
setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
|
|
14221
15033
|
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
14222
15034
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
14223
15035
|
message.classList.remove("is-streaming");
|
|
@@ -14232,11 +15044,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14232
15044
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
14233
15045
|
meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
|
|
14234
15046
|
attachAssistantCopyAction(message, streamedText);
|
|
14235
|
-
scrollAiFeedToBottom();
|
|
15047
|
+
scrollAiFeedToBottom(feed);
|
|
14236
15048
|
} else if (eventName === "request_status") {
|
|
14237
15049
|
streamError = createClientError(payload, "AI 请求状态不可重放", response.status);
|
|
14238
15050
|
} else if (eventName === "error") {
|
|
14239
|
-
|
|
15051
|
+
setAiChatTabStatus(tab, "error");
|
|
14240
15052
|
streamError = createClientError(payload, "AI 流式调用失败", response.status);
|
|
14241
15053
|
}
|
|
14242
15054
|
};
|
|
@@ -14279,7 +15091,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14279
15091
|
message.dataset.status = "interrupted";
|
|
14280
15092
|
content.setAttribute("aria-busy", "false");
|
|
14281
15093
|
const headingRole = message.querySelector(".message-heading > span");
|
|
14282
|
-
headingRole.textContent = aiAssistantLabel(interruptionLabel);
|
|
15094
|
+
headingRole.textContent = aiAssistantLabel(interruptionLabel, tab.roleplayCharacter);
|
|
14283
15095
|
const interruptionBadge = document.createElement("strong");
|
|
14284
15096
|
interruptionBadge.className = "ai-message-status is-error";
|
|
14285
15097
|
interruptionBadge.textContent = "中断";
|
|
@@ -14288,12 +15100,14 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
14288
15100
|
renderStreamingProcessSteps(true, elapsedProcessTime());
|
|
14289
15101
|
meta.textContent = formatAiStreamInterruptionMeta(interruptionCode, streamedText.length);
|
|
14290
15102
|
if (streamedText) attachAssistantCopyAction(message, streamedText);
|
|
14291
|
-
scrollAiFeedToBottom();
|
|
15103
|
+
scrollAiFeedToBottom(feed);
|
|
14292
15104
|
throw streamFailure;
|
|
14293
15105
|
}
|
|
14294
15106
|
}
|
|
14295
15107
|
|
|
14296
|
-
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
|
|
15108
|
+
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
|
|
15109
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
15110
|
+
const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
|
|
14297
15111
|
const message = document.createElement("div");
|
|
14298
15112
|
const isFailure = role === "assistant" && text.startsWith("调用失败:");
|
|
14299
15113
|
const isInterrupted = role === "assistant" && metadata?.interrupted === true;
|
|
@@ -14309,8 +15123,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
14309
15123
|
});
|
|
14310
15124
|
const heading = attachMessageHeading(
|
|
14311
15125
|
message,
|
|
14312
|
-
role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : ""),
|
|
14313
|
-
createdAt ?? undefined
|
|
15126
|
+
role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "", tab?.roleplayCharacter),
|
|
15127
|
+
createdAt ?? undefined,
|
|
15128
|
+
tab
|
|
14314
15129
|
);
|
|
14315
15130
|
if (isFailure || isInterrupted) {
|
|
14316
15131
|
message.dataset.status = isInterrupted ? "interrupted" : "failed";
|
|
@@ -14360,7 +15175,8 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
14360
15175
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
14361
15176
|
}
|
|
14362
15177
|
if (role === "assistant" && !text.startsWith("调用失败:")) {
|
|
14363
|
-
const
|
|
15178
|
+
const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
|
|
15179
|
+
const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
|
|
14364
15180
|
const modelDisplayName = metadata?.modelDisplayName || selectedModel?.displayName || "模型";
|
|
14365
15181
|
const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
|
|
14366
15182
|
const meta = document.createElement("div");
|
|
@@ -14372,18 +15188,20 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
14372
15188
|
attachAssistantCopyAction(message, text);
|
|
14373
15189
|
}
|
|
14374
15190
|
attachMessageIdentity(message, messageId);
|
|
14375
|
-
|
|
14376
|
-
scrollAiFeedToBottom();
|
|
15191
|
+
feed.append(message);
|
|
15192
|
+
scrollAiFeedToBottom(feed);
|
|
14377
15193
|
}
|
|
14378
15194
|
|
|
14379
|
-
function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
15195
|
+
function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
|
|
15196
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
15197
|
+
const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
|
|
14380
15198
|
const message = document.createElement("div");
|
|
14381
15199
|
message.className = "assistant-message";
|
|
14382
15200
|
const applicable = suggestion.action !== "note";
|
|
14383
15201
|
const guard = suggestion.guard;
|
|
14384
15202
|
const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
|
|
14385
15203
|
message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`, suggestion.processDurationMs))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
|
|
14386
|
-
attachMessageHeading(message, "助手建议", createdAt ?? undefined);
|
|
15204
|
+
attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
|
|
14387
15205
|
attachAssistantCopyAction(message, suggestion.content);
|
|
14388
15206
|
attachMessageIdentity(message, messageId);
|
|
14389
15207
|
if (applicable) {
|
|
@@ -14406,8 +15224,8 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
14406
15224
|
message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
|
|
14407
15225
|
});
|
|
14408
15226
|
}
|
|
14409
|
-
|
|
14410
|
-
scrollAiFeedToBottom();
|
|
15227
|
+
feed.append(message);
|
|
15228
|
+
scrollAiFeedToBottom(feed);
|
|
14411
15229
|
return message;
|
|
14412
15230
|
}
|
|
14413
15231
|
|
|
@@ -15696,6 +16514,20 @@ $("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
|
15696
16514
|
$("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
|
|
15697
16515
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
15698
16516
|
$("#save-button").addEventListener("click", saveChapter);
|
|
16517
|
+
$("#chapter-search-close").addEventListener("click", () => closeChapterSearchPanel());
|
|
16518
|
+
$("#chapter-search-query").addEventListener("input", () => {
|
|
16519
|
+
chapterSearchMatchIndex = -1;
|
|
16520
|
+
renderChapterSearchStatus();
|
|
16521
|
+
});
|
|
16522
|
+
$("#chapter-search-query").addEventListener("keydown", (event) => {
|
|
16523
|
+
if (event.key !== "Enter") return;
|
|
16524
|
+
event.preventDefault();
|
|
16525
|
+
moveChapterSearchMatch(event.shiftKey ? -1 : 1);
|
|
16526
|
+
});
|
|
16527
|
+
$("#chapter-search-previous").addEventListener("click", () => moveChapterSearchMatch(-1));
|
|
16528
|
+
$("#chapter-search-next").addEventListener("click", () => moveChapterSearchMatch(1));
|
|
16529
|
+
$("#chapter-search-replace").addEventListener("click", replaceCurrentChapterSearchMatch);
|
|
16530
|
+
$("#chapter-search-replace-all").addEventListener("click", replaceAllChapterSearchMatches);
|
|
15699
16531
|
$("#chapter-foreshadow-reminder-previous").addEventListener("click", () => {
|
|
15700
16532
|
showChapterForeshadowReminderAt(chapterForeshadowReminderIndex - 1);
|
|
15701
16533
|
});
|
|
@@ -15862,6 +16694,10 @@ $("#mobile-panel-backdrop").addEventListener("click", () => {
|
|
|
15862
16694
|
applyPanelLayout(true);
|
|
15863
16695
|
});
|
|
15864
16696
|
$("#ai-panel-toggle").addEventListener("click", () => {
|
|
16697
|
+
if (aiConversationWorkspaceOpen) {
|
|
16698
|
+
setAiConversationWorkspaceVisible(false);
|
|
16699
|
+
return;
|
|
16700
|
+
}
|
|
15865
16701
|
panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
|
|
15866
16702
|
applyPanelLayout(true);
|
|
15867
16703
|
});
|
|
@@ -15874,6 +16710,17 @@ window.addEventListener("resize", () => {
|
|
|
15874
16710
|
applyPanelLayout();
|
|
15875
16711
|
scheduleChapterLineNumbers();
|
|
15876
16712
|
});
|
|
16713
|
+
$("#ai-assistant-entry").addEventListener("click", () => {
|
|
16714
|
+
if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) {
|
|
16715
|
+
return toast("当前账户没有创作助手读取权限", "error");
|
|
16716
|
+
}
|
|
16717
|
+
setModuleNavExpanded(false);
|
|
16718
|
+
if (isMobileViewport()) {
|
|
16719
|
+
panelLayout.leftCollapsed = true;
|
|
16720
|
+
applyPanelLayout(true);
|
|
16721
|
+
}
|
|
16722
|
+
setAiConversationWorkspaceVisible(true);
|
|
16723
|
+
});
|
|
15877
16724
|
$("#module-nav").addEventListener("click", (event) => {
|
|
15878
16725
|
const button = event.target.closest("button");
|
|
15879
16726
|
if (!button || button.id === "module-more-button") return;
|
|
@@ -16139,6 +16986,7 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
16139
16986
|
closeManuscriptExportMenu(true);
|
|
16140
16987
|
}
|
|
16141
16988
|
if (!event.target.closest("#ai-history-action-menu") && !event.target.closest(".ai-history-more")) closeAiHistoryActionMenu();
|
|
16989
|
+
if (!event.target.closest("#ai-conversation-switcher-menu") && !event.target.closest("#ai-conversation-switcher")) setAiConversationSwitcherVisible(false);
|
|
16142
16990
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
16143
16991
|
if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
|
|
16144
16992
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
@@ -16158,7 +17006,20 @@ document.addEventListener("keydown", (event) => {
|
|
|
16158
17006
|
return;
|
|
16159
17007
|
}
|
|
16160
17008
|
if (event.key === "Escape") {
|
|
17009
|
+
if (!$("#chapter-search-panel").classList.contains("hidden")) {
|
|
17010
|
+
closeChapterSearchPanel();
|
|
17011
|
+
return;
|
|
17012
|
+
}
|
|
16161
17013
|
if (!$("#ai-history-action-menu").classList.contains("hidden")) return;
|
|
17014
|
+
if (!$("#ai-conversation-switcher-menu").classList.contains("hidden")) {
|
|
17015
|
+
setAiConversationSwitcherVisible(false);
|
|
17016
|
+
$("#ai-conversation-switcher").focus();
|
|
17017
|
+
return;
|
|
17018
|
+
}
|
|
17019
|
+
if (aiConversationWorkspaceOpen) {
|
|
17020
|
+
setAiConversationWorkspaceVisible(false);
|
|
17021
|
+
return;
|
|
17022
|
+
}
|
|
16162
17023
|
closeChapterTypeMenu();
|
|
16163
17024
|
closeLineCitationMenu();
|
|
16164
17025
|
closeMarkdownTableMenu(true);
|
|
@@ -16173,14 +17034,37 @@ document.addEventListener("keydown", (event) => {
|
|
|
16173
17034
|
event.preventDefault();
|
|
16174
17035
|
event.stopPropagation();
|
|
16175
17036
|
if (event.repeat) return;
|
|
17037
|
+
if (isChapterSearchContext()) {
|
|
17038
|
+
openChapterSearchPanel();
|
|
17039
|
+
return;
|
|
17040
|
+
}
|
|
16176
17041
|
openSearchDialog().catch((error) => toast(error.message, "error"));
|
|
16177
17042
|
}, { capture: true });
|
|
16178
17043
|
$("#ai-context-meter").addEventListener("click", () => {
|
|
16179
17044
|
setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
|
|
16180
17045
|
});
|
|
16181
17046
|
$("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
|
|
16182
|
-
$("#ai-send").addEventListener("click",
|
|
17047
|
+
$("#ai-send").addEventListener("click", activateAiSendControl);
|
|
17048
|
+
$("#ai-conversation-switcher").addEventListener("click", () => {
|
|
17049
|
+
setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
|
|
17050
|
+
});
|
|
17051
|
+
$("#ai-conversation-switcher-close").addEventListener("click", () => {
|
|
17052
|
+
setAiConversationSwitcherVisible(false);
|
|
17053
|
+
$("#ai-conversation-switcher").focus();
|
|
17054
|
+
});
|
|
17055
|
+
$("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
|
|
17056
|
+
$("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
|
|
17057
|
+
$("#ai-switcher-history").addEventListener("click", async () => {
|
|
17058
|
+
setAiConversationSwitcherVisible(false);
|
|
17059
|
+
try {
|
|
17060
|
+
await ensureAiConversationsLoaded();
|
|
17061
|
+
setAiHistoryVisible(true);
|
|
17062
|
+
} catch (error) {
|
|
17063
|
+
toast(`对话历史加载失败:${error.message}`, "error");
|
|
17064
|
+
}
|
|
17065
|
+
});
|
|
16183
17066
|
$("#ai-new-conversation").addEventListener("click", async () => {
|
|
17067
|
+
setAiConversationSwitcherVisible(false);
|
|
16184
17068
|
const button = $("#ai-new-conversation");
|
|
16185
17069
|
button.disabled = true;
|
|
16186
17070
|
try {
|
|
@@ -16244,6 +17128,7 @@ $("#ai-context-dismiss").addEventListener("click", async () => {
|
|
|
16244
17128
|
}
|
|
16245
17129
|
});
|
|
16246
17130
|
$("#ai-history-toggle").addEventListener("click", async () => {
|
|
17131
|
+
setAiConversationSwitcherVisible(false);
|
|
16247
17132
|
if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
|
|
16248
17133
|
try {
|
|
16249
17134
|
await ensureAiConversationsLoaded();
|
|
@@ -16304,6 +17189,20 @@ $("#ai-history-action-menu").addEventListener("click", async (event) => {
|
|
|
16304
17189
|
}
|
|
16305
17190
|
return;
|
|
16306
17191
|
}
|
|
17192
|
+
if (action === "copy-session-id") {
|
|
17193
|
+
option.disabled = true;
|
|
17194
|
+
try {
|
|
17195
|
+
await copyAiConversationSessionId(conversation);
|
|
17196
|
+
closeAiHistoryActionMenu(true);
|
|
17197
|
+
toast("对话 Session ID 已复制");
|
|
17198
|
+
} catch (error) {
|
|
17199
|
+
toast(`Session ID 复制失败:${error.message}`, "error");
|
|
17200
|
+
option.focus();
|
|
17201
|
+
} finally {
|
|
17202
|
+
option.disabled = false;
|
|
17203
|
+
}
|
|
17204
|
+
return;
|
|
17205
|
+
}
|
|
16307
17206
|
if (action === "delete") {
|
|
16308
17207
|
option.disabled = true;
|
|
16309
17208
|
try {
|
|
@@ -16356,7 +17255,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
|
|
|
16356
17255
|
}
|
|
16357
17256
|
if (shouldSendAiPrompt(event)) {
|
|
16358
17257
|
event.preventDefault();
|
|
16359
|
-
if (!$("#ai-send").disabled) void sendAi();
|
|
17258
|
+
if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
|
|
16360
17259
|
}
|
|
16361
17260
|
});
|
|
16362
17261
|
$(".quick-actions").addEventListener("click", (event) => {
|
|
@@ -16452,7 +17351,6 @@ $("#reader-font-size").addEventListener("change", (event) => updateReadingPrefer
|
|
|
16452
17351
|
$("#reader-line-height").addEventListener("change", (event) => updateReadingPreference({ lineHeight: Number(event.currentTarget.value) }));
|
|
16453
17352
|
$("#reader-theme").addEventListener("change", (event) => updateReadingPreference({ theme: event.currentTarget.value }));
|
|
16454
17353
|
$("#reader-view").addEventListener("keydown", handleReadingKeyboard);
|
|
16455
|
-
$("#reader-viewport").addEventListener("wheel", handleReadingWheel, { passive: false });
|
|
16456
17354
|
$("#reader-viewport").addEventListener("scroll", () => {
|
|
16457
17355
|
if (readingPreferences.mode === "scroll") scheduleReadingPositionSave();
|
|
16458
17356
|
}, { passive: true });
|
|
@@ -16469,7 +17367,7 @@ window.addEventListener("pagehide", dismissDeleteToasts);
|
|
|
16469
17367
|
window.addEventListener("beforeunload", (event) => {
|
|
16470
17368
|
if (hasUnsavedEditorChanges()) event.preventDefault();
|
|
16471
17369
|
if (aiRequestManager.hasActive()) {
|
|
16472
|
-
toast("
|
|
17370
|
+
toast("仍有 Agent turn 尚未结束,刷新会中断全部生成;已收到的内容会保留在历史记录中", "warning");
|
|
16473
17371
|
}
|
|
16474
17372
|
});
|
|
16475
17373
|
window.addEventListener("online", () => {
|
|
@@ -16486,6 +17384,7 @@ window.addEventListener("resize", () => {
|
|
|
16486
17384
|
});
|
|
16487
17385
|
});
|
|
16488
17386
|
|
|
17387
|
+
initializeAiChatTabs();
|
|
16489
17388
|
initializePage().catch((error) => {
|
|
16490
17389
|
restoringPageRoute = false;
|
|
16491
17390
|
if (state.user) {
|