@musnows/scriverse 0.4.7 → 0.4.10
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/dist/ai.js +33 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +117 -24
- package/dist/app.js.map +1 -1
- package/dist/collaboration-presence.js +77 -2
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +30 -1
- package/dist/database.js.map +1 -1
- package/dist/pagination.js +3 -2
- package/dist/pagination.js.map +1 -1
- package/dist/public/app.js +513 -43
- package/dist/public/avatar-crop.d.ts +57 -0
- package/dist/public/avatar-crop.js +201 -0
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +8 -0
- package/dist/public/index.html +68 -34
- package/dist/public/markdown.js +16 -0
- package/dist/public/styles.css +110 -13
- package/dist/store.js +8 -5
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +12 -2
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260725-galaxy-ripple";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
|
-
import { renderMarkdown } from "/markdown.js?v=
|
|
3
|
+
import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
@@ -29,8 +29,9 @@ import {
|
|
|
29
29
|
searchResultTypeLabel,
|
|
30
30
|
settingStatusLabel,
|
|
31
31
|
taskScopeLabel,
|
|
32
|
-
timelineStatusLabel
|
|
33
|
-
|
|
32
|
+
timelineStatusLabel,
|
|
33
|
+
characterStateFieldLabel
|
|
34
|
+
} from "/display-labels.js?v=20260725-state-field-zh";
|
|
34
35
|
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
|
|
35
36
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
36
37
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
@@ -40,6 +41,16 @@ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canW
|
|
|
40
41
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
41
42
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
42
43
|
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
|
|
44
|
+
import {
|
|
45
|
+
clampCropRect,
|
|
46
|
+
containImageRect,
|
|
47
|
+
cropOutputSize,
|
|
48
|
+
defaultCropRect,
|
|
49
|
+
mapDisplayPointToImage,
|
|
50
|
+
mapImageRectToDisplay,
|
|
51
|
+
moveCropRect,
|
|
52
|
+
resizeCropRect
|
|
53
|
+
} from "/avatar-crop.js?v=20260725-avatar-crop";
|
|
43
54
|
|
|
44
55
|
const state = {
|
|
45
56
|
user: null,
|
|
@@ -68,6 +79,7 @@ const state = {
|
|
|
68
79
|
relationshipMindMap: null,
|
|
69
80
|
relationshipExpandedMap: null,
|
|
70
81
|
collapsedVolumeIds: new Set(),
|
|
82
|
+
collapsedRaceIds: new Set(),
|
|
71
83
|
contextChapterId: null
|
|
72
84
|
};
|
|
73
85
|
|
|
@@ -86,6 +98,9 @@ let presenceParticipants = [];
|
|
|
86
98
|
let presenceHeartbeatTimer = null;
|
|
87
99
|
let presenceHeartbeatQueued = null;
|
|
88
100
|
let presenceHeartbeatRequest = 0;
|
|
101
|
+
const acknowledgedCollaborativeChangeIds = new Set();
|
|
102
|
+
let collaborativeChangePromptOpen = false;
|
|
103
|
+
let peerPageStale = false;
|
|
89
104
|
let collaborationAutoSaveDisabled = false;
|
|
90
105
|
|
|
91
106
|
let timelineMultiSelectEnabled = false;
|
|
@@ -179,17 +194,15 @@ function applyWorkAccessMode() {
|
|
|
179
194
|
$("#import-file-button").setAttribute("title", proseReadOnly ? "当前权限不能导入正文" : "导入 TXT / DOCX");
|
|
180
195
|
$("#import-file").disabled = proseReadOnly;
|
|
181
196
|
$(".ai-panel").classList.toggle("permission-hidden", aiHidden);
|
|
182
|
-
$("#chapter-title").readOnly = proseReadOnly;
|
|
183
|
-
$("#chapter-content").readOnly = proseReadOnly;
|
|
184
|
-
$("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
|
|
185
|
-
$("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
|
|
186
197
|
$("#ai-prompt").readOnly = aiReadOnly;
|
|
187
198
|
$("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
|
|
188
199
|
$("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
|
|
189
200
|
if (proseReadOnly) {
|
|
201
|
+
chapterEditorReadOnly = true;
|
|
190
202
|
cancelChapterAutoSave();
|
|
191
203
|
state.dirty = false;
|
|
192
204
|
}
|
|
205
|
+
applyChapterEditorMode();
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -493,12 +506,12 @@ function syncChapterAutoSaveWithPresence() {
|
|
|
493
506
|
collaborationAutoSaveDisabled = hasOtherCollaborators();
|
|
494
507
|
if (collaborationAutoSaveDisabled) {
|
|
495
508
|
cancelChapterAutoSave();
|
|
496
|
-
if (state.chapter && canEditProse()) {
|
|
509
|
+
if (state.chapter && canEditProse() && !chapterEditorReadOnly) {
|
|
497
510
|
setSaveState(state.dirty ? "多人协作,自动保存已关闭" : "自动保存已关闭", state.dirty);
|
|
498
511
|
}
|
|
499
512
|
return;
|
|
500
513
|
}
|
|
501
|
-
if (wasDisabled && state.dirty && state.chapter && canEditProse()) scheduleChapterAutoSave(250);
|
|
514
|
+
if (wasDisabled && state.dirty && state.chapter && canEditProse() && !chapterEditorReadOnly) scheduleChapterAutoSave(250);
|
|
502
515
|
}
|
|
503
516
|
|
|
504
517
|
function renderPresence() {
|
|
@@ -538,14 +551,16 @@ async function refreshPresence() {
|
|
|
538
551
|
return [];
|
|
539
552
|
}
|
|
540
553
|
try {
|
|
541
|
-
const
|
|
554
|
+
const payload = await api(`/api/works/${encodeURIComponent(workId)}/presence`, {
|
|
542
555
|
method: "POST",
|
|
543
556
|
body: { clientId: presenceClientId, page },
|
|
544
557
|
skipOptimisticVersion: true
|
|
545
558
|
});
|
|
546
559
|
if (state.work?.id === workId && presenceHeartbeatRequest === requestId) {
|
|
547
|
-
presenceParticipants = participants;
|
|
560
|
+
presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
|
|
548
561
|
renderPresence();
|
|
562
|
+
const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
|
|
563
|
+
void handleCollaborativeChanges(recentChanges);
|
|
549
564
|
}
|
|
550
565
|
} catch {
|
|
551
566
|
if (state.work?.id === workId) renderPresence();
|
|
@@ -555,6 +570,58 @@ async function refreshPresence() {
|
|
|
555
570
|
return presenceParticipants;
|
|
556
571
|
}
|
|
557
572
|
|
|
573
|
+
function localEditingDirty() {
|
|
574
|
+
return Boolean(state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function handleCollaborativeChanges(recentChanges) {
|
|
578
|
+
if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
|
|
579
|
+
const localKey = presencePageKey(presencePageForRoute());
|
|
580
|
+
if (!localKey) return;
|
|
581
|
+
const selfId = state.user?.userId;
|
|
582
|
+
const incoming = recentChanges.filter((change) => (
|
|
583
|
+
change
|
|
584
|
+
&& change.pageKey === localKey
|
|
585
|
+
&& change.actorUserId
|
|
586
|
+
&& change.actorUserId !== selfId
|
|
587
|
+
&& change.id
|
|
588
|
+
&& !acknowledgedCollaborativeChangeIds.has(change.id)
|
|
589
|
+
));
|
|
590
|
+
if (!incoming.length) return;
|
|
591
|
+
const latest = incoming[0];
|
|
592
|
+
for (const change of incoming) acknowledgedCollaborativeChangeIds.add(change.id);
|
|
593
|
+
while (acknowledgedCollaborativeChangeIds.size > 200) {
|
|
594
|
+
const oldest = acknowledgedCollaborativeChangeIds.values().next().value;
|
|
595
|
+
acknowledgedCollaborativeChangeIds.delete(oldest);
|
|
596
|
+
}
|
|
597
|
+
peerPageStale = true;
|
|
598
|
+
collaborativeChangePromptOpen = true;
|
|
599
|
+
const dirtyHint = localEditingDirty()
|
|
600
|
+
? "你当前页面还有未保存修改。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。"
|
|
601
|
+
: "请先确认本地没有需要保留的草稿;如有,请复制保存到别处(本页无法代为另存),再刷新页面后继续编辑。";
|
|
602
|
+
try {
|
|
603
|
+
const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新「${latest.label || "当前页面"}」。${dirtyHint}`, {
|
|
604
|
+
title: "协作者已更新",
|
|
605
|
+
confirmLabel: "刷新页面",
|
|
606
|
+
cancelLabel: "稍后处理"
|
|
607
|
+
});
|
|
608
|
+
if (shouldReload) window.location.reload();
|
|
609
|
+
} finally {
|
|
610
|
+
collaborativeChangePromptOpen = false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function confirmPeerStaleSave() {
|
|
615
|
+
if (!peerPageStale) return true;
|
|
616
|
+
const confirmed = await confirmToast("协作者已更新当前页面。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。继续保存可能覆盖对方修改或触发冲突。", {
|
|
617
|
+
title: "页面内容已过期",
|
|
618
|
+
confirmLabel: "仍然保存",
|
|
619
|
+
cancelLabel: "暂不保存"
|
|
620
|
+
});
|
|
621
|
+
if (confirmed) peerPageStale = false;
|
|
622
|
+
return confirmed;
|
|
623
|
+
}
|
|
624
|
+
|
|
558
625
|
function schedulePresenceHeartbeat() {
|
|
559
626
|
if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
|
|
560
627
|
presenceHeartbeatQueued = setTimeout(() => {
|
|
@@ -565,6 +632,7 @@ function schedulePresenceHeartbeat() {
|
|
|
565
632
|
|
|
566
633
|
async function confirmConcurrentSave() {
|
|
567
634
|
await refreshPresence();
|
|
635
|
+
if (!(await confirmPeerStaleSave())) return false;
|
|
568
636
|
const localKey = presencePageKey(presencePageForRoute());
|
|
569
637
|
const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
|
|
570
638
|
if (!peers.length) return true;
|
|
@@ -704,6 +772,7 @@ let settingsReturnContext = null;
|
|
|
704
772
|
let entityEditorType = null;
|
|
705
773
|
let entityEditorDirty = false;
|
|
706
774
|
let entityEditorReadOnly = false;
|
|
775
|
+
let chapterEditorReadOnly = true;
|
|
707
776
|
let characterListPage = 1;
|
|
708
777
|
const characterFilters = { raceIds: [], organizationIds: [] };
|
|
709
778
|
let characterFiltersPanelOpen = false;
|
|
@@ -727,6 +796,28 @@ let characterSectionVditor = null;
|
|
|
727
796
|
let entityHistoryContext = null;
|
|
728
797
|
let moduleContentInteractionsBound = false;
|
|
729
798
|
|
|
799
|
+
function applyChapterEditorMode() {
|
|
800
|
+
const permissionBlocked = Boolean(state.work) && !canEditProse();
|
|
801
|
+
const viewOnly = permissionBlocked || chapterEditorReadOnly;
|
|
802
|
+
$("#editor-view").classList.toggle("is-read-only", viewOnly);
|
|
803
|
+
$("#chapter-title").readOnly = viewOnly;
|
|
804
|
+
$("#chapter-content").readOnly = viewOnly;
|
|
805
|
+
$("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
|
|
806
|
+
$("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
|
|
807
|
+
$("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
|
|
808
|
+
if (viewOnly) cancelChapterAutoSave();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function enterChapterEditMode() {
|
|
812
|
+
if (!state.chapter || !canEditProse()) return;
|
|
813
|
+
chapterEditorReadOnly = false;
|
|
814
|
+
applyChapterEditorMode();
|
|
815
|
+
const draft = chapterDraftSnapshot();
|
|
816
|
+
if (!sameChapterSnapshot(draft, lastSavedChapterSnapshot)) scheduleChapterAutoSave(120);
|
|
817
|
+
else setSaveState("已保存");
|
|
818
|
+
$("#chapter-content").focus();
|
|
819
|
+
}
|
|
820
|
+
|
|
730
821
|
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
731
822
|
const module = type === "setting" ? "settings" : type === "character" ? "characters" : type === "race" ? "races" : "organizations";
|
|
732
823
|
const viewOnly = readOnly || !canEditModule(module);
|
|
@@ -1165,7 +1256,7 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1165
1256
|
story_index: "作品目录与章节概要",
|
|
1166
1257
|
read_chapters: "读取章节",
|
|
1167
1258
|
grep: "查询正文关键字",
|
|
1168
|
-
|
|
1259
|
+
search_story_entities: "搜索作品实体",
|
|
1169
1260
|
read_character_sections: "读取人物 Markdown 章节"
|
|
1170
1261
|
};
|
|
1171
1262
|
|
|
@@ -1173,7 +1264,7 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
1173
1264
|
story_index: "分页读取当前作品的卷章目录和章节概要。",
|
|
1174
1265
|
read_chapters: "读取指定章节的概要、正文或两者。",
|
|
1175
1266
|
grep: "查询正文关键字所在的完整段落及章节信息。",
|
|
1176
|
-
|
|
1267
|
+
search_story_entities: "按实体名或关键词子串匹配设定、人物、组织等结构化记录;非语义检索。",
|
|
1177
1268
|
read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。"
|
|
1178
1269
|
};
|
|
1179
1270
|
|
|
@@ -1783,7 +1874,8 @@ async function api(path, options = {}) {
|
|
|
1783
1874
|
});
|
|
1784
1875
|
if (!response.ok) {
|
|
1785
1876
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
1786
|
-
|
|
1877
|
+
// Presence is best-effort; a heartbeat 401 must not force the login wall.
|
|
1878
|
+
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
1787
1879
|
state.user = null;
|
|
1788
1880
|
state.csrfToken = null;
|
|
1789
1881
|
showAuth(false);
|
|
@@ -2084,7 +2176,7 @@ function cancelChapterAutoSave() {
|
|
|
2084
2176
|
}
|
|
2085
2177
|
|
|
2086
2178
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
2087
|
-
if (!state.chapter || !canEditProse()) return;
|
|
2179
|
+
if (!state.chapter || !canEditProse() || chapterEditorReadOnly) return;
|
|
2088
2180
|
cancelChapterAutoSave();
|
|
2089
2181
|
if (collaborationAutoSaveDisabled) {
|
|
2090
2182
|
setSaveState("多人协作,自动保存已关闭", true);
|
|
@@ -2098,7 +2190,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
|
2098
2190
|
}
|
|
2099
2191
|
|
|
2100
2192
|
async function persistChapter({ automatic = false } = {}) {
|
|
2101
|
-
if (!canEditProse()) return null;
|
|
2193
|
+
if (!canEditProse() || chapterEditorReadOnly) return null;
|
|
2102
2194
|
if (!state.chapter) {
|
|
2103
2195
|
if (!automatic) toast("请先选择章节", "error");
|
|
2104
2196
|
return null;
|
|
@@ -2679,6 +2771,7 @@ function resetWorkScopedUiCaches() {
|
|
|
2679
2771
|
state.settings = [];
|
|
2680
2772
|
characterListPage = 1;
|
|
2681
2773
|
state.collapsedVolumeIds.clear();
|
|
2774
|
+
state.collapsedRaceIds.clear();
|
|
2682
2775
|
lastSavedChapterSnapshot = null;
|
|
2683
2776
|
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
2684
2777
|
aiContextUsageTimer = null;
|
|
@@ -2701,7 +2794,7 @@ function resetWorkScopedUiCaches() {
|
|
|
2701
2794
|
async function selectWork(workId, preferredChapterId = null) {
|
|
2702
2795
|
const discarding = state.work?.id !== workId && state.dirty;
|
|
2703
2796
|
if (discarding && !(await confirmDiscardChanges())) return false;
|
|
2704
|
-
const nextWork = await api(`/api/works/${workId}
|
|
2797
|
+
const nextWork = await api(`/api/works/${workId}`);
|
|
2705
2798
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
2706
2799
|
if (discarding) setSaveState("就绪");
|
|
2707
2800
|
$("#app").classList.remove("shelf-mode");
|
|
@@ -2712,6 +2805,7 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
2712
2805
|
settingsReturnContext = null;
|
|
2713
2806
|
state.work = nextWork;
|
|
2714
2807
|
state.chapter = null;
|
|
2808
|
+
chapterEditorReadOnly = true;
|
|
2715
2809
|
if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
|
|
2716
2810
|
applyWorkAccessMode();
|
|
2717
2811
|
updateDocumentTitle(state.work);
|
|
@@ -2800,11 +2894,12 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
|
|
|
2800
2894
|
menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
|
|
2801
2895
|
}
|
|
2802
2896
|
|
|
2803
|
-
async function selectChapter(chapterId) {
|
|
2897
|
+
async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
2804
2898
|
if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
|
|
2805
2899
|
cancelChapterAutoSave();
|
|
2806
2900
|
state.chapter = await api(`/api/chapters/${chapterId}`);
|
|
2807
2901
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
2902
|
+
chapterEditorReadOnly = !canEditProse() || !editMode;
|
|
2808
2903
|
state.module = "editor";
|
|
2809
2904
|
applyWorkAccessMode();
|
|
2810
2905
|
markActiveModule("editor");
|
|
@@ -2824,6 +2919,7 @@ async function selectChapter(chapterId) {
|
|
|
2824
2919
|
$("#chapter-insight").classList.add("hidden");
|
|
2825
2920
|
updateChapterStats();
|
|
2826
2921
|
if (!canEditProse()) setSaveState("正文只读");
|
|
2922
|
+
else if (chapterEditorReadOnly) setSaveState("阅读模式");
|
|
2827
2923
|
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
2828
2924
|
else setSaveState("已保存");
|
|
2829
2925
|
renderTree();
|
|
@@ -2843,6 +2939,7 @@ async function saveChapter() {
|
|
|
2843
2939
|
|
|
2844
2940
|
function tidyChapterBlankLines() {
|
|
2845
2941
|
if (!state.chapter) return toast("请先选择章节", "error");
|
|
2942
|
+
if (chapterEditorReadOnly || !canEditProse()) return toast("请先进入编辑模式", "error");
|
|
2846
2943
|
const input = $("#chapter-content");
|
|
2847
2944
|
const normalized = normalizeParagraphSpacing(input.value);
|
|
2848
2945
|
if (normalized === input.value) return toast("正文空行已经符合要求");
|
|
@@ -3131,6 +3228,12 @@ function mountModuleLayoutToggle(layout, ariaLabel) {
|
|
|
3131
3228
|
$("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
|
|
3132
3229
|
}
|
|
3133
3230
|
|
|
3231
|
+
function mountModuleCount(count) {
|
|
3232
|
+
$("#module-header-actions").querySelector('[data-module-header-action="count"]')?.remove();
|
|
3233
|
+
const safeCount = Math.max(0, Number(count) || 0);
|
|
3234
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<span class="module-count-badge" data-module-header-action="count" aria-label="列表数量 ${safeCount}">${safeCount}</span>`);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3134
3237
|
function bindModuleLayoutToggle(refresh) {
|
|
3135
3238
|
$("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
|
|
3136
3239
|
saveModuleLayout(button.dataset.moduleLayout);
|
|
@@ -3138,6 +3241,72 @@ function bindModuleLayoutToggle(refresh) {
|
|
|
3138
3241
|
}));
|
|
3139
3242
|
}
|
|
3140
3243
|
|
|
3244
|
+
function raceTreeExpandIconMarkup(action) {
|
|
3245
|
+
if (action === "collapse") {
|
|
3246
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 15 6-6 6 6"></path></svg>';
|
|
3247
|
+
}
|
|
3248
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg>';
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
function raceTreeExpandAction() {
|
|
3252
|
+
return state.collapsedRaceIds.size === 0 ? "collapse" : "expand";
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function renderRaceTreeExpandToggle() {
|
|
3256
|
+
const action = raceTreeExpandAction();
|
|
3257
|
+
const label = action === "collapse" ? "全部折叠" : "全部展开";
|
|
3258
|
+
return `<div class="module-layout-toolbar race-tree-expand-toolbar" data-module-header-action="race-tree-expand">
|
|
3259
|
+
<div class="module-layout-toggle" role="group" aria-label="种族树展开折叠">
|
|
3260
|
+
<button type="button" data-race-tree-expand="${action}" aria-label="${label}" title="${label}">${raceTreeExpandIconMarkup(action)}</button>
|
|
3261
|
+
</div>
|
|
3262
|
+
</div>`;
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function mountRaceTreeExpandToggle() {
|
|
3266
|
+
$("#module-header-actions").querySelector('[data-module-header-action="race-tree-expand"]')?.remove();
|
|
3267
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", renderRaceTreeExpandToggle());
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function syncRaceTreeExpandToggle() {
|
|
3271
|
+
const button = $("#module-header-actions").querySelector("[data-race-tree-expand]");
|
|
3272
|
+
if (!button) return;
|
|
3273
|
+
const action = raceTreeExpandAction();
|
|
3274
|
+
const label = action === "collapse" ? "全部折叠" : "全部展开";
|
|
3275
|
+
button.dataset.raceTreeExpand = action;
|
|
3276
|
+
button.setAttribute("aria-label", label);
|
|
3277
|
+
button.title = label;
|
|
3278
|
+
button.innerHTML = raceTreeExpandIconMarkup(action);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
function setAllRaceTreeNodesOpen(open) {
|
|
3282
|
+
const nodes = $("#module-content").querySelectorAll("details.race-tree-node[data-race-node]");
|
|
3283
|
+
if (open) state.collapsedRaceIds.clear();
|
|
3284
|
+
else nodes.forEach((node) => state.collapsedRaceIds.add(node.dataset.raceNode));
|
|
3285
|
+
nodes.forEach((node) => {
|
|
3286
|
+
node.open = open;
|
|
3287
|
+
});
|
|
3288
|
+
syncRaceTreeExpandToggle();
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
function bindRaceTreeNodeToggles() {
|
|
3292
|
+
$("#module-content").querySelectorAll("details.race-tree-node[data-race-node]").forEach((node) => {
|
|
3293
|
+
node.addEventListener("toggle", () => {
|
|
3294
|
+
const raceId = node.dataset.raceNode;
|
|
3295
|
+
if (!raceId) return;
|
|
3296
|
+
if (node.open) state.collapsedRaceIds.delete(raceId);
|
|
3297
|
+
else state.collapsedRaceIds.add(raceId);
|
|
3298
|
+
syncRaceTreeExpandToggle();
|
|
3299
|
+
});
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
function bindRaceTreeExpandToggle() {
|
|
3304
|
+
$("#module-header-actions").querySelector("[data-race-tree-expand]")?.addEventListener("click", (event) => {
|
|
3305
|
+
const button = event.currentTarget;
|
|
3306
|
+
setAllRaceTreeNodesOpen(button.dataset.raceTreeExpand === "expand");
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3141
3310
|
function mountCharacterFilterToggle() {
|
|
3142
3311
|
$("#module-header-actions").querySelector('[data-module-header-action="character-filter-toggle"]')?.remove();
|
|
3143
3312
|
$("#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>`);
|
|
@@ -3251,8 +3420,9 @@ function renderSettingRows(records) {
|
|
|
3251
3420
|
}
|
|
3252
3421
|
|
|
3253
3422
|
async function renderSettings() {
|
|
3254
|
-
const records =
|
|
3423
|
+
const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
|
|
3255
3424
|
state.settings = records;
|
|
3425
|
+
mountModuleCount(records.length);
|
|
3256
3426
|
const layout = readModuleLayout();
|
|
3257
3427
|
if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
|
|
3258
3428
|
$("#module-content").innerHTML = records.length
|
|
@@ -3276,7 +3446,8 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3276
3446
|
: characterSource;
|
|
3277
3447
|
if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
|
|
3278
3448
|
characterListPage = characterPage.page;
|
|
3279
|
-
state.characters = characterPage.items;
|
|
3449
|
+
[state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
|
|
3450
|
+
mountModuleCount(characterPage.total);
|
|
3280
3451
|
const layout = readModuleLayout();
|
|
3281
3452
|
const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
|
|
3282
3453
|
const characterLockBadge = (item) => item.lockedFields.length
|
|
@@ -3291,14 +3462,14 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3291
3462
|
${item.aliases.length ? `<div class="character-aliases"><b>别名</b>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
3292
3463
|
${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
|
|
3293
3464
|
${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
|
|
3294
|
-
${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
|
|
3465
|
+
${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(characterStateFieldLabel(detail.label))}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
|
|
3295
3466
|
<div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
|
|
3296
|
-
${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
|
|
3467
|
+
${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
|
|
3297
3468
|
${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
|
|
3298
3469
|
</article>`;
|
|
3299
3470
|
}).join("")}</div>`;
|
|
3300
3471
|
const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
|
|
3301
|
-
const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join(" ") || "尚未记录当前状态");
|
|
3472
|
+
const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
|
|
3302
3473
|
const meta = [
|
|
3303
3474
|
item.code ? `编号 ${item.code}` : "",
|
|
3304
3475
|
item.species ? (racePathLabel(item.race) || item.species) : "",
|
|
@@ -3316,7 +3487,7 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3316
3487
|
const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
|
|
3317
3488
|
? `<nav class="module-pagination" aria-label="角色列表分页">
|
|
3318
3489
|
<button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
|
|
3319
|
-
<span>第 ${characterPage.page} 页 · 本页 ${state.characters.length} 个角色</span>
|
|
3490
|
+
<span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${state.characters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
|
|
3320
3491
|
<button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
|
|
3321
3492
|
</nav>`
|
|
3322
3493
|
: "";
|
|
@@ -3370,7 +3541,11 @@ async function renderCharacters(page = characterListPage) {
|
|
|
3370
3541
|
}
|
|
3371
3542
|
|
|
3372
3543
|
async function renderRaces() {
|
|
3373
|
-
state.races = await
|
|
3544
|
+
[state.races, state.characters] = await Promise.all([
|
|
3545
|
+
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
3546
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
3547
|
+
]);
|
|
3548
|
+
mountModuleCount(state.races.length);
|
|
3374
3549
|
const layout = readModuleLayout();
|
|
3375
3550
|
const canEditRaces = canEditModule("races");
|
|
3376
3551
|
const raceActions = (item) => canEditRaces
|
|
@@ -3379,7 +3554,7 @@ async function renderRaces() {
|
|
|
3379
3554
|
const raceCardActions = (item) => canEditRaces
|
|
3380
3555
|
? raceActions(item)
|
|
3381
3556
|
: `<div class="card-actions">${raceActions(item)}</div>`;
|
|
3382
|
-
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
3557
|
+
const renderRaceNode = (item) => `<details class="race-tree-node"${state.collapsedRaceIds.has(item.id) ? "" : " open"} data-race-node="${esc(item.id)}">
|
|
3383
3558
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
3384
3559
|
<div class="race-tree-branch">
|
|
3385
3560
|
<article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settingsCount ?? item.settings?.length ?? 0} 条自身设定</small>
|
|
@@ -3404,17 +3579,24 @@ async function renderRaces() {
|
|
|
3404
3579
|
</article>`;
|
|
3405
3580
|
}).join("")}</div>`;
|
|
3406
3581
|
if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
|
|
3582
|
+
if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
|
|
3407
3583
|
$("#module-content").innerHTML = state.races.length
|
|
3408
3584
|
? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
|
|
3409
3585
|
: emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
3410
3586
|
bindModuleLayoutToggle(renderRaces);
|
|
3587
|
+
bindRaceTreeExpandToggle();
|
|
3588
|
+
bindRaceTreeNodeToggles();
|
|
3411
3589
|
const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
|
|
3412
3590
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
|
|
3413
3591
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
3414
3592
|
}
|
|
3415
3593
|
|
|
3416
3594
|
async function renderOrganizations() {
|
|
3417
|
-
state.organizations = await
|
|
3595
|
+
[state.organizations, state.characters] = await Promise.all([
|
|
3596
|
+
apiAllPages(`/api/works/${state.work.id}/organizations`),
|
|
3597
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
3598
|
+
]);
|
|
3599
|
+
mountModuleCount(state.organizations.length);
|
|
3418
3600
|
const layout = readModuleLayout();
|
|
3419
3601
|
const canEditOrganizations = canEditModule("organizations");
|
|
3420
3602
|
const organizationActions = (item) => canEditOrganizations
|
|
@@ -3475,9 +3657,10 @@ function setTimelineMultiSelectMode(enabled) {
|
|
|
3475
3657
|
|
|
3476
3658
|
async function renderTimeline() {
|
|
3477
3659
|
const [events, tracks] = await Promise.all([
|
|
3478
|
-
|
|
3660
|
+
apiAllPages(`/api/works/${state.work.id}/timeline`),
|
|
3479
3661
|
apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
|
|
3480
3662
|
]);
|
|
3663
|
+
mountModuleCount(events.length);
|
|
3481
3664
|
timelineMultiSelectEnabled = false;
|
|
3482
3665
|
$("#timeline-tools")?.remove();
|
|
3483
3666
|
$("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
|
|
@@ -3515,9 +3698,10 @@ async function renderTimeline() {
|
|
|
3515
3698
|
async function renderOutlines() {
|
|
3516
3699
|
const currentChapterId = state.chapter?.id;
|
|
3517
3700
|
const [outlines, foreshadows] = await Promise.all([
|
|
3518
|
-
|
|
3519
|
-
|
|
3701
|
+
apiAllPages(`/api/works/${state.work.id}/outlines`),
|
|
3702
|
+
apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `¤tChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
|
|
3520
3703
|
]);
|
|
3704
|
+
mountModuleCount(outlines.length + foreshadows.length);
|
|
3521
3705
|
const layout = readModuleLayout();
|
|
3522
3706
|
const unresolved = foreshadows.filter((item) => item.unresolved);
|
|
3523
3707
|
const overdue = unresolved.filter((item) => item.overdue);
|
|
@@ -3563,7 +3747,8 @@ async function renderOutlines() {
|
|
|
3563
3747
|
|
|
3564
3748
|
async function renderRelationships() {
|
|
3565
3749
|
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
3566
|
-
const relationships =
|
|
3750
|
+
const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
|
|
3751
|
+
mountModuleCount(relationships.length);
|
|
3567
3752
|
const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
|
|
3568
3753
|
state.galaxy?.destroy();
|
|
3569
3754
|
state.relationshipExpandedMap?.destroy?.();
|
|
@@ -3598,9 +3783,10 @@ async function renderReviews() {
|
|
|
3598
3783
|
const canMergeCharacters = canResolveReview
|
|
3599
3784
|
&& ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
|
|
3600
3785
|
const [reviews, characters] = await Promise.all([
|
|
3601
|
-
|
|
3786
|
+
apiAllPages(`/api/works/${state.work.id}/reviews`),
|
|
3602
3787
|
canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
|
|
3603
3788
|
]);
|
|
3789
|
+
mountModuleCount(reviews.length);
|
|
3604
3790
|
const characterById = new Map(characters.map((character) => [character.id, character]));
|
|
3605
3791
|
const duplicateCard = (item) => {
|
|
3606
3792
|
const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
|
|
@@ -3685,11 +3871,12 @@ async function renderReviews() {
|
|
|
3685
3871
|
|
|
3686
3872
|
async function renderTasks() {
|
|
3687
3873
|
const [tasks, settings] = await Promise.all([
|
|
3688
|
-
|
|
3874
|
+
apiAllPages(`/api/works/${state.work.id}/tasks?view=summary`),
|
|
3689
3875
|
canReadModule("ai-settings")
|
|
3690
3876
|
? api(`/api/works/${state.work.id}/ai-settings`)
|
|
3691
3877
|
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
|
|
3692
3878
|
]);
|
|
3879
|
+
mountModuleCount(tasks.length);
|
|
3693
3880
|
const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
|
|
3694
3881
|
const pendingCount = tasks.filter((item) => item.status === "pending").length;
|
|
3695
3882
|
const runningCount = tasks.filter((item) => item.status === "running").length;
|
|
@@ -3906,19 +4093,19 @@ async function renderBookAiSettings() {
|
|
|
3906
4093
|
api(`/api/works/${state.work.id}/task-defaults`)
|
|
3907
4094
|
]);
|
|
3908
4095
|
const host = $("#module-content");
|
|
3909
|
-
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "
|
|
3910
|
-
host.innerHTML = `<section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="
|
|
3911
|
-
host.querySelector('input[name="agent-tool"][value="
|
|
4096
|
+
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
|
|
4097
|
+
host.innerHTML = `<section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
|
|
4098
|
+
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
3912
4099
|
"beforebegin",
|
|
3913
4100
|
`<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
|
|
3914
4101
|
);
|
|
3915
|
-
host.querySelector('input[name="agent-tool"][value="
|
|
4102
|
+
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
3916
4103
|
"afterend",
|
|
3917
4104
|
`<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
|
|
3918
4105
|
);
|
|
3919
4106
|
if (!canEditModule("ai-settings")) {
|
|
3920
4107
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
3921
|
-
host.querySelectorAll(".
|
|
4108
|
+
host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
3922
4109
|
}
|
|
3923
4110
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
3924
4111
|
const button = $("#save-work-system-prompt");
|
|
@@ -3951,7 +4138,7 @@ async function renderBookAiSettings() {
|
|
|
3951
4138
|
button.disabled = true;
|
|
3952
4139
|
try {
|
|
3953
4140
|
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
|
|
3954
|
-
toast("
|
|
4141
|
+
toast("对话上下文 compact 阈值已保存");
|
|
3955
4142
|
scheduleAiContextUsage();
|
|
3956
4143
|
} catch (error) {
|
|
3957
4144
|
toast(error.message, "error");
|
|
@@ -4488,7 +4675,7 @@ async function openChapterDialog(volumeId = null) {
|
|
|
4488
4675
|
openDialog("新建章节", field("title", "章节标题") + field("volumeId", "所属卷", "select", selectedVolumeId, state.work.volumes.map((volume) => [volume.id, volume.title])) + field("chapterType", "章节类型", "select", "正文", chapterTypes.map((value) => [value, value])), async (form) => {
|
|
4489
4676
|
const chapter = await api(`/api/works/${state.work.id}/chapters`, { method: "POST", body: { title: form.get("title"), volumeId: form.get("volumeId"), chapterType: form.get("chapterType"), content: "" } });
|
|
4490
4677
|
state.work = await api(`/api/works/${state.work.id}`);
|
|
4491
|
-
await selectChapter(chapter.id);
|
|
4678
|
+
await selectChapter(chapter.id, { editMode: true });
|
|
4492
4679
|
});
|
|
4493
4680
|
}
|
|
4494
4681
|
|
|
@@ -6243,6 +6430,196 @@ $("#account-settings-button").addEventListener("click", () => {
|
|
|
6243
6430
|
});
|
|
6244
6431
|
$("#account-dialog-close").addEventListener("click", () => $("#account-dialog").close());
|
|
6245
6432
|
$("#avatar-upload-button").addEventListener("click", () => $("#avatar-file").click());
|
|
6433
|
+
|
|
6434
|
+
const avatarCropSession = {
|
|
6435
|
+
objectUrl: null,
|
|
6436
|
+
imageWidth: 0,
|
|
6437
|
+
imageHeight: 0,
|
|
6438
|
+
crop: { x: 0, y: 0, size: 0 },
|
|
6439
|
+
display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
|
|
6440
|
+
drag: null,
|
|
6441
|
+
uploading: false
|
|
6442
|
+
};
|
|
6443
|
+
|
|
6444
|
+
function releaseAvatarCropObjectUrl() {
|
|
6445
|
+
if (!avatarCropSession.objectUrl) return;
|
|
6446
|
+
URL.revokeObjectURL(avatarCropSession.objectUrl);
|
|
6447
|
+
avatarCropSession.objectUrl = null;
|
|
6448
|
+
}
|
|
6449
|
+
|
|
6450
|
+
function closeAvatarCropDialog() {
|
|
6451
|
+
const dialog = $("#avatar-crop-dialog");
|
|
6452
|
+
if (dialog?.open) dialog.close();
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
function resetAvatarCropDialog() {
|
|
6456
|
+
releaseAvatarCropObjectUrl();
|
|
6457
|
+
avatarCropSession.drag = null;
|
|
6458
|
+
avatarCropSession.imageWidth = 0;
|
|
6459
|
+
avatarCropSession.imageHeight = 0;
|
|
6460
|
+
avatarCropSession.crop = { x: 0, y: 0, size: 0 };
|
|
6461
|
+
const image = $("#avatar-crop-image");
|
|
6462
|
+
if (image) image.removeAttribute("src");
|
|
6463
|
+
$("#avatar-crop-selection")?.setAttribute("hidden", "");
|
|
6464
|
+
$("#avatar-crop-preview")?.replaceChildren();
|
|
6465
|
+
const fileInput = $("#avatar-file");
|
|
6466
|
+
if (fileInput) fileInput.value = "";
|
|
6467
|
+
}
|
|
6468
|
+
|
|
6469
|
+
function stagePointFromEvent(event) {
|
|
6470
|
+
const stage = $("#avatar-crop-stage");
|
|
6471
|
+
const bounds = stage.getBoundingClientRect();
|
|
6472
|
+
return {
|
|
6473
|
+
x: event.clientX - bounds.left,
|
|
6474
|
+
y: event.clientY - bounds.top
|
|
6475
|
+
};
|
|
6476
|
+
}
|
|
6477
|
+
|
|
6478
|
+
function paintAvatarCropPreview() {
|
|
6479
|
+
const preview = $("#avatar-crop-preview");
|
|
6480
|
+
const image = $("#avatar-crop-image");
|
|
6481
|
+
if (!preview || !image?.naturalWidth || avatarCropSession.crop.size < 1) {
|
|
6482
|
+
preview?.replaceChildren();
|
|
6483
|
+
return;
|
|
6484
|
+
}
|
|
6485
|
+
const output = cropOutputSize(avatarCropSession.crop.size);
|
|
6486
|
+
let canvas = preview.querySelector("canvas");
|
|
6487
|
+
if (!canvas) {
|
|
6488
|
+
preview.replaceChildren();
|
|
6489
|
+
canvas = document.createElement("canvas");
|
|
6490
|
+
preview.append(canvas);
|
|
6491
|
+
}
|
|
6492
|
+
canvas.width = output;
|
|
6493
|
+
canvas.height = output;
|
|
6494
|
+
const context = canvas.getContext("2d");
|
|
6495
|
+
if (!context) return;
|
|
6496
|
+
context.clearRect(0, 0, output, output);
|
|
6497
|
+
context.beginPath();
|
|
6498
|
+
context.arc(output / 2, output / 2, output / 2, 0, Math.PI * 2);
|
|
6499
|
+
context.closePath();
|
|
6500
|
+
context.clip();
|
|
6501
|
+
context.drawImage(
|
|
6502
|
+
image,
|
|
6503
|
+
avatarCropSession.crop.x,
|
|
6504
|
+
avatarCropSession.crop.y,
|
|
6505
|
+
avatarCropSession.crop.size,
|
|
6506
|
+
avatarCropSession.crop.size,
|
|
6507
|
+
0,
|
|
6508
|
+
0,
|
|
6509
|
+
output,
|
|
6510
|
+
output
|
|
6511
|
+
);
|
|
6512
|
+
}
|
|
6513
|
+
|
|
6514
|
+
function renderAvatarCropSelection() {
|
|
6515
|
+
const stage = $("#avatar-crop-stage");
|
|
6516
|
+
const selection = $("#avatar-crop-selection");
|
|
6517
|
+
const shade = $("#avatar-crop-shade");
|
|
6518
|
+
const image = $("#avatar-crop-image");
|
|
6519
|
+
if (!stage || !selection || !shade || !image?.naturalWidth) return;
|
|
6520
|
+
|
|
6521
|
+
avatarCropSession.display = containImageRect(
|
|
6522
|
+
avatarCropSession.imageWidth,
|
|
6523
|
+
avatarCropSession.imageHeight,
|
|
6524
|
+
stage.clientWidth,
|
|
6525
|
+
stage.clientHeight
|
|
6526
|
+
);
|
|
6527
|
+
const shown = mapImageRectToDisplay(avatarCropSession.crop, avatarCropSession.display);
|
|
6528
|
+
if (shown.size < 1) {
|
|
6529
|
+
selection.setAttribute("hidden", "");
|
|
6530
|
+
shade.style.webkitMaskSize = "100% 100%, 0 0";
|
|
6531
|
+
shade.style.maskSize = "100% 100%, 0 0";
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
selection.hidden = false;
|
|
6535
|
+
selection.style.left = `${shown.x}px`;
|
|
6536
|
+
selection.style.top = `${shown.y}px`;
|
|
6537
|
+
selection.style.width = `${shown.size}px`;
|
|
6538
|
+
selection.style.height = `${shown.size}px`;
|
|
6539
|
+
const hole = `${Math.max(0, shown.size)}px ${Math.max(0, shown.size)}px`;
|
|
6540
|
+
const position = `${shown.x}px ${shown.y}px`;
|
|
6541
|
+
shade.style.webkitMaskSize = `100% 100%, ${hole}`;
|
|
6542
|
+
shade.style.maskSize = `100% 100%, ${hole}`;
|
|
6543
|
+
shade.style.webkitMaskPosition = `0 0, ${position}`;
|
|
6544
|
+
shade.style.maskPosition = `0 0, ${position}`;
|
|
6545
|
+
paintAvatarCropPreview();
|
|
6546
|
+
}
|
|
6547
|
+
|
|
6548
|
+
function applyAvatarCropRect(next) {
|
|
6549
|
+
avatarCropSession.crop = clampCropRect(next, avatarCropSession.imageWidth, avatarCropSession.imageHeight);
|
|
6550
|
+
renderAvatarCropSelection();
|
|
6551
|
+
}
|
|
6552
|
+
|
|
6553
|
+
async function openAvatarCropDialog(file) {
|
|
6554
|
+
releaseAvatarCropObjectUrl();
|
|
6555
|
+
const objectUrl = URL.createObjectURL(file);
|
|
6556
|
+
avatarCropSession.objectUrl = objectUrl;
|
|
6557
|
+
const image = $("#avatar-crop-image");
|
|
6558
|
+
const dialog = $("#avatar-crop-dialog");
|
|
6559
|
+
await new Promise((resolve, reject) => {
|
|
6560
|
+
const onLoad = () => {
|
|
6561
|
+
image.removeEventListener("error", onError);
|
|
6562
|
+
resolve();
|
|
6563
|
+
};
|
|
6564
|
+
const onError = () => {
|
|
6565
|
+
image.removeEventListener("load", onLoad);
|
|
6566
|
+
reject(new Error("无法读取所选图片"));
|
|
6567
|
+
};
|
|
6568
|
+
image.addEventListener("load", onLoad, { once: true });
|
|
6569
|
+
image.addEventListener("error", onError, { once: true });
|
|
6570
|
+
image.src = objectUrl;
|
|
6571
|
+
});
|
|
6572
|
+
avatarCropSession.imageWidth = image.naturalWidth;
|
|
6573
|
+
avatarCropSession.imageHeight = image.naturalHeight;
|
|
6574
|
+
if (avatarCropSession.imageWidth > 4096 || avatarCropSession.imageHeight > 4096) {
|
|
6575
|
+
resetAvatarCropDialog();
|
|
6576
|
+
throw new Error("图片尺寸不能超过 4096 × 4096 像素");
|
|
6577
|
+
}
|
|
6578
|
+
avatarCropSession.crop = defaultCropRect(avatarCropSession.imageWidth, avatarCropSession.imageHeight);
|
|
6579
|
+
if (avatarCropSession.crop.size < 1) {
|
|
6580
|
+
resetAvatarCropDialog();
|
|
6581
|
+
throw new Error("图片尺寸无效");
|
|
6582
|
+
}
|
|
6583
|
+
if (!dialog.open) dialog.showModal();
|
|
6584
|
+
requestAnimationFrame(() => {
|
|
6585
|
+
renderAvatarCropSelection();
|
|
6586
|
+
$("#avatar-crop-confirm")?.focus();
|
|
6587
|
+
});
|
|
6588
|
+
}
|
|
6589
|
+
|
|
6590
|
+
function exportAvatarCropBlob() {
|
|
6591
|
+
const image = $("#avatar-crop-image");
|
|
6592
|
+
if (!image?.naturalWidth || avatarCropSession.crop.size < 1) {
|
|
6593
|
+
return Promise.reject(new Error("请先完成头像裁剪"));
|
|
6594
|
+
}
|
|
6595
|
+
const output = cropOutputSize(avatarCropSession.crop.size);
|
|
6596
|
+
const canvas = document.createElement("canvas");
|
|
6597
|
+
canvas.width = output;
|
|
6598
|
+
canvas.height = output;
|
|
6599
|
+
const context = canvas.getContext("2d");
|
|
6600
|
+
if (!context) return Promise.reject(new Error("当前浏览器无法裁剪图片"));
|
|
6601
|
+
context.drawImage(
|
|
6602
|
+
image,
|
|
6603
|
+
avatarCropSession.crop.x,
|
|
6604
|
+
avatarCropSession.crop.y,
|
|
6605
|
+
avatarCropSession.crop.size,
|
|
6606
|
+
avatarCropSession.crop.size,
|
|
6607
|
+
0,
|
|
6608
|
+
0,
|
|
6609
|
+
output,
|
|
6610
|
+
output
|
|
6611
|
+
);
|
|
6612
|
+
return new Promise((resolve, reject) => {
|
|
6613
|
+
canvas.toBlob((blob) => {
|
|
6614
|
+
if (!blob) {
|
|
6615
|
+
reject(new Error("头像裁剪失败"));
|
|
6616
|
+
return;
|
|
6617
|
+
}
|
|
6618
|
+
resolve(blob);
|
|
6619
|
+
}, "image/png");
|
|
6620
|
+
});
|
|
6621
|
+
}
|
|
6622
|
+
|
|
6246
6623
|
$("#avatar-file").addEventListener("change", async (event) => {
|
|
6247
6624
|
const file = event.target.files[0];
|
|
6248
6625
|
if (!file) return;
|
|
@@ -6251,23 +6628,115 @@ $("#avatar-file").addEventListener("change", async (event) => {
|
|
|
6251
6628
|
event.target.value = "";
|
|
6252
6629
|
return;
|
|
6253
6630
|
}
|
|
6254
|
-
|
|
6255
|
-
|
|
6631
|
+
try {
|
|
6632
|
+
await openAvatarCropDialog(file);
|
|
6633
|
+
} catch (error) {
|
|
6634
|
+
toast(error.message, "error");
|
|
6635
|
+
event.target.value = "";
|
|
6636
|
+
}
|
|
6637
|
+
});
|
|
6638
|
+
|
|
6639
|
+
$("#avatar-crop-stage").addEventListener("pointerdown", (event) => {
|
|
6640
|
+
if (!$("#avatar-crop-dialog")?.open || avatarCropSession.uploading) return;
|
|
6641
|
+
const handle = event.target.closest("[data-avatar-crop-handle]")?.dataset.avatarCropHandle;
|
|
6642
|
+
const onSelection = event.target.closest("#avatar-crop-selection");
|
|
6643
|
+
if (!handle && !onSelection) return;
|
|
6644
|
+
event.preventDefault();
|
|
6645
|
+
const point = stagePointFromEvent(event);
|
|
6646
|
+
const imagePoint = mapDisplayPointToImage(point, avatarCropSession.display);
|
|
6647
|
+
avatarCropSession.drag = {
|
|
6648
|
+
mode: handle ? "resize" : "move",
|
|
6649
|
+
handle: handle || null,
|
|
6650
|
+
originX: imagePoint.x,
|
|
6651
|
+
originY: imagePoint.y,
|
|
6652
|
+
startCrop: { ...avatarCropSession.crop }
|
|
6653
|
+
};
|
|
6654
|
+
$("#avatar-crop-selection").classList.add("is-dragging");
|
|
6655
|
+
$("#avatar-crop-stage").setPointerCapture(event.pointerId);
|
|
6656
|
+
});
|
|
6657
|
+
|
|
6658
|
+
$("#avatar-crop-stage").addEventListener("pointermove", (event) => {
|
|
6659
|
+
if (!avatarCropSession.drag) return;
|
|
6660
|
+
const point = stagePointFromEvent(event);
|
|
6661
|
+
const imagePoint = mapDisplayPointToImage(point, avatarCropSession.display);
|
|
6662
|
+
if (avatarCropSession.drag.mode === "move") {
|
|
6663
|
+
applyAvatarCropRect(moveCropRect(
|
|
6664
|
+
avatarCropSession.drag.startCrop,
|
|
6665
|
+
imagePoint.x - avatarCropSession.drag.originX,
|
|
6666
|
+
imagePoint.y - avatarCropSession.drag.originY,
|
|
6667
|
+
avatarCropSession.imageWidth,
|
|
6668
|
+
avatarCropSession.imageHeight
|
|
6669
|
+
));
|
|
6670
|
+
return;
|
|
6671
|
+
}
|
|
6672
|
+
applyAvatarCropRect(resizeCropRect(
|
|
6673
|
+
avatarCropSession.drag.startCrop,
|
|
6674
|
+
avatarCropSession.drag.handle,
|
|
6675
|
+
imagePoint.x,
|
|
6676
|
+
imagePoint.y,
|
|
6677
|
+
avatarCropSession.imageWidth,
|
|
6678
|
+
avatarCropSession.imageHeight
|
|
6679
|
+
));
|
|
6680
|
+
});
|
|
6681
|
+
|
|
6682
|
+
function endAvatarCropDrag(event) {
|
|
6683
|
+
if (!avatarCropSession.drag) return;
|
|
6684
|
+
avatarCropSession.drag = null;
|
|
6685
|
+
$("#avatar-crop-selection")?.classList.remove("is-dragging");
|
|
6686
|
+
if (event?.pointerId != null && $("#avatar-crop-stage")?.hasPointerCapture?.(event.pointerId)) {
|
|
6687
|
+
$("#avatar-crop-stage").releasePointerCapture(event.pointerId);
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
|
|
6691
|
+
$("#avatar-crop-stage").addEventListener("pointerup", endAvatarCropDrag);
|
|
6692
|
+
$("#avatar-crop-stage").addEventListener("pointercancel", endAvatarCropDrag);
|
|
6693
|
+
|
|
6694
|
+
window.addEventListener("resize", () => {
|
|
6695
|
+
if ($("#avatar-crop-dialog")?.open) renderAvatarCropSelection();
|
|
6696
|
+
});
|
|
6697
|
+
|
|
6698
|
+
$("#avatar-crop-close").addEventListener("click", () => {
|
|
6699
|
+
if (!avatarCropSession.uploading) closeAvatarCropDialog();
|
|
6700
|
+
});
|
|
6701
|
+
$("#avatar-crop-cancel").addEventListener("click", () => {
|
|
6702
|
+
if (!avatarCropSession.uploading) closeAvatarCropDialog();
|
|
6703
|
+
});
|
|
6704
|
+
$("#avatar-crop-dialog").addEventListener("cancel", (event) => {
|
|
6705
|
+
if (avatarCropSession.uploading) event.preventDefault();
|
|
6706
|
+
});
|
|
6707
|
+
$("#avatar-crop-dialog").addEventListener("close", () => {
|
|
6708
|
+
if (avatarCropSession.uploading) return;
|
|
6709
|
+
resetAvatarCropDialog();
|
|
6710
|
+
});
|
|
6711
|
+
|
|
6712
|
+
$("#avatar-crop-confirm").addEventListener("click", async () => {
|
|
6713
|
+
if (avatarCropSession.uploading) return;
|
|
6714
|
+
avatarCropSession.uploading = true;
|
|
6715
|
+
$("#avatar-crop-confirm").disabled = true;
|
|
6716
|
+
$("#avatar-crop-cancel").disabled = true;
|
|
6256
6717
|
$("#avatar-upload-button").disabled = true;
|
|
6257
6718
|
$("#avatar-remove-button").disabled = true;
|
|
6258
6719
|
try {
|
|
6720
|
+
const blob = await exportAvatarCropBlob();
|
|
6721
|
+
const body = new FormData();
|
|
6722
|
+
body.append("file", blob, "avatar.png");
|
|
6259
6723
|
const updated = await api("/api/auth/avatar", { method: "PUT", body });
|
|
6260
6724
|
applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
|
|
6261
6725
|
renderProfileAvatar();
|
|
6726
|
+
avatarCropSession.uploading = false;
|
|
6727
|
+
closeAvatarCropDialog();
|
|
6262
6728
|
toast("头像已更新");
|
|
6263
6729
|
} catch (error) {
|
|
6264
6730
|
toast(error.message, "error");
|
|
6265
6731
|
} finally {
|
|
6732
|
+
avatarCropSession.uploading = false;
|
|
6733
|
+
$("#avatar-crop-confirm").disabled = false;
|
|
6734
|
+
$("#avatar-crop-cancel").disabled = false;
|
|
6266
6735
|
$("#avatar-upload-button").disabled = false;
|
|
6267
6736
|
$("#avatar-remove-button").disabled = false;
|
|
6268
|
-
event.target.value = "";
|
|
6269
6737
|
}
|
|
6270
6738
|
});
|
|
6739
|
+
|
|
6271
6740
|
$("#avatar-remove-button").addEventListener("click", async () => {
|
|
6272
6741
|
if (!state.user?.avatarUrl || !(await confirmToast("确定移除当前头像吗?", { title: "移除头像", confirmLabel: "确认移除" }))) return;
|
|
6273
6742
|
$("#avatar-upload-button").disabled = true;
|
|
@@ -6507,6 +6976,7 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
|
|
|
6507
6976
|
$("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
6508
6977
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
6509
6978
|
$("#save-button").addEventListener("click", saveChapter);
|
|
6979
|
+
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
6510
6980
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
6511
6981
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
6512
6982
|
$("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
|