@musnows/scriverse 1.0.12 → 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.
@@ -39,7 +39,18 @@ import {
39
39
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
40
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";
41
41
  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";
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";
43
54
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
44
55
  import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260912-stream-render-v2";
45
56
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
@@ -69,7 +80,7 @@ import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatI
69
80
  import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
70
81
  import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-v1";
71
82
  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";
83
+ import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterAttributes, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260919-character-attributes-prune-v1";
73
84
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
74
85
  import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
75
86
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
@@ -251,6 +262,11 @@ const state = {
251
262
  let platformAiProtocolOptions = [];
252
263
 
253
264
  const aiRequestManager = createAiRequestManager();
265
+ const aiPromptQueue = createAiPromptQueue();
266
+ let pendingQueuedComposer = null;
267
+ let aiPromptQueueDidDrag = false;
268
+ let aiPromptQueueEditorState = null;
269
+ let aiPromptQueueEditorBlurTimer = 0;
254
270
  const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
255
271
  const moduleRequestCache = createModuleRequestCache();
256
272
  const cachedWorkModules = new Set([
@@ -533,6 +549,7 @@ function applyWorkAccessMode() {
533
549
  state.dirty = false;
534
550
  }
535
551
  applyChapterEditorMode();
552
+ syncAiSendModeControl();
536
553
  }
537
554
 
538
555
  const $ = (selector) => document.querySelector(selector);
@@ -674,28 +691,77 @@ function aiInteractionBusy() {
674
691
  }
675
692
 
676
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
+ }
677
697
  return stateName === "stop"
678
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>'
679
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>';
680
700
  }
681
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
+
682
729
  function syncAiRequestControls() {
683
730
  const activeTabId = activeAiChatTab()?.id;
684
731
  const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
685
732
  const sending = aiRequestManager.hasActive(activeTabId) && !continuingQuestion;
686
733
  const switching = aiConversationNavigationPending !== null;
687
734
  const button = $("#ai-send");
688
- const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
689
- 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 ? "正在切换对话" : "发送消息";
690
746
  button.disabled = switching || continuingQuestion;
691
747
  button.dataset.state = stateName;
692
- button.classList.toggle("is-stop", sending);
748
+ button.classList.toggle("is-stop", sendAction === "stop");
749
+ button.classList.toggle("is-queue", sendAction === "queue");
693
750
  button.setAttribute("aria-label", label);
694
- button.title = label;
695
- 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();
696
761
  syncAiTaskOptions();
697
762
  renderAiRoleplayCharacterSelect();
698
763
  syncAiImageAttachmentControl();
764
+ renderAiPromptQueue();
699
765
  }
700
766
 
701
767
  function cancelActiveAiRequest(reason) {
@@ -704,18 +770,388 @@ function cancelActiveAiRequest(reason) {
704
770
  return cancelled;
705
771
  }
706
772
 
707
- function activateAiSendControl() {
773
+ function submitAiComposerPrompt() {
708
774
  const tab = activeAiChatTab();
709
775
  if (aiRequestManager.hasActive(tab?.id)) {
710
- if (cancelActiveAiRequest("用户已终止当前回复")) {
711
- toast("已终止当前回复,可以重新发送");
712
- $("#ai-prompt").focus();
776
+ if (aiComposerSendMode() === "steer") {
777
+ void sendActiveComposerAsSteer();
778
+ return;
713
779
  }
780
+ queueActiveComposerPrompt();
714
781
  return;
715
782
  }
716
783
  void sendAi();
717
784
  }
718
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
+
719
1155
  function beginAiConversationNavigation(reason, action = "切换会话") {
720
1156
  aiConversationNavigationGeneration += 1;
721
1157
  aiConversationNavigationPending = aiConversationNavigationGeneration;
@@ -836,6 +1272,7 @@ let chapterSelectionRequestGeneration = 0;
836
1272
  let aiConversationNavigationGeneration = 0;
837
1273
  let aiConversationNavigationPending = null;
838
1274
  let aiChatTabLimit = 5;
1275
+ let chapterAnnotationNoteMaxLength = 6000;
839
1276
  let aiConversationWorkspaceOpen = false;
840
1277
  const loadedVolumeChapterIds = new Set();
841
1278
  const volumeChapterLoadingIds = new Set();
@@ -2792,6 +3229,12 @@ function applyAiChatTabLimit(value) {
2792
3229
  renderAiChatTabs();
2793
3230
  }
2794
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
+
2795
3238
  function renderAiChatTabs() {
2796
3239
  const host = $("#ai-chat-tabs");
2797
3240
  if (!host) return;
@@ -5647,7 +6090,7 @@ async function createSelectedLineAnnotation(kind) {
5647
6090
  title: kind === "todo" ? "添加正文待办" : "添加正文评论",
5648
6091
  inputLabel: kind === "todo" ? "待办内容" : "评论内容",
5649
6092
  confirmLabel: "添加",
5650
- maxLength: 2000
6093
+ maxLength: chapterAnnotationNoteMaxLength
5651
6094
  });
5652
6095
  if (!note) return;
5653
6096
  try {
@@ -5716,7 +6159,7 @@ function bindChapterAnnotationCards(host, annotations, { refresh, locate, overla
5716
6159
  inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
5717
6160
  value: annotation.note,
5718
6161
  confirmLabel: "保存",
5719
- maxLength: 2000
6162
+ maxLength: chapterAnnotationNoteMaxLength
5720
6163
  });
5721
6164
  if (!note) return resumeOverlay();
5722
6165
  try {
@@ -6084,6 +6527,7 @@ async function api(path, options = {}) {
6084
6527
  if (path === "/api/health") {
6085
6528
  applyImageUploadLimits(payload.data?.uploadLimits);
6086
6529
  applyAiChatTabLimit(payload.data?.aiChatTabLimit);
6530
+ applyChapterAnnotationNoteMaxLength(payload.data?.chapterAnnotationNoteMaxLength);
6087
6531
  updateSystemHealth({
6088
6532
  status: payload.data?.status === "ok" ? "ready" : "degraded",
6089
6533
  version: payload.data?.version
@@ -11352,11 +11796,12 @@ async function renderCharacters(page = characterListPage) {
11352
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>`
11353
11797
  : "";
11354
11798
  const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
11355
- const details = normalizeCharacterDetails(item.attributes?.details);
11799
+ const attributes = normalizeCharacterAttributes(item.attributes);
11800
+ const details = attributes.details ?? [];
11356
11801
  return `
11357
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}”`)}
11358
11803
  <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>` : ""}
11804
+ ${attributes.identity ? `<p class="character-identity">${esc(attributes.identity)}</p>` : ""}
11360
11805
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
11361
11806
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
11362
11807
  ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
@@ -11368,7 +11813,7 @@ async function renderCharacters(page = characterListPage) {
11368
11813
  </article>`;
11369
11814
  }).join("")}</div>`;
11370
11815
  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(" ") || "尚未记录当前状态");
11816
+ const preview = moduleRowPreview(item.profile?.summary || normalizeCharacterAttributes(item.attributes).identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
11372
11817
  const meta = [
11373
11818
  item.code ? `编号 ${item.code}` : "",
11374
11819
  `性别 ${characterGenderLabel(item.gender)}`,
@@ -13227,7 +13672,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13227
13672
  const character = await api(`/api/characters/${encodeURIComponent(details.characterId)}`);
13228
13673
  const anchors = [
13229
13674
  character.code,
13230
- character.attributes?.identity,
13675
+ normalizeCharacterAttributes(character.attributes).identity,
13231
13676
  character.race?.name || character.species,
13232
13677
  ...(Array.isArray(character.organizations) ? character.organizations.map((organization) => organization.name) : [])
13233
13678
  ].map((value) => String(value ?? "").trim()).filter(Boolean);
@@ -13246,7 +13691,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13246
13691
  <label>角色标准名<input type="text" value="${esc(character.name)}" readonly></label>
13247
13692
  ${field("aliases", "确认别名", "item-list", character.aliases ?? [])}
13248
13693
  ${field("code", "人物编号或代号", "text", character.code)}
13249
- ${field("identity", "身份与定位", "text", character.attributes?.identity)}
13694
+ ${field("identity", "身份与定位", "text", normalizeCharacterAttributes(character.attributes).identity)}
13250
13695
  <button class="ghost-button relationship-identity-full-profile" type="button" data-open-identity-character-profile>打开完整人物档案</button>`,
13251
13696
  async (form) => {
13252
13697
  const aliases = form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean);
@@ -13257,7 +13702,7 @@ async function openRelationshipIdentityRepairDialog(task, failure) {
13257
13702
  body: {
13258
13703
  aliases,
13259
13704
  code,
13260
- attributes: { ...(character.attributes ?? {}), identity },
13705
+ attributes: normalizeCharacterAttributes({ ...(character.attributes ?? {}), identity }),
13261
13706
  expectedVersionNo: character.versionNo,
13262
13707
  changeNote: `修复人物关系来源匹配:${failure.message}`.slice(0, 500)
13263
13708
  }
@@ -16925,6 +17370,7 @@ function renderCharacterEditorFields(item) {
16925
17370
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
16926
17371
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
16927
17372
  const stateEntries = characterStateEntries(item?.currentState ?? {});
17373
+ const attributes = normalizeCharacterAttributes(item?.attributes);
16928
17374
  const raceField = !canReadModule("races")
16929
17375
  ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
16930
17376
  : state.races.length
@@ -16946,12 +17392,12 @@ function renderCharacterEditorFields(item) {
16946
17392
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
16947
17393
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力、公开人设和便于创作时快速理解的简介。",
16948
17394
  field("code", "编号", "text", item?.code) +
16949
- field("identity", "身份与定位", "text", item?.attributes?.identity) +
17395
+ field("identity", "身份与定位", "text", attributes.identity) +
16950
17396
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
16951
17397
  field("summary", "人物简介", "textarea", item?.profile?.summary) +
16952
17398
  '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
16953
17399
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
16954
- field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
17400
+ field("details", "扩展属性", "key-value-list", attributes.details, { multilineValue: true }) +
16955
17401
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
16956
17402
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
16957
17403
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
@@ -16994,11 +17440,11 @@ function collectCharacterBody(form) {
16994
17440
  isDead: form.has("isDead"),
16995
17441
  code: String(form.get("code") ?? "").trim(),
16996
17442
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
16997
- attributes: {
16998
- ...(item?.attributes ?? {}),
17443
+ attributes: normalizeCharacterAttributes({
17444
+ species: typeof item?.attributes?.species === "string" ? item.attributes.species : "",
16999
17445
  identity: String(form.get("identity") ?? "").trim(),
17000
17446
  details: buildCharacterDetails(form.getAll("detailLabel"), form.getAll("detailValue"))
17001
- },
17447
+ }),
17002
17448
  profile: {
17003
17449
  ...profile,
17004
17450
  motivation: String(form.get("motivation") ?? "").trim(),
@@ -18505,6 +18951,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18505
18951
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
18506
18952
  if (aiRequestManager.hasActive(tab.id)) return;
18507
18953
  if (aiQuestionContinuationTabIds.has(tab.id)) return toast("AI 正在根据你的回答继续处理,请稍候");
18954
+ const queuedComposer = pendingQueuedComposer;
18955
+ pendingQueuedComposer = null;
18508
18956
  const composerSnapshot = captureAiPromptComposer();
18509
18957
  const requestComposerSnapshot = retry
18510
18958
  ? {
@@ -18516,7 +18964,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18516
18964
  sceneDirection: retry.sceneDirection ?? "",
18517
18965
  scenePin: captureAiScenePin()
18518
18966
  }
18519
- : composerSnapshot;
18967
+ : queuedComposer ?? composerSnapshot;
18520
18968
  const instruction = String(requestComposerSnapshot.markup ?? requestComposerSnapshot.text).trim();
18521
18969
  const instructionText = String(requestComposerSnapshot.text ?? "").trim();
18522
18970
  const sceneDirection = $("#ai-task").value === "roleplay"
@@ -18531,6 +18979,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18531
18979
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
18532
18980
  const requestScope = currentAiRequestScope();
18533
18981
  if (!requestScope) return toast("请先选择章节", "error");
18982
+ if (queuedComposer) {
18983
+ aiPromptQueue.remove(tab.id, queuedComposer.id);
18984
+ renderAiPromptQueue();
18985
+ }
18534
18986
  const { scope } = requestScope;
18535
18987
  const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
18536
18988
  const selectedTaskType = $("#ai-task").value;
@@ -18541,7 +18993,8 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18541
18993
  tabId: tab.id,
18542
18994
  workId: state.work.id,
18543
18995
  conversationId: state.aiConversationId
18544
- })
18996
+ }),
18997
+ preserveComposer: Boolean(queuedComposer)
18545
18998
  };
18546
18999
  if (retry?.userMessageId) {
18547
19000
  requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
@@ -18549,6 +19002,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18549
19002
  setAiChatTabStatus(tab, "streaming");
18550
19003
  syncAiRequestControls();
18551
19004
  scrollAiFeedToBottom(tab.feed, { force: true });
19005
+ let shouldFlushQueuedPrompt = false;
18552
19006
  try {
18553
19007
  try {
18554
19008
  await ensureAiModelsLoaded();
@@ -18593,6 +19047,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18593
19047
  const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
18594
19048
  if (streamed.action === "warn") return;
18595
19049
  assistantContent = streamed.content;
19050
+ shouldFlushQueuedPrompt = true;
18596
19051
  assistantMessage = streamed.message;
18597
19052
  assistantMetadata = streamed.metadata;
18598
19053
  persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
@@ -18723,6 +19178,18 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18723
19178
  if (tab.status === "streaming") setAiChatTabStatus(tab, "ready");
18724
19179
  else renderAiChatTabs();
18725
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);
18726
19193
  }
18727
19194
  }
18728
19195
 
@@ -18742,13 +19209,13 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18742
19209
  const tab = aiChatTabForRequest(requestHolder.snapshot);
18743
19210
  if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
18744
19211
  const feed = tab.feed;
18745
- const message = document.createElement("div");
19212
+ let message = document.createElement("div");
18746
19213
  message.className = "assistant-message is-streaming";
18747
19214
  message.dataset.testid = "ai-stream-message";
18748
19215
  const streamConnectionStartedAt = Date.now();
18749
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>';
18750
- const content = message.querySelector(".message-body");
18751
- const meta = message.querySelector(".message-meta");
19217
+ let content = message.querySelector(".message-body");
19218
+ let meta = message.querySelector(".message-meta");
18752
19219
  const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
18753
19220
  const renderStreamConnectionElapsed = () => {
18754
19221
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
@@ -18773,7 +19240,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18773
19240
  scrollAiFeedToBottom(feed);
18774
19241
  return true;
18775
19242
  };
18776
- const typewriter = createStreamTypewriter({
19243
+ const createAssistantTypewriter = () => createStreamTypewriter({
18777
19244
  speedController: streamSpeedController,
18778
19245
  shouldAnimate: () => aiStreamTargetVisible(feed),
18779
19246
  onRender: (text, progress) => {
@@ -18786,6 +19253,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18786
19253
  });
18787
19254
  }
18788
19255
  });
19256
+ let typewriter = createAssistantTypewriter();
18789
19257
  let streamedText = "";
18790
19258
  let streamedPendingText = "";
18791
19259
  let generatedMetadata = {};
@@ -18806,8 +19274,9 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18806
19274
  const processStepVisibleContents = new Map();
18807
19275
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
18808
19276
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18809
- aiStreamRenders.enqueue(message, () => {
18810
- renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
19277
+ const target = message;
19278
+ aiStreamRenders.enqueue(target, () => {
19279
+ renderAiProcessSteps(target, processSteps, completed, durationMs, processStepVisibleContents);
18811
19280
  scrollAiFeedToBottom(feed);
18812
19281
  });
18813
19282
  };
@@ -18832,6 +19301,45 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18832
19301
  const revealProcessStepTypewriters = () => {
18833
19302
  for (const typewriter of processStepTypewriters.values()) typewriter.reveal();
18834
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
+ };
18835
19343
  try {
18836
19344
  const request = assertAiRequestCurrent(requestHolder.snapshot);
18837
19345
  const response = await fetch(endpoint ?? `/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
@@ -18848,7 +19356,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18848
19356
  let streamError = null;
18849
19357
  const consume = async (eventName, payload) => {
18850
19358
  assertAiRequestCurrent(requestHolder.snapshot);
18851
- if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
19359
+ if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") stopStreamConnectionTimer();
18852
19360
  if (eventName === "continuation") {
18853
19361
  if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
18854
19362
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
@@ -18898,7 +19406,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18898
19406
  tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
18899
19407
  tab.selectedModelId = lockedModelId;
18900
19408
  tab.promptSent = true;
18901
- clearAiChatTabComposer(tab);
19409
+ if (!requestHolder.preserveComposer) clearAiChatTabComposer(tab);
18902
19410
  const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
18903
19411
  .find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
18904
19412
  if (!existingUserMessage) {
@@ -18911,10 +19419,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18911
19419
  syncAiTaskOptions();
18912
19420
  renderAiRoleplayCharacterSelect();
18913
19421
  renderAiQuickActions();
18914
- clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
19422
+ if (!requestHolder.preserveComposer) {
19423
+ clearAiPromptComposer({ collapseScenePanel: Boolean(String(body.sceneDirection ?? "").trim()) });
19424
+ }
18915
19425
  }
18916
19426
  mountAssistantMessage();
18917
19427
  }
19428
+ } else if (eventName === "steer") {
19429
+ await beginSteeredAssistantSegment(payload);
18918
19430
  } else if (eventName === "delta") {
18919
19431
  mountAssistantMessage();
18920
19432
  const delta = typeof payload.delta === "string" ? payload.delta : "";
@@ -19126,6 +19638,14 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
19126
19638
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
19127
19639
  heading.firstElementChild?.append(failureBadge);
19128
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
+ }
19129
19649
  if (parsedUserTurn?.hasMarkup && parsedUserTurn.sceneDirection) {
19130
19650
  const scene = document.createElement("div");
19131
19651
  scene.className = "user-message-scene";
@@ -21009,6 +21529,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
21009
21529
  $(field)?.addEventListener("input", () => {
21010
21530
  syncAiSceneComposer();
21011
21531
  persistActiveAiChatTab();
21532
+ syncAiRequestControls();
21012
21533
  });
21013
21534
  }
21014
21535
  $("#ai-attachment-input").addEventListener("change", (event) => {
@@ -21018,6 +21539,7 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
21018
21539
  $("#ai-prompt").addEventListener("input", async () => {
21019
21540
  updateAiMentionMenu();
21020
21541
  setAiContextMeter(null);
21542
+ syncAiRequestControls();
21021
21543
  const textBeforeCursor = aiPromptTextBeforeCursor();
21022
21544
  if ($("#ai-task").value !== "roleplay" && findAiSkillCommand(textBeforeCursor)) return;
21023
21545
  if (!findAiMention(textBeforeCursor)) return;
@@ -21426,6 +21948,11 @@ $("#ai-context-meter").addEventListener("click", () => {
21426
21948
  $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
21427
21949
  $("#ai-citation-popover-close").addEventListener("click", () => closeAiCitationPopover({ restoreFocus: true }));
21428
21950
  $("#ai-send").addEventListener("click", activateAiSendControl);
21951
+ $("#ai-send-mode").addEventListener("change", (event) => {
21952
+ writeStoredAiSendMode(localStorage, event.currentTarget.value);
21953
+ syncAiRequestControls();
21954
+ });
21955
+ applyAiSendModeFromStorage();
21429
21956
  $("#ai-conversation-switcher").addEventListener("click", () => {
21430
21957
  setAiConversationSwitcherVisible($("#ai-conversation-switcher-menu").classList.contains("hidden"));
21431
21958
  });
@@ -21769,9 +22296,9 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
21769
22296
  return;
21770
22297
  }
21771
22298
  }
21772
- if (shouldSendAiPrompt(event)) {
22299
+ if (shouldActivateAiSendControl(event)) {
21773
22300
  event.preventDefault();
21774
- if (!$("#ai-send").disabled && !aiRequestManager.hasActive(activeAiChatTab()?.id)) void sendAi();
22301
+ if (!$("#ai-send").disabled) submitAiComposerPrompt();
21775
22302
  }
21776
22303
  });
21777
22304
  $(".quick-actions").addEventListener("click", (event) => {