@musnows/scriverse 0.5.9 → 0.5.11

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,12 +1,14 @@
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";
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";
11
+ import { createStreamTypewriter } from "/stream-typewriter.js?v=20260729-ai-stream-typewriter-v1";
10
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
11
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
12
14
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
@@ -14,7 +16,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
14
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
15
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
16
18
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
17
- 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";
18
20
  import {
19
21
  chapterVersionSourceLabel,
20
22
  foreshadowStatusLabel,
@@ -34,12 +36,12 @@ import {
34
36
  timelineStatusLabel,
35
37
  characterStateFieldLabel
36
38
  } from "/display-labels.js?v=20260728-hybrid-search-v1";
37
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
39
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
38
40
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
39
41
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
40
42
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
41
43
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
42
- 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";
43
45
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
44
46
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
45
47
  import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
@@ -58,6 +60,7 @@ import {
58
60
  } from "/avatar-crop.js?v=20260725-avatar-crop";
59
61
 
60
62
  const defaultPageSizes = Object.freeze({
63
+ drafts: 30,
61
64
  settings: 30,
62
65
  characters: 30,
63
66
  races: 30,
@@ -141,6 +144,8 @@ let chapterMovePending = false;
141
144
 
142
145
  let timelineMultiSelectEnabled = false;
143
146
  let taskProgressRefreshTimer = null;
147
+ let taskAutoRunEditing = false;
148
+ let taskAutoRunEditingWorkId = null;
144
149
  let relationshipSearchIndexRefreshTimer = null;
145
150
  let backgroundTaskCenterTimer = null;
146
151
  let backgroundTaskCenterRequest = 0;
@@ -868,7 +873,9 @@ let entityEditorReadOnly = false;
868
873
  let chapterEditorReadOnly = true;
869
874
  let characterListPage = 1;
870
875
  let taskListPage = 1;
876
+ let draftTypeFilter = "all";
871
877
  const moduleListPages = {
878
+ drafts: 1,
872
879
  settings: 1,
873
880
  organizations: 1,
874
881
  timeline: 1,
@@ -898,6 +905,7 @@ let characterSectionEditorDirty = false;
898
905
  let settingEditorVditor = null;
899
906
  let knowledgeSectionVditor = null;
900
907
  let characterSectionVditor = null;
908
+ let formDialogVditors = [];
901
909
  let entityHistoryContext = null;
902
910
  let moduleContentInteractionsBound = false;
903
911
 
@@ -1364,7 +1372,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1364
1372
  read_chapters: "读取章节",
1365
1373
  grep: "查询正文关键字",
1366
1374
  search_story_entities: "搜索作品实体",
1367
- read_character_sections: "读取人物 Markdown 章节"
1375
+ read_character_sections: "读取人物 Markdown 章节",
1376
+ search_drafts: "搜索草稿"
1368
1377
  };
1369
1378
 
1370
1379
  const AI_TOOL_DESCRIPTIONS = {
@@ -1372,10 +1381,50 @@ const AI_TOOL_DESCRIPTIONS = {
1372
1381
  read_chapters: "读取指定章节的概要、正文或两者。",
1373
1382
  grep: "查询正文关键字所在的完整段落及章节信息。",
1374
1383
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1375
- read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
1384
+ read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1385
+ search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
1376
1386
  };
1377
1387
 
1378
1388
  let aiFeedScrollFrame = null;
1389
+ let markdownTableMenuTarget = null;
1390
+ let markdownTableMenuTrigger = null;
1391
+
1392
+ function closeMarkdownTableMenu(restoreFocus = false) {
1393
+ const trigger = markdownTableMenuTrigger;
1394
+ $("#markdown-table-menu").classList.add("hidden");
1395
+ markdownTableMenuTarget = null;
1396
+ markdownTableMenuTrigger = null;
1397
+ if (restoreFocus && trigger?.isConnected) trigger.focus();
1398
+ }
1399
+
1400
+ function setMarkdownTableWrapping(table, wrapping) {
1401
+ table.classList.toggle("is-wrapping", wrapping);
1402
+ table.setAttribute("aria-label", wrapping ? "Markdown 表格,内容自动换行" : "Markdown 表格,内容不换行");
1403
+ }
1404
+
1405
+ function openMarkdownTableMenu(header, clientX, clientY) {
1406
+ const table = header.closest(".markdown-table-scroll");
1407
+ if (!table) return;
1408
+ if (header.closest(".is-streaming")) {
1409
+ toast("回复生成完成后可以设置表格换行");
1410
+ return;
1411
+ }
1412
+ closeChapterTypeMenu();
1413
+ closeLineCitationMenu();
1414
+ markdownTableMenuTarget = table;
1415
+ markdownTableMenuTrigger = header;
1416
+ const menu = $("#markdown-table-menu");
1417
+ const toggle = $("#markdown-table-wrap-toggle");
1418
+ toggle.setAttribute("aria-checked", String(table.classList.contains("is-wrapping")));
1419
+ menu.classList.remove("hidden");
1420
+ const menuRect = menu.getBoundingClientRect();
1421
+ const headerRect = header.getBoundingClientRect();
1422
+ const anchorX = clientX > 0 ? clientX : headerRect.left;
1423
+ const anchorY = clientY > 0 ? clientY : headerRect.bottom;
1424
+ menu.style.left = `${Math.max(8, Math.min(anchorX, window.innerWidth - menuRect.width - 8))}px`;
1425
+ menu.style.top = `${Math.max(8, Math.min(anchorY, window.innerHeight - menuRect.height - 8))}px`;
1426
+ toggle.focus();
1427
+ }
1379
1428
 
1380
1429
  function scrollAiFeedToBottom() {
1381
1430
  const feed = $("#ai-feed");
@@ -2703,6 +2752,7 @@ function renderSettingsHub() {
2703
2752
  const hasWork = Boolean(state.work);
2704
2753
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
2705
2754
  const canReadAggregate = hasWork && canReadAggregateContent();
2755
+ const canReadFullExport = canReadAggregate && canReadModule("drafts");
2706
2756
  const isAdmin = state.user?.role === "admin";
2707
2757
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
2708
2758
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -2712,7 +2762,7 @@ function renderSettingsHub() {
2712
2762
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
2713
2763
  $("#work-audit-button").disabled = !canManageWork;
2714
2764
  $("#top-search-button").disabled = !canReadAggregate;
2715
- $("#export-button").disabled = !canReadAggregate;
2765
+ $("#export-button").disabled = !canReadFullExport;
2716
2766
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
2717
2767
  $("#settings-work-note").textContent = hasWork
2718
2768
  ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
@@ -2793,11 +2843,15 @@ const workAuditActionLabels = {
2793
2843
  "chapter.deleted": "删除章节",
2794
2844
  "chapter.purged": "彻底删除章节",
2795
2845
  "chapter.restored": "恢复章节",
2846
+ "draft.created": "创建草稿",
2847
+ "draft.updated": "更新草稿",
2848
+ "draft.deleted": "删除草稿",
2849
+ "draft.restored": "恢复草稿",
2796
2850
  "work.imported": "导入正文"
2797
2851
  };
2798
2852
 
2799
2853
  function workAuditEntityLabel(type) {
2800
- return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
2854
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
2801
2855
  }
2802
2856
 
2803
2857
  function workAuditDetailText(detail) {
@@ -2908,6 +2962,7 @@ async function openPlatformUiSettingsDialog() {
2908
2962
  const settings = await api("/api/platform/ui-settings");
2909
2963
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2910
2964
  const pageSizes = normalizePageSizes(settings.pageSizes);
2965
+ $("#page-size-drafts").value = String(pageSizes.drafts);
2911
2966
  $("#page-size-settings").value = String(pageSizes.settings);
2912
2967
  $("#page-size-characters").value = String(pageSizes.characters);
2913
2968
  $("#page-size-organizations").value = String(pageSizes.organizations);
@@ -3289,6 +3344,7 @@ function resetWorkScopedUiCaches() {
3289
3344
  state.settings = [];
3290
3345
  state.races = [];
3291
3346
  characterListPage = 1;
3347
+ draftTypeFilter = "all";
3292
3348
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3293
3349
  relationshipFilters.fromCharacterIds = [];
3294
3350
  relationshipFilters.toCharacterIds = [];
@@ -3727,6 +3783,7 @@ function markActiveModule(module) {
3727
3783
  }
3728
3784
 
3729
3785
  const moduleMeta = {
3786
+ drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
3730
3787
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
3731
3788
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
3732
3789
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -3749,6 +3806,10 @@ async function showModule(module) {
3749
3806
  }
3750
3807
  module = fallback;
3751
3808
  }
3809
+ if (module !== "tasks") {
3810
+ taskAutoRunEditing = false;
3811
+ taskAutoRunEditingWorkId = null;
3812
+ }
3752
3813
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
3753
3814
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
3754
3815
  state.module = module;
@@ -3778,6 +3839,7 @@ async function showModule(module) {
3778
3839
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
3779
3840
  bindModuleContentInteractions();
3780
3841
  try {
3842
+ if (module === "drafts") await renderDrafts();
3781
3843
  if (module === "settings") await renderSettings();
3782
3844
  if (module === "characters") await renderCharacters(characterListPage);
3783
3845
  if (module === "races") await renderRaces();
@@ -4178,6 +4240,135 @@ function settingRecordActions(item) {
4178
4240
  : recordHistoryButton("setting", item.id, item.title);
4179
4241
  }
4180
4242
 
4243
+ function draftTypeLabel(draftType) {
4244
+ return draftType === "setting" ? "设定草稿" : "正文草稿";
4245
+ }
4246
+
4247
+ async function deleteDraft(item) {
4248
+ if (!item || !canEditModule("drafts")) return;
4249
+ if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4250
+ title: "删除草稿",
4251
+ confirmLabel: "确认删除"
4252
+ })) return;
4253
+ try {
4254
+ await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4255
+ await renderDrafts(moduleListPages.drafts);
4256
+ toast("草稿已删除");
4257
+ } catch (error) {
4258
+ toast(error.message, "error");
4259
+ }
4260
+ }
4261
+
4262
+ function openDraftDialog(item = null, { readOnly = false } = {}) {
4263
+ const viewOnly = readOnly || !canEditModule("drafts");
4264
+ const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4265
+ + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4266
+ + field("title", "标题", "text", item?.title ?? "")
4267
+ + field("content", "内容", "markdown", item?.content ?? "", {
4268
+ placeholder: "记录尚未定稿的片段、方向或设定想法……",
4269
+ readOnly: viewOnly
4270
+ });
4271
+ openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4272
+ if (viewOnly) return;
4273
+ const title = String(form.get("title") ?? "").trim();
4274
+ if (!title) throw new Error("请填写草稿标题");
4275
+ const body = {
4276
+ draftType: form.get("draftType"),
4277
+ title,
4278
+ content: String(form.get("content") ?? ""),
4279
+ ...(item ? { expectedVersionNo: item.versionNo } : {})
4280
+ };
4281
+ await api(item ? `/api/drafts/${encodeURIComponent(item.id)}` : `/api/works/${encodeURIComponent(state.work.id)}/drafts`, {
4282
+ method: item ? "PATCH" : "POST",
4283
+ body
4284
+ });
4285
+ await renderDrafts(moduleListPages.drafts);
4286
+ toast(item ? "草稿已保存" : "草稿已创建");
4287
+ }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4288
+ submitLabel: viewOnly ? "关闭" : "保存草稿",
4289
+ hideCancel: viewOnly,
4290
+ wide: true,
4291
+ errorPrefix: "草稿保存失败:"
4292
+ });
4293
+ if (viewOnly) {
4294
+ $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
4295
+ if (control instanceof HTMLSelectElement) control.disabled = true;
4296
+ else control.readOnly = true;
4297
+ });
4298
+ }
4299
+ }
4300
+
4301
+ async function renderDrafts(page = moduleListPages.drafts) {
4302
+ const allDrafts = await apiAllPages(`/api/works/${state.work.id}/drafts`);
4303
+ const drafts = draftTypeFilter === "all"
4304
+ ? allDrafts
4305
+ : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
4306
+ mountModuleCount(drafts.length);
4307
+ const pageResult = paginateModuleItems(drafts, page, "drafts");
4308
+ moduleListPages.drafts = pageResult.page;
4309
+ const layout = readModuleLayout();
4310
+ if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4311
+ else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4312
+ const filterToolbar = `<section class="draft-filter-toolbar" aria-label="草稿筛选">
4313
+ <label for="draft-type-filter">草稿类型</label>
4314
+ <select id="draft-type-filter" aria-label="按草稿类型筛选">
4315
+ <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
4316
+ <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
4317
+ <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
4318
+ </select>
4319
+ ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4320
+ </section>`;
4321
+ const actions = (item) => canEditModule("drafts")
4322
+ ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}<button type="button" data-delete-draft="${esc(item.id)}">删除</button>${recordHistoryButton("draft", item.id, item.title)}`
4323
+ : recordHistoryButton("draft", item.id, item.title);
4324
+ const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4325
+ <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4326
+ <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4327
+ <h3>${esc(item.title)}</h3>
4328
+ <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
4329
+ <div class="card-actions">${actions(item)}</div>
4330
+ </article>`).join("")}</div>`;
4331
+ const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4332
+ const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4333
+ 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)}">
4334
+ <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4335
+ <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4336
+ <div class="card-actions">${actions(item)}</div>
4337
+ </article>`;
4338
+ }).join("")}</div>`;
4339
+ const emptyDrafts = allDrafts.length
4340
+ ? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
4341
+ : emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4342
+ $("#module-content").innerHTML = filterToolbar + (drafts.length
4343
+ ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
4344
+ : emptyDrafts);
4345
+ $("#draft-type-filter").addEventListener("change", async (event) => {
4346
+ draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
4347
+ moduleListPages.drafts = 1;
4348
+ await renderDrafts(1);
4349
+ });
4350
+ bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4351
+ bindModulePagination("drafts", renderDrafts);
4352
+ const draftById = (draftId) => drafts.find((draft) => draft.id === draftId);
4353
+ $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
4354
+ const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
4355
+ card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
4356
+ card.addEventListener("keydown", (event) => {
4357
+ if ((event.key === "Enter" || event.key === " ") && !event.target.closest("button, a")) {
4358
+ event.preventDefault();
4359
+ void open();
4360
+ }
4361
+ });
4362
+ });
4363
+ $("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
4364
+ openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
4365
+ }));
4366
+ $("#module-content").querySelectorAll("[data-delete-draft]").forEach((button) => button.addEventListener("click", () => {
4367
+ void deleteDraft(draftById(button.dataset.deleteDraft));
4368
+ }));
4369
+ bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
4370
+ }
4371
+
4181
4372
  function renderSettingCards(records) {
4182
4373
  return `<div class="card-grid">${records.map((item) => `
4183
4374
  <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>
@@ -4771,12 +4962,14 @@ async function renderTasks(page = taskListPage) {
4771
4962
  const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
4772
4963
  mountModuleCount(taskTotal);
4773
4964
  const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
4965
+ const autoRunEditing = canConfigureAutoRun && taskAutoRunEditing && taskAutoRunEditingWorkId === state.work.id;
4774
4966
  const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
4775
4967
  const runningCount = Number(taskPage.stats?.runningCount ?? 0);
4776
4968
  const activeTaskCount = pendingCount + runningCount;
4777
4969
  const runningProgress = runningCount ? analysisTaskProgressValue(taskPage.stats?.runningProgress) : 0;
4778
4970
  const autoRunPaused = Boolean(settings.autoRunEnabled && settings.autoRunPaused);
4779
4971
  const autoRunActive = Boolean(settings.autoRunEnabled && !autoRunPaused);
4972
+ const autoRunPauseLabel = autoRunPaused ? "恢复自动执行" : "暂停自动执行";
4780
4973
  const queueProgressLabel = runningCount
4781
4974
  ? "运行中任务平均进度"
4782
4975
  : autoRunPaused ? "自动执行已暂停" : autoRunActive ? "等待任务开始" : "自动执行已关闭";
@@ -4794,18 +4987,24 @@ async function renderTasks(page = taskListPage) {
4794
4987
  : "";
4795
4988
  $("#module-content").innerHTML = `
4796
4989
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
4797
- <div class="task-auto-run-copy">
4798
- <strong id="task-auto-run-title">自动执行待分析任务</strong>
4799
- <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4800
- <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
4990
+ <div class="task-auto-run-header">
4991
+ <div class="task-auto-run-copy">
4992
+ <strong id="task-auto-run-title">自动执行待分析任务</strong>
4993
+ <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4994
+ <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
4995
+ </div>
4996
+ <div class="task-auto-run-actions">
4997
+ ${autoRunEditing ? "" : '<button id="task-auto-run-edit" class="ghost-button" type="button">编辑</button>'}
4998
+ </div>
4801
4999
  </div>
4802
5000
  <div class="task-auto-run-controls">
4803
- <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""}><span>持续自动执行</span></label>
4804
- <label>同时运行上限<input id="task-auto-run-concurrency" type="number" min="1" max="8" value="${esc(String(settings.autoRunConcurrency ?? 2))}"></label>
4805
- <label>每日任务上限<input id="task-auto-run-daily-limit" type="number" min="0" max="10000" value="${esc(String(settings.autoRunDailyTaskLimit ?? 0))}" aria-describedby="task-auto-run-daily-help"></label>
4806
- <label>连续失败暂停阈值<input id="task-auto-run-failure-threshold" type="number" min="1" max="10" value="${esc(String(settings.autoRunFailureThreshold ?? 3))}"></label>
4807
- <button id="task-auto-run-save" class="primary-button" type="button">保存并生效</button>
4808
- <button id="task-auto-run-toggle" class="ghost-button" type="button" ${settings.autoRunEnabled ? "" : "disabled"}>${autoRunPaused ? "恢复自动执行" : "暂停自动执行"}</button>
5001
+ <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""} ${autoRunEditing ? "" : "disabled"}><span>持续自动执行</span></label>
5002
+ <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>
5003
+ <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>
5004
+ <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>
5005
+ ${autoRunEditing
5006
+ ? `<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>`
5007
+ : ""}
4809
5008
  </div>
4810
5009
  <p id="task-auto-run-daily-help" class="task-auto-run-help">每日任务上限填 0 表示不限制;达到上限后会在下一个 UTC 自然日自动恢复。</p>
4811
5010
  <p class="task-auto-run-meta">待执行队列 ${pendingCount} 个 · 正在运行 ${runningCount} 个 · ${autoRunPaused ? "已暂停" : autoRunActive ? "持续执行中" : "未开启"}</p>
@@ -4844,6 +5043,12 @@ async function renderTasks(page = taskListPage) {
4844
5043
  await renderTasks(Number(button.dataset.taskPage));
4845
5044
  }));
4846
5045
 
5046
+ $("#task-auto-run-edit")?.addEventListener("click", () => {
5047
+ taskAutoRunEditing = true;
5048
+ taskAutoRunEditingWorkId = state.work.id;
5049
+ void renderTasks(taskListPage).catch((error) => toast(error.message, "error"));
5050
+ });
5051
+
4847
5052
  $("#task-auto-run-save")?.addEventListener("click", async () => {
4848
5053
  const button = $("#task-auto-run-save");
4849
5054
  button.disabled = true;
@@ -4860,15 +5065,16 @@ async function renderTasks(page = taskListPage) {
4860
5065
  toast(updated.autoRunEnabled
4861
5066
  ? `持续自动执行已开启:同时最多 ${updated.autoRunConcurrency} 个`
4862
5067
  : "自动执行已关闭");
5068
+ taskAutoRunEditing = false;
5069
+ taskAutoRunEditingWorkId = null;
4863
5070
  await renderTasks();
4864
- window.setTimeout(() => $("#task-auto-run-save")?.focus(), 0);
4865
5071
  } catch (error) {
4866
5072
  toast(error.message, "error");
4867
5073
  button.disabled = false;
4868
5074
  }
4869
5075
  });
4870
- $("#task-auto-run-toggle")?.addEventListener("click", async () => {
4871
- const button = $("#task-auto-run-toggle");
5076
+ $("#task-auto-run-pause")?.addEventListener("click", async () => {
5077
+ const button = $("#task-auto-run-pause");
4872
5078
  button.disabled = true;
4873
5079
  try {
4874
5080
  if (autoRunPaused) {
@@ -4876,17 +5082,18 @@ async function renderTasks(page = taskListPage) {
4876
5082
  toast("自动执行已恢复");
4877
5083
  } else {
4878
5084
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { autoRunEnabled: false } });
4879
- toast("已暂停启动新的分析任务,正在运行的任务会继续完成");
5085
+ toast("已暂停自动执行");
4880
5086
  }
5087
+ taskAutoRunEditing = false;
5088
+ taskAutoRunEditingWorkId = null;
4881
5089
  await refreshBackgroundTaskCenter({ announce: false });
4882
5090
  await renderTasks();
4883
- window.setTimeout(() => $(autoRunPaused ? "#task-auto-run-toggle" : "#task-auto-run-enabled")?.focus(), 0);
5091
+ window.setTimeout(() => $("#task-auto-run-edit")?.focus(), 0);
4884
5092
  } catch (error) {
4885
5093
  toast(error.message, "error");
4886
5094
  button.disabled = false;
4887
5095
  }
4888
5096
  });
4889
-
4890
5097
  $("#module-content").querySelectorAll("[data-task-detail]").forEach((button) => button.addEventListener("click", () => {
4891
5098
  if (button.disabled) return;
4892
5099
  button.disabled = true;
@@ -4957,6 +5164,51 @@ async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
4957
5164
  }
4958
5165
  }
4959
5166
 
5167
+ async function rerunAnalysisTaskWithModel(task, button) {
5168
+ const originalLabel = button.textContent;
5169
+ button.disabled = true;
5170
+ button.textContent = "加载模型";
5171
+ try {
5172
+ const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
5173
+ const currentModelId = String(task.model?.id ?? "");
5174
+ const availableModels = models.filter((model) =>
5175
+ model.id !== currentModelId
5176
+ && model.enabled
5177
+ && model.providerStatus === "enabled"
5178
+ && model.providerConnectionStatus === "success"
5179
+ );
5180
+ if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
5181
+ $("#form-dialog").close();
5182
+ const currentModelLabel = task.model?.displayName || "运行时默认模型";
5183
+ openDialog("选择重试模型",
5184
+ `<p class="task-rerun-model-intro">当前模型:${esc(currentModelLabel)}。选择其他模型后,将按原任务范围和最新资料创建新任务。</p><label>重试模型<select name="modelId" required aria-label="重试模型">
5185
+ ${availableModels.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")}
5186
+ </select></label>`,
5187
+ async (form) => {
5188
+ const modelId = String(form.get("modelId") ?? "").trim();
5189
+ const selectedModel = availableModels.find((model) => model.id === modelId);
5190
+ const rerun = await api(`/api/tasks/${encodeURIComponent(task.id)}/rerun`, {
5191
+ method: "POST",
5192
+ body: { modelId }
5193
+ });
5194
+ toast(`已使用${selectedModel ? `“${modelOptionLabel(selectedModel)}”` : "新模型"}创建重试任务 ${rerun.id}`);
5195
+ await refreshBackgroundTaskCenter({ announce: false });
5196
+ if (state.module === "tasks" && state.work?.id === task.workId) await renderTasks();
5197
+ },
5198
+ "AI 分析重试",
5199
+ {
5200
+ submitLabel: "换模型重试",
5201
+ pendingLabel: "创建中…",
5202
+ pendingMessage: "正在使用新模型创建重试任务,请稍候",
5203
+ errorPrefix: "换模型重试失败:"
5204
+ });
5205
+ } catch (error) {
5206
+ toast(error.message, "error");
5207
+ button.disabled = false;
5208
+ button.textContent = originalLabel;
5209
+ }
5210
+ }
5211
+
4960
5212
  function stopTaskProgressRefresh() {
4961
5213
  if (taskProgressRefreshTimer === null) return;
4962
5214
  window.clearTimeout(taskProgressRefreshTimer);
@@ -5407,7 +5659,7 @@ function openTaskDetailDialog(task, trace) {
5407
5659
  <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
5408
5660
  <div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
5409
5661
  <div><strong>结果摘要</strong>${resultPreview}</div>
5410
- ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
5662
+ ${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>` : ""}
5411
5663
  </section>
5412
5664
  ${renderTaskTraceVisualization(trace, task.id)}
5413
5665
  </div>`,
@@ -5419,6 +5671,9 @@ function openTaskDetailDialog(task, trace) {
5419
5671
  $("#dialog-fields").querySelector("[data-rerun-task-detail]")?.addEventListener("click", async (event) => {
5420
5672
  await rerunAnalysisTask(event.currentTarget.dataset.rerunTaskDetail, event.currentTarget, { closeDetail: true });
5421
5673
  });
5674
+ $("#dialog-fields").querySelector("[data-rerun-task-model]")?.addEventListener("click", async (event) => {
5675
+ await rerunAnalysisTaskWithModel(task, event.currentTarget);
5676
+ });
5422
5677
  $("#dialog-fields").querySelector("[data-task-identity-repair]")?.addEventListener("click", async (event) => {
5423
5678
  const button = event.currentTarget;
5424
5679
  button.disabled = true;
@@ -5860,7 +6115,7 @@ async function renderBookAiSettings() {
5860
6115
  ]);
5861
6116
  const host = $("#module-content");
5862
6117
  const workId = String(state.work.id);
5863
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
6118
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
5864
6119
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5865
6120
  title: "本书 Token 用量",
5866
6121
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
@@ -5874,6 +6129,10 @@ async function renderBookAiSettings() {
5874
6129
  "afterend",
5875
6130
  `<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>`
5876
6131
  );
6132
+ host.querySelector(".ai-agent-tools").insertAdjacentHTML(
6133
+ "beforeend",
6134
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
6135
+ );
5877
6136
  if (!canEditModule("ai-settings")) {
5878
6137
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
5879
6138
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
@@ -6187,7 +6446,7 @@ async function ensureAiReferencesLoaded() {
6187
6446
 
6188
6447
  function field(name, label, type = "text", value = "", options = []) {
6189
6448
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
6190
- 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>`;
6449
+ 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>`;
6191
6450
  if (type === "item-list") {
6192
6451
  const values = Array.isArray(value) && value.length ? value : [""];
6193
6452
  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>`;
@@ -6357,6 +6616,8 @@ function commitRelationshipKeywordInputs(container) {
6357
6616
  }
6358
6617
 
6359
6618
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6619
+ formDialogVditors.forEach(destroyVditorEditor);
6620
+ formDialogVditors = [];
6360
6621
  void discardPendingMarkdownAttachments();
6361
6622
  const dialog = $("#form-dialog");
6362
6623
  const form = $("#dynamic-form");
@@ -6379,9 +6640,10 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6379
6640
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
6380
6641
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
6381
6642
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
6643
+ dialog.classList.toggle("large-dialog", Boolean(options.large));
6382
6644
  bindDynamicListControls($("#dialog-fields"));
6383
6645
  bindRelationshipKeywordControls($("#dialog-fields"));
6384
- bindVditorEditors($("#dialog-fields"));
6646
+ formDialogVditors = bindVditorEditors($("#dialog-fields"));
6385
6647
  form.onclick = null;
6386
6648
  form.onkeydown = null;
6387
6649
  dialog.oncancel = (event) => {
@@ -6962,6 +7224,107 @@ function toggleVditorLineNumbers(editor) {
6962
7224
  const enabled = host.classList.toggle("show-line-numbers");
6963
7225
  const button = host.querySelector(".vditor-line-number-button");
6964
7226
  if (button) button.setAttribute("aria-pressed", String(enabled));
7227
+ updateVditorLineNumbers(editor, getVditorMarkdown(editor));
7228
+ }
7229
+
7230
+ function getVditorMarkdown(editor) {
7231
+ if (typeof editor?.getValue === "function") {
7232
+ try {
7233
+ return String(editor.getValue() ?? "");
7234
+ } catch {
7235
+ return "";
7236
+ }
7237
+ }
7238
+ const host = editor?.vditor?.element;
7239
+ const valueField = host?.closest("[data-vditor-editor-field], .setting-markdown-field, .character-markdown-editor")?.querySelector("[data-vditor-value]")
7240
+ ?? host?.parentElement?.querySelector("[data-vditor-value]");
7241
+ return String(valueField?.value ?? "");
7242
+ }
7243
+
7244
+ function getVditorVisibleSurface(content) {
7245
+ const candidates = [
7246
+ content.querySelector(".vditor-ir .vditor-reset"),
7247
+ content.querySelector(".vditor-wysiwyg .vditor-reset"),
7248
+ content.querySelector(".vditor-sv textarea"),
7249
+ content.querySelector(".vditor-sv")
7250
+ ];
7251
+ return candidates.find((surface) => {
7252
+ if (!surface) return false;
7253
+ const rect = surface.getBoundingClientRect();
7254
+ const style = getComputedStyle(surface);
7255
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
7256
+ }) ?? null;
7257
+ }
7258
+
7259
+ function getVditorLineHeight(surface) {
7260
+ const style = getComputedStyle(surface);
7261
+ return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 24;
7262
+ }
7263
+
7264
+ function collectVditorVisualLineRects(surface) {
7265
+ const rects = [];
7266
+ const scrollTop = Number(surface.scrollTop) || 0;
7267
+ const visit = (node) => {
7268
+ if (node.nodeType === Node.TEXT_NODE) {
7269
+ if (!node.textContent) return;
7270
+ const range = document.createRange();
7271
+ range.selectNodeContents(node);
7272
+ [...range.getClientRects()].forEach((rect) => {
7273
+ if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
7274
+ });
7275
+ return;
7276
+ }
7277
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
7278
+ if (node.tagName === "BR") {
7279
+ const rect = node.getBoundingClientRect();
7280
+ if (rect.height > 0) rects.push({ top: rect.top + scrollTop, height: rect.height });
7281
+ }
7282
+ [...node.childNodes].forEach(visit);
7283
+ };
7284
+ visit(surface);
7285
+ if (rects.length === 0) {
7286
+ const rect = surface.getBoundingClientRect();
7287
+ const style = getComputedStyle(surface);
7288
+ rects.push({ top: rect.top + scrollTop + (parseFloat(style.paddingTop) || 0), height: getVditorLineHeight(surface) });
7289
+ }
7290
+ return rects;
7291
+ }
7292
+
7293
+ function syncVditorLineNumberScroll(editor) {
7294
+ const surface = editor?.__vditorLineNumberSurface;
7295
+ const gutter = editor?.vditor?.element?.querySelector(".vditor-line-numbers");
7296
+ if (!surface || !gutter) return;
7297
+ gutter.style.transform = `translateY(${-surface.scrollTop}px)`;
7298
+ gutter.dataset.scrollTop = String(surface.scrollTop);
7299
+ }
7300
+
7301
+ function scheduleVditorLineNumbers(editor) {
7302
+ if (!editor || editor.__vditorLineNumberFrame) return;
7303
+ editor.__vditorLineNumberFrame = requestAnimationFrame(() => {
7304
+ editor.__vditorLineNumberFrame = null;
7305
+ updateVditorLineNumbers(editor, getVditorMarkdown(editor));
7306
+ });
7307
+ }
7308
+
7309
+ function setupVditorLineNumberSync(editor, content, surface) {
7310
+ if (editor.__vditorLineNumberSurface !== surface) {
7311
+ editor.__vditorLineNumberSurface?.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
7312
+ editor.__vditorLineNumberSurface = surface;
7313
+ editor.__vditorLineNumberScrollHandler = () => syncVditorLineNumberScroll(editor);
7314
+ surface.addEventListener("scroll", editor.__vditorLineNumberScrollHandler, { passive: true });
7315
+ }
7316
+ if (!editor.__vditorLineNumberObserver) {
7317
+ editor.__vditorLineNumberObserver = new MutationObserver((mutations) => {
7318
+ if (mutations.some((mutation) => !(mutation.target instanceof Element) || !mutation.target.closest(".vditor-line-numbers"))) {
7319
+ scheduleVditorLineNumbers(editor);
7320
+ }
7321
+ });
7322
+ editor.__vditorLineNumberObserver.observe(content, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "style"] });
7323
+ }
7324
+ if (!editor.__vditorLineNumberResizeObserver && typeof ResizeObserver !== "undefined") {
7325
+ editor.__vditorLineNumberResizeObserver = new ResizeObserver(() => scheduleVditorLineNumbers(editor));
7326
+ editor.__vditorLineNumberResizeObserver.observe(content);
7327
+ }
6965
7328
  }
6966
7329
 
6967
7330
  function updateVditorLineNumbers(editor, markdown) {
@@ -6974,14 +7337,30 @@ function updateVditorLineNumbers(editor, markdown) {
6974
7337
  gutter.setAttribute("aria-hidden", "true");
6975
7338
  content.prepend(gutter);
6976
7339
  }
6977
- const lineCount = Math.max(1, String(markdown ?? "").split(/\r?\n/gu).length);
7340
+ const surface = getVditorVisibleSurface(content);
7341
+ if (!surface) return;
7342
+ setupVditorLineNumberSync(editor, content, surface);
7343
+ const contentRect = content.getBoundingClientRect();
7344
+ const lineHeight = getVditorLineHeight(surface);
7345
+ const normalizedMarkdown = String(markdown ?? "").replace(/\r\n?/gu, "\n");
7346
+ const blankLineCount = normalizedMarkdown.trim() === "" ? 0 : normalizedMarkdown.split("\n").filter((line) => line.trim() === "").length;
7347
+ const visualRows = buildVditorLineNumberRows(collectVditorVisualLineRects(surface), { lineHeight, blankLineCount });
7348
+ gutter.style.height = `${content.clientHeight}px`;
6978
7349
  const fragment = document.createDocumentFragment();
6979
- for (let line = 1; line <= lineCount; line += 1) {
7350
+ visualRows.forEach((row, index) => {
6980
7351
  const number = document.createElement("span");
6981
- number.textContent = String(line);
7352
+ number.className = "vditor-line-number";
7353
+ number.textContent = String(index + 1);
7354
+ number.dataset.lineNumber = String(index + 1);
7355
+ number.dataset.blank = String(row.blank);
7356
+ number.style.top = `${row.top - contentRect.top}px`;
7357
+ number.style.height = `${row.height}px`;
7358
+ number.style.lineHeight = `${row.height}px`;
6982
7359
  fragment.append(number);
6983
- }
7360
+ });
6984
7361
  gutter.replaceChildren(fragment);
7362
+ gutter.dataset.lineCount = String(visualRows.length);
7363
+ syncVditorLineNumberScroll(editor);
6985
7364
  }
6986
7365
 
6987
7366
  function updateVditorWordCount(editor, markdown) {
@@ -7026,12 +7405,19 @@ function ensureVditorIconScript() {
7026
7405
  function destroyVditorEditor(editor) {
7027
7406
  if (!editor) return;
7028
7407
  editor.__attachmentObserver?.disconnect();
7408
+ editor.__vditorLineNumberObserver?.disconnect();
7409
+ editor.__vditorLineNumberResizeObserver?.disconnect();
7410
+ if (editor.__vditorLineNumberSurface && editor.__vditorLineNumberScrollHandler) {
7411
+ editor.__vditorLineNumberSurface.removeEventListener("scroll", editor.__vditorLineNumberScrollHandler);
7412
+ }
7413
+ if (editor.__vditorLineNumberFrame) cancelAnimationFrame(editor.__vditorLineNumberFrame);
7029
7414
  const host = editor.vditor?.element;
7030
7415
  editor.destroy();
7031
7416
  if (host) delete host.__vditor;
7032
7417
  }
7033
7418
 
7034
7419
  function bindVditorEditors(container) {
7420
+ const editors = [];
7035
7421
  container.querySelectorAll("[data-vditor-editor]").forEach((host) => {
7036
7422
  const valueField = host.parentElement?.querySelector("[data-vditor-value]");
7037
7423
  const editor = createVditorEditor(host, valueField?.value ?? "", {
@@ -7039,10 +7425,12 @@ function bindVditorEditors(container) {
7039
7425
  if (valueField) valueField.value = markdown;
7040
7426
  markEntityEditorDirty();
7041
7427
  },
7042
- placeholder: "",
7428
+ placeholder: host.dataset.placeholder ?? "",
7043
7429
  readOnly: Boolean(valueField?.readOnly)
7044
7430
  });
7431
+ if (editor) editors.push(editor);
7045
7432
  });
7433
+ return editors;
7046
7434
  }
7047
7435
 
7048
7436
  function characterSectionImageLabel(file, fallback = "图片附件") {
@@ -7753,49 +8141,151 @@ function openOutlineDialog(item) {
7753
8141
  }, "章节大纲");
7754
8142
  }
7755
8143
 
8144
+ const foreshadowOccurrenceRoleOptions = [["setup", "埋设"], ["reminder", "提醒"], ["payoff", "回收"]];
8145
+
8146
+ function foreshadowOccurrenceRow(record, chapters, index) {
8147
+ const chapterOptions = [["", "暂不关联"], ...chapters];
8148
+ const occurrenceId = record?.id ?? "";
8149
+ const role = record?.role ?? "setup";
8150
+ const chapterId = record?.chapterId ?? "";
8151
+ const chapterLabel = chapters.find(([value]) => value === chapterId)?.[1] ?? "未关联章节";
8152
+ return `<fieldset class="foreshadow-occurrence-row" data-foreshadow-occurrence data-occurrence-id="${esc(occurrenceId)}" data-occurrence-role="${esc(role)}">
8153
+ <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>
8154
+ <button class="foreshadow-occurrence-remove" type="button" data-foreshadow-occurrence-remove aria-label="删除第 ${index + 1} 个章节节点">删除节点</button>
8155
+ <div class="foreshadow-occurrence-grid">
8156
+ <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>
8157
+ <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>
8158
+ </div>
8159
+ <label>节点说明<textarea name="occurrenceNote" data-foreshadow-occurrence-note aria-label="第 ${index + 1} 个节点说明">${esc(record?.note ?? "")}</textarea></label>
8160
+ </fieldset>`;
8161
+ }
8162
+
8163
+ function foreshadowOccurrencesField(item, chapters) {
8164
+ const occurrences = item?.occurrences?.length ? item.occurrences : [null];
8165
+ return `<section class="form-field foreshadow-editor-section foreshadow-occurrence-field" data-foreshadow-occurrences aria-labelledby="foreshadow-occurrence-title">
8166
+ <div class="foreshadow-occurrence-toolbar"><div><h3 id="foreshadow-occurrence-title">章节节点</h3><p>每张卡片代表一次独立的埋设、提醒或回收,类型、章节和说明始终绑定在同一组内。</p></div><button type="button" data-foreshadow-occurrence-add>添加节点</button></div>
8167
+ <div class="foreshadow-occurrence-rows" data-foreshadow-occurrence-rows>${occurrences.map((record, index) => foreshadowOccurrenceRow(record, chapters, index)).join("")}</div>
8168
+ <p class="foreshadow-occurrence-empty hidden" data-foreshadow-occurrence-empty>尚未添加章节节点。</p>
8169
+ </section>`;
8170
+ }
8171
+
8172
+ function foreshadowOverviewField(item, options, firstPayoff) {
8173
+ return `<section class="foreshadow-editor-section foreshadow-overview-field" aria-labelledby="foreshadow-overview-title">
8174
+ <header><h3 id="foreshadow-overview-title">伏笔信息</h3><p>维护伏笔本身的内容、状态与计划回收位置。</p></header>
8175
+ <div class="foreshadow-overview-grid">
8176
+ ${field("title", "伏笔名称", "text", item?.title)}
8177
+ ${field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]])}
8178
+ ${field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]])}
8179
+ ${field("plannedPayoffChapterId", "计划回收章节", "select", item?.plannedPayoffChapterId ?? firstPayoff?.chapterId ?? "", options)}
8180
+ </div>
8181
+ <div class="foreshadow-overview-description">${field("description", "内容与预期作用", "textarea", item?.description)}</div>
8182
+ </section>`;
8183
+ }
8184
+
8185
+ function foreshadowResolutionField(item) {
8186
+ return `<section class="foreshadow-editor-section foreshadow-resolution-field" aria-labelledby="foreshadow-resolution-title">
8187
+ <header><h3 id="foreshadow-resolution-title">回收结果</h3><p>记录伏笔最终如何揭示或完成;它不属于上方任意单个章节节点。</p></header>
8188
+ ${field("resolutionNote", "回收结论", "textarea", item?.resolutionNote)}
8189
+ </section>`;
8190
+ }
8191
+
8192
+ function updateForeshadowOccurrenceRowSummary(row) {
8193
+ const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
8194
+ const chapter = row.querySelector("[data-foreshadow-occurrence-chapter]");
8195
+ row.dataset.occurrenceRole = role;
8196
+ row.querySelector("[data-foreshadow-occurrence-role-label]").textContent = occurrenceRoleLabel(role);
8197
+ row.querySelector("[data-foreshadow-occurrence-summary]").textContent = chapter.value ? chapter.selectedOptions[0]?.textContent ?? "已关联章节" : "未关联章节";
8198
+ }
8199
+
8200
+ function renumberForeshadowOccurrenceRows(section) {
8201
+ const rows = [...section.querySelectorAll("[data-foreshadow-occurrence]")];
8202
+ rows.forEach((row, index) => {
8203
+ const sequence = index + 1;
8204
+ row.querySelector("[data-foreshadow-occurrence-index]").textContent = `节点 ${sequence}`;
8205
+ row.querySelector("[data-foreshadow-occurrence-remove]").setAttribute("aria-label", `删除第 ${sequence} 个章节节点`);
8206
+ row.querySelector("[data-foreshadow-occurrence-role]").setAttribute("aria-label", `第 ${sequence} 个节点类型`);
8207
+ row.querySelector("[data-foreshadow-occurrence-chapter]").setAttribute("aria-label", `第 ${sequence} 个关联章节`);
8208
+ row.querySelector("[data-foreshadow-occurrence-note]").setAttribute("aria-label", `第 ${sequence} 个节点说明`);
8209
+ updateForeshadowOccurrenceRowSummary(row);
8210
+ });
8211
+ section.querySelector("[data-foreshadow-occurrence-empty]").classList.toggle("hidden", rows.length > 0);
8212
+ }
8213
+
8214
+ function bindForeshadowOccurrenceControls(container, chapters) {
8215
+ const section = container.querySelector("[data-foreshadow-occurrences]");
8216
+ if (!section) return;
8217
+ const rows = section.querySelector("[data-foreshadow-occurrence-rows]");
8218
+ section.addEventListener("change", (event) => {
8219
+ const row = event.target.closest("[data-foreshadow-occurrence]");
8220
+ if (row && event.target.matches("[data-foreshadow-occurrence-role], [data-foreshadow-occurrence-chapter]")) updateForeshadowOccurrenceRowSummary(row);
8221
+ });
8222
+ section.addEventListener("click", (event) => {
8223
+ if (event.target.closest("[data-foreshadow-occurrence-add]")) {
8224
+ const index = rows.querySelectorAll("[data-foreshadow-occurrence]").length;
8225
+ rows.insertAdjacentHTML("beforeend", foreshadowOccurrenceRow(null, chapters, index));
8226
+ renumberForeshadowOccurrenceRows(section);
8227
+ rows.lastElementChild?.querySelector("[data-foreshadow-occurrence-role]")?.focus();
8228
+ return;
8229
+ }
8230
+ const remove = event.target.closest("[data-foreshadow-occurrence-remove]");
8231
+ if (!remove) return;
8232
+ const row = remove.closest("[data-foreshadow-occurrence]");
8233
+ const nextFocus = row.nextElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
8234
+ ?? row.previousElementSibling?.querySelector("[data-foreshadow-occurrence-role]")
8235
+ ?? section.querySelector("[data-foreshadow-occurrence-add]");
8236
+ row.remove();
8237
+ renumberForeshadowOccurrenceRows(section);
8238
+ nextFocus?.focus();
8239
+ });
8240
+ renumberForeshadowOccurrenceRows(section);
8241
+ }
8242
+
8243
+ function readForeshadowOccurrences(item) {
8244
+ const previousById = new Map((item?.occurrences ?? []).map((record) => [record.id, record]));
8245
+ const seen = new Map();
8246
+ return [...$("#dialog-fields").querySelectorAll("[data-foreshadow-occurrence]")].flatMap((row, index) => {
8247
+ const role = row.querySelector("[data-foreshadow-occurrence-role]").value;
8248
+ const chapterId = row.querySelector("[data-foreshadow-occurrence-chapter]").value;
8249
+ const note = row.querySelector("[data-foreshadow-occurrence-note]").value;
8250
+ if (!chapterId) {
8251
+ if (note.trim()) throw new Error(`第 ${index + 1} 个章节节点填写了说明,请选择关联章节`);
8252
+ return [];
8253
+ }
8254
+ const key = `${role}:${chapterId}`;
8255
+ const duplicateIndex = seen.get(key);
8256
+ if (duplicateIndex) throw new Error(`第 ${index + 1} 个章节节点与第 ${duplicateIndex} 个重复`);
8257
+ seen.set(key, index + 1);
8258
+ const previous = previousById.get(row.dataset.occurrenceId);
8259
+ return [{
8260
+ chapterId,
8261
+ role,
8262
+ note,
8263
+ evidence: previous?.chapterId === chapterId && previous?.role === role ? previous.evidence : []
8264
+ }];
8265
+ });
8266
+ }
8267
+
7756
8268
  function openForeshadowDialog(item) {
7757
8269
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
7758
8270
  if (!chapters.length) return toast("请先创建章节", "error");
7759
8271
  const options = [["", "暂不关联"], ...chapters];
7760
- const occurrence = (role) => item?.occurrences?.find((record) => record.role === role);
7761
- const editableOccurrenceIds = new Set(["setup", "reminder", "payoff"].map((role) => occurrence(role)?.id).filter(Boolean));
7762
- const preservedOccurrences = (item?.occurrences ?? [])
7763
- .filter((record) => !editableOccurrenceIds.has(record.id))
7764
- .map((record) => ({ chapterId: record.chapterId, role: record.role, note: record.note, evidence: record.evidence }));
8272
+ const firstPayoff = item?.occurrences?.find((record) => record.role === "payoff");
7765
8273
  openDialog(item ? "编辑伏笔" : "新建伏笔",
7766
- field("title", "伏笔名称", "text", item?.title) +
7767
- field("description", "内容与预期作用", "textarea", item?.description) +
7768
- field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]]) +
7769
- field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]]) +
7770
- field("setupChapterId", "埋设章节", "select", occurrence("setup")?.chapterId ?? "", options) +
7771
- field("setupNote", "埋设说明", "textarea", occurrence("setup")?.note ?? "") +
7772
- field("reminderChapterId", "提醒章节", "select", occurrence("reminder")?.chapterId ?? "", options) +
7773
- field("reminderNote", "提醒说明", "textarea", occurrence("reminder")?.note ?? "") +
7774
- field("payoffChapterId", "回收章节", "select", occurrence("payoff")?.chapterId ?? item?.plannedPayoffChapterId ?? "", options) +
7775
- field("payoffNote", "回收说明", "textarea", occurrence("payoff")?.note ?? "") +
7776
- field("resolutionNote", "回收结论", "textarea", item?.resolutionNote),
8274
+ foreshadowOverviewField(item, options, firstPayoff) +
8275
+ foreshadowOccurrencesField(item, chapters) +
8276
+ foreshadowResolutionField(item),
7777
8277
  async (form) => {
7778
- const editedOccurrences = ["setup", "reminder", "payoff"].flatMap((role) => {
7779
- const chapterId = form.get(`${role}ChapterId`);
7780
- if (!chapterId) return [];
7781
- const previous = occurrence(role);
7782
- return [{
7783
- chapterId,
7784
- role,
7785
- note: form.get(`${role}Note`),
7786
- evidence: previous?.chapterId === chapterId ? previous.evidence : []
7787
- }];
7788
- });
7789
- const occurrences = [...preservedOccurrences, ...editedOccurrences];
8278
+ const occurrences = readForeshadowOccurrences(item);
7790
8279
  const body = {
7791
8280
  title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
7792
- plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
8281
+ plannedPayoffChapterId: form.get("plannedPayoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
7793
8282
  ...(item ? { expectedVersionNo: item.versionNo } : {})
7794
8283
  };
7795
8284
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
7796
8285
  await renderOutlines();
7797
8286
  toast(item ? "伏笔已更新" : "伏笔已创建");
7798
- }, item ? "伏笔管理" : "创作线索");
8287
+ }, item ? "伏笔管理" : "创作线索", { large: true, meta: "完整编辑伏笔信息、章节节点与回收结果" });
8288
+ bindForeshadowOccurrenceControls($("#dialog-fields"), chapters);
7799
8289
  }
7800
8290
 
7801
8291
  function openTimelineSplitDialog(item) {
@@ -8304,12 +8794,22 @@ async function streamChat(body) {
8304
8794
  const message = document.createElement("div");
8305
8795
  message.className = "assistant-message is-streaming";
8306
8796
  message.dataset.testid = "ai-stream-message";
8307
- message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite"></div><div class="message-meta">正在连接模型流……</div>';
8797
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
8308
8798
  attachMessageHeading(message, "助手 · 正在生成");
8309
8799
  $("#ai-feed").append(message);
8310
8800
  scrollAiFeedToBottom();
8311
8801
  const content = message.querySelector(".message-body");
8312
8802
  const meta = message.querySelector(".message-meta");
8803
+ const typewriter = createStreamTypewriter({
8804
+ onRender: (text, progress) => {
8805
+ content.innerHTML = renderMarkdown(text);
8806
+ const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
8807
+ meta.textContent = progress.pendingCharacters > 0
8808
+ ? `正在生成 · ${progress.visibleCharacters} / ${receivedCharacters} 字`
8809
+ : `正在生成 · ${progress.visibleCharacters} 字`;
8810
+ scrollAiFeedToBottom();
8811
+ }
8812
+ });
8313
8813
  let streamedText = "";
8314
8814
  let generatedMetadata = {};
8315
8815
  let toolCalls = [];
@@ -8331,7 +8831,7 @@ async function streamChat(body) {
8331
8831
  const decoder = new TextDecoder();
8332
8832
  let buffer = "";
8333
8833
  let streamError = null;
8334
- const consume = (eventText) => {
8834
+ const consume = async (eventText) => {
8335
8835
  let eventName = "message";
8336
8836
  const dataLines = [];
8337
8837
  for (const line of eventText.split(/\r?\n/)) {
@@ -8342,12 +8842,12 @@ async function streamChat(body) {
8342
8842
  const payload = JSON.parse(dataLines.join("\n"));
8343
8843
  if (eventName === "delta") {
8344
8844
  const firstFinalDelta = streamedText.length === 0;
8345
- streamedText += payload.delta ?? "";
8845
+ const delta = typeof payload.delta === "string" ? payload.delta : "";
8846
+ streamedText += delta;
8346
8847
  if (streamedText.length > 0) finalAnswerStarted = true;
8347
- content.innerHTML = renderMarkdown(streamedText);
8848
+ typewriter.append(delta);
8348
8849
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8349
- meta.textContent = `已接收 ${Array.from(streamedText).length} 字`;
8350
- scrollAiFeedToBottom();
8850
+ meta.textContent = "正在生成回复……";
8351
8851
  } else if (eventName === "process_step") {
8352
8852
  const step = { ...payload };
8353
8853
  const append = step.append === true;
@@ -8368,7 +8868,9 @@ async function streamChat(body) {
8368
8868
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
8369
8869
  scrollAiFeedToBottom();
8370
8870
  } else if (eventName === "complete") {
8871
+ await typewriter.finish();
8371
8872
  message.classList.remove("is-streaming");
8873
+ content.setAttribute("aria-busy", "false");
8372
8874
  message.querySelector(".message-heading > span").textContent = "助手";
8373
8875
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
8374
8876
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
@@ -8387,14 +8889,17 @@ async function streamChat(body) {
8387
8889
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });
8388
8890
  const events = buffer.split(/\r?\n\r?\n/);
8389
8891
  buffer = events.pop() ?? "";
8390
- events.forEach(consume);
8892
+ for (const eventText of events) await consume(eventText);
8391
8893
  if (chunk.done) break;
8392
8894
  }
8393
- if (buffer.trim()) consume(buffer);
8895
+ if (buffer.trim()) await consume(buffer);
8896
+ await typewriter.finish();
8394
8897
  if (streamError) throw streamError;
8395
8898
  return { content: streamedText, message, metadata: generatedMetadata };
8396
8899
  } catch (error) {
8900
+ typewriter.reveal();
8397
8901
  message.classList.remove("is-streaming");
8902
+ content.setAttribute("aria-busy", "false");
8398
8903
  message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
8399
8904
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8400
8905
  meta.textContent = "生成中断";
@@ -8715,18 +9220,22 @@ async function showChapterInsight() {
8715
9220
  if (requestId !== chapterInsightRequestId || state.chapter?.id !== chapterId || $("#editor-view").classList.contains("hidden")) return;
8716
9221
  const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
8717
9222
  const region = $("#toast-region");
9223
+ const chapterTitle = state.chapter.title;
8718
9224
  const element = document.createElement("section");
8719
9225
  element.id = "chapter-insight-toast";
8720
9226
  element.className = "toast chapter-insight-toast";
8721
9227
  element.setAttribute("role", "region");
8722
- element.setAttribute("aria-label", "章节概览");
9228
+ element.setAttribute("aria-label", `章节概览:${chapterTitle}`);
8723
9229
  const header = document.createElement("header");
8724
9230
  header.className = "chapter-insight-toast-header";
8725
9231
  const heading = document.createElement("div");
8726
9232
  heading.className = "chapter-insight-toast-heading";
8727
9233
  const title = document.createElement("strong");
8728
9234
  title.textContent = "章节概览";
8729
- heading.append(title);
9235
+ const chapterName = document.createElement("span");
9236
+ chapterName.className = "chapter-insight-toast-chapter-title";
9237
+ chapterName.textContent = chapterTitle;
9238
+ heading.append(title, chapterName);
8730
9239
  if (insight && insight.chapterVersion !== state.chapter.versionNo) {
8731
9240
  const stale = document.createElement("span");
8732
9241
  stale.textContent = `基于旧版本 v${insight.chapterVersion}`;
@@ -9369,6 +9878,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
9369
9878
  body: {
9370
9879
  toastPosition: $("#toast-position").value,
9371
9880
  pageSizes: {
9881
+ drafts: Number($("#page-size-drafts").value),
9372
9882
  settings: Number($("#page-size-settings").value),
9373
9883
  characters: Number($("#page-size-characters").value),
9374
9884
  organizations: Number($("#page-size-organizations").value),
@@ -9401,6 +9911,9 @@ $("#members-dialog").addEventListener("close", () => {
9401
9911
  memberDialogDirectory = [];
9402
9912
  });
9403
9913
  $("#form-dialog").addEventListener("close", () => {
9914
+ formDialogVditors.forEach(destroyVditorEditor);
9915
+ formDialogVditors = [];
9916
+ void discardPendingMarkdownAttachments();
9404
9917
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
9405
9918
  });
9406
9919
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -9597,7 +10110,7 @@ $("#module-nav").addEventListener("click", (event) => {
9597
10110
  }
9598
10111
  });
9599
10112
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
9600
- $("#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]?.());
10113
+ $("#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]?.());
9601
10114
  $("#ai-prompt").addEventListener("input", async () => {
9602
10115
  updateAiMentionMenu();
9603
10116
  scheduleAiContextUsage();
@@ -9738,9 +10251,24 @@ $("#chapter-type-menu").addEventListener("click", async (event) => {
9738
10251
  toast(error.message, "error");
9739
10252
  }
9740
10253
  });
10254
+ $("#markdown-table-wrap-toggle").addEventListener("click", () => {
10255
+ const table = markdownTableMenuTarget;
10256
+ if (!table?.isConnected) return closeMarkdownTableMenu();
10257
+ const wrapping = !table.classList.contains("is-wrapping");
10258
+ setMarkdownTableWrapping(table, wrapping);
10259
+ closeMarkdownTableMenu(true);
10260
+ toast(wrapping ? "表格已启用自动换行" : "表格已恢复横向滚动");
10261
+ });
10262
+ document.addEventListener("contextmenu", (event) => {
10263
+ const header = event.target.closest?.("[data-markdown-table-header]");
10264
+ if (!header) return;
10265
+ event.preventDefault();
10266
+ openMarkdownTableMenu(header, event.clientX, event.clientY);
10267
+ });
9741
10268
  document.addEventListener("pointerdown", (event) => {
9742
10269
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
9743
10270
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10271
+ if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
9744
10272
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
9745
10273
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
9746
10274
  $("#account-menu").classList.add("hidden");
@@ -9752,9 +10280,16 @@ document.addEventListener("pointerdown", (event) => {
9752
10280
  }
9753
10281
  });
9754
10282
  document.addEventListener("keydown", (event) => {
10283
+ const header = event.target.closest?.("[data-markdown-table-header]");
10284
+ if (header && (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))) {
10285
+ event.preventDefault();
10286
+ openMarkdownTableMenu(header, 0, 0);
10287
+ return;
10288
+ }
9755
10289
  if (event.key === "Escape") {
9756
10290
  closeChapterTypeMenu();
9757
10291
  closeLineCitationMenu();
10292
+ closeMarkdownTableMenu(true);
9758
10293
  hideAiMentionMenu();
9759
10294
  }
9760
10295
  });