@musnows/scriverse 0.7.11 → 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,13 +19,14 @@ 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";
26
26
  import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
27
27
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
28
28
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
29
+ import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
29
30
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
30
31
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
31
32
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
@@ -550,7 +551,15 @@ function cancelActiveAiRequest(reason) {
550
551
  return cancelled;
551
552
  }
552
553
 
553
- 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
+ }
554
563
  aiConversationNavigationGeneration += 1;
555
564
  aiConversationNavigationPending = aiConversationNavigationGeneration;
556
565
  const navigation = Object.freeze({
@@ -573,7 +582,10 @@ function finishAiConversationNavigation(navigation) {
573
582
  return true;
574
583
  }
575
584
 
576
- function invalidateAiConversationNavigation(reason) {
585
+ function invalidateAiConversationNavigation(reason, action = "切换作品") {
586
+ if (aiRequestManager.hasActive()) {
587
+ toast(`当前 turn 尚未结束,${action}会中断生成;已收到的内容会保留在历史记录中`, "warning");
588
+ }
577
589
  aiConversationNavigationGeneration += 1;
578
590
  aiConversationNavigationPending = null;
579
591
  cancelActiveAiRequest(reason);
@@ -1870,7 +1882,7 @@ function renderMessageCardActions(message) {
1870
1882
  });
1871
1883
  actions.append(copy);
1872
1884
  }
1873
- if (message.dataset.messageId) {
1885
+ if (message.dataset.messageId && message.classList.contains("assistant-message")) {
1874
1886
  const fork = document.createElement("button");
1875
1887
  fork.type = "button";
1876
1888
  fork.className = "message-fork-button";
@@ -2000,6 +2012,29 @@ function formatAiToolCallTime(value) {
2000
2012
  }).format(date);
2001
2013
  }
2002
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
+
2003
2038
  function openAiToolCallDetail(toolCall) {
2004
2039
  const name = String(toolCall?.name ?? "unknown");
2005
2040
  const status = toolCall?.status === "failed" ? "调用失败" : "调用成功";
@@ -2016,6 +2051,7 @@ function openAiToolCallDetail(toolCall) {
2016
2051
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
2017
2052
  $("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
2018
2053
  $("#ai-tool-call-result").textContent = resultDetails.text;
2054
+ document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => setAiToolCallCopyButtonState(button, false));
2019
2055
  $("#ai-tool-call-dialog").showModal();
2020
2056
  }
2021
2057
 
@@ -2417,7 +2453,7 @@ async function ensureAiConversationsLoaded() {
2417
2453
 
2418
2454
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2419
2455
  if (!state.work) return null;
2420
- const navigation = beginAiConversationNavigation("已切换 AI 对话");
2456
+ const navigation = beginAiConversationNavigation("已切换 AI 对话", "切换会话");
2421
2457
  try {
2422
2458
  const parameters = new URLSearchParams({ page: "1", limit: "100" });
2423
2459
  if (focusMessageId) parameters.set("messageId", String(focusMessageId));
@@ -2487,7 +2523,7 @@ function applyNewAiConversation(conversation) {
2487
2523
 
2488
2524
  async function createNewAiConversation(taskType = "chat") {
2489
2525
  if (!state.work) return null;
2490
- const navigation = beginAiConversationNavigation("已新建 AI 对话");
2526
+ const navigation = beginAiConversationNavigation("已新建 AI 对话", "新建会话");
2491
2527
  try {
2492
2528
  const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2493
2529
  if (!isAiConversationNavigationCurrent(navigation)) return null;
@@ -3897,11 +3933,11 @@ function dismissChapterInsightToast() {
3897
3933
  }
3898
3934
  }
3899
3935
 
3900
- function toast(message, type = "info") {
3936
+ function toast(message, type = "info", extraClass = "") {
3901
3937
  if (systemRestartDetected || (!state.user && document.documentElement.classList.contains("login-route"))) return;
3902
3938
  const region = $("#toast-region");
3903
3939
  const element = document.createElement("div");
3904
- element.className = `toast ${type}`;
3940
+ element.className = `toast ${type}${extraClass ? ` ${extraClass}` : ""}`;
3905
3941
  element.setAttribute("role", type === "error" ? "alert" : "status");
3906
3942
  element.setAttribute("aria-atomic", "true");
3907
3943
  element.textContent = message;
@@ -9319,7 +9355,8 @@ function renderCharacterExtractionEditor(preview) {
9319
9355
  if (!items.length) return '<p class="character-extraction-empty">没有可应用的角色候选。</p>';
9320
9356
  return `<div class="character-extraction-editor" data-character-extraction-editor data-preview-token="${esc(preview.previewToken)}">
9321
9357
  <div class="character-extraction-policy" role="note"><strong>合并规则</strong><span>优先使用任务保存的稳定角色引用,其次使用当前标准名和别名匹配。合并只追加无冲突别名、空缺身份、种族和首次登场;已有非空字段保持不变。</span></div>
9322
- <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>
9323
9360
  <div class="character-extraction-candidate-list">${items.map((item, index) => {
9324
9361
  const matches = Array.isArray(item.matchCandidates) ? item.matchCandidates : [];
9325
9362
  const suggestedAction = item.suggestedAction === "merge" && matches.length ? "merge" : item.suggestedAction === "skip" ? "skip" : "create";
@@ -9350,7 +9387,6 @@ function renderCharacterExtractionEditor(preview) {
9350
9387
  </article>`;
9351
9388
  }).join("")}</div>
9352
9389
  <p class="character-extraction-apply-error hidden" data-character-extraction-error role="alert"></p>
9353
- <div class="character-extraction-apply-actions"><button class="primary-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><small>确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</small></div>
9354
9390
  </div>`;
9355
9391
  }
9356
9392
 
@@ -11821,6 +11857,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
11821
11857
  const attachmentObserver = new MutationObserver(() => normalizeVditorAttachmentImages(editor));
11822
11858
  attachmentObserver.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ["src"] });
11823
11859
  editor.__attachmentObserver = attachmentObserver;
11860
+ editor.__plainTextPasteCleanup = bindPlainTextPaste(host);
11824
11861
  host.__vditor = editor;
11825
11862
  return editor;
11826
11863
  }
@@ -12012,6 +12049,7 @@ function ensureVditorIconScript() {
12012
12049
  function destroyVditorEditor(editor) {
12013
12050
  if (!editor) return;
12014
12051
  editor.__attachmentObserver?.disconnect();
12052
+ editor.__plainTextPasteCleanup?.();
12015
12053
  editor.__vditorLineNumberObserver?.disconnect();
12016
12054
  editor.__vditorLineNumberResizeObserver?.disconnect();
12017
12055
  if (editor.__vditorLineNumberSurface && editor.__vditorLineNumberScrollHandler) {
@@ -13084,7 +13122,13 @@ function openReviewDialog() {
13084
13122
  }
13085
13123
 
13086
13124
  async function openTaskDialog() {
13087
- const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
13125
+ const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
13126
+ id: String(chapter.id),
13127
+ title: String(chapter.title),
13128
+ volumeId: String(volume.id),
13129
+ volumeTitle: String(volume.title),
13130
+ chapterType: String(chapter.chapterType || "正文")
13131
+ })));
13088
13132
  let relationshipCharacters = [];
13089
13133
  let taskModels = [];
13090
13134
  let taskDefaults = [];
@@ -13100,6 +13144,67 @@ async function openTaskDialog() {
13100
13144
  toast(`分析任务配置加载失败:${error.message}`, "error");
13101
13145
  return;
13102
13146
  }
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>
13205
+ </div>
13206
+ </div>`;
13207
+ };
13103
13208
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
13104
13209
  const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
13105
13210
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
@@ -13126,8 +13231,8 @@ async function openTaskDialog() {
13126
13231
  const modelField = `<label>任务模型<select name="modelId" required aria-describedby="analysis-task-model-help">
13127
13232
  <option value="" ${availableTaskModels.some((model) => model.id === defaultModelId) ? "" : "selected"} disabled>${availableTaskModels.length ? "请选择模型" : "没有可用模型"}</option>
13128
13233
  ${availableTaskModels.map((model) => `<option value="${esc(model.id)}" ${model.id === defaultModelId ? "selected" : ""}>${esc(modelOptionLabel(model))}</option>`).join("")}
13129
- </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small></label>`;
13130
- const chapterField = `<label class="task-chapter-field">章节<select name="chapterId">${chapterOptions.map(([key, text], index) => `<option value="${esc(key)}" ${index === 0 ? "selected" : ""}>${esc(text)}</option>`).join("")}</select></label>`;
13234
+ </select><small id="analysis-task-model-help">默认值来自“本书 AI 设置”,只修改当前任务,不会改变全书默认模型。</small><p class="analysis-context-warning hidden" data-analysis-context-warning role="alert"></p></label>`;
13235
+ const chapterField = taskScopePicker(chapterOptions, "选择需要分析的章节");
13131
13236
  const relationshipFields = `<div class="relationship-analysis-options hidden">
13132
13237
  ${relationshipCharacterPicker}
13133
13238
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
@@ -13164,25 +13269,34 @@ async function openTaskDialog() {
13164
13269
  const preFilterRelationshipSources = characterIds.length > 0 && form.get("preFilterRelationshipSources") === "on";
13165
13270
  const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
13166
13271
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
13272
+ const chapterIds = form.getAll("chapterIds").map(String).filter(Boolean);
13273
+ const chapterScope = {
13274
+ type: "chapter",
13275
+ ...(chapterIds[0] ? { chapterId: chapterIds[0] } : {}),
13276
+ ...(chapterIds.length ? { chapterIds } : {})
13277
+ };
13167
13278
  const scope = settingsOnly
13168
13279
  ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13169
- : taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
13280
+ : scopeType === "book" || includeAllSettings
13170
13281
  ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
13171
- : { type: "chapter", chapterId: form.get("chapterId"), ...(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 } : {}) };
13172
13283
  return scope;
13173
13284
  };
13174
13285
  const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
13175
13286
  type: scope.type,
13176
13287
  chapterId: scope.chapterId ?? null,
13288
+ chapterIds: scope.chapterIds ?? [],
13177
13289
  includeAllSettings: scope.includeAllSettings === true,
13178
13290
  characterIds: scope.characterIds ?? [],
13179
13291
  preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
13180
13292
  modelId
13181
13293
  });
13182
13294
  openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
13295
+ const workId = state.work.id;
13183
13296
  const taskType = String(form.get("taskType"));
13184
13297
  const modelId = String(form.get("modelId"));
13185
13298
  const scope = buildRelationshipScope(form);
13299
+ if (scope.type === "chapter" && !scope.chapterIds?.length) return toast("请先选择至少一个章节", "error");
13186
13300
  if (taskType === "relationship-analysis" && relationshipSourcePreview
13187
13301
  && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
13188
13302
  scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
@@ -13192,16 +13306,21 @@ async function openTaskDialog() {
13192
13306
  sourceVersion: input.dataset.sourceVersion
13193
13307
  }));
13194
13308
  }
13195
- await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
13196
- await refreshBackgroundTaskCenter({ announce: false });
13197
- taskListPage = 1;
13309
+ clearContextWarning();
13198
13310
  try {
13199
- await renderTasks(1);
13200
- toast("分析任务已创建,已进入任务队列");
13311
+ await api(`/api/works/${workId}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
13201
13312
  } catch (error) {
13202
- toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
13313
+ if (error.code === "AI_CONTEXT_TOO_LARGE") {
13314
+ contextWarning.textContent = error.message || "当前分析范围超过模型上下文阈值,请切换到上下文更长的模型后重试。";
13315
+ contextWarning.classList.remove("hidden");
13316
+ }
13317
+ throw error;
13203
13318
  }
13319
+ taskListPage = 1;
13320
+ toast("分析任务已创建,已进入任务队列");
13321
+ void refreshAnalysisTaskViewsAfterCreate(workId);
13204
13322
  }, "AI 分析", {
13323
+ large: true,
13205
13324
  submitLabel: "创建任务",
13206
13325
  pendingLabel: "创建中…",
13207
13326
  pendingMessage: "正在创建分析任务,请稍候",
@@ -13210,8 +13329,45 @@ async function openTaskDialog() {
13210
13329
  const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
13211
13330
  const taskModelSelect = $("#dialog-fields").querySelector('select[name="modelId"]');
13212
13331
  const scopeTypeSelect = $("#dialog-fields").querySelector('select[name="scopeType"]');
13213
- const chapterSelect = $("#dialog-fields").querySelector('select[name="chapterId"]');
13214
- const chapterFieldElement = chapterSelect.closest(".task-chapter-field");
13332
+ const scopeFields = {
13333
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-field="chapter"]')
13334
+ };
13335
+ const scopeTriggers = {
13336
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-trigger="chapter"]')
13337
+ };
13338
+ const scopeBubbles = {
13339
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-bubble="chapter"]')
13340
+ };
13341
+ const scopeSearches = {
13342
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-search="chapter"]')
13343
+ };
13344
+ const scopeInputs = {
13345
+ chapter: [...$("#dialog-fields").querySelectorAll('[data-task-scope-input="chapter"]')]
13346
+ };
13347
+ const scopeVolumeInputs = [
13348
+ ...$("#dialog-fields").querySelectorAll("[data-task-scope-volume-input]")
13349
+ ];
13350
+ const scopeSummaries = {
13351
+ chapter: $("#dialog-fields").querySelector("#task-chapter-summary")
13352
+ };
13353
+ const scopeCounts = {
13354
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-count="chapter"]')
13355
+ };
13356
+ const scopeClearButtons = {
13357
+ chapter: $("#dialog-fields").querySelector('[data-task-scope-clear="chapter"]')
13358
+ };
13359
+ const scopeEmptyMessages = {
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"]')
13370
+ };
13215
13371
  const description = $("#analysis-type-description");
13216
13372
  const relationshipOptions = $("#dialog-fields").querySelector(".relationship-analysis-options");
13217
13373
  const relationshipPrompt = relationshipOptions.querySelector('textarea[name="additionalPrompt"]');
@@ -13229,17 +13385,82 @@ async function openTaskDialog() {
13229
13385
  const relationshipSourcePreviewContent = relationshipOptions.querySelector("[data-relationship-source-preview-content]");
13230
13386
  const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
13231
13387
  const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-existing-overwrite-card");
13388
+ const contextWarning = $("#dialog-fields").querySelector("[data-analysis-context-warning]");
13389
+ const clearContextWarning = () => {
13390
+ contextWarning.textContent = "";
13391
+ contextWarning.classList.add("hidden");
13392
+ };
13232
13393
  const allSettingsOption = document.createElement("option");
13233
13394
  allSettingsOption.value = "book-with-settings";
13234
13395
  allSettingsOption.textContent = "全书 + 设定集";
13235
13396
  const settingsOnlyOption = document.createElement("option");
13236
13397
  settingsOnlyOption.value = "settings";
13237
13398
  settingsOnlyOption.textContent = "仅设定集";
13399
+ const setTaskScopeBubbleOpen = (kind, open) => {
13400
+ scopeBubbles[kind].classList.toggle("hidden", !open);
13401
+ scopeTriggers[kind].setAttribute("aria-expanded", String(open));
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
+ }
13416
+ };
13417
+ const syncTaskScopePicker = (kind) => {
13418
+ const selected = scopeInputs[kind].filter((input) => input.checked);
13419
+ syncTaskScopeVolumeInputs();
13420
+ const selectedNames = selected.map((input) => input.dataset.taskScopeTitle ?? "");
13421
+ scopeSummaries[kind].textContent = selectedNames.length
13422
+ ? selectedNames.length === 1 ? selectedNames[0] : `已选择 ${selectedNames.length} 个章节`
13423
+ : "选择需要分析的章节";
13424
+ scopeCounts[kind].textContent = selected.length ? `已选 ${selected.length}` : "未选择";
13425
+ scopeSummaryCounts[kind].textContent = `${selected.length} 章`;
13426
+ scopeClearButtons[kind].disabled = selected.length === 0;
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>`;
13434
+ };
13435
+ const filterTaskScopeOptions = (kind) => {
13436
+ const query = scopeSearches[kind].value.trim().toLocaleLowerCase();
13437
+ let visibleCount = 0;
13438
+ for (const input of scopeInputs[kind]) {
13439
+ const option = input.closest(".task-scope-option");
13440
+ const visible = !query || option.dataset.taskScopeSearchName.includes(query);
13441
+ option.classList.toggle("hidden", !visible);
13442
+ if (visible) visibleCount += 1;
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} 章`;
13450
+ scopeEmptyMessages[kind].classList.toggle("hidden", visibleCount > 0);
13451
+ };
13238
13452
  const syncChapterField = () => {
13239
- const disabled = scopeTypeSelect.value !== "chapter";
13240
- chapterSelect.disabled = disabled;
13241
- chapterFieldElement.classList.toggle("is-disabled", disabled);
13242
- chapterFieldElement.setAttribute("aria-disabled", String(disabled));
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);
13243
13464
  };
13244
13465
  const setRelationshipCharacterBubbleOpen = (open) => {
13245
13466
  relationshipCharacterBubble.classList.toggle("hidden", !open);
@@ -13337,10 +13558,12 @@ async function openTaskDialog() {
13337
13558
  taskTypeSelect.addEventListener("change", () => {
13338
13559
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
13339
13560
  syncTaskModelDefault();
13561
+ clearContextWarning();
13340
13562
  invalidateRelationshipSourcePreview();
13341
13563
  syncRelationshipOptions();
13342
13564
  });
13343
13565
  taskModelSelect.addEventListener("change", () => {
13566
+ clearContextWarning();
13344
13567
  invalidateRelationshipSourcePreview();
13345
13568
  syncRelationshipOptions();
13346
13569
  });
@@ -13387,11 +13610,56 @@ async function openTaskDialog() {
13387
13610
  syncRelationshipOptions();
13388
13611
  }
13389
13612
  });
13613
+ for (const kind of ["chapter"]) {
13614
+ scopeTriggers[kind].addEventListener("click", () => {
13615
+ setTaskScopeBubbleOpen(kind, scopeTriggers[kind].getAttribute("aria-expanded") !== "true");
13616
+ });
13617
+ scopeSearches[kind].addEventListener("input", () => filterTaskScopeOptions(kind));
13618
+ for (const input of scopeInputs[kind]) input.addEventListener("change", () => {
13619
+ syncTaskScopePicker(kind);
13620
+ clearContextWarning();
13621
+ invalidateRelationshipSourcePreview();
13622
+ });
13623
+ scopeClearButtons[kind].addEventListener("click", () => {
13624
+ for (const input of scopeInputs[kind]) input.checked = false;
13625
+ syncTaskScopePicker(kind);
13626
+ clearContextWarning();
13627
+ invalidateRelationshipSourcePreview();
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
+ });
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
+ });
13390
13647
  $("#dynamic-form").onclick = (event) => {
13391
13648
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
13649
+ for (const kind of ["chapter"]) {
13650
+ if (!scopeFields[kind].contains(event.target)) setTaskScopeBubbleOpen(kind, false);
13651
+ }
13392
13652
  };
13393
13653
  $("#dynamic-form").onkeydown = (event) => {
13394
- if (event.key !== "Escape" || relationshipCharacterBubble.classList.contains("hidden")) return;
13654
+ if (event.key !== "Escape") return;
13655
+ const openScope = ["chapter"].find((kind) => !scopeBubbles[kind].classList.contains("hidden"));
13656
+ if (openScope) {
13657
+ event.preventDefault();
13658
+ setTaskScopeBubbleOpen(openScope, false);
13659
+ scopeTriggers[openScope].focus();
13660
+ return;
13661
+ }
13662
+ if (relationshipCharacterBubble.classList.contains("hidden")) return;
13395
13663
  event.preventDefault();
13396
13664
  setRelationshipCharacterBubbleOpen(false);
13397
13665
  relationshipCharacterTrigger.focus();
@@ -13400,10 +13668,23 @@ async function openTaskDialog() {
13400
13668
  invalidateRelationshipSourcePreview();
13401
13669
  syncChapterField();
13402
13670
  });
13403
- chapterSelect.addEventListener("change", invalidateRelationshipSourcePreview);
13404
13671
  syncRelationshipOptions();
13405
13672
  }
13406
13673
 
13674
+ async function refreshAnalysisTaskViewsAfterCreate(workId) {
13675
+ try {
13676
+ await Promise.all([
13677
+ refreshBackgroundTaskCenter({ announce: false }),
13678
+ state.module === "tasks" && state.work?.id === workId
13679
+ ? renderTasks(1, { refresh: true })
13680
+ : Promise.resolve()
13681
+ ]);
13682
+ } catch (error) {
13683
+ console.error("Failed to refresh analysis task views after creation", error);
13684
+ toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
13685
+ }
13686
+ }
13687
+
13407
13688
  function openProviderDialog(item) {
13408
13689
  const protocol = item?.protocol ?? "openai-chat-completions";
13409
13690
  const providerProtocolOptions = [
@@ -13765,6 +14046,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13765
14046
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
13766
14047
  const content = message.querySelector(".message-body");
13767
14048
  const meta = message.querySelector(".message-meta");
14049
+ const streamSpeedController = createStreamTypewriterSpeedController();
13768
14050
  let messageMounted = false;
13769
14051
  const mountAssistantMessage = () => {
13770
14052
  if (messageMounted) return true;
@@ -13776,6 +14058,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13776
14058
  return true;
13777
14059
  };
13778
14060
  const typewriter = createStreamTypewriter({
14061
+ speedController: streamSpeedController,
13779
14062
  onRender: (text, progress) => {
13780
14063
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
13781
14064
  content.innerHTML = renderMarkdown(text);
@@ -13811,6 +14094,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13811
14094
  if (existing) return existing;
13812
14095
  processStepVisibleContents.set(step, "");
13813
14096
  const typewriter = createStreamTypewriter({
14097
+ speedController: streamSpeedController,
13814
14098
  onRender: (text) => {
13815
14099
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
13816
14100
  processStepVisibleContents.set(step, text);
@@ -15464,6 +15748,10 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
15464
15748
  $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
15465
15749
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
15466
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
+ });
15467
15755
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
15468
15756
  $("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
15469
15757
  $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
@@ -15605,6 +15893,7 @@ $("#module-nav").addEventListener("click", (event) => {
15605
15893
  });
15606
15894
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
15607
15895
  $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
15896
+ bindPlainTextPaste($("#ai-prompt"));
15608
15897
  $("#ai-prompt").addEventListener("input", async () => {
15609
15898
  updateAiMentionMenu();
15610
15899
  setAiContextMeter(null);
@@ -16179,6 +16468,9 @@ document.addEventListener("click", (event) => {
16179
16468
  window.addEventListener("pagehide", dismissDeleteToasts);
16180
16469
  window.addEventListener("beforeunload", (event) => {
16181
16470
  if (hasUnsavedEditorChanges()) event.preventDefault();
16471
+ if (aiRequestManager.hasActive()) {
16472
+ toast("当前 turn 尚未结束,刷新会中断生成;已收到的内容会保留在历史记录中", "warning");
16473
+ }
16182
16474
  });
16183
16475
  window.addEventListener("online", () => {
16184
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-ai-history-favorite-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>