@musnows/scriverse 1.0.11 → 1.0.12

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.
@@ -21,6 +21,13 @@ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManage
21
21
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
22
22
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
23
23
  import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
24
+ import {
25
+ calculateChapterEditorContentHeight,
26
+ chapterLineNumberLayerTransform,
27
+ needsChapterLineVirtualWindowRefresh,
28
+ readChapterEditorScrollMetrics,
29
+ transferNestedEditorScroll
30
+ } from "/chapter-editor-scroll.js?v=20260916-line-scroll-bind-v1";
24
31
  import {
25
32
  FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
26
33
  foreshadowReminderRequestTargetsState,
@@ -202,6 +209,7 @@ function isSelectableModel(model) {
202
209
  const state = {
203
210
  user: null,
204
211
  csrfToken: null,
212
+ registrationMode: "disabled",
205
213
  works: [],
206
214
  work: null,
207
215
  chapter: null,
@@ -501,6 +509,7 @@ function applyWorkAccessMode() {
501
509
  $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
502
510
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
503
511
  $("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
512
+ $("#writing-progress-button").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("editor"));
504
513
  $("#ai-assistant-entry").classList.toggle("permission-hidden", !state.work || aiHidden);
505
514
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
506
515
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -1739,24 +1748,27 @@ function setModuleNavExpanded(expanded) {
1739
1748
  $("#module-nav").querySelectorAll(".module-nav-secondary").forEach((button) => button.classList.toggle("hidden", !expanded));
1740
1749
  }
1741
1750
 
1751
+ function getChapterEditorScroller() {
1752
+ // 行号与正文只允许这个容器滚动。
1753
+ return $("#chapter-editor-scroll");
1754
+ }
1755
+
1742
1756
  function syncChapterLineNumberScroll() {
1757
+ const scroller = getChapterEditorScroller();
1743
1758
  const input = $("#chapter-content");
1744
1759
  const inner = $("#chapter-line-numbers-inner");
1745
1760
  const whitespace = $("#chapter-whitespace-inner");
1746
- if (!input || !inner) return;
1747
- inner.style.transform = `translateY(${-input.scrollTop}px)`;
1748
- inner.dataset.scrollTop = String(input.scrollTop);
1761
+ if (!scroller || !inner) return;
1762
+ if (input) transferNestedEditorScroll(input, scroller);
1763
+ const metrics = readChapterEditorScrollMetrics(scroller);
1764
+ const layerTransform = chapterLineNumberLayerTransform();
1765
+ inner.style.transform = layerTransform;
1766
+ inner.dataset.scrollTop = String(metrics.scrollTop);
1749
1767
  if (whitespace) {
1750
- whitespace.style.transform = `translate(${-input.scrollLeft}px, ${-input.scrollTop}px)`;
1751
- whitespace.dataset.scrollTop = String(input.scrollTop);
1768
+ whitespace.style.transform = layerTransform;
1769
+ whitespace.dataset.scrollTop = String(metrics.scrollTop);
1752
1770
  }
1753
- if (!chapterLineVirtualWindow) return;
1754
- const buffer = input.clientHeight * 0.35;
1755
- const viewportBottom = input.scrollTop + input.clientHeight;
1756
- const needsPreviousLines = chapterLineVirtualWindow.start > 0 && input.scrollTop < chapterLineVirtualWindow.top + buffer;
1757
- const needsNextLines = chapterLineVirtualWindow.end < chapterLineVirtualWindow.lineCount
1758
- && viewportBottom > chapterLineVirtualWindow.bottom - buffer;
1759
- if (needsPreviousLines || needsNextLines) scheduleChapterLineNumbers();
1771
+ if (needsChapterLineVirtualWindowRefresh(chapterLineVirtualWindow, metrics)) scheduleChapterLineNumbers();
1760
1772
  }
1761
1773
 
1762
1774
  function syncChapterWhitespaceControls() {
@@ -1908,7 +1920,8 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1908
1920
  const input = $("#chapter-content");
1909
1921
  const inner = $("#chapter-line-numbers-inner");
1910
1922
  const measure = $("#chapter-line-measure");
1911
- if (!input || !inner || !measure || input.clientWidth === 0) return;
1923
+ const scroller = getChapterEditorScroller();
1924
+ if (!input || !inner || !measure || !scroller || input.clientWidth === 0) return;
1912
1925
  const style = getComputedStyle(input);
1913
1926
  const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
1914
1927
  const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
@@ -1919,20 +1932,31 @@ function renderChapterLineNumbers({ targetLineIndex = null } = {}) {
1919
1932
  const numberTextOffset = calculateLineNumberTextOffset(lineHeight, numberLineHeight);
1920
1933
  const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
1921
1934
  const paddingBottom = parseFloat(style.paddingBottom) || 0;
1922
- const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
1923
- const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
1924
- const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
1935
+ const { getLineBounds, measureHeight } = createChapterLineBoundsGetter(layout, measure, lineHeight);
1936
+ const nextHeight = calculateChapterEditorContentHeight({
1937
+ measureHeight,
1938
+ paddingTop,
1939
+ paddingBottom,
1940
+ viewportHeight: scroller.clientHeight
1941
+ });
1942
+ const nextHeightPx = `${nextHeight}px`;
1943
+ if (input.style.height !== nextHeightPx) {
1944
+ const savedScrollTop = scroller.scrollTop;
1945
+ input.style.height = nextHeightPx;
1946
+ scroller.scrollTop = savedScrollTop;
1947
+ }
1948
+ const metrics = readChapterEditorScrollMetrics(scroller);
1925
1949
  if (Number.isInteger(targetLineIndex)) {
1926
1950
  const safeTarget = Math.max(0, Math.min(targetLineIndex, layout.lines.length - 1));
1927
- input.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - input.clientHeight / 3);
1951
+ scroller.scrollTop = Math.max(0, getLineBounds(safeTarget).top + paddingTop - metrics.clientHeight / 3);
1928
1952
  }
1929
- const viewportTop = Math.max(0, input.scrollTop - paddingTop);
1930
- const overscan = Math.max(input.clientHeight, lineHeight * 8);
1953
+ const viewportTop = Math.max(0, scroller.scrollTop - paddingTop);
1954
+ const overscan = Math.max(metrics.clientHeight, lineHeight * 8);
1931
1955
  const lineWindow = findChapterLineWindow(
1932
1956
  layout.lines.length,
1933
1957
  getLineBounds,
1934
1958
  viewportTop - overscan,
1935
- viewportTop + input.clientHeight + overscan
1959
+ viewportTop + metrics.clientHeight + overscan
1936
1960
  );
1937
1961
  const numbers = document.createDocumentFragment();
1938
1962
  for (let index = lineWindow.start; index < lineWindow.end; index += 1) {
@@ -2018,26 +2042,25 @@ function scheduleChapterCaretScroll() {
2018
2042
  chapterCaretScrollFrame = null;
2019
2043
  const input = $("#chapter-content");
2020
2044
  const measure = $("#chapter-line-measure");
2021
- if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || input.readOnly || input.clientWidth === 0 || input.clientHeight === 0) return;
2045
+ const scroller = getChapterEditorScroller();
2046
+ if (!state.work?.editorTypewriterModeEnabled || document.activeElement !== input || !scroller || input.readOnly || input.clientWidth === 0 || scroller.clientHeight === 0) return;
2022
2047
  const style = getComputedStyle(input);
2023
2048
  const paddingTop = parseFloat(style.paddingTop) || 0;
2024
- const paddingBottom = parseFloat(style.paddingBottom) || 0;
2025
2049
  const contentWidth = Math.max(1, input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
2026
2050
  const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.55;
2027
2051
  const layout = prepareChapterLineLayout(input, measure, style, contentWidth);
2028
- const scrollContentHeight = input.scrollHeight - paddingTop - paddingBottom;
2029
- const targetHeight = input.scrollHeight > input.clientHeight + 1 ? scrollContentHeight : null;
2030
- const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight, targetHeight);
2052
+ const { getLineBounds } = createChapterLineBoundsGetter(layout, measure, lineHeight);
2031
2053
  const lineIndex = Math.min(layout.lines.length - 1, chapterLineIndexAtOffset(input.value, input.selectionEnd));
2032
2054
  const caretBottom = getLineBounds(lineIndex).bottom + paddingTop;
2055
+ const metrics = readChapterEditorScrollMetrics(scroller);
2033
2056
  const nextScrollTop = calculateChapterCaretScroll({
2034
2057
  caretBottom,
2035
- scrollTop: input.scrollTop,
2036
- clientHeight: input.clientHeight,
2037
- scrollHeight: input.scrollHeight
2058
+ scrollTop: metrics.scrollTop,
2059
+ clientHeight: metrics.clientHeight,
2060
+ scrollHeight: metrics.scrollHeight
2038
2061
  });
2039
- if (nextScrollTop === input.scrollTop) return;
2040
- input.scrollTop = nextScrollTop;
2062
+ if (nextScrollTop === metrics.scrollTop) return;
2063
+ scroller.scrollTop = nextScrollTop;
2041
2064
  syncChapterLineNumberScroll();
2042
2065
  scheduleChapterLineNumbers();
2043
2066
  });
@@ -6524,7 +6547,7 @@ function invalidateAuthentication() {
6524
6547
  showAuth(false);
6525
6548
  }
6526
6549
 
6527
- function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
6550
+ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false, registrationMode = "disabled") {
6528
6551
  if (state.user) return;
6529
6552
  document.documentElement.classList.remove("dev-auth-bypass");
6530
6553
  document.documentElement.classList.add("login-route");
@@ -6533,14 +6556,17 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
6533
6556
  document.body.classList.add("auth-pending");
6534
6557
  $("#auth-view").classList.remove("hidden");
6535
6558
  const canRegister = registrationOpen === true;
6559
+ const inviteRequired = registrationMode === "invite" && setupRequired !== true;
6536
6560
  $("#auth-title").textContent = setupRequired
6537
6561
  ? canRegister ? "创建首个管理员账户" : "注册已禁用"
6538
6562
  : "登录后继续创作";
6539
6563
  $("#auth-description").textContent = setupRequired
6540
6564
  ? canRegister
6541
6565
  ? "这是首次启动。首个注册用户会成为系统管理员,并接管现有作品。"
6542
- : "请将 APP_ALLOW_REGISTRATION 设置为 true 后创建首个管理员账户。"
6543
- : "你的作品、协作权限和每一次修改都会绑定到账户。";
6566
+ : "请将 APP_ALLOW_REGISTRATION 设置为 true 或 invite 后创建首个管理员账户。"
6567
+ : inviteRequired
6568
+ ? "当前部署需要邀请码才能注册。请向系统管理员获取一次性邀请码。"
6569
+ : "你的作品、协作权限和每一次修改都会绑定到账户。";
6544
6570
  const registerTab = $("#auth-register-tab");
6545
6571
  registerTab.disabled = !canRegister;
6546
6572
  registerTab.setAttribute("aria-disabled", String(!canRegister));
@@ -6549,12 +6575,19 @@ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired =
6549
6575
  const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
6550
6576
  setupTokenField.classList.toggle("hidden", !setupTokenRequired);
6551
6577
  setupTokenInput.required = setupTokenRequired;
6578
+ const inviteField = $("#register-invite-code-field");
6579
+ const inviteInput = inviteField.querySelector('input[name="inviteCode"]');
6580
+ inviteField.classList.toggle("hidden", !inviteRequired);
6581
+ inviteInput.required = inviteRequired;
6552
6582
  selectAuthMode(setupRequired && canRegister ? "register" : "login");
6553
6583
  }
6554
6584
 
6555
6585
  function applyAuthenticatedUser(session) {
6556
6586
  state.user = session.user;
6557
6587
  state.csrfToken = session.csrfToken;
6588
+ state.registrationMode = session.registrationMode === "invite" || session.registrationMode === "open"
6589
+ ? session.registrationMode
6590
+ : "disabled";
6558
6591
  const isSystemAdmin = session.user.isSystemAdmin === true;
6559
6592
  const accountButton = $("#account-button");
6560
6593
  $("#account-name").textContent = session.user.displayName;
@@ -6637,7 +6670,7 @@ async function initializeAuthentication() {
6637
6670
  if (!session.authenticated) {
6638
6671
  // 未登录时一律转到登录页路由;登录页本身则保持原样
6639
6672
  if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
6640
- showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
6673
+ showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true, session.registrationMode);
6641
6674
  return false;
6642
6675
  }
6643
6676
  // 已登录却停在登录页路由时,回到书架首页
@@ -7736,9 +7769,61 @@ async function openUsersDialog() {
7736
7769
  return;
7737
7770
  }
7738
7771
  $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
7772
+ $("#invite-codes-list").innerHTML = '<p class="empty-state">正在读取邀请码……</p>';
7773
+ $("#invite-code-latest").classList.add("hidden");
7739
7774
  $("#users-dialog").showModal();
7740
- try { renderUsers((await apiPage("/api/users")).items); }
7741
- catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
7775
+ try {
7776
+ const [usersPage] = await Promise.all([apiPage("/api/users"), loadRegistrationInvites()]);
7777
+ renderUsers(usersPage.items);
7778
+ } catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
7779
+ }
7780
+
7781
+ function registrationModeLabel(mode) {
7782
+ if (mode === "invite") return "需要邀请码才能注册";
7783
+ if (mode === "open") return "完全开放注册";
7784
+ return "彻底禁止注册";
7785
+ }
7786
+
7787
+ function renderRegistrationInvites(payload) {
7788
+ const mode = payload?.registrationMode === "invite" || payload?.registrationMode === "open"
7789
+ ? payload.registrationMode
7790
+ : "disabled";
7791
+ state.registrationMode = mode;
7792
+ $("#invite-codes-mode").textContent = `当前注册策略:${registrationModeLabel(mode)}。邀请码由系统管理员生成,每个只能使用一次。`;
7793
+ const invites = Array.isArray(payload?.items) ? payload.items : [];
7794
+ $("#invite-codes-list").innerHTML = invites.length
7795
+ ? invites.map((invite) => {
7796
+ const used = Boolean(invite.usedAt);
7797
+ const usedBy = invite.usedBy ? `${invite.usedBy.displayName} · @${invite.usedBy.username}` : "已失效";
7798
+ return `<article class="access-row invite-code-row">
7799
+ <div class="access-person-copy"><strong>${used ? "已使用" : "未使用"}</strong><small>${esc(formatDateTime(invite.createdAt))} 由 ${esc(invite.createdBy.displayName)} 生成${used ? ` · ${esc(usedBy)} 于 ${esc(formatDateTime(invite.usedAt))}` : ""}</small></div>
7800
+ <span>${used ? "已核销" : "待使用"}</span>
7801
+ </article>`;
7802
+ }).join("")
7803
+ : '<p class="empty-state">还没有邀请码。需要邀请码注册时,请先生成。</p>';
7804
+ }
7805
+
7806
+ async function loadRegistrationInvites() {
7807
+ const payload = await api("/api/registration-invites");
7808
+ renderRegistrationInvites(payload);
7809
+ return payload;
7810
+ }
7811
+
7812
+ async function generateRegistrationInvite() {
7813
+ const button = $("#invite-code-generate");
7814
+ button.disabled = true;
7815
+ try {
7816
+ const created = await api("/api/registration-invites", { method: "POST", body: {} });
7817
+ const latest = $("#invite-code-latest");
7818
+ latest.innerHTML = `新邀请码(请立即复制,页面不会再次完整显示):<strong>${esc(created.code)}</strong>`;
7819
+ latest.classList.remove("hidden");
7820
+ await loadRegistrationInvites();
7821
+ toast("邀请码已生成");
7822
+ } catch (error) {
7823
+ toast(error.message, "error");
7824
+ } finally {
7825
+ button.disabled = false;
7826
+ }
7742
7827
  }
7743
7828
 
7744
7829
  function renderAdminAiConversationFilterOptions(works, users) {
@@ -20333,6 +20418,7 @@ $("#register-form").addEventListener("submit", async (event) => {
20333
20418
  password: form.get("password"),
20334
20419
  passwordConfirmation: form.get("passwordConfirmation"),
20335
20420
  setupToken: form.get("setupToken") || undefined,
20421
+ inviteCode: form.get("inviteCode") || undefined,
20336
20422
  captchaId: form.get("captchaId"),
20337
20423
  captchaAnswer: form.get("captchaAnswer")
20338
20424
  }
@@ -20375,6 +20461,7 @@ $("#platform-usage-pricing-refresh").addEventListener("click", async () => {
20375
20461
  }
20376
20462
  });
20377
20463
  $("#user-management-button").addEventListener("click", openUsersDialog);
20464
+ $("#invite-code-generate").addEventListener("click", () => generateRegistrationInvite().catch((error) => toast(error.message, "error")));
20378
20465
  $("#admin-ai-conversations-button").addEventListener("click", () => openAdminAiConversationsDialog().catch((error) => toast(error.message, "error")));
20379
20466
  $("#writing-progress-button").addEventListener("click", () => {
20380
20467
  void openWritingProgressDialog().catch((error) => toast(error.message, "error"));
@@ -20765,6 +20852,7 @@ $("#chapter-content").addEventListener("input", (event) => {
20765
20852
  });
20766
20853
  $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
20767
20854
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
20855
+ $("#chapter-editor-scroll").addEventListener("scroll", syncChapterLineNumberScroll, { passive: true });
20768
20856
  $("#chapter-content").addEventListener("contextmenu", (event) => {
20769
20857
  if (!state.chapter) return;
20770
20858
  showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
@@ -20856,7 +20944,7 @@ $("#ai-semantic-query").addEventListener("keydown", (event) => {
20856
20944
  $("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
20857
20945
  setupPanelResize($("#left-panel-resize"), "left");
20858
20946
  setupPanelResize($("#ai-panel-resize"), "ai");
20859
- if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
20947
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-editor-scroll"));
20860
20948
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(syncMobileAiPanelSafeTop).observe($("#chapter-foreshadow-reminder"));
20861
20949
  window.addEventListener("resize", () => {
20862
20950
  if (isMobileViewport() && $("#onboarding-dialog").open) completeOnboarding();
@@ -0,0 +1,37 @@
1
+ export type ChapterEditorScrollMetrics = {
2
+ scrollTop: number;
3
+ scrollLeft: number;
4
+ clientHeight: number;
5
+ clientWidth: number;
6
+ scrollHeight: number;
7
+ };
8
+
9
+ export type ChapterLineVirtualWindow = {
10
+ start: number;
11
+ end: number;
12
+ lineCount: number;
13
+ top: number;
14
+ bottom: number;
15
+ };
16
+
17
+ export function readChapterEditorScrollMetrics(scroller?: { scrollTop?: number; scrollLeft?: number; clientHeight?: number; clientWidth?: number; scrollHeight?: number } | null): ChapterEditorScrollMetrics;
18
+
19
+ export function calculateChapterEditorContentHeight(input?: {
20
+ measureHeight?: number;
21
+ paddingTop?: number;
22
+ paddingBottom?: number;
23
+ viewportHeight?: number;
24
+ }): number;
25
+
26
+ export function needsChapterLineVirtualWindowRefresh(
27
+ virtualWindow?: ChapterLineVirtualWindow | null,
28
+ metrics?: Pick<ChapterEditorScrollMetrics, "scrollTop" | "clientHeight"> | null,
29
+ bufferRatio?: number
30
+ ): boolean;
31
+
32
+ export function chapterLineNumberLayerTransform(): "none";
33
+
34
+ export function transferNestedEditorScroll(
35
+ source?: { scrollTop?: number; scrollLeft?: number } | null,
36
+ destination?: { scrollTop?: number; scrollLeft?: number } | null
37
+ ): { transferredTop: number; transferredLeft: number };
@@ -0,0 +1,52 @@
1
+ /** 正文行号与文字共用同一滚动容器,行号层不再独立滚动。 */
2
+
3
+ export function readChapterEditorScrollMetrics(scroller) {
4
+ return {
5
+ scrollTop: Math.max(0, Number(scroller?.scrollTop) || 0),
6
+ scrollLeft: Number(scroller?.scrollLeft) || 0,
7
+ clientHeight: Math.max(0, Number(scroller?.clientHeight) || 0),
8
+ clientWidth: Math.max(0, Number(scroller?.clientWidth) || 0),
9
+ scrollHeight: Math.max(0, Number(scroller?.scrollHeight) || 0)
10
+ };
11
+ }
12
+
13
+ export function calculateChapterEditorContentHeight({
14
+ measureHeight,
15
+ paddingTop,
16
+ paddingBottom,
17
+ viewportHeight
18
+ } = {}) {
19
+ const contentHeight = Math.max(0, Number(measureHeight) || 0)
20
+ + Math.max(0, Number(paddingTop) || 0)
21
+ + Math.max(0, Number(paddingBottom) || 0);
22
+ return Math.max(contentHeight, Math.max(0, Number(viewportHeight) || 0));
23
+ }
24
+
25
+ export function needsChapterLineVirtualWindowRefresh(virtualWindow, metrics, bufferRatio = 0.35) {
26
+ if (!virtualWindow) return false;
27
+ const scrollTop = Math.max(0, Number(metrics?.scrollTop) || 0);
28
+ const clientHeight = Math.max(0, Number(metrics?.clientHeight) || 0);
29
+ const buffer = clientHeight * (Number.isFinite(Number(bufferRatio)) ? Number(bufferRatio) : 0.35);
30
+ const viewportBottom = scrollTop + clientHeight;
31
+ const needsPreviousLines = virtualWindow.start > 0 && scrollTop < virtualWindow.top + buffer;
32
+ const needsNextLines = virtualWindow.end < virtualWindow.lineCount
33
+ && viewportBottom > virtualWindow.bottom - buffer;
34
+ return needsPreviousLines || needsNextLines;
35
+ }
36
+
37
+ export function chapterLineNumberLayerTransform() {
38
+ return "none";
39
+ }
40
+
41
+ export function transferNestedEditorScroll(source, destination) {
42
+ const transferredTop = Number(source?.scrollTop) || 0;
43
+ const transferredLeft = Number(source?.scrollLeft) || 0;
44
+ if (!source || !destination || (transferredTop === 0 && transferredLeft === 0)) {
45
+ return { transferredTop: 0, transferredLeft: 0 };
46
+ }
47
+ destination.scrollTop += transferredTop;
48
+ destination.scrollLeft += transferredLeft;
49
+ source.scrollTop = 0;
50
+ source.scrollLeft = 0;
51
+ return { transferredTop, transferredLeft };
52
+ }
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260827-reader-prefetch-v1"></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=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=ai-composer-compact-controls-v1&feature=ai-context-meter-ring-only-v2&feature=ai-context-cache-hit-v1&feature=annotation-line-counts-v1&feature=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=entity-editor-back-icon-v1&feature=toast-stack-v1&feature=chapter-header-compact-v4&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1">
12
+ <link rel="stylesheet" href="/styles.css?v=20260816-task-scope-volume-collapse-v2&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v3&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=galaxy-compact-controls-v2&feature=galaxy-motion-mode-v2&feature=chapter-search-replace-v3&feature=task-auto-run-ring-center-v3&feature=character-relationship-delete-v1&feature=ai-assistant-workspace-v2&feature=mobile-module-tab-position-v1&feature=volume-detail-icon-v1&feature=editor-actions-flow-v1&feature=reader-controls-subpanel-v1&feature=reader-focus-ring-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v2&feature=ai-composer-square-controls-v2&feature=ai-composer-compact-controls-v1&feature=ai-context-meter-ring-only-v2&feature=ai-context-cache-hit-v1&feature=annotation-line-counts-v1&feature=chapter-comment-filters-v1&feature=line-number-gutter-fill-v1&feature=ai-relationship-roleplay-v1&feature=ai-model-picker-v1&feature=markdown-word-count-five-digit-v2&feature=ai-stream-character-count-stable-v1&feature=ai-stream-character-count-five-digit-v1&feature=annotation-marker-offset-v1&feature=mobile-ai-entry-hidden-v1&feature=phone-client-entry-v1&feature=ai-stream-idle-timeout-v1&feature=ai-user-message-width-v2&feature=ai-chat-image-attachments-v9&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=toast-click-dismiss-v3&feature=character-avatar-v6&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-token-quota-positive-v5&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-stream-connection-seconds-v1&feature=ai-token-usage-estimated-price-v1&feature=record-favorites-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v1&feature=admin-account-identity-v2&feature=task-detail-failure-orange-v1&feature=character-card-header-alignment-v6&feature=character-card-title-fit-v2&feature=book-import-progress-v1&feature=editor-toolbar-compact-v2&feature=reader-first-frame-v1&feature=auth-compact-v2&feature=editor-preview-toggle-v2&feature=setting-title-chrome-v2&feature=entity-pin-icon-center-v1&feature=chapter-center-bottom-space-v1&feature=work-editor-preferences-v1&feature=work-editor-preference-checkbox-v1&feature=ai-roleplay-memory-v2&feature=ai-roleplay-memory-v3&feature=ai-roleplay-memory-v5&feature=character-editor-header-actions-v1&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=roleplay-memory-pin-border-v1&feature=ai-write-tools-v2&feature=ai-write-card-actions-compact-v1&feature=ai-write-plan-actions-footer-v1&feature=ai-question-actions-footer-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-answer-limit-v1&feature=ai-question-batch-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v1&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=ai-settings-textarea-font-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=ai-skill-slash-menu-v1&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=im-narration-contrast-v1&feature=im-member-add-plus-v2&feature=im-button-hierarchy-v1&feature=im-icon-button-size-v1&feature=compact-sidebar-directory-v5&feature=ai-model-config-dialog-v1&feature=system-prompt-override-v3&feature=entity-editor-back-icon-v1&feature=toast-stack-v1&feature=chapter-header-compact-v4&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=chapter-line-scroll-bind-v1&feature=writing-goal-module-navigation-v2&feature=invite-registration-v1">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -40,6 +40,7 @@
40
40
  <form id="register-form" class="auth-form hidden" method="post" action="/api/auth/register">
41
41
  <label>用户名<input name="username" autocomplete="username" minlength="3" maxlength="40" pattern="[A-Za-z0-9_.\-\u4e00-\u9fff]+" required></label>
42
42
  <label id="register-setup-token-field" class="hidden">初始化令牌<input name="setupToken" type="password" autocomplete="off" minlength="32" maxlength="500" aria-describedby="register-setup-token-hint"><small id="register-setup-token-hint">请输入服务端 APP_SETUP_TOKEN 的值。</small></label>
43
+ <label id="register-invite-code-field" class="hidden">邀请码<input name="inviteCode" type="text" autocomplete="off" maxlength="32" spellcheck="false" autocapitalize="characters" aria-describedby="register-invite-code-hint"><small id="register-invite-code-hint">请输入系统管理员提供的一次性邀请码。</small></label>
43
44
  <label>密码<span class="password-field"><input id="register-password" name="password" type="password" autocomplete="new-password" minlength="10" maxlength="200" required><button class="password-toggle" type="button" data-password-toggle="register-password" aria-label="显示密码" aria-pressed="false" title="显示密码"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg></button></span></label>
44
45
  <label>确认密码<span class="password-field"><input id="register-password-confirmation" name="passwordConfirmation" type="password" autocomplete="new-password" minlength="10" maxlength="200" required><button class="password-toggle" type="button" data-password-toggle="register-password-confirmation" aria-label="显示确认密码" aria-pressed="false" title="显示确认密码"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 12s3.3-5.5 9.5-5.5S21.5 12 21.5 12 18.2 17.5 12 17.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/></svg></button></span></label>
45
46
  <div class="auth-captcha">
@@ -136,7 +137,6 @@
136
137
  <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
137
138
  <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
138
139
  <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
139
- <button id="writing-progress-button" type="button"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>写作目标</button>
140
140
  <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
141
141
  <button id="ai-assistant-entry" class="module-nav-secondary hidden ai-assistant-entry" type="button" aria-controls="ai-panel" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5.25a3.25 3.25 0 0 0-5.95 1.82A3.74 3.74 0 0 0 4.7 13.5a3.25 3.25 0 0 0 4.55 4.53A3.6 3.6 0 0 0 12 19.5"></path><path d="M12 5.25a3.25 3.25 0 0 1 5.95 1.82 3.74 3.74 0 0 1 1.35 6.43 3.25 3.25 0 0 1-4.55 4.53A3.6 3.6 0 0 1 12 19.5"></path><path d="M12 5.25v14.25"></path><path d="M8 9.1c1.1.1 2 .9 2.25 1.95"></path><path d="M16 9.1c-1.1.1-2 .9-2.25 1.95"></path></svg>创作助手</button>
142
142
  <button id="reader-open-button" class="module-nav-secondary hidden" type="button" aria-label="打开沉浸式阅读预览" title="阅读预览"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H11v16H5.5A2.5 2.5 0 0 0 3 21.5Z"/><path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H13v16h5.5a2.5 2.5 0 0 1 2.5 2.5Z"/></svg>阅读预览</button>
@@ -145,6 +145,7 @@
145
145
  <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
146
146
  <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
147
147
  <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
148
+ <button id="writing-progress-button" class="module-nav-secondary hidden" type="button"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2"/></svg><span class="nav-label">写作目标</span></button>
148
149
  </nav>
149
150
  <div class="panel-heading">
150
151
  <span class="panel-heading-label"><span class="panel-heading-label-full">作品目录</span><span class="panel-heading-label-compact">目录</span></span>
@@ -240,7 +241,7 @@
240
241
  <div class="settings-hub-grid">
241
242
  <button id="platform-ai-button" class="settings-hub-card hidden" type="button" data-settings-action="ai"><span class="settings-card-mark">AI</span><strong>AI 管理</strong><small>供应商、模型、上下文与全局提示词</small></button>
242
243
  <button id="platform-usage-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">T</span><strong>Token 用量</strong><small>总消耗、缓存命中率、每日网格与作品明细</small></button>
243
- <button id="user-management-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">人</span><strong>用户管理</strong><small>管理员、普通用户与账户状态</small></button>
244
+ <button id="user-management-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">人</span><strong>用户管理</strong><small>管理员、普通用户、邀请码与账户状态</small></button>
244
245
  <button id="admin-ai-conversations-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">话</span><strong>Chat 对话</strong><small>查看所有作品、所有用户的对话记录</small></button>
245
246
  <button id="platform-ui-settings-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">界</span><strong>界面与分页</strong><small>设置通知位置、银河图性能及各模块的单页数量</small></button>
246
247
  <button id="s3-backup-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">S3</span><strong>S3 备份</strong><small>将整个系统的数据库和图片同步到多个目标</small></button>
@@ -324,10 +325,12 @@
324
325
  </dl>
325
326
  </section>
326
327
  <div class="editor-body">
327
- <div class="chapter-editor-frame">
328
- <aside id="chapter-line-numbers" class="chapter-line-numbers" aria-label="正文行号,可拖拽引用多行"><div id="chapter-line-numbers-inner"></div></aside>
329
- <textarea id="chapter-content" class="chapter-content" aria-label="章节正文" placeholder="在这里开始你的故事……"></textarea>
330
- <div id="chapter-whitespace-overlay" class="chapter-whitespace-overlay is-visible" aria-hidden="true"><div id="chapter-whitespace-inner"></div></div>
328
+ <div id="chapter-editor-frame" class="chapter-editor-frame">
329
+ <div id="chapter-editor-scroll" class="chapter-editor-scroll">
330
+ <aside id="chapter-line-numbers" class="chapter-line-numbers" aria-label="正文行号,可拖拽引用多行"><div id="chapter-line-numbers-inner"></div></aside>
331
+ <textarea id="chapter-content" class="chapter-content" aria-label="章节正文" placeholder="在这里开始你的故事……"></textarea>
332
+ <div id="chapter-whitespace-overlay" class="chapter-whitespace-overlay is-visible" aria-hidden="true"><div id="chapter-whitespace-inner"></div></div>
333
+ </div>
331
334
  <div id="chapter-line-measure" class="chapter-line-measure" aria-hidden="true"></div>
332
335
  </div>
333
336
  </div>
@@ -986,6 +989,17 @@
986
989
  <dialog id="users-dialog" class="dialog wide-dialog" aria-labelledby="users-dialog-title">
987
990
  <div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="users-dialog-title">用户管理</h2></div><div class="settings-dialog-header-actions"><button id="users-settings-return" class="ghost-button settings-parent-button" type="button">返回设置</button><button id="users-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div></div>
988
991
  <div class="access-dialog-body">
992
+ <section id="invite-codes-panel" class="invite-codes-panel" aria-labelledby="invite-codes-title">
993
+ <div class="invite-codes-heading">
994
+ <div>
995
+ <h3 id="invite-codes-title">注册邀请码</h3>
996
+ <p id="invite-codes-mode" class="dialog-header-meta">正在读取注册策略……</p>
997
+ </div>
998
+ <button id="invite-code-generate" class="primary-button" type="button">生成邀请码</button>
999
+ </div>
1000
+ <p id="invite-code-latest" class="invite-code-latest hidden" role="status"></p>
1001
+ <div id="invite-codes-list" class="access-list" aria-live="polite"></div>
1002
+ </section>
989
1003
  <div id="users-list" class="access-list"></div>
990
1004
  </div>
991
1005
  </dialog>
@@ -1427,7 +1441,7 @@
1427
1441
  </dialog>
1428
1442
 
1429
1443
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
1430
- <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v3&feature=ai-error-origin-v1&feature=ai-stream-render-performance-v2&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=ai-inline-message-references-v1&feature=ai-write-plan-status-sync-v1&feature=writing-goal-module-navigation-v1"></script>
1444
+ <script type="module" src="/app.js?v=20260816-extended-thinking-effort-v1&feature=ai-history-rename-v1&feature=ai-tool-call-copy-feedback-v2&feature=ai-send-control-v2&feature=analysis-task-queue-refresh-v1&feature=character-gender-v1&feature=character-filter-state-v1&feature=relationship-canvas-scale-v1&feature=relationship-table-scroll-v1&feature=ai-session-id-copy-v2&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1&feature=calculate-time-tool-v2&feature=analysis-task-expired-toast-v1&feature=global-replace-volume-v1&feature=chapter-search-replace-v1&feature=chapter-save-toast-v1&feature=character-relationship-delete-v1&feature=character-relationship-group-v1&feature=analysis-task-stability-delay-v1&feature=ai-assistant-workspace-v2&feature=volume-detail-icon-v1&feature=volume-story-order-v1&feature=reader-manual-chapter-navigation-v1&feature=ai-message-reference-badges-v1&feature=ai-roleplay-message-reference-v1&feature=ai-message-actions-v1&feature=assistant-responsive-navigation-v3&feature=annotation-permissions-v1&feature=annotation-line-counts-v1&feature=ai-relationship-roleplay-v1&feature=ai-roleplay-user-character-visibility-v1&feature=ai-roleplay-story-recall-v1&feature=ai-model-picker-v1&feature=ai-fork-model-unlock-v1&feature=context-percent-format-v1&feature=annotation-precise-locate-v1&feature=markdown-word-count-stable-v1&feature=ai-stream-character-count-stable-v2&feature=ai-process-empty-intermediate-v1&feature=ai-feed-scroll-follow-v2&feature=ai-message-retry-v1&feature=ai-stream-idle-timeout-v2&feature=ai-config-delete-v1&feature=ai-provider-protocol-options-v1&feature=ai-provider-thinking-type-v1&feature=ai-chat-image-attachments-v8&feature=ai-message-image-preview-v1&feature=ai-thinking-scroll-v2&feature=ai-image-conversation-model-lock-v1&feature=toast-click-dismiss-v2&feature=system-restart-dialog-delay-v1&feature=character-avatar-v6&feature=character-death-position-v1&feature=admin-ai-conversations-v1&feature=ai-usage-pricing-v1&feature=ai-usage-pricing-badge-v2&feature=ai-usage-pricing-label-v1&feature=ai-usage-token-breakdown-v1&feature=ai-usage-pricing-cache-v2&feature=ai-usage-pricing-manual-refresh-v1&feature=ai-monthly-token-quota-v1&feature=ai-provider-token-quota-v1&feature=ai-token-quota-positive-v6&feature=ai-model-thinking-label-v1&feature=ai-model-picker-focus-v1&feature=ai-provider-model-import-v1&feature=ai-assistant-brain-icon-v1&feature=ai-token-usage-details-v1&feature=ai-token-usage-details-centered-v1&feature=ai-token-usage-raw-input-v1&feature=ai-stream-connection-seconds-v1&feature=phone-client-entry-v1&feature=ai-usage-pricing-cache-v1&feature=ai-model-thinking-label-v3&feature=ai-roleplay-speaker-label-v1&feature=toast-modal-host-v1&feature=api-key-copy-existing-v1&feature=character-favorite-v1&feature=record-favorites-v1&feature=ai-token-usage-estimated-price-v1&feature=entity-detail-favorites-v1&feature=entity-pin-v1&feature=entity-pin-icon-v1&feature=roleplay-favorite-label-v1&feature=ai-roleplay-knowledge-tools-v1&feature=character-persona-summary-v1&feature=ai-roleplay-scene-turn-v2&feature=admin-account-identity-v2&feature=ai-provider-analysis-timeout-v1&feature=task-detail-failure-orange-v1&feature=book-import-progress-v1&feature=presence-multiple-users-v1&feature=ai-context-input-output-v1&feature=editor-blank-lines-preserved-v1&feature=reader-first-frame-v1&feature=vditor-lazy-load-v1&feature=ui-module-preload-v1&feature=reader-initial-prefetch-v1&feature=editor-preview-toggle-v2&feature=vditor-fullscreen-disabled-v1&feature=chapter-auto-indent-v1&feature=chapter-centered-scroll-v1&feature=work-editor-preferences-v1&feature=ai-context-output-usage-v1&feature=ai-context-meter-ring-only-v1&feature=ai-context-cache-hit-v1&feature=ai-roleplay-memory-v4&feature=ai-roleplay-memory-v5&feature=roleplay-memory-header-actions-v1&feature=roleplay-memory-header-toolbar-v1&feature=roleplay-memory-action-icons-v1&feature=roleplay-memory-action-colors-v1&feature=annotation-line-anchor-v1&feature=stable-line-ids-v1&feature=live-annotation-anchors-v1&feature=chapter-comment-filters-v1&feature=ai-write-tools-v3&feature=ai-question-option-supplement-v1&feature=ai-question-selection-highlight-v1&feature=ai-question-continuation-ui-v3&feature=ai-background-stream-v1&feature=ai-question-tool-result-v1&feature=ai-question-tool-summary-v1&feature=ai-question-submit-guidance-v1&feature=ai-question-batch-v1&feature=ai-question-answer-limit-v1&feature=semantic-search-v6&feature=chapter-title-renumber-v1&feature=ai-writing-skills-v3&feature=ai-cancel-preserve-process-v2&feature=remote-mcp-v1&feature=character-alias-chips-v2&feature=character-title-input-v1&feature=character-detail-value-wrap-v2&feature=chapter-batch-editor-refresh-v1&feature=ai-usage-stat-display-v1&feature=ai-usage-year-v1&feature=semantic-search-rag-label-v1&feature=ai-skill-slash-menu-v1&feature=ai-roleplay-scene-bubble-v1&feature=ai-roleplay-scene-collapse-v1&feature=markdown-adjacent-blockquotes-v1&feature=chapter-save-shortcut-v2&feature=ai-message-citation-popover-v1&feature=line-citation-menu-separator-v1&feature=global-im-v106&feature=im-sidebar-compact-v1&feature=ai-fork-progress-toast-v1&feature=im-settings-gear-v1&feature=compact-sidebar-directory-v2&feature=chapter-word-count-consistency-v1&feature=ai-all-message-references-v2&feature=ai-question-render-recovery-v3&feature=setting-category-preservation-v1&feature=toast-stack-v1&feature=toast-stack-label-v2&feature=agent-tool-limits-300-v1&feature=server-dev-logo-v1&feature=chapter-directory-ai-reference-v3&feature=ai-error-origin-v1&feature=ai-stream-render-performance-v2&feature=ai-attachment-disabled-v1&feature=ai-prose-acceptance-removed-v1&feature=ai-inline-message-references-v1&feature=ai-write-plan-status-sync-v1&feature=writing-goal-module-navigation-v2&feature=chapter-line-scroll-bind-v1&feature=invite-registration-v1"></script>
1431
1445
 
1432
1446
  </body>
1433
1447
  </html>