@musnows/scriverse 0.5.11 → 0.5.12

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.
@@ -8,7 +8,7 @@ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260
8
8
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
9
9
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
10
10
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
11
- import { createStreamTypewriter } from "/stream-typewriter.js?v=20260729-ai-stream-typewriter-v1";
11
+ import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
12
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
13
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
14
14
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
@@ -48,6 +48,7 @@ import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-s
48
48
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
49
49
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
50
50
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
51
+ import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
51
52
  import {
52
53
  clampCropRect,
53
54
  containImageRect,
@@ -117,6 +118,21 @@ const state = {
117
118
  contextChapterId: null
118
119
  };
119
120
 
121
+ const moduleRequestCache = createModuleRequestCache();
122
+ const cachedWorkModules = new Set([
123
+ "drafts",
124
+ "settings",
125
+ "characters",
126
+ "races",
127
+ "organizations",
128
+ "timeline",
129
+ "outlines",
130
+ "relationships",
131
+ "reviews",
132
+ "tasks",
133
+ "ai-settings"
134
+ ]);
135
+
120
136
  function createPresenceClientId() {
121
137
  if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
122
138
  const bytes = crypto.getRandomValues(new Uint8Array(16));
@@ -371,6 +387,9 @@ let aiReferencesLoadWorkId = null;
371
387
  let aiConversationsLoadPromise = null;
372
388
  let aiConversationsLoadWorkId = null;
373
389
  let workScopedUiGeneration = 0;
390
+ const loadedVolumeChapterIds = new Set();
391
+ const volumeChapterLoadingIds = new Set();
392
+ const volumeChapterRequests = new Map();
374
393
  let raceHierarchyLoadPromise = null;
375
394
  let raceHierarchyLoadWorkId = null;
376
395
  let loadedRaceHierarchyWorkId = null;
@@ -874,6 +893,7 @@ let chapterEditorReadOnly = true;
874
893
  let characterListPage = 1;
875
894
  let taskListPage = 1;
876
895
  let draftTypeFilter = "all";
896
+ let draftFiltersPanelOpen = false;
877
897
  const moduleListPages = {
878
898
  drafts: 1,
879
899
  settings: 1,
@@ -2126,14 +2146,19 @@ async function api(path, options = {}) {
2126
2146
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2127
2147
  state.user = null;
2128
2148
  state.csrfToken = null;
2149
+ moduleRequestCache.clear();
2129
2150
  showAuth(false);
2130
2151
  }
2131
2152
  const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
2132
2153
  error.code = payload.error?.code;
2133
2154
  throw error;
2134
2155
  }
2135
- if (response.status === 204) return null;
2156
+ if (response.status === 204) {
2157
+ invalidateModuleRequestsAfterMutation(path, method);
2158
+ return null;
2159
+ }
2136
2160
  const payload = await response.json();
2161
+ invalidateModuleRequestsAfterMutation(path, method);
2137
2162
  return payload.data;
2138
2163
  }
2139
2164
 
@@ -2155,6 +2180,63 @@ async function apiAllPages(path, limit = 100) {
2155
2180
  }
2156
2181
  }
2157
2182
 
2183
+ function cachedModuleRequest(module, requestKey, loader, options = {}) {
2184
+ const workId = state.work?.id;
2185
+ if (!workId) return Promise.resolve().then(loader);
2186
+ return moduleRequestCache.request(workId, module, requestKey, loader, options);
2187
+ }
2188
+
2189
+ function moduleApi(module, path, options = {}) {
2190
+ return cachedModuleRequest(module, `api:${path}`, () => api(path), options);
2191
+ }
2192
+
2193
+ function moduleApiPage(module, path, page = 1, limit = 30, options = {}) {
2194
+ return cachedModuleRequest(module, `page:${path}:${page}:${limit}`, () => apiPage(path, page, limit), options);
2195
+ }
2196
+
2197
+ function moduleApiAllPages(module, path, limit = 100, options = {}) {
2198
+ return cachedModuleRequest(module, `all:${path}:${limit}`, () => apiAllPages(path, limit), options);
2199
+ }
2200
+
2201
+ function invalidateModuleRequestsAfterMutation(path, method) {
2202
+ if (["GET", "HEAD", "OPTIONS"].includes(method) || !state.work) return;
2203
+ if (
2204
+ path.startsWith("/api/auth/")
2205
+ || path.includes("/presence")
2206
+ || path.includes("/context/prepare")
2207
+ || path.includes("/ai-context-usage")
2208
+ || path.includes("/chat/stream")
2209
+ ) return;
2210
+
2211
+ const affected = new Set();
2212
+ if (path.includes("/ai-settings") || path.includes("/task-defaults") || path.includes("/providers") || path.includes("/models")) {
2213
+ affected.add("ai-settings");
2214
+ }
2215
+ if (path.includes("/tasks")) affected.add("tasks");
2216
+ if (path.includes("/drafts")) affected.add("drafts");
2217
+ if (path.includes("/settings") && !path.includes("/ai-settings")) affected.add("settings");
2218
+ if (path.includes("/characters") || path.includes("/character-sections")) affected.add("characters");
2219
+ if (path.includes("/races")) affected.add("races");
2220
+ if (path.includes("/organizations")) affected.add("organizations");
2221
+ if (path.includes("/timeline")) affected.add("timeline");
2222
+ if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
2223
+ if (path.includes("/relationships")) affected.add("relationships");
2224
+ if (path.includes("/reviews")) affected.add("reviews");
2225
+ if (path.includes("/entity-versions/")) {
2226
+ if (path.includes("/draft/")) affected.add("drafts");
2227
+ if (path.includes("/setting/")) affected.add("settings");
2228
+ if (path.includes("/character/")) affected.add("characters");
2229
+ if (path.includes("/race/")) affected.add("races");
2230
+ if (path.includes("/organization/")) affected.add("organizations");
2231
+ if (path.includes("/timeline-event/") || path.includes("/timeline-track/")) affected.add("timeline");
2232
+ if (path.includes("/chapter-outline/") || path.includes("/foreshadow/")) affected.add("outlines");
2233
+ if (path.includes("/relationship/")) affected.add("relationships");
2234
+ if (path.includes("/review/")) affected.add("reviews");
2235
+ }
2236
+ if (cachedWorkModules.has(state.module)) affected.add(state.module);
2237
+ affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
2238
+ }
2239
+
2158
2240
  async function initializeProductFooters() {
2159
2241
  const year = String(new Date().getFullYear());
2160
2242
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
@@ -3345,11 +3427,15 @@ function resetWorkScopedUiCaches() {
3345
3427
  state.races = [];
3346
3428
  characterListPage = 1;
3347
3429
  draftTypeFilter = "all";
3430
+ draftFiltersPanelOpen = false;
3348
3431
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3349
3432
  relationshipFilters.fromCharacterIds = [];
3350
3433
  relationshipFilters.toCharacterIds = [];
3351
3434
  taskListPage = 1;
3352
3435
  state.collapsedVolumeIds.clear();
3436
+ loadedVolumeChapterIds.clear();
3437
+ volumeChapterLoadingIds.clear();
3438
+ volumeChapterRequests.clear();
3353
3439
  state.collapsedRaceIds.clear();
3354
3440
  lastSavedChapterSnapshot = null;
3355
3441
  if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
@@ -3373,7 +3459,13 @@ function resetWorkScopedUiCaches() {
3373
3459
  async function selectWork(workId, preferredChapterId = null) {
3374
3460
  const discarding = state.work?.id !== workId && state.dirty;
3375
3461
  if (discarding && !(await confirmDiscardChanges())) return false;
3376
- const nextWork = await api(`/api/works/${workId}`);
3462
+ if (state.work?.id === workId) {
3463
+ workScopedUiGeneration += 1;
3464
+ loadedVolumeChapterIds.clear();
3465
+ volumeChapterLoadingIds.clear();
3466
+ volumeChapterRequests.clear();
3467
+ }
3468
+ const nextWork = await api(`/api/works/${workId}?directory=volumes`);
3377
3469
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3378
3470
  if (discarding) setSaveState("就绪");
3379
3471
  $("#app").classList.remove("shelf-mode");
@@ -3384,6 +3476,8 @@ async function selectWork(workId, preferredChapterId = null) {
3384
3476
  $("#settings-button").removeAttribute("aria-current");
3385
3477
  settingsReturnContext = null;
3386
3478
  state.work = nextWork;
3479
+ state.work.volumes = state.work.volumes.map((volume) => ({ ...volume, chapters: Array.isArray(volume.chapters) ? volume.chapters : [] }));
3480
+ state.collapsedVolumeIds = new Set(state.work.volumes.map((volume) => volume.id));
3387
3481
  state.chapter = null;
3388
3482
  chapterEditorReadOnly = true;
3389
3483
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
@@ -3393,40 +3487,121 @@ async function selectWork(workId, preferredChapterId = null) {
3393
3487
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
3394
3488
  $("#top-search-button").disabled = !canReadAggregateContent();
3395
3489
  renderTree();
3396
- const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
3397
- const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
3490
+ void loadAllVolumeChapters(nextWork.id);
3398
3491
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
3399
- else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
3400
3492
  else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
3401
3493
  else if (!canReadModule(state.module)) showWelcome(true);
3402
3494
  else await showModule(state.module);
3403
3495
  return true;
3404
3496
  }
3405
3497
 
3498
+ async function loadVolumeChapters(volumeId) {
3499
+ if (!state.work || loadedVolumeChapterIds.has(volumeId)) return;
3500
+ const existingRequest = volumeChapterRequests.get(volumeId);
3501
+ if (existingRequest) return existingRequest;
3502
+ const workId = state.work.id;
3503
+ const generation = workScopedUiGeneration;
3504
+ volumeChapterLoadingIds.add(volumeId);
3505
+ renderTree();
3506
+ const request = (async () => {
3507
+ try {
3508
+ const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
3509
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
3510
+ const volume = state.work.volumes.find((item) => item.id === volumeId);
3511
+ if (!volume) return;
3512
+ volume.chapters = chapters;
3513
+ volume.chapterCount = chapters.length;
3514
+ loadedVolumeChapterIds.add(volumeId);
3515
+ renderTree();
3516
+ } catch (error) {
3517
+ if (state.work?.id === workId && generation === workScopedUiGeneration) {
3518
+ toast(`加载分卷章节失败:${error.message}`, "error");
3519
+ renderTree();
3520
+ }
3521
+ } finally {
3522
+ volumeChapterLoadingIds.delete(volumeId);
3523
+ volumeChapterRequests.delete(volumeId);
3524
+ if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
3525
+ }
3526
+ })();
3527
+ volumeChapterRequests.set(volumeId, request);
3528
+ return request;
3529
+ }
3530
+
3531
+ async function loadAllVolumeChapters(workId) {
3532
+ const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
3533
+ for (const volumeId of volumeIds) {
3534
+ if (state.work?.id !== workId) return;
3535
+ await loadVolumeChapters(volumeId);
3536
+ }
3537
+ }
3538
+
3539
+ function mergeChapterDirectoryEntry(chapter) {
3540
+ if (!state.work || !chapter?.volumeId) return;
3541
+ const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
3542
+ if (!volume) return;
3543
+ const directoryEntry = {
3544
+ id: chapter.id,
3545
+ workId: chapter.workId,
3546
+ volumeId: chapter.volumeId,
3547
+ title: chapter.title,
3548
+ chapterType: chapter.chapterType,
3549
+ sortOrder: chapter.sortOrder,
3550
+ wordCount: chapter.wordCount,
3551
+ versionNo: chapter.versionNo,
3552
+ analysisStatus: chapter.analysisStatus,
3553
+ excludedFromAnalysis: chapter.excludedFromAnalysis,
3554
+ createdAt: chapter.createdAt,
3555
+ updatedAt: chapter.updatedAt
3556
+ };
3557
+ const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
3558
+ const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
3559
+ if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
3560
+ else chapters.push(directoryEntry);
3561
+ chapters.sort((left, right) => Number(left.sortOrder) - Number(right.sortOrder));
3562
+ volume.chapters = chapters;
3563
+ volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
3564
+ }
3565
+
3406
3566
  function renderTree() {
3407
3567
  if (!state.work) return;
3408
- const count = state.work.volumes.reduce((total, volume) => total + volume.chapters.length, 0);
3568
+ const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
3409
3569
  const proseEditable = canEditProse();
3410
3570
  $("#chapter-count").textContent = `${count} 章`;
3411
3571
  $("#novel-tree").classList.remove("empty-copy");
3412
- $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
3413
- <div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
3572
+ $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
3573
+ const collapsed = state.collapsedVolumeIds.has(volume.id);
3574
+ const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
3575
+ const chapterContent = collapsed
3576
+ ? ""
3577
+ : volumeChapterLoadingIds.has(volume.id)
3578
+ ? '<p class="entity-history-empty">正在加载章节……</p>'
3579
+ : !loadedVolumeChapterIds.has(volume.id)
3580
+ ? '<p class="entity-history-empty">展开后加载章节。</p>'
3581
+ : chapters.length
3582
+ ? chapters.map((chapter) => `
3583
+ <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+方向键跨卷" : ""}">
3584
+ <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>
3585
+ </button>`).join("")
3586
+ : '<p class="entity-history-empty">本卷还没有章节。</p>';
3587
+ return `
3588
+ <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
3414
3589
  <div class="volume-title">
3415
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
3590
+ <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>
3416
3591
  ${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>` : ""}
3417
3592
  </div>
3418
3593
  <div class="volume-chapters">
3419
- ${volume.chapters.map((chapter) => `
3420
- <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+方向键跨卷" : ""}">
3421
- <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>
3422
- </button>`).join("")}
3594
+ ${chapterContent}
3423
3595
  </div>
3424
- </div>`).join("");
3596
+ </div>`;
3597
+ }).join("");
3425
3598
  $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
3426
3599
  button.addEventListener("click", () => {
3427
3600
  const volumeId = button.dataset.volumeToggle;
3428
- if (state.collapsedVolumeIds.has(volumeId)) state.collapsedVolumeIds.delete(volumeId);
3429
- else state.collapsedVolumeIds.add(volumeId);
3601
+ if (state.collapsedVolumeIds.has(volumeId)) {
3602
+ state.collapsedVolumeIds.delete(volumeId);
3603
+ void loadVolumeChapters(volumeId);
3604
+ } else state.collapsedVolumeIds.add(volumeId);
3430
3605
  renderTree();
3431
3606
  });
3432
3607
  button.addEventListener("contextmenu", (event) => {
@@ -3446,7 +3621,7 @@ function renderTree() {
3446
3621
  button.closest(".volume-node")?.classList.remove("is-drag-target");
3447
3622
  const chapterId = event.dataTransfer?.getData("text/plain");
3448
3623
  const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
3449
- if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
3624
+ if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, Number(volume.chapterCount ?? volume.chapters.length));
3450
3625
  });
3451
3626
  }
3452
3627
  });
@@ -3715,7 +3890,11 @@ async function deleteChapter(chapterId) {
3715
3890
  async function selectChapter(chapterId, { editMode = false } = {}) {
3716
3891
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
3717
3892
  cancelChapterAutoSave();
3718
- state.chapter = await api(`/api/chapters/${chapterId}`);
3893
+ if (state.chapter?.id !== chapterId) {
3894
+ state.chapter = await api(`/api/chapters/${chapterId}`);
3895
+ mergeChapterDirectoryEntry(state.chapter);
3896
+ }
3897
+ state.collapsedVolumeIds.delete(state.chapter.volumeId);
3719
3898
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
3720
3899
  chapterEditorReadOnly = !canEditProse() || !editMode;
3721
3900
  state.module = "editor";
@@ -4152,6 +4331,15 @@ function mountRelationshipFilterToggle() {
4152
4331
  });
4153
4332
  }
4154
4333
 
4334
+ function mountDraftFilterToggle() {
4335
+ $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
4336
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选草稿" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" title="筛选草稿"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
4337
+ $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
4338
+ draftFiltersPanelOpen = !draftFiltersPanelOpen;
4339
+ await renderDrafts(moduleListPages.drafts);
4340
+ });
4341
+ }
4342
+
4155
4343
  function bindRecordPreview(selector, open) {
4156
4344
  $("#module-content").querySelectorAll(selector).forEach((card) => {
4157
4345
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -4246,28 +4434,42 @@ function draftTypeLabel(draftType) {
4246
4434
 
4247
4435
  async function deleteDraft(item) {
4248
4436
  if (!item || !canEditModule("drafts")) return;
4437
+ const dialog = $("#form-dialog");
4438
+ dialog.close();
4249
4439
  if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4250
4440
  title: "删除草稿",
4251
4441
  confirmLabel: "确认删除"
4252
- })) return;
4442
+ })) {
4443
+ openDraftDialog(item);
4444
+ return;
4445
+ }
4253
4446
  try {
4254
4447
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4255
4448
  await renderDrafts(moduleListPages.drafts);
4256
4449
  toast("草稿已删除");
4257
4450
  } catch (error) {
4258
4451
  toast(error.message, "error");
4452
+ try {
4453
+ openDraftDialog(await api(`/api/drafts/${encodeURIComponent(item.id)}`));
4454
+ } catch (reloadError) {
4455
+ toast(reloadError.message, "error");
4456
+ }
4259
4457
  }
4260
4458
  }
4261
4459
 
4262
4460
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4263
4461
  const viewOnly = readOnly || !canEditModule("drafts");
4462
+ const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
4463
+ <div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
4464
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
4465
+ </section>` : "";
4264
4466
  const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4265
4467
  + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4266
4468
  + field("title", "标题", "text", item?.title ?? "")
4267
4469
  + field("content", "内容", "markdown", item?.content ?? "", {
4268
4470
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
4269
4471
  readOnly: viewOnly
4270
- });
4472
+ }) + management;
4271
4473
  openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4272
4474
  if (viewOnly) return;
4273
4475
  const title = String(form.get("title") ?? "").trim();
@@ -4287,7 +4489,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4287
4489
  }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4288
4490
  submitLabel: viewOnly ? "关闭" : "保存草稿",
4289
4491
  hideCancel: viewOnly,
4290
- wide: true,
4492
+ editor: true,
4291
4493
  errorPrefix: "草稿保存失败:"
4292
4494
  });
4293
4495
  if (viewOnly) {
@@ -4296,10 +4498,13 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4296
4498
  else control.readOnly = true;
4297
4499
  });
4298
4500
  }
4501
+ $("#dialog-fields").querySelector("[data-dialog-draft-delete]")?.addEventListener("click", () => {
4502
+ void deleteDraft(item);
4503
+ });
4299
4504
  }
4300
4505
 
4301
4506
  async function renderDrafts(page = moduleListPages.drafts) {
4302
- const allDrafts = await apiAllPages(`/api/works/${state.work.id}/drafts`);
4507
+ const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
4303
4508
  const drafts = draftTypeFilter === "all"
4304
4509
  ? allDrafts
4305
4510
  : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
@@ -4309,7 +4514,8 @@ async function renderDrafts(page = moduleListPages.drafts) {
4309
4514
  const layout = readModuleLayout();
4310
4515
  if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4311
4516
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4312
- const filterToolbar = `<section class="draft-filter-toolbar" aria-label="草稿筛选">
4517
+ mountDraftFilterToggle();
4518
+ const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4313
4519
  <label for="draft-type-filter">草稿类型</label>
4314
4520
  <select id="draft-type-filter" aria-label="按草稿类型筛选">
4315
4521
  <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
@@ -4319,7 +4525,7 @@ async function renderDrafts(page = moduleListPages.drafts) {
4319
4525
  ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4320
4526
  </section>`;
4321
4527
  const actions = (item) => canEditModule("drafts")
4322
- ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}<button type="button" data-delete-draft="${esc(item.id)}">删除</button>${recordHistoryButton("draft", item.id, item.title)}`
4528
+ ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4323
4529
  : recordHistoryButton("draft", item.id, item.title);
4324
4530
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4325
4531
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
@@ -4344,12 +4550,12 @@ async function renderDrafts(page = moduleListPages.drafts) {
4344
4550
  : emptyDrafts);
4345
4551
  $("#draft-type-filter").addEventListener("change", async (event) => {
4346
4552
  draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
4553
+ draftFiltersPanelOpen = true;
4347
4554
  moduleListPages.drafts = 1;
4348
4555
  await renderDrafts(1);
4349
4556
  });
4350
4557
  bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4351
4558
  bindModulePagination("drafts", renderDrafts);
4352
- const draftById = (draftId) => drafts.find((draft) => draft.id === draftId);
4353
4559
  $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
4354
4560
  const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
4355
4561
  card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
@@ -4363,9 +4569,6 @@ async function renderDrafts(page = moduleListPages.drafts) {
4363
4569
  $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
4364
4570
  openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
4365
4571
  }));
4366
- $("#module-content").querySelectorAll("[data-delete-draft]").forEach((button) => button.addEventListener("click", () => {
4367
- void deleteDraft(draftById(button.dataset.deleteDraft));
4368
- }));
4369
4572
  bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
4370
4573
  }
4371
4574
 
@@ -4387,7 +4590,7 @@ function renderSettingRows(records) {
4387
4590
  }
4388
4591
 
4389
4592
  async function renderSettings(page = moduleListPages.settings) {
4390
- const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
4593
+ const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
4391
4594
  state.settings = records;
4392
4595
  mountModuleCount(records.length);
4393
4596
  const pageResult = paginateModuleItems(records, page, "settings");
@@ -4408,9 +4611,11 @@ async function renderCharacters(page = characterListPage) {
4408
4611
  const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
4409
4612
  const pageSize = pageSizeFor("characters");
4410
4613
  const [characterSource, races, organizations] = await Promise.all([
4411
- hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
4412
- canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4413
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4614
+ hasCharacterFilters
4615
+ ? moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
4616
+ : moduleApiPage("characters", `/api/works/${state.work.id}/characters`, page, pageSize),
4617
+ canReadModule("races") ? moduleApi("characters", `/api/works/${state.work.id}/races`) : Promise.resolve([]),
4618
+ canReadModule("organizations") ? moduleApiAllPages("characters", `/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4414
4619
  ]);
4415
4620
  const characterPage = hasCharacterFilters
4416
4621
  ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
@@ -4586,7 +4791,7 @@ async function renderRaces() {
4586
4791
  const workId = state.work.id;
4587
4792
  const generation = workScopedUiGeneration;
4588
4793
  const requestId = ++raceListRequestId;
4589
- const roots = await api(`/api/works/${workId}/races?scope=roots`);
4794
+ const roots = await moduleApi("races", `/api/works/${workId}/races?scope=roots`);
4590
4795
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4591
4796
  state.races = roots.items;
4592
4797
  loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
@@ -4594,7 +4799,7 @@ async function renderRaces() {
4594
4799
  if (loadedRaceHierarchyWorkId === workId) return;
4595
4800
 
4596
4801
  const dismissLoadingToast = persistentToast("正在加载子种族……");
4597
- const loadPromise = api(`/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4802
+ const loadPromise = moduleApi("races", `/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4598
4803
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4599
4804
  state.races = [...roots.items, ...descendants];
4600
4805
  loadedRaceHierarchyWorkId = workId;
@@ -4618,8 +4823,8 @@ async function renderRaces() {
4618
4823
 
4619
4824
  async function renderOrganizations(page = moduleListPages.organizations) {
4620
4825
  [state.organizations, state.characters] = await Promise.all([
4621
- apiAllPages(`/api/works/${state.work.id}/organizations`),
4622
- canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
4826
+ moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`),
4827
+ canReadModule("characters") ? moduleApiAllPages("organizations", `/api/works/${state.work.id}/characters`) : Promise.resolve([])
4623
4828
  ]);
4624
4829
  mountModuleCount(state.organizations.length);
4625
4830
  const pageResult = paginateModuleItems(state.organizations, page, "organizations");
@@ -4685,8 +4890,8 @@ function setTimelineMultiSelectMode(enabled) {
4685
4890
 
4686
4891
  async function renderTimeline(page = moduleListPages.timeline) {
4687
4892
  const [events, tracks] = await Promise.all([
4688
- apiAllPages(`/api/works/${state.work.id}/timeline`),
4689
- apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
4893
+ moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline`),
4894
+ moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline-tracks`)
4690
4895
  ]);
4691
4896
  mountModuleCount(events.length);
4692
4897
  const pageResult = paginateModuleItems(events, page, "timeline");
@@ -4729,8 +4934,8 @@ async function renderTimeline(page = moduleListPages.timeline) {
4729
4934
  async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
4730
4935
  const currentChapterId = state.chapter?.id;
4731
4936
  const [outlines, foreshadows] = await Promise.all([
4732
- apiAllPages(`/api/works/${state.work.id}/outlines`),
4733
- apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
4937
+ moduleApiAllPages("outlines", `/api/works/${state.work.id}/outlines`),
4938
+ moduleApiAllPages("outlines", `/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
4734
4939
  ]);
4735
4940
  mountModuleCount(outlines.length + foreshadows.length);
4736
4941
  const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
@@ -4783,8 +4988,8 @@ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foresh
4783
4988
  }
4784
4989
 
4785
4990
  async function renderRelationships(page = moduleListPages.relationships) {
4786
- state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4787
- const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
4991
+ state.characters = canReadModule("characters") ? await moduleApiAllPages("relationships", `/api/works/${state.work.id}/characters`) : [];
4992
+ const relationships = await moduleApiAllPages("relationships", `/api/works/${state.work.id}/relationships`);
4788
4993
  const filteredRelationships = filterRelationships(relationships, relationshipFilters);
4789
4994
  const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
4790
4995
  moduleListPages.relationships = pageResult.page;
@@ -4858,8 +5063,8 @@ async function renderReviews(page = moduleListPages.reviews) {
4858
5063
  const canMergeCharacters = canResolveReview
4859
5064
  && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
4860
5065
  const [reviews, characters] = await Promise.all([
4861
- apiAllPages(`/api/works/${state.work.id}/reviews`),
4862
- canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
5066
+ moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`),
5067
+ canReadCharacters ? moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
4863
5068
  ]);
4864
5069
  mountModuleCount(reviews.length);
4865
5070
  const pageResult = paginateModuleItems(reviews, page, "reviews");
@@ -4947,16 +5152,16 @@ async function renderReviews(page = moduleListPages.reviews) {
4947
5152
  }));
4948
5153
  }
4949
5154
 
4950
- async function renderTasks(page = taskListPage) {
5155
+ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
4951
5156
  stopTaskProgressRefresh();
4952
5157
  const pageSize = pageSizeFor("analysisTasks");
4953
5158
  const [taskPage, settings] = await Promise.all([
4954
- apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
5159
+ moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
4955
5160
  canReadModule("ai-settings")
4956
- ? api(`/api/works/${state.work.id}/ai-settings`)
5161
+ ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
4957
5162
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
4958
5163
  ]);
4959
- if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
5164
+ if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
4960
5165
  taskListPage = taskPage.page;
4961
5166
  const tasks = taskPage.items;
4962
5167
  const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
@@ -5067,7 +5272,7 @@ async function renderTasks(page = taskListPage) {
5067
5272
  : "自动执行已关闭");
5068
5273
  taskAutoRunEditing = false;
5069
5274
  taskAutoRunEditingWorkId = null;
5070
- await renderTasks();
5275
+ await renderTasks(taskListPage, { refresh: true });
5071
5276
  } catch (error) {
5072
5277
  toast(error.message, "error");
5073
5278
  button.disabled = false;
@@ -5226,7 +5431,7 @@ function scheduleTaskProgressRefresh(workId, runningCount) {
5226
5431
  return;
5227
5432
  }
5228
5433
  try {
5229
- await renderTasks();
5434
+ await renderTasks(taskListPage, { refresh: true });
5230
5435
  } catch (error) {
5231
5436
  console.error("Failed to refresh task progress", error);
5232
5437
  scheduleTaskProgressRefresh(workId, runningCount);
@@ -6106,12 +6311,12 @@ async function renderBookAiSettings() {
6106
6311
  relationshipSearchIndexRefreshTimer = null;
6107
6312
  }
6108
6313
  const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
6109
- api(`/api/works/${state.work.id}/ai-settings`),
6110
- api("/api/platform/ai/providers"),
6111
- api(`/api/works/${state.work.id}/models`),
6112
- api(`/api/works/${state.work.id}/task-defaults`),
6113
- api(`/api/works/${state.work.id}/ai-settings/relationship-search-index`),
6114
- api(`/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
6314
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
6315
+ moduleApi("ai-settings", "/api/platform/ai/providers"),
6316
+ moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
6317
+ moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
6318
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
6319
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
6115
6320
  ]);
6116
6321
  const host = $("#module-content");
6117
6322
  const workId = String(state.work.id);
@@ -6641,6 +6846,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6641
6846
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
6642
6847
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
6643
6848
  dialog.classList.toggle("large-dialog", Boolean(options.large));
6849
+ dialog.classList.toggle("editor-dialog", Boolean(options.editor));
6644
6850
  bindDynamicListControls($("#dialog-fields"));
6645
6851
  bindRelationshipKeywordControls($("#dialog-fields"));
6646
6852
  formDialogVditors = bindVditorEditors($("#dialog-fields"));
@@ -8758,23 +8964,30 @@ async function sendAi() {
8758
8964
  let assistantContent = "";
8759
8965
  let assistantMessage;
8760
8966
  let assistantMetadata = {};
8967
+ let persistedStreamMessage = null;
8761
8968
  let suggestion = null;
8762
8969
  if (taskType === "chat") {
8763
8970
  const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
8764
8971
  assistantContent = streamed.content;
8765
8972
  assistantMessage = streamed.message;
8766
8973
  assistantMetadata = streamed.metadata;
8974
+ persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
8767
8975
  } else {
8768
8976
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
8769
8977
  assistantContent = suggestion.content;
8770
8978
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
8771
8979
  }
8772
8980
  try {
8773
- const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
8774
- if (assistantMessage) {
8775
- updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
8776
- attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
8777
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
8981
+ if (persistedStreamMessage) {
8982
+ updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
8983
+ attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
8984
+ } else {
8985
+ const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
8986
+ if (assistantMessage) {
8987
+ updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
8988
+ attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
8989
+ } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
8990
+ }
8778
8991
  } catch (error) {
8779
8992
  if (suggestion) appendSuggestion(suggestion);
8780
8993
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
@@ -8814,6 +9027,8 @@ async function streamChat(body) {
8814
9027
  let generatedMetadata = {};
8815
9028
  let toolCalls = [];
8816
9029
  let processSteps = [];
9030
+ let persistedMessageId = null;
9031
+ let persistedMessageCreatedAt = null;
8817
9032
  let finalAnswerStarted = false;
8818
9033
  const processStartedAt = Date.now();
8819
9034
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -8868,6 +9083,8 @@ async function streamChat(body) {
8868
9083
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
8869
9084
  scrollAiFeedToBottom();
8870
9085
  } else if (eventName === "complete") {
9086
+ persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9087
+ persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
8871
9088
  await typewriter.finish();
8872
9089
  message.classList.remove("is-streaming");
8873
9090
  content.setAttribute("aria-busy", "false");
@@ -8895,7 +9112,7 @@ async function streamChat(body) {
8895
9112
  if (buffer.trim()) await consume(buffer);
8896
9113
  await typewriter.finish();
8897
9114
  if (streamError) throw streamError;
8898
- return { content: streamedText, message, metadata: generatedMetadata };
9115
+ return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt };
8899
9116
  } catch (error) {
8900
9117
  typewriter.reveal();
8901
9118
  message.classList.remove("is-streaming");