@musnows/scriverse 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,21 @@
1
- export function formatAiMessageMeta(modelDisplayName, outputTokens, cacheHitPercent, suffix = "") {
1
+ export function resolveAiTokensPerSecond(outputTokens, durationMs) {
2
+ const tokenCount = Number(outputTokens);
3
+ const duration = Number(durationMs);
4
+ if (!Number.isFinite(tokenCount) || tokenCount < 0 || !Number.isFinite(duration) || duration <= 0) return null;
5
+ return Math.max(0, Math.round(tokenCount / (duration / 1000)));
6
+ }
7
+
8
+ export function formatAiMessageMeta(modelDisplayName, outputTokens, cacheHitPercent, suffix = "", durationMs) {
2
9
  const modelName = String(modelDisplayName || "模型").trim();
3
10
  const tokenCount = Math.max(0, Math.round(Number(outputTokens) || 0)).toLocaleString("zh-CN");
11
+ const tokensPerSecond = resolveAiTokensPerSecond(outputTokens, durationMs);
4
12
  const cachePercent = Number(cacheHitPercent);
5
13
  const roundedCachePercent = Math.round((cachePercent + Number.EPSILON) * 10) / 10;
6
14
  const cacheLabel = Number.isFinite(cachePercent)
7
15
  ? `缓存命中 ${Math.max(0, Math.min(100, roundedCachePercent)).toLocaleString("zh-CN")}%`
8
16
  : "";
9
- return [modelName, `${tokenCount} tok`, cacheLabel, String(suffix || "").trim()].filter(Boolean).join(" · ");
17
+ const speedLabel = tokensPerSecond === null ? "" : `${tokensPerSecond.toLocaleString("zh-CN")}\u00a0tok/s`;
18
+ return [modelName, `${tokenCount} tok`, speedLabel, cacheLabel, String(suffix || "").trim()].filter(Boolean).join(" · ");
10
19
  }
11
20
 
12
21
  export function estimateAiMessageTokens(value) {
@@ -18,7 +18,7 @@ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260
18
18
  import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
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
- import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
21
+ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
22
22
  import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
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";
@@ -156,6 +156,7 @@ const state = {
156
156
  aiTaskType: "chat",
157
157
  aiContextScope: { type: "none" },
158
158
  aiConversationId: null,
159
+ aiConversationModelId: null,
159
160
  aiConversations: [],
160
161
  aiRoleplayCharacter: null,
161
162
  aiLastMessageAt: null,
@@ -2324,6 +2325,7 @@ async function openAiConversation(conversationId, hideHistory = true, focusMessa
2324
2325
  if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
2325
2326
  upsertAiConversationSummary(conversation);
2326
2327
  state.aiConversationId = conversation.id;
2328
+ state.aiConversationModelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
2327
2329
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
2328
2330
  applyAiConversationTaskType(conversation.taskType);
2329
2331
  applyAiConversationContextScope(conversation.contextScope);
@@ -2366,6 +2368,7 @@ function focusAiConversationMessage(messageId) {
2366
2368
  function applyNewAiConversation(conversation) {
2367
2369
  upsertAiConversationSummary(conversation);
2368
2370
  state.aiConversationId = conversation.id;
2371
+ state.aiConversationModelId = null;
2369
2372
  state.aiPromptSent = false;
2370
2373
  applyAiConversationTaskType(conversation.taskType);
2371
2374
  applyAiConversationContextScope(conversation.contextScope);
@@ -2401,7 +2404,7 @@ function renderAiRoleplayCharacterSelect() {
2401
2404
  const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
2402
2405
  const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
2403
2406
  id: String(character.id),
2404
- name: String(character.name)
2407
+ name: `${String(character.name)}${character.isDead ? "(已死亡)" : ""}`
2405
2408
  }))];
2406
2409
  if (selectedId && !options.some((option) => option.id === selectedId)) {
2407
2410
  options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
@@ -2435,6 +2438,8 @@ function syncAiTaskOptions() {
2435
2438
  $("#ai-scope").title = state.aiPromptSent
2436
2439
  ? "对话开始后不能切换上下文引用"
2437
2440
  : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
2441
+ $("#ai-model").disabled = interactionBusy || state.aiPromptSent;
2442
+ $("#ai-model").title = state.aiPromptSent ? "对话开始后不能切换模型" : "";
2438
2443
  }
2439
2444
 
2440
2445
  function applyAiConversationTaskType(taskType) {
@@ -3597,7 +3602,8 @@ async function refreshAuthCaptcha(target = "login") {
3597
3602
  }
3598
3603
 
3599
3604
  function clearAuthenticationOverlays() {
3600
- clearToastRegion();
3605
+ const toastRegion = $("#toast-region");
3606
+ toastRegion.replaceChildren();
3601
3607
  document.querySelectorAll("[popover]").forEach((popover) => {
3602
3608
  if (typeof popover.hidePopover === "function" && popover.matches(":popover-open")) popover.hidePopover();
3603
3609
  });
@@ -3739,47 +3745,27 @@ function raiseToastRegion() {
3739
3745
  region.showPopover();
3740
3746
  }
3741
3747
 
3742
- const TOAST_DURATION_MS = 15_000;
3743
- const toastTimers = new Map();
3744
-
3745
- function hideToastRegionIfEmpty() {
3748
+ function dismissDeleteToasts() {
3746
3749
  const region = $("#toast-region");
3750
+ region.querySelectorAll(".delete-toast").forEach((element) => element.remove());
3747
3751
  if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
3748
3752
  region.hidePopover();
3749
3753
  }
3750
3754
  }
3751
3755
 
3752
- function removeToastElement(element) {
3753
- const timer = toastTimers.get(element);
3754
- if (timer !== undefined) {
3755
- window.clearTimeout(timer);
3756
- toastTimers.delete(element);
3757
- }
3758
- element.remove();
3759
- hideToastRegionIfEmpty();
3760
- }
3761
-
3762
- function clearToastRegion() {
3763
- toastTimers.forEach((timer) => window.clearTimeout(timer));
3764
- toastTimers.clear();
3765
- $("#toast-region").replaceChildren();
3766
- hideToastRegionIfEmpty();
3767
- }
3768
-
3769
- function clearTransientToasts() {
3770
- const region = $("#toast-region");
3771
- [...region.children]
3772
- .filter((element) => !element.classList.contains("toast-confirmation") && !element.classList.contains("chapter-insight-toast"))
3773
- .forEach(removeToastElement);
3756
+ function deleteToast(message) {
3757
+ toast(message);
3758
+ $("#toast-region").lastElementChild?.classList.add("delete-toast");
3774
3759
  }
3775
3760
 
3776
3761
  function dismissChapterInsightToast() {
3777
3762
  chapterInsightRequestId += 1;
3778
3763
  const region = $("#toast-region");
3779
- const element = region.querySelector(".chapter-insight-toast");
3780
- if (element) removeToastElement(element);
3764
+ region.querySelector(".chapter-insight-toast")?.remove();
3781
3765
  $("#insight-button").setAttribute("aria-expanded", "false");
3782
- hideToastRegionIfEmpty();
3766
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
3767
+ region.hidePopover();
3768
+ }
3783
3769
  }
3784
3770
 
3785
3771
  function toast(message, type = "info") {
@@ -3792,7 +3778,12 @@ function toast(message, type = "info") {
3792
3778
  element.textContent = message;
3793
3779
  region.append(element);
3794
3780
  raiseToastRegion();
3795
- toastTimers.set(element, window.setTimeout(() => removeToastElement(element), TOAST_DURATION_MS));
3781
+ setTimeout(() => {
3782
+ element.remove();
3783
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
3784
+ region.hidePopover();
3785
+ }
3786
+ }, 3600);
3796
3787
  }
3797
3788
 
3798
3789
  async function runEntityEditorSave({ busyTarget, button, prepare, save }) {
@@ -4290,7 +4281,7 @@ async function initializePage() {
4290
4281
  function showShelf() {
4291
4282
  stopBackgroundTaskCenter();
4292
4283
  dismissChapterInsightToast();
4293
- clearTransientToasts();
4284
+ dismissDeleteToasts();
4294
4285
  state.dirty = false;
4295
4286
  settingsReturnContext = null;
4296
4287
  updateDocumentTitle();
@@ -4591,7 +4582,7 @@ async function showWorkAudit() {
4591
4582
  workAuditRecords = [];
4592
4583
  workAuditNextPage = null;
4593
4584
  dismissChapterInsightToast();
4594
- clearTransientToasts();
4585
+ dismissDeleteToasts();
4595
4586
  updateDocumentTitle(state.work);
4596
4587
  $("#app").classList.add("shelf-mode");
4597
4588
  $("#shelf-view").classList.add("hidden");
@@ -4769,7 +4760,7 @@ function renderS3BackupTargets() {
4769
4760
  try {
4770
4761
  await api(`/api/platform/backups/targets/${encodeURIComponent(target.id)}`, { method: "DELETE" });
4771
4762
  await loadS3BackupData();
4772
- toast(`备份目标“${target.name}”已删除`);
4763
+ deleteToast(`备份目标“${target.name}”已删除`);
4773
4764
  } catch (error) {
4774
4765
  button.disabled = false;
4775
4766
  toast(error.message, "error");
@@ -5137,7 +5128,7 @@ function renderMembers(members) {
5137
5128
  const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
5138
5129
  renderMembers(updated);
5139
5130
  renderMemberSelector();
5140
- toast("协作者已移除");
5131
+ deleteToast("协作者已移除");
5141
5132
  } catch (error) { toast(error.message, "error"); }
5142
5133
  }));
5143
5134
  }
@@ -5514,7 +5505,7 @@ async function showSettingsHub() {
5514
5505
  state.dirty = false;
5515
5506
  }
5516
5507
  dismissChapterInsightToast();
5517
- clearTransientToasts();
5508
+ dismissDeleteToasts();
5518
5509
  updateDocumentTitle(state.work);
5519
5510
  $("#app").classList.add("shelf-mode");
5520
5511
  $("#shelf-view").classList.add("hidden");
@@ -5562,7 +5553,7 @@ async function showPlatformAi() {
5562
5553
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?"))) return false;
5563
5554
  state.dirty = false;
5564
5555
  dismissChapterInsightToast();
5565
- clearTransientToasts();
5556
+ dismissDeleteToasts();
5566
5557
  updateDocumentTitle();
5567
5558
  $("#app").classList.add("shelf-mode");
5568
5559
  $("#shelf-view").classList.add("hidden");
@@ -5585,7 +5576,7 @@ async function showPlatformUsage() {
5585
5576
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 Token 用量面板将放弃本地修改。是否继续?"))) return false;
5586
5577
  state.dirty = false;
5587
5578
  dismissChapterInsightToast();
5588
- clearTransientToasts();
5579
+ dismissDeleteToasts();
5589
5580
  updateDocumentTitle();
5590
5581
  $("#app").classList.add("shelf-mode");
5591
5582
  $("#shelf-view").classList.add("hidden");
@@ -5681,6 +5672,7 @@ function resetWorkScopedUiCaches() {
5681
5672
  state.aiReferences = [];
5682
5673
  state.aiPromptSent = false;
5683
5674
  state.aiConversationId = null;
5675
+ state.aiConversationModelId = null;
5684
5676
  state.aiConversations = [];
5685
5677
  state.aiRoleplayCharacter = null;
5686
5678
  renderAiCitations();
@@ -6140,7 +6132,7 @@ async function deleteChapter(chapterId) {
6140
6132
  } else {
6141
6133
  renderTree();
6142
6134
  }
6143
- toast(`已删除章节“${chapter.title}”,正文和版本记录已保留`);
6135
+ deleteToast(`已删除章节“${chapter.title}”,正文和版本记录已保留`);
6144
6136
  } catch (error) {
6145
6137
  resumeAutoSave();
6146
6138
  toast(error.message, "error");
@@ -6349,6 +6341,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6349
6341
  if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
6350
6342
  return false;
6351
6343
  }
6344
+ dismissDeleteToasts();
6352
6345
  if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6353
6346
  cancelChapterAutoSave();
6354
6347
  let selectedChapter = state.chapter;
@@ -6380,7 +6373,6 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
6380
6373
  clearChapterLineSelection();
6381
6374
  scheduleChapterLineNumbers();
6382
6375
  dismissChapterInsightToast();
6383
- clearTransientToasts();
6384
6376
  updateChapterStats();
6385
6377
  if (!canEditProse()) setSaveState("正文只读");
6386
6378
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
@@ -6899,7 +6891,7 @@ function showWelcome(hasWork = false) {
6899
6891
  chapterSelectionRequestId += 1;
6900
6892
  clearChapterForeshadowReminders({ invalidateRequest: true });
6901
6893
  dismissChapterInsightToast();
6902
- clearTransientToasts();
6894
+ dismissDeleteToasts();
6903
6895
  $("#editor-view").classList.add("hidden");
6904
6896
  $("#module-view").classList.add("hidden");
6905
6897
  $("#welcome-view").classList.remove("hidden");
@@ -6944,6 +6936,7 @@ async function showModule(module) {
6944
6936
  }
6945
6937
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
6946
6938
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
6939
+ dismissDeleteToasts();
6947
6940
  state.module = module;
6948
6941
  if (module !== "editor") {
6949
6942
  chapterSelectionRequestId += 1;
@@ -6963,7 +6956,6 @@ async function showModule(module) {
6963
6956
  }
6964
6957
  showSystemStatus();
6965
6958
  dismissChapterInsightToast();
6966
- clearTransientToasts();
6967
6959
  $("#welcome-view").classList.add("hidden");
6968
6960
  $("#editor-view").classList.add("hidden");
6969
6961
  $("#module-view").classList.remove("hidden");
@@ -7143,7 +7135,7 @@ async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning
7143
7135
  await onDeleted?.();
7144
7136
  await refresh();
7145
7137
  await loadAiReferences();
7146
- toast(`已删除${typeLabel}“${item.name}”`);
7138
+ deleteToast(`已删除${typeLabel}“${item.name}”`);
7147
7139
  } catch (error) {
7148
7140
  toast(error.message, "error");
7149
7141
  }
@@ -7445,28 +7437,17 @@ async function deleteDraft(item) {
7445
7437
  if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
7446
7438
  title: "删除想法",
7447
7439
  confirmLabel: "继续删除"
7448
- })) {
7449
- openDraftDialog(item);
7450
- return;
7451
- }
7440
+ })) return;
7452
7441
  if (!await confirmToast(`删除想法“${item.title}”后将从想法列表移除,版本历史仍会保留。仍要删除吗?`, {
7453
7442
  title: "删除操作需要再次确认",
7454
7443
  confirmLabel: "确认删除"
7455
- })) {
7456
- openDraftDialog(item);
7457
- return;
7458
- }
7444
+ })) return;
7459
7445
  try {
7460
7446
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
7461
7447
  await renderDrafts(moduleListPages.drafts);
7462
- toast("想法已删除");
7448
+ deleteToast("想法已删除");
7463
7449
  } catch (error) {
7464
7450
  toast(error.message, "error");
7465
- try {
7466
- openDraftDialog(await api(`/api/drafts/${encodeURIComponent(item.id)}`));
7467
- } catch (reloadError) {
7468
- toast(reloadError.message, "error");
7469
- }
7470
7451
  }
7471
7452
  }
7472
7453
 
@@ -7474,19 +7455,14 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
7474
7455
  const viewOnly = readOnly || !canEditModule("drafts");
7475
7456
  const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
7476
7457
  const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
7477
- const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
7478
- <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
7479
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
7480
- </section>` : "";
7481
7458
  const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
7482
7459
  + `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
7483
7460
  + `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
7484
- + field("title", "标题", "text", item?.title ?? "")
7485
7461
  + field("content", "内容", "markdown", item?.content ?? "", {
7486
7462
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
7487
7463
  attachmentModule: "drafts",
7488
7464
  readOnly: viewOnly
7489
- }) + management;
7465
+ });
7490
7466
  openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
7491
7467
  if (viewOnly) return;
7492
7468
  const title = String(form.get("title") ?? "").trim();
@@ -7511,7 +7487,9 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
7511
7487
  hideCancel: viewOnly,
7512
7488
  editor: true,
7513
7489
  errorPrefix: "想法保存失败:",
7514
- meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
7490
+ meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。",
7491
+ titleInput: { value: item?.title ?? "", readOnly: viewOnly, placeholder: "输入想法标题", label: "想法标题" },
7492
+ dangerAction: item && !viewOnly ? { label: "删除想法", onClick: () => deleteDraft(item) } : null
7515
7493
  });
7516
7494
  const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
7517
7495
  const syncDraftBindingFields = () => {
@@ -7531,9 +7509,6 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
7531
7509
  else control.readOnly = true;
7532
7510
  });
7533
7511
  }
7534
- $("#dialog-fields").querySelector("[data-dialog-draft-delete]")?.addEventListener("click", () => {
7535
- void deleteDraft(item);
7536
- });
7537
7512
  }
7538
7513
 
7539
7514
  async function renderDrafts(page = moduleListPages.drafts) {
@@ -8015,7 +7990,7 @@ async function deleteTimelineEvent(item, page = moduleListPages.timeline) {
8015
7990
  try {
8016
7991
  await api(`/api/timeline/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
8017
7992
  await renderTimeline(page);
8018
- toast(`已删除时间事件“${item.name}”`);
7993
+ deleteToast(`已删除时间事件“${item.name}”`);
8019
7994
  } catch (error) {
8020
7995
  toast(error.message, "error");
8021
7996
  }
@@ -8075,6 +8050,7 @@ async function renderTimeline(page = moduleListPages.timeline) {
8075
8050
  $("#module-content").querySelectorAll("[data-timeline-track-tab]").forEach((button) => button.addEventListener("click", async () => {
8076
8051
  const nextTrackId = button.dataset.timelineTrackTab ?? "";
8077
8052
  if (String(nextTrackId) === String(timelineActiveTrackId)) return;
8053
+ dismissDeleteToasts();
8078
8054
  timelineActiveTrackId = nextTrackId;
8079
8055
  await renderTimeline(1);
8080
8056
  }));
@@ -10498,7 +10474,11 @@ function applyAiModels(models) {
10498
10474
  select.innerHTML = state.models.length
10499
10475
  ? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
10500
10476
  : '<option value="">请先配置模型</option>';
10477
+ if (state.aiConversationModelId && state.models.some((model) => model.id === state.aiConversationModelId)) {
10478
+ select.value = state.aiConversationModelId;
10479
+ }
10501
10480
  setAiContextMeter(null);
10481
+ syncAiTaskOptions();
10502
10482
  }
10503
10483
 
10504
10484
  async function ensureAiModelsLoaded() {
@@ -10866,10 +10846,18 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
10866
10846
  const submit = $("#dialog-submit");
10867
10847
  const submitStatus = $("#dialog-submit-status");
10868
10848
  const submitStatusMessage = $("#dialog-submit-status-message");
10849
+ const titleInput = $("#dialog-title-input");
10869
10850
  const submitLabel = options.submitLabel ?? "保存";
10870
10851
  let submitting = false;
10871
10852
  let disabledStates = [];
10872
10853
  $("#dialog-title").textContent = title;
10854
+ $("#dialog-title").classList.toggle("hidden", Boolean(options.titleInput));
10855
+ titleInput.classList.toggle("hidden", !options.titleInput);
10856
+ titleInput.name = options.titleInput ? "title" : "";
10857
+ titleInput.value = options.titleInput ? String(options.titleInput.value ?? "") : "";
10858
+ titleInput.placeholder = options.titleInput?.placeholder ?? "";
10859
+ titleInput.readOnly = Boolean(options.titleInput?.readOnly);
10860
+ titleInput.setAttribute("aria-label", options.titleInput?.label ?? "记录标题");
10873
10861
  $("#dialog-eyebrow").textContent = eyebrow;
10874
10862
  $("#dialog-meta").textContent = options.meta ?? "";
10875
10863
  $("#dialog-meta").classList.toggle("hidden", !options.meta);
@@ -10880,6 +10868,17 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
10880
10868
  form.classList.remove("is-submitting");
10881
10869
  form.removeAttribute("aria-busy");
10882
10870
  $("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
10871
+ const dialogActions = form.querySelector(".dialog-actions");
10872
+ dialogActions?.querySelector("[data-dialog-danger-action]")?.remove();
10873
+ if (options.dangerAction) {
10874
+ const dangerButton = document.createElement("button");
10875
+ dangerButton.className = "danger-button dialog-danger-action";
10876
+ dangerButton.type = "button";
10877
+ dangerButton.dataset.dialogDangerAction = "";
10878
+ dangerButton.textContent = options.dangerAction.label;
10879
+ dangerButton.addEventListener("click", () => { void options.dangerAction.onClick(); });
10880
+ dialogActions?.insertBefore(dangerButton, dialogActions.querySelector("[value='cancel']"));
10881
+ }
10883
10882
  dialog.classList.toggle("wide-dialog", Boolean(options.wide));
10884
10883
  dialog.classList.toggle("trace-dialog", Boolean(options.trace));
10885
10884
  dialog.classList.toggle("large-dialog", Boolean(options.large));
@@ -11006,7 +11005,7 @@ function bindWorkCoverControls(work) {
11006
11005
  Object.assign(work, updated);
11007
11006
  refreshWorkCoverField(work);
11008
11007
  renderShelf();
11009
- toast("封面已移除");
11008
+ deleteToast("封面已移除");
11010
11009
  } catch (error) {
11011
11010
  toast(error.message, "error");
11012
11011
  }
@@ -11193,7 +11192,7 @@ async function deleteWork(work) {
11193
11192
  state.chapter = null;
11194
11193
  }
11195
11194
  await loadWorks();
11196
- toast(`作品“${work.title}”已移入回收站`);
11195
+ deleteToast(`作品“${work.title}”已移入回收站`);
11197
11196
  } catch (error) {
11198
11197
  if (deletingCurrentWork && state.dirty) scheduleChapterAutoSave();
11199
11198
  toast(error.message, "error");
@@ -11263,7 +11262,7 @@ async function deleteVolume(item) {
11263
11262
  state.collapsedVolumeIds.delete(item.id);
11264
11263
  state.work = await api(`/api/works/${state.work.id}`);
11265
11264
  renderTree();
11266
- toast(`分卷“${item.title}”已移入回收站`);
11265
+ deleteToast(`分卷“${item.title}”已移入回收站`);
11267
11266
  } catch (error) {
11268
11267
  toast(error.message, "error");
11269
11268
  openVolumeDialog(item);
@@ -11956,7 +11955,7 @@ function knowledgeSectionEditorHtml(section = null) {
11956
11955
  <div><span class="eyebrow">${label} Markdown 设定</span><input id="knowledge-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建设定" aria-label="设定标题" required></div>
11957
11956
  <button class="entity-editor-back" type="button" data-knowledge-section-edit-close>返回设定列表</button>
11958
11957
  </header>
11959
- <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}${label} Markdown 设定">
11958
+ <section class="character-markdown-editor knowledge-markdown-editor" aria-label="${section ? "编辑" : "新建"}${label} Markdown 设定">
11960
11959
  <div id="knowledge-section-markdown" class="vditor-editor-host" data-vditor-editor aria-label="Markdown 编辑器"></div>
11961
11960
  <div class="character-markdown-editor-footer">
11962
11961
  <div class="character-markdown-editor-actions"><button type="button" data-knowledge-section-edit-cancel>取消</button><button type="button" class="primary-button" data-knowledge-section-edit-save>${section ? "保存设定" : "添加设定"}</button></div>
@@ -12232,7 +12231,7 @@ function renderCharacterMarkdownSections() {
12232
12231
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
12233
12232
  renderCharacterMarkdownSections();
12234
12233
  await Promise.all([renderCharacters(), loadAiReferences()]);
12235
- toast("人物 Markdown 章节已删除");
12234
+ deleteToast("人物 Markdown 章节已删除");
12236
12235
  } catch (error) {
12237
12236
  button.disabled = false;
12238
12237
  toast(error.message, "error");
@@ -12940,7 +12939,7 @@ async function openRelationshipDialog(item, options = {}) {
12940
12939
  try {
12941
12940
  await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
12942
12941
  await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
12943
- toast(`已删除人物关系“${relationshipName}”`);
12942
+ deleteToast(`已删除人物关系“${relationshipName}”`);
12944
12943
  } catch (error) {
12945
12944
  reopenDialog();
12946
12945
  toast(error.message, "error");
@@ -13465,12 +13464,13 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13465
13464
  "user",
13466
13465
  instruction,
13467
13466
  citations,
13468
- {},
13467
+ { modelId },
13469
13468
  { signal: request.signal }
13470
13469
  );
13471
13470
  assertAiRequestCurrent(request);
13472
13471
  updateAiConversationSummaryFromMessage(persistedUserMessage);
13473
13472
  requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
13473
+ state.aiConversationModelId = modelId;
13474
13474
  state.aiPromptSent = true;
13475
13475
  syncAiTaskOptions();
13476
13476
  renderAiRoleplayCharacterSelect();
@@ -13508,7 +13508,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13508
13508
  const request = assertAiRequestCurrent(requestHolder.snapshot);
13509
13509
  suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
13510
13510
  method: "POST",
13511
- body: { taskType, instruction, scope, modelId, citations },
13511
+ body: { taskType, instruction, scope, modelId, citations, conversationId: requestHolder.snapshot.conversationId },
13512
13512
  signal: request.signal
13513
13513
  });
13514
13514
  assertAiRequestCurrent(request);
@@ -13518,7 +13518,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
13518
13518
  if (suggestionFailed) setAiAssistantStatus("error");
13519
13519
  setAiContextMeter(suggestion.contextUsage, false);
13520
13520
  assistantContent = suggestion.content;
13521
- assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
13521
+ assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
13522
13522
  }
13523
13523
  try {
13524
13524
  const request = assertAiRequestCurrent(requestHolder.snapshot);
@@ -13738,6 +13738,10 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13738
13738
  }
13739
13739
  requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: persistedUserMessage.id });
13740
13740
  updateAiConversationSummaryFromMessage(persistedUserMessage);
13741
+ const lockedModelId = typeof persistedUserMessage.metadata?.modelId === "string"
13742
+ ? persistedUserMessage.metadata.modelId
13743
+ : typeof body.modelId === "string" ? body.modelId : null;
13744
+ state.aiConversationModelId = lockedModelId;
13741
13745
  state.aiPromptSent = true;
13742
13746
  syncAiTaskOptions();
13743
13747
  renderAiRoleplayCharacterSelect();
@@ -13808,10 +13812,12 @@ async function streamChat(requestHolder, body, idempotencyKey) {
13808
13812
  message.querySelector(".message-heading > span").textContent = "助手";
13809
13813
  toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
13810
13814
  processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
13811
- const processDurationMs = elapsedProcessTime();
13815
+ const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
13816
+ ? payload.processDurationMs
13817
+ : elapsedProcessTime();
13812
13818
  generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
13813
13819
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
13814
- meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent);
13820
+ meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
13815
13821
  attachAssistantCopyAction(message, streamedText);
13816
13822
  scrollAiFeedToBottom();
13817
13823
  } else if (eventName === "request_status") {
@@ -13929,11 +13935,16 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
13929
13935
  }
13930
13936
  message.append(references);
13931
13937
  }
13932
- if (role === "assistant") {
13933
- const processSteps = Array.isArray(metadata?.processSteps) && metadata.processSteps.length
13938
+ const processSteps = role === "assistant"
13939
+ ? (Array.isArray(metadata?.processSteps) && metadata.processSteps.length
13934
13940
  ? metadata.processSteps
13935
- : (Array.isArray(metadata?.toolCalls) ? metadata.toolCalls : []).map((toolCall) => aiToolProcessStep(toolCall));
13936
- renderAiProcessSteps(message, processSteps, true, resolveAiProcessDuration(metadata, processSteps, createdAt));
13941
+ : (Array.isArray(metadata?.toolCalls) ? metadata.toolCalls : []).map((toolCall) => aiToolProcessStep(toolCall)))
13942
+ : [];
13943
+ const processDurationMs = role === "assistant"
13944
+ ? resolveAiProcessDuration(metadata, processSteps, createdAt)
13945
+ : null;
13946
+ if (role === "assistant") {
13947
+ renderAiProcessSteps(message, processSteps, true, processDurationMs);
13937
13948
  }
13938
13949
  if (role === "assistant" && !text.startsWith("调用失败:")) {
13939
13950
  const selectedModel = state.models.find((model) => model.id === $("#ai-model").value) ?? state.models[0];
@@ -13943,7 +13954,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
13943
13954
  meta.className = "message-meta";
13944
13955
  meta.textContent = isInterrupted
13945
13956
  ? formatAiStreamInterruptionMeta(interruptionCode, text.length)
13946
- : formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
13957
+ : formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent, "", processDurationMs);
13947
13958
  message.append(meta);
13948
13959
  attachAssistantCopyAction(message, text);
13949
13960
  }
@@ -13958,7 +13969,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
13958
13969
  const applicable = suggestion.action !== "note";
13959
13970
  const guard = suggestion.guard;
13960
13971
  const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
13961
- message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
13972
+ message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`, suggestion.processDurationMs))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
13962
13973
  attachMessageHeading(message, "助手建议", createdAt ?? undefined);
13963
13974
  attachAssistantCopyAction(message, suggestion.content);
13964
13975
  attachMessageIdentity(message, messageId);
@@ -14270,7 +14281,7 @@ function renderWorkRecycleBin(result) {
14270
14281
  });
14271
14282
  await loadWorkRecycleBin();
14272
14283
  dialog.showModal();
14273
- toast(`已彻底删除作品“${work.title}”`);
14284
+ deleteToast(`已彻底删除作品“${work.title}”`);
14274
14285
  } catch (error) {
14275
14286
  dialog.showModal();
14276
14287
  toast(error.message, "error");
@@ -14376,7 +14387,7 @@ function renderChapterRecycleBin(result) {
14376
14387
  renderTree();
14377
14388
  await loadChapterRecycleBin();
14378
14389
  dialog.showModal();
14379
- toast(`已彻底删除分卷“${volume.title}”`);
14390
+ deleteToast(`已彻底删除分卷“${volume.title}”`);
14380
14391
  } catch (error) {
14381
14392
  dialog.showModal();
14382
14393
  toast(error.message, "error");
@@ -14432,7 +14443,7 @@ function renderChapterRecycleBin(result) {
14432
14443
  renderTree();
14433
14444
  await loadChapterRecycleBin();
14434
14445
  dialog.showModal();
14435
- toast(`已彻底删除章节“${chapter.title}”`);
14446
+ deleteToast(`已彻底删除章节“${chapter.title}”`);
14436
14447
  } catch (error) {
14437
14448
  dialog.showModal();
14438
14449
  toast(error.message, "error");
@@ -14937,7 +14948,7 @@ $("#avatar-remove-button").addEventListener("click", async () => {
14937
14948
  const updated = await api("/api/auth/avatar", { method: "DELETE" });
14938
14949
  applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
14939
14950
  renderProfileAvatar();
14940
- toast("头像已移除");
14951
+ deleteToast("头像已移除");
14941
14952
  } catch (error) {
14942
14953
  toast(error.message, "error");
14943
14954
  } finally {
@@ -15482,7 +15493,13 @@ $("#ai-prompt").addEventListener("focus", () => {
15482
15493
  $("#ai-model").addEventListener("focus", () => {
15483
15494
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
15484
15495
  });
15485
- $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
15496
+ $("#ai-model").addEventListener("change", (event) => {
15497
+ if (state.aiPromptSent) {
15498
+ event.currentTarget.value = state.aiConversationModelId ?? event.currentTarget.value;
15499
+ return toast("当前对话已经开始,请新建对话后再切换模型", "error");
15500
+ }
15501
+ setAiContextMeter(null);
15502
+ });
15486
15503
  $("#ai-roleplay-character").addEventListener("focus", async () => {
15487
15504
  try {
15488
15505
  await ensureAiReferencesLoaded();
@@ -15990,7 +16007,10 @@ document.addEventListener("visibilitychange", () => {
15990
16007
  if (state.user?.role === "admin" && !systemRestartDetected) void refreshS3BackupEvents();
15991
16008
  void refreshSystemHealth();
15992
16009
  });
15993
- window.addEventListener("pagehide", clearToastRegion);
16010
+ document.addEventListener("click", (event) => {
16011
+ if (event.target instanceof Element && event.target.closest('[role="tab"]')) dismissDeleteToasts();
16012
+ }, true);
16013
+ window.addEventListener("pagehide", dismissDeleteToasts);
15994
16014
  window.addEventListener("beforeunload", (event) => {
15995
16015
  if (hasUnsavedEditorChanges()) event.preventDefault();
15996
16016
  });
@@ -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=20260814-shelf-action-height-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260814-vditor-ui-fixes-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -512,7 +512,7 @@
512
512
  <dialog id="form-dialog" class="dialog">
513
513
  <form id="dynamic-form" method="dialog">
514
514
  <div class="dialog-header">
515
- <div><span id="dialog-eyebrow" class="eyebrow">新增</span><h2 id="dialog-title">创建记录</h2><p id="dialog-meta" class="dialog-header-meta hidden"></p></div>
515
+ <div><span id="dialog-eyebrow" class="eyebrow">新增</span><h2 id="dialog-title">创建记录</h2><input id="dialog-title-input" class="dialog-title-input hidden" type="text" autocomplete="off" aria-label="记录标题"><p id="dialog-meta" class="dialog-header-meta hidden"></p></div>
516
516
  <button class="dialog-close" value="cancel" aria-label="关闭" type="submit">×</button>
517
517
  </div>
518
518
  <div id="dialog-fields" class="dialog-fields"></div>
@@ -1156,6 +1156,6 @@
1156
1156
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1157
1157
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1158
1158
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1159
- <script type="module" src="/app.js?v=20260814-toast-lifecycle-v1"></script>
1159
+ <script type="module" src="/app.js?v=20260814-vditor-ui-fixes-v1"></script>
1160
1160
  </body>
1161
1161
  </html>