@musnows/scriverse 0.7.12 → 0.7.13

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.
@@ -19,7 +19,7 @@ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelCo
19
19
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
20
20
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
21
21
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
22
- import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
22
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260815-ai-stream-typewriter-v4";
23
23
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
24
24
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
25
25
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -551,7 +551,15 @@ function cancelActiveAiRequest(reason) {
551
551
  return cancelled;
552
552
  }
553
553
 
554
- function beginAiConversationNavigation(reason) {
554
+ function beginAiConversationNavigation(reason, action = "切换会话") {
555
+ if (aiRequestManager.hasActive()) {
556
+ const isNewConversation = action === "新建会话";
557
+ toast(
558
+ `当前 turn 尚未结束,${action}会中断生成;${isNewConversation ? "\n" : ""}已收到的内容会保留在历史记录中`,
559
+ "warning",
560
+ isNewConversation ? "ai-new-conversation-toast" : ""
561
+ );
562
+ }
555
563
  aiConversationNavigationGeneration += 1;
556
564
  aiConversationNavigationPending = aiConversationNavigationGeneration;
557
565
  const navigation = Object.freeze({
@@ -574,7 +582,10 @@ function finishAiConversationNavigation(navigation) {
574
582
  return true;
575
583
  }
576
584
 
577
- function invalidateAiConversationNavigation(reason) {
585
+ function invalidateAiConversationNavigation(reason, action = "切换作品") {
586
+ if (aiRequestManager.hasActive()) {
587
+ toast(`当前 turn 尚未结束,${action}会中断生成;已收到的内容会保留在历史记录中`, "warning");
588
+ }
578
589
  aiConversationNavigationGeneration += 1;
579
590
  aiConversationNavigationPending = null;
580
591
  cancelActiveAiRequest(reason);
@@ -1871,7 +1882,7 @@ function renderMessageCardActions(message) {
1871
1882
  });
1872
1883
  actions.append(copy);
1873
1884
  }
1874
- if (message.dataset.messageId) {
1885
+ if (message.dataset.messageId && message.classList.contains("assistant-message")) {
1875
1886
  const fork = document.createElement("button");
1876
1887
  fork.type = "button";
1877
1888
  fork.className = "message-fork-button";
@@ -2001,6 +2012,29 @@ function formatAiToolCallTime(value) {
2001
2012
  }).format(date);
2002
2013
  }
2003
2014
 
2015
+ function setAiToolCallCopyButtonState(button, copied) {
2016
+ const label = button.dataset.copyLabel ?? "代码块";
2017
+ button.dataset.copyState = copied ? "copied" : "idle";
2018
+ button.classList.toggle("is-copied", copied);
2019
+ button.setAttribute("aria-label", copied ? `${label}已复制` : `复制${label}`);
2020
+ button.title = copied ? "已复制" : `复制${label}`;
2021
+ }
2022
+
2023
+ async function copyAiToolCallCode(button) {
2024
+ const targetId = button.dataset.copyTarget;
2025
+ const target = targetId ? document.getElementById(targetId) : null;
2026
+ if (!target) return;
2027
+ try {
2028
+ await copyAiRawMarkdown(target.textContent);
2029
+ setAiToolCallCopyButtonState(button, true);
2030
+ window.setTimeout(() => {
2031
+ if (button.isConnected && button.dataset.copyState === "copied") setAiToolCallCopyButtonState(button, false);
2032
+ }, 1200);
2033
+ } catch (error) {
2034
+ toast(error.message, "error");
2035
+ }
2036
+ }
2037
+
2004
2038
  function openAiToolCallDetail(toolCall) {
2005
2039
  const name = String(toolCall?.name ?? "unknown");
2006
2040
  const status = toolCall?.status === "failed" ? "调用失败" : "调用成功";
@@ -2017,6 +2051,7 @@ function openAiToolCallDetail(toolCall) {
2017
2051
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
2018
2052
  $("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
2019
2053
  $("#ai-tool-call-result").textContent = resultDetails.text;
2054
+ document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => setAiToolCallCopyButtonState(button, false));
2020
2055
  $("#ai-tool-call-dialog").showModal();
2021
2056
  }
2022
2057
 
@@ -2418,7 +2453,7 @@ async function ensureAiConversationsLoaded() {
2418
2453
 
2419
2454
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2420
2455
  if (!state.work) return null;
2421
- const navigation = beginAiConversationNavigation("已切换 AI 对话");
2456
+ const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
2422
2457
  try {
2423
2458
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
2424
2459
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
@@ -2488,7 +2523,7 @@ function applyNewAiConversation(conversation) {
2488
2523
 
2489
2524
  async function createNewAiConversation(taskType = "chat") {
2490
2525
  if (!state.work) return null;
2491
- const navigation = beginAiConversationNavigation("已新建 AI 对话");
2526
+ const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
2492
2527
  try {
2493
2528
  const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2494
2529
  if (!isAiConversationNavigationCurrent(navigation)) return null;
@@ -3898,11 +3933,11 @@ function dismissChapterInsightToast() {
3898
3933
  }
3899
3934
  }
3900
3935
 
3901
- function toast(message, type = "info") {
3936
+ function toast(message, type = "info", extraClass = "") {
3902
3937
  if (systemRestartDetected || (!state.user && document.documentElement.classList.contains("login-route"))) return;
3903
3938
  const region = $("#toast-region");
3904
3939
  const element = document.createElement("div");
3905
- element.className = `toast ${type}`;
3940
+ element.className = `toast ${type}${extraClass ? ` ${extraClass}` : ""}`;
3906
3941
  element.setAttribute("role", type === "error" ? "alert" : "status");
3907
3942
  element.setAttribute("aria-atomic", "true");
3908
3943
  element.textContent = message;
@@ -9320,7 +9355,8 @@ function renderCharacterExtractionEditor(preview) {
9320
9355
  if (!items.length) return '<p class="character-extraction-empty">没有可应用的角色候选。</p>';
9321
9356
  return `<div class="character-extraction-editor" data-character-extraction-editor data-preview-token="${esc(preview.previewToken)}">
9322
9357
  <div class="character-extraction-policy" role="note"><strong>合并规则</strong><span>优先使用任务保存的稳定角色引用,其次使用当前标准名和别名匹配。合并只追加无冲突别名、空缺身份、种族和首次登场;已有非空字段保持不变。</span></div>
9323
- <div class="character-extraction-editor-toolbar"><span data-character-extraction-selected-count>已选择 0 项</span><button class="ghost-button" type="button" data-character-extraction-select-all>全选可处理项</button><button class="ghost-button" type="button" data-character-extraction-clear>全部跳过</button></div>
9358
+ <div class="character-extraction-editor-toolbar"><button class="primary-button character-extraction-apply-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><span data-character-extraction-selected-count>已选择 0 项</span><button class="ghost-button" type="button" data-character-extraction-select-all>全选可处理项</button><button class="ghost-button" type="button" data-character-extraction-clear>全部跳过</button></div>
9359
+ <p class="character-extraction-apply-note">确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</p>
9324
9360
  <div class="character-extraction-candidate-list">${items.map((item, index) => {
9325
9361
  const matches = Array.isArray(item.matchCandidates) ? item.matchCandidates : [];
9326
9362
  const suggestedAction = item.suggestedAction === "merge" && matches.length ? "merge" : item.suggestedAction === "skip" ? "skip" : "create";
@@ -9351,7 +9387,6 @@ function renderCharacterExtractionEditor(preview) {
9351
9387
  </article>`;
9352
9388
  }).join("")}</div>
9353
9389
  <p class="character-extraction-apply-error hidden" data-character-extraction-error role="alert"></p>
9354
- <div class="character-extraction-apply-actions"><button class="primary-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><small>确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</small></div>
9355
9390
  </div>`;
9356
9391
  }
9357
9392
 
@@ -13087,11 +13122,12 @@ function openReviewDialog() {
13087
13122
  }
13088
13123
 
13089
13124
  async function openTaskDialog() {
13090
- const volumeOptions = state.work.volumes.map((volume) => ({ id: String(volume.id), title: String(volume.title) }));
13091
13125
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
13092
13126
  id: String(chapter.id),
13093
13127
  title: String(chapter.title),
13094
- volumeTitle: String(volume.title)
13128
+ volumeId: String(volume.id),
13129
+ volumeTitle: String(volume.title),
13130
+ chapterType: String(chapter.chapterType || "正文")
13095
13131
  })));
13096
13132
  let relationshipCharacters = [];
13097
13133
  let taskModels = [];
@@ -13108,23 +13144,67 @@ async function openTaskDialog() {
13108
13144
  toast(`分析任务配置加载失败:${error.message}`, "error");
13109
13145
  return;
13110
13146
  }
13111
- const taskScopePicker = (kind, label, options, emptyLabel) => `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13112
- <span id="task-${kind}-label">${esc(label)}(可多选)</span>
13113
- <div class="task-scope-picker">
13114
- <button class="task-scope-trigger" type="button" data-task-scope-trigger="${kind}" aria-expanded="false" aria-controls="task-${kind}-bubble" aria-labelledby="task-${kind}-label task-${kind}-summary" disabled>
13115
- <span id="task-${kind}-summary">${esc(emptyLabel)}</span>
13116
- <span class="task-scope-trigger-meta"><span data-task-scope-count="${kind}">未选择</span><span class="task-scope-chevron" aria-hidden="true">⌄</span></span>
13117
- </button>
13118
- <div id="task-${kind}-bubble" class="task-scope-bubble hidden" data-task-scope-bubble="${kind}">
13119
- <label class="task-scope-search">筛选${esc(label)}<input type="search" data-task-scope-search="${kind}" placeholder="输入名称" autocomplete="off"></label>
13120
- <div class="task-scope-bubble-meta"><span>共 ${options.length} ${kind === "chapter" ? "章" : "卷"}</span><button type="button" data-task-scope-clear="${kind}" disabled>清空选择</button></div>
13121
- <div class="task-scope-options" role="group" aria-labelledby="task-${kind}-label">
13122
- ${options.map((item) => `<label class="task-scope-option" data-task-scope-search-name="${esc(`${item.title} ${item.volumeTitle ?? ""}`.toLocaleLowerCase())}"><input type="checkbox" name="${kind === "chapter" ? "chapterIds" : "volumeIds"}" value="${esc(item.id)}" data-task-scope-input="${kind}"><span><strong>${esc(item.title)}</strong>${item.volumeTitle ? `<small>${esc(item.volumeTitle)}</small>` : ""}</span></label>`).join("")}
13123
- </div>
13124
- <p class="task-scope-empty hidden" data-task-scope-empty="${kind}">没有匹配的${esc(label)}</p>
13147
+ const taskScopePicker = (options, emptyLabel) => {
13148
+ const kind = "chapter";
13149
+ const label = "章节";
13150
+ const inputName = "chapterIds";
13151
+ const renderOption = (item) => `<label class="task-scope-option" data-task-scope-search-name="${esc(`${item.title} ${item.volumeTitle ?? ""}`.toLocaleLowerCase())}">
13152
+ <input type="checkbox" name="${inputName}" value="${esc(item.id)}" data-task-scope-input="${kind}" data-task-scope-title="${esc(item.title)}" data-task-scope-subtitle="${esc(item.volumeTitle ?? "")}">
13153
+ <span class="task-scope-checkbox" aria-hidden="true"></span>
13154
+ <span class="task-scope-option-copy"><strong>${esc(item.title)}</strong>${item.volumeTitle ? `<small>${esc(item.volumeTitle)}${item.chapterType && item.chapterType !== "正文" ? ` · ${esc(item.chapterType)}` : ""}</small>` : ""}</span>
13155
+ </label>`;
13156
+ const optionMarkup = [...options.reduce((groups, item) => {
13157
+ const groupId = String(item.volumeId || "");
13158
+ const groupTitle = String(item.volumeTitle || "未分卷章节");
13159
+ const groupKey = `${groupId}:${groupTitle}`;
13160
+ const group = groups.get(groupKey)?.options ?? [];
13161
+ group.push(item);
13162
+ groups.set(groupKey, { id: groupId, title: groupTitle, options: group });
13163
+ return groups;
13164
+ }, new Map())].map(([, group]) => `<section class="task-scope-volume-group" data-task-scope-group data-task-scope-volume-id="${esc(group.id)}">
13165
+ <header>
13166
+ ${group.id ? `<label class="task-scope-volume-option">
13167
+ <input type="checkbox" data-task-scope-volume-input="${esc(group.id)}" data-task-scope-volume-title="${esc(group.title)}" aria-label="全选${esc(group.title)}章节">
13168
+ <span class="task-scope-checkbox" aria-hidden="true"></span>
13169
+ <span class="task-scope-volume-copy"><strong>${esc(group.title)}</strong><small>勾选以全选本卷章节</small></span>
13170
+ </label>` : `<strong>${esc(group.title)}</strong>`}
13171
+ <span>${group.options.length} 章</span>
13172
+ </header>
13173
+ ${group.options.map(renderOption).join("")}
13174
+ </section>`).join("");
13175
+ return `<div class="form-field task-scope-field task-scope-${kind}-field is-disabled" data-task-scope-field="${kind}" aria-disabled="true">
13176
+ <div class="task-scope-field-heading"><span id="task-${kind}-label">${esc(label)}(可多选)</span><small>从左侧选择,右侧确认已选内容</small></div>
13177
+ <div class="task-scope-picker">
13178
+ <button class="task-scope-trigger" type="button" data-task-scope-trigger="${kind}" aria-expanded="false" aria-controls="task-${kind}-bubble" aria-labelledby="task-${kind}-label task-${kind}-summary" disabled>
13179
+ <span id="task-${kind}-summary">${esc(emptyLabel)}</span>
13180
+ <span class="task-scope-trigger-meta"><span data-task-scope-count="${kind}">未选择</span><span class="task-scope-chevron" aria-hidden="true">⌄</span></span>
13181
+ </button>
13182
+ <section id="task-${kind}-bubble" class="task-scope-panel hidden" data-task-scope-bubble="${kind}" aria-hidden="true" aria-labelledby="task-${kind}-label">
13183
+ <header class="task-scope-panel-header">
13184
+ <div><strong>选择${esc(label)}</strong><small>支持搜索和按分卷浏览,右侧会实时汇总。</small></div>
13185
+ <button type="button" class="task-scope-clear" data-task-scope-clear="${kind}" disabled>清空已选</button>
13186
+ </header>
13187
+ <div class="task-scope-author-note-banner" role="note">
13188
+ <span class="task-scope-author-note-banner-icon" aria-hidden="true">i</span>
13189
+ <div><strong>范围提示</strong><span>标记为“作者的话”的章节不会纳入 AI 分析输入;即使被选中,也只会保留在作品目录中。</span></div>
13190
+ </div>
13191
+ <div class="task-scope-panel-grid">
13192
+ <section class="task-scope-available" aria-label="待选${esc(label)}">
13193
+ <header class="task-scope-panel-section-header"><strong>待选章节</strong><span data-task-scope-available-count="${kind}">${options.length} 章</span></header>
13194
+ <label class="task-scope-search"><span>筛选${esc(label)}</span><input type="search" data-task-scope-search="${kind}" placeholder="输入名称" autocomplete="off"></label>
13195
+ <div class="task-scope-options" role="group" aria-labelledby="task-${kind}-label">${optionMarkup}</div>
13196
+ <p class="task-scope-empty hidden" data-task-scope-empty="${kind}">没有匹配的${esc(label)}</p>
13197
+ </section>
13198
+ <aside class="task-scope-selected" aria-label="已选章节汇总">
13199
+ <header class="task-scope-panel-section-header"><strong>已选汇总</strong><span data-task-scope-summary-count="${kind}">0 章</span></header>
13200
+ <p class="task-scope-selected-note">创建任务时会分析这里列出的章节;标记为“作者的话”的章节除外。</p>
13201
+ <div class="task-scope-selected-list" data-task-scope-selected-list="${kind}" role="list" aria-live="polite"><p class="task-scope-selected-empty">暂未选择章节</p></div>
13202
+ </aside>
13203
+ </div>
13204
+ </section>
13125
13205
  </div>
13126
- </div>
13127
- </div>`;
13206
+ </div>`;
13207
+ };
13128
13208
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
13129
13209
  const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
13130
13210
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
@@ -13152,8 +13232,7 @@ async function openTaskDialog() {
13152
13232
  <option value="" ${availableTaskModels.some((model) => model.id === defaultModelId) ? "" : "selected"} disabled>${availableTaskModels.length ? "请选择模型" : "没有可用模型"}</option>
13153
13233
  ${availableTaskModels.map((model) => `<option value="${esc(model.id)}" ${model.id === defaultModelId ? "selected" : ""}>${esc(modelOptionLabel(model))}</option>`).join("")}
13154
13234
  </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small><p class="analysis-context-warning hidden" data-analysis-context-warning role="alert"></p></label>`;
13155
- const chapterField = taskScopePicker("chapter", "章节", chapterOptions, "选择需要分析的章节");
13156
- const volumeField = taskScopePicker("volume", "分卷", volumeOptions, "选择需要分析的分卷");
13235
+ const chapterField = taskScopePicker(chapterOptions, "选择需要分析的章节");
13157
13236
  const relationshipFields = `<div class="relationship-analysis-options hidden">
13158
13237
  ${relationshipCharacterPicker}
13159
13238
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
@@ -13191,42 +13270,33 @@ async function openTaskDialog() {
13191
13270
  const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
13192
13271
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
13193
13272
  const chapterIds = form.getAll("chapterIds").map(String).filter(Boolean);
13194
- const volumeIds = form.getAll("volumeIds").map(String).filter(Boolean);
13195
13273
  const chapterScope = {
13196
13274
  type: "chapter",
13197
13275
  ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13198
13276
  ...(chapterIds.length ? { chapterIds } : {})
13199
13277
  };
13200
- const volumeScope = {
13201
- type: "volume",
13202
- ...(volumeIds[0] ? { volumeId: volumeIds[0] } : {}),
13203
- ...(volumeIds.length ? { volumeIds } : {})
13204
- };
13205
13278
  const scope = settingsOnly
13206
13279
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13207
13280
  : scopeType === "book" || includeAllSettings
13208
13281
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13209
- : { ...(scopeType === "volume" ? volumeScope : chapterScope), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13282
+ : { ...chapterScope, ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
13210
13283
  return scope;
13211
13284
  };
13212
13285
  const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
13213
13286
  type: scope.type,
13214
13287
  chapterId: scope.chapterId ?? null,
13215
13288
  chapterIds: scope.chapterIds ?? [],
13216
- volumeId: scope.volumeId ?? null,
13217
- volumeIds: scope.volumeIds ?? [],
13218
13289
  includeAllSettings: scope.includeAllSettings === true,
13219
13290
  characterIds: scope.characterIds ?? [],
13220
13291
  preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
13221
13292
  modelId
13222
13293
  });
13223
- openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["volume", "指定分卷"], ["book", "全书"]]) + chapterField + volumeField + relationshipFields, async (form) => {
13294
+ openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
13224
13295
  const workId = state.work.id;
13225
13296
  const taskType = String(form.get("taskType"));
13226
13297
  const modelId = String(form.get("modelId"));
13227
13298
  const scope = buildRelationshipScope(form);
13228
13299
  if (scope.type === "chapter" && !scope.chapterIds?.length) return toast("请先选择至少一个章节", "error");
13229
- if (scope.type === "volume" && !scope.volumeIds?.length) return toast("请先选择至少一个分卷", "error");
13230
13300
  if (taskType === "relationship-analysis" && relationshipSourcePreview
13231
13301
  && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
13232
13302
  scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
@@ -13250,6 +13320,7 @@ async function openTaskDialog() {
13250
13320
  toast("分析任务已创建,已进入任务队列");
13251
13321
  void refreshAnalysisTaskViewsAfterCreate(workId);
13252
13322
  }, "AI 分析", {
13323
+ large: true,
13253
13324
  submitLabel: "创建任务",
13254
13325
  pendingLabel: "创建中…",
13255
13326
  pendingMessage: "正在创建分析任务,请稍候",
@@ -13259,40 +13330,43 @@ async function openTaskDialog() {
13259
13330
  const taskModelSelect = $("#dialog-fields").querySelector('select[name="modelId"]');
13260
13331
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
13261
13332
  const scopeFields = {
13262
- chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]'),
13263
- volume: $("#dialog-fields").querySelector('[data-task-scope-field="volume"]')
13333
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]')
13264
13334
  };
13265
13335
  const scopeTriggers = {
13266
- chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]'),
13267
- volume: $("#dialog-fields").querySelector('[data-task-scope-trigger="volume"]')
13336
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]')
13268
13337
  };
13269
13338
  const scopeBubbles = {
13270
- chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]'),
13271
- volume: $("#dialog-fields").querySelector('[data-task-scope-bubble="volume"]')
13339
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]')
13272
13340
  };
13273
13341
  const scopeSearches = {
13274
- chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]'),
13275
- volume: $("#dialog-fields").querySelector('[data-task-scope-search="volume"]')
13342
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]')
13276
13343
  };
13277
13344
  const scopeInputs = {
13278
- chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')],
13279
- volume: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="volume"]')]
13345
+ chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')]
13280
13346
  };
13347
+ const scopeVolumeInputs = [
13348
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
13349
+ ];
13281
13350
  const scopeSummaries = {
13282
- chapter: $("#dialog-fields").querySelector("#task-chapter-summary"),
13283
- volume: $("#dialog-fields").querySelector("#task-volume-summary")
13351
+ chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
13284
13352
  };
13285
13353
  const scopeCounts = {
13286
- chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]'),
13287
- volume: $("#dialog-fields").querySelector('[data-task-scope-count="volume"]')
13354
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]')
13288
13355
  };
13289
13356
  const scopeClearButtons = {
13290
- chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]'),
13291
- volume: $("#dialog-fields").querySelector('[data-task-scope-clear="volume"]')
13357
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]')
13292
13358
  };
13293
13359
  const scopeEmptyMessages = {
13294
- chapter: $("#dialog-fields").querySelector('[data-task-scope-empty="chapter"]'),
13295
- volume: $("#dialog-fields").querySelector('[data-task-scope-empty="volume"]')
13360
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-empty="chapter"]')
13361
+ };
13362
+ const scopeSelectedLists = {
13363
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-selected-list="chapter"]')
13364
+ };
13365
+ const scopeSummaryCounts = {
13366
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-summary-count="chapter"]')
13367
+ };
13368
+ const scopeAvailableCounts = {
13369
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-available-count="chapter"]')
13296
13370
  };
13297
13371
  const description = $("#analysis-type-description");
13298
13372
  const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
@@ -13325,18 +13399,38 @@ async function openTaskDialog() {
13325
13399
  const setTaskScopeBubbleOpen = (kind, open) => {
13326
13400
  scopeBubbles[kind].classList.toggle("hidden", !open);
13327
13401
  scopeTriggers[kind].setAttribute("aria-expanded", String(open));
13328
- if (open) scopeSearches[kind].focus();
13402
+ scopeBubbles[kind].setAttribute("aria-hidden", String(!open));
13403
+ if (open) {
13404
+ scopeBubbles[kind].scrollIntoView({ block: "start" });
13405
+ scopeSearches[kind].focus({ preventScroll: true });
13406
+ }
13407
+ };
13408
+ const syncTaskScopeVolumeInputs = () => {
13409
+ for (const volumeInput of scopeVolumeInputs) {
13410
+ const group = volumeInput.closest("[data-task-scope-group]");
13411
+ const chapters = [...group.querySelectorAll('[data-task-scope-input="chapter"]')];
13412
+ const selectedCount = chapters.filter((input) => input.checked).length;
13413
+ volumeInput.checked = chapters.length > 0 && selectedCount === chapters.length;
13414
+ volumeInput.indeterminate = selectedCount > 0 && selectedCount < chapters.length;
13415
+ }
13329
13416
  };
13330
13417
  const syncTaskScopePicker = (kind) => {
13331
13418
  const selected = scopeInputs[kind].filter((input) => input.checked);
13332
- const selectedNames = selected.map((input) => input.closest(".task-scope-option")?.querySelector("strong")?.textContent ?? "");
13333
- const label = kind === "chapter" ? "选择需要分析的章节" : "选择需要分析的分卷";
13419
+ syncTaskScopeVolumeInputs();
13420
+ const selectedNames = selected.map((input) => input.dataset.taskScopeTitle ?? "");
13334
13421
  scopeSummaries[kind].textContent = selectedNames.length
13335
- ? `${selectedNames.slice(0, 2).join("、")}${selectedNames.length > 2 ? ` ${selectedNames.length} 个` : ""}`
13336
- : label;
13422
+ ? selectedNames.length === 1 ? selectedNames[0] : `已选择 ${selectedNames.length} 个章节`
13423
+ : "选择需要分析的章节";
13337
13424
  scopeCounts[kind].textContent = selected.length ? `已选 ${selected.length}` : "未选择";
13425
+ scopeSummaryCounts[kind].textContent = `${selected.length} 章`;
13338
13426
  scopeClearButtons[kind].disabled = selected.length === 0;
13339
- scopeTriggers[kind].setAttribute("aria-label", `筛选${kind === "chapter" ? "章节" : "分卷"},已选择 ${selected.length} 个`);
13427
+ scopeTriggers[kind].setAttribute("aria-label", `筛选章节,已选择 ${selected.length} 个`);
13428
+ scopeSelectedLists[kind].innerHTML = selected.length
13429
+ ? selected.map((input) => `<div class="task-scope-selected-item" role="listitem">
13430
+ <span class="task-scope-selected-copy"><strong>${esc(input.dataset.taskScopeTitle ?? "")}</strong>${input.dataset.taskScopeSubtitle ? `<small>${esc(input.dataset.taskScopeSubtitle)}</small>` : ""}</span>
13431
+ <button type="button" data-task-scope-remove="${kind}" data-task-scope-remove-id="${esc(input.value)}" aria-label="移除${esc(input.dataset.taskScopeTitle ?? "")}">×</button>
13432
+ </div>`).join("")
13433
+ : `<p class="task-scope-selected-empty">暂未选择章节</p>`;
13340
13434
  };
13341
13435
  const filterTaskScopeOptions = (kind) => {
13342
13436
  const query = scopeSearches[kind].value.trim().toLocaleLowerCase();
@@ -13347,21 +13441,26 @@ async function openTaskDialog() {
13347
13441
  option.classList.toggle("hidden", !visible);
13348
13442
  if (visible) visibleCount += 1;
13349
13443
  }
13444
+ scopeFields[kind].querySelectorAll("[data-task-scope-group]").forEach((group) => {
13445
+ group.classList.toggle("hidden", !group.querySelector(".task-scope-option:not(.hidden)"));
13446
+ });
13447
+ scopeAvailableCounts[kind].textContent = query
13448
+ ? `${visibleCount} / ${scopeInputs[kind].length} 章`
13449
+ : `${scopeInputs[kind].length} 章`;
13350
13450
  scopeEmptyMessages[kind].classList.toggle("hidden", visibleCount > 0);
13351
13451
  };
13352
13452
  const syncChapterField = () => {
13353
- const activeKind = ["chapter", "volume"].includes(scopeTypeSelect.value) ? scopeTypeSelect.value : null;
13354
- for (const kind of ["chapter", "volume"]) {
13355
- const enabled = kind === activeKind;
13356
- scopeFields[kind].classList.toggle("is-disabled", !enabled);
13357
- scopeFields[kind].classList.toggle("hidden", !enabled);
13358
- scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
13359
- scopeTriggers[kind].disabled = !enabled;
13360
- for (const input of scopeInputs[kind]) input.disabled = !enabled;
13361
- if (!enabled) setTaskScopeBubbleOpen(kind, false);
13362
- syncTaskScopePicker(kind);
13363
- filterTaskScopeOptions(kind);
13364
- }
13453
+ const kind = "chapter";
13454
+ const enabled = scopeTypeSelect.value === "chapter";
13455
+ scopeFields[kind].classList.toggle("is-disabled", !enabled);
13456
+ scopeFields[kind].classList.toggle("hidden", !enabled);
13457
+ scopeFields[kind].setAttribute("aria-disabled", String(!enabled));
13458
+ scopeTriggers[kind].disabled = !enabled;
13459
+ for (const input of scopeInputs[kind]) input.disabled = !enabled;
13460
+ for (const input of scopeVolumeInputs) input.disabled = !enabled;
13461
+ if (!enabled) setTaskScopeBubbleOpen(kind, false);
13462
+ syncTaskScopePicker(kind);
13463
+ filterTaskScopeOptions(kind);
13365
13464
  };
13366
13465
  const setRelationshipCharacterBubbleOpen = (open) => {
13367
13466
  relationshipCharacterBubble.classList.toggle("hidden", !open);
@@ -13511,7 +13610,7 @@ async function openTaskDialog() {
13511
13610
  syncRelationshipOptions();
13512
13611
  }
13513
13612
  });
13514
- for (const kind of ["chapter", "volume"]) {
13613
+ for (const kind of ["chapter"]) {
13515
13614
  scopeTriggers[kind].addEventListener("click", () => {
13516
13615
  setTaskScopeBubbleOpen(kind, scopeTriggers[kind].getAttribute("aria-expanded") !== "true");
13517
13616
  });
@@ -13527,16 +13626,33 @@ async function openTaskDialog() {
13527
13626
  clearContextWarning();
13528
13627
  invalidateRelationshipSourcePreview();
13529
13628
  });
13629
+ scopeSelectedLists[kind].addEventListener("click", (event) => {
13630
+ const removeButton = event.target.closest("[data-task-scope-remove]");
13631
+ if (!removeButton) return;
13632
+ const input = scopeInputs[kind].find((candidate) => candidate.value === removeButton.dataset.taskScopeRemoveId);
13633
+ if (!input) return;
13634
+ input.checked = false;
13635
+ syncTaskScopePicker(kind);
13636
+ clearContextWarning();
13637
+ invalidateRelationshipSourcePreview();
13638
+ });
13530
13639
  }
13640
+ for (const volumeInput of scopeVolumeInputs) volumeInput.addEventListener("change", () => {
13641
+ const group = volumeInput.closest("[data-task-scope-group]");
13642
+ for (const chapterInput of group.querySelectorAll('[data-task-scope-input="chapter"]')) chapterInput.checked = volumeInput.checked;
13643
+ syncTaskScopePicker("chapter");
13644
+ clearContextWarning();
13645
+ invalidateRelationshipSourcePreview();
13646
+ });
13531
13647
  $("#dynamic-form").onclick = (event) => {
13532
13648
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13533
- for (const kind of ["chapter", "volume"]) {
13649
+ for (const kind of ["chapter"]) {
13534
13650
  if (!scopeFields[kind].contains(event.target)) setTaskScopeBubbleOpen(kind, false);
13535
13651
  }
13536
13652
  };
13537
13653
  $("#dynamic-form").onkeydown = (event) => {
13538
13654
  if (event.key !== "Escape") return;
13539
- const openScope = ["chapter", "volume"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
13655
+ const openScope = ["chapter"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
13540
13656
  if (openScope) {
13541
13657
  event.preventDefault();
13542
13658
  setTaskScopeBubbleOpen(openScope, false);
@@ -13930,6 +14046,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13930
14046
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
13931
14047
  const content = message.querySelector(".message-body");
13932
14048
  const meta = message.querySelector(".message-meta");
14049
+ const streamSpeedController = createStreamTypewriterSpeedController();
13933
14050
  let messageMounted = false;
13934
14051
  const mountAssistantMessage = () => {
13935
14052
  if (messageMounted) return true;
@@ -13941,6 +14058,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13941
14058
  return true;
13942
14059
  };
13943
14060
  const typewriter = createStreamTypewriter({
14061
+ speedController: streamSpeedController,
13944
14062
  onRender: (text, progress) => {
13945
14063
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
13946
14064
  content.innerHTML = renderMarkdown(text);
@@ -13976,6 +14094,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13976
14094
  if (existing) return existing;
13977
14095
  processStepVisibleContents.set(step, "");
13978
14096
  const typewriter = createStreamTypewriter({
14097
+ speedController: streamSpeedController,
13979
14098
  onRender: (text) => {
13980
14099
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
13981
14100
  processStepVisibleContents.set(step, text);
@@ -15629,6 +15748,10 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
15629
15748
  $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
15630
15749
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
15631
15750
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
15751
+ document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => {
15752
+ setAiToolCallCopyButtonState(button, false);
15753
+ button.addEventListener("click", () => void copyAiToolCallCode(button));
15754
+ });
15632
15755
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
15633
15756
  $("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
15634
15757
  $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
@@ -16345,6 +16468,9 @@ document.addEventListener("click", (event) => {
16345
16468
  window.addEventListener("pagehide", dismissDeleteToasts);
16346
16469
  window.addEventListener("beforeunload", (event) => {
16347
16470
  if (hasUnsavedEditorChanges()) event.preventDefault();
16471
+ if (aiRequestManager.hasActive()) {
16472
+ toast("当前 turn 尚未结束,刷新会中断生成;已收到的内容会保留在历史记录中", "warning");
16473
+ }
16348
16474
  });
16349
16475
  window.addEventListener("online", () => {
16350
16476
  updateSystemHealth({ status: "checking" });
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260815-ai-history-favorite-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260815-ai-history-favorite-v3&feature=ai-tool-call-copy-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -1099,8 +1099,8 @@
1099
1099
  <div><dt>函数信息</dt><dd id="ai-tool-call-description"></dd></div>
1100
1100
  <div><dt>返回字符数</dt><dd id="ai-tool-call-result-length"></dd></div>
1101
1101
  </dl>
1102
- <section><h3>参数</h3><pre id="ai-tool-call-arguments"></pre></section>
1103
- <section><h3>返回值</h3><pre id="ai-tool-call-result"></pre></section>
1102
+ <section class="ai-tool-call-code-section"><h3>参数</h3><div class="ai-tool-call-code-block"><button class="ai-tool-call-copy-button" type="button" data-ai-tool-call-copy data-copy-target="ai-tool-call-arguments" data-copy-label="参数" aria-label="复制参数" title="复制参数"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg></button><pre id="ai-tool-call-arguments"></pre></div></section>
1103
+ <section class="ai-tool-call-code-section"><h3>返回值</h3><div class="ai-tool-call-code-block"><button class="ai-tool-call-copy-button" type="button" data-ai-tool-call-copy data-copy-target="ai-tool-call-result" data-copy-label="返回值" aria-label="复制返回值" title="复制返回值"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg></button><pre id="ai-tool-call-result"></pre></div></section>
1104
1104
  </div>
1105
1105
  </dialog>
1106
1106
 
@@ -1164,6 +1164,6 @@
1164
1164
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1165
1165
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1166
1166
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1167
- <script type="module" src="/app.js?v=20260815-analysis-task-create-v1"></script>
1167
+ <script type="module" src="/app.js?v=20260815-ai-stream-persistence-v4&feature=ai-tool-call-copy-v1"></script>
1168
1168
  </body>
1169
1169
  </html>
@@ -9,11 +9,22 @@ export type StreamTypewriter = {
9
9
  reveal(): string;
10
10
  };
11
11
 
12
- export function streamTypewriterBatchSize(pendingCharacters: number, finishing?: boolean): number;
12
+ export type StreamTypewriterSpeedController = {
13
+ observe(characterCount: number): void;
14
+ charactersPerSecond(): number;
15
+ };
16
+
17
+ export function createStreamTypewriterSpeedController(options?: {
18
+ now?: () => number;
19
+ initialCharactersPerSecond?: number;
20
+ }): StreamTypewriterSpeedController;
21
+
22
+ export function streamTypewriterBatchSize(pendingCharacters: number, finishing?: boolean, charactersPerSecond?: number): number;
13
23
 
14
24
  export function createStreamTypewriter<FrameHandle = number>(options: {
15
25
  onRender: (text: string, progress: StreamTypewriterProgress) => void;
16
26
  scheduleFrame?: (callback: () => void) => FrameHandle;
17
27
  cancelFrame?: (handle: FrameHandle) => void;
18
28
  reducedMotion?: boolean;
29
+ speedController?: StreamTypewriterSpeedController | null;
19
30
  }): StreamTypewriter;