@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.
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[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
- try { return localStorage.getItem(onboardingStorageKey()) === "completed"; }
162
- catch { return false; }
183
+ return state.user?.onboardingCompleted === true;
163
184
  }
164
185
 
165
186
  function persistOnboardingCompletion() {
166
- try { localStorage.setItem(onboardingStorageKey(), "completed"); }
167
- catch { /* 浏览器禁用存储时仅在当前页面关闭导览 */ }
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
- if (!state.work) return;
981
- state.aiConversations = await api(`/api/works/${state.work.id}/ai-conversations`);
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 login = mode === "login";
1389
+ const registerTab = $("#auth-register-tab");
1390
+ const login = mode === "login" || registerTab.disabled;
1344
1391
  $("#auth-login-tab").setAttribute("aria-selected", String(login));
1345
- $("#auth-register-tab").setAttribute("aria-selected", String(!login));
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").classList.toggle("hidden", !canRegister);
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
- $("#toast-region").append(element);
1417
- setTimeout(() => element.remove(), 3600);
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>${member.role === "owner" ? "所有者" : "可编辑"}</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
- <button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>
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
- await loadModels();
1935
- await loadAiReferences();
1936
- await loadAiConversations();
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 (spacingChanged) scheduleChapterAutoSave(120);
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
- if (!state.work) return;
2659
- state.models = await api(`/api/works/${state.work.id}/models`);
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
- if (!state.work) return;
2776
- [state.characters, state.settings] = await Promise.all([
2777
- api(`/api/works/${state.work.id}/characters`),
2778
- api(`/api/works/${state.work.id}/settings`)
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>作品创建者和受邀协作者可以访问并共同编辑这部作品。</small></div>
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 members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId } });
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
- setAiHistoryVisible(!$("#ai-history-dialog").open);
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", () => {
@@ -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=20260718-toast-position">
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">邀请协作</button></form>
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-group-relation-list"></script>
545
+ <script type="module" src="/app.js?v=20260719-registration-disabled"></script>
546
546
  </body>
547
547
  </html>