@musnows/scriverse 0.5.8 → 0.5.10

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.
@@ -4,6 +4,7 @@ import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
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";
7
+ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
7
8
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
9
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
10
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
@@ -33,16 +34,16 @@ import {
33
34
  taskScopeLabel,
34
35
  timelineStatusLabel,
35
36
  characterStateFieldLabel
36
- } from "/display-labels.js?v=20260726-anthropic-messages-v2";
37
+ } from "/display-labels.js?v=20260728-hybrid-search-v1";
37
38
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
38
39
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
39
40
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
40
- import { eligibleRaceParents, orderRaceFilterOptions, paginateRaceForest, racePathLabel } from "/race-hierarchy.js?v=20260727-race-tree-pagination-v1";
41
+ import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
41
42
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
42
43
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
43
44
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
44
45
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
45
- import { resolveGlobalSearchTarget } from "/global-search.js?v=20260726-search-result-details";
46
+ import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
46
47
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
47
48
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
48
49
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
@@ -141,6 +142,8 @@ let chapterMovePending = false;
141
142
 
142
143
  let timelineMultiSelectEnabled = false;
143
144
  let taskProgressRefreshTimer = null;
145
+ let taskAutoRunEditing = false;
146
+ let taskAutoRunEditingWorkId = null;
144
147
  let relationshipSearchIndexRefreshTimer = null;
145
148
  let backgroundTaskCenterTimer = null;
146
149
  let backgroundTaskCenterRequest = 0;
@@ -210,8 +213,12 @@ function renderAnalysisTaskStatus(item) {
210
213
  const status = normalizedAnalysisTaskStatus(item.status);
211
214
  const statusChanged = taskStatusSnapshots.get(taskId) !== status;
212
215
  taskStatusSnapshots.set(taskId, status);
213
- const label = analysisTaskStatusLabel(item.status);
214
- return `<span class="task-status-badge is-${status}${statusChanged ? " is-state-change" : ""}" aria-label="任务状态:${esc(label)}">
216
+ const waitingToRetry = status === "pending" && Boolean(item.nextAttemptAt);
217
+ const label = waitingToRetry ? "等待重试" : analysisTaskStatusLabel(item.status);
218
+ const retryTitle = waitingToRetry
219
+ ? ` title="第 ${esc(String(item.attemptCount ?? 0))} 次尝试失败,将于 ${esc(formatDateTime(item.nextAttemptAt))} 重试"`
220
+ : "";
221
+ return `<span class="task-status-badge is-${status}${statusChanged ? " is-state-change" : ""}" aria-label="任务状态:${esc(label)}"${retryTitle}>
215
222
  <span class="task-status-indicator" aria-hidden="true"></span>
216
223
  <span>${esc(label)}</span>
217
224
  </span>`;
@@ -362,6 +369,10 @@ let aiReferencesLoadWorkId = null;
362
369
  let aiConversationsLoadPromise = null;
363
370
  let aiConversationsLoadWorkId = null;
364
371
  let workScopedUiGeneration = 0;
372
+ let raceHierarchyLoadPromise = null;
373
+ let raceHierarchyLoadWorkId = null;
374
+ let loadedRaceHierarchyWorkId = null;
375
+ let raceListRequestId = 0;
365
376
  let importHistoryRecords = [];
366
377
  let importHistoryNextPage = null;
367
378
  let importHistoryRequestId = 0;
@@ -862,7 +873,6 @@ let characterListPage = 1;
862
873
  let taskListPage = 1;
863
874
  const moduleListPages = {
864
875
  settings: 1,
865
- races: 1,
866
876
  organizations: 1,
867
877
  timeline: 1,
868
878
  outlinePlans: 1,
@@ -1364,7 +1374,7 @@ const AI_TOOL_DESCRIPTIONS = {
1364
1374
  story_index: "分页读取当前作品的卷章目录和章节概要。",
1365
1375
  read_chapters: "读取指定章节的概要、正文或两者。",
1366
1376
  grep: "查询正文关键字所在的完整段落及章节信息。",
1367
- search_story_entities: "按实体名或关键词子串匹配设定、人物、组织等结构化记录;非语义检索。",
1377
+ search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1368
1378
  read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
1369
1379
  };
1370
1380
 
@@ -2290,6 +2300,22 @@ function toast(message, type = "info") {
2290
2300
  }, 3600);
2291
2301
  }
2292
2302
 
2303
+ function persistentToast(message, type = "info") {
2304
+ const region = $("#toast-region");
2305
+ const element = document.createElement("div");
2306
+ element.className = `toast ${type}`;
2307
+ element.setAttribute("role", "status");
2308
+ element.textContent = message;
2309
+ region.append(element);
2310
+ raiseToastRegion();
2311
+ return () => {
2312
+ element.remove();
2313
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
2314
+ region.hidePopover();
2315
+ }
2316
+ };
2317
+ }
2318
+
2293
2319
  function confirmToast(message, { title = "请再次确认", confirmLabel = "确认", cancelLabel = "取消" } = {}) {
2294
2320
  const region = $("#toast-region");
2295
2321
  const element = document.createElement("section");
@@ -2887,7 +2913,6 @@ async function openPlatformUiSettingsDialog() {
2887
2913
  const pageSizes = normalizePageSizes(settings.pageSizes);
2888
2914
  $("#page-size-settings").value = String(pageSizes.settings);
2889
2915
  $("#page-size-characters").value = String(pageSizes.characters);
2890
- $("#page-size-races").value = String(pageSizes.races);
2891
2916
  $("#page-size-organizations").value = String(pageSizes.organizations);
2892
2917
  $("#page-size-timeline").value = String(pageSizes.timeline);
2893
2918
  $("#page-size-outlines").value = String(pageSizes.outlines);
@@ -3010,24 +3035,40 @@ async function openSearchDialog() {
3010
3035
  }
3011
3036
  $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
3012
3037
  $("#search-query").value = "";
3038
+ $("#search-type").value = "";
3013
3039
  $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
3014
3040
  $("#search-dialog").showModal();
3015
3041
  queueMicrotask(() => $("#search-query").focus());
3016
3042
  }
3017
3043
 
3018
- function renderSearchResults(results) {
3044
+ function highlightedSearchText(value, query) {
3045
+ return splitGlobalSearchHighlight(value, query)
3046
+ .map((segment) => segment.match ? `<mark>${esc(segment.text)}</mark>` : esc(segment.text))
3047
+ .join("");
3048
+ }
3049
+
3050
+ function renderSearchResults(results, query) {
3019
3051
  if (!results.length) {
3020
3052
  $("#search-results").innerHTML = '<p class="search-results-status">未找到相关内容。</p>';
3021
3053
  return;
3022
3054
  }
3023
- $("#search-results").innerHTML = results.map((item) => `
3024
- <button type="button" class="search-result" data-search-type="${esc(item.type)}" data-search-id="${esc(item.id)}">
3025
- <div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${esc(item.title)}</strong></div>
3026
- <p>${esc(item.snippet || "无摘要")}</p>
3027
- </button>`).join("");
3028
- $("#search-results").querySelectorAll(".search-result").forEach((button) => {
3055
+ const matchKindLabel = { metadata: "资料命中", exact: "精确命中", phonetic: "拼音命中" };
3056
+ $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${results.length} 条结果,按综合相关度排序。</p>${results.map((item) => {
3057
+ const matchKinds = Array.isArray(item.matchKinds) ? item.matchKinds : [];
3058
+ const lineRange = Number.isInteger(item.startLine)
3059
+ ? `<span class="search-result-chip">${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
3060
+ : "";
3061
+ const subtitle = item.subtitle ? `<small>${esc(item.subtitle)}</small>` : "";
3062
+ return `
3063
+ <button type="button" class="search-result">
3064
+ <div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${highlightedSearchText(item.title, query)}</strong></div>
3065
+ <p>${highlightedSearchText(item.snippet || "无摘要", query)}</p>
3066
+ <div class="search-result-details">${subtitle}${lineRange}${matchKinds.map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchKindLabel[kind] ?? kind)}</span>`).join("")}</div>
3067
+ </button>`;
3068
+ }).join("")}`;
3069
+ $("#search-results").querySelectorAll(".search-result").forEach((button, index) => {
3029
3070
  button.addEventListener("click", () => {
3030
- openSearchResult({ type: button.dataset.searchType, id: button.dataset.searchId })
3071
+ openSearchResult(results[index])
3031
3072
  .catch((error) => toast(error.message, "error"));
3032
3073
  });
3033
3074
  });
@@ -3041,8 +3082,50 @@ async function runWorkSearch() {
3041
3082
  return;
3042
3083
  }
3043
3084
  $("#search-results").innerHTML = '<p class="search-results-status">正在检索……</p>';
3044
- const results = await api(`/api/works/${encodeURIComponent(state.work.id)}/search?q=${encodeURIComponent(query)}`);
3045
- renderSearchResults(results);
3085
+ const parameters = new URLSearchParams({ q: query });
3086
+ const type = $("#search-type").value;
3087
+ if (type) parameters.set("type", type);
3088
+ const results = await api(`/api/works/${encodeURIComponent(state.work.id)}/search?${parameters}`);
3089
+ renderSearchResults(results, query);
3090
+ }
3091
+
3092
+ function revealChapterSearchLines(startLine, endLine) {
3093
+ const start = Math.max(0, Number(startLine) - 1);
3094
+ const end = Math.max(start, Number(endLine ?? startLine) - 1);
3095
+ const input = $("#chapter-content");
3096
+ const selection = selectedChapterLinePayload(start, end);
3097
+ chapterLineSelection = { start: selection.safeStart, end: selection.safeEnd };
3098
+ input.focus({ preventScroll: true });
3099
+ input.setSelectionRange(selection.startOffset, selection.startOffset + selection.text.length);
3100
+ scheduleChapterLineNumbers();
3101
+ requestAnimationFrame(() => {
3102
+ paintChapterLineSelection(selection.safeStart, selection.safeEnd);
3103
+ const row = $("#chapter-line-numbers-inner").querySelector(`[data-line-index="${selection.safeStart}"]`);
3104
+ if (row) input.scrollTop = Math.max(0, row.offsetTop - input.clientHeight / 3);
3105
+ syncChapterLineNumberScroll();
3106
+ });
3107
+ }
3108
+
3109
+ function openSearchEntityPreview(result, item) {
3110
+ const rowsByType = {
3111
+ "timeline-track": [["时间轴名称", item.name], ["简介", item.description], ["排序", item.sortOrder]],
3112
+ "timeline-event": [["事件名称", item.name], ["时间", item.timeLabel], ["类型", item.eventType], ["地点", item.location], ["说明", item.description], ["状态", item.status]],
3113
+ relationship: [["关系", result.title], ["大类", relationshipCategoryLabel(item.category)], ["子类", item.subtype], ["关键词", Array.isArray(item.keywords) ? item.keywords.join("、") : ""], ["当前状态", item.currentStatus], ["置信度", typeof item.confidence === "number" ? `${Math.round(item.confidence * 100)}%` : ""]],
3114
+ "chapter-outline": [["章节", item.chapterTitle], ["本章目标", item.goal], ["核心冲突", item.conflict], ["关键转折", item.turningPoint], ["补充说明", item.notes], ["规划状态", outlineStatusLabel(item.status)]],
3115
+ foreshadow: [["伏笔名称", item.title], ["内容与作用", item.description], ["重要程度", levelLabel(item.importance)], ["状态", foreshadowStatusLabel(item.status)], ["回收结论", item.resolutionNote]]
3116
+ };
3117
+ const rows = rowsByType[result.type] ?? [];
3118
+ const content = rows
3119
+ .filter(([, value]) => value !== undefined && value !== null && String(value).trim())
3120
+ .map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`)
3121
+ .join("");
3122
+ openDialog(
3123
+ result.title || searchResultTypeLabel(result.type),
3124
+ `<dl class="search-result-preview">${content || "<div><dt>详情</dt><dd>暂无补充信息</dd></div>"}</dl>`,
3125
+ async () => undefined,
3126
+ searchResultTypeLabel(result.type),
3127
+ { submitLabel: "关闭", hideCancel: true, wide: true }
3128
+ );
3046
3129
  }
3047
3130
 
3048
3131
  async function openSearchResult(result) {
@@ -3055,6 +3138,10 @@ async function openSearchResult(result) {
3055
3138
  if (inSettings) await returnFromSettings();
3056
3139
  if (target.kind === "chapter") {
3057
3140
  await selectChapter(target.id);
3141
+ if (state.chapter?.id === target.id && target.startLine) {
3142
+ revealChapterSearchLines(target.startLine, target.endLine);
3143
+ toast(target.startLine === target.endLine ? `已定位到第 ${target.startLine} 行` : `已定位到第 ${target.startLine}-${target.endLine} 行`);
3144
+ }
3058
3145
  return;
3059
3146
  }
3060
3147
  await showModule(target.module);
@@ -3064,6 +3151,10 @@ async function openSearchResult(result) {
3064
3151
  if (target.entity === "character") await openCharacterEditor(item, { readOnly: true });
3065
3152
  if (target.entity === "race") await openRaceDialog(item, { readOnly: true });
3066
3153
  if (target.entity === "organization") await openOrganizationDialog(item, { readOnly: true });
3154
+ if (target.entity === "review") openReviewDetailDialog(item);
3155
+ if (["timeline-track", "timeline-event", "relationship", "chapter-outline", "foreshadow"].includes(target.entity)) {
3156
+ openSearchEntityPreview(result, item);
3157
+ }
3067
3158
  }
3068
3159
 
3069
3160
  async function showSettingsHub() {
@@ -3192,9 +3283,14 @@ function resetWorkScopedUiCaches() {
3192
3283
  aiReferencesLoadWorkId = null;
3193
3284
  aiConversationsLoadPromise = null;
3194
3285
  aiConversationsLoadWorkId = null;
3286
+ raceHierarchyLoadPromise = null;
3287
+ raceHierarchyLoadWorkId = null;
3288
+ loadedRaceHierarchyWorkId = null;
3289
+ raceListRequestId += 1;
3195
3290
  state.models = [];
3196
3291
  state.characters = [];
3197
3292
  state.settings = [];
3293
+ state.races = [];
3198
3294
  characterListPage = 1;
3199
3295
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3200
3296
  relationshipFilters.fromCharacterIds = [];
@@ -3656,6 +3752,10 @@ async function showModule(module) {
3656
3752
  }
3657
3753
  module = fallback;
3658
3754
  }
3755
+ if (module !== "tasks") {
3756
+ taskAutoRunEditing = false;
3757
+ taskAutoRunEditingWorkId = null;
3758
+ }
3659
3759
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
3660
3760
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
3661
3761
  state.module = module;
@@ -4125,7 +4225,7 @@ async function renderCharacters(page = characterListPage) {
4125
4225
  const pageSize = pageSizeFor("characters");
4126
4226
  const [characterSource, races, organizations] = await Promise.all([
4127
4227
  hasCharacterFilters ? apiAllPages(`/api/works/${state.work.id}/characters`) : apiPage(`/api/works/${state.work.id}/characters`, page, pageSize),
4128
- canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4228
+ canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4129
4229
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4130
4230
  ]);
4131
4231
  const characterPage = hasCharacterFilters
@@ -4228,15 +4328,16 @@ async function renderCharacters(page = characterListPage) {
4228
4328
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(pageCharacters.find((item) => item.id === button.dataset.editCharacter))));
4229
4329
  }
4230
4330
 
4231
- async function renderRaces(page = moduleListPages.races) {
4232
- state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
4233
- mountModuleCount(state.races.length);
4331
+ function renderRaceCollection(total, descendantsLoading = false) {
4332
+ mountModuleCount(total);
4234
4333
  const layout = readModuleLayout();
4235
- const pageResult = layout === "rows"
4236
- ? paginateModuleItems(state.races, page, "races")
4237
- : paginateRaceForest(state.races, page, pageSizeFor("races"));
4238
- moduleListPages.races = pageResult.page;
4334
+ const raceItems = layout === "rows"
4335
+ ? [...state.races].sort((left, right) => String(left.name).localeCompare(String(right.name), "zh-CN"))
4336
+ : buildRaceForest(state.races);
4239
4337
  const canEditRaces = canEditModule("races");
4338
+ const directChildCount = (item) => Number.isInteger(Number(item.childCount))
4339
+ ? Number(item.childCount)
4340
+ : Array.isArray(item.children) ? item.children.length : 0;
4240
4341
  const raceActions = (item) => canEditRaces
4241
4342
  ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
4242
4343
  : recordHistoryButton("race", item.id, item.name);
@@ -4244,7 +4345,7 @@ async function renderRaces(page = moduleListPages.races) {
4244
4345
  ? raceActions(item)
4245
4346
  : `<div class="card-actions">${raceActions(item)}</div>`;
4246
4347
  const renderRaceNode = (item) => `<details class="race-tree-node"${state.collapsedRaceIds.has(item.id) ? "" : " open"} data-race-node="${esc(item.id)}">
4247
- <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
4348
+ <summary><span>${esc(item.name)}</span><small>${directChildCount(item)} 个直接子种族</small></summary>
4248
4349
  <div class="race-tree-branch">
4249
4350
  <article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settingsCount ?? item.settings?.length ?? 0} 条自身设定</small>
4250
4351
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
@@ -4256,9 +4357,9 @@ async function renderRaces(page = moduleListPages.races) {
4256
4357
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
4257
4358
  </div>
4258
4359
  </details>`;
4259
- const raceRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
4360
+ const raceRows = () => `<div class="module-row-list">${raceItems.map((item) => {
4260
4361
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
4261
- const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
4362
+ const meta = `${directChildCount(item)} 个直接子种族 · ${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
4262
4363
  return `
4263
4364
  <article class="record-card module-row race-card preview-record-card" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}">
4264
4365
  <small>${esc(meta)}</small>
@@ -4270,15 +4371,65 @@ async function renderRaces(page = moduleListPages.races) {
4270
4371
  if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
4271
4372
  if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
4272
4373
  $("#module-content").innerHTML = state.races.length
4273
- ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${pageResult.items.map(renderRaceNode).join("")}</section>`}${renderModulePagination(pageResult, "races", "种族列表")}`
4374
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级" aria-busy="${descendantsLoading}">${raceItems.map(renderRaceNode).join("")}</section>`}`
4274
4375
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
4275
- bindModuleLayoutToggle(() => renderRaces(pageResult.page));
4276
- bindModulePagination("races", renderRaces);
4376
+ bindModuleLayoutToggle(() => renderRaceCollection(total, descendantsLoading));
4277
4377
  bindRaceTreeExpandToggle();
4278
4378
  bindRaceTreeNodeToggles();
4279
4379
  const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
4280
4380
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
4281
- bindEntityHistoryButtons(async () => { await renderRaces(pageResult.page); await loadAiReferences(); });
4381
+ bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
4382
+ }
4383
+
4384
+ async function ensureCompleteRaceList() {
4385
+ const workId = state.work?.id;
4386
+ if (!workId) return false;
4387
+ const generation = workScopedUiGeneration;
4388
+ if (loadedRaceHierarchyWorkId === workId) return true;
4389
+ if (raceHierarchyLoadPromise && raceHierarchyLoadWorkId === workId) {
4390
+ await raceHierarchyLoadPromise.catch(() => {});
4391
+ }
4392
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return false;
4393
+ if (loadedRaceHierarchyWorkId === workId) return true;
4394
+ const races = await api(`/api/works/${workId}/races`);
4395
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return false;
4396
+ state.races = races;
4397
+ loadedRaceHierarchyWorkId = workId;
4398
+ return true;
4399
+ }
4400
+
4401
+ async function renderRaces() {
4402
+ const workId = state.work.id;
4403
+ const generation = workScopedUiGeneration;
4404
+ const requestId = ++raceListRequestId;
4405
+ const roots = await api(`/api/works/${workId}/races?scope=roots`);
4406
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4407
+ state.races = roots.items;
4408
+ loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
4409
+ renderRaceCollection(roots.total, loadedRaceHierarchyWorkId !== workId);
4410
+ if (loadedRaceHierarchyWorkId === workId) return;
4411
+
4412
+ const dismissLoadingToast = persistentToast("正在加载子种族……");
4413
+ const loadPromise = api(`/api/works/${workId}/races?scope=descendants`).then((descendants) => {
4414
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
4415
+ state.races = [...roots.items, ...descendants];
4416
+ loadedRaceHierarchyWorkId = workId;
4417
+ if (state.module === "races") renderRaceCollection(roots.total);
4418
+ });
4419
+ raceHierarchyLoadPromise = loadPromise;
4420
+ raceHierarchyLoadWorkId = workId;
4421
+ void loadPromise.catch((error) => {
4422
+ if (state.work?.id === workId && generation === workScopedUiGeneration && requestId === raceListRequestId) {
4423
+ renderRaceCollection(roots.total);
4424
+ toast(`父种族已显示,但子种族载入失败:${error.message}`, "error");
4425
+ }
4426
+ }).finally(() => {
4427
+ if (raceHierarchyLoadPromise === loadPromise) {
4428
+ raceHierarchyLoadPromise = null;
4429
+ raceHierarchyLoadWorkId = null;
4430
+ }
4431
+ dismissLoadingToast();
4432
+ });
4282
4433
  }
4283
4434
 
4284
4435
  async function renderOrganizations(page = moduleListPages.organizations) {
@@ -4619,7 +4770,7 @@ async function renderTasks(page = taskListPage) {
4619
4770
  apiPage(`/api/works/${state.work.id}/tasks`, page, pageSize),
4620
4771
  canReadModule("ai-settings")
4621
4772
  ? api(`/api/works/${state.work.id}/ai-settings`)
4622
- : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
4773
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
4623
4774
  ]);
4624
4775
  if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
4625
4776
  taskListPage = taskPage.page;
@@ -4627,10 +4778,18 @@ async function renderTasks(page = taskListPage) {
4627
4778
  const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
4628
4779
  mountModuleCount(taskTotal);
4629
4780
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
4781
+ const autoRunEditing = canConfigureAutoRun && taskAutoRunEditing && taskAutoRunEditingWorkId === state.work.id;
4630
4782
  const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
4631
4783
  const runningCount = Number(taskPage.stats?.runningCount ?? 0);
4632
4784
  const activeTaskCount = pendingCount + runningCount;
4633
4785
  const runningProgress = runningCount ? analysisTaskProgressValue(taskPage.stats?.runningProgress) : 0;
4786
+ const autoRunPaused = Boolean(settings.autoRunEnabled && settings.autoRunPaused);
4787
+ const autoRunActive = Boolean(settings.autoRunEnabled && !autoRunPaused);
4788
+ const autoRunPauseLabel = autoRunPaused ? "恢复自动执行" : "暂停自动执行";
4789
+ const queueProgressLabel = runningCount
4790
+ ? "运行中任务平均进度"
4791
+ : autoRunPaused ? "自动执行已暂停" : autoRunActive ? "等待任务开始" : "自动执行已关闭";
4792
+ const queueProgressClass = runningCount ? "is-running" : autoRunPaused ? "is-paused" : "is-waiting";
4634
4793
  const visibleTaskIds = new Set(tasks.map((item) => String(item.id)));
4635
4794
  for (const taskId of taskStatusSnapshots.keys()) {
4636
4795
  if (!visibleTaskIds.has(taskId)) taskStatusSnapshots.delete(taskId);
@@ -4647,27 +4806,34 @@ async function renderTasks(page = taskListPage) {
4647
4806
  <div class="task-auto-run-copy">
4648
4807
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
4649
4808
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4650
- <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>'}
4651
4813
  </div>
4652
4814
  <div class="task-auto-run-controls">
4653
- <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>自动执行待分析任务</span></label>
4654
- <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}"></label>
4655
- <label>每轮任务上限<input id="task-auto-run-batch-limit" type="number" min="1" max="200" value="${esc(String(settings.autoRunBatchLimit ?? 20))}"></label>
4656
- <button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button>
4657
- <button id="task-auto-run-continue" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>开始下一轮</button>
4815
+ <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""} ${autoRunEditing ? "" : "disabled"}><span>持续自动执行</span></label>
4816
+ <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
4817
+ <label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}" aria-describedby="task-auto-run-daily-help"></label>
4818
+ <label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}" ${autoRunEditing ? "" : "disabled"} aria-readonly="${String(!autoRunEditing)}"></label>
4819
+ ${autoRunEditing
4820
+ ? `<button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button><button id="task-auto-run-pause" class="ghost-button" type="button">${autoRunPauseLabel}</button>`
4821
+ : ""}
4658
4822
  </div>
4659
- <p class="task-auto-run-meta">待执行队列 ${pendingCount} · 正在运行 ${runningCount} 个</p>
4823
+ <p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
4824
+ <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
4825
+ ${autoRunPaused ? `<p class="task-auto-run-alert" role="status"><strong>自动执行已暂停</strong><span>${esc(settings.autoRunPauseReason || "需要人工确认后恢复")}</span>${settings.autoRunResumeAt ? `<small>预计 ${esc(formatDateTime(settings.autoRunResumeAt))} 自动恢复</small>` : ""}</p>` : ""}
4660
4826
  <div class="task-auto-run-progress ${activeTaskCount ? "" : "hidden"}" aria-live="polite">
4661
- <div class="task-auto-run-progress-ring ${runningCount ? "is-running" : "is-waiting"}" role="progressbar" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${runningProgress}">
4827
+ <div class="task-auto-run-progress-ring ${queueProgressClass}" role="progressbar" aria-label="${queueProgressLabel}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${runningProgress}">
4662
4828
  <svg viewBox="0 0 120 120" aria-hidden="true" focusable="false">
4663
4829
  <circle class="task-auto-run-progress-ring-track" cx="60" cy="60" r="52"></circle>
4664
4830
  <circle class="task-auto-run-progress-ring-value" cx="60" cy="60" r="52" pathLength="100" stroke-dasharray="${runningProgress} 100"></circle>
4665
4831
  </svg>
4666
- <div class="task-auto-run-progress-ring-label"><strong>${runningProgress}%</strong><span>${runningCount ? "运行中平均进度" : "等待任务开始"}</span></div>
4832
+ <div class="task-auto-run-progress-ring-label"><strong>${runningProgress}%</strong><span>${queueProgressLabel}</span></div>
4667
4833
  </div>
4668
4834
  <div class="task-auto-run-progress-bar-layout">
4669
- <div class="task-auto-run-progress-label"><span>${runningCount ? "运行中任务平均进度" : "等待任务开始"}</span><strong>${runningProgress}%</strong></div>
4670
- <progress class="task-auto-run-progress-bar ${runningCount ? "is-running" : "is-waiting"}" max="100" value="${runningProgress}" aria-label="${runningCount ? "运行中任务平均进度" : "待执行任务进度"}">${runningProgress}%</progress>
4835
+ <div class="task-auto-run-progress-label"><span>${queueProgressLabel}</span><strong>${runningProgress}%</strong></div>
4836
+ <progress class="task-auto-run-progress-bar ${queueProgressClass}" max="100" value="${runningProgress}" aria-label="${queueProgressLabel}">${runningProgress}%</progress>
4671
4837
  </div>
4672
4838
  </div>
4673
4839
  </section>
@@ -4680,7 +4846,7 @@ async function renderTasks(page = taskListPage) {
4680
4846
  <td class="task-progress-cell">${renderAnalysisTaskProgress(item)}</td>
4681
4847
  <td class="task-row-actions">
4682
4848
  <button class="ghost-button" type="button" data-task-detail="${esc(item.id)}">详情</button>
4683
- ${item.status === "pending" ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
4849
+ ${item.status === "pending" && !item.nextAttemptAt ? `<button class="ghost-button" type="button" data-run-task="${esc(item.id)}">运行</button>` : ""}
4684
4850
  ${item.status === "pending" || item.status === "running" ? `<button class="ghost-button" type="button" data-cancel-task="${esc(item.id)}">取消</button>` : ""}
4685
4851
  </td>
4686
4852
  </tr>`).join("")}</tbody></table>${pagination}` : emptyModule("还没有 AI 分析记录", "点击“开始 AI 分析”,可分析指定章节或整部作品。")}`;
@@ -4691,6 +4857,12 @@ async function renderTasks(page = taskListPage) {
4691
4857
  await renderTasks(Number(button.dataset.taskPage));
4692
4858
  }));
4693
4859
 
4860
+ $("#task-auto-run-edit")?.addEventListener("click", () => {
4861
+ taskAutoRunEditing = true;
4862
+ taskAutoRunEditingWorkId = state.work.id;
4863
+ void renderTasks(taskListPage).catch((error) => toast(error.message, "error"));
4864
+ });
4865
+
4694
4866
  $("#task-auto-run-save")?.addEventListener("click", async () => {
4695
4867
  const button = $("#task-auto-run-save");
4696
4868
  button.disabled = true;
@@ -4700,32 +4872,42 @@ async function renderTasks(page = taskListPage) {
4700
4872
  body: {
4701
4873
  autoRunEnabled: $("#task-auto-run-enabled").checked,
4702
4874
  autoRunConcurrency: Number($("#task-auto-run-concurrency").value),
4703
- autoRunBatchLimit: Number($("#task-auto-run-batch-limit").value)
4875
+ autoRunDailyTaskLimit: Number($("#task-auto-run-daily-limit").value),
4876
+ autoRunFailureThreshold: Number($("#task-auto-run-failure-threshold").value)
4704
4877
  }
4705
4878
  });
4706
4879
  toast(updated.autoRunEnabled
4707
- ? `自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个,每轮最多 ${updated.autoRunBatchLimit} 个`
4880
+ ? `持续自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个`
4708
4881
  : "自动执行已关闭");
4882
+ taskAutoRunEditing = false;
4883
+ taskAutoRunEditingWorkId = null;
4709
4884
  await renderTasks();
4710
4885
  } catch (error) {
4711
4886
  toast(error.message, "error");
4712
4887
  button.disabled = false;
4713
4888
  }
4714
4889
  });
4715
- $("#task-auto-run-continue")?.addEventListener("click", async () => {
4716
- const button = $("#task-auto-run-continue");
4890
+ $("#task-auto-run-pause")?.addEventListener("click", async () => {
4891
+ const button = $("#task-auto-run-pause");
4717
4892
  button.disabled = true;
4718
4893
  try {
4719
- const result = await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4720
- toast(`已开始下一轮,队列中还有 ${result.pendingCount} 个待执行任务`);
4894
+ if (autoRunPaused) {
4895
+ await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4896
+ toast("自动执行已恢复");
4897
+ } else {
4898
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { autoRunEnabled: false } });
4899
+ toast("已暂停自动执行");
4900
+ }
4901
+ taskAutoRunEditing = false;
4902
+ taskAutoRunEditingWorkId = null;
4721
4903
  await refreshBackgroundTaskCenter({ announce: false });
4722
4904
  await renderTasks();
4905
+ window.setTimeout(() => $("#task-auto-run-edit")?.focus(), 0);
4723
4906
  } catch (error) {
4724
4907
  toast(error.message, "error");
4725
4908
  button.disabled = false;
4726
4909
  }
4727
4910
  });
4728
-
4729
4911
  $("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
4730
4912
  if (button.disabled) return;
4731
4913
  button.disabled = true;
@@ -4776,7 +4958,7 @@ async function renderTasks(page = taskListPage) {
4776
4958
  button.disabled = false;
4777
4959
  }
4778
4960
  }));
4779
- scheduleTaskProgressRefresh(state.work.id, runningCount);
4961
+ scheduleTaskProgressRefresh(state.work.id, runningCount > 0 || (pendingCount > 0 && autoRunActive) ? 1 : 0);
4780
4962
  }
4781
4963
 
4782
4964
  async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
@@ -4796,6 +4978,51 @@ async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
4796
4978
  }
4797
4979
  }
4798
4980
 
4981
+ async function rerunAnalysisTaskWithModel(task, button) {
4982
+ const originalLabel = button.textContent;
4983
+ button.disabled = true;
4984
+ button.textContent = "加载模型";
4985
+ try {
4986
+ const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
4987
+ const currentModelId = String(task.model?.id ?? "");
4988
+ const availableModels = models.filter((model) =>
4989
+ model.id !== currentModelId
4990
+ && model.enabled
4991
+ && model.providerStatus === "enabled"
4992
+ && model.providerConnectionStatus === "success"
4993
+ );
4994
+ if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
4995
+ $("#form-dialog").close();
4996
+ const currentModelLabel = task.model?.displayName || "运行时默认模型";
4997
+ openDialog("选择重试模型",
4998
+ `<p class="task-rerun-model-intro">当前模型:${esc(currentModelLabel)}。选择其他模型后,将按原任务范围和最新资料创建新任务。</p><label>重试模型<select name="modelId" required aria-label="重试模型">
4999
+ ${availableModels.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")}
5000
+ </select></label>`,
5001
+ async (form) => {
5002
+ const modelId = String(form.get("modelId") ?? "").trim();
5003
+ const selectedModel = availableModels.find((model) => model.id === modelId);
5004
+ const rerun = await api(`/api/tasks/${encodeURIComponent(task.id)}/rerun`, {
5005
+ method: "POST",
5006
+ body: { modelId }
5007
+ });
5008
+ toast(`已使用${selectedModel ? `“${modelOptionLabel(selectedModel)}”` : "新模型"}创建重试任务 ${rerun.id}`);
5009
+ await refreshBackgroundTaskCenter({ announce: false });
5010
+ if (state.module === "tasks" && state.work?.id === task.workId) await renderTasks();
5011
+ },
5012
+ "AI 分析重试",
5013
+ {
5014
+ submitLabel: "换模型重试",
5015
+ pendingLabel: "创建中…",
5016
+ pendingMessage: "正在使用新模型创建重试任务,请稍候",
5017
+ errorPrefix: "换模型重试失败:"
5018
+ });
5019
+ } catch (error) {
5020
+ toast(error.message, "error");
5021
+ button.disabled = false;
5022
+ button.textContent = originalLabel;
5023
+ }
5024
+ }
5025
+
4799
5026
  function stopTaskProgressRefresh() {
4800
5027
  if (taskProgressRefreshTimer === null) return;
4801
5028
  window.clearTimeout(taskProgressRefreshTimer);
@@ -5246,7 +5473,7 @@ function openTaskDetailDialog(task, trace) {
5246
5473
  <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
5247
5474
  <div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
5248
5475
  <div><strong>结果摘要</strong>${resultPreview}</div>
5249
- ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
5476
+ ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><button class="ghost-button" type="button" data-rerun-task-model="${esc(task.id)}">换模型重试</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
5250
5477
  </section>
5251
5478
  ${renderTaskTraceVisualization(trace, task.id)}
5252
5479
  </div>`,
@@ -5258,6 +5485,9 @@ function openTaskDetailDialog(task, trace) {
5258
5485
  $("#dialog-fields").querySelector("[data-rerun-task-detail]")?.addEventListener("click", async (event) => {
5259
5486
  await rerunAnalysisTask(event.currentTarget.dataset.rerunTaskDetail, event.currentTarget, { closeDetail: true });
5260
5487
  });
5488
+ $("#dialog-fields").querySelector("[data-rerun-task-model]")?.addEventListener("click", async (event) => {
5489
+ await rerunAnalysisTaskWithModel(task, event.currentTarget);
5490
+ });
5261
5491
  $("#dialog-fields").querySelector("[data-task-identity-repair]")?.addEventListener("click", async (event) => {
5262
5492
  const button = event.currentTarget;
5263
5493
  button.disabled = true;
@@ -5703,7 +5933,7 @@ async function renderBookAiSettings() {
5703
5933
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5704
5934
  title: "本书 Token 用量",
5705
5935
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
5706
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5936
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5707
5937
  scrollUsageCalendarsToLatest(host);
5708
5938
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
5709
5939
  "beforebegin",
@@ -6801,6 +7031,107 @@ function toggleVditorLineNumbers(editor) {
6801
7031
  const enabled = host.classList.toggle("show-line-numbers");
6802
7032
  const button = host.querySelector(".vditor-line-number-button");
6803
7033
  if (button) button.setAttribute("aria-pressed", String(enabled));
7034
+ updateVditorLineNumbers(editor, getVditorMarkdown(editor));
7035
+ }
7036
+
7037
+ function getVditorMarkdown(editor) {
7038
+ if (typeof editor?.getValue === "function") {
7039
+ try {
7040
+ return String(editor.getValue() ?? "");
7041
+ } catch {
7042
+ return "";
7043
+ }
7044
+ }
7045
+ const host = editor?.vditor?.element;
7046
+ const valueField = host?.closest("[data-vditor-editor-field], .setting-markdown-field, .character-markdown-editor")?.querySelector("[data-vditor-value]")
7047
+ ?? host?.parentElement?.querySelector("[data-vditor-value]");
7048
+ return String(valueField?.value ?? "");
7049
+ }
7050
+
7051
+ function getVditorVisibleSurface(content) {
7052
+ const candidates = [
7053
+ content.querySelector(".vditor-ir .vditor-reset"),
7054
+ content.querySelector(".vditor-wysiwyg .vditor-reset"),
7055
+ content.querySelector(".vditor-sv textarea"),
7056
+ content.querySelector(".vditor-sv")
7057
+ ];
7058
+ return candidates.find((surface) => {
7059
+ if (!surface) return false;
7060
+ const rect = surface.getBoundingClientRect();
7061
+ const style = getComputedStyle(surface);
7062
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
7063
+ }) ?? null;
7064
+ }
7065
+
7066
+ function getVditorLineHeight(surface) {
7067
+ const style = getComputedStyle(surface);
7068
+ return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 24;
7069
+ }
7070
+
7071
+ function collectVditorVisualLineRects(surface) {
7072
+ const rects = [];
7073
+ const scrollTop = Number(surface.scrollTop) || 0;
7074
+ const visit = (node) => {
7075
+ if (node.nodeType === Node.TEXT_NODE) {
7076
+ if (!node.textContent) return;
7077
+ const range = document.createRange();
7078
+ range.selectNodeContents(node);
7079
+ [...range.getClientRects()].forEach((rect) => {
7080
+ if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
7081
+ });
7082
+ return;
7083
+ }
7084
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
7085
+ if (node.tagName === "BR") {
7086
+ const rect = node.getBoundingClientRect();
7087
+ if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
7088
+ }
7089
+ [...node.childNodes].forEach(visit);
7090
+ };
7091
+ visit(surface);
7092
+ if (rects.length === 0) {
7093
+ const rect = surface.getBoundingClientRect();
7094
+ const style = getComputedStyle(surface);
7095
+ rects.push({ top: rect.top + scrollTop + (parseFloat(style.paddingTop) || 0), height: getVditorLineHeight(surface) });
7096
+ }
7097
+ return rects;
7098
+ }
7099
+
7100
+ function syncVditorLineNumberScroll(editor) {
7101
+ const surface = editor?.__vditorLineNumberSurface;
7102
+ const gutter = editor?.vditor?.element?.querySelector(".vditor-line-numbers");
7103
+ if (!surface || !gutter) return;
7104
+ gutter.style.transform = `translateY(${-surface.scrollTop}px)`;
7105
+ gutter.dataset.scrollTop = String(surface.scrollTop);
7106
+ }
7107
+
7108
+ function scheduleVditorLineNumbers(editor) {
7109
+ if (!editor || editor.__vditorLineNumberFrame) return;
7110
+ editor.__vditorLineNumberFrame = requestAnimationFrame(() => {
7111
+ editor.__vditorLineNumberFrame = null;
7112
+ updateVditorLineNumbers(editor, getVditorMarkdown(editor));
7113
+ });
7114
+ }
7115
+
7116
+ function setupVditorLineNumberSync(editor, content, surface) {
7117
+ if (editor.__vditorLineNumberSurface !== surface) {
7118
+ editor.__vditorLineNumberSurface?.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
7119
+ editor.__vditorLineNumberSurface = surface;
7120
+ editor.__vditorLineNumberScrollHandler = () => syncVditorLineNumberScroll(editor);
7121
+ surface.addEventListener("scroll", editor.__vditorLineNumberScrollHandler, { passive: true });
7122
+ }
7123
+ if (!editor.__vditorLineNumberObserver) {
7124
+ editor.__vditorLineNumberObserver = new MutationObserver((mutations) => {
7125
+ if (mutations.some((mutation) => !(mutation.target instanceof Element) || !mutation.target.closest(".vditor-line-numbers"))) {
7126
+ scheduleVditorLineNumbers(editor);
7127
+ }
7128
+ });
7129
+ editor.__vditorLineNumberObserver.observe(content, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "style"] });
7130
+ }
7131
+ if (!editor.__vditorLineNumberResizeObserver && typeof ResizeObserver !== "undefined") {
7132
+ editor.__vditorLineNumberResizeObserver = new ResizeObserver(() => scheduleVditorLineNumbers(editor));
7133
+ editor.__vditorLineNumberResizeObserver.observe(content);
7134
+ }
6804
7135
  }
6805
7136
 
6806
7137
  function updateVditorLineNumbers(editor, markdown) {
@@ -6813,14 +7144,30 @@ function updateVditorLineNumbers(editor, markdown) {
6813
7144
  gutter.setAttribute("aria-hidden", "true");
6814
7145
  content.prepend(gutter);
6815
7146
  }
6816
- const lineCount = Math.max(1, String(markdown ?? "").split(/\r?\n/gu).length);
7147
+ const surface = getVditorVisibleSurface(content);
7148
+ if (!surface) return;
7149
+ setupVditorLineNumberSync(editor, content, surface);
7150
+ const contentRect = content.getBoundingClientRect();
7151
+ const lineHeight = getVditorLineHeight(surface);
7152
+ const normalizedMarkdown = String(markdown ?? "").replace(/\r\n?/gu, "\n");
7153
+ const blankLineCount = normalizedMarkdown.trim() === "" ? 0 : normalizedMarkdown.split("\n").filter((line) => line.trim() === "").length;
7154
+ const visualRows = buildVditorLineNumberRows(collectVditorVisualLineRects(surface), { lineHeight, blankLineCount });
7155
+ gutter.style.height = `${content.clientHeight}px`;
6817
7156
  const fragment = document.createDocumentFragment();
6818
- for (let line = 1; line <= lineCount; line += 1) {
7157
+ visualRows.forEach((row, index) => {
6819
7158
  const number = document.createElement("span");
6820
- number.textContent = String(line);
7159
+ number.className = "vditor-line-number";
7160
+ number.textContent = String(index + 1);
7161
+ number.dataset.lineNumber = String(index + 1);
7162
+ number.dataset.blank = String(row.blank);
7163
+ number.style.top = `${row.top - contentRect.top}px`;
7164
+ number.style.height = `${row.height}px`;
7165
+ number.style.lineHeight = `${row.height}px`;
6821
7166
  fragment.append(number);
6822
- }
7167
+ });
6823
7168
  gutter.replaceChildren(fragment);
7169
+ gutter.dataset.lineCount = String(visualRows.length);
7170
+ syncVditorLineNumberScroll(editor);
6824
7171
  }
6825
7172
 
6826
7173
  function updateVditorWordCount(editor, markdown) {
@@ -6865,6 +7212,12 @@ function ensureVditorIconScript() {
6865
7212
  function destroyVditorEditor(editor) {
6866
7213
  if (!editor) return;
6867
7214
  editor.__attachmentObserver?.disconnect();
7215
+ editor.__vditorLineNumberObserver?.disconnect();
7216
+ editor.__vditorLineNumberResizeObserver?.disconnect();
7217
+ if (editor.__vditorLineNumberSurface && editor.__vditorLineNumberScrollHandler) {
7218
+ editor.__vditorLineNumberSurface.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
7219
+ }
7220
+ if (editor.__vditorLineNumberFrame) cancelAnimationFrame(editor.__vditorLineNumberFrame);
6868
7221
  const host = editor.vditor?.element;
6869
7222
  editor.destroy();
6870
7223
  if (host) delete host.__vditor;
@@ -7307,7 +7660,7 @@ async function showCharacterHistory() {
7307
7660
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
7308
7661
  entityEditorReadOnly = readOnly;
7309
7662
  [state.races, state.organizations, state.characters] = await Promise.all([
7310
- canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
7663
+ canReadModule("races") ? api(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
7311
7664
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([]),
7312
7665
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
7313
7666
  ]);
@@ -7440,6 +7793,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
7440
7793
  async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
7441
7794
  entityEditorReadOnly = readOnly;
7442
7795
  await discardPendingMarkdownAttachments();
7796
+ if (kind === "race" && !(await ensureCompleteRaceList())) return;
7443
7797
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
7444
7798
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
7445
7799
  const isRace = kind === "race";
@@ -8553,18 +8907,22 @@ async function showChapterInsight() {
8553
8907
  if (requestId !== chapterInsightRequestId || state.chapter?.id !== chapterId || $("#editor-view").classList.contains("hidden")) return;
8554
8908
  const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
8555
8909
  const region = $("#toast-region");
8910
+ const chapterTitle = state.chapter.title;
8556
8911
  const element = document.createElement("section");
8557
8912
  element.id = "chapter-insight-toast";
8558
8913
  element.className = "toast chapter-insight-toast";
8559
8914
  element.setAttribute("role", "region");
8560
- element.setAttribute("aria-label", "章节概览");
8915
+ element.setAttribute("aria-label", `章节概览:${chapterTitle}`);
8561
8916
  const header = document.createElement("header");
8562
8917
  header.className = "chapter-insight-toast-header";
8563
8918
  const heading = document.createElement("div");
8564
8919
  heading.className = "chapter-insight-toast-heading";
8565
8920
  const title = document.createElement("strong");
8566
8921
  title.textContent = "章节概览";
8567
- heading.append(title);
8922
+ const chapterName = document.createElement("span");
8923
+ chapterName.className = "chapter-insight-toast-chapter-title";
8924
+ chapterName.textContent = chapterTitle;
8925
+ heading.append(title, chapterName);
8568
8926
  if (insight && insight.chapterVersion !== state.chapter.versionNo) {
8569
8927
  const stale = document.createElement("span");
8570
8928
  stale.textContent = `基于旧版本 v${insight.chapterVersion}`;
@@ -9209,7 +9567,6 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
9209
9567
  pageSizes: {
9210
9568
  settings: Number($("#page-size-settings").value),
9211
9569
  characters: Number($("#page-size-characters").value),
9212
- races: Number($("#page-size-races").value),
9213
9570
  organizations: Number($("#page-size-organizations").value),
9214
9571
  timeline: Number($("#page-size-timeline").value),
9215
9572
  outlines: Number($("#page-size-outlines").value),