@musnows/scriverse 1.0.12 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { applyChapterDirectoryMove, chapterDirectoryEntry } from "/chapter-directory.js?v=20260921-directory-performance-v1";
1
2
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
3
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
4
  import { countProseWords } from "/text-count.js?v=20260906-chapter-word-count-consistency-v1";
@@ -6,8 +7,9 @@ import { createStreamingMarkdownRenderer } from "/stream-markdown.js?v=20260912-
6
7
  import { createAiRenderScheduler } from "/ai-render-scheduler.js?v=20260912-stream-render-v2";
7
8
  import { createImWorkspace } from "/im.js?v=20260904-im-judge-outcomes-v106";
8
9
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
9
- import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
10
+ import { applyAiSkillCommand, findAiSkillCommand, listAiSlashOptions } from "/ai-skill-menu.js?v=20260921-ai-compact-slash-command-v2";
10
11
  import {
12
+ composeRoleplayStoredUserContent,
11
13
  emptyRoleplayScenePin,
12
14
  normalizeRoleplayScenePin,
13
15
  parseRoleplayUserTurn,
@@ -39,7 +41,18 @@ import {
39
41
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
40
42
  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";
41
43
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
42
- import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
44
+ import { shouldActivateAiSendControl, shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260919-ai-send-mode-v2";
45
+ import { aiSendModeAction, normalizeAiSendMode, readStoredAiSendMode, writeStoredAiSendMode } from "/ai-send-mode.js?v=20260919-ai-send-mode-v2";
46
+ import {
47
+ aiPromptQueueDragPayload,
48
+ aiPromptQueuePreview,
49
+ canSendQueuedPromptAsSteer,
50
+ createAiPromptQueue,
51
+ parseAiPromptQueueDragPayload,
52
+ queuedPromptEditContent,
53
+ queuedPromptEditPatch,
54
+ queuedPromptSteerContent
55
+ } from "/ai-prompt-queue.js?v=20260919-ai-prompt-queue-v2";
43
56
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
44
57
  import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260912-stream-render-v2";
45
58
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
@@ -69,7 +82,7 @@ import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatI
69
82
  import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
70
83
  import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-v1";
71
84
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
72
- import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
85
+ import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterAttributes, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260919-character-attributes-prune-v1";
73
86
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
74
87
  import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
75
88
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
@@ -251,6 +264,11 @@ const state = {
251
264
  let platformAiProtocolOptions = [];
252
265
 
253
266
  const aiRequestManager = createAiRequestManager();
267
+ const aiPromptQueue = createAiPromptQueue();
268
+ let pendingQueuedComposer = null;
269
+ let aiPromptQueueDidDrag = false;
270
+ let aiPromptQueueEditorState = null;
271
+ let aiPromptQueueEditorBlurTimer = 0;
254
272
  const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
255
273
  const moduleRequestCache = createModuleRequestCache();
256
274
  const cachedWorkModules = new Set([
@@ -533,6 +551,7 @@ function applyWorkAccessMode() {
533
551
  state.dirty = false;
534
552
  }
535
553
  applyChapterEditorMode();
554
+ syncAiSendModeControl();
536
555
  }
537
556
 
538
557
  const $ = (selector) => document.querySelector(selector);
@@ -674,28 +693,77 @@ function aiInteractionBusy() {
674
693
  }
675
694
 
676
695
  function aiSendButtonIconMarkup(stateName) {
696
+ if (stateName === "queue") {
697
+ 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>';
698
+ }
677
699
  return stateName === "stop"
678
700
  ? '<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>'
679
701
  : '<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>';
680
702
  }
681
703
 
704
+ function applyAiSendModeFromStorage() {
705
+ const select = $("#ai-send-mode");
706
+ if (!select) return;
707
+ select.value = readStoredAiSendMode(localStorage);
708
+ syncAiSendModeControl();
709
+ }
710
+
711
+ function aiComposerSendMode() {
712
+ return normalizeAiSendMode($("#ai-send-mode")?.value);
713
+ }
714
+
715
+ function syncAiSendModeControl() {
716
+ const select = $("#ai-send-mode");
717
+ if (!select) return;
718
+ const mode = normalizeAiSendMode(select.value);
719
+ if (select.value !== mode) select.value = mode;
720
+ const aiReadOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
721
+ select.disabled = aiReadOnly;
722
+ select.title = mode === "steer"
723
+ ? "引导发送:生成中再发送会立即作用于当前执行流,不会打断本轮。空输入时发送键用于终止"
724
+ : "排队发送:生成中再发送会加入队列,当前回复结束后按顺序发送,不会打断正在执行的回复。空输入时发送键用于终止";
725
+ }
726
+
727
+ function aiComposerHasSendablePrompt() {
728
+ return Boolean(queuedPromptSteerContent(captureAiPromptComposer()));
729
+ }
730
+
682
731
  function syncAiRequestControls() {
683
732
  const activeTabId = activeAiChatTab()?.id;
684
733
  const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
685
734
  const sending = aiRequestManager.hasActive(activeTabId) && !continuingQuestion;
686
735
  const switching = aiConversationNavigationPending !== null;
687
736
  const button = $("#ai-send");
688
- const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
689
- const label = sending ? "终止当前回复" : continuingQuestion ? "AI 正在根据回答继续处理" : switching ? "正在切换对话" : "发送消息";
737
+ const sendAction = aiSendModeAction(aiComposerSendMode(), sending, aiComposerHasSendablePrompt());
738
+ const stateName = sendAction === "queue" || sendAction === "steer" || sendAction === "stop"
739
+ ? sendAction
740
+ : (switching || continuingQuestion) ? "switching" : "send";
741
+ const label = sendAction === "stop"
742
+ ? "终止当前回复"
743
+ : sendAction === "queue"
744
+ ? "排队发送"
745
+ : sendAction === "steer" ? "引导发送"
746
+ : continuingQuestion ? "AI 正在根据回答继续处理"
747
+ : switching ? "正在切换对话" : "发送消息";
690
748
  button.disabled = switching || continuingQuestion;
691
749
  button.dataset.state = stateName;
692
- button.classList.toggle("is-stop", sending);
750
+ button.classList.toggle("is-stop", sendAction === "stop");
751
+ button.classList.toggle("is-queue", sendAction === "queue");
693
752
  button.setAttribute("aria-label", label);
694
- button.title = label;
695
- button.innerHTML = aiSendButtonIconMarkup(stateName);
753
+ button.title = sendAction === "stop"
754
+ ? "终止当前回复,排队 Prompt 仍会保留"
755
+ : sendAction === "queue"
756
+ ? "排队发送,当前回复结束后按顺序发送,不会打断正在执行的回复"
757
+ : sendAction === "steer"
758
+ ? "引导发送,立即作用于当前执行流,不会打断本轮"
759
+ : label;
760
+ button.innerHTML = aiSendButtonIconMarkup(stateName === "steer" ? "send" : stateName);
761
+ $(".prompt-composer")?.classList.toggle("is-streaming", sending);
762
+ syncAiSendModeControl();
696
763
  syncAiTaskOptions();
697
764
  renderAiRoleplayCharacterSelect();
698
765
  syncAiImageAttachmentControl();
766
+ renderAiPromptQueue();
699
767
  }
700
768
 
701
769
  function cancelActiveAiRequest(reason) {
@@ -704,18 +772,394 @@ function cancelActiveAiRequest(reason) {
704
772
  return cancelled;
705
773
  }
706
774
 
707
- function activateAiSendControl() {
775
+ function submitAiComposerPrompt() {
708
776
  const tab = activeAiChatTab();
777
+ if ($("#ai-task").value === "chat" && aiPromptText().trim() === "/compact") {
778
+ if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
779
+ hideAiMentionMenu();
780
+ void compactAiConversation({ clearComposer: true });
781
+ return;
782
+ }
709
783
  if (aiRequestManager.hasActive(tab?.id)) {
710
- if (cancelActiveAiRequest("用户已终止当前回复")) {
711
- toast("已终止当前回复,可以重新发送");
712
- $("#ai-prompt").focus();
784
+ if (aiComposerSendMode() === "steer") {
785
+ void sendActiveComposerAsSteer();
786
+ return;
713
787
  }
788
+ queueActiveComposerPrompt();
714
789
  return;
715
790
  }
716
791
  void sendAi();
717
792
  }
718
793
 
794
+ function activateAiSendControl() {
795
+ const tab = activeAiChatTab();
796
+ if (aiRequestManager.hasActive(tab?.id) && !aiComposerHasSendablePrompt()) {
797
+ activateAiStopControl();
798
+ return;
799
+ }
800
+ submitAiComposerPrompt();
801
+ }
802
+
803
+ function activateAiStopControl() {
804
+ if (cancelActiveAiRequest("用户已终止当前回复")) {
805
+ toast("已终止当前回复,排队 Prompt 仍会保留");
806
+ $("#ai-prompt").focus();
807
+ }
808
+ }
809
+
810
+ function activeAiConversationId(tab = activeAiChatTab()) {
811
+ return String(tab?.conversationId ?? state.aiConversationId ?? "").trim();
812
+ }
813
+
814
+ function queueActiveComposerPrompt() {
815
+ const tab = activeAiChatTab();
816
+ if (!tab) return toast("Agent 对话页签尚未就绪", "error");
817
+ const snapshot = captureAiPromptComposer();
818
+ try {
819
+ aiPromptQueue.enqueue(tab.id, snapshot);
820
+ } catch (error) {
821
+ return toast(error.message, "error");
822
+ }
823
+ clearAiPromptComposer({ collapseScenePanel: Boolean(snapshot.sceneDirection) });
824
+ persistActiveAiChatTab();
825
+ syncAiRequestControls();
826
+ toast("已加入排队,当前回复结束后发送");
827
+ $("#ai-prompt").focus();
828
+ }
829
+
830
+ async function postAiConversationSteer(conversationId, content) {
831
+ const id = String(conversationId ?? "").trim();
832
+ if (!id) throw new Error("当前对话尚未开始,无法发送引导");
833
+ return api(`/api/ai-conversations/${encodeURIComponent(id)}/steer`, {
834
+ method: "POST",
835
+ body: { content }
836
+ });
837
+ }
838
+
839
+ async function sendActiveComposerAsSteer() {
840
+ const tab = activeAiChatTab();
841
+ if (!tab) return toast("Agent 对话页签尚未就绪", "error");
842
+ if (!aiRequestManager.hasActive(tab.id)) return toast("当前没有正在执行的回复,无法发送引导", "error");
843
+ const snapshot = captureAiPromptComposer();
844
+ const content = queuedPromptSteerContent(snapshot);
845
+ if (!content) {
846
+ return toast($("#ai-task").value === "roleplay" ? "请输入台词或场景旁白" : "请输入引导内容", "error");
847
+ }
848
+ try {
849
+ await postAiConversationSteer(activeAiConversationId(tab), content);
850
+ } catch (error) {
851
+ return toast(error.message, "error");
852
+ }
853
+ clearAiPromptComposer({ collapseScenePanel: Boolean(snapshot.sceneDirection) });
854
+ syncAiRequestControls();
855
+ toast("已发送执行流引导");
856
+ $("#ai-prompt").focus();
857
+ }
858
+
859
+ async function sendQueuedPromptAsSteer(itemId) {
860
+ const tab = activeAiChatTab();
861
+ if (!tab) return;
862
+ const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
863
+ if (!canSendQueuedPromptAsSteer(item, aiRequestManager.hasActive(tab.id))) {
864
+ return toast("当前没有正在执行的回复,无法发送引导", "error");
865
+ }
866
+ try {
867
+ await postAiConversationSteer(activeAiConversationId(tab), queuedPromptSteerContent(item));
868
+ } catch (error) {
869
+ return toast(error.message, "error");
870
+ }
871
+ aiPromptQueue.remove(tab.id, itemId);
872
+ renderAiPromptQueue();
873
+ toast("已将排队 Prompt 立即引导");
874
+ }
875
+
876
+ function sendQueuedPromptNow(itemId) {
877
+ const tab = activeAiChatTab();
878
+ if (!tab || aiRequestManager.hasActive(tab.id)) return;
879
+ const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
880
+ if (!item) return;
881
+ pendingQueuedComposer = item;
882
+ void sendAiWithOptions();
883
+ }
884
+
885
+ function flushNextQueuedPrompt(tab) {
886
+ if (!tab || !isActiveAiChatTab(tab) || aiRequestManager.hasActive(tab.id) || aiQuestionContinuationTabIds.has(tab.id)) {
887
+ renderAiPromptQueue();
888
+ return;
889
+ }
890
+ const next = aiPromptQueue.list(tab.id)[0];
891
+ renderAiPromptQueue();
892
+ if (!next) return;
893
+ pendingQueuedComposer = next;
894
+ void sendAiWithOptions();
895
+ }
896
+
897
+ function clearAiPromptQueueEditorState(itemId = null) {
898
+ if (!itemId || aiPromptQueueEditorState?.itemId === itemId) aiPromptQueueEditorState = null;
899
+ if (aiPromptQueueEditorBlurTimer) {
900
+ window.clearTimeout(aiPromptQueueEditorBlurTimer);
901
+ aiPromptQueueEditorBlurTimer = 0;
902
+ }
903
+ }
904
+
905
+ function captureAiPromptQueueEditorState() {
906
+ if (!aiPromptQueueEditorState) return null;
907
+ const editor = document.querySelector("#ai-prompt-queue-list .ai-prompt-queue-editor");
908
+ if (!(editor instanceof HTMLTextAreaElement)) return aiPromptQueueEditorState;
909
+ const itemId = editor.closest("[data-queue-id]")?.dataset.queueId;
910
+ if (!itemId || itemId !== aiPromptQueueEditorState.itemId) return aiPromptQueueEditorState;
911
+ return {
912
+ itemId,
913
+ value: editor.value,
914
+ selectionStart: editor.selectionStart,
915
+ selectionEnd: editor.selectionEnd
916
+ };
917
+ }
918
+
919
+ function commitAiPromptQueueEdit(itemId, nextValue = null, { render = true } = {}) {
920
+ const tab = activeAiChatTab();
921
+ if (!tab || !itemId) return false;
922
+ const item = aiPromptQueue.list(tab.id).find((entry) => entry.id === itemId);
923
+ if (!item) {
924
+ clearAiPromptQueueEditorState(itemId);
925
+ return false;
926
+ }
927
+ const value = nextValue == null ? queuedPromptEditContent(item) : nextValue;
928
+ if (value === queuedPromptEditContent(item)) {
929
+ clearAiPromptQueueEditorState(itemId);
930
+ if (render) renderAiPromptQueue();
931
+ return true;
932
+ }
933
+ try {
934
+ aiPromptQueue.update(tab.id, itemId, queuedPromptEditPatch(item, value));
935
+ } catch (error) {
936
+ toast(error.message, "error");
937
+ return false;
938
+ }
939
+ clearAiPromptQueueEditorState(itemId);
940
+ if (render) renderAiPromptQueue();
941
+ return true;
942
+ }
943
+
944
+ function beginAiPromptQueueEdit(item) {
945
+ const tab = activeAiChatTab();
946
+ if (!tab || !item || (state.work && !canWritePermissionModule(state.work, "ai-chat"))) return;
947
+ if (aiPromptQueueEditorState && aiPromptQueueEditorState.itemId !== item.id) {
948
+ const previous = document.querySelector("#ai-prompt-queue-list .ai-prompt-queue-editor");
949
+ const previousValue = previous instanceof HTMLTextAreaElement ? previous.value : aiPromptQueueEditorState.value;
950
+ if (!commitAiPromptQueueEdit(aiPromptQueueEditorState.itemId, previousValue)) return;
951
+ }
952
+ aiPromptQueueEditorState = {
953
+ itemId: item.id,
954
+ value: queuedPromptEditContent(item),
955
+ selectionStart: 0,
956
+ selectionEnd: queuedPromptEditContent(item).length
957
+ };
958
+ renderAiPromptQueue();
959
+ const editor = document.querySelector(`#ai-prompt-queue-list [data-queue-id="${CSS.escape(item.id)}"] .ai-prompt-queue-editor`);
960
+ if (editor instanceof HTMLTextAreaElement) {
961
+ editor.focus();
962
+ editor.setSelectionRange(0, editor.value.length);
963
+ }
964
+ }
965
+
966
+ function moveAiPromptQueueItem(sourceId, targetId, placeAfter) {
967
+ const tab = activeAiChatTab();
968
+ if (!tab || !sourceId || !targetId) return;
969
+ aiPromptQueue.move(tab.id, sourceId, targetId, placeAfter);
970
+ renderAiPromptQueue();
971
+ }
972
+
973
+ function moveAiPromptQueueItemByOffset(itemId, offset) {
974
+ const tab = activeAiChatTab();
975
+ if (!tab || !itemId) return;
976
+ aiPromptQueue.moveByOffset(tab.id, itemId, offset);
977
+ renderAiPromptQueue();
978
+ }
979
+
980
+ function bindAiPromptQueueItemDrag(row, item, aiReadOnly, editing) {
981
+ if (aiReadOnly || editing) {
982
+ row.draggable = false;
983
+ row.removeAttribute("aria-grabbed");
984
+ return;
985
+ }
986
+ row.draggable = true;
987
+ row.setAttribute("aria-grabbed", "false");
988
+ row.addEventListener("dragstart", (event) => {
989
+ if (event.target.closest("button, textarea, .ai-prompt-queue-editor")) {
990
+ event.preventDefault();
991
+ return;
992
+ }
993
+ aiPromptQueueDidDrag = true;
994
+ event.dataTransfer?.setData("text/plain", aiPromptQueueDragPayload(item.id));
995
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
996
+ row.classList.add("is-dragging");
997
+ row.setAttribute("aria-grabbed", "true");
998
+ });
999
+ row.addEventListener("dragend", () => {
1000
+ row.classList.remove("is-dragging");
1001
+ row.setAttribute("aria-grabbed", "false");
1002
+ listAiPromptQueueDropTargets().forEach((node) => node.classList.remove("is-drag-over", "drop-after"));
1003
+ window.setTimeout(() => { aiPromptQueueDidDrag = false; }, 0);
1004
+ });
1005
+ row.addEventListener("dragover", (event) => {
1006
+ if (!event.dataTransfer?.types.includes("text/plain")) return;
1007
+ event.preventDefault();
1008
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
1009
+ const after = event.clientY >= row.getBoundingClientRect().top + row.offsetHeight / 2;
1010
+ listAiPromptQueueDropTargets().forEach((node) => {
1011
+ if (node !== row) node.classList.remove("is-drag-over", "drop-after");
1012
+ });
1013
+ row.classList.toggle("drop-after", after);
1014
+ row.classList.add("is-drag-over");
1015
+ });
1016
+ row.addEventListener("dragleave", (event) => {
1017
+ if (row.contains(event.relatedTarget)) return;
1018
+ row.classList.remove("is-drag-over", "drop-after");
1019
+ });
1020
+ row.addEventListener("drop", (event) => {
1021
+ event.preventDefault();
1022
+ event.stopPropagation();
1023
+ const sourceId = parseAiPromptQueueDragPayload(event.dataTransfer?.getData("text/plain"));
1024
+ const after = row.classList.contains("drop-after");
1025
+ row.classList.remove("is-drag-over", "drop-after");
1026
+ if (!sourceId || sourceId === item.id) return;
1027
+ moveAiPromptQueueItem(sourceId, item.id, after);
1028
+ });
1029
+ }
1030
+
1031
+ function listAiPromptQueueDropTargets() {
1032
+ return [...document.querySelectorAll("#ai-prompt-queue-list .ai-prompt-queue-item")];
1033
+ }
1034
+
1035
+ function renderAiPromptQueue() {
1036
+ const tab = activeAiChatTab();
1037
+ const panel = $("#ai-prompt-queue");
1038
+ const list = $("#ai-prompt-queue-list");
1039
+ if (!panel || !list) return;
1040
+ const items = tab ? aiPromptQueue.list(tab.id) : [];
1041
+ const streaming = Boolean(tab && aiRequestManager.hasActive(tab.id));
1042
+ const aiReadOnly = Boolean(state.work) && !canWritePermissionModule(state.work, "ai-chat");
1043
+ const editorState = captureAiPromptQueueEditorState();
1044
+ if (editorState && !items.some((item) => item.id === editorState.itemId)) clearAiPromptQueueEditorState(editorState.itemId);
1045
+ const activeEditor = items.some((item) => item.id === (editorState?.itemId ?? aiPromptQueueEditorState?.itemId))
1046
+ ? editorState ?? aiPromptQueueEditorState
1047
+ : null;
1048
+ if (activeEditor) aiPromptQueueEditorState = activeEditor;
1049
+ else if (!items.length) clearAiPromptQueueEditorState();
1050
+ panel.classList.toggle("hidden", items.length === 0);
1051
+ list.replaceChildren(...items.map((item) => {
1052
+ const editing = Boolean(!aiReadOnly && activeEditor?.itemId === item.id);
1053
+ const row = document.createElement("li");
1054
+ row.className = `ai-prompt-queue-item${editing ? " is-editing" : ""}`;
1055
+ row.dataset.queueId = item.id;
1056
+ row.title = aiReadOnly ? "" : "拖拽调整顺序;点击文字可编辑";
1057
+ const previewText = aiPromptQueuePreview(queuedPromptSteerContent(item) || item.text);
1058
+ const fullText = queuedPromptSteerContent(item) || item.text;
1059
+ let preview;
1060
+ if (editing) {
1061
+ preview = document.createElement("textarea");
1062
+ preview.className = "ai-prompt-queue-editor";
1063
+ preview.value = activeEditor.value ?? queuedPromptEditContent(item);
1064
+ preview.rows = Math.min(6, Math.max(1, preview.value.split("\n").length));
1065
+ preview.setAttribute("aria-label", `编辑排队 Prompt:${previewText}`);
1066
+ preview.addEventListener("click", (event) => event.stopPropagation());
1067
+ preview.addEventListener("pointerdown", (event) => event.stopPropagation());
1068
+ preview.addEventListener("keydown", (event) => {
1069
+ event.stopPropagation();
1070
+ if (event.key === "Escape") {
1071
+ event.preventDefault();
1072
+ clearAiPromptQueueEditorState(item.id);
1073
+ renderAiPromptQueue();
1074
+ return;
1075
+ }
1076
+ if (shouldSendAiPrompt(event)) {
1077
+ event.preventDefault();
1078
+ commitAiPromptQueueEdit(item.id, preview.value);
1079
+ }
1080
+ });
1081
+ preview.addEventListener("blur", () => {
1082
+ aiPromptQueueEditorBlurTimer = window.setTimeout(() => {
1083
+ aiPromptQueueEditorBlurTimer = 0;
1084
+ if (aiPromptQueueEditorState?.itemId !== item.id) return;
1085
+ if (document.activeElement?.classList.contains("ai-prompt-queue-editor")) return;
1086
+ commitAiPromptQueueEdit(item.id, preview.value);
1087
+ }, 0);
1088
+ });
1089
+ } else {
1090
+ preview = document.createElement("p");
1091
+ preview.className = "ai-prompt-queue-preview";
1092
+ preview.textContent = previewText;
1093
+ preview.title = aiReadOnly ? fullText : `${fullText}\n点击编辑`;
1094
+ preview.tabIndex = aiReadOnly ? -1 : 0;
1095
+ preview.setAttribute("role", "button");
1096
+ preview.setAttribute("aria-label", `编辑排队 Prompt:${previewText}`);
1097
+ const startEdit = (event) => {
1098
+ if (aiReadOnly || aiPromptQueueDidDrag) return;
1099
+ event.preventDefault();
1100
+ beginAiPromptQueueEdit(item);
1101
+ };
1102
+ preview.addEventListener("click", startEdit);
1103
+ preview.addEventListener("keydown", (event) => {
1104
+ if (event.altKey && ["ArrowUp", "ArrowDown"].includes(event.key)) {
1105
+ event.preventDefault();
1106
+ moveAiPromptQueueItemByOffset(item.id, event.key === "ArrowDown" ? 1 : -1);
1107
+ return;
1108
+ }
1109
+ if (event.key === "Enter" || event.key === " ") {
1110
+ startEdit(event);
1111
+ }
1112
+ });
1113
+ }
1114
+ const actions = document.createElement("div");
1115
+ actions.className = "ai-prompt-queue-actions";
1116
+ const primary = document.createElement("button");
1117
+ primary.type = "button";
1118
+ const flushEditorBeforeAction = () => {
1119
+ if (!(preview instanceof HTMLTextAreaElement)) return;
1120
+ commitAiPromptQueueEdit(item.id, preview.value, { render: false });
1121
+ };
1122
+ if (streaming) {
1123
+ primary.className = "ai-prompt-queue-steer";
1124
+ primary.textContent = "立即引导";
1125
+ primary.disabled = aiReadOnly || !canSendQueuedPromptAsSteer(item, true);
1126
+ primary.setAttribute("aria-label", `立即引导排队 Prompt:${previewText}`);
1127
+ primary.addEventListener("pointerdown", flushEditorBeforeAction);
1128
+ primary.addEventListener("click", () => { void sendQueuedPromptAsSteer(item.id); });
1129
+ } else {
1130
+ primary.className = "ai-prompt-queue-send";
1131
+ primary.textContent = "现在发送";
1132
+ primary.disabled = aiReadOnly;
1133
+ primary.setAttribute("aria-label", `现在发送排队 Prompt:${previewText}`);
1134
+ primary.addEventListener("pointerdown", flushEditorBeforeAction);
1135
+ primary.addEventListener("click", () => sendQueuedPromptNow(item.id));
1136
+ }
1137
+ const remove = document.createElement("button");
1138
+ remove.type = "button";
1139
+ remove.className = "ai-prompt-queue-remove";
1140
+ remove.setAttribute("aria-label", `移除排队 Prompt:${previewText}`);
1141
+ remove.textContent = "×";
1142
+ remove.addEventListener("click", () => {
1143
+ aiPromptQueue.remove(tab.id, item.id);
1144
+ clearAiPromptQueueEditorState(item.id);
1145
+ renderAiPromptQueue();
1146
+ });
1147
+ actions.append(primary, remove);
1148
+ row.append(preview, actions);
1149
+ bindAiPromptQueueItemDrag(row, item, aiReadOnly, editing);
1150
+ return row;
1151
+ }));
1152
+ if (activeEditor) {
1153
+ const editor = list.querySelector(`[data-queue-id="${CSS.escape(activeEditor.itemId)}"] .ai-prompt-queue-editor`);
1154
+ if (editor instanceof HTMLTextAreaElement) {
1155
+ const start = Number.isInteger(activeEditor.selectionStart) ? activeEditor.selectionStart : editor.value.length;
1156
+ const end = Number.isInteger(activeEditor.selectionEnd) ? activeEditor.selectionEnd : editor.value.length;
1157
+ editor.focus();
1158
+ editor.setSelectionRange(start, end);
1159
+ }
1160
+ }
1161
+ }
1162
+
719
1163
  function beginAiConversationNavigation(reason, action = "切换会话") {
720
1164
  aiConversationNavigationGeneration += 1;
721
1165
  aiConversationNavigationPending = aiConversationNavigationGeneration;
@@ -836,6 +1280,7 @@ let chapterSelectionRequestGeneration = 0;
836
1280
  let aiConversationNavigationGeneration = 0;
837
1281
  let aiConversationNavigationPending = null;
838
1282
  let aiChatTabLimit = 5;
1283
+ let chapterAnnotationNoteMaxLength = 6000;
839
1284
  let aiConversationWorkspaceOpen = false;
840
1285
  const loadedVolumeChapterIds = new Set();
841
1286
  const volumeChapterLoadingIds = new Set();
@@ -2792,6 +3237,12 @@ function applyAiChatTabLimit(value) {
2792
3237
  renderAiChatTabs();
2793
3238
  }
2794
3239
 
3240
+ function applyChapterAnnotationNoteMaxLength(value) {
3241
+ const parsed = Number(value);
3242
+ if (!Number.isSafeInteger(parsed)) return;
3243
+ chapterAnnotationNoteMaxLength = Math.min(20000, Math.max(2000, parsed));
3244
+ }
3245
+
2795
3246
  function renderAiChatTabs() {
2796
3247
  const host = $("#ai-chat-tabs");
2797
3248
  if (!host) return;
@@ -5513,13 +5964,13 @@ function updateAiMentionMenu() {
5513
5964
  aiMentionRange = selection.getRangeAt(0).cloneRange();
5514
5965
  const menu = $("#ai-mention-menu");
5515
5966
  if (skillMatch) {
5516
- const options = listAiSkillOptions(skillMatch.query);
5967
+ const options = listAiSlashOptions(skillMatch.query);
5517
5968
  aiMentionActiveIndex = -1;
5518
5969
  prompt.removeAttribute("aria-activedescendant");
5519
- menu.setAttribute("aria-label", "选择写作 Skill");
5970
+ menu.setAttribute("aria-label", "选择 Chat Slash 操作");
5520
5971
  menu.innerHTML = options.length
5521
- ? options.map((item, index) => `<button id="ai-skill-option-${index}" class="ai-mention-option ai-skill-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-skill-name="${esc(item.name)}"><small>Skill</small><span><strong>/${esc(item.name)}</strong><em>${esc(item.label)} · ${esc(item.description)}</em></span></button>`).join("")
5522
- : '<p class="ai-mention-empty">没有匹配的写作 Skill</p>';
5972
+ ? options.map((item, index) => `<button id="ai-skill-option-${index}" class="ai-mention-option ai-skill-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-command-name="${esc(item.name)}"${item.kind === "skill" ? ` data-ai-skill-name="${esc(item.name)}"` : ""}><small>${item.kind === "skill" ? "Skill" : "压缩"}</small><span><strong>/${esc(item.name)}</strong><em>${esc(item.label)} · ${esc(item.description)}</em></span></button>`).join("")
5973
+ : '<p class="ai-mention-empty">没有匹配的 Chat Slash 操作</p>';
5523
5974
  menu.classList.remove("hidden");
5524
5975
  prompt.setAttribute("aria-expanded", "true");
5525
5976
  return;
@@ -5597,6 +6048,15 @@ function selectAiSkill(button) {
5597
6048
  hideAiMentionMenu();
5598
6049
  }
5599
6050
 
6051
+ function selectAiSlashCommand(button) {
6052
+ if (button.dataset.aiCommandName === "compact") {
6053
+ hideAiMentionMenu();
6054
+ void compactAiConversation({ clearComposer: true });
6055
+ return;
6056
+ }
6057
+ selectAiSkill(button);
6058
+ }
6059
+
5600
6060
  function addSelectedLinesAsCitation() {
5601
6061
  if (!state.chapter || !chapterLineSelection) return;
5602
6062
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
@@ -5647,7 +6107,7 @@ async function createSelectedLineAnnotation(kind) {
5647
6107
  title: kind === "todo" ? "添加正文待办" : "添加正文评论",
5648
6108
  inputLabel: kind === "todo" ? "待办内容" : "评论内容",
5649
6109
  confirmLabel: "添加",
5650
- maxLength: 2000
6110
+ maxLength: chapterAnnotationNoteMaxLength
5651
6111
  });
5652
6112
  if (!note) return;
5653
6113
  try {
@@ -5716,7 +6176,7 @@ function bindChapterAnnotationCards(host, annotations, { refresh, locate, overla
5716
6176
  inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
5717
6177
  value: annotation.note,
5718
6178
  confirmLabel: "保存",
5719
- maxLength: 2000
6179
+ maxLength: chapterAnnotationNoteMaxLength
5720
6180
  });
5721
6181
  if (!note) return resumeOverlay();
5722
6182
  try {
@@ -6084,6 +6544,7 @@ async function api(path, options = {}) {
6084
6544
  if (path === "/api/health") {
6085
6545
  applyImageUploadLimits(payload.data?.uploadLimits);
6086
6546
  applyAiChatTabLimit(payload.data?.aiChatTabLimit);
6547
+ applyChapterAnnotationNoteMaxLength(payload.data?.chapterAnnotationNoteMaxLength);
6087
6548
  updateSystemHealth({
6088
6549
  status: payload.data?.status === "ok" ? "ready" : "degraded",
6089
6550
  version: payload.data?.version
@@ -9002,7 +9463,7 @@ async function loadVolumeChapters(volumeId) {
9002
9463
  const workId = state.work.id;
9003
9464
  const generation = workScopedUiGeneration;
9004
9465
  volumeChapterLoadingIds.add(volumeId);
9005
- renderTree();
9466
+ renderTree([volumeId]);
9006
9467
  const request = (async () => {
9007
9468
  try {
9008
9469
  const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
@@ -9012,17 +9473,15 @@ async function loadVolumeChapters(volumeId) {
9012
9473
  volume.chapters = chapters;
9013
9474
  volume.chapterCount = chapters.length;
9014
9475
  loadedVolumeChapterIds.add(volumeId);
9015
- renderTree();
9016
9476
  } catch (error) {
9017
9477
  if (state.work?.id === workId && generation === workScopedUiGeneration) {
9018
9478
  toast(`加载分卷章节失败:${error.message}`, "error");
9019
- renderTree();
9020
9479
  }
9021
9480
  } finally {
9022
9481
  if (volumeChapterRequests.get(volumeId) === request) {
9023
9482
  volumeChapterLoadingIds.delete(volumeId);
9024
9483
  volumeChapterRequests.delete(volumeId);
9025
- if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
9484
+ if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree([volumeId]);
9026
9485
  }
9027
9486
  }
9028
9487
  })();
@@ -9046,20 +9505,7 @@ function mergeChapterDirectoryEntry(chapter) {
9046
9505
  if (!state.work || !chapter?.volumeId) return;
9047
9506
  const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
9048
9507
  if (!volume) return;
9049
- const directoryEntry = {
9050
- id: chapter.id,
9051
- workId: chapter.workId,
9052
- volumeId: chapter.volumeId,
9053
- title: chapter.title,
9054
- chapterType: chapter.chapterType,
9055
- sortOrder: chapter.sortOrder,
9056
- wordCount: chapter.wordCount,
9057
- versionNo: chapter.versionNo,
9058
- analysisStatus: chapter.analysisStatus,
9059
- excludedFromAnalysis: chapter.excludedFromAnalysis,
9060
- createdAt: chapter.createdAt,
9061
- updatedAt: chapter.updatedAt
9062
- };
9508
+ const directoryEntry = chapterDirectoryEntry(chapter);
9063
9509
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
9064
9510
  const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
9065
9511
  if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
@@ -9069,14 +9515,61 @@ function mergeChapterDirectoryEntry(chapter) {
9069
9515
  volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
9070
9516
  }
9071
9517
 
9072
- function renderTree() {
9518
+ function syncChapterTreeSelection() {
9519
+ const tree = $("#novel-tree");
9520
+ tree.querySelectorAll(".chapter-node.active").forEach((node) => node.classList.remove("active"));
9521
+ const chapter = state.chapter;
9522
+ if (!chapter) return;
9523
+ let button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
9524
+ if (!button || button.closest(".volume-node")?.classList.contains("is-collapsed")) {
9525
+ renderTree([chapter.volumeId]);
9526
+ button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
9527
+ }
9528
+ if (!button) return;
9529
+ button.classList.add("active");
9530
+ button.firstElementChild.textContent = chapter.title;
9531
+ button.querySelector("small").textContent = Number(chapter.wordCount ?? 0).toLocaleString("zh-CN");
9532
+ const badge = button.querySelector(".chapter-type-badge")?.textContent ?? "";
9533
+ const chapterType = chapter.chapterType && chapter.chapterType !== "正文" ? chapter.chapterType : "";
9534
+ if (badge !== chapterType) {
9535
+ button.querySelector(".chapter-node-meta").innerHTML = `${chapterType ? `<em class="chapter-type-badge">${esc(chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small>`;
9536
+ }
9537
+ }
9538
+
9539
+ function syncMovedChapterTree(chapterId, volumeIds) {
9540
+ const tree = $("#novel-tree");
9541
+ const location = findChapterLocation(chapterId);
9542
+ const button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`);
9543
+ const target = location && tree.querySelector(`[data-volume-id="${CSS.escape(location.volume.id)}"] .volume-chapters`);
9544
+ if (!button || !target) return renderTree(volumeIds);
9545
+ if (state.collapsedVolumeIds.has(location.volume.id)) button.remove();
9546
+ else {
9547
+ target.querySelector(".entity-history-empty")?.remove();
9548
+ const nextChapter = location.volume.chapters[location.chapterIndex + 1];
9549
+ const nextButton = nextChapter ? target.querySelector(`[data-chapter-id="${CSS.escape(nextChapter.id)}"]`) : null;
9550
+ target.insertBefore(button, nextButton);
9551
+ }
9552
+ for (const volumeId of volumeIds) {
9553
+ const volume = state.work.volumes.find((item) => item.id === volumeId);
9554
+ const node = tree.querySelector(`[data-volume-id="${CSS.escape(volumeId)}"]`);
9555
+ if (!volume || !node) continue;
9556
+ node.querySelector(".volume-chapter-count-number").textContent = String(volume.chapterCount);
9557
+ if (!volume.chapters.length && !state.collapsedVolumeIds.has(volumeId)) {
9558
+ node.querySelector(".volume-chapters").innerHTML = '<p class="entity-history-empty">本卷还没有章节。</p>';
9559
+ }
9560
+ }
9561
+ }
9562
+
9563
+ function renderTree(volumeIds = null) {
9073
9564
  if (!state.work) return;
9074
9565
  const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
9075
9566
  const proseEditable = canEditProse();
9076
9567
  $("#chapter-count").querySelector(".chapter-count-number").textContent = String(count);
9077
9568
  $("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
9078
9569
  $("#novel-tree").classList.remove("empty-copy");
9079
- $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
9570
+ const tree = $("#novel-tree");
9571
+ const volumes = volumeIds ? state.work.volumes.filter((volume) => volumeIds.includes(volume.id)) : state.work.volumes;
9572
+ const markup = volumes.map((volume) => {
9080
9573
  const collapsed = state.collapsedVolumeIds.has(volume.id);
9081
9574
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
9082
9575
  const chapterContent = collapsed
@@ -9087,8 +9580,9 @@ function renderTree() {
9087
9580
  ? '<p class="entity-history-empty">展开后加载章节。</p>'
9088
9581
  : chapters.length
9089
9582
  ? chapters.map((chapter) => `
9090
- <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
9583
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="false" title="点击打开章节${proseEditable ? ";拖动左侧手柄排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
9091
9584
  <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
9585
+ ${proseEditable ? '<span class="chapter-drag-handle" draggable="true" aria-hidden="true" title="拖动排序"><svg viewBox="0 0 12 18" focusable="false"><path d="M4 4h.01M8 4h.01M4 9h.01M8 9h.01M4 14h.01M8 14h.01"></path></svg></span>' : ""}
9092
9586
  </button>`).join("")
9093
9587
  : '<p class="entity-history-empty">本卷还没有章节。</p>';
9094
9588
  return `
@@ -9102,15 +9596,28 @@ function renderTree() {
9102
9596
  ${chapterContent}
9103
9597
  </div>
9104
9598
  </div>`;
9105
- }).join("");
9106
- $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
9599
+ });
9600
+ const roots = [];
9601
+ if (volumeIds) {
9602
+ volumes.forEach((volume, index) => {
9603
+ const node = tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`);
9604
+ if (!node) return;
9605
+ node.outerHTML = markup[index];
9606
+ roots.push(tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`));
9607
+ });
9608
+ } else {
9609
+ tree.innerHTML = markup.join("");
9610
+ roots.push(tree);
9611
+ }
9612
+ const renderedNodes = (selector) => roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
9613
+ renderedNodes("[data-volume-toggle]").forEach((button) => {
9107
9614
  button.addEventListener("click", () => {
9108
9615
  const volumeId = button.dataset.volumeToggle;
9109
9616
  if (state.collapsedVolumeIds.has(volumeId)) {
9110
9617
  state.collapsedVolumeIds.delete(volumeId);
9111
9618
  void loadVolumeChapters(volumeId);
9112
9619
  } else state.collapsedVolumeIds.add(volumeId);
9113
- renderTree();
9620
+ renderTree([volumeId]);
9114
9621
  });
9115
9622
  button.addEventListener("contextmenu", (event) => {
9116
9623
  if (!canEditProse()) return;
@@ -9138,18 +9645,19 @@ function renderTree() {
9138
9645
  });
9139
9646
  }
9140
9647
  });
9141
- $("#novel-tree").querySelectorAll("[data-volume-detail]").forEach((button) => {
9648
+ renderedNodes("[data-volume-detail]").forEach((button) => {
9142
9649
  button.addEventListener("click", () => {
9143
9650
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeDetail));
9144
9651
  });
9145
9652
  });
9146
- $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
9653
+ renderedNodes("[data-new-chapter-volume]").forEach((button) => {
9147
9654
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
9148
9655
  });
9149
- $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
9150
- button.addEventListener("click", async () => {
9656
+ renderedNodes("[data-chapter-id]").forEach((button) => {
9657
+ button.addEventListener("click", async (event) => {
9658
+ if (event.target.closest(".chapter-drag-handle")) return;
9151
9659
  const chapterId = button.dataset.chapterId;
9152
- await selectChapter(chapterId);
9660
+ if (!(await selectChapter(chapterId))) return;
9153
9661
  $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
9154
9662
  if (isMobileViewport()) {
9155
9663
  panelLayout.leftCollapsed = true;
@@ -9170,6 +9678,10 @@ function renderTree() {
9170
9678
  });
9171
9679
  if (proseEditable) {
9172
9680
  button.addEventListener("dragstart", (event) => {
9681
+ if (!canEditProse() || !event.target.closest(".chapter-drag-handle")) {
9682
+ event.preventDefault();
9683
+ return;
9684
+ }
9173
9685
  event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
9174
9686
  if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
9175
9687
  button.classList.add("is-dragging");
@@ -9192,7 +9704,7 @@ function renderTree() {
9192
9704
  const target = findChapterLocation(button.dataset.chapterId);
9193
9705
  const after = button.classList.contains("drop-after");
9194
9706
  button.classList.remove("is-drag-over", "drop-after");
9195
- if (!chapterId || !target) return;
9707
+ if (!chapterId || !target || chapterId === button.dataset.chapterId) return;
9196
9708
  const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
9197
9709
  const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
9198
9710
  await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
@@ -9345,22 +9857,36 @@ function findChapterLocation(chapterId) {
9345
9857
 
9346
9858
  async function moveChapterInTree(chapterId, volumeId, sortOrder) {
9347
9859
  const location = findChapterLocation(chapterId);
9348
- if (!location || chapterMovePending) return;
9860
+ if (!location || chapterMovePending || !canEditProse()) return;
9349
9861
  const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
9350
9862
  if (samePosition) return;
9863
+ const workId = state.work.id;
9864
+ const generation = workScopedUiGeneration;
9865
+ const volumeIds = [...new Set([location.volume.id, volumeId])];
9351
9866
  chapterMovePending = true;
9352
9867
  try {
9868
+ await Promise.all(volumeIds.map((id) => loadVolumeChapters(id)));
9869
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
9870
+ if (volumeIds.some((id) => !loadedVolumeChapterIds.has(id))) return;
9353
9871
  const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
9354
9872
  method: "POST",
9355
9873
  body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
9356
9874
  });
9357
- state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
9358
- if (state.chapter?.id === chapterId) state.chapter = { ...state.chapter, ...moved };
9359
- renderTree();
9360
- $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
9875
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
9876
+ const affectedVolumeIds = applyChapterDirectoryMove(state.work, moved);
9877
+ const selectedEntry = state.chapter && findChapterLocation(state.chapter.id)?.chapter;
9878
+ if (selectedEntry) {
9879
+ state.chapter = { ...state.chapter, ...selectedEntry };
9880
+ updateChapterPath();
9881
+ updateChapterStats();
9882
+ }
9883
+ syncMovedChapterTree(chapterId, affectedVolumeIds);
9884
+ const focusTarget = $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)
9885
+ ?? $("#novel-tree").querySelector(`[data-volume-toggle="${CSS.escape(volumeId)}"]`);
9886
+ focusTarget?.focus();
9361
9887
  toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
9362
9888
  } catch (error) {
9363
- toast(error.message, "error");
9889
+ if (state.work?.id === workId && generation === workScopedUiGeneration) toast(error.message, "error");
9364
9890
  } finally {
9365
9891
  chapterMovePending = false;
9366
9892
  }
@@ -9372,7 +9898,7 @@ async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
9372
9898
  if (crossVolume) {
9373
9899
  const targetVolume = state.work.volumes[location.volumeIndex + direction];
9374
9900
  if (!targetVolume) return toast("已经是最前或最后一个分卷");
9375
- await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
9901
+ await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? Number(targetVolume.chapterCount ?? targetVolume.chapters.length) : 0);
9376
9902
  return;
9377
9903
  }
9378
9904
  const targetIndex = location.chapterIndex + direction;
@@ -9687,10 +10213,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
9687
10213
  $("#welcome-view").classList.add("hidden");
9688
10214
  $("#module-view").classList.add("hidden");
9689
10215
  $("#editor-view").classList.remove("hidden");
9690
- const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
9691
- const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
9692
- $("#chapter-path").textContent = chapterPath;
9693
- $("#chapter-path").title = chapterPath;
10216
+ updateChapterPath();
9694
10217
  $("#chapter-title").value = state.chapter.title;
9695
10218
  $("#chapter-content").value = state.chapter.content;
9696
10219
  resetChapterDraftLineIds(state.chapter);
@@ -9705,12 +10228,20 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
9705
10228
  if (!canEditProse()) setSaveState("正文只读");
9706
10229
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
9707
10230
  else setSaveState("已保存");
9708
- renderTree();
10231
+ syncChapterTreeSelection();
9709
10232
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
9710
- await loadChapterForeshadowReminders();
10233
+ void loadChapterForeshadowReminders();
9711
10234
  return true;
9712
10235
  }
9713
10236
 
10237
+ function updateChapterPath() {
10238
+ if (!state.work || !state.chapter) return;
10239
+ const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
10240
+ const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
10241
+ $("#chapter-path").textContent = chapterPath;
10242
+ $("#chapter-path").title = chapterPath;
10243
+ }
10244
+
9714
10245
  function updateChapterStats() {
9715
10246
  if (!state.chapter) return;
9716
10247
  const text = $("#chapter-content").value;
@@ -11352,11 +11883,12 @@ async function renderCharacters(page = characterListPage) {
11352
11883
  ? `<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>`
11353
11884
  : "";
11354
11885
  const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
11355
- const details = normalizeCharacterDetails(item.attributes?.details);
11886
+ const attributes = normalizeCharacterAttributes(item.attributes);
11887
+ const details = attributes.details ?? [];
11356
11888
  return `
11357
11889
  <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}”`)}
11358
11890
  <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
11359
- ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
11891
+ ${attributes.identity ? `<p class="character-identity">${esc(attributes.identity)}</p>` : ""}
11360
11892
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
11361
11893
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
11362
11894
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
@@ -11368,7 +11900,7 @@ async function renderCharacters(page = characterListPage) {
11368
11900
  </article>`;
11369
11901
  }).join("")}</div>`;
11370
11902
  const characterRows = () => `<div class="module-row-list">${pageCharacters.map((item) => {
11371
- const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
11903
+ const preview = moduleRowPreview(item.profile?.summary || normalizeCharacterAttributes(item.attributes).identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
11372
11904
  const meta = [
11373
11905
  item.code ? `编号 ${item.code}` : "",
11374
11906
  `性别 ${characterGenderLabel(item.gender)}`,
@@ -13227,7 +13759,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13227
13759
  const character = await api(`/api/characters/${encodeURIComponent(details.characterId)}`);
13228
13760
  const anchors = [
13229
13761
  character.code,
13230
- character.attributes?.identity,
13762
+ normalizeCharacterAttributes(character.attributes).identity,
13231
13763
  character.race?.name || character.species,
13232
13764
  ...(Array.isArray(character.organizations) ? character.organizations.map((organization) => organization.name) : [])
13233
13765
  ].map((value) => String(value ?? "").trim()).filter(Boolean);
@@ -13246,7 +13778,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13246
13778
  <label>角色标准名<input type="text" value="${esc(character.name)}" readonly></label>
13247
13779
  ${field("aliases", "确认别名", "item-list", character.aliases ?? [])}
13248
13780
  ${field("code", "人物编号或代号", "text", character.code)}
13249
- ${field("identity", "身份与定位", "text", character.attributes?.identity)}
13781
+ ${field("identity", "身份与定位", "text", normalizeCharacterAttributes(character.attributes).identity)}
13250
13782
  <button class="ghost-button relationship-identity-full-profile" type="button" data-open-identity-character-profile>打开完整人物档案</button>`,
13251
13783
  async (form) => {
13252
13784
  const aliases = form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean);
@@ -13257,7 +13789,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13257
13789
  body: {
13258
13790
  aliases,
13259
13791
  code,
13260
- attributes: { ...(character.attributes ?? {}), identity },
13792
+ attributes: normalizeCharacterAttributes({ ...(character.attributes ?? {}), identity }),
13261
13793
  expectedVersionNo: character.versionNo,
13262
13794
  changeNote: `修复人物关系来源匹配:${failure.message}`.slice(0, 500)
13263
13795
  }
@@ -15041,6 +15573,43 @@ function setAiContextWarningActionsDisabled(disabled) {
15041
15573
  for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
15042
15574
  }
15043
15575
 
15576
+ async function compactAiConversation({ clearComposer = false, button = null } = {}) {
15577
+ if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
15578
+ const requestScope = currentAiRequestScope();
15579
+ const modelId = $("#ai-model").value;
15580
+ if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
15581
+ const previousLabel = button?.textContent ?? "";
15582
+ setAiContextWarningActionsDisabled(true);
15583
+ if (button) button.textContent = "压缩中";
15584
+ try {
15585
+ const conversationId = await ensureAiConversation();
15586
+ const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
15587
+ method: "POST",
15588
+ body: { modelId, scope: requestScope.scope }
15589
+ });
15590
+ hideAiContextWarning();
15591
+ if (clearComposer) clearAiPromptComposer();
15592
+ $("#ai-prompt").focus();
15593
+ toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
15594
+ setAiContextMeter(result.usage);
15595
+ if (result.changed) {
15596
+ const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
15597
+ if (current) {
15598
+ current.compactedMessageCount = result.compactedMessageCount;
15599
+ current.hasCompactedSummary = true;
15600
+ }
15601
+ renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
15602
+ }
15603
+ return result;
15604
+ } catch (error) {
15605
+ toast(`上下文压缩失败:${error.message}`, "error");
15606
+ return null;
15607
+ } finally {
15608
+ setAiContextWarningActionsDisabled(false);
15609
+ if (button) button.textContent = previousLabel;
15610
+ }
15611
+ }
15612
+
15044
15613
  async function loadAiReferences() {
15045
15614
  const workId = state.work?.id;
15046
15615
  if (!workId) return;
@@ -16925,6 +17494,7 @@ function renderCharacterEditorFields(item) {
16925
17494
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
16926
17495
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
16927
17496
  const stateEntries = characterStateEntries(item?.currentState ?? {});
17497
+ const attributes = normalizeCharacterAttributes(item?.attributes);
16928
17498
  const raceField = !canReadModule("races")
16929
17499
  ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
16930
17500
  : state.races.length
@@ -16946,12 +17516,12 @@ function renderCharacterEditorFields(item) {
16946
17516
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
16947
17517
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
16948
17518
  field("code", "编号", "text", item?.code) +
16949
- field("identity", "身份与定位", "text", item?.attributes?.identity) +
17519
+ field("identity", "身份与定位", "text", attributes.identity) +
16950
17520
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
16951
17521
  field("summary", "人物简介", "textarea", item?.profile?.summary) +
16952
17522
  '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
16953
17523
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
16954
- field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
17524
+ field("details", "扩展属性", "key-value-list", attributes.details, { multilineValue: true }) +
16955
17525
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
16956
17526
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
16957
17527
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
@@ -16994,11 +17564,11 @@ function collectCharacterBody(form) {
16994
17564
  isDead: form.has("isDead"),
16995
17565
  code: String(form.get("code") ?? "").trim(),
16996
17566
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
16997
- attributes: {
16998
- ...(item?.attributes ?? {}),
17567
+ attributes: normalizeCharacterAttributes({
17568
+ species: typeof item?.attributes?.species === "string" ? item.attributes.species : "",
16999
17569
  identity: String(form.get("identity") ?? "").trim(),
17000
17570
  details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
17001
- },
17571
+ }),
17002
17572
  profile: {
17003
17573
  ...profile,
17004
17574
  motivation: String(form.get("motivation") ?? "").trim(),
@@ -18499,12 +19069,31 @@ async function sendAi() {
18499
19069
  return sendAiWithOptions();
18500
19070
  }
18501
19071
 
19072
+ function appendAiPendingRequestMessage(tab) {
19073
+ const message = document.createElement("div");
19074
+ message.className = "assistant-message is-streaming";
19075
+ message.dataset.testid = "ai-stream-message";
19076
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在请求……</div>';
19077
+ attachMessageHeading(message, aiAssistantLabel("正在请求", tab.roleplayCharacter), undefined, tab);
19078
+ tab.feed.append(message);
19079
+ scrollAiFeedToBottom(tab.feed);
19080
+ return message;
19081
+ }
19082
+
19083
+ function prefixAiRequestError(error, prefix) {
19084
+ const failure = error instanceof Error ? error : new Error(String(error ?? "未知错误"));
19085
+ failure.message = `${prefix}:${failure.message}`;
19086
+ return failure;
19087
+ }
19088
+
18502
19089
  async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
18503
19090
  if (!state.work) return toast("请先选择作品", "error");
18504
19091
  const tab = activeAiChatTab();
18505
19092
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
18506
19093
  if (aiRequestManager.hasActive(tab.id)) return;
18507
19094
  if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
19095
+ const queuedComposer = pendingQueuedComposer;
19096
+ pendingQueuedComposer = null;
18508
19097
  const composerSnapshot = captureAiPromptComposer();
18509
19098
  const requestComposerSnapshot = retry
18510
19099
  ? {
@@ -18516,7 +19105,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18516
19105
  sceneDirection: retry.sceneDirection ?? "",
18517
19106
  scenePin: captureAiScenePin()
18518
19107
  }
18519
- : composerSnapshot;
19108
+ : queuedComposer ?? composerSnapshot;
18520
19109
  const instruction = String(requestComposerSnapshot.markup ?? requestComposerSnapshot.text).trim();
18521
19110
  const instructionText = String(requestComposerSnapshot.text ?? "").trim();
18522
19111
  const sceneDirection = $("#ai-task").value === "roleplay"
@@ -18531,6 +19120,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18531
19120
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
18532
19121
  const requestScope = currentAiRequestScope();
18533
19122
  if (!requestScope) return toast("请先选择章节", "error");
19123
+ if (queuedComposer) {
19124
+ aiPromptQueue.remove(tab.id, queuedComposer.id);
19125
+ renderAiPromptQueue();
19126
+ }
18534
19127
  const { scope } = requestScope;
18535
19128
  const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
18536
19129
  const selectedTaskType = $("#ai-task").value;
@@ -18541,7 +19134,11 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18541
19134
  tabId: tab.id,
18542
19135
  workId: state.work.id,
18543
19136
  conversationId: state.aiConversationId
18544
- })
19137
+ }),
19138
+ preserveComposer: Boolean(queuedComposer),
19139
+ optimisticUserMessage: null,
19140
+ pendingAssistantMessage: null,
19141
+ requestStartedAt: Date.now()
18545
19142
  };
18546
19143
  if (retry?.userMessageId) {
18547
19144
  requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
@@ -18549,6 +19146,34 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18549
19146
  setAiChatTabStatus(tab, "streaming");
18550
19147
  syncAiRequestControls();
18551
19148
  scrollAiFeedToBottom(tab.feed, { force: true });
19149
+ if (!retry) {
19150
+ const optimisticContent = selectedTaskType === "roleplay"
19151
+ ? composeRoleplayStoredUserContent(sceneDirection, instruction)
19152
+ : instruction;
19153
+ requestHolder.optimisticUserMessage = appendMessage(
19154
+ "user",
19155
+ optimisticContent,
19156
+ citations,
19157
+ null,
19158
+ {
19159
+ ...(requestScope.scope.characterIds?.length ? { mentionCharacterIds: requestScope.scope.characterIds } : {}),
19160
+ ...(requestScope.scope.settingIds?.length ? { mentionSettingIds: requestScope.scope.settingIds } : {}),
19161
+ ...(requestScope.scope.chapterIds?.length ? { mentionChapterIds: requestScope.scope.chapterIds } : {}),
19162
+ ...(requestScope.scope.includeSettingInfo === true ? { mentionContextSettingIds: ["include-setting-info"] } : {}),
19163
+ ...(requestComposerSnapshot.images.length ? { chatImageAttachmentIds: aiChatImageAttachmentIds(requestComposerSnapshot.images) } : {})
19164
+ },
19165
+ null,
19166
+ { tab }
19167
+ );
19168
+ requestHolder.optimisticUserMessage.dataset.status = "pending";
19169
+ requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息正在发送");
19170
+ if (!requestHolder.preserveComposer) {
19171
+ clearAiChatTabComposer(tab);
19172
+ if (isActiveAiChatTab(tab)) clearAiPromptComposer({ collapseScenePanel: Boolean(sceneDirection) });
19173
+ }
19174
+ requestHolder.pendingAssistantMessage = appendAiPendingRequestMessage(tab);
19175
+ }
19176
+ let shouldFlushQueuedPrompt = false;
18552
19177
  try {
18553
19178
  try {
18554
19179
  await ensureAiModelsLoaded();
@@ -18556,21 +19181,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18556
19181
  } catch (error) {
18557
19182
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
18558
19183
  setAiChatTabStatus(tab, "error");
18559
- return toast(`创作助手加载失败:${error.message}`, "error");
19184
+ const failure = prefixAiRequestError(error, "创作助手加载失败");
19185
+ if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
19186
+ throw failure;
18560
19187
  }
18561
19188
  assertAiRequestCurrent(requestHolder.snapshot);
18562
19189
  const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
18563
- if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
19190
+ if (!modelId) {
19191
+ const error = new Error("请先在 AI 管理中配置并选择模型");
19192
+ if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
19193
+ throw error;
19194
+ }
18564
19195
  tab.selectedModelId = modelId;
18565
19196
  const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
18566
19197
  if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
18567
- return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
19198
+ const error = new Error("当前选择的模型不是多模态模型,无法发送图片附件");
19199
+ if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
19200
+ throw error;
18568
19201
  }
18569
19202
  try {
18570
19203
  await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
18571
19204
  } catch (error) {
18572
19205
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
18573
- return toast(`对话配置锁定失败:${error.message}`, "error");
19206
+ const failure = prefixAiRequestError(error, "对话配置锁定失败");
19207
+ if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
19208
+ throw failure;
18574
19209
  }
18575
19210
  setAiChatTabStatus(tab, "streaming");
18576
19211
  if (retry?.message?.isConnected) retry.message.remove();
@@ -18591,8 +19226,14 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18591
19226
  ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
18592
19227
  }, retry), createAiIdempotencyKey());
18593
19228
  const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
18594
- if (streamed.action === "warn") return;
19229
+ if (streamed.action === "warn") {
19230
+ requestHolder.optimisticUserMessage?.remove();
19231
+ setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
19232
+ if (isActiveAiChatTab(tab) && !requestHolder.preserveComposer) restoreAiPromptComposer(requestComposerSnapshot);
19233
+ return;
19234
+ }
18595
19235
  assistantContent = streamed.content;
19236
+ shouldFlushQueuedPrompt = true;
18596
19237
  assistantMessage = streamed.message;
18597
19238
  assistantMetadata = streamed.metadata;
18598
19239
  persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
@@ -18631,6 +19272,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18631
19272
  }
18632
19273
  } catch (error) {
18633
19274
  const request = requestHolder.snapshot;
19275
+ if (!error?.streamInterruption) requestHolder.pendingAssistantMessage?.remove();
18634
19276
  if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
18635
19277
  const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
18636
19278
  const userStoppedCurrentReply = request.signal.reason instanceof Error
@@ -18649,6 +19291,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18649
19291
  return;
18650
19292
  }
18651
19293
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
19294
+ requestHolder.optimisticUserMessage?.remove();
18652
19295
  setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
18653
19296
  if (isActiveAiChatTab(tab)) {
18654
19297
  if (retry) restoreAiPromptComposer(requestComposerSnapshot);
@@ -18668,6 +19311,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18668
19311
  return;
18669
19312
  }
18670
19313
  setAiChatTabStatus(tab, "error");
19314
+ if (requestHolder.optimisticUserMessage?.dataset.status === "pending") {
19315
+ requestHolder.optimisticUserMessage.dataset.status = "failed";
19316
+ requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息发送失败");
19317
+ }
18671
19318
  const interruption = error?.streamInterruption;
18672
19319
  if (interruption?.content) {
18673
19320
  try {
@@ -18723,6 +19370,18 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18723
19370
  if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
18724
19371
  else renderAiChatTabs();
18725
19372
  if (isActiveAiChatTab(tab)) syncAiRequestControls();
19373
+ if (queuedComposer && !shouldFlushQueuedPrompt) {
19374
+ const stillQueued = aiPromptQueue.list(tab.id).some((item) => item.id === queuedComposer.id);
19375
+ if (!stillQueued) {
19376
+ try {
19377
+ aiPromptQueue.restore(tab.id, queuedComposer, 0);
19378
+ } catch {
19379
+ /* 失败时尽量把未发出的排队 Prompt 放回队首,超限则保留当前队列。 */
19380
+ }
19381
+ renderAiPromptQueue();
19382
+ }
19383
+ }
19384
+ if (shouldFlushQueuedPrompt) flushNextQueuedPrompt(tab);
18726
19385
  }
18727
19386
  }
18728
19387
 
@@ -18742,14 +19401,21 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18742
19401
  const tab = aiChatTabForRequest(requestHolder.snapshot);
18743
19402
  if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
18744
19403
  const feed = tab.feed;
18745
- const message = document.createElement("div");
19404
+ let message = requestHolder.pendingAssistantMessage?.isConnected
19405
+ ? requestHolder.pendingAssistantMessage
19406
+ : document.createElement("div");
18746
19407
  message.className = "assistant-message is-streaming";
18747
19408
  message.dataset.testid = "ai-stream-message";
18748
- const streamConnectionStartedAt = Date.now();
18749
- 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>';
18750
- const content = message.querySelector(".message-body");
18751
- const meta = message.querySelector(".message-meta");
18752
- const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
19409
+ const streamConnectionStartedAt = requestHolder.requestStartedAt ?? Date.now();
19410
+ if (!message.querySelector(".message-body")) {
19411
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta"></div>';
19412
+ }
19413
+ let content = message.querySelector(".message-body");
19414
+ let meta = message.querySelector(".message-meta");
19415
+ const connectionSeconds = document.createElement("span");
19416
+ connectionSeconds.className = "ai-stream-connection-seconds";
19417
+ connectionSeconds.dataset.testid = "ai-stream-connection-seconds";
19418
+ meta.replaceChildren("正在请求…… ", connectionSeconds, " 秒");
18753
19419
  const renderStreamConnectionElapsed = () => {
18754
19420
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
18755
19421
  connectionSeconds.textContent = String(elapsedSeconds);
@@ -18763,17 +19429,18 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18763
19429
  };
18764
19430
  const streamConnectionEstablishedEvents = new Set(["continuation", "delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
18765
19431
  const streamSpeedController = createStreamTypewriterSpeedController();
18766
- let messageMounted = false;
19432
+ let streamConnectionEstablished = false;
19433
+ let messageMounted = message.isConnected;
18767
19434
  const mountAssistantMessage = () => {
18768
19435
  if (messageMounted) return true;
18769
19436
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
18770
- attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
19437
+ attachMessageHeading(message, aiAssistantLabel(streamConnectionEstablished ? "正在生成" : "正在请求", tab.roleplayCharacter), undefined, tab);
18771
19438
  feed.append(message);
18772
19439
  messageMounted = true;
18773
19440
  scrollAiFeedToBottom(feed);
18774
19441
  return true;
18775
19442
  };
18776
- const typewriter = createStreamTypewriter({
19443
+ const createAssistantTypewriter = () => createStreamTypewriter({
18777
19444
  speedController: streamSpeedController,
18778
19445
  shouldAnimate: () => aiStreamTargetVisible(feed),
18779
19446
  onRender: (text, progress) => {
@@ -18786,6 +19453,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18786
19453
  });
18787
19454
  }
18788
19455
  });
19456
+ let typewriter = createAssistantTypewriter();
18789
19457
  let streamedText = "";
18790
19458
  let streamedPendingText = "";
18791
19459
  let generatedMetadata = {};
@@ -18806,8 +19474,9 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18806
19474
  const processStepVisibleContents = new Map();
18807
19475
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
18808
19476
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18809
- aiStreamRenders.enqueue(message, () => {
18810
- renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
19477
+ const target = message;
19478
+ aiStreamRenders.enqueue(target, () => {
19479
+ renderAiProcessSteps(target, processSteps, completed, durationMs, processStepVisibleContents);
18811
19480
  scrollAiFeedToBottom(feed);
18812
19481
  });
18813
19482
  };
@@ -18832,6 +19501,45 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18832
19501
  const revealProcessStepTypewriters = () => {
18833
19502
  for (const typewriter of processStepTypewriters.values()) typewriter.reveal();
18834
19503
  };
19504
+ const beginSteeredAssistantSegment = async (steerPayload) => {
19505
+ await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
19506
+ if (messageMounted) {
19507
+ message.classList.remove("is-streaming");
19508
+ content.setAttribute("aria-busy", "false");
19509
+ const headingRole = message.querySelector(".message-heading > span");
19510
+ if (headingRole) headingRole.textContent = aiAssistantLabel("", tab.roleplayCharacter);
19511
+ renderStreamingProcessSteps(true);
19512
+ if (streamedText) attachAssistantCopyAction(message, streamedText);
19513
+ }
19514
+ const steerMessage = steerPayload?.message;
19515
+ if (steerMessage?.id) {
19516
+ updateAiConversationSummaryFromMessage(steerMessage);
19517
+ const existingSteerMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19518
+ .find((candidate) => candidate.dataset.messageId === String(steerMessage.id));
19519
+ if (!existingSteerMessage) {
19520
+ appendMessage("user", steerMessage.content, steerMessage.citations, steerMessage.createdAt, {
19521
+ ...steerMessage.metadata,
19522
+ kind: "steer"
19523
+ }, steerMessage.id, { tab });
19524
+ }
19525
+ }
19526
+ streamedText = "";
19527
+ streamedPendingText = "";
19528
+ toolCalls = [];
19529
+ processSteps = [];
19530
+ processStepTypewriters.clear();
19531
+ processStepVisibleContents.clear();
19532
+ message = document.createElement("div");
19533
+ message.className = "assistant-message is-streaming";
19534
+ message.dataset.testid = "ai-stream-message";
19535
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">已收到执行流引导,正在继续生成</div>';
19536
+ content = message.querySelector(".message-body");
19537
+ meta = message.querySelector(".message-meta");
19538
+ messageMounted = false;
19539
+ typewriter = createAssistantTypewriter();
19540
+ mountAssistantMessage();
19541
+ scrollAiFeedToBottom(feed);
19542
+ };
18835
19543
  try {
18836
19544
  const request = assertAiRequestCurrent(requestHolder.snapshot);
18837
19545
  const response = await fetch(endpoint ?? `/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
@@ -18848,7 +19556,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18848
19556
  let streamError = null;
18849
19557
  const consume = async (eventName, payload) => {
18850
19558
  assertAiRequestCurrent(requestHolder.snapshot);
18851
- if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
19559
+ if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") {
19560
+ stopStreamConnectionTimer();
19561
+ streamConnectionEstablished = true;
19562
+ const headingRole = messageMounted ? message.querySelector(".message-heading > span") : null;
19563
+ if (headingRole && !["complete", "request_status", "error"].includes(eventName)) {
19564
+ headingRole.textContent = aiAssistantLabel("正在生成", tab.roleplayCharacter);
19565
+ }
19566
+ }
18852
19567
  if (eventName === "continuation") {
18853
19568
  if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
18854
19569
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
@@ -18898,11 +19613,20 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18898
19613
  tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
18899
19614
  tab.selectedModelId = lockedModelId;
18900
19615
  tab.promptSent = true;
18901
- clearAiChatTabComposer(tab);
18902
- const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19616
+ if (!requestHolder.preserveComposer) clearAiChatTabComposer(tab);
19617
+ let existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
18903
19618
  .find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
18904
- if (!existingUserMessage) {
18905
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
19619
+ if (requestHolder.optimisticUserMessage?.isConnected) {
19620
+ if (existingUserMessage && existingUserMessage !== requestHolder.optimisticUserMessage) {
19621
+ requestHolder.optimisticUserMessage.remove();
19622
+ } else {
19623
+ const confirmedUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
19624
+ requestHolder.optimisticUserMessage.replaceWith(confirmedUserMessage);
19625
+ requestHolder.optimisticUserMessage = confirmedUserMessage;
19626
+ existingUserMessage = confirmedUserMessage;
19627
+ }
19628
+ } else if (!existingUserMessage) {
19629
+ existingUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
18906
19630
  }
18907
19631
  if (isActiveAiChatTab(tab)) {
18908
19632
  state.aiConversationModelId = lockedModelId;
@@ -18911,10 +19635,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18911
19635
  syncAiTaskOptions();
18912
19636
  renderAiRoleplayCharacterSelect();
18913
19637
  renderAiQuickActions();
18914
- clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
19638
+ if (!requestHolder.preserveComposer) {
19639
+ clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
19640
+ }
18915
19641
  }
18916
19642
  mountAssistantMessage();
18917
19643
  }
19644
+ } else if (eventName === "steer") {
19645
+ await beginSteeredAssistantSegment(payload);
18918
19646
  } else if (eventName === "delta") {
18919
19647
  mountAssistantMessage();
18920
19648
  const delta = typeof payload.delta === "string" ? payload.delta : "";
@@ -19008,6 +19736,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19008
19736
  assertAiRequestCurrent(requestHolder.snapshot);
19009
19737
  if (streamError) throw streamError;
19010
19738
  assertAiStreamCompleted(streamCompleted);
19739
+ if (warningOnly && messageMounted) message.remove();
19011
19740
  return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage, question };
19012
19741
  } catch (error) {
19013
19742
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
@@ -19126,6 +19855,14 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
19126
19855
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
19127
19856
  heading.firstElementChild?.append(failureBadge);
19128
19857
  }
19858
+ if (role === "user" && metadata?.kind === "steer") {
19859
+ message.classList.add("is-steer");
19860
+ const steerBadge = document.createElement("strong");
19861
+ steerBadge.className = "ai-message-status is-steer";
19862
+ steerBadge.textContent = "引导";
19863
+ steerBadge.setAttribute("aria-label", "消息类型:执行流引导");
19864
+ heading.firstElementChild?.append(steerBadge);
19865
+ }
19129
19866
  if (parsedUserTurn?.hasMarkup && parsedUserTurn.sceneDirection) {
19130
19867
  const scene = document.createElement("div");
19131
19868
  scene.className = "user-message-scene";
@@ -21009,6 +21746,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
21009
21746
  $(field)?.addEventListener("input", () => {
21010
21747
  syncAiSceneComposer();
21011
21748
  persistActiveAiChatTab();
21749
+ syncAiRequestControls();
21012
21750
  });
21013
21751
  }
21014
21752
  $("#ai-attachment-input").addEventListener("change", (event) => {
@@ -21018,6 +21756,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
21018
21756
  $("#ai-prompt").addEventListener("input", async () => {
21019
21757
  updateAiMentionMenu();
21020
21758
  setAiContextMeter(null);
21759
+ syncAiRequestControls();
21021
21760
  const textBeforeCursor = aiPromptTextBeforeCursor();
21022
21761
  if ($("#ai-task").value !== "roleplay" && findAiSkillCommand(textBeforeCursor)) return;
21023
21762
  if (!findAiMention(textBeforeCursor)) return;
@@ -21161,8 +21900,8 @@ $("#ai-scope").addEventListener("change", (event) => {
21161
21900
  setAiContextMeter(null);
21162
21901
  });
21163
21902
  $("#ai-mention-menu").addEventListener("click", (event) => {
21164
- const skillButton = event.target.closest("[data-ai-skill-name]");
21165
- if (skillButton) return selectAiSkill(skillButton);
21903
+ const commandButton = event.target.closest("[data-ai-command-name]");
21904
+ if (commandButton) return selectAiSlashCommand(commandButton);
21166
21905
  const button = event.target.closest("[data-ai-reference-id]");
21167
21906
  if (button) selectAiMention(button);
21168
21907
  });
@@ -21426,6 +22165,11 @@ $("#ai-context-meter").addEventListener("click", () => {
21426
22165
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
21427
22166
  $("#ai-citation-popover-close").addEventListener("click", () => closeAiCitationPopover({ restoreFocus: true }));
21428
22167
  $("#ai-send").addEventListener("click", activateAiSendControl);
22168
+ $("#ai-send-mode").addEventListener("change", (event) => {
22169
+ writeStoredAiSendMode(localStorage, event.currentTarget.value);
22170
+ syncAiRequestControls();
22171
+ });
22172
+ applyAiSendModeFromStorage();
21429
22173
  $("#ai-conversation-switcher").addEventListener("click", () => {
21430
22174
  setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
21431
22175
  });
@@ -21456,36 +22200,7 @@ $("#ai-new-conversation").addEventListener("click", async () => {
21456
22200
  }
21457
22201
  });
21458
22202
  $("#ai-context-compact").addEventListener("click", async () => {
21459
- const requestScope = currentAiRequestScope();
21460
- const modelId = $("#ai-model").value;
21461
- if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
21462
- const button = $("#ai-context-compact");
21463
- setAiContextWarningActionsDisabled(true);
21464
- button.textContent = "压缩中";
21465
- try {
21466
- const conversationId = await ensureAiConversation();
21467
- const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
21468
- method: "POST",
21469
- body: { modelId, scope: requestScope.scope }
21470
- });
21471
- hideAiContextWarning();
21472
- $("#ai-prompt").focus();
21473
- toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
21474
- setAiContextMeter(result.usage);
21475
- if (result.changed) {
21476
- const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
21477
- if (current) {
21478
- current.compactedMessageCount = result.compactedMessageCount;
21479
- current.hasCompactedSummary = true;
21480
- }
21481
- renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
21482
- }
21483
- } catch (error) {
21484
- toast(`上下文压缩失败:${error.message}`, "error");
21485
- } finally {
21486
- setAiContextWarningActionsDisabled(false);
21487
- button.textContent = "压缩";
21488
- }
22203
+ void compactAiConversation({ button: $("#ai-context-compact") });
21489
22204
  });
21490
22205
  $("#ai-context-new-conversation").addEventListener("click", async () => {
21491
22206
  setAiContextWarningActionsDisabled(true);
@@ -21758,7 +22473,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
21758
22473
  const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
21759
22474
  if (activeOption) {
21760
22475
  event.preventDefault();
21761
- if (activeOption.dataset.aiSkillName) selectAiSkill(activeOption);
22476
+ if (activeOption.dataset.aiCommandName) selectAiSlashCommand(activeOption);
21762
22477
  else selectAiMention(activeOption);
21763
22478
  return;
21764
22479
  }
@@ -21769,9 +22484,9 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
21769
22484
  return;
21770
22485
  }
21771
22486
  }
21772
- if (shouldSendAiPrompt(event)) {
22487
+ if (shouldActivateAiSendControl(event)) {
21773
22488
  event.preventDefault();
21774
- if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
22489
+ if (!$("#ai-send").disabled) submitAiComposerPrompt();
21775
22490
  }
21776
22491
  });
21777
22492
  $(".quick-actions").addEventListener("click", (event) => {