@musnows/scriverse 0.3.0 → 0.3.2
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.en.md +1 -1
- package/README.md +1 -1
- package/dist/ai.js +182 -8
- package/dist/ai.js.map +1 -1
- package/dist/app.js +49 -8
- package/dist/app.js.map +1 -1
- package/dist/database.js +117 -10
- package/dist/database.js.map +1 -1
- package/dist/http-logging.js +76 -0
- package/dist/http-logging.js.map +1 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/public/app.js +224 -43
- package/dist/public/index.html +4 -4
- package/dist/public/styles.css +50 -3
- package/dist/request-context.js +10 -3
- package/dist/request-context.js.map +1 -1
- package/dist/security.js +12 -0
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +22 -9
- package/dist/server-runtime.js.map +1 -1
- package/dist/server.js +12 -1
- package/dist/server.js.map +1 -1
- package/dist/store.js +80 -4
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +41 -8
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -76,6 +76,24 @@ function analysisTaskStatusLabel(status) {
|
|
|
76
76
|
})[String(status)] ?? String(status);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function canEditWork(work = state.work) {
|
|
80
|
+
return ["admin", "owner", "editor"].includes(String(work?.accessRole));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyWorkAccessMode() {
|
|
84
|
+
const viewOnly = Boolean(state.work) && !canEditWork();
|
|
85
|
+
$("#app").classList.toggle("view-only-mode", viewOnly);
|
|
86
|
+
document.body.classList.toggle("work-viewer-mode", viewOnly);
|
|
87
|
+
$("#chapter-title").readOnly = viewOnly;
|
|
88
|
+
$("#chapter-content").readOnly = viewOnly;
|
|
89
|
+
$("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
|
|
90
|
+
$("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
|
|
91
|
+
if (viewOnly) {
|
|
92
|
+
cancelChapterAutoSave();
|
|
93
|
+
state.dirty = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
const $ = (selector) => document.querySelector(selector);
|
|
80
98
|
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
|
81
99
|
const maximumAvatarFileSize = 5 * 1024 * 1024;
|
|
@@ -118,7 +136,6 @@ function renderProfileAvatar() {
|
|
|
118
136
|
$("#avatar-remove-button").classList.toggle("hidden", !state.user?.avatarUrl);
|
|
119
137
|
}
|
|
120
138
|
const platformDocumentTitle = "叙界 · 小说 AI 创作工作台";
|
|
121
|
-
const onboardingStoragePrefix = "scriverse-onboarding-v2";
|
|
122
139
|
const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
|
|
123
140
|
const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
|
|
124
141
|
let restoringPageRoute = true;
|
|
@@ -127,6 +144,15 @@ let onboardingStep = 0;
|
|
|
127
144
|
let onboardingAutoScheduled = false;
|
|
128
145
|
let onboardingPositionFrame = null;
|
|
129
146
|
let onboardingSteps = [];
|
|
147
|
+
let loadedAiModelsWorkId = null;
|
|
148
|
+
let loadedAiReferencesWorkId = null;
|
|
149
|
+
let loadedAiConversationsWorkId = null;
|
|
150
|
+
let aiModelsLoadPromise = null;
|
|
151
|
+
let aiModelsLoadWorkId = null;
|
|
152
|
+
let aiReferencesLoadPromise = null;
|
|
153
|
+
let aiReferencesLoadWorkId = null;
|
|
154
|
+
let aiConversationsLoadPromise = null;
|
|
155
|
+
let aiConversationsLoadWorkId = null;
|
|
130
156
|
|
|
131
157
|
const shelfOnboardingSteps = [
|
|
132
158
|
{ selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
|
|
@@ -153,18 +179,16 @@ const workspaceOnboardingSteps = [
|
|
|
153
179
|
{ selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
|
|
154
180
|
];
|
|
155
181
|
|
|
156
|
-
function onboardingStorageKey() {
|
|
157
|
-
return `${onboardingStoragePrefix}:${state.user?.userId ?? "anonymous"}`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
182
|
function hasCompletedOnboarding() {
|
|
161
|
-
|
|
162
|
-
catch { return false; }
|
|
183
|
+
return state.user?.onboardingCompleted === true;
|
|
163
184
|
}
|
|
164
185
|
|
|
165
186
|
function persistOnboardingCompletion() {
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
if (!state.user || state.user.onboardingCompleted) return;
|
|
188
|
+
state.user = { ...state.user, onboardingCompleted: true };
|
|
189
|
+
api("/api/auth/onboarding/complete", { method: "POST", body: {} })
|
|
190
|
+
.then((user) => { state.user = user; })
|
|
191
|
+
.catch(() => { state.user = { ...state.user, onboardingCompleted: false }; });
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
function isOnboardingTargetVisible(target) {
|
|
@@ -799,12 +823,14 @@ function attachMessageIdentity(message, messageId) {
|
|
|
799
823
|
const AI_TOOL_DISPLAY_NAMES = {
|
|
800
824
|
story_index: "作品目录与章节概要",
|
|
801
825
|
read_chapters: "读取章节",
|
|
826
|
+
grep: "查询正文关键字",
|
|
802
827
|
query_story_knowledge: "查询作品知识"
|
|
803
828
|
};
|
|
804
829
|
|
|
805
830
|
const AI_TOOL_DESCRIPTIONS = {
|
|
806
831
|
story_index: "分页读取当前作品的卷章目录和章节概要。",
|
|
807
832
|
read_chapters: "读取指定章节的概要、正文或两者。",
|
|
833
|
+
grep: "查询正文关键字所在的完整段落及章节信息。",
|
|
808
834
|
query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。"
|
|
809
835
|
};
|
|
810
836
|
|
|
@@ -977,8 +1003,12 @@ function renderAiConversationHistory() {
|
|
|
977
1003
|
}
|
|
978
1004
|
|
|
979
1005
|
async function loadAiConversations(openLatest = true) {
|
|
980
|
-
|
|
981
|
-
|
|
1006
|
+
const workId = state.work?.id;
|
|
1007
|
+
if (!workId) return;
|
|
1008
|
+
const conversations = await api(`/api/works/${workId}/ai-conversations`);
|
|
1009
|
+
if (state.work?.id !== workId) return;
|
|
1010
|
+
state.aiConversations = conversations;
|
|
1011
|
+
loadedAiConversationsWorkId = workId;
|
|
982
1012
|
renderAiConversationHistory();
|
|
983
1013
|
if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
|
|
984
1014
|
else {
|
|
@@ -987,6 +1017,22 @@ async function loadAiConversations(openLatest = true) {
|
|
|
987
1017
|
}
|
|
988
1018
|
}
|
|
989
1019
|
|
|
1020
|
+
async function ensureAiConversationsLoaded() {
|
|
1021
|
+
const workId = state.work?.id;
|
|
1022
|
+
if (!workId || loadedAiConversationsWorkId === workId) return;
|
|
1023
|
+
if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
|
|
1024
|
+
aiConversationsLoadWorkId = workId;
|
|
1025
|
+
aiConversationsLoadPromise = loadAiConversations(false);
|
|
1026
|
+
try {
|
|
1027
|
+
await aiConversationsLoadPromise;
|
|
1028
|
+
} finally {
|
|
1029
|
+
if (aiConversationsLoadWorkId === workId) {
|
|
1030
|
+
aiConversationsLoadPromise = null;
|
|
1031
|
+
aiConversationsLoadWorkId = null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
990
1036
|
async function openAiConversation(conversationId, hideHistory = true) {
|
|
991
1037
|
const conversation = await api(`/api/ai-conversations/${conversationId}`);
|
|
992
1038
|
state.aiConversationId = conversation.id;
|
|
@@ -1340,9 +1386,10 @@ async function api(path, options = {}) {
|
|
|
1340
1386
|
}
|
|
1341
1387
|
|
|
1342
1388
|
function selectAuthMode(mode) {
|
|
1343
|
-
const
|
|
1389
|
+
const registerTab = $("#auth-register-tab");
|
|
1390
|
+
const login = mode === "login" || registerTab.disabled;
|
|
1344
1391
|
$("#auth-login-tab").setAttribute("aria-selected", String(login));
|
|
1345
|
-
|
|
1392
|
+
registerTab.setAttribute("aria-selected", String(!login));
|
|
1346
1393
|
$("#login-form").classList.toggle("hidden", !login);
|
|
1347
1394
|
$("#register-form").classList.toggle("hidden", login);
|
|
1348
1395
|
$("#auth-error").textContent = "";
|
|
@@ -1368,7 +1415,10 @@ function showAuth(setupRequired, registrationOpen = true) {
|
|
|
1368
1415
|
? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
|
|
1369
1416
|
: "你的作品、协作权限和每一次修改都会绑定到账户。";
|
|
1370
1417
|
const canRegister = setupRequired || registrationOpen;
|
|
1371
|
-
$("#auth-register-tab")
|
|
1418
|
+
const registerTab = $("#auth-register-tab");
|
|
1419
|
+
registerTab.disabled = !canRegister;
|
|
1420
|
+
registerTab.setAttribute("aria-disabled", String(!canRegister));
|
|
1421
|
+
registerTab.textContent = canRegister ? "注册" : "注册已禁用";
|
|
1372
1422
|
selectAuthMode(setupRequired ? "register" : "login");
|
|
1373
1423
|
}
|
|
1374
1424
|
|
|
@@ -1409,14 +1459,35 @@ async function initializeAuthentication() {
|
|
|
1409
1459
|
return true;
|
|
1410
1460
|
}
|
|
1411
1461
|
|
|
1462
|
+
function raiseToastRegion() {
|
|
1463
|
+
const region = $("#toast-region");
|
|
1464
|
+
if (typeof region.showPopover !== "function") return;
|
|
1465
|
+
if (region.matches(":popover-open")) region.hidePopover();
|
|
1466
|
+
region.showPopover();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1412
1469
|
function toast(message, type = "info") {
|
|
1470
|
+
const region = $("#toast-region");
|
|
1413
1471
|
const element = document.createElement("div");
|
|
1414
1472
|
element.className = `toast ${type}`;
|
|
1415
1473
|
element.textContent = message;
|
|
1416
|
-
|
|
1417
|
-
|
|
1474
|
+
region.append(element);
|
|
1475
|
+
raiseToastRegion();
|
|
1476
|
+
setTimeout(() => {
|
|
1477
|
+
element.remove();
|
|
1478
|
+
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
|
|
1479
|
+
region.hidePopover();
|
|
1480
|
+
}
|
|
1481
|
+
}, 3600);
|
|
1418
1482
|
}
|
|
1419
1483
|
|
|
1484
|
+
document.addEventListener("toggle", (event) => {
|
|
1485
|
+
const target = event.target;
|
|
1486
|
+
if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
|
|
1487
|
+
raiseToastRegion();
|
|
1488
|
+
}
|
|
1489
|
+
}, true);
|
|
1490
|
+
|
|
1420
1491
|
function setSaveState(text, dirty = false) {
|
|
1421
1492
|
state.dirty = dirty;
|
|
1422
1493
|
$("#save-state").textContent = text;
|
|
@@ -1442,7 +1513,7 @@ function cancelChapterAutoSave() {
|
|
|
1442
1513
|
}
|
|
1443
1514
|
|
|
1444
1515
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
1445
|
-
if (!state.chapter) return;
|
|
1516
|
+
if (!state.chapter || !canEditWork()) return;
|
|
1446
1517
|
cancelChapterAutoSave();
|
|
1447
1518
|
setSaveState("等待自动保存", true);
|
|
1448
1519
|
chapterAutoSaveTimer = setTimeout(() => {
|
|
@@ -1452,6 +1523,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
|
1452
1523
|
}
|
|
1453
1524
|
|
|
1454
1525
|
async function persistChapter({ automatic = false } = {}) {
|
|
1526
|
+
if (!canEditWork()) return null;
|
|
1455
1527
|
if (!state.chapter) {
|
|
1456
1528
|
if (!automatic) toast("请先选择章节", "error");
|
|
1457
1529
|
return null;
|
|
@@ -1564,7 +1636,7 @@ async function initializePage() {
|
|
|
1564
1636
|
|
|
1565
1637
|
if (requestedWork) {
|
|
1566
1638
|
state.module = route.view === "module" ? route.module : "editor";
|
|
1567
|
-
await selectWork(requestedWork.id);
|
|
1639
|
+
await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
|
|
1568
1640
|
}
|
|
1569
1641
|
|
|
1570
1642
|
if (route.view === "editor") {
|
|
@@ -1696,11 +1768,23 @@ function renderMembers(members) {
|
|
|
1696
1768
|
const work = memberDialogWork ?? state.work;
|
|
1697
1769
|
const canManage = ["admin", "owner"].includes(String(work?.accessRole));
|
|
1698
1770
|
$("#members-list").innerHTML = members.map((member) => `<article class="access-row">
|
|
1699
|
-
<div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : "协作者"}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
|
|
1700
|
-
<span
|
|
1771
|
+
<div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : member.role === "viewer" ? "查看者" : "协作者"}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
|
|
1772
|
+
${member.role === "owner" ? "<span>所有者</span>" : `<select data-member-role="${esc(member.userId)}" aria-label="${esc(member.displayName)}的作品权限" ${canManage ? "" : "disabled"}><option value="viewer" ${member.role === "viewer" ? "selected" : ""}>仅查看</option><option value="editor" ${member.role === "editor" ? "selected" : ""}>可编辑</option></select>`}
|
|
1701
1773
|
${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
|
|
1702
1774
|
</article>`).join("");
|
|
1703
1775
|
bindUserAvatarFallbacks($("#members-list"));
|
|
1776
|
+
$("#members-list").querySelectorAll("[data-member-role]").forEach((select) => select.addEventListener("change", async () => {
|
|
1777
|
+
if (!work) return;
|
|
1778
|
+
const previousRole = members.find((member) => member.userId === select.dataset.memberRole)?.role ?? "viewer";
|
|
1779
|
+
try {
|
|
1780
|
+
const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
|
|
1781
|
+
renderMembers(updated);
|
|
1782
|
+
toast("成员权限已更新");
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
select.value = previousRole;
|
|
1785
|
+
toast(error.message, "error");
|
|
1786
|
+
}
|
|
1787
|
+
}));
|
|
1704
1788
|
$("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
|
|
1705
1789
|
if (!work) return;
|
|
1706
1790
|
try {
|
|
@@ -1888,9 +1972,9 @@ function renderShelf() {
|
|
|
1888
1972
|
<span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
|
|
1889
1973
|
${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
|
|
1890
1974
|
</span>
|
|
1891
|
-
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "editor" ? "协作作品" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
1975
|
+
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "仅查看" : work.accessRole === "editor" ? "协作作品" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
1892
1976
|
</button>
|
|
1893
|
-
|
|
1977
|
+
${canEditWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
|
|
1894
1978
|
</article>`).join("")}
|
|
1895
1979
|
<button class="book-card book-add-card" id="book-add-card" type="button" aria-label="新建作品" data-testid="book-add-card"><span>+</span><strong>新建作品</strong><small>从零开始或导入 TXT / DOCX</small></button>`;
|
|
1896
1980
|
shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
|
|
@@ -1901,11 +1985,20 @@ function renderShelf() {
|
|
|
1901
1985
|
$("#book-add-card").addEventListener("click", openWorkDialog);
|
|
1902
1986
|
}
|
|
1903
1987
|
|
|
1904
|
-
async function selectWork(workId) {
|
|
1988
|
+
async function selectWork(workId, preferredChapterId = null) {
|
|
1905
1989
|
const discarding = state.work?.id !== workId && state.dirty;
|
|
1906
1990
|
if (discarding && !confirmDiscardChanges()) return false;
|
|
1907
1991
|
const nextWork = await api(`/api/works/${workId}`);
|
|
1908
1992
|
if (state.work?.id !== nextWork.id) {
|
|
1993
|
+
loadedAiModelsWorkId = null;
|
|
1994
|
+
loadedAiReferencesWorkId = null;
|
|
1995
|
+
loadedAiConversationsWorkId = null;
|
|
1996
|
+
state.models = [];
|
|
1997
|
+
state.characters = [];
|
|
1998
|
+
state.settings = [];
|
|
1999
|
+
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
2000
|
+
aiContextUsageTimer = null;
|
|
2001
|
+
aiContextUsageRequest += 1;
|
|
1909
2002
|
state.aiCitations = [];
|
|
1910
2003
|
state.aiReferences = [];
|
|
1911
2004
|
state.aiPromptSent = false;
|
|
@@ -1916,6 +2009,8 @@ async function selectWork(workId) {
|
|
|
1916
2009
|
renderAiQuickActions();
|
|
1917
2010
|
resetAiFeed();
|
|
1918
2011
|
$("#ai-conversation-title").textContent = "新对话";
|
|
2012
|
+
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
2013
|
+
setAiContextMeter(null);
|
|
1919
2014
|
renderAiConversationHistory();
|
|
1920
2015
|
}
|
|
1921
2016
|
if (discarding) setSaveState("就绪");
|
|
@@ -1927,15 +2022,14 @@ async function selectWork(workId) {
|
|
|
1927
2022
|
settingsReturnContext = null;
|
|
1928
2023
|
state.work = nextWork;
|
|
1929
2024
|
state.chapter = null;
|
|
2025
|
+
applyWorkAccessMode();
|
|
1930
2026
|
updateDocumentTitle(state.work);
|
|
1931
2027
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
|
|
1932
2028
|
$("#top-search-button").disabled = false;
|
|
1933
2029
|
renderTree();
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
await
|
|
1937
|
-
const firstChapter = state.work.volumes.flatMap((volume) => volume.chapters)[0];
|
|
1938
|
-
if (state.module === "editor" && firstChapter) await selectChapter(firstChapter.id);
|
|
2030
|
+
const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
2031
|
+
const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
|
|
2032
|
+
if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
|
|
1939
2033
|
else if (state.module === "editor") showWelcome(true);
|
|
1940
2034
|
else await showModule(state.module);
|
|
1941
2035
|
return true;
|
|
@@ -1964,6 +2058,7 @@ function renderTree() {
|
|
|
1964
2058
|
renderTree();
|
|
1965
2059
|
});
|
|
1966
2060
|
button.addEventListener("contextmenu", (event) => {
|
|
2061
|
+
if (!canEditWork()) return;
|
|
1967
2062
|
event.preventDefault();
|
|
1968
2063
|
openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
|
|
1969
2064
|
});
|
|
@@ -1971,6 +2066,7 @@ function renderTree() {
|
|
|
1971
2066
|
$("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
|
|
1972
2067
|
button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
|
|
1973
2068
|
button.addEventListener("contextmenu", (event) => {
|
|
2069
|
+
if (!canEditWork()) return;
|
|
1974
2070
|
event.preventDefault();
|
|
1975
2071
|
openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
|
|
1976
2072
|
});
|
|
@@ -2020,7 +2116,8 @@ async function selectChapter(chapterId) {
|
|
|
2020
2116
|
scheduleChapterLineNumbers();
|
|
2021
2117
|
$("#chapter-insight").classList.add("hidden");
|
|
2022
2118
|
updateChapterStats();
|
|
2023
|
-
if (
|
|
2119
|
+
if (!canEditWork()) setSaveState("仅查看");
|
|
2120
|
+
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
2024
2121
|
else setSaveState("已保存");
|
|
2025
2122
|
renderTree();
|
|
2026
2123
|
replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
|
|
@@ -2077,6 +2174,7 @@ const moduleMeta = {
|
|
|
2077
2174
|
|
|
2078
2175
|
async function showModule(module) {
|
|
2079
2176
|
if (!state.work) return showWelcome();
|
|
2177
|
+
if (!canEditWork() && ["tasks", "ai-settings"].includes(module)) module = "editor";
|
|
2080
2178
|
if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
|
|
2081
2179
|
if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
|
|
2082
2180
|
state.module = module;
|
|
@@ -2098,7 +2196,7 @@ async function showModule(module) {
|
|
|
2098
2196
|
$("#module-title").textContent = meta[1];
|
|
2099
2197
|
$("#module-description").textContent = meta[2];
|
|
2100
2198
|
$("#module-create-button").textContent = meta[3];
|
|
2101
|
-
$("#module-create-button").classList.toggle("hidden", module === "ai-settings");
|
|
2199
|
+
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
|
|
2102
2200
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
2103
2201
|
try {
|
|
2104
2202
|
if (module === "settings") await renderSettings();
|
|
@@ -2189,6 +2287,7 @@ function bindEntityHistoryButtons(refresh) {
|
|
|
2189
2287
|
|
|
2190
2288
|
async function renderSettings() {
|
|
2191
2289
|
const records = await api(`/api/works/${state.work.id}/settings`);
|
|
2290
|
+
state.settings = records;
|
|
2192
2291
|
$("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
|
|
2193
2292
|
<article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
2194
2293
|
<h3>${esc(item.title)}</h3><p>${esc(item.content)}</p>
|
|
@@ -2587,8 +2686,12 @@ async function renderBookAiSettings() {
|
|
|
2587
2686
|
api(`/api/works/${state.work.id}/task-defaults`)
|
|
2588
2687
|
]);
|
|
2589
2688
|
const host = $("#module-content");
|
|
2590
|
-
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "query_story_knowledge"]);
|
|
2689
|
+
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge"]);
|
|
2591
2690
|
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="primary-button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="field-label"><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></div><div class="card-actions"><button id="save-book-summary-context-percent" class="primary-button">保存概要配额</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话长期记忆</h2><p>对话历史使用独立预算;达到阈值时先提醒,继续发送会把较早消息整理成带来源的结构化长期记忆,并尽量保留最近八条原文。</p></div></div><div class="field-label"><label class="context-compact-threshold-field">整理提醒阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话长期记忆整理提醒阈值"></label></div><div class="card-actions"><button id="save-context-compact-threshold" class="primary-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="query_story_knowledge" ${agentTools.has("query_story_knowledge") ? "checked" : ""}><span><strong>查询作品知识</strong><small>按关键词查询设定、人物、组织、时间线、关系、大纲和伏笔。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="primary-button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
|
|
2691
|
+
host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
|
|
2692
|
+
"beforebegin",
|
|
2693
|
+
`<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
|
|
2694
|
+
);
|
|
2592
2695
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
2593
2696
|
const button = $("#save-work-system-prompt");
|
|
2594
2697
|
button.disabled = true;
|
|
@@ -2655,8 +2758,12 @@ async function renderBookAiSettings() {
|
|
|
2655
2758
|
}
|
|
2656
2759
|
|
|
2657
2760
|
async function loadModels() {
|
|
2658
|
-
|
|
2659
|
-
|
|
2761
|
+
const workId = state.work?.id;
|
|
2762
|
+
if (!workId) return;
|
|
2763
|
+
const models = await api(`/api/works/${workId}/models`);
|
|
2764
|
+
if (state.work?.id !== workId) return;
|
|
2765
|
+
state.models = models;
|
|
2766
|
+
loadedAiModelsWorkId = workId;
|
|
2660
2767
|
const select = $("#ai-model");
|
|
2661
2768
|
select.innerHTML = state.models.length
|
|
2662
2769
|
? state.models.map((model) => `<option value="${esc(model.id)}" ${model.enabled ? "" : "disabled"}>${esc(modelOptionLabel(model))}</option>`).join("")
|
|
@@ -2664,6 +2771,27 @@ async function loadModels() {
|
|
|
2664
2771
|
scheduleAiContextUsage();
|
|
2665
2772
|
}
|
|
2666
2773
|
|
|
2774
|
+
async function ensureAiModelsLoaded() {
|
|
2775
|
+
const workId = state.work?.id;
|
|
2776
|
+
if (!workId || loadedAiModelsWorkId === workId) return;
|
|
2777
|
+
if (aiModelsLoadPromise && aiModelsLoadWorkId === workId) return aiModelsLoadPromise;
|
|
2778
|
+
const select = $("#ai-model");
|
|
2779
|
+
select.innerHTML = '<option value="">正在加载模型……</option>';
|
|
2780
|
+
aiModelsLoadWorkId = workId;
|
|
2781
|
+
aiModelsLoadPromise = loadModels();
|
|
2782
|
+
try {
|
|
2783
|
+
await aiModelsLoadPromise;
|
|
2784
|
+
} catch (error) {
|
|
2785
|
+
if (state.work?.id === workId) select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
|
|
2786
|
+
throw error;
|
|
2787
|
+
} finally {
|
|
2788
|
+
if (aiModelsLoadWorkId === workId) {
|
|
2789
|
+
aiModelsLoadPromise = null;
|
|
2790
|
+
aiModelsLoadWorkId = null;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2667
2795
|
let aiContextUsageTimer = null;
|
|
2668
2796
|
let aiContextUsageRequest = 0;
|
|
2669
2797
|
|
|
@@ -2772,11 +2900,32 @@ async function refreshAiContextUsage() {
|
|
|
2772
2900
|
}
|
|
2773
2901
|
|
|
2774
2902
|
async function loadAiReferences() {
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
api(`/api/works/${
|
|
2903
|
+
const workId = state.work?.id;
|
|
2904
|
+
if (!workId) return;
|
|
2905
|
+
const [characters, settings] = await Promise.all([
|
|
2906
|
+
api(`/api/works/${workId}/characters`),
|
|
2907
|
+
api(`/api/works/${workId}/settings`)
|
|
2779
2908
|
]);
|
|
2909
|
+
if (state.work?.id !== workId) return;
|
|
2910
|
+
state.characters = characters;
|
|
2911
|
+
state.settings = settings;
|
|
2912
|
+
loadedAiReferencesWorkId = workId;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
async function ensureAiReferencesLoaded() {
|
|
2916
|
+
const workId = state.work?.id;
|
|
2917
|
+
if (!workId || loadedAiReferencesWorkId === workId) return;
|
|
2918
|
+
if (aiReferencesLoadPromise && aiReferencesLoadWorkId === workId) return aiReferencesLoadPromise;
|
|
2919
|
+
aiReferencesLoadWorkId = workId;
|
|
2920
|
+
aiReferencesLoadPromise = loadAiReferences();
|
|
2921
|
+
try {
|
|
2922
|
+
await aiReferencesLoadPromise;
|
|
2923
|
+
} finally {
|
|
2924
|
+
if (aiReferencesLoadWorkId === workId) {
|
|
2925
|
+
aiReferencesLoadPromise = null;
|
|
2926
|
+
aiReferencesLoadWorkId = null;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2780
2929
|
}
|
|
2781
2930
|
|
|
2782
2931
|
function field(name, label, type = "text", value = "", options = []) {
|
|
@@ -2938,7 +3087,7 @@ function openWorkSettingsDialog(work) {
|
|
|
2938
3087
|
if (!work) return;
|
|
2939
3088
|
const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
|
|
2940
3089
|
const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
|
|
2941
|
-
<div><strong id="work-access-title">可访问人</strong><small
|
|
3090
|
+
<div><strong id="work-access-title">可访问人</strong><small>可以分别授予成员仅查看或共同编辑权限。</small></div>
|
|
2942
3091
|
${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">添加或管理可访问人</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
|
|
2943
3092
|
</section>`;
|
|
2944
3093
|
openDialog("作品信息",
|
|
@@ -3192,6 +3341,12 @@ async function openCharacterDialog(item) {
|
|
|
3192
3341
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
3193
3342
|
setCharacterHistoryVisible(false);
|
|
3194
3343
|
renderCharacterEditorFields(item);
|
|
3344
|
+
const viewOnly = !canEditWork();
|
|
3345
|
+
if (viewOnly) {
|
|
3346
|
+
$("#character-editor-eyebrow").textContent = "人物档案";
|
|
3347
|
+
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
3348
|
+
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
3349
|
+
}
|
|
3195
3350
|
document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
|
|
3196
3351
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
3197
3352
|
});
|
|
@@ -3199,6 +3354,7 @@ async function openCharacterDialog(item) {
|
|
|
3199
3354
|
const form = $("#character-editor-form");
|
|
3200
3355
|
form.onsubmit = async (event) => {
|
|
3201
3356
|
event.preventDefault();
|
|
3357
|
+
if (!canEditWork()) return;
|
|
3202
3358
|
const submit = $("#character-editor-submit");
|
|
3203
3359
|
submit.disabled = true;
|
|
3204
3360
|
try {
|
|
@@ -3391,6 +3547,11 @@ function openModelDialog(providerId, item = null) {
|
|
|
3391
3547
|
|
|
3392
3548
|
async function sendAi() {
|
|
3393
3549
|
if (!state.work || !state.chapter) return toast("请先选择章节", "error");
|
|
3550
|
+
try {
|
|
3551
|
+
await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
|
|
3552
|
+
} catch (error) {
|
|
3553
|
+
return toast(`创作助手加载失败:${error.message}`, "error");
|
|
3554
|
+
}
|
|
3394
3555
|
const modelId = $("#ai-model").value;
|
|
3395
3556
|
if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
|
|
3396
3557
|
const instruction = aiPromptText().trim();
|
|
@@ -3936,10 +4097,11 @@ $("#member-invite-form").addEventListener("submit", async (event) => {
|
|
|
3936
4097
|
const work = memberDialogWork ?? state.work;
|
|
3937
4098
|
if (!work || !userId) return;
|
|
3938
4099
|
try {
|
|
3939
|
-
const
|
|
4100
|
+
const role = $("#member-role-select").value;
|
|
4101
|
+
const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
|
|
3940
4102
|
renderMembers(members);
|
|
3941
4103
|
await fillMemberCandidates(members);
|
|
3942
|
-
toast("协作者已邀请");
|
|
4104
|
+
toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
|
|
3943
4105
|
} catch (error) { toast(error.message, "error"); }
|
|
3944
4106
|
});
|
|
3945
4107
|
$("#platform-new-provider").addEventListener("click", () => openProviderDialog());
|
|
@@ -4055,9 +4217,22 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
4055
4217
|
});
|
|
4056
4218
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
4057
4219
|
$("#module-create-button").addEventListener("click", () => ({ settings: openSettingDialog, characters: openCharacterDialog, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
4058
|
-
$("#ai-prompt").addEventListener("input", () => {
|
|
4220
|
+
$("#ai-prompt").addEventListener("input", async () => {
|
|
4059
4221
|
updateAiMentionMenu();
|
|
4060
4222
|
scheduleAiContextUsage();
|
|
4223
|
+
if (!findAiMention(aiPromptTextBeforeCursor())) return;
|
|
4224
|
+
try {
|
|
4225
|
+
await ensureAiReferencesLoaded();
|
|
4226
|
+
updateAiMentionMenu();
|
|
4227
|
+
} catch (error) {
|
|
4228
|
+
toast(`引用数据加载失败:${error.message}`, "error");
|
|
4229
|
+
}
|
|
4230
|
+
});
|
|
4231
|
+
$("#ai-prompt").addEventListener("focus", () => {
|
|
4232
|
+
ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
|
|
4233
|
+
});
|
|
4234
|
+
$("#ai-model").addEventListener("focus", () => {
|
|
4235
|
+
ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
|
|
4061
4236
|
});
|
|
4062
4237
|
$("#ai-model").addEventListener("change", scheduleAiContextUsage);
|
|
4063
4238
|
$("#ai-task").addEventListener("change", scheduleAiContextUsage);
|
|
@@ -4208,8 +4383,14 @@ $("#ai-context-new-conversation").addEventListener("click", async () => {
|
|
|
4208
4383
|
}
|
|
4209
4384
|
});
|
|
4210
4385
|
$("#ai-context-dismiss").addEventListener("click", hideAiContextWarning);
|
|
4211
|
-
$("#ai-history-toggle").addEventListener("click", () => {
|
|
4212
|
-
|
|
4386
|
+
$("#ai-history-toggle").addEventListener("click", async () => {
|
|
4387
|
+
if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
|
|
4388
|
+
try {
|
|
4389
|
+
await ensureAiConversationsLoaded();
|
|
4390
|
+
setAiHistoryVisible(true);
|
|
4391
|
+
} catch (error) {
|
|
4392
|
+
toast(`对话历史加载失败:${error.message}`, "error");
|
|
4393
|
+
}
|
|
4213
4394
|
});
|
|
4214
4395
|
$("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
|
|
4215
4396
|
$("#ai-history-dialog").addEventListener("close", () => {
|
package/dist/public/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<script src="/theme-init.js?v=20260713-dark-mode"></script>
|
|
10
10
|
<link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
|
|
11
11
|
<link rel="manifest" href="/site.webmanifest">
|
|
12
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
12
|
+
<link rel="stylesheet" href="/styles.css?v=20260719-registration-disabled">
|
|
13
13
|
</head>
|
|
14
14
|
<body class="auth-pending">
|
|
15
15
|
<section id="auth-view" class="auth-view" aria-labelledby="auth-title">
|
|
@@ -263,7 +263,7 @@
|
|
|
263
263
|
</aside>
|
|
264
264
|
</div>
|
|
265
265
|
|
|
266
|
-
<div id="toast-region" class="toast-region" data-position="bottom-right" aria-live="polite"></div>
|
|
266
|
+
<div id="toast-region" class="toast-region" data-position="bottom-right" aria-live="polite" popover="manual"></div>
|
|
267
267
|
<div id="chapter-type-menu" class="chapter-type-menu hidden" role="menu" aria-label="设置章节类型" data-testid="chapter-type-menu">
|
|
268
268
|
<strong>标记章节</strong>
|
|
269
269
|
<div>
|
|
@@ -476,7 +476,7 @@
|
|
|
476
476
|
<dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
|
|
477
477
|
<div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">可访问人</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
|
|
478
478
|
<div class="access-dialog-body">
|
|
479
|
-
<form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><button class="primary-button" type="submit"
|
|
479
|
+
<form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><label>访问权限<select id="member-role-select" required><option value="viewer">仅查看</option><option value="editor">可编辑</option></select></label><button class="primary-button" type="submit">发送邀请</button></form>
|
|
480
480
|
<div id="members-list" class="access-list"></div>
|
|
481
481
|
</div>
|
|
482
482
|
</dialog>
|
|
@@ -542,6 +542,6 @@
|
|
|
542
542
|
</section>
|
|
543
543
|
</dialog>
|
|
544
544
|
|
|
545
|
-
<script type="module" src="/app.js?v=20260719-
|
|
545
|
+
<script type="module" src="/app.js?v=20260719-registration-disabled"></script>
|
|
546
546
|
</body>
|
|
547
547
|
</html>
|