@musnows/scriverse 1.0.11 → 1.1.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 +6 -3
- package/README.md +6 -3
- package/dist/ai-stream-steer.js +96 -0
- package/dist/ai-stream-steer.js.map +1 -0
- package/dist/ai-write-plans.js +44 -38
- package/dist/ai-write-plans.js.map +1 -1
- package/dist/ai.js +241 -128
- package/dist/ai.js.map +1 -1
- package/dist/app.js +134 -14
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +3 -0
- package/dist/attachment-storage.js.map +1 -1
- package/dist/chapter-annotation-note.js +14 -0
- package/dist/chapter-annotation-note.js.map +1 -0
- package/dist/character-structured-fields.js +7 -0
- package/dist/character-structured-fields.js.map +1 -0
- package/dist/cli-contract.js +1 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +29 -2
- package/dist/database.js.map +1 -1
- package/dist/im.js +2 -1
- package/dist/im.js.map +1 -1
- package/dist/public/ai-prompt-keyboard.js +5 -1
- package/dist/public/ai-prompt-queue.js +226 -0
- package/dist/public/ai-send-mode.js +31 -0
- package/dist/public/app.js +689 -74
- package/dist/public/chapter-editor-scroll.d.ts +37 -0
- package/dist/public/chapter-editor-scroll.js +52 -0
- package/dist/public/character-profile.d.ts +7 -0
- package/dist/public/character-profile.js +123 -5
- package/dist/public/index.html +31 -10
- package/dist/public/styles.css +75 -8
- package/dist/security.js +22 -3
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +7 -6
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +125 -15
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -21,6 +21,13 @@ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManage
|
|
|
21
21
|
import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
22
22
|
import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
|
|
23
23
|
import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
|
|
24
|
+
import {
|
|
25
|
+
calculateChapterEditorContentHeight,
|
|
26
|
+
chapterLineNumberLayerTransform,
|
|
27
|
+
needsChapterLineVirtualWindowRefresh,
|
|
28
|
+
readChapterEditorScrollMetrics,
|
|
29
|
+
transferNestedEditorScroll
|
|
30
|
+
} from "/chapter-editor-scroll.js?v=20260916-line-scroll-bind-v1";
|
|
24
31
|
import {
|
|
25
32
|
FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
|
|
26
33
|
foreshadowReminderRequestTargetsState,
|
|
@@ -32,7 +39,18 @@ import {
|
|
|
32
39
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
33
40
|
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1&feature=semantic-search-v6";
|
|
34
41
|
import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
|
|
35
|
-
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=
|
|
42
|
+
import { shouldActivateAiSendControl, shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260919-ai-send-mode-v2";
|
|
43
|
+
import { aiSendModeAction, normalizeAiSendMode, readStoredAiSendMode, writeStoredAiSendMode } from "/ai-send-mode.js?v=20260919-ai-send-mode-v2";
|
|
44
|
+
import {
|
|
45
|
+
aiPromptQueueDragPayload,
|
|
46
|
+
aiPromptQueuePreview,
|
|
47
|
+
canSendQueuedPromptAsSteer,
|
|
48
|
+
createAiPromptQueue,
|
|
49
|
+
parseAiPromptQueueDragPayload,
|
|
50
|
+
queuedPromptEditContent,
|
|
51
|
+
queuedPromptEditPatch,
|
|
52
|
+
queuedPromptSteerContent
|
|
53
|
+
} from "/ai-prompt-queue.js?v=20260919-ai-prompt-queue-v2";
|
|
36
54
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
|
|
37
55
|
import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260912-stream-render-v2";
|
|
38
56
|
import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
|
|
@@ -62,7 +80,7 @@ import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatI
|
|
|
62
80
|
import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
|
|
63
81
|
import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-v1";
|
|
64
82
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
65
|
-
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=
|
|
83
|
+
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterAttributes, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260919-character-attributes-prune-v1";
|
|
66
84
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
|
|
67
85
|
import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
|
|
68
86
|
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
|
|
@@ -202,6 +220,7 @@ function isSelectableModel(model) {
|
|
|
202
220
|
const state = {
|
|
203
221
|
user: null,
|
|
204
222
|
csrfToken: null,
|
|
223
|
+
registrationMode: "disabled",
|
|
205
224
|
works: [],
|
|
206
225
|
work: null,
|
|
207
226
|
chapter: null,
|
|
@@ -243,6 +262,11 @@ const state = {
|
|
|
243
262
|
let platformAiProtocolOptions = [];
|
|
244
263
|
|
|
245
264
|
const aiRequestManager = createAiRequestManager();
|
|
265
|
+
const aiPromptQueue = createAiPromptQueue();
|
|
266
|
+
let pendingQueuedComposer = null;
|
|
267
|
+
let aiPromptQueueDidDrag = false;
|
|
268
|
+
let aiPromptQueueEditorState = null;
|
|
269
|
+
let aiPromptQueueEditorBlurTimer = 0;
|
|
246
270
|
const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
|
|
247
271
|
const moduleRequestCache = createModuleRequestCache();
|
|
248
272
|
const cachedWorkModules = new Set([
|
|
@@ -501,6 +525,7 @@ function applyWorkAccessMode() {
|
|
|
501
525
|
$("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
|
|
502
526
|
$("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
|
|
503
527
|
$("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
|
|
528
|
+
$("#writing-progress-button").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("editor"));
|
|
504
529
|
$("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
|
|
505
530
|
$("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
506
531
|
$("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
|
|
@@ -524,6 +549,7 @@ function applyWorkAccessMode() {
|
|
|
524
549
|
state.dirty = false;
|
|
525
550
|
}
|
|
526
551
|
applyChapterEditorMode();
|
|
552
|
+
syncAiSendModeControl();
|
|
527
553
|
}
|
|
528
554
|
|
|
529
555
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -665,28 +691,77 @@ function aiInteractionBusy() {
|
|
|
665
691
|
}
|
|
666
692
|
|
|
667
693
|
function aiSendButtonIconMarkup(stateName) {
|
|
694
|
+
if (stateName === "queue") {
|
|
695
|
+
return '<svg class="ai-send-button-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 7h11M5 12h8M5 17h11"></path><path d="M16 10v7M12.5 13.5H19.5"></path></svg>';
|
|
696
|
+
}
|
|
668
697
|
return stateName === "stop"
|
|
669
698
|
? '<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>'
|
|
670
699
|
: '<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>';
|
|
671
700
|
}
|
|
672
701
|
|
|
702
|
+
function applyAiSendModeFromStorage() {
|
|
703
|
+
const select = $("#ai-send-mode");
|
|
704
|
+
if (!select) return;
|
|
705
|
+
select.value = readStoredAiSendMode(localStorage);
|
|
706
|
+
syncAiSendModeControl();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function aiComposerSendMode() {
|
|
710
|
+
return normalizeAiSendMode($("#ai-send-mode")?.value);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function syncAiSendModeControl() {
|
|
714
|
+
const select = $("#ai-send-mode");
|
|
715
|
+
if (!select) return;
|
|
716
|
+
const mode = normalizeAiSendMode(select.value);
|
|
717
|
+
if (select.value !== mode) select.value = mode;
|
|
718
|
+
const aiReadOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
|
|
719
|
+
select.disabled = aiReadOnly;
|
|
720
|
+
select.title = mode === "steer"
|
|
721
|
+
? "引导发送:生成中再发送会立即作用于当前执行流,不会打断本轮。空输入时发送键用于终止"
|
|
722
|
+
: "排队发送:生成中再发送会加入队列,当前回复结束后按顺序发送,不会打断正在执行的回复。空输入时发送键用于终止";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function aiComposerHasSendablePrompt() {
|
|
726
|
+
return Boolean(queuedPromptSteerContent(captureAiPromptComposer()));
|
|
727
|
+
}
|
|
728
|
+
|
|
673
729
|
function syncAiRequestControls() {
|
|
674
730
|
const activeTabId = activeAiChatTab()?.id;
|
|
675
731
|
const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
|
|
676
732
|
const sending = aiRequestManager.hasActive(activeTabId) && !continuingQuestion;
|
|
677
733
|
const switching = aiConversationNavigationPending !== null;
|
|
678
734
|
const button = $("#ai-send");
|
|
679
|
-
const
|
|
680
|
-
const
|
|
735
|
+
const sendAction = aiSendModeAction(aiComposerSendMode(), sending, aiComposerHasSendablePrompt());
|
|
736
|
+
const stateName = sendAction === "queue" || sendAction === "steer" || sendAction === "stop"
|
|
737
|
+
? sendAction
|
|
738
|
+
: (switching || continuingQuestion) ? "switching" : "send";
|
|
739
|
+
const label = sendAction === "stop"
|
|
740
|
+
? "终止当前回复"
|
|
741
|
+
: sendAction === "queue"
|
|
742
|
+
? "排队发送"
|
|
743
|
+
: sendAction === "steer" ? "引导发送"
|
|
744
|
+
: continuingQuestion ? "AI 正在根据回答继续处理"
|
|
745
|
+
: switching ? "正在切换对话" : "发送消息";
|
|
681
746
|
button.disabled = switching || continuingQuestion;
|
|
682
747
|
button.dataset.state = stateName;
|
|
683
|
-
button.classList.toggle("is-stop",
|
|
748
|
+
button.classList.toggle("is-stop", sendAction === "stop");
|
|
749
|
+
button.classList.toggle("is-queue", sendAction === "queue");
|
|
684
750
|
button.setAttribute("aria-label", label);
|
|
685
|
-
button.title =
|
|
686
|
-
|
|
751
|
+
button.title = sendAction === "stop"
|
|
752
|
+
? "终止当前回复,排队 Prompt 仍会保留"
|
|
753
|
+
: sendAction === "queue"
|
|
754
|
+
? "排队发送,当前回复结束后按顺序发送,不会打断正在执行的回复"
|
|
755
|
+
: sendAction === "steer"
|
|
756
|
+
? "引导发送,立即作用于当前执行流,不会打断本轮"
|
|
757
|
+
: label;
|
|
758
|
+
button.innerHTML = aiSendButtonIconMarkup(stateName === "steer" ? "send" : stateName);
|
|
759
|
+
$(".prompt-composer")?.classList.toggle("is-streaming", sending);
|
|
760
|
+
syncAiSendModeControl();
|
|
687
761
|
syncAiTaskOptions();
|
|
688
762
|
renderAiRoleplayCharacterSelect();
|
|
689
763
|
syncAiImageAttachmentControl();
|
|
764
|
+
renderAiPromptQueue();
|
|
690
765
|
}
|
|
691
766
|
|
|
692
767
|
function cancelActiveAiRequest(reason) {
|
|
@@ -695,18 +770,388 @@ function cancelActiveAiRequest(reason) {
|
|
|
695
770
|
return cancelled;
|
|
696
771
|
}
|
|
697
772
|
|
|
698
|
-
function
|
|
773
|
+
function submitAiComposerPrompt() {
|
|
699
774
|
const tab = activeAiChatTab();
|
|
700
775
|
if (aiRequestManager.hasActive(tab?.id)) {
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
776
|
+
if (aiComposerSendMode() === "steer") {
|
|
777
|
+
void sendActiveComposerAsSteer();
|
|
778
|
+
return;
|
|
704
779
|
}
|
|
780
|
+
queueActiveComposerPrompt();
|
|
705
781
|
return;
|
|
706
782
|
}
|
|
707
783
|
void sendAi();
|
|
708
784
|
}
|
|
709
785
|
|
|
786
|
+
function activateAiSendControl() {
|
|
787
|
+
const tab = activeAiChatTab();
|
|
788
|
+
if (aiRequestManager.hasActive(tab?.id) && !aiComposerHasSendablePrompt()) {
|
|
789
|
+
activateAiStopControl();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
submitAiComposerPrompt();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function activateAiStopControl() {
|
|
796
|
+
if (cancelActiveAiRequest("用户已终止当前回复")) {
|
|
797
|
+
toast("已终止当前回复,排队 Prompt 仍会保留");
|
|
798
|
+
$("#ai-prompt").focus();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function activeAiConversationId(tab = activeAiChatTab()) {
|
|
803
|
+
return String(tab?.conversationId ?? state.aiConversationId ?? "").trim();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function queueActiveComposerPrompt() {
|
|
807
|
+
const tab = activeAiChatTab();
|
|
808
|
+
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
809
|
+
const snapshot = captureAiPromptComposer();
|
|
810
|
+
try {
|
|
811
|
+
aiPromptQueue.enqueue(tab.id, snapshot);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
return toast(error.message, "error");
|
|
814
|
+
}
|
|
815
|
+
clearAiPromptComposer({ collapseScenePanel: Boolean(snapshot.sceneDirection) });
|
|
816
|
+
persistActiveAiChatTab();
|
|
817
|
+
syncAiRequestControls();
|
|
818
|
+
toast("已加入排队,当前回复结束后发送");
|
|
819
|
+
$("#ai-prompt").focus();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function postAiConversationSteer(conversationId, content) {
|
|
823
|
+
const id = String(conversationId ?? "").trim();
|
|
824
|
+
if (!id) throw new Error("当前对话尚未开始,无法发送引导");
|
|
825
|
+
return api(`/api/ai-conversations/${encodeURIComponent(id)}/steer`, {
|
|
826
|
+
method: "POST",
|
|
827
|
+
body: { content }
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function sendActiveComposerAsSteer() {
|
|
832
|
+
const tab = activeAiChatTab();
|
|
833
|
+
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
834
|
+
if (!aiRequestManager.hasActive(tab.id)) return toast("当前没有正在执行的回复,无法发送引导", "error");
|
|
835
|
+
const snapshot = captureAiPromptComposer();
|
|
836
|
+
const content = queuedPromptSteerContent(snapshot);
|
|
837
|
+
if (!content) {
|
|
838
|
+
return toast($("#ai-task").value === "roleplay" ? "请输入台词或场景旁白" : "请输入引导内容", "error");
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
await postAiConversationSteer(activeAiConversationId(tab), content);
|
|
842
|
+
} catch (error) {
|
|
843
|
+
return toast(error.message, "error");
|
|
844
|
+
}
|
|
845
|
+
clearAiPromptComposer({ collapseScenePanel: Boolean(snapshot.sceneDirection) });
|
|
846
|
+
syncAiRequestControls();
|
|
847
|
+
toast("已发送执行流引导");
|
|
848
|
+
$("#ai-prompt").focus();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function sendQueuedPromptAsSteer(itemId) {
|
|
852
|
+
const tab = activeAiChatTab();
|
|
853
|
+
if (!tab) return;
|
|
854
|
+
const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
|
|
855
|
+
if (!canSendQueuedPromptAsSteer(item, aiRequestManager.hasActive(tab.id))) {
|
|
856
|
+
return toast("当前没有正在执行的回复,无法发送引导", "error");
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
await postAiConversationSteer(activeAiConversationId(tab), queuedPromptSteerContent(item));
|
|
860
|
+
} catch (error) {
|
|
861
|
+
return toast(error.message, "error");
|
|
862
|
+
}
|
|
863
|
+
aiPromptQueue.remove(tab.id, itemId);
|
|
864
|
+
renderAiPromptQueue();
|
|
865
|
+
toast("已将排队 Prompt 立即引导");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function sendQueuedPromptNow(itemId) {
|
|
869
|
+
const tab = activeAiChatTab();
|
|
870
|
+
if (!tab || aiRequestManager.hasActive(tab.id)) return;
|
|
871
|
+
const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
|
|
872
|
+
if (!item) return;
|
|
873
|
+
pendingQueuedComposer = item;
|
|
874
|
+
void sendAiWithOptions();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function flushNextQueuedPrompt(tab) {
|
|
878
|
+
if (!tab || !isActiveAiChatTab(tab) || aiRequestManager.hasActive(tab.id) || aiQuestionContinuationTabIds.has(tab.id)) {
|
|
879
|
+
renderAiPromptQueue();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const next = aiPromptQueue.list(tab.id)[0];
|
|
883
|
+
renderAiPromptQueue();
|
|
884
|
+
if (!next) return;
|
|
885
|
+
pendingQueuedComposer = next;
|
|
886
|
+
void sendAiWithOptions();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function clearAiPromptQueueEditorState(itemId = null) {
|
|
890
|
+
if (!itemId || aiPromptQueueEditorState?.itemId === itemId) aiPromptQueueEditorState = null;
|
|
891
|
+
if (aiPromptQueueEditorBlurTimer) {
|
|
892
|
+
window.clearTimeout(aiPromptQueueEditorBlurTimer);
|
|
893
|
+
aiPromptQueueEditorBlurTimer = 0;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function captureAiPromptQueueEditorState() {
|
|
898
|
+
if (!aiPromptQueueEditorState) return null;
|
|
899
|
+
const editor = document.querySelector("#ai-prompt-queue-list .ai-prompt-queue-editor");
|
|
900
|
+
if (!(editor instanceof HTMLTextAreaElement)) return aiPromptQueueEditorState;
|
|
901
|
+
const itemId = editor.closest("[data-queue-id]")?.dataset.queueId;
|
|
902
|
+
if (!itemId || itemId !== aiPromptQueueEditorState.itemId) return aiPromptQueueEditorState;
|
|
903
|
+
return {
|
|
904
|
+
itemId,
|
|
905
|
+
value: editor.value,
|
|
906
|
+
selectionStart: editor.selectionStart,
|
|
907
|
+
selectionEnd: editor.selectionEnd
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function commitAiPromptQueueEdit(itemId, nextValue = null, { render = true } = {}) {
|
|
912
|
+
const tab = activeAiChatTab();
|
|
913
|
+
if (!tab || !itemId) return false;
|
|
914
|
+
const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
|
|
915
|
+
if (!item) {
|
|
916
|
+
clearAiPromptQueueEditorState(itemId);
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
const value = nextValue == null ? queuedPromptEditContent(item) : nextValue;
|
|
920
|
+
if (value === queuedPromptEditContent(item)) {
|
|
921
|
+
clearAiPromptQueueEditorState(itemId);
|
|
922
|
+
if (render) renderAiPromptQueue();
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
aiPromptQueue.update(tab.id, itemId, queuedPromptEditPatch(item, value));
|
|
927
|
+
} catch (error) {
|
|
928
|
+
toast(error.message, "error");
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
clearAiPromptQueueEditorState(itemId);
|
|
932
|
+
if (render) renderAiPromptQueue();
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function beginAiPromptQueueEdit(item) {
|
|
937
|
+
const tab = activeAiChatTab();
|
|
938
|
+
if (!tab || !item || (state.work && !canWritePermissionModule(state.work, "ai-chat"))) return;
|
|
939
|
+
if (aiPromptQueueEditorState && aiPromptQueueEditorState.itemId !== item.id) {
|
|
940
|
+
const previous = document.querySelector("#ai-prompt-queue-list .ai-prompt-queue-editor");
|
|
941
|
+
const previousValue = previous instanceof HTMLTextAreaElement ? previous.value : aiPromptQueueEditorState.value;
|
|
942
|
+
if (!commitAiPromptQueueEdit(aiPromptQueueEditorState.itemId, previousValue)) return;
|
|
943
|
+
}
|
|
944
|
+
aiPromptQueueEditorState = {
|
|
945
|
+
itemId: item.id,
|
|
946
|
+
value: queuedPromptEditContent(item),
|
|
947
|
+
selectionStart: 0,
|
|
948
|
+
selectionEnd: queuedPromptEditContent(item).length
|
|
949
|
+
};
|
|
950
|
+
renderAiPromptQueue();
|
|
951
|
+
const editor = document.querySelector(`#ai-prompt-queue-list [data-queue-id="${CSS.escape(item.id)}"] .ai-prompt-queue-editor`);
|
|
952
|
+
if (editor instanceof HTMLTextAreaElement) {
|
|
953
|
+
editor.focus();
|
|
954
|
+
editor.setSelectionRange(0, editor.value.length);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function moveAiPromptQueueItem(sourceId, targetId, placeAfter) {
|
|
959
|
+
const tab = activeAiChatTab();
|
|
960
|
+
if (!tab || !sourceId || !targetId) return;
|
|
961
|
+
aiPromptQueue.move(tab.id, sourceId, targetId, placeAfter);
|
|
962
|
+
renderAiPromptQueue();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function moveAiPromptQueueItemByOffset(itemId, offset) {
|
|
966
|
+
const tab = activeAiChatTab();
|
|
967
|
+
if (!tab || !itemId) return;
|
|
968
|
+
aiPromptQueue.moveByOffset(tab.id, itemId, offset);
|
|
969
|
+
renderAiPromptQueue();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function bindAiPromptQueueItemDrag(row, item, aiReadOnly, editing) {
|
|
973
|
+
if (aiReadOnly || editing) {
|
|
974
|
+
row.draggable = false;
|
|
975
|
+
row.removeAttribute("aria-grabbed");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
row.draggable = true;
|
|
979
|
+
row.setAttribute("aria-grabbed", "false");
|
|
980
|
+
row.addEventListener("dragstart", (event) => {
|
|
981
|
+
if (event.target.closest("button, textarea, .ai-prompt-queue-editor")) {
|
|
982
|
+
event.preventDefault();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
aiPromptQueueDidDrag = true;
|
|
986
|
+
event.dataTransfer?.setData("text/plain", aiPromptQueueDragPayload(item.id));
|
|
987
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
988
|
+
row.classList.add("is-dragging");
|
|
989
|
+
row.setAttribute("aria-grabbed", "true");
|
|
990
|
+
});
|
|
991
|
+
row.addEventListener("dragend", () => {
|
|
992
|
+
row.classList.remove("is-dragging");
|
|
993
|
+
row.setAttribute("aria-grabbed", "false");
|
|
994
|
+
listAiPromptQueueDropTargets().forEach((node) => node.classList.remove("is-drag-over", "drop-after"));
|
|
995
|
+
window.setTimeout(() => { aiPromptQueueDidDrag = false; }, 0);
|
|
996
|
+
});
|
|
997
|
+
row.addEventListener("dragover", (event) => {
|
|
998
|
+
if (!event.dataTransfer?.types.includes("text/plain")) return;
|
|
999
|
+
event.preventDefault();
|
|
1000
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
1001
|
+
const after = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2;
|
|
1002
|
+
listAiPromptQueueDropTargets().forEach((node) => {
|
|
1003
|
+
if (node !== row) node.classList.remove("is-drag-over", "drop-after");
|
|
1004
|
+
});
|
|
1005
|
+
row.classList.toggle("drop-after", after);
|
|
1006
|
+
row.classList.add("is-drag-over");
|
|
1007
|
+
});
|
|
1008
|
+
row.addEventListener("dragleave", (event) => {
|
|
1009
|
+
if (row.contains(event.relatedTarget)) return;
|
|
1010
|
+
row.classList.remove("is-drag-over", "drop-after");
|
|
1011
|
+
});
|
|
1012
|
+
row.addEventListener("drop", (event) => {
|
|
1013
|
+
event.preventDefault();
|
|
1014
|
+
event.stopPropagation();
|
|
1015
|
+
const sourceId = parseAiPromptQueueDragPayload(event.dataTransfer?.getData("text/plain"));
|
|
1016
|
+
const after = row.classList.contains("drop-after");
|
|
1017
|
+
row.classList.remove("is-drag-over", "drop-after");
|
|
1018
|
+
if (!sourceId || sourceId === item.id) return;
|
|
1019
|
+
moveAiPromptQueueItem(sourceId, item.id, after);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function listAiPromptQueueDropTargets() {
|
|
1024
|
+
return [...document.querySelectorAll("#ai-prompt-queue-list .ai-prompt-queue-item")];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function renderAiPromptQueue() {
|
|
1028
|
+
const tab = activeAiChatTab();
|
|
1029
|
+
const panel = $("#ai-prompt-queue");
|
|
1030
|
+
const list = $("#ai-prompt-queue-list");
|
|
1031
|
+
if (!panel || !list) return;
|
|
1032
|
+
const items = tab ? aiPromptQueue.list(tab.id) : [];
|
|
1033
|
+
const streaming = Boolean(tab && aiRequestManager.hasActive(tab.id));
|
|
1034
|
+
const aiReadOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
|
|
1035
|
+
const editorState = captureAiPromptQueueEditorState();
|
|
1036
|
+
if (editorState && !items.some((item) => item.id === editorState.itemId)) clearAiPromptQueueEditorState(editorState.itemId);
|
|
1037
|
+
const activeEditor = items.some((item) => item.id === (editorState?.itemId ?? aiPromptQueueEditorState?.itemId))
|
|
1038
|
+
? editorState ?? aiPromptQueueEditorState
|
|
1039
|
+
: null;
|
|
1040
|
+
if (activeEditor) aiPromptQueueEditorState = activeEditor;
|
|
1041
|
+
else if (!items.length) clearAiPromptQueueEditorState();
|
|
1042
|
+
panel.classList.toggle("hidden", items.length === 0);
|
|
1043
|
+
list.replaceChildren(...items.map((item) => {
|
|
1044
|
+
const editing = Boolean(!aiReadOnly && activeEditor?.itemId === item.id);
|
|
1045
|
+
const row = document.createElement("li");
|
|
1046
|
+
row.className = `ai-prompt-queue-item${editing ? " is-editing" : ""}`;
|
|
1047
|
+
row.dataset.queueId = item.id;
|
|
1048
|
+
row.title = aiReadOnly ? "" : "拖拽调整顺序;点击文字可编辑";
|
|
1049
|
+
const previewText = aiPromptQueuePreview(queuedPromptSteerContent(item) || item.text);
|
|
1050
|
+
const fullText = queuedPromptSteerContent(item) || item.text;
|
|
1051
|
+
let preview;
|
|
1052
|
+
if (editing) {
|
|
1053
|
+
preview = document.createElement("textarea");
|
|
1054
|
+
preview.className = "ai-prompt-queue-editor";
|
|
1055
|
+
preview.value = activeEditor.value ?? queuedPromptEditContent(item);
|
|
1056
|
+
preview.rows = Math.min(6, Math.max(1, preview.value.split("\n").length));
|
|
1057
|
+
preview.setAttribute("aria-label", `编辑排队 Prompt:${previewText}`);
|
|
1058
|
+
preview.addEventListener("click", (event) => event.stopPropagation());
|
|
1059
|
+
preview.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
1060
|
+
preview.addEventListener("keydown", (event) => {
|
|
1061
|
+
event.stopPropagation();
|
|
1062
|
+
if (event.key === "Escape") {
|
|
1063
|
+
event.preventDefault();
|
|
1064
|
+
clearAiPromptQueueEditorState(item.id);
|
|
1065
|
+
renderAiPromptQueue();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (shouldSendAiPrompt(event)) {
|
|
1069
|
+
event.preventDefault();
|
|
1070
|
+
commitAiPromptQueueEdit(item.id, preview.value);
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
preview.addEventListener("blur", () => {
|
|
1074
|
+
aiPromptQueueEditorBlurTimer = window.setTimeout(() => {
|
|
1075
|
+
aiPromptQueueEditorBlurTimer = 0;
|
|
1076
|
+
if (aiPromptQueueEditorState?.itemId !== item.id) return;
|
|
1077
|
+
if (document.activeElement?.classList.contains("ai-prompt-queue-editor")) return;
|
|
1078
|
+
commitAiPromptQueueEdit(item.id, preview.value);
|
|
1079
|
+
}, 0);
|
|
1080
|
+
});
|
|
1081
|
+
} else {
|
|
1082
|
+
preview = document.createElement("p");
|
|
1083
|
+
preview.className = "ai-prompt-queue-preview";
|
|
1084
|
+
preview.textContent = previewText;
|
|
1085
|
+
preview.title = aiReadOnly ? fullText : `${fullText}\n点击编辑`;
|
|
1086
|
+
preview.tabIndex = aiReadOnly ? -1 : 0;
|
|
1087
|
+
preview.setAttribute("role", "button");
|
|
1088
|
+
preview.setAttribute("aria-label", `编辑排队 Prompt:${previewText}`);
|
|
1089
|
+
const startEdit = (event) => {
|
|
1090
|
+
if (aiReadOnly || aiPromptQueueDidDrag) return;
|
|
1091
|
+
event.preventDefault();
|
|
1092
|
+
beginAiPromptQueueEdit(item);
|
|
1093
|
+
};
|
|
1094
|
+
preview.addEventListener("click", startEdit);
|
|
1095
|
+
preview.addEventListener("keydown", (event) => {
|
|
1096
|
+
if (event.altKey && ["ArrowUp", "ArrowDown"].includes(event.key)) {
|
|
1097
|
+
event.preventDefault();
|
|
1098
|
+
moveAiPromptQueueItemByOffset(item.id, event.key === "ArrowDown" ? 1 : -1);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1102
|
+
startEdit(event);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const actions = document.createElement("div");
|
|
1107
|
+
actions.className = "ai-prompt-queue-actions";
|
|
1108
|
+
const primary = document.createElement("button");
|
|
1109
|
+
primary.type = "button";
|
|
1110
|
+
const flushEditorBeforeAction = () => {
|
|
1111
|
+
if (!(preview instanceof HTMLTextAreaElement)) return;
|
|
1112
|
+
commitAiPromptQueueEdit(item.id, preview.value, { render: false });
|
|
1113
|
+
};
|
|
1114
|
+
if (streaming) {
|
|
1115
|
+
primary.className = "ai-prompt-queue-steer";
|
|
1116
|
+
primary.textContent = "立即引导";
|
|
1117
|
+
primary.disabled = aiReadOnly || !canSendQueuedPromptAsSteer(item, true);
|
|
1118
|
+
primary.setAttribute("aria-label", `立即引导排队 Prompt:${previewText}`);
|
|
1119
|
+
primary.addEventListener("pointerdown", flushEditorBeforeAction);
|
|
1120
|
+
primary.addEventListener("click", () => { void sendQueuedPromptAsSteer(item.id); });
|
|
1121
|
+
} else {
|
|
1122
|
+
primary.className = "ai-prompt-queue-send";
|
|
1123
|
+
primary.textContent = "现在发送";
|
|
1124
|
+
primary.disabled = aiReadOnly;
|
|
1125
|
+
primary.setAttribute("aria-label", `现在发送排队 Prompt:${previewText}`);
|
|
1126
|
+
primary.addEventListener("pointerdown", flushEditorBeforeAction);
|
|
1127
|
+
primary.addEventListener("click", () => sendQueuedPromptNow(item.id));
|
|
1128
|
+
}
|
|
1129
|
+
const remove = document.createElement("button");
|
|
1130
|
+
remove.type = "button";
|
|
1131
|
+
remove.className = "ai-prompt-queue-remove";
|
|
1132
|
+
remove.setAttribute("aria-label", `移除排队 Prompt:${previewText}`);
|
|
1133
|
+
remove.textContent = "×";
|
|
1134
|
+
remove.addEventListener("click", () => {
|
|
1135
|
+
aiPromptQueue.remove(tab.id, item.id);
|
|
1136
|
+
clearAiPromptQueueEditorState(item.id);
|
|
1137
|
+
renderAiPromptQueue();
|
|
1138
|
+
});
|
|
1139
|
+
actions.append(primary, remove);
|
|
1140
|
+
row.append(preview, actions);
|
|
1141
|
+
bindAiPromptQueueItemDrag(row, item, aiReadOnly, editing);
|
|
1142
|
+
return row;
|
|
1143
|
+
}));
|
|
1144
|
+
if (activeEditor) {
|
|
1145
|
+
const editor = list.querySelector(`[data-queue-id="${CSS.escape(activeEditor.itemId)}"] .ai-prompt-queue-editor`);
|
|
1146
|
+
if (editor instanceof HTMLTextAreaElement) {
|
|
1147
|
+
const start = Number.isInteger(activeEditor.selectionStart) ? activeEditor.selectionStart : editor.value.length;
|
|
1148
|
+
const end = Number.isInteger(activeEditor.selectionEnd) ? activeEditor.selectionEnd : editor.value.length;
|
|
1149
|
+
editor.focus();
|
|
1150
|
+
editor.setSelectionRange(start, end);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
710
1155
|
function beginAiConversationNavigation(reason, action = "切换会话") {
|
|
711
1156
|
aiConversationNavigationGeneration += 1;
|
|
712
1157
|
aiConversationNavigationPending = aiConversationNavigationGeneration;
|
|
@@ -827,6 +1272,7 @@ let chapterSelectionRequestGeneration = 0;
|
|
|
827
1272
|
let aiConversationNavigationGeneration = 0;
|
|
828
1273
|
let aiConversationNavigationPending = null;
|
|
829
1274
|
let aiChatTabLimit = 5;
|
|
1275
|
+
let chapterAnnotationNoteMaxLength = 6000;
|
|
830
1276
|
let aiConversationWorkspaceOpen = false;
|
|
831
1277
|
const loadedVolumeChapterIds = new Set();
|
|
832
1278
|
const volumeChapterLoadingIds = new Set();
|
|
@@ -1739,24 +2185,27 @@ function setModuleNavExpanded(expanded) {
|
|
|
1739
2185
|
$("#module-nav").querySelectorAll(".module-nav-secondary").forEach((button) => button.classList.toggle("hidden", !expanded));
|
|
1740
2186
|
}
|
|
1741
2187
|
|
|
2188
|
+
function getChapterEditorScroller() {
|
|
2189
|
+
// 行号与正文只允许这个容器滚动。
|
|
2190
|
+
return $("#chapter-editor-scroll");
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1742
2193
|
function syncChapterLineNumberScroll() {
|
|
2194
|
+
const scroller = getChapterEditorScroller();
|
|
1743
2195
|
const input = $("#chapter-content");
|
|
1744
2196
|
const inner = $("#chapter-line-numbers-inner");
|
|
1745
2197
|
const whitespace = $("#chapter-whitespace-inner");
|
|
1746
|
-
if (!
|
|
1747
|
-
|
|
1748
|
-
|
|
2198
|
+
if (!scroller || !inner) return;
|
|
2199
|
+
if (input) transferNestedEditorScroll(input, scroller);
|
|
2200
|
+
const metrics = readChapterEditorScrollMetrics(scroller);
|
|
2201
|
+
const layerTransform = chapterLineNumberLayerTransform();
|
|
2202
|
+
inner.style.transform = layerTransform;
|
|
2203
|
+
inner.dataset.scrollTop = String(metrics.scrollTop);
|
|
1749
2204
|
if (whitespace) {
|
|
1750
|
-
whitespace.style.transform =
|
|
1751
|
-
whitespace.dataset.scrollTop = String(
|
|
2205
|
+
whitespace.style.transform = layerTransform;
|
|
2206
|
+
whitespace.dataset.scrollTop = String(metrics.scrollTop);
|
|
1752
2207
|
}
|
|
1753
|
-
if (
|
|
1754
|
-
const buffer = input.clientHeight * 0.35;
|
|
1755
|
-
const viewportBottom = input.scrollTop + input.clientHeight;
|
|
1756
|
-
const needsPreviousLines = chapterLineVirtualWindow.start > 0 && input.scrollTop < chapterLineVirtualWindow.top + buffer;
|
|
1757
|
-
const needsNextLines = chapterLineVirtualWindow.end < chapterLineVirtualWindow.lineCount
|
|
1758
|
-
&& viewportBottom > chapterLineVirtualWindow.bottom - buffer;
|
|
1759
|
-
if (needsPreviousLines || needsNextLines) scheduleChapterLineNumbers();
|
|
2208
|
+
if (needsChapterLineVirtualWindowRefresh(chapterLineVirtualWindow, metrics)) scheduleChapterLineNumbers();
|
|
1760
2209
|
}
|
|
1761
2210
|
|
|
1762
2211
|
function syncChapterWhitespaceControls() {
|
|
@@ -1908,7 +2357,8 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
|
|
|
1908
2357
|
const input = $("#chapter-content");
|
|
1909
2358
|
const inner = $("#chapter-line-numbers-inner");
|
|
1910
2359
|
const measure = $("#chapter-line-measure");
|
|
1911
|
-
|
|
2360
|
+
const scroller = getChapterEditorScroller();
|
|
2361
|
+
if (!input || !inner || !measure || !scroller || input.clientWidth === 0) return;
|
|
1912
2362
|
const style = getComputedStyle(input);
|
|
1913
2363
|
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
1914
2364
|
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
@@ -1919,20 +2369,31 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
|
|
|
1919
2369
|
const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
|
|
1920
2370
|
const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
|
|
1921
2371
|
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
1922
|
-
const
|
|
1923
|
-
const
|
|
1924
|
-
|
|
2372
|
+
const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight);
|
|
2373
|
+
const nextHeight = calculateChapterEditorContentHeight({
|
|
2374
|
+
measureHeight,
|
|
2375
|
+
paddingTop,
|
|
2376
|
+
paddingBottom,
|
|
2377
|
+
viewportHeight: scroller.clientHeight
|
|
2378
|
+
});
|
|
2379
|
+
const nextHeightPx = `${nextHeight}px`;
|
|
2380
|
+
if (input.style.height !== nextHeightPx) {
|
|
2381
|
+
const savedScrollTop = scroller.scrollTop;
|
|
2382
|
+
input.style.height = nextHeightPx;
|
|
2383
|
+
scroller.scrollTop = savedScrollTop;
|
|
2384
|
+
}
|
|
2385
|
+
const metrics = readChapterEditorScrollMetrics(scroller);
|
|
1925
2386
|
if (Number.isInteger(targetLineIndex)) {
|
|
1926
2387
|
const safeTarget = Math.max(0, Math.min(targetLineIndex, layout.lines.length - 1));
|
|
1927
|
-
|
|
2388
|
+
scroller.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - metrics.clientHeight / 3);
|
|
1928
2389
|
}
|
|
1929
|
-
const viewportTop = Math.max(0,
|
|
1930
|
-
const overscan = Math.max(
|
|
2390
|
+
const viewportTop = Math.max(0, scroller.scrollTop - paddingTop);
|
|
2391
|
+
const overscan = Math.max(metrics.clientHeight, lineHeight * 8);
|
|
1931
2392
|
const lineWindow = findChapterLineWindow(
|
|
1932
2393
|
layout.lines.length,
|
|
1933
2394
|
getLineBounds,
|
|
1934
2395
|
viewportTop - overscan,
|
|
1935
|
-
viewportTop +
|
|
2396
|
+
viewportTop + metrics.clientHeight + overscan
|
|
1936
2397
|
);
|
|
1937
2398
|
const numbers = document.createDocumentFragment();
|
|
1938
2399
|
for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
|
|
@@ -2018,26 +2479,25 @@ function scheduleChapterCaretScroll() {
|
|
|
2018
2479
|
chapterCaretScrollFrame = null;
|
|
2019
2480
|
const input = $("#chapter-content");
|
|
2020
2481
|
const measure = $("#chapter-line-measure");
|
|
2021
|
-
|
|
2482
|
+
const scroller = getChapterEditorScroller();
|
|
2483
|
+
if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || !scroller || input.readOnly || input.clientWidth === 0 || scroller.clientHeight === 0) return;
|
|
2022
2484
|
const style = getComputedStyle(input);
|
|
2023
2485
|
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
2024
|
-
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
2025
2486
|
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
2026
2487
|
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
2027
2488
|
const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
|
|
2028
|
-
const
|
|
2029
|
-
const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
|
|
2030
|
-
const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
|
|
2489
|
+
const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight);
|
|
2031
2490
|
const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
|
|
2032
2491
|
const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
|
|
2492
|
+
const metrics = readChapterEditorScrollMetrics(scroller);
|
|
2033
2493
|
const nextScrollTop = calculateChapterCaretScroll({
|
|
2034
2494
|
caretBottom,
|
|
2035
|
-
scrollTop:
|
|
2036
|
-
clientHeight:
|
|
2037
|
-
scrollHeight:
|
|
2495
|
+
scrollTop: metrics.scrollTop,
|
|
2496
|
+
clientHeight: metrics.clientHeight,
|
|
2497
|
+
scrollHeight: metrics.scrollHeight
|
|
2038
2498
|
});
|
|
2039
|
-
if (nextScrollTop ===
|
|
2040
|
-
|
|
2499
|
+
if (nextScrollTop === metrics.scrollTop) return;
|
|
2500
|
+
scroller.scrollTop = nextScrollTop;
|
|
2041
2501
|
syncChapterLineNumberScroll();
|
|
2042
2502
|
scheduleChapterLineNumbers();
|
|
2043
2503
|
});
|
|
@@ -2769,6 +3229,12 @@ function applyAiChatTabLimit(value) {
|
|
|
2769
3229
|
renderAiChatTabs();
|
|
2770
3230
|
}
|
|
2771
3231
|
|
|
3232
|
+
function applyChapterAnnotationNoteMaxLength(value) {
|
|
3233
|
+
const parsed = Number(value);
|
|
3234
|
+
if (!Number.isSafeInteger(parsed)) return;
|
|
3235
|
+
chapterAnnotationNoteMaxLength = Math.min(20000, Math.max(2000, parsed));
|
|
3236
|
+
}
|
|
3237
|
+
|
|
2772
3238
|
function renderAiChatTabs() {
|
|
2773
3239
|
const host = $("#ai-chat-tabs");
|
|
2774
3240
|
if (!host) return;
|
|
@@ -5624,7 +6090,7 @@ async function createSelectedLineAnnotation(kind) {
|
|
|
5624
6090
|
title: kind === "todo" ? "添加正文待办" : "添加正文评论",
|
|
5625
6091
|
inputLabel: kind === "todo" ? "待办内容" : "评论内容",
|
|
5626
6092
|
confirmLabel: "添加",
|
|
5627
|
-
maxLength:
|
|
6093
|
+
maxLength: chapterAnnotationNoteMaxLength
|
|
5628
6094
|
});
|
|
5629
6095
|
if (!note) return;
|
|
5630
6096
|
try {
|
|
@@ -5693,7 +6159,7 @@ function bindChapterAnnotationCards(host, annotations, { refresh, locate, overla
|
|
|
5693
6159
|
inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
|
|
5694
6160
|
value: annotation.note,
|
|
5695
6161
|
confirmLabel: "保存",
|
|
5696
|
-
maxLength:
|
|
6162
|
+
maxLength: chapterAnnotationNoteMaxLength
|
|
5697
6163
|
});
|
|
5698
6164
|
if (!note) return resumeOverlay();
|
|
5699
6165
|
try {
|
|
@@ -6061,6 +6527,7 @@ async function api(path, options = {}) {
|
|
|
6061
6527
|
if (path === "/api/health") {
|
|
6062
6528
|
applyImageUploadLimits(payload.data?.uploadLimits);
|
|
6063
6529
|
applyAiChatTabLimit(payload.data?.aiChatTabLimit);
|
|
6530
|
+
applyChapterAnnotationNoteMaxLength(payload.data?.chapterAnnotationNoteMaxLength);
|
|
6064
6531
|
updateSystemHealth({
|
|
6065
6532
|
status: payload.data?.status === "ok" ? "ready" : "degraded",
|
|
6066
6533
|
version: payload.data?.version
|
|
@@ -6524,7 +6991,7 @@ function invalidateAuthentication() {
|
|
|
6524
6991
|
showAuth(false);
|
|
6525
6992
|
}
|
|
6526
6993
|
|
|
6527
|
-
function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
|
|
6994
|
+
function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false, registrationMode = "disabled") {
|
|
6528
6995
|
if (state.user) return;
|
|
6529
6996
|
document.documentElement.classList.remove("dev-auth-bypass");
|
|
6530
6997
|
document.documentElement.classList.add("login-route");
|
|
@@ -6533,14 +7000,17 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
|
|
|
6533
7000
|
document.body.classList.add("auth-pending");
|
|
6534
7001
|
$("#auth-view").classList.remove("hidden");
|
|
6535
7002
|
const canRegister = registrationOpen === true;
|
|
7003
|
+
const inviteRequired = registrationMode === "invite" && setupRequired !== true;
|
|
6536
7004
|
$("#auth-title").textContent = setupRequired
|
|
6537
7005
|
? canRegister ? "创建首个管理员账户" : "注册已禁用"
|
|
6538
7006
|
: "登录后继续创作";
|
|
6539
7007
|
$("#auth-description").textContent = setupRequired
|
|
6540
7008
|
? canRegister
|
|
6541
7009
|
? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
|
|
6542
|
-
: "请将 APP_ALLOW_REGISTRATION 设置为 true 后创建首个管理员账户。"
|
|
6543
|
-
:
|
|
7010
|
+
: "请将 APP_ALLOW_REGISTRATION 设置为 true 或 invite 后创建首个管理员账户。"
|
|
7011
|
+
: inviteRequired
|
|
7012
|
+
? "当前部署需要邀请码才能注册。请向系统管理员获取一次性邀请码。"
|
|
7013
|
+
: "你的作品、协作权限和每一次修改都会绑定到账户。";
|
|
6544
7014
|
const registerTab = $("#auth-register-tab");
|
|
6545
7015
|
registerTab.disabled = !canRegister;
|
|
6546
7016
|
registerTab.setAttribute("aria-disabled", String(!canRegister));
|
|
@@ -6549,12 +7019,19 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
|
|
|
6549
7019
|
const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
|
|
6550
7020
|
setupTokenField.classList.toggle("hidden", !setupTokenRequired);
|
|
6551
7021
|
setupTokenInput.required = setupTokenRequired;
|
|
7022
|
+
const inviteField = $("#register-invite-code-field");
|
|
7023
|
+
const inviteInput = inviteField.querySelector('input[name="inviteCode"]');
|
|
7024
|
+
inviteField.classList.toggle("hidden", !inviteRequired);
|
|
7025
|
+
inviteInput.required = inviteRequired;
|
|
6552
7026
|
selectAuthMode(setupRequired && canRegister ? "register" : "login");
|
|
6553
7027
|
}
|
|
6554
7028
|
|
|
6555
7029
|
function applyAuthenticatedUser(session) {
|
|
6556
7030
|
state.user = session.user;
|
|
6557
7031
|
state.csrfToken = session.csrfToken;
|
|
7032
|
+
state.registrationMode = session.registrationMode === "invite" || session.registrationMode === "open"
|
|
7033
|
+
? session.registrationMode
|
|
7034
|
+
: "disabled";
|
|
6558
7035
|
const isSystemAdmin = session.user.isSystemAdmin === true;
|
|
6559
7036
|
const accountButton = $("#account-button");
|
|
6560
7037
|
$("#account-name").textContent = session.user.displayName;
|
|
@@ -6637,7 +7114,7 @@ async function initializeAuthentication() {
|
|
|
6637
7114
|
if (!session.authenticated) {
|
|
6638
7115
|
// 未登录时一律转到登录页路由;登录页本身则保持原样
|
|
6639
7116
|
if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
|
|
6640
|
-
showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
|
|
7117
|
+
showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true, session.registrationMode);
|
|
6641
7118
|
return false;
|
|
6642
7119
|
}
|
|
6643
7120
|
// 已登录却停在登录页路由时,回到书架首页
|
|
@@ -7736,9 +8213,61 @@ async function openUsersDialog() {
|
|
|
7736
8213
|
return;
|
|
7737
8214
|
}
|
|
7738
8215
|
$("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
|
|
8216
|
+
$("#invite-codes-list").innerHTML = '<p class="empty-state">正在读取邀请码……</p>';
|
|
8217
|
+
$("#invite-code-latest").classList.add("hidden");
|
|
7739
8218
|
$("#users-dialog").showModal();
|
|
7740
|
-
try {
|
|
7741
|
-
|
|
8219
|
+
try {
|
|
8220
|
+
const [usersPage] = await Promise.all([apiPage("/api/users"), loadRegistrationInvites()]);
|
|
8221
|
+
renderUsers(usersPage.items);
|
|
8222
|
+
} catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8225
|
+
function registrationModeLabel(mode) {
|
|
8226
|
+
if (mode === "invite") return "需要邀请码才能注册";
|
|
8227
|
+
if (mode === "open") return "完全开放注册";
|
|
8228
|
+
return "彻底禁止注册";
|
|
8229
|
+
}
|
|
8230
|
+
|
|
8231
|
+
function renderRegistrationInvites(payload) {
|
|
8232
|
+
const mode = payload?.registrationMode === "invite" || payload?.registrationMode === "open"
|
|
8233
|
+
? payload.registrationMode
|
|
8234
|
+
: "disabled";
|
|
8235
|
+
state.registrationMode = mode;
|
|
8236
|
+
$("#invite-codes-mode").textContent = `当前注册策略:${registrationModeLabel(mode)}。邀请码由系统管理员生成,每个只能使用一次。`;
|
|
8237
|
+
const invites = Array.isArray(payload?.items) ? payload.items : [];
|
|
8238
|
+
$("#invite-codes-list").innerHTML = invites.length
|
|
8239
|
+
? invites.map((invite) => {
|
|
8240
|
+
const used = Boolean(invite.usedAt);
|
|
8241
|
+
const usedBy = invite.usedBy ? `${invite.usedBy.displayName} · @${invite.usedBy.username}` : "已失效";
|
|
8242
|
+
return `<article class="access-row invite-code-row">
|
|
8243
|
+
<div class="access-person-copy"><strong>${used ? "已使用" : "未使用"}</strong><small>${esc(formatDateTime(invite.createdAt))} 由 ${esc(invite.createdBy.displayName)} 生成${used ? ` · ${esc(usedBy)} 于 ${esc(formatDateTime(invite.usedAt))}` : ""}</small></div>
|
|
8244
|
+
<span>${used ? "已核销" : "待使用"}</span>
|
|
8245
|
+
</article>`;
|
|
8246
|
+
}).join("")
|
|
8247
|
+
: '<p class="empty-state">还没有邀请码。需要邀请码注册时,请先生成。</p>';
|
|
8248
|
+
}
|
|
8249
|
+
|
|
8250
|
+
async function loadRegistrationInvites() {
|
|
8251
|
+
const payload = await api("/api/registration-invites");
|
|
8252
|
+
renderRegistrationInvites(payload);
|
|
8253
|
+
return payload;
|
|
8254
|
+
}
|
|
8255
|
+
|
|
8256
|
+
async function generateRegistrationInvite() {
|
|
8257
|
+
const button = $("#invite-code-generate");
|
|
8258
|
+
button.disabled = true;
|
|
8259
|
+
try {
|
|
8260
|
+
const created = await api("/api/registration-invites", { method: "POST", body: {} });
|
|
8261
|
+
const latest = $("#invite-code-latest");
|
|
8262
|
+
latest.innerHTML = `新邀请码(请立即复制,页面不会再次完整显示):<strong>${esc(created.code)}</strong>`;
|
|
8263
|
+
latest.classList.remove("hidden");
|
|
8264
|
+
await loadRegistrationInvites();
|
|
8265
|
+
toast("邀请码已生成");
|
|
8266
|
+
} catch (error) {
|
|
8267
|
+
toast(error.message, "error");
|
|
8268
|
+
} finally {
|
|
8269
|
+
button.disabled = false;
|
|
8270
|
+
}
|
|
7742
8271
|
}
|
|
7743
8272
|
|
|
7744
8273
|
function renderAdminAiConversationFilterOptions(works, users) {
|
|
@@ -11267,11 +11796,12 @@ async function renderCharacters(page = characterListPage) {
|
|
|
11267
11796
|
? `<span class="character-lock-badge" aria-label="${item.lockedFields.length} 个锁定字段" title="锁定字段:${esc(item.lockedFields.join("、"))}"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg><span>${item.lockedFields.length}</span></span>`
|
|
11268
11797
|
: "";
|
|
11269
11798
|
const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
|
|
11270
|
-
const
|
|
11799
|
+
const attributes = normalizeCharacterAttributes(item.attributes);
|
|
11800
|
+
const details = attributes.details ?? [];
|
|
11271
11801
|
return `
|
|
11272
11802
|
<article class="record-card character-card preview-record-card has-card-edit has-pin-control" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${characterPinButton(item)}${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
|
|
11273
11803
|
<div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
11274
|
-
${
|
|
11804
|
+
${attributes.identity ? `<p class="character-identity">${esc(attributes.identity)}</p>` : ""}
|
|
11275
11805
|
<div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
|
|
11276
11806
|
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
11277
11807
|
${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
|
|
@@ -11283,7 +11813,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
11283
11813
|
</article>`;
|
|
11284
11814
|
}).join("")}</div>`;
|
|
11285
11815
|
const characterRows = () => `<div class="module-row-list">${pageCharacters.map((item) => {
|
|
11286
|
-
const preview = moduleRowPreview(item.profile?.summary || item.attributes
|
|
11816
|
+
const preview = moduleRowPreview(item.profile?.summary || normalizeCharacterAttributes(item.attributes).identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
|
|
11287
11817
|
const meta = [
|
|
11288
11818
|
item.code ? `编号 ${item.code}` : "",
|
|
11289
11819
|
`性别 ${characterGenderLabel(item.gender)}`,
|
|
@@ -13142,7 +13672,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
|
|
|
13142
13672
|
const character = await api(`/api/characters/${encodeURIComponent(details.characterId)}`);
|
|
13143
13673
|
const anchors = [
|
|
13144
13674
|
character.code,
|
|
13145
|
-
character.attributes
|
|
13675
|
+
normalizeCharacterAttributes(character.attributes).identity,
|
|
13146
13676
|
character.race?.name || character.species,
|
|
13147
13677
|
...(Array.isArray(character.organizations) ? character.organizations.map((organization) => organization.name) : [])
|
|
13148
13678
|
].map((value) => String(value ?? "").trim()).filter(Boolean);
|
|
@@ -13161,7 +13691,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
|
|
|
13161
13691
|
<label>角色标准名<input type="text" value="${esc(character.name)}" readonly></label>
|
|
13162
13692
|
${field("aliases", "确认别名", "item-list", character.aliases ?? [])}
|
|
13163
13693
|
${field("code", "人物编号或代号", "text", character.code)}
|
|
13164
|
-
${field("identity", "身份与定位", "text", character.attributes
|
|
13694
|
+
${field("identity", "身份与定位", "text", normalizeCharacterAttributes(character.attributes).identity)}
|
|
13165
13695
|
<button class="ghost-button relationship-identity-full-profile" type="button" data-open-identity-character-profile>打开完整人物档案</button>`,
|
|
13166
13696
|
async (form) => {
|
|
13167
13697
|
const aliases = form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean);
|
|
@@ -13172,7 +13702,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
|
|
|
13172
13702
|
body: {
|
|
13173
13703
|
aliases,
|
|
13174
13704
|
code,
|
|
13175
|
-
attributes: { ...(character.attributes ?? {}), identity },
|
|
13705
|
+
attributes: normalizeCharacterAttributes({ ...(character.attributes ?? {}), identity }),
|
|
13176
13706
|
expectedVersionNo: character.versionNo,
|
|
13177
13707
|
changeNote: `修复人物关系来源匹配:${failure.message}`.slice(0, 500)
|
|
13178
13708
|
}
|
|
@@ -16840,6 +17370,7 @@ function renderCharacterEditorFields(item) {
|
|
|
16840
17370
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
16841
17371
|
const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
|
|
16842
17372
|
const stateEntries = characterStateEntries(item?.currentState ?? {});
|
|
17373
|
+
const attributes = normalizeCharacterAttributes(item?.attributes);
|
|
16843
17374
|
const raceField = !canReadModule("races")
|
|
16844
17375
|
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
16845
17376
|
: state.races.length
|
|
@@ -16861,12 +17392,12 @@ function renderCharacterEditorFields(item) {
|
|
|
16861
17392
|
: '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
|
|
16862
17393
|
characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
|
|
16863
17394
|
field("code", "编号", "text", item?.code) +
|
|
16864
|
-
field("identity", "身份与定位", "text",
|
|
17395
|
+
field("identity", "身份与定位", "text", attributes.identity) +
|
|
16865
17396
|
field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
|
|
16866
17397
|
field("summary", "人物简介", "textarea", item?.profile?.summary) +
|
|
16867
17398
|
'<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
|
|
16868
17399
|
characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
|
|
16869
|
-
field("details", "扩展属性", "key-value-list",
|
|
17400
|
+
field("details", "扩展属性", "key-value-list", attributes.details, { multilineValue: true }) +
|
|
16870
17401
|
'<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
|
|
16871
17402
|
characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
|
|
16872
17403
|
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
@@ -16909,11 +17440,11 @@ function collectCharacterBody(form) {
|
|
|
16909
17440
|
isDead: form.has("isDead"),
|
|
16910
17441
|
code: String(form.get("code") ?? "").trim(),
|
|
16911
17442
|
aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
|
|
16912
|
-
attributes: {
|
|
16913
|
-
|
|
17443
|
+
attributes: normalizeCharacterAttributes({
|
|
17444
|
+
species: typeof item?.attributes?.species === "string" ? item.attributes.species : "",
|
|
16914
17445
|
identity: String(form.get("identity") ?? "").trim(),
|
|
16915
17446
|
details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
|
|
16916
|
-
},
|
|
17447
|
+
}),
|
|
16917
17448
|
profile: {
|
|
16918
17449
|
...profile,
|
|
16919
17450
|
motivation: String(form.get("motivation") ?? "").trim(),
|
|
@@ -18420,6 +18951,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18420
18951
|
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
18421
18952
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
18422
18953
|
if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
|
|
18954
|
+
const queuedComposer = pendingQueuedComposer;
|
|
18955
|
+
pendingQueuedComposer = null;
|
|
18423
18956
|
const composerSnapshot = captureAiPromptComposer();
|
|
18424
18957
|
const requestComposerSnapshot = retry
|
|
18425
18958
|
? {
|
|
@@ -18431,7 +18964,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18431
18964
|
sceneDirection: retry.sceneDirection ?? "",
|
|
18432
18965
|
scenePin: captureAiScenePin()
|
|
18433
18966
|
}
|
|
18434
|
-
: composerSnapshot;
|
|
18967
|
+
: queuedComposer ?? composerSnapshot;
|
|
18435
18968
|
const instruction = String(requestComposerSnapshot.markup ?? requestComposerSnapshot.text).trim();
|
|
18436
18969
|
const instructionText = String(requestComposerSnapshot.text ?? "").trim();
|
|
18437
18970
|
const sceneDirection = $("#ai-task").value === "roleplay"
|
|
@@ -18446,6 +18979,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18446
18979
|
if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
|
|
18447
18980
|
const requestScope = currentAiRequestScope();
|
|
18448
18981
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
18982
|
+
if (queuedComposer) {
|
|
18983
|
+
aiPromptQueue.remove(tab.id, queuedComposer.id);
|
|
18984
|
+
renderAiPromptQueue();
|
|
18985
|
+
}
|
|
18449
18986
|
const { scope } = requestScope;
|
|
18450
18987
|
const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
18451
18988
|
const selectedTaskType = $("#ai-task").value;
|
|
@@ -18456,7 +18993,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18456
18993
|
tabId: tab.id,
|
|
18457
18994
|
workId: state.work.id,
|
|
18458
18995
|
conversationId: state.aiConversationId
|
|
18459
|
-
})
|
|
18996
|
+
}),
|
|
18997
|
+
preserveComposer: Boolean(queuedComposer)
|
|
18460
18998
|
};
|
|
18461
18999
|
if (retry?.userMessageId) {
|
|
18462
19000
|
requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
|
|
@@ -18464,6 +19002,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18464
19002
|
setAiChatTabStatus(tab, "streaming");
|
|
18465
19003
|
syncAiRequestControls();
|
|
18466
19004
|
scrollAiFeedToBottom(tab.feed, { force: true });
|
|
19005
|
+
let shouldFlushQueuedPrompt = false;
|
|
18467
19006
|
try {
|
|
18468
19007
|
try {
|
|
18469
19008
|
await ensureAiModelsLoaded();
|
|
@@ -18508,6 +19047,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18508
19047
|
const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
|
|
18509
19048
|
if (streamed.action === "warn") return;
|
|
18510
19049
|
assistantContent = streamed.content;
|
|
19050
|
+
shouldFlushQueuedPrompt = true;
|
|
18511
19051
|
assistantMessage = streamed.message;
|
|
18512
19052
|
assistantMetadata = streamed.metadata;
|
|
18513
19053
|
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
@@ -18638,6 +19178,18 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18638
19178
|
if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
|
|
18639
19179
|
else renderAiChatTabs();
|
|
18640
19180
|
if (isActiveAiChatTab(tab)) syncAiRequestControls();
|
|
19181
|
+
if (queuedComposer && !shouldFlushQueuedPrompt) {
|
|
19182
|
+
const stillQueued = aiPromptQueue.list(tab.id).some((item) => item.id === queuedComposer.id);
|
|
19183
|
+
if (!stillQueued) {
|
|
19184
|
+
try {
|
|
19185
|
+
aiPromptQueue.restore(tab.id, queuedComposer, 0);
|
|
19186
|
+
} catch {
|
|
19187
|
+
/* 失败时尽量把未发出的排队 Prompt 放回队首,超限则保留当前队列。 */
|
|
19188
|
+
}
|
|
19189
|
+
renderAiPromptQueue();
|
|
19190
|
+
}
|
|
19191
|
+
}
|
|
19192
|
+
if (shouldFlushQueuedPrompt) flushNextQueuedPrompt(tab);
|
|
18641
19193
|
}
|
|
18642
19194
|
}
|
|
18643
19195
|
|
|
@@ -18657,13 +19209,13 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18657
19209
|
const tab = aiChatTabForRequest(requestHolder.snapshot);
|
|
18658
19210
|
if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
|
|
18659
19211
|
const feed = tab.feed;
|
|
18660
|
-
|
|
19212
|
+
let message = document.createElement("div");
|
|
18661
19213
|
message.className = "assistant-message is-streaming";
|
|
18662
19214
|
message.dataset.testid = "ai-stream-message";
|
|
18663
19215
|
const streamConnectionStartedAt = Date.now();
|
|
18664
19216
|
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流…… <span class="ai-stream-connection-seconds" data-testid="ai-stream-connection-seconds"></span> 秒</div>';
|
|
18665
|
-
|
|
18666
|
-
|
|
19217
|
+
let content = message.querySelector(".message-body");
|
|
19218
|
+
let meta = message.querySelector(".message-meta");
|
|
18667
19219
|
const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
|
|
18668
19220
|
const renderStreamConnectionElapsed = () => {
|
|
18669
19221
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
|
|
@@ -18688,7 +19240,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18688
19240
|
scrollAiFeedToBottom(feed);
|
|
18689
19241
|
return true;
|
|
18690
19242
|
};
|
|
18691
|
-
const
|
|
19243
|
+
const createAssistantTypewriter = () => createStreamTypewriter({
|
|
18692
19244
|
speedController: streamSpeedController,
|
|
18693
19245
|
shouldAnimate: () => aiStreamTargetVisible(feed),
|
|
18694
19246
|
onRender: (text, progress) => {
|
|
@@ -18701,6 +19253,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18701
19253
|
});
|
|
18702
19254
|
}
|
|
18703
19255
|
});
|
|
19256
|
+
let typewriter = createAssistantTypewriter();
|
|
18704
19257
|
let streamedText = "";
|
|
18705
19258
|
let streamedPendingText = "";
|
|
18706
19259
|
let generatedMetadata = {};
|
|
@@ -18721,8 +19274,9 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18721
19274
|
const processStepVisibleContents = new Map();
|
|
18722
19275
|
const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
|
|
18723
19276
|
if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
|
|
18724
|
-
|
|
18725
|
-
|
|
19277
|
+
const target = message;
|
|
19278
|
+
aiStreamRenders.enqueue(target, () => {
|
|
19279
|
+
renderAiProcessSteps(target, processSteps, completed, durationMs, processStepVisibleContents);
|
|
18726
19280
|
scrollAiFeedToBottom(feed);
|
|
18727
19281
|
});
|
|
18728
19282
|
};
|
|
@@ -18747,6 +19301,45 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18747
19301
|
const revealProcessStepTypewriters = () => {
|
|
18748
19302
|
for (const typewriter of processStepTypewriters.values()) typewriter.reveal();
|
|
18749
19303
|
};
|
|
19304
|
+
const beginSteeredAssistantSegment = async (steerPayload) => {
|
|
19305
|
+
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
19306
|
+
if (messageMounted) {
|
|
19307
|
+
message.classList.remove("is-streaming");
|
|
19308
|
+
content.setAttribute("aria-busy", "false");
|
|
19309
|
+
const headingRole = message.querySelector(".message-heading > span");
|
|
19310
|
+
if (headingRole) headingRole.textContent = aiAssistantLabel("", tab.roleplayCharacter);
|
|
19311
|
+
renderStreamingProcessSteps(true);
|
|
19312
|
+
if (streamedText) attachAssistantCopyAction(message, streamedText);
|
|
19313
|
+
}
|
|
19314
|
+
const steerMessage = steerPayload?.message;
|
|
19315
|
+
if (steerMessage?.id) {
|
|
19316
|
+
updateAiConversationSummaryFromMessage(steerMessage);
|
|
19317
|
+
const existingSteerMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
|
|
19318
|
+
.find((candidate) => candidate.dataset.messageId === String(steerMessage.id));
|
|
19319
|
+
if (!existingSteerMessage) {
|
|
19320
|
+
appendMessage("user", steerMessage.content, steerMessage.citations, steerMessage.createdAt, {
|
|
19321
|
+
...steerMessage.metadata,
|
|
19322
|
+
kind: "steer"
|
|
19323
|
+
}, steerMessage.id, { tab });
|
|
19324
|
+
}
|
|
19325
|
+
}
|
|
19326
|
+
streamedText = "";
|
|
19327
|
+
streamedPendingText = "";
|
|
19328
|
+
toolCalls = [];
|
|
19329
|
+
processSteps = [];
|
|
19330
|
+
processStepTypewriters.clear();
|
|
19331
|
+
processStepVisibleContents.clear();
|
|
19332
|
+
message = document.createElement("div");
|
|
19333
|
+
message.className = "assistant-message is-streaming";
|
|
19334
|
+
message.dataset.testid = "ai-stream-message";
|
|
19335
|
+
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">已收到执行流引导,正在继续生成</div>';
|
|
19336
|
+
content = message.querySelector(".message-body");
|
|
19337
|
+
meta = message.querySelector(".message-meta");
|
|
19338
|
+
messageMounted = false;
|
|
19339
|
+
typewriter = createAssistantTypewriter();
|
|
19340
|
+
mountAssistantMessage();
|
|
19341
|
+
scrollAiFeedToBottom(feed);
|
|
19342
|
+
};
|
|
18750
19343
|
try {
|
|
18751
19344
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
18752
19345
|
const response = await fetch(endpoint ?? `/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
|
|
@@ -18763,7 +19356,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18763
19356
|
let streamError = null;
|
|
18764
19357
|
const consume = async (eventName, payload) => {
|
|
18765
19358
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
18766
|
-
if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
|
|
19359
|
+
if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") stopStreamConnectionTimer();
|
|
18767
19360
|
if (eventName === "continuation") {
|
|
18768
19361
|
if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
|
|
18769
19362
|
toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
|
|
@@ -18813,7 +19406,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18813
19406
|
tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
|
|
18814
19407
|
tab.selectedModelId = lockedModelId;
|
|
18815
19408
|
tab.promptSent = true;
|
|
18816
|
-
clearAiChatTabComposer(tab);
|
|
19409
|
+
if (!requestHolder.preserveComposer) clearAiChatTabComposer(tab);
|
|
18817
19410
|
const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
|
|
18818
19411
|
.find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
|
|
18819
19412
|
if (!existingUserMessage) {
|
|
@@ -18826,10 +19419,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
18826
19419
|
syncAiTaskOptions();
|
|
18827
19420
|
renderAiRoleplayCharacterSelect();
|
|
18828
19421
|
renderAiQuickActions();
|
|
18829
|
-
|
|
19422
|
+
if (!requestHolder.preserveComposer) {
|
|
19423
|
+
clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
|
|
19424
|
+
}
|
|
18830
19425
|
}
|
|
18831
19426
|
mountAssistantMessage();
|
|
18832
19427
|
}
|
|
19428
|
+
} else if (eventName === "steer") {
|
|
19429
|
+
await beginSteeredAssistantSegment(payload);
|
|
18833
19430
|
} else if (eventName === "delta") {
|
|
18834
19431
|
mountAssistantMessage();
|
|
18835
19432
|
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
|
@@ -19041,6 +19638,14 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
19041
19638
|
failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
|
|
19042
19639
|
heading.firstElementChild?.append(failureBadge);
|
|
19043
19640
|
}
|
|
19641
|
+
if (role === "user" && metadata?.kind === "steer") {
|
|
19642
|
+
message.classList.add("is-steer");
|
|
19643
|
+
const steerBadge = document.createElement("strong");
|
|
19644
|
+
steerBadge.className = "ai-message-status is-steer";
|
|
19645
|
+
steerBadge.textContent = "引导";
|
|
19646
|
+
steerBadge.setAttribute("aria-label", "消息类型:执行流引导");
|
|
19647
|
+
heading.firstElementChild?.append(steerBadge);
|
|
19648
|
+
}
|
|
19044
19649
|
if (parsedUserTurn?.hasMarkup && parsedUserTurn.sceneDirection) {
|
|
19045
19650
|
const scene = document.createElement("div");
|
|
19046
19651
|
scene.className = "user-message-scene";
|
|
@@ -20333,6 +20938,7 @@ $("#register-form").addEventListener("submit", async (event) => {
|
|
|
20333
20938
|
password: form.get("password"),
|
|
20334
20939
|
passwordConfirmation: form.get("passwordConfirmation"),
|
|
20335
20940
|
setupToken: form.get("setupToken") || undefined,
|
|
20941
|
+
inviteCode: form.get("inviteCode") || undefined,
|
|
20336
20942
|
captchaId: form.get("captchaId"),
|
|
20337
20943
|
captchaAnswer: form.get("captchaAnswer")
|
|
20338
20944
|
}
|
|
@@ -20375,6 +20981,7 @@ $("#platform-usage-pricing-refresh").addEventListener("click", async () => {
|
|
|
20375
20981
|
}
|
|
20376
20982
|
});
|
|
20377
20983
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
20984
|
+
$("#invite-code-generate").addEventListener("click", () => generateRegistrationInvite().catch((error) => toast(error.message, "error")));
|
|
20378
20985
|
$("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
|
|
20379
20986
|
$("#writing-progress-button").addEventListener("click", () => {
|
|
20380
20987
|
void openWritingProgressDialog().catch((error) => toast(error.message, "error"));
|
|
@@ -20765,6 +21372,7 @@ $("#chapter-content").addEventListener("input", (event) => {
|
|
|
20765
21372
|
});
|
|
20766
21373
|
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
20767
21374
|
$("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
|
|
21375
|
+
$("#chapter-editor-scroll").addEventListener("scroll", syncChapterLineNumberScroll, { passive: true });
|
|
20768
21376
|
$("#chapter-content").addEventListener("contextmenu", (event) => {
|
|
20769
21377
|
if (!state.chapter) return;
|
|
20770
21378
|
showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
|
|
@@ -20856,7 +21464,7 @@ $("#ai-semantic-query").addEventListener("keydown", (event) => {
|
|
|
20856
21464
|
$("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
|
|
20857
21465
|
setupPanelResize($("#left-panel-resize"), "left");
|
|
20858
21466
|
setupPanelResize($("#ai-panel-resize"), "ai");
|
|
20859
|
-
if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-
|
|
21467
|
+
if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-editor-scroll"));
|
|
20860
21468
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(syncMobileAiPanelSafeTop).observe($("#chapter-foreshadow-reminder"));
|
|
20861
21469
|
window.addEventListener("resize", () => {
|
|
20862
21470
|
if (isMobileViewport() && $("#onboarding-dialog").open) completeOnboarding();
|
|
@@ -20921,6 +21529,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
|
|
|
20921
21529
|
$(field)?.addEventListener("input", () => {
|
|
20922
21530
|
syncAiSceneComposer();
|
|
20923
21531
|
persistActiveAiChatTab();
|
|
21532
|
+
syncAiRequestControls();
|
|
20924
21533
|
});
|
|
20925
21534
|
}
|
|
20926
21535
|
$("#ai-attachment-input").addEventListener("change", (event) => {
|
|
@@ -20930,6 +21539,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
|
|
|
20930
21539
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
20931
21540
|
updateAiMentionMenu();
|
|
20932
21541
|
setAiContextMeter(null);
|
|
21542
|
+
syncAiRequestControls();
|
|
20933
21543
|
const textBeforeCursor = aiPromptTextBeforeCursor();
|
|
20934
21544
|
if ($("#ai-task").value !== "roleplay" && findAiSkillCommand(textBeforeCursor)) return;
|
|
20935
21545
|
if (!findAiMention(textBeforeCursor)) return;
|
|
@@ -21338,6 +21948,11 @@ $("#ai-context-meter").addEventListener("click", () => {
|
|
|
21338
21948
|
$("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
|
|
21339
21949
|
$("#ai-citation-popover-close").addEventListener("click", () => closeAiCitationPopover({ restoreFocus: true }));
|
|
21340
21950
|
$("#ai-send").addEventListener("click", activateAiSendControl);
|
|
21951
|
+
$("#ai-send-mode").addEventListener("change", (event) => {
|
|
21952
|
+
writeStoredAiSendMode(localStorage, event.currentTarget.value);
|
|
21953
|
+
syncAiRequestControls();
|
|
21954
|
+
});
|
|
21955
|
+
applyAiSendModeFromStorage();
|
|
21341
21956
|
$("#ai-conversation-switcher").addEventListener("click", () => {
|
|
21342
21957
|
setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
|
|
21343
21958
|
});
|
|
@@ -21681,9 +22296,9 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
|
|
|
21681
22296
|
return;
|
|
21682
22297
|
}
|
|
21683
22298
|
}
|
|
21684
|
-
if (
|
|
22299
|
+
if (shouldActivateAiSendControl(event)) {
|
|
21685
22300
|
event.preventDefault();
|
|
21686
|
-
if (!$("#ai-send").disabled
|
|
22301
|
+
if (!$("#ai-send").disabled) submitAiComposerPrompt();
|
|
21687
22302
|
}
|
|
21688
22303
|
});
|
|
21689
22304
|
$(".quick-actions").addEventListener("click", (event) => {
|