@musnows/scriverse 1.1.0 → 1.1.2
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/dist/ai.js +23 -5
- package/dist/ai.js.map +1 -1
- package/dist/app.js +14 -0
- package/dist/app.js.map +1 -1
- package/dist/public/ai-skill-menu.js +25 -5
- package/dist/public/app.js +325 -101
- package/dist/public/chapter-directory.d.ts +22 -0
- package/dist/public/chapter-directory.js +22 -0
- package/dist/public/index.html +3 -2
- package/dist/public/mobile-app-runtime.js +662 -0
- package/dist/public/mobile-app.css +19 -0
- package/dist/public/mobile-offline-api.js +196 -0
- package/dist/public/mobile-service-worker.js +82 -0
- package/dist/public/mobile-sync-client.js +292 -0
- package/dist/public/mobile-sync-store.js +1004 -0
- package/dist/public/styles.css +6 -3
- package/dist/store.js +15 -8
- package/dist/store.js.map +1 -1
- package/dist/version.js +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,
|
|
@@ -143,6 +145,7 @@ import {
|
|
|
143
145
|
} from "/outline-board.js?v=20260813-outline-board-page-v1";
|
|
144
146
|
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions, filterBackgroundTaskTransitionsForAnnouncement } from "/background-task-center.js?v=20260817-analysis-task-expired-toast-v1";
|
|
145
147
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
148
|
+
import { mobileOfflineRuntime } from "/mobile-app-runtime.js?v=20260923-mobile-app-runtime-v6";
|
|
146
149
|
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
147
150
|
import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
|
|
148
151
|
import { createPresenceClientId, stagePresenceClientIdForRelogin } from "/presence-client-id.js?v=20260810-presence-relogin-v1";
|
|
@@ -772,6 +775,12 @@ function cancelActiveAiRequest(reason) {
|
|
|
772
775
|
|
|
773
776
|
function submitAiComposerPrompt() {
|
|
774
777
|
const tab = activeAiChatTab();
|
|
778
|
+
if ($("#ai-task").value === "chat" && aiPromptText().trim() === "/compact") {
|
|
779
|
+
if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
|
|
780
|
+
hideAiMentionMenu();
|
|
781
|
+
void compactAiConversation({ clearComposer: true });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
775
784
|
if (aiRequestManager.hasActive(tab?.id)) {
|
|
776
785
|
if (aiComposerSendMode() === "steer") {
|
|
777
786
|
void sendActiveComposerAsSteer();
|
|
@@ -4999,6 +5008,16 @@ function applyAiConversationTitle(title, conversationId = state.aiConversationId
|
|
|
4999
5008
|
renderAiChatTabs();
|
|
5000
5009
|
}
|
|
5001
5010
|
|
|
5011
|
+
function refreshAiConversationTitleAfterGeneration(conversationId, workId) {
|
|
5012
|
+
void api(`/api/ai-conversations/${encodeURIComponent(conversationId)}/title`)
|
|
5013
|
+
.then((conversation) => {
|
|
5014
|
+
if (String(state.work?.id ?? "") !== workId) return;
|
|
5015
|
+
upsertAiConversationSummary(conversation);
|
|
5016
|
+
applyAiConversationTitle(conversation.title, conversationId);
|
|
5017
|
+
})
|
|
5018
|
+
.catch(() => undefined);
|
|
5019
|
+
}
|
|
5020
|
+
|
|
5002
5021
|
function applyAiConversations(pageResult) {
|
|
5003
5022
|
state.aiConversations = pageResult.items;
|
|
5004
5023
|
aiConversationHistoryPage = {
|
|
@@ -5956,13 +5975,13 @@ function updateAiMentionMenu() {
|
|
|
5956
5975
|
aiMentionRange = selection.getRangeAt(0).cloneRange();
|
|
5957
5976
|
const menu = $("#ai-mention-menu");
|
|
5958
5977
|
if (skillMatch) {
|
|
5959
|
-
const options =
|
|
5978
|
+
const options = listAiSlashOptions(skillMatch.query);
|
|
5960
5979
|
aiMentionActiveIndex = -1;
|
|
5961
5980
|
prompt.removeAttribute("aria-activedescendant");
|
|
5962
|
-
menu.setAttribute("aria-label", "
|
|
5981
|
+
menu.setAttribute("aria-label", "选择 Chat Slash 操作");
|
|
5963
5982
|
menu.innerHTML = options.length
|
|
5964
|
-
? 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
|
|
5965
|
-
: '<p class="ai-mention-empty"
|
|
5983
|
+
? 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("")
|
|
5984
|
+
: '<p class="ai-mention-empty">没有匹配的 Chat Slash 操作</p>';
|
|
5966
5985
|
menu.classList.remove("hidden");
|
|
5967
5986
|
prompt.setAttribute("aria-expanded", "true");
|
|
5968
5987
|
return;
|
|
@@ -6040,6 +6059,15 @@ function selectAiSkill(button) {
|
|
|
6040
6059
|
hideAiMentionMenu();
|
|
6041
6060
|
}
|
|
6042
6061
|
|
|
6062
|
+
function selectAiSlashCommand(button) {
|
|
6063
|
+
if (button.dataset.aiCommandName === "compact") {
|
|
6064
|
+
hideAiMentionMenu();
|
|
6065
|
+
void compactAiConversation({ clearComposer: true });
|
|
6066
|
+
return;
|
|
6067
|
+
}
|
|
6068
|
+
selectAiSkill(button);
|
|
6069
|
+
}
|
|
6070
|
+
|
|
6043
6071
|
function addSelectedLinesAsCitation() {
|
|
6044
6072
|
if (!state.chapter || !chapterLineSelection) return;
|
|
6045
6073
|
const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
|
|
@@ -6493,6 +6521,7 @@ function attachOptimisticVersion(path, method, body) {
|
|
|
6493
6521
|
async function api(path, options = {}) {
|
|
6494
6522
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
6495
6523
|
const body = options.skipOptimisticVersion ? options.body : attachOptimisticVersion(path, method, options.body);
|
|
6524
|
+
if (mobileOfflineRuntime.offline) return mobileOfflineRuntime.request(path, { method, body });
|
|
6496
6525
|
const headers = { ...(options.headers ?? {}) };
|
|
6497
6526
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
6498
6527
|
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
@@ -6504,11 +6533,19 @@ async function api(path, options = {}) {
|
|
|
6504
6533
|
body: body && typeof body !== "string" ? JSON.stringify(body) : body
|
|
6505
6534
|
});
|
|
6506
6535
|
} catch (error) {
|
|
6536
|
+
if (mobileOfflineRuntime.enabled) {
|
|
6537
|
+
const offlineSession = await mobileOfflineRuntime.activateOffline();
|
|
6538
|
+
if (offlineSession) return mobileOfflineRuntime.request(path, { method, body });
|
|
6539
|
+
}
|
|
6507
6540
|
if (!isAiRequestCancellation(error)) updateSystemHealth({ status: "offline" });
|
|
6508
6541
|
throw error;
|
|
6509
6542
|
}
|
|
6510
6543
|
updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
|
|
6511
6544
|
if (!response.ok) {
|
|
6545
|
+
if (response.status === 401 && mobileOfflineRuntime.enabled && !path.startsWith("/api/auth/")) {
|
|
6546
|
+
const offlineSession = await mobileOfflineRuntime.activateOffline();
|
|
6547
|
+
if (offlineSession) return mobileOfflineRuntime.request(path, { method, body });
|
|
6548
|
+
}
|
|
6512
6549
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
6513
6550
|
// Presence is best-effort; a heartbeat 401 must not force the login wall.
|
|
6514
6551
|
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
@@ -7029,6 +7066,7 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
|
|
|
7029
7066
|
function applyAuthenticatedUser(session) {
|
|
7030
7067
|
state.user = session.user;
|
|
7031
7068
|
state.csrfToken = session.csrfToken;
|
|
7069
|
+
mobileOfflineRuntime.setSession(session);
|
|
7032
7070
|
state.registrationMode = session.registrationMode === "invite" || session.registrationMode === "open"
|
|
7033
7071
|
? session.registrationMode
|
|
7034
7072
|
: "disabled";
|
|
@@ -7108,9 +7146,16 @@ async function loadPlatformUiSettings() {
|
|
|
7108
7146
|
|
|
7109
7147
|
async function initializeAuthentication() {
|
|
7110
7148
|
const route = parsePageRoute(window.location.hash);
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7149
|
+
let session;
|
|
7150
|
+
try {
|
|
7151
|
+
const response = await fetch("/api/auth/session", { headers: { Accept: "application/json" } });
|
|
7152
|
+
if (!response.ok) throw new Error("无法读取登录状态");
|
|
7153
|
+
session = (await response.json()).data;
|
|
7154
|
+
} catch (error) {
|
|
7155
|
+
const offlineSession = await mobileOfflineRuntime.getOfflineSession();
|
|
7156
|
+
if (!offlineSession) throw error;
|
|
7157
|
+
session = offlineSession;
|
|
7158
|
+
}
|
|
7114
7159
|
if (!session.authenticated) {
|
|
7115
7160
|
// 未登录时一律转到登录页路由;登录页本身则保持原样
|
|
7116
7161
|
if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
|
|
@@ -9446,7 +9491,7 @@ async function loadVolumeChapters(volumeId) {
|
|
|
9446
9491
|
const workId = state.work.id;
|
|
9447
9492
|
const generation = workScopedUiGeneration;
|
|
9448
9493
|
volumeChapterLoadingIds.add(volumeId);
|
|
9449
|
-
renderTree();
|
|
9494
|
+
renderTree([volumeId]);
|
|
9450
9495
|
const request = (async () => {
|
|
9451
9496
|
try {
|
|
9452
9497
|
const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
|
|
@@ -9456,17 +9501,15 @@ async function loadVolumeChapters(volumeId) {
|
|
|
9456
9501
|
volume.chapters = chapters;
|
|
9457
9502
|
volume.chapterCount = chapters.length;
|
|
9458
9503
|
loadedVolumeChapterIds.add(volumeId);
|
|
9459
|
-
renderTree();
|
|
9460
9504
|
} catch (error) {
|
|
9461
9505
|
if (state.work?.id === workId && generation === workScopedUiGeneration) {
|
|
9462
9506
|
toast(`加载分卷章节失败:${error.message}`, "error");
|
|
9463
|
-
renderTree();
|
|
9464
9507
|
}
|
|
9465
9508
|
} finally {
|
|
9466
9509
|
if (volumeChapterRequests.get(volumeId) === request) {
|
|
9467
9510
|
volumeChapterLoadingIds.delete(volumeId);
|
|
9468
9511
|
volumeChapterRequests.delete(volumeId);
|
|
9469
|
-
if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
|
|
9512
|
+
if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree([volumeId]);
|
|
9470
9513
|
}
|
|
9471
9514
|
}
|
|
9472
9515
|
})();
|
|
@@ -9490,20 +9533,7 @@ function mergeChapterDirectoryEntry(chapter) {
|
|
|
9490
9533
|
if (!state.work || !chapter?.volumeId) return;
|
|
9491
9534
|
const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
|
|
9492
9535
|
if (!volume) return;
|
|
9493
|
-
const directoryEntry =
|
|
9494
|
-
id: chapter.id,
|
|
9495
|
-
workId: chapter.workId,
|
|
9496
|
-
volumeId: chapter.volumeId,
|
|
9497
|
-
title: chapter.title,
|
|
9498
|
-
chapterType: chapter.chapterType,
|
|
9499
|
-
sortOrder: chapter.sortOrder,
|
|
9500
|
-
wordCount: chapter.wordCount,
|
|
9501
|
-
versionNo: chapter.versionNo,
|
|
9502
|
-
analysisStatus: chapter.analysisStatus,
|
|
9503
|
-
excludedFromAnalysis: chapter.excludedFromAnalysis,
|
|
9504
|
-
createdAt: chapter.createdAt,
|
|
9505
|
-
updatedAt: chapter.updatedAt
|
|
9506
|
-
};
|
|
9536
|
+
const directoryEntry = chapterDirectoryEntry(chapter);
|
|
9507
9537
|
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
9508
9538
|
const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
|
|
9509
9539
|
if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
|
|
@@ -9513,14 +9543,61 @@ function mergeChapterDirectoryEntry(chapter) {
|
|
|
9513
9543
|
volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
|
|
9514
9544
|
}
|
|
9515
9545
|
|
|
9516
|
-
function
|
|
9546
|
+
function syncChapterTreeSelection() {
|
|
9547
|
+
const tree = $("#novel-tree");
|
|
9548
|
+
tree.querySelectorAll(".chapter-node.active").forEach((node) => node.classList.remove("active"));
|
|
9549
|
+
const chapter = state.chapter;
|
|
9550
|
+
if (!chapter) return;
|
|
9551
|
+
let button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
|
|
9552
|
+
if (!button || button.closest(".volume-node")?.classList.contains("is-collapsed")) {
|
|
9553
|
+
renderTree([chapter.volumeId]);
|
|
9554
|
+
button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
|
|
9555
|
+
}
|
|
9556
|
+
if (!button) return;
|
|
9557
|
+
button.classList.add("active");
|
|
9558
|
+
button.firstElementChild.textContent = chapter.title;
|
|
9559
|
+
button.querySelector("small").textContent = Number(chapter.wordCount ?? 0).toLocaleString("zh-CN");
|
|
9560
|
+
const badge = button.querySelector(".chapter-type-badge")?.textContent ?? "";
|
|
9561
|
+
const chapterType = chapter.chapterType && chapter.chapterType !== "正文" ? chapter.chapterType : "";
|
|
9562
|
+
if (badge !== chapterType) {
|
|
9563
|
+
button.querySelector(".chapter-node-meta").innerHTML = `${chapterType ? `<em class="chapter-type-badge">${esc(chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small>`;
|
|
9564
|
+
}
|
|
9565
|
+
}
|
|
9566
|
+
|
|
9567
|
+
function syncMovedChapterTree(chapterId, volumeIds) {
|
|
9568
|
+
const tree = $("#novel-tree");
|
|
9569
|
+
const location = findChapterLocation(chapterId);
|
|
9570
|
+
const button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`);
|
|
9571
|
+
const target = location && tree.querySelector(`[data-volume-id="${CSS.escape(location.volume.id)}"] .volume-chapters`);
|
|
9572
|
+
if (!button || !target) return renderTree(volumeIds);
|
|
9573
|
+
if (state.collapsedVolumeIds.has(location.volume.id)) button.remove();
|
|
9574
|
+
else {
|
|
9575
|
+
target.querySelector(".entity-history-empty")?.remove();
|
|
9576
|
+
const nextChapter = location.volume.chapters[location.chapterIndex + 1];
|
|
9577
|
+
const nextButton = nextChapter ? target.querySelector(`[data-chapter-id="${CSS.escape(nextChapter.id)}"]`) : null;
|
|
9578
|
+
target.insertBefore(button, nextButton);
|
|
9579
|
+
}
|
|
9580
|
+
for (const volumeId of volumeIds) {
|
|
9581
|
+
const volume = state.work.volumes.find((item) => item.id === volumeId);
|
|
9582
|
+
const node = tree.querySelector(`[data-volume-id="${CSS.escape(volumeId)}"]`);
|
|
9583
|
+
if (!volume || !node) continue;
|
|
9584
|
+
node.querySelector(".volume-chapter-count-number").textContent = String(volume.chapterCount);
|
|
9585
|
+
if (!volume.chapters.length && !state.collapsedVolumeIds.has(volumeId)) {
|
|
9586
|
+
node.querySelector(".volume-chapters").innerHTML = '<p class="entity-history-empty">本卷还没有章节。</p>';
|
|
9587
|
+
}
|
|
9588
|
+
}
|
|
9589
|
+
}
|
|
9590
|
+
|
|
9591
|
+
function renderTree(volumeIds = null) {
|
|
9517
9592
|
if (!state.work) return;
|
|
9518
9593
|
const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
|
|
9519
9594
|
const proseEditable = canEditProse();
|
|
9520
9595
|
$("#chapter-count").querySelector(".chapter-count-number").textContent = String(count);
|
|
9521
9596
|
$("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
|
|
9522
9597
|
$("#novel-tree").classList.remove("empty-copy");
|
|
9523
|
-
$("#novel-tree")
|
|
9598
|
+
const tree = $("#novel-tree");
|
|
9599
|
+
const volumes = volumeIds ? state.work.volumes.filter((volume) => volumeIds.includes(volume.id)) : state.work.volumes;
|
|
9600
|
+
const markup = volumes.map((volume) => {
|
|
9524
9601
|
const collapsed = state.collapsedVolumeIds.has(volume.id);
|
|
9525
9602
|
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
9526
9603
|
const chapterContent = collapsed
|
|
@@ -9531,8 +9608,9 @@ function renderTree() {
|
|
|
9531
9608
|
? '<p class="entity-history-empty">展开后加载章节。</p>'
|
|
9532
9609
|
: chapters.length
|
|
9533
9610
|
? chapters.map((chapter) => `
|
|
9534
|
-
<button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="
|
|
9611
|
+
<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+方向键跨卷" : ""}">
|
|
9535
9612
|
<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>
|
|
9613
|
+
${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>' : ""}
|
|
9536
9614
|
</button>`).join("")
|
|
9537
9615
|
: '<p class="entity-history-empty">本卷还没有章节。</p>';
|
|
9538
9616
|
return `
|
|
@@ -9546,15 +9624,28 @@ function renderTree() {
|
|
|
9546
9624
|
${chapterContent}
|
|
9547
9625
|
</div>
|
|
9548
9626
|
</div>`;
|
|
9549
|
-
})
|
|
9550
|
-
|
|
9627
|
+
});
|
|
9628
|
+
const roots = [];
|
|
9629
|
+
if (volumeIds) {
|
|
9630
|
+
volumes.forEach((volume, index) => {
|
|
9631
|
+
const node = tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`);
|
|
9632
|
+
if (!node) return;
|
|
9633
|
+
node.outerHTML = markup[index];
|
|
9634
|
+
roots.push(tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`));
|
|
9635
|
+
});
|
|
9636
|
+
} else {
|
|
9637
|
+
tree.innerHTML = markup.join("");
|
|
9638
|
+
roots.push(tree);
|
|
9639
|
+
}
|
|
9640
|
+
const renderedNodes = (selector) => roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
|
|
9641
|
+
renderedNodes("[data-volume-toggle]").forEach((button) => {
|
|
9551
9642
|
button.addEventListener("click", () => {
|
|
9552
9643
|
const volumeId = button.dataset.volumeToggle;
|
|
9553
9644
|
if (state.collapsedVolumeIds.has(volumeId)) {
|
|
9554
9645
|
state.collapsedVolumeIds.delete(volumeId);
|
|
9555
9646
|
void loadVolumeChapters(volumeId);
|
|
9556
9647
|
} else state.collapsedVolumeIds.add(volumeId);
|
|
9557
|
-
renderTree();
|
|
9648
|
+
renderTree([volumeId]);
|
|
9558
9649
|
});
|
|
9559
9650
|
button.addEventListener("contextmenu", (event) => {
|
|
9560
9651
|
if (!canEditProse()) return;
|
|
@@ -9582,18 +9673,19 @@ function renderTree() {
|
|
|
9582
9673
|
});
|
|
9583
9674
|
}
|
|
9584
9675
|
});
|
|
9585
|
-
|
|
9676
|
+
renderedNodes("[data-volume-detail]").forEach((button) => {
|
|
9586
9677
|
button.addEventListener("click", () => {
|
|
9587
9678
|
openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeDetail));
|
|
9588
9679
|
});
|
|
9589
9680
|
});
|
|
9590
|
-
|
|
9681
|
+
renderedNodes("[data-new-chapter-volume]").forEach((button) => {
|
|
9591
9682
|
button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
|
|
9592
9683
|
});
|
|
9593
|
-
|
|
9594
|
-
button.addEventListener("click", async () => {
|
|
9684
|
+
renderedNodes("[data-chapter-id]").forEach((button) => {
|
|
9685
|
+
button.addEventListener("click", async (event) => {
|
|
9686
|
+
if (event.target.closest(".chapter-drag-handle")) return;
|
|
9595
9687
|
const chapterId = button.dataset.chapterId;
|
|
9596
|
-
await selectChapter(chapterId);
|
|
9688
|
+
if (!(await selectChapter(chapterId))) return;
|
|
9597
9689
|
$("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
|
|
9598
9690
|
if (isMobileViewport()) {
|
|
9599
9691
|
panelLayout.leftCollapsed = true;
|
|
@@ -9614,6 +9706,10 @@ function renderTree() {
|
|
|
9614
9706
|
});
|
|
9615
9707
|
if (proseEditable) {
|
|
9616
9708
|
button.addEventListener("dragstart", (event) => {
|
|
9709
|
+
if (!canEditProse() || !event.target.closest(".chapter-drag-handle")) {
|
|
9710
|
+
event.preventDefault();
|
|
9711
|
+
return;
|
|
9712
|
+
}
|
|
9617
9713
|
event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
|
|
9618
9714
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
9619
9715
|
button.classList.add("is-dragging");
|
|
@@ -9636,7 +9732,7 @@ function renderTree() {
|
|
|
9636
9732
|
const target = findChapterLocation(button.dataset.chapterId);
|
|
9637
9733
|
const after = button.classList.contains("drop-after");
|
|
9638
9734
|
button.classList.remove("is-drag-over", "drop-after");
|
|
9639
|
-
if (!chapterId || !target) return;
|
|
9735
|
+
if (!chapterId || !target || chapterId === button.dataset.chapterId) return;
|
|
9640
9736
|
const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
|
|
9641
9737
|
const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
|
|
9642
9738
|
await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
|
|
@@ -9789,22 +9885,36 @@ function findChapterLocation(chapterId) {
|
|
|
9789
9885
|
|
|
9790
9886
|
async function moveChapterInTree(chapterId, volumeId, sortOrder) {
|
|
9791
9887
|
const location = findChapterLocation(chapterId);
|
|
9792
|
-
if (!location || chapterMovePending) return;
|
|
9888
|
+
if (!location || chapterMovePending || !canEditProse()) return;
|
|
9793
9889
|
const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
|
|
9794
9890
|
if (samePosition) return;
|
|
9891
|
+
const workId = state.work.id;
|
|
9892
|
+
const generation = workScopedUiGeneration;
|
|
9893
|
+
const volumeIds = [...new Set([location.volume.id, volumeId])];
|
|
9795
9894
|
chapterMovePending = true;
|
|
9796
9895
|
try {
|
|
9896
|
+
await Promise.all(volumeIds.map((id) => loadVolumeChapters(id)));
|
|
9897
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
9898
|
+
if (volumeIds.some((id) => !loadedVolumeChapterIds.has(id))) return;
|
|
9797
9899
|
const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
|
|
9798
9900
|
method: "POST",
|
|
9799
9901
|
body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
|
|
9800
9902
|
});
|
|
9801
|
-
state.work
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9903
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
9904
|
+
const affectedVolumeIds = applyChapterDirectoryMove(state.work, moved);
|
|
9905
|
+
const selectedEntry = state.chapter && findChapterLocation(state.chapter.id)?.chapter;
|
|
9906
|
+
if (selectedEntry) {
|
|
9907
|
+
state.chapter = { ...state.chapter, ...selectedEntry };
|
|
9908
|
+
updateChapterPath();
|
|
9909
|
+
updateChapterStats();
|
|
9910
|
+
}
|
|
9911
|
+
syncMovedChapterTree(chapterId, affectedVolumeIds);
|
|
9912
|
+
const focusTarget = $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)
|
|
9913
|
+
?? $("#novel-tree").querySelector(`[data-volume-toggle="${CSS.escape(volumeId)}"]`);
|
|
9914
|
+
focusTarget?.focus();
|
|
9805
9915
|
toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
|
|
9806
9916
|
} catch (error) {
|
|
9807
|
-
toast(error.message, "error");
|
|
9917
|
+
if (state.work?.id === workId && generation === workScopedUiGeneration) toast(error.message, "error");
|
|
9808
9918
|
} finally {
|
|
9809
9919
|
chapterMovePending = false;
|
|
9810
9920
|
}
|
|
@@ -9816,7 +9926,7 @@ async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
|
|
|
9816
9926
|
if (crossVolume) {
|
|
9817
9927
|
const targetVolume = state.work.volumes[location.volumeIndex + direction];
|
|
9818
9928
|
if (!targetVolume) return toast("已经是最前或最后一个分卷");
|
|
9819
|
-
await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
|
|
9929
|
+
await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? Number(targetVolume.chapterCount ?? targetVolume.chapters.length) : 0);
|
|
9820
9930
|
return;
|
|
9821
9931
|
}
|
|
9822
9932
|
const targetIndex = location.chapterIndex + direction;
|
|
@@ -10131,10 +10241,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
10131
10241
|
$("#welcome-view").classList.add("hidden");
|
|
10132
10242
|
$("#module-view").classList.add("hidden");
|
|
10133
10243
|
$("#editor-view").classList.remove("hidden");
|
|
10134
|
-
|
|
10135
|
-
const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
|
|
10136
|
-
$("#chapter-path").textContent = chapterPath;
|
|
10137
|
-
$("#chapter-path").title = chapterPath;
|
|
10244
|
+
updateChapterPath();
|
|
10138
10245
|
$("#chapter-title").value = state.chapter.title;
|
|
10139
10246
|
$("#chapter-content").value = state.chapter.content;
|
|
10140
10247
|
resetChapterDraftLineIds(state.chapter);
|
|
@@ -10149,12 +10256,20 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
|
10149
10256
|
if (!canEditProse()) setSaveState("正文只读");
|
|
10150
10257
|
else if (chapterEditorReadOnly) setSaveState("阅读模式");
|
|
10151
10258
|
else setSaveState("已保存");
|
|
10152
|
-
|
|
10259
|
+
syncChapterTreeSelection();
|
|
10153
10260
|
replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
|
|
10154
|
-
|
|
10261
|
+
void loadChapterForeshadowReminders();
|
|
10155
10262
|
return true;
|
|
10156
10263
|
}
|
|
10157
10264
|
|
|
10265
|
+
function updateChapterPath() {
|
|
10266
|
+
if (!state.work || !state.chapter) return;
|
|
10267
|
+
const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
|
|
10268
|
+
const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
|
|
10269
|
+
$("#chapter-path").textContent = chapterPath;
|
|
10270
|
+
$("#chapter-path").title = chapterPath;
|
|
10271
|
+
}
|
|
10272
|
+
|
|
10158
10273
|
function updateChapterStats() {
|
|
10159
10274
|
if (!state.chapter) return;
|
|
10160
10275
|
const text = $("#chapter-content").value;
|
|
@@ -15486,6 +15601,43 @@ function setAiContextWarningActionsDisabled(disabled) {
|
|
|
15486
15601
|
for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
|
|
15487
15602
|
}
|
|
15488
15603
|
|
|
15604
|
+
async function compactAiConversation({ clearComposer = false, button = null } = {}) {
|
|
15605
|
+
if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
|
|
15606
|
+
const requestScope = currentAiRequestScope();
|
|
15607
|
+
const modelId = $("#ai-model").value;
|
|
15608
|
+
if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
|
|
15609
|
+
const previousLabel = button?.textContent ?? "";
|
|
15610
|
+
setAiContextWarningActionsDisabled(true);
|
|
15611
|
+
if (button) button.textContent = "压缩中";
|
|
15612
|
+
try {
|
|
15613
|
+
const conversationId = await ensureAiConversation();
|
|
15614
|
+
const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
|
|
15615
|
+
method: "POST",
|
|
15616
|
+
body: { modelId, scope: requestScope.scope }
|
|
15617
|
+
});
|
|
15618
|
+
hideAiContextWarning();
|
|
15619
|
+
if (clearComposer) clearAiPromptComposer();
|
|
15620
|
+
$("#ai-prompt").focus();
|
|
15621
|
+
toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
|
|
15622
|
+
setAiContextMeter(result.usage);
|
|
15623
|
+
if (result.changed) {
|
|
15624
|
+
const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
|
|
15625
|
+
if (current) {
|
|
15626
|
+
current.compactedMessageCount = result.compactedMessageCount;
|
|
15627
|
+
current.hasCompactedSummary = true;
|
|
15628
|
+
}
|
|
15629
|
+
renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
|
|
15630
|
+
}
|
|
15631
|
+
return result;
|
|
15632
|
+
} catch (error) {
|
|
15633
|
+
toast(`上下文压缩失败:${error.message}`, "error");
|
|
15634
|
+
return null;
|
|
15635
|
+
} finally {
|
|
15636
|
+
setAiContextWarningActionsDisabled(false);
|
|
15637
|
+
if (button) button.textContent = previousLabel;
|
|
15638
|
+
}
|
|
15639
|
+
}
|
|
15640
|
+
|
|
15489
15641
|
async function loadAiReferences() {
|
|
15490
15642
|
const workId = state.work?.id;
|
|
15491
15643
|
if (!workId) return;
|
|
@@ -18945,6 +19097,23 @@ async function sendAi() {
|
|
|
18945
19097
|
return sendAiWithOptions();
|
|
18946
19098
|
}
|
|
18947
19099
|
|
|
19100
|
+
function appendAiPendingRequestMessage(tab) {
|
|
19101
|
+
const message = document.createElement("div");
|
|
19102
|
+
message.className = "assistant-message is-streaming";
|
|
19103
|
+
message.dataset.testid = "ai-stream-message";
|
|
19104
|
+
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在请求……</div>';
|
|
19105
|
+
attachMessageHeading(message, aiAssistantLabel("正在请求", tab.roleplayCharacter), undefined, tab);
|
|
19106
|
+
tab.feed.append(message);
|
|
19107
|
+
scrollAiFeedToBottom(tab.feed);
|
|
19108
|
+
return message;
|
|
19109
|
+
}
|
|
19110
|
+
|
|
19111
|
+
function prefixAiRequestError(error, prefix) {
|
|
19112
|
+
const failure = error instanceof Error ? error : new Error(String(error ?? "未知错误"));
|
|
19113
|
+
failure.message = `${prefix}:${failure.message}`;
|
|
19114
|
+
return failure;
|
|
19115
|
+
}
|
|
19116
|
+
|
|
18948
19117
|
async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
|
|
18949
19118
|
if (!state.work) return toast("请先选择作品", "error");
|
|
18950
19119
|
const tab = activeAiChatTab();
|
|
@@ -18994,7 +19163,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
18994
19163
|
workId: state.work.id,
|
|
18995
19164
|
conversationId: state.aiConversationId
|
|
18996
19165
|
}),
|
|
18997
|
-
preserveComposer: Boolean(queuedComposer)
|
|
19166
|
+
preserveComposer: Boolean(queuedComposer),
|
|
19167
|
+
optimisticUserMessage: null,
|
|
19168
|
+
pendingAssistantMessage: null,
|
|
19169
|
+
requestStartedAt: Date.now()
|
|
18998
19170
|
};
|
|
18999
19171
|
if (retry?.userMessageId) {
|
|
19000
19172
|
requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
|
|
@@ -19002,6 +19174,33 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19002
19174
|
setAiChatTabStatus(tab, "streaming");
|
|
19003
19175
|
syncAiRequestControls();
|
|
19004
19176
|
scrollAiFeedToBottom(tab.feed, { force: true });
|
|
19177
|
+
if (!retry) {
|
|
19178
|
+
const optimisticContent = selectedTaskType === "roleplay"
|
|
19179
|
+
? composeRoleplayStoredUserContent(sceneDirection, instruction)
|
|
19180
|
+
: instruction;
|
|
19181
|
+
requestHolder.optimisticUserMessage = appendMessage(
|
|
19182
|
+
"user",
|
|
19183
|
+
optimisticContent,
|
|
19184
|
+
citations,
|
|
19185
|
+
null,
|
|
19186
|
+
{
|
|
19187
|
+
...(requestScope.scope.characterIds?.length ? { mentionCharacterIds: requestScope.scope.characterIds } : {}),
|
|
19188
|
+
...(requestScope.scope.settingIds?.length ? { mentionSettingIds: requestScope.scope.settingIds } : {}),
|
|
19189
|
+
...(requestScope.scope.chapterIds?.length ? { mentionChapterIds: requestScope.scope.chapterIds } : {}),
|
|
19190
|
+
...(requestScope.scope.includeSettingInfo === true ? { mentionContextSettingIds: ["include-setting-info"] } : {}),
|
|
19191
|
+
...(requestComposerSnapshot.images.length ? { chatImageAttachmentIds: aiChatImageAttachmentIds(requestComposerSnapshot.images) } : {})
|
|
19192
|
+
},
|
|
19193
|
+
null,
|
|
19194
|
+
{ tab }
|
|
19195
|
+
);
|
|
19196
|
+
requestHolder.optimisticUserMessage.dataset.status = "pending";
|
|
19197
|
+
requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息正在发送");
|
|
19198
|
+
if (!requestHolder.preserveComposer) {
|
|
19199
|
+
clearAiChatTabComposer(tab);
|
|
19200
|
+
if (isActiveAiChatTab(tab)) clearAiPromptComposer({ collapseScenePanel: Boolean(sceneDirection) });
|
|
19201
|
+
}
|
|
19202
|
+
requestHolder.pendingAssistantMessage = appendAiPendingRequestMessage(tab);
|
|
19203
|
+
}
|
|
19005
19204
|
let shouldFlushQueuedPrompt = false;
|
|
19006
19205
|
try {
|
|
19007
19206
|
try {
|
|
@@ -19010,21 +19209,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19010
19209
|
} catch (error) {
|
|
19011
19210
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
19012
19211
|
setAiChatTabStatus(tab, "error");
|
|
19013
|
-
|
|
19212
|
+
const failure = prefixAiRequestError(error, "创作助手加载失败");
|
|
19213
|
+
if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
|
|
19214
|
+
throw failure;
|
|
19014
19215
|
}
|
|
19015
19216
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
19016
19217
|
const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
|
|
19017
|
-
if (!modelId)
|
|
19218
|
+
if (!modelId) {
|
|
19219
|
+
const error = new Error("请先在 AI 管理中配置并选择模型");
|
|
19220
|
+
if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
|
|
19221
|
+
throw error;
|
|
19222
|
+
}
|
|
19018
19223
|
tab.selectedModelId = modelId;
|
|
19019
19224
|
const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
|
|
19020
19225
|
if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
|
|
19021
|
-
|
|
19226
|
+
const error = new Error("当前选择的模型不是多模态模型,无法发送图片附件");
|
|
19227
|
+
if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
|
|
19228
|
+
throw error;
|
|
19022
19229
|
}
|
|
19023
19230
|
try {
|
|
19024
19231
|
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
19025
19232
|
} catch (error) {
|
|
19026
19233
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
19027
|
-
|
|
19234
|
+
const failure = prefixAiRequestError(error, "对话配置锁定失败");
|
|
19235
|
+
if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
|
|
19236
|
+
throw failure;
|
|
19028
19237
|
}
|
|
19029
19238
|
setAiChatTabStatus(tab, "streaming");
|
|
19030
19239
|
if (retry?.message?.isConnected) retry.message.remove();
|
|
@@ -19045,13 +19254,24 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19045
19254
|
...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
|
|
19046
19255
|
}, retry), createAiIdempotencyKey());
|
|
19047
19256
|
const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
|
|
19048
|
-
if (streamed.action === "warn")
|
|
19257
|
+
if (streamed.action === "warn") {
|
|
19258
|
+
requestHolder.optimisticUserMessage?.remove();
|
|
19259
|
+
setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
|
|
19260
|
+
if (isActiveAiChatTab(tab) && !requestHolder.preserveComposer) restoreAiPromptComposer(requestComposerSnapshot);
|
|
19261
|
+
return;
|
|
19262
|
+
}
|
|
19049
19263
|
assistantContent = streamed.content;
|
|
19050
19264
|
shouldFlushQueuedPrompt = true;
|
|
19051
19265
|
assistantMessage = streamed.message;
|
|
19052
19266
|
assistantMetadata = streamed.metadata;
|
|
19053
19267
|
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
19054
19268
|
applyAiConversationTitle(streamed.conversationTitle, streamedRequest.conversationId);
|
|
19269
|
+
if (streamed.conversationTitleGenerationStarted) {
|
|
19270
|
+
refreshAiConversationTitleAfterGeneration(
|
|
19271
|
+
streamedRequest.conversationId,
|
|
19272
|
+
String(state.work?.id ?? "")
|
|
19273
|
+
);
|
|
19274
|
+
}
|
|
19055
19275
|
try {
|
|
19056
19276
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
19057
19277
|
if (persistedStreamMessage) {
|
|
@@ -19086,6 +19306,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19086
19306
|
}
|
|
19087
19307
|
} catch (error) {
|
|
19088
19308
|
const request = requestHolder.snapshot;
|
|
19309
|
+
if (!error?.streamInterruption) requestHolder.pendingAssistantMessage?.remove();
|
|
19089
19310
|
if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
|
|
19090
19311
|
const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
|
|
19091
19312
|
const userStoppedCurrentReply = request.signal.reason instanceof Error
|
|
@@ -19104,6 +19325,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19104
19325
|
return;
|
|
19105
19326
|
}
|
|
19106
19327
|
if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
|
|
19328
|
+
requestHolder.optimisticUserMessage?.remove();
|
|
19107
19329
|
setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
|
|
19108
19330
|
if (isActiveAiChatTab(tab)) {
|
|
19109
19331
|
if (retry) restoreAiPromptComposer(requestComposerSnapshot);
|
|
@@ -19123,6 +19345,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
19123
19345
|
return;
|
|
19124
19346
|
}
|
|
19125
19347
|
setAiChatTabStatus(tab, "error");
|
|
19348
|
+
if (requestHolder.optimisticUserMessage?.dataset.status === "pending") {
|
|
19349
|
+
requestHolder.optimisticUserMessage.dataset.status = "failed";
|
|
19350
|
+
requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息发送失败");
|
|
19351
|
+
}
|
|
19126
19352
|
const interruption = error?.streamInterruption;
|
|
19127
19353
|
if (interruption?.content) {
|
|
19128
19354
|
try {
|
|
@@ -19209,14 +19435,21 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19209
19435
|
const tab = aiChatTabForRequest(requestHolder.snapshot);
|
|
19210
19436
|
if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
|
|
19211
19437
|
const feed = tab.feed;
|
|
19212
|
-
let message =
|
|
19438
|
+
let message = requestHolder.pendingAssistantMessage?.isConnected
|
|
19439
|
+
? requestHolder.pendingAssistantMessage
|
|
19440
|
+
: document.createElement("div");
|
|
19213
19441
|
message.className = "assistant-message is-streaming";
|
|
19214
19442
|
message.dataset.testid = "ai-stream-message";
|
|
19215
|
-
const streamConnectionStartedAt = Date.now();
|
|
19216
|
-
message.
|
|
19443
|
+
const streamConnectionStartedAt = requestHolder.requestStartedAt ?? Date.now();
|
|
19444
|
+
if (!message.querySelector(".message-body")) {
|
|
19445
|
+
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta"></div>';
|
|
19446
|
+
}
|
|
19217
19447
|
let content = message.querySelector(".message-body");
|
|
19218
19448
|
let meta = message.querySelector(".message-meta");
|
|
19219
|
-
const connectionSeconds =
|
|
19449
|
+
const connectionSeconds = document.createElement("span");
|
|
19450
|
+
connectionSeconds.className = "ai-stream-connection-seconds";
|
|
19451
|
+
connectionSeconds.dataset.testid = "ai-stream-connection-seconds";
|
|
19452
|
+
meta.replaceChildren("正在请求…… ", connectionSeconds, " 秒");
|
|
19220
19453
|
const renderStreamConnectionElapsed = () => {
|
|
19221
19454
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
|
|
19222
19455
|
connectionSeconds.textContent = String(elapsedSeconds);
|
|
@@ -19230,11 +19463,12 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19230
19463
|
};
|
|
19231
19464
|
const streamConnectionEstablishedEvents = new Set(["continuation", "delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
|
|
19232
19465
|
const streamSpeedController = createStreamTypewriterSpeedController();
|
|
19233
|
-
let
|
|
19466
|
+
let streamConnectionEstablished = false;
|
|
19467
|
+
let messageMounted = message.isConnected;
|
|
19234
19468
|
const mountAssistantMessage = () => {
|
|
19235
19469
|
if (messageMounted) return true;
|
|
19236
19470
|
if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
|
|
19237
|
-
attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
|
|
19471
|
+
attachMessageHeading(message, aiAssistantLabel(streamConnectionEstablished ? "正在生成" : "正在请求", tab.roleplayCharacter), undefined, tab);
|
|
19238
19472
|
feed.append(message);
|
|
19239
19473
|
messageMounted = true;
|
|
19240
19474
|
scrollAiFeedToBottom(feed);
|
|
@@ -19262,6 +19496,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19262
19496
|
let persistedMessageId = null;
|
|
19263
19497
|
let persistedMessageCreatedAt = null;
|
|
19264
19498
|
let conversationTitle = null;
|
|
19499
|
+
let conversationTitleGenerationStarted = false;
|
|
19265
19500
|
let question = null;
|
|
19266
19501
|
let persistedUserMessage = null;
|
|
19267
19502
|
let contextAction = "ready";
|
|
@@ -19356,7 +19591,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19356
19591
|
let streamError = null;
|
|
19357
19592
|
const consume = async (eventName, payload) => {
|
|
19358
19593
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
19359
|
-
if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer")
|
|
19594
|
+
if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") {
|
|
19595
|
+
stopStreamConnectionTimer();
|
|
19596
|
+
streamConnectionEstablished = true;
|
|
19597
|
+
const headingRole = messageMounted ? message.querySelector(".message-heading > span") : null;
|
|
19598
|
+
if (headingRole && !["complete", "request_status", "error"].includes(eventName)) {
|
|
19599
|
+
headingRole.textContent = aiAssistantLabel("正在生成", tab.roleplayCharacter);
|
|
19600
|
+
}
|
|
19601
|
+
}
|
|
19360
19602
|
if (eventName === "continuation") {
|
|
19361
19603
|
if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
|
|
19362
19604
|
toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
|
|
@@ -19407,10 +19649,19 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19407
19649
|
tab.selectedModelId = lockedModelId;
|
|
19408
19650
|
tab.promptSent = true;
|
|
19409
19651
|
if (!requestHolder.preserveComposer) clearAiChatTabComposer(tab);
|
|
19410
|
-
|
|
19652
|
+
let existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
|
|
19411
19653
|
.find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
|
|
19412
|
-
if (
|
|
19413
|
-
|
|
19654
|
+
if (requestHolder.optimisticUserMessage?.isConnected) {
|
|
19655
|
+
if (existingUserMessage && existingUserMessage !== requestHolder.optimisticUserMessage) {
|
|
19656
|
+
requestHolder.optimisticUserMessage.remove();
|
|
19657
|
+
} else {
|
|
19658
|
+
const confirmedUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
|
|
19659
|
+
requestHolder.optimisticUserMessage.replaceWith(confirmedUserMessage);
|
|
19660
|
+
requestHolder.optimisticUserMessage = confirmedUserMessage;
|
|
19661
|
+
existingUserMessage = confirmedUserMessage;
|
|
19662
|
+
}
|
|
19663
|
+
} else if (!existingUserMessage) {
|
|
19664
|
+
existingUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
|
|
19414
19665
|
}
|
|
19415
19666
|
if (isActiveAiChatTab(tab)) {
|
|
19416
19667
|
state.aiConversationModelId = lockedModelId;
|
|
@@ -19484,6 +19735,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19484
19735
|
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
19485
19736
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
19486
19737
|
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
19738
|
+
conversationTitleGenerationStarted = payload.conversationTitleGenerationStarted === true;
|
|
19487
19739
|
const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
|
|
19488
19740
|
setAiChatTabContextUsage(tab, attachAiContextCacheHitPercent(payload.contextUsage, payload.cacheHitPercent), announcedCompaction);
|
|
19489
19741
|
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
@@ -19520,7 +19772,8 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
|
|
|
19520
19772
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
19521
19773
|
if (streamError) throw streamError;
|
|
19522
19774
|
assertAiStreamCompleted(streamCompleted);
|
|
19523
|
-
|
|
19775
|
+
if (warningOnly && messageMounted) message.remove();
|
|
19776
|
+
return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, conversationTitleGenerationStarted, userMessage: persistedUserMessage, question };
|
|
19524
19777
|
} catch (error) {
|
|
19525
19778
|
const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
|
|
19526
19779
|
const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
|
|
@@ -21683,8 +21936,8 @@ $("#ai-scope").addEventListener("change", (event) => {
|
|
|
21683
21936
|
setAiContextMeter(null);
|
|
21684
21937
|
});
|
|
21685
21938
|
$("#ai-mention-menu").addEventListener("click", (event) => {
|
|
21686
|
-
const
|
|
21687
|
-
if (
|
|
21939
|
+
const commandButton = event.target.closest("[data-ai-command-name]");
|
|
21940
|
+
if (commandButton) return selectAiSlashCommand(commandButton);
|
|
21688
21941
|
const button = event.target.closest("[data-ai-reference-id]");
|
|
21689
21942
|
if (button) selectAiMention(button);
|
|
21690
21943
|
});
|
|
@@ -21983,36 +22236,7 @@ $("#ai-new-conversation").addEventListener("click", async () => {
|
|
|
21983
22236
|
}
|
|
21984
22237
|
});
|
|
21985
22238
|
$("#ai-context-compact").addEventListener("click", async () => {
|
|
21986
|
-
|
|
21987
|
-
const modelId = $("#ai-model").value;
|
|
21988
|
-
if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
|
|
21989
|
-
const button = $("#ai-context-compact");
|
|
21990
|
-
setAiContextWarningActionsDisabled(true);
|
|
21991
|
-
button.textContent = "压缩中";
|
|
21992
|
-
try {
|
|
21993
|
-
const conversationId = await ensureAiConversation();
|
|
21994
|
-
const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
|
|
21995
|
-
method: "POST",
|
|
21996
|
-
body: { modelId, scope: requestScope.scope }
|
|
21997
|
-
});
|
|
21998
|
-
hideAiContextWarning();
|
|
21999
|
-
$("#ai-prompt").focus();
|
|
22000
|
-
toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
|
|
22001
|
-
setAiContextMeter(result.usage);
|
|
22002
|
-
if (result.changed) {
|
|
22003
|
-
const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
|
|
22004
|
-
if (current) {
|
|
22005
|
-
current.compactedMessageCount = result.compactedMessageCount;
|
|
22006
|
-
current.hasCompactedSummary = true;
|
|
22007
|
-
}
|
|
22008
|
-
renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
|
|
22009
|
-
}
|
|
22010
|
-
} catch (error) {
|
|
22011
|
-
toast(`上下文压缩失败:${error.message}`, "error");
|
|
22012
|
-
} finally {
|
|
22013
|
-
setAiContextWarningActionsDisabled(false);
|
|
22014
|
-
button.textContent = "压缩";
|
|
22015
|
-
}
|
|
22239
|
+
void compactAiConversation({ button: $("#ai-context-compact") });
|
|
22016
22240
|
});
|
|
22017
22241
|
$("#ai-context-new-conversation").addEventListener("click", async () => {
|
|
22018
22242
|
setAiContextWarningActionsDisabled(true);
|
|
@@ -22285,7 +22509,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
|
|
|
22285
22509
|
const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
|
|
22286
22510
|
if (activeOption) {
|
|
22287
22511
|
event.preventDefault();
|
|
22288
|
-
if (activeOption.dataset.
|
|
22512
|
+
if (activeOption.dataset.aiCommandName) selectAiSlashCommand(activeOption);
|
|
22289
22513
|
else selectAiMention(activeOption);
|
|
22290
22514
|
return;
|
|
22291
22515
|
}
|