@musnows/scriverse 0.5.10 → 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,6 +1,6 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
3
+ import { renderMarkdown } from "/markdown.js?v=20260730-table-wrap-menu-v1";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
@@ -8,6 +8,7 @@ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260
8
8
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
9
9
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
10
10
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
11
+ import { createStreamTypewriter } from "/stream-typewriter.js?v=20260729-ai-stream-typewriter-v1";
11
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
12
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
13
14
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
@@ -15,7 +16,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
15
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
16
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
17
18
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
18
- import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260725-enum-labels-zh";
19
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260729-drafts-v1";
19
20
  import {
20
21
  chapterVersionSourceLabel,
21
22
  foreshadowStatusLabel,
@@ -35,12 +36,12 @@ import {
35
36
  timelineStatusLabel,
36
37
  characterStateFieldLabel
37
38
  } from "/display-labels.js?v=20260728-hybrid-search-v1";
38
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
39
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
39
40
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
40
41
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
41
42
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
42
43
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
43
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
44
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260729-drafts-v1";
44
45
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
45
46
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
46
47
  import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
@@ -59,6 +60,7 @@ import {
59
60
  } from "/avatar-crop.js?v=20260725-avatar-crop";
60
61
 
61
62
  const defaultPageSizes = Object.freeze({
63
+ drafts: 30,
62
64
  settings: 30,
63
65
  characters: 30,
64
66
  races: 30,
@@ -871,7 +873,9 @@ let entityEditorReadOnly = false;
871
873
  let chapterEditorReadOnly = true;
872
874
  let characterListPage = 1;
873
875
  let taskListPage = 1;
876
+ let draftTypeFilter = "all";
874
877
  const moduleListPages = {
878
+ drafts: 1,
875
879
  settings: 1,
876
880
  organizations: 1,
877
881
  timeline: 1,
@@ -901,6 +905,7 @@ let characterSectionEditorDirty = false;
901
905
  let settingEditorVditor = null;
902
906
  let knowledgeSectionVditor = null;
903
907
  let characterSectionVditor = null;
908
+ let formDialogVditors = [];
904
909
  let entityHistoryContext = null;
905
910
  let moduleContentInteractionsBound = false;
906
911
 
@@ -1367,7 +1372,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1367
1372
  read_chapters: "读取章节",
1368
1373
  grep: "查询正文关键字",
1369
1374
  search_story_entities: "搜索作品实体",
1370
- read_character_sections: "读取人物 Markdown 章节"
1375
+ read_character_sections: "读取人物 Markdown 章节",
1376
+ search_drafts: "搜索草稿"
1371
1377
  };
1372
1378
 
1373
1379
  const AI_TOOL_DESCRIPTIONS = {
@@ -1375,10 +1381,50 @@ const AI_TOOL_DESCRIPTIONS = {
1375
1381
  read_chapters: "读取指定章节的概要、正文或两者。",
1376
1382
  grep: "查询正文关键字所在的完整段落及章节信息。",
1377
1383
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1378
- read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
1384
+ read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1385
+ search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
1379
1386
  };
1380
1387
 
1381
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
+ }
1382
1428
 
1383
1429
  function scrollAiFeedToBottom() {
1384
1430
  const feed = $("#ai-feed");
@@ -2706,6 +2752,7 @@ function renderSettingsHub() {
2706
2752
  const hasWork = Boolean(state.work);
2707
2753
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
2708
2754
  const canReadAggregate = hasWork && canReadAggregateContent();
2755
+ const canReadFullExport = canReadAggregate && canReadModule("drafts");
2709
2756
  const isAdmin = state.user?.role === "admin";
2710
2757
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
2711
2758
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -2715,7 +2762,7 @@ function renderSettingsHub() {
2715
2762
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
2716
2763
  $("#work-audit-button").disabled = !canManageWork;
2717
2764
  $("#top-search-button").disabled = !canReadAggregate;
2718
- $("#export-button").disabled = !canReadAggregate;
2765
+ $("#export-button").disabled = !canReadFullExport;
2719
2766
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
2720
2767
  $("#settings-work-note").textContent = hasWork
2721
2768
  ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
@@ -2796,11 +2843,15 @@ const workAuditActionLabels = {
2796
2843
  "chapter.deleted": "删除章节",
2797
2844
  "chapter.purged": "彻底删除章节",
2798
2845
  "chapter.restored": "恢复章节",
2846
+ "draft.created": "创建草稿",
2847
+ "draft.updated": "更新草稿",
2848
+ "draft.deleted": "删除草稿",
2849
+ "draft.restored": "恢复草稿",
2799
2850
  "work.imported": "导入正文"
2800
2851
  };
2801
2852
 
2802
2853
  function workAuditEntityLabel(type) {
2803
- return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
2854
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
2804
2855
  }
2805
2856
 
2806
2857
  function workAuditDetailText(detail) {
@@ -2911,6 +2962,7 @@ async function openPlatformUiSettingsDialog() {
2911
2962
  const settings = await api("/api/platform/ui-settings");
2912
2963
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2913
2964
  const pageSizes = normalizePageSizes(settings.pageSizes);
2965
+ $("#page-size-drafts").value = String(pageSizes.drafts);
2914
2966
  $("#page-size-settings").value = String(pageSizes.settings);
2915
2967
  $("#page-size-characters").value = String(pageSizes.characters);
2916
2968
  $("#page-size-organizations").value = String(pageSizes.organizations);
@@ -3292,6 +3344,7 @@ function resetWorkScopedUiCaches() {
3292
3344
  state.settings = [];
3293
3345
  state.races = [];
3294
3346
  characterListPage = 1;
3347
+ draftTypeFilter = "all";
3295
3348
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3296
3349
  relationshipFilters.fromCharacterIds = [];
3297
3350
  relationshipFilters.toCharacterIds = [];
@@ -3730,6 +3783,7 @@ function markActiveModule(module) {
3730
3783
  }
3731
3784
 
3732
3785
  const moduleMeta = {
3786
+ drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
3733
3787
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
3734
3788
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
3735
3789
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -3785,6 +3839,7 @@ async function showModule(module) {
3785
3839
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
3786
3840
  bindModuleContentInteractions();
3787
3841
  try {
3842
+ if (module === "drafts") await renderDrafts();
3788
3843
  if (module === "settings") await renderSettings();
3789
3844
  if (module === "characters") await renderCharacters(characterListPage);
3790
3845
  if (module === "races") await renderRaces();
@@ -4185,6 +4240,135 @@ function settingRecordActions(item) {
4185
4240
  : recordHistoryButton("setting", item.id, item.title);
4186
4241
  }
4187
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
+
4188
4372
  function renderSettingCards(records) {
4189
4373
  return `<div class="card-grid">${records.map((item) => `
4190
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>
@@ -4803,13 +4987,15 @@ async function renderTasks(page = taskListPage) {
4803
4987
  : "";
4804
4988
  $("#module-content").innerHTML = `
4805
4989
  <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
4806
- <div class="task-auto-run-copy">
4807
- <strong id="task-auto-run-title">自动执行待分析任务</strong>
4808
- <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
4809
- <small>开启后会持续执行直到队列清空;临时错误自动退避重试,连续失败达到阈值后暂停。</small>
4810
- </div>
4811
- <div class="task-auto-run-actions">
4812
- ${autoRunEditing ? "" : '<button id="task-auto-run-edit" class="ghost-button" type="button">编辑</button>'}
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>
4813
4999
  </div>
4814
5000
  <div class="task-auto-run-controls">
4815
5001
  <label class="checkbox-field"><input id="task-auto-run-enabled" type="checkbox" ${settings.autoRunEnabled ? "checked" : ""} ${autoRunEditing ? "" : "disabled"}><span>持续自动执行</span></label>
@@ -5929,7 +6115,7 @@ async function renderBookAiSettings() {
5929
6115
  ]);
5930
6116
  const host = $("#module-content");
5931
6117
  const workId = String(state.work.id);
5932
- 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"]);
5933
6119
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5934
6120
  title: "本书 Token 用量",
5935
6121
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
@@ -5943,6 +6129,10 @@ async function renderBookAiSettings() {
5943
6129
  "afterend",
5944
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>`
5945
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
+ );
5946
6136
  if (!canEditModule("ai-settings")) {
5947
6137
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
5948
6138
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
@@ -6256,7 +6446,7 @@ async function ensureAiReferencesLoaded() {
6256
6446
 
6257
6447
  function field(name, label, type = "text", value = "", options = []) {
6258
6448
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
6259
- if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="Markdown 原文">${esc(value)}</textarea></div>`;
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>`;
6260
6450
  if (type === "item-list") {
6261
6451
  const values = Array.isArray(value) && value.length ? value : [""];
6262
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>`;
@@ -6426,6 +6616,8 @@ function commitRelationshipKeywordInputs(container) {
6426
6616
  }
6427
6617
 
6428
6618
  function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6619
+ formDialogVditors.forEach(destroyVditorEditor);
6620
+ formDialogVditors = [];
6429
6621
  void discardPendingMarkdownAttachments();
6430
6622
  const dialog = $("#form-dialog");
6431
6623
  const form = $("#dynamic-form");
@@ -6448,9 +6640,10 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
6448
6640
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
6449
6641
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
6450
6642
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
6643
+ dialog.classList.toggle("large-dialog", Boolean(options.large));
6451
6644
  bindDynamicListControls($("#dialog-fields"));
6452
6645
  bindRelationshipKeywordControls($("#dialog-fields"));
6453
- bindVditorEditors($("#dialog-fields"));
6646
+ formDialogVditors = bindVditorEditors($("#dialog-fields"));
6454
6647
  form.onclick = null;
6455
6648
  form.onkeydown = null;
6456
6649
  dialog.oncancel = (event) => {
@@ -7224,6 +7417,7 @@ function destroyVditorEditor(editor) {
7224
7417
  }
7225
7418
 
7226
7419
  function bindVditorEditors(container) {
7420
+ const editors = [];
7227
7421
  container.querySelectorAll("[data-vditor-editor]").forEach((host) => {
7228
7422
  const valueField = host.parentElement?.querySelector("[data-vditor-value]");
7229
7423
  const editor = createVditorEditor(host, valueField?.value ?? "", {
@@ -7231,10 +7425,12 @@ function bindVditorEditors(container) {
7231
7425
  if (valueField) valueField.value = markdown;
7232
7426
  markEntityEditorDirty();
7233
7427
  },
7234
- placeholder: "",
7428
+ placeholder: host.dataset.placeholder ?? "",
7235
7429
  readOnly: Boolean(valueField?.readOnly)
7236
7430
  });
7431
+ if (editor) editors.push(editor);
7237
7432
  });
7433
+ return editors;
7238
7434
  }
7239
7435
 
7240
7436
  function characterSectionImageLabel(file, fallback = "图片附件") {
@@ -7945,49 +8141,151 @@ function openOutlineDialog(item) {
7945
8141
  }, "章节大纲");
7946
8142
  }
7947
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
+
7948
8268
  function openForeshadowDialog(item) {
7949
8269
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
7950
8270
  if (!chapters.length) return toast("请先创建章节", "error");
7951
8271
  const options = [["", "暂不关联"], ...chapters];
7952
- const occurrence = (role) => item?.occurrences?.find((record) => record.role === role);
7953
- const editableOccurrenceIds = new Set(["setup", "reminder", "payoff"].map((role) => occurrence(role)?.id).filter(Boolean));
7954
- const preservedOccurrences = (item?.occurrences ?? [])
7955
- .filter((record) => !editableOccurrenceIds.has(record.id))
7956
- .map((record) => ({ chapterId: record.chapterId, role: record.role, note: record.note, evidence: record.evidence }));
8272
+ const firstPayoff = item?.occurrences?.find((record) => record.role === "payoff");
7957
8273
  openDialog(item ? "编辑伏笔" : "新建伏笔",
7958
- field("title", "伏笔名称", "text", item?.title) +
7959
- field("description", "内容与预期作用", "textarea", item?.description) +
7960
- field("importance", "重要程度", "select", item?.importance ?? "medium", [["low", "低"], ["medium", "中"], ["high", "高"]]) +
7961
- field("status", "状态", "select", item?.status ?? "planned", [["planned", "计划中"], ["planted", "已埋设"], ["resolved", "已回收"], ["abandoned", "已放弃"]]) +
7962
- field("setupChapterId", "埋设章节", "select", occurrence("setup")?.chapterId ?? "", options) +
7963
- field("setupNote", "埋设说明", "textarea", occurrence("setup")?.note ?? "") +
7964
- field("reminderChapterId", "提醒章节", "select", occurrence("reminder")?.chapterId ?? "", options) +
7965
- field("reminderNote", "提醒说明", "textarea", occurrence("reminder")?.note ?? "") +
7966
- field("payoffChapterId", "回收章节", "select", occurrence("payoff")?.chapterId ?? item?.plannedPayoffChapterId ?? "", options) +
7967
- field("payoffNote", "回收说明", "textarea", occurrence("payoff")?.note ?? "") +
7968
- field("resolutionNote", "回收结论", "textarea", item?.resolutionNote),
8274
+ foreshadowOverviewField(item, options, firstPayoff) +
8275
+ foreshadowOccurrencesField(item, chapters) +
8276
+ foreshadowResolutionField(item),
7969
8277
  async (form) => {
7970
- const editedOccurrences = ["setup", "reminder", "payoff"].flatMap((role) => {
7971
- const chapterId = form.get(`${role}ChapterId`);
7972
- if (!chapterId) return [];
7973
- const previous = occurrence(role);
7974
- return [{
7975
- chapterId,
7976
- role,
7977
- note: form.get(`${role}Note`),
7978
- evidence: previous?.chapterId === chapterId ? previous.evidence : []
7979
- }];
7980
- });
7981
- const occurrences = [...preservedOccurrences, ...editedOccurrences];
8278
+ const occurrences = readForeshadowOccurrences(item);
7982
8279
  const body = {
7983
8280
  title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
7984
- plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
8281
+ plannedPayoffChapterId: form.get("plannedPayoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
7985
8282
  ...(item ? { expectedVersionNo: item.versionNo } : {})
7986
8283
  };
7987
8284
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
7988
8285
  await renderOutlines();
7989
8286
  toast(item ? "伏笔已更新" : "伏笔已创建");
7990
- }, item ? "伏笔管理" : "创作线索");
8287
+ }, item ? "伏笔管理" : "创作线索", { large: true, meta: "完整编辑伏笔信息、章节节点与回收结果" });
8288
+ bindForeshadowOccurrenceControls($("#dialog-fields"), chapters);
7991
8289
  }
7992
8290
 
7993
8291
  function openTimelineSplitDialog(item) {
@@ -8496,12 +8794,22 @@ async function streamChat(body) {
8496
8794
  const message = document.createElement("div");
8497
8795
  message.className = "assistant-message is-streaming";
8498
8796
  message.dataset.testid = "ai-stream-message";
8499
- message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite"></div><div class="message-meta">正在连接模型流……</div>';
8797
+ message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
8500
8798
  attachMessageHeading(message, "助手 · 正在生成");
8501
8799
  $("#ai-feed").append(message);
8502
8800
  scrollAiFeedToBottom();
8503
8801
  const content = message.querySelector(".message-body");
8504
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
+ });
8505
8813
  let streamedText = "";
8506
8814
  let generatedMetadata = {};
8507
8815
  let toolCalls = [];
@@ -8523,7 +8831,7 @@ async function streamChat(body) {
8523
8831
  const decoder = new TextDecoder();
8524
8832
  let buffer = "";
8525
8833
  let streamError = null;
8526
- const consume = (eventText) => {
8834
+ const consume = async (eventText) => {
8527
8835
  let eventName = "message";
8528
8836
  const dataLines = [];
8529
8837
  for (const line of eventText.split(/\r?\n/)) {
@@ -8534,12 +8842,12 @@ async function streamChat(body) {
8534
8842
  const payload = JSON.parse(dataLines.join("\n"));
8535
8843
  if (eventName === "delta") {
8536
8844
  const firstFinalDelta = streamedText.length === 0;
8537
- streamedText += payload.delta ?? "";
8845
+ const delta = typeof payload.delta === "string" ? payload.delta : "";
8846
+ streamedText += delta;
8538
8847
  if (streamedText.length > 0) finalAnswerStarted = true;
8539
- content.innerHTML = renderMarkdown(streamedText);
8848
+ typewriter.append(delta);
8540
8849
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8541
- meta.textContent = `已接收 ${Array.from(streamedText).length} 字`;
8542
- scrollAiFeedToBottom();
8850
+ meta.textContent = "正在生成回复……";
8543
8851
  } else if (eventName === "process_step") {
8544
8852
  const step = { ...payload };
8545
8853
  const append = step.append === true;
@@ -8560,7 +8868,9 @@ async function streamChat(body) {
8560
8868
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
8561
8869
  scrollAiFeedToBottom();
8562
8870
  } else if (eventName === "complete") {
8871
+ await typewriter.finish();
8563
8872
  message.classList.remove("is-streaming");
8873
+ content.setAttribute("aria-busy", "false");
8564
8874
  message.querySelector(".message-heading > span").textContent = "助手";
8565
8875
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
8566
8876
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
@@ -8579,14 +8889,17 @@ async function streamChat(body) {
8579
8889
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });
8580
8890
  const events = buffer.split(/\r?\n\r?\n/);
8581
8891
  buffer = events.pop() ?? "";
8582
- events.forEach(consume);
8892
+ for (const eventText of events) await consume(eventText);
8583
8893
  if (chunk.done) break;
8584
8894
  }
8585
- if (buffer.trim()) consume(buffer);
8895
+ if (buffer.trim()) await consume(buffer);
8896
+ await typewriter.finish();
8586
8897
  if (streamError) throw streamError;
8587
8898
  return { content: streamedText, message, metadata: generatedMetadata };
8588
8899
  } catch (error) {
8900
+ typewriter.reveal();
8589
8901
  message.classList.remove("is-streaming");
8902
+ content.setAttribute("aria-busy", "false");
8590
8903
  message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
8591
8904
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
8592
8905
  meta.textContent = "生成中断";
@@ -9565,6 +9878,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
9565
9878
  body: {
9566
9879
  toastPosition: $("#toast-position").value,
9567
9880
  pageSizes: {
9881
+ drafts: Number($("#page-size-drafts").value),
9568
9882
  settings: Number($("#page-size-settings").value),
9569
9883
  characters: Number($("#page-size-characters").value),
9570
9884
  organizations: Number($("#page-size-organizations").value),
@@ -9597,6 +9911,9 @@ $("#members-dialog").addEventListener("close", () => {
9597
9911
  memberDialogDirectory = [];
9598
9912
  });
9599
9913
  $("#form-dialog").addEventListener("close", () => {
9914
+ formDialogVditors.forEach(destroyVditorEditor);
9915
+ formDialogVditors = [];
9916
+ void discardPendingMarkdownAttachments();
9600
9917
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
9601
9918
  });
9602
9919
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -9793,7 +10110,7 @@ $("#module-nav").addEventListener("click", (event) => {
9793
10110
  }
9794
10111
  });
9795
10112
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
9796
- $("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
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]?.());
9797
10114
  $("#ai-prompt").addEventListener("input", async () => {
9798
10115
  updateAiMentionMenu();
9799
10116
  scheduleAiContextUsage();
@@ -9934,9 +10251,24 @@ $("#chapter-type-menu").addEventListener("click", async (event) => {
9934
10251
  toast(error.message, "error");
9935
10252
  }
9936
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
+ });
9937
10268
  document.addEventListener("pointerdown", (event) => {
9938
10269
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
9939
10270
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10271
+ if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
9940
10272
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
9941
10273
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
9942
10274
  $("#account-menu").classList.add("hidden");
@@ -9948,9 +10280,16 @@ document.addEventListener("pointerdown", (event) => {
9948
10280
  }
9949
10281
  });
9950
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
+ }
9951
10289
  if (event.key === "Escape") {
9952
10290
  closeChapterTypeMenu();
9953
10291
  closeLineCitationMenu();
10292
+ closeMarkdownTableMenu(true);
9954
10293
  hideAiMentionMenu();
9955
10294
  }
9956
10295
  });