@musnows/scriverse 0.7.4 → 0.7.6

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.
@@ -53,15 +53,18 @@ import {
53
53
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
54
54
  import {
55
55
  READING_PREFERENCES_STORAGE_KEY,
56
+ READING_PREFERENCES_VERSION,
56
57
  adjacentReadingChapter,
57
58
  buildReadingChapterSequence,
58
59
  createReadingRequestGate,
59
60
  normalizeReadingPosition,
60
61
  normalizeReadingPreferences,
62
+ normalizeStoredReadingPreferences,
61
63
  readingPositionStorageKey,
62
64
  resolvePagedReadingStep,
65
+ resolveReadingTheme,
63
66
  resolveReadingStart
64
- } from "/reading-preview.js?v=20260812-reader-preview-v1";
67
+ } from "/reading-preview.js?v=20260813-reader-theme-v2";
65
68
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
66
69
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
67
70
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
@@ -81,9 +84,9 @@ import {
81
84
  } from "/timeline-view.js?v=20260801-timeline-sort-actions-v1";
82
85
  import {
83
86
  normalizeOutlineBoardState,
84
- outlineBoardUnresolvedCount,
85
- prepareOutlineBoard
86
- } from "/outline-board.js?v=20260812-outline-board-v1";
87
+ outlineBoardRequestPath,
88
+ outlineBoardUnresolvedCount
89
+ } from "/outline-board.js?v=20260813-outline-board-page-v1";
87
90
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260810-analysis-task-failed-v1";
88
91
  import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
89
92
  import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
@@ -1225,6 +1228,7 @@ let settingFiltersPanelOpen = false;
1225
1228
  const outlineBoardFilters = normalizeOutlineBoardState();
1226
1229
  let outlineBoardFiltersPanelOpen = false;
1227
1230
  let outlineBoardRenderRequestId = 0;
1231
+ let outlineBoardSearchTimer = null;
1228
1232
  const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
1229
1233
  let relationshipFiltersPanelOpen = false;
1230
1234
 
@@ -1805,7 +1809,7 @@ function aiAssistantLabel(suffix = "") {
1805
1809
  return suffix ? `${name} · ${suffix}` : name;
1806
1810
  }
1807
1811
 
1808
- function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
1812
+ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "当前上下文已压缩", title = "" } = {}) {
1809
1813
  const divider = document.createElement("div");
1810
1814
  divider.className = "ai-context-compaction-divider";
1811
1815
  divider.dataset.contextCompaction = kind;
@@ -1813,7 +1817,7 @@ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "
1813
1817
  divider.setAttribute("role", "separator");
1814
1818
  divider.setAttribute("aria-label", ariaLabel);
1815
1819
  if (title) divider.title = title;
1816
- divider.innerHTML = "<span>已压缩上下文</span>";
1820
+ divider.innerHTML = "<span>—— 当前上下文已压缩 ——</span>";
1817
1821
  return divider;
1818
1822
  }
1819
1823
 
@@ -5624,6 +5628,8 @@ function resetWorkScopedUiCaches() {
5624
5628
  Object.assign(outlineBoardFilters, normalizeOutlineBoardState());
5625
5629
  outlineBoardFiltersPanelOpen = false;
5626
5630
  outlineBoardRenderRequestId += 1;
5631
+ clearTimeout(outlineBoardSearchTimer);
5632
+ outlineBoardSearchTimer = null;
5627
5633
  chapterSelectionRequestGeneration += 1;
5628
5634
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
5629
5635
  relationshipFilters.fromCharacterIds = [];
@@ -5797,8 +5803,8 @@ function renderTree() {
5797
5803
  return `
5798
5804
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
5799
5805
  <div class="volume-title">
5800
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
5801
- <button class="chapter-batch-button volume-export-button" type="button" data-export-volume="${esc(volume.id)}" aria-label="导出“${esc(volume.title)}”为 EPUB" title="导出当前分卷为 EPUB"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg></button>
5806
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
5807
+ ${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情">详情</button>` : ""}
5802
5808
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
5803
5809
  </div>
5804
5810
  <div class="volume-chapters">
@@ -5820,6 +5826,11 @@ function renderTree() {
5820
5826
  event.preventDefault();
5821
5827
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
5822
5828
  });
5829
+ button.addEventListener("keydown", (event) => {
5830
+ if (!canEditProse() || (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))) return;
5831
+ event.preventDefault();
5832
+ openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
5833
+ });
5823
5834
  if (proseEditable) {
5824
5835
  button.addEventListener("dragover", (event) => {
5825
5836
  if (!event.dataTransfer?.types.includes("text/plain")) return;
@@ -5836,15 +5847,14 @@ function renderTree() {
5836
5847
  });
5837
5848
  }
5838
5849
  });
5839
- $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
5840
- button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
5841
- });
5842
- $("#novel-tree").querySelectorAll("[data-export-volume]").forEach((button) => {
5850
+ $("#novel-tree").querySelectorAll("[data-volume-detail]").forEach((button) => {
5843
5851
  button.addEventListener("click", () => {
5844
- const volume = state.work?.volumes.find((item) => item.id === button.dataset.exportVolume);
5845
- if (volume) void downloadVolumeEpub(volume, button);
5852
+ openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeDetail));
5846
5853
  });
5847
5854
  });
5855
+ $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
5856
+ button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
5857
+ });
5848
5858
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
5849
5859
  button.addEventListener("click", async () => {
5850
5860
  const chapterId = button.dataset.chapterId;
@@ -6370,7 +6380,7 @@ function storedReadingPosition() {
6370
6380
  }
6371
6381
 
6372
6382
  function persistReadingPreferences() {
6373
- try { localStorage.setItem(READING_PREFERENCES_STORAGE_KEY, JSON.stringify(readingPreferences)); } catch { /* 禁用本地存储时保留本次会话设置 */ }
6383
+ try { localStorage.setItem(READING_PREFERENCES_STORAGE_KEY, JSON.stringify({ ...readingPreferences, version: READING_PREFERENCES_VERSION })); } catch { /* 禁用本地存储时保留本次会话设置 */ }
6374
6384
  }
6375
6385
 
6376
6386
  function readingProgressRatio() {
@@ -6482,7 +6492,7 @@ function renderReadingNavigation() {
6482
6492
  function applyReadingPreferences() {
6483
6493
  const view = $("#reader-view");
6484
6494
  const viewport = $("#reader-viewport");
6485
- view.dataset.readerTheme = readingPreferences.theme;
6495
+ view.dataset.readerTheme = resolveReadingTheme(readingPreferences.theme, currentColorTheme());
6486
6496
  view.style.setProperty("--reader-font-size", `${readingPreferences.fontSize}px`);
6487
6497
  view.style.setProperty("--reader-line-height", String(readingPreferences.lineHeight));
6488
6498
  viewport.classList.toggle("is-paged", readingPreferences.mode === "paged");
@@ -6738,7 +6748,7 @@ async function openReadingPreview({ chapterId = null, volumeId = null, restorePo
6738
6748
  toast("当前作品还没有可阅读的章节", "error");
6739
6749
  return false;
6740
6750
  }
6741
- readingPreferences = normalizeReadingPreferences(readReadingStorage(READING_PREFERENCES_STORAGE_KEY));
6751
+ readingPreferences = normalizeStoredReadingPreferences(readReadingStorage(READING_PREFERENCES_STORAGE_KEY));
6742
6752
  const saved = restorePosition ? storedReadingPosition() : null;
6743
6753
  const target = resolveReadingStart(readingSequence, { chapterId, volumeId, storedPosition: saved });
6744
6754
  if (!target) return false;
@@ -8191,16 +8201,17 @@ function bindOutlineBoardCards(board, workId) {
8191
8201
  }));
8192
8202
  }
8193
8203
 
8194
- async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8204
+ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows, boardPage = moduleListPages.outlinePlans) {
8195
8205
  const workId = state.work.id;
8196
8206
  const generation = workScopedUiGeneration;
8197
8207
  const requestId = ++outlineBoardRenderRequestId;
8198
8208
  const currentChapterId = state.chapter?.id;
8209
+ const boardPageSize = pageSizeFor("outlines");
8199
8210
  let board;
8200
8211
  let foreshadows;
8201
8212
  try {
8202
8213
  [board, foreshadows] = await Promise.all([
8203
- moduleApi("outlines", `/api/works/${encodeURIComponent(workId)}/outline-board`),
8214
+ moduleApi("outlines", outlineBoardRequestPath(workId, outlineBoardFilters, boardPage, boardPageSize)),
8204
8215
  moduleApiAllPages("outlines", `/api/works/${encodeURIComponent(workId)}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
8205
8216
  ]);
8206
8217
  } catch (error) {
@@ -8209,9 +8220,12 @@ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8209
8220
  }
8210
8221
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8211
8222
 
8212
- if (outlineBoardFilters.volumeId && !board.volumes.some((volume) => String(volume.id) === outlineBoardFilters.volumeId)) {
8223
+ if (outlineBoardFilters.volumeId && !board.volumeOptions.some((volume) => String(volume.id) === outlineBoardFilters.volumeId)) {
8213
8224
  outlineBoardFilters.volumeId = "";
8225
+ board = await moduleApi("outlines", outlineBoardRequestPath(workId, outlineBoardFilters, 1, boardPageSize));
8226
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8214
8227
  }
8228
+ moduleListPages.outlinePlans = board.page;
8215
8229
  const foreshadowPageResult = paginateModuleItems(foreshadows, foreshadowPage, "outlines");
8216
8230
  moduleListPages.foreshadows = foreshadowPageResult.page;
8217
8231
  const layout = readModuleLayout();
@@ -8247,9 +8261,9 @@ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8247
8261
 
8248
8262
  const filterState = normalizeOutlineBoardState(outlineBoardFilters);
8249
8263
  Object.assign(outlineBoardFilters, filterState);
8250
- const volumeOptions = board.volumes.map((volume) => `<option value="${esc(volume.id)}" ${filterState.volumeId === String(volume.id) ? "selected" : ""}>${esc(volume.title)}</option>`).join("");
8264
+ const volumeOptions = board.volumeOptions.map((volume) => `<option value="${esc(volume.id)}" ${filterState.volumeId === String(volume.id) ? "selected" : ""}>${esc(volume.title)}</option>`).join("");
8251
8265
  const filterToolbar = `<section id="outline-board-filter-panel" class="character-filter-toolbar outline-board-filter-toolbar${outlineBoardFiltersPanelOpen ? "" : " hidden"}" aria-label="章节大纲看板筛选">
8252
- <label class="setting-filter-field outline-board-search-field" for="outline-board-search"><span>搜索章节与摘要</span><input id="outline-board-search" type="search" value="${esc(filterState.query)}" placeholder="章节、目标、冲突、转折或伏笔" autocomplete="off"></label>
8266
+ <label class="setting-filter-field outline-board-search-field" for="outline-board-search"><span>搜索章节与摘要</span><input id="outline-board-search" type="search" value="${esc(filterState.query)}" placeholder="章节、目标、冲突、转折或伏笔" autocomplete="off" maxlength="200"></label>
8253
8267
  <label class="setting-filter-field" for="outline-board-volume-filter"><span>按分卷筛选</span><select id="outline-board-volume-filter"><option value="">全部分卷</option>${volumeOptions}</select></label>
8254
8268
  <label class="setting-filter-field" for="outline-board-status-filter"><span>按大纲状态筛选</span><select id="outline-board-status-filter"><option value="all" ${filterState.outlineStatus === "all" ? "selected" : ""}>全部大纲状态</option><option value="empty" ${filterState.outlineStatus === "empty" ? "selected" : ""}>尚未规划</option><option value="draft" ${filterState.outlineStatus === "draft" ? "selected" : ""}>草稿</option><option value="ready" ${filterState.outlineStatus === "ready" ? "selected" : ""}>可执行</option><option value="completed" ${filterState.outlineStatus === "completed" ? "selected" : ""}>已完成</option></select></label>
8255
8269
  <label class="setting-filter-field" for="outline-board-foreshadow-filter"><span>按伏笔状态筛选</span><select id="outline-board-foreshadow-filter"><option value="all" ${filterState.foreshadowStatus === "all" ? "selected" : ""}>全部伏笔状态</option><option value="none" ${filterState.foreshadowStatus === "none" ? "selected" : ""}>无关联伏笔</option><option value="unresolved" ${filterState.foreshadowStatus === "unresolved" ? "selected" : ""}>有未回收伏笔</option><option value="resolved" ${filterState.foreshadowStatus === "resolved" ? "selected" : ""}>有已回收伏笔</option><option value="abandoned" ${filterState.foreshadowStatus === "abandoned" ? "selected" : ""}>有已放弃伏笔</option></select></label>
@@ -8259,21 +8273,44 @@ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8259
8273
  $("#module-content").innerHTML = `${filterToolbar}<div id="outline-board-results"></div><section class="planning-section outline-foreshadow-library"><div class="section-title"><div><span class="eyebrow">伏笔追踪</span><h2>尚未回收与历史伏笔</h2></div></div>${foreshadowHtml}</section>`;
8260
8274
  mountOutlineBoardFilterToggle();
8261
8275
 
8262
- const renderOutlineBoardResults = () => {
8263
- const prepared = prepareOutlineBoard(board, outlineBoardFilters);
8264
- const controlsActive = prepared.filtersActive || prepared.state.sort !== "tree";
8265
- const summary = `<div class="outline-summary"><article><strong>${prepared.filtersActive ? prepared.visibleChapterCount : board.stats.chapterCount}</strong><span>${prepared.filtersActive ? `筛选结果 / 共 ${board.stats.chapterCount} 章` : "全书章节"}</span></article><article><strong>${board.stats.outlinedChapterCount}</strong><span>已有章节大纲</span></article><article class="${board.stats.unresolvedForeshadowCount ? "danger-text" : ""}"><strong>${board.stats.unresolvedForeshadowCount}</strong><span>未回收伏笔</span></article></div>`;
8266
- const boardHtml = prepared.volumes.length
8267
- ? `<div class="outline-board">${prepared.volumes.map((volume) => `<section class="outline-board-volume" data-outline-board-volume="${esc(volume.id)}" aria-labelledby="outline-board-volume-${esc(volume.id)}"><header><div><span class="eyebrow">分卷</span><h3 id="outline-board-volume-${esc(volume.id)}">${esc(volume.title)}</h3></div><span>${volume.chapters.length} 章</span></header>${volume.chapters.length ? `<div class="outline-board-grid">${volume.chapters.map((chapter) => outlineBoardCard(chapter, volume)).join("")}</div>` : '<div class="outline-board-volume-empty"><b>本卷暂无章节</b><span>空分卷会保留在全书看板中。</span></div>'}</section>`).join("")}</div>`
8268
- : emptyModule(board.stats.chapterCount ? "没有符合筛选条件的章节" : board.volumes.length ? "当前分卷还没有章节" : "还没有分卷", board.stats.chapterCount ? "可以调整关键词、状态、分卷或排序条件。" : "先在作品目录创建分卷和章节,再维护每章目标、冲突与转折。");
8269
- $("#outline-board-results").innerHTML = `${summary}<section class="planning-section"><div class="section-title"><div><span class="eyebrow">全书总览</span><h2>章节大纲看板</h2></div><span class="outline-board-result-note">${prepared.visibleChapterCount} / ${board.stats.chapterCount} 章</span></div>${boardHtml}</section>`;
8270
- $("#outline-board-filter-count").textContent = prepared.filtersActive ? `筛选后剩余 ${prepared.visibleChapterCount} 章` : "";
8276
+ const renderOutlineBoardResults = (pageResult) => {
8277
+ const currentFilters = normalizeOutlineBoardState(outlineBoardFilters);
8278
+ const filtersActive = Boolean(currentFilters.query.trim() || currentFilters.volumeId || currentFilters.outlineStatus !== "all" || currentFilters.foreshadowStatus !== "all");
8279
+ const controlsActive = filtersActive || currentFilters.sort !== "tree";
8280
+ const summary = `<div class="outline-summary"><article><strong>${filtersActive ? pageResult.total : pageResult.stats.chapterCount}</strong><span>${filtersActive ? `筛选结果 / 共 ${pageResult.stats.chapterCount} 章` : "全书章节"}</span></article><article><strong>${pageResult.stats.outlinedChapterCount}</strong><span>已有章节大纲</span></article><article class="${pageResult.stats.unresolvedForeshadowCount ? "danger-text" : ""}"><strong>${pageResult.stats.unresolvedForeshadowCount}</strong><span>未回收伏笔</span></article></div>`;
8281
+ const boardHtml = pageResult.volumes.length
8282
+ ? `<div class="outline-board">${pageResult.volumes.map((volume) => `<section class="outline-board-volume" data-outline-board-volume="${esc(volume.id)}" aria-labelledby="outline-board-volume-${esc(volume.id)}"><header><div><span class="eyebrow">分卷</span><h3 id="outline-board-volume-${esc(volume.id)}">${esc(volume.title)}</h3></div><span>${volume.chapters.length === volume.filteredChapterCount ? `${volume.chapters.length} 章` : `本页 ${volume.chapters.length} / ${volume.filteredChapterCount} 章`}</span></header>${volume.chapters.length ? `<div class="outline-board-grid">${volume.chapters.map((chapter) => outlineBoardCard(chapter, volume)).join("")}</div>` : '<div class="outline-board-volume-empty"><b>本卷暂无章节</b><span>空分卷会保留在全书看板中。</span></div>'}</section>`).join("")}</div>`
8283
+ : emptyModule(pageResult.stats.chapterCount ? "没有符合筛选条件的章节" : pageResult.volumeOptions.length ? "当前分卷还没有章节" : "还没有分卷", pageResult.stats.chapterCount ? "可以调整关键词、状态、分卷或排序条件。" : "先在作品目录创建分卷和章节,再维护每章目标、冲突与转折。");
8284
+ const pagination = renderModulePagination(pageResult, "outlinePlans", "章节大纲看板");
8285
+ $("#outline-board-results").innerHTML = `${summary}<section class="planning-section"><div class="section-title"><div><span class="eyebrow">全书总览</span><h2>章节大纲看板</h2></div><span class="outline-board-result-note">本页 ${pageResult.itemCount} / ${pageResult.total} 章</span></div>${boardHtml}${pagination}</section>`;
8286
+ $("#outline-board-filter-count").textContent = filtersActive ? `筛选后共 ${pageResult.total} 章` : "";
8271
8287
  $("#clear-outline-board-filters").disabled = !controlsActive;
8272
- mountModuleCount(prepared.visibleChapterCount + foreshadows.length);
8273
- bindOutlineBoardCards(prepared, workId);
8288
+ mountModuleCount(pageResult.total + foreshadows.length);
8289
+ bindOutlineBoardCards(pageResult, workId);
8290
+ bindModulePagination("outlinePlans", (page) => refreshOutlineBoard(page));
8291
+ };
8292
+
8293
+ const refreshOutlineBoard = async (page = 1) => {
8294
+ const pageRequestId = ++outlineBoardRenderRequestId;
8295
+ moduleListPages.outlinePlans = page;
8296
+ $("#outline-board-results")?.setAttribute("aria-busy", "true");
8297
+ $("#outline-board-filter-count").textContent = "正在加载看板…";
8298
+ try {
8299
+ const nextBoard = await moduleApi("outlines", outlineBoardRequestPath(workId, outlineBoardFilters, page, boardPageSize));
8300
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || pageRequestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8301
+ board = nextBoard;
8302
+ moduleListPages.outlinePlans = nextBoard.page;
8303
+ renderOutlineBoardResults(nextBoard);
8304
+ } catch (error) {
8305
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || pageRequestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8306
+ $("#outline-board-filter-count").textContent = "看板加载失败";
8307
+ toast(`读取章节大纲看板失败:${error.message}`, "error");
8308
+ } finally {
8309
+ if (pageRequestId === outlineBoardRenderRequestId) $("#outline-board-results")?.removeAttribute("aria-busy");
8310
+ }
8274
8311
  };
8275
8312
 
8276
- const updateFilters = () => {
8313
+ const updateFilters = (defer = false) => {
8277
8314
  Object.assign(outlineBoardFilters, normalizeOutlineBoardState({
8278
8315
  query: $("#outline-board-search").value,
8279
8316
  volumeId: $("#outline-board-volume-filter").value,
@@ -8282,10 +8319,16 @@ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8282
8319
  sort: $("#outline-board-sort").value
8283
8320
  }));
8284
8321
  outlineBoardFiltersPanelOpen = true;
8285
- renderOutlineBoardResults();
8322
+ clearTimeout(outlineBoardSearchTimer);
8323
+ if (defer) {
8324
+ outlineBoardSearchTimer = window.setTimeout(() => { void refreshOutlineBoard(1); }, 250);
8325
+ } else {
8326
+ outlineBoardSearchTimer = null;
8327
+ void refreshOutlineBoard(1);
8328
+ }
8286
8329
  };
8287
- $("#outline-board-search").addEventListener("input", updateFilters);
8288
- ["#outline-board-volume-filter", "#outline-board-status-filter", "#outline-board-foreshadow-filter", "#outline-board-sort"].forEach((selector) => $(selector).addEventListener("change", updateFilters));
8330
+ $("#outline-board-search").addEventListener("input", () => updateFilters(true));
8331
+ ["#outline-board-volume-filter", "#outline-board-status-filter", "#outline-board-foreshadow-filter", "#outline-board-sort"].forEach((selector) => $(selector).addEventListener("change", () => updateFilters(false)));
8289
8332
  $("#clear-outline-board-filters").addEventListener("click", () => {
8290
8333
  Object.assign(outlineBoardFilters, normalizeOutlineBoardState());
8291
8334
  $("#outline-board-search").value = "";
@@ -8294,10 +8337,12 @@ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8294
8337
  $("#outline-board-foreshadow-filter").value = "all";
8295
8338
  $("#outline-board-sort").value = "tree";
8296
8339
  outlineBoardFiltersPanelOpen = true;
8297
- renderOutlineBoardResults();
8298
8340
  $("#outline-board-search").focus();
8341
+ clearTimeout(outlineBoardSearchTimer);
8342
+ outlineBoardSearchTimer = null;
8343
+ void refreshOutlineBoard(1);
8299
8344
  });
8300
- renderOutlineBoardResults();
8345
+ renderOutlineBoardResults(board);
8301
8346
  bindModuleLayoutToggle(() => renderOutlines(foreshadowPageResult.page));
8302
8347
  bindModulePagination("foreshadows", renderOutlines);
8303
8348
  $("#module-content").querySelectorAll("[data-edit-foreshadow]").forEach((button) => button.addEventListener("click", () => openForeshadowDialog(foreshadows.find((item) => item.id === button.dataset.editForeshadow))));
@@ -10138,7 +10183,7 @@ async function renderBookAiSettings() {
10138
10183
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
10139
10184
  title: "本书 Token 用量",
10140
10185
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
10141
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
10186
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
10142
10187
  const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
10143
10188
  agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
10144
10189
  const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
@@ -10555,10 +10600,8 @@ function setAiContextDistributionVisible(visible) {
10555
10600
  }
10556
10601
 
10557
10602
  function showAiContextWarning(usage = null) {
10558
- const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
10559
- const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
10560
- $("#ai-context-warning-title").textContent = percent ? `对话历史已使用 ${percent}% 的独立预算` : "对话历史接近整理阈值";
10561
- $("#ai-context-warning-message").textContent = `已达到 ${threshold}% 的长期记忆整理阈值。现在可整理较早对话或新开对话;若继续发送,系统会先生成带来源的结构化长期记忆。作品正文超限不会触发此操作。`;
10603
+ $("#ai-context-warning-title").textContent = "对话上下文过长";
10604
+ $("#ai-context-warning-message").textContent = "当前对话上下文过长,是否进行压缩?不压缩可能会导致后续请求失败";
10562
10605
  $("#ai-context-warning").classList.remove("hidden");
10563
10606
  }
10564
10607
 
@@ -10566,6 +10609,10 @@ function hideAiContextWarning() {
10566
10609
  $("#ai-context-warning").classList.add("hidden");
10567
10610
  }
10568
10611
 
10612
+ function setAiContextWarningActionsDisabled(disabled) {
10613
+ for (const button of $("#ai-context-warning").querySelectorAll("button")) button.disabled = disabled;
10614
+ }
10615
+
10569
10616
  async function loadAiReferences() {
10570
10617
  const workId = state.work?.id;
10571
10618
  if (!workId) return;
@@ -11138,11 +11185,11 @@ function openVolumeDialog(item) {
11138
11185
  if (!state.work) return openWorkDialog();
11139
11186
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
11140
11187
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
11141
- const management = item ? `<section class="entity-dialog-management" aria-label="分卷操作">
11142
- <div><strong>分卷操作</strong><small>分卷会连同其中章节移入回收站,正文、版本和关联资料默认保留 30 天。</small></div>
11143
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete>移入回收站</button></div>
11188
+ const management = item ? `<section class="entity-dialog-management" aria-label="分卷详情操作">
11189
+ <div><strong>分卷详情</strong><small>可将当前分卷单独导出为 EPUB;移入回收站时会连同其中章节隐藏,正文、版本和关联资料默认保留 30 天。</small></div>
11190
+ <div class="entity-dialog-management-actions"><button class="ghost-button" type="button" data-dialog-volume-export>导出 EPUB</button><button class="danger-button" type="button" data-dialog-volume-delete>移入回收站</button></div>
11144
11191
  </section>` : "";
11145
- openDialog(item ? "编辑分卷" : "新建分卷",
11192
+ openDialog(item ? "分卷详情" : "新建分卷",
11146
11193
  field("title", "分卷名称", "text", item?.title) +
11147
11194
  field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
11148
11195
  field("description", "分卷简介", "textarea", item?.description) +
@@ -11160,6 +11207,9 @@ function openVolumeDialog(item) {
11160
11207
  renderTree();
11161
11208
  toast(item ? "分卷设置已保存" : "分卷已创建");
11162
11209
  }, "分卷设置");
11210
+ $("#dialog-fields").querySelector("[data-dialog-volume-export]")?.addEventListener("click", (event) => {
11211
+ void downloadVolumeEpub(item, event.currentTarget);
11212
+ });
11163
11213
  $("#dialog-fields").querySelector("[data-dialog-volume-delete]")?.addEventListener("click", () => {
11164
11214
  void deleteVolume(item);
11165
11215
  });
@@ -13332,6 +13382,10 @@ function openModelDialog(providerId, item = null, provider = null) {
13332
13382
  }
13333
13383
 
13334
13384
  async function sendAi() {
13385
+ return sendAiWithOptions();
13386
+ }
13387
+
13388
+ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13335
13389
  if (!state.work) return toast("请先选择作品", "error");
13336
13390
  const composerSnapshot = captureAiPromptComposer();
13337
13391
  const instruction = composerSnapshot.text.trim();
@@ -13404,7 +13458,8 @@ async function sendAi() {
13404
13458
  scope,
13405
13459
  modelId,
13406
13460
  citations,
13407
- conversationId: requestHolder.snapshot.conversationId
13461
+ conversationId: requestHolder.snapshot.conversationId,
13462
+ ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
13408
13463
  }, createAiIdempotencyKey());
13409
13464
  const request = assertAiRequestCurrent(requestHolder.snapshot);
13410
13465
  if (streamed.action === "warn") return;
@@ -13575,6 +13630,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13575
13630
  let conversationTitle = null;
13576
13631
  let persistedUserMessage = null;
13577
13632
  let contextAction = "ready";
13633
+ let warningOnly = false;
13578
13634
  let streamContextCompacted = false;
13579
13635
  let finalAnswerStarted = false;
13580
13636
  const processStartedAt = Date.now();
@@ -13698,6 +13754,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13698
13754
  setAiContextMeter(payload.contextUsage);
13699
13755
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
13700
13756
  } else if (eventName === "complete") {
13757
+ if (payload.warningOnly === true) {
13758
+ warningOnly = true;
13759
+ setAiContextMeter(payload.contextUsage);
13760
+ return;
13761
+ }
13701
13762
  mountAssistantMessage();
13702
13763
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
13703
13764
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
@@ -13729,7 +13790,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13729
13790
  assertAiRequestCurrent(requestHolder.snapshot);
13730
13791
  if (streamError) throw streamError;
13731
13792
  assertAiStreamCompleted(streamCompleted);
13732
- return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
13793
+ return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
13733
13794
  } catch (error) {
13734
13795
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
13735
13796
  const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
@@ -15655,7 +15716,7 @@ $("#ai-context-compact").addEventListener("click", async () => {
15655
15716
  const modelId = $("#ai-model").value;
15656
15717
  if (!requestScope || !modelId) return toast("请先选择章节和模型", "error");
15657
15718
  const button = $("#ai-context-compact");
15658
- button.disabled = true;
15719
+ setAiContextWarningActionsDisabled(true);
15659
15720
  button.textContent = "压缩中";
15660
15721
  try {
15661
15722
  const conversationId = await ensureAiConversation();
@@ -15664,6 +15725,7 @@ $("#ai-context-compact").addEventListener("click", async () => {
15664
15725
  body: { modelId, scope: requestScope.scope }
15665
15726
  });
15666
15727
  hideAiContextWarning();
15728
+ $("#ai-prompt").focus();
15667
15729
  toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
15668
15730
  setAiContextMeter(result.usage);
15669
15731
  if (result.changed) {
@@ -15677,19 +15739,30 @@ $("#ai-context-compact").addEventListener("click", async () => {
15677
15739
  } catch (error) {
15678
15740
  toast(`上下文压缩失败:${error.message}`, "error");
15679
15741
  } finally {
15680
- button.disabled = false;
15681
- button.textContent = "压缩上下文";
15742
+ setAiContextWarningActionsDisabled(false);
15743
+ button.textContent = "压缩";
15682
15744
  }
15683
15745
  });
15684
15746
  $("#ai-context-new-conversation").addEventListener("click", async () => {
15747
+ setAiContextWarningActionsDisabled(true);
15685
15748
  try {
15686
15749
  await createNewAiConversation();
15687
15750
  hideAiContextWarning();
15688
15751
  } catch (error) {
15689
15752
  toast(error.message, "error");
15753
+ } finally {
15754
+ setAiContextWarningActionsDisabled(false);
15755
+ }
15756
+ });
15757
+ $("#ai-context-dismiss").addEventListener("click", async () => {
15758
+ setAiContextWarningActionsDisabled(true);
15759
+ try {
15760
+ await sendAiWithOptions({ ignoreContextWarning: true });
15761
+ if ($("#ai-context-warning").classList.contains("hidden")) $("#ai-prompt").focus();
15762
+ } finally {
15763
+ setAiContextWarningActionsDisabled(false);
15690
15764
  }
15691
15765
  });
15692
- $("#ai-context-dismiss").addEventListener("click", hideAiContextWarning);
15693
15766
  $("#ai-history-toggle").addEventListener("click", async () => {
15694
15767
  if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
15695
15768
  try {
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260812-ai-conversation-export-mobile-foreshadow-epub-character-extraction-outline-reader-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260813-scrollbar-stable-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -125,7 +125,6 @@
125
125
  </div>
126
126
  <nav id="module-nav" class="module-nav" aria-label="作品模块">
127
127
  <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
128
- <button id="reader-open-button" type="button" aria-label="打开沉浸式阅读预览" title="阅读预览"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v16H5.5A2.5 2.5 0 0 0 3 21.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v16h5.5a2.5 2.5 0 0 1 2.5 2.5Z"/></svg>阅读预览</button>
129
128
  <button type="button" data-module="drafts"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>想法</button>
130
129
  <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定</button>
131
130
  <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
@@ -135,6 +134,7 @@
135
134
  <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
136
135
  <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
137
136
  <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
137
+ <button id="reader-open-button" class="module-nav-secondary hidden" type="button" aria-label="打开沉浸式阅读预览" title="阅读预览"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v16H5.5A2.5 2.5 0 0 0 3 21.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v16h5.5a2.5 2.5 0 0 1 2.5 2.5Z"/></svg>阅读预览</button>
138
138
  <button class="module-nav-secondary hidden" type="button" data-module="comments"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/><path d="M8 9h8M8 13h5"/></svg><span class="nav-label">正文评论</span></button>
139
139
  <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
140
140
  <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
@@ -331,8 +331,8 @@
331
331
  <div class="prompt-box">
332
332
  <div id="ai-citations" class="ai-citations hidden" aria-label="已添加的正文引用"></div>
333
333
  <section id="ai-context-warning" class="ai-context-warning hidden" role="status" aria-live="polite">
334
- <div><strong id="ai-context-warning-title">对话历史接近整理阈值</strong><p id="ai-context-warning-message">建议整理为长期记忆或新开对话。</p></div>
335
- <div><button id="ai-context-compact" type="button">整理长期记忆</button><button id="ai-context-new-conversation" type="button">新开对话</button><button id="ai-context-dismiss" type="button">稍后处理</button></div>
334
+ <div><strong id="ai-context-warning-title">对话上下文过长</strong><p id="ai-context-warning-message">当前对话上下文过长,是否进行压缩?不压缩可能会导致后续请求失败</p></div>
335
+ <div><button id="ai-context-compact" type="button">压缩</button><button id="ai-context-dismiss" type="button">忽略</button><button id="ai-context-new-conversation" type="button">新开对话</button></div>
336
336
  </section>
337
337
  <div class="prompt-options">
338
338
  <select id="ai-task" aria-label="任务类型">
@@ -389,7 +389,7 @@
389
389
  <option value="1.6">紧凑 1.6</option><option value="1.8">适中 1.8</option><option value="1.9">舒展 1.9</option><option value="2">宽松 2.0</option><option value="2.2">宽松 2.2</option>
390
390
  </select></label>
391
391
  <label>主题<select id="reader-theme" aria-label="阅读主题">
392
- <option value="paper">纸张</option><option value="light">明亮</option><option value="dark">夜间</option>
392
+ <option value="auto">跟随工作台</option><option value="paper">纸张</option><option value="light">明亮</option><option value="dark">夜间</option>
393
393
  </select></label>
394
394
  </div>
395
395
  </details>
@@ -1156,6 +1156,6 @@
1156
1156
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1157
1157
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1158
1158
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1159
- <script type="module" src="/app.js?v=20260812-ai-stream-connectivity-global-replace-foreshadow-epub-character-extraction-outline-reader-v1"></script>
1159
+ <script type="module" src="/app.js?v=20260813-outline-board-page-v1"></script>
1160
1160
  </body>
1161
1161
  </html>
@@ -30,11 +30,21 @@ export type OutlineBoardVolume<T extends OutlineBoardChapter = OutlineBoardChapt
30
30
  id?: string | null;
31
31
  title?: string | null;
32
32
  sortOrder?: number | null;
33
+ chapterCount?: number | null;
34
+ filteredChapterCount?: number | null;
33
35
  chapters?: T[];
34
36
  };
35
37
 
36
38
  export type OutlineBoard<T extends OutlineBoardChapter = OutlineBoardChapter> = {
37
39
  volumes?: Array<OutlineBoardVolume<T>>;
40
+ volumeOptions?: Array<Omit<OutlineBoardVolume<T>, "chapters">>;
41
+ page?: number;
42
+ limit?: number;
43
+ itemCount?: number;
44
+ total?: number;
45
+ pageCount?: number;
46
+ hasMore?: boolean;
47
+ nextPage?: number | null;
38
48
  };
39
49
 
40
50
  export type OutlineBoardState = {
@@ -47,15 +57,11 @@ export type OutlineBoardState = {
47
57
 
48
58
  export declare function normalizeOutlineBoardState(value?: Partial<OutlineBoardState>): OutlineBoardState;
49
59
 
50
- export declare function prepareOutlineBoard<T extends OutlineBoardChapter>(
51
- board: OutlineBoard<T> | null | undefined,
52
- value?: Partial<OutlineBoardState>
53
- ): {
54
- state: OutlineBoardState;
55
- volumes: Array<OutlineBoardVolume<T> & { chapters: T[] }>;
56
- totalChapterCount: number;
57
- visibleChapterCount: number;
58
- filtersActive: boolean;
59
- };
60
+ export declare function outlineBoardRequestPath(
61
+ workId: string,
62
+ value?: Partial<OutlineBoardState>,
63
+ page?: number,
64
+ limit?: number
65
+ ): string;
60
66
 
61
67
  export declare function outlineBoardUnresolvedCount(chapter: OutlineBoardChapter): number;