@musnows/scriverse 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +177 -0
  4. package/dist/ai-tool-results.js.map +1 -0
  5. package/dist/ai.js +516 -138
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +186 -55
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +9 -5
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +14 -2
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/collaboration-presence.js +1 -0
  16. package/dist/collaboration-presence.js.map +1 -1
  17. package/dist/credential-vault.js +7 -5
  18. package/dist/credential-vault.js.map +1 -1
  19. package/dist/database.js +121 -2
  20. package/dist/database.js.map +1 -1
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/public/ai-context-meter.js +7 -0
  24. package/dist/public/app.js +556 -223
  25. package/dist/public/entity-version.js +2 -2
  26. package/dist/public/index.html +19 -11
  27. package/dist/public/markdown.js +1 -2
  28. package/dist/public/model-config.d.ts +3 -0
  29. package/dist/public/model-config.js +14 -0
  30. package/dist/public/page-route.js +1 -0
  31. package/dist/public/styles.css +40 -20
  32. package/dist/public/work-permissions.d.ts +1 -1
  33. package/dist/public/work-permissions.js +1 -1
  34. package/dist/security.js +45 -10
  35. package/dist/security.js.map +1 -1
  36. package/dist/server-runtime.js +56 -1
  37. package/dist/server-runtime.js.map +1 -1
  38. package/dist/store.js +236 -25
  39. package/dist/store.js.map +1 -1
  40. package/dist/user-auth.js +19 -71
  41. package/dist/user-auth.js.map +1 -1
  42. package/dist/version.js +1 -1
  43. package/dist/work-permissions.js +1 -1
  44. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { levelLabel, relationshipCategoryLabel } from "./display-labels.js?v=20260725-enum-labels-zh";
2
2
 
3
3
  export const VERSIONED_ENTITY_LABELS = Object.freeze({
4
- draft: "创作草稿",
4
+ draft: "创作想法",
5
5
  setting: "世界观设定",
6
6
  race: "种族档案",
7
7
  organization: "组织档案",
@@ -25,7 +25,7 @@ export function entityVersionSourceLabel(source) {
25
25
  }
26
26
 
27
27
  export function entityVersionSnapshotSummary(type, snapshot = {}) {
28
- if (type === "draft") return `${snapshot.draftType === "setting" ? "设定草稿" : "正文草稿"} · ${snapshot.title || "未命名草稿"}`;
28
+ if (type === "draft") return `${snapshot.draftType === "setting" ? "设定想法" : "正文想法"} · ${snapshot.title || "未命名想法"}`;
29
29
  if (type === "setting") return `${snapshot.category || "未分类"} · ${snapshot.title || "未命名设定"}`;
30
30
  if (type === "race") return `${snapshot.name || "未命名种族"} · ${(snapshot.memberIds ?? []).length} 位角色`;
31
31
  if (type === "organization") return `${snapshot.name || "未命名组织"} · ${(snapshot.memberIds ?? []).length} 位成员`;
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260730-ai-error-drafts-filter-align-model-availability-token-distribution-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260731-draft-bindings-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -26,7 +26,7 @@
26
26
  <form id="login-form" class="auth-form">
27
27
  <label>用户名<input name="username" autocomplete="username" maxlength="100" required></label>
28
28
  <label>密码<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="200" required aria-describedby="login-lock-hint"></label>
29
- <p id="login-lock-hint" class="auth-security-hint">5 分钟内连续输错 5 次密码,登录将锁定 30 分钟。</p>
29
+ <p id="login-lock-hint" class="auth-security-hint">登录和注册采用验证码与来源限速防护,不会因他人输错密码而锁定账户。</p>
30
30
  <div class="auth-captcha">
31
31
  <label>验证码<input name="captchaAnswer" autocomplete="off" inputmode="text" maxlength="8" spellcheck="false" required aria-describedby="login-captcha-hint"></label>
32
32
  <button id="login-captcha-refresh" class="auth-captcha-image" type="button" aria-label="刷新验证码" title="刷新验证码">
@@ -40,6 +40,7 @@
40
40
  </form>
41
41
  <form id="register-form" class="auth-form hidden">
42
42
  <label>用户名<input name="username" autocomplete="username" minlength="3" maxlength="40" pattern="[A-Za-z0-9_.\-\u4e00-\u9fff]+" required></label>
43
+ <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
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">
@@ -75,7 +76,7 @@
75
76
  <div class="top-actions">
76
77
  <span id="save-state" class="save-state">就绪</span>
77
78
  <div id="presence-control" class="presence-control hidden">
78
- <button id="presence-button" class="presence-button" type="button" aria-expanded="false" aria-controls="presence-panel"><span class="presence-status-dot" aria-hidden="true"></span><span id="presence-count">1 人在线</span></button>
79
+ <button id="presence-button" class="presence-button" type="button" aria-expanded="false" aria-controls="presence-panel"><span class="presence-status-dot" aria-hidden="true"></span><span id="presence-count">1 人</span></button>
79
80
  <section id="presence-panel" class="presence-panel hidden" aria-label="当前作品在线协作者">
80
81
  <header><strong>正在访问这部作品</strong><small>离开后状态会自动过期</small></header>
81
82
  <div id="presence-list" class="presence-list" aria-live="polite"></div>
@@ -123,7 +124,7 @@
123
124
  </div>
124
125
  <nav id="module-nav" class="module-nav" aria-label="作品模块">
125
126
  <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
126
- <button type="button" data-module="drafts"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>草稿</button>
127
+ <button type="button" data-module="drafts"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/></svg>想法</button>
127
128
  <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
128
129
  <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
129
130
  <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
@@ -132,6 +133,7 @@
132
133
  <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>
133
134
  <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>
134
135
  <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>
136
+ <button class="module-nav-secondary hidden" type="button" data-module="comments"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/><path d="M8 9h8M8 13h5"/></svg><span class="nav-label">正文评论</span></button>
135
137
  <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
136
138
  <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>
137
139
  <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>
@@ -227,7 +229,7 @@
227
229
  <span id="chapter-stats" class="chapter-stats">0 字 · v0</span>
228
230
  <button id="insight-button" class="ghost-button" type="button" aria-controls="chapter-insight-toast" aria-expanded="false">章节概览</button>
229
231
  <button id="versions-button" class="ghost-button" type="button">版本</button>
230
- <button id="chapter-annotations-button" class="ghost-button" type="button">批注与待办</button>
232
+ <button id="chapter-annotations-button" class="ghost-button" type="button">评论</button>
231
233
  <button id="chapter-delete-button" class="danger-button" type="button">删除章节</button>
232
234
  <button id="chapter-edit-button" class="primary-button hidden" type="button">编辑</button>
233
235
  <button id="tidy-blank-lines-button" class="ghost-button" type="button">整理空行</button>
@@ -332,10 +334,10 @@
332
334
  <button class="danger-button" type="button" role="menuitem" data-delete-chapter>删除章节</button>
333
335
  </div>
334
336
  </div>
335
- <div id="line-citation-menu" class="line-citation-menu hidden" role="menu" aria-label="正文行引用操作">
337
+ <div id="line-citation-menu" class="line-citation-menu hidden" role="menu" aria-label="正文行操作">
336
338
  <strong id="line-citation-label">已选择正文</strong>
337
339
  <button id="add-line-citation" type="button" role="menuitem">添加到创作助手引用</button>
338
- <button id="add-line-annotation" type="button" role="menuitem">添加批注</button>
340
+ <button id="add-line-annotation" type="button" role="menuitem">添加评论</button>
339
341
  <button id="add-line-todo" type="button" role="menuitem">添加待办</button>
340
342
  </div>
341
343
  <div id="markdown-table-menu" class="markdown-table-menu hidden" role="menu" aria-label="Markdown 表格显示方式">
@@ -691,8 +693,8 @@
691
693
  </dialog>
692
694
 
693
695
  <dialog id="chapter-annotations-dialog" class="dialog wide-dialog chapter-annotations-dialog" aria-labelledby="chapter-annotations-title">
694
- <div class="dialog-header"><div><span class="eyebrow">正文协作</span><h2 id="chapter-annotations-title">批注与待办</h2><p id="chapter-annotations-meta" class="dialog-header-meta">选择正文行后可添加批注或待办。</p></div><button id="chapter-annotations-close" class="dialog-close" aria-label="关闭批注与待办" type="button">×</button></div>
695
- <div id="chapter-annotations-list" class="chapter-annotations-list"></div>
696
+ <div class="dialog-header"><div><span class="eyebrow">正文协作</span><h2 id="chapter-annotations-title">正文评论</h2><p id="chapter-annotations-meta" class="dialog-header-meta">在正文行上点击右键即可添加评论。</p></div><button id="chapter-annotations-close" class="dialog-close" aria-label="关闭正文评论" type="button">×</button></div>
697
+ <div id="chapter-annotations-list" class="chapter-annotations-list" aria-live="polite"></div>
696
698
  <div class="dialog-actions"><button id="chapter-annotations-refresh" class="ghost-button" type="button">刷新</button><button id="chapter-annotations-done" class="primary-button" type="button">完成</button></div>
697
699
  </dialog>
698
700
 
@@ -724,13 +726,14 @@
724
726
  <legend>可分页模块单页数量</legend>
725
727
  <small>以下模块独立设置,允许 10–100 条;默认均为 30 条。种族层级始终完整展示。</small>
726
728
  <div class="pagination-settings-grid">
727
- <label>草稿列表<input id="page-size-drafts" name="pageSizeDrafts" type="number" min="10" max="100" step="1" required aria-label="草稿列表单页数量"></label>
729
+ <label>想法列表<input id="page-size-drafts" name="pageSizeDrafts" type="number" min="10" max="100" step="1" required aria-label="想法列表单页数量"></label>
728
730
  <label>设定库<input id="page-size-settings" name="pageSizeSettings" type="number" min="10" max="100" step="1" required aria-label="设定库单页数量"></label>
729
731
  <label>角色列表<input id="page-size-characters" name="pageSizeCharacters" type="number" min="10" max="100" step="1" required aria-label="角色列表单页数量"></label>
730
732
  <label>组织列表<input id="page-size-organizations" name="pageSizeOrganizations" type="number" min="10" max="100" step="1" required aria-label="组织列表单页数量"></label>
731
733
  <label>时间线事件<input id="page-size-timeline" name="pageSizeTimeline" type="number" min="10" max="100" step="1" required aria-label="时间线事件单页数量"></label>
732
734
  <label>大纲与伏笔<input id="page-size-outlines" name="pageSizeOutlines" type="number" min="10" max="100" step="1" required aria-label="大纲与伏笔单页数量"></label>
733
735
  <label>人物关系列表<input id="page-size-relationships" name="pageSizeRelationships" type="number" min="10" max="100" step="1" required aria-label="人物关系列表单页数量"></label>
736
+ <label>正文评论<input id="page-size-comments" name="pageSizeComments" type="number" min="10" max="100" step="1" required aria-label="正文评论单页数量"></label>
734
737
  <label>审核列表<input id="page-size-reviews" name="pageSizeReviews" type="number" min="10" max="100" step="1" required aria-label="审核列表单页数量"></label>
735
738
  <label>AI 分析任务<input id="page-size-analysis-tasks" name="pageSizeAnalysisTasks" type="number" min="10" max="100" step="1" required aria-label="AI 分析任务单页数量"></label>
736
739
  <label>正文导入历史<input id="page-size-file-versions" name="pageSizeFileVersions" type="number" min="10" max="100" step="1" required aria-label="正文导入历史单页数量"></label>
@@ -854,6 +857,11 @@
854
857
  </div>
855
858
  <div class="ai-history-dialog-body">
856
859
  <div id="ai-history-list" class="ai-history-list" aria-live="polite"></div>
860
+ <nav id="ai-history-pagination" class="module-pagination ai-history-pagination hidden" aria-label="历史记录分页">
861
+ <button id="ai-history-previous" type="button" disabled>上一页</button>
862
+ <span id="ai-history-page-label">第 1 页</span>
863
+ <button id="ai-history-next" type="button" disabled>下一页</button>
864
+ </nav>
857
865
  </div>
858
866
  </dialog>
859
867
 
@@ -888,6 +896,6 @@
888
896
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
889
897
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
890
898
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
891
- <script type="module" src="/app.js?v=20260730-ai-error-model-availability-token-distribution-conversation-title-v1"></script>
899
+ <script type="module" src="/app.js?v=20260731-draft-bindings-v1"></script>
892
900
  </body>
893
901
  </html>
@@ -17,7 +17,6 @@ function safeImageTarget(value) {
17
17
  const target = String(value ?? "").trim();
18
18
  const attachment = target.match(/^attachment:\/\/([A-Za-z0-9_-]{1,300})$/u);
19
19
  if (attachment) return `/api/attachments/${encodeURIComponent(attachment[1])}/content`;
20
- if (/^https:\/\//iu.test(target)) return target;
21
20
  if (target.startsWith("/") && !target.startsWith("//")) return target;
22
21
  return null;
23
22
  }
@@ -67,7 +66,7 @@ function renderInlineMarkdown(value) {
67
66
  source = replaceInlineCodeSpans(source, preserve);
68
67
  source = source.replace(/!\[([^\]\n]*)\]\(([^)\s]+)\)/gu, (_match, label, src) => {
69
68
  const target = safeImageTarget(src);
70
- if (!target) return `[图片:${label || src}]`;
69
+ if (!target) return `[外部图片已阻止:${label || "未命名图片"}]`;
71
70
  const caption = String(label ?? "").trim();
72
71
  return preserve(`<span class="markdown-image"><img src="${escapeHtml(target)}" alt="${escapeHtml(caption)}" loading="lazy" decoding="async">${caption ? `<small>${escapeHtml(caption)}</small>` : ""}</span>`);
73
72
  });
@@ -1,4 +1,6 @@
1
1
  export const MODEL_PURPOSE_OPTIONS: ReadonlyArray<readonly [string, string]>;
2
+ export const MIN_MODEL_CONTEXT_WINDOW: number;
3
+ export const RECOMMENDED_MODEL_CONTEXT_WINDOW: number;
2
4
 
3
5
  export type ModelFormValues = {
4
6
  displayName: string;
@@ -13,6 +15,7 @@ export type ModelFormValues = {
13
15
 
14
16
  export function normalizeModelPurposes(purposes: unknown): string[];
15
17
  export function isKimiModelId(modelId: unknown): boolean;
18
+ export function modelContextWindowGuidance(value: unknown): { belowMinimum: boolean; showRecommendation: boolean };
16
19
  export function modelFormValues(model?: Record<string, unknown> | null): ModelFormValues;
17
20
  export function modelPayload(values: ModelFormValues, existingPreset?: Record<string, unknown>): Record<string, unknown> & { thinkingEnabled: boolean };
18
21
  export function modelOptionLabel(model: Record<string, unknown> | null | undefined): string;
@@ -9,6 +9,9 @@ export const MODEL_PURPOSE_OPTIONS = Object.freeze([
9
9
  ["consistency-check", "一致性校对"]
10
10
  ]);
11
11
 
12
+ export const MIN_MODEL_CONTEXT_WINDOW = 32_768;
13
+ export const RECOMMENDED_MODEL_CONTEXT_WINDOW = 128_000;
14
+
12
15
  const purposeAliases = new Map([
13
16
  ...MODEL_PURPOSE_OPTIONS.flatMap(([key, label]) => [[key, key], [label, key]]),
14
17
  ["章节分析", "chapter-analysis"],
@@ -25,6 +28,17 @@ export function isKimiModelId(modelId) {
25
28
  return String(modelId ?? "").toLowerCase().includes("kimi");
26
29
  }
27
30
 
31
+ export function modelContextWindowGuidance(value) {
32
+ const contextWindow = Number(value);
33
+ const hasNumericValue = String(value ?? "").trim() !== "" && Number.isFinite(contextWindow);
34
+ return {
35
+ belowMinimum: hasNumericValue && contextWindow < MIN_MODEL_CONTEXT_WINDOW,
36
+ showRecommendation: hasNumericValue
37
+ && contextWindow >= MIN_MODEL_CONTEXT_WINDOW
38
+ && contextWindow < RECOMMENDED_MODEL_CONTEXT_WINDOW
39
+ };
40
+ }
41
+
28
42
  export function modelFormValues(model = null) {
29
43
  const modelId = String(model?.modelId ?? "");
30
44
  const configuredTemperature = model?.preset?.temperature;
@@ -5,6 +5,7 @@ export const RESTORABLE_MODULES = Object.freeze([
5
5
  "races",
6
6
  "organizations",
7
7
  "timeline",
8
+ "comments",
8
9
  "outlines",
9
10
  "relationships",
10
11
  "reviews",
@@ -509,7 +509,7 @@ input[type="checkbox"][hidden] { display: none; }
509
509
 
510
510
  .left-panel, .ai-panel { position: relative; min-height: 0; background: var(--panel); }
511
511
  .left-panel { display: flex; flex-direction: column; border-right: 1px solid var(--line); padding: 0; overflow: hidden; }
512
- .left-panel-body { flex: 1 1 auto; min-height: 0; padding: 18px 28px 16px 14px; overflow-y: auto; scrollbar-gutter: stable; }
512
+ .left-panel-body { flex: 1 1 auto; min-height: 0; padding: 18px 24px 16px 14px; overflow-y: auto; scrollbar-gutter: stable; }
513
513
  .ai-panel { border-left: 1px solid var(--line); display: flex; flex-direction: column; padding: 14px 16px 18px; min-width: 0; }
514
514
  .panel-collapse-button { z-index: 8; flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font-size: 18px; line-height: 1; }
515
515
  .ai-heading #ai-panel-toggle { flex-basis: 30px; width: 30px; height: 30px; }
@@ -1275,10 +1275,12 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1275
1275
  .character-filter-toolbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 38px; }
1276
1276
  .character-filter-result-count { color: var(--muted); font-size: 11px; white-space: nowrap; }
1277
1277
  .character-filter-toolbar-actions button:disabled { cursor: default; opacity: .45; }
1278
- .draft-filter-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
1279
- .draft-filter-toolbar label { color: var(--muted); font-size: 11px; }
1280
- .draft-filter-toolbar select { min-width: 180px; min-height: 38px; padding: 8px 10px; background: var(--surface); font-size: 11px; }
1281
- .draft-filter-toolbar span { margin-left: auto; align-self: center; color: var(--muted); font-size: 11px; }
1278
+ .draft-filter-toolbar { grid-template-columns: minmax(180px, .72fr) minmax(240px, 1fr) auto; }
1279
+ .draft-type-filter-field { display: grid; min-width: 0; }
1280
+ .draft-type-filter-field > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1281
+ .draft-type-filter-field select { width: 100%; min-width: 0; min-height: 38px; padding: 8px 10px; background: var(--surface); color: var(--ink); font-size: 11px; }
1282
+ .draft-binding-field { min-width: 0; }
1283
+ .draft-binding-field > label { display: grid; gap: 7px; }
1282
1284
  .character-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1283
1285
  .character-duplicate-review { grid-column: 1 / -1; }
1284
1286
  .character-duplicate-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
@@ -1292,7 +1294,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1292
1294
  .character-duplicate-actions { flex-wrap: wrap; }
1293
1295
  .card-actions { display: flex; gap: 6px; margin-top: 15px; }
1294
1296
  .card-actions button { border: 1px solid var(--line); background: transparent; border-radius: 3px; font-size: 10px; padding: 5px 8px; }
1295
- .card-actions .record-card-edit { padding: 0; }
1297
+ .card-actions .record-card-edit { position: static; padding: 0; }
1296
1298
  .danger-button { border: 1px solid color-mix(in srgb, var(--accent) 52%, var(--line)); border-radius: 4px; background: transparent; color: var(--accent-dark); padding: 8px 13px; font-size: 12px; }
1297
1299
  .danger-button:hover, .danger-button:focus-visible { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); outline: none; }
1298
1300
  .card-actions .danger-button { border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); color: var(--accent-dark); }
@@ -1318,9 +1320,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1318
1320
  .provider-card .model-pill { flex: 1 1 auto; max-width: 100%; margin: 0; border: 0; text-align: left; white-space: normal; cursor: pointer; }
1319
1321
  .provider-model-row .model-pill { min-width: 0; }
1320
1322
  .provider-model-row.is-unavailable .model-pill { color: var(--muted); opacity: .72; }
1321
- .provider-model-row .model-test-button { flex: 0 0 auto; min-height: 30px; padding: 6px 9px; }
1322
1323
  .provider-card .model-pill:hover, .provider-card .model-pill:focus-visible { background: var(--surface-hover); color: var(--ink); }
1323
1324
  .provider-card .card-actions { flex-wrap: wrap; }
1325
+ .model-connection-test { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface-soft); }
1326
+ .model-connection-test > div { display: grid; gap: 4px; }
1327
+ .model-connection-test strong { color: var(--ink); font-size: 11px; }
1328
+ .model-connection-test p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
1329
+ .model-connection-test button { flex: 0 0 auto; min-height: 34px; }
1324
1330
  .pill { display: inline-block; padding: 3px 7px; margin: 0 4px 4px 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--muted); font-size: 9px; }
1325
1331
  .organization-links { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; margin: 8px 0 6px; }.organization-links b { color: var(--muted); font-size: 9px; font-weight: 500; }.organization-links .pill { margin: 0; }.organization-empty { color: var(--muted); font-size: 9px; }.organization-pill { border: 1px solid color-mix(in srgb, var(--accent) 48%, var(--line)); color: var(--accent-dark); background: color-mix(in srgb, var(--accent) 20%, var(--surface)); }.organization-settings { margin: 10px 0 6px; }.organization-members { margin-top: 9px !important; }.relationship-keyword { white-space: nowrap; }
1326
1332
  .race-settings { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 6px; }.race-settings .pill { margin: 0; }.race-members { margin-top: 9px !important; }
@@ -2046,12 +2052,20 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2046
2052
  .quick-actions button { min-width: 0; min-height: 30px; border: 1px solid var(--line); background: transparent; border-radius: 3px; padding: 5px 3px; color: var(--muted); font-size: 10px; white-space: nowrap; }
2047
2053
  .quick-actions button:hover { color: var(--ink); background: var(--paper-deep); }
2048
2054
  .ai-feed { flex: 1; min-height: 0; overflow-y: auto; margin: 10px -5px 12px; padding: 0 5px; }
2055
+ .ai-context-compaction-divider { display: grid; grid-template-columns: minmax(16px, 1fr) auto minmax(16px, 1fr); gap: 9px; align-items: center; margin: 4px 0 16px; color: var(--muted); font-size: 9px; line-height: 1; text-align: center; }
2056
+ .ai-context-compaction-divider::before, .ai-context-compaction-divider::after { height: 1px; background: color-mix(in srgb, var(--muted) 36%, transparent); content: ""; }
2057
+ .ai-context-compaction-divider span { letter-spacing: .08em; white-space: nowrap; }
2049
2058
  .assistant-message, .user-message { position: relative; margin-bottom: 12px; padding: 12px 13px; border-radius: 5px; font-size: 12px; line-height: 1.55; }
2050
2059
  .assistant-message { background: var(--paper-deep); }
2060
+ .assistant-message.is-error { border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); border-left: 3px solid var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--paper-deep)); }
2051
2061
  .assistant-message.has-message-actions, .user-message.has-message-actions { margin-bottom: 42px; }
2052
2062
  .user-message { margin-left: 24px; background: var(--accent); color: #fff; }
2053
2063
  .assistant-message > .message-heading, .user-message > .message-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; opacity: .65; font-size: 9px; }
2054
2064
  .message-heading > span { letter-spacing: .12em; }.message-heading time { flex: 0 0 auto; font-variant-numeric: tabular-nums; letter-spacing: .03em; }
2065
+ .assistant-message.is-error > .message-heading { color: var(--accent-dark); opacity: 1; }
2066
+ .assistant-message.is-error > .message-heading > span { display: inline-flex; align-items: center; gap: 7px; }
2067
+ .ai-message-status { display: inline-flex; align-items: center; min-height: 20px; padding: 2px 7px; border: 1px solid var(--line); border-radius: 999px; font-size: 9px; font-weight: 700; line-height: 1; letter-spacing: .08em; }
2068
+ .ai-message-status.is-error { border-color: var(--accent); background: var(--accent); color: #fff; }
2055
2069
  .message-body { min-width: 0; overflow-wrap: anywhere; }
2056
2070
  .assistant-message.is-error .message-body { font-family: inherit; font-size: inherit; line-height: inherit; }
2057
2071
  .assistant-message.is-error .ai-error-text { margin: 0; white-space: pre-wrap; font: inherit; }
@@ -2102,6 +2116,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2102
2116
  .ai-process-step-body { max-height: 220px; overflow: auto; padding: 8px 9px; border-left: 2px solid color-mix(in srgb, var(--accent) 46%, var(--line)); background: var(--paper-deep); color: var(--muted); font-size: 10px; }
2103
2117
  .ai-process-intermediate-step .ai-process-step-body { border-left-color: var(--muted); }
2104
2118
  .ai-process-tool-step { padding-top: 1px; }
2119
+ .ai-process-context-compaction { display: grid; grid-template-columns: minmax(12px, 1fr) auto minmax(12px, 1fr); gap: 8px; align-items: center; min-width: 0; padding: 2px 0; color: var(--muted); font-size: 8px; line-height: 1; text-align: center; }
2120
+ .ai-process-context-compaction::before, .ai-process-context-compaction::after { height: 1px; background: color-mix(in srgb, var(--muted) 38%, transparent); content: ""; }
2121
+ .ai-process-context-compaction span { letter-spacing: .08em; white-space: nowrap; }
2105
2122
  @keyframes ai-process-pulse { 0%, 100% { opacity: .45; transform: scale(.8); } 50% { opacity: 1; transform: scale(1); } }
2106
2123
  @media (prefers-reduced-motion: reduce) { .is-streaming .ai-process-details[open] > summary::before { animation: none; } }
2107
2124
  .ai-tool-call-list { display: grid; gap: 5px; margin-top: 10px; }
@@ -2163,7 +2180,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2163
2180
  .ai-context-meter b { position: relative; z-index: 1; font-size: 9px; font-weight: 600; }.ai-context-meter.is-warning { --context-meter-color: #bf6a35; }.ai-context-meter.is-danger { --context-meter-color: #ad463c; }.ai-context-meter.is-empty { opacity: .62; }
2164
2181
  .ai-context-popover { position: absolute; right: 0; bottom: calc(100% + 10px); z-index: 40; display: grid; width: min(320px, calc(100vw - 32px)); gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--paper); color: var(--ink); box-shadow: 0 14px 38px rgba(36, 30, 24, .18); }
2165
2182
  .ai-context-popover.hidden { display: none; }
2166
- .ai-context-popover::after { position: absolute; right: 12px; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--paper); content: ""; transform: rotate(45deg); }
2183
+ .ai-context-popover::after { position: absolute; right: 71px; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--paper); content: ""; transform: rotate(45deg); }
2167
2184
  .ai-context-popover-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
2168
2185
  .ai-context-popover-header > div { display: grid; gap: 3px; min-width: 0; }
2169
2186
  .ai-context-popover-header strong { font-size: 12px; }
@@ -2295,14 +2312,17 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2295
2312
  .markdown-table-menu button small { color: var(--muted); font-size: 9px; }
2296
2313
  .chapter-annotations-dialog { width: min(820px, 94vw); }
2297
2314
  .chapter-annotations-list { display: grid; gap: 10px; max-height: 62vh; overflow: auto; padding: 18px 24px; }
2315
+ .chapter-comment-module-list { display: grid; gap: 10px; }
2298
2316
  .chapter-annotation-card { display: grid; gap: 8px; padding: 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
2299
2317
  .chapter-annotation-card.is-resolved { opacity: .65; }
2300
2318
  .chapter-annotation-card header { display: flex; align-items: center; gap: 8px; }
2301
2319
  .chapter-annotation-card header strong { font-size: 11px; }
2320
+ .chapter-annotation-card header .chapter-annotation-source { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2321
+ .chapter-annotation-line { flex: none; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 9px; }
2302
2322
  .chapter-annotation-card header em { margin-left: auto; color: var(--muted); font-size: 9px; font-style: normal; }
2303
2323
  .chapter-annotation-kind { padding: 2px 6px; border: 1px solid var(--accent); border-radius: 10px; color: var(--accent-dark); font-size: 8px; }
2304
- .chapter-annotation-card blockquote { max-height: 100px; overflow: auto; margin: 0; padding: 8px 10px; border-left: 3px solid var(--line); background: var(--surface); color: var(--muted); font: 10px/1.6 var(--font-mono), monospace; white-space: pre-wrap; }
2305
- .chapter-annotation-card p { margin: 0; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
2324
+ .chapter-annotation-card blockquote { max-height: 100px; overflow: auto; margin: 0; padding: 8px 10px; border-left: 3px solid var(--line); background: var(--surface); color: var(--muted); font-family: var(--font-mono), monospace; font-size: 10px; line-height: 1.6; white-space: pre-wrap; }
2325
+ .chapter-annotation-card p { margin: 0; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; white-space: pre-wrap; }
2306
2326
  .chapter-annotation-card > small { color: var(--muted); font-size: 9px; }
2307
2327
  .chapter-annotation-card footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
2308
2328
  .chapter-annotation-card footer button { min-height: 30px; padding: 5px 9px; font-size: 9px; }
@@ -2423,7 +2443,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2423
2443
  .relationship-source-preview-match { padding: 2px 6px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent-dark); font-size: 8px; white-space: nowrap; }
2424
2444
  .relationship-source-preview-match.is-fuzzy { background: color-mix(in srgb, var(--warning, #a56f19) 12%, transparent); color: var(--warning, #8b5c10); }
2425
2445
  .relationship-source-preview-empty { margin: 0; padding: 9px; border: 1px dashed var(--line); border-radius: 4px; }
2426
- .model-temperature-hint { margin: 0; color: var(--accent-dark); font-size: 10px; line-height: 1.55; }
2446
+ .model-temperature-hint, .model-context-window-hint { margin: 0; color: var(--accent-dark); font-size: 10px; line-height: 1.55; }
2427
2447
  .item-list-rows { display: grid; gap: 7px; }
2428
2448
  .item-list-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
2429
2449
  .item-list-row button, .item-list-add { border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font-size: 10px; }
@@ -2540,9 +2560,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2540
2560
  .import-mode-dialog .dialog-actions { flex-wrap: wrap; }
2541
2561
  .character-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
2542
2562
  .character-filter-toolbar-actions { justify-content: flex-start; }
2543
- .draft-filter-toolbar { align-items: stretch; flex-direction: column; }
2544
- .draft-filter-toolbar select { width: 100%; min-width: 0; }
2545
- .draft-filter-toolbar span { margin-left: 0; }
2563
+ .draft-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
2546
2564
  }
2547
2565
  .entity-editor-open { overflow: hidden; }
2548
2566
  .preview-record-card { cursor: pointer; }
@@ -2933,9 +2951,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2933
2951
  .brand-block .brand-mark { width: 30px; height: 30px; }
2934
2952
  .brand-block strong { font-size: 14px; }
2935
2953
  .brand-block small { display: none; }
2936
- .mobile-module-tab { position: fixed; z-index: 32; top: 132px; right: 0; display: grid; width: 34px; height: 42px; padding: 0; place-items: center; border: 1px solid var(--line); border-right: 0; border-radius: 5px 0 0 5px; background: var(--surface-soft); color: var(--accent-dark); font-size: 10px; writing-mode: vertical-rl; }
2954
+ .mobile-module-tab { position: fixed; z-index: 38; top: 132px; right: 0; display: grid; width: 34px; height: 42px; padding: 0; place-items: center; border: 1px solid var(--line); border-right: 0; border-radius: 5px 0 0 5px; background: var(--surface-soft); color: var(--accent-dark); font-size: 10px; writing-mode: vertical-rl; }
2937
2955
  .app-shell.shelf-mode .mobile-module-tab { display: none; }
2938
- .mobile-panel-backdrop { position: fixed; z-index: 30; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; background: rgba(20, 17, 14, .28); }
2956
+ .mobile-panel-backdrop { position: fixed; z-index: 36; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; background: rgba(20, 17, 14, .28); }
2939
2957
  .app-shell:not(.shelf-mode):not(.left-panel-collapsed) .mobile-panel-backdrop { display: block; }
2940
2958
  .work-meta {
2941
2959
  grid-column: 1 / -1;
@@ -2960,7 +2978,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2960
2978
 
2961
2979
  .left-panel {
2962
2980
  position: fixed;
2963
- z-index: 31;
2981
+ z-index: 37;
2964
2982
  top: 0;
2965
2983
  bottom: 0;
2966
2984
  right: 0;
@@ -3049,8 +3067,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3049
3067
  .welcome-grid { grid-template-columns: 1fr; margin-top: 34px; }
3050
3068
 
3051
3069
  .editor-toolbar { padding: 18px var(--mobile-gutter) 14px; }
3052
- .editor-actions { position: fixed; z-index: 34; right: 0; bottom: 18px; left: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: min(300px, 84vw); max-width: none; gap: 6px; padding: 10px; border: 1px solid var(--line); border-right: 0; border-radius: 6px 0 0 6px; background: var(--panel); box-shadow: -12px 0 36px rgba(36, 30, 24, .2); transform: translateX(0); transition: transform .2s ease; }
3053
- .app-shell.left-panel-collapsed .editor-actions { transform: translateX(100%); pointer-events: none; }
3070
+ .editor-actions { position: fixed; z-index: 34; right: 0; bottom: calc(76px + env(safe-area-inset-bottom)); left: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: min(300px, 84vw); max-width: none; gap: 6px; padding: 10px; border: 1px solid var(--line); border-right: 0; border-radius: 6px 0 0 6px; background: var(--panel); box-shadow: -12px 0 36px rgba(36, 30, 24, .2); transform: translateX(0); transition: transform .2s ease; }
3071
+ .app-shell.left-panel-collapsed .editor-actions { transform: translateX(0); pointer-events: auto; }
3054
3072
  .editor-actions .chapter-stats { grid-column: 1 / -1; }
3055
3073
  .editor-actions > button { min-width: 0; min-height: 36px; padding-inline: 8px; font-size: 11px; }
3056
3074
  .chapter-title { font-size: 23px; }
@@ -3083,7 +3101,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3083
3101
  .dialog-header { padding: 14px 16px 12px; }
3084
3102
  .dialog-header h2 { font-size: 20px; }
3085
3103
  .settings-dialog-header-actions { gap: 6px; }
3086
- .dialog-fields, .appearance-grid, .access-dialog-body, .account-settings-body, .versions-list, .entity-history-list { padding-inline: 16px; }
3104
+ .dialog-fields, .appearance-grid, .access-dialog-body, .account-settings-body, .versions-list, .entity-history-list, .chapter-annotations-list { padding-inline: 16px; }
3087
3105
  .task-detail-overview { grid-template-columns: minmax(0, 1fr); padding: 12px; }
3088
3106
  .task-detail-overview > * { grid-column: 1 !important; }
3089
3107
  .task-result-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -3109,6 +3127,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3109
3127
  .dialog-submit-status { padding: 10px 16px 4px; }
3110
3128
  .dialog-actions { flex-wrap: wrap; padding: 12px 16px 16px; }
3111
3129
  .dialog-actions > button { flex: 1 1 120px; }
3130
+ .model-connection-test { align-items: stretch; flex-direction: column; }
3131
+ .model-connection-test button { width: 100%; }
3112
3132
  .search-form { grid-template-columns: minmax(0, 1fr); }
3113
3133
  .search-form .primary-button { width: 100%; }
3114
3134
  .appearance-grid { grid-template-columns: minmax(0, 1fr); }
@@ -1,5 +1,5 @@
1
1
  export type WorkPermissionModule = "prose" | "drafts" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "ai-chat" | "ai-analysis" | "ai-settings";
2
- export type WorkUiModule = "editor" | "drafts" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "tasks" | "ai-settings";
2
+ export type WorkUiModule = "editor" | "drafts" | "settings" | "characters" | "races" | "organizations" | "timeline" | "comments" | "relationships" | "outlines" | "reviews" | "tasks" | "ai-settings";
3
3
  export type WorkModuleAccess = "none" | "read" | "write";
4
4
  export type WorkModulePermissions = Record<WorkPermissionModule, WorkModuleAccess>;
5
5
  export type WorkPermissionAware = { accessRole?: string | null; modulePermissions?: WorkModulePermissions };
@@ -1,6 +1,6 @@
1
1
  export const WORK_PERMISSION_MODULES = Object.freeze([
2
2
  { id: "prose", uiModule: "editor", label: "正文" },
3
- { id: "drafts", uiModule: "drafts", label: "草稿" },
3
+ { id: "drafts", uiModule: "drafts", label: "想法" },
4
4
  { id: "settings", uiModule: "settings", label: "设定库" },
5
5
  { id: "characters", uiModule: "characters", label: "角色" },
6
6
  { id: "races", uiModule: "races", label: "种族" },
package/dist/security.js CHANGED
@@ -5,22 +5,32 @@ import { lookup } from "node:dns/promises";
5
5
  import { Agent } from "undici";
6
6
  import { AppError } from "./errors.js";
7
7
  import { logger } from "./logger.js";
8
+ const maximumRateEntries = 10_000;
8
9
  const digest = (value) => createHash("sha256").update(value).digest();
9
10
  const constantTimeEqual = (left, right) => timingSafeEqual(digest(left), digest(right));
11
+ export function verifySetupToken(expected, provided) {
12
+ return Boolean(expected && provided) && constantTimeEqual(expected ?? "", provided ?? "");
13
+ }
10
14
  function requestKey(request) {
11
15
  return request.ip || request.socket.remoteAddress || "unknown";
12
16
  }
13
- function consumeRate(entries, key, limit, windowMs) {
17
+ function consumeRate(entries, key, limit, windowMs, entryLimit = maximumRateEntries) {
14
18
  const currentTime = Date.now();
15
19
  const existing = entries.get(key);
16
20
  const entry = !existing || existing.resetAt <= currentTime ? { count: 0, resetAt: currentTime + windowMs } : existing;
17
21
  entry.count += 1;
18
- entries.set(key, entry);
19
- if (entries.size > 10_000) {
22
+ if (!existing && entries.size >= entryLimit) {
20
23
  for (const [candidate, value] of entries)
21
24
  if (value.resetAt <= currentTime)
22
25
  entries.delete(candidate);
26
+ while (entries.size >= entryLimit) {
27
+ const oldest = entries.keys().next().value;
28
+ if (oldest === undefined)
29
+ break;
30
+ entries.delete(oldest);
31
+ }
23
32
  }
33
+ entries.set(key, entry);
24
34
  return { allowed: entry.count <= limit, retryAfter: Math.max(1, Math.ceil((entry.resetAt - currentTime) / 1000)) };
25
35
  }
26
36
  function unauthorized(response, realm) {
@@ -73,7 +83,7 @@ export function createBasicAuthMiddleware(options) {
73
83
  }
74
84
  export function createSecurityHeadersMiddleware() {
75
85
  return (request, response, next) => {
76
- response.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: blob: https:; manifest-src 'self'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
86
+ response.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: blob:; manifest-src 'self'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
77
87
  response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
78
88
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
79
89
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
@@ -118,12 +128,12 @@ export function createSameOriginMiddleware() {
118
128
  next();
119
129
  };
120
130
  }
121
- export function createApiRateLimitMiddleware(limit = 600, windowMs = 60_000) {
131
+ export function createApiRateLimitMiddleware(limit = 600, windowMs = 60_000, entryLimit = maximumRateEntries) {
122
132
  const entries = new Map();
123
133
  return (request, response, next) => {
124
134
  if (!request.path.startsWith("/api/") || request.path === "/api/health")
125
135
  return next();
126
- const rate = consumeRate(entries, requestKey(request), limit, windowMs);
136
+ const rate = consumeRate(entries, requestKey(request), limit, windowMs, entryLimit);
127
137
  if (rate.allowed)
128
138
  return next();
129
139
  logger.warn("security.request.blocked", { control: "api_rate_limit", retryAfterSeconds: rate.retryAfter });
@@ -131,7 +141,7 @@ export function createApiRateLimitMiddleware(limit = 600, windowMs = 60_000) {
131
141
  response.status(429).json({ error: { code: "API_RATE_LIMITED", message: "请求过于频繁,请稍后重试" } });
132
142
  };
133
143
  }
134
- export function createAuthenticationRateLimitMiddleware(limit = 20, windowMs = 15 * 60_000) {
144
+ export function createAuthenticationRateLimitMiddleware(limit = 10, windowMs = 15 * 60_000) {
135
145
  const entries = new Map();
136
146
  return (request, response, next) => {
137
147
  const authenticationWrite = request.method === "POST" && ["/api/auth/login", "/api/auth/register"].includes(request.path);
@@ -145,6 +155,23 @@ export function createAuthenticationRateLimitMiddleware(limit = 20, windowMs = 1
145
155
  response.status(429).json({ error: { code: "AUTH_RATE_LIMITED", message: "登录或注册尝试过于频繁,请稍后重试" } });
146
156
  };
147
157
  }
158
+ export function createUploadRateLimitMiddleware(limit = 30, windowMs = 10 * 60_000, entryLimit = maximumRateEntries) {
159
+ const entries = new Map();
160
+ return (request, response, next) => {
161
+ const uploadWrite = (request.method === "POST" || request.method === "PUT") && (request.path === "/api/auth/avatar"
162
+ || request.path === "/api/works/import"
163
+ || /^\/api\/works\/[^/]+\/(?:import|cover|attachments)$/u.test(request.path));
164
+ if (!uploadWrite)
165
+ return next();
166
+ const actorKey = request.authUser?.userId ?? requestKey(request);
167
+ const rate = consumeRate(entries, actorKey, limit, windowMs, entryLimit);
168
+ if (rate.allowed)
169
+ return next();
170
+ logger.warn("security.request.blocked", { control: "upload_rate_limit", retryAfterSeconds: rate.retryAfter });
171
+ response.setHeader("Retry-After", String(rate.retryAfter));
172
+ response.status(429).json({ error: { code: "UPLOAD_RATE_LIMITED", message: "文件上传过于频繁,请稍后重试" } });
173
+ };
174
+ }
148
175
  function parseIpv4(address) {
149
176
  if (isIP(address) !== 4)
150
177
  return null;
@@ -214,12 +241,15 @@ export async function assertSafeAiEndpoint(value, allowPrivateNetwork = false) {
214
241
  throw new AppError(400, "UNSAFE_PROVIDER_ENDPOINT", "AI 供应商地址指向受保护的本机、内网或链路本地网络");
215
242
  }
216
243
  }
244
+ if (endpoint.protocol === "http:" && addresses.some(({ address }) => unsafeIpKind(address) !== "private")) {
245
+ throw new AppError(400, "INSECURE_PROVIDER_ENDPOINT", "公网 AI 供应商地址必须使用 HTTPS");
246
+ }
217
247
  return addresses;
218
248
  }
219
249
  const redirectStatuses = new Set([301, 302, 303, 307, 308]);
220
250
  /**
221
251
  * 出站 AI 请求:禁止浏览器式自动跟随重定向。
222
- * 每一跳都重新做 SSRF 校验;跨主机跳转时剥离 Authorization,避免密钥泄露到跳转目标。
252
+ * 每一跳都重新做 SSRF 校验;只允许同源跳转,避免密钥或请求正文泄露到其他目标。
223
253
  */
224
254
  export async function fetchSafeAiEndpoint(fetchImpl, url, init, validateOutboundUrl, maxRedirects = 5) {
225
255
  let currentUrl = url;
@@ -251,7 +281,7 @@ export async function fetchSafeAiEndpoint(fetchImpl, url, init, validateOutbound
251
281
  throw new AppError(502, "PROVIDER_REDIRECT_INVALID", "AI 供应商返回了无效的重定向地址");
252
282
  }
253
283
  if (nextUrl.origin !== new URL(currentUrl).origin) {
254
- baseHeaders.delete("authorization");
284
+ throw new AppError(502, "PROVIDER_REDIRECT_CROSS_ORIGIN", "AI 供应商返回了不安全的跨域重定向");
255
285
  }
256
286
  if (response.status === 303) {
257
287
  init = { ...init, method: "GET", body: undefined };
@@ -274,12 +304,17 @@ export function resolveRuntimeSecurity(environment, requireAuthentication = fals
274
304
  const trustProxy = trustProxyValue === "true" ? true : /^\d+$/u.test(trustProxyValue) ? Number(trustProxyValue) : false;
275
305
  if (typeof trustProxy === "number" && (trustProxy < 0 || trustProxy > 10))
276
306
  throw new Error("APP_TRUST_PROXY 只能是 true 或 0-10 的整数");
307
+ const allowRegistration = environment.APP_ALLOW_REGISTRATION === "true";
308
+ const setupToken = environment.APP_SETUP_TOKEN ?? "";
309
+ if (allowRegistration && setupToken.length < 32)
310
+ throw new Error("开放注册时 APP_SETUP_TOKEN 至少需要 32 个字符");
277
311
  return {
278
312
  ...(username ? { auth: { username, password } } : {}),
279
313
  trustProxy,
280
314
  enforceSameOrigin: true,
281
315
  allowPrivateAiEndpoints: environment.APP_ALLOW_PRIVATE_AI_ENDPOINTS === "true" || !production,
282
- allowRegistration: environment.APP_ALLOW_REGISTRATION === "true"
316
+ allowRegistration,
317
+ ...(setupToken ? { setupToken } : {})
283
318
  };
284
319
  }
285
320
  export function isDevelopmentAuthBypassEnabled(environment, containerRuntime = detectContainerRuntime(environment)) {