@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.
@@ -1,3 +1,4 @@
1
+ import { applyChapterDirectoryMove, chapterDirectoryEntry } from "/chapter-directory.js?v=20260921-directory-performance-v1";
1
2
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
3
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
4
  import { countProseWords } from "/text-count.js?v=20260906-chapter-word-count-consistency-v1";
@@ -6,8 +7,9 @@ import { createStreamingMarkdownRenderer } from "/stream-markdown.js?v=20260912-
6
7
  import { createAiRenderScheduler } from "/ai-render-scheduler.js?v=20260912-stream-render-v2";
7
8
  import { createImWorkspace } from "/im.js?v=20260904-im-judge-outcomes-v106";
8
9
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
9
- import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
10
+ import { applyAiSkillCommand, findAiSkillCommand, listAiSlashOptions } from "/ai-skill-menu.js?v=20260921-ai-compact-slash-command-v2";
10
11
  import {
12
+ composeRoleplayStoredUserContent,
11
13
  emptyRoleplayScenePin,
12
14
  normalizeRoleplayScenePin,
13
15
  parseRoleplayUserTurn,
@@ -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 = listAiSkillOptions(skillMatch.query);
5978
+ const options = listAiSlashOptions(skillMatch.query);
5960
5979
  aiMentionActiveIndex = -1;
5961
5980
  prompt.removeAttribute("aria-activedescendant");
5962
- menu.setAttribute("aria-label", "选择写作 Skill");
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>Skill</small><span><strong>/${esc(item.name)}</strong><em>${esc(item.label)} · ${esc(item.description)}</em></span></button>`).join("")
5965
- : '<p class="ai-mention-empty">没有匹配的写作 Skill</p>';
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
- const response = await fetch("/api/auth/session", { headers: { Accept: "application/json" } });
7112
- if (!response.ok) throw new Error("无法读取登录状态");
7113
- const session = (await response.json()).data;
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 renderTree() {
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").innerHTML = state.work.volumes.map((volume) => {
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="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
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
- }).join("");
9550
- $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
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
- $("#novel-tree").querySelectorAll("[data-volume-detail]").forEach((button) => {
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
- $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
9681
+ renderedNodes("[data-new-chapter-volume]").forEach((button) => {
9591
9682
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
9592
9683
  });
9593
- $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
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 = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
9802
- if (state.chapter?.id === chapterId) state.chapter = { ...state.chapter, ...moved };
9803
- renderTree();
9804
- $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
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
- const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
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
- renderTree();
10259
+ syncChapterTreeSelection();
10153
10260
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
10154
- await loadChapterForeshadowReminders();
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
- return toast(`创作助手加载失败:${error.message}`, "error");
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) return toast("请先在 AI 管理中配置并选择模型", "error");
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
- return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
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
- return toast(`对话配置锁定失败:${error.message}`, "error");
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") return;
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 = document.createElement("div");
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.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流…… <span class="ai-stream-connection-seconds" data-testid="ai-stream-connection-seconds"></span> 秒</div>';
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 = message.querySelector(".ai-stream-connection-seconds");
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 messageMounted = false;
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") stopStreamConnectionTimer();
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
- const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19652
+ let existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19411
19653
  .find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
19412
- if (!existingUserMessage) {
19413
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
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
- return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage, question };
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 skillButton = event.target.closest("[data-ai-skill-name]");
21687
- if (skillButton) return selectAiSkill(skillButton);
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
- const requestScope = currentAiRequestScope();
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.aiSkillName) selectAiSkill(activeOption);
22512
+ if (activeOption.dataset.aiCommandName) selectAiSlashCommand(activeOption);
22289
22513
  else selectAiMention(activeOption);
22290
22514
  return;
22291
22515
  }