@musnows/scriverse 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/ai-model-pricing.js +295 -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-stream-timeout.js +9 -6
- package/dist/ai-stream-timeout.js.map +1 -1
- package/dist/ai.js +1103 -142
- package/dist/ai.js.map +1 -1
- package/dist/app.js +230 -28
- 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 +352 -3
- 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 +1125 -150
- 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 +3 -1
- package/dist/public/model-config.js +9 -2
- package/dist/public/styles.css +120 -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 +10 -2
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +562 -47
- 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=20260821-ai-model-thinking-label-v1&feature=ai-provider-responses-v1";
|
|
20
20
|
import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
|
|
21
21
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
22
22
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
|
|
23
23
|
import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
|
|
24
24
|
import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
|
|
25
|
-
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=
|
|
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 });
|
|
@@ -2035,12 +2067,14 @@ function createAiChatTabState(input = {}) {
|
|
|
2035
2067
|
return existing;
|
|
2036
2068
|
}
|
|
2037
2069
|
const feed = input.feed ?? createAiChatFeed();
|
|
2070
|
+
bindAiFeedAutoScroll(feed);
|
|
2038
2071
|
const tab = aiChatTabManager.open({
|
|
2039
2072
|
workId: String(state.work?.id ?? ""),
|
|
2040
2073
|
conversationId: input.conversationId ?? null,
|
|
2041
2074
|
title: input.title ?? "新对话",
|
|
2042
2075
|
modelId: input.modelId ?? null,
|
|
2043
2076
|
selectedModelId: input.selectedModelId ?? null,
|
|
2077
|
+
hasImageAttachments: input.hasImageAttachments === true,
|
|
2044
2078
|
promptSent: input.promptSent === true,
|
|
2045
2079
|
taskType: input.taskType ?? "chat",
|
|
2046
2080
|
contextScope: input.contextScope ?? { type: "none" },
|
|
@@ -2048,7 +2082,7 @@ function createAiChatTabState(input = {}) {
|
|
|
2048
2082
|
roleplayUserCharacter: input.roleplayUserCharacter ?? null,
|
|
2049
2083
|
citations: input.citations ?? [],
|
|
2050
2084
|
references: input.references ?? [],
|
|
2051
|
-
composer: input.composer ?? { text: "", citations: [], references: [] },
|
|
2085
|
+
composer: input.composer ?? { text: "", citations: [], references: [], images: [] },
|
|
2052
2086
|
contextUsage: input.contextUsage ?? null,
|
|
2053
2087
|
contextWarning: input.contextWarning === true,
|
|
2054
2088
|
lastMessageAt: input.lastMessageAt ?? null,
|
|
@@ -2068,6 +2102,7 @@ function persistActiveAiChatTab() {
|
|
|
2068
2102
|
tab.conversationId = state.aiConversationId;
|
|
2069
2103
|
tab.title = $("#ai-conversation-title").textContent || tab.title || "新对话";
|
|
2070
2104
|
tab.modelId = state.aiConversationModelId;
|
|
2105
|
+
tab.hasImageAttachments = state.aiConversationHasImages === true;
|
|
2071
2106
|
tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
|
|
2072
2107
|
tab.promptSent = state.aiPromptSent;
|
|
2073
2108
|
tab.taskType = state.aiTaskType;
|
|
@@ -2088,20 +2123,23 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
|
|
|
2088
2123
|
tab.composer = {
|
|
2089
2124
|
text: snapshot.text,
|
|
2090
2125
|
citations: tab.citations.map((citation) => ({ ...citation })),
|
|
2091
|
-
references: tab.references.map((reference) => ({ ...reference }))
|
|
2126
|
+
references: tab.references.map((reference) => ({ ...reference })),
|
|
2127
|
+
images: normalizeAiChatImageAttachments(snapshot.images)
|
|
2092
2128
|
};
|
|
2093
2129
|
}
|
|
2094
2130
|
|
|
2095
2131
|
function clearAiChatTabComposer(tab) {
|
|
2096
|
-
setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [] });
|
|
2132
|
+
setAiChatTabComposerSnapshot(tab, { text: "", citations: [], references: [], images: [] });
|
|
2097
2133
|
}
|
|
2098
2134
|
|
|
2099
2135
|
function applyAiChatTabState(tab) {
|
|
2100
2136
|
state.aiConversationId = tab.conversationId;
|
|
2101
2137
|
state.aiConversationModelId = tab.modelId;
|
|
2138
|
+
state.aiConversationHasImages = tab.hasImageAttachments === true;
|
|
2102
2139
|
state.aiPromptSent = tab.promptSent;
|
|
2103
2140
|
state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
|
|
2104
2141
|
state.aiReferences = tab.references.map((reference) => ({ ...reference }));
|
|
2142
|
+
state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
|
|
2105
2143
|
state.aiLastMessageAt = tab.lastMessageAt;
|
|
2106
2144
|
$("#ai-conversation-title").textContent = tab.title || "新对话";
|
|
2107
2145
|
applyAiConversationTaskType(tab.taskType);
|
|
@@ -2113,6 +2151,7 @@ function applyAiChatTabState(tab) {
|
|
|
2113
2151
|
setAiPromptText(tab.composer.text);
|
|
2114
2152
|
renderAiCitations();
|
|
2115
2153
|
renderAiReferences();
|
|
2154
|
+
renderAiImageAttachments();
|
|
2116
2155
|
latestAiContextUsage = null;
|
|
2117
2156
|
setAiContextMeter(tab.contextUsage);
|
|
2118
2157
|
if (tab.contextWarning) showAiContextWarning();
|
|
@@ -2130,7 +2169,8 @@ function aiChatTabIsReplaceable(tab = activeAiChatTab()) {
|
|
|
2130
2169
|
&& !aiRequestManager.hasActive(tab.id)
|
|
2131
2170
|
&& !(tab.composer?.text || "").trim()
|
|
2132
2171
|
&& !(tab.composer?.citations?.length)
|
|
2133
|
-
&& !(tab.composer?.references?.length)
|
|
2172
|
+
&& !(tab.composer?.references?.length)
|
|
2173
|
+
&& !(tab.composer?.images?.length));
|
|
2134
2174
|
}
|
|
2135
2175
|
|
|
2136
2176
|
function aiChatTabOpeningSlot() {
|
|
@@ -2232,7 +2272,6 @@ function setAiConversationWorkspaceVisible(visible) {
|
|
|
2232
2272
|
$("#ai-assistant-entry").setAttribute("aria-expanded", String(aiConversationWorkspaceOpen));
|
|
2233
2273
|
applyPanelLayout(true);
|
|
2234
2274
|
$("#ai-chat-tabs").classList.add("hidden");
|
|
2235
|
-
$("#ai-workspace-close").classList.toggle("hidden", !aiConversationWorkspaceOpen);
|
|
2236
2275
|
$("#ai-panel-resize").setAttribute("aria-hidden", String(aiConversationWorkspaceOpen));
|
|
2237
2276
|
setAiConversationSwitcherVisible(false);
|
|
2238
2277
|
renderAiChatTabs();
|
|
@@ -2404,7 +2443,7 @@ function resetAiFeed(
|
|
|
2404
2443
|
const roleplayName = roleplayCharacter?.name;
|
|
2405
2444
|
const roleplayUserName = roleplayUserCharacter?.name;
|
|
2406
2445
|
feed.innerHTML = roleplayName
|
|
2407
|
-
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "
|
|
2446
|
+
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系和故事正文回答。"}</p></div></div>`
|
|
2408
2447
|
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
|
|
2409
2448
|
}
|
|
2410
2449
|
|
|
@@ -2449,6 +2488,85 @@ function renderConversationCompactionDivider(compactedMessageCount, totalMessage
|
|
|
2449
2488
|
return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null, feed);
|
|
2450
2489
|
}
|
|
2451
2490
|
|
|
2491
|
+
function findAiRetryUserMessage(message) {
|
|
2492
|
+
const feed = message.closest(".ai-feed");
|
|
2493
|
+
if (!feed) return null;
|
|
2494
|
+
const messages = [...feed.children].filter((candidate) => candidate.matches(".user-message, .assistant-message"));
|
|
2495
|
+
const messageIndex = messages.indexOf(message);
|
|
2496
|
+
for (let index = messageIndex - 1; index >= 0; index -= 1) {
|
|
2497
|
+
if (messages[index].classList.contains("user-message")) return messages[index];
|
|
2498
|
+
}
|
|
2499
|
+
return null;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
function aiMessageCitations(message) {
|
|
2503
|
+
try {
|
|
2504
|
+
const citations = JSON.parse(message.dataset.aiCitations ?? "[]");
|
|
2505
|
+
return Array.isArray(citations) ? citations : [];
|
|
2506
|
+
} catch {
|
|
2507
|
+
return [];
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
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
|
+
|
|
2520
|
+
async function retryAiMessage(message) {
|
|
2521
|
+
const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
|
|
2522
|
+
const userMessage = findAiRetryUserMessage(message);
|
|
2523
|
+
const prompt = userMessage?.dataset.copyText ?? "";
|
|
2524
|
+
const userMessageId = userMessage?.dataset.messageId ?? "";
|
|
2525
|
+
if (!sourceTab?.conversationId || !userMessageId || !prompt.trim()) {
|
|
2526
|
+
toast("找不到需要重试的用户指令", "error");
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
if (aiRequestManager.hasActive(sourceTab.id)) {
|
|
2530
|
+
toast("当前对话仍在生成回复,请等待完成或取消后再重试", "error");
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
if (!isActiveAiChatTab(sourceTab)) activateAiChatTab(sourceTab.id, { persistCurrent: false, force: true });
|
|
2534
|
+
await sendAiWithOptions({
|
|
2535
|
+
retry: {
|
|
2536
|
+
message,
|
|
2537
|
+
prompt,
|
|
2538
|
+
citations: aiMessageCitations(userMessage),
|
|
2539
|
+
images: aiMessageImageAttachments(userMessage),
|
|
2540
|
+
userMessageId
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
function clearAiRetryComposer() {
|
|
2546
|
+
clearAiPromptComposer();
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function prepareAiRetryState(tab, modelId) {
|
|
2550
|
+
tab.modelId = modelId;
|
|
2551
|
+
tab.selectedModelId = modelId;
|
|
2552
|
+
tab.promptSent = true;
|
|
2553
|
+
clearAiChatTabComposer(tab);
|
|
2554
|
+
if (isActiveAiChatTab(tab)) {
|
|
2555
|
+
state.aiConversationModelId = modelId;
|
|
2556
|
+
state.aiPromptSent = true;
|
|
2557
|
+
syncAiTaskOptions();
|
|
2558
|
+
renderAiRoleplayCharacterSelect();
|
|
2559
|
+
renderAiQuickActions();
|
|
2560
|
+
clearAiRetryComposer();
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function aiRetryStreamRequestBody(body, retry) {
|
|
2565
|
+
return retry?.userMessageId
|
|
2566
|
+
? { ...body, currentMessageId: retry.userMessageId }
|
|
2567
|
+
: body;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2452
2570
|
function renderMessageCardActions(message) {
|
|
2453
2571
|
let actions = message.querySelector(".message-card-actions");
|
|
2454
2572
|
if (!actions) {
|
|
@@ -2477,7 +2595,20 @@ function renderMessageCardActions(message) {
|
|
|
2477
2595
|
});
|
|
2478
2596
|
actions.append(copy);
|
|
2479
2597
|
}
|
|
2480
|
-
if (message.dataset.
|
|
2598
|
+
if (message.dataset.status === "failed" && message.classList.contains("assistant-message")) {
|
|
2599
|
+
const retry = document.createElement("button");
|
|
2600
|
+
retry.type = "button";
|
|
2601
|
+
retry.className = "message-retry-button";
|
|
2602
|
+
retry.setAttribute("aria-label", "重试");
|
|
2603
|
+
retry.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.7-4L4 9"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4L20 15"/><path d="M20 20v-5h-5"/></svg><span>重试</span>';
|
|
2604
|
+
retry.addEventListener("click", () => {
|
|
2605
|
+
retry.disabled = true;
|
|
2606
|
+
void retryAiMessage(message).finally(() => {
|
|
2607
|
+
if (retry.isConnected) retry.disabled = false;
|
|
2608
|
+
});
|
|
2609
|
+
});
|
|
2610
|
+
actions.append(retry);
|
|
2611
|
+
} else if (message.dataset.messageId && message.classList.contains("assistant-message")) {
|
|
2481
2612
|
const fork = document.createElement("button");
|
|
2482
2613
|
fork.type = "button";
|
|
2483
2614
|
fork.className = "message-fork-button";
|
|
@@ -2539,6 +2670,7 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
2539
2670
|
recall_self: "回忆自身",
|
|
2540
2671
|
image: "读取设定图片",
|
|
2541
2672
|
recall_relationship: "回忆人物关系",
|
|
2673
|
+
recall_story: "回忆故事",
|
|
2542
2674
|
calculate_time: "计算日期"
|
|
2543
2675
|
};
|
|
2544
2676
|
|
|
@@ -2552,10 +2684,15 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
2552
2684
|
recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。",
|
|
2553
2685
|
image: "读取设定正文引用的图片附件,并返回多模态模型的理解内容。",
|
|
2554
2686
|
recall_relationship: "不传角色列表时读取有关系的角色列表;传入一个或多个角色后读取当前角色与这些角色之间的关系详情。",
|
|
2687
|
+
recall_story: "查询当前作品已保存正文中的关键词,返回匹配段落及章节信息。",
|
|
2555
2688
|
calculate_time: "计算日期差值,或从起始日期推算目标日期。"
|
|
2556
2689
|
};
|
|
2557
2690
|
|
|
2558
2691
|
const aiFeedScrollFrames = new WeakMap();
|
|
2692
|
+
const aiFeedAutoScrollStates = new WeakMap();
|
|
2693
|
+
const aiFeedScrollBindings = new WeakSet();
|
|
2694
|
+
const aiProcessScrollFrames = new WeakMap();
|
|
2695
|
+
const AI_FEED_BOTTOM_THRESHOLD_PX = 24;
|
|
2559
2696
|
let markdownTableMenuTarget = null;
|
|
2560
2697
|
let markdownTableMenuTrigger = null;
|
|
2561
2698
|
|
|
@@ -2596,7 +2733,30 @@ function openMarkdownTableMenu(header, clientX, clientY) {
|
|
|
2596
2733
|
toggle.focus();
|
|
2597
2734
|
}
|
|
2598
2735
|
|
|
2736
|
+
function aiFeedIsNearBottom(feed) {
|
|
2737
|
+
if (!feed.clientHeight) return true;
|
|
2738
|
+
return feed.scrollHeight - feed.scrollTop - feed.clientHeight <= AI_FEED_BOTTOM_THRESHOLD_PX;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function bindAiFeedAutoScroll(feed) {
|
|
2742
|
+
if (!feed || aiFeedScrollBindings.has(feed)) return;
|
|
2743
|
+
const update = () => {
|
|
2744
|
+
const shouldFollow = aiFeedIsNearBottom(feed);
|
|
2745
|
+
aiFeedAutoScrollStates.set(feed, shouldFollow);
|
|
2746
|
+
if (shouldFollow) return;
|
|
2747
|
+
const currentFrame = aiFeedScrollFrames.get(feed);
|
|
2748
|
+
if (currentFrame === undefined) return;
|
|
2749
|
+
window.cancelAnimationFrame(currentFrame);
|
|
2750
|
+
aiFeedScrollFrames.delete(feed);
|
|
2751
|
+
};
|
|
2752
|
+
feed.addEventListener("scroll", update, { passive: true });
|
|
2753
|
+
aiFeedScrollBindings.add(feed);
|
|
2754
|
+
update();
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2599
2757
|
function scrollAiFeedToBottom(feed = $("#ai-feed")) {
|
|
2758
|
+
bindAiFeedAutoScroll(feed);
|
|
2759
|
+
if (!aiFeedAutoScrollStates.get(feed)) return;
|
|
2600
2760
|
feed.scrollTop = feed.scrollHeight;
|
|
2601
2761
|
const currentFrame = aiFeedScrollFrames.get(feed);
|
|
2602
2762
|
if (currentFrame !== undefined) window.cancelAnimationFrame(currentFrame);
|
|
@@ -2607,6 +2767,22 @@ function scrollAiFeedToBottom(feed = $("#ai-feed")) {
|
|
|
2607
2767
|
aiFeedScrollFrames.set(feed, nextFrame);
|
|
2608
2768
|
}
|
|
2609
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
|
+
|
|
2610
2786
|
function formatAiToolCallTime(value) {
|
|
2611
2787
|
if (!value) return "历史记录未保存";
|
|
2612
2788
|
const date = new Date(value);
|
|
@@ -2719,9 +2895,27 @@ function resolveAiProcessDuration(metadata, steps, completedAt) {
|
|
|
2719
2895
|
return Math.max(0, completedTime - Math.min(...startedTimes));
|
|
2720
2896
|
}
|
|
2721
2897
|
|
|
2898
|
+
function shouldRenderAiProcessStep(step) {
|
|
2899
|
+
if (step?.type === "context_compaction") return true;
|
|
2900
|
+
if (step?.type === "tool" && step.toolCall) return true;
|
|
2901
|
+
if (!step?.content || !["thinking", "intermediate"].includes(step.type)) return false;
|
|
2902
|
+
return step.type !== "intermediate" || typeof step.content !== "string" || step.content.trim().length > 0;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2722
2905
|
function renderAiProcessSteps(message, steps, completed, durationMs = null, visibleContents = null) {
|
|
2723
|
-
message.querySelector(".ai-process-details")
|
|
2724
|
-
|
|
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();
|
|
2917
|
+
const renderableSteps = (Array.isArray(steps) ? steps : []).filter(shouldRenderAiProcessStep);
|
|
2918
|
+
if (!renderableSteps.length) return;
|
|
2725
2919
|
const details = document.createElement("details");
|
|
2726
2920
|
details.className = "ai-process-details";
|
|
2727
2921
|
details.open = !completed;
|
|
@@ -2730,11 +2924,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2730
2924
|
title.textContent = completed ? "思考与执行过程" : "正在思考与执行";
|
|
2731
2925
|
const status = document.createElement("small");
|
|
2732
2926
|
const duration = durationMs === null || durationMs === undefined ? "" : formatAiProcessDuration(durationMs);
|
|
2733
|
-
status.textContent = `${
|
|
2927
|
+
status.textContent = `${renderableSteps.length} 个步骤${duration ? ` · 耗时 ${duration}` : ""}`;
|
|
2734
2928
|
summary.append(title, status);
|
|
2735
2929
|
const list = document.createElement("div");
|
|
2736
2930
|
list.className = "ai-process-list";
|
|
2737
|
-
for (const step of
|
|
2931
|
+
for (const step of renderableSteps) {
|
|
2738
2932
|
if (step?.type === "context_compaction") {
|
|
2739
2933
|
list.append(createAiContextCompactionDivider({
|
|
2740
2934
|
kind: "tool",
|
|
@@ -2752,9 +2946,9 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2752
2946
|
list.append(tool);
|
|
2753
2947
|
continue;
|
|
2754
2948
|
}
|
|
2755
|
-
if (!step?.content || !["thinking", "intermediate"].includes(step.type)) continue;
|
|
2756
2949
|
const section = document.createElement("section");
|
|
2757
2950
|
section.className = `ai-process-step ai-process-${step.type}-step`;
|
|
2951
|
+
section.dataset.aiProcessStepId = `${step.type}:${String(step.id ?? step.round ?? "")}`;
|
|
2758
2952
|
const label = document.createElement("small");
|
|
2759
2953
|
label.textContent = `第 ${Number(step.round) || 1} 轮 · ${step.type === "thinking" ? "Thinking" : "中间输出"}`;
|
|
2760
2954
|
const body = document.createElement("div");
|
|
@@ -2765,9 +2959,18 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null, visi
|
|
|
2765
2959
|
list.append(section);
|
|
2766
2960
|
}
|
|
2767
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
|
+
});
|
|
2768
2970
|
const body = message.querySelector(".message-body");
|
|
2769
2971
|
if (body) body.before(details);
|
|
2770
2972
|
else message.append(details);
|
|
2973
|
+
if (!completed) scrollAiProcessStepsToBottom(message);
|
|
2771
2974
|
}
|
|
2772
2975
|
|
|
2773
2976
|
function renderAiToolCalls(message, toolCalls, completed = false) {
|
|
@@ -3012,6 +3215,7 @@ function updateAiConversationSummaryFromMessage(message) {
|
|
|
3012
3215
|
title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
|
|
3013
3216
|
messageCount: Number(current.messageCount ?? 0) + 1,
|
|
3014
3217
|
preview: message.content,
|
|
3218
|
+
...(aiConversationMessageHasImages(message) ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
|
|
3015
3219
|
updatedAt: message.createdAt ?? current.updatedAt
|
|
3016
3220
|
});
|
|
3017
3221
|
}
|
|
@@ -3125,11 +3329,18 @@ function focusAiConversationMessage(messageId) {
|
|
|
3125
3329
|
window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
|
|
3126
3330
|
}
|
|
3127
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
|
+
|
|
3128
3337
|
function applyConversationToAiChatTab(tab, conversation) {
|
|
3129
3338
|
tab.workId = String(conversation.workId ?? state.work?.id ?? "");
|
|
3130
3339
|
tab.conversationId = conversation.id;
|
|
3131
3340
|
tab.title = conversation.title || "新对话";
|
|
3132
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));
|
|
3133
3344
|
tab.selectedModelId = tab.modelId ?? tab.selectedModelId;
|
|
3134
3345
|
tab.promptSent = Array.isArray(conversation.messages) && conversation.messages.some((message) => message.role === "user");
|
|
3135
3346
|
tab.taskType = conversation.taskType ?? "chat";
|
|
@@ -3138,7 +3349,7 @@ function applyConversationToAiChatTab(tab, conversation) {
|
|
|
3138
3349
|
tab.roleplayUserCharacter = conversation.roleplayUserCharacter ?? null;
|
|
3139
3350
|
tab.citations = [];
|
|
3140
3351
|
tab.references = [];
|
|
3141
|
-
tab.composer = { text: "", citations: [], references: [] };
|
|
3352
|
+
tab.composer = { text: "", citations: [], references: [], images: [] };
|
|
3142
3353
|
tab.contextUsage = null;
|
|
3143
3354
|
tab.contextWarning = conversation.contextWarningPending === true;
|
|
3144
3355
|
resetAiFeed(tab.feed, tab.roleplayCharacter, tab.roleplayUserCharacter);
|
|
@@ -3214,7 +3425,7 @@ function renderAiRoleplayCharacterSelect() {
|
|
|
3214
3425
|
select.title = canSelectCharacter
|
|
3215
3426
|
? state.aiPromptSent
|
|
3216
3427
|
? aiConversationOptionLockedMessage
|
|
3217
|
-
: "为当前对话选择角色卡;角色扮演时 Agent
|
|
3428
|
+
: "为当前对话选择角色卡;角色扮演时 Agent 可以查询角色记忆、人物关系和故事正文"
|
|
3218
3429
|
: "当前账户没有角色模块读取权限";
|
|
3219
3430
|
renderAiRoleplayUserCharacterSelect();
|
|
3220
3431
|
}
|
|
@@ -3256,13 +3467,75 @@ function renderAiRoleplayUserCharacterSelect() {
|
|
|
3256
3467
|
const aiConversationOptionLockedMessage = "会话选项在会话开始后不支持修改,若需要修改,请新建会话";
|
|
3257
3468
|
|
|
3258
3469
|
function aiConversationModelLocked() {
|
|
3259
|
-
return
|
|
3470
|
+
return state.aiConversationHasImages === true
|
|
3471
|
+
|| (typeof state.aiConversationModelId === "string" && state.aiConversationModelId.trim().length > 0);
|
|
3260
3472
|
}
|
|
3261
3473
|
|
|
3262
3474
|
function selectedAiModelLabel() {
|
|
3263
3475
|
return $("#ai-model").selectedOptions[0]?.textContent?.trim() || "尚未选择模型";
|
|
3264
3476
|
}
|
|
3265
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
|
+
|
|
3266
3539
|
function syncAiModelPicker() {
|
|
3267
3540
|
const select = $("#ai-model");
|
|
3268
3541
|
const button = $("#ai-model-picker");
|
|
@@ -3277,6 +3550,8 @@ function syncAiModelPicker() {
|
|
|
3277
3550
|
button.disabled = interactionBusy;
|
|
3278
3551
|
button.title = label;
|
|
3279
3552
|
button.setAttribute("aria-label", label);
|
|
3553
|
+
renderAiModelOptions();
|
|
3554
|
+
syncAiImageAttachmentControl();
|
|
3280
3555
|
if (interactionBusy) setAiModelPickerVisible(false);
|
|
3281
3556
|
}
|
|
3282
3557
|
|
|
@@ -3310,7 +3585,9 @@ function blockLockedAiConversationOptionKeydown(event) {
|
|
|
3310
3585
|
|
|
3311
3586
|
function syncAiTaskOptions() {
|
|
3312
3587
|
const roleplaySelected = $("#ai-task").value === "roleplay";
|
|
3313
|
-
const relationshipRoleplaySelectable = roleplaySelected
|
|
3588
|
+
const relationshipRoleplaySelectable = roleplaySelected
|
|
3589
|
+
&& Boolean(state.aiRoleplayCharacter)
|
|
3590
|
+
&& (!state.aiPromptSent || Boolean(state.aiRoleplayUserCharacter));
|
|
3314
3591
|
const interactionBusy = aiInteractionBusy();
|
|
3315
3592
|
$("#ai-scope").classList.toggle("hidden", roleplaySelected);
|
|
3316
3593
|
$("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
|
|
@@ -3323,7 +3600,7 @@ function syncAiTaskOptions() {
|
|
|
3323
3600
|
$("#ai-scope").disabled = interactionBusy || roleplaySelected;
|
|
3324
3601
|
$("#ai-scope").title = state.aiPromptSent
|
|
3325
3602
|
? aiConversationOptionLockedMessage
|
|
3326
|
-
: roleplaySelected ? "
|
|
3603
|
+
: roleplaySelected ? "角色扮演模式可以查询角色记忆、人物关系和故事正文" : "";
|
|
3327
3604
|
syncAiModelPicker();
|
|
3328
3605
|
}
|
|
3329
3606
|
|
|
@@ -3541,11 +3818,136 @@ function setAiPromptText(value) {
|
|
|
3541
3818
|
renderAiReferences();
|
|
3542
3819
|
}
|
|
3543
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
|
+
|
|
3544
3944
|
function clearAiPromptComposer() {
|
|
3545
3945
|
state.aiCitations = [];
|
|
3546
3946
|
state.aiReferences = [];
|
|
3947
|
+
state.aiImageAttachments = [];
|
|
3547
3948
|
setAiPromptText("");
|
|
3548
3949
|
renderAiCitations();
|
|
3950
|
+
renderAiImageAttachments();
|
|
3549
3951
|
hideAiMentionMenu();
|
|
3550
3952
|
}
|
|
3551
3953
|
|
|
@@ -3553,15 +3955,18 @@ function captureAiPromptComposer() {
|
|
|
3553
3955
|
return {
|
|
3554
3956
|
text: aiPromptText(),
|
|
3555
3957
|
citations: state.aiCitations.map((citation) => ({ ...citation })),
|
|
3556
|
-
references: state.aiReferences.map((reference) => ({ ...reference }))
|
|
3958
|
+
references: state.aiReferences.map((reference) => ({ ...reference })),
|
|
3959
|
+
images: normalizeAiChatImageAttachments(state.aiImageAttachments)
|
|
3557
3960
|
};
|
|
3558
3961
|
}
|
|
3559
3962
|
|
|
3560
3963
|
function restoreAiPromptComposer(snapshot) {
|
|
3561
3964
|
state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
|
|
3562
3965
|
state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
|
|
3966
|
+
state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
|
|
3563
3967
|
setAiPromptText(snapshot.text);
|
|
3564
3968
|
renderAiCitations();
|
|
3969
|
+
renderAiImageAttachments();
|
|
3565
3970
|
hideAiMentionMenu();
|
|
3566
3971
|
}
|
|
3567
3972
|
|
|
@@ -4257,6 +4662,12 @@ function formatAiFailureMessage(error) {
|
|
|
4257
4662
|
const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
|
|
4258
4663
|
if (code) lines.push(`错误码:${code}`);
|
|
4259
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
|
+
}
|
|
4260
4671
|
if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
|
|
4261
4672
|
if (modelId) lines.push(`模型 ID:${modelId}`);
|
|
4262
4673
|
if (callId) lines.push(`调用 ID:${callId}`);
|
|
@@ -4475,9 +4886,14 @@ function observeSystemBootId(value) {
|
|
|
4475
4886
|
systemRestartDetected = true;
|
|
4476
4887
|
if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
|
|
4477
4888
|
systemBootCheckTimer = null;
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
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);
|
|
4481
4897
|
return true;
|
|
4482
4898
|
}
|
|
4483
4899
|
|
|
@@ -4700,6 +5116,14 @@ function raiseToastRegion() {
|
|
|
4700
5116
|
region.showPopover();
|
|
4701
5117
|
}
|
|
4702
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
|
+
|
|
4703
5127
|
function dismissDeleteToasts() {
|
|
4704
5128
|
const region = $("#toast-region");
|
|
4705
5129
|
region.querySelectorAll(".delete-toast").forEach((element) => element.remove());
|
|
@@ -4730,15 +5154,13 @@ function toast(message, type = "info", extraClass = "") {
|
|
|
4730
5154
|
element.className = `toast ${type}${extraClass ? ` ${extraClass}` : ""}`;
|
|
4731
5155
|
element.setAttribute("role", type === "error" ? "alert" : "status");
|
|
4732
5156
|
element.setAttribute("aria-atomic", "true");
|
|
4733
|
-
|
|
5157
|
+
const messageContent = document.createElement("span");
|
|
5158
|
+
messageContent.textContent = message;
|
|
5159
|
+
element.append(messageContent);
|
|
5160
|
+
element.addEventListener("click", () => dismissToastElement(element), { once: true });
|
|
4734
5161
|
region.append(element);
|
|
4735
5162
|
raiseToastRegion();
|
|
4736
|
-
setTimeout(() =>
|
|
4737
|
-
element.remove();
|
|
4738
|
-
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
|
|
4739
|
-
region.hidePopover();
|
|
4740
|
-
}
|
|
4741
|
-
}, 3600);
|
|
5163
|
+
setTimeout(() => dismissToastElement(element), 3600);
|
|
4742
5164
|
}
|
|
4743
5165
|
|
|
4744
5166
|
async function runEntityEditorSave({ busyTarget, button, prepare, save }) {
|
|
@@ -4791,15 +5213,13 @@ function persistentToast(message, type = "info") {
|
|
|
4791
5213
|
const element = document.createElement("div");
|
|
4792
5214
|
element.className = `toast ${type}`;
|
|
4793
5215
|
element.setAttribute("role", "status");
|
|
4794
|
-
|
|
5216
|
+
const messageContent = document.createElement("span");
|
|
5217
|
+
messageContent.textContent = message;
|
|
5218
|
+
element.append(messageContent);
|
|
5219
|
+
element.addEventListener("click", () => dismissToastElement(element), { once: true });
|
|
4795
5220
|
region.append(element);
|
|
4796
5221
|
raiseToastRegion();
|
|
4797
|
-
return () =>
|
|
4798
|
-
element.remove();
|
|
4799
|
-
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
|
|
4800
|
-
region.hidePopover();
|
|
4801
|
-
}
|
|
4802
|
-
};
|
|
5222
|
+
return () => dismissToastElement(element);
|
|
4803
5223
|
}
|
|
4804
5224
|
|
|
4805
5225
|
function restoreToastFocus(previousFocus) {
|
|
@@ -5272,7 +5692,9 @@ function renderSettingsHub() {
|
|
|
5272
5692
|
const isAdmin = state.user?.role === "admin";
|
|
5273
5693
|
$("#platform-ai-button").classList.toggle("hidden", !isAdmin);
|
|
5274
5694
|
$("#platform-usage-button").classList.toggle("hidden", !isAdmin);
|
|
5695
|
+
$("#platform-usage-pricing-refresh").classList.toggle("hidden", !isAdmin);
|
|
5275
5696
|
$("#user-management-button").classList.toggle("hidden", !isAdmin);
|
|
5697
|
+
$("#admin-ai-conversations-button").classList.toggle("hidden", !isAdmin);
|
|
5276
5698
|
$("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
|
|
5277
5699
|
$("#s3-backup-button").classList.toggle("hidden", !isAdmin);
|
|
5278
5700
|
$("#collaboration-button").disabled = !canManageWork;
|
|
@@ -5628,6 +6050,86 @@ async function openUsersDialog() {
|
|
|
5628
6050
|
catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
|
|
5629
6051
|
}
|
|
5630
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
|
+
|
|
5631
6133
|
async function openPlatformUiSettingsDialog() {
|
|
5632
6134
|
if (state.user?.role !== "admin") {
|
|
5633
6135
|
toast("需要系统管理员权限", "error");
|
|
@@ -6568,6 +7070,7 @@ async function showPlatformUsage() {
|
|
|
6568
7070
|
$("#module-view").classList.add("hidden");
|
|
6569
7071
|
$("#work-meta").textContent = "Token 用量";
|
|
6570
7072
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
7073
|
+
$("#platform-usage-pricing-refresh").classList.toggle("hidden", state.user?.role !== "admin");
|
|
6571
7074
|
setTopbarViewState("Token 用量");
|
|
6572
7075
|
await renderPlatformTokenUsage();
|
|
6573
7076
|
replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
@@ -8695,7 +9198,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
8695
9198
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
8696
9199
|
return `
|
|
8697
9200
|
<article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
|
|
8698
|
-
<div class="character-card-heading"
|
|
9201
|
+
<div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
8699
9202
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
8700
9203
|
<div class="character-gender"><b>性别</b><span class="pill">${esc(characterGenderLabel(item.gender))}</span></div>
|
|
8701
9204
|
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
@@ -8719,7 +9222,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
8719
9222
|
const line = meta ? `${meta} · ${preview}` : preview;
|
|
8720
9223
|
return `
|
|
8721
9224
|
<article class="record-card module-row character-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
|
|
8722
|
-
<div class="character-card-heading"
|
|
9225
|
+
<div class="character-card-heading">${characterAvatarHtml(item)}<h3>${esc(item.name)}</h3>${entityLifecycleBadge(item.isDead, "已死亡")}${characterLockBadge(item)}</div>
|
|
8723
9226
|
<p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
|
|
8724
9227
|
<div class="card-actions">${characterActions(item)}</div>
|
|
8725
9228
|
</article>`;
|
|
@@ -10597,7 +11100,7 @@ function openTaskDetailDialog(task, trace) {
|
|
|
10597
11100
|
});
|
|
10598
11101
|
}
|
|
10599
11102
|
|
|
10600
|
-
function renderProviderCards(providers, models) {
|
|
11103
|
+
function renderProviderCards(providers, models, protocolOptions) {
|
|
10601
11104
|
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
|
|
10602
11105
|
const providerModels = models.filter((model) => model.providerId === provider.id);
|
|
10603
11106
|
const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
|
|
@@ -10605,8 +11108,8 @@ function renderProviderCards(providers, models) {
|
|
|
10605
11108
|
? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
|
|
10606
11109
|
: "";
|
|
10607
11110
|
return `
|
|
10608
|
-
<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>
|
|
10609
|
-
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
11111
|
+
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
11112
|
+
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
10610
11113
|
<div class="provider-models">${providerModels.map((model) => {
|
|
10611
11114
|
const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
10612
11115
|
const modelStatus = !model.enabled
|
|
@@ -10619,12 +11122,54 @@ function renderProviderCards(providers, models) {
|
|
|
10619
11122
|
const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
|
|
10620
11123
|
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
|
|
10621
11124
|
}).join("")}</div>
|
|
10622
|
-
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
11125
|
+
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
10623
11126
|
}).join("")}</div>`
|
|
10624
|
-
: emptyModule("尚未配置 AI 供应商", "
|
|
11127
|
+
: emptyModule("尚未配置 AI 供应商", "添加后端返回的供应商协议、接口地址和凭据,测试成功后再添加模型。");
|
|
10625
11128
|
}
|
|
10626
11129
|
|
|
10627
|
-
function
|
|
11130
|
+
async function deletePlatformModel(item) {
|
|
11131
|
+
if (!item) return;
|
|
11132
|
+
$("#form-dialog").close();
|
|
11133
|
+
if (!await confirmToast(`确认删除模型“${item.displayName}”吗?删除后将从当前供应商的模型列表移除。`, {
|
|
11134
|
+
title: "删除模型",
|
|
11135
|
+
confirmLabel: "继续删除"
|
|
11136
|
+
})) return;
|
|
11137
|
+
if (!await confirmToast(`删除模型“${item.displayName}”后,相关默认模型设置会被清空,且无法恢复。仍要删除吗?`, {
|
|
11138
|
+
title: "删除操作需要再次确认",
|
|
11139
|
+
confirmLabel: "确认删除"
|
|
11140
|
+
})) return;
|
|
11141
|
+
try {
|
|
11142
|
+
await api(`/api/models/${encodeURIComponent(item.id)}`, { method: "DELETE" });
|
|
11143
|
+
await renderPlatformAiConfig();
|
|
11144
|
+
await loadModels();
|
|
11145
|
+
deleteToast(`已删除模型“${item.displayName}”`);
|
|
11146
|
+
} catch (error) {
|
|
11147
|
+
toast(error.message, "error");
|
|
11148
|
+
}
|
|
11149
|
+
}
|
|
11150
|
+
|
|
11151
|
+
async function deletePlatformProvider(item) {
|
|
11152
|
+
if (!item) return;
|
|
11153
|
+
$("#form-dialog").close();
|
|
11154
|
+
if (!await confirmToast(`确认删除供应商“${item.name}”吗?该供应商下的模型也会一并删除。`, {
|
|
11155
|
+
title: "删除供应商",
|
|
11156
|
+
confirmLabel: "继续删除"
|
|
11157
|
+
})) return;
|
|
11158
|
+
if (!await confirmToast(`删除供应商“${item.name}”后,供应商、模型及相关默认模型设置都无法恢复。仍要删除吗?`, {
|
|
11159
|
+
title: "删除操作需要再次确认",
|
|
11160
|
+
confirmLabel: "确认删除"
|
|
11161
|
+
})) return;
|
|
11162
|
+
try {
|
|
11163
|
+
await api(`/api/providers/${encodeURIComponent(item.id)}`, { method: "DELETE" });
|
|
11164
|
+
await renderPlatformAiConfig();
|
|
11165
|
+
await loadModels();
|
|
11166
|
+
deleteToast(`已删除供应商“${item.name}”`);
|
|
11167
|
+
} catch (error) {
|
|
11168
|
+
toast(error.message, "error");
|
|
11169
|
+
}
|
|
11170
|
+
}
|
|
11171
|
+
|
|
11172
|
+
function bindPlatformProviderActions(host, providers, models, protocolOptions) {
|
|
10628
11173
|
host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
|
|
10629
11174
|
const providerId = button.dataset.testProvider;
|
|
10630
11175
|
button.disabled = true;
|
|
@@ -10645,19 +11190,43 @@ function bindPlatformProviderActions(host, providers, models) {
|
|
|
10645
11190
|
focusTarget?.focus({ preventScroll: true });
|
|
10646
11191
|
}
|
|
10647
11192
|
}));
|
|
10648
|
-
host.querySelectorAll("[data-
|
|
11193
|
+
host.querySelectorAll("[data-import-provider-models]").forEach((button) => button.addEventListener("click", async () => {
|
|
11194
|
+
const providerId = button.dataset.importProviderModels;
|
|
11195
|
+
button.disabled = true;
|
|
11196
|
+
button.textContent = "获取中";
|
|
11197
|
+
try {
|
|
11198
|
+
const result = await api(`/api/providers/${encodeURIComponent(providerId)}/models/import`, { method: "POST", body: {} });
|
|
11199
|
+
const ignored = Number(result.invalidItemCount ?? 0);
|
|
11200
|
+
const ignoredMessage = ignored > 0 ? `,忽略 ${ignored} 条无效记录` : "";
|
|
11201
|
+
const message = Number(result.importedCount ?? 0) > 0
|
|
11202
|
+
? `已获取 ${result.availableCount} 个模型,新增 ${result.importedCount} 个${ignoredMessage}`
|
|
11203
|
+
: `已获取 ${result.availableCount} 个模型,供应商列表中均已存在${ignoredMessage}`;
|
|
11204
|
+
await renderPlatformAiConfig();
|
|
11205
|
+
await loadModels();
|
|
11206
|
+
toast(message);
|
|
11207
|
+
} catch (error) {
|
|
11208
|
+
toast(error.message, "error");
|
|
11209
|
+
} finally {
|
|
11210
|
+
button.disabled = false;
|
|
11211
|
+
button.textContent = "获取模型";
|
|
11212
|
+
const focusTarget = button.isConnected ? button : host.querySelector(`[data-import-provider-models="${CSS.escape(providerId)}"]`);
|
|
11213
|
+
focusTarget?.focus({ preventScroll: true });
|
|
11214
|
+
}
|
|
11215
|
+
}));
|
|
11216
|
+
host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel), protocolOptions)));
|
|
10649
11217
|
host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
|
|
10650
11218
|
const model = models.find((item) => item.id === button.dataset.editModel);
|
|
10651
|
-
openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId));
|
|
11219
|
+
openModelDialog(undefined, model, providers.find((provider) => provider.id === model?.providerId), protocolOptions);
|
|
10652
11220
|
}));
|
|
10653
|
-
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
|
|
11221
|
+
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider), protocolOptions)));
|
|
10654
11222
|
}
|
|
10655
11223
|
|
|
10656
|
-
function renderTaskDefaults(models, providers, taskDefaults, settings) {
|
|
11224
|
+
function renderTaskDefaults(models, providers, taskDefaults, settings, protocolOptions = platformAiProtocolOptions) {
|
|
10657
11225
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
11226
|
+
const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
|
|
10658
11227
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
10659
11228
|
const availableModels = models.filter((model) => isSelectableModel(model));
|
|
10660
|
-
const imageModels = availableModels.filter((model) => model.multimodalEnabled && providerById.get(model.providerId)?.protocol ===
|
|
11229
|
+
const imageModels = availableModels.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
|
|
10661
11230
|
const availableModelIds = new Set(availableModels.map((model) => model.id));
|
|
10662
11231
|
const currentDefaultModels = taskDefaults
|
|
10663
11232
|
.map((item) => item.model)
|
|
@@ -10990,20 +11559,23 @@ function startBackgroundTaskCenter(workId) {
|
|
|
10990
11559
|
}
|
|
10991
11560
|
|
|
10992
11561
|
async function renderPlatformAiConfig() {
|
|
10993
|
-
const [providers, models, settings] = await Promise.all([
|
|
11562
|
+
const [providers, models, settings, protocolOptions] = await Promise.all([
|
|
10994
11563
|
api("/api/platform/ai/providers"),
|
|
10995
11564
|
api("/api/platform/ai/models"),
|
|
10996
|
-
api("/api/platform/ai/settings")
|
|
11565
|
+
api("/api/platform/ai/settings"),
|
|
11566
|
+
api("/api/platform/ai/protocols")
|
|
10997
11567
|
]);
|
|
11568
|
+
platformAiProtocolOptions = protocolOptions;
|
|
10998
11569
|
const host = $("#platform-ai-content");
|
|
10999
11570
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
11000
|
-
const
|
|
11571
|
+
const protocolByValue = new Map(protocolOptions.map((option) => [option.value, option]));
|
|
11572
|
+
const imageModels = models.filter((model) => model.multimodalEnabled && protocolByValue.get(providerById.get(model.providerId)?.protocol)?.supportsMultimodal === true);
|
|
11001
11573
|
const imageModelOptions = imageModels.map((model) => {
|
|
11002
11574
|
const provider = providerById.get(model.providerId);
|
|
11003
11575
|
const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
|
|
11004
11576
|
return `<option value="${esc(model.id)}" ${model.id === settings.imageToolModelId ? "selected" : ""} ${available || model.id === settings.imageToolModelId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
|
|
11005
11577
|
}).join("");
|
|
11006
|
-
host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section><section class="config-section platform-image-tool-section"><div class="config-section-header"><div><h2>多模态读图默认模型</h2><p>Agent 的 image 工具使用这里配置的模型读取设定库图片;作品可以在自己的 AI 设置中覆盖此选择。</p></div></div><div class="platform-image-tool-panel"><label class="platform-image-tool-field"><span>当前平台默认模型</span><select id="platform-image-tool-model" aria-label="平台多模态读图默认模型"><option value="">未配置</option>${imageModelOptions}</select></label><button id="save-platform-image-tool-model" class="ghost-button config-save-button" type="button">保存默认模型</button></div></section><section class="config-section platform-providers-section"><div class="config-section-header"><div><h2>模型供应商配置</h2><p>管理供应商连接、模型列表和连接状态;模型的多模态能力在对应模型配置中设置。</p></div></div>${renderProviderCards(providers, models)}</section>`;
|
|
11578
|
+
host.innerHTML = `<section class="config-section platform-system-prompt-section"><div class="config-section-header"><div><h2>平台全局系统提示词</h2><p>会追加在内置系统提示词之后,并在所有作品的专属提示词之前发送给模型。</p></div></div><div class="field-label"><textarea id="platform-system-prompt" rows="7" aria-label="全局系统提示词" placeholder="例如:默认使用简体中文,避免代替作者做最终决定。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-platform-system-prompt" class="primary-button">保存全局提示词</button></div></section><section class="config-section platform-image-tool-section"><div class="config-section-header"><div><h2>多模态读图默认模型</h2><p>Agent 的 image 工具使用这里配置的模型读取设定库图片;作品可以在自己的 AI 设置中覆盖此选择。</p></div></div><div class="platform-image-tool-panel"><label class="platform-image-tool-field"><span>当前平台默认模型</span><select id="platform-image-tool-model" aria-label="平台多模态读图默认模型"><option value="">未配置</option>${imageModelOptions}</select></label><button id="save-platform-image-tool-model" class="ghost-button config-save-button" type="button">保存默认模型</button></div></section><section class="config-section platform-stream-timeout-section"><div class="config-section-header"><div><h2>AI 流事件空闲超时</h2><p>首个流事件或相邻流事件在此时间内没有新数据时,请求会被关闭。默认 90 秒,最低 30 秒,最高 600 秒。</p></div></div><div class="platform-stream-timeout-panel"><label class="platform-stream-timeout-field"><span>超时时间(秒)</span><input id="platform-ai-stream-idle-timeout" type="number" min="30" max="600" step="1" value="${esc(String(settings.streamIdleTimeoutSeconds ?? 90))}" aria-label="AI 流事件空闲超时时间(秒)"></label><button id="save-platform-ai-stream-idle-timeout" class="ghost-button config-save-button" type="button">保存流超时设置</button></div></section><section class="config-section platform-providers-section"><div class="config-section-header"><div><h2>模型供应商配置</h2><p>管理供应商连接、模型列表和连接状态;模型的多模态能力在对应模型配置中设置。</p></div></div>${renderProviderCards(providers, models, protocolOptions)}</section>`;
|
|
11007
11579
|
$("#save-platform-system-prompt").addEventListener("click", async () => {
|
|
11008
11580
|
const button = $("#save-platform-system-prompt");
|
|
11009
11581
|
button.disabled = true;
|
|
@@ -11029,7 +11601,21 @@ async function renderPlatformAiConfig() {
|
|
|
11029
11601
|
button.disabled = false;
|
|
11030
11602
|
}
|
|
11031
11603
|
});
|
|
11032
|
-
|
|
11604
|
+
$("#save-platform-ai-stream-idle-timeout").addEventListener("click", async () => {
|
|
11605
|
+
const button = $("#save-platform-ai-stream-idle-timeout");
|
|
11606
|
+
const input = $("#platform-ai-stream-idle-timeout");
|
|
11607
|
+
button.disabled = true;
|
|
11608
|
+
try {
|
|
11609
|
+
await api("/api/platform/ai/settings", { method: "PATCH", body: { streamIdleTimeoutSeconds: Number(input.value) } });
|
|
11610
|
+
toast("AI 流事件空闲超时已更新");
|
|
11611
|
+
await renderPlatformAiConfig();
|
|
11612
|
+
} catch (error) {
|
|
11613
|
+
toast(error.message, "error");
|
|
11614
|
+
} finally {
|
|
11615
|
+
button.disabled = false;
|
|
11616
|
+
}
|
|
11617
|
+
});
|
|
11618
|
+
bindPlatformProviderActions(host, providers, models, protocolOptions);
|
|
11033
11619
|
}
|
|
11034
11620
|
|
|
11035
11621
|
function tokenUsageDateLabel(date) {
|
|
@@ -11127,9 +11713,19 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
11127
11713
|
const requestCount = Number(summary.requestCount) || 0;
|
|
11128
11714
|
const cachedInputTokens = Number(summary.cachedInputTokens) || 0;
|
|
11129
11715
|
const cacheEligibleInputTokens = Number(summary.cacheEligibleInputTokens) || 0;
|
|
11716
|
+
const directInputTokens = Math.max(0, Math.round(Number(summary.directInputTokens) || 0));
|
|
11717
|
+
const cacheReadInputTokens = Math.max(0, Math.round(Number(summary.cacheReadInputTokens ?? cachedInputTokens) || 0));
|
|
11718
|
+
const cacheWriteInputTokens = Math.max(0, Math.round(Number(summary.cacheWriteInputTokens) || 0));
|
|
11719
|
+
const inputDescription = `直接 Input ${directInputTokens.toLocaleString("zh-CN")} · Cache Read ${cacheReadInputTokens.toLocaleString("zh-CN")} · Cache Write ${cacheWriteInputTokens.toLocaleString("zh-CN")}`;
|
|
11130
11720
|
const cacheDescription = summary.cacheHitRate === null || summary.cacheHitRate === undefined
|
|
11131
11721
|
? "供应商尚未返回可计算的缓存明细"
|
|
11132
11722
|
: `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
|
|
11723
|
+
const estimatedCost = formatEstimatedCost(summary.estimatedCost);
|
|
11724
|
+
const pricingDescription = "根据 LiteLLM 模型 ID 价格表估算,价格单位为美元;未匹配模型不计入。";
|
|
11725
|
+
const pricingBadge = summary.pricingAvailable === true && summary.estimatedCost !== null && summary.estimatedCost !== undefined
|
|
11726
|
+
? `<span class="usage-cost-bubble" title="${esc(pricingDescription)}">估价 ${esc(estimatedCost)}</span>`
|
|
11727
|
+
: "";
|
|
11728
|
+
const unpricedModelCount = Math.max(0, Math.round(Number(summary.unpricedModelCount) || 0));
|
|
11133
11729
|
const estimateNote = estimatedRequests > 0
|
|
11134
11730
|
? `其中 ${estimatedRequests.toLocaleString("zh-CN")} 次调用包含历史或供应商缺失用量时的估算。`
|
|
11135
11731
|
: "全部用量均来自供应商返回的 Token 统计。";
|
|
@@ -11145,12 +11741,12 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
11145
11741
|
return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
|
|
11146
11742
|
<div class="config-section-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div></div>
|
|
11147
11743
|
<div class="usage-stat-grid">
|
|
11148
|
-
<article class="usage-stat is-primary"><span>总消耗</span><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
|
|
11149
|
-
<article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small
|
|
11744
|
+
<article class="usage-stat is-primary"><div class="usage-stat-label"><span>总消耗</span>${pricingBadge}</div><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
|
|
11745
|
+
<article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small title="${esc(inputDescription)}">${esc(inputDescription)}</small></article>
|
|
11150
11746
|
<article class="usage-stat"><span>输出 Token</span><strong>${esc(formatTokenCount(summary.outputTokens))}</strong><small>${Number(summary.outputTokens || 0).toLocaleString("zh-CN")}</small></article>
|
|
11151
11747
|
<article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
|
|
11152
11748
|
</div>
|
|
11153
|
-
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)}
|
|
11749
|
+
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
|
|
11154
11750
|
<section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
|
|
11155
11751
|
<header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
|
|
11156
11752
|
${tokenUsageCalendarMarkup(usage?.daily)}
|
|
@@ -11178,15 +11774,17 @@ async function renderBookAiSettings() {
|
|
|
11178
11774
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
11179
11775
|
relationshipSearchIndexRefreshTimer = null;
|
|
11180
11776
|
}
|
|
11181
|
-
const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
|
|
11777
|
+
const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions] = await Promise.all([
|
|
11182
11778
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
11183
11779
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
11184
11780
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
11185
11781
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
11186
11782
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
11187
|
-
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
|
|
11783
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
11784
|
+
moduleApi("ai-settings", "/api/platform/ai/protocols")
|
|
11188
11785
|
]);
|
|
11189
11786
|
const host = $("#module-content");
|
|
11787
|
+
platformAiProtocolOptions = protocolOptions;
|
|
11190
11788
|
const workId = String(state.work.id);
|
|
11191
11789
|
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
11192
11790
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
|
|
@@ -11195,14 +11793,37 @@ async function renderBookAiSettings() {
|
|
|
11195
11793
|
const quotaRemainingTokens = usage?.quota?.remainingTokens === null
|
|
11196
11794
|
? null
|
|
11197
11795
|
: Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
|
|
11796
|
+
const monthlyTokenQuota = settings.monthlyTokenQuota === null ? null : Number(settings.monthlyTokenQuota);
|
|
11797
|
+
const monthlyQuotaUsedTokens = Number(usage?.quota?.monthlyUsedTokens) || 0;
|
|
11798
|
+
const monthlyQuotaRemainingTokens = usage?.quota?.monthlyRemainingTokens === null
|
|
11799
|
+
? null
|
|
11800
|
+
: Math.max(0, Number(usage?.quota?.monthlyRemainingTokens) || 0);
|
|
11198
11801
|
const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
|
|
11199
11802
|
const quotaStatusText = dailyTokenQuota === null
|
|
11200
11803
|
? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
11201
11804
|
: `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
11805
|
+
const monthlyQuotaStatusText = monthlyTokenQuota === null
|
|
11806
|
+
? `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
11807
|
+
: `本月已使用 ${monthlyQuotaUsedTokens.toLocaleString("zh-CN")} / ${monthlyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(monthlyQuotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
11202
11808
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
11203
11809
|
title: "本书 Token 用量",
|
|
11204
11810
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
11205
|
-
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token
|
|
11811
|
+
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
11812
|
+
const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
|
|
11813
|
+
dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
|
|
11814
|
+
const configureTokenQuotaInput = (input, warningId, threshold) => {
|
|
11815
|
+
input.min = "1";
|
|
11816
|
+
input.step = "1";
|
|
11817
|
+
const warning = document.createElement("small");
|
|
11818
|
+
warning.id = warningId;
|
|
11819
|
+
warning.className = "token-quota-warning hidden";
|
|
11820
|
+
warning.setAttribute("role", "alert");
|
|
11821
|
+
warning.dataset.threshold = String(threshold);
|
|
11822
|
+
warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
|
|
11823
|
+
input.closest(".config-inline-save")?.querySelector(".config-save-button")?.insertAdjacentElement("afterend", warning);
|
|
11824
|
+
};
|
|
11825
|
+
configureTokenQuotaInput(host.querySelector("#daily-token-quota"), "daily-token-quota-warning", 10_000);
|
|
11826
|
+
configureTokenQuotaInput(host.querySelector("#monthly-token-quota"), "monthly-token-quota-warning", 1_000_000);
|
|
11206
11827
|
const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
|
|
11207
11828
|
agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
|
|
11208
11829
|
const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
|
|
@@ -11256,15 +11877,27 @@ async function renderBookAiSettings() {
|
|
|
11256
11877
|
return status;
|
|
11257
11878
|
};
|
|
11258
11879
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
11880
|
+
const syncTokenQuotaWarnings = () => {
|
|
11881
|
+
for (const period of ["daily", "monthly"]) {
|
|
11882
|
+
const enabled = $(`#${period}-token-quota-enabled`).checked;
|
|
11883
|
+
const input = $(`#${period}-token-quota`);
|
|
11884
|
+
const warning = $(`#${period}-token-quota-warning`);
|
|
11885
|
+
const value = Number(input.value);
|
|
11886
|
+
const threshold = Number(warning.dataset.threshold);
|
|
11887
|
+
warning.classList.toggle("hidden", !enabled || !Number.isInteger(value) || value <= 0 || value >= threshold);
|
|
11888
|
+
}
|
|
11889
|
+
};
|
|
11259
11890
|
$("#daily-token-quota-enabled").addEventListener("change", (event) => {
|
|
11260
11891
|
$("#daily-token-quota").disabled = !event.currentTarget.checked;
|
|
11892
|
+
syncTokenQuotaWarnings();
|
|
11261
11893
|
});
|
|
11894
|
+
$("#daily-token-quota").addEventListener("input", syncTokenQuotaWarnings);
|
|
11262
11895
|
$("#save-daily-token-quota").addEventListener("click", async () => {
|
|
11263
11896
|
const button = $("#save-daily-token-quota");
|
|
11264
11897
|
const enabled = $("#daily-token-quota-enabled").checked;
|
|
11265
11898
|
const quota = Number($("#daily-token-quota").value);
|
|
11266
|
-
if (enabled && (!Number.isInteger(quota) || quota
|
|
11267
|
-
toast("每日 Token
|
|
11899
|
+
if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
|
|
11900
|
+
toast("每日 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
|
|
11268
11901
|
$("#daily-token-quota").focus();
|
|
11269
11902
|
return;
|
|
11270
11903
|
}
|
|
@@ -11281,6 +11914,34 @@ async function renderBookAiSettings() {
|
|
|
11281
11914
|
button.disabled = false;
|
|
11282
11915
|
}
|
|
11283
11916
|
});
|
|
11917
|
+
$("#monthly-token-quota-enabled").addEventListener("change", (event) => {
|
|
11918
|
+
$("#monthly-token-quota").disabled = !event.currentTarget.checked;
|
|
11919
|
+
syncTokenQuotaWarnings();
|
|
11920
|
+
});
|
|
11921
|
+
$("#monthly-token-quota").addEventListener("input", syncTokenQuotaWarnings);
|
|
11922
|
+
$("#save-monthly-token-quota").addEventListener("click", async () => {
|
|
11923
|
+
const button = $("#save-monthly-token-quota");
|
|
11924
|
+
const enabled = $("#monthly-token-quota-enabled").checked;
|
|
11925
|
+
const quota = Number($("#monthly-token-quota").value);
|
|
11926
|
+
if (enabled && (!Number.isInteger(quota) || quota <= 0 || quota > 2_000_000_000)) {
|
|
11927
|
+
toast("每月 Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000", "error");
|
|
11928
|
+
$("#monthly-token-quota").focus();
|
|
11929
|
+
return;
|
|
11930
|
+
}
|
|
11931
|
+
button.disabled = true;
|
|
11932
|
+
try {
|
|
11933
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
11934
|
+
method: "PATCH",
|
|
11935
|
+
body: { monthlyTokenQuota: enabled ? quota : null }
|
|
11936
|
+
});
|
|
11937
|
+
toast(enabled ? "本书每月 Token 额度已保存" : "本书每月 Token 额度限制已关闭");
|
|
11938
|
+
await renderBookAiSettings();
|
|
11939
|
+
} catch (error) {
|
|
11940
|
+
toast(error.message, "error");
|
|
11941
|
+
button.disabled = false;
|
|
11942
|
+
}
|
|
11943
|
+
});
|
|
11944
|
+
syncTokenQuotaWarnings();
|
|
11284
11945
|
$("#save-always-include-setting-info").addEventListener("click", async () => {
|
|
11285
11946
|
const button = $("#save-always-include-setting-info");
|
|
11286
11947
|
button.disabled = true;
|
|
@@ -11486,6 +12147,7 @@ function applyAiModels(models) {
|
|
|
11486
12147
|
}
|
|
11487
12148
|
setAiContextMeter(null);
|
|
11488
12149
|
syncAiTaskOptions();
|
|
12150
|
+
syncAiModelPicker();
|
|
11489
12151
|
}
|
|
11490
12152
|
|
|
11491
12153
|
async function ensureAiModelsLoaded() {
|
|
@@ -11643,7 +12305,8 @@ function setAiModelPickerVisible(visible) {
|
|
|
11643
12305
|
const popover = $("#ai-model-popover");
|
|
11644
12306
|
popover.classList.toggle("hidden", !visible);
|
|
11645
12307
|
button.setAttribute("aria-expanded", String(visible));
|
|
11646
|
-
if (!visible && document.activeElement === $("#ai-model")) button.focus();
|
|
12308
|
+
if (!visible && (document.activeElement === $("#ai-model") || document.activeElement?.closest("#ai-model-options"))) button.focus();
|
|
12309
|
+
if (visible) focusAiModelOption();
|
|
11647
12310
|
}
|
|
11648
12311
|
|
|
11649
12312
|
function showAiContextWarning(usage = null) {
|
|
@@ -11886,6 +12549,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
11886
12549
|
void discardPendingMarkdownAttachments();
|
|
11887
12550
|
const dialog = $("#form-dialog");
|
|
11888
12551
|
const form = $("#dynamic-form");
|
|
12552
|
+
form.noValidate = false;
|
|
11889
12553
|
const submit = $("#dialog-submit");
|
|
11890
12554
|
const submitStatus = $("#dialog-submit-status");
|
|
11891
12555
|
const submitStatusMessage = $("#dialog-submit-status-message");
|
|
@@ -12263,6 +12927,9 @@ function openVolumeDialog(item) {
|
|
|
12263
12927
|
if (!state.work) return openWorkDialog();
|
|
12264
12928
|
if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
|
|
12265
12929
|
const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
|
|
12930
|
+
const nextStoryOrder = Math.max(-1, ...state.work.volumes.map((volume) => Number(volume.storyOrder)).filter(Number.isInteger)) + 1;
|
|
12931
|
+
const storyOrder = Number.isInteger(Number(item?.storyOrder)) ? Number(item.storyOrder) : nextStoryOrder;
|
|
12932
|
+
const storyOrderField = `<label>剧情顺序<input name="storyOrder" type="number" min="0" max="1000000" step="1" required value="${esc(String(storyOrder))}" aria-describedby="volume-story-order-help"></label><p id="volume-story-order-help" class="form-field-note">仅用于 AI 判断分卷间的剧情先后,不改变左侧目录、阅读或导出顺序;相同值表示并行或暂时无法定序。</p>`;
|
|
12266
12933
|
const management = item ? `<section class="entity-dialog-management" aria-label="分卷详情操作">
|
|
12267
12934
|
<div><strong>分卷详情</strong><small>可将当前分卷单独导出为 EPUB;移入回收站时会连同其中章节隐藏,正文、版本和关联资料默认保留 30 天。</small></div>
|
|
12268
12935
|
<div class="entity-dialog-management-actions"><button class="ghost-button" type="button" data-dialog-volume-export>导出 EPUB</button><button class="danger-button" type="button" data-dialog-volume-delete>移入回收站</button></div>
|
|
@@ -12270,6 +12937,7 @@ function openVolumeDialog(item) {
|
|
|
12270
12937
|
openDialog(item ? "分卷详情" : "新建分卷",
|
|
12271
12938
|
field("title", "分卷名称", "text", item?.title) +
|
|
12272
12939
|
field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
|
|
12940
|
+
storyOrderField +
|
|
12273
12941
|
field("description", "分卷简介", "textarea", item?.description) +
|
|
12274
12942
|
field("keywords", "分卷关键词", "keyword-chips", item?.keywords ?? []) +
|
|
12275
12943
|
management,
|
|
@@ -12277,6 +12945,7 @@ function openVolumeDialog(item) {
|
|
|
12277
12945
|
const body = {
|
|
12278
12946
|
title: form.get("title"),
|
|
12279
12947
|
kind: form.get("kind"),
|
|
12948
|
+
storyOrder: Number(form.get("storyOrder")),
|
|
12280
12949
|
description: form.get("description"),
|
|
12281
12950
|
keywords: uniqueRelationshipKeywords(form.getAll("keywords").map(String))
|
|
12282
12951
|
};
|
|
@@ -13331,6 +14000,34 @@ async function loadCharacterMarkdownSections(characterId) {
|
|
|
13331
14000
|
}
|
|
13332
14001
|
}
|
|
13333
14002
|
|
|
14003
|
+
function renderCharacterAvatar(item) {
|
|
14004
|
+
const preview = $("#character-avatar-preview");
|
|
14005
|
+
if (!preview) return;
|
|
14006
|
+
preview.replaceChildren();
|
|
14007
|
+
const fallback = document.createElement("span");
|
|
14008
|
+
fallback.className = "character-avatar-fallback";
|
|
14009
|
+
fallback.textContent = characterAvatarInitial(item);
|
|
14010
|
+
preview.append(fallback);
|
|
14011
|
+
if (item?.avatarUrl) {
|
|
14012
|
+
const image = document.createElement("img");
|
|
14013
|
+
image.alt = "";
|
|
14014
|
+
image.decoding = "async";
|
|
14015
|
+
image.src = item.avatarUrl;
|
|
14016
|
+
image.addEventListener("error", () => image.remove(), { once: true });
|
|
14017
|
+
preview.append(image);
|
|
14018
|
+
}
|
|
14019
|
+
const uploadButton = $("#character-avatar-upload-button");
|
|
14020
|
+
const removeButton = $("#character-avatar-remove-button");
|
|
14021
|
+
if (uploadButton) {
|
|
14022
|
+
uploadButton.textContent = item?.avatarUrl ? "更换头像" : "上传头像";
|
|
14023
|
+
uploadButton.disabled = !item?.id || entityEditorReadOnly || !canEditModule("characters");
|
|
14024
|
+
}
|
|
14025
|
+
if (removeButton) {
|
|
14026
|
+
removeButton.classList.toggle("hidden", !item?.avatarUrl);
|
|
14027
|
+
removeButton.disabled = entityEditorReadOnly || !canEditModule("characters");
|
|
14028
|
+
}
|
|
14029
|
+
}
|
|
14030
|
+
|
|
13334
14031
|
function renderCharacterEditorFields(item) {
|
|
13335
14032
|
const raceOptions = [["", "未指定"], ...state.races.map((race) => [race.id, racePathLabel(race)])];
|
|
13336
14033
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
@@ -13338,9 +14035,9 @@ function renderCharacterEditorFields(item) {
|
|
|
13338
14035
|
const stateEntries = characterStateEntries(item?.currentState ?? {});
|
|
13339
14036
|
$("#character-editor-fields").innerHTML = [
|
|
13340
14037
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
14038
|
+
`<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
|
|
13341
14039
|
field("name", "标准名", "text", item?.name) +
|
|
13342
14040
|
field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
|
|
13343
|
-
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
13344
14041
|
field("aliases", "别名", "item-list", item?.aliases ?? []) +
|
|
13345
14042
|
(!canReadModule("races")
|
|
13346
14043
|
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
@@ -13364,6 +14061,7 @@ function renderCharacterEditorFields(item) {
|
|
|
13364
14061
|
field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
|
|
13365
14062
|
'<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
|
|
13366
14063
|
characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
|
|
14064
|
+
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
13367
14065
|
field("currentState", "当前状态", "key-value-list", stateEntries, {
|
|
13368
14066
|
keyName: "stateKey",
|
|
13369
14067
|
valueName: "stateValue",
|
|
@@ -13382,6 +14080,7 @@ function renderCharacterEditorFields(item) {
|
|
|
13382
14080
|
const name = $("#character-editor-fields [name='name']");
|
|
13383
14081
|
if (name) name.required = true;
|
|
13384
14082
|
bindDynamicListControls($("#character-editor-fields"));
|
|
14083
|
+
renderCharacterAvatar(item);
|
|
13385
14084
|
renderCharacterEditorRelationships();
|
|
13386
14085
|
renderCharacterMarkdownSections();
|
|
13387
14086
|
activateCharacterEditorTab("basic");
|
|
@@ -13588,6 +14287,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
13588
14287
|
characterEditorItem = saved;
|
|
13589
14288
|
state.characters = upsertEntityCollection(state.characters, saved);
|
|
13590
14289
|
entityEditorDirty = false;
|
|
14290
|
+
renderCharacterAvatar(saved);
|
|
13591
14291
|
$("#character-editor-title").textContent = saved.name;
|
|
13592
14292
|
$("#character-editor-version").textContent = `v${saved.versionNo}`;
|
|
13593
14293
|
$("#character-change-note").value = "";
|
|
@@ -13608,6 +14308,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
13608
14308
|
return { buttonLabel: "保存新版本", message };
|
|
13609
14309
|
}
|
|
13610
14310
|
});
|
|
14311
|
+
renderCharacterAvatar(characterEditorItem);
|
|
13611
14312
|
};
|
|
13612
14313
|
showEntityEditorPage("character", { readOnly });
|
|
13613
14314
|
if (item) {
|
|
@@ -14627,22 +15328,12 @@ async function refreshAnalysisTaskViewsAfterCreate(workId) {
|
|
|
14627
15328
|
}
|
|
14628
15329
|
}
|
|
14629
15330
|
|
|
14630
|
-
function openProviderDialog(item) {
|
|
14631
|
-
const protocol = item?.protocol ?? "
|
|
14632
|
-
const
|
|
14633
|
-
|
|
14634
|
-
["anthropic-messages", "Anthropic Messages"],
|
|
14635
|
-
["google-vertex", "Google Vertex"]
|
|
14636
|
-
];
|
|
14637
|
-
const defaultBaseUrlForProtocol = (value) => {
|
|
14638
|
-
if (value === "anthropic-messages") return "https://api.anthropic.com";
|
|
14639
|
-
if (value === "google-vertex") {
|
|
14640
|
-
return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
|
|
14641
|
-
}
|
|
14642
|
-
return "https://api.openai.com/v1";
|
|
14643
|
-
};
|
|
15331
|
+
function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
|
|
15332
|
+
const protocol = item?.protocol ?? protocolOptions[0]?.value ?? "";
|
|
15333
|
+
const selectedProtocolOption = (value) => protocolOptions.find((option) => option.value === value);
|
|
15334
|
+
const defaultBaseUrlForProtocol = (value) => protocolOptions.find((option) => option.value === value)?.defaultBaseUrl ?? "";
|
|
14644
15335
|
const credentialFieldForProtocol = (value) => {
|
|
14645
|
-
if (value === "
|
|
15336
|
+
if (protocolOptions.find((option) => option.value === value)?.credentialKind === "service-account-json") {
|
|
14646
15337
|
return field(
|
|
14647
15338
|
"apiKey",
|
|
14648
15339
|
item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
|
|
@@ -14653,17 +15344,26 @@ function openProviderDialog(item) {
|
|
|
14653
15344
|
return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
|
|
14654
15345
|
};
|
|
14655
15346
|
const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
|
|
14656
|
-
const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens"
|
|
15347
|
+
const useMaxCompletionTokens = item?.maxTokensParameter === "max_completion_tokens"
|
|
15348
|
+
&& protocolOptions.find((option) => option.value === protocol)?.supportsMaxCompletionTokens !== false;
|
|
15349
|
+
const useAdaptiveThinking = item?.thinkingType === "adaptive";
|
|
15350
|
+
const providerProtocolFieldOptions = protocolOptions.map((option) => [option.value, option.label]);
|
|
14657
15351
|
const maxTokensParameterField = `<div class="form-field provider-max-tokens-parameter-field" data-provider-max-tokens-parameter-field><span>最大输出令牌参数</span><label class="checkbox-field"><input name="useMaxCompletionTokens" type="checkbox" ${useMaxCompletionTokens ? "checked" : ""} aria-describedby="provider-max-tokens-parameter-hint"><span>使用 max_completion_tokens</span></label><small id="provider-max-tokens-parameter-hint" data-provider-max-tokens-parameter-hint>默认使用 max_tokens;OpenAI 新版模型可按需切换。</small></div>`;
|
|
15352
|
+
const dailyTokenQuota = item?.dailyTokenQuota ?? null;
|
|
15353
|
+
const monthlyTokenQuota = item?.monthlyTokenQuota ?? null;
|
|
15354
|
+
const providerTokenQuotaFields = `<div class="form-field provider-token-quota-fields" data-provider-token-quota-fields><span>供应商 Token 额度</span><small>按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。</small><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="dailyTokenQuotaEnabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}><span>启用每日额度</span></label><label class="provider-token-quota-input">每日额度<input name="dailyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="供应商每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label></div><div class="provider-token-quota-row"><label class="checkbox-field provider-token-quota-toggle"><input name="monthlyTokenQuotaEnabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}><span>启用每月额度</span></label><label class="provider-token-quota-input">每月额度<input name="monthlyTokenQuota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="供应商每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label></div></div>`;
|
|
15355
|
+
const thinkingTypeField = `<div class="form-field provider-thinking-type-field" data-provider-thinking-type-field><span>思考类型(开启时)</span><label class="checkbox-field"><input name="useAdaptiveThinking" type="checkbox" ${useAdaptiveThinking ? "checked" : ""} aria-describedby="provider-thinking-type-hint"><span>使用 adaptive(关闭时发送 enabled)</span></label><small id="provider-thinking-type-hint">关闭模型思考时仍发送 disabled;请只在供应商支持 adaptive 时开启。</small></div>`;
|
|
14658
15356
|
openDialog(
|
|
14659
15357
|
item ? "编辑 AI 供应商" : "新建 AI 供应商",
|
|
14660
15358
|
field("name", "显示名称", "text", item?.name)
|
|
14661
|
-
+ field("protocol", "接口协议", "select", protocol,
|
|
15359
|
+
+ field("protocol", "接口协议", "select", protocol, providerProtocolFieldOptions)
|
|
14662
15360
|
+ field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
|
|
14663
15361
|
+ `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
|
|
14664
15362
|
+ maxTokensParameterField
|
|
15363
|
+
+ thinkingTypeField
|
|
14665
15364
|
+ field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
|
|
14666
15365
|
+ field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
|
|
15366
|
+
+ providerTokenQuotaFields
|
|
14667
15367
|
+ field("note", "用途备注", "textarea", item?.note)
|
|
14668
15368
|
+ field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
|
|
14669
15369
|
async (form) => {
|
|
@@ -14672,30 +15372,67 @@ function openProviderDialog(item) {
|
|
|
14672
15372
|
protocol: form.get("protocol"),
|
|
14673
15373
|
baseUrl: form.get("baseUrl"),
|
|
14674
15374
|
maxTokensParameter: form.get("useMaxCompletionTokens") === "on" ? "max_completion_tokens" : "max_tokens",
|
|
15375
|
+
thinkingType: form.get("useAdaptiveThinking") === "on" ? "adaptive" : "enabled",
|
|
14675
15376
|
concurrencyLimit: Number(form.get("concurrencyLimit")),
|
|
14676
15377
|
rpmLimit: Number(form.get("rpmLimit")),
|
|
15378
|
+
dailyTokenQuota: form.get("dailyTokenQuotaEnabled") === "on" ? Number(form.get("dailyTokenQuota")) : null,
|
|
15379
|
+
monthlyTokenQuota: form.get("monthlyTokenQuotaEnabled") === "on" ? Number(form.get("monthlyTokenQuota")) : null,
|
|
14677
15380
|
note: form.get("note"),
|
|
14678
15381
|
status: form.get("enabled") === "on" ? "enabled" : "disabled"
|
|
14679
15382
|
};
|
|
14680
15383
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
15384
|
+
for (const [period, label] of [["daily", "每日"], ["monthly", "每月"]]) {
|
|
15385
|
+
const value = body[`${period}TokenQuota`];
|
|
15386
|
+
if (value !== null && (!Number.isInteger(value) || value <= 0 || value > 2_000_000_000)) {
|
|
15387
|
+
throw new Error(`${label} Token 额度必须设置大于 0 的整数,且不超过 2,000,000,000`);
|
|
15388
|
+
}
|
|
15389
|
+
}
|
|
14681
15390
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
14682
15391
|
await renderPlatformAiConfig();
|
|
14683
15392
|
await loadModels();
|
|
14684
15393
|
if (item) toast(connectivityConfigurationSavedToast("provider"));
|
|
14685
15394
|
},
|
|
14686
|
-
item ? "协议、限流与凭据" : "
|
|
15395
|
+
item ? "协议、限流与凭据" : "供应商协议、限流与凭据", {
|
|
15396
|
+
dangerAction: item ? { label: "删除供应商", onClick: () => deletePlatformProvider(item) } : null
|
|
15397
|
+
}
|
|
14687
15398
|
);
|
|
15399
|
+
$("#dynamic-form").noValidate = true;
|
|
15400
|
+
const providerQuotaDescription = $("#dialog-fields [data-provider-token-quota-fields] > small");
|
|
15401
|
+
if (providerQuotaDescription) providerQuotaDescription.textContent = "按服务器部署时区统计该供应商跨所有小说的输入与输出 Token;与单个小说额度独立。额度必须设置为大于 0;低于每日 10,000 或每月 1,000,000 时仅提示风险。";
|
|
15402
|
+
for (const [period, threshold] of [["daily", 10_000], ["monthly", 1_000_000]]) {
|
|
15403
|
+
const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
|
|
15404
|
+
input.min = "1";
|
|
15405
|
+
input.step = "1";
|
|
15406
|
+
const warning = document.createElement("small");
|
|
15407
|
+
warning.className = "provider-token-quota-warning hidden";
|
|
15408
|
+
warning.dataset.period = period;
|
|
15409
|
+
warning.dataset.threshold = String(threshold);
|
|
15410
|
+
warning.setAttribute("role", "alert");
|
|
15411
|
+
warning.textContent = "ai 小说用量巨大,低用量基本等于不可用,建议增大限制量";
|
|
15412
|
+
input.insertAdjacentElement("afterend", warning);
|
|
15413
|
+
}
|
|
14688
15414
|
const protocolSelect = $("#dialog-fields select[name='protocol']");
|
|
14689
15415
|
const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
|
|
14690
15416
|
const credentialHost = $("#dialog-fields [data-provider-credential-field]");
|
|
14691
15417
|
const maxTokensParameterFieldElement = $("#dialog-fields [data-provider-max-tokens-parameter-field]");
|
|
14692
15418
|
const maxTokensParameterInput = $("#dialog-fields input[name='useMaxCompletionTokens']");
|
|
15419
|
+
const syncProviderTokenQuotaFields = () => {
|
|
15420
|
+
for (const period of ["daily", "monthly"]) {
|
|
15421
|
+
const enabled = $(`#dialog-fields input[name='${period}TokenQuotaEnabled']`);
|
|
15422
|
+
const input = $(`#dialog-fields input[name='${period}TokenQuota']`);
|
|
15423
|
+
const warning = $(`#dialog-fields [data-period='${period}']`);
|
|
15424
|
+
input.disabled = !enabled.checked;
|
|
15425
|
+
const value = Number(input.value);
|
|
15426
|
+
const threshold = Number(warning.dataset.threshold);
|
|
15427
|
+
warning.classList.toggle("hidden", !enabled.checked || !Number.isInteger(value) || value <= 0 || value >= threshold);
|
|
15428
|
+
}
|
|
15429
|
+
};
|
|
14693
15430
|
const syncMaxTokensParameter = () => {
|
|
14694
|
-
const
|
|
14695
|
-
maxTokensParameterFieldElement.hidden =
|
|
14696
|
-
maxTokensParameterFieldElement.classList.toggle("hidden",
|
|
14697
|
-
maxTokensParameterInput.disabled =
|
|
14698
|
-
if (
|
|
15431
|
+
const supportsMaxCompletionTokens = selectedProtocolOption(protocolSelect.value)?.supportsMaxCompletionTokens !== false;
|
|
15432
|
+
maxTokensParameterFieldElement.hidden = !supportsMaxCompletionTokens;
|
|
15433
|
+
maxTokensParameterFieldElement.classList.toggle("hidden", !supportsMaxCompletionTokens);
|
|
15434
|
+
maxTokensParameterInput.disabled = !supportsMaxCompletionTokens;
|
|
15435
|
+
if (!supportsMaxCompletionTokens) maxTokensParameterInput.checked = false;
|
|
14699
15436
|
};
|
|
14700
15437
|
const syncProviderCredentialField = () => {
|
|
14701
15438
|
const nextProtocol = protocolSelect.value;
|
|
@@ -14703,14 +15440,19 @@ function openProviderDialog(item) {
|
|
|
14703
15440
|
if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
|
|
14704
15441
|
syncMaxTokensParameter();
|
|
14705
15442
|
};
|
|
15443
|
+
$("#dialog-fields input[name='dailyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
|
|
15444
|
+
$("#dialog-fields input[name='monthlyTokenQuotaEnabled']").addEventListener("change", syncProviderTokenQuotaFields);
|
|
15445
|
+
$("#dialog-fields input[name='dailyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
|
|
15446
|
+
$("#dialog-fields input[name='monthlyTokenQuota']").addEventListener("input", syncProviderTokenQuotaFields);
|
|
14706
15447
|
protocolSelect.addEventListener("change", syncProviderCredentialField);
|
|
14707
15448
|
syncMaxTokensParameter();
|
|
15449
|
+
syncProviderTokenQuotaFields();
|
|
14708
15450
|
}
|
|
14709
15451
|
|
|
14710
|
-
function openModelDialog(providerId, item = null, provider = null) {
|
|
15452
|
+
function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
|
|
14711
15453
|
const values = modelFormValues(item);
|
|
14712
|
-
const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol);
|
|
14713
|
-
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
|
|
15454
|
+
const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
|
|
15455
|
+
const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
|
|
14714
15456
|
const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
|
|
14715
15457
|
const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
|
|
14716
15458
|
const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
|
|
@@ -14728,7 +15470,9 @@ function openModelDialog(providerId, item = null, provider = null) {
|
|
|
14728
15470
|
await renderPlatformAiConfig();
|
|
14729
15471
|
await loadModels();
|
|
14730
15472
|
if (item) toast(connectivityConfigurationSavedToast("model"));
|
|
14731
|
-
}, item ? "模型配置" : "供应商模型"
|
|
15473
|
+
}, item ? "模型配置" : "供应商模型", {
|
|
15474
|
+
dangerAction: item ? { label: "删除模型", onClick: () => deletePlatformModel(item) } : null
|
|
15475
|
+
});
|
|
14732
15476
|
const modelIdInput = $("#dialog-fields input[name='modelId']");
|
|
14733
15477
|
const contextWindowInput = $("#model-context-window");
|
|
14734
15478
|
const contextWindowHint = $("#model-context-window-hint");
|
|
@@ -14794,20 +15538,23 @@ async function sendAi() {
|
|
|
14794
15538
|
return sendAiWithOptions();
|
|
14795
15539
|
}
|
|
14796
15540
|
|
|
14797
|
-
async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
15541
|
+
async function sendAiWithOptions({ ignoreContextWarning = false, retry = null } = {}) {
|
|
14798
15542
|
if (!state.work) return toast("请先选择作品", "error");
|
|
14799
15543
|
const tab = activeAiChatTab();
|
|
14800
15544
|
if (!tab) return toast("Agent 对话页签尚未就绪", "error");
|
|
14801
15545
|
if (aiRequestManager.hasActive(tab.id)) return;
|
|
14802
15546
|
const composerSnapshot = captureAiPromptComposer();
|
|
14803
|
-
const
|
|
15547
|
+
const requestComposerSnapshot = retry
|
|
15548
|
+
? { text: retry.prompt, citations: retry.citations ?? [], references: [], images: retry.images ?? [] }
|
|
15549
|
+
: composerSnapshot;
|
|
15550
|
+
const instruction = requestComposerSnapshot.text.trim();
|
|
14804
15551
|
if (!instruction) return toast("请输入指令", "error");
|
|
14805
15552
|
if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
|
|
14806
15553
|
const requestScope = currentAiRequestScope();
|
|
14807
15554
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
14808
15555
|
const { taskType, scope, selection } = requestScope;
|
|
14809
15556
|
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
14810
|
-
const citations =
|
|
15557
|
+
const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
14811
15558
|
const selectedTaskType = $("#ai-task").value;
|
|
14812
15559
|
persistActiveAiChatTab();
|
|
14813
15560
|
tab.selectedModelId = $("#ai-model").value || tab.selectedModelId || null;
|
|
@@ -14818,6 +15565,9 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14818
15565
|
conversationId: state.aiConversationId
|
|
14819
15566
|
})
|
|
14820
15567
|
};
|
|
15568
|
+
if (retry?.userMessageId) {
|
|
15569
|
+
requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: retry.userMessageId });
|
|
15570
|
+
}
|
|
14821
15571
|
setAiChatTabStatus(tab, "streaming");
|
|
14822
15572
|
syncAiRequestControls();
|
|
14823
15573
|
try {
|
|
@@ -14833,6 +15583,13 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14833
15583
|
const modelId = tab.selectedModelId || state.models[0]?.id || (isActiveAiChatTab(tab) ? $("#ai-model").value : "");
|
|
14834
15584
|
if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
|
|
14835
15585
|
tab.selectedModelId = modelId;
|
|
15586
|
+
const imageAttachmentIds = aiChatImageAttachmentIds(requestComposerSnapshot.images);
|
|
15587
|
+
if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
|
|
15588
|
+
return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
|
|
15589
|
+
}
|
|
15590
|
+
if (imageAttachmentIds.length > 0 && taskType !== "chat") {
|
|
15591
|
+
return toast("图片附件目前仅支持问答对话", "error");
|
|
15592
|
+
}
|
|
14836
15593
|
try {
|
|
14837
15594
|
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
14838
15595
|
} catch (error) {
|
|
@@ -14840,38 +15597,45 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14840
15597
|
return toast(`对话配置锁定失败:${error.message}`, "error");
|
|
14841
15598
|
}
|
|
14842
15599
|
setAiChatTabStatus(tab, "streaming");
|
|
15600
|
+
if (retry?.message?.isConnected) retry.message.remove();
|
|
14843
15601
|
if (taskType !== "chat") {
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
14858
|
-
|
|
14859
|
-
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
15602
|
+
if (!retry) {
|
|
15603
|
+
try {
|
|
15604
|
+
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
15605
|
+
const persistedUserMessage = await persistAiConversationMessage(
|
|
15606
|
+
request.conversationId,
|
|
15607
|
+
"user",
|
|
15608
|
+
instruction,
|
|
15609
|
+
citations,
|
|
15610
|
+
{ modelId },
|
|
15611
|
+
{ signal: request.signal }
|
|
15612
|
+
);
|
|
15613
|
+
assertAiRequestCurrent(request);
|
|
15614
|
+
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
15615
|
+
requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
|
|
15616
|
+
tab.modelId = modelId;
|
|
15617
|
+
tab.selectedModelId = modelId;
|
|
15618
|
+
tab.promptSent = true;
|
|
15619
|
+
clearAiChatTabComposer(tab);
|
|
15620
|
+
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
|
|
15621
|
+
if (isActiveAiChatTab(tab)) {
|
|
15622
|
+
state.aiConversationModelId = modelId;
|
|
15623
|
+
state.aiPromptSent = true;
|
|
15624
|
+
syncAiTaskOptions();
|
|
15625
|
+
renderAiRoleplayCharacterSelect();
|
|
15626
|
+
renderAiQuickActions();
|
|
15627
|
+
clearAiPromptComposer();
|
|
15628
|
+
}
|
|
15629
|
+
} catch (error) {
|
|
15630
|
+
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
15631
|
+
setAiChatTabStatus(tab, "error");
|
|
15632
|
+
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
14869
15633
|
}
|
|
14870
|
-
}
|
|
14871
|
-
|
|
14872
|
-
setAiChatTabStatus(tab, "error");
|
|
14873
|
-
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
15634
|
+
} else {
|
|
15635
|
+
prepareAiRetryState(tab, modelId);
|
|
14874
15636
|
}
|
|
15637
|
+
} else if (retry) {
|
|
15638
|
+
prepareAiRetryState(tab, modelId);
|
|
14875
15639
|
}
|
|
14876
15640
|
let assistantContent = "";
|
|
14877
15641
|
let assistantMessage;
|
|
@@ -14879,14 +15643,15 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14879
15643
|
let persistedStreamMessage = null;
|
|
14880
15644
|
let suggestion = null;
|
|
14881
15645
|
if (taskType === "chat") {
|
|
14882
|
-
const streamed = await streamChat(requestHolder, {
|
|
15646
|
+
const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
|
|
14883
15647
|
instruction,
|
|
14884
15648
|
scope,
|
|
14885
15649
|
modelId,
|
|
14886
15650
|
citations,
|
|
15651
|
+
...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
|
|
14887
15652
|
conversationId: requestHolder.snapshot.conversationId,
|
|
14888
15653
|
...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
|
|
14889
|
-
}, createAiIdempotencyKey());
|
|
15654
|
+
}, retry), createAiIdempotencyKey());
|
|
14890
15655
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
14891
15656
|
if (streamed.action === "warn") return;
|
|
14892
15657
|
assistantContent = streamed.content;
|
|
@@ -14929,7 +15694,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14929
15694
|
assistantContent,
|
|
14930
15695
|
[],
|
|
14931
15696
|
assistantMetadata,
|
|
14932
|
-
{ signal: request.signal, requestId: aiAssistantRequestId(request) }
|
|
15697
|
+
{ signal: request.signal, requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
|
|
14933
15698
|
);
|
|
14934
15699
|
assertAiRequestCurrent(request);
|
|
14935
15700
|
updateAiConversationSummaryFromMessage(persistedAssistantMessage);
|
|
@@ -14964,8 +15729,11 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
14964
15729
|
return;
|
|
14965
15730
|
}
|
|
14966
15731
|
if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
|
|
14967
|
-
setAiChatTabComposerSnapshot(tab,
|
|
14968
|
-
if (isActiveAiChatTab(tab))
|
|
15732
|
+
setAiChatTabComposerSnapshot(tab, requestComposerSnapshot);
|
|
15733
|
+
if (isActiveAiChatTab(tab)) {
|
|
15734
|
+
if (retry) restoreAiPromptComposer(requestComposerSnapshot);
|
|
15735
|
+
else restoreAiPromptComposer(composerSnapshot);
|
|
15736
|
+
}
|
|
14969
15737
|
toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
|
|
14970
15738
|
if (isActiveAiChatTab(tab)) $("#ai-prompt").focus();
|
|
14971
15739
|
return;
|
|
@@ -15021,7 +15789,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false } = {}) {
|
|
|
15021
15789
|
failureMessage,
|
|
15022
15790
|
[],
|
|
15023
15791
|
{},
|
|
15024
|
-
{ requestId: aiAssistantRequestId(request) }
|
|
15792
|
+
{ requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
|
|
15025
15793
|
);
|
|
15026
15794
|
updateAiConversationSummaryFromMessage(persistedFailureMessage);
|
|
15027
15795
|
} catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
@@ -15165,12 +15933,18 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15165
15933
|
? persistedUserMessage.metadata.modelId
|
|
15166
15934
|
: typeof body.modelId === "string" ? body.modelId : null;
|
|
15167
15935
|
tab.modelId = lockedModelId;
|
|
15936
|
+
tab.hasImageAttachments = tab.hasImageAttachments === true || aiConversationMessageHasImages(persistedUserMessage);
|
|
15168
15937
|
tab.selectedModelId = lockedModelId;
|
|
15169
15938
|
tab.promptSent = true;
|
|
15170
15939
|
clearAiChatTabComposer(tab);
|
|
15171
|
-
|
|
15940
|
+
const existingUserMessage = [...tab.feed.querySelectorAll(".user-message[data-message-id]")]
|
|
15941
|
+
.find((candidate) => candidate.dataset.messageId === String(persistedUserMessage.id));
|
|
15942
|
+
if (!existingUserMessage) {
|
|
15943
|
+
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id, { tab });
|
|
15944
|
+
}
|
|
15172
15945
|
if (isActiveAiChatTab(tab)) {
|
|
15173
15946
|
state.aiConversationModelId = lockedModelId;
|
|
15947
|
+
state.aiConversationHasImages = tab.hasImageAttachments === true;
|
|
15174
15948
|
state.aiPromptSent = true;
|
|
15175
15949
|
syncAiTaskOptions();
|
|
15176
15950
|
renderAiRoleplayCharacterSelect();
|
|
@@ -15311,6 +16085,31 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
15311
16085
|
}
|
|
15312
16086
|
}
|
|
15313
16087
|
|
|
16088
|
+
function appendAiMessageImageAttachments(message, attachments) {
|
|
16089
|
+
const normalized = normalizeAiChatImageAttachments(attachments);
|
|
16090
|
+
if (normalized.length === 0) return;
|
|
16091
|
+
const host = document.createElement("div");
|
|
16092
|
+
host.className = "ai-message-images";
|
|
16093
|
+
host.setAttribute("aria-label", "消息中的图片附件");
|
|
16094
|
+
for (const [index, attachment] of normalized.entries()) {
|
|
16095
|
+
const preview = document.createElement("button");
|
|
16096
|
+
preview.type = "button";
|
|
16097
|
+
preview.className = "ai-message-image-preview";
|
|
16098
|
+
preview.setAttribute("aria-label", `查看图片附件:${attachment.originalName}`);
|
|
16099
|
+
preview.title = "点击查看大图";
|
|
16100
|
+
preview.addEventListener("click", () => openAiImagePreview(attachment, index + 1));
|
|
16101
|
+
const image = document.createElement("img");
|
|
16102
|
+
image.src = attachment.contentUrl;
|
|
16103
|
+
image.alt = attachment.originalName;
|
|
16104
|
+
image.loading = "lazy";
|
|
16105
|
+
image.decoding = "async";
|
|
16106
|
+
image.title = attachment.originalName;
|
|
16107
|
+
preview.append(image);
|
|
16108
|
+
host.append(preview);
|
|
16109
|
+
}
|
|
16110
|
+
message.append(host);
|
|
16111
|
+
}
|
|
16112
|
+
|
|
15314
16113
|
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null, options = {}) {
|
|
15315
16114
|
const tab = options.tab ?? activeAiChatTab();
|
|
15316
16115
|
const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
|
|
@@ -15389,7 +16188,17 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
15389
16188
|
if (role === "assistant") {
|
|
15390
16189
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
15391
16190
|
}
|
|
15392
|
-
if (role === "user")
|
|
16191
|
+
if (role === "user") {
|
|
16192
|
+
message.dataset.aiCitations = JSON.stringify(Array.isArray(citations) ? citations : []);
|
|
16193
|
+
const imageAttachments = normalizeAiChatImageAttachments(
|
|
16194
|
+
Array.isArray(metadata?.chatImageAttachmentIds)
|
|
16195
|
+
? metadata.chatImageAttachmentIds.map((id) => ({ id }))
|
|
16196
|
+
: []
|
|
16197
|
+
);
|
|
16198
|
+
message.dataset.aiImageAttachmentIds = JSON.stringify(imageAttachments.map((attachment) => attachment.id));
|
|
16199
|
+
appendAiMessageImageAttachments(message, imageAttachments);
|
|
16200
|
+
attachUserCopyAction(message, text);
|
|
16201
|
+
}
|
|
15393
16202
|
if (role === "assistant" && !text.startsWith("调用失败:")) {
|
|
15394
16203
|
const selectedModelId = tab?.modelId ?? tab?.selectedModelId ?? $("#ai-model").value;
|
|
15395
16204
|
const selectedModel = state.models.find((model) => model.id === selectedModelId) ?? state.models[0];
|
|
@@ -15403,6 +16212,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
15403
16212
|
message.append(meta);
|
|
15404
16213
|
attachAssistantCopyAction(message, text);
|
|
15405
16214
|
}
|
|
16215
|
+
if (isFailure) renderMessageCardActions(message);
|
|
15406
16216
|
attachMessageIdentity(message, messageId);
|
|
15407
16217
|
feed.append(message);
|
|
15408
16218
|
scrollAiFeedToBottom(feed);
|
|
@@ -16064,7 +16874,10 @@ $("#account-settings-button").addEventListener("click", () => {
|
|
|
16064
16874
|
});
|
|
16065
16875
|
});
|
|
16066
16876
|
$("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
|
|
16067
|
-
$("#avatar-upload-button").addEventListener("click", () =>
|
|
16877
|
+
$("#avatar-upload-button").addEventListener("click", () => {
|
|
16878
|
+
configureAvatarCropTarget({ type: "user", characterId: null });
|
|
16879
|
+
$("#avatar-file").click();
|
|
16880
|
+
});
|
|
16068
16881
|
|
|
16069
16882
|
const avatarCropSession = {
|
|
16070
16883
|
objectUrl: null,
|
|
@@ -16074,9 +16887,22 @@ const avatarCropSession = {
|
|
|
16074
16887
|
display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
|
|
16075
16888
|
drag: null,
|
|
16076
16889
|
fileName: "",
|
|
16890
|
+
target: { type: "user", characterId: null },
|
|
16077
16891
|
uploading: false
|
|
16078
16892
|
};
|
|
16079
16893
|
|
|
16894
|
+
function configureAvatarCropTarget(target) {
|
|
16895
|
+
avatarCropSession.target = target;
|
|
16896
|
+
const isCharacter = target.type === "character";
|
|
16897
|
+
const fileInput = $("#avatar-file");
|
|
16898
|
+
if (fileInput) fileInput.setAttribute("accept", isCharacter ? characterAvatarFileAccept : userAvatarFileAccept);
|
|
16899
|
+
$("#avatar-crop-dialog-eyebrow").textContent = isCharacter ? "角色档案" : "个人账户";
|
|
16900
|
+
$("#avatar-crop-dialog-title").textContent = isCharacter ? "设置角色头像" : "裁剪头像";
|
|
16901
|
+
$("#avatar-crop-dialog-description").textContent = isCharacter
|
|
16902
|
+
? "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。头像文件不超过 2 MB。"
|
|
16903
|
+
: "拖动选区或四角手柄调整范围,圆形预览即最终显示效果。";
|
|
16904
|
+
}
|
|
16905
|
+
|
|
16080
16906
|
function releaseAvatarCropObjectUrl() {
|
|
16081
16907
|
if (!avatarCropSession.objectUrl) return;
|
|
16082
16908
|
URL.revokeObjectURL(avatarCropSession.objectUrl);
|
|
@@ -16095,12 +16921,16 @@ function resetAvatarCropDialog() {
|
|
|
16095
16921
|
avatarCropSession.imageHeight = 0;
|
|
16096
16922
|
avatarCropSession.crop = { x: 0, y: 0, size: 0 };
|
|
16097
16923
|
avatarCropSession.fileName = "";
|
|
16924
|
+
avatarCropSession.target = { type: "user", characterId: null };
|
|
16098
16925
|
const image = $("#avatar-crop-image");
|
|
16099
16926
|
if (image) image.removeAttribute("src");
|
|
16100
16927
|
$("#avatar-crop-selection")?.setAttribute("hidden", "");
|
|
16101
16928
|
$("#avatar-crop-preview")?.replaceChildren();
|
|
16102
16929
|
const fileInput = $("#avatar-file");
|
|
16103
|
-
if (fileInput)
|
|
16930
|
+
if (fileInput) {
|
|
16931
|
+
fileInput.value = "";
|
|
16932
|
+
fileInput.setAttribute("accept", userAvatarFileAccept);
|
|
16933
|
+
}
|
|
16104
16934
|
}
|
|
16105
16935
|
|
|
16106
16936
|
function stagePointFromEvent(event) {
|
|
@@ -16270,8 +17100,17 @@ function renderAvatarUploadProgress(fileName, progress, visible = true) {
|
|
|
16270
17100
|
$("#avatar-file").addEventListener("change", async (event) => {
|
|
16271
17101
|
const file = event.target.files[0];
|
|
16272
17102
|
if (!file) return;
|
|
16273
|
-
|
|
16274
|
-
|
|
17103
|
+
const isCharacter = avatarCropSession.target.type === "character";
|
|
17104
|
+
if (isCharacter && isGifImageFile(file)) {
|
|
17105
|
+
toast("角色头像不支持 GIF 图片", "error");
|
|
17106
|
+
event.target.value = "";
|
|
17107
|
+
return;
|
|
17108
|
+
}
|
|
17109
|
+
const maximumBytes = isCharacter
|
|
17110
|
+
? characterAvatarImageMaxBytes
|
|
17111
|
+
: imageUploadLimits.avatarBytes;
|
|
17112
|
+
if (file.size > maximumBytes) {
|
|
17113
|
+
toast(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`, "error");
|
|
16275
17114
|
event.target.value = "";
|
|
16276
17115
|
return;
|
|
16277
17116
|
}
|
|
@@ -16359,22 +17198,46 @@ $("#avatar-crop-dialog").addEventListener("close", () => {
|
|
|
16359
17198
|
|
|
16360
17199
|
$("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
16361
17200
|
if (avatarCropSession.uploading) return;
|
|
17201
|
+
const target = avatarCropSession.target;
|
|
17202
|
+
if (target.type === "character" && !target.characterId) {
|
|
17203
|
+
toast("请先保存角色档案", "error");
|
|
17204
|
+
return;
|
|
17205
|
+
}
|
|
17206
|
+
const isCharacter = target.type === "character";
|
|
16362
17207
|
avatarCropSession.uploading = true;
|
|
16363
17208
|
$("#avatar-crop-confirm").disabled = true;
|
|
16364
17209
|
$("#avatar-crop-cancel").disabled = true;
|
|
16365
17210
|
$("#avatar-upload-button").disabled = true;
|
|
16366
17211
|
$("#avatar-remove-button").disabled = true;
|
|
17212
|
+
$("#character-avatar-upload-button")?.setAttribute("disabled", "true");
|
|
17213
|
+
$("#character-avatar-remove-button")?.setAttribute("disabled", "true");
|
|
16367
17214
|
renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", 0);
|
|
16368
17215
|
try {
|
|
16369
17216
|
const blob = await exportAvatarCropBlob();
|
|
17217
|
+
const maximumBytes = isCharacter ? characterAvatarImageMaxBytes : imageUploadLimits.avatarBytes;
|
|
17218
|
+
if (blob.size > maximumBytes) {
|
|
17219
|
+
throw new Error(`头像图片不能超过 ${formatUploadLimit(maximumBytes)}`);
|
|
17220
|
+
}
|
|
16370
17221
|
const body = new FormData();
|
|
16371
17222
|
body.append("file", blob, "avatar.png");
|
|
16372
|
-
const
|
|
16373
|
-
|
|
16374
|
-
|
|
17223
|
+
const progress = (value) => renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", value);
|
|
17224
|
+
const updated = isCharacter
|
|
17225
|
+
? await uploadWithProgress(`/api/characters/${encodeURIComponent(target.characterId)}/avatar`, { method: "PUT", body }, progress)
|
|
17226
|
+
: await uploadWithProgress("/api/auth/avatar", { method: "PUT", body }, progress);
|
|
17227
|
+
if (isCharacter) {
|
|
17228
|
+
if (characterEditorItem?.id === target.characterId) {
|
|
17229
|
+
characterEditorItem = updated;
|
|
17230
|
+
state.characters = upsertEntityCollection(state.characters, updated);
|
|
17231
|
+
renderCharacterAvatar(updated);
|
|
17232
|
+
}
|
|
17233
|
+
toast("角色头像已更新");
|
|
17234
|
+
} else {
|
|
17235
|
+
applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
|
|
17236
|
+
renderProfileAvatar();
|
|
17237
|
+
toast("头像已更新");
|
|
17238
|
+
}
|
|
16375
17239
|
avatarCropSession.uploading = false;
|
|
16376
17240
|
closeAvatarCropDialog();
|
|
16377
|
-
toast("头像已更新");
|
|
16378
17241
|
} catch (error) {
|
|
16379
17242
|
toast(error.message, "error");
|
|
16380
17243
|
} finally {
|
|
@@ -16383,6 +17246,8 @@ $("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
|
16383
17246
|
$("#avatar-crop-cancel").disabled = false;
|
|
16384
17247
|
$("#avatar-upload-button").disabled = false;
|
|
16385
17248
|
$("#avatar-remove-button").disabled = false;
|
|
17249
|
+
$("#character-avatar-upload-button")?.removeAttribute("disabled");
|
|
17250
|
+
$("#character-avatar-remove-button")?.removeAttribute("disabled");
|
|
16386
17251
|
renderAvatarUploadProgress("", 0, false);
|
|
16387
17252
|
}
|
|
16388
17253
|
});
|
|
@@ -16581,7 +17446,21 @@ $("#platform-usage-refresh").addEventListener("click", async () => {
|
|
|
16581
17446
|
button.disabled = false;
|
|
16582
17447
|
}
|
|
16583
17448
|
});
|
|
17449
|
+
$("#platform-usage-pricing-refresh").addEventListener("click", async () => {
|
|
17450
|
+
const button = $("#platform-usage-pricing-refresh");
|
|
17451
|
+
button.disabled = true;
|
|
17452
|
+
try {
|
|
17453
|
+
await api("/api/platform/ai/usage/pricing/refresh", { method: "POST", body: {}, skipOptimisticVersion: true });
|
|
17454
|
+
await renderPlatformTokenUsage();
|
|
17455
|
+
toast("模型价格已刷新");
|
|
17456
|
+
} catch (error) {
|
|
17457
|
+
toast(error.message, "error");
|
|
17458
|
+
} finally {
|
|
17459
|
+
button.disabled = false;
|
|
17460
|
+
}
|
|
17461
|
+
});
|
|
16584
17462
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
17463
|
+
$("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
|
|
16585
17464
|
$("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
|
|
16586
17465
|
$("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
|
|
16587
17466
|
$("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
|
|
@@ -16613,6 +17492,34 @@ $("#presence-button").addEventListener("click", () => {
|
|
|
16613
17492
|
});
|
|
16614
17493
|
$("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
|
|
16615
17494
|
$("#users-settings-return").addEventListener("click", () => returnToSettingsHub("#user-management-button", "#users-dialog").catch((error) => toast(error.message, "error")));
|
|
17495
|
+
$("#admin-ai-conversations-close").addEventListener("click", () => $("#admin-ai-conversations-dialog").close());
|
|
17496
|
+
$("#admin-ai-conversations-settings-return").addEventListener("click", () => returnToSettingsHub("#admin-ai-conversations-button", "#admin-ai-conversations-dialog").catch((error) => toast(error.message, "error")));
|
|
17497
|
+
$("#admin-ai-conversations-filter-toggle").addEventListener("click", () => {
|
|
17498
|
+
adminAiConversationFiltersOpen = !adminAiConversationFiltersOpen;
|
|
17499
|
+
$("#admin-ai-conversations-filter-panel").classList.toggle("hidden", !adminAiConversationFiltersOpen);
|
|
17500
|
+
$("#admin-ai-conversations-filter-toggle").setAttribute("aria-expanded", String(adminAiConversationFiltersOpen));
|
|
17501
|
+
if (adminAiConversationFiltersOpen) $("#admin-ai-conversations-query").focus();
|
|
17502
|
+
});
|
|
17503
|
+
$("#admin-ai-conversations-filter-panel").addEventListener("submit", (event) => {
|
|
17504
|
+
event.preventDefault();
|
|
17505
|
+
loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
|
|
17506
|
+
});
|
|
17507
|
+
$("#admin-ai-conversations-reset").addEventListener("click", () => {
|
|
17508
|
+
$("#admin-ai-conversations-filter-panel").reset();
|
|
17509
|
+
loadAdminAiConversationPage().catch((error) => toast(error.message, "error"));
|
|
17510
|
+
});
|
|
17511
|
+
$("#admin-ai-conversations-refresh").addEventListener("click", (event) => {
|
|
17512
|
+
const button = event.currentTarget;
|
|
17513
|
+
button.disabled = true;
|
|
17514
|
+
loadAdminAiConversationPage()
|
|
17515
|
+
.catch((error) => toast(error.message, "error"))
|
|
17516
|
+
.finally(() => { button.disabled = false; });
|
|
17517
|
+
});
|
|
17518
|
+
$("#admin-ai-conversations-load-more").addEventListener("click", () => {
|
|
17519
|
+
if (adminAiConversationNextPage !== null) {
|
|
17520
|
+
loadAdminAiConversationPage(adminAiConversationNextPage, true).catch((error) => toast(error.message, "error"));
|
|
17521
|
+
}
|
|
17522
|
+
});
|
|
16616
17523
|
$("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
16617
17524
|
$("#platform-ui-settings-return").addEventListener("click", () => returnToSettingsHub("#platform-ui-settings-button", "#platform-ui-settings-dialog").catch((error) => toast(error.message, "error")));
|
|
16618
17525
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
@@ -16725,7 +17632,7 @@ $("#member-permission-form").addEventListener("submit", async (event) => {
|
|
|
16725
17632
|
toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
|
|
16726
17633
|
} catch (error) { toast(error.message, "error"); }
|
|
16727
17634
|
});
|
|
16728
|
-
$("#platform-new-provider").addEventListener("click", () => openProviderDialog());
|
|
17635
|
+
$("#platform-new-provider").addEventListener("click", () => openProviderDialog(null, platformAiProtocolOptions));
|
|
16729
17636
|
$("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
16730
17637
|
$("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
|
|
16731
17638
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
@@ -16796,6 +17703,7 @@ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bi
|
|
|
16796
17703
|
$("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
|
|
16797
17704
|
$("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
|
|
16798
17705
|
$("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
|
|
17706
|
+
$("#ai-image-preview-close").addEventListener("click", () => $("#ai-image-preview-dialog").close());
|
|
16799
17707
|
document.querySelectorAll("[data-ai-tool-call-copy]").forEach((button) => {
|
|
16800
17708
|
setAiToolCallCopyButtonState(button, false);
|
|
16801
17709
|
button.addEventListener("click", () => void copyAiToolCallCode(button));
|
|
@@ -16813,6 +17721,37 @@ $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
|
16813
17721
|
$("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
16814
17722
|
$("#character-editor-fields").addEventListener("click", (event) => {
|
|
16815
17723
|
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
|
|
17724
|
+
const uploadButton = event.target.closest("#character-avatar-upload-button");
|
|
17725
|
+
if (uploadButton) {
|
|
17726
|
+
if (!characterEditorItem?.id) {
|
|
17727
|
+
toast("请先保存角色档案,再上传头像", "error");
|
|
17728
|
+
return;
|
|
17729
|
+
}
|
|
17730
|
+
configureAvatarCropTarget({ type: "character", characterId: String(characterEditorItem.id) });
|
|
17731
|
+
$("#avatar-file").click();
|
|
17732
|
+
return;
|
|
17733
|
+
}
|
|
17734
|
+
const removeButton = event.target.closest("#character-avatar-remove-button");
|
|
17735
|
+
if (!removeButton || removeButton.disabled || !characterEditorItem?.id) return;
|
|
17736
|
+
const currentItem = characterEditorItem;
|
|
17737
|
+
void (async () => {
|
|
17738
|
+
if (!(await confirmToast(`确定移除“${currentItem.name}”的头像吗?`, { title: "移除角色头像", confirmLabel: "确认移除" }))) return;
|
|
17739
|
+
removeButton.disabled = true;
|
|
17740
|
+
const upload = $("#character-avatar-upload-button");
|
|
17741
|
+
if (upload) upload.disabled = true;
|
|
17742
|
+
try {
|
|
17743
|
+
const updated = await api(`/api/characters/${encodeURIComponent(currentItem.id)}/avatar`, { method: "DELETE" });
|
|
17744
|
+
if (characterEditorItem?.id !== currentItem.id) return;
|
|
17745
|
+
characterEditorItem = updated;
|
|
17746
|
+
state.characters = upsertEntityCollection(state.characters, updated);
|
|
17747
|
+
renderCharacterAvatar(updated);
|
|
17748
|
+
deleteToast("角色头像已移除");
|
|
17749
|
+
} catch (error) {
|
|
17750
|
+
toast(error.message, "error");
|
|
17751
|
+
} finally {
|
|
17752
|
+
if (characterEditorItem?.id === currentItem.id) renderCharacterAvatar(characterEditorItem);
|
|
17753
|
+
}
|
|
17754
|
+
})();
|
|
16816
17755
|
});
|
|
16817
17756
|
$("#character-history-button").addEventListener("click", () => {
|
|
16818
17757
|
if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
|
|
@@ -16978,7 +17917,29 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
16978
17917
|
});
|
|
16979
17918
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
16980
17919
|
$("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
16981
|
-
|
|
17920
|
+
$("#ai-prompt").addEventListener("paste", (event) => {
|
|
17921
|
+
const files = clipboardImageFiles(event.clipboardData);
|
|
17922
|
+
if (files.length === 0) return;
|
|
17923
|
+
event.preventDefault();
|
|
17924
|
+
event.stopImmediatePropagation();
|
|
17925
|
+
if (!aiModelSupportsImageInput()) {
|
|
17926
|
+
toast("当前选择的模型不是多模态模型,无法粘贴图片附件", "error");
|
|
17927
|
+
return;
|
|
17928
|
+
}
|
|
17929
|
+
void addAiImageFiles(files);
|
|
17930
|
+
}, true);
|
|
17931
|
+
bindPlainTextPaste($("#ai-prompt"));
|
|
17932
|
+
$("#ai-attachment-button").addEventListener("click", () => {
|
|
17933
|
+
if (!aiModelSupportsImageInput()) {
|
|
17934
|
+
toast("当前选择的模型不是多模态模型,无法添加图片附件", "error");
|
|
17935
|
+
return;
|
|
17936
|
+
}
|
|
17937
|
+
$("#ai-attachment-input").click();
|
|
17938
|
+
});
|
|
17939
|
+
$("#ai-attachment-input").addEventListener("change", (event) => {
|
|
17940
|
+
const input = event.currentTarget;
|
|
17941
|
+
void addAiImageFiles(input.files).finally(() => { input.value = ""; });
|
|
17942
|
+
});
|
|
16982
17943
|
$("#ai-prompt").addEventListener("input", async () => {
|
|
16983
17944
|
updateAiMentionMenu();
|
|
16984
17945
|
setAiContextMeter(null);
|
|
@@ -17008,10 +17969,10 @@ $("#ai-model").addEventListener("change", (event) => {
|
|
|
17008
17969
|
return toast(aiConversationOptionLockedMessage);
|
|
17009
17970
|
}
|
|
17010
17971
|
setAiContextMeter(null);
|
|
17011
|
-
syncAiModelPicker();
|
|
17012
17972
|
setAiModelPickerVisible(false);
|
|
17973
|
+
syncAiModelPicker();
|
|
17013
17974
|
});
|
|
17014
|
-
$("#ai-model-picker").addEventListener("click", async (event) => {
|
|
17975
|
+
$("#ai-model-picker").addEventListener("click", async (event) => {
|
|
17015
17976
|
const button = event.currentTarget;
|
|
17016
17977
|
if (notifyAiConversationModelLocked(button)) return;
|
|
17017
17978
|
const willOpen = $("#ai-model-popover").classList.contains("hidden");
|
|
@@ -17023,7 +17984,22 @@ $("#ai-model-picker").addEventListener("click", async (event) => {
|
|
|
17023
17984
|
} catch (error) {
|
|
17024
17985
|
toast(`模型加载失败:${error.message}`, "error");
|
|
17025
17986
|
}
|
|
17026
|
-
if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled)
|
|
17987
|
+
if (!$("#ai-model-popover").classList.contains("hidden") && !$("#ai-model").disabled) focusAiModelOption();
|
|
17988
|
+
});
|
|
17989
|
+
$("#ai-model-options").addEventListener("keydown", (event) => {
|
|
17990
|
+
const options = [...event.currentTarget.querySelectorAll('[role="option"]')].filter((option) => !option.disabled);
|
|
17991
|
+
const currentIndex = options.indexOf(document.activeElement);
|
|
17992
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
17993
|
+
event.preventDefault();
|
|
17994
|
+
if (options.length === 0) return;
|
|
17995
|
+
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
17996
|
+
options[(currentIndex + direction + options.length) % options.length]?.focus();
|
|
17997
|
+
return;
|
|
17998
|
+
}
|
|
17999
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
18000
|
+
event.preventDefault();
|
|
18001
|
+
document.activeElement?.click();
|
|
18002
|
+
}
|
|
17027
18003
|
});
|
|
17028
18004
|
$("#ai-roleplay-character").addEventListener("focus", async () => {
|
|
17029
18005
|
try {
|
|
@@ -17339,7 +18315,6 @@ $("#ai-conversation-switcher-close").addEventListener("click", () => {
|
|
|
17339
18315
|
$("#ai-conversation-switcher").focus();
|
|
17340
18316
|
});
|
|
17341
18317
|
$("#ai-workspace-open").addEventListener("click", () => setAiConversationWorkspaceVisible(true));
|
|
17342
|
-
$("#ai-workspace-close").addEventListener("click", () => setAiConversationWorkspaceVisible(false));
|
|
17343
18318
|
$("#ai-switcher-history").addEventListener("click", async () => {
|
|
17344
18319
|
setAiConversationSwitcherVisible(false);
|
|
17345
18320
|
try {
|