@musnows/scriverse 1.0.7 → 1.0.8

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,3 +1,25 @@
1
+ export function formatAiContextCacheHitPercent(value) {
2
+ const rate = Number(value);
3
+ if (!Number.isFinite(rate)) return "—";
4
+ const clamped = Math.max(0, Math.min(100, Math.round((rate + Number.EPSILON) * 10) / 10));
5
+ const label = Number.isInteger(clamped) ? String(clamped) : clamped.toFixed(1);
6
+ return `${label}%`;
7
+ }
8
+
9
+ export function formatAiContextPercentSummary(usage) {
10
+ const distribution = normalizeAiContextTokenDistribution(usage);
11
+ const contextPercent = formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow);
12
+ return `context ${contextPercent} · cache hit ${formatAiContextCacheHitPercent(usage?.cacheHitPercent)}`;
13
+ }
14
+
15
+ export function formatAiContextPopoverDescription(usage) {
16
+ if (!usage) return "选择可用模型后显示当前上下文用量";
17
+ const distribution = normalizeAiContextTokenDistribution(usage);
18
+ const occupied = distribution.occupiedTokens.toLocaleString("zh-CN");
19
+ const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
20
+ return `已占用 ${occupied} / ${contextWindow} tok · ${formatAiContextPercentSummary(usage)}`;
21
+ }
22
+
1
23
  export function formatAiContextUsageTooltip(usage) {
2
24
  if (!usage) return "选择可用模型后显示当前上下文用量";
3
25
  const inputTokens = Math.max(0, Math.round(Number(usage.inputTokens) || 0)).toLocaleString("zh-CN");
@@ -6,7 +28,23 @@ export function formatAiContextUsageTooltip(usage) {
6
28
  const conversationTokens = Math.max(0, Math.round(Number(usage.conversationTokens) || 0)).toLocaleString("zh-CN");
7
29
  const conversationBudget = Math.max(0, Math.round(Number(usage.conversationBudgetTokens) || 0)).toLocaleString("zh-CN");
8
30
  const outputTokens = Math.max(0, Math.round(Number(usage.outputTokens) || 0)).toLocaleString("zh-CN");
9
- return `总输入 ${inputTokens} / ${contextWindow} tok · 作品上下文 ${contextTokens} tok · 对话历史 ${conversationTokens} / ${conversationBudget} tok · 当前调用实际输出 ${outputTokens} tok`;
31
+ return `总输入 ${inputTokens} / ${contextWindow} tok · 作品上下文 ${contextTokens} tok · 对话历史 ${conversationTokens} / ${conversationBudget} tok · 当前调用实际输出 ${outputTokens} tok · ${formatAiContextPercentSummary(usage)}`;
32
+ }
33
+
34
+ export function attachAiContextCacheHitPercent(usage, cacheHitPercent) {
35
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage ?? null;
36
+ const rate = Number(cacheHitPercent);
37
+ if (!Number.isFinite(rate)) return usage;
38
+ return { ...usage, cacheHitPercent: Math.max(0, Math.min(100, rate)) };
39
+ }
40
+
41
+ function withPreservedCacheHitPercent(previousUsage, nextUsage) {
42
+ if (!nextUsage || typeof nextUsage !== "object" || Array.isArray(nextUsage)) return nextUsage;
43
+ const nextCacheHit = Number(nextUsage.cacheHitPercent);
44
+ if (Number.isFinite(nextCacheHit)) return nextUsage;
45
+ const previousCacheHit = Number(previousUsage?.cacheHitPercent);
46
+ if (!Number.isFinite(previousCacheHit)) return nextUsage;
47
+ return { ...nextUsage, cacheHitPercent: previousCacheHit };
10
48
  }
11
49
 
12
50
  function tokenCount(value) {
@@ -14,7 +52,8 @@ function tokenCount(value) {
14
52
  }
15
53
 
16
54
  export function resolveAiContextUsage(previousUsage, nextUsage) {
17
- return nextUsage && typeof nextUsage === "object" ? nextUsage : previousUsage ?? null;
55
+ if (!nextUsage || typeof nextUsage !== "object") return previousUsage ?? null;
56
+ return withPreservedCacheHitPercent(previousUsage, nextUsage);
18
57
  }
19
58
 
20
59
  const maximumAiContextUsageFields = [
@@ -28,7 +67,9 @@ const minimumAiContextUsageFields = ["remainingTokens"];
28
67
 
29
68
  export function mergeAiContextUsage(previousUsage, nextUsage, allowShrink = false) {
30
69
  if (!nextUsage || typeof nextUsage !== "object" || Array.isArray(nextUsage)) return previousUsage ?? null;
31
- if (!previousUsage || typeof previousUsage !== "object" || Array.isArray(previousUsage) || allowShrink) return nextUsage;
70
+ if (!previousUsage || typeof previousUsage !== "object" || Array.isArray(previousUsage) || allowShrink) {
71
+ return withPreservedCacheHitPercent(previousUsage, nextUsage);
72
+ }
32
73
  const mergedUsage = { ...nextUsage };
33
74
  for (const field of [...maximumAiContextUsageFields, ...minimumAiContextUsageFields]) {
34
75
  const previousValue = Number(previousUsage[field]);
@@ -41,7 +82,7 @@ export function mergeAiContextUsage(previousUsage, nextUsage, allowShrink = fals
41
82
  else if (Number.isFinite(previousValue)) mergedUsage[field] = previousValue;
42
83
  else if (Number.isFinite(nextValue)) mergedUsage[field] = nextValue;
43
84
  }
44
- return mergedUsage;
85
+ return withPreservedCacheHitPercent(previousUsage, mergedUsage);
45
86
  }
46
87
 
47
88
  export function formatAiContextUsagePercent(occupiedTokens, contextWindow) {
@@ -0,0 +1,30 @@
1
+ export function createAiRenderScheduler({ isVisible, isConnected = () => true, schedule = requestAnimationFrame, now = () => performance.now(), intervalMs = 32, budgetMs = 4 }) {
2
+ const pending = new Map();
3
+ let frame = null;
4
+ let lastRendered = -Infinity;
5
+ const refresh = () => {
6
+ for (const target of pending.keys()) {
7
+ if (!isConnected(target)) pending.delete(target);
8
+ }
9
+ if (frame !== null || ![...pending.keys()].some(isVisible)) return;
10
+ frame = schedule(() => {
11
+ frame = null;
12
+ const started = now();
13
+ if (started - lastRendered >= intervalMs) {
14
+ for (const [target, render] of pending) {
15
+ if (!isConnected(target)) { pending.delete(target); continue; }
16
+ if (!isVisible(target)) continue;
17
+ pending.delete(target);
18
+ render();
19
+ lastRendered = started;
20
+ if (now() - started >= budgetMs) break;
21
+ }
22
+ }
23
+ refresh();
24
+ });
25
+ };
26
+ return {
27
+ enqueue(target, render) { pending.set(target, render); refresh(); },
28
+ refresh
29
+ };
30
+ }
@@ -1,7 +1,9 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
2
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { countProseWords } from "/text-count.js?v=20260906-chapter-word-count-consistency-v1";
4
- import { renderMarkdown } from "/markdown.js?v=20260830-adjacent-blockquotes-v1";
4
+ import { renderMarkdown } from "/markdown.js?v=20260912-stream-render-v2";
5
+ import { createStreamingMarkdownRenderer } from "/stream-markdown.js?v=20260912-stream-render-v2";
6
+ import { createAiRenderScheduler } from "/ai-render-scheduler.js?v=20260912-stream-render-v2";
5
7
  import { createImWorkspace } from "/im.js?v=20260904-im-judge-outcomes-v106";
6
8
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
7
9
  import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
@@ -32,11 +34,11 @@ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_
32
34
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
33
35
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
34
36
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
35
- import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260906-background-stream-v2";
37
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260912-stream-render-v2";
36
38
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
37
39
  import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount, usageCalendarYears } from "/ai-usage.js?v=20260830-ai-usage-year-v1";
38
40
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
39
- import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260828-context-output-usage-v1";
41
+ import { attachAiContextCacheHitPercent, formatAiContextPopoverDescription, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260911-context-cache-hit-v1";
40
42
  import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
41
43
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
42
44
  import {
@@ -2688,6 +2690,7 @@ function activateAiChatTab(tabId, { persistCurrent = true, force = false } = {})
2688
2690
  feed.id = feed === tab.feed ? "ai-feed" : `ai-chat-panel-${feed.dataset.aiTabId}`;
2689
2691
  }
2690
2692
  tab.feed.classList.remove("hidden");
2693
+ aiStreamRenders.refresh();
2691
2694
  applyAiChatTabState(tab);
2692
2695
  renderAiChatTabs();
2693
2696
  return tab;
@@ -2703,6 +2706,7 @@ function closeAiChatTab(tabId) {
2703
2706
  }
2704
2707
  const { active } = aiChatTabManager.close(tab.id);
2705
2708
  tab.feed.remove();
2709
+ aiStreamRenders.refresh();
2706
2710
  if (!wasActive) {
2707
2711
  renderAiChatTabs();
2708
2712
  return;
@@ -3087,6 +3091,42 @@ const aiFeedScrollFrames = new WeakMap();
3087
3091
  const aiFeedAutoScrollStates = new WeakMap();
3088
3092
  const aiFeedScrollBindings = new WeakSet();
3089
3093
  const aiProcessScrollFrames = new WeakMap();
3094
+ const aiProcessRenderStates = new WeakMap();
3095
+ const aiMarkdownRenderers = new WeakMap();
3096
+ function aiStreamTargetVisible(target) {
3097
+ target = target.element ?? target;
3098
+ const feed = target.closest(".ai-feed");
3099
+ const app = $("#app");
3100
+ return document.visibilityState !== "hidden" && Boolean(feed?.isConnected)
3101
+ && !feed.classList.contains("hidden") && !app.classList.contains("shelf-mode")
3102
+ && (!app.classList.contains("ai-panel-collapsed") || app.classList.contains("ai-workspace-mode"));
3103
+ }
3104
+ const aiStreamRenders = createAiRenderScheduler({
3105
+ isVisible: aiStreamTargetVisible,
3106
+ isConnected: (target) => (target.element ?? target).isConnected
3107
+ });
3108
+ document.addEventListener("visibilitychange", () => aiStreamRenders.refresh());
3109
+ // 只监听面板显隐,不观察正文或消息内容,避免渲染自身触发新的刷新。
3110
+ const aiPanelVisibilityObserver = new MutationObserver(() => aiStreamRenders.refresh());
3111
+ aiPanelVisibilityObserver.observe($("#app"), { attributes: true, attributeFilter: ["class"] });
3112
+
3113
+ function updateAiMarkdown(body, content) {
3114
+ let render = aiMarkdownRenderers.get(body);
3115
+ if (!render) {
3116
+ const target = { element: body };
3117
+ render = createStreamingMarkdownRenderer(body, {
3118
+ enqueue: (callback) => queueMicrotask(() => aiStreamRenders.enqueue(target, callback)),
3119
+ onRender: () => {
3120
+ const message = body.closest(".assistant-message");
3121
+ if (message?.classList.contains("is-streaming")) scrollAiProcessStepsToBottom(message);
3122
+ const feed = body.closest(".ai-feed");
3123
+ if (feed) scrollAiFeedToBottom(feed);
3124
+ }
3125
+ });
3126
+ aiMarkdownRenderers.set(body, render);
3127
+ }
3128
+ return render(content);
3129
+ }
3090
3130
  const AI_FEED_BOTTOM_THRESHOLD_PX = 24;
3091
3131
  let markdownTableMenuTarget = null;
3092
3132
  let markdownTableMenuTrigger = null;
@@ -3153,9 +3193,8 @@ function scrollAiFeedToBottom(feed = $("#ai-feed"), { force = false } = {}) {
3153
3193
  bindAiFeedAutoScroll(feed);
3154
3194
  if (force) aiFeedAutoScrollStates.set(feed, true);
3155
3195
  if (!aiFeedAutoScrollStates.get(feed)) return;
3156
- feed.scrollTop = feed.scrollHeight;
3157
3196
  const currentFrame = aiFeedScrollFrames.get(feed);
3158
- if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
3197
+ if (currentFrame !== undefined || !aiStreamTargetVisible(feed)) return;
3159
3198
  const nextFrame = window.requestAnimationFrame(() => {
3160
3199
  feed.scrollTop = feed.scrollHeight;
3161
3200
  aiFeedScrollFrames.delete(feed);
@@ -3169,9 +3208,8 @@ function scrollAiProcessStepsToBottom(message) {
3169
3208
  body.scrollTop = body.scrollHeight;
3170
3209
  });
3171
3210
  };
3172
- scroll();
3173
3211
  const currentFrame = aiProcessScrollFrames.get(message);
3174
- if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
3212
+ if (currentFrame !== undefined || !aiStreamTargetVisible(message)) return;
3175
3213
  const nextFrame = window.requestAnimationFrame(() => {
3176
3214
  scroll();
3177
3215
  aiProcessScrollFrames.delete(message);
@@ -3724,74 +3762,80 @@ function shouldRenderAiProcessStep(step) {
3724
3762
  }
3725
3763
 
3726
3764
  function renderAiProcessSteps(message, steps, completed, durationMs = null, visibleContents = null) {
3727
- const previousDetails = message.querySelector(".ai-process-details");
3728
- const previousScrollStates = new Map();
3729
- previousDetails?.querySelectorAll(".ai-process-step[data-ai-process-step-id]").forEach((section) => {
3730
- const body = section.querySelector(".ai-process-step-body");
3731
- if (!body) return;
3732
- previousScrollStates.set(section.dataset.aiProcessStepId, {
3733
- scrollTop: body.scrollTop,
3734
- nearBottom: body.scrollHeight - body.scrollTop - body.clientHeight < 24
3735
- });
3736
- });
3737
- previousDetails?.remove();
3765
+ const cached = aiProcessRenderStates.get(message);
3738
3766
  const renderableSteps = (Array.isArray(steps) ? steps : []).filter(shouldRenderAiProcessStep);
3739
- if (!renderableSteps.length) return;
3740
- const details = document.createElement("details");
3767
+ if (!renderableSteps.length) {
3768
+ cached?.details.remove();
3769
+ aiProcessRenderStates.delete(message);
3770
+ return;
3771
+ }
3772
+ const details = cached?.details ?? document.createElement("details");
3741
3773
  details.className = "ai-process-details";
3742
3774
  // 存在待确认/待回答的交互卡片时保持展开,避免审批入口在历史消息中被折叠。
3743
3775
  details.open = !completed || steps.some((step) => step?.type === "tool" && isInteractiveToolPending(step.toolCall));
3744
- const summary = document.createElement("summary");
3745
- const title = document.createElement("span");
3776
+ const summary = cached?.summary ?? document.createElement("summary");
3777
+ const title = cached?.title ?? document.createElement("span");
3746
3778
  title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
3747
- const status = document.createElement("small");
3779
+ const status = cached?.status ?? document.createElement("small");
3748
3780
  const duration = durationMs === null || durationMs === undefined ? "" : formatAiProcessDuration(durationMs);
3749
3781
  status.textContent = `${renderableSteps.length} 个步骤${duration ? ` · 耗时 ${duration}` : ""}`;
3750
- summary.append(title, status);
3751
- const list = document.createElement("div");
3782
+ if (!cached) summary.append(title, status);
3783
+ const list = cached?.list ?? document.createElement("div");
3752
3784
  list.className = "ai-process-list";
3753
- for (const step of renderableSteps) {
3785
+ const entries = new Map();
3786
+ const nextNodes = [];
3787
+ for (const [index, step] of renderableSteps.entries()) {
3788
+ const key = `${step.type}:${String(step.id ?? index)}`;
3789
+ const previous = cached?.entries.get(key);
3754
3790
  if (step?.type === "context_compaction") {
3755
- list.append(createAiContextCompactionDivider({
3791
+ const divider = previous?.node ?? createAiContextCompactionDivider({
3756
3792
  kind: "tool",
3757
3793
  ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
3758
3794
  title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
3759
- }));
3795
+ });
3796
+ entries.set(key, { node: divider });
3797
+ nextNodes.push(divider);
3760
3798
  continue;
3761
3799
  }
3762
3800
  if (step?.type === "tool" && step.toolCall) {
3801
+ if (previous?.value === step.toolCall) {
3802
+ entries.set(key, previous);
3803
+ nextNodes.push(previous.node);
3804
+ continue;
3805
+ }
3763
3806
  const tool = document.createElement("section");
3764
3807
  tool.className = "ai-process-step ai-process-tool-step";
3765
3808
  const label = document.createElement("small");
3766
3809
  label.textContent = `第 ${Number(step.round) || 1} 轮 · 工具调用`;
3767
3810
  tool.append(label, createAiToolCallButton(step.toolCall));
3768
- list.append(tool);
3811
+ entries.set(key, { node: tool, value: step.toolCall });
3812
+ nextNodes.push(tool);
3769
3813
  continue;
3770
3814
  }
3771
- const section = document.createElement("section");
3815
+ const section = previous?.node ?? document.createElement("section");
3772
3816
  section.className = `ai-process-step ai-process-${step.type}-step`;
3773
3817
  section.dataset.aiProcessStepId = `${step.type}:${String(step.id ?? step.round ?? "")}`;
3774
- const label = document.createElement("small");
3818
+ const label = previous?.label ?? document.createElement("small");
3775
3819
  label.textContent = `第 ${Number(step.round) || 1} 轮 · ${step.type === "thinking" ? "Thinking" : "中间输出"}`;
3776
- const body = document.createElement("div");
3820
+ const body = previous?.body ?? document.createElement("div");
3777
3821
  body.className = "message-body ai-process-step-body";
3778
3822
  const content = visibleContents?.has(step) ? visibleContents.get(step) : step.content;
3779
- body.innerHTML = renderMarkdown(content);
3780
- section.append(label, body);
3781
- list.append(section);
3782
- }
3783
- details.append(summary, list);
3784
- list.querySelectorAll(".ai-process-step[data-ai-process-step-id]").forEach((section) => {
3785
- const scrollState = previousScrollStates.get(section.dataset.aiProcessStepId);
3786
- const body = section.querySelector(".ai-process-step-body");
3787
- if (!scrollState || !body) return;
3788
- body.scrollTop = scrollState.nearBottom
3789
- ? body.scrollHeight
3790
- : Math.min(scrollState.scrollTop, body.scrollHeight);
3791
- });
3792
- const body = message.querySelector(".message-body");
3793
- if (body) body.before(details);
3794
- else message.append(details);
3823
+ updateAiMarkdown(body, content);
3824
+ if (!previous) section.append(label, body);
3825
+ entries.set(key, { node: section, label, body });
3826
+ nextNodes.push(section);
3827
+ }
3828
+ if (!cached) details.append(summary, list);
3829
+ nextNodes.forEach((node, index) => {
3830
+ if (list.children[index] !== node) list.insertBefore(node, list.children[index] ?? null);
3831
+ });
3832
+ while (list.children.length > nextNodes.length) list.lastElementChild.remove();
3833
+ if (!cached) {
3834
+ const body = message.querySelector(".message-body");
3835
+ if (body) body.before(details);
3836
+ else message.append(details);
3837
+ }
3838
+ aiProcessRenderStates.set(message, { details, summary, title, status, list, entries, completed });
3795
3839
  if (!completed) scrollAiProcessStepsToBottom(message);
3796
3840
  }
3797
3841
 
@@ -9108,6 +9152,7 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
9108
9152
  menu.querySelector("strong").textContent = `操作“${chapter.title}”`;
9109
9153
  menu.querySelectorAll("[data-chapter-type], [data-delete-chapter]").forEach((button) => button.classList.toggle("hidden", !canManageChapter));
9110
9154
  menu.querySelector("[data-add-chapter-ai-reference]")?.classList.toggle("hidden", !canAddAiReference);
9155
+ menu.querySelector("#chapter-type-ai-reference-separator")?.classList.toggle("hidden", !(canManageChapter && canAddAiReference));
9111
9156
  menu.querySelectorAll("[data-chapter-type]").forEach((button) => {
9112
9157
  button.classList.toggle("active", button.dataset.chapterType === (chapter.chapterType || "正文"));
9113
9158
  button.setAttribute("aria-checked", String(button.classList.contains("active")));
@@ -14633,10 +14678,7 @@ function renderAiContextDistribution(usage) {
14633
14678
  const popover = $("#ai-context-popover");
14634
14679
  const host = $("#ai-context-distribution");
14635
14680
  const distribution = normalizeAiContextTokenDistribution(usage);
14636
- const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
14637
- $("#ai-context-popover-description").textContent = usage
14638
- ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
14639
- : "选择可用模型后显示当前上下文用量";
14681
+ $("#ai-context-popover-description").textContent = formatAiContextPopoverDescription(usage);
14640
14682
  host.replaceChildren(...distribution.items.map((item) => {
14641
14683
  const row = document.createElement("div");
14642
14684
  row.className = "ai-context-distribution-row";
@@ -14678,13 +14720,11 @@ function setAiContextMeter(usage, allowShrink = true) {
14678
14720
  : mergeAiContextUsage(latestAiContextUsage, usage, false);
14679
14721
  latestAiContextUsage = displayUsage;
14680
14722
  const meter = $("#ai-context-meter");
14681
- const value = meter.querySelector("b");
14682
14723
  const distribution = renderAiContextDistribution(displayUsage);
14683
14724
  if (!displayUsage) {
14684
14725
  meter.classList.add("is-empty");
14685
14726
  meter.classList.remove("is-warning", "is-danger");
14686
14727
  meter.style.setProperty("--context-usage", "0");
14687
- value.textContent = "—";
14688
14728
  const tooltip = formatAiContextUsageTooltip(null);
14689
14729
  meter.dataset.tooltip = tooltip;
14690
14730
  meter.setAttribute("aria-label", tooltip);
@@ -14697,7 +14737,6 @@ function setAiContextMeter(usage, allowShrink = true) {
14697
14737
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
14698
14738
  meter.classList.toggle("is-danger", percent >= 90);
14699
14739
  meter.style.setProperty("--context-usage", String(percent));
14700
- value.textContent = formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow);
14701
14740
  const tooltip = formatAiContextUsageTooltip(displayUsage);
14702
14741
  meter.dataset.tooltip = tooltip;
14703
14742
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
@@ -18494,11 +18533,15 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18494
18533
  };
18495
18534
  const typewriter = createStreamTypewriter({
18496
18535
  speedController: streamSpeedController,
18536
+ shouldAnimate: () => aiStreamTargetVisible(feed),
18497
18537
  onRender: (text, progress) => {
18498
18538
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
18499
- content.innerHTML = renderMarkdown(text);
18500
- renderAiStreamingCharacterProgress(meta, progress.visibleCharacters);
18501
- scrollAiFeedToBottom(feed);
18539
+ aiStreamRenders.enqueue(content, () => {
18540
+ if (!aiRequestTargetsCurrentState(requestHolder.snapshot) && message.classList.contains("is-streaming")) return;
18541
+ updateAiMarkdown(content, text);
18542
+ if (message.classList.contains("is-streaming")) renderAiStreamingCharacterProgress(meta, progress.visibleCharacters);
18543
+ scrollAiFeedToBottom(feed);
18544
+ });
18502
18545
  }
18503
18546
  });
18504
18547
  let streamedText = "";
@@ -18522,7 +18565,10 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18522
18565
  const processStepVisibleContents = new Map();
18523
18566
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
18524
18567
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18525
- renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
18568
+ aiStreamRenders.enqueue(message, () => {
18569
+ renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
18570
+ scrollAiFeedToBottom(feed);
18571
+ });
18526
18572
  };
18527
18573
  const processStepTypewriter = (step) => {
18528
18574
  const existing = processStepTypewriters.get(step);
@@ -18530,6 +18576,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18530
18576
  processStepVisibleContents.set(step, "");
18531
18577
  const typewriter = createStreamTypewriter({
18532
18578
  speedController: streamSpeedController,
18579
+ shouldAnimate: () => aiStreamTargetVisible(feed),
18533
18580
  onRender: (text) => {
18534
18581
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18535
18582
  processStepVisibleContents.set(step, text);
@@ -18692,7 +18739,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18692
18739
  || writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
18693
18740
  if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
18694
18741
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
18695
- setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
18742
+ setAiChatTabContextUsage(tab, attachAiContextCacheHitPercent(payload.contextUsage, payload.cacheHitPercent), announcedCompaction);
18696
18743
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
18697
18744
  assertAiRequestCurrent(requestHolder.snapshot);
18698
18745
  message.classList.remove("is-streaming");
@@ -18715,7 +18762,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18715
18762
  writingSuggestionId: writingSuggestion.id
18716
18763
  } : {})
18717
18764
  };
18718
- renderAiProcessSteps(message, processSteps, true, processDurationMs);
18765
+ renderStreamingProcessSteps(true, processDurationMs);
18719
18766
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
18720
18767
  attachAssistantCopyAction(message, streamedText);
18721
18768
  scrollAiFeedToBottom(feed);
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260827-reader-prefetch-v1"></script>
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
- <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=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=continuation-guard-failure-details-v1&feature=entity-editor-back-icon-v1&feature=toast-stack-v1">
12
+ <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=ai-composer-compact-controls-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=annotation-line-counts-v1&feature=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=continuation-guard-failure-details-v1&feature=entity-editor-back-icon-v1&feature=toast-stack-v1">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -440,7 +440,7 @@
440
440
  <div id="ai-prompt" class="ai-prompt" contenteditable="true" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-controls="ai-mention-menu" aria-haspopup="listbox" aria-expanded="false" aria-keyshortcuts="Enter" title="Enter 发送,Shift+Enter 换行" data-placeholder="告诉 AI 你想讨论或修改什么……"></div>
441
441
  <div id="ai-mention-menu" class="ai-mention-menu hidden" role="listbox" aria-label="引用角色、设定、章节或上下文能力"></div>
442
442
  <div class="prompt-composer-actions">
443
- <button id="ai-context-meter" class="ai-context-meter is-empty" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="ai-context-popover" aria-live="polite" aria-label="当前上下文用量"><b>—</b></button>
443
+ <button id="ai-context-meter" class="ai-context-meter is-empty" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="ai-context-popover" aria-live="polite" aria-label="当前上下文用量"></button>
444
444
  <section id="ai-context-popover" class="ai-context-popover hidden" role="dialog" aria-labelledby="ai-context-popover-title" aria-describedby="ai-context-popover-description">
445
445
  <header class="ai-context-popover-header"><div><strong id="ai-context-popover-title">Token 分布</strong><small id="ai-context-popover-description">按当前模型上下文窗口计算</small></div><button id="ai-context-popover-close" class="ai-context-popover-close" type="button" aria-label="关闭 Token 分布">×</button></header>
446
446
  <div id="ai-context-distribution" class="ai-context-distribution" role="list" aria-label="当前 Token 分布"></div>
@@ -522,6 +522,7 @@
522
522
  <button type="button" role="menuitemradio" data-chapter-type="设定">设定</button>
523
523
  <button type="button" role="menuitemradio" data-chapter-type="作者的话">作者的话</button>
524
524
  <button type="button" role="menuitemradio" data-chapter-type="其他">其他</button>
525
+ <div id="chapter-type-ai-reference-separator" class="line-citation-menu-separator hidden" role="separator" aria-orientation="horizontal"></div>
525
526
  <button type="button" role="menuitem" data-add-chapter-ai-reference>添加到助手引用</button>
526
527
  <button class="danger-button" type="button" role="menuitem" data-delete-chapter>删除章节</button>
527
528
  </div>
@@ -1426,6 +1427,7 @@
1426
1427
  </dialog>
1427
1428
 
1428
1429
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1429
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-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=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&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-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=continuation-guard-failure-details-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=ai-suggestion-editor-boundary-v1&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v2&feature=ai-error-origin-v1"></script>
1430
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-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=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&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-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=continuation-guard-failure-details-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=ai-suggestion-editor-boundary-v1&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v3&feature=ai-error-origin-v1&feature=ai-stream-render-performance-v2"></script>
1431
+
1430
1432
  </body>
1431
1433
  </html>
@@ -142,8 +142,20 @@ function renderMarkdownTable(headers, alignments, rows) {
142
142
  }
143
143
 
144
144
  export function renderMarkdown(value) {
145
+ return renderMarkdownParts(value).map((part) => part.html).join("");
146
+ }
147
+
148
+ export function renderMarkdownParts(value) {
145
149
  const lines = String(value ?? "").replace(/\r\n?/gu, "\n").split("\n");
146
150
  const output = [];
151
+ const offsets = [];
152
+ let offset = 0;
153
+ for (const line of lines) {
154
+ offsets.push(offset);
155
+ offset += line.length + 1;
156
+ }
157
+ let lineIndex = 0;
158
+ const emit = (html, endLine = lineIndex) => output.push({ html, end: offsets[endLine] ?? offset - 1 });
147
159
  let paragraph = [];
148
160
  let list = null;
149
161
  let quote = null;
@@ -151,12 +163,12 @@ export function renderMarkdown(value) {
151
163
 
152
164
  const flushParagraph = () => {
153
165
  if (!paragraph.length) return;
154
- output.push(`<p>${paragraph.map(renderInlineMarkdown).join("<br>")}</p>`);
166
+ emit(`<p>${paragraph.map(renderInlineMarkdown).join("<br>")}</p>`);
155
167
  paragraph = [];
156
168
  };
157
169
  const flushList = () => {
158
170
  if (!list) return;
159
- output.push(`<${list.tag}>${list.items.map((item) => `<li class="markdown-depth-${item.depth}">${renderInlineMarkdown(item.text)}</li>`).join("")}</${list.tag}>`);
171
+ emit(`<${list.tag}>${list.items.map((item) => `<li class="markdown-depth-${item.depth}">${renderInlineMarkdown(item.text)}</li>`).join("")}</${list.tag}>`);
160
172
  list = null;
161
173
  };
162
174
  const flushQuote = () => {
@@ -166,7 +178,7 @@ export function renderMarkdown(value) {
166
178
  const html = content.split(/\n\s*\n/gu)
167
179
  .map((part) => part.split("\n").map(renderInlineMarkdown).join("<br>"))
168
180
  .join("<br><br>");
169
- output.push(`<blockquote>${html}</blockquote>`);
181
+ emit(`<blockquote>${html}</blockquote>`);
170
182
  }
171
183
  quote = null;
172
184
  };
@@ -176,11 +188,11 @@ export function renderMarkdown(value) {
176
188
  flushQuote();
177
189
  };
178
190
 
179
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
191
+ for (lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
180
192
  const line = lines[lineIndex];
181
193
  if (codeFence) {
182
194
  if (/^\s*```/u.test(line)) {
183
- output.push(`<pre><code${codeFence.language ? ` class="language-${codeFence.language}"` : ""}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`);
195
+ emit(`<pre><code${codeFence.language ? ` class="language-${codeFence.language}"` : ""}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`, lineIndex + 1);
184
196
  codeFence = null;
185
197
  } else {
186
198
  codeFence.lines.push(line);
@@ -230,12 +242,12 @@ export function renderMarkdown(value) {
230
242
  if (heading) {
231
243
  flushBlocks();
232
244
  const level = heading[1].length;
233
- output.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
245
+ emit(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`, lineIndex + 1);
234
246
  continue;
235
247
  }
236
248
  if (/^\s*(---+|___+|\*\*\*+)\s*$/u.test(line)) {
237
249
  flushBlocks();
238
- output.push("<hr>");
250
+ emit("<hr>", lineIndex + 1);
239
251
  continue;
240
252
  }
241
253
  const tableHeaders = splitMarkdownTableRow(line);
@@ -251,7 +263,7 @@ export function renderMarkdown(value) {
251
263
  lineIndex += 1;
252
264
  }
253
265
  lineIndex -= 1;
254
- output.push(renderMarkdownTable(tableHeaders, alignments, rows));
266
+ emit(renderMarkdownTable(tableHeaders, alignments, rows), lineIndex + 1);
255
267
  continue;
256
268
  }
257
269
  const listItem = line.match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/u);
@@ -266,7 +278,7 @@ export function renderMarkdown(value) {
266
278
  if (list) flushList();
267
279
  paragraph.push(line);
268
280
  }
269
- if (codeFence) output.push(`<pre><code${codeFence.language ? ` class="language-${codeFence.language}"` : ""}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`);
281
+ if (codeFence) emit(`<pre><code${codeFence.language ? ` class="language-${codeFence.language}"` : ""}>${escapeHtml(codeFence.lines.join("\n"))}</code></pre>`);
270
282
  flushBlocks();
271
- return output.join("");
283
+ return output;
272
284
  }