@musnows/scriverse 0.8.4 → 0.8.5

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.
@@ -2035,6 +2035,7 @@ function createAiChatTabState(input = {}) {
2035
2035
  return existing;
2036
2036
  }
2037
2037
  const feed = input.feed ?? createAiChatFeed();
2038
+ bindAiFeedAutoScroll(feed);
2038
2039
  const tab = aiChatTabManager.open({
2039
2040
  workId: String(state.work?.id ?? ""),
2040
2041
  conversationId: input.conversationId ?? null,
@@ -2404,7 +2405,7 @@ function resetAiFeed(
2404
2405
  const roleplayName = roleplayCharacter?.name;
2405
2406
  const roleplayUserName = roleplayUserCharacter?.name;
2406
2407
  feed.innerHTML = roleplayName
2407
- ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我只能通过角色卡和与自己有关的记忆回答。"}</p></div></div>`
2408
+ ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系和故事正文回答。"}</p></div></div>`
2408
2409
  : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2409
2410
  }
2410
2411
 
@@ -2449,6 +2450,75 @@ function renderConversationCompactionDivider(compactedMessageCount, totalMessage
2449
2450
  return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
2450
2451
  }
2451
2452
 
2453
+ function findAiRetryUserMessage(message) {
2454
+ const feed = message.closest(".ai-feed");
2455
+ if (!feed) return null;
2456
+ const messages = [...feed.children].filter((candidate) => candidate.matches(".user-message, .assistant-message"));
2457
+ const messageIndex = messages.indexOf(message);
2458
+ for (let index = messageIndex - 1; index >= 0; index -= 1) {
2459
+ if (messages[index].classList.contains("user-message")) return messages[index];
2460
+ }
2461
+ return null;
2462
+ }
2463
+
2464
+ function aiMessageCitations(message) {
2465
+ try {
2466
+ const citations = JSON.parse(message.dataset.aiCitations ?? "[]");
2467
+ return Array.isArray(citations) ? citations : [];
2468
+ } catch {
2469
+ return [];
2470
+ }
2471
+ }
2472
+
2473
+ async function retryAiMessage(message) {
2474
+ const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2475
+ const userMessage = findAiRetryUserMessage(message);
2476
+ const prompt = userMessage?.dataset.copyText ?? "";
2477
+ const userMessageId = userMessage?.dataset.messageId ?? "";
2478
+ if (!sourceTab?.conversationId || !userMessageId || !prompt.trim()) {
2479
+ toast("找不到需要重试的用户指令", "error");
2480
+ return;
2481
+ }
2482
+ if (aiRequestManager.hasActive(sourceTab.id)) {
2483
+ toast("当前对话仍在生成回复,请等待完成或取消后再重试", "error");
2484
+ return;
2485
+ }
2486
+ if (!isActiveAiChatTab(sourceTab)) activateAiChatTab(sourceTab.id, { persistCurrent: false, force: true });
2487
+ await sendAiWithOptions({
2488
+ retry: {
2489
+ message,
2490
+ prompt,
2491
+ citations: aiMessageCitations(userMessage),
2492
+ userMessageId
2493
+ }
2494
+ });
2495
+ }
2496
+
2497
+ function clearAiRetryComposer() {
2498
+ clearAiPromptComposer();
2499
+ }
2500
+
2501
+ function prepareAiRetryState(tab, modelId) {
2502
+ tab.modelId = modelId;
2503
+ tab.selectedModelId = modelId;
2504
+ tab.promptSent = true;
2505
+ clearAiChatTabComposer(tab);
2506
+ if (isActiveAiChatTab(tab)) {
2507
+ state.aiConversationModelId = modelId;
2508
+ state.aiPromptSent = true;
2509
+ syncAiTaskOptions();
2510
+ renderAiRoleplayCharacterSelect();
2511
+ renderAiQuickActions();
2512
+ clearAiRetryComposer();
2513
+ }
2514
+ }
2515
+
2516
+ function aiRetryStreamRequestBody(body, retry) {
2517
+ return retry?.userMessageId
2518
+ ? { ...body, currentMessageId: retry.userMessageId }
2519
+ : body;
2520
+ }
2521
+
2452
2522
  function renderMessageCardActions(message) {
2453
2523
  let actions = message.querySelector(".message-card-actions");
2454
2524
  if (!actions) {
@@ -2477,7 +2547,20 @@ function renderMessageCardActions(message) {
2477
2547
  });
2478
2548
  actions.append(copy);
2479
2549
  }
2480
- if (message.dataset.messageId && message.classList.contains("assistant-message")) {
2550
+ if (message.dataset.status === "failed" && message.classList.contains("assistant-message")) {
2551
+ const retry = document.createElement("button");
2552
+ retry.type = "button";
2553
+ retry.className = "message-retry-button";
2554
+ retry.setAttribute("aria-label", "重试");
2555
+ retry.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.7-4L4 9"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4L20 15"/><path d="M20 20v-5h-5"/></svg><span>重试</span>';
2556
+ retry.addEventListener("click", () => {
2557
+ retry.disabled = true;
2558
+ void retryAiMessage(message).finally(() => {
2559
+ if (retry.isConnected) retry.disabled = false;
2560
+ });
2561
+ });
2562
+ actions.append(retry);
2563
+ } else if (message.dataset.messageId && message.classList.contains("assistant-message")) {
2481
2564
  const fork = document.createElement("button");
2482
2565
  fork.type = "button";
2483
2566
  fork.className = "message-fork-button";
@@ -2539,6 +2622,7 @@ const AI_TOOL_DISPLAY_NAMES = {
2539
2622
  recall_self: "回忆自身",
2540
2623
  image: "读取设定图片",
2541
2624
  recall_relationship: "回忆人物关系",
2625
+ recall_story: "回忆故事",
2542
2626
  calculate_time: "计算日期"
2543
2627
  };
2544
2628
 
@@ -2552,10 +2636,14 @@ const AI_TOOL_DESCRIPTIONS = {
2552
2636
  recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
2553
2637
  image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
2554
2638
  recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
2639
+ recall_story: "查询当前作品已保存正文中的关键词,返回匹配段落及章节信息。",
2555
2640
  calculate_time: "计算日期差值,或从起始日期推算目标日期。"
2556
2641
  };
2557
2642
 
2558
2643
  const aiFeedScrollFrames = new WeakMap();
2644
+ const aiFeedAutoScrollStates = new WeakMap();
2645
+ const aiFeedScrollBindings = new WeakSet();
2646
+ const AI_FEED_BOTTOM_THRESHOLD_PX = 24;
2559
2647
  let markdownTableMenuTarget = null;
2560
2648
  let markdownTableMenuTrigger = null;
2561
2649
 
@@ -2596,7 +2684,30 @@ function openMarkdownTableMenu(header, clientX, clientY) {
2596
2684
  toggle.focus();
2597
2685
  }
2598
2686
 
2687
+ function aiFeedIsNearBottom(feed) {
2688
+ if (!feed.clientHeight) return true;
2689
+ return feed.scrollHeight - feed.scrollTop - feed.clientHeight <= AI_FEED_BOTTOM_THRESHOLD_PX;
2690
+ }
2691
+
2692
+ function bindAiFeedAutoScroll(feed) {
2693
+ if (!feed || aiFeedScrollBindings.has(feed)) return;
2694
+ const update = () => {
2695
+ const shouldFollow = aiFeedIsNearBottom(feed);
2696
+ aiFeedAutoScrollStates.set(feed, shouldFollow);
2697
+ if (shouldFollow) return;
2698
+ const currentFrame = aiFeedScrollFrames.get(feed);
2699
+ if (currentFrame === undefined) return;
2700
+ window.cancelAnimationFrame(currentFrame);
2701
+ aiFeedScrollFrames.delete(feed);
2702
+ };
2703
+ feed.addEventListener("scroll", update, { passive: true });
2704
+ aiFeedScrollBindings.add(feed);
2705
+ update();
2706
+ }
2707
+
2599
2708
  function scrollAiFeedToBottom(feed = $("#ai-feed")) {
2709
+ bindAiFeedAutoScroll(feed);
2710
+ if (!aiFeedAutoScrollStates.get(feed)) return;
2600
2711
  feed.scrollTop = feed.scrollHeight;
2601
2712
  const currentFrame = aiFeedScrollFrames.get(feed);
2602
2713
  if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
@@ -2719,9 +2830,17 @@ function resolveAiProcessDuration(metadata, steps, completedAt) {
2719
2830
  return Math.max(0, completedTime - Math.min(...startedTimes));
2720
2831
  }
2721
2832
 
2833
+ function shouldRenderAiProcessStep(step) {
2834
+ if (step?.type === "context_compaction") return true;
2835
+ if (step?.type === "tool" && step.toolCall) return true;
2836
+ if (!step?.content || !["thinking", "intermediate"].includes(step.type)) return false;
2837
+ return step.type !== "intermediate" || typeof step.content !== "string" || step.content.trim().length > 0;
2838
+ }
2839
+
2722
2840
  function renderAiProcessSteps(message, steps, completed, durationMs = null, visibleContents = null) {
2723
2841
  message.querySelector(".ai-process-details")?.remove();
2724
- if (!Array.isArray(steps) || !steps.length) return;
2842
+ const renderableSteps = (Array.isArray(steps) ? steps : []).filter(shouldRenderAiProcessStep);
2843
+ if (!renderableSteps.length) return;
2725
2844
  const details = document.createElement("details");
2726
2845
  details.className = "ai-process-details";
2727
2846
  details.open = !completed;
@@ -2730,11 +2849,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2730
2849
  title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
2731
2850
  const status = document.createElement("small");
2732
2851
  const duration = durationMs === null || durationMs === undefined ? "" : formatAiProcessDuration(durationMs);
2733
- status.textContent = `${steps.length} 个步骤${duration ? ` · 耗时 ${duration}` : ""}`;
2852
+ status.textContent = `${renderableSteps.length} 个步骤${duration ? ` · 耗时 ${duration}` : ""}`;
2734
2853
  summary.append(title, status);
2735
2854
  const list = document.createElement("div");
2736
2855
  list.className = "ai-process-list";
2737
- for (const step of steps) {
2856
+ for (const step of renderableSteps) {
2738
2857
  if (step?.type === "context_compaction") {
2739
2858
  list.append(createAiContextCompactionDivider({
2740
2859
  kind: "tool",
@@ -2752,7 +2871,6 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2752
2871
  list.append(tool);
2753
2872
  continue;
2754
2873
  }
2755
- if (!step?.content || !["thinking", "intermediate"].includes(step.type)) continue;
2756
2874
  const section = document.createElement("section");
2757
2875
  section.className = `ai-process-step ai-process-${step.type}-step`;
2758
2876
  const label = document.createElement("small");
@@ -3214,7 +3332,7 @@ function renderAiRoleplayCharacterSelect() {
3214
3332
  select.title = canSelectCharacter
3215
3333
  ? state.aiPromptSent
3216
3334
  ? aiConversationOptionLockedMessage
3217
- : "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
3335
+ : "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、人物关系和故事正文"
3218
3336
  : "当前账户没有角色模块读取权限";
3219
3337
  renderAiRoleplayUserCharacterSelect();
3220
3338
  }
@@ -3323,7 +3441,7 @@ function syncAiTaskOptions() {
3323
3441
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3324
3442
  $("#ai-scope").title = state.aiPromptSent
3325
3443
  ? aiConversationOptionLockedMessage
3326
- : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
3444
+ : roleplaySelected ? "角色扮演模式可以查询角色记忆、人物关系和故事正文" : "";
3327
3445
  syncAiModelPicker();
3328
3446
  }
3329
3447
 
@@ -10624,6 +10742,48 @@ function renderProviderCards(providers, models) {
10624
10742
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、Anthropic 或 Google Vertex 接口地址和凭据,测试成功后再添加模型。");
10625
10743
  }
10626
10744
 
10745
+ async function deletePlatformModel(item) {
10746
+ if (!item) return;
10747
+ $("#form-dialog").close();
10748
+ if (!await confirmToast(`确认删除模型“${item.displayName}”吗?删除后将从当前供应商的模型列表移除。`, {
10749
+ title: "删除模型",
10750
+ confirmLabel: "继续删除"
10751
+ })) return;
10752
+ if (!await confirmToast(`删除模型“${item.displayName}”后,相关默认模型设置会被清空,且无法恢复。仍要删除吗?`, {
10753
+ title: "删除操作需要再次确认",
10754
+ confirmLabel: "确认删除"
10755
+ })) return;
10756
+ try {
10757
+ await api(`/api/models/${encodeURIComponent(item.id)}`, { method: "DELETE" });
10758
+ await renderPlatformAiConfig();
10759
+ await loadModels();
10760
+ deleteToast(`已删除模型“${item.displayName}”`);
10761
+ } catch (error) {
10762
+ toast(error.message, "error");
10763
+ }
10764
+ }
10765
+
10766
+ async function deletePlatformProvider(item) {
10767
+ if (!item) return;
10768
+ $("#form-dialog").close();
10769
+ if (!await confirmToast(`确认删除供应商“${item.name}”吗?该供应商下的模型也会一并删除。`, {
10770
+ title: "删除供应商",
10771
+ confirmLabel: "继续删除"
10772
+ })) return;
10773
+ if (!await confirmToast(`删除供应商“${item.name}”后,供应商、模型及相关默认模型设置都无法恢复。仍要删除吗?`, {
10774
+ title: "删除操作需要再次确认",
10775
+ confirmLabel: "确认删除"
10776
+ })) return;
10777
+ try {
10778
+ await api(`/api/providers/${encodeURIComponent(item.id)}`, { method: "DELETE" });
10779
+ await renderPlatformAiConfig();
10780
+ await loadModels();
10781
+ deleteToast(`已删除供应商“${item.name}”`);
10782
+ } catch (error) {
10783
+ toast(error.message, "error");
10784
+ }
10785
+ }
10786
+
10627
10787
  function bindPlatformProviderActions(host, providers, models) {
10628
10788
  host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
10629
10789
  const providerId = button.dataset.testProvider;
@@ -11003,7 +11163,7 @@ async function renderPlatformAiConfig() {
11003
11163
  const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
11004
11164
  return `<option value="${esc(model.id)}" ${model.id === settings.imageToolModelId ? "selected" : ""} ${available || model.id === settings.imageToolModelId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
11005
11165
  }).join("");
11006
- host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section><section class="config-section platform-image-tool-section"><div class="config-section-header"><div><h2>多模态读图默认模型</h2><p>Agent 的 image 工具使用这里配置的模型读取设定库图片;作品可以在自己的 AI 设置中覆盖此选择。</p></div></div><div class="platform-image-tool-panel"><label class="platform-image-tool-field"><span>当前平台默认模型</span><select id="platform-image-tool-model" aria-label="平台多模态读图默认模型"><option value="">未配置</option>${imageModelOptions}</select></label><button id="save-platform-image-tool-model" class="ghost-button config-save-button" type="button">保存默认模型</button></div></section><section class="config-section platform-providers-section"><div class="config-section-header"><div><h2>模型供应商配置</h2><p>管理供应商连接、模型列表和连接状态;模型的多模态能力在对应模型配置中设置。</p></div></div>${renderProviderCards(providers, models)}</section>`;
11166
+ host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section><section class="config-section platform-image-tool-section"><div class="config-section-header"><div><h2>多模态读图默认模型</h2><p>Agent 的 image 工具使用这里配置的模型读取设定库图片;作品可以在自己的 AI 设置中覆盖此选择。</p></div></div><div class="platform-image-tool-panel"><label class="platform-image-tool-field"><span>当前平台默认模型</span><select id="platform-image-tool-model" aria-label="平台多模态读图默认模型"><option value="">未配置</option>${imageModelOptions}</select></label><button id="save-platform-image-tool-model" class="ghost-button config-save-button" type="button">保存默认模型</button></div></section><section class="config-section platform-stream-timeout-section"><div class="config-section-header"><div><h2>AI 流事件空闲超时</h2><p>首个流事件或相邻流事件在此时间内没有新数据时,请求会被关闭。默认 90 秒,最低 30 秒,最高 600 秒。</p></div></div><div class="platform-stream-timeout-panel"><label class="platform-stream-timeout-field"><span>超时时间(秒)</span><input id="platform-ai-stream-idle-timeout" type="number" min="30" max="600" step="1" value="${esc(String(settings.streamIdleTimeoutSeconds ?? 90))}" aria-label="AI 流事件空闲超时时间(秒)"></label><button id="save-platform-ai-stream-idle-timeout" class="ghost-button config-save-button" type="button">保存流超时设置</button></div></section><section class="config-section platform-providers-section"><div class="config-section-header"><div><h2>模型供应商配置</h2><p>管理供应商连接、模型列表和连接状态;模型的多模态能力在对应模型配置中设置。</p></div></div>${renderProviderCards(providers, models)}</section>`;
11007
11167
  $("#save-platform-system-prompt").addEventListener("click", async () => {
11008
11168
  const button = $("#save-platform-system-prompt");
11009
11169
  button.disabled = true;
@@ -11029,6 +11189,20 @@ async function renderPlatformAiConfig() {
11029
11189
  button.disabled = false;
11030
11190
  }
11031
11191
  });
11192
+ $("#save-platform-ai-stream-idle-timeout").addEventListener("click", async () => {
11193
+ const button = $("#save-platform-ai-stream-idle-timeout");
11194
+ const input = $("#platform-ai-stream-idle-timeout");
11195
+ button.disabled = true;
11196
+ try {
11197
+ await api("/api/platform/ai/settings", { method: "PATCH", body: { streamIdleTimeoutSeconds: Number(input.value) } });
11198
+ toast("AI 流事件空闲超时已更新");
11199
+ await renderPlatformAiConfig();
11200
+ } catch (error) {
11201
+ toast(error.message, "error");
11202
+ } finally {
11203
+ button.disabled = false;
11204
+ }
11205
+ });
11032
11206
  bindPlatformProviderActions(host, providers, models);
11033
11207
  }
11034
11208
 
@@ -14683,7 +14857,9 @@ function openProviderDialog(item) {
14683
14857
  await loadModels();
14684
14858
  if (item) toast(connectivityConfigurationSavedToast("provider"));
14685
14859
  },
14686
- item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
14860
+ item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex", {
14861
+ dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
14862
+ }
14687
14863
  );
14688
14864
  const protocolSelect = $("#dialog-fields select[name='protocol']");
14689
14865
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
@@ -14728,7 +14904,9 @@ function openModelDialog(providerId, item = null, provider = null) {
14728
14904
  await renderPlatformAiConfig();
14729
14905
  await loadModels();
14730
14906
  if (item) toast(connectivityConfigurationSavedToast("model"));
14731
- }, item ? "模型配置" : "供应商模型");
14907
+ }, item ? "模型配置" : "供应商模型", {
14908
+ dangerAction: item ? { label: "删除模型", onClick: () => deletePlatformModel(item) } : null
14909
+ });
14732
14910
  const modelIdInput = $("#dialog-fields input[name='modelId']");
14733
14911
  const contextWindowInput = $("#model-context-window");
14734
14912
  const contextWindowHint = $("#model-context-window-hint");
@@ -14794,20 +14972,23 @@ async function sendAi() {
14794
14972
  return sendAiWithOptions();
14795
14973
  }
14796
14974
 
14797
- async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14975
+ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
14798
14976
  if (!state.work) return toast("请先选择作品", "error");
14799
14977
  const tab = activeAiChatTab();
14800
14978
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
14801
14979
  if (aiRequestManager.hasActive(tab.id)) return;
14802
14980
  const composerSnapshot = captureAiPromptComposer();
14803
- const instruction = composerSnapshot.text.trim();
14981
+ const requestComposerSnapshot = retry
14982
+ ? { text: retry.prompt, citations: retry.citations ?? [], references: [] }
14983
+ : composerSnapshot;
14984
+ const instruction = requestComposerSnapshot.text.trim();
14804
14985
  if (!instruction) return toast("请输入指令", "error");
14805
14986
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
14806
14987
  const requestScope = currentAiRequestScope();
14807
14988
  if (!requestScope) return toast("请先选择章节", "error");
14808
14989
  const { taskType, scope, selection } = requestScope;
14809
14990
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
14810
- const citations = composerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
14991
+ const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
14811
14992
  const selectedTaskType = $("#ai-task").value;
14812
14993
  persistActiveAiChatTab();
14813
14994
  tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
@@ -14818,6 +14999,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14818
14999
  conversationId: state.aiConversationId
14819
15000
  })
14820
15001
  };
15002
+ if (retry?.userMessageId) {
15003
+ requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
15004
+ }
14821
15005
  setAiChatTabStatus(tab, "streaming");
14822
15006
  syncAiRequestControls();
14823
15007
  try {
@@ -14840,38 +15024,45 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14840
15024
  return toast(`对话配置锁定失败:${error.message}`, "error");
14841
15025
  }
14842
15026
  setAiChatTabStatus(tab, "streaming");
15027
+ if (retry?.message?.isConnected) retry.message.remove();
14843
15028
  if (taskType !== "chat") {
14844
- try {
14845
- const request = assertAiRequestCurrent(requestHolder.snapshot);
14846
- const persistedUserMessage = await persistAiConversationMessage(
14847
- request.conversationId,
14848
- "user",
14849
- instruction,
14850
- citations,
14851
- { modelId },
14852
- { signal: request.signal }
14853
- );
14854
- assertAiRequestCurrent(request);
14855
- updateAiConversationSummaryFromMessage(persistedUserMessage);
14856
- requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
14857
- tab.modelId = modelId;
14858
- tab.selectedModelId = modelId;
14859
- tab.promptSent = true;
14860
- clearAiChatTabComposer(tab);
14861
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
14862
- if (isActiveAiChatTab(tab)) {
14863
- state.aiConversationModelId = modelId;
14864
- state.aiPromptSent = true;
14865
- syncAiTaskOptions();
14866
- renderAiRoleplayCharacterSelect();
14867
- renderAiQuickActions();
14868
- clearAiPromptComposer();
15029
+ if (!retry) {
15030
+ try {
15031
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
15032
+ const persistedUserMessage = await persistAiConversationMessage(
15033
+ request.conversationId,
15034
+ "user",
15035
+ instruction,
15036
+ citations,
15037
+ { modelId },
15038
+ { signal: request.signal }
15039
+ );
15040
+ assertAiRequestCurrent(request);
15041
+ updateAiConversationSummaryFromMessage(persistedUserMessage);
15042
+ requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
15043
+ tab.modelId = modelId;
15044
+ tab.selectedModelId = modelId;
15045
+ tab.promptSent = true;
15046
+ clearAiChatTabComposer(tab);
15047
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
15048
+ if (isActiveAiChatTab(tab)) {
15049
+ state.aiConversationModelId = modelId;
15050
+ state.aiPromptSent = true;
15051
+ syncAiTaskOptions();
15052
+ renderAiRoleplayCharacterSelect();
15053
+ renderAiQuickActions();
15054
+ clearAiPromptComposer();
15055
+ }
15056
+ } catch (error) {
15057
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
15058
+ setAiChatTabStatus(tab, "error");
15059
+ return toast(`对话记录创建失败:${error.message}`, "error");
14869
15060
  }
14870
- } catch (error) {
14871
- if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
14872
- setAiChatTabStatus(tab, "error");
14873
- return toast(`对话记录创建失败:${error.message}`, "error");
15061
+ } else {
15062
+ prepareAiRetryState(tab, modelId);
14874
15063
  }
15064
+ } else if (retry) {
15065
+ prepareAiRetryState(tab, modelId);
14875
15066
  }
14876
15067
  let assistantContent = "";
14877
15068
  let assistantMessage;
@@ -14879,14 +15070,14 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14879
15070
  let persistedStreamMessage = null;
14880
15071
  let suggestion = null;
14881
15072
  if (taskType === "chat") {
14882
- const streamed = await streamChat(requestHolder, {
15073
+ const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
14883
15074
  instruction,
14884
15075
  scope,
14885
15076
  modelId,
14886
15077
  citations,
14887
15078
  conversationId: requestHolder.snapshot.conversationId,
14888
15079
  ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
14889
- }, createAiIdempotencyKey());
15080
+ }, retry), createAiIdempotencyKey());
14890
15081
  const request = assertAiRequestCurrent(requestHolder.snapshot);
14891
15082
  if (streamed.action === "warn") return;
14892
15083
  assistantContent = streamed.content;
@@ -14929,7 +15120,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14929
15120
  assistantContent,
14930
15121
  [],
14931
15122
  assistantMetadata,
14932
- { signal: request.signal, requestId: aiAssistantRequestId(request) }
15123
+ { signal: request.signal, requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
14933
15124
  );
14934
15125
  assertAiRequestCurrent(request);
14935
15126
  updateAiConversationSummaryFromMessage(persistedAssistantMessage);
@@ -14964,8 +15155,11 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14964
15155
  return;
14965
15156
  }
14966
15157
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
14967
- setAiChatTabComposerSnapshot(tab, composerSnapshot);
14968
- if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
15158
+ setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
15159
+ if (isActiveAiChatTab(tab)) {
15160
+ if (retry) restoreAiPromptComposer(requestComposerSnapshot);
15161
+ else restoreAiPromptComposer(composerSnapshot);
15162
+ }
14969
15163
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
14970
15164
  if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
14971
15165
  return;
@@ -15021,7 +15215,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
15021
15215
  failureMessage,
15022
15216
  [],
15023
15217
  {},
15024
- { requestId: aiAssistantRequestId(request) }
15218
+ { requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
15025
15219
  );
15026
15220
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
15027
15221
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -15168,7 +15362,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15168
15362
  tab.selectedModelId = lockedModelId;
15169
15363
  tab.promptSent = true;
15170
15364
  clearAiChatTabComposer(tab);
15171
- appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
15365
+ const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
15366
+ .find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
15367
+ if (!existingUserMessage) {
15368
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
15369
+ }
15172
15370
  if (isActiveAiChatTab(tab)) {
15173
15371
  state.aiConversationModelId = lockedModelId;
15174
15372
  state.aiPromptSent = true;
@@ -15389,7 +15587,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15389
15587
  if (role === "assistant") {
15390
15588
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
15391
15589
  }
15392
- if (role === "user") attachUserCopyAction(message, text);
15590
+ if (role === "user") {
15591
+ message.dataset.aiCitations = JSON.stringify(Array.isArray(citations) ? citations : []);
15592
+ attachUserCopyAction(message, text);
15593
+ }
15393
15594
  if (role === "assistant" && !text.startsWith("调用失败:")) {
15394
15595
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15395
15596
  const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
@@ -15403,6 +15604,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15403
15604
  message.append(meta);
15404
15605
  attachAssistantCopyAction(message, text);
15405
15606
  }
15607
+ if (isFailure) renderMessageCardActions(message);
15406
15608
  attachMessageIdentity(message, messageId);
15407
15609
  feed.append(message);
15408
15610
  scrollAiFeedToBottom(feed);
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=annotation-line-counts-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -1210,6 +1210,6 @@
1210
1210
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1211
1211
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1212
1212
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1213
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=phone-client-entry-v1"></script>
1213
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v1&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v1&feature=volume-detail-icon-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=phone-client-entry-v1&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v1&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1"></script>
1214
1214
  </body>
1215
1215
  </html>
@@ -1598,6 +1598,11 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1598
1598
  .platform-image-tool-field { display: grid; flex: 1 1 360px; gap: 7px; min-width: 0; color: var(--muted); font-size: 10px; }
1599
1599
  .platform-image-tool-field select { width: 100%; height: 36px; min-height: 36px; padding: 8px 10px; font-size: 12px; }
1600
1600
  .platform-image-tool-panel .config-save-button { flex: 0 0 auto; min-height: 36px; padding: 8px 14px; }
1601
+ .platform-stream-timeout-section { margin-bottom: 0; }
1602
+ .platform-stream-timeout-panel { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 16px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
1603
+ .platform-stream-timeout-field { display: grid; flex: 0 1 260px; gap: 7px; min-width: 0; color: var(--muted); font-size: 10px; }
1604
+ .platform-stream-timeout-field input { width: 100%; height: 36px; min-height: 36px; padding: 8px 10px; font-size: 12px; }
1605
+ .platform-stream-timeout-panel .config-save-button { flex: 0 0 auto; min-height: 36px; padding: 8px 14px; }
1601
1606
  .task-auto-run-panel {
1602
1607
  position: relative;
1603
1608
  display: grid;
@@ -2580,6 +2585,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2580
2585
  .platform-image-tool-field { flex: 0 0 auto; }
2581
2586
  .platform-image-tool-field select, .platform-image-tool-panel .config-save-button { height: 40px; min-height: 40px; }
2582
2587
  .platform-image-tool-panel .config-save-button { width: 100%; }
2588
+ .platform-stream-timeout-panel { align-items: stretch; flex-direction: column; }
2589
+ .platform-stream-timeout-field { flex: 0 0 auto; }
2590
+ .platform-stream-timeout-field input, .platform-stream-timeout-panel .config-save-button { height: 40px; min-height: 40px; }
2591
+ .platform-stream-timeout-panel .config-save-button { width: 100%; }
2583
2592
  }
2584
2593
  @media (max-width: 640px) {
2585
2594
  .config-inline-save .book-summary-context-percent-field,
@@ -4,7 +4,7 @@ import { basename, join } from "node:path";
4
4
  import { createRuntime } from "./app.js";
5
5
  import { resolveAiChatTabLimit } from "./ai-chat-tab-limit.js";
6
6
  import { resolveAiRetryPolicy } from "./ai-retry.js";
7
- import { resolveAiStreamIdleTimeoutMs } from "./ai-stream-timeout.js";
7
+ import { AI_STREAM_IDLE_TIMEOUT_SECONDS_ENV, resolveAiStreamIdleTimeoutMs } from "./ai-stream-timeout.js";
8
8
  import { DATABASE_SCHEMA_VERSION, readDatabaseSchemaVersion } from "./database.js";
9
9
  import { loadMasterSecret } from "./credential-vault.js";
10
10
  import { isDevelopmentAuthBypassEnabled, resolveRuntimeSecurity } from "./security.js";
@@ -204,7 +204,9 @@ export async function startLocalServer(options) {
204
204
  releaseCheckRetries: resolveReleaseCheckRetries(options.env.APP_UPDATE_CHECK_RETRIES),
205
205
  aiChatTabLimit: resolveAiChatTabLimit(options.env),
206
206
  aiRetryPolicy: resolveAiRetryPolicy(options.env),
207
- aiStreamIdleTimeoutMs: resolveAiStreamIdleTimeoutMs(options.env),
207
+ ...(options.env[AI_STREAM_IDLE_TIMEOUT_SECONDS_ENV]?.trim()
208
+ ? { aiStreamIdleTimeoutMs: resolveAiStreamIdleTimeoutMs(options.env) }
209
+ : {}),
208
210
  uploadLimits: resolveImageUploadLimits(options.env)
209
211
  });
210
212
  await runtime.cleanupAttachments();