@musnows/scriverse 0.8.5 → 0.8.6

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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/ai-model-pricing.js +295 -0
  3. package/dist/ai-model-pricing.js.map +1 -0
  4. package/dist/ai-protocol.js +335 -15
  5. package/dist/ai-protocol.js.map +1 -1
  6. package/dist/ai-retry.js +1 -1
  7. package/dist/ai-retry.js.map +1 -1
  8. package/dist/ai.js +1073 -138
  9. package/dist/ai.js.map +1 -1
  10. package/dist/app.js +220 -24
  11. package/dist/app.js.map +1 -1
  12. package/dist/attachment-download.js +26 -0
  13. package/dist/attachment-download.js.map +1 -0
  14. package/dist/attachment-storage.js +8 -6
  15. package/dist/attachment-storage.js.map +1 -1
  16. package/dist/cli-contract.js +6 -4
  17. package/dist/cli-contract.js.map +1 -1
  18. package/dist/database.js +306 -3
  19. package/dist/database.js.map +1 -1
  20. package/dist/public/ai-image-attachments.d.ts +21 -0
  21. package/dist/public/ai-image-attachments.js +42 -0
  22. package/dist/public/ai-usage.d.ts +1 -0
  23. package/dist/public/ai-usage.js +11 -0
  24. package/dist/public/app.js +875 -102
  25. package/dist/public/display-labels.d.ts +2 -1
  26. package/dist/public/display-labels.js +6 -6
  27. package/dist/public/index.html +42 -7
  28. package/dist/public/model-config.d.ts +3 -1
  29. package/dist/public/model-config.js +9 -2
  30. package/dist/public/styles.css +111 -6
  31. package/dist/s3-backup.js +21 -0
  32. package/dist/s3-backup.js.map +1 -1
  33. package/dist/security.js +2 -1
  34. package/dist/security.js.map +1 -1
  35. package/dist/server-runtime.js +6 -0
  36. package/dist/server-runtime.js.map +1 -1
  37. package/dist/store.js +550 -44
  38. package/dist/store.js.map +1 -1
  39. package/dist/upload-limits.js +10 -4
  40. package/dist/upload-limits.js.map +1 -1
  41. package/dist/user-auth.js +3 -0
  42. package/dist/user-auth.js.map +1 -1
  43. package/dist/utils.js +1 -1
  44. package/dist/utils.js.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/writing-progress-time.js +15 -0
  47. package/dist/writing-progress-time.js.map +1 -1
  48. package/package.json +1 -1
@@ -16,19 +16,21 @@ import {
16
16
  visibleForeshadowReminders
17
17
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
18
18
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
19
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260816-extended-thinking-effort-v1";
19
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260821-ai-model-thinking-label-v1&feature=ai-provider-responses-v1";
20
20
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
21
21
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
22
22
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
23
23
  import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
24
24
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
25
- import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
25
+ import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount } from "/ai-usage.js?v=20260821-ai-usage-pricing-v1";
26
26
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
27
27
  import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260819-context-percent-v1";
28
28
  import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
29
29
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
30
30
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
31
31
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
32
+ import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
33
+ import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatImageFile, normalizeAiChatImageAttachments } from "/ai-image-attachments.js?v=20260820-ai-chat-image-attachments-v2";
32
34
  import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
33
35
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
34
36
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
@@ -54,7 +56,7 @@ import {
54
56
  timelineStatusLabel,
55
57
  characterGenderLabel,
56
58
  characterStateFieldLabel
57
- } from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1";
59
+ } from "/display-labels.js?v=20260816-character-gender-v1&feature=global-replace-volume-v1&feature=ai-provider-responses-v1";
58
60
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
59
61
  import {
60
62
  READING_PREFERENCES_STORAGE_KEY,
@@ -172,11 +174,13 @@ const state = {
172
174
  timelineTracks: [],
173
175
  aiCitations: [],
174
176
  aiReferences: [],
177
+ aiImageAttachments: [],
175
178
  aiPromptSent: false,
176
179
  aiTaskType: "chat",
177
180
  aiContextScope: { type: "none" },
178
181
  aiConversationId: null,
179
182
  aiConversationModelId: null,
183
+ aiConversationHasImages: false,
180
184
  aiConversations: [],
181
185
  aiRoleplayCharacter: null,
182
186
  aiRoleplayUserCharacter: null,
@@ -196,6 +200,8 @@ const state = {
196
200
  contextChapterId: null
197
201
  };
198
202
 
203
+ let platformAiProtocolOptions = [];
204
+
199
205
  const aiRequestManager = createAiRequestManager();
200
206
  const aiChatTabManager = createAiChatTabManager(() => createAiIdempotencyKey());
201
207
  const moduleRequestCache = createModuleRequestCache();
@@ -219,6 +225,7 @@ try { presenceSessionStorage = window.sessionStorage; } catch { /* 浏览器禁
219
225
  const presenceClientId = createPresenceClientId(presenceSessionStorage);
220
226
  const presenceHeartbeatInterval = 12_000;
221
227
  const systemBootCheckInterval = 8_000;
228
+ const systemRestartDialogDelay = 500;
222
229
  let presenceParticipants = [];
223
230
  let presenceHeartbeatTimer = null;
224
231
  let presenceHeartbeatQueued = null;
@@ -230,12 +237,16 @@ let collaborationAutoSaveDisabled = false;
230
237
  let systemBootId = null;
231
238
  let systemBootCheckTimer = null;
232
239
  let systemBootCheckPromise = null;
240
+ let systemRestartDialogTimer = null;
233
241
  let systemRestartDetected = false;
234
242
  let chapterAnnotations = [];
235
243
  let chapterAnnotationCounts = new Map();
236
244
  let chapterAnnotationsLineIndex = null;
237
245
  let workAuditRecords = [];
238
246
  let workAuditNextPage = null;
247
+ let adminAiConversationRecords = [];
248
+ let adminAiConversationNextPage = null;
249
+ let adminAiConversationFiltersOpen = false;
239
250
  const chapterBatchSelectedIds = new Set();
240
251
  let chapterMovePending = false;
241
252
  const readingRequestGate = createReadingRequestGate();
@@ -483,8 +494,12 @@ document.addEventListener("scroll", (event) => showScrollbarWhileScrolling(event
483
494
  const defaultImageUploadLimits = {
484
495
  avatarBytes: 2 * 1024 * 1024,
485
496
  coverBytes: 5 * 1024 * 1024,
486
- attachmentBytes: 30 * 1024 * 1024
497
+ attachmentBytes: 30 * 1024 * 1024,
498
+ chatImageBytes: 5 * 1024 * 1024
487
499
  };
500
+ const characterAvatarImageMaxBytes = 2 * 1024 * 1024;
501
+ const userAvatarFileAccept = "image/png,image/jpeg,image/webp,image/gif";
502
+ const characterAvatarFileAccept = "image/png,image/jpeg,image/webp";
488
503
  let imageUploadLimits = { ...defaultImageUploadLimits };
489
504
 
490
505
  function formatUploadLimit(bytes) {
@@ -498,9 +513,10 @@ function applyImageUploadLimits(nextLimits) {
498
513
  const next = {
499
514
  avatarBytes: Number(nextLimits.avatarBytes),
500
515
  coverBytes: Number(nextLimits.coverBytes),
501
- attachmentBytes: Number(nextLimits.attachmentBytes)
516
+ attachmentBytes: Number(nextLimits.attachmentBytes),
517
+ chatImageBytes: Number(nextLimits.chatImageBytes ?? defaultImageUploadLimits.chatImageBytes)
502
518
  };
503
- if (![next.avatarBytes, next.coverBytes, next.attachmentBytes].every((value) => Number.isSafeInteger(value) && value > 0)) return;
519
+ if (![next.avatarBytes, next.coverBytes, next.attachmentBytes, next.chatImageBytes].every((value) => Number.isSafeInteger(value) && value > 0)) return;
504
520
  imageUploadLimits = next;
505
521
  const avatarCopy = document.querySelector(".avatar-settings-copy small");
506
522
  if (avatarCopy) avatarCopy.textContent = `支持 PNG、JPEG、WebP、GIF,文件不超过 ${formatUploadLimit(next.avatarBytes)}。选择后可框选正方形选区再裁剪上传。`;
@@ -518,6 +534,10 @@ function assertAttachmentFileSize(file) {
518
534
  if (file.size > imageUploadLimits.attachmentBytes) throw new Error(`图片附件不能超过 ${formatUploadLimit(imageUploadLimits.attachmentBytes)}`);
519
535
  }
520
536
 
537
+ function assertAiChatImageFileSize(file) {
538
+ if (file.size > imageUploadLimits.chatImageBytes) throw new Error(`图片附件不能超过 ${formatUploadLimit(imageUploadLimits.chatImageBytes)}`);
539
+ }
540
+
521
541
  function uploadProgressHtml(label, fileName, progress = 0) {
522
542
  const normalized = normalizeUploadProgress(progress);
523
543
  const value = normalized ?? 0;
@@ -607,6 +627,7 @@ function syncAiRequestControls() {
607
627
  button.innerHTML = aiSendButtonIconMarkup(stateName);
608
628
  syncAiTaskOptions();
609
629
  renderAiRoleplayCharacterSelect();
630
+ syncAiImageAttachmentControl();
610
631
  }
611
632
 
612
633
  function cancelActiveAiRequest(reason) {
@@ -679,6 +700,17 @@ function userAvatarHtml(user, extraClass = "") {
679
700
  return `<span class="user-avatar ${esc(extraClass)}" aria-hidden="true"><span class="user-avatar-fallback">${esc(userAvatarInitial(user))}</span>${image}</span>`;
680
701
  }
681
702
 
703
+ function characterAvatarInitial(character) {
704
+ return Array.from(String(character?.name || "角"))[0] ?? "角";
705
+ }
706
+
707
+ function characterAvatarHtml(character, extraClass = "") {
708
+ const image = character?.avatarUrl
709
+ ? `<img src="${esc(character.avatarUrl)}" alt="" loading="lazy" decoding="async">`
710
+ : "";
711
+ return `<span class="character-avatar ${esc(extraClass)}" aria-hidden="true"><span class="character-avatar-fallback">${esc(characterAvatarInitial(character))}</span>${image}</span>`;
712
+ }
713
+
682
714
  function bindUserAvatarFallbacks(root) {
683
715
  root.querySelectorAll("[data-user-avatar-image]").forEach((image) => {
684
716
  image.addEventListener("error", () => image.remove(), { once: true });
@@ -2042,6 +2074,7 @@ function createAiChatTabState(input = {}) {
2042
2074
  title: input.title ?? "新对话",
2043
2075
  modelId: input.modelId ?? null,
2044
2076
  selectedModelId: input.selectedModelId ?? null,
2077
+ hasImageAttachments: input.hasImageAttachments === true,
2045
2078
  promptSent: input.promptSent === true,
2046
2079
  taskType: input.taskType ?? "chat",
2047
2080
  contextScope: input.contextScope ?? { type: "none" },
@@ -2049,7 +2082,7 @@ function createAiChatTabState(input = {}) {
2049
2082
  roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2050
2083
  citations: input.citations ?? [],
2051
2084
  references: input.references ?? [],
2052
- composer: input.composer ?? { text: "", citations: [], references: [] },
2085
+ composer: input.composer ?? { text: "", citations: [], references: [], images: [] },
2053
2086
  contextUsage: input.contextUsage ?? null,
2054
2087
  contextWarning: input.contextWarning === true,
2055
2088
  lastMessageAt: input.lastMessageAt ?? null,
@@ -2069,6 +2102,7 @@ function persistActiveAiChatTab() {
2069
2102
  tab.conversationId = state.aiConversationId;
2070
2103
  tab.title = $("#ai-conversation-title").textContent || tab.title || "新对话";
2071
2104
  tab.modelId = state.aiConversationModelId;
2105
+ tab.hasImageAttachments = state.aiConversationHasImages === true;
2072
2106
  tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
2073
2107
  tab.promptSent = state.aiPromptSent;
2074
2108
  tab.taskType = state.aiTaskType;
@@ -2089,20 +2123,23 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
2089
2123
  tab.composer = {
2090
2124
  text: snapshot.text,
2091
2125
  citations: tab.citations.map((citation) => ({ ...citation })),
2092
- references: tab.references.map((reference) => ({ ...reference }))
2126
+ references: tab.references.map((reference) => ({ ...reference })),
2127
+ images: normalizeAiChatImageAttachments(snapshot.images)
2093
2128
  };
2094
2129
  }
2095
2130
 
2096
2131
  function clearAiChatTabComposer(tab) {
2097
- setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [] });
2132
+ setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [], images: [] });
2098
2133
  }
2099
2134
 
2100
2135
  function applyAiChatTabState(tab) {
2101
2136
  state.aiConversationId = tab.conversationId;
2102
2137
  state.aiConversationModelId = tab.modelId;
2138
+ state.aiConversationHasImages = tab.hasImageAttachments === true;
2103
2139
  state.aiPromptSent = tab.promptSent;
2104
2140
  state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
2105
2141
  state.aiReferences = tab.references.map((reference) => ({ ...reference }));
2142
+ state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
2106
2143
  state.aiLastMessageAt = tab.lastMessageAt;
2107
2144
  $("#ai-conversation-title").textContent = tab.title || "新对话";
2108
2145
  applyAiConversationTaskType(tab.taskType);
@@ -2114,6 +2151,7 @@ function applyAiChatTabState(tab) {
2114
2151
  setAiPromptText(tab.composer.text);
2115
2152
  renderAiCitations();
2116
2153
  renderAiReferences();
2154
+ renderAiImageAttachments();
2117
2155
  latestAiContextUsage = null;
2118
2156
  setAiContextMeter(tab.contextUsage);
2119
2157
  if (tab.contextWarning) showAiContextWarning();
@@ -2131,7 +2169,8 @@ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
2131
2169
  && !aiRequestManager.hasActive(tab.id)
2132
2170
  && !(tab.composer?.text || "").trim()
2133
2171
  && !(tab.composer?.citations?.length)
2134
- && !(tab.composer?.references?.length));
2172
+ && !(tab.composer?.references?.length)
2173
+ && !(tab.composer?.images?.length));
2135
2174
  }
2136
2175
 
2137
2176
  function aiChatTabOpeningSlot() {
@@ -2233,7 +2272,6 @@ function setAiConversationWorkspaceVisible(visible) {
2233
2272
  $("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
2234
2273
  applyPanelLayout(true);
2235
2274
  $("#ai-chat-tabs").classList.add("hidden");
2236
- $("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
2237
2275
  $("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
2238
2276
  setAiConversationSwitcherVisible(false);
2239
2277
  renderAiChatTabs();
@@ -2470,6 +2508,15 @@ function aiMessageCitations(message) {
2470
2508
  }
2471
2509
  }
2472
2510
 
2511
+ function aiMessageImageAttachments(message) {
2512
+ try {
2513
+ const ids = JSON.parse(message.dataset.aiImageAttachmentIds ?? "[]");
2514
+ return normalizeAiChatImageAttachments(Array.isArray(ids) ? ids.map((id) => ({ id })) : []);
2515
+ } catch {
2516
+ return [];
2517
+ }
2518
+ }
2519
+
2473
2520
  async function retryAiMessage(message) {
2474
2521
  const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2475
2522
  const userMessage = findAiRetryUserMessage(message);
@@ -2489,6 +2536,7 @@ async function retryAiMessage(message) {
2489
2536
  message,
2490
2537
  prompt,
2491
2538
  citations: aiMessageCitations(userMessage),
2539
+ images: aiMessageImageAttachments(userMessage),
2492
2540
  userMessageId
2493
2541
  }
2494
2542
  });
@@ -2643,6 +2691,7 @@ const AI_TOOL_DESCRIPTIONS = {
2643
2691
  const aiFeedScrollFrames = new WeakMap();
2644
2692
  const aiFeedAutoScrollStates = new WeakMap();
2645
2693
  const aiFeedScrollBindings = new WeakSet();
2694
+ const aiProcessScrollFrames = new WeakMap();
2646
2695
  const AI_FEED_BOTTOM_THRESHOLD_PX = 24;
2647
2696
  let markdownTableMenuTarget = null;
2648
2697
  let markdownTableMenuTrigger = null;
@@ -2718,6 +2767,22 @@ function scrollAiFeedToBottom(feed = $("#ai-feed")) {
2718
2767
  aiFeedScrollFrames.set(feed, nextFrame);
2719
2768
  }
2720
2769
 
2770
+ function scrollAiProcessStepsToBottom(message) {
2771
+ const scroll = () => {
2772
+ message.querySelectorAll(".ai-process-step-body").forEach((body) => {
2773
+ body.scrollTop = body.scrollHeight;
2774
+ });
2775
+ };
2776
+ scroll();
2777
+ const currentFrame = aiProcessScrollFrames.get(message);
2778
+ if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
2779
+ const nextFrame = window.requestAnimationFrame(() => {
2780
+ scroll();
2781
+ aiProcessScrollFrames.delete(message);
2782
+ });
2783
+ aiProcessScrollFrames.set(message, nextFrame);
2784
+ }
2785
+
2721
2786
  function formatAiToolCallTime(value) {
2722
2787
  if (!value) return "历史记录未保存";
2723
2788
  const date = new Date(value);
@@ -2838,7 +2903,17 @@ function shouldRenderAiProcessStep(step) {
2838
2903
  }
2839
2904
 
2840
2905
  function renderAiProcessSteps(message, steps, completed, durationMs = null, visibleContents = null) {
2841
- message.querySelector(".ai-process-details")?.remove();
2906
+ const previousDetails = message.querySelector(".ai-process-details");
2907
+ const previousScrollStates = new Map();
2908
+ previousDetails?.querySelectorAll(".ai-process-step[data-ai-process-step-id]").forEach((section) => {
2909
+ const body = section.querySelector(".ai-process-step-body");
2910
+ if (!body) return;
2911
+ previousScrollStates.set(section.dataset.aiProcessStepId, {
2912
+ scrollTop: body.scrollTop,
2913
+ nearBottom: body.scrollHeight - body.scrollTop - body.clientHeight < 24
2914
+ });
2915
+ });
2916
+ previousDetails?.remove();
2842
2917
  const renderableSteps = (Array.isArray(steps) ? steps : []).filter(shouldRenderAiProcessStep);
2843
2918
  if (!renderableSteps.length) return;
2844
2919
  const details = document.createElement("details");
@@ -2873,6 +2948,7 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2873
2948
  }
2874
2949
  const section = document.createElement("section");
2875
2950
  section.className = `ai-process-step ai-process-${step.type}-step`;
2951
+ section.dataset.aiProcessStepId = `${step.type}:${String(step.id ?? step.round ?? "")}`;
2876
2952
  const label = document.createElement("small");
2877
2953
  label.textContent = `第 ${Number(step.round) || 1} 轮 · ${step.type === "thinking" ? "Thinking" : "中间输出"}`;
2878
2954
  const body = document.createElement("div");
@@ -2883,9 +2959,18 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
2883
2959
  list.append(section);
2884
2960
  }
2885
2961
  details.append(summary, list);
2962
+ list.querySelectorAll(".ai-process-step[data-ai-process-step-id]").forEach((section) => {
2963
+ const scrollState = previousScrollStates.get(section.dataset.aiProcessStepId);
2964
+ const body = section.querySelector(".ai-process-step-body");
2965
+ if (!scrollState || !body) return;
2966
+ body.scrollTop = scrollState.nearBottom
2967
+ ? body.scrollHeight
2968
+ : Math.min(scrollState.scrollTop, body.scrollHeight);
2969
+ });
2886
2970
  const body = message.querySelector(".message-body");
2887
2971
  if (body) body.before(details);
2888
2972
  else message.append(details);
2973
+ if (!completed) scrollAiProcessStepsToBottom(message);
2889
2974
  }
2890
2975
 
2891
2976
  function renderAiToolCalls(message, toolCalls, completed = false) {
@@ -3130,6 +3215,7 @@ function updateAiConversationSummaryFromMessage(message) {
3130
3215
  title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
3131
3216
  messageCount: Number(current.messageCount ?? 0) + 1,
3132
3217
  preview: message.content,
3218
+ ...(aiConversationMessageHasImages(message) ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
3133
3219
  updatedAt: message.createdAt ?? current.updatedAt
3134
3220
  });
3135
3221
  }
@@ -3243,11 +3329,18 @@ function focusAiConversationMessage(messageId) {
3243
3329
  window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
3244
3330
  }
3245
3331
 
3332
+ function aiConversationMessageHasImages(message) {
3333
+ return Array.isArray(message?.metadata?.chatImageAttachmentIds)
3334
+ && message.metadata.chatImageAttachmentIds.some((attachmentId) => String(attachmentId ?? "").trim().length > 0);
3335
+ }
3336
+
3246
3337
  function applyConversationToAiChatTab(tab, conversation) {
3247
3338
  tab.workId = String(conversation.workId ?? state.work?.id ?? "");
3248
3339
  tab.conversationId = conversation.id;
3249
3340
  tab.title = conversation.title || "新对话";
3250
3341
  tab.modelId = typeof conversation.modelId === "string" ? conversation.modelId : null;
3342
+ tab.hasImageAttachments = conversation.hasImageAttachments === true
3343
+ || (Array.isArray(conversation.messages) && conversation.messages.some(aiConversationMessageHasImages));
3251
3344
  tab.selectedModelId = tab.modelId ?? tab.selectedModelId;
3252
3345
  tab.promptSent = Array.isArray(conversation.messages) && conversation.messages.some((message) => message.role === "user");
3253
3346
  tab.taskType = conversation.taskType ?? "chat";
@@ -3256,7 +3349,7 @@ function applyConversationToAiChatTab(tab, conversation) {
3256
3349
  tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
3257
3350
  tab.citations = [];
3258
3351
  tab.references = [];
3259
- tab.composer = { text: "", citations: [], references: [] };
3352
+ tab.composer = { text: "", citations: [], references: [], images: [] };
3260
3353
  tab.contextUsage = null;
3261
3354
  tab.contextWarning = conversation.contextWarningPending === true;
3262
3355
  resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
@@ -3374,13 +3467,75 @@ function renderAiRoleplayUserCharacterSelect() {
3374
3467
  const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
3375
3468
 
3376
3469
  function aiConversationModelLocked() {
3377
- return typeof state.aiConversationModelId === "string" && state.aiConversationModelId.trim().length > 0;
3470
+ return state.aiConversationHasImages === true
3471
+ || (typeof state.aiConversationModelId === "string" && state.aiConversationModelId.trim().length > 0);
3378
3472
  }
3379
3473
 
3380
3474
  function selectedAiModelLabel() {
3381
3475
  return $("#ai-model").selectedOptions[0]?.textContent?.trim() || "尚未选择模型";
3382
3476
  }
3383
3477
 
3478
+ function aiModelImageIconMarkup() {
3479
+ return '<svg class="ai-model-option-image-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="4.5" width="17" height="15" rx="2"></rect><circle cx="9" cy="10" r="1.5"></circle><path d="m5.5 17 4.4-4.4 3.3 3.1 2.2-2.2 3.1 3.5"></path></svg>';
3480
+ }
3481
+
3482
+ function renderAiModelOptions() {
3483
+ const host = $("#ai-model-options");
3484
+ if (!host) return;
3485
+ const select = $("#ai-model");
3486
+ const selectedId = select.value;
3487
+ host.replaceChildren();
3488
+ if (state.models.length === 0) {
3489
+ const empty = document.createElement("p");
3490
+ empty.className = "ai-model-options-empty";
3491
+ empty.textContent = "请先配置模型";
3492
+ host.append(empty);
3493
+ return;
3494
+ }
3495
+ for (const model of state.models) {
3496
+ const option = document.createElement("button");
3497
+ option.type = "button";
3498
+ option.className = "ai-model-option";
3499
+ option.dataset.modelId = model.id;
3500
+ option.setAttribute("role", "option");
3501
+ option.setAttribute("aria-selected", String(model.id === selectedId));
3502
+ option.tabIndex = model.id === selectedId ? 0 : -1;
3503
+ option.disabled = aiInteractionBusy();
3504
+ option.title = model.multimodalEnabled === true ? "支持图片输入" : "不支持图片输入";
3505
+ const copy = document.createElement("span");
3506
+ copy.className = "ai-model-option-copy";
3507
+ const name = document.createElement("strong");
3508
+ name.textContent = String(model.displayName ?? model.modelId ?? "模型");
3509
+ const provider = document.createElement("small");
3510
+ provider.textContent = [String(model.providerName ?? "").trim(), modelThinkingEffortLabel(model)]
3511
+ .filter(Boolean)
3512
+ .join(" · ");
3513
+ copy.append(name, provider);
3514
+ option.append(copy);
3515
+ if (model.multimodalEnabled === true) {
3516
+ const icon = document.createElement("span");
3517
+ icon.className = "ai-model-option-image";
3518
+ icon.setAttribute("aria-label", "支持图片输入");
3519
+ icon.innerHTML = aiModelImageIconMarkup();
3520
+ option.append(icon);
3521
+ }
3522
+ option.addEventListener("click", () => {
3523
+ if (notifyAiConversationModelLocked(option)) return;
3524
+ select.value = model.id;
3525
+ select.dispatchEvent(new Event("change", { bubbles: true }));
3526
+ });
3527
+ host.append(option);
3528
+ }
3529
+ }
3530
+
3531
+ function focusAiModelOption() {
3532
+ const host = $("#ai-model-options");
3533
+ if (!host) return;
3534
+ const selected = host.querySelector('[role="option"][aria-selected="true"]')
3535
+ ?? host.querySelector('[role="option"]');
3536
+ selected?.focus();
3537
+ }
3538
+
3384
3539
  function syncAiModelPicker() {
3385
3540
  const select = $("#ai-model");
3386
3541
  const button = $("#ai-model-picker");
@@ -3395,6 +3550,8 @@ function syncAiModelPicker() {
3395
3550
  button.disabled = interactionBusy;
3396
3551
  button.title = label;
3397
3552
  button.setAttribute("aria-label", label);
3553
+ renderAiModelOptions();
3554
+ syncAiImageAttachmentControl();
3398
3555
  if (interactionBusy) setAiModelPickerVisible(false);
3399
3556
  }
3400
3557
 
@@ -3428,7 +3585,9 @@ function blockLockedAiConversationOptionKeydown(event) {
3428
3585
 
3429
3586
  function syncAiTaskOptions() {
3430
3587
  const roleplaySelected = $("#ai-task").value === "roleplay";
3431
- const relationshipRoleplaySelectable = roleplaySelected && Boolean(state.aiRoleplayCharacter);
3588
+ const relationshipRoleplaySelectable = roleplaySelected
3589
+ && Boolean(state.aiRoleplayCharacter)
3590
+ && (!state.aiPromptSent || Boolean(state.aiRoleplayUserCharacter));
3432
3591
  const interactionBusy = aiInteractionBusy();
3433
3592
  $("#ai-scope").classList.toggle("hidden", roleplaySelected);
3434
3593
  $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
@@ -3659,11 +3818,136 @@ function setAiPromptText(value) {
3659
3818
  renderAiReferences();
3660
3819
  }
3661
3820
 
3821
+ function activeAiModel() {
3822
+ const tab = activeAiChatTab();
3823
+ const selectedId = (isActiveAiChatTab(tab) ? $("#ai-model").value : "")
3824
+ || tab?.selectedModelId
3825
+ || state.aiConversationModelId
3826
+ || "";
3827
+ return state.models.find((model) => model.id === selectedId) ?? null;
3828
+ }
3829
+
3830
+ function aiModelSupportsImageInput() {
3831
+ return activeAiModel()?.multimodalEnabled === true;
3832
+ }
3833
+
3834
+ function syncAiImageAttachmentControl() {
3835
+ const button = $("#ai-attachment-button");
3836
+ if (!button) return;
3837
+ const enabled = aiModelSupportsImageInput();
3838
+ button.classList.toggle("hidden", !enabled);
3839
+ button.disabled = !enabled || aiInteractionBusy();
3840
+ button.setAttribute("aria-hidden", String(!enabled));
3841
+ button.title = enabled ? "添加图片附件" : "当前模型不支持图片输入";
3842
+ }
3843
+
3844
+ function renderAiImageAttachments() {
3845
+ const host = $("#ai-image-attachments");
3846
+ if (!host) return;
3847
+ const attachments = normalizeAiChatImageAttachments(state.aiImageAttachments);
3848
+ state.aiImageAttachments = attachments;
3849
+ host.replaceChildren();
3850
+ host.classList.toggle("hidden", attachments.length === 0);
3851
+ host.setAttribute("aria-hidden", String(attachments.length === 0));
3852
+ for (const [index, attachment] of attachments.entries()) {
3853
+ const card = document.createElement("figure");
3854
+ card.className = "ai-image-attachment";
3855
+ card.dataset.attachmentId = attachment.id;
3856
+ const preview = document.createElement("button");
3857
+ preview.type = "button";
3858
+ preview.className = "ai-image-attachment-preview";
3859
+ preview.setAttribute("aria-label", `查看第 ${index + 1} 个图片附件:${attachment.originalName}`);
3860
+ preview.title = "点击查看原图";
3861
+ const image = document.createElement("img");
3862
+ image.src = attachment.contentUrl;
3863
+ image.alt = attachment.originalName;
3864
+ image.loading = "lazy";
3865
+ image.decoding = "async";
3866
+ const label = document.createElement("span");
3867
+ label.className = "ai-image-attachment-label";
3868
+ label.textContent = `#${index + 1}`;
3869
+ preview.append(image, label);
3870
+ preview.addEventListener("click", () => openAiImagePreview(attachment, index + 1));
3871
+ const remove = document.createElement("button");
3872
+ remove.type = "button";
3873
+ remove.className = "ai-image-attachment-remove";
3874
+ remove.setAttribute("aria-label", `移除图片附件:${attachment.originalName}`);
3875
+ remove.title = "移除图片附件";
3876
+ remove.textContent = "×";
3877
+ remove.addEventListener("click", async () => {
3878
+ remove.disabled = true;
3879
+ state.aiImageAttachments = state.aiImageAttachments.filter((item) => item.id !== attachment.id);
3880
+ renderAiImageAttachments();
3881
+ persistActiveAiChatTab();
3882
+ try {
3883
+ await api(`/api/attachments/${encodeURIComponent(attachment.id)}`, { method: "DELETE" });
3884
+ } catch {
3885
+ /* 待发送附件从当前输入移除即可,未被历史消息引用的文件会由服务端回收。 */
3886
+ }
3887
+ });
3888
+ card.append(preview, remove);
3889
+ host.append(card);
3890
+ }
3891
+ }
3892
+
3893
+ function openAiImagePreview(attachment, ordinal = null) {
3894
+ const dialog = $("#ai-image-preview-dialog");
3895
+ const image = $("#ai-image-preview-image");
3896
+ if (!dialog || !image) return;
3897
+ image.src = attachment.contentUrl;
3898
+ image.alt = attachment.originalName;
3899
+ $("#ai-image-preview-title").textContent = Number.isInteger(ordinal) ? `图片附件 #${ordinal}` : "图片附件";
3900
+ $("#ai-image-preview-meta").textContent = attachment.originalName;
3901
+ if (!dialog.open) dialog.showModal();
3902
+ }
3903
+
3904
+ async function discardAiImageAttachments(attachments) {
3905
+ const ids = aiChatImageAttachmentIds(attachments);
3906
+ await Promise.all(ids.map((attachmentId) => api(`/api/attachments/${encodeURIComponent(attachmentId)}`, { method: "DELETE" }).catch(() => null)));
3907
+ }
3908
+
3909
+ async function addAiImageFiles(files) {
3910
+ if (!aiModelSupportsImageInput()) {
3911
+ toast("当前选择的模型不是多模态模型,无法添加图片附件", "error");
3912
+ return;
3913
+ }
3914
+ const candidates = Array.from(files ?? []).filter(isAiChatImageFile);
3915
+ if (candidates.length === 0) {
3916
+ toast("图片附件仅支持 PNG、JPG、JPEG", "error");
3917
+ return;
3918
+ }
3919
+ const remaining = AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT - state.aiImageAttachments.length;
3920
+ if (remaining <= 0) {
3921
+ toast(`一次最多添加 ${AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT} 张图片附件`, "error");
3922
+ return;
3923
+ }
3924
+ if (candidates.length > remaining) toast(`一次最多添加 ${AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT} 张图片附件`, "error");
3925
+ for (const file of candidates.slice(0, remaining)) {
3926
+ try {
3927
+ assertAiChatImageFileSize(file);
3928
+ const body = new FormData();
3929
+ body.append("file", file, file.name || "pasted-image.png");
3930
+ const uploaded = await uploadWithProgress(
3931
+ `/api/works/${encodeURIComponent(state.work.id)}/attachments?module=ai-chat`,
3932
+ { method: "POST", body }
3933
+ );
3934
+ const attachment = normalizeAiChatImageAttachments([uploaded])[0];
3935
+ if (attachment) state.aiImageAttachments = normalizeAiChatImageAttachments([...state.aiImageAttachments, attachment]);
3936
+ renderAiImageAttachments();
3937
+ } catch (error) {
3938
+ toast(`图片附件上传失败:${error.message}`, "error");
3939
+ }
3940
+ }
3941
+ persistActiveAiChatTab();
3942
+ }
3943
+
3662
3944
  function clearAiPromptComposer() {
3663
3945
  state.aiCitations = [];
3664
3946
  state.aiReferences = [];
3947
+ state.aiImageAttachments = [];
3665
3948
  setAiPromptText("");
3666
3949
  renderAiCitations();
3950
+ renderAiImageAttachments();
3667
3951
  hideAiMentionMenu();
3668
3952
  }
3669
3953
 
@@ -3671,15 +3955,18 @@ function captureAiPromptComposer() {
3671
3955
  return {
3672
3956
  text: aiPromptText(),
3673
3957
  citations: state.aiCitations.map((citation) => ({ ...citation })),
3674
- references: state.aiReferences.map((reference) => ({ ...reference }))
3958
+ references: state.aiReferences.map((reference) => ({ ...reference })),
3959
+ images: normalizeAiChatImageAttachments(state.aiImageAttachments)
3675
3960
  };
3676
3961
  }
3677
3962
 
3678
3963
  function restoreAiPromptComposer(snapshot) {
3679
3964
  state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
3680
3965
  state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
3966
+ state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
3681
3967
  setAiPromptText(snapshot.text);
3682
3968
  renderAiCitations();
3969
+ renderAiImageAttachments();
3683
3970
  hideAiMentionMenu();
3684
3971
  }
3685
3972
 
@@ -4375,6 +4662,12 @@ function formatAiFailureMessage(error) {
4375
4662
  const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
4376
4663
  if (code) lines.push(`错误码:${code}`);
4377
4664
  if (status) lines.push(`服务端状态:HTTP ${status}`);
4665
+ if (details.platformLimited === true) {
4666
+ const limitSource = details.limitScope === "provider"
4667
+ ? `配置的供应商额度${providerName ? `(${providerName})` : ""}`
4668
+ : "单个小说额度";
4669
+ lines.push(`叙界平台限制来源:${limitSource}`);
4670
+ }
4378
4671
  if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
4379
4672
  if (modelId) lines.push(`模型 ID:${modelId}`);
4380
4673
  if (callId) lines.push(`调用 ID:${callId}`);
@@ -4593,9 +4886,14 @@ function observeSystemBootId(value) {
4593
4886
  systemRestartDetected = true;
4594
4887
  if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
4595
4888
  systemBootCheckTimer = null;
4596
- const dialog = $("#system-restart-dialog");
4597
- if (!dialog.open) dialog.showModal();
4598
- window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
4889
+ if (systemRestartDialogTimer !== null) clearTimeout(systemRestartDialogTimer);
4890
+ systemRestartDialogTimer = window.setTimeout(() => {
4891
+ systemRestartDialogTimer = null;
4892
+ if (!systemRestartDetected) return;
4893
+ const dialog = $("#system-restart-dialog");
4894
+ if (!dialog.open) dialog.showModal();
4895
+ window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
4896
+ }, systemRestartDialogDelay);
4599
4897
  return true;
4600
4898
  }
4601
4899
 
@@ -4818,6 +5116,14 @@ function raiseToastRegion() {
4818
5116
  region.showPopover();
4819
5117
  }
4820
5118
 
5119
+ function dismissToastElement(element) {
5120
+ element.remove();
5121
+ const region = $("#toast-region");
5122
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
5123
+ region.hidePopover();
5124
+ }
5125
+ }
5126
+
4821
5127
  function dismissDeleteToasts() {
4822
5128
  const region = $("#toast-region");
4823
5129
  region.querySelectorAll(".delete-toast").forEach((element) => element.remove());
@@ -4848,15 +5154,13 @@ function toast(message, type = "info", extraClass = "") {
4848
5154
  element.className = `toast ${type}${extraClass ? ` ${extraClass}` : ""}`;
4849
5155
  element.setAttribute("role", type === "error" ? "alert" : "status");
4850
5156
  element.setAttribute("aria-atomic", "true");
4851
- element.textContent = message;
5157
+ const messageContent = document.createElement("span");
5158
+ messageContent.textContent = message;
5159
+ element.append(messageContent);
5160
+ element.addEventListener("click", () => dismissToastElement(element), { once: true });
4852
5161
  region.append(element);
4853
5162
  raiseToastRegion();
4854
- setTimeout(() => {
4855
- element.remove();
4856
- if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
4857
- region.hidePopover();
4858
- }
4859
- }, 3600);
5163
+ setTimeout(() => dismissToastElement(element), 3600);
4860
5164
  }
4861
5165
 
4862
5166
  async function runEntityEditorSave({ busyTarget, button, prepare, save }) {
@@ -4909,15 +5213,13 @@ function persistentToast(message, type = "info") {
4909
5213
  const element = document.createElement("div");
4910
5214
  element.className = `toast ${type}`;
4911
5215
  element.setAttribute("role", "status");
4912
- element.textContent = message;
5216
+ const messageContent = document.createElement("span");
5217
+ messageContent.textContent = message;
5218
+ element.append(messageContent);
5219
+ element.addEventListener("click", () => dismissToastElement(element), { once: true });
4913
5220
  region.append(element);
4914
5221
  raiseToastRegion();
4915
- return () => {
4916
- element.remove();
4917
- if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
4918
- region.hidePopover();
4919
- }
4920
- };
5222
+ return () => dismissToastElement(element);
4921
5223
  }
4922
5224
 
4923
5225
  function restoreToastFocus(previousFocus) {
@@ -5390,7 +5692,9 @@ function renderSettingsHub() {
5390
5692
  const isAdmin = state.user?.role === "admin";
5391
5693
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
5392
5694
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
5695
+ $("#platform-usage-pricing-refresh").classList.toggle("hidden", !isAdmin);
5393
5696
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
5697
+ $("#admin-ai-conversations-button").classList.toggle("hidden", !isAdmin);
5394
5698
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
5395
5699
  $("#s3-backup-button").classList.toggle("hidden", !isAdmin);
5396
5700
  $("#collaboration-button").disabled = !canManageWork;
@@ -5746,6 +6050,86 @@ async function openUsersDialog() {
5746
6050
  catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
5747
6051
  }
5748
6052
 
6053
+ function renderAdminAiConversationFilterOptions(works, users) {
6054
+ const workSelect = $("#admin-ai-conversations-work");
6055
+ const userSelect = $("#admin-ai-conversations-user");
6056
+ const selectedWorkId = workSelect.value;
6057
+ const selectedUserId = userSelect.value;
6058
+ workSelect.innerHTML = `<option value="">全部作品</option>${works
6059
+ .map((work) => `<option value="${esc(work.id)}">${esc(work.title)}</option>`).join("")}`;
6060
+ userSelect.innerHTML = `<option value="">全部用户</option>${users
6061
+ .map((user) => `<option value="${esc(user.userId)}">${esc(user.displayName)} · @${esc(user.username)}</option>`).join("")}`;
6062
+ if ([...workSelect.options].some((option) => option.value === selectedWorkId)) workSelect.value = selectedWorkId;
6063
+ if ([...userSelect.options].some((option) => option.value === selectedUserId)) userSelect.value = selectedUserId;
6064
+ }
6065
+
6066
+ function renderAdminAiConversations() {
6067
+ const host = $("#admin-ai-conversations-list");
6068
+ host.innerHTML = adminAiConversationRecords.length ? adminAiConversationRecords.map((conversation) => {
6069
+ const creator = conversation.creator
6070
+ ? `${esc(conversation.creator.displayName)} · @${esc(conversation.creator.username)}`
6071
+ : "未归属历史对话";
6072
+ const work = conversation.work ?? { id: conversation.workId, title: "作品已删除", deleted: true };
6073
+ const preview = String(conversation.preview || "").trim() || "暂无对话消息";
6074
+ return `<article class="admin-ai-conversation-row" data-admin-ai-conversation="${esc(conversation.id)}">
6075
+ <div class="admin-ai-conversation-heading"><div><strong>${esc(conversation.title || "新对话")}</strong><small>${creator}</small></div><span>${Number(conversation.messageCount ?? 0)} 条消息</span></div>
6076
+ <dl class="admin-ai-conversation-meta">
6077
+ <div><dt>作品</dt><dd>${esc(work.title)}${work.deleted ? "(回收站)" : ""}</dd></div>
6078
+ <div><dt>任务类型</dt><dd>${esc(aiConversationTaskTypeLabel(conversation.taskType || "chat"))}</dd></div>
6079
+ <div><dt>最后更新</dt><dd>${esc(formatDateTime(conversation.updatedAt))}</dd></div>
6080
+ </dl>
6081
+ <p>${esc(preview)}</p>
6082
+ </article>`;
6083
+ }).join("") : '<p class="empty-state">没有符合当前筛选条件的对话。</p>';
6084
+ $("#admin-ai-conversations-summary").textContent = adminAiConversationRecords.length
6085
+ ? `已显示 ${adminAiConversationRecords.length} 条对话${adminAiConversationNextPage === null ? ",已加载全部记录" : ",还有更多记录可继续加载"}`
6086
+ : "当前筛选条件下没有对话记录。";
6087
+ $("#admin-ai-conversations-load-more").classList.toggle("hidden", adminAiConversationNextPage === null);
6088
+ }
6089
+
6090
+ function adminAiConversationListPath() {
6091
+ const parameters = new URLSearchParams();
6092
+ const query = $("#admin-ai-conversations-query").value.trim();
6093
+ const workId = $("#admin-ai-conversations-work").value;
6094
+ const userId = $("#admin-ai-conversations-user").value;
6095
+ if (query) parameters.set("q", query);
6096
+ if (workId) parameters.set("workId", workId);
6097
+ if (userId) parameters.set("userId", userId);
6098
+ const serialized = parameters.toString();
6099
+ return `/api/platform/ai-conversations${serialized ? `?${serialized}` : ""}`;
6100
+ }
6101
+
6102
+ async function loadAdminAiConversationPage(page = 1, append = false) {
6103
+ const result = await apiPage(adminAiConversationListPath(), page, 30);
6104
+ adminAiConversationRecords = append ? [...adminAiConversationRecords, ...result.items] : result.items;
6105
+ adminAiConversationNextPage = result.nextPage;
6106
+ renderAdminAiConversations();
6107
+ }
6108
+
6109
+ async function openAdminAiConversationsDialog() {
6110
+ if (state.user?.role !== "admin") {
6111
+ toast("需要系统管理员权限", "error");
6112
+ return;
6113
+ }
6114
+ adminAiConversationRecords = [];
6115
+ adminAiConversationNextPage = null;
6116
+ adminAiConversationFiltersOpen = false;
6117
+ $("#admin-ai-conversations-filter-panel").classList.add("hidden");
6118
+ $("#admin-ai-conversations-filter-toggle").setAttribute("aria-expanded", "false");
6119
+ $("#admin-ai-conversations-filter-panel").reset();
6120
+ $("#admin-ai-conversations-list").innerHTML = '<p class="empty-state">正在读取对话……</p>';
6121
+ $("#admin-ai-conversations-summary").textContent = "正在读取对话……";
6122
+ $("#admin-ai-conversations-dialog").showModal();
6123
+ try {
6124
+ const [works, users] = await Promise.all([apiAllPages("/api/works", 100), apiAllPages("/api/users", 100)]);
6125
+ renderAdminAiConversationFilterOptions(works, users);
6126
+ await loadAdminAiConversationPage();
6127
+ } catch (error) {
6128
+ $("#admin-ai-conversations-dialog").close();
6129
+ toast(error.message, "error");
6130
+ }
6131
+ }
6132
+
5749
6133
  async function openPlatformUiSettingsDialog() {
5750
6134
  if (state.user?.role !== "admin") {
5751
6135
  toast("需要系统管理员权限", "error");
@@ -6686,6 +7070,7 @@ async function showPlatformUsage() {
6686
7070
  $("#module-view").classList.add("hidden");
6687
7071
  $("#work-meta").textContent = "Token 用量";
6688
7072
  $("#settings-button").setAttribute("aria-current", "page");
7073
+ $("#platform-usage-pricing-refresh").classList.toggle("hidden", state.user?.role !== "admin");
6689
7074
  setTopbarViewState("Token 用量");
6690
7075
  await renderPlatformTokenUsage();
6691
7076
  replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
@@ -8813,7 +9198,7 @@ async function renderCharacters(page = characterListPage) {
8813
9198
  const details = normalizeCharacterDetails(item.attributes?.details);
8814
9199
  return `
8815
9200
  <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
8816
- <div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
9201
+ <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
8817
9202
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
8818
9203
  <div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
8819
9204
  ${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
@@ -8837,7 +9222,7 @@ async function renderCharacters(page = characterListPage) {
8837
9222
  const line = meta ? `${meta} · ${preview}` : preview;
8838
9223
  return `
8839
9224
  <article class="record-card module-row character-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
8840
- <div class="character-card-heading"><h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
9225
+ <div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
8841
9226
  <p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
8842
9227
  <div class="card-actions">${characterActions(item)}</div>
8843
9228
  </article>`;
@@ -10715,7 +11100,7 @@ function openTaskDetailDialog(task, trace) {
10715
11100
  });
10716
11101
  }
10717
11102
 
10718
- function renderProviderCards(providers, models) {
11103
+ function renderProviderCards(providers, models, protocolOptions) {
10719
11104
  return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
10720
11105
  const providerModels = models.filter((model) => model.providerId === provider.id);
10721
11106
  const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
@@ -10723,8 +11108,8 @@ function renderProviderCards(providers, models) {
10723
11108
  ? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
10724
11109
  : "";
10725
11110
  return `
10726
- <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
10727
- ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
11111
+ <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
11112
+ ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
10728
11113
  <div class="provider-models">${providerModels.map((model) => {
10729
11114
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
10730
11115
  const modelStatus = !model.enabled
@@ -10737,9 +11122,9 @@ function renderProviderCards(providers, models) {
10737
11122
  const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
10738
11123
  return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
10739
11124
  }).join("")}</div>
10740
- <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
11125
+ <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
10741
11126
  }).join("")}</div>`
10742
- : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、Anthropic 或 Google Vertex 接口地址和凭据,测试成功后再添加模型。");
11127
+ : emptyModule("尚未配置 AI 供应商", "添加后端返回的供应商协议、接口地址和凭据,测试成功后再添加模型。");
10743
11128
  }
10744
11129
 
10745
11130
  async function deletePlatformModel(item) {
@@ -10784,7 +11169,7 @@ async function deletePlatformProvider(item) {
10784
11169
  }
10785
11170
  }
10786
11171
 
10787
- function bindPlatformProviderActions(host, providers, models) {
11172
+ function bindPlatformProviderActions(host, providers, models, protocolOptions) {
10788
11173
  host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
10789
11174
  const providerId = button.dataset.testProvider;
10790
11175
  button.disabled = true;
@@ -10805,19 +11190,43 @@ function bindPlatformProviderActions(host, providers, models) {
10805
11190
  focusTarget?.focus({ preventScroll: true });
10806
11191
  }
10807
11192
  }));
10808
- host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel))));
11193
+ host.querySelectorAll("[data-import-provider-models]").forEach((button) => button.addEventListener("click", async () => {
11194
+ const providerId = button.dataset.importProviderModels;
11195
+ button.disabled = true;
11196
+ button.textContent = "获取中";
11197
+ try {
11198
+ const result = await api(`/api/providers/${encodeURIComponent(providerId)}/models/import`, { method: "POST", body: {} });
11199
+ const ignored = Number(result.invalidItemCount ?? 0);
11200
+ const ignoredMessage = ignored > 0 ? `,忽略 ${ignored} 条无效记录` : "";
11201
+ const message = Number(result.importedCount ?? 0) > 0
11202
+ ? `已获取 ${result.availableCount} 个模型,新增 ${result.importedCount} 个${ignoredMessage}`
11203
+ : `已获取 ${result.availableCount} 个模型,供应商列表中均已存在${ignoredMessage}`;
11204
+ await renderPlatformAiConfig();
11205
+ await loadModels();
11206
+ toast(message);
11207
+ } catch (error) {
11208
+ toast(error.message, "error");
11209
+ } finally {
11210
+ button.disabled = false;
11211
+ button.textContent = "获取模型";
11212
+ const focusTarget = button.isConnected ? button : host.querySelector(`[data-import-provider-models="${CSS.escape(providerId)}"]`);
11213
+ focusTarget?.focus({ preventScroll: true });
11214
+ }
11215
+ }));
11216
+ host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel), protocolOptions)));
10809
11217
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
10810
11218
  const model = models.find((item) => item.id === button.dataset.editModel);
10811
- openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId));
11219
+ openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId), protocolOptions);
10812
11220
  }));
10813
- host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
11221
+ host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider), protocolOptions)));
10814
11222
  }
10815
11223
 
10816
- function renderTaskDefaults(models, providers, taskDefaults, settings) {
11224
+ function renderTaskDefaults(models, providers, taskDefaults, settings, protocolOptions = platformAiProtocolOptions) {
10817
11225
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
11226
+ const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
10818
11227
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
10819
11228
  const availableModels = models.filter((model) => isSelectableModel(model));
10820
- const imageModels = availableModels.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
11229
+ const imageModels = availableModels.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
10821
11230
  const availableModelIds = new Set(availableModels.map((model) => model.id));
10822
11231
  const currentDefaultModels = taskDefaults
10823
11232
  .map((item) => item.model)
@@ -11150,20 +11559,23 @@ function startBackgroundTaskCenter(workId) {
11150
11559
  }
11151
11560
 
11152
11561
  async function renderPlatformAiConfig() {
11153
- const [providers, models, settings] = await Promise.all([
11562
+ const [providers, models, settings, protocolOptions] = await Promise.all([
11154
11563
  api("/api/platform/ai/providers"),
11155
11564
  api("/api/platform/ai/models"),
11156
- api("/api/platform/ai/settings")
11565
+ api("/api/platform/ai/settings"),
11566
+ api("/api/platform/ai/protocols")
11157
11567
  ]);
11568
+ platformAiProtocolOptions = protocolOptions;
11158
11569
  const host = $("#platform-ai-content");
11159
11570
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
11160
- const imageModels = models.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol === "openai-chat-completions");
11571
+ const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
11572
+ const imageModels = models.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
11161
11573
  const imageModelOptions = imageModels.map((model) => {
11162
11574
  const provider = providerById.get(model.providerId);
11163
11575
  const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
11164
11576
  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>`;
11165
11577
  }).join("");
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>`;
11578
+ 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, protocolOptions)}</section>`;
11167
11579
  $("#save-platform-system-prompt").addEventListener("click", async () => {
11168
11580
  const button = $("#save-platform-system-prompt");
11169
11581
  button.disabled = true;
@@ -11203,7 +11615,7 @@ async function renderPlatformAiConfig() {
11203
11615
  button.disabled = false;
11204
11616
  }
11205
11617
  });
11206
- bindPlatformProviderActions(host, providers, models);
11618
+ bindPlatformProviderActions(host, providers, models, protocolOptions);
11207
11619
  }
11208
11620
 
11209
11621
  function tokenUsageDateLabel(date) {
@@ -11301,9 +11713,19 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
11301
11713
  const requestCount = Number(summary.requestCount) || 0;
11302
11714
  const cachedInputTokens = Number(summary.cachedInputTokens) || 0;
11303
11715
  const cacheEligibleInputTokens = Number(summary.cacheEligibleInputTokens) || 0;
11716
+ const directInputTokens = Math.max(0, Math.round(Number(summary.directInputTokens) || 0));
11717
+ const cacheReadInputTokens = Math.max(0, Math.round(Number(summary.cacheReadInputTokens ?? cachedInputTokens) || 0));
11718
+ const cacheWriteInputTokens = Math.max(0, Math.round(Number(summary.cacheWriteInputTokens) || 0));
11719
+ const inputDescription = `直接 Input ${directInputTokens.toLocaleString("zh-CN")} · Cache Read ${cacheReadInputTokens.toLocaleString("zh-CN")} · Cache Write ${cacheWriteInputTokens.toLocaleString("zh-CN")}`;
11304
11720
  const cacheDescription = summary.cacheHitRate === null || summary.cacheHitRate === undefined
11305
11721
  ? "供应商尚未返回可计算的缓存明细"
11306
11722
  : `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
11723
+ const estimatedCost = formatEstimatedCost(summary.estimatedCost);
11724
+ const pricingDescription = "根据 LiteLLM 模型 ID 价格表估算,价格单位为美元;未匹配模型不计入。";
11725
+ const pricingBadge = summary.pricingAvailable === true && summary.estimatedCost !== null && summary.estimatedCost !== undefined
11726
+ ? `<span class="usage-cost-bubble" title="${esc(pricingDescription)}">估价 ${esc(estimatedCost)}</span>`
11727
+ : "";
11728
+ const unpricedModelCount = Math.max(0, Math.round(Number(summary.unpricedModelCount) || 0));
11307
11729
  const estimateNote = estimatedRequests > 0
11308
11730
  ? `其中 ${estimatedRequests.toLocaleString("zh-CN")} 次调用包含历史或供应商缺失用量时的估算。`
11309
11731
  : "全部用量均来自供应商返回的 Token 统计。";
@@ -11319,12 +11741,12 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
11319
11741
  return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
11320
11742
  <div class="config-section-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div></div>
11321
11743
  <div class="usage-stat-grid">
11322
- <article class="usage-stat is-primary"><span>总消耗</span><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
11323
- <article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small>${Number(summary.inputTokens || 0).toLocaleString("zh-CN")}</small></article>
11744
+ <article class="usage-stat is-primary"><div class="usage-stat-label"><span>总消耗</span>${pricingBadge}</div><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
11745
+ <article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small title="${esc(inputDescription)}">${esc(inputDescription)}</small></article>
11324
11746
  <article class="usage-stat"><span>输出 Token</span><strong>${esc(formatTokenCount(summary.outputTokens))}</strong><small>${Number(summary.outputTokens || 0).toLocaleString("zh-CN")}</small></article>
11325
11747
  <article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
11326
11748
  </div>
11327
- <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)}</p>
11749
+ <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
11328
11750
  <section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
11329
11751
  <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
11330
11752
  ${tokenUsageCalendarMarkup(usage?.daily)}
@@ -11352,15 +11774,17 @@ async function renderBookAiSettings() {
11352
11774
  clearTimeout(relationshipSearchIndexRefreshTimer);
11353
11775
  relationshipSearchIndexRefreshTimer = null;
11354
11776
  }
11355
- const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
11777
+ const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
11356
11778
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
11357
11779
  moduleApi("ai-settings", "/api/platform/ai/providers"),
11358
11780
  moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
11359
11781
  moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
11360
11782
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
11361
- moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
11783
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
11784
+ moduleApi("ai-settings", "/api/platform/ai/protocols")
11362
11785
  ]);
11363
11786
  const host = $("#module-content");
11787
+ platformAiProtocolOptions = protocolOptions;
11364
11788
  const workId = String(state.work.id);
11365
11789
  const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
11366
11790
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
@@ -11369,14 +11793,37 @@ async function renderBookAiSettings() {
11369
11793
  const quotaRemainingTokens = usage?.quota?.remainingTokens === null
11370
11794
  ? null
11371
11795
  : Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
11796
+ const monthlyTokenQuota = settings.monthlyTokenQuota === null ? null : Number(settings.monthlyTokenQuota);
11797
+ const monthlyQuotaUsedTokens = Number(usage?.quota?.monthlyUsedTokens) || 0;
11798
+ const monthlyQuotaRemainingTokens = usage?.quota?.monthlyRemainingTokens === null
11799
+ ? null
11800
+ : Math.max(0, Number(usage?.quota?.monthlyRemainingTokens) || 0);
11372
11801
  const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
11373
11802
  const quotaStatusText = dailyTokenQuota === null
11374
11803
  ? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
11375
11804
  : `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
11805
+ const monthlyQuotaStatusText = monthlyTokenQuota === null
11806
+ ? `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
11807
+ : `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} / ${monthlyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(monthlyQuotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
11376
11808
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
11377
11809
  title: "本书 Token 用量",
11378
11810
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
11379
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
11811
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
11812
+ const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
11813
+ dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
11814
+ const configureTokenQuotaInput = (input, warningId, threshold) => {
11815
+ input.min = "1";
11816
+ input.step = "1";
11817
+ const warning = document.createElement("small");
11818
+ warning.id = warningId;
11819
+ warning.className = "token-quota-warning hidden";
11820
+ warning.setAttribute("role", "alert");
11821
+ warning.dataset.threshold = String(threshold);
11822
+ warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
11823
+ input.closest(".config-inline-save")?.querySelector(".config-save-button")?.insertAdjacentElement("afterend", warning);
11824
+ };
11825
+ configureTokenQuotaInput(host.querySelector("#daily-token-quota"), "daily-token-quota-warning", 10_000);
11826
+ configureTokenQuotaInput(host.querySelector("#monthly-token-quota"), "monthly-token-quota-warning", 1_000_000);
11380
11827
  const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
11381
11828
  agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
11382
11829
  const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
@@ -11430,15 +11877,27 @@ async function renderBookAiSettings() {
11430
11877
  return status;
11431
11878
  };
11432
11879
  updateRelationshipIndexStatus(relationshipIndex);
11880
+ const syncTokenQuotaWarnings = () => {
11881
+ for (const period of ["daily", "monthly"]) {
11882
+ const enabled = $(`#${period}-token-quota-enabled`).checked;
11883
+ const input = $(`#${period}-token-quota`);
11884
+ const warning = $(`#${period}-token-quota-warning`);
11885
+ const value = Number(input.value);
11886
+ const threshold = Number(warning.dataset.threshold);
11887
+ warning.classList.toggle("hidden", !enabled || !Number.isInteger(value) || value <= 0 || value >= threshold);
11888
+ }
11889
+ };
11433
11890
  $("#daily-token-quota-enabled").addEventListener("change", (event) => {
11434
11891
  $("#daily-token-quota").disabled = !event.currentTarget.checked;
11892
+ syncTokenQuotaWarnings();
11435
11893
  });
11894
+ $("#daily-token-quota").addEventListener("input", syncTokenQuotaWarnings);
11436
11895
  $("#save-daily-token-quota").addEventListener("click", async () => {
11437
11896
  const button = $("#save-daily-token-quota");
11438
11897
  const enabled = $("#daily-token-quota-enabled").checked;
11439
11898
  const quota = Number($("#daily-token-quota").value);
11440
- if (enabled && (!Number.isInteger(quota) || quota < 10_000 || quota > 2_000_000_000)) {
11441
- toast("每日 Token 额度必须是 10,000 2,000,000,000 之间的整数", "error");
11899
+ if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
11900
+ toast("每日 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
11442
11901
  $("#daily-token-quota").focus();
11443
11902
  return;
11444
11903
  }
@@ -11455,6 +11914,34 @@ async function renderBookAiSettings() {
11455
11914
  button.disabled = false;
11456
11915
  }
11457
11916
  });
11917
+ $("#monthly-token-quota-enabled").addEventListener("change", (event) => {
11918
+ $("#monthly-token-quota").disabled = !event.currentTarget.checked;
11919
+ syncTokenQuotaWarnings();
11920
+ });
11921
+ $("#monthly-token-quota").addEventListener("input", syncTokenQuotaWarnings);
11922
+ $("#save-monthly-token-quota").addEventListener("click", async () => {
11923
+ const button = $("#save-monthly-token-quota");
11924
+ const enabled = $("#monthly-token-quota-enabled").checked;
11925
+ const quota = Number($("#monthly-token-quota").value);
11926
+ if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
11927
+ toast("每月 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
11928
+ $("#monthly-token-quota").focus();
11929
+ return;
11930
+ }
11931
+ button.disabled = true;
11932
+ try {
11933
+ await api(`/api/works/${state.work.id}/ai-settings`, {
11934
+ method: "PATCH",
11935
+ body: { monthlyTokenQuota: enabled ? quota : null }
11936
+ });
11937
+ toast(enabled ? "本书每月 Token 额度已保存" : "本书每月 Token 额度限制已关闭");
11938
+ await renderBookAiSettings();
11939
+ } catch (error) {
11940
+ toast(error.message, "error");
11941
+ button.disabled = false;
11942
+ }
11943
+ });
11944
+ syncTokenQuotaWarnings();
11458
11945
  $("#save-always-include-setting-info").addEventListener("click", async () => {
11459
11946
  const button = $("#save-always-include-setting-info");
11460
11947
  button.disabled = true;
@@ -11660,6 +12147,7 @@ function applyAiModels(models) {
11660
12147
  }
11661
12148
  setAiContextMeter(null);
11662
12149
  syncAiTaskOptions();
12150
+ syncAiModelPicker();
11663
12151
  }
11664
12152
 
11665
12153
  async function ensureAiModelsLoaded() {
@@ -11817,7 +12305,8 @@ function setAiModelPickerVisible(visible) {
11817
12305
  const popover = $("#ai-model-popover");
11818
12306
  popover.classList.toggle("hidden", !visible);
11819
12307
  button.setAttribute("aria-expanded", String(visible));
11820
- if (!visible && document.activeElement === $("#ai-model")) button.focus();
12308
+ if (!visible && (document.activeElement === $("#ai-model") || document.activeElement?.closest("#ai-model-options"))) button.focus();
12309
+ if (visible) focusAiModelOption();
11821
12310
  }
11822
12311
 
11823
12312
  function showAiContextWarning(usage = null) {
@@ -12060,6 +12549,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
12060
12549
  void discardPendingMarkdownAttachments();
12061
12550
  const dialog = $("#form-dialog");
12062
12551
  const form = $("#dynamic-form");
12552
+ form.noValidate = false;
12063
12553
  const submit = $("#dialog-submit");
12064
12554
  const submitStatus = $("#dialog-submit-status");
12065
12555
  const submitStatusMessage = $("#dialog-submit-status-message");
@@ -12437,6 +12927,9 @@ function openVolumeDialog(item) {
12437
12927
  if (!state.work) return openWorkDialog();
12438
12928
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
12439
12929
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
12930
+ const nextStoryOrder = Math.max(-1, ...state.work.volumes.map((volume) => Number(volume.storyOrder)).filter(Number.isInteger)) + 1;
12931
+ const storyOrder = Number.isInteger(Number(item?.storyOrder)) ? Number(item.storyOrder) : nextStoryOrder;
12932
+ const storyOrderField = `<label>剧情顺序<input name="storyOrder" type="number" min="0" max="1000000" step="1" required value="${esc(String(storyOrder))}" aria-describedby="volume-story-order-help"></label><p id="volume-story-order-help" class="form-field-note">仅用于 AI 判断分卷间的剧情先后,不改变左侧目录、阅读或导出顺序;相同值表示并行或暂时无法定序。</p>`;
12440
12933
  const management = item ? `<section class="entity-dialog-management" aria-label="分卷详情操作">
12441
12934
  <div><strong>分卷详情</strong><small>可将当前分卷单独导出为 EPUB;移入回收站时会连同其中章节隐藏,正文、版本和关联资料默认保留 30 天。</small></div>
12442
12935
  <div class="entity-dialog-management-actions"><button class="ghost-button" type="button" data-dialog-volume-export>导出 EPUB</button><button class="danger-button" type="button" data-dialog-volume-delete>移入回收站</button></div>
@@ -12444,6 +12937,7 @@ function openVolumeDialog(item) {
12444
12937
  openDialog(item ? "分卷详情" : "新建分卷",
12445
12938
  field("title", "分卷名称", "text", item?.title) +
12446
12939
  field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
12940
+ storyOrderField +
12447
12941
  field("description", "分卷简介", "textarea", item?.description) +
12448
12942
  field("keywords", "分卷关键词", "keyword-chips", item?.keywords ?? []) +
12449
12943
  management,
@@ -12451,6 +12945,7 @@ function openVolumeDialog(item) {
12451
12945
  const body = {
12452
12946
  title: form.get("title"),
12453
12947
  kind: form.get("kind"),
12948
+ storyOrder: Number(form.get("storyOrder")),
12454
12949
  description: form.get("description"),
12455
12950
  keywords: uniqueRelationshipKeywords(form.getAll("keywords").map(String))
12456
12951
  };
@@ -13505,6 +14000,34 @@ async function loadCharacterMarkdownSections(characterId) {
13505
14000
  }
13506
14001
  }
13507
14002
 
14003
+ function renderCharacterAvatar(item) {
14004
+ const preview = $("#character-avatar-preview");
14005
+ if (!preview) return;
14006
+ preview.replaceChildren();
14007
+ const fallback = document.createElement("span");
14008
+ fallback.className = "character-avatar-fallback";
14009
+ fallback.textContent = characterAvatarInitial(item);
14010
+ preview.append(fallback);
14011
+ if (item?.avatarUrl) {
14012
+ const image = document.createElement("img");
14013
+ image.alt = "";
14014
+ image.decoding = "async";
14015
+ image.src = item.avatarUrl;
14016
+ image.addEventListener("error", () => image.remove(), { once: true });
14017
+ preview.append(image);
14018
+ }
14019
+ const uploadButton = $("#character-avatar-upload-button");
14020
+ const removeButton = $("#character-avatar-remove-button");
14021
+ if (uploadButton) {
14022
+ uploadButton.textContent = item?.avatarUrl ? "更换头像" : "上传头像";
14023
+ uploadButton.disabled = !item?.id || entityEditorReadOnly || !canEditModule("characters");
14024
+ }
14025
+ if (removeButton) {
14026
+ removeButton.classList.toggle("hidden", !item?.avatarUrl);
14027
+ removeButton.disabled = entityEditorReadOnly || !canEditModule("characters");
14028
+ }
14029
+ }
14030
+
13508
14031
  function renderCharacterEditorFields(item) {
13509
14032
  const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
13510
14033
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
@@ -13512,9 +14035,9 @@ function renderCharacterEditorFields(item) {
13512
14035
  const stateEntries = characterStateEntries(item?.currentState ?? {});
13513
14036
  $("#character-editor-fields").innerHTML = [
13514
14037
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
14038
+ `<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
13515
14039
  field("name", "标准名", "text", item?.name) +
13516
14040
  field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
13517
- field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
13518
14041
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
13519
14042
  (!canReadModule("races")
13520
14043
  ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
@@ -13538,6 +14061,7 @@ function renderCharacterEditorFields(item) {
13538
14061
  field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
13539
14062
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
13540
14063
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
14064
+ field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
13541
14065
  field("currentState", "当前状态", "key-value-list", stateEntries, {
13542
14066
  keyName: "stateKey",
13543
14067
  valueName: "stateValue",
@@ -13556,6 +14080,7 @@ function renderCharacterEditorFields(item) {
13556
14080
  const name = $("#character-editor-fields [name='name']");
13557
14081
  if (name) name.required = true;
13558
14082
  bindDynamicListControls($("#character-editor-fields"));
14083
+ renderCharacterAvatar(item);
13559
14084
  renderCharacterEditorRelationships();
13560
14085
  renderCharacterMarkdownSections();
13561
14086
  activateCharacterEditorTab("basic");
@@ -13762,6 +14287,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
13762
14287
  characterEditorItem = saved;
13763
14288
  state.characters = upsertEntityCollection(state.characters, saved);
13764
14289
  entityEditorDirty = false;
14290
+ renderCharacterAvatar(saved);
13765
14291
  $("#character-editor-title").textContent = saved.name;
13766
14292
  $("#character-editor-version").textContent = `v${saved.versionNo}`;
13767
14293
  $("#character-change-note").value = "";
@@ -13782,6 +14308,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
13782
14308
  return { buttonLabel: "保存新版本", message };
13783
14309
  }
13784
14310
  });
14311
+ renderCharacterAvatar(characterEditorItem);
13785
14312
  };
13786
14313
  showEntityEditorPage("character", { readOnly });
13787
14314
  if (item) {
@@ -14801,22 +15328,12 @@ async function refreshAnalysisTaskViewsAfterCreate(workId) {
14801
15328
  }
14802
15329
  }
14803
15330
 
14804
- function openProviderDialog(item) {
14805
- const protocol = item?.protocol ?? "openai-chat-completions";
14806
- const providerProtocolOptions = [
14807
- ["openai-chat-completions", "OpenAI Chat Completions"],
14808
- ["anthropic-messages", "Anthropic Messages"],
14809
- ["google-vertex", "Google Vertex"]
14810
- ];
14811
- const defaultBaseUrlForProtocol = (value) => {
14812
- if (value === "anthropic-messages") return "https://api.anthropic.com";
14813
- if (value === "google-vertex") {
14814
- return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
14815
- }
14816
- return "https://api.openai.com/v1";
14817
- };
15331
+ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
15332
+ const protocol = item?.protocol ?? protocolOptions[0]?.value ?? "";
15333
+ const selectedProtocolOption = (value) => protocolOptions.find((option) => option.value === value);
15334
+ const defaultBaseUrlForProtocol = (value) => protocolOptions.find((option) => option.value === value)?.defaultBaseUrl ?? "";
14818
15335
  const credentialFieldForProtocol = (value) => {
14819
- if (value === "google-vertex") {
15336
+ if (protocolOptions.find((option) => option.value === value)?.credentialKind === "service-account-json") {
14820
15337
  return field(
14821
15338
  "apiKey",
14822
15339
  item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
@@ -14827,17 +15344,26 @@ function openProviderDialog(item) {
14827
15344
  return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
14828
15345
  };
14829
15346
  const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
14830
- const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens" && protocol !== "anthropic-messages";
15347
+ const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens"
15348
+ && protocolOptions.find((option) => option.value === protocol)?.supportsMaxCompletionTokens !== false;
15349
+ const useAdaptiveThinking = item?.thinkingType === "adaptive";
15350
+ const providerProtocolFieldOptions = protocolOptions.map((option) => [option.value, option.label]);
14831
15351
  const maxTokensParameterField = `<div class="form-field provider-max-tokens-parameter-field" data-provider-max-tokens-parameter-field><span>最大输出令牌参数</span><label class="checkbox-field"><input name="useMaxCompletionTokens" type="checkbox" ${useMaxCompletionTokens ? "checked" : ""} aria-describedby="provider-max-tokens-parameter-hint"><span>使用 max_completion_tokens</span></label><small id="provider-max-tokens-parameter-hint" data-provider-max-tokens-parameter-hint>默认使用 max_tokens;OpenAI 新版模型可按需切换。</small></div>`;
15352
+ const dailyTokenQuota = item?.dailyTokenQuota ?? null;
15353
+ const monthlyTokenQuota = item?.monthlyTokenQuota ?? null;
15354
+ const providerTokenQuotaFields = `<div class="form-field provider-token-quota-fields" data-provider-token-quota-fields><span>供应商 Token 额度</span><small>按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。</small><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="dailyTokenQuotaEnabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}><span>启用每日额度</span></label><label class="provider-token-quota-input">每日额度<input name="dailyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="供应商每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label></div><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="monthlyTokenQuotaEnabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}><span>启用每月额度</span></label><label class="provider-token-quota-input">每月额度<input name="monthlyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="供应商每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label></div></div>`;
15355
+ const thinkingTypeField = `<div class="form-field provider-thinking-type-field" data-provider-thinking-type-field><span>思考类型(开启时)</span><label class="checkbox-field"><input name="useAdaptiveThinking" type="checkbox" ${useAdaptiveThinking ? "checked" : ""} aria-describedby="provider-thinking-type-hint"><span>使用 adaptive(关闭时发送 enabled)</span></label><small id="provider-thinking-type-hint">关闭模型思考时仍发送 disabled;请只在供应商支持 adaptive 时开启。</small></div>`;
14832
15356
  openDialog(
14833
15357
  item ? "编辑 AI 供应商" : "新建 AI 供应商",
14834
15358
  field("name", "显示名称", "text", item?.name)
14835
- + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
15359
+ + field("protocol", "接口协议", "select", protocol, providerProtocolFieldOptions)
14836
15360
  + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
14837
15361
  + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
14838
15362
  + maxTokensParameterField
15363
+ + thinkingTypeField
14839
15364
  + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
14840
15365
  + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
15366
+ + providerTokenQuotaFields
14841
15367
  + field("note", "用途备注", "textarea", item?.note)
14842
15368
  + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
14843
15369
  async (form) => {
@@ -14846,32 +15372,67 @@ function openProviderDialog(item) {
14846
15372
  protocol: form.get("protocol"),
14847
15373
  baseUrl: form.get("baseUrl"),
14848
15374
  maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
15375
+ thinkingType: form.get("useAdaptiveThinking") === "on" ? "adaptive" : "enabled",
14849
15376
  concurrencyLimit: Number(form.get("concurrencyLimit")),
14850
15377
  rpmLimit: Number(form.get("rpmLimit")),
15378
+ dailyTokenQuota: form.get("dailyTokenQuotaEnabled") === "on" ? Number(form.get("dailyTokenQuota")) : null,
15379
+ monthlyTokenQuota: form.get("monthlyTokenQuotaEnabled") === "on" ? Number(form.get("monthlyTokenQuota")) : null,
14851
15380
  note: form.get("note"),
14852
15381
  status: form.get("enabled") === "on" ? "enabled" : "disabled"
14853
15382
  };
14854
15383
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
15384
+ for (const [period, label] of [["daily", "每日"], ["monthly", "每月"]]) {
15385
+ const value = body[`${period}TokenQuota`];
15386
+ if (value !== null && (!Number.isInteger(value) || value <= 0 || value > 2_000_000_000)) {
15387
+ throw new Error(`${label} Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000`);
15388
+ }
15389
+ }
14855
15390
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
14856
15391
  await renderPlatformAiConfig();
14857
15392
  await loadModels();
14858
15393
  if (item) toast(connectivityConfigurationSavedToast("provider"));
14859
15394
  },
14860
- item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex", {
15395
+ item ? "协议、限流与凭据" : "供应商协议、限流与凭据", {
14861
15396
  dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
14862
15397
  }
14863
15398
  );
15399
+ $("#dynamic-form").noValidate = true;
15400
+ const providerQuotaDescription = $("#dialog-fields [data-provider-token-quota-fields] > small");
15401
+ if (providerQuotaDescription) providerQuotaDescription.textContent = "按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。";
15402
+ for (const [period, threshold] of [["daily", 10_000], ["monthly", 1_000_000]]) {
15403
+ const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
15404
+ input.min = "1";
15405
+ input.step = "1";
15406
+ const warning = document.createElement("small");
15407
+ warning.className = "provider-token-quota-warning hidden";
15408
+ warning.dataset.period = period;
15409
+ warning.dataset.threshold = String(threshold);
15410
+ warning.setAttribute("role", "alert");
15411
+ warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
15412
+ input.insertAdjacentElement("afterend", warning);
15413
+ }
14864
15414
  const protocolSelect = $("#dialog-fields select[name='protocol']");
14865
15415
  const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
14866
15416
  const credentialHost = $("#dialog-fields [data-provider-credential-field]");
14867
15417
  const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
14868
15418
  const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
15419
+ const syncProviderTokenQuotaFields = () => {
15420
+ for (const period of ["daily", "monthly"]) {
15421
+ const enabled = $(`#dialog-fields input[name='${period}TokenQuotaEnabled']`);
15422
+ const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
15423
+ const warning = $(`#dialog-fields [data-period='${period}']`);
15424
+ input.disabled = !enabled.checked;
15425
+ const value = Number(input.value);
15426
+ const threshold = Number(warning.dataset.threshold);
15427
+ warning.classList.toggle("hidden", !enabled.checked || !Number.isInteger(value) || value <= 0 || value >= threshold);
15428
+ }
15429
+ };
14869
15430
  const syncMaxTokensParameter = () => {
14870
- const anthropic = protocolSelect.value === "anthropic-messages";
14871
- maxTokensParameterFieldElement.hidden = anthropic;
14872
- maxTokensParameterFieldElement.classList.toggle("hidden", anthropic);
14873
- maxTokensParameterInput.disabled = anthropic;
14874
- if (anthropic) maxTokensParameterInput.checked = false;
15431
+ const supportsMaxCompletionTokens = selectedProtocolOption(protocolSelect.value)?.supportsMaxCompletionTokens !== false;
15432
+ maxTokensParameterFieldElement.hidden = !supportsMaxCompletionTokens;
15433
+ maxTokensParameterFieldElement.classList.toggle("hidden", !supportsMaxCompletionTokens);
15434
+ maxTokensParameterInput.disabled = !supportsMaxCompletionTokens;
15435
+ if (!supportsMaxCompletionTokens) maxTokensParameterInput.checked = false;
14875
15436
  };
14876
15437
  const syncProviderCredentialField = () => {
14877
15438
  const nextProtocol = protocolSelect.value;
@@ -14879,14 +15440,19 @@ function openProviderDialog(item) {
14879
15440
  if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
14880
15441
  syncMaxTokensParameter();
14881
15442
  };
15443
+ $("#dialog-fields input[name='dailyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
15444
+ $("#dialog-fields input[name='monthlyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
15445
+ $("#dialog-fields input[name='dailyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
15446
+ $("#dialog-fields input[name='monthlyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
14882
15447
  protocolSelect.addEventListener("change", syncProviderCredentialField);
14883
15448
  syncMaxTokensParameter();
15449
+ syncProviderTokenQuotaFields();
14884
15450
  }
14885
15451
 
14886
- function openModelDialog(providerId, item = null, provider = null) {
15452
+ function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
14887
15453
  const values = modelFormValues(item);
14888
- const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol);
14889
- const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>平台默认模型只能由 Chat Completions 协议提供。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
15454
+ const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
15455
+ const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
14890
15456
  const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
14891
15457
  const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
14892
15458
  const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
@@ -14979,7 +15545,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
14979
15545
  if (aiRequestManager.hasActive(tab.id)) return;
14980
15546
  const composerSnapshot = captureAiPromptComposer();
14981
15547
  const requestComposerSnapshot = retry
14982
- ? { text: retry.prompt, citations: retry.citations ?? [], references: [] }
15548
+ ? { text: retry.prompt, citations: retry.citations ?? [], references: [], images: retry.images ?? [] }
14983
15549
  : composerSnapshot;
14984
15550
  const instruction = requestComposerSnapshot.text.trim();
14985
15551
  if (!instruction) return toast("请输入指令", "error");
@@ -15017,6 +15583,13 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15017
15583
  const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
15018
15584
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
15019
15585
  tab.selectedModelId = modelId;
15586
+ const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
15587
+ if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
15588
+ return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
15589
+ }
15590
+ if (imageAttachmentIds.length > 0 && taskType !== "chat") {
15591
+ return toast("图片附件目前仅支持问答对话", "error");
15592
+ }
15020
15593
  try {
15021
15594
  await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
15022
15595
  } catch (error) {
@@ -15075,6 +15648,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
15075
15648
  scope,
15076
15649
  modelId,
15077
15650
  citations,
15651
+ ...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
15078
15652
  conversationId: requestHolder.snapshot.conversationId,
15079
15653
  ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
15080
15654
  }, retry), createAiIdempotencyKey());
@@ -15359,6 +15933,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15359
15933
  ? persistedUserMessage.metadata.modelId
15360
15934
  : typeof body.modelId === "string" ? body.modelId : null;
15361
15935
  tab.modelId = lockedModelId;
15936
+ tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
15362
15937
  tab.selectedModelId = lockedModelId;
15363
15938
  tab.promptSent = true;
15364
15939
  clearAiChatTabComposer(tab);
@@ -15369,6 +15944,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15369
15944
  }
15370
15945
  if (isActiveAiChatTab(tab)) {
15371
15946
  state.aiConversationModelId = lockedModelId;
15947
+ state.aiConversationHasImages = tab.hasImageAttachments === true;
15372
15948
  state.aiPromptSent = true;
15373
15949
  syncAiTaskOptions();
15374
15950
  renderAiRoleplayCharacterSelect();
@@ -15509,6 +16085,31 @@ async function streamChat(requestHolder, body, idempotencyKey) {
15509
16085
  }
15510
16086
  }
15511
16087
 
16088
+ function appendAiMessageImageAttachments(message, attachments) {
16089
+ const normalized = normalizeAiChatImageAttachments(attachments);
16090
+ if (normalized.length === 0) return;
16091
+ const host = document.createElement("div");
16092
+ host.className = "ai-message-images";
16093
+ host.setAttribute("aria-label", "消息中的图片附件");
16094
+ for (const [index, attachment] of normalized.entries()) {
16095
+ const preview = document.createElement("button");
16096
+ preview.type = "button";
16097
+ preview.className = "ai-message-image-preview";
16098
+ preview.setAttribute("aria-label", `查看图片附件:${attachment.originalName}`);
16099
+ preview.title = "点击查看大图";
16100
+ preview.addEventListener("click", () => openAiImagePreview(attachment, index + 1));
16101
+ const image = document.createElement("img");
16102
+ image.src = attachment.contentUrl;
16103
+ image.alt = attachment.originalName;
16104
+ image.loading = "lazy";
16105
+ image.decoding = "async";
16106
+ image.title = attachment.originalName;
16107
+ preview.append(image);
16108
+ host.append(preview);
16109
+ }
16110
+ message.append(host);
16111
+ }
16112
+
15512
16113
  function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
15513
16114
  const tab = options.tab ?? activeAiChatTab();
15514
16115
  const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
@@ -15589,6 +16190,13 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
15589
16190
  }
15590
16191
  if (role === "user") {
15591
16192
  message.dataset.aiCitations = JSON.stringify(Array.isArray(citations) ? citations : []);
16193
+ const imageAttachments = normalizeAiChatImageAttachments(
16194
+ Array.isArray(metadata?.chatImageAttachmentIds)
16195
+ ? metadata.chatImageAttachmentIds.map((id) => ({ id }))
16196
+ : []
16197
+ );
16198
+ message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
16199
+ appendAiMessageImageAttachments(message, imageAttachments);
15592
16200
  attachUserCopyAction(message, text);
15593
16201
  }
15594
16202
  if (role === "assistant" && !text.startsWith("调用失败:")) {
@@ -16266,7 +16874,10 @@ $("#account-settings-button").addEventListener("click", () => {
16266
16874
  });
16267
16875
  });
16268
16876
  $("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
16269
- $("#avatar-upload-button").addEventListener("click", () => $("#avatar-file").click());
16877
+ $("#avatar-upload-button").addEventListener("click", () => {
16878
+ configureAvatarCropTarget({ type: "user", characterId: null });
16879
+ $("#avatar-file").click();
16880
+ });
16270
16881
 
16271
16882
  const avatarCropSession = {
16272
16883
  objectUrl: null,
@@ -16276,9 +16887,22 @@ const avatarCropSession = {
16276
16887
  display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
16277
16888
  drag: null,
16278
16889
  fileName: "",
16890
+ target: { type: "user", characterId: null },
16279
16891
  uploading: false
16280
16892
  };
16281
16893
 
16894
+ function configureAvatarCropTarget(target) {
16895
+ avatarCropSession.target = target;
16896
+ const isCharacter = target.type === "character";
16897
+ const fileInput = $("#avatar-file");
16898
+ if (fileInput) fileInput.setAttribute("accept", isCharacter ? characterAvatarFileAccept : userAvatarFileAccept);
16899
+ $("#avatar-crop-dialog-eyebrow").textContent = isCharacter ? "角色档案" : "个人账户";
16900
+ $("#avatar-crop-dialog-title").textContent = isCharacter ? "设置角色头像" : "裁剪头像";
16901
+ $("#avatar-crop-dialog-description").textContent = isCharacter
16902
+ ? "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。头像文件不超过 2 MB。"
16903
+ : "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。";
16904
+ }
16905
+
16282
16906
  function releaseAvatarCropObjectUrl() {
16283
16907
  if (!avatarCropSession.objectUrl) return;
16284
16908
  URL.revokeObjectURL(avatarCropSession.objectUrl);
@@ -16297,12 +16921,16 @@ function resetAvatarCropDialog() {
16297
16921
  avatarCropSession.imageHeight = 0;
16298
16922
  avatarCropSession.crop = { x: 0, y: 0, size: 0 };
16299
16923
  avatarCropSession.fileName = "";
16924
+ avatarCropSession.target = { type: "user", characterId: null };
16300
16925
  const image = $("#avatar-crop-image");
16301
16926
  if (image) image.removeAttribute("src");
16302
16927
  $("#avatar-crop-selection")?.setAttribute("hidden", "");
16303
16928
  $("#avatar-crop-preview")?.replaceChildren();
16304
16929
  const fileInput = $("#avatar-file");
16305
- if (fileInput) fileInput.value = "";
16930
+ if (fileInput) {
16931
+ fileInput.value = "";
16932
+ fileInput.setAttribute("accept", userAvatarFileAccept);
16933
+ }
16306
16934
  }
16307
16935
 
16308
16936
  function stagePointFromEvent(event) {
@@ -16472,8 +17100,17 @@ function renderAvatarUploadProgress(fileName, progress, visible = true) {
16472
17100
  $("#avatar-file").addEventListener("change", async (event) => {
16473
17101
  const file = event.target.files[0];
16474
17102
  if (!file) return;
16475
- if (file.size > imageUploadLimits.avatarBytes) {
16476
- toast(`头像图片不能超过 ${formatUploadLimit(imageUploadLimits.avatarBytes)}`, "error");
17103
+ const isCharacter = avatarCropSession.target.type === "character";
17104
+ if (isCharacter && isGifImageFile(file)) {
17105
+ toast("角色头像不支持 GIF 图片", "error");
17106
+ event.target.value = "";
17107
+ return;
17108
+ }
17109
+ const maximumBytes = isCharacter
17110
+ ? characterAvatarImageMaxBytes
17111
+ : imageUploadLimits.avatarBytes;
17112
+ if (file.size > maximumBytes) {
17113
+ toast(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`, "error");
16477
17114
  event.target.value = "";
16478
17115
  return;
16479
17116
  }
@@ -16561,22 +17198,46 @@ $("#avatar-crop-dialog").addEventListener("close", () => {
16561
17198
 
16562
17199
  $("#avatar-crop-confirm").addEventListener("click", async () => {
16563
17200
  if (avatarCropSession.uploading) return;
17201
+ const target = avatarCropSession.target;
17202
+ if (target.type === "character" && !target.characterId) {
17203
+ toast("请先保存角色档案", "error");
17204
+ return;
17205
+ }
17206
+ const isCharacter = target.type === "character";
16564
17207
  avatarCropSession.uploading = true;
16565
17208
  $("#avatar-crop-confirm").disabled = true;
16566
17209
  $("#avatar-crop-cancel").disabled = true;
16567
17210
  $("#avatar-upload-button").disabled = true;
16568
17211
  $("#avatar-remove-button").disabled = true;
17212
+ $("#character-avatar-upload-button")?.setAttribute("disabled", "true");
17213
+ $("#character-avatar-remove-button")?.setAttribute("disabled", "true");
16569
17214
  renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", 0);
16570
17215
  try {
16571
17216
  const blob = await exportAvatarCropBlob();
17217
+ const maximumBytes = isCharacter ? characterAvatarImageMaxBytes : imageUploadLimits.avatarBytes;
17218
+ if (blob.size > maximumBytes) {
17219
+ throw new Error(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`);
17220
+ }
16572
17221
  const body = new FormData();
16573
17222
  body.append("file", blob, "avatar.png");
16574
- const updated = await uploadWithProgress("/api/auth/avatar", { method: "PUT", body }, (progress) => renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", progress));
16575
- applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
16576
- renderProfileAvatar();
17223
+ const progress = (value) => renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", value);
17224
+ const updated = isCharacter
17225
+ ? await uploadWithProgress(`/api/characters/${encodeURIComponent(target.characterId)}/avatar`, { method: "PUT", body }, progress)
17226
+ : await uploadWithProgress("/api/auth/avatar", { method: "PUT", body }, progress);
17227
+ if (isCharacter) {
17228
+ if (characterEditorItem?.id === target.characterId) {
17229
+ characterEditorItem = updated;
17230
+ state.characters = upsertEntityCollection(state.characters, updated);
17231
+ renderCharacterAvatar(updated);
17232
+ }
17233
+ toast("角色头像已更新");
17234
+ } else {
17235
+ applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
17236
+ renderProfileAvatar();
17237
+ toast("头像已更新");
17238
+ }
16577
17239
  avatarCropSession.uploading = false;
16578
17240
  closeAvatarCropDialog();
16579
- toast("头像已更新");
16580
17241
  } catch (error) {
16581
17242
  toast(error.message, "error");
16582
17243
  } finally {
@@ -16585,6 +17246,8 @@ $("#avatar-crop-confirm").addEventListener("click", async () => {
16585
17246
  $("#avatar-crop-cancel").disabled = false;
16586
17247
  $("#avatar-upload-button").disabled = false;
16587
17248
  $("#avatar-remove-button").disabled = false;
17249
+ $("#character-avatar-upload-button")?.removeAttribute("disabled");
17250
+ $("#character-avatar-remove-button")?.removeAttribute("disabled");
16588
17251
  renderAvatarUploadProgress("", 0, false);
16589
17252
  }
16590
17253
  });
@@ -16783,7 +17446,21 @@ $("#platform-usage-refresh").addEventListener("click", async () => {
16783
17446
  button.disabled = false;
16784
17447
  }
16785
17448
  });
17449
+ $("#platform-usage-pricing-refresh").addEventListener("click", async () => {
17450
+ const button = $("#platform-usage-pricing-refresh");
17451
+ button.disabled = true;
17452
+ try {
17453
+ await api("/api/platform/ai/usage/pricing/refresh", { method: "POST", body: {}, skipOptimisticVersion: true });
17454
+ await renderPlatformTokenUsage();
17455
+ toast("模型价格已刷新");
17456
+ } catch (error) {
17457
+ toast(error.message, "error");
17458
+ } finally {
17459
+ button.disabled = false;
17460
+ }
17461
+ });
16786
17462
  $("#user-management-button").addEventListener("click", openUsersDialog);
17463
+ $("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
16787
17464
  $("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
16788
17465
  $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
16789
17466
  $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
@@ -16815,6 +17492,34 @@ $("#presence-button").addEventListener("click", () => {
16815
17492
  });
16816
17493
  $("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
16817
17494
  $("#users-settings-return").addEventListener("click", () => returnToSettingsHub("#user-management-button", "#users-dialog").catch((error) => toast(error.message, "error")));
17495
+ $("#admin-ai-conversations-close").addEventListener("click", () => $("#admin-ai-conversations-dialog").close());
17496
+ $("#admin-ai-conversations-settings-return").addEventListener("click", () => returnToSettingsHub("#admin-ai-conversations-button", "#admin-ai-conversations-dialog").catch((error) => toast(error.message, "error")));
17497
+ $("#admin-ai-conversations-filter-toggle").addEventListener("click", () => {
17498
+ adminAiConversationFiltersOpen = !adminAiConversationFiltersOpen;
17499
+ $("#admin-ai-conversations-filter-panel").classList.toggle("hidden", !adminAiConversationFiltersOpen);
17500
+ $("#admin-ai-conversations-filter-toggle").setAttribute("aria-expanded", String(adminAiConversationFiltersOpen));
17501
+ if (adminAiConversationFiltersOpen) $("#admin-ai-conversations-query").focus();
17502
+ });
17503
+ $("#admin-ai-conversations-filter-panel").addEventListener("submit", (event) => {
17504
+ event.preventDefault();
17505
+ loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
17506
+ });
17507
+ $("#admin-ai-conversations-reset").addEventListener("click", () => {
17508
+ $("#admin-ai-conversations-filter-panel").reset();
17509
+ loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
17510
+ });
17511
+ $("#admin-ai-conversations-refresh").addEventListener("click", (event) => {
17512
+ const button = event.currentTarget;
17513
+ button.disabled = true;
17514
+ loadAdminAiConversationPage()
17515
+ .catch((error) => toast(error.message, "error"))
17516
+ .finally(() => { button.disabled = false; });
17517
+ });
17518
+ $("#admin-ai-conversations-load-more").addEventListener("click", () => {
17519
+ if (adminAiConversationNextPage !== null) {
17520
+ loadAdminAiConversationPage(adminAiConversationNextPage, true).catch((error) => toast(error.message, "error"));
17521
+ }
17522
+ });
16818
17523
  $("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
16819
17524
  $("#platform-ui-settings-return").addEventListener("click", () => returnToSettingsHub("#platform-ui-settings-button", "#platform-ui-settings-dialog").catch((error) => toast(error.message, "error")));
16820
17525
  $("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
@@ -16927,7 +17632,7 @@ $("#member-permission-form").addEventListener("submit", async (event) => {
16927
17632
  toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
16928
17633
  } catch (error) { toast(error.message, "error"); }
16929
17634
  });
16930
- $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
17635
+ $("#platform-new-provider").addEventListener("click", () => openProviderDialog(null, platformAiProtocolOptions));
16931
17636
  $("#shelf-new-work").addEventListener("click", openWorkDialog);
16932
17637
  $("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
16933
17638
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
@@ -16998,6 +17703,7 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
16998
17703
  $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
16999
17704
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
17000
17705
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
17706
+ $("#ai-image-preview-close").addEventListener("click", () => $("#ai-image-preview-dialog").close());
17001
17707
  document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => {
17002
17708
  setAiToolCallCopyButtonState(button, false);
17003
17709
  button.addEventListener("click", () => void copyAiToolCallCode(button));
@@ -17015,6 +17721,37 @@ $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
17015
17721
  $("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
17016
17722
  $("#character-editor-fields").addEventListener("click", (event) => {
17017
17723
  if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
17724
+ const uploadButton = event.target.closest("#character-avatar-upload-button");
17725
+ if (uploadButton) {
17726
+ if (!characterEditorItem?.id) {
17727
+ toast("请先保存角色档案,再上传头像", "error");
17728
+ return;
17729
+ }
17730
+ configureAvatarCropTarget({ type: "character", characterId: String(characterEditorItem.id) });
17731
+ $("#avatar-file").click();
17732
+ return;
17733
+ }
17734
+ const removeButton = event.target.closest("#character-avatar-remove-button");
17735
+ if (!removeButton || removeButton.disabled || !characterEditorItem?.id) return;
17736
+ const currentItem = characterEditorItem;
17737
+ void (async () => {
17738
+ if (!(await confirmToast(`确定移除“${currentItem.name}”的头像吗?`, { title: "移除角色头像", confirmLabel: "确认移除" }))) return;
17739
+ removeButton.disabled = true;
17740
+ const upload = $("#character-avatar-upload-button");
17741
+ if (upload) upload.disabled = true;
17742
+ try {
17743
+ const updated = await api(`/api/characters/${encodeURIComponent(currentItem.id)}/avatar`, { method: "DELETE" });
17744
+ if (characterEditorItem?.id !== currentItem.id) return;
17745
+ characterEditorItem = updated;
17746
+ state.characters = upsertEntityCollection(state.characters, updated);
17747
+ renderCharacterAvatar(updated);
17748
+ deleteToast("角色头像已移除");
17749
+ } catch (error) {
17750
+ toast(error.message, "error");
17751
+ } finally {
17752
+ if (characterEditorItem?.id === currentItem.id) renderCharacterAvatar(characterEditorItem);
17753
+ }
17754
+ })();
17018
17755
  });
17019
17756
  $("#character-history-button").addEventListener("click", () => {
17020
17757
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
@@ -17180,7 +17917,29 @@ $("#module-nav").addEventListener("click", (event) => {
17180
17917
  });
17181
17918
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
17182
17919
  $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
17183
- bindPlainTextPaste($("#ai-prompt"));
17920
+ $("#ai-prompt").addEventListener("paste", (event) => {
17921
+ const files = clipboardImageFiles(event.clipboardData);
17922
+ if (files.length === 0) return;
17923
+ event.preventDefault();
17924
+ event.stopImmediatePropagation();
17925
+ if (!aiModelSupportsImageInput()) {
17926
+ toast("当前选择的模型不是多模态模型,无法粘贴图片附件", "error");
17927
+ return;
17928
+ }
17929
+ void addAiImageFiles(files);
17930
+ }, true);
17931
+ bindPlainTextPaste($("#ai-prompt"));
17932
+ $("#ai-attachment-button").addEventListener("click", () => {
17933
+ if (!aiModelSupportsImageInput()) {
17934
+ toast("当前选择的模型不是多模态模型,无法添加图片附件", "error");
17935
+ return;
17936
+ }
17937
+ $("#ai-attachment-input").click();
17938
+ });
17939
+ $("#ai-attachment-input").addEventListener("change", (event) => {
17940
+ const input = event.currentTarget;
17941
+ void addAiImageFiles(input.files).finally(() => { input.value = ""; });
17942
+ });
17184
17943
  $("#ai-prompt").addEventListener("input", async () => {
17185
17944
  updateAiMentionMenu();
17186
17945
  setAiContextMeter(null);
@@ -17210,10 +17969,10 @@ $("#ai-model").addEventListener("change", (event) => {
17210
17969
  return toast(aiConversationOptionLockedMessage);
17211
17970
  }
17212
17971
  setAiContextMeter(null);
17213
- syncAiModelPicker();
17214
17972
  setAiModelPickerVisible(false);
17973
+ syncAiModelPicker();
17215
17974
  });
17216
- $("#ai-model-picker").addEventListener("click", async (event) => {
17975
+ $("#ai-model-picker").addEventListener("click", async (event) => {
17217
17976
  const button = event.currentTarget;
17218
17977
  if (notifyAiConversationModelLocked(button)) return;
17219
17978
  const willOpen = $("#ai-model-popover").classList.contains("hidden");
@@ -17225,7 +17984,22 @@ $("#ai-model-picker").addEventListener("click", async (event) => {
17225
17984
  } catch (error) {
17226
17985
  toast(`模型加载失败:${error.message}`, "error");
17227
17986
  }
17228
- if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) $("#ai-model").focus();
17987
+ if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) focusAiModelOption();
17988
+ });
17989
+ $("#ai-model-options").addEventListener("keydown", (event) => {
17990
+ const options = [...event.currentTarget.querySelectorAll('[role="option"]')].filter((option) => !option.disabled);
17991
+ const currentIndex = options.indexOf(document.activeElement);
17992
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
17993
+ event.preventDefault();
17994
+ if (options.length === 0) return;
17995
+ const direction = event.key === "ArrowDown" ? 1 : -1;
17996
+ options[(currentIndex + direction + options.length) % options.length]?.focus();
17997
+ return;
17998
+ }
17999
+ if (event.key === "Enter" || event.key === " ") {
18000
+ event.preventDefault();
18001
+ document.activeElement?.click();
18002
+ }
17229
18003
  });
17230
18004
  $("#ai-roleplay-character").addEventListener("focus", async () => {
17231
18005
  try {
@@ -17541,7 +18315,6 @@ $("#ai-conversation-switcher-close").addEventListener("click", () => {
17541
18315
  $("#ai-conversation-switcher").focus();
17542
18316
  });
17543
18317
  $("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
17544
- $("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
17545
18318
  $("#ai-switcher-history").addEventListener("click", async () => {
17546
18319
  setAiConversationSwitcherVisible(false);
17547
18320
  try {