@musnows/scriverse 1.0.6 → 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,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
 
@@ -5393,6 +5437,26 @@ function addSelectedLinesAsCitation() {
5393
5437
  toast(`已引用《${citation.chapterTitle}》第 ${citation.startLine}${citation.startLine === citation.endLine ? "" : `-${citation.endLine}`} 行`);
5394
5438
  }
5395
5439
 
5440
+ function addChapterAsAiReference(chapterId) {
5441
+ if (!state.work || !canWritePermissionModule(state.work, "ai-chat")) return toast("当前账户没有创作助手编辑权限", "error");
5442
+ const volume = state.work.volumes.find((item) => item.chapters.some((chapter) => chapter.id === chapterId));
5443
+ const chapter = volume?.chapters.find((item) => item.id === chapterId);
5444
+ if (!volume || !chapter) return toast("章节不存在或已被删除", "error");
5445
+ const reference = { kind: "chapter", id: String(chapter.id), name: `${volume.title} / ${chapter.title}` };
5446
+ if (state.aiReferences.some((item) => aiReferenceKey(item) === aiReferenceKey(reference))) {
5447
+ ensureAiPanelExpanded();
5448
+ renderAiReferences();
5449
+ return toast(`《${chapter.title}》已在助手引用中`);
5450
+ }
5451
+ const chapterReferenceCount = state.aiReferences.filter((item) => item.kind === "chapter").length;
5452
+ if (chapterReferenceCount >= 20) return toast("一次最多添加 20 个章节助手引用", "error");
5453
+ state.aiReferences.push(reference);
5454
+ ensureAiPanelExpanded();
5455
+ renderAiReferences();
5456
+ persistActiveAiChatTab();
5457
+ toast(`已将《${chapter.title}》添加到助手引用`);
5458
+ }
5459
+
5396
5460
  async function createSelectedLineAnnotation(kind) {
5397
5461
  const permissionModule = kind === "todo" ? "todos" : "comments";
5398
5462
  if (!state.chapter || !chapterLineSelection || !canWritePermissionModule(state.work, permissionModule)) return;
@@ -5909,6 +5973,7 @@ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
5909
5973
  const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
5910
5974
  error.code = typeof source.code === "string" ? source.code : undefined;
5911
5975
  error.status = Number.isInteger(source.status) ? source.status : fallbackStatus;
5976
+ error.failureOrigin = source.failureOrigin === "platform" || source.failureOrigin === "provider" ? source.failureOrigin : undefined;
5912
5977
  error.details = source.details;
5913
5978
  error.failure = typeof source.failure === "string" ? source.failure : undefined;
5914
5979
  error.callId = typeof source.callId === "string" ? source.callId : undefined;
@@ -5946,21 +6011,31 @@ function formatAiFailureMessage(error) {
5946
6011
  const providerName = typeof error?.providerName === "string" ? error.providerName : typeof details.providerName === "string" ? details.providerName : "";
5947
6012
  const providerId = typeof error?.providerId === "string" ? error.providerId : typeof details.providerId === "string" ? details.providerId : "";
5948
6013
  const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
6014
+ const failureOrigin = aiFailureOrigin(error, details);
6015
+ const providerLabel = providerName || providerId;
5949
6016
  if (code) lines.push(`错误码:${code}`);
5950
- if (status) lines.push(`服务端状态:HTTP ${status}`);
6017
+ lines.push(`错误来源:${failureOrigin === "provider" ? `LLM 供应商${providerLabel ? `(${providerLabel})` : ""}` : "叙界平台"}`);
6018
+ if (status) lines.push(`叙界响应状态:HTTP ${status}`);
5951
6019
  if (details.platformLimited === true) {
5952
6020
  const limitSource = details.limitScope === "provider"
5953
6021
  ? `配置的供应商额度${providerName ? `(${providerName})` : ""}`
5954
6022
  : "单个小说额度";
5955
6023
  lines.push(`叙界平台限制来源:${limitSource}`);
5956
6024
  }
5957
- if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
5958
- if (modelId) lines.push(`模型 ID:${modelId}`);
6025
+ if (failureOrigin === "platform" && providerLabel) lines.push(`请求模型供应商:${providerLabel}`);
6026
+ if (modelId) lines.push(`请求模型 ID:${modelId}`);
5959
6027
  if (callId) lines.push(`调用 ID:${callId}`);
5960
- if (failure && failure !== message) lines.push(`详细原因:${failure}`);
6028
+ if (failure && failure !== message) lines.push(`${failureOrigin === "provider" ? "LLM 供应商详情" : "平台详情"}:${failure}`);
5961
6029
  return lines.join("\n");
5962
6030
  }
5963
6031
 
6032
+ function aiFailureOrigin(error, details) {
6033
+ if (error?.failureOrigin === "platform" || error?.failureOrigin === "provider") return error.failureOrigin;
6034
+ if (details.failureOrigin === "platform" || details.failureOrigin === "provider") return details.failureOrigin;
6035
+ if (details.platformLimited === true || error?.code !== "AI_CALL_FAILED") return "platform";
6036
+ return "provider";
6037
+ }
6038
+
5964
6039
  function aiFailureMessageMetadata(error) {
5965
6040
  const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
5966
6041
  return {
@@ -6074,6 +6149,12 @@ function invalidateModuleRequestsAfterMutation(path, method) {
6074
6149
  function applyProductHealthMetadata(health) {
6075
6150
  const version = String(health?.version ?? "").trim();
6076
6151
  const versionLabel = String(health?.versionLabel ?? "").trim();
6152
+ const iconPath = health?.development === true ? "/icon-dev.svg?v=20260910" : "/icon.svg?v=20260712";
6153
+ document.querySelectorAll(".brand-mark").forEach((element) => {
6154
+ element.src = iconPath;
6155
+ });
6156
+ const favicon = document.querySelector('link[rel="icon"]');
6157
+ if (favicon) favicon.href = iconPath;
6077
6158
  document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
6078
6159
  element.textContent = versionLabel || (version ? `v${version}` : "v—");
6079
6160
  });
@@ -8833,10 +8914,17 @@ function renderTree() {
8833
8914
  }
8834
8915
  });
8835
8916
  button.addEventListener("contextmenu", (event) => {
8836
- if (!canEditProse()) return;
8917
+ if (!canEditProse() && !canWritePermissionModule(state.work, "ai-chat")) return;
8837
8918
  event.preventDefault();
8838
8919
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
8839
8920
  });
8921
+ button.addEventListener("keydown", (event) => {
8922
+ if (!canEditProse() && !canWritePermissionModule(state.work, "ai-chat")) return;
8923
+ if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
8924
+ event.preventDefault();
8925
+ const rect = button.getBoundingClientRect();
8926
+ openChapterTypeMenu(button.dataset.chapterId, rect.left, rect.bottom);
8927
+ });
8840
8928
  if (proseEditable) {
8841
8929
  button.addEventListener("dragstart", (event) => {
8842
8930
  event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
@@ -9059,7 +9147,12 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
9059
9147
  if (!chapter) return;
9060
9148
  state.contextChapterId = chapterId;
9061
9149
  const menu = $("#chapter-type-menu");
9062
- menu.querySelector("strong").textContent = `标记“${chapter.title}”`;
9150
+ const canManageChapter = canEditProse();
9151
+ const canAddAiReference = canWritePermissionModule(state.work, "ai-chat");
9152
+ menu.querySelector("strong").textContent = `操作“${chapter.title}”`;
9153
+ menu.querySelectorAll("[data-chapter-type], [data-delete-chapter]").forEach((button) => button.classList.toggle("hidden", !canManageChapter));
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));
9063
9156
  menu.querySelectorAll("[data-chapter-type]").forEach((button) => {
9064
9157
  button.classList.toggle("active", button.dataset.chapterType === (chapter.chapterType || "正文"));
9065
9158
  button.setAttribute("aria-checked", String(button.classList.contains("active")));
@@ -13973,7 +14066,7 @@ async function renderBookAiSettings() {
13973
14066
  const remoteMcpStatusText = remoteMcpServers.length > 0
13974
14067
  ? `已验证 ${remoteMcpServers.length} 个远程 MCP Server,共发现 ${remoteMcpToolCount} 个工具。`
13975
14068
  : "尚未配置远程 MCP Server。";
13976
- const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
14069
+ const maximumAgentToolCallLimit = Math.max(10, Number(settings.agentToolCallLimitMaximum) || 300);
13977
14070
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
13978
14071
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
13979
14072
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
@@ -13995,7 +14088,7 @@ async function renderBookAiSettings() {
13995
14088
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
13996
14089
  title: "本书 Token 用量",
13997
14090
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
13998
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
14091
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 10–${maximumAgentToolCallLimit}(默认 20);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="10" max="${maximumAgentToolCallLimit}" value="${esc(String(settings.agentToolCallLimit ?? 20))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
13999
14092
  const workSystemPromptSection = host.querySelector("#work-system-prompt")?.closest(".config-section");
14000
14093
  workSystemPromptSection?.insertAdjacentHTML("afterend", `<section class="config-section remote-mcp-settings"><div class="config-section-header"><div><h2>远程 MCP 工具</h2><p>填写标准的 <code>mcpServers</code> JSON 配置。保存前会逐个检查 JSON、远程传输、安全地址、MCP 握手与工具列表;任一 Server 失败时都不会覆盖当前配置。</p></div></div><label class="field-label remote-mcp-config-field"><span>mcpServers JSON</span><textarea id="remote-mcp-config" rows="12" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="remote-mcp-config-help remote-mcp-status" placeholder='{"mcpServers":{"example":{"url":"https://example.com/mcp"}}}'>${esc(remoteMcpConfigText)}</textarea></label><small id="remote-mcp-config-help" class="remote-mcp-config-help">仅支持远程 MCP 工具(SSE / Streamable HTTP),不支持会执行本地命令的 stdio 配置。敏感 Header 会加密保存,页面中的 ${esc("********")} 掩码再次保存时会保留原值。</small><p id="remote-mcp-status" class="remote-mcp-status" role="status" aria-live="polite">${esc(remoteMcpStatusText)}</p><div class="card-actions"><button id="save-remote-mcp-config" class="ghost-button config-save-button" type="button">测试并保存 MCP 配置</button></div></section>`);
14001
14094
  const semanticModelOptions = (kind, selectedId) => semanticModels
@@ -14035,10 +14128,6 @@ async function renderBookAiSettings() {
14035
14128
  };
14036
14129
  configureTokenQuotaInput(host.querySelector("#daily-token-quota"), "daily-token-quota-warning", 10_000);
14037
14130
  configureTokenQuotaInput(host.querySelector("#monthly-token-quota"), "monthly-token-quota-warning", 1_000_000);
14038
- const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
14039
- agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
14040
- const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
14041
- if (agentToolCallDescription?.firstChild) agentToolCallDescription.firstChild.nodeValue = agentToolCallDescription.firstChild.nodeValue.replace("5–48", `5–${maximumAgentToolCallLimit}`);
14042
14131
  host.querySelectorAll(".config-section").forEach((section) => {
14043
14132
  if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
14044
14133
  });
@@ -14398,9 +14487,9 @@ async function renderBookAiSettings() {
14398
14487
  const button = $("#save-agent-tool-call-limit");
14399
14488
  const input = $("#agent-tool-call-limit");
14400
14489
  const value = Number(input.value);
14401
- const maximum = Number(input.max) || 80;
14402
- if (!Number.isInteger(value) || value < 5) {
14403
- toast(`Agent 工具调用上限必须是 5 到 ${maximum} 之间的整数`, "error");
14490
+ const maximum = Number(input.max) || 300;
14491
+ if (!Number.isInteger(value) || value < 10) {
14492
+ toast(`Agent 工具调用上限必须是 10 到 ${maximum} 之间的整数`, "error");
14404
14493
  input.focus();
14405
14494
  return;
14406
14495
  }
@@ -14589,10 +14678,7 @@ function renderAiContextDistribution(usage) {
14589
14678
  const popover = $("#ai-context-popover");
14590
14679
  const host = $("#ai-context-distribution");
14591
14680
  const distribution = normalizeAiContextTokenDistribution(usage);
14592
- const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
14593
- $("#ai-context-popover-description").textContent = usage
14594
- ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
14595
- : "选择可用模型后显示当前上下文用量";
14681
+ $("#ai-context-popover-description").textContent = formatAiContextPopoverDescription(usage);
14596
14682
  host.replaceChildren(...distribution.items.map((item) => {
14597
14683
  const row = document.createElement("div");
14598
14684
  row.className = "ai-context-distribution-row";
@@ -14634,13 +14720,11 @@ function setAiContextMeter(usage, allowShrink = true) {
14634
14720
  : mergeAiContextUsage(latestAiContextUsage, usage, false);
14635
14721
  latestAiContextUsage = displayUsage;
14636
14722
  const meter = $("#ai-context-meter");
14637
- const value = meter.querySelector("b");
14638
14723
  const distribution = renderAiContextDistribution(displayUsage);
14639
14724
  if (!displayUsage) {
14640
14725
  meter.classList.add("is-empty");
14641
14726
  meter.classList.remove("is-warning", "is-danger");
14642
14727
  meter.style.setProperty("--context-usage", "0");
14643
- value.textContent = "—";
14644
14728
  const tooltip = formatAiContextUsageTooltip(null);
14645
14729
  meter.dataset.tooltip = tooltip;
14646
14730
  meter.setAttribute("aria-label", tooltip);
@@ -14653,7 +14737,6 @@ function setAiContextMeter(usage, allowShrink = true) {
14653
14737
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
14654
14738
  meter.classList.toggle("is-danger", percent >= 90);
14655
14739
  meter.style.setProperty("--context-usage", String(percent));
14656
- value.textContent = formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow);
14657
14740
  const tooltip = formatAiContextUsageTooltip(displayUsage);
14658
14741
  meter.dataset.tooltip = tooltip;
14659
14742
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
@@ -18450,11 +18533,15 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18450
18533
  };
18451
18534
  const typewriter = createStreamTypewriter({
18452
18535
  speedController: streamSpeedController,
18536
+ shouldAnimate: () => aiStreamTargetVisible(feed),
18453
18537
  onRender: (text, progress) => {
18454
18538
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
18455
- content.innerHTML = renderMarkdown(text);
18456
- renderAiStreamingCharacterProgress(meta, progress.visibleCharacters);
18457
- 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
+ });
18458
18545
  }
18459
18546
  });
18460
18547
  let streamedText = "";
@@ -18478,7 +18565,10 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18478
18565
  const processStepVisibleContents = new Map();
18479
18566
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
18480
18567
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18481
- renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
18568
+ aiStreamRenders.enqueue(message, () => {
18569
+ renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
18570
+ scrollAiFeedToBottom(feed);
18571
+ });
18482
18572
  };
18483
18573
  const processStepTypewriter = (step) => {
18484
18574
  const existing = processStepTypewriters.get(step);
@@ -18486,6 +18576,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18486
18576
  processStepVisibleContents.set(step, "");
18487
18577
  const typewriter = createStreamTypewriter({
18488
18578
  speedController: streamSpeedController,
18579
+ shouldAnimate: () => aiStreamTargetVisible(feed),
18489
18580
  onRender: (text) => {
18490
18581
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
18491
18582
  processStepVisibleContents.set(step, text);
@@ -18648,7 +18739,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18648
18739
  || writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
18649
18740
  if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
18650
18741
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
18651
- setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
18742
+ setAiChatTabContextUsage(tab, attachAiContextCacheHitPercent(payload.contextUsage, payload.cacheHitPercent), announcedCompaction);
18652
18743
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
18653
18744
  assertAiRequestCurrent(requestHolder.snapshot);
18654
18745
  message.classList.remove("is-streaming");
@@ -18671,7 +18762,7 @@ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null
18671
18762
  writingSuggestionId: writingSuggestion.id
18672
18763
  } : {})
18673
18764
  };
18674
- renderAiProcessSteps(message, processSteps, true, processDurationMs);
18765
+ renderStreamingProcessSteps(true, processDurationMs);
18675
18766
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
18676
18767
  attachAssistantCopyAction(message, streamedText);
18677
18768
  scrollAiFeedToBottom(feed);
@@ -21051,6 +21142,13 @@ $("#cover-file").addEventListener("change", async (event) => {
21051
21142
  }
21052
21143
  });
21053
21144
  $("#chapter-type-menu").addEventListener("click", async (event) => {
21145
+ const aiReferenceButton = event.target.closest("[data-add-chapter-ai-reference]");
21146
+ if (aiReferenceButton) {
21147
+ const chapterId = state.contextChapterId;
21148
+ closeChapterTypeMenu();
21149
+ if (chapterId) addChapterAsAiReference(chapterId);
21150
+ return;
21151
+ }
21054
21152
  const deleteButton = event.target.closest("[data-delete-chapter]");
21055
21153
  if (deleteButton) {
21056
21154
  const chapterId = state.contextChapterId;
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title description">
2
+ <title id="title">叙界</title>
3
+ <desc id="description">一本展开的书与一颗星,代表小说创作与灵感</desc>
4
+ <rect width="64" height="64" rx="14" fill="#bb911a"/>
5
+ <path d="M10 16.5c8.7-1.6 15.7.4 21 6v29c-5.4-4.8-12.4-6.5-21-5.1V16.5Z" fill="#fffaf0"/>
6
+ <path d="M54 16.5c-8.7-1.6-15.7.4-21 6v29c5.4-4.8 12.4-6.5 21-5.1V16.5Z" fill="#fffaf0"/>
7
+ <path d="M31 22.5h2V52h-2z" fill="#c79b32"/>
8
+ <path d="m48 9.5 1.7 4.1 4.3 1.7-4.3 1.7-1.7 4.1-1.7-4.1-4.3-1.7 4.3-1.7L48 9.5Z" fill="#ffe6a0"/>
9
+ <path d="M15.5 25.5c4.2-.3 7.8.6 10.8 2.7M15.5 32c4.2-.3 7.8.6 10.8 2.7M48.5 25.5c-4.2-.3-7.8.6-10.8 2.7M48.5 32c-4.2-.3-7.8.6-10.8 2.7" fill="none" stroke="#b69048" stroke-linecap="round" stroke-width="2"/>
10
+ </svg>