@musnows/scriverse 0.3.0 → 0.3.1

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;
@@ -127,6 +145,15 @@ let onboardingStep = 0;
127
145
  let onboardingAutoScheduled = false;
128
146
  let onboardingPositionFrame = null;
129
147
  let onboardingSteps = [];
148
+ let loadedAiModelsWorkId = null;
149
+ let loadedAiReferencesWorkId = null;
150
+ let loadedAiConversationsWorkId = null;
151
+ let aiModelsLoadPromise = null;
152
+ let aiModelsLoadWorkId = null;
153
+ let aiReferencesLoadPromise = null;
154
+ let aiReferencesLoadWorkId = null;
155
+ let aiConversationsLoadPromise = null;
156
+ let aiConversationsLoadWorkId = null;
130
157
 
131
158
  const shelfOnboardingSteps = [
132
159
  { selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
@@ -799,12 +826,14 @@ function attachMessageIdentity(message, messageId) {
799
826
  const AI_TOOL_DISPLAY_NAMES = {
800
827
  story_index: "作品目录与章节概要",
801
828
  read_chapters: "读取章节",
829
+ grep: "查询正文关键字",
802
830
  query_story_knowledge: "查询作品知识"
803
831
  };
804
832
 
805
833
  const AI_TOOL_DESCRIPTIONS = {
806
834
  story_index: "分页读取当前作品的卷章目录和章节概要。",
807
835
  read_chapters: "读取指定章节的概要、正文或两者。",
836
+ grep: "查询正文关键字所在的完整段落及章节信息。",
808
837
  query_story_knowledge: "按关键词查询设定、人物、组织、时间线等作品知识。"
809
838
  };
810
839
 
@@ -977,8 +1006,12 @@ function renderAiConversationHistory() {
977
1006
  }
978
1007
 
979
1008
  async function loadAiConversations(openLatest = true) {
980
- if (!state.work) return;
981
- state.aiConversations = await api(`/api/works/${state.work.id}/ai-conversations`);
1009
+ const workId = state.work?.id;
1010
+ if (!workId) return;
1011
+ const conversations = await api(`/api/works/${workId}/ai-conversations`);
1012
+ if (state.work?.id !== workId) return;
1013
+ state.aiConversations = conversations;
1014
+ loadedAiConversationsWorkId = workId;
982
1015
  renderAiConversationHistory();
983
1016
  if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
984
1017
  else {
@@ -987,6 +1020,22 @@ async function loadAiConversations(openLatest = true) {
987
1020
  }
988
1021
  }
989
1022
 
1023
+ async function ensureAiConversationsLoaded() {
1024
+ const workId = state.work?.id;
1025
+ if (!workId || loadedAiConversationsWorkId === workId) return;
1026
+ if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
1027
+ aiConversationsLoadWorkId = workId;
1028
+ aiConversationsLoadPromise = loadAiConversations(false);
1029
+ try {
1030
+ await aiConversationsLoadPromise;
1031
+ } finally {
1032
+ if (aiConversationsLoadWorkId === workId) {
1033
+ aiConversationsLoadPromise = null;
1034
+ aiConversationsLoadWorkId = null;
1035
+ }
1036
+ }
1037
+ }
1038
+
990
1039
  async function openAiConversation(conversationId, hideHistory = true) {
991
1040
  const conversation = await api(`/api/ai-conversations/${conversationId}`);
992
1041
  state.aiConversationId = conversation.id;
@@ -1409,14 +1458,35 @@ async function initializeAuthentication() {
1409
1458
  return true;
1410
1459
  }
1411
1460
 
1461
+ function raiseToastRegion() {
1462
+ const region = $("#toast-region");
1463
+ if (typeof region.showPopover !== "function") return;
1464
+ if (region.matches(":popover-open")) region.hidePopover();
1465
+ region.showPopover();
1466
+ }
1467
+
1412
1468
  function toast(message, type = "info") {
1469
+ const region = $("#toast-region");
1413
1470
  const element = document.createElement("div");
1414
1471
  element.className = `toast ${type}`;
1415
1472
  element.textContent = message;
1416
- $("#toast-region").append(element);
1417
- setTimeout(() => element.remove(), 3600);
1473
+ region.append(element);
1474
+ raiseToastRegion();
1475
+ setTimeout(() => {
1476
+ element.remove();
1477
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
1478
+ region.hidePopover();
1479
+ }
1480
+ }, 3600);
1418
1481
  }
1419
1482
 
1483
+ document.addEventListener("toggle", (event) => {
1484
+ const target = event.target;
1485
+ if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
1486
+ raiseToastRegion();
1487
+ }
1488
+ }, true);
1489
+
1420
1490
  function setSaveState(text, dirty = false) {
1421
1491
  state.dirty = dirty;
1422
1492
  $("#save-state").textContent = text;
@@ -1442,7 +1512,7 @@ function cancelChapterAutoSave() {
1442
1512
  }
1443
1513
 
1444
1514
  function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1445
- if (!state.chapter) return;
1515
+ if (!state.chapter || !canEditWork()) return;
1446
1516
  cancelChapterAutoSave();
1447
1517
  setSaveState("等待自动保存", true);
1448
1518
  chapterAutoSaveTimer = setTimeout(() => {
@@ -1452,6 +1522,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1452
1522
  }
1453
1523
 
1454
1524
  async function persistChapter({ automatic = false } = {}) {
1525
+ if (!canEditWork()) return null;
1455
1526
  if (!state.chapter) {
1456
1527
  if (!automatic) toast("请先选择章节", "error");
1457
1528
  return null;
@@ -1564,7 +1635,7 @@ async function initializePage() {
1564
1635
 
1565
1636
  if (requestedWork) {
1566
1637
  state.module = route.view === "module" ? route.module : "editor";
1567
- await selectWork(requestedWork.id);
1638
+ await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
1568
1639
  }
1569
1640
 
1570
1641
  if (route.view === "editor") {
@@ -1696,11 +1767,23 @@ function renderMembers(members) {
1696
1767
  const work = memberDialogWork ?? state.work;
1697
1768
  const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1698
1769
  $("#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>
1770
+ <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>
1771
+ ${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
1772
  ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1702
1773
  </article>`).join("");
1703
1774
  bindUserAvatarFallbacks($("#members-list"));
1775
+ $("#members-list").querySelectorAll("[data-member-role]").forEach((select) => select.addEventListener("change", async () => {
1776
+ if (!work) return;
1777
+ const previousRole = members.find((member) => member.userId === select.dataset.memberRole)?.role ?? "viewer";
1778
+ try {
1779
+ const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
1780
+ renderMembers(updated);
1781
+ toast("成员权限已更新");
1782
+ } catch (error) {
1783
+ select.value = previousRole;
1784
+ toast(error.message, "error");
1785
+ }
1786
+ }));
1704
1787
  $("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
1705
1788
  if (!work) return;
1706
1789
  try {
@@ -1888,9 +1971,9 @@ function renderShelf() {
1888
1971
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
1889
1972
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
1890
1973
  </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>
1974
+ <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
1975
  </button>
1893
- <button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>
1976
+ ${canEditWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
1894
1977
  </article>`).join("")}
1895
1978
  <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
1979
  shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
@@ -1901,11 +1984,20 @@ function renderShelf() {
1901
1984
  $("#book-add-card").addEventListener("click", openWorkDialog);
1902
1985
  }
1903
1986
 
1904
- async function selectWork(workId) {
1987
+ async function selectWork(workId, preferredChapterId = null) {
1905
1988
  const discarding = state.work?.id !== workId && state.dirty;
1906
1989
  if (discarding && !confirmDiscardChanges()) return false;
1907
1990
  const nextWork = await api(`/api/works/${workId}`);
1908
1991
  if (state.work?.id !== nextWork.id) {
1992
+ loadedAiModelsWorkId = null;
1993
+ loadedAiReferencesWorkId = null;
1994
+ loadedAiConversationsWorkId = null;
1995
+ state.models = [];
1996
+ state.characters = [];
1997
+ state.settings = [];
1998
+ if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
1999
+ aiContextUsageTimer = null;
2000
+ aiContextUsageRequest += 1;
1909
2001
  state.aiCitations = [];
1910
2002
  state.aiReferences = [];
1911
2003
  state.aiPromptSent = false;
@@ -1916,6 +2008,8 @@ async function selectWork(workId) {
1916
2008
  renderAiQuickActions();
1917
2009
  resetAiFeed();
1918
2010
  $("#ai-conversation-title").textContent = "新对话";
2011
+ $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
2012
+ setAiContextMeter(null);
1919
2013
  renderAiConversationHistory();
1920
2014
  }
1921
2015
  if (discarding) setSaveState("就绪");
@@ -1927,15 +2021,14 @@ async function selectWork(workId) {
1927
2021
  settingsReturnContext = null;
1928
2022
  state.work = nextWork;
1929
2023
  state.chapter = null;
2024
+ applyWorkAccessMode();
1930
2025
  updateDocumentTitle(state.work);
1931
2026
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
1932
2027
  $("#top-search-button").disabled = false;
1933
2028
  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);
2029
+ const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2030
+ const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2031
+ if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
1939
2032
  else if (state.module === "editor") showWelcome(true);
1940
2033
  else await showModule(state.module);
1941
2034
  return true;
@@ -1964,6 +2057,7 @@ function renderTree() {
1964
2057
  renderTree();
1965
2058
  });
1966
2059
  button.addEventListener("contextmenu", (event) => {
2060
+ if (!canEditWork()) return;
1967
2061
  event.preventDefault();
1968
2062
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
1969
2063
  });
@@ -1971,6 +2065,7 @@ function renderTree() {
1971
2065
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
1972
2066
  button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
1973
2067
  button.addEventListener("contextmenu", (event) => {
2068
+ if (!canEditWork()) return;
1974
2069
  event.preventDefault();
1975
2070
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
1976
2071
  });
@@ -2020,7 +2115,8 @@ async function selectChapter(chapterId) {
2020
2115
  scheduleChapterLineNumbers();
2021
2116
  $("#chapter-insight").classList.add("hidden");
2022
2117
  updateChapterStats();
2023
- if (spacingChanged) scheduleChapterAutoSave(120);
2118
+ if (!canEditWork()) setSaveState("仅查看");
2119
+ else if (spacingChanged) scheduleChapterAutoSave(120);
2024
2120
  else setSaveState("已保存");
2025
2121
  renderTree();
2026
2122
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
@@ -2077,6 +2173,7 @@ const moduleMeta = {
2077
2173
 
2078
2174
  async function showModule(module) {
2079
2175
  if (!state.work) return showWelcome();
2176
+ if (!canEditWork() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2080
2177
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2081
2178
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2082
2179
  state.module = module;
@@ -2098,7 +2195,7 @@ async function showModule(module) {
2098
2195
  $("#module-title").textContent = meta[1];
2099
2196
  $("#module-description").textContent = meta[2];
2100
2197
  $("#module-create-button").textContent = meta[3];
2101
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings");
2198
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
2102
2199
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2103
2200
  try {
2104
2201
  if (module === "settings") await renderSettings();
@@ -2189,6 +2286,7 @@ function bindEntityHistoryButtons(refresh) {
2189
2286
 
2190
2287
  async function renderSettings() {
2191
2288
  const records = await api(`/api/works/${state.work.id}/settings`);
2289
+ state.settings = records;
2192
2290
  $("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
2193
2291
  <article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
2194
2292
  <h3>${esc(item.title)}</h3><p>${esc(item.content)}</p>
@@ -2587,8 +2685,12 @@ async function renderBookAiSettings() {
2587
2685
  api(`/api/works/${state.work.id}/task-defaults`)
2588
2686
  ]);
2589
2687
  const host = $("#module-content");
2590
- const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "query_story_knowledge"]);
2688
+ const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "query_story_knowledge"]);
2591
2689
  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)}`;
2690
+ host.querySelector('input[name="agent-tool"][value="query_story_knowledge"]').closest("label").insertAdjacentHTML(
2691
+ "beforebegin",
2692
+ `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
2693
+ );
2592
2694
  $("#save-work-system-prompt").addEventListener("click", async () => {
2593
2695
  const button = $("#save-work-system-prompt");
2594
2696
  button.disabled = true;
@@ -2655,8 +2757,12 @@ async function renderBookAiSettings() {
2655
2757
  }
2656
2758
 
2657
2759
  async function loadModels() {
2658
- if (!state.work) return;
2659
- state.models = await api(`/api/works/${state.work.id}/models`);
2760
+ const workId = state.work?.id;
2761
+ if (!workId) return;
2762
+ const models = await api(`/api/works/${workId}/models`);
2763
+ if (state.work?.id !== workId) return;
2764
+ state.models = models;
2765
+ loadedAiModelsWorkId = workId;
2660
2766
  const select = $("#ai-model");
2661
2767
  select.innerHTML = state.models.length
2662
2768
  ? state.models.map((model) => `<option value="${esc(model.id)}" ${model.enabled ? "" : "disabled"}>${esc(modelOptionLabel(model))}</option>`).join("")
@@ -2664,6 +2770,27 @@ async function loadModels() {
2664
2770
  scheduleAiContextUsage();
2665
2771
  }
2666
2772
 
2773
+ async function ensureAiModelsLoaded() {
2774
+ const workId = state.work?.id;
2775
+ if (!workId || loadedAiModelsWorkId === workId) return;
2776
+ if (aiModelsLoadPromise && aiModelsLoadWorkId === workId) return aiModelsLoadPromise;
2777
+ const select = $("#ai-model");
2778
+ select.innerHTML = '<option value="">正在加载模型……</option>';
2779
+ aiModelsLoadWorkId = workId;
2780
+ aiModelsLoadPromise = loadModels();
2781
+ try {
2782
+ await aiModelsLoadPromise;
2783
+ } catch (error) {
2784
+ if (state.work?.id === workId) select.innerHTML = '<option value="">模型加载失败,点击重试</option>';
2785
+ throw error;
2786
+ } finally {
2787
+ if (aiModelsLoadWorkId === workId) {
2788
+ aiModelsLoadPromise = null;
2789
+ aiModelsLoadWorkId = null;
2790
+ }
2791
+ }
2792
+ }
2793
+
2667
2794
  let aiContextUsageTimer = null;
2668
2795
  let aiContextUsageRequest = 0;
2669
2796
 
@@ -2772,11 +2899,32 @@ async function refreshAiContextUsage() {
2772
2899
  }
2773
2900
 
2774
2901
  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`)
2902
+ const workId = state.work?.id;
2903
+ if (!workId) return;
2904
+ const [characters, settings] = await Promise.all([
2905
+ api(`/api/works/${workId}/characters`),
2906
+ api(`/api/works/${workId}/settings`)
2779
2907
  ]);
2908
+ if (state.work?.id !== workId) return;
2909
+ state.characters = characters;
2910
+ state.settings = settings;
2911
+ loadedAiReferencesWorkId = workId;
2912
+ }
2913
+
2914
+ async function ensureAiReferencesLoaded() {
2915
+ const workId = state.work?.id;
2916
+ if (!workId || loadedAiReferencesWorkId === workId) return;
2917
+ if (aiReferencesLoadPromise && aiReferencesLoadWorkId === workId) return aiReferencesLoadPromise;
2918
+ aiReferencesLoadWorkId = workId;
2919
+ aiReferencesLoadPromise = loadAiReferences();
2920
+ try {
2921
+ await aiReferencesLoadPromise;
2922
+ } finally {
2923
+ if (aiReferencesLoadWorkId === workId) {
2924
+ aiReferencesLoadPromise = null;
2925
+ aiReferencesLoadWorkId = null;
2926
+ }
2927
+ }
2780
2928
  }
2781
2929
 
2782
2930
  function field(name, label, type = "text", value = "", options = []) {
@@ -2938,7 +3086,7 @@ function openWorkSettingsDialog(work) {
2938
3086
  if (!work) return;
2939
3087
  const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
2940
3088
  const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
2941
- <div><strong id="work-access-title">可访问人</strong><small>作品创建者和受邀协作者可以访问并共同编辑这部作品。</small></div>
3089
+ <div><strong id="work-access-title">可访问人</strong><small>可以分别授予成员仅查看或共同编辑权限。</small></div>
2942
3090
  ${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">添加或管理可访问人</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
2943
3091
  </section>`;
2944
3092
  openDialog("作品信息",
@@ -3192,6 +3340,12 @@ async function openCharacterDialog(item) {
3192
3340
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
3193
3341
  setCharacterHistoryVisible(false);
3194
3342
  renderCharacterEditorFields(item);
3343
+ const viewOnly = !canEditWork();
3344
+ if (viewOnly) {
3345
+ $("#character-editor-eyebrow").textContent = "人物档案";
3346
+ $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
3347
+ $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
3348
+ }
3195
3349
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3196
3350
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3197
3351
  });
@@ -3199,6 +3353,7 @@ async function openCharacterDialog(item) {
3199
3353
  const form = $("#character-editor-form");
3200
3354
  form.onsubmit = async (event) => {
3201
3355
  event.preventDefault();
3356
+ if (!canEditWork()) return;
3202
3357
  const submit = $("#character-editor-submit");
3203
3358
  submit.disabled = true;
3204
3359
  try {
@@ -3391,6 +3546,11 @@ function openModelDialog(providerId, item = null) {
3391
3546
 
3392
3547
  async function sendAi() {
3393
3548
  if (!state.work || !state.chapter) return toast("请先选择章节", "error");
3549
+ try {
3550
+ await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
3551
+ } catch (error) {
3552
+ return toast(`创作助手加载失败:${error.message}`, "error");
3553
+ }
3394
3554
  const modelId = $("#ai-model").value;
3395
3555
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
3396
3556
  const instruction = aiPromptText().trim();
@@ -3936,10 +4096,11 @@ $("#member-invite-form").addEventListener("submit", async (event) => {
3936
4096
  const work = memberDialogWork ?? state.work;
3937
4097
  if (!work || !userId) return;
3938
4098
  try {
3939
- const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId } });
4099
+ const role = $("#member-role-select").value;
4100
+ const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
3940
4101
  renderMembers(members);
3941
4102
  await fillMemberCandidates(members);
3942
- toast("协作者已邀请");
4103
+ toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
3943
4104
  } catch (error) { toast(error.message, "error"); }
3944
4105
  });
3945
4106
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
@@ -4055,9 +4216,22 @@ $("#module-nav").addEventListener("click", (event) => {
4055
4216
  });
4056
4217
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
4057
4218
  $("#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", () => {
4219
+ $("#ai-prompt").addEventListener("input", async () => {
4059
4220
  updateAiMentionMenu();
4060
4221
  scheduleAiContextUsage();
4222
+ if (!findAiMention(aiPromptTextBeforeCursor())) return;
4223
+ try {
4224
+ await ensureAiReferencesLoaded();
4225
+ updateAiMentionMenu();
4226
+ } catch (error) {
4227
+ toast(`引用数据加载失败:${error.message}`, "error");
4228
+ }
4229
+ });
4230
+ $("#ai-prompt").addEventListener("focus", () => {
4231
+ ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
4232
+ });
4233
+ $("#ai-model").addEventListener("focus", () => {
4234
+ ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
4061
4235
  });
4062
4236
  $("#ai-model").addEventListener("change", scheduleAiContextUsage);
4063
4237
  $("#ai-task").addEventListener("change", scheduleAiContextUsage);
@@ -4208,8 +4382,14 @@ $("#ai-context-new-conversation").addEventListener("click", async () => {
4208
4382
  }
4209
4383
  });
4210
4384
  $("#ai-context-dismiss").addEventListener("click", hideAiContextWarning);
4211
- $("#ai-history-toggle").addEventListener("click", () => {
4212
- setAiHistoryVisible(!$("#ai-history-dialog").open);
4385
+ $("#ai-history-toggle").addEventListener("click", async () => {
4386
+ if ($("#ai-history-dialog").open) return setAiHistoryVisible(false);
4387
+ try {
4388
+ await ensureAiConversationsLoaded();
4389
+ setAiHistoryVisible(true);
4390
+ } catch (error) {
4391
+ toast(`对话历史加载失败:${error.message}`, "error");
4392
+ }
4213
4393
  });
4214
4394
  $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
4215
4395
  $("#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-viewer-toast-layer">
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-ai-grep-lazy-viewer-toast"></script>
546
546
  </body>
547
547
  </html>
@@ -424,7 +424,7 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
424
424
  cursor: not-allowed;
425
425
  opacity: .46;
426
426
  }
427
- .member-invite-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; margin: 0; }
427
+ .member-invite-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, .3fr) auto; align-items: end; gap: 10px; margin: 0; }
428
428
  .member-invite-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
429
429
  .member-invite-form select {
430
430
  width: 100%;
@@ -437,6 +437,47 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
437
437
  font-size: 13px;
438
438
  }
439
439
  .member-invite-form .primary-button { min-height: 38px; white-space: nowrap; }
440
+ .app-shell.view-only-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
441
+ .app-shell.view-only-mode:not(.shelf-mode) .ai-panel { display: none; }
442
+ .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions .file-button,
443
+ .app-shell.view-only-mode:not(.shelf-mode) #new-volume-button,
444
+ .app-shell.view-only-mode:not(.shelf-mode) #new-chapter-button,
445
+ .app-shell.view-only-mode:not(.shelf-mode) #module-create-button,
446
+ .app-shell.view-only-mode:not(.shelf-mode) [data-work-settings],
447
+ .app-shell.view-only-mode:not(.shelf-mode) [data-module="tasks"],
448
+ .app-shell.view-only-mode:not(.shelf-mode) [data-module="ai-settings"],
449
+ .app-shell.view-only-mode:not(.shelf-mode) #tidy-blank-lines-button,
450
+ .app-shell.view-only-mode:not(.shelf-mode) #save-button { display: none !important; }
451
+ .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
452
+ .app-shell.view-only-mode:not(.shelf-mode) #chapter-title[readonly],
453
+ .app-shell.view-only-mode:not(.shelf-mode) #chapter-content[readonly] { cursor: default; }
454
+ body.work-viewer-mode [data-setting-status],
455
+ body.work-viewer-mode [data-edit-setting],
456
+ body.work-viewer-mode [data-edit-character],
457
+ body.work-viewer-mode [data-edit-race],
458
+ body.work-viewer-mode [data-edit-organization],
459
+ body.work-viewer-mode [data-edit-timeline-track],
460
+ body.work-viewer-mode [data-add-event-track],
461
+ body.work-viewer-mode [data-edit-event],
462
+ body.work-viewer-mode [data-split-event],
463
+ body.work-viewer-mode [data-event-select],
464
+ body.work-viewer-mode #create-timeline-track,
465
+ body.work-viewer-mode #merge-events,
466
+ body.work-viewer-mode [data-edit-foreshadow],
467
+ body.work-viewer-mode [data-edit-outline],
468
+ body.work-viewer-mode [data-edit-relationship],
469
+ body.work-viewer-mode [data-review-id],
470
+ body.work-viewer-mode .task-auto-run-panel,
471
+ body.work-viewer-mode [data-run-task],
472
+ body.work-viewer-mode [data-cancel-task],
473
+ body.work-viewer-mode [data-restore-version],
474
+ body.work-viewer-mode [data-entity-version-restore],
475
+ body.work-viewer-mode [data-character-restore],
476
+ body.work-viewer-mode [data-item-list-add],
477
+ body.work-viewer-mode [data-item-list-remove],
478
+ body.work-viewer-mode [data-structured-list-add],
479
+ body.work-viewer-mode [data-structured-list-remove],
480
+ body.work-viewer-mode .character-editor-actions { display: none !important; }
440
481
  .search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; margin: 0; }
441
482
  .search-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
442
483
  .search-form input {
@@ -1185,7 +1226,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1185
1226
  .ai-citation-card.is-expanded pre { display: block; }
1186
1227
  .message-citations { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }.message-citations span { padding: 3px 6px; border: 1px solid currentColor; border-radius: 10px; font-size: 8px; opacity: .75; }
1187
1228
 
1188
- .toast-region { position: fixed; right: 24px; bottom: 24px; z-index: 30; display: grid; max-width: calc(100vw - 48px); gap: 8px; }
1229
+ .toast-region { position: fixed; inset: auto 24px 24px auto; z-index: 2147483647; display: grid; max-width: calc(100vw - 48px); margin: 0; padding: 0; gap: 8px; overflow: visible; border: 0; background: transparent; color: inherit; pointer-events: none; }
1189
1230
  .toast-region[data-position="top-right"] { top: 78px; bottom: auto; }
1190
1231
  .toast-region[data-position="bottom-right"] { top: auto; bottom: 24px; }
1191
1232
  .toast { max-width: 360px; background: var(--toast-bg); color: var(--toast-fg); padding: 11px 15px; border-radius: 4px; box-shadow: var(--shadow); font-size: 12px; animation: toast-appear-top .18s ease; }
@@ -1348,6 +1389,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1348
1389
  .relationship-table .relationship-actions button + button { margin-left: 5px; }
1349
1390
 
1350
1391
  @media (max-width: 540px) {
1392
+ .member-invite-form { grid-template-columns: minmax(0, 1fr); }
1351
1393
  .onboarding-popover { width: calc(100vw - 24px); }
1352
1394
  .onboarding-popover-header { padding-inline: 15px; }
1353
1395
  .onboarding-popover-body { padding: 16px 17px 18px; }
@@ -1,9 +1,16 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- const actorStorage = new AsyncLocalStorage();
2
+ const requestStorage = new AsyncLocalStorage();
3
+ export function runWithRequestContext(context, operation) {
4
+ return requestStorage.run(context, operation);
5
+ }
3
6
  export function runWithRequestActor(actor, operation) {
4
- return actorStorage.run(actor, operation);
7
+ const current = requestStorage.getStore();
8
+ return requestStorage.run({ ...(current?.requestId ? { requestId: current.requestId } : {}), actor }, operation);
5
9
  }
6
10
  export function currentRequestActor() {
7
- return actorStorage.getStore() ?? null;
11
+ return requestStorage.getStore()?.actor ?? null;
12
+ }
13
+ export function currentRequestContext() {
14
+ return requestStorage.getStore() ?? null;
8
15
  }
9
16
  //# sourceMappingURL=request-context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"request-context.js","sourceRoot":"","sources":["../src/request-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAUrD,MAAM,YAAY,GAAG,IAAI,iBAAiB,EAAuB,CAAC;AAElE,MAAM,UAAU,mBAAmB,CAAI,KAA0B,EAAE,SAAkB;IACnF,OAAO,YAAY,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,mBAAmB;IACjC,OAAO,YAAY,CAAC,QAAQ,EAAE,IAAI,IAAI,CAAC;AACzC,CAAC"}
1
+ {"version":3,"file":"request-context.js","sourceRoot":"","sources":["../src/request-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAerD,MAAM,cAAc,GAAG,IAAI,iBAAiB,EAAyB,CAAC;AAEtE,MAAM,UAAU,qBAAqB,CAAI,OAAuB,EAAE,SAAkB;IAClF,OAAO,cAAc,CAAC,GAAG,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAChD,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAI,KAA0B,EAAE,SAAkB;IACnF,MAAM,OAAO,GAAG,cAAc,CAAC,QAAQ,EAAE,CAAC;IAC1C,OAAO,cAAc,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,SAAS,CAAC,CAAC;AACnH,CAAC;AAED,MAAM,UAAU,mBAAmB;IACjC,OAAO,cAAc,CAAC,QAAQ,EAAE,EAAE,KAAK,IAAI,IAAI,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,OAAO,cAAc,CAAC,QAAQ,EAAE,IAAI,IAAI,CAAC;AAC3C,CAAC"}