@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.
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/ai-stream-steer.js +96 -0
- package/dist/ai-stream-steer.js.map +1 -0
- package/dist/ai-write-plans.js +44 -38
- package/dist/ai-write-plans.js.map +1 -1
- package/dist/ai.js +241 -128
- package/dist/ai.js.map +1 -1
- package/dist/app.js +29 -5
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +3 -0
- package/dist/attachment-storage.js.map +1 -1
- package/dist/chapter-annotation-note.js +14 -0
- package/dist/chapter-annotation-note.js.map +1 -0
- package/dist/character-structured-fields.js +7 -0
- package/dist/character-structured-fields.js.map +1 -0
- package/dist/cli-contract.js +1 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/im.js +2 -1
- package/dist/im.js.map +1 -1
- package/dist/public/ai-prompt-keyboard.js +5 -1
- package/dist/public/ai-prompt-queue.js +226 -0
- package/dist/public/ai-send-mode.js +31 -0
- package/dist/public/ai-skill-menu.js +25 -5
- package/dist/public/app.js +846 -131
- package/dist/public/chapter-directory.d.ts +22 -0
- package/dist/public/chapter-directory.js +22 -0
- package/dist/public/character-profile.d.ts +7 -0
- package/dist/public/character-profile.js +123 -5
- package/dist/public/index.html +11 -4
- package/dist/public/styles.css +34 -4
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +22 -14
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -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,
|
|
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=
|
|
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=
|
|
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
|
|
689
|
-
const
|
|
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",
|
|
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 =
|
|
695
|
-
|
|
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
|
|
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 (
|
|
711
|
-
|
|
712
|
-
|
|
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 =
|
|
5967
|
+
const options = listAiSlashOptions(skillMatch.query);
|
|
5517
5968
|
aiMentionActiveIndex = -1;
|
|
5518
5969
|
prompt.removeAttribute("aria-activedescendant");
|
|
5519
|
-
menu.setAttribute("aria-label", "
|
|
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
|
|
5522
|
-
: '<p class="ai-mention-empty"
|
|
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:
|
|
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:
|
|
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
|
|
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")
|
|
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="
|
|
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
|
-
})
|
|
9106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9653
|
+
renderedNodes("[data-new-chapter-volume]").forEach((button) => {
|
|
9147
9654
|
button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
|
|
9148
9655
|
});
|
|
9149
|
-
|
|
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
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10231
|
+
syncChapterTreeSelection();
|
|
9709
10232
|
replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
|
|
9710
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
|
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
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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.
|
|
18750
|
-
|
|
18751
|
-
|
|
18752
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
18810
|
-
|
|
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))
|
|
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
|
-
|
|
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 (
|
|
18905
|
-
|
|
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
|
-
|
|
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
|
|
21165
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
22487
|
+
if (shouldActivateAiSendControl(event)) {
|
|
21773
22488
|
event.preventDefault();
|
|
21774
|
-
if (!$("#ai-send").disabled
|
|
22489
|
+
if (!$("#ai-send").disabled) submitAiComposerPrompt();
|
|
21775
22490
|
}
|
|
21776
22491
|
});
|
|
21777
22492
|
$(".quick-actions").addEventListener("click", (event) => {
|