@musnows/scriverse 0.8.0 → 0.8.1

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.
@@ -20,7 +20,7 @@ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_
20
20
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
21
21
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
22
22
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
23
- import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260815-ai-stream-typewriter-v4";
23
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
24
24
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
25
25
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
26
26
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -73,7 +73,7 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
73
73
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
74
74
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
75
75
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
76
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
76
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260818-annotation-permissions-v1";
77
77
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
78
78
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
79
79
  import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
@@ -230,6 +230,8 @@ let systemBootCheckTimer = null;
230
230
  let systemBootCheckPromise = null;
231
231
  let systemRestartDetected = false;
232
232
  let chapterAnnotations = [];
233
+ let chapterAnnotationCounts = new Map();
234
+ let chapterAnnotationsLineIndex = null;
233
235
  let workAuditRecords = [];
234
236
  let workAuditNextPage = null;
235
237
  const chapterBatchSelectedIds = new Set();
@@ -376,6 +378,13 @@ function canEditProse(work = state.work) {
376
378
  return canWriteUiModule(work, "editor");
377
379
  }
378
380
 
381
+ function canManageChapterAnnotation(annotation, work = state.work) {
382
+ if (!work) return false;
383
+ if (annotation.kind !== "todo") return canEditProse(work);
384
+ if (["admin", "owner"].includes(String(work.accessRole))) return true;
385
+ return annotation.createdByUserId === state.user?.userId && canWritePermissionModule(work, "todos");
386
+ }
387
+
379
388
  function canReplaceProse(work = state.work) {
380
389
  return WORK_PERMISSION_MODULES
381
390
  .filter((item) => item.id !== "ai-settings")
@@ -1334,9 +1343,9 @@ function applyPanelLayout(persist = false) {
1334
1343
  ? (panelLayout.leftCollapsed ? "打开作品模块" : "关闭作品模块")
1335
1344
  : (panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏"));
1336
1345
  $("#mobile-module-tab").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
1337
- $("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
1346
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
1338
1347
  $("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
1339
- $("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
1348
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
1340
1349
  syncMobileAiPanelSafeTop();
1341
1350
  scheduleChapterLineNumbers();
1342
1351
  if (persist) {
@@ -1346,7 +1355,12 @@ function applyPanelLayout(persist = false) {
1346
1355
 
1347
1356
  function ensureAiPanelExpanded() {
1348
1357
  if (!state.work || !canReadPermissionModule(state.work, "ai-chat")) return;
1349
- setAiConversationWorkspaceVisible(true);
1358
+ if (isMobileViewport()) {
1359
+ setAiConversationWorkspaceVisible(true);
1360
+ return;
1361
+ }
1362
+ panelLayout.aiCollapsed = false;
1363
+ applyPanelLayout(true);
1350
1364
  }
1351
1365
 
1352
1366
  function setupPanelResize(handle, side) {
@@ -1496,7 +1510,7 @@ function applyChapterEditorMode() {
1496
1510
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
1497
1511
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
1498
1512
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1499
- $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1513
+ $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter || !canReadModule("comments"));
1500
1514
  $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1501
1515
  syncChapterSearchControls();
1502
1516
  if (viewOnly) cancelChapterAutoSave();
@@ -1788,22 +1802,36 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1788
1802
  );
1789
1803
  const numbers = document.createDocumentFragment();
1790
1804
  for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
1791
- const number = document.createElement("button");
1792
- number.type = "button";
1805
+ const number = document.createElement("div");
1793
1806
  number.className = "chapter-line-number";
1794
- number.textContent = String(index + 1);
1795
1807
  number.dataset.lineIndex = String(index);
1796
- number.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1797
- number.tabIndex = -1;
1808
+ const lineSelect = document.createElement("button");
1809
+ lineSelect.type = "button";
1810
+ lineSelect.className = "chapter-line-number-select";
1811
+ lineSelect.textContent = String(index + 1);
1812
+ lineSelect.setAttribute("aria-label", `选择第 ${index + 1} 行`);
1813
+ lineSelect.tabIndex = -1;
1814
+ number.append(lineSelect);
1815
+ const annotationCount = chapterAnnotationCounts.get(index + 1) ?? 0;
1816
+ if (annotationCount > 0) {
1817
+ const bubble = document.createElement("button");
1818
+ bubble.type = "button";
1819
+ bubble.className = "chapter-line-annotation-count";
1820
+ bubble.dataset.lineIndex = String(index);
1821
+ bubble.dataset.lineAnnotationCount = String(annotationCount);
1822
+ bubble.textContent = String(annotationCount);
1823
+ bubble.setAttribute("aria-label", `查看第 ${index + 1} 行的 ${annotationCount} 条评论和待办`);
1824
+ number.append(bubble);
1825
+ }
1798
1826
  const selected = chapterLineSelection && index >= chapterLineSelection.start && index <= chapterLineSelection.end;
1799
1827
  if (selected) {
1800
1828
  number.classList.add("is-line-selected");
1801
- number.setAttribute("aria-pressed", "true");
1829
+ lineSelect.setAttribute("aria-pressed", "true");
1802
1830
  }
1803
1831
  const bounds = getLineBounds(index);
1804
1832
  number.style.top = `${bounds.top}px`;
1805
1833
  number.style.height = `${bounds.bottom - bounds.top}px`;
1806
- number.style.paddingTop = `${numberTextOffset}px`;
1834
+ lineSelect.style.paddingTop = `${numberTextOffset}px`;
1807
1835
  numbers.append(number);
1808
1836
  }
1809
1837
  inner.replaceChildren(numbers);
@@ -1879,7 +1907,7 @@ function paintChapterLineSelection(anchor, focus) {
1879
1907
  $("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number").forEach((row) => {
1880
1908
  const selected = Number(row.dataset.lineIndex) >= start && Number(row.dataset.lineIndex) <= end;
1881
1909
  row.classList.toggle("is-line-selected", selected);
1882
- row.setAttribute("aria-pressed", String(selected));
1910
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", String(selected));
1883
1911
  });
1884
1912
  }
1885
1913
 
@@ -2187,20 +2215,21 @@ function setAiConversationSwitcherVisible(visible) {
2187
2215
  }
2188
2216
 
2189
2217
  function setAiConversationWorkspaceVisible(visible) {
2218
+ const mobileWorkspace = isMobileViewport();
2190
2219
  aiConversationWorkspaceOpen = Boolean(visible);
2191
- if (aiConversationWorkspaceOpen) {
2192
- panelLayout.aiCollapsed = false;
2193
- $("#app").classList.remove("ai-panel-collapsed");
2194
- }
2220
+ panelLayout.aiCollapsed = aiConversationWorkspaceOpen ? false : mobileWorkspace;
2195
2221
  $("#app").classList.toggle("ai-workspace-mode", aiConversationWorkspaceOpen);
2196
2222
  $(".ai-panel").classList.toggle("is-conversation-workspace", aiConversationWorkspaceOpen);
2197
2223
  $("#ai-assistant-entry").classList.toggle("active", aiConversationWorkspaceOpen);
2198
2224
  $("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
2225
+ applyPanelLayout(true);
2199
2226
  $("#ai-chat-tabs").classList.add("hidden");
2200
2227
  $("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
2201
2228
  $("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
2202
2229
  setAiConversationSwitcherVisible(false);
2203
2230
  renderAiChatTabs();
2231
+ $("#ai-panel-toggle").textContent = aiConversationWorkspaceOpen ? "×" : (panelLayout.aiCollapsed ? "‹" : "›");
2232
+ $("#ai-panel-toggle").setAttribute("aria-label", aiConversationWorkspaceOpen ? "关闭创作助手全屏面板" : (panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手"));
2204
2233
  if (aiConversationWorkspaceOpen) {
2205
2234
  window.requestAnimationFrame(() => $("#ai-prompt").focus({ preventScroll: true }));
2206
2235
  }
@@ -2411,16 +2440,18 @@ function renderMessageCardActions(message) {
2411
2440
  message.append(actions);
2412
2441
  }
2413
2442
  actions.replaceChildren();
2414
- if (Object.hasOwn(message.dataset, "rawMarkdown")) {
2443
+ const hasCopyValue = Object.hasOwn(message.dataset, "rawMarkdown") || Object.hasOwn(message.dataset, "copyText");
2444
+ if (hasCopyValue) {
2415
2445
  const copy = document.createElement("button");
2416
2446
  copy.type = "button";
2417
2447
  copy.className = "message-copy-button";
2418
- copy.setAttribute("aria-label", "复制 AI 原始 Markdown");
2448
+ const isUserMessage = message.classList.contains("user-message");
2449
+ copy.setAttribute("aria-label", isUserMessage ? "复制用户指令" : "复制 AI 回复");
2419
2450
  copy.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>复制</span>';
2420
2451
  const copyLabel = copy.querySelector("span");
2421
2452
  copy.addEventListener("click", async () => {
2422
2453
  try {
2423
- await copyAiRawMarkdown(message.dataset.rawMarkdown);
2454
+ await copyAiRawMarkdown(message.dataset.rawMarkdown ?? message.dataset.copyText ?? "");
2424
2455
  copyLabel.textContent = "已复制";
2425
2456
  window.setTimeout(() => { copyLabel.textContent = "复制"; }, 1200);
2426
2457
  } catch (error) {
@@ -2470,6 +2501,11 @@ function attachAssistantCopyAction(message, rawMarkdown) {
2470
2501
  renderMessageCardActions(message);
2471
2502
  }
2472
2503
 
2504
+ function attachUserCopyAction(message, text) {
2505
+ message.dataset.copyText = String(text ?? "");
2506
+ renderMessageCardActions(message);
2507
+ }
2508
+
2473
2509
  function attachMessageIdentity(message, messageId) {
2474
2510
  if (!messageId) return;
2475
2511
  message.dataset.messageId = messageId;
@@ -3576,7 +3612,8 @@ function addSelectedLinesAsCitation() {
3576
3612
  }
3577
3613
 
3578
3614
  async function createSelectedLineAnnotation(kind) {
3579
- if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
3615
+ const permissionModule = kind === "todo" ? "todos" : "comments";
3616
+ if (!state.chapter || !chapterLineSelection || !canWritePermissionModule(state.work, permissionModule)) return;
3580
3617
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
3581
3618
  closeLineCitationMenu();
3582
3619
  const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
@@ -3592,7 +3629,8 @@ async function createSelectedLineAnnotation(kind) {
3592
3629
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
3593
3630
  });
3594
3631
  toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
3595
- await openChapterAnnotationsDialog();
3632
+ await loadChapterAnnotationCounts();
3633
+ await openChapterAnnotationsDialog(selection.safeStart);
3596
3634
  } catch (error) {
3597
3635
  toast(error.message, "error");
3598
3636
  }
@@ -3620,7 +3658,7 @@ function chapterAnnotationCard(annotation, { showSource = false } = {}) {
3620
3658
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
3621
3659
  <p data-annotation-content>${esc(annotation.note)}</p>
3622
3660
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
3623
- <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
3661
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canManageChapterAnnotation(annotation) ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
3624
3662
  </article>`;
3625
3663
  }
3626
3664
 
@@ -3680,9 +3718,12 @@ function renderChapterAnnotations() {
3680
3718
  const host = $("#chapter-annotations-list");
3681
3719
  host.innerHTML = chapterAnnotations.length
3682
3720
  ? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
3683
- : '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
3721
+ : `<p class="entity-history-empty">${chapterAnnotationsLineIndex === null ? "本章还没有评论。在正文行上点击右键即可添加。" : `第 ${chapterAnnotationsLineIndex + 1} 行还没有评论或待办。`}</p>`;
3684
3722
  bindChapterAnnotationCards(host, chapterAnnotations, {
3685
- refresh: loadChapterAnnotations,
3723
+ refresh: async () => {
3724
+ await loadChapterAnnotationCounts();
3725
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3726
+ },
3686
3727
  locate: (annotation) => {
3687
3728
  $("#chapter-annotations-dialog").close();
3688
3729
  paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
@@ -3692,19 +3733,43 @@ function renderChapterAnnotations() {
3692
3733
  });
3693
3734
  }
3694
3735
 
3695
- async function loadChapterAnnotations() {
3736
+ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
3737
+ if (!chapterId || !canReadModule("comments") && !canReadModule("todos")) {
3738
+ chapterAnnotationCounts = new Map();
3739
+ scheduleChapterLineNumbers();
3740
+ return;
3741
+ }
3742
+ const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
3743
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3744
+ chapterAnnotationCounts = new Map(
3745
+ (Array.isArray(counts) ? counts : [])
3746
+ .map((item) => [Number(item.line), Number(item.count)])
3747
+ .filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
3748
+ );
3749
+ scheduleChapterLineNumbers();
3750
+ }
3751
+
3752
+ async function loadChapterAnnotations(lineIndex = chapterAnnotationsLineIndex) {
3696
3753
  if (!state.chapter) return;
3697
- chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
3754
+ const chapterId = state.chapter.id;
3755
+ const line = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex + 1 : null;
3756
+ const query = line === null ? "" : `?line=${encodeURIComponent(line)}`;
3757
+ const annotations = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotations${query}`);
3758
+ if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
3759
+ chapterAnnotationsLineIndex = line === null ? null : line - 1;
3760
+ chapterAnnotations = annotations;
3698
3761
  renderChapterAnnotations();
3699
3762
  }
3700
3763
 
3701
- async function openChapterAnnotationsDialog() {
3764
+ async function openChapterAnnotationsDialog(lineIndex = null) {
3702
3765
  if (!state.chapter) return;
3703
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
3766
+ chapterAnnotationsLineIndex = Number.isInteger(lineIndex) && lineIndex >= 0 ? lineIndex : null;
3767
+ const lineLabel = chapterAnnotationsLineIndex === null ? "" : `第 ${chapterAnnotationsLineIndex + 1} 行的`;
3768
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》${lineLabel}正文评论与待办`;
3704
3769
  $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
3705
3770
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
3706
3771
  try {
3707
- await loadChapterAnnotations();
3772
+ await loadChapterAnnotations(chapterAnnotationsLineIndex);
3708
3773
  } catch (error) {
3709
3774
  $("#chapter-annotations-dialog").close();
3710
3775
  toast(error.message, "error");
@@ -3722,8 +3787,8 @@ function showLineCitationMenu(event, lineIndex) {
3722
3787
  selectChapterLines(lineIndex, lineIndex);
3723
3788
  }
3724
3789
  const menu = $("#line-citation-menu");
3725
- $("#add-line-annotation").classList.toggle("hidden", !canEditProse());
3726
- $("#add-line-todo").classList.toggle("hidden", !canEditProse());
3790
+ $("#add-line-annotation").classList.toggle("hidden", !canWritePermissionModule(state.work, "comments"));
3791
+ $("#add-line-todo").classList.toggle("hidden", !canWritePermissionModule(state.work, "todos"));
3727
3792
  const { start, end } = chapterLineSelection;
3728
3793
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
3729
3794
  menu.classList.remove("hidden");
@@ -3736,7 +3801,7 @@ function clearChapterLineSelection() {
3736
3801
  chapterLineSelection = null;
3737
3802
  $("#chapter-line-numbers-inner")?.querySelectorAll(".is-line-selected").forEach((row) => {
3738
3803
  row.classList.remove("is-line-selected");
3739
- row.setAttribute("aria-pressed", "false");
3804
+ row.querySelector(".chapter-line-number-select")?.setAttribute("aria-pressed", "false");
3740
3805
  });
3741
3806
  }
3742
3807
 
@@ -5860,7 +5925,7 @@ function renderMemberPermissionGrid(value) {
5860
5925
  <select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
5861
5926
  <option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
5862
5927
  <option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
5863
- <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
5928
+ <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>${["comments", "todos"].includes(item.id) ? "可添加" : "可编辑"}</option>
5864
5929
  </select>
5865
5930
  </label>`).join("");
5866
5931
  }
@@ -6458,6 +6523,7 @@ function resetWorkScopedUiCaches() {
6458
6523
  state.characters = [];
6459
6524
  state.settings = [];
6460
6525
  state.races = [];
6526
+ state.organizations = [];
6461
6527
  characterListPage = 1;
6462
6528
  draftTypeFilter = "all";
6463
6529
  draftBindingFilters = [];
@@ -7174,8 +7240,12 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
7174
7240
  const normalizedContent = normalizeParagraphSpacing(state.chapter.content);
7175
7241
  const spacingChanged = normalizedContent !== state.chapter.content;
7176
7242
  $("#chapter-content").value = normalizedContent;
7243
+ chapterAnnotationCounts = new Map();
7177
7244
  clearChapterLineSelection();
7178
7245
  scheduleChapterLineNumbers();
7246
+ void loadChapterAnnotationCounts(state.chapter.id).catch((error) => {
7247
+ if (String(state.chapter?.id ?? "") === String(chapterId)) toast("正文评论数量读取失败,请稍后重试", "error");
7248
+ });
7179
7249
  dismissChapterInsightToast();
7180
7250
  updateChapterStats();
7181
7251
  if (!canEditProse()) setSaveState("正文只读");
@@ -7711,7 +7781,7 @@ const moduleMeta = {
7711
7781
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
7712
7782
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
7713
7783
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
7714
- comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7784
+ comments: ["正文协作", "正文评论与待办", "集中查看并处理当前作品所有章节的评论与待办。", ""],
7715
7785
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
7716
7786
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
7717
7787
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -9281,8 +9351,8 @@ async function renderWorkChapterComments(page = moduleListPages.comments) {
9281
9351
  moduleListPages.comments = pageResult.page;
9282
9352
  mountModuleCount(total);
9283
9353
  $("#module-content").innerHTML = result.items.length
9284
- ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
9285
- : emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
9354
+ ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
9355
+ : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
9286
9356
  bindModulePagination("comments", renderWorkChapterComments);
9287
9357
  bindChapterAnnotationCards($("#module-content"), result.items, {
9288
9358
  refresh: () => renderWorkChapterComments(pageResult.page),
@@ -11491,13 +11561,17 @@ async function loadAiReferences() {
11491
11561
  const workId = state.work?.id;
11492
11562
  if (!workId) return;
11493
11563
  const generation = workScopedUiGeneration;
11494
- const [characters, settings] = await Promise.all([
11564
+ const [characters, settings, races, organizations] = await Promise.all([
11495
11565
  canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
11496
- canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([])
11566
+ canReadModule("settings") ? api(`/api/works/${workId}/settings/context`) : Promise.resolve([]),
11567
+ canReadModule("races") ? api(`/api/works/${workId}/races`) : Promise.resolve([]),
11568
+ canReadModule("organizations") ? apiAllPages(`/api/works/${workId}/organizations`) : Promise.resolve([])
11497
11569
  ]);
11498
11570
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
11499
11571
  state.characters = characters;
11500
11572
  state.settings = settings;
11573
+ state.races = races;
11574
+ state.organizations = organizations;
11501
11575
  renderAiRoleplayCharacterSelect();
11502
11576
  loadedAiReferencesWorkId = workId;
11503
11577
  }
@@ -14629,6 +14703,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14629
14703
  try {
14630
14704
  try {
14631
14705
  await ensureAiModelsLoaded();
14706
+ await ensureAiReferencesLoaded();
14632
14707
  } catch (error) {
14633
14708
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
14634
14709
  setAiChatTabStatus(tab, "error");
@@ -14876,6 +14951,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14876
14951
  }
14877
14952
  });
14878
14953
  let streamedText = "";
14954
+ let streamedPendingText = "";
14879
14955
  let generatedMetadata = {};
14880
14956
  let toolCalls = [];
14881
14957
  let processSteps = [];
@@ -14886,7 +14962,6 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14886
14962
  let contextAction = "ready";
14887
14963
  let warningOnly = false;
14888
14964
  let streamContextCompacted = false;
14889
- let finalAnswerStarted = false;
14890
14965
  const processStartedAt = Date.now();
14891
14966
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
14892
14967
  const processStepTypewriters = new Map();
@@ -14904,7 +14979,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14904
14979
  onRender: (text) => {
14905
14980
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
14906
14981
  processStepVisibleContents.set(step, text);
14907
- renderStreamingProcessSteps(finalAnswerStarted);
14982
+ renderStreamingProcessSteps(false);
14908
14983
  scrollAiFeedToBottom(feed);
14909
14984
  }
14910
14985
  });
@@ -14977,18 +15052,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14977
15052
  }
14978
15053
  } else if (eventName === "delta") {
14979
15054
  mountAssistantMessage();
14980
- const firstFinalDelta = streamedText.length === 0;
14981
15055
  const delta = typeof payload.delta === "string" ? payload.delta : "";
14982
15056
  streamedText += delta;
14983
- if (streamedText.length > 0) finalAnswerStarted = true;
15057
+ streamedPendingText += delta;
14984
15058
  typewriter.append(delta);
14985
- if (firstFinalDelta && processSteps.length) renderStreamingProcessSteps(true, elapsedProcessTime());
14986
15059
  meta.textContent = "正在生成回复……";
14987
15060
  } else if (eventName === "process_step") {
14988
15061
  mountAssistantMessage();
14989
15062
  const step = { ...payload };
14990
15063
  const append = step.append === true;
14991
15064
  delete step.append;
15065
+ if (step.type === "intermediate" && typeof step.content === "string" && streamedPendingText.endsWith(step.content)) {
15066
+ streamedPendingText = streamedPendingText.slice(0, -step.content.length);
15067
+ streamedText = streamedText.slice(0, -step.content.length);
15068
+ typewriter.replace(streamedText);
15069
+ }
14992
15070
  const existing = append ? processSteps.find((item) => item.id === step.id && item.type === step.type) : null;
14993
15071
  if (existing && typeof step.content === "string") existing.content += step.content;
14994
15072
  else processSteps.push(step);
@@ -14996,7 +15074,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
14996
15074
  if (typeof step.content === "string" && step.content.length > 0 && step.type === "thinking") {
14997
15075
  processStepTypewriter(targetStep).append(step.content);
14998
15076
  }
14999
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15077
+ renderStreamingProcessSteps(false, elapsedProcessTime());
15000
15078
  meta.textContent = step.type === "thinking"
15001
15079
  ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
15002
15080
  : step.type === "context_compaction"
@@ -15011,7 +15089,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15011
15089
  if (toolCall.status === "failed") setAiChatTabStatus(tab, "error");
15012
15090
  toolCalls.push(toolCall);
15013
15091
  processSteps.push(aiToolProcessStep(toolCall, round));
15014
- renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
15092
+ renderStreamingProcessSteps(false, elapsedProcessTime());
15015
15093
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
15016
15094
  scrollAiFeedToBottom(feed);
15017
15095
  } else if (eventName === "context_compacted") {
@@ -15135,20 +15213,27 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15135
15213
  failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
15136
15214
  heading.firstElementChild?.append(failureBadge);
15137
15215
  }
15138
- const mentionNames = role === "user"
15139
- ? userMessageMentionNames(metadata?.mentionCharacterIds, state.characters)
15216
+ const mentionGroups = role === "user"
15217
+ ? [
15218
+ ["角色", metadata?.mentionCharacterIds, state.characters],
15219
+ ["种族", metadata?.mentionRaceIds, state.races],
15220
+ ["组织", metadata?.mentionOrganizationIds, state.organizations]
15221
+ ]
15222
+ .flatMap(([kind, ids, items]) => userMessageMentionNames(ids, items).map((name) => ({ kind, name })))
15140
15223
  : [];
15141
- if (mentionNames.length) {
15224
+ if (mentionGroups.length) {
15142
15225
  const references = document.createElement("div");
15143
15226
  references.className = "user-message-mentions";
15144
15227
  const label = document.createElement("span");
15145
15228
  label.className = "user-message-mentions-label";
15146
- label.textContent = "引用角色";
15229
+ label.textContent = "引用";
15147
15230
  references.append(label);
15148
- for (const name of mentionNames) {
15231
+ for (const { kind, name } of mentionGroups) {
15149
15232
  const reference = document.createElement("span");
15150
15233
  reference.className = "user-message-mention";
15151
15234
  reference.textContent = name;
15235
+ reference.title = `${kind}:${name}`;
15236
+ reference.setAttribute("aria-label", `${kind}:${name}`);
15152
15237
  references.append(reference);
15153
15238
  }
15154
15239
  message.append(references);
@@ -15174,6 +15259,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15174
15259
  if (role === "assistant") {
15175
15260
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
15176
15261
  }
15262
+ if (role === "user") attachUserCopyAction(message, text);
15177
15263
  if (role === "assistant" && !text.startsWith("调用失败:")) {
15178
15264
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15179
15265
  const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
@@ -16648,6 +16734,7 @@ $("#chapter-content").addEventListener("contextmenu", (event) => {
16648
16734
  showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
16649
16735
  });
16650
16736
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
16737
+ if (event.target.closest(".chapter-line-annotation-count")) return;
16651
16738
  const row = event.target.closest(".chapter-line-number");
16652
16739
  if (!row || event.button !== 0) return;
16653
16740
  event.preventDefault();
@@ -16670,7 +16757,23 @@ const finishChapterLineDrag = (event) => {
16670
16757
  };
16671
16758
  $("#chapter-line-numbers-inner").addEventListener("pointerup", finishChapterLineDrag);
16672
16759
  $("#chapter-line-numbers-inner").addEventListener("pointercancel", finishChapterLineDrag);
16760
+ $("#chapter-line-numbers-inner").addEventListener("click", (event) => {
16761
+ const bubble = event.target.closest(".chapter-line-annotation-count");
16762
+ if (bubble) {
16763
+ event.preventDefault();
16764
+ event.stopPropagation();
16765
+ openChapterAnnotationsDialog(Number(bubble.dataset.lineIndex)).catch((error) => toast(error.message, "error"));
16766
+ return;
16767
+ }
16768
+ const lineSelect = event.target.closest(".chapter-line-number-select");
16769
+ if (!lineSelect) return;
16770
+ const lineIndex = Number(lineSelect.closest(".chapter-line-number")?.dataset.lineIndex);
16771
+ if (!Number.isInteger(lineIndex)) return;
16772
+ paintChapterLineSelection(lineIndex, lineIndex);
16773
+ selectChapterLines(lineIndex, lineIndex);
16774
+ });
16673
16775
  $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
16776
+ if (event.target.closest(".chapter-line-annotation-count")) return;
16674
16777
  const row = event.target.closest(".chapter-line-number");
16675
16778
  if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
16676
16779
  });
@@ -16680,7 +16783,10 @@ $("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation
16680
16783
  $("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
16681
16784
  $("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
16682
16785
  $("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
16683
- $("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
16786
+ $("#chapter-annotations-refresh").addEventListener("click", () => Promise.all([
16787
+ loadChapterAnnotationCounts(),
16788
+ loadChapterAnnotations(chapterAnnotationsLineIndex)
16789
+ ]).catch((error) => toast(error.message, "error")));
16684
16790
  $("#left-panel-toggle").addEventListener("click", () => {
16685
16791
  panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
16686
16792
  applyPanelLayout(true);
@@ -16718,6 +16824,8 @@ $("#ai-assistant-entry").addEventListener("click", () => {
16718
16824
  if (isMobileViewport()) {
16719
16825
  panelLayout.leftCollapsed = true;
16720
16826
  applyPanelLayout(true);
16827
+ setAiConversationWorkspaceVisible(true);
16828
+ return;
16721
16829
  }
16722
16830
  setAiConversationWorkspaceVisible(true);
16723
16831
  });
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -133,8 +133,8 @@
133
133
  <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
134
134
  <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
135
135
  <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
136
- <button id="ai-assistant-entry" class="ai-assistant-entry" type="button" aria-controls="ai-panel" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v8a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 4v-4h0A2.5 2.5 0 0 1 4 13.5z"/><path d="M8 8h8M8 11h5"/></svg>创作助手</button>
137
136
  <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
137
+ <button id="ai-assistant-entry" class="module-nav-secondary hidden ai-assistant-entry" type="button" aria-controls="ai-panel" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v8a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 4v-4h0A2.5 2.5 0 0 1 4 13.5z"/><path d="M8 8h8M8 11h5"/></svg>创作助手</button>
138
138
  <button id="reader-open-button" class="module-nav-secondary hidden" type="button" aria-label="打开沉浸式阅读预览" title="阅读预览"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v16H5.5A2.5 2.5 0 0 0 3 21.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v16h5.5a2.5 2.5 0 0 1 2.5 2.5Z"/></svg>阅读预览</button>
139
139
  <button class="module-nav-secondary hidden" type="button" data-module="comments"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/><path d="M8 9h8M8 13h5"/></svg><span class="nav-label">正文评论</span></button>
140
140
  <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
@@ -1106,7 +1106,7 @@
1106
1106
  <button type="button" data-permission-preset="write">全部可编辑</button>
1107
1107
  </div>
1108
1108
  <div id="member-permission-grid" class="member-permission-grid"></div>
1109
- <small class="member-permission-help">“可编辑”自动包含读取权限;未授权模块不会显示,也不能通过 API 读取。</small>
1109
+ <small class="member-permission-help">正文评论和正文待办的“可添加”只控制新增;删除、内容更新和状态更新需要正文可编辑权限。未授权模块不会显示,也不能通过 API 读取。</small>
1110
1110
  <button id="member-permission-submit" class="primary-button" type="submit">保存成员权限</button>
1111
1111
  </fieldset>
1112
1112
  </form>
@@ -1205,6 +1205,6 @@
1205
1205
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1206
1206
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1207
1207
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1208
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1"></script>
1208
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1"></script>
1209
1209
  </body>
1210
1210
  </html>
@@ -5,6 +5,7 @@ export type StreamTypewriterProgress = {
5
5
 
6
6
  export type StreamTypewriter = {
7
7
  append(value: unknown): void;
8
+ replace(value: unknown): string;
8
9
  finish(): Promise<string>;
9
10
  reveal(): string;
10
11
  };
@@ -122,6 +122,18 @@ export function createStreamTypewriter({
122
122
  pendingCharacters.push(...characters);
123
123
  schedule();
124
124
  },
125
+ replace(value) {
126
+ if (scheduledFrame !== null) {
127
+ cancelFrame(scheduledFrame);
128
+ scheduledFrame = null;
129
+ }
130
+ visibleCharacters.splice(0, visibleCharacters.length, ...Array.from(String(value ?? "")));
131
+ pendingCharacters.splice(0);
132
+ finishing = false;
133
+ render();
134
+ resolveIdle();
135
+ return snapshot();
136
+ },
125
137
  finish() {
126
138
  if (!pendingCharacters.length && scheduledFrame === null) return Promise.resolve(snapshot());
127
139
  finishing = true;