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