@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.
Files changed (43) hide show
  1. package/README.en.md +6 -3
  2. package/README.md +6 -3
  3. package/dist/ai-stream-steer.js +96 -0
  4. package/dist/ai-stream-steer.js.map +1 -0
  5. package/dist/ai-write-plans.js +44 -38
  6. package/dist/ai-write-plans.js.map +1 -1
  7. package/dist/ai.js +241 -128
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +134 -14
  10. package/dist/app.js.map +1 -1
  11. package/dist/attachment-storage.js +3 -0
  12. package/dist/attachment-storage.js.map +1 -1
  13. package/dist/chapter-annotation-note.js +14 -0
  14. package/dist/chapter-annotation-note.js.map +1 -0
  15. package/dist/character-structured-fields.js +7 -0
  16. package/dist/character-structured-fields.js.map +1 -0
  17. package/dist/cli-contract.js +1 -1
  18. package/dist/cli-contract.js.map +1 -1
  19. package/dist/database.js +29 -2
  20. package/dist/database.js.map +1 -1
  21. package/dist/im.js +2 -1
  22. package/dist/im.js.map +1 -1
  23. package/dist/public/ai-prompt-keyboard.js +5 -1
  24. package/dist/public/ai-prompt-queue.js +226 -0
  25. package/dist/public/ai-send-mode.js +31 -0
  26. package/dist/public/app.js +689 -74
  27. package/dist/public/chapter-editor-scroll.d.ts +37 -0
  28. package/dist/public/chapter-editor-scroll.js +52 -0
  29. package/dist/public/character-profile.d.ts +7 -0
  30. package/dist/public/character-profile.js +123 -5
  31. package/dist/public/index.html +31 -10
  32. package/dist/public/styles.css +75 -8
  33. package/dist/security.js +22 -3
  34. package/dist/security.js.map +1 -1
  35. package/dist/server-runtime.js +2 -0
  36. package/dist/server-runtime.js.map +1 -1
  37. package/dist/store.js +7 -6
  38. package/dist/store.js.map +1 -1
  39. package/dist/user-auth.js +125 -15
  40. package/dist/user-auth.js.map +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/version.js.map +1 -1
  43. package/package.json +1 -1
@@ -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=20260713-enter-to-send";
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=20260713-character-editor";
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 stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
680
- const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
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", sending);
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 = label;
686
- button.innerHTML = aiSendButtonIconMarkup(stateName);
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 activateAiSendControl() {
773
+ function submitAiComposerPrompt() {
699
774
  const tab = activeAiChatTab();
700
775
  if (aiRequestManager.hasActive(tab?.id)) {
701
- if (cancelActiveAiRequest("用户已终止当前回复")) {
702
- toast("已终止当前回复,可以重新发送");
703
- $("#ai-prompt").focus();
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 (!input || !inner) return;
1747
- inner.style.transform = `translateY(${-input.scrollTop}px)`;
1748
- inner.dataset.scrollTop = String(input.scrollTop);
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 = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
1751
- whitespace.dataset.scrollTop = String(input.scrollTop);
2205
+ whitespace.style.transform = layerTransform;
2206
+ whitespace.dataset.scrollTop = String(metrics.scrollTop);
1752
2207
  }
1753
- if (!chapterLineVirtualWindow) return;
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
- if (!input || !inner || !measure || input.clientWidth === 0) return;
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 scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
1923
- const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
1924
- const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
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
- input.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - input.clientHeight / 3);
2388
+ scroller.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - metrics.clientHeight / 3);
1928
2389
  }
1929
- const viewportTop = Math.max(0, input.scrollTop - paddingTop);
1930
- const overscan = Math.max(input.clientHeight, lineHeight * 8);
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 + input.clientHeight + overscan
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
- if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
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 scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
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: input.scrollTop,
2036
- clientHeight: input.clientHeight,
2037
- scrollHeight: input.scrollHeight
2495
+ scrollTop: metrics.scrollTop,
2496
+ clientHeight: metrics.clientHeight,
2497
+ scrollHeight: metrics.scrollHeight
2038
2498
  });
2039
- if (nextScrollTop === input.scrollTop) return;
2040
- input.scrollTop = nextScrollTop;
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: 2000
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: 2000
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 { renderUsers((await apiPage("/api/users")).items); }
7741
- catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
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 details = normalizeCharacterDetails(item.attributes?.details);
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
- ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
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?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
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?.identity,
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?.identity)}
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", item?.attributes?.identity) +
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", item?.attributes?.details, { multilineValue: true }) +
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
- ...(item?.attributes ?? {}),
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
- const message = document.createElement("div");
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
- const content = message.querySelector(".message-body");
18666
- const meta = message.querySelector(".message-meta");
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 typewriter = createStreamTypewriter({
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
- aiStreamRenders.enqueue(message, () => {
18725
- renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
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
- clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
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-content"));
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 (shouldSendAiPrompt(event)) {
22299
+ if (shouldActivateAiSendControl(event)) {
21685
22300
  event.preventDefault();
21686
- if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
22301
+ if (!$("#ai-send").disabled) submitAiComposerPrompt();
21687
22302
  }
21688
22303
  });
21689
22304
  $(".quick-actions").addEventListener("click", (event) => {