@musnows/scriverse 0.8.3 → 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
  }
@@ -3255,6 +3373,10 @@ function renderAiRoleplayUserCharacterSelect() {
3255
3373
 
3256
3374
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
3257
3375
 
3376
+ function aiConversationModelLocked() {
3377
+ return typeof state.aiConversationModelId === "string" && state.aiConversationModelId.trim().length > 0;
3378
+ }
3379
+
3258
3380
  function selectedAiModelLabel() {
3259
3381
  return $("#ai-model").selectedOptions[0]?.textContent?.trim() || "尚未选择模型";
3260
3382
  }
@@ -3263,20 +3385,21 @@ function syncAiModelPicker() {
3263
3385
  const select = $("#ai-model");
3264
3386
  const button = $("#ai-model-picker");
3265
3387
  const interactionBusy = aiInteractionBusy();
3388
+ const modelLocked = aiConversationModelLocked();
3266
3389
  const selectedLabel = selectedAiModelLabel();
3267
- const label = state.aiPromptSent
3390
+ const label = modelLocked
3268
3391
  ? `当前模型:${selectedLabel}。${aiConversationOptionLockedMessage}`
3269
3392
  : `选择实际使用模型:${selectedLabel}`;
3270
3393
  select.disabled = interactionBusy;
3271
- select.title = state.aiPromptSent ? aiConversationOptionLockedMessage : "";
3394
+ select.title = modelLocked ? aiConversationOptionLockedMessage : "";
3272
3395
  button.disabled = interactionBusy;
3273
3396
  button.title = label;
3274
3397
  button.setAttribute("aria-label", label);
3275
3398
  if (interactionBusy) setAiModelPickerVisible(false);
3276
3399
  }
3277
3400
 
3278
- function notifyAiConversationOptionLocked(select) {
3279
- if (!state.aiPromptSent) return false;
3401
+ function notifyAiConversationOptionLocked(select, locked = state.aiPromptSent) {
3402
+ if (!locked) return false;
3280
3403
  const now = Date.now();
3281
3404
  const lastToastAt = Number(select.dataset.lockedToastAt ?? 0);
3282
3405
  if (now - lastToastAt > 500) toast(aiConversationOptionLockedMessage);
@@ -3284,9 +3407,16 @@ function notifyAiConversationOptionLocked(select) {
3284
3407
  return true;
3285
3408
  }
3286
3409
 
3410
+ function notifyAiConversationModelLocked(control) {
3411
+ return notifyAiConversationOptionLocked(control, aiConversationModelLocked());
3412
+ }
3413
+
3287
3414
  function blockLockedAiConversationOptionInteraction(event) {
3288
3415
  const select = event.currentTarget;
3289
- if (!notifyAiConversationOptionLocked(select)) return;
3416
+ const locked = select.id === "ai-model"
3417
+ ? notifyAiConversationModelLocked(select)
3418
+ : notifyAiConversationOptionLocked(select);
3419
+ if (!locked) return;
3290
3420
  event.preventDefault();
3291
3421
  event.stopPropagation();
3292
3422
  }
@@ -3311,7 +3441,7 @@ function syncAiTaskOptions() {
3311
3441
  $("#ai-scope").disabled = interactionBusy || roleplaySelected;
3312
3442
  $("#ai-scope").title = state.aiPromptSent
3313
3443
  ? aiConversationOptionLockedMessage
3314
- : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
3444
+ : roleplaySelected ? "角色扮演模式可以查询角色记忆、人物关系和故事正文" : "";
3315
3445
  syncAiModelPicker();
3316
3446
  }
3317
3447
 
@@ -10612,6 +10742,48 @@ function renderProviderCards(providers, models) {
10612
10742
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、Anthropic 或 Google Vertex 接口地址和凭据,测试成功后再添加模型。");
10613
10743
  }
10614
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
+
10615
10787
  function bindPlatformProviderActions(host, providers, models) {
10616
10788
  host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
10617
10789
  const providerId = button.dataset.testProvider;
@@ -10991,7 +11163,7 @@ async function renderPlatformAiConfig() {
10991
11163
  const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
10992
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>`;
10993
11165
  }).join("");
10994
- 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>`;
10995
11167
  $("#save-platform-system-prompt").addEventListener("click", async () => {
10996
11168
  const button = $("#save-platform-system-prompt");
10997
11169
  button.disabled = true;
@@ -11017,6 +11189,20 @@ async function renderPlatformAiConfig() {
11017
11189
  button.disabled = false;
11018
11190
  }
11019
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
+ });
11020
11206
  bindPlatformProviderActions(host, providers, models);
11021
11207
  }
11022
11208
 
@@ -14671,7 +14857,9 @@ function openProviderDialog(item) {
14671
14857
  await loadModels();
14672
14858
  if (item) toast(connectivityConfigurationSavedToast("provider"));
14673
14859
  },
14674
- item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
14860
+ item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex", {
14861
+ dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
14862
+ }
14675
14863
  );
14676
14864
  const protocolSelect = $("#dialog-fields select[name='protocol']");
14677
14865
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
@@ -14716,7 +14904,9 @@ function openModelDialog(providerId, item = null, provider = null) {
14716
14904
  await renderPlatformAiConfig();
14717
14905
  await loadModels();
14718
14906
  if (item) toast(connectivityConfigurationSavedToast("model"));
14719
- }, item ? "模型配置" : "供应商模型");
14907
+ }, item ? "模型配置" : "供应商模型", {
14908
+ dangerAction: item ? { label: "删除模型", onClick: () => deletePlatformModel(item) } : null
14909
+ });
14720
14910
  const modelIdInput = $("#dialog-fields input[name='modelId']");
14721
14911
  const contextWindowInput = $("#model-context-window");
14722
14912
  const contextWindowHint = $("#model-context-window-hint");
@@ -14782,20 +14972,23 @@ async function sendAi() {
14782
14972
  return sendAiWithOptions();
14783
14973
  }
14784
14974
 
14785
- async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14975
+ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
14786
14976
  if (!state.work) return toast("请先选择作品", "error");
14787
14977
  const tab = activeAiChatTab();
14788
14978
  if (!tab) return toast("Agent 对话页签尚未就绪", "error");
14789
14979
  if (aiRequestManager.hasActive(tab.id)) return;
14790
14980
  const composerSnapshot = captureAiPromptComposer();
14791
- 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();
14792
14985
  if (!instruction) return toast("请输入指令", "error");
14793
14986
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
14794
14987
  const requestScope = currentAiRequestScope();
14795
14988
  if (!requestScope) return toast("请先选择章节", "error");
14796
14989
  const { taskType, scope, selection } = requestScope;
14797
14990
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
14798
- 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 }));
14799
14992
  const selectedTaskType = $("#ai-task").value;
14800
14993
  persistActiveAiChatTab();
14801
14994
  tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
@@ -14806,6 +14999,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14806
14999
  conversationId: state.aiConversationId
14807
15000
  })
14808
15001
  };
15002
+ if (retry?.userMessageId) {
15003
+ requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
15004
+ }
14809
15005
  setAiChatTabStatus(tab, "streaming");
14810
15006
  syncAiRequestControls();
14811
15007
  try {
@@ -14828,38 +15024,45 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14828
15024
  return toast(`对话配置锁定失败:${error.message}`, "error");
14829
15025
  }
14830
15026
  setAiChatTabStatus(tab, "streaming");
15027
+ if (retry?.message?.isConnected) retry.message.remove();
14831
15028
  if (taskType !== "chat") {
14832
- try {
14833
- const request = assertAiRequestCurrent(requestHolder.snapshot);
14834
- const persistedUserMessage = await persistAiConversationMessage(
14835
- request.conversationId,
14836
- "user",
14837
- instruction,
14838
- citations,
14839
- { modelId },
14840
- { signal: request.signal }
14841
- );
14842
- assertAiRequestCurrent(request);
14843
- updateAiConversationSummaryFromMessage(persistedUserMessage);
14844
- requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
14845
- tab.modelId = modelId;
14846
- tab.selectedModelId = modelId;
14847
- tab.promptSent = true;
14848
- clearAiChatTabComposer(tab);
14849
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
14850
- if (isActiveAiChatTab(tab)) {
14851
- state.aiConversationModelId = modelId;
14852
- state.aiPromptSent = true;
14853
- syncAiTaskOptions();
14854
- renderAiRoleplayCharacterSelect();
14855
- renderAiQuickActions();
14856
- 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");
14857
15060
  }
14858
- } catch (error) {
14859
- if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
14860
- setAiChatTabStatus(tab, "error");
14861
- return toast(`对话记录创建失败:${error.message}`, "error");
15061
+ } else {
15062
+ prepareAiRetryState(tab, modelId);
14862
15063
  }
15064
+ } else if (retry) {
15065
+ prepareAiRetryState(tab, modelId);
14863
15066
  }
14864
15067
  let assistantContent = "";
14865
15068
  let assistantMessage;
@@ -14867,14 +15070,14 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14867
15070
  let persistedStreamMessage = null;
14868
15071
  let suggestion = null;
14869
15072
  if (taskType === "chat") {
14870
- const streamed = await streamChat(requestHolder, {
15073
+ const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
14871
15074
  instruction,
14872
15075
  scope,
14873
15076
  modelId,
14874
15077
  citations,
14875
15078
  conversationId: requestHolder.snapshot.conversationId,
14876
15079
  ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
14877
- }, createAiIdempotencyKey());
15080
+ }, retry), createAiIdempotencyKey());
14878
15081
  const request = assertAiRequestCurrent(requestHolder.snapshot);
14879
15082
  if (streamed.action === "warn") return;
14880
15083
  assistantContent = streamed.content;
@@ -14917,7 +15120,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14917
15120
  assistantContent,
14918
15121
  [],
14919
15122
  assistantMetadata,
14920
- { signal: request.signal, requestId: aiAssistantRequestId(request) }
15123
+ { signal: request.signal, requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
14921
15124
  );
14922
15125
  assertAiRequestCurrent(request);
14923
15126
  updateAiConversationSummaryFromMessage(persistedAssistantMessage);
@@ -14952,8 +15155,11 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
14952
15155
  return;
14953
15156
  }
14954
15157
  if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
14955
- setAiChatTabComposerSnapshot(tab, composerSnapshot);
14956
- if (isActiveAiChatTab(tab)) restoreAiPromptComposer(composerSnapshot);
15158
+ setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
15159
+ if (isActiveAiChatTab(tab)) {
15160
+ if (retry) restoreAiPromptComposer(requestComposerSnapshot);
15161
+ else restoreAiPromptComposer(composerSnapshot);
15162
+ }
14957
15163
  toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
14958
15164
  if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
14959
15165
  return;
@@ -15009,7 +15215,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
15009
15215
  failureMessage,
15010
15216
  [],
15011
15217
  {},
15012
- { requestId: aiAssistantRequestId(request) }
15218
+ { requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
15013
15219
  );
15014
15220
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
15015
15221
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -15032,13 +15238,9 @@ function createAiStreamCharacterCount(value) {
15032
15238
  return count;
15033
15239
  }
15034
15240
 
15035
- function renderAiStreamingCharacterProgress(meta, visibleCharacters, receivedCharacters) {
15241
+ function renderAiStreamingCharacterProgress(meta, visibleCharacters) {
15036
15242
  const visible = Math.max(0, Number(visibleCharacters) || 0);
15037
- const received = Math.max(visible, Number(receivedCharacters) || 0);
15038
- const children = ["正在生成 · ", createAiStreamCharacterCount(visible)];
15039
- if (received > visible) children.push(" / ", createAiStreamCharacterCount(received));
15040
- children.push(" 字");
15041
- meta.replaceChildren(...children);
15243
+ meta.replaceChildren("正在生成 · ", createAiStreamCharacterCount(visible), " 字");
15042
15244
  }
15043
15245
 
15044
15246
  async function streamChat(requestHolder, body, idempotencyKey) {
@@ -15067,8 +15269,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15067
15269
  onRender: (text, progress) => {
15068
15270
  if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
15069
15271
  content.innerHTML = renderMarkdown(text);
15070
- const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
15071
- renderAiStreamingCharacterProgress(meta, progress.visibleCharacters, receivedCharacters);
15272
+ renderAiStreamingCharacterProgress(meta, progress.visibleCharacters);
15072
15273
  scrollAiFeedToBottom(feed);
15073
15274
  }
15074
15275
  });
@@ -15161,7 +15362,11 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15161
15362
  tab.selectedModelId = lockedModelId;
15162
15363
  tab.promptSent = true;
15163
15364
  clearAiChatTabComposer(tab);
15164
- 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
+ }
15165
15370
  if (isActiveAiChatTab(tab)) {
15166
15371
  state.aiConversationModelId = lockedModelId;
15167
15372
  state.aiPromptSent = true;
@@ -15178,7 +15383,6 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15178
15383
  streamedText += delta;
15179
15384
  streamedPendingText += delta;
15180
15385
  typewriter.append(delta);
15181
- meta.textContent = "正在生成回复……";
15182
15386
  } else if (eventName === "process_step") {
15183
15387
  mountAssistantMessage();
15184
15388
  const step = { ...payload };
@@ -15383,7 +15587,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15383
15587
  if (role === "assistant") {
15384
15588
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
15385
15589
  }
15386
- 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
+ }
15387
15594
  if (role === "assistant" && !text.startsWith("调用失败:")) {
15388
15595
  const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
15389
15596
  const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
@@ -15397,6 +15604,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15397
15604
  message.append(meta);
15398
15605
  attachAssistantCopyAction(message, text);
15399
15606
  }
15607
+ if (isFailure) renderMessageCardActions(message);
15400
15608
  attachMessageIdentity(message, messageId);
15401
15609
  feed.append(message);
15402
15610
  scrollAiFeedToBottom(feed);
@@ -16996,7 +17204,7 @@ $("#ai-model").addEventListener("focus", () => {
16996
17204
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
16997
17205
  });
16998
17206
  $("#ai-model").addEventListener("change", (event) => {
16999
- if (state.aiPromptSent) {
17207
+ if (aiConversationModelLocked()) {
17000
17208
  event.currentTarget.value = state.aiConversationModelId ?? event.currentTarget.value;
17001
17209
  syncAiModelPicker();
17002
17210
  return toast(aiConversationOptionLockedMessage);
@@ -17007,7 +17215,7 @@ $("#ai-model").addEventListener("change", (event) => {
17007
17215
  });
17008
17216
  $("#ai-model-picker").addEventListener("click", async (event) => {
17009
17217
  const button = event.currentTarget;
17010
- if (notifyAiConversationOptionLocked(button)) return;
17218
+ if (notifyAiConversationModelLocked(button)) return;
17011
17219
  const willOpen = $("#ai-model-popover").classList.contains("hidden");
17012
17220
  setAiContextDistributionVisible(false);
17013
17221
  setAiModelPickerVisible(willOpen);
@@ -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=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v1&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>