@musnows/scriverse 0.5.10 → 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.
@@ -1,6 +1,6 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
3
+ import { renderMarkdown } from "/markdown.js?v=20260730-table-wrap-menu-v1";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
@@ -8,6 +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=20260730-ai-stream-typewriter-v3";
11
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
12
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
13
14
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
@@ -15,7 +16,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
15
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
16
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
17
18
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
18
- import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260725-enum-labels-zh";
19
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260729-drafts-v1";
19
20
  import {
20
21
  chapterVersionSourceLabel,
21
22
  foreshadowStatusLabel,
@@ -35,18 +36,19 @@ import {
35
36
  timelineStatusLabel,
36
37
  characterStateFieldLabel
37
38
  } from "/display-labels.js?v=20260728-hybrid-search-v1";
38
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
39
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
39
40
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
40
41
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
41
42
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
42
43
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
43
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
44
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260729-drafts-v1";
44
45
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
45
46
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
46
47
  import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
47
48
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
48
49
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
49
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";
50
52
  import {
51
53
  clampCropRect,
52
54
  containImageRect,
@@ -59,6 +61,7 @@ import {
59
61
  } from "/avatar-crop.js?v=20260725-avatar-crop";
60
62
 
61
63
  const defaultPageSizes = Object.freeze({
64
+ drafts: 30,
62
65
  settings: 30,
63
66
  characters: 30,
64
67
  races: 30,
@@ -115,6 +118,21 @@ const state = {
115
118
  contextChapterId: null
116
119
  };
117
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
+
118
136
  function createPresenceClientId() {
119
137
  if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
120
138
  const bytes = crypto.getRandomValues(new Uint8Array(16));
@@ -369,6 +387,9 @@ let aiReferencesLoadWorkId = null;
369
387
  let aiConversationsLoadPromise = null;
370
388
  let aiConversationsLoadWorkId = null;
371
389
  let workScopedUiGeneration = 0;
390
+ const loadedVolumeChapterIds = new Set();
391
+ const volumeChapterLoadingIds = new Set();
392
+ const volumeChapterRequests = new Map();
372
393
  let raceHierarchyLoadPromise = null;
373
394
  let raceHierarchyLoadWorkId = null;
374
395
  let loadedRaceHierarchyWorkId = null;
@@ -871,7 +892,10 @@ let entityEditorReadOnly = false;
871
892
  let chapterEditorReadOnly = true;
872
893
  let characterListPage = 1;
873
894
  let taskListPage = 1;
895
+ let draftTypeFilter = "all";
896
+ let draftFiltersPanelOpen = false;
874
897
  const moduleListPages = {
898
+ drafts: 1,
875
899
  settings: 1,
876
900
  organizations: 1,
877
901
  timeline: 1,
@@ -901,6 +925,7 @@ let characterSectionEditorDirty = false;
901
925
  let settingEditorVditor = null;
902
926
  let knowledgeSectionVditor = null;
903
927
  let characterSectionVditor = null;
928
+ let formDialogVditors = [];
904
929
  let entityHistoryContext = null;
905
930
  let moduleContentInteractionsBound = false;
906
931
 
@@ -1367,7 +1392,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1367
1392
  read_chapters: "读取章节",
1368
1393
  grep: "查询正文关键字",
1369
1394
  search_story_entities: "搜索作品实体",
1370
- read_character_sections: "读取人物 Markdown 章节"
1395
+ read_character_sections: "读取人物 Markdown 章节",
1396
+ search_drafts: "搜索草稿"
1371
1397
  };
1372
1398
 
1373
1399
  const AI_TOOL_DESCRIPTIONS = {
@@ -1375,10 +1401,50 @@ const AI_TOOL_DESCRIPTIONS = {
1375
1401
  read_chapters: "读取指定章节的概要、正文或两者。",
1376
1402
  grep: "查询正文关键字所在的完整段落及章节信息。",
1377
1403
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1378
- read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
1404
+ read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1405
+ search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
1379
1406
  };
1380
1407
 
1381
1408
  let aiFeedScrollFrame = null;
1409
+ let markdownTableMenuTarget = null;
1410
+ let markdownTableMenuTrigger = null;
1411
+
1412
+ function closeMarkdownTableMenu(restoreFocus = false) {
1413
+ const trigger = markdownTableMenuTrigger;
1414
+ $("#markdown-table-menu").classList.add("hidden");
1415
+ markdownTableMenuTarget = null;
1416
+ markdownTableMenuTrigger = null;
1417
+ if (restoreFocus && trigger?.isConnected) trigger.focus();
1418
+ }
1419
+
1420
+ function setMarkdownTableWrapping(table, wrapping) {
1421
+ table.classList.toggle("is-wrapping", wrapping);
1422
+ table.setAttribute("aria-label", wrapping ? "Markdown 表格,内容自动换行" : "Markdown 表格,内容不换行");
1423
+ }
1424
+
1425
+ function openMarkdownTableMenu(header, clientX, clientY) {
1426
+ const table = header.closest(".markdown-table-scroll");
1427
+ if (!table) return;
1428
+ if (header.closest(".is-streaming")) {
1429
+ toast("回复生成完成后可以设置表格换行");
1430
+ return;
1431
+ }
1432
+ closeChapterTypeMenu();
1433
+ closeLineCitationMenu();
1434
+ markdownTableMenuTarget = table;
1435
+ markdownTableMenuTrigger = header;
1436
+ const menu = $("#markdown-table-menu");
1437
+ const toggle = $("#markdown-table-wrap-toggle");
1438
+ toggle.setAttribute("aria-checked", String(table.classList.contains("is-wrapping")));
1439
+ menu.classList.remove("hidden");
1440
+ const menuRect = menu.getBoundingClientRect();
1441
+ const headerRect = header.getBoundingClientRect();
1442
+ const anchorX = clientX > 0 ? clientX : headerRect.left;
1443
+ const anchorY = clientY > 0 ? clientY : headerRect.bottom;
1444
+ menu.style.left = `${Math.max(8, Math.min(anchorX, window.innerWidth - menuRect.width - 8))}px`;
1445
+ menu.style.top = `${Math.max(8, Math.min(anchorY, window.innerHeight - menuRect.height - 8))}px`;
1446
+ toggle.focus();
1447
+ }
1382
1448
 
1383
1449
  function scrollAiFeedToBottom() {
1384
1450
  const feed = $("#ai-feed");
@@ -2080,14 +2146,19 @@ async function api(path, options = {}) {
2080
2146
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2081
2147
  state.user = null;
2082
2148
  state.csrfToken = null;
2149
+ moduleRequestCache.clear();
2083
2150
  showAuth(false);
2084
2151
  }
2085
2152
  const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
2086
2153
  error.code = payload.error?.code;
2087
2154
  throw error;
2088
2155
  }
2089
- if (response.status === 204) return null;
2156
+ if (response.status === 204) {
2157
+ invalidateModuleRequestsAfterMutation(path, method);
2158
+ return null;
2159
+ }
2090
2160
  const payload = await response.json();
2161
+ invalidateModuleRequestsAfterMutation(path, method);
2091
2162
  return payload.data;
2092
2163
  }
2093
2164
 
@@ -2109,6 +2180,63 @@ async function apiAllPages(path, limit = 100) {
2109
2180
  }
2110
2181
  }
2111
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
+
2112
2240
  async function initializeProductFooters() {
2113
2241
  const year = String(new Date().getFullYear());
2114
2242
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
@@ -2706,6 +2834,7 @@ function renderSettingsHub() {
2706
2834
  const hasWork = Boolean(state.work);
2707
2835
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
2708
2836
  const canReadAggregate = hasWork && canReadAggregateContent();
2837
+ const canReadFullExport = canReadAggregate && canReadModule("drafts");
2709
2838
  const isAdmin = state.user?.role === "admin";
2710
2839
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
2711
2840
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -2715,7 +2844,7 @@ function renderSettingsHub() {
2715
2844
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
2716
2845
  $("#work-audit-button").disabled = !canManageWork;
2717
2846
  $("#top-search-button").disabled = !canReadAggregate;
2718
- $("#export-button").disabled = !canReadAggregate;
2847
+ $("#export-button").disabled = !canReadFullExport;
2719
2848
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
2720
2849
  $("#settings-work-note").textContent = hasWork
2721
2850
  ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
@@ -2796,11 +2925,15 @@ const workAuditActionLabels = {
2796
2925
  "chapter.deleted": "删除章节",
2797
2926
  "chapter.purged": "彻底删除章节",
2798
2927
  "chapter.restored": "恢复章节",
2928
+ "draft.created": "创建草稿",
2929
+ "draft.updated": "更新草稿",
2930
+ "draft.deleted": "删除草稿",
2931
+ "draft.restored": "恢复草稿",
2799
2932
  "work.imported": "导入正文"
2800
2933
  };
2801
2934
 
2802
2935
  function workAuditEntityLabel(type) {
2803
- return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
2936
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
2804
2937
  }
2805
2938
 
2806
2939
  function workAuditDetailText(detail) {
@@ -2911,6 +3044,7 @@ async function openPlatformUiSettingsDialog() {
2911
3044
  const settings = await api("/api/platform/ui-settings");
2912
3045
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2913
3046
  const pageSizes = normalizePageSizes(settings.pageSizes);
3047
+ $("#page-size-drafts").value = String(pageSizes.drafts);
2914
3048
  $("#page-size-settings").value = String(pageSizes.settings);
2915
3049
  $("#page-size-characters").value = String(pageSizes.characters);
2916
3050
  $("#page-size-organizations").value = String(pageSizes.organizations);
@@ -3292,11 +3426,16 @@ function resetWorkScopedUiCaches() {
3292
3426
  state.settings = [];
3293
3427
  state.races = [];
3294
3428
  characterListPage = 1;
3429
+ draftTypeFilter = "all";
3430
+ draftFiltersPanelOpen = false;
3295
3431
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3296
3432
  relationshipFilters.fromCharacterIds = [];
3297
3433
  relationshipFilters.toCharacterIds = [];
3298
3434
  taskListPage = 1;
3299
3435
  state.collapsedVolumeIds.clear();
3436
+ loadedVolumeChapterIds.clear();
3437
+ volumeChapterLoadingIds.clear();
3438
+ volumeChapterRequests.clear();
3300
3439
  state.collapsedRaceIds.clear();
3301
3440
  lastSavedChapterSnapshot = null;
3302
3441
  if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
@@ -3320,7 +3459,13 @@ function resetWorkScopedUiCaches() {
3320
3459
  async function selectWork(workId, preferredChapterId = null) {
3321
3460
  const discarding = state.work?.id !== workId && state.dirty;
3322
3461
  if (discarding && !(await confirmDiscardChanges())) return false;
3323
- 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`);
3324
3469
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3325
3470
  if (discarding) setSaveState("就绪");
3326
3471
  $("#app").classList.remove("shelf-mode");
@@ -3331,6 +3476,8 @@ async function selectWork(workId, preferredChapterId = null) {
3331
3476
  $("#settings-button").removeAttribute("aria-current");
3332
3477
  settingsReturnContext = null;
3333
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));
3334
3481
  state.chapter = null;
3335
3482
  chapterEditorReadOnly = true;
3336
3483
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
@@ -3340,40 +3487,121 @@ async function selectWork(workId, preferredChapterId = null) {
3340
3487
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
3341
3488
  $("#top-search-button").disabled = !canReadAggregateContent();
3342
3489
  renderTree();
3343
- const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
3344
- const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
3490
+ void loadAllVolumeChapters(nextWork.id);
3345
3491
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
3346
- else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
3347
3492
  else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
3348
3493
  else if (!canReadModule(state.module)) showWelcome(true);
3349
3494
  else await showModule(state.module);
3350
3495
  return true;
3351
3496
  }
3352
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
+
3353
3566
  function renderTree() {
3354
3567
  if (!state.work) return;
3355
- 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);
3356
3569
  const proseEditable = canEditProse();
3357
3570
  $("#chapter-count").textContent = `${count} 章`;
3358
3571
  $("#novel-tree").classList.remove("empty-copy");
3359
- $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
3360
- <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)}">
3361
3589
  <div class="volume-title">
3362
- <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>
3363
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>` : ""}
3364
3592
  </div>
3365
3593
  <div class="volume-chapters">
3366
- ${volume.chapters.map((chapter) => `
3367
- <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+方向键跨卷" : ""}">
3368
- <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>
3369
- </button>`).join("")}
3594
+ ${chapterContent}
3370
3595
  </div>
3371
- </div>`).join("");
3596
+ </div>`;
3597
+ }).join("");
3372
3598
  $("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
3373
3599
  button.addEventListener("click", () => {
3374
3600
  const volumeId = button.dataset.volumeToggle;
3375
- if (state.collapsedVolumeIds.has(volumeId)) state.collapsedVolumeIds.delete(volumeId);
3376
- 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);
3377
3605
  renderTree();
3378
3606
  });
3379
3607
  button.addEventListener("contextmenu", (event) => {
@@ -3393,7 +3621,7 @@ function renderTree() {
3393
3621
  button.closest(".volume-node")?.classList.remove("is-drag-target");
3394
3622
  const chapterId = event.dataTransfer?.getData("text/plain");
3395
3623
  const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
3396
- 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));
3397
3625
  });
3398
3626
  }
3399
3627
  });
@@ -3662,7 +3890,11 @@ async function deleteChapter(chapterId) {
3662
3890
  async function selectChapter(chapterId, { editMode = false } = {}) {
3663
3891
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
3664
3892
  cancelChapterAutoSave();
3665
- 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);
3666
3898
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
3667
3899
  chapterEditorReadOnly = !canEditProse() || !editMode;
3668
3900
  state.module = "editor";
@@ -3730,6 +3962,7 @@ function markActiveModule(module) {
3730
3962
  }
3731
3963
 
3732
3964
  const moduleMeta = {
3965
+ drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
3733
3966
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
3734
3967
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
3735
3968
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -3785,6 +4018,7 @@ async function showModule(module) {
3785
4018
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
3786
4019
  bindModuleContentInteractions();
3787
4020
  try {
4021
+ if (module === "drafts") await renderDrafts();
3788
4022
  if (module === "settings") await renderSettings();
3789
4023
  if (module === "characters") await renderCharacters(characterListPage);
3790
4024
  if (module === "races") await renderRaces();
@@ -4097,6 +4331,15 @@ function mountRelationshipFilterToggle() {
4097
4331
  });
4098
4332
  }
4099
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
+
4100
4343
  function bindRecordPreview(selector, open) {
4101
4344
  $("#module-content").querySelectorAll(selector).forEach((card) => {
4102
4345
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -4185,6 +4428,150 @@ function settingRecordActions(item) {
4185
4428
  : recordHistoryButton("setting", item.id, item.title);
4186
4429
  }
4187
4430
 
4431
+ function draftTypeLabel(draftType) {
4432
+ return draftType === "setting" ? "设定草稿" : "正文草稿";
4433
+ }
4434
+
4435
+ async function deleteDraft(item) {
4436
+ if (!item || !canEditModule("drafts")) return;
4437
+ const dialog = $("#form-dialog");
4438
+ dialog.close();
4439
+ if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4440
+ title: "删除草稿",
4441
+ confirmLabel: "确认删除"
4442
+ })) {
4443
+ openDraftDialog(item);
4444
+ return;
4445
+ }
4446
+ try {
4447
+ await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4448
+ await renderDrafts(moduleListPages.drafts);
4449
+ toast("草稿已删除");
4450
+ } catch (error) {
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
+ }
4457
+ }
4458
+ }
4459
+
4460
+ function openDraftDialog(item = null, { readOnly = false } = {}) {
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>` : "";
4466
+ const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4467
+ + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4468
+ + field("title", "标题", "text", item?.title ?? "")
4469
+ + field("content", "内容", "markdown", item?.content ?? "", {
4470
+ placeholder: "记录尚未定稿的片段、方向或设定想法……",
4471
+ readOnly: viewOnly
4472
+ }) + management;
4473
+ openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4474
+ if (viewOnly) return;
4475
+ const title = String(form.get("title") ?? "").trim();
4476
+ if (!title) throw new Error("请填写草稿标题");
4477
+ const body = {
4478
+ draftType: form.get("draftType"),
4479
+ title,
4480
+ content: String(form.get("content") ?? ""),
4481
+ ...(item ? { expectedVersionNo: item.versionNo } : {})
4482
+ };
4483
+ await api(item ? `/api/drafts/${encodeURIComponent(item.id)}` : `/api/works/${encodeURIComponent(state.work.id)}/drafts`, {
4484
+ method: item ? "PATCH" : "POST",
4485
+ body
4486
+ });
4487
+ await renderDrafts(moduleListPages.drafts);
4488
+ toast(item ? "草稿已保存" : "草稿已创建");
4489
+ }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4490
+ submitLabel: viewOnly ? "关闭" : "保存草稿",
4491
+ hideCancel: viewOnly,
4492
+ editor: true,
4493
+ errorPrefix: "草稿保存失败:"
4494
+ });
4495
+ if (viewOnly) {
4496
+ $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
4497
+ if (control instanceof HTMLSelectElement) control.disabled = true;
4498
+ else control.readOnly = true;
4499
+ });
4500
+ }
4501
+ $("#dialog-fields").querySelector("[data-dialog-draft-delete]")?.addEventListener("click", () => {
4502
+ void deleteDraft(item);
4503
+ });
4504
+ }
4505
+
4506
+ async function renderDrafts(page = moduleListPages.drafts) {
4507
+ const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
4508
+ const drafts = draftTypeFilter === "all"
4509
+ ? allDrafts
4510
+ : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
4511
+ mountModuleCount(drafts.length);
4512
+ const pageResult = paginateModuleItems(drafts, page, "drafts");
4513
+ moduleListPages.drafts = pageResult.page;
4514
+ const layout = readModuleLayout();
4515
+ if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4516
+ else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4517
+ mountDraftFilterToggle();
4518
+ const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4519
+ <label for="draft-type-filter">草稿类型</label>
4520
+ <select id="draft-type-filter" aria-label="按草稿类型筛选">
4521
+ <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
4522
+ <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
4523
+ <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
4524
+ </select>
4525
+ ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4526
+ </section>`;
4527
+ const actions = (item) => canEditModule("drafts")
4528
+ ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4529
+ : recordHistoryButton("draft", item.id, item.title);
4530
+ const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4531
+ <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4532
+ <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4533
+ <h3>${esc(item.title)}</h3>
4534
+ <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
4535
+ <div class="card-actions">${actions(item)}</div>
4536
+ </article>`).join("")}</div>`;
4537
+ const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4538
+ const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4539
+ return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4540
+ <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4541
+ <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4542
+ <div class="card-actions">${actions(item)}</div>
4543
+ </article>`;
4544
+ }).join("")}</div>`;
4545
+ const emptyDrafts = allDrafts.length
4546
+ ? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
4547
+ : emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4548
+ $("#module-content").innerHTML = filterToolbar + (drafts.length
4549
+ ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
4550
+ : emptyDrafts);
4551
+ $("#draft-type-filter").addEventListener("change", async (event) => {
4552
+ draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
4553
+ draftFiltersPanelOpen = true;
4554
+ moduleListPages.drafts = 1;
4555
+ await renderDrafts(1);
4556
+ });
4557
+ bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4558
+ bindModulePagination("drafts", renderDrafts);
4559
+ $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
4560
+ const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
4561
+ card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
4562
+ card.addEventListener("keydown", (event) => {
4563
+ if ((event.key === "Enter" || event.key === " ") && !event.target.closest("button, a")) {
4564
+ event.preventDefault();
4565
+ void open();
4566
+ }
4567
+ });
4568
+ });
4569
+ $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
4570
+ openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
4571
+ }));
4572
+ bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
4573
+ }
4574
+
4188
4575
  function renderSettingCards(records) {
4189
4576
  return `<div class="card-grid">${records.map((item) => `
4190
4577
  <article class="record-card preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
@@ -4203,7 +4590,7 @@ function renderSettingRows(records) {
4203
4590
  }
4204
4591
 
4205
4592
  async function renderSettings(page = moduleListPages.settings) {
4206
- const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
4593
+ const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
4207
4594
  state.settings = records;
4208
4595
  mountModuleCount(records.length);
4209
4596
  const pageResult = paginateModuleItems(records, page, "settings");
@@ -4224,9 +4611,11 @@ async function renderCharacters(page = characterListPage) {
4224
4611
  const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
4225
4612
  const pageSize = pageSizeFor("characters");
4226
4613
  const [characterSource, races, organizations] = await Promise.all([
4227
- hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
4228
- canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4229
- 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([])
4230
4619
  ]);
4231
4620
  const characterPage = hasCharacterFilters
4232
4621
  ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
@@ -4402,7 +4791,7 @@ async function renderRaces() {
4402
4791
  const workId = state.work.id;
4403
4792
  const generation = workScopedUiGeneration;
4404
4793
  const requestId = ++raceListRequestId;
4405
- const roots = await api(`/api/works/${workId}/races?scope=roots`);
4794
+ const roots = await moduleApi("races", `/api/works/${workId}/races?scope=roots`);
4406
4795
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4407
4796
  state.races = roots.items;
4408
4797
  loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
@@ -4410,7 +4799,7 @@ async function renderRaces() {
4410
4799
  if (loadedRaceHierarchyWorkId === workId) return;
4411
4800
 
4412
4801
  const dismissLoadingToast = persistentToast("正在加载子种族……");
4413
- const loadPromise = api(`/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4802
+ const loadPromise = moduleApi("races", `/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4414
4803
  if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4415
4804
  state.races = [...roots.items, ...descendants];
4416
4805
  loadedRaceHierarchyWorkId = workId;
@@ -4434,8 +4823,8 @@ async function renderRaces() {
4434
4823
 
4435
4824
  async function renderOrganizations(page = moduleListPages.organizations) {
4436
4825
  [state.organizations, state.characters] = await Promise.all([
4437
- apiAllPages(`/api/works/${state.work.id}/organizations`),
4438
- 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([])
4439
4828
  ]);
4440
4829
  mountModuleCount(state.organizations.length);
4441
4830
  const pageResult = paginateModuleItems(state.organizations, page, "organizations");
@@ -4501,8 +4890,8 @@ function setTimelineMultiSelectMode(enabled) {
4501
4890
 
4502
4891
  async function renderTimeline(page = moduleListPages.timeline) {
4503
4892
  const [events, tracks] = await Promise.all([
4504
- apiAllPages(`/api/works/${state.work.id}/timeline`),
4505
- 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`)
4506
4895
  ]);
4507
4896
  mountModuleCount(events.length);
4508
4897
  const pageResult = paginateModuleItems(events, page, "timeline");
@@ -4545,8 +4934,8 @@ async function renderTimeline(page = moduleListPages.timeline) {
4545
4934
  async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
4546
4935
  const currentChapterId = state.chapter?.id;
4547
4936
  const [outlines, foreshadows] = await Promise.all([
4548
- apiAllPages(`/api/works/${state.work.id}/outlines`),
4549
- 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)}` : ""}`)
4550
4939
  ]);
4551
4940
  mountModuleCount(outlines.length + foreshadows.length);
4552
4941
  const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
@@ -4599,8 +4988,8 @@ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foresh
4599
4988
  }
4600
4989
 
4601
4990
  async function renderRelationships(page = moduleListPages.relationships) {
4602
- state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4603
- 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`);
4604
4993
  const filteredRelationships = filterRelationships(relationships, relationshipFilters);
4605
4994
  const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
4606
4995
  moduleListPages.relationships = pageResult.page;
@@ -4674,8 +5063,8 @@ async function renderReviews(page = moduleListPages.reviews) {
4674
5063
  const canMergeCharacters = canResolveReview
4675
5064
  && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
4676
5065
  const [reviews, characters] = await Promise.all([
4677
- apiAllPages(`/api/works/${state.work.id}/reviews`),
4678
- 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([])
4679
5068
  ]);
4680
5069
  mountModuleCount(reviews.length);
4681
5070
  const pageResult = paginateModuleItems(reviews, page, "reviews");
@@ -4763,16 +5152,16 @@ async function renderReviews(page = moduleListPages.reviews) {
4763
5152
  }));
4764
5153
  }
4765
5154
 
4766
- async function renderTasks(page = taskListPage) {
5155
+ async function renderTasks(page = taskListPage, { refresh = false } = {}) {
4767
5156
  stopTaskProgressRefresh();
4768
5157
  const pageSize = pageSizeFor("analysisTasks");
4769
5158
  const [taskPage, settings] = await Promise.all([
4770
- apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
5159
+ moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
4771
5160
  canReadModule("ai-settings")
4772
- ? api(`/api/works/${state.work.id}/ai-settings`)
5161
+ ? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
4773
5162
  : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
4774
5163
  ]);
4775
- if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
5164
+ if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
4776
5165
  taskListPage = taskPage.page;
4777
5166
  const tasks = taskPage.items;
4778
5167
  const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
@@ -4803,13 +5192,15 @@ async function renderTasks(page = taskListPage) {
4803
5192
  : "";
4804
5193
  $("#module-content").innerHTML = `
4805
5194
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
4806
- <div class="task-auto-run-copy">
4807
- <strong id="task-auto-run-title">自动执行待分析任务</strong>
4808
- <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4809
- <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
4810
- </div>
4811
- <div class="task-auto-run-actions">
4812
- ${autoRunEditing ? "" : '<button id="task-auto-run-edit" class="ghost-button" type="button">编辑</button>'}
5195
+ <div class="task-auto-run-header">
5196
+ <div class="task-auto-run-copy">
5197
+ <strong id="task-auto-run-title">自动执行待分析任务</strong>
5198
+ <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
5199
+ <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
5200
+ </div>
5201
+ <div class="task-auto-run-actions">
5202
+ ${autoRunEditing ? "" : '<button id="task-auto-run-edit" class="ghost-button" type="button">编辑</button>'}
5203
+ </div>
4813
5204
  </div>
4814
5205
  <div class="task-auto-run-controls">
4815
5206
  <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""} ${autoRunEditing ? "" : "disabled"}><span>持续自动执行</span></label>
@@ -4881,7 +5272,7 @@ async function renderTasks(page = taskListPage) {
4881
5272
  : "自动执行已关闭");
4882
5273
  taskAutoRunEditing = false;
4883
5274
  taskAutoRunEditingWorkId = null;
4884
- await renderTasks();
5275
+ await renderTasks(taskListPage, { refresh: true });
4885
5276
  } catch (error) {
4886
5277
  toast(error.message, "error");
4887
5278
  button.disabled = false;
@@ -5040,7 +5431,7 @@ function scheduleTaskProgressRefresh(workId, runningCount) {
5040
5431
  return;
5041
5432
  }
5042
5433
  try {
5043
- await renderTasks();
5434
+ await renderTasks(taskListPage, { refresh: true });
5044
5435
  } catch (error) {
5045
5436
  console.error("Failed to refresh task progress", error);
5046
5437
  scheduleTaskProgressRefresh(workId, runningCount);
@@ -5920,16 +6311,16 @@ async function renderBookAiSettings() {
5920
6311
  relationshipSearchIndexRefreshTimer = null;
5921
6312
  }
5922
6313
  const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
5923
- api(`/api/works/${state.work.id}/ai-settings`),
5924
- api("/api/platform/ai/providers"),
5925
- api(`/api/works/${state.work.id}/models`),
5926
- api(`/api/works/${state.work.id}/task-defaults`),
5927
- api(`/api/works/${state.work.id}/ai-settings/relationship-search-index`),
5928
- 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()}`)
5929
6320
  ]);
5930
6321
  const host = $("#module-content");
5931
6322
  const workId = String(state.work.id);
5932
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
6323
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
5933
6324
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5934
6325
  title: "本书 Token 用量",
5935
6326
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
@@ -5943,6 +6334,10 @@ async function renderBookAiSettings() {
5943
6334
  "afterend",
5944
6335
  `<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
5945
6336
  );
6337
+ host.querySelector(".ai-agent-tools").insertAdjacentHTML(
6338
+ "beforeend",
6339
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
6340
+ );
5946
6341
  if (!canEditModule("ai-settings")) {
5947
6342
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
5948
6343
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
@@ -6256,7 +6651,7 @@ async function ensureAiReferencesLoaded() {
6256
6651
 
6257
6652
  function field(name, label, type = "text", value = "", options = []) {
6258
6653
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
6259
- if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="Markdown 原文">${esc(value)}</textarea></div>`;
6654
+ if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
6260
6655
  if (type === "item-list") {
6261
6656
  const values = Array.isArray(value) && value.length ? value : [""];
6262
6657
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
@@ -6426,6 +6821,8 @@ function commitRelationshipKeywordInputs(container) {
6426
6821
  }
6427
6822
 
6428
6823
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6824
+ formDialogVditors.forEach(destroyVditorEditor);
6825
+ formDialogVditors = [];
6429
6826
  void discardPendingMarkdownAttachments();
6430
6827
  const dialog = $("#form-dialog");
6431
6828
  const form = $("#dynamic-form");
@@ -6448,9 +6845,11 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6448
6845
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
6449
6846
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
6450
6847
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
6848
+ dialog.classList.toggle("large-dialog", Boolean(options.large));
6849
+ dialog.classList.toggle("editor-dialog", Boolean(options.editor));
6451
6850
  bindDynamicListControls($("#dialog-fields"));
6452
6851
  bindRelationshipKeywordControls($("#dialog-fields"));
6453
- bindVditorEditors($("#dialog-fields"));
6852
+ formDialogVditors = bindVditorEditors($("#dialog-fields"));
6454
6853
  form.onclick = null;
6455
6854
  form.onkeydown = null;
6456
6855
  dialog.oncancel = (event) => {
@@ -7224,6 +7623,7 @@ function destroyVditorEditor(editor) {
7224
7623
  }
7225
7624
 
7226
7625
  function bindVditorEditors(container) {
7626
+ const editors = [];
7227
7627
  container.querySelectorAll("[data-vditor-editor]").forEach((host) => {
7228
7628
  const valueField = host.parentElement?.querySelector("[data-vditor-value]");
7229
7629
  const editor = createVditorEditor(host, valueField?.value ?? "", {
@@ -7231,10 +7631,12 @@ function bindVditorEditors(container) {
7231
7631
  if (valueField) valueField.value = markdown;
7232
7632
  markEntityEditorDirty();
7233
7633
  },
7234
- placeholder: "",
7634
+ placeholder: host.dataset.placeholder ?? "",
7235
7635
  readOnly: Boolean(valueField?.readOnly)
7236
7636
  });
7637
+ if (editor) editors.push(editor);
7237
7638
  });
7639
+ return editors;
7238
7640
  }
7239
7641
 
7240
7642
  function characterSectionImageLabel(file, fallback = "图片附件") {
@@ -7945,49 +8347,151 @@ function openOutlineDialog(item) {
7945
8347
  }, "章节大纲");
7946
8348
  }
7947
8349
 
8350
+ const foreshadowOccurrenceRoleOptions = [["setup", "埋设"], ["reminder", "提醒"], ["payoff", "回收"]];
8351
+
8352
+ function foreshadowOccurrenceRow(record, chapters, index) {
8353
+ const chapterOptions = [["", "暂不关联"], ...chapters];
8354
+ const occurrenceId = record?.id ?? "";
8355
+ const role = record?.role ?? "setup";
8356
+ const chapterId = record?.chapterId ?? "";
8357
+ const chapterLabel = chapters.find(([value]) => value === chapterId)?.[1] ?? "未关联章节";
8358
+ return `<fieldset class="foreshadow-occurrence-row" data-foreshadow-occurrence data-occurrence-id="${esc(occurrenceId)}" data-occurrence-role="${esc(role)}">
8359
+ <legend><span class="foreshadow-occurrence-role-badge" data-foreshadow-occurrence-role-label>${esc(occurrenceRoleLabel(role))}</span><strong data-foreshadow-occurrence-index>节点 ${index + 1}</strong><small data-foreshadow-occurrence-summary>${esc(chapterLabel)}</small></legend>
8360
+ <button class="foreshadow-occurrence-remove" type="button" data-foreshadow-occurrence-remove aria-label="删除第 ${index + 1} 个章节节点">删除节点</button>
8361
+ <div class="foreshadow-occurrence-grid">
8362
+ <label>节点类型<select name="occurrenceRole" data-foreshadow-occurrence-role aria-label="第 ${index + 1} 个节点类型">${foreshadowOccurrenceRoleOptions.map(([value, label]) => `<option value="${value}" ${value === role ? "selected" : ""}>${label}</option>`).join("")}</select></label>
8363
+ <label>关联章节<select name="occurrenceChapterId" data-foreshadow-occurrence-chapter aria-label="第 ${index + 1} 个关联章节">${chapterOptions.map(([value, label]) => `<option value="${esc(value)}" ${value === chapterId ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label>
8364
+ </div>
8365
+ <label>节点说明<textarea name="occurrenceNote" data-foreshadow-occurrence-note aria-label="第 ${index + 1} 个节点说明">${esc(record?.note ?? "")}</textarea></label>
8366
+ </fieldset>`;
8367
+ }
8368
+
8369
+ function foreshadowOccurrencesField(item, chapters) {
8370
+ const occurrences = item?.occurrences?.length ? item.occurrences : [null];
8371
+ return `<section class="form-field foreshadow-editor-section foreshadow-occurrence-field" data-foreshadow-occurrences aria-labelledby="foreshadow-occurrence-title">
8372
+ <div class="foreshadow-occurrence-toolbar"><div><h3 id="foreshadow-occurrence-title">章节节点</h3><p>每张卡片代表一次独立的埋设、提醒或回收,类型、章节和说明始终绑定在同一组内。</p></div><button type="button" data-foreshadow-occurrence-add>添加节点</button></div>
8373
+ <div class="foreshadow-occurrence-rows" data-foreshadow-occurrence-rows>${occurrences.map((record, index) => foreshadowOccurrenceRow(record, chapters, index)).join("")}</div>
8374
+ <p class="foreshadow-occurrence-empty hidden" data-foreshadow-occurrence-empty>尚未添加章节节点。</p>
8375
+ </section>`;
8376
+ }
8377
+
8378
+ function foreshadowOverviewField(item, options, firstPayoff) {
8379
+ return `<section class="foreshadow-editor-section foreshadow-overview-field" aria-labelledby="foreshadow-overview-title">
8380
+ <header><h3 id="foreshadow-overview-title">伏笔信息</h3><p>维护伏笔本身的内容、状态与计划回收位置。</p></header>
8381
+ <div class="foreshadow-overview-grid">
8382
+ ${field("title", "伏笔名称", "text", item?.title)}
8383
+ ${field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]])}
8384
+ ${field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]])}
8385
+ ${field("plannedPayoffChapterId", "计划回收章节", "select", item?.plannedPayoffChapterId ?? firstPayoff?.chapterId ?? "", options)}
8386
+ </div>
8387
+ <div class="foreshadow-overview-description">${field("description", "内容与预期作用", "textarea", item?.description)}</div>
8388
+ </section>`;
8389
+ }
8390
+
8391
+ function foreshadowResolutionField(item) {
8392
+ return `<section class="foreshadow-editor-section foreshadow-resolution-field" aria-labelledby="foreshadow-resolution-title">
8393
+ <header><h3 id="foreshadow-resolution-title">回收结果</h3><p>记录伏笔最终如何揭示或完成;它不属于上方任意单个章节节点。</p></header>
8394
+ ${field("resolutionNote", "回收结论", "textarea", item?.resolutionNote)}
8395
+ </section>`;
8396
+ }
8397
+
8398
+ function updateForeshadowOccurrenceRowSummary(row) {
8399
+ const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
8400
+ const chapter = row.querySelector("[data-foreshadow-occurrence-chapter]");
8401
+ row.dataset.occurrenceRole = role;
8402
+ row.querySelector("[data-foreshadow-occurrence-role-label]").textContent = occurrenceRoleLabel(role);
8403
+ row.querySelector("[data-foreshadow-occurrence-summary]").textContent = chapter.value ? chapter.selectedOptions[0]?.textContent ?? "已关联章节" : "未关联章节";
8404
+ }
8405
+
8406
+ function renumberForeshadowOccurrenceRows(section) {
8407
+ const rows = [...section.querySelectorAll("[data-foreshadow-occurrence]")];
8408
+ rows.forEach((row, index) => {
8409
+ const sequence = index + 1;
8410
+ row.querySelector("[data-foreshadow-occurrence-index]").textContent = `节点 ${sequence}`;
8411
+ row.querySelector("[data-foreshadow-occurrence-remove]").setAttribute("aria-label", `删除第 ${sequence} 个章节节点`);
8412
+ row.querySelector("[data-foreshadow-occurrence-role]").setAttribute("aria-label", `第 ${sequence} 个节点类型`);
8413
+ row.querySelector("[data-foreshadow-occurrence-chapter]").setAttribute("aria-label", `第 ${sequence} 个关联章节`);
8414
+ row.querySelector("[data-foreshadow-occurrence-note]").setAttribute("aria-label", `第 ${sequence} 个节点说明`);
8415
+ updateForeshadowOccurrenceRowSummary(row);
8416
+ });
8417
+ section.querySelector("[data-foreshadow-occurrence-empty]").classList.toggle("hidden", rows.length > 0);
8418
+ }
8419
+
8420
+ function bindForeshadowOccurrenceControls(container, chapters) {
8421
+ const section = container.querySelector("[data-foreshadow-occurrences]");
8422
+ if (!section) return;
8423
+ const rows = section.querySelector("[data-foreshadow-occurrence-rows]");
8424
+ section.addEventListener("change", (event) => {
8425
+ const row = event.target.closest("[data-foreshadow-occurrence]");
8426
+ if (row && event.target.matches("[data-foreshadow-occurrence-role], [data-foreshadow-occurrence-chapter]")) updateForeshadowOccurrenceRowSummary(row);
8427
+ });
8428
+ section.addEventListener("click", (event) => {
8429
+ if (event.target.closest("[data-foreshadow-occurrence-add]")) {
8430
+ const index = rows.querySelectorAll("[data-foreshadow-occurrence]").length;
8431
+ rows.insertAdjacentHTML("beforeend", foreshadowOccurrenceRow(null, chapters, index));
8432
+ renumberForeshadowOccurrenceRows(section);
8433
+ rows.lastElementChild?.querySelector("[data-foreshadow-occurrence-role]")?.focus();
8434
+ return;
8435
+ }
8436
+ const remove = event.target.closest("[data-foreshadow-occurrence-remove]");
8437
+ if (!remove) return;
8438
+ const row = remove.closest("[data-foreshadow-occurrence]");
8439
+ const nextFocus = row.nextElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
8440
+ ?? row.previousElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
8441
+ ?? section.querySelector("[data-foreshadow-occurrence-add]");
8442
+ row.remove();
8443
+ renumberForeshadowOccurrenceRows(section);
8444
+ nextFocus?.focus();
8445
+ });
8446
+ renumberForeshadowOccurrenceRows(section);
8447
+ }
8448
+
8449
+ function readForeshadowOccurrences(item) {
8450
+ const previousById = new Map((item?.occurrences ?? []).map((record) => [record.id, record]));
8451
+ const seen = new Map();
8452
+ return [...$("#dialog-fields").querySelectorAll("[data-foreshadow-occurrence]")].flatMap((row, index) => {
8453
+ const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
8454
+ const chapterId = row.querySelector("[data-foreshadow-occurrence-chapter]").value;
8455
+ const note = row.querySelector("[data-foreshadow-occurrence-note]").value;
8456
+ if (!chapterId) {
8457
+ if (note.trim()) throw new Error(`第 ${index + 1} 个章节节点填写了说明,请选择关联章节`);
8458
+ return [];
8459
+ }
8460
+ const key = `${role}:${chapterId}`;
8461
+ const duplicateIndex = seen.get(key);
8462
+ if (duplicateIndex) throw new Error(`第 ${index + 1} 个章节节点与第 ${duplicateIndex} 个重复`);
8463
+ seen.set(key, index + 1);
8464
+ const previous = previousById.get(row.dataset.occurrenceId);
8465
+ return [{
8466
+ chapterId,
8467
+ role,
8468
+ note,
8469
+ evidence: previous?.chapterId === chapterId && previous?.role === role ? previous.evidence : []
8470
+ }];
8471
+ });
8472
+ }
8473
+
7948
8474
  function openForeshadowDialog(item) {
7949
8475
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
7950
8476
  if (!chapters.length) return toast("请先创建章节", "error");
7951
8477
  const options = [["", "暂不关联"], ...chapters];
7952
- const occurrence = (role) => item?.occurrences?.find((record) => record.role === role);
7953
- const editableOccurrenceIds = new Set(["setup", "reminder", "payoff"].map((role) => occurrence(role)?.id).filter(Boolean));
7954
- const preservedOccurrences = (item?.occurrences ?? [])
7955
- .filter((record) => !editableOccurrenceIds.has(record.id))
7956
- .map((record) => ({ chapterId: record.chapterId, role: record.role, note: record.note, evidence: record.evidence }));
8478
+ const firstPayoff = item?.occurrences?.find((record) => record.role === "payoff");
7957
8479
  openDialog(item ? "编辑伏笔" : "新建伏笔",
7958
- field("title", "伏笔名称", "text", item?.title) +
7959
- field("description", "内容与预期作用", "textarea", item?.description) +
7960
- field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]]) +
7961
- field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]]) +
7962
- field("setupChapterId", "埋设章节", "select", occurrence("setup")?.chapterId ?? "", options) +
7963
- field("setupNote", "埋设说明", "textarea", occurrence("setup")?.note ?? "") +
7964
- field("reminderChapterId", "提醒章节", "select", occurrence("reminder")?.chapterId ?? "", options) +
7965
- field("reminderNote", "提醒说明", "textarea", occurrence("reminder")?.note ?? "") +
7966
- field("payoffChapterId", "回收章节", "select", occurrence("payoff")?.chapterId ?? item?.plannedPayoffChapterId ?? "", options) +
7967
- field("payoffNote", "回收说明", "textarea", occurrence("payoff")?.note ?? "") +
7968
- field("resolutionNote", "回收结论", "textarea", item?.resolutionNote),
8480
+ foreshadowOverviewField(item, options, firstPayoff) +
8481
+ foreshadowOccurrencesField(item, chapters) +
8482
+ foreshadowResolutionField(item),
7969
8483
  async (form) => {
7970
- const editedOccurrences = ["setup", "reminder", "payoff"].flatMap((role) => {
7971
- const chapterId = form.get(`${role}ChapterId`);
7972
- if (!chapterId) return [];
7973
- const previous = occurrence(role);
7974
- return [{
7975
- chapterId,
7976
- role,
7977
- note: form.get(`${role}Note`),
7978
- evidence: previous?.chapterId === chapterId ? previous.evidence : []
7979
- }];
7980
- });
7981
- const occurrences = [...preservedOccurrences, ...editedOccurrences];
8484
+ const occurrences = readForeshadowOccurrences(item);
7982
8485
  const body = {
7983
8486
  title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
7984
- plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
8487
+ plannedPayoffChapterId: form.get("plannedPayoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
7985
8488
  ...(item ? { expectedVersionNo: item.versionNo } : {})
7986
8489
  };
7987
8490
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
7988
8491
  await renderOutlines();
7989
8492
  toast(item ? "伏笔已更新" : "伏笔已创建");
7990
- }, item ? "伏笔管理" : "创作线索");
8493
+ }, item ? "伏笔管理" : "创作线索", { large: true, meta: "完整编辑伏笔信息、章节节点与回收结果" });
8494
+ bindForeshadowOccurrenceControls($("#dialog-fields"), chapters);
7991
8495
  }
7992
8496
 
7993
8497
  function openTimelineSplitDialog(item) {
@@ -8460,23 +8964,30 @@ async function sendAi() {
8460
8964
  let assistantContent = "";
8461
8965
  let assistantMessage;
8462
8966
  let assistantMetadata = {};
8967
+ let persistedStreamMessage = null;
8463
8968
  let suggestion = null;
8464
8969
  if (taskType === "chat") {
8465
8970
  const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
8466
8971
  assistantContent = streamed.content;
8467
8972
  assistantMessage = streamed.message;
8468
8973
  assistantMetadata = streamed.metadata;
8974
+ persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
8469
8975
  } else {
8470
8976
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
8471
8977
  assistantContent = suggestion.content;
8472
8978
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
8473
8979
  }
8474
8980
  try {
8475
- const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
8476
- if (assistantMessage) {
8477
- updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
8478
- attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
8479
- } 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
+ }
8480
8991
  } catch (error) {
8481
8992
  if (suggestion) appendSuggestion(suggestion);
8482
8993
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
@@ -8496,16 +9007,28 @@ async function streamChat(body) {
8496
9007
  const message = document.createElement("div");
8497
9008
  message.className = "assistant-message is-streaming";
8498
9009
  message.dataset.testid = "ai-stream-message";
8499
- message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite"></div><div class="message-meta">正在连接模型流……</div>';
9010
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
8500
9011
  attachMessageHeading(message, "助手 · 正在生成");
8501
9012
  $("#ai-feed").append(message);
8502
9013
  scrollAiFeedToBottom();
8503
9014
  const content = message.querySelector(".message-body");
8504
9015
  const meta = message.querySelector(".message-meta");
9016
+ const typewriter = createStreamTypewriter({
9017
+ onRender: (text, progress) => {
9018
+ content.innerHTML = renderMarkdown(text);
9019
+ const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
9020
+ meta.textContent = progress.pendingCharacters > 0
9021
+ ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
9022
+ : `正在生成 · ${progress.visibleCharacters} 字`;
9023
+ scrollAiFeedToBottom();
9024
+ }
9025
+ });
8505
9026
  let streamedText = "";
8506
9027
  let generatedMetadata = {};
8507
9028
  let toolCalls = [];
8508
9029
  let processSteps = [];
9030
+ let persistedMessageId = null;
9031
+ let persistedMessageCreatedAt = null;
8509
9032
  let finalAnswerStarted = false;
8510
9033
  const processStartedAt = Date.now();
8511
9034
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -8523,7 +9046,7 @@ async function streamChat(body) {
8523
9046
  const decoder = new TextDecoder();
8524
9047
  let buffer = "";
8525
9048
  let streamError = null;
8526
- const consume = (eventText) => {
9049
+ const consume = async (eventText) => {
8527
9050
  let eventName = "message";
8528
9051
  const dataLines = [];
8529
9052
  for (const line of eventText.split(/\r?\n/)) {
@@ -8534,12 +9057,12 @@ async function streamChat(body) {
8534
9057
  const payload = JSON.parse(dataLines.join("\n"));
8535
9058
  if (eventName === "delta") {
8536
9059
  const firstFinalDelta = streamedText.length === 0;
8537
- streamedText += payload.delta ?? "";
9060
+ const delta = typeof payload.delta === "string" ? payload.delta : "";
9061
+ streamedText += delta;
8538
9062
  if (streamedText.length > 0) finalAnswerStarted = true;
8539
- content.innerHTML = renderMarkdown(streamedText);
9063
+ typewriter.append(delta);
8540
9064
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8541
- meta.textContent = `已接收 ${Array.from(streamedText).length} 字`;
8542
- scrollAiFeedToBottom();
9065
+ meta.textContent = "正在生成回复……";
8543
9066
  } else if (eventName === "process_step") {
8544
9067
  const step = { ...payload };
8545
9068
  const append = step.append === true;
@@ -8560,7 +9083,11 @@ async function streamChat(body) {
8560
9083
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
8561
9084
  scrollAiFeedToBottom();
8562
9085
  } else if (eventName === "complete") {
9086
+ persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9087
+ persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9088
+ await typewriter.finish();
8563
9089
  message.classList.remove("is-streaming");
9090
+ content.setAttribute("aria-busy", "false");
8564
9091
  message.querySelector(".message-heading > span").textContent = "助手";
8565
9092
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
8566
9093
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
@@ -8579,14 +9106,17 @@ async function streamChat(body) {
8579
9106
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });
8580
9107
  const events = buffer.split(/\r?\n\r?\n/);
8581
9108
  buffer = events.pop() ?? "";
8582
- events.forEach(consume);
9109
+ for (const eventText of events) await consume(eventText);
8583
9110
  if (chunk.done) break;
8584
9111
  }
8585
- if (buffer.trim()) consume(buffer);
9112
+ if (buffer.trim()) await consume(buffer);
9113
+ await typewriter.finish();
8586
9114
  if (streamError) throw streamError;
8587
- return { content: streamedText, message, metadata: generatedMetadata };
9115
+ return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt };
8588
9116
  } catch (error) {
9117
+ typewriter.reveal();
8589
9118
  message.classList.remove("is-streaming");
9119
+ content.setAttribute("aria-busy", "false");
8590
9120
  message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
8591
9121
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8592
9122
  meta.textContent = "生成中断";
@@ -9565,6 +10095,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
9565
10095
  body: {
9566
10096
  toastPosition: $("#toast-position").value,
9567
10097
  pageSizes: {
10098
+ drafts: Number($("#page-size-drafts").value),
9568
10099
  settings: Number($("#page-size-settings").value),
9569
10100
  characters: Number($("#page-size-characters").value),
9570
10101
  organizations: Number($("#page-size-organizations").value),
@@ -9597,6 +10128,9 @@ $("#members-dialog").addEventListener("close", () => {
9597
10128
  memberDialogDirectory = [];
9598
10129
  });
9599
10130
  $("#form-dialog").addEventListener("close", () => {
10131
+ formDialogVditors.forEach(destroyVditorEditor);
10132
+ formDialogVditors = [];
10133
+ void discardPendingMarkdownAttachments();
9600
10134
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
9601
10135
  });
9602
10136
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -9793,7 +10327,7 @@ $("#module-nav").addEventListener("click", (event) => {
9793
10327
  }
9794
10328
  });
9795
10329
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
9796
- $("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
10330
+ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
9797
10331
  $("#ai-prompt").addEventListener("input", async () => {
9798
10332
  updateAiMentionMenu();
9799
10333
  scheduleAiContextUsage();
@@ -9934,9 +10468,24 @@ $("#chapter-type-menu").addEventListener("click", async (event) => {
9934
10468
  toast(error.message, "error");
9935
10469
  }
9936
10470
  });
10471
+ $("#markdown-table-wrap-toggle").addEventListener("click", () => {
10472
+ const table = markdownTableMenuTarget;
10473
+ if (!table?.isConnected) return closeMarkdownTableMenu();
10474
+ const wrapping = !table.classList.contains("is-wrapping");
10475
+ setMarkdownTableWrapping(table, wrapping);
10476
+ closeMarkdownTableMenu(true);
10477
+ toast(wrapping ? "表格已启用自动换行" : "表格已恢复横向滚动");
10478
+ });
10479
+ document.addEventListener("contextmenu", (event) => {
10480
+ const header = event.target.closest?.("[data-markdown-table-header]");
10481
+ if (!header) return;
10482
+ event.preventDefault();
10483
+ openMarkdownTableMenu(header, event.clientX, event.clientY);
10484
+ });
9937
10485
  document.addEventListener("pointerdown", (event) => {
9938
10486
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
9939
10487
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10488
+ if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
9940
10489
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
9941
10490
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
9942
10491
  $("#account-menu").classList.add("hidden");
@@ -9948,9 +10497,16 @@ document.addEventListener("pointerdown", (event) => {
9948
10497
  }
9949
10498
  });
9950
10499
  document.addEventListener("keydown", (event) => {
10500
+ const header = event.target.closest?.("[data-markdown-table-header]");
10501
+ if (header && (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))) {
10502
+ event.preventDefault();
10503
+ openMarkdownTableMenu(header, 0, 0);
10504
+ return;
10505
+ }
9951
10506
  if (event.key === "Escape") {
9952
10507
  closeChapterTypeMenu();
9953
10508
  closeLineCitationMenu();
10509
+ closeMarkdownTableMenu(true);
9954
10510
  hideAiMentionMenu();
9955
10511
  }
9956
10512
  });