@musnows/scriverse 0.7.2 → 0.7.3
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 +5 -0
- package/dist/ai.js +248 -250
- package/dist/ai.js.map +1 -1
- package/dist/app.js +184 -39
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +28 -12
- package/dist/attachment-storage.js.map +1 -1
- package/dist/backup-encryption.js +129 -0
- package/dist/backup-encryption.js.map +1 -0
- package/dist/collaboration-presence.js +200 -18
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +162 -3
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +1 -1
- package/dist/docx-export.js.map +1 -1
- package/dist/domain.js +18 -0
- package/dist/domain.js.map +1 -1
- package/dist/image-metadata.js +17 -2
- package/dist/image-metadata.js.map +1 -1
- package/dist/presence-store.js +160 -0
- package/dist/presence-store.js.map +1 -0
- package/dist/public/ai-context-meter.js +27 -0
- package/dist/public/ai-mentions.js +14 -0
- package/dist/public/app.js +1017 -291
- package/dist/public/background-task-center.js +1 -1
- package/dist/public/chapter-editor-virtualization.js +52 -0
- package/dist/public/index.html +43 -21
- package/dist/public/presence-client-id.d.ts +10 -0
- package/dist/public/presence-client-id.js +31 -0
- package/dist/public/s3-backup-ui.d.ts +10 -0
- package/dist/public/s3-backup-ui.js +29 -0
- package/dist/public/setting-filters.d.ts +16 -0
- package/dist/public/setting-filters.js +19 -0
- package/dist/public/styles.css +37 -7
- package/dist/s3-backup.js +197 -12
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +82 -20
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +36 -18
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +75 -17
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +35 -0
- package/dist/upload-limits.js.map +1 -0
- package/dist/user-auth.js +11 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/dist/public/app.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, renderRelationshipMindMap } from "/relationship-graph.js?v=20260809-galaxy-size-threshold-v1";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
|
|
4
|
-
import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=
|
|
4
|
+
import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
|
-
import {
|
|
6
|
+
import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
7
|
+
import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
|
|
7
8
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
8
9
|
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
|
|
9
10
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
@@ -11,7 +12,7 @@ import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.j
|
|
|
11
12
|
import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
|
|
12
13
|
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
|
|
13
14
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
|
|
14
|
-
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=
|
|
15
|
+
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
|
|
15
16
|
import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
|
|
16
17
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
17
18
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
@@ -48,16 +49,18 @@ import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global
|
|
|
48
49
|
import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
|
|
49
50
|
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
|
|
50
51
|
import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
|
|
52
|
+
import { filterSettings } from "/setting-filters.js?v=20260810-setting-inline-filters-v1";
|
|
51
53
|
import {
|
|
52
54
|
prepareTimelineEvents,
|
|
53
55
|
resolveTimelineActiveTrackId,
|
|
54
56
|
normalizeTimelineSortDirection,
|
|
55
57
|
timelineTrackColorIndex
|
|
56
58
|
} from "/timeline-view.js?v=20260801-timeline-sort-actions-v1";
|
|
57
|
-
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=
|
|
59
|
+
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260810-analysis-task-failed-v1";
|
|
58
60
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
59
61
|
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
60
|
-
import { collectS3BackupRunTransitions, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=
|
|
62
|
+
import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
|
|
63
|
+
import { createPresenceClientId, stagePresenceClientIdForRelogin } from "/presence-client-id.js?v=20260810-presence-relogin-v1";
|
|
61
64
|
import {
|
|
62
65
|
clampCropRect,
|
|
63
66
|
containImageRect,
|
|
@@ -153,16 +156,9 @@ const cachedWorkModules = new Set([
|
|
|
153
156
|
"ai-settings"
|
|
154
157
|
]);
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
160
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
161
|
-
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
162
|
-
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const presenceClientId = createPresenceClientId();
|
|
159
|
+
let presenceSessionStorage = null;
|
|
160
|
+
try { presenceSessionStorage = window.sessionStorage; } catch { /* 浏览器禁用存储时使用页面级新标识 */ }
|
|
161
|
+
const presenceClientId = createPresenceClientId(presenceSessionStorage);
|
|
166
162
|
const presenceHeartbeatInterval = 12_000;
|
|
167
163
|
const systemBootCheckInterval = 8_000;
|
|
168
164
|
let presenceParticipants = [];
|
|
@@ -198,6 +194,8 @@ let backgroundTaskCenterTaskSnapshots = new Map();
|
|
|
198
194
|
let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
199
195
|
let s3BackupTargets = [];
|
|
200
196
|
let s3BackupRuns = [];
|
|
197
|
+
let s3BackupEncryption = { enabled: false, keyConfiguredAt: null };
|
|
198
|
+
let s3BackupEncryptionConfirmationToken = null;
|
|
201
199
|
let editingS3BackupTargetId = null;
|
|
202
200
|
let s3BackupPollTimer = null;
|
|
203
201
|
let s3BackupPollRequest = 0;
|
|
@@ -248,18 +246,19 @@ function analysisTaskStatusLabel(status) {
|
|
|
248
246
|
review: "已完成",
|
|
249
247
|
completed: "已完成",
|
|
250
248
|
partial: "部分失败",
|
|
249
|
+
failed: "失败",
|
|
251
250
|
expired: "已过期",
|
|
252
251
|
cancelled: "已取消"
|
|
253
252
|
})[String(status)] ?? "未知状态";
|
|
254
253
|
}
|
|
255
254
|
|
|
256
255
|
function canRerunAnalysisTask(task) {
|
|
257
|
-
return ["review", "completed", "partial", "expired", "cancelled"].includes(String(task?.status ?? ""));
|
|
256
|
+
return ["review", "completed", "partial", "failed", "expired", "cancelled"].includes(String(task?.status ?? ""));
|
|
258
257
|
}
|
|
259
258
|
|
|
260
259
|
function normalizedAnalysisTaskStatus(status) {
|
|
261
260
|
const value = String(status);
|
|
262
|
-
return ["pending", "running", "review", "completed", "partial", "expired", "cancelled"].includes(value)
|
|
261
|
+
return ["pending", "running", "review", "completed", "partial", "failed", "expired", "cancelled"].includes(value)
|
|
263
262
|
? value
|
|
264
263
|
: "unknown";
|
|
265
264
|
}
|
|
@@ -382,7 +381,43 @@ function applyWorkAccessMode() {
|
|
|
382
381
|
|
|
383
382
|
const $ = (selector) => document.querySelector(selector);
|
|
384
383
|
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
|
385
|
-
const
|
|
384
|
+
const defaultImageUploadLimits = {
|
|
385
|
+
avatarBytes: 2 * 1024 * 1024,
|
|
386
|
+
coverBytes: 5 * 1024 * 1024,
|
|
387
|
+
attachmentBytes: 30 * 1024 * 1024
|
|
388
|
+
};
|
|
389
|
+
let imageUploadLimits = { ...defaultImageUploadLimits };
|
|
390
|
+
|
|
391
|
+
function formatUploadLimit(bytes) {
|
|
392
|
+
if (bytes < 1024 * 1024) return `${bytes} B`;
|
|
393
|
+
const megabytes = bytes / (1024 * 1024);
|
|
394
|
+
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2)} MB`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function applyImageUploadLimits(nextLimits) {
|
|
398
|
+
if (!nextLimits || typeof nextLimits !== "object") return;
|
|
399
|
+
const next = {
|
|
400
|
+
avatarBytes: Number(nextLimits.avatarBytes),
|
|
401
|
+
coverBytes: Number(nextLimits.coverBytes),
|
|
402
|
+
attachmentBytes: Number(nextLimits.attachmentBytes)
|
|
403
|
+
};
|
|
404
|
+
if (![next.avatarBytes, next.coverBytes, next.attachmentBytes].every((value) => Number.isSafeInteger(value) && value > 0)) return;
|
|
405
|
+
imageUploadLimits = next;
|
|
406
|
+
const avatarCopy = document.querySelector(".avatar-settings-copy small");
|
|
407
|
+
if (avatarCopy) avatarCopy.textContent = `支持 PNG、JPEG、WebP、GIF,文件不超过 ${formatUploadLimit(next.avatarBytes)}。选择后可框选正方形选区再裁剪上传。`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isGifImageFile(file) {
|
|
411
|
+
return file.type === "image/gif" || /\.gif$/iu.test(file.name ?? "");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function coverFileSizeMessage(file) {
|
|
415
|
+
return isGifImageFile(file) ? "封面不支持 GIF 图片" : `封面图片不能超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertAttachmentFileSize(file) {
|
|
419
|
+
if (file.size > imageUploadLimits.attachmentBytes) throw new Error(`图片附件不能超过 ${formatUploadLimit(imageUploadLimits.attachmentBytes)}`);
|
|
420
|
+
}
|
|
386
421
|
|
|
387
422
|
function setAiAssistantStatus(status) {
|
|
388
423
|
const failed = status === "error";
|
|
@@ -756,7 +791,7 @@ async function refreshPresence() {
|
|
|
756
791
|
presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
|
|
757
792
|
renderPresence();
|
|
758
793
|
const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
|
|
759
|
-
void
|
|
794
|
+
void handleCollaborativeChanges(recentChanges);
|
|
760
795
|
}
|
|
761
796
|
} catch {
|
|
762
797
|
if (state.work?.id === workId) renderPresence();
|
|
@@ -766,10 +801,9 @@ async function refreshPresence() {
|
|
|
766
801
|
return presenceParticipants;
|
|
767
802
|
}
|
|
768
803
|
|
|
769
|
-
async function
|
|
804
|
+
async function handleCollaborativeChanges(recentChanges) {
|
|
770
805
|
if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
|
|
771
806
|
const localKey = presencePageKey(presencePageForRoute());
|
|
772
|
-
if (!localKey.startsWith("entity-editor:relationship:")) return;
|
|
773
807
|
const selfId = state.user?.userId;
|
|
774
808
|
const incoming = recentChanges.filter((change) => (
|
|
775
809
|
change
|
|
@@ -788,17 +822,43 @@ async function handleRelationshipCollaborativeChanges(recentChanges) {
|
|
|
788
822
|
}
|
|
789
823
|
collaborativeChangePromptOpen = true;
|
|
790
824
|
try {
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
825
|
+
const changeLabel = latest.label || "当前页面";
|
|
826
|
+
const deleted = latest.action === "delete";
|
|
827
|
+
const targetLabel = deleted ? changeLabel.replace(/编辑$/u, "") : changeLabel;
|
|
828
|
+
const message = deleted
|
|
829
|
+
? `${latest.actorDisplayName || "协作者"}已删除当前${targetLabel}。请先确认本地没有需要保留的修改,${latest.pageDeleted ? "确认后返回对应列表。" : "再刷新页面查看最新状态。"}`
|
|
830
|
+
: `${latest.actorDisplayName || "协作者"}已在“${changeLabel}”保存新内容。请先确认本地没有需要保留的修改,再刷新页面继续查看。`;
|
|
831
|
+
const shouldReload = await confirmToast(message, {
|
|
832
|
+
title: deleted ? `${targetLabel}已删除` : `${changeLabel}已更新`,
|
|
833
|
+
confirmLabel: deleted && latest.pageDeleted ? (latest.pageKey.startsWith("editor:") ? "返回正文" : "返回列表") : "刷新页面",
|
|
794
834
|
cancelLabel: "稍后处理"
|
|
795
835
|
});
|
|
796
|
-
if (shouldReload)
|
|
836
|
+
if (shouldReload) reloadAfterCollaborativeChange(latest);
|
|
797
837
|
} finally {
|
|
798
838
|
collaborativeChangePromptOpen = false;
|
|
799
839
|
}
|
|
800
840
|
}
|
|
801
841
|
|
|
842
|
+
function reloadAfterCollaborativeChange(change) {
|
|
843
|
+
const workId = state.work?.id;
|
|
844
|
+
if (change?.action === "delete" && change.pageDeleted && workId) {
|
|
845
|
+
const entityModule = {
|
|
846
|
+
setting: "settings",
|
|
847
|
+
character: "characters",
|
|
848
|
+
race: "races",
|
|
849
|
+
organization: "organizations",
|
|
850
|
+
relationship: "relationships"
|
|
851
|
+
}[String(change.pageKey ?? "").split(":")[1] ?? ""];
|
|
852
|
+
const safeRoute = change.pageKey.startsWith("editor:")
|
|
853
|
+
? { view: "editor", workId }
|
|
854
|
+
: entityModule
|
|
855
|
+
? { view: "module", workId, module: entityModule }
|
|
856
|
+
: { view: "welcome", workId };
|
|
857
|
+
window.history.replaceState(null, "", serializePageRoute(safeRoute));
|
|
858
|
+
}
|
|
859
|
+
window.location.reload();
|
|
860
|
+
}
|
|
861
|
+
|
|
802
862
|
function schedulePresenceHeartbeat() {
|
|
803
863
|
if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
|
|
804
864
|
presenceHeartbeatQueued = setTimeout(() => {
|
|
@@ -942,6 +1002,9 @@ function setupPanelResize(handle, side) {
|
|
|
942
1002
|
}
|
|
943
1003
|
|
|
944
1004
|
let chapterLineNumberFrame = null;
|
|
1005
|
+
let chapterLineNumberTimer = null;
|
|
1006
|
+
let chapterLineLayout = null;
|
|
1007
|
+
let chapterLineVirtualWindow = null;
|
|
945
1008
|
let chapterLineSelection = null;
|
|
946
1009
|
let chapterLineDrag = null;
|
|
947
1010
|
let chapterWhitespaceVisible = true;
|
|
@@ -951,8 +1014,10 @@ let chapterSaveGuardInFlight = null;
|
|
|
951
1014
|
let lastSavedChapterSnapshot = null;
|
|
952
1015
|
let moduleNavExpanded = false;
|
|
953
1016
|
const chapterAutoSaveDelay = 800;
|
|
1017
|
+
const chapterLineInputRenderDelay = 32;
|
|
954
1018
|
let aiMentionMatch = null;
|
|
955
1019
|
let aiMentionRange = null;
|
|
1020
|
+
let aiMentionActiveIndex = -1;
|
|
956
1021
|
let settingsReturnContext = null;
|
|
957
1022
|
let entityEditorType = null;
|
|
958
1023
|
let entityEditorDirty = false;
|
|
@@ -976,8 +1041,23 @@ const moduleListPages = {
|
|
|
976
1041
|
};
|
|
977
1042
|
const characterFilters = { raceIds: [], organizationIds: [] };
|
|
978
1043
|
let characterFiltersPanelOpen = false;
|
|
1044
|
+
const settingFilters = { keyword: "", category: "", lockState: "all" };
|
|
1045
|
+
let settingFiltersPanelOpen = false;
|
|
979
1046
|
const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
|
|
980
1047
|
let relationshipFiltersPanelOpen = false;
|
|
1048
|
+
|
|
1049
|
+
function createEditorDirtyTracker(initialSnapshot = "") {
|
|
1050
|
+
let savedSnapshot = String(initialSnapshot);
|
|
1051
|
+
return {
|
|
1052
|
+
markSaved(snapshot) {
|
|
1053
|
+
savedSnapshot = String(snapshot);
|
|
1054
|
+
},
|
|
1055
|
+
isDirty(snapshot) {
|
|
1056
|
+
return String(snapshot) !== savedSnapshot;
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
981
1061
|
let settingEditorItem = null;
|
|
982
1062
|
let characterEditorItem = null;
|
|
983
1063
|
let knowledgeEditorItem = null;
|
|
@@ -996,6 +1076,8 @@ let characterSectionEditorDirty = false;
|
|
|
996
1076
|
let settingEditorVditor = null;
|
|
997
1077
|
let knowledgeSectionVditor = null;
|
|
998
1078
|
let characterSectionVditor = null;
|
|
1079
|
+
const settingEditorDirtyTracker = createEditorDirtyTracker();
|
|
1080
|
+
const characterSectionEditorDirtyTracker = createEditorDirtyTracker();
|
|
999
1081
|
let formDialogVditors = [];
|
|
1000
1082
|
let entityHistoryContext = null;
|
|
1001
1083
|
let moduleContentInteractionsBound = false;
|
|
@@ -1058,7 +1140,15 @@ async function confirmEntityEditorDiscard(message) {
|
|
|
1058
1140
|
});
|
|
1059
1141
|
}
|
|
1060
1142
|
|
|
1143
|
+
function isEntityEditorSaving() {
|
|
1144
|
+
return Boolean($("#entity-editor-view").querySelector('[aria-busy="true"]'));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1061
1147
|
async function closeEntityEditor({ force = false } = {}) {
|
|
1148
|
+
if (isEntityEditorSaving()) {
|
|
1149
|
+
toast("正在保存,请稍候");
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1062
1152
|
if (!$("#character-section-editor-view").classList.contains("hidden")) return closeCharacterSectionEditor({ force });
|
|
1063
1153
|
if (!$("#knowledge-section-editor-view").classList.contains("hidden")) return closeKnowledgeSectionEditor({ force });
|
|
1064
1154
|
if (!force && !(await confirmEntityEditorDiscard())) return false;
|
|
@@ -1106,6 +1196,13 @@ function syncChapterLineNumberScroll() {
|
|
|
1106
1196
|
whitespace.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
|
|
1107
1197
|
whitespace.dataset.scrollTop = String(input.scrollTop);
|
|
1108
1198
|
}
|
|
1199
|
+
if (!chapterLineVirtualWindow) return;
|
|
1200
|
+
const buffer = input.clientHeight * 0.35;
|
|
1201
|
+
const viewportBottom = input.scrollTop + input.clientHeight;
|
|
1202
|
+
const needsPreviousLines = chapterLineVirtualWindow.start > 0 && input.scrollTop < chapterLineVirtualWindow.top + buffer;
|
|
1203
|
+
const needsNextLines = chapterLineVirtualWindow.end < chapterLineVirtualWindow.lineCount
|
|
1204
|
+
&& viewportBottom > chapterLineVirtualWindow.bottom - buffer;
|
|
1205
|
+
if (needsPreviousLines || needsNextLines) scheduleChapterLineNumbers();
|
|
1109
1206
|
}
|
|
1110
1207
|
|
|
1111
1208
|
function syncChapterWhitespaceControls() {
|
|
@@ -1121,7 +1218,7 @@ function toggleChapterWhitespaceVisibility() {
|
|
|
1121
1218
|
scheduleChapterLineNumbers();
|
|
1122
1219
|
}
|
|
1123
1220
|
|
|
1124
|
-
function renderChapterWhitespaceMarkers(input, style) {
|
|
1221
|
+
function renderChapterWhitespaceMarkers(input, style, layout, lineWindow, getLineBounds, totalHeight) {
|
|
1125
1222
|
const overlay = $("#chapter-whitespace-overlay");
|
|
1126
1223
|
const inner = $("#chapter-whitespace-inner");
|
|
1127
1224
|
syncChapterWhitespaceControls();
|
|
@@ -1129,10 +1226,17 @@ function renderChapterWhitespaceMarkers(input, style) {
|
|
|
1129
1226
|
overlay.classList.toggle("is-visible", chapterWhitespaceVisible);
|
|
1130
1227
|
if (!chapterWhitespaceVisible) {
|
|
1131
1228
|
inner.replaceChildren();
|
|
1229
|
+
delete inner.dataset.virtualStart;
|
|
1230
|
+
delete inner.dataset.virtualEnd;
|
|
1231
|
+
delete inner.dataset.renderedLineCount;
|
|
1132
1232
|
return;
|
|
1133
1233
|
}
|
|
1234
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
1235
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
1236
|
+
const firstLineTop = getLineBounds(lineWindow.start).top;
|
|
1134
1237
|
Object.assign(inner.style, {
|
|
1135
1238
|
width: `${input.clientWidth}px`,
|
|
1239
|
+
height: `${Math.max(input.clientHeight, totalHeight + paddingTop + paddingBottom)}px`,
|
|
1136
1240
|
fontFamily: style.fontFamily,
|
|
1137
1241
|
fontSize: style.fontSize,
|
|
1138
1242
|
fontWeight: style.fontWeight,
|
|
@@ -1141,11 +1245,14 @@ function renderChapterWhitespaceMarkers(input, style) {
|
|
|
1141
1245
|
letterSpacing: style.letterSpacing,
|
|
1142
1246
|
tabSize: style.tabSize,
|
|
1143
1247
|
padding: style.padding,
|
|
1248
|
+
paddingTop: `${paddingTop + firstLineTop}px`,
|
|
1249
|
+
whiteSpace: style.whiteSpace,
|
|
1144
1250
|
overflowWrap: style.overflowWrap,
|
|
1145
1251
|
wordBreak: style.wordBreak
|
|
1146
1252
|
});
|
|
1147
1253
|
const fragment = document.createDocumentFragment();
|
|
1148
|
-
|
|
1254
|
+
const visibleText = layout.lines.slice(lineWindow.start, lineWindow.end).join("\n");
|
|
1255
|
+
for (const token of tokenizeVisibleSpaces(visibleText)) {
|
|
1149
1256
|
if (token.type === "text") {
|
|
1150
1257
|
fragment.append(document.createTextNode(token.text));
|
|
1151
1258
|
continue;
|
|
@@ -1157,20 +1264,12 @@ function renderChapterWhitespaceMarkers(input, style) {
|
|
|
1157
1264
|
fragment.append(marker);
|
|
1158
1265
|
}
|
|
1159
1266
|
inner.replaceChildren(fragment);
|
|
1267
|
+
inner.dataset.virtualStart = String(lineWindow.start);
|
|
1268
|
+
inner.dataset.virtualEnd = String(lineWindow.end - 1);
|
|
1269
|
+
inner.dataset.renderedLineCount = String(lineWindow.end - lineWindow.start);
|
|
1160
1270
|
}
|
|
1161
1271
|
|
|
1162
|
-
function
|
|
1163
|
-
const input = $("#chapter-content");
|
|
1164
|
-
const inner = $("#chapter-line-numbers-inner");
|
|
1165
|
-
const measure = $("#chapter-line-measure");
|
|
1166
|
-
if (!input || !inner || !measure || input.clientWidth === 0) return;
|
|
1167
|
-
const style = getComputedStyle(input);
|
|
1168
|
-
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
1169
|
-
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
1170
|
-
const numberStyle = getComputedStyle($("#chapter-line-numbers"));
|
|
1171
|
-
const numberLineHeight = parseFloat(numberStyle.lineHeight) || parseFloat(numberStyle.fontSize) * 1.2;
|
|
1172
|
-
inner.style.top = `${calculateLineNumberTop(parseFloat(style.paddingTop), lineHeight, numberLineHeight)}px`;
|
|
1173
|
-
const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
|
|
1272
|
+
function prepareChapterLineLayout(input, measure, style, contentWidth) {
|
|
1174
1273
|
Object.assign(measure.style, {
|
|
1175
1274
|
width: `${contentWidth}px`,
|
|
1176
1275
|
fontFamily: style.fontFamily,
|
|
@@ -1179,18 +1278,110 @@ function renderChapterLineNumbers() {
|
|
|
1179
1278
|
fontStyle: style.fontStyle,
|
|
1180
1279
|
lineHeight: style.lineHeight,
|
|
1181
1280
|
letterSpacing: style.letterSpacing,
|
|
1182
|
-
tabSize: style.tabSize
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
const measureRows = lines.map((line) => {
|
|
1186
|
-
const row = document.createElement("div");
|
|
1187
|
-
row.textContent = line || "\u200b";
|
|
1188
|
-
return row;
|
|
1281
|
+
tabSize: style.tabSize,
|
|
1282
|
+
overflowWrap: style.overflowWrap,
|
|
1283
|
+
wordBreak: style.wordBreak
|
|
1189
1284
|
});
|
|
1190
|
-
|
|
1285
|
+
const value = input.value.replace(/\r\n?/gu, "\n");
|
|
1286
|
+
const styleKey = JSON.stringify([
|
|
1287
|
+
contentWidth,
|
|
1288
|
+
style.fontFamily,
|
|
1289
|
+
style.fontSize,
|
|
1290
|
+
style.fontWeight,
|
|
1291
|
+
style.fontStyle,
|
|
1292
|
+
style.lineHeight,
|
|
1293
|
+
style.letterSpacing,
|
|
1294
|
+
style.tabSize,
|
|
1295
|
+
style.overflowWrap,
|
|
1296
|
+
style.wordBreak
|
|
1297
|
+
]);
|
|
1298
|
+
if (!chapterLineLayout || chapterLineLayout.value !== value) {
|
|
1299
|
+
const mirror = buildChapterLineMirror(value);
|
|
1300
|
+
measure.textContent = mirror.text;
|
|
1301
|
+
chapterLineLayout = { ...mirror, value, styleKey, bounds: new Map() };
|
|
1302
|
+
} else if (chapterLineLayout.styleKey !== styleKey) {
|
|
1303
|
+
chapterLineLayout.styleKey = styleKey;
|
|
1304
|
+
chapterLineLayout.bounds.clear();
|
|
1305
|
+
}
|
|
1306
|
+
return chapterLineLayout;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight = null) {
|
|
1310
|
+
const textNode = measure.firstChild;
|
|
1191
1311
|
const measureRect = measure.getBoundingClientRect();
|
|
1312
|
+
const contentHeight = Number.isFinite(targetHeight) && targetHeight > 0 ? targetHeight : measureRect.height;
|
|
1313
|
+
const geometryScale = measureRect.height > 0 ? contentHeight / measureRect.height : 1;
|
|
1314
|
+
const geometryKey = `${measureRect.height}:${contentHeight}:${lineHeight}`;
|
|
1315
|
+
if (layout.geometryKey !== geometryKey) {
|
|
1316
|
+
layout.geometryKey = geometryKey;
|
|
1317
|
+
layout.bounds.clear();
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
measureHeight: contentHeight,
|
|
1321
|
+
getLineBounds(index) {
|
|
1322
|
+
const cached = layout.bounds.get(index);
|
|
1323
|
+
if (cached) return cached;
|
|
1324
|
+
const range = document.createRange();
|
|
1325
|
+
const start = layout.offsets[index];
|
|
1326
|
+
range.setStart(textNode, start);
|
|
1327
|
+
range.setEnd(textNode, start + layout.lines[index].length + 1);
|
|
1328
|
+
const rects = [...range.getClientRects()].filter((rect) => rect.height > 0);
|
|
1329
|
+
let top;
|
|
1330
|
+
let bottom;
|
|
1331
|
+
if (rects.length > 0) {
|
|
1332
|
+
const lineBoxes = rects.map((rect) => {
|
|
1333
|
+
const height = Math.max(lineHeight, rect.height);
|
|
1334
|
+
const leading = Math.max(0, (lineHeight - rect.height) / 2);
|
|
1335
|
+
const boxTop = (rect.top - measureRect.top - leading) * geometryScale;
|
|
1336
|
+
return { top: boxTop, bottom: boxTop + height * geometryScale };
|
|
1337
|
+
});
|
|
1338
|
+
top = Math.max(0, Math.min(...lineBoxes.map((box) => box.top)));
|
|
1339
|
+
bottom = Math.max(...lineBoxes.map((box) => box.bottom));
|
|
1340
|
+
} else {
|
|
1341
|
+
const scaledLineHeight = lineHeight * geometryScale;
|
|
1342
|
+
const availableHeight = Math.max(0, contentHeight - scaledLineHeight);
|
|
1343
|
+
top = layout.lines.length > 1 ? availableHeight * index / (layout.lines.length - 1) : 0;
|
|
1344
|
+
bottom = top + scaledLineHeight;
|
|
1345
|
+
}
|
|
1346
|
+
const bounds = { top, bottom: Math.max(top + lineHeight * geometryScale, bottom) };
|
|
1347
|
+
layout.bounds.set(index, bounds);
|
|
1348
|
+
return bounds;
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
|
|
1354
|
+
const input = $("#chapter-content");
|
|
1355
|
+
const inner = $("#chapter-line-numbers-inner");
|
|
1356
|
+
const measure = $("#chapter-line-measure");
|
|
1357
|
+
if (!input || !inner || !measure || input.clientWidth === 0) return;
|
|
1358
|
+
const style = getComputedStyle(input);
|
|
1359
|
+
const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
|
1360
|
+
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
|
|
1361
|
+
const numberStyle = getComputedStyle($("#chapter-line-numbers"));
|
|
1362
|
+
const numberLineHeight = parseFloat(numberStyle.lineHeight) || parseFloat(numberStyle.fontSize) * 1.2;
|
|
1363
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
1364
|
+
inner.style.top = `${calculateLineNumberTop(paddingTop, lineHeight, numberLineHeight)}px`;
|
|
1365
|
+
const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
|
|
1366
|
+
const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
|
|
1367
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
1368
|
+
const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
|
|
1369
|
+
const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
|
|
1370
|
+
const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
|
|
1371
|
+
if (Number.isInteger(targetLineIndex)) {
|
|
1372
|
+
const safeTarget = Math.max(0, Math.min(targetLineIndex, layout.lines.length - 1));
|
|
1373
|
+
input.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - input.clientHeight / 3);
|
|
1374
|
+
}
|
|
1375
|
+
const viewportTop = Math.max(0, input.scrollTop - paddingTop);
|
|
1376
|
+
const overscan = Math.max(input.clientHeight, lineHeight * 8);
|
|
1377
|
+
const lineWindow = findChapterLineWindow(
|
|
1378
|
+
layout.lines.length,
|
|
1379
|
+
getLineBounds,
|
|
1380
|
+
viewportTop - overscan,
|
|
1381
|
+
viewportTop + input.clientHeight + overscan
|
|
1382
|
+
);
|
|
1192
1383
|
const numbers = document.createDocumentFragment();
|
|
1193
|
-
|
|
1384
|
+
for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
|
|
1194
1385
|
const number = document.createElement("button");
|
|
1195
1386
|
number.type = "button";
|
|
1196
1387
|
number.className = "chapter-line-number";
|
|
@@ -1203,22 +1394,32 @@ function renderChapterLineNumbers() {
|
|
|
1203
1394
|
number.classList.add("is-line-selected");
|
|
1204
1395
|
number.setAttribute("aria-pressed", "true");
|
|
1205
1396
|
}
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
number.style.
|
|
1209
|
-
number.style.height = `${rowHeight}px`;
|
|
1397
|
+
const bounds = getLineBounds(index);
|
|
1398
|
+
number.style.top = `${bounds.top}px`;
|
|
1399
|
+
number.style.height = `${bounds.bottom - bounds.top}px`;
|
|
1210
1400
|
number.style.paddingTop = `${numberTextOffset}px`;
|
|
1211
1401
|
numbers.append(number);
|
|
1212
|
-
}
|
|
1402
|
+
}
|
|
1213
1403
|
inner.replaceChildren(numbers);
|
|
1214
|
-
inner.style.height = `${
|
|
1215
|
-
inner.dataset.lineCount = String(lines.length);
|
|
1216
|
-
|
|
1217
|
-
|
|
1404
|
+
inner.style.height = `${measureHeight}px`;
|
|
1405
|
+
inner.dataset.lineCount = String(layout.lines.length);
|
|
1406
|
+
inner.dataset.virtualStart = String(lineWindow.start);
|
|
1407
|
+
inner.dataset.virtualEnd = String(lineWindow.end - 1);
|
|
1408
|
+
inner.dataset.renderedLineCount = String(lineWindow.end - lineWindow.start);
|
|
1409
|
+
const firstBounds = getLineBounds(lineWindow.start);
|
|
1410
|
+
const lastBounds = getLineBounds(lineWindow.end - 1);
|
|
1411
|
+
chapterLineVirtualWindow = {
|
|
1412
|
+
start: lineWindow.start,
|
|
1413
|
+
end: lineWindow.end,
|
|
1414
|
+
lineCount: layout.lines.length,
|
|
1415
|
+
top: firstBounds.top + paddingTop,
|
|
1416
|
+
bottom: lastBounds.bottom + paddingTop
|
|
1417
|
+
};
|
|
1418
|
+
renderChapterWhitespaceMarkers(input, style, layout, lineWindow, getLineBounds, measureHeight);
|
|
1218
1419
|
syncChapterLineNumberScroll();
|
|
1219
1420
|
}
|
|
1220
1421
|
|
|
1221
|
-
function
|
|
1422
|
+
function requestChapterLineNumberFrame() {
|
|
1222
1423
|
if (chapterLineNumberFrame !== null) return;
|
|
1223
1424
|
chapterLineNumberFrame = requestAnimationFrame(() => {
|
|
1224
1425
|
chapterLineNumberFrame = null;
|
|
@@ -1226,6 +1427,23 @@ function scheduleChapterLineNumbers() {
|
|
|
1226
1427
|
});
|
|
1227
1428
|
}
|
|
1228
1429
|
|
|
1430
|
+
function scheduleChapterLineNumbers(delay = 0) {
|
|
1431
|
+
const wait = typeof delay === "number" && Number.isFinite(delay) ? Math.max(0, delay) : 0;
|
|
1432
|
+
if (wait === 0) {
|
|
1433
|
+
if (chapterLineNumberTimer !== null) {
|
|
1434
|
+
clearTimeout(chapterLineNumberTimer);
|
|
1435
|
+
chapterLineNumberTimer = null;
|
|
1436
|
+
}
|
|
1437
|
+
requestChapterLineNumberFrame();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (chapterLineNumberFrame !== null || chapterLineNumberTimer !== null) return;
|
|
1441
|
+
chapterLineNumberTimer = setTimeout(() => {
|
|
1442
|
+
chapterLineNumberTimer = null;
|
|
1443
|
+
requestChapterLineNumberFrame();
|
|
1444
|
+
}, wait);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1229
1447
|
function collapseChapterInputBlankLines(input) {
|
|
1230
1448
|
const value = input.value;
|
|
1231
1449
|
const normalized = collapseExcessBlankLines(value);
|
|
@@ -1242,10 +1460,10 @@ function collapseChapterInputBlankLines(input) {
|
|
|
1242
1460
|
function lineIndexAtPointer(clientY) {
|
|
1243
1461
|
const rows = [...$("#chapter-line-numbers-inner").querySelectorAll(".chapter-line-number")];
|
|
1244
1462
|
if (!rows.length) return 0;
|
|
1245
|
-
for (
|
|
1246
|
-
if (clientY <
|
|
1463
|
+
for (const row of rows) {
|
|
1464
|
+
if (clientY < row.getBoundingClientRect().bottom) return Number(row.dataset.lineIndex);
|
|
1247
1465
|
}
|
|
1248
|
-
return rows.length - 1;
|
|
1466
|
+
return Number(rows[rows.length - 1].dataset.lineIndex);
|
|
1249
1467
|
}
|
|
1250
1468
|
|
|
1251
1469
|
function paintChapterLineSelection(anchor, focus) {
|
|
@@ -1835,7 +2053,10 @@ async function ensureAiConversationsLoaded() {
|
|
|
1835
2053
|
async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
|
|
1836
2054
|
const parameters = new URLSearchParams({ page: "1", limit: "100" });
|
|
1837
2055
|
if (focusMessageId) parameters.set("messageId", String(focusMessageId));
|
|
1838
|
-
const conversation = await
|
|
2056
|
+
const [conversation] = await Promise.all([
|
|
2057
|
+
api(`/api/ai-conversations/${conversationId}?${parameters}`),
|
|
2058
|
+
ensureAiReferencesLoaded()
|
|
2059
|
+
]);
|
|
1839
2060
|
upsertAiConversationSummary(conversation);
|
|
1840
2061
|
state.aiConversationId = conversation.id;
|
|
1841
2062
|
state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
|
|
@@ -1958,7 +2179,6 @@ function applyAiRoleplayCharacter(character) {
|
|
|
1958
2179
|
renderAiRoleplayCharacterSelect();
|
|
1959
2180
|
syncAiTaskOptions();
|
|
1960
2181
|
renderAiQuickActions();
|
|
1961
|
-
resetAiContextMeter();
|
|
1962
2182
|
}
|
|
1963
2183
|
|
|
1964
2184
|
function refreshAiMessageRoleLabels() {
|
|
@@ -1976,6 +2196,7 @@ async function updateAiRoleplayCharacter(characterId) {
|
|
|
1976
2196
|
upsertAiConversationSummary(conversation);
|
|
1977
2197
|
applyAiConversationTaskType(conversation.taskType);
|
|
1978
2198
|
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
2199
|
+
resetAiContextMeter();
|
|
1979
2200
|
if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
|
|
1980
2201
|
else resetAiFeed();
|
|
1981
2202
|
toast(conversation.roleplayCharacter
|
|
@@ -2063,7 +2284,46 @@ function aiPromptTextBeforeCursor() {
|
|
|
2063
2284
|
function hideAiMentionMenu() {
|
|
2064
2285
|
aiMentionMatch = null;
|
|
2065
2286
|
aiMentionRange = null;
|
|
2066
|
-
|
|
2287
|
+
aiMentionActiveIndex = -1;
|
|
2288
|
+
const prompt = $("#ai-prompt");
|
|
2289
|
+
const menu = $("#ai-mention-menu");
|
|
2290
|
+
prompt.setAttribute("aria-expanded", "false");
|
|
2291
|
+
prompt.removeAttribute("aria-activedescendant");
|
|
2292
|
+
menu.querySelectorAll("[role=option]").forEach((option) => {
|
|
2293
|
+
option.classList.remove("is-active");
|
|
2294
|
+
option.setAttribute("aria-selected", "false");
|
|
2295
|
+
});
|
|
2296
|
+
menu.classList.add("hidden");
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function setAiMentionActiveOption(nextIndex) {
|
|
2300
|
+
const prompt = $("#ai-prompt");
|
|
2301
|
+
const options = [...$("#ai-mention-menu").querySelectorAll("[role=option]")];
|
|
2302
|
+
if (!options.length) {
|
|
2303
|
+
aiMentionActiveIndex = -1;
|
|
2304
|
+
prompt.removeAttribute("aria-activedescendant");
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
aiMentionActiveIndex = (nextIndex + options.length) % options.length;
|
|
2308
|
+
let activeOption = null;
|
|
2309
|
+
options.forEach((option, index) => {
|
|
2310
|
+
const active = index === aiMentionActiveIndex;
|
|
2311
|
+
option.classList.toggle("is-active", active);
|
|
2312
|
+
option.setAttribute("aria-selected", String(active));
|
|
2313
|
+
if (active) activeOption = option;
|
|
2314
|
+
});
|
|
2315
|
+
prompt.setAttribute("aria-activedescendant", activeOption.id);
|
|
2316
|
+
activeOption.scrollIntoView({ block: "nearest" });
|
|
2317
|
+
return activeOption;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function moveAiMentionActiveOption(direction) {
|
|
2321
|
+
const optionCount = $("#ai-mention-menu").querySelectorAll("[role=option]").length;
|
|
2322
|
+
if (!optionCount) return null;
|
|
2323
|
+
const nextIndex = aiMentionActiveIndex < 0
|
|
2324
|
+
? (direction > 0 ? 0 : optionCount - 1)
|
|
2325
|
+
: aiMentionActiveIndex + direction;
|
|
2326
|
+
return setAiMentionActiveOption(nextIndex);
|
|
2067
2327
|
}
|
|
2068
2328
|
|
|
2069
2329
|
function syncAiReferencesWithPrompt() {
|
|
@@ -2092,10 +2352,13 @@ function updateAiMentionMenu() {
|
|
|
2092
2352
|
}))) ?? [];
|
|
2093
2353
|
const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query)
|
|
2094
2354
|
.filter((item) => item.kind !== "context-settings" || $("#ai-task").value !== "roleplay");
|
|
2355
|
+
aiMentionActiveIndex = -1;
|
|
2356
|
+
prompt.removeAttribute("aria-activedescendant");
|
|
2095
2357
|
menu.innerHTML = options.length
|
|
2096
|
-
? options.map((item) => `<button class="ai-mention-option" type="button" role="option" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
|
|
2358
|
+
? options.map((item, index) => `<button id="ai-mention-option-${index}" class="ai-mention-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
|
|
2097
2359
|
: '<p class="ai-mention-empty">没有匹配的角色、设定、章节或上下文能力</p>';
|
|
2098
2360
|
menu.classList.remove("hidden");
|
|
2361
|
+
prompt.setAttribute("aria-expanded", "true");
|
|
2099
2362
|
}
|
|
2100
2363
|
|
|
2101
2364
|
function selectAiMention(button) {
|
|
@@ -2316,7 +2579,7 @@ function clearChapterLineSelection() {
|
|
|
2316
2579
|
}
|
|
2317
2580
|
|
|
2318
2581
|
const typographyStorageKey = "ai-novel-typography-v1";
|
|
2319
|
-
const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 14, aiFontSize:
|
|
2582
|
+
const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 14, aiFontSize: 12, density: "balanced" });
|
|
2320
2583
|
const cjkFontStacks = {
|
|
2321
2584
|
system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
|
|
2322
2585
|
pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
|
|
@@ -2486,6 +2749,8 @@ function optimisticVersionForPath(path) {
|
|
|
2486
2749
|
if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
|
|
2487
2750
|
if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
|
|
2488
2751
|
if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
|
|
2752
|
+
if (resourceMatch[1] === "settings" && settingEditorItem?.id === resourceId) return settingEditorItem.versionNo;
|
|
2753
|
+
if (["races", "organizations"].includes(resourceMatch[1]) && knowledgeEditorItem?.id === resourceId) return knowledgeEditorItem.versionNo;
|
|
2489
2754
|
return find(collection, resourceId);
|
|
2490
2755
|
}
|
|
2491
2756
|
const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
|
|
@@ -2558,6 +2823,7 @@ async function api(path, options = {}) {
|
|
|
2558
2823
|
}
|
|
2559
2824
|
const payload = await response.json();
|
|
2560
2825
|
if (path === "/api/health") {
|
|
2826
|
+
applyImageUploadLimits(payload.data?.uploadLimits);
|
|
2561
2827
|
updateSystemHealth({
|
|
2562
2828
|
status: payload.data?.status === "ok" ? "ready" : "degraded",
|
|
2563
2829
|
version: payload.data?.version
|
|
@@ -3063,6 +3329,51 @@ function toast(message, type = "info") {
|
|
|
3063
3329
|
}, 3600);
|
|
3064
3330
|
}
|
|
3065
3331
|
|
|
3332
|
+
async function runEntityEditorSave({ busyTarget, button, prepare, save }) {
|
|
3333
|
+
if (button.disabled) return null;
|
|
3334
|
+
const initialLabel = button.textContent;
|
|
3335
|
+
const initialInert = busyTarget.inert;
|
|
3336
|
+
const activeElement = document.activeElement;
|
|
3337
|
+
let focusTarget = busyTarget.contains(activeElement) ? activeElement : null;
|
|
3338
|
+
let finalLabel = initialLabel;
|
|
3339
|
+
button.disabled = true;
|
|
3340
|
+
try {
|
|
3341
|
+
// prepare 会在首次 await 前同步采集快照;同一事件循环内随即锁定,避免新的输入插入。
|
|
3342
|
+
const preparation = prepare();
|
|
3343
|
+
if (busyTarget.contains(document.activeElement)) focusTarget = document.activeElement;
|
|
3344
|
+
busyTarget.inert = true;
|
|
3345
|
+
busyTarget.setAttribute("aria-busy", "true");
|
|
3346
|
+
const prepared = await preparation;
|
|
3347
|
+
if (prepared === null) return null;
|
|
3348
|
+
button.textContent = "保存中…";
|
|
3349
|
+
toast("正在保存…");
|
|
3350
|
+
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
3351
|
+
const result = await save(prepared);
|
|
3352
|
+
finalLabel = result?.buttonLabel ?? finalLabel;
|
|
3353
|
+
toast(result?.message ?? "保存成功");
|
|
3354
|
+
return result;
|
|
3355
|
+
} catch (error) {
|
|
3356
|
+
toast(error instanceof Error ? error.message : "保存失败,请重试", "error");
|
|
3357
|
+
return null;
|
|
3358
|
+
} finally {
|
|
3359
|
+
busyTarget.removeAttribute("aria-busy");
|
|
3360
|
+
busyTarget.inert = initialInert;
|
|
3361
|
+
button.disabled = false;
|
|
3362
|
+
button.textContent = finalLabel;
|
|
3363
|
+
if (!busyTarget.contains(document.activeElement) && focusTarget?.isConnected) {
|
|
3364
|
+
focusTarget.focus({ preventScroll: true });
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function upsertEntityCollection(items, saved) {
|
|
3370
|
+
const nextItems = Array.isArray(items) ? [...items] : [];
|
|
3371
|
+
const index = nextItems.findIndex((item) => item.id === saved.id);
|
|
3372
|
+
if (index >= 0) nextItems[index] = saved;
|
|
3373
|
+
else nextItems.push(saved);
|
|
3374
|
+
return nextItems;
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3066
3377
|
function persistentToast(message, type = "info") {
|
|
3067
3378
|
const region = $("#toast-region");
|
|
3068
3379
|
const element = document.createElement("div");
|
|
@@ -3079,7 +3390,26 @@ function persistentToast(message, type = "info") {
|
|
|
3079
3390
|
};
|
|
3080
3391
|
}
|
|
3081
3392
|
|
|
3393
|
+
function restoreToastFocus(previousFocus) {
|
|
3394
|
+
if (
|
|
3395
|
+
previousFocus instanceof HTMLElement
|
|
3396
|
+
&& previousFocus.isConnected
|
|
3397
|
+
&& !previousFocus.matches(":disabled")
|
|
3398
|
+
&& previousFocus.getClientRects().length > 0
|
|
3399
|
+
) {
|
|
3400
|
+
previousFocus.focus({ preventScroll: true });
|
|
3401
|
+
if (document.activeElement === previousFocus) return;
|
|
3402
|
+
}
|
|
3403
|
+
const body = document.body;
|
|
3404
|
+
const previousTabIndex = body.getAttribute("tabindex");
|
|
3405
|
+
body.setAttribute("tabindex", "-1");
|
|
3406
|
+
body.focus({ preventScroll: true });
|
|
3407
|
+
if (previousTabIndex === null) body.removeAttribute("tabindex");
|
|
3408
|
+
else body.setAttribute("tabindex", previousTabIndex);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3082
3411
|
function confirmToast(message, { title = "请再次确认", confirmLabel = "确认", cancelLabel = "取消" } = {}) {
|
|
3412
|
+
const previousFocus = document.activeElement;
|
|
3083
3413
|
const region = $("#toast-region");
|
|
3084
3414
|
const element = document.createElement("section");
|
|
3085
3415
|
element.className = "toast toast-confirmation";
|
|
@@ -3108,6 +3438,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
|
|
|
3108
3438
|
const finish = (confirmed) => {
|
|
3109
3439
|
element.remove();
|
|
3110
3440
|
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
|
|
3441
|
+
restoreToastFocus(previousFocus);
|
|
3111
3442
|
resolve(confirmed);
|
|
3112
3443
|
};
|
|
3113
3444
|
cancel.addEventListener("click", () => finish(false), { once: true });
|
|
@@ -3911,6 +4242,17 @@ function s3BackupTargetFailed(target) {
|
|
|
3911
4242
|
return Number.isFinite(failedAt) && (!Number.isFinite(succeededAt) || failedAt > succeededAt);
|
|
3912
4243
|
}
|
|
3913
4244
|
|
|
4245
|
+
function renderS3BackupEncryption() {
|
|
4246
|
+
const presentation = s3BackupEncryptionPresentation(s3BackupEncryption);
|
|
4247
|
+
const toggle = $("#s3-backup-encryption-toggle");
|
|
4248
|
+
toggle.checked = s3BackupEncryption.enabled;
|
|
4249
|
+
$("#s3-backup-encryption-description").textContent = presentation.description;
|
|
4250
|
+
const status = $("#s3-backup-encryption-status");
|
|
4251
|
+
status.className = `s3-backup-status ${presentation.statusClass}`.trim();
|
|
4252
|
+
status.textContent = presentation.label;
|
|
4253
|
+
$("#s3-backup-encryption-warning").classList.toggle("hidden", !presentation.showPrivateBucketWarning);
|
|
4254
|
+
}
|
|
4255
|
+
|
|
3914
4256
|
function renderS3BackupTargets() {
|
|
3915
4257
|
const enabledCount = s3BackupTargets.filter((target) => target.enabled).length;
|
|
3916
4258
|
$("#s3-backup-summary").textContent = s3BackupTargets.length
|
|
@@ -3983,12 +4325,15 @@ async function loadS3BackupData({ loading = false } = {}) {
|
|
|
3983
4325
|
$("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">正在读取备份目标……</p>';
|
|
3984
4326
|
$("#s3-backup-runs").innerHTML = '<p class="s3-backup-empty">正在读取运行记录……</p>';
|
|
3985
4327
|
}
|
|
3986
|
-
const [targets, runs] = await Promise.all([
|
|
4328
|
+
const [targets, runs, encryption] = await Promise.all([
|
|
3987
4329
|
api("/api/platform/backups/targets"),
|
|
3988
|
-
api("/api/platform/backups/runs?limit=30")
|
|
4330
|
+
api("/api/platform/backups/runs?limit=30"),
|
|
4331
|
+
api("/api/platform/backups/encryption")
|
|
3989
4332
|
]);
|
|
3990
4333
|
s3BackupTargets = targets;
|
|
3991
4334
|
s3BackupRuns = runs.items;
|
|
4335
|
+
s3BackupEncryption = encryption;
|
|
4336
|
+
renderS3BackupEncryption();
|
|
3992
4337
|
renderS3BackupTargets();
|
|
3993
4338
|
renderS3BackupRuns();
|
|
3994
4339
|
}
|
|
@@ -3999,12 +4344,106 @@ async function openS3BackupDialog() {
|
|
|
3999
4344
|
if (!dialog.open) dialog.showModal();
|
|
4000
4345
|
try {
|
|
4001
4346
|
await loadS3BackupData({ loading: true });
|
|
4347
|
+
if (!s3BackupEncryption.enabled) {
|
|
4348
|
+
toast("未开启备份加密,请将 S3 桶设置为私有桶,避免数据泄露", "warning");
|
|
4349
|
+
}
|
|
4002
4350
|
} catch (error) {
|
|
4003
4351
|
$("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">备份配置加载失败,请稍后刷新。</p>';
|
|
4004
4352
|
toast(error.message, "error");
|
|
4005
4353
|
}
|
|
4006
4354
|
}
|
|
4007
4355
|
|
|
4356
|
+
function showS3BackupEncryptionKey(key, confirmationToken) {
|
|
4357
|
+
s3BackupEncryptionConfirmationToken = confirmationToken;
|
|
4358
|
+
$("#s3-backup-key-value").value = key;
|
|
4359
|
+
const dialog = $("#s3-backup-key-dialog");
|
|
4360
|
+
if (!dialog.open) dialog.showModal();
|
|
4361
|
+
queueMicrotask(() => $("#s3-backup-key-copy").focus());
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
async function changeS3BackupEncryption(event) {
|
|
4365
|
+
const toggle = event.currentTarget;
|
|
4366
|
+
const enabled = toggle.checked;
|
|
4367
|
+
const restoreToggleFocus = document.activeElement === toggle;
|
|
4368
|
+
toggle.disabled = true;
|
|
4369
|
+
try {
|
|
4370
|
+
const result = await api("/api/platform/backups/encryption", {
|
|
4371
|
+
method: "POST",
|
|
4372
|
+
body: { enabled }
|
|
4373
|
+
});
|
|
4374
|
+
s3BackupEncryption = {
|
|
4375
|
+
enabled: result.enabled === true,
|
|
4376
|
+
keyConfiguredAt: result.keyConfiguredAt ?? s3BackupEncryption.keyConfiguredAt
|
|
4377
|
+
};
|
|
4378
|
+
renderS3BackupEncryption();
|
|
4379
|
+
if (typeof result.key === "string" && result.key && typeof result.confirmationToken === "string" && result.confirmationToken) {
|
|
4380
|
+
showS3BackupEncryptionKey(result.key, result.confirmationToken);
|
|
4381
|
+
} else {
|
|
4382
|
+
toast(enabled ? "S3 备份加密已开启" : "S3 备份加密已关闭;历史加密备份仍可使用原密钥恢复");
|
|
4383
|
+
}
|
|
4384
|
+
} catch (error) {
|
|
4385
|
+
renderS3BackupEncryption();
|
|
4386
|
+
toast(error.message, "error");
|
|
4387
|
+
} finally {
|
|
4388
|
+
toggle.disabled = false;
|
|
4389
|
+
if (restoreToggleFocus && !$("#s3-backup-key-dialog").open) toggle.focus();
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
async function copyS3BackupEncryptionKey() {
|
|
4394
|
+
const field = $("#s3-backup-key-value");
|
|
4395
|
+
try {
|
|
4396
|
+
await navigator.clipboard.writeText(field.value);
|
|
4397
|
+
toast("备份加密密钥已复制");
|
|
4398
|
+
} catch {
|
|
4399
|
+
field.focus();
|
|
4400
|
+
field.select();
|
|
4401
|
+
toast("无法自动复制,请手动复制密钥", "error");
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
|
|
4405
|
+
function downloadS3BackupEncryptionKey() {
|
|
4406
|
+
const value = $("#s3-backup-key-value").value;
|
|
4407
|
+
const url = URL.createObjectURL(new Blob([s3BackupEncryptionKeyFile(value)], { type: "text/plain;charset=utf-8" }));
|
|
4408
|
+
const anchor = document.createElement("a");
|
|
4409
|
+
anchor.href = url;
|
|
4410
|
+
anchor.download = "scriverse-s3-backup-key.txt";
|
|
4411
|
+
document.body.append(anchor);
|
|
4412
|
+
anchor.click();
|
|
4413
|
+
anchor.remove();
|
|
4414
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
4415
|
+
toast("备份加密密钥文件已下载");
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
async function confirmS3BackupEncryptionKeySaved() {
|
|
4419
|
+
const confirmationToken = s3BackupEncryptionConfirmationToken;
|
|
4420
|
+
if (!confirmationToken) return toast("备份加密确认已失效,请刷新后重新开启", "error");
|
|
4421
|
+
const button = $("#s3-backup-key-confirm");
|
|
4422
|
+
button.disabled = true;
|
|
4423
|
+
button.textContent = "正在开启";
|
|
4424
|
+
try {
|
|
4425
|
+
const result = await api("/api/platform/backups/encryption/confirm", {
|
|
4426
|
+
method: "POST",
|
|
4427
|
+
body: { confirmationToken }
|
|
4428
|
+
});
|
|
4429
|
+
s3BackupEncryption = {
|
|
4430
|
+
enabled: result.enabled === true,
|
|
4431
|
+
keyConfiguredAt: result.keyConfiguredAt ?? null
|
|
4432
|
+
};
|
|
4433
|
+
renderS3BackupEncryption();
|
|
4434
|
+
s3BackupEncryptionConfirmationToken = null;
|
|
4435
|
+
$("#s3-backup-key-value").value = "";
|
|
4436
|
+
$("#s3-backup-key-dialog").close();
|
|
4437
|
+
$("#s3-backup-encryption-toggle").focus();
|
|
4438
|
+
toast("S3 备份加密已开启,请妥善保管密钥");
|
|
4439
|
+
} catch (error) {
|
|
4440
|
+
toast(error.message, "error");
|
|
4441
|
+
} finally {
|
|
4442
|
+
button.disabled = false;
|
|
4443
|
+
button.textContent = "我已保存";
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4008
4447
|
function updateS3BackupRootPreview() {
|
|
4009
4448
|
$("#s3-backup-root-preview").textContent = s3BackupRootPrefix($("#s3-backup-base-path").value);
|
|
4010
4449
|
}
|
|
@@ -4476,10 +4915,8 @@ function revealChapterSearchLines(startLine, endLine) {
|
|
|
4476
4915
|
input.setSelectionRange(selection.startOffset, selection.startOffset + selection.text.length);
|
|
4477
4916
|
scheduleChapterLineNumbers();
|
|
4478
4917
|
requestAnimationFrame(() => {
|
|
4918
|
+
renderChapterLineNumbers({ targetLineIndex: selection.safeStart });
|
|
4479
4919
|
paintChapterLineSelection(selection.safeStart, selection.safeEnd);
|
|
4480
|
-
const row = $("#chapter-line-numbers-inner").querySelector(`[data-line-index="${selection.safeStart}"]`);
|
|
4481
|
-
if (row) input.scrollTop = Math.max(0, row.offsetTop - input.clientHeight / 3);
|
|
4482
|
-
syncChapterLineNumberScroll();
|
|
4483
4920
|
});
|
|
4484
4921
|
}
|
|
4485
4922
|
|
|
@@ -4685,6 +5122,10 @@ function resetWorkScopedUiCaches() {
|
|
|
4685
5122
|
draftTypeFilter = "all";
|
|
4686
5123
|
draftBindingFilters = [];
|
|
4687
5124
|
draftFiltersPanelOpen = false;
|
|
5125
|
+
settingFilters.keyword = "";
|
|
5126
|
+
settingFilters.category = "";
|
|
5127
|
+
settingFilters.lockState = "all";
|
|
5128
|
+
settingFiltersPanelOpen = false;
|
|
4688
5129
|
Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
|
|
4689
5130
|
relationshipFilters.fromCharacterIds = [];
|
|
4690
5131
|
relationshipFilters.toCharacterIds = [];
|
|
@@ -5579,6 +6020,17 @@ function bindRaceTreeExpandToggle() {
|
|
|
5579
6020
|
});
|
|
5580
6021
|
}
|
|
5581
6022
|
|
|
6023
|
+
function mountSettingFilterToggle() {
|
|
6024
|
+
$("#module-header-actions").querySelector('[data-module-header-action="setting-filter-toggle"]')?.remove();
|
|
6025
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="setting-filter-toggle" aria-label="筛选设定" aria-controls="setting-filter-panel" aria-expanded="${settingFiltersPanelOpen}" title="筛选设定"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
|
|
6026
|
+
const toggle = $("#module-header-actions").querySelector('[data-module-header-action="setting-filter-toggle"]');
|
|
6027
|
+
toggle?.addEventListener("click", () => {
|
|
6028
|
+
settingFiltersPanelOpen = !settingFiltersPanelOpen;
|
|
6029
|
+
$("#setting-filter-panel")?.classList.toggle("hidden", !settingFiltersPanelOpen);
|
|
6030
|
+
toggle.setAttribute("aria-expanded", String(settingFiltersPanelOpen));
|
|
6031
|
+
});
|
|
6032
|
+
}
|
|
6033
|
+
|
|
5582
6034
|
function mountCharacterFilterToggle() {
|
|
5583
6035
|
$("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
|
|
5584
6036
|
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="character-filter-toggle" aria-label="筛选角色" aria-controls="character-filter-panel" aria-expanded="${characterFiltersPanelOpen}" title="筛选角色"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
|
|
@@ -5936,19 +6388,76 @@ function entityLifecycleBadge(active, label) {
|
|
|
5936
6388
|
async function renderSettings(page = moduleListPages.settings) {
|
|
5937
6389
|
const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
|
|
5938
6390
|
state.settings = records;
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
$("#module-content").
|
|
5951
|
-
|
|
6391
|
+
const categories = [...new Set([
|
|
6392
|
+
...records.map((item) => String(item.category ?? "").trim()).filter(Boolean),
|
|
6393
|
+
...(settingFilters.category ? [settingFilters.category] : [])
|
|
6394
|
+
])].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
|
6395
|
+
const hasSettingFilters = () => Boolean(settingFilters.keyword.trim() || settingFilters.category || settingFilters.lockState !== "all");
|
|
6396
|
+
const filterToolbar = `<section id="setting-filter-panel" class="character-filter-toolbar setting-filter-toolbar${settingFiltersPanelOpen ? "" : " hidden"}" aria-label="设定筛选">
|
|
6397
|
+
<label class="setting-filter-field" for="setting-keyword-filter"><span>按关键词搜索</span><input id="setting-keyword-filter" type="search" value="${esc(settingFilters.keyword)}" placeholder="搜索标题或内容" aria-label="按关键词搜索设定" autocomplete="off"></label>
|
|
6398
|
+
<label class="setting-filter-field" for="setting-category-filter"><span>按分类筛选</span><select id="setting-category-filter" aria-label="按分类筛选设定"><option value="">全部分类</option>${categories.map((category) => `<option value="${esc(category)}" ${settingFilters.category === category ? "selected" : ""}>${esc(category)}</option>`).join("")}</select></label>
|
|
6399
|
+
<label class="setting-filter-field" for="setting-lock-filter"><span>按锁定状态筛选</span><select id="setting-lock-filter" aria-label="按锁定状态筛选设定"><option value="all" ${settingFilters.lockState === "all" ? "selected" : ""}>全部锁定状态</option><option value="locked" ${settingFilters.lockState === "locked" ? "selected" : ""}>仅已锁定</option><option value="unlocked" ${settingFilters.lockState === "unlocked" ? "selected" : ""}>仅未锁定</option></select></label>
|
|
6400
|
+
<div class="character-filter-toolbar-actions"><span id="setting-filter-result-count" class="character-filter-result-count${hasSettingFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-setting-filters" class="ghost-button" type="button" ${hasSettingFilters() ? "" : "disabled"}>重置筛选</button></div>
|
|
6401
|
+
</section><div id="setting-filter-results"></div>`;
|
|
6402
|
+
$("#module-content").innerHTML = filterToolbar;
|
|
6403
|
+
mountSettingFilterToggle();
|
|
6404
|
+
|
|
6405
|
+
const renderSettingResults = (requestedPage = moduleListPages.settings) => {
|
|
6406
|
+
const filteredRecords = filterSettings(state.settings, settingFilters);
|
|
6407
|
+
const pageResult = paginateModuleItems(filteredRecords, requestedPage, "settings");
|
|
6408
|
+
moduleListPages.settings = pageResult.page;
|
|
6409
|
+
mountModuleCount(filteredRecords.length);
|
|
6410
|
+
const layout = readModuleLayout();
|
|
6411
|
+
if (filteredRecords.length) mountModuleLayoutToggle(layout, "设定列表样式");
|
|
6412
|
+
else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
6413
|
+
$("#setting-filter-results").innerHTML = filteredRecords.length
|
|
6414
|
+
? `${layout === "rows" ? renderSettingRows(pageResult.items) : renderSettingCards(pageResult.items)}${renderModulePagination(pageResult, "settings", "设定库")}`
|
|
6415
|
+
: records.length
|
|
6416
|
+
? emptyModule("没有符合筛选条件的设定", "可以调整关键词、分类、锁定状态或重置筛选。")
|
|
6417
|
+
: emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
|
|
6418
|
+
const filtersActive = hasSettingFilters();
|
|
6419
|
+
const resultCount = $("#setting-filter-result-count");
|
|
6420
|
+
resultCount.textContent = filtersActive ? `筛选后剩余 ${filteredRecords.length} 条设定` : "";
|
|
6421
|
+
resultCount.classList.toggle("hidden", !filtersActive);
|
|
6422
|
+
$("#clear-setting-filters").disabled = !filtersActive;
|
|
6423
|
+
bindModuleLayoutToggle(() => renderSettingResults(pageResult.page));
|
|
6424
|
+
bindModulePagination("settings", renderSettingResults);
|
|
6425
|
+
const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
|
|
6426
|
+
$("#setting-filter-results").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
|
|
6427
|
+
bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
|
|
6428
|
+
};
|
|
6429
|
+
|
|
6430
|
+
$("#setting-keyword-filter").addEventListener("input", (event) => {
|
|
6431
|
+
settingFilters.keyword = event.currentTarget.value;
|
|
6432
|
+
settingFiltersPanelOpen = true;
|
|
6433
|
+
moduleListPages.settings = 1;
|
|
6434
|
+
renderSettingResults(1);
|
|
6435
|
+
});
|
|
6436
|
+
$("#setting-category-filter").addEventListener("change", (event) => {
|
|
6437
|
+
settingFilters.category = event.currentTarget.value;
|
|
6438
|
+
settingFiltersPanelOpen = true;
|
|
6439
|
+
moduleListPages.settings = 1;
|
|
6440
|
+
renderSettingResults(1);
|
|
6441
|
+
});
|
|
6442
|
+
$("#setting-lock-filter").addEventListener("change", (event) => {
|
|
6443
|
+
settingFilters.lockState = ["locked", "unlocked"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
|
|
6444
|
+
settingFiltersPanelOpen = true;
|
|
6445
|
+
moduleListPages.settings = 1;
|
|
6446
|
+
renderSettingResults(1);
|
|
6447
|
+
});
|
|
6448
|
+
$("#clear-setting-filters").addEventListener("click", () => {
|
|
6449
|
+
settingFilters.keyword = "";
|
|
6450
|
+
settingFilters.category = "";
|
|
6451
|
+
settingFilters.lockState = "all";
|
|
6452
|
+
$("#setting-keyword-filter").value = "";
|
|
6453
|
+
$("#setting-category-filter").value = "";
|
|
6454
|
+
$("#setting-lock-filter").value = "all";
|
|
6455
|
+
settingFiltersPanelOpen = true;
|
|
6456
|
+
moduleListPages.settings = 1;
|
|
6457
|
+
renderSettingResults(1);
|
|
6458
|
+
$("#setting-keyword-filter").focus();
|
|
6459
|
+
});
|
|
6460
|
+
renderSettingResults(page);
|
|
5952
6461
|
}
|
|
5953
6462
|
|
|
5954
6463
|
async function renderCharacters(page = characterListPage) {
|
|
@@ -6530,8 +7039,14 @@ async function renderReviews(page = moduleListPages.reviews) {
|
|
|
6530
7039
|
bindModulePagination("reviews", renderReviews);
|
|
6531
7040
|
bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
|
|
6532
7041
|
$("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
|
|
6533
|
-
|
|
6534
|
-
|
|
7042
|
+
button.disabled = true;
|
|
7043
|
+
try {
|
|
7044
|
+
await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
|
|
7045
|
+
await renderReviews(pageResult.page);
|
|
7046
|
+
} catch (error) {
|
|
7047
|
+
toast(error.message, "error");
|
|
7048
|
+
button.disabled = false;
|
|
7049
|
+
}
|
|
6535
7050
|
}));
|
|
6536
7051
|
$("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
|
|
6537
7052
|
const target = characterById.get(button.dataset.mergeTarget);
|
|
@@ -7570,6 +8085,7 @@ function backgroundProductUpdateMarkup() {
|
|
|
7570
8085
|
|
|
7571
8086
|
function backgroundTaskTransitionMessage(transition) {
|
|
7572
8087
|
const label = analysisTaskTypeLabel(transition.task.taskType);
|
|
8088
|
+
if (transition.status === "failed") return { message: `${label}失败,请打开任务详情查看`, type: "error" };
|
|
7573
8089
|
if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
|
|
7574
8090
|
if (transition.status === "expired") return { message: `${label}已过期,正文可能已发生变化`, type: "error" };
|
|
7575
8091
|
if (transition.status === "cancelled") return { message: `${label}已取消`, type: "info" };
|
|
@@ -8350,8 +8866,10 @@ function renderAiContextDistribution(usage) {
|
|
|
8350
8866
|
popover.dataset.hasUsage = String(Boolean(usage));
|
|
8351
8867
|
}
|
|
8352
8868
|
|
|
8353
|
-
function setAiContextMeter(usage) {
|
|
8354
|
-
const displayUsage =
|
|
8869
|
+
function setAiContextMeter(usage, allowShrink = true) {
|
|
8870
|
+
const displayUsage = allowShrink
|
|
8871
|
+
? resolveAiContextUsage(latestAiContextUsage, usage)
|
|
8872
|
+
: mergeAiContextUsage(latestAiContextUsage, usage, false);
|
|
8355
8873
|
latestAiContextUsage = displayUsage;
|
|
8356
8874
|
const meter = $("#ai-context-meter");
|
|
8357
8875
|
const value = meter.querySelector("b");
|
|
@@ -8371,7 +8889,9 @@ function setAiContextMeter(usage) {
|
|
|
8371
8889
|
meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
|
|
8372
8890
|
meter.classList.toggle("is-danger", percent >= 90);
|
|
8373
8891
|
meter.style.setProperty("--context-usage", String(percent));
|
|
8374
|
-
value.textContent =
|
|
8892
|
+
value.textContent = Number(displayUsage.inputTokens) > 0
|
|
8893
|
+
? formatAiContextUsagePercent(displayUsage.inputTokens, displayUsage.contextWindow)
|
|
8894
|
+
: `${percent}%`;
|
|
8375
8895
|
const tooltip = formatAiContextUsageTooltip(displayUsage);
|
|
8376
8896
|
meter.dataset.tooltip = tooltip;
|
|
8377
8897
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
@@ -8706,7 +9226,7 @@ function workCoverFieldHtml(work) {
|
|
|
8706
9226
|
return `<section class="work-cover-field" aria-labelledby="work-cover-title">
|
|
8707
9227
|
<div class="work-cover-copy">
|
|
8708
9228
|
<strong id="work-cover-title">封面</strong>
|
|
8709
|
-
<small>用于书架展示。支持 PNG、JPEG、WebP。</small>
|
|
9229
|
+
<small>用于书架展示。支持 PNG、JPEG、WebP;文件不超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}。</small>
|
|
8710
9230
|
</div>
|
|
8711
9231
|
<div class="work-cover-preview ${work.coverUrl ? "has-cover" : ""}" aria-hidden="${work.coverUrl ? "false" : "true"}">
|
|
8712
9232
|
${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面预览">` : "<span>暂无封面</span>"}
|
|
@@ -8910,42 +9430,65 @@ async function deleteVolume(item) {
|
|
|
8910
9430
|
}
|
|
8911
9431
|
}
|
|
8912
9432
|
|
|
9433
|
+
function syncSettingEditorChrome(viewOnly) {
|
|
9434
|
+
const item = settingEditorItem;
|
|
9435
|
+
$("#setting-editor-eyebrow").textContent = viewOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
|
|
9436
|
+
$("#setting-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
|
|
9437
|
+
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
9438
|
+
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
9439
|
+
const showManagementActions = Boolean(item && !viewOnly);
|
|
9440
|
+
$("#setting-editor-confirm").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
9441
|
+
$("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
9442
|
+
$("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
|
|
9443
|
+
$("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
|
|
9444
|
+
}
|
|
9445
|
+
|
|
9446
|
+
function settingEditorSnapshot(markdown = null) {
|
|
9447
|
+
const content = markdown ?? settingEditorVditor?.getValue() ?? $("#setting-editor-body").value;
|
|
9448
|
+
return JSON.stringify({
|
|
9449
|
+
title: $("#setting-editor-name").value,
|
|
9450
|
+
category: $("#setting-editor-category").value,
|
|
9451
|
+
content,
|
|
9452
|
+
locked: $("#setting-editor-locked").checked
|
|
9453
|
+
});
|
|
9454
|
+
}
|
|
9455
|
+
|
|
9456
|
+
function syncSettingEditorDirty(markdown = null) {
|
|
9457
|
+
const currentMarkdown = settingEditorVditor?.getValue() ?? markdown ?? $("#setting-editor-body").value;
|
|
9458
|
+
$("#setting-editor-body").value = currentMarkdown;
|
|
9459
|
+
entityEditorDirty = settingEditorDirtyTracker.isDirty(settingEditorSnapshot(currentMarkdown));
|
|
9460
|
+
}
|
|
9461
|
+
|
|
8913
9462
|
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
8914
9463
|
entityEditorReadOnly = readOnly;
|
|
8915
9464
|
destroyVditorEditor(settingEditorVditor);
|
|
8916
9465
|
settingEditorVditor = null;
|
|
8917
9466
|
settingEditorItem = item;
|
|
8918
|
-
$("#setting-editor-eyebrow").textContent = item ? "编辑设定" : "新建设定";
|
|
8919
9467
|
$("#setting-editor-name").value = item?.title ?? "";
|
|
8920
9468
|
$("#setting-editor-category").value = item?.category ?? "世界规则";
|
|
8921
9469
|
$("#setting-editor-locked").checked = Boolean(item?.locked);
|
|
8922
9470
|
$("#setting-editor-body").value = item?.content ?? "";
|
|
8923
|
-
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
8924
9471
|
const viewOnly = readOnly || !canEditModule("settings");
|
|
8925
9472
|
$("#setting-editor-form").classList.toggle("is-read-only", viewOnly);
|
|
8926
|
-
$("#setting-editor-eyebrow").textContent = readOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
|
|
8927
9473
|
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
8928
9474
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
8929
|
-
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
8930
9475
|
const editButton = $("#setting-editor-edit");
|
|
8931
9476
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
8932
|
-
editButton.onclick = () => openSettingEditor(
|
|
8933
|
-
const showManagementActions = Boolean(item && !viewOnly);
|
|
9477
|
+
editButton.onclick = () => openSettingEditor(settingEditorItem);
|
|
8934
9478
|
const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
|
|
8935
|
-
|
|
8936
|
-
$("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
8937
|
-
$("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
|
|
8938
|
-
$("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
|
|
9479
|
+
syncSettingEditorChrome(viewOnly);
|
|
8939
9480
|
$("#setting-editor-history").onclick = async () => {
|
|
8940
|
-
|
|
9481
|
+
const currentItem = settingEditorItem;
|
|
9482
|
+
if (!currentItem) return;
|
|
8941
9483
|
if (!(await closeEntityEditor())) return;
|
|
8942
|
-
openEntityHistory("setting",
|
|
9484
|
+
openEntityHistory("setting", currentItem.id, currentItem.title, async () => { await renderSettings(); await loadAiReferences(); });
|
|
8943
9485
|
};
|
|
8944
9486
|
$("#setting-editor-delete").onclick = () => {
|
|
8945
|
-
|
|
9487
|
+
const currentItem = settingEditorItem;
|
|
9488
|
+
if (!currentItem) return;
|
|
8946
9489
|
void deleteManagedEntity({
|
|
8947
9490
|
typeLabel: "设定",
|
|
8948
|
-
item,
|
|
9491
|
+
item: currentItem,
|
|
8949
9492
|
endpoint: (setting) => `/api/settings/${encodeURIComponent(setting.id)}`,
|
|
8950
9493
|
refresh: () => closeEntityEditor({ force: true }),
|
|
8951
9494
|
warning: "这条设定及其版本历史会被删除。"
|
|
@@ -8953,11 +9496,12 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
8953
9496
|
};
|
|
8954
9497
|
statusButtons.forEach((button) => {
|
|
8955
9498
|
button.onclick = async () => {
|
|
8956
|
-
|
|
9499
|
+
const currentItem = settingEditorItem;
|
|
9500
|
+
if (!currentItem) return;
|
|
8957
9501
|
button.disabled = true;
|
|
8958
9502
|
const status = button.id === "setting-editor-confirm" ? "confirmed" : "deprecated";
|
|
8959
9503
|
try {
|
|
8960
|
-
await api(`/api/settings/${
|
|
9504
|
+
await api(`/api/settings/${currentItem.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
|
|
8961
9505
|
await closeEntityEditor({ force: true });
|
|
8962
9506
|
await loadAiReferences();
|
|
8963
9507
|
toast(status === "confirmed" ? "设定候选已确认" : "设定候选已弃用");
|
|
@@ -8971,54 +9515,65 @@ function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
|
8971
9515
|
$("#setting-editor-form").onsubmit = async (event) => {
|
|
8972
9516
|
event.preventDefault();
|
|
8973
9517
|
if (readOnly || !canEditModule("settings")) return;
|
|
8974
|
-
const form = new FormData(event.currentTarget);
|
|
8975
9518
|
const submit = $("#setting-editor-submit");
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
9519
|
+
await runEntityEditorSave({
|
|
9520
|
+
busyTarget: event.currentTarget,
|
|
9521
|
+
button: submit,
|
|
9522
|
+
prepare: async () => {
|
|
9523
|
+
const form = new FormData(event.currentTarget);
|
|
9524
|
+
const title = String(form.get("title") ?? "").trim();
|
|
9525
|
+
if (!title) {
|
|
9526
|
+
toast("请填写设定标题", "error");
|
|
9527
|
+
$("#setting-editor-name").focus();
|
|
9528
|
+
return null;
|
|
9529
|
+
}
|
|
9530
|
+
const content = String(form.get("content") ?? "");
|
|
9531
|
+
if (!content.trim()) {
|
|
9532
|
+
toast("请填写设定正文", "error");
|
|
9533
|
+
$("#setting-editor-body").focus();
|
|
9534
|
+
return null;
|
|
9535
|
+
}
|
|
9536
|
+
const currentItem = settingEditorItem;
|
|
9537
|
+
const changeNote = currentItem ? await inputToast("简要说明这次修改,便于以后查看版本历史。可以留空。", {
|
|
9538
|
+
title: "填写版本说明",
|
|
9539
|
+
inputLabel: "版本说明",
|
|
9540
|
+
placeholder: "例如:补充纪元历法限制",
|
|
9541
|
+
confirmLabel: "保存新版本"
|
|
9542
|
+
}) : "";
|
|
9543
|
+
if (changeNote === null || !(await confirmConcurrentSave())) return null;
|
|
9544
|
+
const locked = form.get("locked") === "on";
|
|
9545
|
+
return {
|
|
9546
|
+
currentItem,
|
|
9547
|
+
body: {
|
|
9548
|
+
title,
|
|
9549
|
+
category: String(form.get("category") ?? "世界规则"),
|
|
9550
|
+
content,
|
|
9551
|
+
locked,
|
|
9552
|
+
status: locked ? "confirmed" : (currentItem?.status ?? "draft"),
|
|
9553
|
+
...(currentItem ? { changeNote } : {})
|
|
9554
|
+
}
|
|
9555
|
+
};
|
|
9556
|
+
},
|
|
9557
|
+
save: async ({ currentItem, body }) => {
|
|
9558
|
+
const saved = await api(currentItem ? `/api/settings/${currentItem.id}` : `/api/works/${state.work.id}/settings`, { method: currentItem ? "PATCH" : "POST", body });
|
|
9559
|
+
await cleanupPendingMarkdownAttachments(body.content);
|
|
9560
|
+
settingEditorItem = saved;
|
|
9561
|
+
state.settings = upsertEntityCollection(state.settings, saved);
|
|
9562
|
+
syncSettingEditorChrome(false);
|
|
9563
|
+
settingEditorDirtyTracker.markSaved(settingEditorSnapshot(body.content));
|
|
9564
|
+
entityEditorDirty = false;
|
|
9565
|
+
replacePageRoute(currentPageRoute());
|
|
9566
|
+
return {
|
|
9567
|
+
buttonLabel: "保存新版本",
|
|
9568
|
+
message: currentItem ? `设定已保存为 v${saved.versionNo}` : `设定已创建为 v${saved.versionNo}`
|
|
9569
|
+
};
|
|
8989
9570
|
}
|
|
8990
|
-
|
|
8991
|
-
title: "填写版本说明",
|
|
8992
|
-
inputLabel: "版本说明",
|
|
8993
|
-
placeholder: "例如:补充纪元历法限制",
|
|
8994
|
-
confirmLabel: "保存新版本"
|
|
8995
|
-
}) : "";
|
|
8996
|
-
if (changeNote === null) return;
|
|
8997
|
-
const locked = form.get("locked") === "on";
|
|
8998
|
-
const body = {
|
|
8999
|
-
title,
|
|
9000
|
-
category: String(form.get("category") ?? "世界规则"),
|
|
9001
|
-
content,
|
|
9002
|
-
locked,
|
|
9003
|
-
status: locked ? "confirmed" : (item?.status ?? "draft"),
|
|
9004
|
-
...(item ? { changeNote } : {})
|
|
9005
|
-
};
|
|
9006
|
-
if (!(await confirmConcurrentSave())) return;
|
|
9007
|
-
await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
|
|
9008
|
-
await cleanupPendingMarkdownAttachments(body.content);
|
|
9009
|
-
entityEditorDirty = false;
|
|
9010
|
-
await loadAiReferences();
|
|
9011
|
-
await closeEntityEditor({ force: true });
|
|
9012
|
-
toast(item ? "设定新版本已保存" : "设定已创建");
|
|
9013
|
-
} catch (error) {
|
|
9014
|
-
toast(error.message, "error");
|
|
9015
|
-
} finally {
|
|
9016
|
-
submit.disabled = false;
|
|
9017
|
-
}
|
|
9571
|
+
});
|
|
9018
9572
|
};
|
|
9019
9573
|
showEntityEditorPage("setting", { readOnly });
|
|
9574
|
+
settingEditorDirtyTracker.markSaved(settingEditorSnapshot(item?.content ?? ""));
|
|
9020
9575
|
settingEditorVditor = createVditorEditor($("#setting-editor-markdown"), item?.content ?? "", {
|
|
9021
|
-
onInput: (markdown) =>
|
|
9576
|
+
onInput: (markdown) => syncSettingEditorDirty(markdown),
|
|
9022
9577
|
readOnly: viewOnly
|
|
9023
9578
|
});
|
|
9024
9579
|
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
@@ -9172,6 +9727,7 @@ function markdownImageLabel(file, fallback = "图片附件") {
|
|
|
9172
9727
|
}
|
|
9173
9728
|
|
|
9174
9729
|
async function uploadMarkdownAttachment(file, module = "settings") {
|
|
9730
|
+
assertAttachmentFileSize(file);
|
|
9175
9731
|
const body = new FormData();
|
|
9176
9732
|
body.append("file", file);
|
|
9177
9733
|
const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
|
|
@@ -9479,6 +10035,7 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
|
|
|
9479
10035
|
}
|
|
9480
10036
|
|
|
9481
10037
|
async function uploadCharacterSectionAttachment(file) {
|
|
10038
|
+
assertAttachmentFileSize(file);
|
|
9482
10039
|
const body = new FormData();
|
|
9483
10040
|
body.append("file", file);
|
|
9484
10041
|
const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
|
|
@@ -9490,6 +10047,10 @@ async function uploadCharacterSectionAttachment(file) {
|
|
|
9490
10047
|
}
|
|
9491
10048
|
|
|
9492
10049
|
async function closeCharacterSectionEditor({ force = false } = {}) {
|
|
10050
|
+
if (isEntityEditorSaving()) {
|
|
10051
|
+
toast("正在保存,请稍候");
|
|
10052
|
+
return false;
|
|
10053
|
+
}
|
|
9493
10054
|
if (!force && characterSectionEditorDirty && !(await confirmToast(
|
|
9494
10055
|
"当前 Markdown 章节有未保存修改,返回人物档案将丢弃这些修改。是否继续?",
|
|
9495
10056
|
{ title: "放弃未保存修改", confirmLabel: "放弃并继续", cancelLabel: "继续编辑" }
|
|
@@ -9522,6 +10083,10 @@ function knowledgeSectionEditorHtml(section = null) {
|
|
|
9522
10083
|
}
|
|
9523
10084
|
|
|
9524
10085
|
async function closeKnowledgeSectionEditor({ force = false } = {}) {
|
|
10086
|
+
if (isEntityEditorSaving()) {
|
|
10087
|
+
toast("正在保存,请稍候");
|
|
10088
|
+
return false;
|
|
10089
|
+
}
|
|
9525
10090
|
if (!force && knowledgeSectionEditorDirty && !(await confirmToast(
|
|
9526
10091
|
"当前 Markdown 设定有未保存修改,返回设定列表将丢弃这些修改。是否继续?",
|
|
9527
10092
|
{ title: "放弃未保存修改", confirmLabel: "放弃并继续", cancelLabel: "继续编辑" }
|
|
@@ -9540,6 +10105,7 @@ async function closeKnowledgeSectionEditor({ force = false } = {}) {
|
|
|
9540
10105
|
|
|
9541
10106
|
async function openKnowledgeSectionEditor(index = null) {
|
|
9542
10107
|
if (!canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations")) return;
|
|
10108
|
+
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
9543
10109
|
destroyVditorEditor(knowledgeSectionVditor);
|
|
9544
10110
|
knowledgeSectionVditor = null;
|
|
9545
10111
|
const section = Number.isInteger(index) ? knowledgeEditorSections[index] : null;
|
|
@@ -9562,21 +10128,45 @@ async function openKnowledgeSectionEditor(index = null) {
|
|
|
9562
10128
|
host.querySelector("[data-knowledge-section-edit-cancel]").addEventListener("click", () => void closeKnowledgeSectionEditor());
|
|
9563
10129
|
host.querySelector("[data-knowledge-section-edit-save]").addEventListener("click", async (event) => {
|
|
9564
10130
|
const button = event.currentTarget;
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
10131
|
+
await runEntityEditorSave({
|
|
10132
|
+
busyTarget: $("#knowledge-section-editor-view"),
|
|
10133
|
+
button,
|
|
10134
|
+
prepare: async () => {
|
|
10135
|
+
const title = titleInput.value.trim();
|
|
10136
|
+
if (!title) {
|
|
10137
|
+
toast("请填写设定标题", "error");
|
|
10138
|
+
titleInput.focus();
|
|
10139
|
+
return null;
|
|
10140
|
+
}
|
|
10141
|
+
return {
|
|
10142
|
+
title,
|
|
10143
|
+
contentMarkdown: knowledgeSectionVditor?.getValue() ?? "",
|
|
10144
|
+
targetIndex: knowledgeSectionEditorIndex
|
|
10145
|
+
};
|
|
10146
|
+
},
|
|
10147
|
+
save: async ({ title, contentMarkdown, targetIndex }) => {
|
|
10148
|
+
const currentSection = targetIndex === null ? null : knowledgeEditorSections[targetIndex];
|
|
10149
|
+
const nextSection = {
|
|
10150
|
+
title,
|
|
10151
|
+
contentMarkdown,
|
|
10152
|
+
summary: String(currentSection?.summary ?? ""),
|
|
10153
|
+
sortOrder: targetIndex ?? knowledgeEditorSections.length
|
|
10154
|
+
};
|
|
10155
|
+
if (targetIndex === null) {
|
|
10156
|
+
knowledgeEditorSections.push(nextSection);
|
|
10157
|
+
knowledgeSectionEditorIndex = knowledgeEditorSections.length - 1;
|
|
10158
|
+
} else {
|
|
10159
|
+
knowledgeEditorSections[targetIndex] = nextSection;
|
|
10160
|
+
}
|
|
10161
|
+
knowledgeEditorSections.forEach((item, sortOrder) => { item.sortOrder = sortOrder; });
|
|
10162
|
+
knowledgeSectionEditorDirty = false;
|
|
10163
|
+
markEntityEditorDirty();
|
|
10164
|
+
return {
|
|
10165
|
+
buttonLabel: "保存设定",
|
|
10166
|
+
message: `Markdown 设定保存成功,保存${label}档案后生效`
|
|
10167
|
+
};
|
|
10168
|
+
}
|
|
10169
|
+
});
|
|
9580
10170
|
});
|
|
9581
10171
|
titleInput.focus();
|
|
9582
10172
|
}
|
|
@@ -9585,7 +10175,7 @@ function characterSectionEditorHtml(section = null) {
|
|
|
9585
10175
|
const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
|
|
9586
10176
|
return `<div class="character-section-editor-shell">
|
|
9587
10177
|
<header class="character-section-editor-header">
|
|
9588
|
-
<div><span class="eyebrow">人物 Markdown
|
|
10178
|
+
<div><span id="character-section-editor-version" class="eyebrow">人物 Markdown 档案${section ? ` · v${esc(section.versionNo)}` : ""}</span><input id="character-section-title" class="character-section-editor-title-input" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="新建档案章节" aria-label="章节标题" required></div>
|
|
9589
10179
|
<button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
|
|
9590
10180
|
</header>
|
|
9591
10181
|
<section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
|
|
@@ -9602,19 +10192,45 @@ function characterSectionEditorHtml(section = null) {
|
|
|
9602
10192
|
</div>`;
|
|
9603
10193
|
}
|
|
9604
10194
|
|
|
10195
|
+
function upsertCharacterEditorSection(saved) {
|
|
10196
|
+
const index = characterEditorSections.findIndex((item) => item.id === saved.id);
|
|
10197
|
+
if (index >= 0) characterEditorSections[index] = saved;
|
|
10198
|
+
else characterEditorSections.push(saved);
|
|
10199
|
+
characterEditorSections.sort((left, right) => Number(left.sortOrder ?? 0) - Number(right.sortOrder ?? 0));
|
|
10200
|
+
renderCharacterMarkdownSections();
|
|
10201
|
+
}
|
|
10202
|
+
|
|
10203
|
+
function characterSectionEditorSnapshot(markdown = null) {
|
|
10204
|
+
const contentMarkdown = markdown ?? characterSectionVditor?.getValue() ?? "";
|
|
10205
|
+
return JSON.stringify({
|
|
10206
|
+
sectionType: $("#character-section-type").value,
|
|
10207
|
+
title: $("#character-section-title").value,
|
|
10208
|
+
summary: $("#character-section-summary").value,
|
|
10209
|
+
contentMarkdown,
|
|
10210
|
+
changeNote: $("#character-section-change-note").value
|
|
10211
|
+
});
|
|
10212
|
+
}
|
|
10213
|
+
|
|
10214
|
+
function syncCharacterSectionEditorDirty(markdown = null) {
|
|
10215
|
+
const currentMarkdown = characterSectionVditor?.getValue() ?? markdown ?? "";
|
|
10216
|
+
characterSectionEditorDirty = characterSectionEditorDirtyTracker.isDirty(characterSectionEditorSnapshot(currentMarkdown));
|
|
10217
|
+
}
|
|
10218
|
+
|
|
9605
10219
|
async function openCharacterSectionEditor(section = null) {
|
|
9606
10220
|
await discardPendingCharacterAttachments();
|
|
9607
10221
|
destroyVditorEditor(characterSectionVditor);
|
|
9608
10222
|
characterSectionVditor = null;
|
|
10223
|
+
let currentSection = section;
|
|
9609
10224
|
const host = $("#character-section-editor-host");
|
|
9610
|
-
host.innerHTML = characterSectionEditorHtml(
|
|
10225
|
+
host.innerHTML = characterSectionEditorHtml(currentSection);
|
|
10226
|
+
characterSectionEditorDirtyTracker.markSaved(characterSectionEditorSnapshot(currentSection?.contentMarkdown ?? ""));
|
|
9611
10227
|
characterSectionEditorDirty = false;
|
|
9612
10228
|
$("#character-editor-form").classList.add("hidden");
|
|
9613
10229
|
$("#character-section-editor-view").classList.remove("hidden");
|
|
9614
|
-
host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () =>
|
|
9615
|
-
characterSectionVditor = createVditorEditor($("#character-section-markdown"),
|
|
10230
|
+
host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => syncCharacterSectionEditorDirty()));
|
|
10231
|
+
characterSectionVditor = createVditorEditor($("#character-section-markdown"), currentSection?.contentMarkdown ?? "", {
|
|
9616
10232
|
uploadAttachment: uploadCharacterSectionAttachment,
|
|
9617
|
-
onInput: () =>
|
|
10233
|
+
onInput: (markdown) => syncCharacterSectionEditorDirty(markdown),
|
|
9618
10234
|
placeholder: "从这里开始写人物章节…",
|
|
9619
10235
|
width: "100%"
|
|
9620
10236
|
});
|
|
@@ -9622,43 +10238,50 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
9622
10238
|
host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
|
|
9623
10239
|
host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
|
|
9624
10240
|
const button = event.currentTarget;
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
if (!(await confirmConcurrentSave())) {
|
|
9635
|
-
button.disabled = false;
|
|
9636
|
-
return;
|
|
9637
|
-
}
|
|
9638
|
-
const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
|
|
9639
|
-
method: section ? "PATCH" : "POST",
|
|
9640
|
-
body: {
|
|
9641
|
-
sectionType: $("#character-section-type").value,
|
|
9642
|
-
title,
|
|
9643
|
-
summary: $("#character-section-summary").value.trim(),
|
|
9644
|
-
contentMarkdown,
|
|
9645
|
-
...(section ? { changeNote: $("#character-section-change-note").value.trim() } : {})
|
|
10241
|
+
await runEntityEditorSave({
|
|
10242
|
+
busyTarget: $("#character-section-editor-view"),
|
|
10243
|
+
button,
|
|
10244
|
+
prepare: async () => {
|
|
10245
|
+
const title = $("#character-section-title").value.trim();
|
|
10246
|
+
if (!title) {
|
|
10247
|
+
toast("请填写章节标题", "error");
|
|
10248
|
+
$("#character-section-title").focus();
|
|
10249
|
+
return null;
|
|
9646
10250
|
}
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
10251
|
+
if (!(await confirmConcurrentSave())) return null;
|
|
10252
|
+
return {
|
|
10253
|
+
title,
|
|
10254
|
+
contentMarkdown: characterSectionVditor?.getValue() ?? "",
|
|
10255
|
+
editingSection: currentSection
|
|
10256
|
+
};
|
|
10257
|
+
},
|
|
10258
|
+
save: async ({ title, contentMarkdown, editingSection }) => {
|
|
10259
|
+
const saved = await api(editingSection ? `/api/character-sections/${editingSection.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
|
|
10260
|
+
method: editingSection ? "PATCH" : "POST",
|
|
10261
|
+
body: {
|
|
10262
|
+
sectionType: $("#character-section-type").value,
|
|
10263
|
+
title,
|
|
10264
|
+
summary: $("#character-section-summary").value.trim(),
|
|
10265
|
+
contentMarkdown,
|
|
10266
|
+
...(editingSection ? { changeNote: $("#character-section-change-note").value.trim() } : {})
|
|
10267
|
+
}
|
|
10268
|
+
});
|
|
10269
|
+
const referenced = new Set([...contentMarkdown.matchAll(/attachment:\/\/([A-Za-z0-9_-]+)/gu)].map((match) => String(match[1])));
|
|
10270
|
+
const unused = characterSectionPendingAttachments.filter((attachmentId) => !referenced.has(attachmentId));
|
|
10271
|
+
characterSectionPendingAttachments = [];
|
|
10272
|
+
await Promise.all(unused.map((attachmentId) => api(`/api/attachments/${attachmentId}`, { method: "DELETE" }).catch(() => null)));
|
|
10273
|
+
currentSection = saved;
|
|
10274
|
+
upsertCharacterEditorSection(saved);
|
|
10275
|
+
$("#character-section-change-note").value = "";
|
|
10276
|
+
$("#character-section-editor-version").textContent = `人物 Markdown 档案 · v${saved.versionNo}`;
|
|
10277
|
+
characterSectionEditorDirtyTracker.markSaved(characterSectionEditorSnapshot(contentMarkdown));
|
|
10278
|
+
characterSectionEditorDirty = false;
|
|
10279
|
+
return {
|
|
10280
|
+
buttonLabel: "保存章节版本",
|
|
10281
|
+
message: editingSection ? `“${saved.title}”已保存为 v${saved.versionNo}` : `“${saved.title}”已创建为 v${saved.versionNo}`
|
|
10282
|
+
};
|
|
10283
|
+
}
|
|
10284
|
+
});
|
|
9662
10285
|
});
|
|
9663
10286
|
}
|
|
9664
10287
|
|
|
@@ -9918,25 +10541,26 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
9918
10541
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
9919
10542
|
const characterMergeButton = $("#character-merge-button");
|
|
9920
10543
|
const characterDeleteButton = $("#character-delete-button");
|
|
9921
|
-
const canManageCharacter = Boolean(
|
|
10544
|
+
const canManageCharacter = Boolean(characterEditorItem && !readOnly && canEditModule("characters"));
|
|
9922
10545
|
characterMergeButton.classList.toggle("hidden", !canManageCharacter);
|
|
9923
10546
|
characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
|
|
9924
10547
|
characterMergeButton.onclick = async () => {
|
|
9925
|
-
|
|
10548
|
+
const currentItem = characterEditorItem;
|
|
10549
|
+
if (!currentItem) return;
|
|
9926
10550
|
const workId = state.work?.id;
|
|
9927
10551
|
if (!workId) return;
|
|
9928
10552
|
try {
|
|
9929
10553
|
const candidates = await moduleApiAllPages("characters", `/api/works/${workId}/characters`);
|
|
9930
|
-
if (state.work?.id !== workId || characterEditorItem?.id !==
|
|
10554
|
+
if (state.work?.id !== workId || characterEditorItem?.id !== currentItem.id) return;
|
|
9931
10555
|
state.characters = candidates;
|
|
9932
10556
|
if (candidates.length < 2) return toast("至少需要两个角色才能合并", "error");
|
|
9933
10557
|
await closeEntityEditor({ force: true });
|
|
9934
10558
|
openEntityMergeDialog({
|
|
9935
10559
|
typeLabel: "角色",
|
|
9936
|
-
source:
|
|
10560
|
+
source: currentItem,
|
|
9937
10561
|
candidates,
|
|
9938
10562
|
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
|
|
9939
|
-
body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo:
|
|
10563
|
+
body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: currentItem.versionNo }),
|
|
9940
10564
|
refresh: renderCharacters,
|
|
9941
10565
|
impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
|
|
9942
10566
|
});
|
|
@@ -9945,10 +10569,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
9945
10569
|
}
|
|
9946
10570
|
};
|
|
9947
10571
|
characterDeleteButton.onclick = () => {
|
|
9948
|
-
|
|
10572
|
+
const currentItem = characterEditorItem;
|
|
10573
|
+
if (!currentItem) return;
|
|
9949
10574
|
void deleteManagedEntity({
|
|
9950
10575
|
typeLabel: "角色",
|
|
9951
|
-
item,
|
|
10576
|
+
item: currentItem,
|
|
9952
10577
|
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
|
|
9953
10578
|
refresh: () => closeEntityEditor({ force: true }),
|
|
9954
10579
|
warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
|
|
@@ -9967,7 +10592,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
9967
10592
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
9968
10593
|
const editButton = $("#character-editor-edit");
|
|
9969
10594
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
|
|
9970
|
-
editButton.onclick = () => void openCharacterEditor(
|
|
10595
|
+
editButton.onclick = () => void openCharacterEditor(characterEditorItem);
|
|
9971
10596
|
document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
|
|
9972
10597
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
9973
10598
|
});
|
|
@@ -9979,24 +10604,46 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
9979
10604
|
event.preventDefault();
|
|
9980
10605
|
if (readOnly || !canEditModule("characters")) return;
|
|
9981
10606
|
const submit = $("#character-editor-submit");
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
|
|
9991
|
-
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10607
|
+
await runEntityEditorSave({
|
|
10608
|
+
busyTarget: form,
|
|
10609
|
+
button: submit,
|
|
10610
|
+
prepare: async () => {
|
|
10611
|
+
const body = collectCharacterBody(new FormData(form));
|
|
10612
|
+
if (!body.name) {
|
|
10613
|
+
toast("请填写角色标准名", "error");
|
|
10614
|
+
form.querySelector("[name='name']")?.focus();
|
|
10615
|
+
return null;
|
|
10616
|
+
}
|
|
10617
|
+
const currentItem = characterEditorItem;
|
|
10618
|
+
if (!currentItem) delete body.changeNote;
|
|
10619
|
+
if (!(await confirmConcurrentSave())) return null;
|
|
10620
|
+
return { body, currentItem, previousVersion: currentItem?.versionNo };
|
|
10621
|
+
},
|
|
10622
|
+
save: async ({ body, currentItem, previousVersion }) => {
|
|
10623
|
+
const saved = await api(currentItem ? `/api/characters/${currentItem.id}` : `/api/works/${state.work.id}/characters`, { method: currentItem ? "PATCH" : "POST", body });
|
|
10624
|
+
characterEditorItem = saved;
|
|
10625
|
+
state.characters = upsertEntityCollection(state.characters, saved);
|
|
10626
|
+
entityEditorDirty = false;
|
|
10627
|
+
$("#character-editor-title").textContent = saved.name;
|
|
10628
|
+
$("#character-editor-version").textContent = `v${saved.versionNo}`;
|
|
10629
|
+
$("#character-change-note").value = "";
|
|
10630
|
+
$("#character-history-button").disabled = false;
|
|
10631
|
+
$("#character-history-button").title = "查看、比较和回滚历史版本";
|
|
10632
|
+
characterMergeButton.classList.remove("hidden");
|
|
10633
|
+
characterDeleteButton.classList.remove("hidden");
|
|
10634
|
+
relationshipTab.disabled = !canReadModule("relationships");
|
|
10635
|
+
relationshipTab.title = canReadModule("relationships") ? "查看和编辑人物关系" : "当前账户没有关系模块读取权限";
|
|
10636
|
+
renderCharacterMarkdownSections();
|
|
10637
|
+
renderAiRoleplayCharacterSelect();
|
|
10638
|
+
replacePageRoute(currentPageRoute());
|
|
10639
|
+
const message = !currentItem
|
|
10640
|
+
? `人物档案已创建为 v${saved.versionNo}`
|
|
10641
|
+
: saved.versionNo === previousVersion
|
|
10642
|
+
? `人物档案没有变更,当前为 v${saved.versionNo}`
|
|
10643
|
+
: `人物档案已保存为 v${saved.versionNo}`;
|
|
10644
|
+
return { buttonLabel: "保存新版本", message };
|
|
10645
|
+
}
|
|
10646
|
+
});
|
|
10000
10647
|
};
|
|
10001
10648
|
showEntityEditorPage("character", { readOnly });
|
|
10002
10649
|
if (item) {
|
|
@@ -10031,7 +10678,7 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
|
|
|
10031
10678
|
+ field("description", `${label}简介`, "textarea", item?.description, []);
|
|
10032
10679
|
const memberField = memberOptions.length
|
|
10033
10680
|
? field("memberIds", isRace ? "属于该种族的角色(可多选)" : "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions)
|
|
10034
|
-
: `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${
|
|
10681
|
+
: `<div class="character-editor-empty-field"><strong>${isRace ? "种族成员" : "组织成员"}</strong><span>${entityEditorReadOnly ? "该档案尚未绑定角色。" : "当前还没有可绑定的角色。"}</span></div>`;
|
|
10035
10682
|
$("#knowledge-editor-fields").innerHTML = knowledgeEditorSection("basic", "基础资料", isRace ? "先定义名称、层级和简介,再补充共同设定。" : "先定义组织名称和简介,再补充完整的组织设定。", basicFields)
|
|
10036
10683
|
+ knowledgeEditorSection("settings", title, "", '<div id="knowledge-markdown-sections" class="knowledge-markdown-sections"></div>')
|
|
10037
10684
|
+ knowledgeEditorSection("members", isRace ? "种族成员" : "组织成员", "成员关系会同步到角色档案中。", memberField);
|
|
@@ -10056,6 +10703,8 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
|
|
|
10056
10703
|
const isRace = kind === "race";
|
|
10057
10704
|
const module = isRace ? "races" : "organizations";
|
|
10058
10705
|
const label = isRace ? "种族" : "组织";
|
|
10706
|
+
const viewOnly = readOnly || !canEditModule(module);
|
|
10707
|
+
entityEditorReadOnly = viewOnly;
|
|
10059
10708
|
const parentOptions = isRace
|
|
10060
10709
|
? [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
|
|
10061
10710
|
.sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
|
|
@@ -10066,32 +10715,39 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
|
|
|
10066
10715
|
knowledgeEditorSections = normalizeKnowledgeEditorSections(item);
|
|
10067
10716
|
knowledgeSectionEditorIndex = null;
|
|
10068
10717
|
knowledgeSectionEditorDirty = false;
|
|
10069
|
-
$("#knowledge-editor-eyebrow").textContent = item ? `${label}档案` : `建立${label}档案`;
|
|
10070
|
-
$("#knowledge-editor-title").textContent = item?.name || `新建${label}`;
|
|
10071
|
-
$("#knowledge-editor-version").textContent = item ? `v${item.versionNo ?? 1}` : "新档案";
|
|
10072
10718
|
$("#knowledge-editor-header-note").textContent = isRace ? "层级、共同设定与角色归属" : "简介、组织设定与成员归属";
|
|
10073
|
-
$("#knowledge-editor-footer-note").textContent = `保存${label}档案后返回${isRace ? "种族" : "组织"}列表。`;
|
|
10074
|
-
$("#knowledge-editor-submit").textContent = item ? `保存${label}档案` : `创建${label}档案`;
|
|
10075
10719
|
const historyButton = $("#knowledge-editor-history");
|
|
10076
10720
|
const mergeButton = $("#knowledge-editor-merge");
|
|
10077
10721
|
const deleteButton = $("#knowledge-editor-delete");
|
|
10078
10722
|
const refresh = isRace ? renderRaces : renderOrganizations;
|
|
10079
10723
|
const candidates = isRace ? state.races : state.organizations;
|
|
10080
10724
|
const typeLabel = label;
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10725
|
+
const syncKnowledgeEditorChrome = () => {
|
|
10726
|
+
const currentItem = knowledgeEditorItem;
|
|
10727
|
+
$("#knowledge-editor-eyebrow").textContent = viewOnly ? `阅读${label}档案` : currentItem ? `${label}档案` : `建立${label}档案`;
|
|
10728
|
+
$("#knowledge-editor-title").textContent = currentItem?.name || `新建${label}`;
|
|
10729
|
+
$("#knowledge-editor-version").textContent = currentItem ? `v${currentItem.versionNo ?? 1}` : "新档案";
|
|
10730
|
+
$("#knowledge-editor-footer-note").textContent = `保存${label}档案后继续留在当前页面,可使用“返回列表”离开。`;
|
|
10731
|
+
$("#knowledge-editor-submit").textContent = currentItem ? `保存${label}档案` : `创建${label}档案`;
|
|
10732
|
+
$("#knowledge-editor-submit").classList.toggle("hidden", viewOnly);
|
|
10733
|
+
const hasMergeCandidate = candidates.some((candidate) => candidate.id !== currentItem?.id);
|
|
10734
|
+
historyButton.classList.toggle("hidden", !currentItem);
|
|
10735
|
+
mergeButton.classList.toggle("hidden", !currentItem || viewOnly || !hasMergeCandidate);
|
|
10736
|
+
deleteButton.classList.toggle("hidden", !currentItem || viewOnly);
|
|
10737
|
+
};
|
|
10084
10738
|
historyButton.onclick = async () => {
|
|
10085
|
-
|
|
10739
|
+
const currentItem = knowledgeEditorItem;
|
|
10740
|
+
if (!currentItem) return;
|
|
10086
10741
|
if (!(await closeEntityEditor())) return;
|
|
10087
|
-
openEntityHistory(kind,
|
|
10742
|
+
openEntityHistory(kind, currentItem.id, currentItem.name, async () => { await refresh(); await loadAiReferences(); });
|
|
10088
10743
|
};
|
|
10089
10744
|
mergeButton.onclick = async () => {
|
|
10090
|
-
|
|
10745
|
+
const currentItem = knowledgeEditorItem;
|
|
10746
|
+
if (!currentItem) return;
|
|
10091
10747
|
await closeEntityEditor({ force: true });
|
|
10092
10748
|
openEntityMergeDialog({
|
|
10093
10749
|
typeLabel,
|
|
10094
|
-
source:
|
|
10750
|
+
source: currentItem,
|
|
10095
10751
|
candidates,
|
|
10096
10752
|
endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}/merge`,
|
|
10097
10753
|
body: (target) => isRace ? { targetRaceId: target.id } : { targetOrganizationId: target.id },
|
|
@@ -10100,61 +10756,73 @@ async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
|
|
|
10100
10756
|
});
|
|
10101
10757
|
};
|
|
10102
10758
|
deleteButton.onclick = () => {
|
|
10103
|
-
|
|
10759
|
+
const currentItem = knowledgeEditorItem;
|
|
10760
|
+
if (!currentItem) return;
|
|
10104
10761
|
void deleteManagedEntity({
|
|
10105
10762
|
typeLabel,
|
|
10106
|
-
item,
|
|
10763
|
+
item: currentItem,
|
|
10107
10764
|
endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}`,
|
|
10108
10765
|
refresh: () => closeEntityEditor({ force: true }),
|
|
10109
10766
|
warning: isRace ? "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。" : "角色与该组织的成员关系会一并移除。"
|
|
10110
10767
|
});
|
|
10111
10768
|
};
|
|
10112
10769
|
renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
|
|
10113
|
-
const viewOnly = readOnly || !canEditModule(module);
|
|
10114
10770
|
if (viewOnly) {
|
|
10115
|
-
$("#knowledge-editor-eyebrow").textContent = readOnly ? `阅读${label}档案` : `${label}档案`;
|
|
10116
10771
|
$("#knowledge-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
10117
10772
|
$("#knowledge-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
10118
10773
|
$("#knowledge-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
10119
10774
|
}
|
|
10120
|
-
|
|
10775
|
+
syncKnowledgeEditorChrome();
|
|
10121
10776
|
const editButton = $("#knowledge-editor-edit");
|
|
10122
10777
|
editButton.textContent = `编辑${label}`;
|
|
10123
10778
|
editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
|
|
10124
|
-
editButton.onclick = () => void openKnowledgeEditor(kind,
|
|
10779
|
+
editButton.onclick = () => void openKnowledgeEditor(kind, knowledgeEditorItem);
|
|
10125
10780
|
const form = $("#knowledge-editor-form");
|
|
10126
10781
|
form.onsubmit = async (event) => {
|
|
10127
10782
|
event.preventDefault();
|
|
10128
10783
|
if (readOnly || !canEditModule(module)) return;
|
|
10129
10784
|
const submit = $("#knowledge-editor-submit");
|
|
10130
|
-
|
|
10131
|
-
|
|
10132
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10785
|
+
await runEntityEditorSave({
|
|
10786
|
+
busyTarget: form,
|
|
10787
|
+
button: submit,
|
|
10788
|
+
prepare: async () => {
|
|
10789
|
+
const data = new FormData(form);
|
|
10790
|
+
const name = String(data.get("name") ?? "").trim();
|
|
10791
|
+
if (!name) {
|
|
10792
|
+
toast(`请填写${label}名称`, "error");
|
|
10793
|
+
form.querySelector("[name='name']")?.focus();
|
|
10794
|
+
return null;
|
|
10795
|
+
}
|
|
10796
|
+
const settingsSections = knowledgeEditorSections
|
|
10797
|
+
.map((section, index) => ({ title: String(section.title ?? "").trim(), contentMarkdown: String(section.contentMarkdown ?? ""), summary: String(section.summary ?? "").trim(), sortOrder: index }))
|
|
10798
|
+
.filter((section) => section.title || section.contentMarkdown.trim());
|
|
10799
|
+
const untitled = settingsSections.findIndex((section) => !section.title);
|
|
10800
|
+
if (untitled >= 0) {
|
|
10801
|
+
toast(`请填写第 ${untitled + 1} 条 Markdown 设定的标题`, "error");
|
|
10802
|
+
return null;
|
|
10803
|
+
}
|
|
10804
|
+
const settingsMarkdown = settingsSections.map((section) => section.contentMarkdown).join("\n\n");
|
|
10805
|
+
const body = isRace
|
|
10806
|
+
? { name, isExtinct: data.has("isExtinct"), parentRaceId: data.get("parentRaceId") || null, description: data.get("description"), settingsMarkdown, settingsSections }
|
|
10807
|
+
: { name, isDissolved: data.has("isDissolved"), description: data.get("description"), settingsMarkdown, settingsSections };
|
|
10808
|
+
if (canReadModule("characters")) body.memberIds = data.getAll("memberIds").map(String);
|
|
10809
|
+
const currentItem = knowledgeEditorItem;
|
|
10810
|
+
if (!(await confirmConcurrentSave())) return null;
|
|
10811
|
+
return { body, currentItem, settingsMarkdown };
|
|
10812
|
+
},
|
|
10813
|
+
save: async ({ body, currentItem, settingsMarkdown }) => {
|
|
10814
|
+
const saved = await api(currentItem ? `/api/${module}/${currentItem.id}` : `/api/works/${state.work.id}/${module}`, { method: currentItem ? "PATCH" : "POST", body });
|
|
10815
|
+
await cleanupPendingMarkdownAttachments(settingsMarkdown);
|
|
10816
|
+
knowledgeEditorItem = saved;
|
|
10817
|
+
entityEditorDirty = false;
|
|
10818
|
+
syncKnowledgeEditorChrome();
|
|
10819
|
+
replacePageRoute(currentPageRoute());
|
|
10820
|
+
return {
|
|
10821
|
+
buttonLabel: `保存${label}档案`,
|
|
10822
|
+
message: currentItem ? `${label}档案已保存为 v${saved.versionNo}` : `${label}档案已创建为 v${saved.versionNo}`
|
|
10823
|
+
};
|
|
10824
|
+
}
|
|
10825
|
+
});
|
|
10158
10826
|
};
|
|
10159
10827
|
showEntityEditorPage(kind, { readOnly });
|
|
10160
10828
|
(readOnly ? $("#knowledge-editor-close") : $("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea"))?.focus();
|
|
@@ -10924,7 +11592,7 @@ async function sendAi() {
|
|
|
10924
11592
|
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
10925
11593
|
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
10926
11594
|
if (suggestionFailed) setAiAssistantStatus("error");
|
|
10927
|
-
setAiContextMeter(suggestion.contextUsage);
|
|
11595
|
+
setAiContextMeter(suggestion.contextUsage, false);
|
|
10928
11596
|
assistantContent = suggestion.content;
|
|
10929
11597
|
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
10930
11598
|
}
|
|
@@ -10998,6 +11666,7 @@ async function streamChat(body) {
|
|
|
10998
11666
|
let conversationTitle = null;
|
|
10999
11667
|
let persistedUserMessage = null;
|
|
11000
11668
|
let contextAction = "ready";
|
|
11669
|
+
let streamContextCompacted = false;
|
|
11001
11670
|
let finalAnswerStarted = false;
|
|
11002
11671
|
const processStartedAt = Date.now();
|
|
11003
11672
|
const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
|
|
@@ -11049,7 +11718,7 @@ async function streamChat(body) {
|
|
|
11049
11718
|
const payload = JSON.parse(dataLines.join("\n"));
|
|
11050
11719
|
if (eventName === "context") {
|
|
11051
11720
|
contextAction = typeof payload.action === "string" ? payload.action : "ready";
|
|
11052
|
-
setAiContextMeter(payload.usage);
|
|
11721
|
+
if (!state.aiPromptSent) setAiContextMeter(payload.usage);
|
|
11053
11722
|
if (payload.conversation?.id) {
|
|
11054
11723
|
state.aiConversationId = payload.conversation.id;
|
|
11055
11724
|
upsertAiConversationSummary(payload.conversation);
|
|
@@ -11072,7 +11741,7 @@ async function streamChat(body) {
|
|
|
11072
11741
|
syncAiTaskOptions();
|
|
11073
11742
|
renderAiRoleplayCharacterSelect();
|
|
11074
11743
|
renderAiQuickActions();
|
|
11075
|
-
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt,
|
|
11744
|
+
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, persistedUserMessage.metadata, persistedUserMessage.id);
|
|
11076
11745
|
clearAiPromptComposer();
|
|
11077
11746
|
mountAssistantMessage();
|
|
11078
11747
|
}
|
|
@@ -11116,6 +11785,7 @@ async function streamChat(body) {
|
|
|
11116
11785
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
11117
11786
|
scrollAiFeedToBottom();
|
|
11118
11787
|
} else if (eventName === "context_compacted") {
|
|
11788
|
+
streamContextCompacted = true;
|
|
11119
11789
|
setAiContextMeter(payload.contextUsage);
|
|
11120
11790
|
if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
|
|
11121
11791
|
} else if (eventName === "complete") {
|
|
@@ -11123,7 +11793,8 @@ async function streamChat(body) {
|
|
|
11123
11793
|
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
11124
11794
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
11125
11795
|
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
11126
|
-
|
|
11796
|
+
const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
|
|
11797
|
+
setAiContextMeter(payload.contextUsage, announcedCompaction);
|
|
11127
11798
|
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
11128
11799
|
message.classList.remove("is-streaming");
|
|
11129
11800
|
content.setAttribute("aria-busy", "false");
|
|
@@ -11188,6 +11859,24 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
11188
11859
|
failureBadge.setAttribute("aria-label", "消息状态:失败");
|
|
11189
11860
|
heading.firstElementChild?.append(failureBadge);
|
|
11190
11861
|
}
|
|
11862
|
+
const mentionNames = role === "user"
|
|
11863
|
+
? userMessageMentionNames(metadata?.mentionCharacterIds, state.characters)
|
|
11864
|
+
: [];
|
|
11865
|
+
if (mentionNames.length) {
|
|
11866
|
+
const references = document.createElement("div");
|
|
11867
|
+
references.className = "user-message-mentions";
|
|
11868
|
+
const label = document.createElement("span");
|
|
11869
|
+
label.className = "user-message-mentions-label";
|
|
11870
|
+
label.textContent = "引用角色";
|
|
11871
|
+
references.append(label);
|
|
11872
|
+
for (const name of mentionNames) {
|
|
11873
|
+
const reference = document.createElement("span");
|
|
11874
|
+
reference.className = "user-message-mention";
|
|
11875
|
+
reference.textContent = name;
|
|
11876
|
+
references.append(reference);
|
|
11877
|
+
}
|
|
11878
|
+
message.append(references);
|
|
11879
|
+
}
|
|
11191
11880
|
if (citations.length) {
|
|
11192
11881
|
const references = document.createElement("div");
|
|
11193
11882
|
references.className = "message-citations";
|
|
@@ -11827,8 +12516,8 @@ function exportAvatarCropBlob() {
|
|
|
11827
12516
|
$("#avatar-file").addEventListener("change", async (event) => {
|
|
11828
12517
|
const file = event.target.files[0];
|
|
11829
12518
|
if (!file) return;
|
|
11830
|
-
if (file.size >
|
|
11831
|
-
toast(
|
|
12519
|
+
if (file.size > imageUploadLimits.avatarBytes) {
|
|
12520
|
+
toast(`头像图片不能超过 ${formatUploadLimit(imageUploadLimits.avatarBytes)}`, "error");
|
|
11832
12521
|
event.target.value = "";
|
|
11833
12522
|
return;
|
|
11834
12523
|
}
|
|
@@ -12081,6 +12770,7 @@ $("#login-form").addEventListener("submit", async (event) => {
|
|
|
12081
12770
|
captchaAnswer: form.get("captchaAnswer")
|
|
12082
12771
|
}
|
|
12083
12772
|
});
|
|
12773
|
+
if (systemRestartDetected) stagePresenceClientIdForRelogin(presenceSessionStorage, presenceClientId);
|
|
12084
12774
|
window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
|
|
12085
12775
|
window.location.reload();
|
|
12086
12776
|
} catch (error) {
|
|
@@ -12171,6 +12861,15 @@ $("#platform-ui-settings-return").addEventListener("click", () => returnToSettin
|
|
|
12171
12861
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
12172
12862
|
$("#s3-backup-close").addEventListener("click", () => $("#s3-backup-dialog").close());
|
|
12173
12863
|
$("#s3-backup-settings-return").addEventListener("click", () => returnToSettingsHub("#s3-backup-button", "#s3-backup-dialog").catch((error) => toast(error.message, "error")));
|
|
12864
|
+
$("#s3-backup-encryption-toggle").addEventListener("change", (event) => void changeS3BackupEncryption(event));
|
|
12865
|
+
$("#s3-backup-key-copy").addEventListener("click", () => void copyS3BackupEncryptionKey());
|
|
12866
|
+
$("#s3-backup-key-download").addEventListener("click", downloadS3BackupEncryptionKey);
|
|
12867
|
+
$("#s3-backup-key-confirm").addEventListener("click", () => void confirmS3BackupEncryptionKeySaved());
|
|
12868
|
+
$("#s3-backup-key-dialog").addEventListener("cancel", (event) => event.preventDefault());
|
|
12869
|
+
$("#s3-backup-key-dialog").addEventListener("close", () => {
|
|
12870
|
+
s3BackupEncryptionConfirmationToken = null;
|
|
12871
|
+
$("#s3-backup-key-value").value = "";
|
|
12872
|
+
});
|
|
12174
12873
|
$("#s3-backup-add").addEventListener("click", () => openS3BackupTargetDialog());
|
|
12175
12874
|
$("#s3-backup-run-all").addEventListener("click", (event) => void queueS3BackupRuns(null, event.currentTarget));
|
|
12176
12875
|
$("#s3-backup-refresh").addEventListener("click", async (event) => {
|
|
@@ -12308,8 +13007,8 @@ $("#character-editor-close").addEventListener("click", () => { void closeEntityE
|
|
|
12308
13007
|
$("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
|
|
12309
13008
|
$("#knowledge-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
|
|
12310
13009
|
$("#knowledge-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
|
|
12311
|
-
$("#setting-editor-form").addEventListener("input",
|
|
12312
|
-
$("#setting-editor-form").addEventListener("change",
|
|
13010
|
+
$("#setting-editor-form").addEventListener("input", () => syncSettingEditorDirty());
|
|
13011
|
+
$("#setting-editor-form").addEventListener("change", () => syncSettingEditorDirty());
|
|
12313
13012
|
$("#character-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
12314
13013
|
$("#character-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
12315
13014
|
$("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
@@ -12357,7 +13056,7 @@ $("#chapter-content").addEventListener("input", (event) => {
|
|
|
12357
13056
|
updateChapterStats();
|
|
12358
13057
|
scheduleChapterAutoSave();
|
|
12359
13058
|
clearChapterLineSelection();
|
|
12360
|
-
scheduleChapterLineNumbers();
|
|
13059
|
+
scheduleChapterLineNumbers(chapterLineInputRenderDelay);
|
|
12361
13060
|
setAiContextMeter(null);
|
|
12362
13061
|
});
|
|
12363
13062
|
$("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
|
|
@@ -12587,6 +13286,18 @@ $("#cover-file").addEventListener("change", async (event) => {
|
|
|
12587
13286
|
const file = event.target.files[0];
|
|
12588
13287
|
const workId = state.pendingCoverWorkId;
|
|
12589
13288
|
if (!file || !workId) return;
|
|
13289
|
+
if (isGifImageFile(file)) {
|
|
13290
|
+
toast("封面不支持 GIF 图片", "error");
|
|
13291
|
+
state.pendingCoverWorkId = null;
|
|
13292
|
+
event.target.value = "";
|
|
13293
|
+
return;
|
|
13294
|
+
}
|
|
13295
|
+
if (file.size > imageUploadLimits.coverBytes) {
|
|
13296
|
+
toast(coverFileSizeMessage(file), "error");
|
|
13297
|
+
state.pendingCoverWorkId = null;
|
|
13298
|
+
event.target.value = "";
|
|
13299
|
+
return;
|
|
13300
|
+
}
|
|
12590
13301
|
const body = new FormData();
|
|
12591
13302
|
body.append("file", file);
|
|
12592
13303
|
try {
|
|
@@ -12782,10 +13493,25 @@ $("#ai-history-dialog").addEventListener("close", () => {
|
|
|
12782
13493
|
$("#ai-history-toggle").setAttribute("aria-expanded", "false");
|
|
12783
13494
|
});
|
|
12784
13495
|
$("#ai-prompt").addEventListener("keydown", (event) => {
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
|
|
12788
|
-
|
|
13496
|
+
const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
|
|
13497
|
+
if (mentionMenuVisible) {
|
|
13498
|
+
if (!event.isComposing && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
|
13499
|
+
if (moveAiMentionActiveOption(event.key === "ArrowDown" ? 1 : -1)) event.preventDefault();
|
|
13500
|
+
return;
|
|
13501
|
+
}
|
|
13502
|
+
if (shouldSendAiPrompt(event)) {
|
|
13503
|
+
const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
|
|
13504
|
+
if (activeOption) {
|
|
13505
|
+
event.preventDefault();
|
|
13506
|
+
selectAiMention(activeOption);
|
|
13507
|
+
return;
|
|
13508
|
+
}
|
|
13509
|
+
}
|
|
13510
|
+
if (event.key === "Escape") {
|
|
13511
|
+
event.preventDefault();
|
|
13512
|
+
hideAiMentionMenu();
|
|
13513
|
+
return;
|
|
13514
|
+
}
|
|
12789
13515
|
}
|
|
12790
13516
|
if (shouldSendAiPrompt(event)) {
|
|
12791
13517
|
event.preventDefault();
|