@musnows/scriverse 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { applyChapterDirectoryMove, chapterDirectoryEntry } from "/chapter-directory.js?v=20260921-directory-performance-v1";
1
2
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
3
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
4
  import { countProseWords } from "/text-count.js?v=20260906-chapter-word-count-consistency-v1";
@@ -6,8 +7,9 @@ import { createStreamingMarkdownRenderer } from "/stream-markdown.js?v=20260912-
6
7
  import { createAiRenderScheduler } from "/ai-render-scheduler.js?v=20260912-stream-render-v2";
7
8
  import { createImWorkspace } from "/im.js?v=20260904-im-judge-outcomes-v106";
8
9
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
9
- import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
10
+ import { applyAiSkillCommand, findAiSkillCommand, listAiSlashOptions } from "/ai-skill-menu.js?v=20260921-ai-compact-slash-command-v2";
10
11
  import {
12
+ composeRoleplayStoredUserContent,
11
13
  emptyRoleplayScenePin,
12
14
  normalizeRoleplayScenePin,
13
15
  parseRoleplayUserTurn,
@@ -772,6 +774,12 @@ function cancelActiveAiRequest(reason) {
772
774
 
773
775
  function submitAiComposerPrompt() {
774
776
  const tab = activeAiChatTab();
777
+ if ($("#ai-task").value === "chat" && aiPromptText().trim() === "/compact") {
778
+ if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
779
+ hideAiMentionMenu();
780
+ void compactAiConversation({ clearComposer: true });
781
+ return;
782
+ }
775
783
  if (aiRequestManager.hasActive(tab?.id)) {
776
784
  if (aiComposerSendMode() === "steer") {
777
785
  void sendActiveComposerAsSteer();
@@ -5956,13 +5964,13 @@ function updateAiMentionMenu() {
5956
5964
  aiMentionRange = selection.getRangeAt(0).cloneRange();
5957
5965
  const menu = $("#ai-mention-menu");
5958
5966
  if (skillMatch) {
5959
- const options = listAiSkillOptions(skillMatch.query);
5967
+ const options = listAiSlashOptions(skillMatch.query);
5960
5968
  aiMentionActiveIndex = -1;
5961
5969
  prompt.removeAttribute("aria-activedescendant");
5962
- menu.setAttribute("aria-label", "选择写作 Skill");
5970
+ menu.setAttribute("aria-label", "选择 Chat Slash 操作");
5963
5971
  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>';
5972
+ ? options.map((item, index) => `<button id="ai-skill-option-${index}" class="ai-mention-option ai-skill-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-command-name="${esc(item.name)}"${item.kind === "skill" ? ` data-ai-skill-name="${esc(item.name)}"` : ""}><small>${item.kind === "skill" ? "Skill" : "压缩"}</small><span><strong>/${esc(item.name)}</strong><em>${esc(item.label)} · ${esc(item.description)}</em></span></button>`).join("")
5973
+ : '<p class="ai-mention-empty">没有匹配的 Chat Slash 操作</p>';
5966
5974
  menu.classList.remove("hidden");
5967
5975
  prompt.setAttribute("aria-expanded", "true");
5968
5976
  return;
@@ -6040,6 +6048,15 @@ function selectAiSkill(button) {
6040
6048
  hideAiMentionMenu();
6041
6049
  }
6042
6050
 
6051
+ function selectAiSlashCommand(button) {
6052
+ if (button.dataset.aiCommandName === "compact") {
6053
+ hideAiMentionMenu();
6054
+ void compactAiConversation({ clearComposer: true });
6055
+ return;
6056
+ }
6057
+ selectAiSkill(button);
6058
+ }
6059
+
6043
6060
  function addSelectedLinesAsCitation() {
6044
6061
  if (!state.chapter || !chapterLineSelection) return;
6045
6062
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
@@ -9446,7 +9463,7 @@ async function loadVolumeChapters(volumeId) {
9446
9463
  const workId = state.work.id;
9447
9464
  const generation = workScopedUiGeneration;
9448
9465
  volumeChapterLoadingIds.add(volumeId);
9449
- renderTree();
9466
+ renderTree([volumeId]);
9450
9467
  const request = (async () => {
9451
9468
  try {
9452
9469
  const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
@@ -9456,17 +9473,15 @@ async function loadVolumeChapters(volumeId) {
9456
9473
  volume.chapters = chapters;
9457
9474
  volume.chapterCount = chapters.length;
9458
9475
  loadedVolumeChapterIds.add(volumeId);
9459
- renderTree();
9460
9476
  } catch (error) {
9461
9477
  if (state.work?.id === workId && generation === workScopedUiGeneration) {
9462
9478
  toast(`加载分卷章节失败:${error.message}`, "error");
9463
- renderTree();
9464
9479
  }
9465
9480
  } finally {
9466
9481
  if (volumeChapterRequests.get(volumeId) === request) {
9467
9482
  volumeChapterLoadingIds.delete(volumeId);
9468
9483
  volumeChapterRequests.delete(volumeId);
9469
- if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
9484
+ if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree([volumeId]);
9470
9485
  }
9471
9486
  }
9472
9487
  })();
@@ -9490,20 +9505,7 @@ function mergeChapterDirectoryEntry(chapter) {
9490
9505
  if (!state.work || !chapter?.volumeId) return;
9491
9506
  const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
9492
9507
  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
- };
9508
+ const directoryEntry = chapterDirectoryEntry(chapter);
9507
9509
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
9508
9510
  const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
9509
9511
  if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
@@ -9513,14 +9515,61 @@ function mergeChapterDirectoryEntry(chapter) {
9513
9515
  volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
9514
9516
  }
9515
9517
 
9516
- function renderTree() {
9518
+ function syncChapterTreeSelection() {
9519
+ const tree = $("#novel-tree");
9520
+ tree.querySelectorAll(".chapter-node.active").forEach((node) => node.classList.remove("active"));
9521
+ const chapter = state.chapter;
9522
+ if (!chapter) return;
9523
+ let button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
9524
+ if (!button || button.closest(".volume-node")?.classList.contains("is-collapsed")) {
9525
+ renderTree([chapter.volumeId]);
9526
+ button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapter.id)}"]`);
9527
+ }
9528
+ if (!button) return;
9529
+ button.classList.add("active");
9530
+ button.firstElementChild.textContent = chapter.title;
9531
+ button.querySelector("small").textContent = Number(chapter.wordCount ?? 0).toLocaleString("zh-CN");
9532
+ const badge = button.querySelector(".chapter-type-badge")?.textContent ?? "";
9533
+ const chapterType = chapter.chapterType && chapter.chapterType !== "正文" ? chapter.chapterType : "";
9534
+ if (badge !== chapterType) {
9535
+ button.querySelector(".chapter-node-meta").innerHTML = `${chapterType ? `<em class="chapter-type-badge">${esc(chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small>`;
9536
+ }
9537
+ }
9538
+
9539
+ function syncMovedChapterTree(chapterId, volumeIds) {
9540
+ const tree = $("#novel-tree");
9541
+ const location = findChapterLocation(chapterId);
9542
+ const button = tree.querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`);
9543
+ const target = location && tree.querySelector(`[data-volume-id="${CSS.escape(location.volume.id)}"] .volume-chapters`);
9544
+ if (!button || !target) return renderTree(volumeIds);
9545
+ if (state.collapsedVolumeIds.has(location.volume.id)) button.remove();
9546
+ else {
9547
+ target.querySelector(".entity-history-empty")?.remove();
9548
+ const nextChapter = location.volume.chapters[location.chapterIndex + 1];
9549
+ const nextButton = nextChapter ? target.querySelector(`[data-chapter-id="${CSS.escape(nextChapter.id)}"]`) : null;
9550
+ target.insertBefore(button, nextButton);
9551
+ }
9552
+ for (const volumeId of volumeIds) {
9553
+ const volume = state.work.volumes.find((item) => item.id === volumeId);
9554
+ const node = tree.querySelector(`[data-volume-id="${CSS.escape(volumeId)}"]`);
9555
+ if (!volume || !node) continue;
9556
+ node.querySelector(".volume-chapter-count-number").textContent = String(volume.chapterCount);
9557
+ if (!volume.chapters.length && !state.collapsedVolumeIds.has(volumeId)) {
9558
+ node.querySelector(".volume-chapters").innerHTML = '<p class="entity-history-empty">本卷还没有章节。</p>';
9559
+ }
9560
+ }
9561
+ }
9562
+
9563
+ function renderTree(volumeIds = null) {
9517
9564
  if (!state.work) return;
9518
9565
  const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
9519
9566
  const proseEditable = canEditProse();
9520
9567
  $("#chapter-count").querySelector(".chapter-count-number").textContent = String(count);
9521
9568
  $("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
9522
9569
  $("#novel-tree").classList.remove("empty-copy");
9523
- $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
9570
+ const tree = $("#novel-tree");
9571
+ const volumes = volumeIds ? state.work.volumes.filter((volume) => volumeIds.includes(volume.id)) : state.work.volumes;
9572
+ const markup = volumes.map((volume) => {
9524
9573
  const collapsed = state.collapsedVolumeIds.has(volume.id);
9525
9574
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
9526
9575
  const chapterContent = collapsed
@@ -9531,8 +9580,9 @@ function renderTree() {
9531
9580
  ? '<p class="entity-history-empty">展开后加载章节。</p>'
9532
9581
  : chapters.length
9533
9582
  ? 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+方向键跨卷" : ""}">
9583
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="false" title="点击打开章节${proseEditable ? ";拖动左侧手柄排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
9535
9584
  <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
9585
+ ${proseEditable ? '<span class="chapter-drag-handle" draggable="true" aria-hidden="true" title="拖动排序"><svg viewBox="0 0 12 18" focusable="false"><path d="M4 4h.01M8 4h.01M4 9h.01M8 9h.01M4 14h.01M8 14h.01"></path></svg></span>' : ""}
9536
9586
  </button>`).join("")
9537
9587
  : '<p class="entity-history-empty">本卷还没有章节。</p>';
9538
9588
  return `
@@ -9546,15 +9596,28 @@ function renderTree() {
9546
9596
  ${chapterContent}
9547
9597
  </div>
9548
9598
  </div>`;
9549
- }).join("");
9550
- $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
9599
+ });
9600
+ const roots = [];
9601
+ if (volumeIds) {
9602
+ volumes.forEach((volume, index) => {
9603
+ const node = tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`);
9604
+ if (!node) return;
9605
+ node.outerHTML = markup[index];
9606
+ roots.push(tree.querySelector(`[data-volume-id="${CSS.escape(volume.id)}"]`));
9607
+ });
9608
+ } else {
9609
+ tree.innerHTML = markup.join("");
9610
+ roots.push(tree);
9611
+ }
9612
+ const renderedNodes = (selector) => roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
9613
+ renderedNodes("[data-volume-toggle]").forEach((button) => {
9551
9614
  button.addEventListener("click", () => {
9552
9615
  const volumeId = button.dataset.volumeToggle;
9553
9616
  if (state.collapsedVolumeIds.has(volumeId)) {
9554
9617
  state.collapsedVolumeIds.delete(volumeId);
9555
9618
  void loadVolumeChapters(volumeId);
9556
9619
  } else state.collapsedVolumeIds.add(volumeId);
9557
- renderTree();
9620
+ renderTree([volumeId]);
9558
9621
  });
9559
9622
  button.addEventListener("contextmenu", (event) => {
9560
9623
  if (!canEditProse()) return;
@@ -9582,18 +9645,19 @@ function renderTree() {
9582
9645
  });
9583
9646
  }
9584
9647
  });
9585
- $("#novel-tree").querySelectorAll("[data-volume-detail]").forEach((button) => {
9648
+ renderedNodes("[data-volume-detail]").forEach((button) => {
9586
9649
  button.addEventListener("click", () => {
9587
9650
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeDetail));
9588
9651
  });
9589
9652
  });
9590
- $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
9653
+ renderedNodes("[data-new-chapter-volume]").forEach((button) => {
9591
9654
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
9592
9655
  });
9593
- $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
9594
- button.addEventListener("click", async () => {
9656
+ renderedNodes("[data-chapter-id]").forEach((button) => {
9657
+ button.addEventListener("click", async (event) => {
9658
+ if (event.target.closest(".chapter-drag-handle")) return;
9595
9659
  const chapterId = button.dataset.chapterId;
9596
- await selectChapter(chapterId);
9660
+ if (!(await selectChapter(chapterId))) return;
9597
9661
  $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
9598
9662
  if (isMobileViewport()) {
9599
9663
  panelLayout.leftCollapsed = true;
@@ -9614,6 +9678,10 @@ function renderTree() {
9614
9678
  });
9615
9679
  if (proseEditable) {
9616
9680
  button.addEventListener("dragstart", (event) => {
9681
+ if (!canEditProse() || !event.target.closest(".chapter-drag-handle")) {
9682
+ event.preventDefault();
9683
+ return;
9684
+ }
9617
9685
  event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
9618
9686
  if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
9619
9687
  button.classList.add("is-dragging");
@@ -9636,7 +9704,7 @@ function renderTree() {
9636
9704
  const target = findChapterLocation(button.dataset.chapterId);
9637
9705
  const after = button.classList.contains("drop-after");
9638
9706
  button.classList.remove("is-drag-over", "drop-after");
9639
- if (!chapterId || !target) return;
9707
+ if (!chapterId || !target || chapterId === button.dataset.chapterId) return;
9640
9708
  const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
9641
9709
  const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
9642
9710
  await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
@@ -9789,22 +9857,36 @@ function findChapterLocation(chapterId) {
9789
9857
 
9790
9858
  async function moveChapterInTree(chapterId, volumeId, sortOrder) {
9791
9859
  const location = findChapterLocation(chapterId);
9792
- if (!location || chapterMovePending) return;
9860
+ if (!location || chapterMovePending || !canEditProse()) return;
9793
9861
  const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
9794
9862
  if (samePosition) return;
9863
+ const workId = state.work.id;
9864
+ const generation = workScopedUiGeneration;
9865
+ const volumeIds = [...new Set([location.volume.id, volumeId])];
9795
9866
  chapterMovePending = true;
9796
9867
  try {
9868
+ await Promise.all(volumeIds.map((id) => loadVolumeChapters(id)));
9869
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
9870
+ if (volumeIds.some((id) => !loadedVolumeChapterIds.has(id))) return;
9797
9871
  const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
9798
9872
  method: "POST",
9799
9873
  body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
9800
9874
  });
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();
9875
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
9876
+ const affectedVolumeIds = applyChapterDirectoryMove(state.work, moved);
9877
+ const selectedEntry = state.chapter && findChapterLocation(state.chapter.id)?.chapter;
9878
+ if (selectedEntry) {
9879
+ state.chapter = { ...state.chapter, ...selectedEntry };
9880
+ updateChapterPath();
9881
+ updateChapterStats();
9882
+ }
9883
+ syncMovedChapterTree(chapterId, affectedVolumeIds);
9884
+ const focusTarget = $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)
9885
+ ?? $("#novel-tree").querySelector(`[data-volume-toggle="${CSS.escape(volumeId)}"]`);
9886
+ focusTarget?.focus();
9805
9887
  toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
9806
9888
  } catch (error) {
9807
- toast(error.message, "error");
9889
+ if (state.work?.id === workId && generation === workScopedUiGeneration) toast(error.message, "error");
9808
9890
  } finally {
9809
9891
  chapterMovePending = false;
9810
9892
  }
@@ -9816,7 +9898,7 @@ async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
9816
9898
  if (crossVolume) {
9817
9899
  const targetVolume = state.work.volumes[location.volumeIndex + direction];
9818
9900
  if (!targetVolume) return toast("已经是最前或最后一个分卷");
9819
- await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
9901
+ await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? Number(targetVolume.chapterCount ?? targetVolume.chapters.length) : 0);
9820
9902
  return;
9821
9903
  }
9822
9904
  const targetIndex = location.chapterIndex + direction;
@@ -10131,10 +10213,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
10131
10213
  $("#welcome-view").classList.add("hidden");
10132
10214
  $("#module-view").classList.add("hidden");
10133
10215
  $("#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;
10216
+ updateChapterPath();
10138
10217
  $("#chapter-title").value = state.chapter.title;
10139
10218
  $("#chapter-content").value = state.chapter.content;
10140
10219
  resetChapterDraftLineIds(state.chapter);
@@ -10149,12 +10228,20 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
10149
10228
  if (!canEditProse()) setSaveState("正文只读");
10150
10229
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
10151
10230
  else setSaveState("已保存");
10152
- renderTree();
10231
+ syncChapterTreeSelection();
10153
10232
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
10154
- await loadChapterForeshadowReminders();
10233
+ void loadChapterForeshadowReminders();
10155
10234
  return true;
10156
10235
  }
10157
10236
 
10237
+ function updateChapterPath() {
10238
+ if (!state.work || !state.chapter) return;
10239
+ const volume = state.work.volumes.find((item) => item.id === state.chapter.volumeId);
10240
+ const chapterPath = `${volume?.title ?? "正文"} / 保存于 ${formatDateTime(state.chapter.updatedAt)}`;
10241
+ $("#chapter-path").textContent = chapterPath;
10242
+ $("#chapter-path").title = chapterPath;
10243
+ }
10244
+
10158
10245
  function updateChapterStats() {
10159
10246
  if (!state.chapter) return;
10160
10247
  const text = $("#chapter-content").value;
@@ -15486,6 +15573,43 @@ function setAiContextWarningActionsDisabled(disabled) {
15486
15573
  for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
15487
15574
  }
15488
15575
 
15576
+ async function compactAiConversation({ clearComposer = false, button = null } = {}) {
15577
+ if (aiInteractionBusy()) return toast("当前回复执行中,暂不能压缩上下文", "error");
15578
+ const requestScope = currentAiRequestScope();
15579
+ const modelId = $("#ai-model").value;
15580
+ if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
15581
+ const previousLabel = button?.textContent ?? "";
15582
+ setAiContextWarningActionsDisabled(true);
15583
+ if (button) button.textContent = "压缩中";
15584
+ try {
15585
+ const conversationId = await ensureAiConversation();
15586
+ const result = await api(`/api/ai-conversations/${conversationId}/compact`, {
15587
+ method: "POST",
15588
+ body: { modelId, scope: requestScope.scope }
15589
+ });
15590
+ hideAiContextWarning();
15591
+ if (clearComposer) clearAiPromptComposer();
15592
+ $("#ai-prompt").focus();
15593
+ toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
15594
+ setAiContextMeter(result.usage);
15595
+ if (result.changed) {
15596
+ const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
15597
+ if (current) {
15598
+ current.compactedMessageCount = result.compactedMessageCount;
15599
+ current.hasCompactedSummary = true;
15600
+ }
15601
+ renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
15602
+ }
15603
+ return result;
15604
+ } catch (error) {
15605
+ toast(`上下文压缩失败:${error.message}`, "error");
15606
+ return null;
15607
+ } finally {
15608
+ setAiContextWarningActionsDisabled(false);
15609
+ if (button) button.textContent = previousLabel;
15610
+ }
15611
+ }
15612
+
15489
15613
  async function loadAiReferences() {
15490
15614
  const workId = state.work?.id;
15491
15615
  if (!workId) return;
@@ -18945,6 +19069,23 @@ async function sendAi() {
18945
19069
  return sendAiWithOptions();
18946
19070
  }
18947
19071
 
19072
+ function appendAiPendingRequestMessage(tab) {
19073
+ const message = document.createElement("div");
19074
+ message.className = "assistant-message is-streaming";
19075
+ message.dataset.testid = "ai-stream-message";
19076
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在请求……</div>';
19077
+ attachMessageHeading(message, aiAssistantLabel("正在请求", tab.roleplayCharacter), undefined, tab);
19078
+ tab.feed.append(message);
19079
+ scrollAiFeedToBottom(tab.feed);
19080
+ return message;
19081
+ }
19082
+
19083
+ function prefixAiRequestError(error, prefix) {
19084
+ const failure = error instanceof Error ? error : new Error(String(error ?? "未知错误"));
19085
+ failure.message = `${prefix}:${failure.message}`;
19086
+ return failure;
19087
+ }
19088
+
18948
19089
  async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
18949
19090
  if (!state.work) return toast("请先选择作品", "error");
18950
19091
  const tab = activeAiChatTab();
@@ -18994,7 +19135,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18994
19135
  workId: state.work.id,
18995
19136
  conversationId: state.aiConversationId
18996
19137
  }),
18997
- preserveComposer: Boolean(queuedComposer)
19138
+ preserveComposer: Boolean(queuedComposer),
19139
+ optimisticUserMessage: null,
19140
+ pendingAssistantMessage: null,
19141
+ requestStartedAt: Date.now()
18998
19142
  };
18999
19143
  if (retry?.userMessageId) {
19000
19144
  requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
@@ -19002,6 +19146,33 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19002
19146
  setAiChatTabStatus(tab, "streaming");
19003
19147
  syncAiRequestControls();
19004
19148
  scrollAiFeedToBottom(tab.feed, { force: true });
19149
+ if (!retry) {
19150
+ const optimisticContent = selectedTaskType === "roleplay"
19151
+ ? composeRoleplayStoredUserContent(sceneDirection, instruction)
19152
+ : instruction;
19153
+ requestHolder.optimisticUserMessage = appendMessage(
19154
+ "user",
19155
+ optimisticContent,
19156
+ citations,
19157
+ null,
19158
+ {
19159
+ ...(requestScope.scope.characterIds?.length ? { mentionCharacterIds: requestScope.scope.characterIds } : {}),
19160
+ ...(requestScope.scope.settingIds?.length ? { mentionSettingIds: requestScope.scope.settingIds } : {}),
19161
+ ...(requestScope.scope.chapterIds?.length ? { mentionChapterIds: requestScope.scope.chapterIds } : {}),
19162
+ ...(requestScope.scope.includeSettingInfo === true ? { mentionContextSettingIds: ["include-setting-info"] } : {}),
19163
+ ...(requestComposerSnapshot.images.length ? { chatImageAttachmentIds: aiChatImageAttachmentIds(requestComposerSnapshot.images) } : {})
19164
+ },
19165
+ null,
19166
+ { tab }
19167
+ );
19168
+ requestHolder.optimisticUserMessage.dataset.status = "pending";
19169
+ requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息正在发送");
19170
+ if (!requestHolder.preserveComposer) {
19171
+ clearAiChatTabComposer(tab);
19172
+ if (isActiveAiChatTab(tab)) clearAiPromptComposer({ collapseScenePanel: Boolean(sceneDirection) });
19173
+ }
19174
+ requestHolder.pendingAssistantMessage = appendAiPendingRequestMessage(tab);
19175
+ }
19005
19176
  let shouldFlushQueuedPrompt = false;
19006
19177
  try {
19007
19178
  try {
@@ -19010,21 +19181,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19010
19181
  } catch (error) {
19011
19182
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
19012
19183
  setAiChatTabStatus(tab, "error");
19013
- return toast(`创作助手加载失败:${error.message}`, "error");
19184
+ const failure = prefixAiRequestError(error, "创作助手加载失败");
19185
+ if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
19186
+ throw failure;
19014
19187
  }
19015
19188
  assertAiRequestCurrent(requestHolder.snapshot);
19016
19189
  const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
19017
- if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
19190
+ if (!modelId) {
19191
+ const error = new Error("请先在 AI 管理中配置并选择模型");
19192
+ if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
19193
+ throw error;
19194
+ }
19018
19195
  tab.selectedModelId = modelId;
19019
19196
  const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
19020
19197
  if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
19021
- return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
19198
+ const error = new Error("当前选择的模型不是多模态模型,无法发送图片附件");
19199
+ if (!requestHolder.optimisticUserMessage) return toast(error.message, "error");
19200
+ throw error;
19022
19201
  }
19023
19202
  try {
19024
19203
  await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
19025
19204
  } catch (error) {
19026
19205
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
19027
- return toast(`对话配置锁定失败:${error.message}`, "error");
19206
+ const failure = prefixAiRequestError(error, "对话配置锁定失败");
19207
+ if (!requestHolder.optimisticUserMessage) return toast(failure.message, "error");
19208
+ throw failure;
19028
19209
  }
19029
19210
  setAiChatTabStatus(tab, "streaming");
19030
19211
  if (retry?.message?.isConnected) retry.message.remove();
@@ -19045,7 +19226,12 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19045
19226
  ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
19046
19227
  }, retry), createAiIdempotencyKey());
19047
19228
  const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
19048
- if (streamed.action === "warn") return;
19229
+ if (streamed.action === "warn") {
19230
+ requestHolder.optimisticUserMessage?.remove();
19231
+ setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
19232
+ if (isActiveAiChatTab(tab) && !requestHolder.preserveComposer) restoreAiPromptComposer(requestComposerSnapshot);
19233
+ return;
19234
+ }
19049
19235
  assistantContent = streamed.content;
19050
19236
  shouldFlushQueuedPrompt = true;
19051
19237
  assistantMessage = streamed.message;
@@ -19086,6 +19272,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19086
19272
  }
19087
19273
  } catch (error) {
19088
19274
  const request = requestHolder.snapshot;
19275
+ if (!error?.streamInterruption) requestHolder.pendingAssistantMessage?.remove();
19089
19276
  if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
19090
19277
  const persistedInterruption = await persistAiRequestInterruption(request, error?.streamInterruption);
19091
19278
  const userStoppedCurrentReply = request.signal.reason instanceof Error
@@ -19104,6 +19291,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19104
19291
  return;
19105
19292
  }
19106
19293
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
19294
+ requestHolder.optimisticUserMessage?.remove();
19107
19295
  setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
19108
19296
  if (isActiveAiChatTab(tab)) {
19109
19297
  if (retry) restoreAiPromptComposer(requestComposerSnapshot);
@@ -19123,6 +19311,10 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
19123
19311
  return;
19124
19312
  }
19125
19313
  setAiChatTabStatus(tab, "error");
19314
+ if (requestHolder.optimisticUserMessage?.dataset.status === "pending") {
19315
+ requestHolder.optimisticUserMessage.dataset.status = "failed";
19316
+ requestHolder.optimisticUserMessage.setAttribute("aria-label", "消息发送失败");
19317
+ }
19126
19318
  const interruption = error?.streamInterruption;
19127
19319
  if (interruption?.content) {
19128
19320
  try {
@@ -19209,14 +19401,21 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19209
19401
  const tab = aiChatTabForRequest(requestHolder.snapshot);
19210
19402
  if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
19211
19403
  const feed = tab.feed;
19212
- let message = document.createElement("div");
19404
+ let message = requestHolder.pendingAssistantMessage?.isConnected
19405
+ ? requestHolder.pendingAssistantMessage
19406
+ : document.createElement("div");
19213
19407
  message.className = "assistant-message is-streaming";
19214
19408
  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>';
19409
+ const streamConnectionStartedAt = requestHolder.requestStartedAt ?? Date.now();
19410
+ if (!message.querySelector(".message-body")) {
19411
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta"></div>';
19412
+ }
19217
19413
  let content = message.querySelector(".message-body");
19218
19414
  let meta = message.querySelector(".message-meta");
19219
- const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
19415
+ const connectionSeconds = document.createElement("span");
19416
+ connectionSeconds.className = "ai-stream-connection-seconds";
19417
+ connectionSeconds.dataset.testid = "ai-stream-connection-seconds";
19418
+ meta.replaceChildren("正在请求…… ", connectionSeconds, " 秒");
19220
19419
  const renderStreamConnectionElapsed = () => {
19221
19420
  const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
19222
19421
  connectionSeconds.textContent = String(elapsedSeconds);
@@ -19230,11 +19429,12 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19230
19429
  };
19231
19430
  const streamConnectionEstablishedEvents = new Set(["continuation", "delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
19232
19431
  const streamSpeedController = createStreamTypewriterSpeedController();
19233
- let messageMounted = false;
19432
+ let streamConnectionEstablished = false;
19433
+ let messageMounted = message.isConnected;
19234
19434
  const mountAssistantMessage = () => {
19235
19435
  if (messageMounted) return true;
19236
19436
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
19237
- attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
19437
+ attachMessageHeading(message, aiAssistantLabel(streamConnectionEstablished ? "正在生成" : "正在请求", tab.roleplayCharacter), undefined, tab);
19238
19438
  feed.append(message);
19239
19439
  messageMounted = true;
19240
19440
  scrollAiFeedToBottom(feed);
@@ -19356,7 +19556,14 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19356
19556
  let streamError = null;
19357
19557
  const consume = async (eventName, payload) => {
19358
19558
  assertAiRequestCurrent(requestHolder.snapshot);
19359
- if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") stopStreamConnectionTimer();
19559
+ if (streamConnectionEstablishedEvents.has(eventName) || eventName === "steer") {
19560
+ stopStreamConnectionTimer();
19561
+ streamConnectionEstablished = true;
19562
+ const headingRole = messageMounted ? message.querySelector(".message-heading > span") : null;
19563
+ if (headingRole && !["complete", "request_status", "error"].includes(eventName)) {
19564
+ headingRole.textContent = aiAssistantLabel("正在生成", tab.roleplayCharacter);
19565
+ }
19566
+ }
19360
19567
  if (eventName === "continuation") {
19361
19568
  if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
19362
19569
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
@@ -19407,10 +19614,19 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19407
19614
  tab.selectedModelId = lockedModelId;
19408
19615
  tab.promptSent = true;
19409
19616
  if (!requestHolder.preserveComposer) clearAiChatTabComposer(tab);
19410
- const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19617
+ let existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
19411
19618
  .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 });
19619
+ if (requestHolder.optimisticUserMessage?.isConnected) {
19620
+ if (existingUserMessage && existingUserMessage !== requestHolder.optimisticUserMessage) {
19621
+ requestHolder.optimisticUserMessage.remove();
19622
+ } else {
19623
+ const confirmedUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
19624
+ requestHolder.optimisticUserMessage.replaceWith(confirmedUserMessage);
19625
+ requestHolder.optimisticUserMessage = confirmedUserMessage;
19626
+ existingUserMessage = confirmedUserMessage;
19627
+ }
19628
+ } else if (!existingUserMessage) {
19629
+ existingUserMessage = appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
19414
19630
  }
19415
19631
  if (isActiveAiChatTab(tab)) {
19416
19632
  state.aiConversationModelId = lockedModelId;
@@ -19520,6 +19736,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
19520
19736
  assertAiRequestCurrent(requestHolder.snapshot);
19521
19737
  if (streamError) throw streamError;
19522
19738
  assertAiStreamCompleted(streamCompleted);
19739
+ if (warningOnly && messageMounted) message.remove();
19523
19740
  return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage, question };
19524
19741
  } catch (error) {
19525
19742
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
@@ -21683,8 +21900,8 @@ $("#ai-scope").addEventListener("change", (event) => {
21683
21900
  setAiContextMeter(null);
21684
21901
  });
21685
21902
  $("#ai-mention-menu").addEventListener("click", (event) => {
21686
- const skillButton = event.target.closest("[data-ai-skill-name]");
21687
- if (skillButton) return selectAiSkill(skillButton);
21903
+ const commandButton = event.target.closest("[data-ai-command-name]");
21904
+ if (commandButton) return selectAiSlashCommand(commandButton);
21688
21905
  const button = event.target.closest("[data-ai-reference-id]");
21689
21906
  if (button) selectAiMention(button);
21690
21907
  });
@@ -21983,36 +22200,7 @@ $("#ai-new-conversation").addEventListener("click", async () => {
21983
22200
  }
21984
22201
  });
21985
22202
  $("#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
- }
22203
+ void compactAiConversation({ button: $("#ai-context-compact") });
22016
22204
  });
22017
22205
  $("#ai-context-new-conversation").addEventListener("click", async () => {
22018
22206
  setAiContextWarningActionsDisabled(true);
@@ -22285,7 +22473,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
22285
22473
  const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
22286
22474
  if (activeOption) {
22287
22475
  event.preventDefault();
22288
- if (activeOption.dataset.aiSkillName) selectAiSkill(activeOption);
22476
+ if (activeOption.dataset.aiCommandName) selectAiSlashCommand(activeOption);
22289
22477
  else selectAiMention(activeOption);
22290
22478
  return;
22291
22479
  }