@musnows/scriverse 0.8.5 → 0.8.7
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.
- package/README.md +1 -0
- package/dist/ai-model-pricing.js +530 -0
- package/dist/ai-model-pricing.js.map +1 -0
- package/dist/ai-protocol.js +335 -15
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +1 -1
- package/dist/ai-retry.js.map +1 -1
- package/dist/ai.js +1083 -139
- package/dist/ai.js.map +1 -1
- package/dist/app.js +222 -25
- package/dist/app.js.map +1 -1
- package/dist/attachment-download.js +26 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachment-storage.js +8 -6
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +6 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +363 -4
- package/dist/database.js.map +1 -1
- package/dist/public/ai-image-attachments.d.ts +21 -0
- package/dist/public/ai-image-attachments.js +42 -0
- package/dist/public/ai-usage.d.ts +1 -0
- package/dist/public/ai-usage.js +11 -0
- package/dist/public/app.js +1030 -106
- package/dist/public/display-labels.d.ts +2 -1
- package/dist/public/display-labels.js +6 -6
- package/dist/public/index.html +42 -7
- package/dist/public/model-config.d.ts +6 -4
- package/dist/public/model-config.js +10 -2
- package/dist/public/styles.css +157 -6
- package/dist/s3-backup.js +21 -0
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +2 -1
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +550 -44
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +10 -4
- package/dist/upload-limits.js.map +1 -1
- package/dist/user-auth.js +3 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -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=
|
|
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=20260822-ai-model-thinking-label-v3&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=
|
|
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")
|
|
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
|
|
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
|
|
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
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -6602,6 +6986,7 @@ async function showSettingsHub() {
|
|
|
6602
6986
|
state.dirty = false;
|
|
6603
6987
|
}
|
|
6604
6988
|
dismissChapterInsightToast();
|
|
6989
|
+
dismissTokenUsageDetails({ restoreFocus: false });
|
|
6605
6990
|
dismissDeleteToasts();
|
|
6606
6991
|
updateDocumentTitle(state.work);
|
|
6607
6992
|
$("#app").classList.add("shelf-mode");
|
|
@@ -6650,6 +7035,7 @@ async function showPlatformAi() {
|
|
|
6650
7035
|
if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?"))) return false;
|
|
6651
7036
|
state.dirty = false;
|
|
6652
7037
|
dismissChapterInsightToast();
|
|
7038
|
+
dismissTokenUsageDetails({ restoreFocus: false });
|
|
6653
7039
|
dismissDeleteToasts();
|
|
6654
7040
|
updateDocumentTitle();
|
|
6655
7041
|
$("#app").classList.add("shelf-mode");
|
|
@@ -6673,6 +7059,7 @@ async function showPlatformUsage() {
|
|
|
6673
7059
|
if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 Token 用量面板将放弃本地修改。是否继续?"))) return false;
|
|
6674
7060
|
state.dirty = false;
|
|
6675
7061
|
dismissChapterInsightToast();
|
|
7062
|
+
dismissTokenUsageDetails({ restoreFocus: false });
|
|
6676
7063
|
dismissDeleteToasts();
|
|
6677
7064
|
updateDocumentTitle();
|
|
6678
7065
|
$("#app").classList.add("shelf-mode");
|
|
@@ -6686,6 +7073,7 @@ async function showPlatformUsage() {
|
|
|
6686
7073
|
$("#module-view").classList.add("hidden");
|
|
6687
7074
|
$("#work-meta").textContent = "Token 用量";
|
|
6688
7075
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
7076
|
+
$("#platform-usage-pricing-refresh").classList.toggle("hidden", state.user?.role !== "admin");
|
|
6689
7077
|
setTopbarViewState("Token 用量");
|
|
6690
7078
|
await renderPlatformTokenUsage();
|
|
6691
7079
|
replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
@@ -8813,7 +9201,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
8813
9201
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
8814
9202
|
return `
|
|
8815
9203
|
<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"
|
|
9204
|
+
<div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
8817
9205
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
8818
9206
|
<div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
|
|
8819
9207
|
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
@@ -8837,7 +9225,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
8837
9225
|
const line = meta ? `${meta} · ${preview}` : preview;
|
|
8838
9226
|
return `
|
|
8839
9227
|
<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"
|
|
9228
|
+
<div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
8841
9229
|
<p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
|
|
8842
9230
|
<div class="card-actions">${characterActions(item)}</div>
|
|
8843
9231
|
</article>`;
|
|
@@ -10715,7 +11103,7 @@ function openTaskDetailDialog(task, trace) {
|
|
|
10715
11103
|
});
|
|
10716
11104
|
}
|
|
10717
11105
|
|
|
10718
|
-
function renderProviderCards(providers, models) {
|
|
11106
|
+
function renderProviderCards(providers, models, protocolOptions) {
|
|
10719
11107
|
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
|
|
10720
11108
|
const providerModels = models.filter((model) => model.providerId === provider.id);
|
|
10721
11109
|
const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
|
|
@@ -10723,8 +11111,8 @@ function renderProviderCards(providers, models) {
|
|
|
10723
11111
|
? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
|
|
10724
11112
|
: "";
|
|
10725
11113
|
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>
|
|
11114
|
+
<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>
|
|
11115
|
+
${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
11116
|
<div class="provider-models">${providerModels.map((model) => {
|
|
10729
11117
|
const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
10730
11118
|
const modelStatus = !model.enabled
|
|
@@ -10737,9 +11125,9 @@ function renderProviderCards(providers, models) {
|
|
|
10737
11125
|
const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
|
|
10738
11126
|
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
11127
|
}).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>`;
|
|
11128
|
+
<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
11129
|
}).join("")}</div>`
|
|
10742
|
-
: emptyModule("尚未配置 AI 供应商", "
|
|
11130
|
+
: emptyModule("尚未配置 AI 供应商", "添加后端返回的供应商协议、接口地址和凭据,测试成功后再添加模型。");
|
|
10743
11131
|
}
|
|
10744
11132
|
|
|
10745
11133
|
async function deletePlatformModel(item) {
|
|
@@ -10784,7 +11172,7 @@ async function deletePlatformProvider(item) {
|
|
|
10784
11172
|
}
|
|
10785
11173
|
}
|
|
10786
11174
|
|
|
10787
|
-
function bindPlatformProviderActions(host, providers, models) {
|
|
11175
|
+
function bindPlatformProviderActions(host, providers, models, protocolOptions) {
|
|
10788
11176
|
host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
|
|
10789
11177
|
const providerId = button.dataset.testProvider;
|
|
10790
11178
|
button.disabled = true;
|
|
@@ -10805,19 +11193,43 @@ function bindPlatformProviderActions(host, providers, models) {
|
|
|
10805
11193
|
focusTarget?.focus({ preventScroll: true });
|
|
10806
11194
|
}
|
|
10807
11195
|
}));
|
|
10808
|
-
host.querySelectorAll("[data-
|
|
11196
|
+
host.querySelectorAll("[data-import-provider-models]").forEach((button) => button.addEventListener("click", async () => {
|
|
11197
|
+
const providerId = button.dataset.importProviderModels;
|
|
11198
|
+
button.disabled = true;
|
|
11199
|
+
button.textContent = "获取中";
|
|
11200
|
+
try {
|
|
11201
|
+
const result = await api(`/api/providers/${encodeURIComponent(providerId)}/models/import`, { method: "POST", body: {} });
|
|
11202
|
+
const ignored = Number(result.invalidItemCount ?? 0);
|
|
11203
|
+
const ignoredMessage = ignored > 0 ? `,忽略 ${ignored} 条无效记录` : "";
|
|
11204
|
+
const message = Number(result.importedCount ?? 0) > 0
|
|
11205
|
+
? `已获取 ${result.availableCount} 个模型,新增 ${result.importedCount} 个${ignoredMessage}`
|
|
11206
|
+
: `已获取 ${result.availableCount} 个模型,供应商列表中均已存在${ignoredMessage}`;
|
|
11207
|
+
await renderPlatformAiConfig();
|
|
11208
|
+
await loadModels();
|
|
11209
|
+
toast(message);
|
|
11210
|
+
} catch (error) {
|
|
11211
|
+
toast(error.message, "error");
|
|
11212
|
+
} finally {
|
|
11213
|
+
button.disabled = false;
|
|
11214
|
+
button.textContent = "获取模型";
|
|
11215
|
+
const focusTarget = button.isConnected ? button : host.querySelector(`[data-import-provider-models="${CSS.escape(providerId)}"]`);
|
|
11216
|
+
focusTarget?.focus({ preventScroll: true });
|
|
11217
|
+
}
|
|
11218
|
+
}));
|
|
11219
|
+
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
11220
|
host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
|
|
10810
11221
|
const model = models.find((item) => item.id === button.dataset.editModel);
|
|
10811
|
-
openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId));
|
|
11222
|
+
openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId), protocolOptions);
|
|
10812
11223
|
}));
|
|
10813
|
-
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
|
|
11224
|
+
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider), protocolOptions)));
|
|
10814
11225
|
}
|
|
10815
11226
|
|
|
10816
|
-
function renderTaskDefaults(models, providers, taskDefaults, settings) {
|
|
11227
|
+
function renderTaskDefaults(models, providers, taskDefaults, settings, protocolOptions = platformAiProtocolOptions) {
|
|
10817
11228
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
11229
|
+
const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
|
|
10818
11230
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
10819
11231
|
const availableModels = models.filter((model) => isSelectableModel(model));
|
|
10820
|
-
const imageModels = availableModels.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol ===
|
|
11232
|
+
const imageModels = availableModels.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
|
|
10821
11233
|
const availableModelIds = new Set(availableModels.map((model) => model.id));
|
|
10822
11234
|
const currentDefaultModels = taskDefaults
|
|
10823
11235
|
.map((item) => item.model)
|
|
@@ -11150,20 +11562,23 @@ function startBackgroundTaskCenter(workId) {
|
|
|
11150
11562
|
}
|
|
11151
11563
|
|
|
11152
11564
|
async function renderPlatformAiConfig() {
|
|
11153
|
-
const [providers, models, settings] = await Promise.all([
|
|
11565
|
+
const [providers, models, settings, protocolOptions] = await Promise.all([
|
|
11154
11566
|
api("/api/platform/ai/providers"),
|
|
11155
11567
|
api("/api/platform/ai/models"),
|
|
11156
|
-
api("/api/platform/ai/settings")
|
|
11568
|
+
api("/api/platform/ai/settings"),
|
|
11569
|
+
api("/api/platform/ai/protocols")
|
|
11157
11570
|
]);
|
|
11571
|
+
platformAiProtocolOptions = protocolOptions;
|
|
11158
11572
|
const host = $("#platform-ai-content");
|
|
11159
11573
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
11160
|
-
const
|
|
11574
|
+
const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
|
|
11575
|
+
const imageModels = models.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
|
|
11161
11576
|
const imageModelOptions = imageModels.map((model) => {
|
|
11162
11577
|
const provider = providerById.get(model.providerId);
|
|
11163
11578
|
const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
|
|
11164
11579
|
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
11580
|
}).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>`;
|
|
11581
|
+
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
11582
|
$("#save-platform-system-prompt").addEventListener("click", async () => {
|
|
11168
11583
|
const button = $("#save-platform-system-prompt");
|
|
11169
11584
|
button.disabled = true;
|
|
@@ -11203,7 +11618,7 @@ async function renderPlatformAiConfig() {
|
|
|
11203
11618
|
button.disabled = false;
|
|
11204
11619
|
}
|
|
11205
11620
|
});
|
|
11206
|
-
bindPlatformProviderActions(host, providers, models);
|
|
11621
|
+
bindPlatformProviderActions(host, providers, models, protocolOptions);
|
|
11207
11622
|
}
|
|
11208
11623
|
|
|
11209
11624
|
function tokenUsageDateLabel(date) {
|
|
@@ -11293,6 +11708,135 @@ function scrollUsageCalendarsToLatest(root) {
|
|
|
11293
11708
|
});
|
|
11294
11709
|
}
|
|
11295
11710
|
|
|
11711
|
+
let tokenUsageDetailsTrigger = null;
|
|
11712
|
+
|
|
11713
|
+
function tokenUsageDetailCount(value) {
|
|
11714
|
+
return Math.max(0, Math.round(Number(value) || 0)).toLocaleString("zh-CN");
|
|
11715
|
+
}
|
|
11716
|
+
|
|
11717
|
+
function dismissTokenUsageDetails({ restoreFocus = true } = {}) {
|
|
11718
|
+
const region = $("#toast-region");
|
|
11719
|
+
const element = region.querySelector("#token-usage-details-toast");
|
|
11720
|
+
if (!element) return;
|
|
11721
|
+
element.remove();
|
|
11722
|
+
const trigger = tokenUsageDetailsTrigger;
|
|
11723
|
+
tokenUsageDetailsTrigger = null;
|
|
11724
|
+
region.classList.remove("token-usage-details-region");
|
|
11725
|
+
if (trigger) {
|
|
11726
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
11727
|
+
if (restoreFocus && trigger.isConnected) trigger.focus({ preventScroll: true });
|
|
11728
|
+
}
|
|
11729
|
+
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
|
|
11730
|
+
region.hidePopover();
|
|
11731
|
+
}
|
|
11732
|
+
}
|
|
11733
|
+
|
|
11734
|
+
function appendTokenUsageDetailRow(parent, label, usage, isSummary = false) {
|
|
11735
|
+
const row = document.createElement("tr");
|
|
11736
|
+
if (isSummary) row.className = "is-summary";
|
|
11737
|
+
const model = document.createElement("th");
|
|
11738
|
+
model.scope = "row";
|
|
11739
|
+
model.textContent = label;
|
|
11740
|
+
row.append(model);
|
|
11741
|
+
["directInputTokens", "cacheWriteInputTokens", "cacheReadInputTokens", "outputTokens", "totalTokens", "requestCount", "estimatedRequestCount"].forEach((key) => {
|
|
11742
|
+
const cell = document.createElement("td");
|
|
11743
|
+
cell.textContent = tokenUsageDetailCount(usage?.[key]);
|
|
11744
|
+
row.append(cell);
|
|
11745
|
+
});
|
|
11746
|
+
parent.append(row);
|
|
11747
|
+
}
|
|
11748
|
+
|
|
11749
|
+
function showTokenUsageDetails(usage, title, trigger) {
|
|
11750
|
+
dismissTokenUsageDetails({ restoreFocus: false });
|
|
11751
|
+
const region = $("#toast-region");
|
|
11752
|
+
const element = document.createElement("section");
|
|
11753
|
+
element.id = "token-usage-details-toast";
|
|
11754
|
+
element.className = "toast token-usage-details-toast";
|
|
11755
|
+
element.setAttribute("role", "dialog");
|
|
11756
|
+
element.setAttribute("aria-labelledby", "token-usage-details-title");
|
|
11757
|
+
element.setAttribute("aria-describedby", "token-usage-details-description");
|
|
11758
|
+
|
|
11759
|
+
const header = document.createElement("header");
|
|
11760
|
+
header.className = "token-usage-details-header";
|
|
11761
|
+
const heading = document.createElement("div");
|
|
11762
|
+
heading.className = "token-usage-details-heading";
|
|
11763
|
+
const headingTitle = document.createElement("strong");
|
|
11764
|
+
headingTitle.id = "token-usage-details-title";
|
|
11765
|
+
headingTitle.textContent = "Token 用量详细数据";
|
|
11766
|
+
const headingScope = document.createElement("span");
|
|
11767
|
+
headingScope.textContent = title;
|
|
11768
|
+
heading.append(headingTitle, headingScope);
|
|
11769
|
+
const close = document.createElement("button");
|
|
11770
|
+
close.className = "ghost-button token-usage-details-close";
|
|
11771
|
+
close.type = "button";
|
|
11772
|
+
close.textContent = "关闭";
|
|
11773
|
+
close.setAttribute("aria-label", "关闭 Token 用量详细数据");
|
|
11774
|
+
header.append(heading, close);
|
|
11775
|
+
|
|
11776
|
+
const body = document.createElement("div");
|
|
11777
|
+
body.className = "token-usage-details-body";
|
|
11778
|
+
const description = document.createElement("p");
|
|
11779
|
+
description.id = "token-usage-details-description";
|
|
11780
|
+
description.className = "token-usage-details-note";
|
|
11781
|
+
description.textContent = "Raw Input 为不含缓存的输入 Token;Cache Write 与 Cache Read 也是输入组成部分,并已包含在总计中。汇总行包含当前范围内全部模型。";
|
|
11782
|
+
body.append(description);
|
|
11783
|
+
|
|
11784
|
+
const tableScroll = document.createElement("div");
|
|
11785
|
+
tableScroll.className = "token-usage-details-table-scroll";
|
|
11786
|
+
const table = document.createElement("table");
|
|
11787
|
+
table.className = "token-usage-details-table";
|
|
11788
|
+
const caption = document.createElement("caption");
|
|
11789
|
+
caption.textContent = `${title}的模型 Token 用量`;
|
|
11790
|
+
table.append(caption);
|
|
11791
|
+
const head = document.createElement("thead");
|
|
11792
|
+
const headRow = document.createElement("tr");
|
|
11793
|
+
["模型", "Raw Input", "Cache Write", "Cache Read", "Output", "总计", "调用", "估算调用"].forEach((label) => {
|
|
11794
|
+
const cell = document.createElement("th");
|
|
11795
|
+
cell.scope = "col";
|
|
11796
|
+
cell.textContent = label;
|
|
11797
|
+
headRow.append(cell);
|
|
11798
|
+
});
|
|
11799
|
+
head.append(headRow);
|
|
11800
|
+
table.append(head);
|
|
11801
|
+
const bodyRows = document.createElement("tbody");
|
|
11802
|
+
const models = Array.isArray(usage?.models) ? usage.models : [];
|
|
11803
|
+
models.forEach((model) => appendTokenUsageDetailRow(bodyRows, model.modelId || "未指定模型", model));
|
|
11804
|
+
if (!models.length) {
|
|
11805
|
+
const emptyRow = document.createElement("tr");
|
|
11806
|
+
const emptyCell = document.createElement("td");
|
|
11807
|
+
emptyCell.colSpan = 8;
|
|
11808
|
+
emptyCell.textContent = "还没有模型用量记录。";
|
|
11809
|
+
emptyRow.append(emptyCell);
|
|
11810
|
+
bodyRows.append(emptyRow);
|
|
11811
|
+
}
|
|
11812
|
+
table.append(bodyRows);
|
|
11813
|
+
const foot = document.createElement("tfoot");
|
|
11814
|
+
appendTokenUsageDetailRow(foot, "汇总", usage?.summary ?? {}, true);
|
|
11815
|
+
table.append(foot);
|
|
11816
|
+
tableScroll.append(table);
|
|
11817
|
+
body.append(tableScroll);
|
|
11818
|
+
element.append(header, body);
|
|
11819
|
+
|
|
11820
|
+
close.addEventListener("click", () => dismissTokenUsageDetails());
|
|
11821
|
+
element.addEventListener("keydown", (event) => {
|
|
11822
|
+
if (event.key !== "Escape") return;
|
|
11823
|
+
event.preventDefault();
|
|
11824
|
+
dismissTokenUsageDetails();
|
|
11825
|
+
});
|
|
11826
|
+
region.append(element);
|
|
11827
|
+
region.classList.add("token-usage-details-region");
|
|
11828
|
+
tokenUsageDetailsTrigger = trigger;
|
|
11829
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
11830
|
+
raiseToastRegion();
|
|
11831
|
+
close.focus({ preventScroll: true });
|
|
11832
|
+
}
|
|
11833
|
+
|
|
11834
|
+
function bindTokenUsageDetails(host, usage, title) {
|
|
11835
|
+
const button = host.querySelector("[data-token-usage-details]");
|
|
11836
|
+
if (!button) return;
|
|
11837
|
+
button.addEventListener("click", () => showTokenUsageDetails(usage, title, button));
|
|
11838
|
+
}
|
|
11839
|
+
|
|
11296
11840
|
function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false } = {}) {
|
|
11297
11841
|
const summary = usage?.summary ?? {};
|
|
11298
11842
|
const totalTokens = Number(summary.totalTokens) || 0;
|
|
@@ -11301,9 +11845,19 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
11301
11845
|
const requestCount = Number(summary.requestCount) || 0;
|
|
11302
11846
|
const cachedInputTokens = Number(summary.cachedInputTokens) || 0;
|
|
11303
11847
|
const cacheEligibleInputTokens = Number(summary.cacheEligibleInputTokens) || 0;
|
|
11848
|
+
const directInputTokens = Math.max(0, Math.round(Number(summary.directInputTokens) || 0));
|
|
11849
|
+
const cacheReadInputTokens = Math.max(0, Math.round(Number(summary.cacheReadInputTokens ?? cachedInputTokens) || 0));
|
|
11850
|
+
const cacheWriteInputTokens = Math.max(0, Math.round(Number(summary.cacheWriteInputTokens) || 0));
|
|
11851
|
+
const inputDescription = `直接 Input ${directInputTokens.toLocaleString("zh-CN")} · Cache Read ${cacheReadInputTokens.toLocaleString("zh-CN")} · Cache Write ${cacheWriteInputTokens.toLocaleString("zh-CN")}`;
|
|
11304
11852
|
const cacheDescription = summary.cacheHitRate === null || summary.cacheHitRate === undefined
|
|
11305
11853
|
? "供应商尚未返回可计算的缓存明细"
|
|
11306
11854
|
: `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
|
|
11855
|
+
const estimatedCost = formatEstimatedCost(summary.estimatedCost);
|
|
11856
|
+
const pricingDescription = "根据多来源模型价格表估算,价格单位为美元;未匹配模型不计入。";
|
|
11857
|
+
const pricingBadge = summary.pricingAvailable === true && summary.estimatedCost !== null && summary.estimatedCost !== undefined
|
|
11858
|
+
? `<span class="usage-cost-bubble" title="${esc(pricingDescription)}">估价 ${esc(estimatedCost)}</span>`
|
|
11859
|
+
: "";
|
|
11860
|
+
const unpricedModelCount = Math.max(0, Math.round(Number(summary.unpricedModelCount) || 0));
|
|
11307
11861
|
const estimateNote = estimatedRequests > 0
|
|
11308
11862
|
? `其中 ${estimatedRequests.toLocaleString("zh-CN")} 次调用包含历史或供应商缺失用量时的估算。`
|
|
11309
11863
|
: "全部用量均来自供应商返回的 Token 统计。";
|
|
@@ -11317,14 +11871,14 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
11317
11871
|
<td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
|
|
11318
11872
|
</tr>`).join("");
|
|
11319
11873
|
return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
|
|
11320
|
-
<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>
|
|
11874
|
+
<div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
|
|
11321
11875
|
<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
|
|
11876
|
+
<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>
|
|
11877
|
+
<article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small title="${esc(inputDescription)}">${esc(inputDescription)}</small></article>
|
|
11324
11878
|
<article class="usage-stat"><span>输出 Token</span><strong>${esc(formatTokenCount(summary.outputTokens))}</strong><small>${Number(summary.outputTokens || 0).toLocaleString("zh-CN")}</small></article>
|
|
11325
11879
|
<article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
|
|
11326
11880
|
</div>
|
|
11327
|
-
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)}
|
|
11881
|
+
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
|
|
11328
11882
|
<section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
|
|
11329
11883
|
<header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
|
|
11330
11884
|
${tokenUsageCalendarMarkup(usage?.daily)}
|
|
@@ -11343,6 +11897,7 @@ async function renderPlatformTokenUsage() {
|
|
|
11343
11897
|
description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
|
|
11344
11898
|
showWorks: true
|
|
11345
11899
|
});
|
|
11900
|
+
bindTokenUsageDetails(host, usage, "项目累计用量");
|
|
11346
11901
|
bindUsageCalendarInteractions(host);
|
|
11347
11902
|
scrollUsageCalendarsToLatest(host);
|
|
11348
11903
|
}
|
|
@@ -11352,15 +11907,17 @@ async function renderBookAiSettings() {
|
|
|
11352
11907
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
11353
11908
|
relationshipSearchIndexRefreshTimer = null;
|
|
11354
11909
|
}
|
|
11355
|
-
const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
|
|
11910
|
+
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
|
|
11356
11911
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
11357
11912
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
11358
11913
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
11359
11914
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
11360
11915
|
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()}`)
|
|
11916
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
11917
|
+
moduleApi("ai-settings", "/api/platform/ai/protocols")
|
|
11362
11918
|
]);
|
|
11363
11919
|
const host = $("#module-content");
|
|
11920
|
+
platformAiProtocolOptions = protocolOptions;
|
|
11364
11921
|
const workId = String(state.work.id);
|
|
11365
11922
|
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
11366
11923
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
|
|
@@ -11369,14 +11926,38 @@ async function renderBookAiSettings() {
|
|
|
11369
11926
|
const quotaRemainingTokens = usage?.quota?.remainingTokens === null
|
|
11370
11927
|
? null
|
|
11371
11928
|
: Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
|
|
11929
|
+
const monthlyTokenQuota = settings.monthlyTokenQuota === null ? null : Number(settings.monthlyTokenQuota);
|
|
11930
|
+
const monthlyQuotaUsedTokens = Number(usage?.quota?.monthlyUsedTokens) || 0;
|
|
11931
|
+
const monthlyQuotaRemainingTokens = usage?.quota?.monthlyRemainingTokens === null
|
|
11932
|
+
? null
|
|
11933
|
+
: Math.max(0, Number(usage?.quota?.monthlyRemainingTokens) || 0);
|
|
11372
11934
|
const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
|
|
11373
11935
|
const quotaStatusText = dailyTokenQuota === null
|
|
11374
11936
|
? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
11375
11937
|
: `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
11938
|
+
const monthlyQuotaStatusText = monthlyTokenQuota === null
|
|
11939
|
+
? `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
11940
|
+
: `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} / ${monthlyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(monthlyQuotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
11376
11941
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
11377
11942
|
title: "本书 Token 用量",
|
|
11378
11943
|
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
|
|
11944
|
+
})}</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)}`;
|
|
11945
|
+
bindTokenUsageDetails(host, usage, "本书 Token 用量");
|
|
11946
|
+
const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
|
|
11947
|
+
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>`);
|
|
11948
|
+
const configureTokenQuotaInput = (input, warningId, threshold) => {
|
|
11949
|
+
input.min = "1";
|
|
11950
|
+
input.step = "1";
|
|
11951
|
+
const warning = document.createElement("small");
|
|
11952
|
+
warning.id = warningId;
|
|
11953
|
+
warning.className = "token-quota-warning hidden";
|
|
11954
|
+
warning.setAttribute("role", "alert");
|
|
11955
|
+
warning.dataset.threshold = String(threshold);
|
|
11956
|
+
warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
|
|
11957
|
+
input.closest(".config-inline-save")?.querySelector(".config-save-button")?.insertAdjacentElement("afterend", warning);
|
|
11958
|
+
};
|
|
11959
|
+
configureTokenQuotaInput(host.querySelector("#daily-token-quota"), "daily-token-quota-warning", 10_000);
|
|
11960
|
+
configureTokenQuotaInput(host.querySelector("#monthly-token-quota"), "monthly-token-quota-warning", 1_000_000);
|
|
11380
11961
|
const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
|
|
11381
11962
|
agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
|
|
11382
11963
|
const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
|
|
@@ -11430,15 +12011,27 @@ async function renderBookAiSettings() {
|
|
|
11430
12011
|
return status;
|
|
11431
12012
|
};
|
|
11432
12013
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
12014
|
+
const syncTokenQuotaWarnings = () => {
|
|
12015
|
+
for (const period of ["daily", "monthly"]) {
|
|
12016
|
+
const enabled = $(`#${period}-token-quota-enabled`).checked;
|
|
12017
|
+
const input = $(`#${period}-token-quota`);
|
|
12018
|
+
const warning = $(`#${period}-token-quota-warning`);
|
|
12019
|
+
const value = Number(input.value);
|
|
12020
|
+
const threshold = Number(warning.dataset.threshold);
|
|
12021
|
+
warning.classList.toggle("hidden", !enabled || !Number.isInteger(value) || value <= 0 || value >= threshold);
|
|
12022
|
+
}
|
|
12023
|
+
};
|
|
11433
12024
|
$("#daily-token-quota-enabled").addEventListener("change", (event) => {
|
|
11434
12025
|
$("#daily-token-quota").disabled = !event.currentTarget.checked;
|
|
12026
|
+
syncTokenQuotaWarnings();
|
|
11435
12027
|
});
|
|
12028
|
+
$("#daily-token-quota").addEventListener("input", syncTokenQuotaWarnings);
|
|
11436
12029
|
$("#save-daily-token-quota").addEventListener("click", async () => {
|
|
11437
12030
|
const button = $("#save-daily-token-quota");
|
|
11438
12031
|
const enabled = $("#daily-token-quota-enabled").checked;
|
|
11439
12032
|
const quota = Number($("#daily-token-quota").value);
|
|
11440
|
-
if (enabled && (!Number.isInteger(quota) || quota
|
|
11441
|
-
toast("每日 Token
|
|
12033
|
+
if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
|
|
12034
|
+
toast("每日 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
|
|
11442
12035
|
$("#daily-token-quota").focus();
|
|
11443
12036
|
return;
|
|
11444
12037
|
}
|
|
@@ -11455,6 +12048,34 @@ async function renderBookAiSettings() {
|
|
|
11455
12048
|
button.disabled = false;
|
|
11456
12049
|
}
|
|
11457
12050
|
});
|
|
12051
|
+
$("#monthly-token-quota-enabled").addEventListener("change", (event) => {
|
|
12052
|
+
$("#monthly-token-quota").disabled = !event.currentTarget.checked;
|
|
12053
|
+
syncTokenQuotaWarnings();
|
|
12054
|
+
});
|
|
12055
|
+
$("#monthly-token-quota").addEventListener("input", syncTokenQuotaWarnings);
|
|
12056
|
+
$("#save-monthly-token-quota").addEventListener("click", async () => {
|
|
12057
|
+
const button = $("#save-monthly-token-quota");
|
|
12058
|
+
const enabled = $("#monthly-token-quota-enabled").checked;
|
|
12059
|
+
const quota = Number($("#monthly-token-quota").value);
|
|
12060
|
+
if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
|
|
12061
|
+
toast("每月 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
|
|
12062
|
+
$("#monthly-token-quota").focus();
|
|
12063
|
+
return;
|
|
12064
|
+
}
|
|
12065
|
+
button.disabled = true;
|
|
12066
|
+
try {
|
|
12067
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
12068
|
+
method: "PATCH",
|
|
12069
|
+
body: { monthlyTokenQuota: enabled ? quota : null }
|
|
12070
|
+
});
|
|
12071
|
+
toast(enabled ? "本书每月 Token 额度已保存" : "本书每月 Token 额度限制已关闭");
|
|
12072
|
+
await renderBookAiSettings();
|
|
12073
|
+
} catch (error) {
|
|
12074
|
+
toast(error.message, "error");
|
|
12075
|
+
button.disabled = false;
|
|
12076
|
+
}
|
|
12077
|
+
});
|
|
12078
|
+
syncTokenQuotaWarnings();
|
|
11458
12079
|
$("#save-always-include-setting-info").addEventListener("click", async () => {
|
|
11459
12080
|
const button = $("#save-always-include-setting-info");
|
|
11460
12081
|
button.disabled = true;
|
|
@@ -11660,6 +12281,7 @@ function applyAiModels(models) {
|
|
|
11660
12281
|
}
|
|
11661
12282
|
setAiContextMeter(null);
|
|
11662
12283
|
syncAiTaskOptions();
|
|
12284
|
+
syncAiModelPicker();
|
|
11663
12285
|
}
|
|
11664
12286
|
|
|
11665
12287
|
async function ensureAiModelsLoaded() {
|
|
@@ -11817,7 +12439,8 @@ function setAiModelPickerVisible(visible) {
|
|
|
11817
12439
|
const popover = $("#ai-model-popover");
|
|
11818
12440
|
popover.classList.toggle("hidden", !visible);
|
|
11819
12441
|
button.setAttribute("aria-expanded", String(visible));
|
|
11820
|
-
if (!visible && document.activeElement === $("#ai-model")) button.focus();
|
|
12442
|
+
if (!visible && (document.activeElement === $("#ai-model") || document.activeElement?.closest("#ai-model-options"))) button.focus();
|
|
12443
|
+
if (visible) focusAiModelOption();
|
|
11821
12444
|
}
|
|
11822
12445
|
|
|
11823
12446
|
function showAiContextWarning(usage = null) {
|
|
@@ -12060,6 +12683,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
12060
12683
|
void discardPendingMarkdownAttachments();
|
|
12061
12684
|
const dialog = $("#form-dialog");
|
|
12062
12685
|
const form = $("#dynamic-form");
|
|
12686
|
+
form.noValidate = false;
|
|
12063
12687
|
const submit = $("#dialog-submit");
|
|
12064
12688
|
const submitStatus = $("#dialog-submit-status");
|
|
12065
12689
|
const submitStatusMessage = $("#dialog-submit-status-message");
|
|
@@ -12437,6 +13061,9 @@ function openVolumeDialog(item) {
|
|
|
12437
13061
|
if (!state.work) return openWorkDialog();
|
|
12438
13062
|
if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
|
|
12439
13063
|
const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
|
|
13064
|
+
const nextStoryOrder = Math.max(-1, ...state.work.volumes.map((volume) => Number(volume.storyOrder)).filter(Number.isInteger)) + 1;
|
|
13065
|
+
const storyOrder = Number.isInteger(Number(item?.storyOrder)) ? Number(item.storyOrder) : nextStoryOrder;
|
|
13066
|
+
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
13067
|
const management = item ? `<section class="entity-dialog-management" aria-label="分卷详情操作">
|
|
12441
13068
|
<div><strong>分卷详情</strong><small>可将当前分卷单独导出为 EPUB;移入回收站时会连同其中章节隐藏,正文、版本和关联资料默认保留 30 天。</small></div>
|
|
12442
13069
|
<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 +13071,7 @@ function openVolumeDialog(item) {
|
|
|
12444
13071
|
openDialog(item ? "分卷详情" : "新建分卷",
|
|
12445
13072
|
field("title", "分卷名称", "text", item?.title) +
|
|
12446
13073
|
field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
|
|
13074
|
+
storyOrderField +
|
|
12447
13075
|
field("description", "分卷简介", "textarea", item?.description) +
|
|
12448
13076
|
field("keywords", "分卷关键词", "keyword-chips", item?.keywords ?? []) +
|
|
12449
13077
|
management,
|
|
@@ -12451,6 +13079,7 @@ function openVolumeDialog(item) {
|
|
|
12451
13079
|
const body = {
|
|
12452
13080
|
title: form.get("title"),
|
|
12453
13081
|
kind: form.get("kind"),
|
|
13082
|
+
storyOrder: Number(form.get("storyOrder")),
|
|
12454
13083
|
description: form.get("description"),
|
|
12455
13084
|
keywords: uniqueRelationshipKeywords(form.getAll("keywords").map(String))
|
|
12456
13085
|
};
|
|
@@ -13505,6 +14134,34 @@ async function loadCharacterMarkdownSections(characterId) {
|
|
|
13505
14134
|
}
|
|
13506
14135
|
}
|
|
13507
14136
|
|
|
14137
|
+
function renderCharacterAvatar(item) {
|
|
14138
|
+
const preview = $("#character-avatar-preview");
|
|
14139
|
+
if (!preview) return;
|
|
14140
|
+
preview.replaceChildren();
|
|
14141
|
+
const fallback = document.createElement("span");
|
|
14142
|
+
fallback.className = "character-avatar-fallback";
|
|
14143
|
+
fallback.textContent = characterAvatarInitial(item);
|
|
14144
|
+
preview.append(fallback);
|
|
14145
|
+
if (item?.avatarUrl) {
|
|
14146
|
+
const image = document.createElement("img");
|
|
14147
|
+
image.alt = "";
|
|
14148
|
+
image.decoding = "async";
|
|
14149
|
+
image.src = item.avatarUrl;
|
|
14150
|
+
image.addEventListener("error", () => image.remove(), { once: true });
|
|
14151
|
+
preview.append(image);
|
|
14152
|
+
}
|
|
14153
|
+
const uploadButton = $("#character-avatar-upload-button");
|
|
14154
|
+
const removeButton = $("#character-avatar-remove-button");
|
|
14155
|
+
if (uploadButton) {
|
|
14156
|
+
uploadButton.textContent = item?.avatarUrl ? "更换头像" : "上传头像";
|
|
14157
|
+
uploadButton.disabled = !item?.id || entityEditorReadOnly || !canEditModule("characters");
|
|
14158
|
+
}
|
|
14159
|
+
if (removeButton) {
|
|
14160
|
+
removeButton.classList.toggle("hidden", !item?.avatarUrl);
|
|
14161
|
+
removeButton.disabled = entityEditorReadOnly || !canEditModule("characters");
|
|
14162
|
+
}
|
|
14163
|
+
}
|
|
14164
|
+
|
|
13508
14165
|
function renderCharacterEditorFields(item) {
|
|
13509
14166
|
const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
|
|
13510
14167
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
@@ -13512,9 +14169,9 @@ function renderCharacterEditorFields(item) {
|
|
|
13512
14169
|
const stateEntries = characterStateEntries(item?.currentState ?? {});
|
|
13513
14170
|
$("#character-editor-fields").innerHTML = [
|
|
13514
14171
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
14172
|
+
`<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
14173
|
field("name", "标准名", "text", item?.name) +
|
|
13516
14174
|
field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
|
|
13517
|
-
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
13518
14175
|
field("aliases", "别名", "item-list", item?.aliases ?? []) +
|
|
13519
14176
|
(!canReadModule("races")
|
|
13520
14177
|
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
@@ -13538,6 +14195,7 @@ function renderCharacterEditorFields(item) {
|
|
|
13538
14195
|
field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
|
|
13539
14196
|
'<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
|
|
13540
14197
|
characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
|
|
14198
|
+
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
13541
14199
|
field("currentState", "当前状态", "key-value-list", stateEntries, {
|
|
13542
14200
|
keyName: "stateKey",
|
|
13543
14201
|
valueName: "stateValue",
|
|
@@ -13556,6 +14214,7 @@ function renderCharacterEditorFields(item) {
|
|
|
13556
14214
|
const name = $("#character-editor-fields [name='name']");
|
|
13557
14215
|
if (name) name.required = true;
|
|
13558
14216
|
bindDynamicListControls($("#character-editor-fields"));
|
|
14217
|
+
renderCharacterAvatar(item);
|
|
13559
14218
|
renderCharacterEditorRelationships();
|
|
13560
14219
|
renderCharacterMarkdownSections();
|
|
13561
14220
|
activateCharacterEditorTab("basic");
|
|
@@ -13762,6 +14421,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
13762
14421
|
characterEditorItem = saved;
|
|
13763
14422
|
state.characters = upsertEntityCollection(state.characters, saved);
|
|
13764
14423
|
entityEditorDirty = false;
|
|
14424
|
+
renderCharacterAvatar(saved);
|
|
13765
14425
|
$("#character-editor-title").textContent = saved.name;
|
|
13766
14426
|
$("#character-editor-version").textContent = `v${saved.versionNo}`;
|
|
13767
14427
|
$("#character-change-note").value = "";
|
|
@@ -13782,6 +14442,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
13782
14442
|
return { buttonLabel: "保存新版本", message };
|
|
13783
14443
|
}
|
|
13784
14444
|
});
|
|
14445
|
+
renderCharacterAvatar(characterEditorItem);
|
|
13785
14446
|
};
|
|
13786
14447
|
showEntityEditorPage("character", { readOnly });
|
|
13787
14448
|
if (item) {
|
|
@@ -14801,22 +15462,12 @@ async function refreshAnalysisTaskViewsAfterCreate(workId) {
|
|
|
14801
15462
|
}
|
|
14802
15463
|
}
|
|
14803
15464
|
|
|
14804
|
-
function openProviderDialog(item) {
|
|
14805
|
-
const protocol = item?.protocol ?? "
|
|
14806
|
-
const
|
|
14807
|
-
|
|
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
|
-
};
|
|
15465
|
+
function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
|
|
15466
|
+
const protocol = item?.protocol ?? protocolOptions[0]?.value ?? "";
|
|
15467
|
+
const selectedProtocolOption = (value) => protocolOptions.find((option) => option.value === value);
|
|
15468
|
+
const defaultBaseUrlForProtocol = (value) => protocolOptions.find((option) => option.value === value)?.defaultBaseUrl ?? "";
|
|
14818
15469
|
const credentialFieldForProtocol = (value) => {
|
|
14819
|
-
if (value === "
|
|
15470
|
+
if (protocolOptions.find((option) => option.value === value)?.credentialKind === "service-account-json") {
|
|
14820
15471
|
return field(
|
|
14821
15472
|
"apiKey",
|
|
14822
15473
|
item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
|
|
@@ -14827,17 +15478,26 @@ function openProviderDialog(item) {
|
|
|
14827
15478
|
return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
|
|
14828
15479
|
};
|
|
14829
15480
|
const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
|
|
14830
|
-
const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens"
|
|
15481
|
+
const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens"
|
|
15482
|
+
&& protocolOptions.find((option) => option.value === protocol)?.supportsMaxCompletionTokens !== false;
|
|
15483
|
+
const useAdaptiveThinking = item?.thinkingType === "adaptive";
|
|
15484
|
+
const providerProtocolFieldOptions = protocolOptions.map((option) => [option.value, option.label]);
|
|
14831
15485
|
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>`;
|
|
15486
|
+
const dailyTokenQuota = item?.dailyTokenQuota ?? null;
|
|
15487
|
+
const monthlyTokenQuota = item?.monthlyTokenQuota ?? null;
|
|
15488
|
+
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>`;
|
|
15489
|
+
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
15490
|
openDialog(
|
|
14833
15491
|
item ? "编辑 AI 供应商" : "新建 AI 供应商",
|
|
14834
15492
|
field("name", "显示名称", "text", item?.name)
|
|
14835
|
-
+ field("protocol", "接口协议", "select", protocol,
|
|
15493
|
+
+ field("protocol", "接口协议", "select", protocol, providerProtocolFieldOptions)
|
|
14836
15494
|
+ field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
|
|
14837
15495
|
+ `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
|
|
14838
15496
|
+ maxTokensParameterField
|
|
15497
|
+
+ thinkingTypeField
|
|
14839
15498
|
+ field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
|
|
14840
15499
|
+ field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
|
|
15500
|
+
+ providerTokenQuotaFields
|
|
14841
15501
|
+ field("note", "用途备注", "textarea", item?.note)
|
|
14842
15502
|
+ field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
|
|
14843
15503
|
async (form) => {
|
|
@@ -14846,32 +15506,67 @@ function openProviderDialog(item) {
|
|
|
14846
15506
|
protocol: form.get("protocol"),
|
|
14847
15507
|
baseUrl: form.get("baseUrl"),
|
|
14848
15508
|
maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
|
|
15509
|
+
thinkingType: form.get("useAdaptiveThinking") === "on" ? "adaptive" : "enabled",
|
|
14849
15510
|
concurrencyLimit: Number(form.get("concurrencyLimit")),
|
|
14850
15511
|
rpmLimit: Number(form.get("rpmLimit")),
|
|
15512
|
+
dailyTokenQuota: form.get("dailyTokenQuotaEnabled") === "on" ? Number(form.get("dailyTokenQuota")) : null,
|
|
15513
|
+
monthlyTokenQuota: form.get("monthlyTokenQuotaEnabled") === "on" ? Number(form.get("monthlyTokenQuota")) : null,
|
|
14851
15514
|
note: form.get("note"),
|
|
14852
15515
|
status: form.get("enabled") === "on" ? "enabled" : "disabled"
|
|
14853
15516
|
};
|
|
14854
15517
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
15518
|
+
for (const [period, label] of [["daily", "每日"], ["monthly", "每月"]]) {
|
|
15519
|
+
const value = body[`${period}TokenQuota`];
|
|
15520
|
+
if (value !== null && (!Number.isInteger(value) || value <= 0 || value > 2_000_000_000)) {
|
|
15521
|
+
throw new Error(`${label} Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000`);
|
|
15522
|
+
}
|
|
15523
|
+
}
|
|
14855
15524
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
14856
15525
|
await renderPlatformAiConfig();
|
|
14857
15526
|
await loadModels();
|
|
14858
15527
|
if (item) toast(connectivityConfigurationSavedToast("provider"));
|
|
14859
15528
|
},
|
|
14860
|
-
item ? "协议、限流与凭据" : "
|
|
15529
|
+
item ? "协议、限流与凭据" : "供应商协议、限流与凭据", {
|
|
14861
15530
|
dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
|
|
14862
15531
|
}
|
|
14863
15532
|
);
|
|
15533
|
+
$("#dynamic-form").noValidate = true;
|
|
15534
|
+
const providerQuotaDescription = $("#dialog-fields [data-provider-token-quota-fields] > small");
|
|
15535
|
+
if (providerQuotaDescription) providerQuotaDescription.textContent = "按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。";
|
|
15536
|
+
for (const [period, threshold] of [["daily", 10_000], ["monthly", 1_000_000]]) {
|
|
15537
|
+
const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
|
|
15538
|
+
input.min = "1";
|
|
15539
|
+
input.step = "1";
|
|
15540
|
+
const warning = document.createElement("small");
|
|
15541
|
+
warning.className = "provider-token-quota-warning hidden";
|
|
15542
|
+
warning.dataset.period = period;
|
|
15543
|
+
warning.dataset.threshold = String(threshold);
|
|
15544
|
+
warning.setAttribute("role", "alert");
|
|
15545
|
+
warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
|
|
15546
|
+
input.insertAdjacentElement("afterend", warning);
|
|
15547
|
+
}
|
|
14864
15548
|
const protocolSelect = $("#dialog-fields select[name='protocol']");
|
|
14865
15549
|
const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
|
|
14866
15550
|
const credentialHost = $("#dialog-fields [data-provider-credential-field]");
|
|
14867
15551
|
const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
|
|
14868
15552
|
const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
|
|
15553
|
+
const syncProviderTokenQuotaFields = () => {
|
|
15554
|
+
for (const period of ["daily", "monthly"]) {
|
|
15555
|
+
const enabled = $(`#dialog-fields input[name='${period}TokenQuotaEnabled']`);
|
|
15556
|
+
const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
|
|
15557
|
+
const warning = $(`#dialog-fields [data-period='${period}']`);
|
|
15558
|
+
input.disabled = !enabled.checked;
|
|
15559
|
+
const value = Number(input.value);
|
|
15560
|
+
const threshold = Number(warning.dataset.threshold);
|
|
15561
|
+
warning.classList.toggle("hidden", !enabled.checked || !Number.isInteger(value) || value <= 0 || value >= threshold);
|
|
15562
|
+
}
|
|
15563
|
+
};
|
|
14869
15564
|
const syncMaxTokensParameter = () => {
|
|
14870
|
-
const
|
|
14871
|
-
maxTokensParameterFieldElement.hidden =
|
|
14872
|
-
maxTokensParameterFieldElement.classList.toggle("hidden",
|
|
14873
|
-
maxTokensParameterInput.disabled =
|
|
14874
|
-
if (
|
|
15565
|
+
const supportsMaxCompletionTokens = selectedProtocolOption(protocolSelect.value)?.supportsMaxCompletionTokens !== false;
|
|
15566
|
+
maxTokensParameterFieldElement.hidden = !supportsMaxCompletionTokens;
|
|
15567
|
+
maxTokensParameterFieldElement.classList.toggle("hidden", !supportsMaxCompletionTokens);
|
|
15568
|
+
maxTokensParameterInput.disabled = !supportsMaxCompletionTokens;
|
|
15569
|
+
if (!supportsMaxCompletionTokens) maxTokensParameterInput.checked = false;
|
|
14875
15570
|
};
|
|
14876
15571
|
const syncProviderCredentialField = () => {
|
|
14877
15572
|
const nextProtocol = protocolSelect.value;
|
|
@@ -14879,14 +15574,19 @@ function openProviderDialog(item) {
|
|
|
14879
15574
|
if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
|
|
14880
15575
|
syncMaxTokensParameter();
|
|
14881
15576
|
};
|
|
15577
|
+
$("#dialog-fields input[name='dailyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
|
|
15578
|
+
$("#dialog-fields input[name='monthlyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
|
|
15579
|
+
$("#dialog-fields input[name='dailyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
|
|
15580
|
+
$("#dialog-fields input[name='monthlyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
|
|
14882
15581
|
protocolSelect.addEventListener("change", syncProviderCredentialField);
|
|
14883
15582
|
syncMaxTokensParameter();
|
|
15583
|
+
syncProviderTokenQuotaFields();
|
|
14884
15584
|
}
|
|
14885
15585
|
|
|
14886
|
-
function openModelDialog(providerId, item = null, provider = null) {
|
|
15586
|
+
function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
|
|
14887
15587
|
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
|
|
15588
|
+
const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
|
|
15589
|
+
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
15590
|
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
15591
|
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
15592
|
const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
|
|
@@ -14979,7 +15679,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
14979
15679
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
14980
15680
|
const composerSnapshot = captureAiPromptComposer();
|
|
14981
15681
|
const requestComposerSnapshot = retry
|
|
14982
|
-
? { text: retry.prompt, citations: retry.citations ?? [], references: [] }
|
|
15682
|
+
? { text: retry.prompt, citations: retry.citations ?? [], references: [], images: retry.images ?? [] }
|
|
14983
15683
|
: composerSnapshot;
|
|
14984
15684
|
const instruction = requestComposerSnapshot.text.trim();
|
|
14985
15685
|
if (!instruction) return toast("请输入指令", "error");
|
|
@@ -15017,6 +15717,13 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
15017
15717
|
const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
|
|
15018
15718
|
if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
|
|
15019
15719
|
tab.selectedModelId = modelId;
|
|
15720
|
+
const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
|
|
15721
|
+
if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
|
|
15722
|
+
return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
|
|
15723
|
+
}
|
|
15724
|
+
if (imageAttachmentIds.length > 0 && taskType !== "chat") {
|
|
15725
|
+
return toast("图片附件目前仅支持问答对话", "error");
|
|
15726
|
+
}
|
|
15020
15727
|
try {
|
|
15021
15728
|
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
15022
15729
|
} catch (error) {
|
|
@@ -15075,6 +15782,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
15075
15782
|
scope,
|
|
15076
15783
|
modelId,
|
|
15077
15784
|
citations,
|
|
15785
|
+
...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
|
|
15078
15786
|
conversationId: requestHolder.snapshot.conversationId,
|
|
15079
15787
|
...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
|
|
15080
15788
|
}, retry), createAiIdempotencyKey());
|
|
@@ -15250,9 +15958,23 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15250
15958
|
const message = document.createElement("div");
|
|
15251
15959
|
message.className = "assistant-message is-streaming";
|
|
15252
15960
|
message.dataset.testid = "ai-stream-message";
|
|
15253
|
-
|
|
15961
|
+
const streamConnectionStartedAt = Date.now();
|
|
15962
|
+
message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流…… <span class="ai-stream-connection-seconds" data-testid="ai-stream-connection-seconds"></span> 秒</div>';
|
|
15254
15963
|
const content = message.querySelector(".message-body");
|
|
15255
15964
|
const meta = message.querySelector(".message-meta");
|
|
15965
|
+
const connectionSeconds = message.querySelector(".ai-stream-connection-seconds");
|
|
15966
|
+
const renderStreamConnectionElapsed = () => {
|
|
15967
|
+
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - streamConnectionStartedAt) / 1000));
|
|
15968
|
+
connectionSeconds.textContent = String(elapsedSeconds);
|
|
15969
|
+
};
|
|
15970
|
+
renderStreamConnectionElapsed();
|
|
15971
|
+
let streamConnectionTimer = window.setInterval(renderStreamConnectionElapsed, 1000);
|
|
15972
|
+
const stopStreamConnectionTimer = () => {
|
|
15973
|
+
if (streamConnectionTimer === null) return;
|
|
15974
|
+
window.clearInterval(streamConnectionTimer);
|
|
15975
|
+
streamConnectionTimer = null;
|
|
15976
|
+
};
|
|
15977
|
+
const streamConnectionEstablishedEvents = new Set(["delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
|
|
15256
15978
|
const streamSpeedController = createStreamTypewriterSpeedController();
|
|
15257
15979
|
let messageMounted = false;
|
|
15258
15980
|
const mountAssistantMessage = () => {
|
|
@@ -15329,6 +16051,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15329
16051
|
let streamError = null;
|
|
15330
16052
|
const consume = async (eventName, payload) => {
|
|
15331
16053
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
16054
|
+
if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
|
|
15332
16055
|
if (eventName === "context") {
|
|
15333
16056
|
contextAction = typeof payload.action === "string" ? payload.action : "ready";
|
|
15334
16057
|
if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
|
|
@@ -15359,6 +16082,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15359
16082
|
? persistedUserMessage.metadata.modelId
|
|
15360
16083
|
: typeof body.modelId === "string" ? body.modelId : null;
|
|
15361
16084
|
tab.modelId = lockedModelId;
|
|
16085
|
+
tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
|
|
15362
16086
|
tab.selectedModelId = lockedModelId;
|
|
15363
16087
|
tab.promptSent = true;
|
|
15364
16088
|
clearAiChatTabComposer(tab);
|
|
@@ -15369,6 +16093,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15369
16093
|
}
|
|
15370
16094
|
if (isActiveAiChatTab(tab)) {
|
|
15371
16095
|
state.aiConversationModelId = lockedModelId;
|
|
16096
|
+
state.aiConversationHasImages = tab.hasImageAttachments === true;
|
|
15372
16097
|
state.aiPromptSent = true;
|
|
15373
16098
|
syncAiTaskOptions();
|
|
15374
16099
|
renderAiRoleplayCharacterSelect();
|
|
@@ -15438,7 +16163,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15438
16163
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
15439
16164
|
message.classList.remove("is-streaming");
|
|
15440
16165
|
content.setAttribute("aria-busy", "false");
|
|
15441
|
-
message.querySelector(".message-heading > span").textContent = "
|
|
16166
|
+
message.querySelector(".message-heading > span").textContent = aiAssistantLabel("", tab.roleplayCharacter);
|
|
15442
16167
|
toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : toolCalls;
|
|
15443
16168
|
processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : processSteps;
|
|
15444
16169
|
const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
|
|
@@ -15506,7 +16231,34 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15506
16231
|
if (streamedText) attachAssistantCopyAction(message, streamedText);
|
|
15507
16232
|
scrollAiFeedToBottom(feed);
|
|
15508
16233
|
throw streamFailure;
|
|
15509
|
-
}
|
|
16234
|
+
} finally {
|
|
16235
|
+
stopStreamConnectionTimer();
|
|
16236
|
+
}
|
|
16237
|
+
}
|
|
16238
|
+
|
|
16239
|
+
function appendAiMessageImageAttachments(message, attachments) {
|
|
16240
|
+
const normalized = normalizeAiChatImageAttachments(attachments);
|
|
16241
|
+
if (normalized.length === 0) return;
|
|
16242
|
+
const host = document.createElement("div");
|
|
16243
|
+
host.className = "ai-message-images";
|
|
16244
|
+
host.setAttribute("aria-label", "消息中的图片附件");
|
|
16245
|
+
for (const [index, attachment] of normalized.entries()) {
|
|
16246
|
+
const preview = document.createElement("button");
|
|
16247
|
+
preview.type = "button";
|
|
16248
|
+
preview.className = "ai-message-image-preview";
|
|
16249
|
+
preview.setAttribute("aria-label", `查看图片附件:${attachment.originalName}`);
|
|
16250
|
+
preview.title = "点击查看大图";
|
|
16251
|
+
preview.addEventListener("click", () => openAiImagePreview(attachment, index + 1));
|
|
16252
|
+
const image = document.createElement("img");
|
|
16253
|
+
image.src = attachment.contentUrl;
|
|
16254
|
+
image.alt = attachment.originalName;
|
|
16255
|
+
image.loading = "lazy";
|
|
16256
|
+
image.decoding = "async";
|
|
16257
|
+
image.title = attachment.originalName;
|
|
16258
|
+
preview.append(image);
|
|
16259
|
+
host.append(preview);
|
|
16260
|
+
}
|
|
16261
|
+
message.append(host);
|
|
15510
16262
|
}
|
|
15511
16263
|
|
|
15512
16264
|
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
|
|
@@ -15589,6 +16341,13 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
15589
16341
|
}
|
|
15590
16342
|
if (role === "user") {
|
|
15591
16343
|
message.dataset.aiCitations = JSON.stringify(Array.isArray(citations) ? citations : []);
|
|
16344
|
+
const imageAttachments = normalizeAiChatImageAttachments(
|
|
16345
|
+
Array.isArray(metadata?.chatImageAttachmentIds)
|
|
16346
|
+
? metadata.chatImageAttachmentIds.map((id) => ({ id }))
|
|
16347
|
+
: []
|
|
16348
|
+
);
|
|
16349
|
+
message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
|
|
16350
|
+
appendAiMessageImageAttachments(message, imageAttachments);
|
|
15592
16351
|
attachUserCopyAction(message, text);
|
|
15593
16352
|
}
|
|
15594
16353
|
if (role === "assistant" && !text.startsWith("调用失败:")) {
|
|
@@ -16266,7 +17025,10 @@ $("#account-settings-button").addEventListener("click", () => {
|
|
|
16266
17025
|
});
|
|
16267
17026
|
});
|
|
16268
17027
|
$("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
|
|
16269
|
-
$("#avatar-upload-button").addEventListener("click", () =>
|
|
17028
|
+
$("#avatar-upload-button").addEventListener("click", () => {
|
|
17029
|
+
configureAvatarCropTarget({ type: "user", characterId: null });
|
|
17030
|
+
$("#avatar-file").click();
|
|
17031
|
+
});
|
|
16270
17032
|
|
|
16271
17033
|
const avatarCropSession = {
|
|
16272
17034
|
objectUrl: null,
|
|
@@ -16276,9 +17038,22 @@ const avatarCropSession = {
|
|
|
16276
17038
|
display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
|
|
16277
17039
|
drag: null,
|
|
16278
17040
|
fileName: "",
|
|
17041
|
+
target: { type: "user", characterId: null },
|
|
16279
17042
|
uploading: false
|
|
16280
17043
|
};
|
|
16281
17044
|
|
|
17045
|
+
function configureAvatarCropTarget(target) {
|
|
17046
|
+
avatarCropSession.target = target;
|
|
17047
|
+
const isCharacter = target.type === "character";
|
|
17048
|
+
const fileInput = $("#avatar-file");
|
|
17049
|
+
if (fileInput) fileInput.setAttribute("accept", isCharacter ? characterAvatarFileAccept : userAvatarFileAccept);
|
|
17050
|
+
$("#avatar-crop-dialog-eyebrow").textContent = isCharacter ? "角色档案" : "个人账户";
|
|
17051
|
+
$("#avatar-crop-dialog-title").textContent = isCharacter ? "设置角色头像" : "裁剪头像";
|
|
17052
|
+
$("#avatar-crop-dialog-description").textContent = isCharacter
|
|
17053
|
+
? "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。头像文件不超过 2 MB。"
|
|
17054
|
+
: "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。";
|
|
17055
|
+
}
|
|
17056
|
+
|
|
16282
17057
|
function releaseAvatarCropObjectUrl() {
|
|
16283
17058
|
if (!avatarCropSession.objectUrl) return;
|
|
16284
17059
|
URL.revokeObjectURL(avatarCropSession.objectUrl);
|
|
@@ -16297,12 +17072,16 @@ function resetAvatarCropDialog() {
|
|
|
16297
17072
|
avatarCropSession.imageHeight = 0;
|
|
16298
17073
|
avatarCropSession.crop = { x: 0, y: 0, size: 0 };
|
|
16299
17074
|
avatarCropSession.fileName = "";
|
|
17075
|
+
avatarCropSession.target = { type: "user", characterId: null };
|
|
16300
17076
|
const image = $("#avatar-crop-image");
|
|
16301
17077
|
if (image) image.removeAttribute("src");
|
|
16302
17078
|
$("#avatar-crop-selection")?.setAttribute("hidden", "");
|
|
16303
17079
|
$("#avatar-crop-preview")?.replaceChildren();
|
|
16304
17080
|
const fileInput = $("#avatar-file");
|
|
16305
|
-
if (fileInput)
|
|
17081
|
+
if (fileInput) {
|
|
17082
|
+
fileInput.value = "";
|
|
17083
|
+
fileInput.setAttribute("accept", userAvatarFileAccept);
|
|
17084
|
+
}
|
|
16306
17085
|
}
|
|
16307
17086
|
|
|
16308
17087
|
function stagePointFromEvent(event) {
|
|
@@ -16472,8 +17251,17 @@ function renderAvatarUploadProgress(fileName, progress, visible = true) {
|
|
|
16472
17251
|
$("#avatar-file").addEventListener("change", async (event) => {
|
|
16473
17252
|
const file = event.target.files[0];
|
|
16474
17253
|
if (!file) return;
|
|
16475
|
-
|
|
16476
|
-
|
|
17254
|
+
const isCharacter = avatarCropSession.target.type === "character";
|
|
17255
|
+
if (isCharacter && isGifImageFile(file)) {
|
|
17256
|
+
toast("角色头像不支持 GIF 图片", "error");
|
|
17257
|
+
event.target.value = "";
|
|
17258
|
+
return;
|
|
17259
|
+
}
|
|
17260
|
+
const maximumBytes = isCharacter
|
|
17261
|
+
? characterAvatarImageMaxBytes
|
|
17262
|
+
: imageUploadLimits.avatarBytes;
|
|
17263
|
+
if (file.size > maximumBytes) {
|
|
17264
|
+
toast(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`, "error");
|
|
16477
17265
|
event.target.value = "";
|
|
16478
17266
|
return;
|
|
16479
17267
|
}
|
|
@@ -16561,22 +17349,46 @@ $("#avatar-crop-dialog").addEventListener("close", () => {
|
|
|
16561
17349
|
|
|
16562
17350
|
$("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
16563
17351
|
if (avatarCropSession.uploading) return;
|
|
17352
|
+
const target = avatarCropSession.target;
|
|
17353
|
+
if (target.type === "character" && !target.characterId) {
|
|
17354
|
+
toast("请先保存角色档案", "error");
|
|
17355
|
+
return;
|
|
17356
|
+
}
|
|
17357
|
+
const isCharacter = target.type === "character";
|
|
16564
17358
|
avatarCropSession.uploading = true;
|
|
16565
17359
|
$("#avatar-crop-confirm").disabled = true;
|
|
16566
17360
|
$("#avatar-crop-cancel").disabled = true;
|
|
16567
17361
|
$("#avatar-upload-button").disabled = true;
|
|
16568
17362
|
$("#avatar-remove-button").disabled = true;
|
|
17363
|
+
$("#character-avatar-upload-button")?.setAttribute("disabled", "true");
|
|
17364
|
+
$("#character-avatar-remove-button")?.setAttribute("disabled", "true");
|
|
16569
17365
|
renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", 0);
|
|
16570
17366
|
try {
|
|
16571
17367
|
const blob = await exportAvatarCropBlob();
|
|
17368
|
+
const maximumBytes = isCharacter ? characterAvatarImageMaxBytes : imageUploadLimits.avatarBytes;
|
|
17369
|
+
if (blob.size > maximumBytes) {
|
|
17370
|
+
throw new Error(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`);
|
|
17371
|
+
}
|
|
16572
17372
|
const body = new FormData();
|
|
16573
17373
|
body.append("file", blob, "avatar.png");
|
|
16574
|
-
const
|
|
16575
|
-
|
|
16576
|
-
|
|
17374
|
+
const progress = (value) => renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", value);
|
|
17375
|
+
const updated = isCharacter
|
|
17376
|
+
? await uploadWithProgress(`/api/characters/${encodeURIComponent(target.characterId)}/avatar`, { method: "PUT", body }, progress)
|
|
17377
|
+
: await uploadWithProgress("/api/auth/avatar", { method: "PUT", body }, progress);
|
|
17378
|
+
if (isCharacter) {
|
|
17379
|
+
if (characterEditorItem?.id === target.characterId) {
|
|
17380
|
+
characterEditorItem = updated;
|
|
17381
|
+
state.characters = upsertEntityCollection(state.characters, updated);
|
|
17382
|
+
renderCharacterAvatar(updated);
|
|
17383
|
+
}
|
|
17384
|
+
toast("角色头像已更新");
|
|
17385
|
+
} else {
|
|
17386
|
+
applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
|
|
17387
|
+
renderProfileAvatar();
|
|
17388
|
+
toast("头像已更新");
|
|
17389
|
+
}
|
|
16577
17390
|
avatarCropSession.uploading = false;
|
|
16578
17391
|
closeAvatarCropDialog();
|
|
16579
|
-
toast("头像已更新");
|
|
16580
17392
|
} catch (error) {
|
|
16581
17393
|
toast(error.message, "error");
|
|
16582
17394
|
} finally {
|
|
@@ -16585,6 +17397,8 @@ $("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
|
16585
17397
|
$("#avatar-crop-cancel").disabled = false;
|
|
16586
17398
|
$("#avatar-upload-button").disabled = false;
|
|
16587
17399
|
$("#avatar-remove-button").disabled = false;
|
|
17400
|
+
$("#character-avatar-upload-button")?.removeAttribute("disabled");
|
|
17401
|
+
$("#character-avatar-remove-button")?.removeAttribute("disabled");
|
|
16588
17402
|
renderAvatarUploadProgress("", 0, false);
|
|
16589
17403
|
}
|
|
16590
17404
|
});
|
|
@@ -16783,7 +17597,21 @@ $("#platform-usage-refresh").addEventListener("click", async () => {
|
|
|
16783
17597
|
button.disabled = false;
|
|
16784
17598
|
}
|
|
16785
17599
|
});
|
|
17600
|
+
$("#platform-usage-pricing-refresh").addEventListener("click", async () => {
|
|
17601
|
+
const button = $("#platform-usage-pricing-refresh");
|
|
17602
|
+
button.disabled = true;
|
|
17603
|
+
try {
|
|
17604
|
+
await api("/api/platform/ai/usage/pricing/refresh", { method: "POST", body: {}, skipOptimisticVersion: true });
|
|
17605
|
+
await renderPlatformTokenUsage();
|
|
17606
|
+
toast("模型价格已刷新");
|
|
17607
|
+
} catch (error) {
|
|
17608
|
+
toast(error.message, "error");
|
|
17609
|
+
} finally {
|
|
17610
|
+
button.disabled = false;
|
|
17611
|
+
}
|
|
17612
|
+
});
|
|
16786
17613
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
17614
|
+
$("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
|
|
16787
17615
|
$("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
|
|
16788
17616
|
$("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
|
|
16789
17617
|
$("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
|
|
@@ -16815,6 +17643,34 @@ $("#presence-button").addEventListener("click", () => {
|
|
|
16815
17643
|
});
|
|
16816
17644
|
$("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
|
|
16817
17645
|
$("#users-settings-return").addEventListener("click", () => returnToSettingsHub("#user-management-button", "#users-dialog").catch((error) => toast(error.message, "error")));
|
|
17646
|
+
$("#admin-ai-conversations-close").addEventListener("click", () => $("#admin-ai-conversations-dialog").close());
|
|
17647
|
+
$("#admin-ai-conversations-settings-return").addEventListener("click", () => returnToSettingsHub("#admin-ai-conversations-button", "#admin-ai-conversations-dialog").catch((error) => toast(error.message, "error")));
|
|
17648
|
+
$("#admin-ai-conversations-filter-toggle").addEventListener("click", () => {
|
|
17649
|
+
adminAiConversationFiltersOpen = !adminAiConversationFiltersOpen;
|
|
17650
|
+
$("#admin-ai-conversations-filter-panel").classList.toggle("hidden", !adminAiConversationFiltersOpen);
|
|
17651
|
+
$("#admin-ai-conversations-filter-toggle").setAttribute("aria-expanded", String(adminAiConversationFiltersOpen));
|
|
17652
|
+
if (adminAiConversationFiltersOpen) $("#admin-ai-conversations-query").focus();
|
|
17653
|
+
});
|
|
17654
|
+
$("#admin-ai-conversations-filter-panel").addEventListener("submit", (event) => {
|
|
17655
|
+
event.preventDefault();
|
|
17656
|
+
loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
|
|
17657
|
+
});
|
|
17658
|
+
$("#admin-ai-conversations-reset").addEventListener("click", () => {
|
|
17659
|
+
$("#admin-ai-conversations-filter-panel").reset();
|
|
17660
|
+
loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
|
|
17661
|
+
});
|
|
17662
|
+
$("#admin-ai-conversations-refresh").addEventListener("click", (event) => {
|
|
17663
|
+
const button = event.currentTarget;
|
|
17664
|
+
button.disabled = true;
|
|
17665
|
+
loadAdminAiConversationPage()
|
|
17666
|
+
.catch((error) => toast(error.message, "error"))
|
|
17667
|
+
.finally(() => { button.disabled = false; });
|
|
17668
|
+
});
|
|
17669
|
+
$("#admin-ai-conversations-load-more").addEventListener("click", () => {
|
|
17670
|
+
if (adminAiConversationNextPage !== null) {
|
|
17671
|
+
loadAdminAiConversationPage(adminAiConversationNextPage, true).catch((error) => toast(error.message, "error"));
|
|
17672
|
+
}
|
|
17673
|
+
});
|
|
16818
17674
|
$("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
16819
17675
|
$("#platform-ui-settings-return").addEventListener("click", () => returnToSettingsHub("#platform-ui-settings-button", "#platform-ui-settings-dialog").catch((error) => toast(error.message, "error")));
|
|
16820
17676
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
@@ -16927,7 +17783,7 @@ $("#member-permission-form").addEventListener("submit", async (event) => {
|
|
|
16927
17783
|
toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
|
|
16928
17784
|
} catch (error) { toast(error.message, "error"); }
|
|
16929
17785
|
});
|
|
16930
|
-
$("#platform-new-provider").addEventListener("click", () => openProviderDialog());
|
|
17786
|
+
$("#platform-new-provider").addEventListener("click", () => openProviderDialog(null, platformAiProtocolOptions));
|
|
16931
17787
|
$("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
16932
17788
|
$("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
|
|
16933
17789
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
@@ -16998,6 +17854,7 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
|
|
|
16998
17854
|
$("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
|
|
16999
17855
|
$("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
|
|
17000
17856
|
$("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
|
|
17857
|
+
$("#ai-image-preview-close").addEventListener("click", () => $("#ai-image-preview-dialog").close());
|
|
17001
17858
|
document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => {
|
|
17002
17859
|
setAiToolCallCopyButtonState(button, false);
|
|
17003
17860
|
button.addEventListener("click", () => void copyAiToolCallCode(button));
|
|
@@ -17015,6 +17872,37 @@ $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
|
17015
17872
|
$("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
17016
17873
|
$("#character-editor-fields").addEventListener("click", (event) => {
|
|
17017
17874
|
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
|
|
17875
|
+
const uploadButton = event.target.closest("#character-avatar-upload-button");
|
|
17876
|
+
if (uploadButton) {
|
|
17877
|
+
if (!characterEditorItem?.id) {
|
|
17878
|
+
toast("请先保存角色档案,再上传头像", "error");
|
|
17879
|
+
return;
|
|
17880
|
+
}
|
|
17881
|
+
configureAvatarCropTarget({ type: "character", characterId: String(characterEditorItem.id) });
|
|
17882
|
+
$("#avatar-file").click();
|
|
17883
|
+
return;
|
|
17884
|
+
}
|
|
17885
|
+
const removeButton = event.target.closest("#character-avatar-remove-button");
|
|
17886
|
+
if (!removeButton || removeButton.disabled || !characterEditorItem?.id) return;
|
|
17887
|
+
const currentItem = characterEditorItem;
|
|
17888
|
+
void (async () => {
|
|
17889
|
+
if (!(await confirmToast(`确定移除“${currentItem.name}”的头像吗?`, { title: "移除角色头像", confirmLabel: "确认移除" }))) return;
|
|
17890
|
+
removeButton.disabled = true;
|
|
17891
|
+
const upload = $("#character-avatar-upload-button");
|
|
17892
|
+
if (upload) upload.disabled = true;
|
|
17893
|
+
try {
|
|
17894
|
+
const updated = await api(`/api/characters/${encodeURIComponent(currentItem.id)}/avatar`, { method: "DELETE" });
|
|
17895
|
+
if (characterEditorItem?.id !== currentItem.id) return;
|
|
17896
|
+
characterEditorItem = updated;
|
|
17897
|
+
state.characters = upsertEntityCollection(state.characters, updated);
|
|
17898
|
+
renderCharacterAvatar(updated);
|
|
17899
|
+
deleteToast("角色头像已移除");
|
|
17900
|
+
} catch (error) {
|
|
17901
|
+
toast(error.message, "error");
|
|
17902
|
+
} finally {
|
|
17903
|
+
if (characterEditorItem?.id === currentItem.id) renderCharacterAvatar(characterEditorItem);
|
|
17904
|
+
}
|
|
17905
|
+
})();
|
|
17018
17906
|
});
|
|
17019
17907
|
$("#character-history-button").addEventListener("click", () => {
|
|
17020
17908
|
if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
|
|
@@ -17180,7 +18068,29 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
17180
18068
|
});
|
|
17181
18069
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
17182
18070
|
$("#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
|
-
|
|
18071
|
+
$("#ai-prompt").addEventListener("paste", (event) => {
|
|
18072
|
+
const files = clipboardImageFiles(event.clipboardData);
|
|
18073
|
+
if (files.length === 0) return;
|
|
18074
|
+
event.preventDefault();
|
|
18075
|
+
event.stopImmediatePropagation();
|
|
18076
|
+
if (!aiModelSupportsImageInput()) {
|
|
18077
|
+
toast("当前选择的模型不是多模态模型,无法粘贴图片附件", "error");
|
|
18078
|
+
return;
|
|
18079
|
+
}
|
|
18080
|
+
void addAiImageFiles(files);
|
|
18081
|
+
}, true);
|
|
18082
|
+
bindPlainTextPaste($("#ai-prompt"));
|
|
18083
|
+
$("#ai-attachment-button").addEventListener("click", () => {
|
|
18084
|
+
if (!aiModelSupportsImageInput()) {
|
|
18085
|
+
toast("当前选择的模型不是多模态模型,无法添加图片附件", "error");
|
|
18086
|
+
return;
|
|
18087
|
+
}
|
|
18088
|
+
$("#ai-attachment-input").click();
|
|
18089
|
+
});
|
|
18090
|
+
$("#ai-attachment-input").addEventListener("change", (event) => {
|
|
18091
|
+
const input = event.currentTarget;
|
|
18092
|
+
void addAiImageFiles(input.files).finally(() => { input.value = ""; });
|
|
18093
|
+
});
|
|
17184
18094
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
17185
18095
|
updateAiMentionMenu();
|
|
17186
18096
|
setAiContextMeter(null);
|
|
@@ -17210,10 +18120,10 @@ $("#ai-model").addEventListener("change", (event) => {
|
|
|
17210
18120
|
return toast(aiConversationOptionLockedMessage);
|
|
17211
18121
|
}
|
|
17212
18122
|
setAiContextMeter(null);
|
|
17213
|
-
syncAiModelPicker();
|
|
17214
18123
|
setAiModelPickerVisible(false);
|
|
18124
|
+
syncAiModelPicker();
|
|
17215
18125
|
});
|
|
17216
|
-
$("#ai-model-picker").addEventListener("click", async (event) => {
|
|
18126
|
+
$("#ai-model-picker").addEventListener("click", async (event) => {
|
|
17217
18127
|
const button = event.currentTarget;
|
|
17218
18128
|
if (notifyAiConversationModelLocked(button)) return;
|
|
17219
18129
|
const willOpen = $("#ai-model-popover").classList.contains("hidden");
|
|
@@ -17225,7 +18135,22 @@ $("#ai-model-picker").addEventListener("click", async (event) => {
|
|
|
17225
18135
|
} catch (error) {
|
|
17226
18136
|
toast(`模型加载失败:${error.message}`, "error");
|
|
17227
18137
|
}
|
|
17228
|
-
if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled)
|
|
18138
|
+
if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) focusAiModelOption();
|
|
18139
|
+
});
|
|
18140
|
+
$("#ai-model-options").addEventListener("keydown", (event) => {
|
|
18141
|
+
const options = [...event.currentTarget.querySelectorAll('[role="option"]')].filter((option) => !option.disabled);
|
|
18142
|
+
const currentIndex = options.indexOf(document.activeElement);
|
|
18143
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
18144
|
+
event.preventDefault();
|
|
18145
|
+
if (options.length === 0) return;
|
|
18146
|
+
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
18147
|
+
options[(currentIndex + direction + options.length) % options.length]?.focus();
|
|
18148
|
+
return;
|
|
18149
|
+
}
|
|
18150
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
18151
|
+
event.preventDefault();
|
|
18152
|
+
document.activeElement?.click();
|
|
18153
|
+
}
|
|
17229
18154
|
});
|
|
17230
18155
|
$("#ai-roleplay-character").addEventListener("focus", async () => {
|
|
17231
18156
|
try {
|
|
@@ -17541,7 +18466,6 @@ $("#ai-conversation-switcher-close").addEventListener("click", () => {
|
|
|
17541
18466
|
$("#ai-conversation-switcher").focus();
|
|
17542
18467
|
});
|
|
17543
18468
|
$("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
|
|
17544
|
-
$("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
|
|
17545
18469
|
$("#ai-switcher-history").addEventListener("click", async () => {
|
|
17546
18470
|
setAiConversationSwitcherVisible(false);
|
|
17547
18471
|
try {
|