@musnows/scriverse 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +163 -38
- package/dist/ai.js.map +1 -1
- package/dist/app.js +155 -36
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +23 -13
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +123 -1
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +659 -89
- package/dist/public/index.html +47 -16
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +77 -19
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +101 -9
- package/dist/security.js.map +1 -1
- package/dist/store.js +111 -20
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +41 -22
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/public/index.html
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
<meta name="description" content="面向长篇小说创作的 AI 协作工作台">
|
|
7
7
|
<meta name="theme-color" content="#8b3d2c">
|
|
8
8
|
<title>叙界 · 小说 AI 创作工作台</title>
|
|
9
|
-
<script src="/theme-init.js?v=
|
|
9
|
+
<script src="/theme-init.js?v=20260801-work-audit-page-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
12
|
<link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
|
|
13
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
13
|
+
<link rel="stylesheet" href="/styles.css?v=20260801-merge-pr-291-v1">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="auth-pending">
|
|
16
16
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<p id="auth-error" class="auth-error" role="alert"></p>
|
|
59
59
|
</div>
|
|
60
60
|
<footer class="product-footer auth-product-footer" data-product-footer aria-label="叙界项目信息">
|
|
61
|
-
<span class="product-footer-meta"><span>© <time data-product-footer-year></time
|
|
61
|
+
<span class="product-footer-meta"><span>© <time data-product-footer-year></time></span><span aria-hidden="true">·</span><span>叙界 <span data-product-footer-version>v—</span></span><span aria-hidden="true">·</span><a href="https://github.com/musnows/Scriverse" target="_blank" rel="noopener noreferrer" aria-label="在 GitHub 查看叙界仓库">GitHub</a></span>
|
|
62
62
|
<span class="product-footer-development hidden" data-product-footer-development>开发模式</span>
|
|
63
63
|
</footer>
|
|
64
64
|
</section>
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
<button id="mobile-module-tab" class="mobile-module-tab" type="button" aria-label="打开作品模块" aria-expanded="false">模块</button>
|
|
75
75
|
<div id="work-meta" class="work-meta">尚未选择作品</div>
|
|
76
76
|
<div class="top-actions">
|
|
77
|
-
<span id="save-state" class="save-state"
|
|
77
|
+
<span id="save-state" class="save-state" role="status" aria-live="polite" aria-label="系统状态:检测中">检测中</span>
|
|
78
78
|
<div id="presence-control" class="presence-control hidden">
|
|
79
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>
|
|
80
80
|
<section id="presence-panel" class="presence-panel hidden" aria-label="当前作品在线协作者">
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
</div>
|
|
160
160
|
<div id="book-shelf" class="book-shelf" data-testid="book-shelf"></div>
|
|
161
161
|
<footer class="product-footer" data-product-footer aria-label="叙界项目信息">
|
|
162
|
-
<span class="product-footer-meta"><span>© <time data-product-footer-year></time
|
|
162
|
+
<span class="product-footer-meta"><span>© <time data-product-footer-year></time></span><span aria-hidden="true">·</span><span>叙界 <span data-product-footer-version>v—</span></span><span aria-hidden="true">·</span><a href="https://github.com/musnows/Scriverse" target="_blank" rel="noopener noreferrer" aria-label="在 GitHub 查看叙界仓库">GitHub</a></span>
|
|
163
163
|
<span class="product-footer-development hidden" data-product-footer-development>开发模式</span>
|
|
164
164
|
</footer>
|
|
165
165
|
</section>
|
|
@@ -186,6 +186,19 @@
|
|
|
186
186
|
<div id="platform-usage-content" class="module-content usage-page-content"></div>
|
|
187
187
|
</section>
|
|
188
188
|
|
|
189
|
+
<section id="work-audit-view" class="shelf-view work-audit-view hidden" aria-labelledby="work-audit-title">
|
|
190
|
+
<div class="shelf-header work-audit-header">
|
|
191
|
+
<div><span id="work-audit-eyebrow" class="eyebrow">作品安全</span><h1 id="work-audit-title">操作记录</h1><p>按时间倒序查看作品写入与管理操作,以及完整的操作者、对象和变更详情。</p></div>
|
|
192
|
+
<div class="settings-detail-actions">
|
|
193
|
+
<button id="work-audit-return" class="ghost-button settings-parent-button" type="button">返回设置</button>
|
|
194
|
+
<button id="work-audit-refresh" class="ghost-button" type="button">刷新记录</button>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="work-audit-toolbar" aria-live="polite"><p id="work-audit-summary">正在读取操作记录……</p></div>
|
|
198
|
+
<div id="work-audit-list" class="work-audit-list" aria-live="polite"></div>
|
|
199
|
+
<div class="work-audit-footer"><button id="work-audit-load-more" class="primary-button hidden" type="button">加载更多记录</button></div>
|
|
200
|
+
</section>
|
|
201
|
+
|
|
189
202
|
<section id="settings-hub-view" class="shelf-view hidden" aria-labelledby="settings-hub-title">
|
|
190
203
|
<div class="shelf-header settings-hub-header">
|
|
191
204
|
<div><span class="eyebrow">工作台配置</span><h1 id="settings-hub-title">设置</h1><p>集中管理 AI、显示偏好、内容检索与作品导出。</p></div>
|
|
@@ -200,11 +213,11 @@
|
|
|
200
213
|
<button id="writing-progress-button" class="settings-hub-card" type="button"><span class="settings-card-mark">字</span><strong>写作目标</strong><small>每日目标、总字数目标与近 30 天趋势</small></button>
|
|
201
214
|
<button id="work-audit-button" class="settings-hub-card" type="button"><span class="settings-card-mark">录</span><strong>操作记录</strong><small>查看作品修改、操作者、对象与时间</small></button>
|
|
202
215
|
<button id="appearance-button" class="settings-hub-card" type="button" data-settings-action="appearance"><span class="settings-card-mark">Aa</span><strong>显示设置</strong><small>中文字体、等宽英文字体、字号与行距</small></button>
|
|
203
|
-
<button id="export-button" class="settings-hub-card" type="button" data-settings-action="export"><span class="settings-card-mark"
|
|
216
|
+
<button id="export-button" class="settings-hub-card" type="button" data-settings-action="export" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false"><span class="settings-card-mark">出</span><strong>导出正文</strong><small>选择导出 Markdown ZIP 或 DOCX;不包含角色和设定资料</small></button>
|
|
204
217
|
</div>
|
|
205
218
|
<p id="settings-work-note" class="settings-work-note"></p>
|
|
206
219
|
<footer class="product-footer settings-product-footer" data-product-footer aria-label="叙界项目信息">
|
|
207
|
-
<span class="product-footer-meta"><span>© <time data-product-footer-year></time
|
|
220
|
+
<span class="product-footer-meta"><span>© <time data-product-footer-year></time></span><span aria-hidden="true">·</span><span>叙界 <span data-product-footer-version>v—</span></span><span aria-hidden="true">·</span><a href="https://github.com/musnows/Scriverse" target="_blank" rel="noopener noreferrer" aria-label="在 GitHub 查看叙界仓库">GitHub</a></span>
|
|
208
221
|
<span class="product-footer-development hidden" data-product-footer-development>开发模式</span>
|
|
209
222
|
</footer>
|
|
210
223
|
</section>
|
|
@@ -265,7 +278,7 @@
|
|
|
265
278
|
<div id="ai-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整创作助手宽度" aria-orientation="vertical" tabindex="0"></div>
|
|
266
279
|
<div class="ai-heading">
|
|
267
280
|
<button id="ai-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起创作助手" aria-expanded="true">›</button>
|
|
268
|
-
<div class="ai-heading-title"><span class="status-dot"></span><strong>创作助手</strong></div>
|
|
281
|
+
<div class="ai-heading-title"><span id="ai-status-dot" class="status-dot" role="status" aria-label="创作助手状态:正常" title="创作助手状态:正常"></span><strong>创作助手</strong></div>
|
|
269
282
|
<div class="ai-heading-actions">
|
|
270
283
|
<span id="ai-conversation-title" title="当前对话">新对话</span>
|
|
271
284
|
<button id="ai-history-toggle" type="button" aria-label="历史记录" aria-controls="ai-history-dialog" aria-expanded="false" aria-haspopup="dialog" title="历史记录"><svg class="ai-heading-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path><path d="M3 3v5h5M12 7v5l3.5 2"></path></svg></button>
|
|
@@ -346,6 +359,17 @@
|
|
|
346
359
|
<small>关闭时可横向滚动</small>
|
|
347
360
|
</button>
|
|
348
361
|
</div>
|
|
362
|
+
<div id="manuscript-export-menu" class="manuscript-export-menu hidden" role="menu" aria-label="导出正文格式">
|
|
363
|
+
<strong>选择导出格式</strong>
|
|
364
|
+
<button type="button" role="menuitem" data-export-format="markdown">
|
|
365
|
+
<span>导出 Markdown</span>
|
|
366
|
+
<small>打包为 ZIP,内含 Markdown 正文</small>
|
|
367
|
+
</button>
|
|
368
|
+
<button type="button" role="menuitem" data-export-format="docx">
|
|
369
|
+
<span>导出 DOCX</span>
|
|
370
|
+
<small>Word 正文;有封面时嵌入为首页</small>
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
349
373
|
<input id="new-import-file" class="hidden" type="file" accept=".txt,.docx">
|
|
350
374
|
<input id="cover-file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp">
|
|
351
375
|
<input id="avatar-file" class="hidden" type="file" accept="image/png,image/jpeg,image/webp">
|
|
@@ -384,6 +408,18 @@
|
|
|
384
408
|
</section>
|
|
385
409
|
</dialog>
|
|
386
410
|
|
|
411
|
+
<dialog id="system-restart-dialog" class="dialog system-restart-dialog" aria-labelledby="system-restart-dialog-title" aria-describedby="system-restart-dialog-description">
|
|
412
|
+
<div class="dialog-header">
|
|
413
|
+
<div><span class="eyebrow">系统状态</span><h2 id="system-restart-dialog-title" tabindex="-1">系统已重启或升级</h2></div>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="dialog-fields">
|
|
416
|
+
<p id="system-restart-dialog-description" class="system-restart-message">本次登录已经失效,请重新登录。</p>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="dialog-actions">
|
|
419
|
+
<button id="system-restart-confirm" class="primary-button" type="button">我知道了</button>
|
|
420
|
+
</div>
|
|
421
|
+
</dialog>
|
|
422
|
+
|
|
387
423
|
<dialog id="form-dialog" class="dialog">
|
|
388
424
|
<form id="dynamic-form" method="dialog">
|
|
389
425
|
<div class="dialog-header">
|
|
@@ -710,12 +746,6 @@
|
|
|
710
746
|
</form>
|
|
711
747
|
</dialog>
|
|
712
748
|
|
|
713
|
-
<dialog id="work-audit-dialog" class="dialog wide-dialog work-audit-dialog" aria-labelledby="work-audit-title">
|
|
714
|
-
<div class="dialog-header"><div><span class="eyebrow">作品安全</span><h2 id="work-audit-title">操作记录</h2><p class="dialog-header-meta">按时间倒序展示当前作品的写入与管理操作。</p></div><div class="settings-dialog-header-actions"><button id="work-audit-settings-return" class="ghost-button settings-parent-button" type="button">返回设置</button><button id="work-audit-close" class="dialog-close" aria-label="关闭操作记录" type="button">×</button></div></div>
|
|
715
|
-
<div id="work-audit-list" class="work-audit-list"></div>
|
|
716
|
-
<div class="dialog-actions"><button id="work-audit-refresh" class="ghost-button" type="button">刷新</button><button id="work-audit-load-more" class="primary-button hidden" type="button">加载更多</button></div>
|
|
717
|
-
</dialog>
|
|
718
|
-
|
|
719
749
|
<dialog id="platform-ui-settings-dialog" class="dialog" aria-labelledby="platform-ui-settings-title">
|
|
720
750
|
<div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="platform-ui-settings-title">界面与分页</h2></div><div class="settings-dialog-header-actions"><button id="platform-ui-settings-return" class="ghost-button settings-parent-button" type="button">返回设置</button><button id="platform-ui-settings-close" class="dialog-close" aria-label="关闭" type="button">×</button></div></div>
|
|
721
751
|
<form id="platform-ui-settings-form">
|
|
@@ -843,7 +873,8 @@
|
|
|
843
873
|
<dl class="ai-tool-call-info">
|
|
844
874
|
<div><dt>函数名称</dt><dd><code id="ai-tool-call-name"></code></dd></div>
|
|
845
875
|
<div><dt>调用时间</dt><dd><time id="ai-tool-call-time"></time></dd></div>
|
|
846
|
-
<div
|
|
876
|
+
<div><dt>函数信息</dt><dd id="ai-tool-call-description"></dd></div>
|
|
877
|
+
<div><dt>返回字符数</dt><dd id="ai-tool-call-result-length"></dd></div>
|
|
847
878
|
</dl>
|
|
848
879
|
<section><h3>参数</h3><pre id="ai-tool-call-arguments"></pre></section>
|
|
849
880
|
<section><h3>返回值</h3><pre id="ai-tool-call-result"></pre></section>
|
|
@@ -896,6 +927,6 @@
|
|
|
896
927
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
897
928
|
<script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
|
|
898
929
|
<script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
|
|
899
|
-
<script type="module" src="/app.js?v=
|
|
930
|
+
<script type="module" src="/app.js?v=20260801-merge-pr-291-v1"></script>
|
|
900
931
|
</body>
|
|
901
932
|
</html>
|
|
@@ -53,7 +53,7 @@ export function serializePageRoute(route = {}) {
|
|
|
53
53
|
} else if (view === "welcome" && workId) {
|
|
54
54
|
params.set("view", "welcome");
|
|
55
55
|
params.set("work", workId);
|
|
56
|
-
} else if (view === "settings" || view === "platform-ai" || view === "platform-usage") {
|
|
56
|
+
} else if (view === "settings" || view === "platform-ai" || view === "platform-usage" || (view === "work-audit" && workId)) {
|
|
57
57
|
params.set("view", view);
|
|
58
58
|
if (workId) params.set("work", workId);
|
|
59
59
|
appendReturnContext(params, route);
|
|
@@ -87,7 +87,7 @@ export function parsePageRoute(hash = "") {
|
|
|
87
87
|
return { view, workId, entity, entityId: entityId || null, entityMode };
|
|
88
88
|
}
|
|
89
89
|
if (view === "welcome" && workId) return { view, workId };
|
|
90
|
-
if (view === "settings" || view === "platform-ai" || view === "platform-usage") {
|
|
90
|
+
if (view === "settings" || view === "platform-ai" || view === "platform-usage" || (view === "work-audit" && workId)) {
|
|
91
91
|
const route = { view, workId: workId || null };
|
|
92
92
|
const returnView = value(params, "from");
|
|
93
93
|
if (returnViewSet.has(returnView)) route.returnView = returnView;
|
package/dist/public/styles.css
CHANGED
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
[data-pending-view="settings"] .auth-pending #settings-hub-view.hidden,
|
|
82
82
|
[data-pending-view="platform-ai"] .auth-pending #platform-ai-view.hidden,
|
|
83
83
|
[data-pending-view="platform-usage"] .auth-pending #platform-usage-view.hidden,
|
|
84
|
+
[data-pending-view="work-audit"] .auth-pending #work-audit-view.hidden,
|
|
84
85
|
[data-pending-view="module"] .auth-pending #module-view.hidden { display: block !important; }
|
|
85
86
|
[data-pending-view="welcome"] .auth-pending #welcome-view.hidden,
|
|
86
87
|
[data-pending-view="editor"] .auth-pending #editor-view.hidden { display: grid !important; }
|
|
@@ -581,14 +582,41 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
|
|
|
581
582
|
.writing-goal-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 10px; font-weight: 650; }
|
|
582
583
|
.writing-goal-fields input { width: 100%; min-height: 38px; padding: 8px 10px; background: var(--surface); font-size: 12px; font-weight: 500; }
|
|
583
584
|
@media (max-width: 680px) { .writing-progress-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .writing-goal-fields { grid-template-columns: 1fr; } }
|
|
584
|
-
.work-audit-
|
|
585
|
-
.work-audit-
|
|
586
|
-
.work-audit-
|
|
587
|
-
.work-audit-
|
|
588
|
-
.work-audit-
|
|
589
|
-
.work-audit-
|
|
590
|
-
.work-audit-
|
|
591
|
-
|
|
585
|
+
.work-audit-view { padding-bottom: 48px; }
|
|
586
|
+
.work-audit-header { margin-bottom: 22px; }
|
|
587
|
+
.work-audit-header h1 { font-size: clamp(30px, 4vw, 48px); }
|
|
588
|
+
.work-audit-toolbar, .work-audit-list, .work-audit-footer { width: 100%; max-width: 1400px; margin-inline: auto; }
|
|
589
|
+
.work-audit-toolbar { display: flex; align-items: center; min-height: 42px; padding: 0 18px; border: 1px solid var(--line); border-bottom: 0; border-radius: 7px 7px 0 0; background: var(--surface-soft); }
|
|
590
|
+
.work-audit-toolbar p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
|
591
|
+
.work-audit-list { display: grid; border: 1px solid var(--line); border-radius: 0 0 7px 7px; background: var(--panel); }
|
|
592
|
+
.work-audit-row { display: grid; grid-template-columns: minmax(160px, 190px) minmax(0, 1fr); gap: 24px; padding: 22px 20px; border-bottom: 1px solid var(--line); }
|
|
593
|
+
.work-audit-row:last-child { border-bottom: 0; }
|
|
594
|
+
.work-audit-time { min-width: 0; }
|
|
595
|
+
.work-audit-time time { display: grid; gap: 4px; color: var(--muted); font-family: var(--font-mono), monospace; font-variant-numeric: tabular-nums; }
|
|
596
|
+
.work-audit-time span { font-size: 13px; line-height: 1.35; }
|
|
597
|
+
.work-audit-time strong { color: var(--ink); font-size: 18px; font-weight: 600; line-height: 1.25; }
|
|
598
|
+
.work-audit-event { display: grid; min-width: 0; gap: 14px; }
|
|
599
|
+
.work-audit-event-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px 12px; }
|
|
600
|
+
.work-audit-event-heading strong { color: var(--ink); font-size: 17px; font-weight: 650; line-height: 1.35; }
|
|
601
|
+
.work-audit-event-heading code { color: var(--muted); font: 11px/1.5 var(--font-mono), monospace; overflow-wrap: anywhere; }
|
|
602
|
+
.work-audit-meta, .work-audit-details { margin: 0; }
|
|
603
|
+
.work-audit-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
|
604
|
+
.work-audit-meta > div, .work-audit-details > div { min-width: 0; }
|
|
605
|
+
.work-audit-meta > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
|
|
606
|
+
.work-audit-meta dt, .work-audit-details dt { color: var(--muted); font-size: 10px; font-weight: 650; line-height: 1.4; }
|
|
607
|
+
.work-audit-meta dd, .work-audit-details dd { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 12px; line-height: 1.55; }
|
|
608
|
+
.work-audit-meta code, .work-audit-details code { font-family: var(--font-mono), monospace; font-size: 11px; }
|
|
609
|
+
.work-audit-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px; padding: 14px 16px; border-left: 3px solid color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 4%, var(--surface-soft)); }
|
|
610
|
+
.work-audit-details > div { display: grid; grid-template-columns: minmax(88px, .35fr) minmax(0, 1fr); gap: 10px; }
|
|
611
|
+
.work-audit-footer { display: flex; justify-content: center; padding-top: 18px; }
|
|
612
|
+
.work-audit-footer button { min-width: 160px; }
|
|
613
|
+
.work-audit-list > .entity-history-empty { font-size: 13px; }
|
|
614
|
+
@media (max-width: 720px) {
|
|
615
|
+
.work-audit-row { grid-template-columns: 1fr; gap: 14px; padding: 18px 14px; }
|
|
616
|
+
.work-audit-time time { display: flex; align-items: baseline; gap: 8px; }
|
|
617
|
+
.work-audit-time strong { font-size: 16px; }
|
|
618
|
+
.work-audit-meta, .work-audit-details { grid-template-columns: 1fr; }
|
|
619
|
+
}
|
|
592
620
|
.settings-work-note { max-width: 1000px; margin: 18px auto 0; color: var(--muted); font-size: 11px; }
|
|
593
621
|
.usage-page-content { width: 100%; max-width: 1400px; margin: 0 auto; padding-top: 0; }
|
|
594
622
|
.usage-overview { min-width: 0; container-name: usage-overview; container-type: inline-size; }
|
|
@@ -613,6 +641,7 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
|
|
|
613
641
|
.usage-calendar-section > header, .usage-work-section > header { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
|
614
642
|
.usage-calendar-section h3, .usage-work-section h3 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
|
|
615
643
|
.usage-calendar-section header p, .usage-work-section header p { margin: 0; color: var(--muted); font-size: 10px; }
|
|
644
|
+
.usage-calendar-widget { position: relative; }
|
|
616
645
|
.usage-calendar-scroll { max-width: 100%; padding: 10px 12px 12px; overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
|
|
617
646
|
.usage-calendar-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
618
647
|
.usage-calendar-frame { width: max-content; min-width: 100%; }
|
|
@@ -640,13 +669,16 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
|
|
|
640
669
|
gap: 3px;
|
|
641
670
|
min-width: calc(var(--usage-week-count) * 14px);
|
|
642
671
|
}
|
|
643
|
-
.usage-calendar-cell { display: block; width: 11px; height: 11px; border: 1px solid color-mix(in srgb, var(--line) 82%, transparent); border-radius: 2px; background: var(--paper-deep); }
|
|
672
|
+
.usage-calendar-cell { display: block; width: 11px; height: 11px; padding: 0; border: 1px solid color-mix(in srgb, var(--line) 82%, transparent); border-radius: 2px; background: var(--paper-deep); cursor: pointer; }
|
|
644
673
|
.usage-calendar-cell[data-level="1"], .usage-calendar-legend i[data-level="1"] { border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); background: color-mix(in srgb, var(--accent) 18%, var(--surface)); }
|
|
645
674
|
.usage-calendar-cell[data-level="2"], .usage-calendar-legend i[data-level="2"] { border-color: color-mix(in srgb, var(--accent) 46%, var(--line)); background: color-mix(in srgb, var(--accent) 42%, var(--surface)); }
|
|
646
675
|
.usage-calendar-cell[data-level="3"], .usage-calendar-legend i[data-level="3"] { border-color: color-mix(in srgb, var(--accent) 72%, var(--line)); background: color-mix(in srgb, var(--accent) 70%, var(--surface)); }
|
|
647
676
|
.usage-calendar-cell[data-level="4"], .usage-calendar-legend i[data-level="4"] { border-color: var(--accent-dark); background: var(--accent-dark); }
|
|
648
|
-
.usage-calendar-cell.is-future { border-color: transparent; background: transparent; }
|
|
649
|
-
.usage-calendar-cell
|
|
677
|
+
.usage-calendar-cell.is-future { border-color: transparent; background: transparent; cursor: default; }
|
|
678
|
+
.usage-calendar-cell:hover, .usage-calendar-cell:focus-visible { position: relative; z-index: 1; border-color: var(--ink); outline: 2px solid color-mix(in srgb, var(--ink) 48%, transparent); outline-offset: 1px; }
|
|
679
|
+
.usage-calendar-tooltip { position: absolute; z-index: 4; width: max-content; max-width: min(280px, calc(100% - 16px)); padding: 7px 9px; border: 1px solid color-mix(in srgb, var(--ink) 24%, transparent); border-radius: 5px; background: color-mix(in srgb, var(--ink) 94%, transparent); box-shadow: 0 8px 22px rgba(0,0,0,.22); color: var(--panel); font: 10px/1.45 var(--font-latin), var(--font-cjk), monospace; overflow-wrap: anywhere; text-align: center; pointer-events: none; transform: translate(-50%, -100%); }
|
|
680
|
+
.usage-calendar-tooltip[data-placement="bottom"] { transform: translateX(-50%); }
|
|
681
|
+
.usage-calendar-tooltip[hidden] { display: none; }
|
|
650
682
|
.usage-calendar-legend { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 8px; color: var(--muted); font-size: 9px; }
|
|
651
683
|
.usage-calendar-legend i { width: 11px; height: 11px; border: 1px solid color-mix(in srgb, var(--line) 82%, transparent); border-radius: 2px; background: var(--paper-deep); }
|
|
652
684
|
.usage-work-table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; }
|
|
@@ -1678,6 +1710,15 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1678
1710
|
.config-section-header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 14px; }
|
|
1679
1711
|
.config-section-header h2 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
|
1680
1712
|
.config-section-header p { margin: 0; color: var(--muted); font-size: 11px; }
|
|
1713
|
+
.config-section-header .config-doc-link {
|
|
1714
|
+
display: inline;
|
|
1715
|
+
margin-left: 4px;
|
|
1716
|
+
color: var(--accent-dark);
|
|
1717
|
+
text-decoration: underline;
|
|
1718
|
+
text-underline-offset: 2px;
|
|
1719
|
+
}
|
|
1720
|
+
.config-section-header .config-doc-link:hover,
|
|
1721
|
+
.config-section-header .config-doc-link:focus-visible { color: var(--accent); }
|
|
1681
1722
|
.ai-agent-tools { display: grid; gap: 8px; }
|
|
1682
1723
|
.ai-agent-tools label { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
|
1683
1724
|
.ai-agent-tools input { width: 16px; margin-top: 2px; }
|
|
@@ -1999,6 +2040,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1999
2040
|
.ai-heading-actions #ai-new-conversation { padding: 0; font: 17px/1 var(--font-latin), monospace; }
|
|
2000
2041
|
.ai-heading-actions button:hover, .ai-heading-actions button[aria-expanded="true"] { background: var(--paper-deep); color: var(--ink); }
|
|
2001
2042
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(94,118,92,.14); }
|
|
2043
|
+
.status-dot.is-error { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
|
|
2002
2044
|
.ai-history-dialog-body { min-height: min(360px, 55vh); max-height: 65vh; overflow-y: auto; padding: 18px 24px 24px; }
|
|
2003
2045
|
.ai-history-list { display: grid; gap: 8px; }
|
|
2004
2046
|
.ai-history-item { display: grid; gap: 5px; width: 100%; min-width: 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); text-align: left; }
|
|
@@ -2011,10 +2053,19 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2011
2053
|
.field-label select { padding: 8px; font-size: 12px; }
|
|
2012
2054
|
.book-summary-context-percent-field { width: min(100%, 280px); }
|
|
2013
2055
|
.context-compact-threshold-field { width: min(100%, 280px); }
|
|
2014
|
-
.
|
|
2056
|
+
.agent-tool-call-limit-field { width: min(100%, 280px); }
|
|
2057
|
+
.daily-token-quota-field { width: min(100%, 144px); }
|
|
2058
|
+
.agent-tool-call-global-multiplier-field { width: auto; min-width: 0; }
|
|
2059
|
+
.book-summary-context-percent-field input, .context-compact-threshold-field input, .agent-tool-call-limit-field input, .daily-token-quota-field input { width: 64px; min-height: 32px; padding: 5px 8px; font-size: 13px; font-family: var(--font-latin), monospace; }
|
|
2060
|
+
.daily-token-quota-field input { width: 144px; }
|
|
2015
2061
|
.config-inline-save { display: flex; align-items: flex-end; gap: 10px; margin: 14px 0 4px; flex-wrap: wrap; }
|
|
2016
2062
|
.config-inline-save .book-summary-context-percent-field,
|
|
2017
|
-
.config-inline-save .context-compact-threshold-field
|
|
2063
|
+
.config-inline-save .context-compact-threshold-field,
|
|
2064
|
+
.config-inline-save .agent-tool-call-limit-field,
|
|
2065
|
+
.config-inline-save .daily-token-quota-field { display: grid; gap: 6px; width: 64px; margin: 0; color: var(--muted); font-size: 10px; }
|
|
2066
|
+
.config-inline-save .daily-token-quota-field { width: 144px; }
|
|
2067
|
+
.config-inline-save .agent-tool-call-global-multiplier-field { display: grid; gap: 6px; width: auto; margin: 0; color: var(--muted); font-size: 10px; }
|
|
2068
|
+
.agent-tool-call-global-multiplier-toggle button { min-width: 32px; padding: 0 10px; font-family: var(--font-latin), monospace; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
2018
2069
|
.relationship-index-summary { display: grid; gap: 14px; padding: 15px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
|
|
2019
2070
|
.relationship-index-state-row { display: flex; align-items: center; gap: 12px 18px; flex-wrap: wrap; color: var(--muted); font-size: 10px; }
|
|
2020
2071
|
.relationship-index-state-row strong { color: var(--ink); font-family: var(--font-latin), monospace; font-weight: 600; }
|
|
@@ -2052,7 +2103,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2052
2103
|
.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; }
|
|
2053
2104
|
.quick-actions button:hover { color: var(--ink); background: var(--paper-deep); }
|
|
2054
2105
|
.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;
|
|
2106
|
+
.ai-context-compaction-divider { display: grid; grid-template-columns: minmax(16px, 1fr) auto minmax(16px, 1fr); gap: 9px; align-items: center; min-width: 0; color: var(--muted); font-size: 9px; line-height: 1; text-align: center; }
|
|
2107
|
+
.ai-feed > .ai-context-compaction-divider { margin: 4px 0 16px; }
|
|
2108
|
+
.ai-process-list > .ai-context-compaction-divider { padding: 2px 0; }
|
|
2056
2109
|
.ai-context-compaction-divider::before, .ai-context-compaction-divider::after { height: 1px; background: color-mix(in srgb, var(--muted) 36%, transparent); content: ""; }
|
|
2057
2110
|
.ai-context-compaction-divider span { letter-spacing: .08em; white-space: nowrap; }
|
|
2058
2111
|
.assistant-message, .user-message { position: relative; margin-bottom: 12px; padding: 12px 13px; border-radius: 5px; font-size: 12px; line-height: 1.55; }
|
|
@@ -2116,9 +2169,6 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2116
2169
|
.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; }
|
|
2117
2170
|
.ai-process-intermediate-step .ai-process-step-body { border-left-color: var(--muted); }
|
|
2118
2171
|
.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; }
|
|
2122
2172
|
@keyframes ai-process-pulse { 0%, 100% { opacity: .45; transform: scale(.8); } 50% { opacity: 1; transform: scale(1); } }
|
|
2123
2173
|
@media (prefers-reduced-motion: reduce) { .is-streaming .ai-process-details[open] > summary::before { animation: none; } }
|
|
2124
2174
|
.ai-tool-call-list { display: grid; gap: 5px; margin-top: 10px; }
|
|
@@ -2133,12 +2183,11 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2133
2183
|
.ai-tool-call-info dt { margin-bottom: 4px; color: var(--muted); font-size: 9px; }
|
|
2134
2184
|
.ai-tool-call-info dd { min-width: 0; margin: 0; color: var(--ink); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
2135
2185
|
.ai-tool-call-info code, .ai-tool-call-info time { font-family: var(--font-latin), monospace; }
|
|
2136
|
-
.ai-tool-call-description { grid-column: 1 / -1; }
|
|
2137
2186
|
.ai-tool-call-detail section { min-width: 0; }
|
|
2138
2187
|
.ai-tool-call-detail h3 { margin: 0 0 7px; color: var(--muted); font-size: 10px; font-weight: 600; }
|
|
2139
2188
|
.ai-tool-call-detail pre { min-height: 120px; max-height: 32vh; overflow: auto; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--paper-deep); color: var(--ink); font: 10px/1.55 var(--font-latin), monospace; white-space: pre-wrap; word-break: break-word; }
|
|
2140
2189
|
.ai-tool-call-detail section:last-child pre { min-height: 180px; }
|
|
2141
|
-
@media (max-width: 680px) { .ai-tool-call-info { grid-template-columns: minmax(0, 1fr); }.ai-tool-call-
|
|
2190
|
+
@media (max-width: 680px) { .ai-tool-call-info { grid-template-columns: minmax(0, 1fr); }.ai-tool-call-detail pre { min-height: 100px; max-height: 26vh; }.ai-tool-call-detail section:last-child pre { min-height: 140px; } }
|
|
2142
2191
|
.guard-card { margin-top: 10px; padding: 9px 10px; border: 1px solid var(--line); border-left: 3px solid var(--green); border-radius: 4px; background: var(--surface-soft); }
|
|
2143
2192
|
.guard-card.warning { border-left-color: #c07b31; }.guard-card.failed { border-left-color: var(--accent); }
|
|
2144
2193
|
.guard-card strong { display: block; margin-bottom: 5px; font-size: 10px; }.guard-card p { margin: 4px 0; color: var(--muted); font-size: 9px; line-height: 1.45; }.guard-card p b { color: var(--ink); }
|
|
@@ -2310,6 +2359,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2310
2359
|
.markdown-table-menu button:hover, .markdown-table-menu button:focus-visible { background: var(--paper-deep); color: var(--accent-dark); }
|
|
2311
2360
|
.markdown-table-menu button span { font-size: 11px; font-weight: 600; }
|
|
2312
2361
|
.markdown-table-menu button small { color: var(--muted); font-size: 9px; }
|
|
2362
|
+
.manuscript-export-menu { position: fixed; z-index: 90; display: grid; min-width: 240px; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 14px 38px rgba(49,42,32,.22); }
|
|
2363
|
+
.manuscript-export-menu.hidden { display: none; }
|
|
2364
|
+
.manuscript-export-menu > strong { padding: 5px 8px 7px; color: var(--muted); font-size: 9px; }
|
|
2365
|
+
.manuscript-export-menu button { display: grid; width: 100%; gap: 2px; padding: 8px 9px; border: 0; border-radius: 4px; background: transparent; color: var(--ink); text-align: left; }
|
|
2366
|
+
.manuscript-export-menu button:hover, .manuscript-export-menu button:focus-visible { background: var(--paper-deep); color: var(--accent-dark); outline: 0; }
|
|
2367
|
+
.manuscript-export-menu button span { font-size: 11px; font-weight: 600; }
|
|
2368
|
+
.manuscript-export-menu button small { color: var(--muted); font-size: 9px; line-height: 1.45; }
|
|
2313
2369
|
.chapter-annotations-dialog { width: min(820px, 94vw); }
|
|
2314
2370
|
.chapter-annotations-list { display: grid; gap: 10px; max-height: 62vh; overflow: auto; padding: 18px 24px; }
|
|
2315
2371
|
.chapter-comment-module-list { display: grid; gap: 10px; }
|
|
@@ -2366,6 +2422,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2366
2422
|
.dialog-fields label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
|
|
2367
2423
|
.dialog-fields input, .dialog-fields textarea, .dialog-fields select { width: 100%; padding: 9px 10px; font-size: 13px; }
|
|
2368
2424
|
.dialog-fields textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
|
|
2425
|
+
.system-restart-dialog { width: min(500px, 92vw); }
|
|
2426
|
+
.system-restart-message { margin: 0; color: var(--ink); font-size: 13px; line-height: 1.7; }
|
|
2369
2427
|
.background-task-dialog { width: min(860px, 94vw); }
|
|
2370
2428
|
.background-task-content { display: grid; gap: 16px; max-height: 64vh; overflow-y: auto; padding: 20px 24px; }
|
|
2371
2429
|
.background-task-section { display: grid; gap: 12px; min-width: 0; padding: 15px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SystemHealthStatus = "checking" | "ready" | "degraded" | "offline";
|
|
2
|
+
|
|
3
|
+
export type SystemStatusPresentation = {
|
|
4
|
+
label: string;
|
|
5
|
+
tone: "ok" | "pending" | "error";
|
|
6
|
+
title: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function systemStatusPresentation(snapshot?: {
|
|
10
|
+
status?: SystemHealthStatus | string;
|
|
11
|
+
version?: string;
|
|
12
|
+
}): SystemStatusPresentation;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const supportedSystemStatuses = new Set(["checking", "ready", "degraded", "offline"]);
|
|
2
|
+
|
|
3
|
+
export function systemStatusPresentation(snapshot = {}) {
|
|
4
|
+
const status = supportedSystemStatuses.has(snapshot.status) ? snapshot.status : "checking";
|
|
5
|
+
const version = String(snapshot.version ?? "").trim();
|
|
6
|
+
if (status === "ready") {
|
|
7
|
+
return {
|
|
8
|
+
label: "就绪",
|
|
9
|
+
tone: "ok",
|
|
10
|
+
title: version ? `服务连接正常 · v${version}` : "服务连接正常"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
if (status === "degraded") return { label: "服务异常", tone: "error", title: "服务可访问,但请求出现异常" };
|
|
14
|
+
if (status === "offline") return { label: "连接中断", tone: "error", title: "无法连接到本地服务,系统将自动重试" };
|
|
15
|
+
return { label: "检测中", tone: "pending", title: "正在检查本地服务状态" };
|
|
16
|
+
}
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
// 会话恢复期间按目标路由预显示对应视图的骨架屏
|
|
15
15
|
const pendingView = ["editor", "module", "welcome"].includes(routeView) && routeParams.get("work")
|
|
16
16
|
? routeView
|
|
17
|
-
: ["settings", "platform-ai", "platform-usage"].includes(routeView) ? routeView : "shelf";
|
|
17
|
+
: ["settings", "platform-ai", "platform-usage", "work-audit"].includes(routeView) ? routeView : "shelf";
|
|
18
18
|
document.documentElement.dataset.pendingView = pendingView;
|
|
19
|
-
if (["shelf", "settings", "platform-ai", "platform-usage"].includes(pendingView)) {
|
|
19
|
+
if (["shelf", "settings", "platform-ai", "platform-usage", "work-audit"].includes(pendingView)) {
|
|
20
20
|
document.documentElement.classList.add("pending-shelf-mode");
|
|
21
21
|
}
|
|
22
22
|
}
|
package/dist/security.js
CHANGED
|
@@ -6,13 +6,54 @@ import { Agent } from "undici";
|
|
|
6
6
|
import { AppError } from "./errors.js";
|
|
7
7
|
import { logger } from "./logger.js";
|
|
8
8
|
const maximumRateEntries = 10_000;
|
|
9
|
+
/**
|
|
10
|
+
* Express 默认路由大小写不敏感,但 request.path 保留原始大小写。
|
|
11
|
+
* 安全中间件统一用小写路径做匹配,避免 /API/... 一类变体绕过鉴权与限速。
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeApiPath(pathname) {
|
|
14
|
+
return pathname.toLocaleLowerCase("en-US");
|
|
15
|
+
}
|
|
16
|
+
/** 强制保持大小写不敏感路由,并拒绝后续改成大小写敏感。 */
|
|
17
|
+
export function enforceCaseInsensitiveRouting(app) {
|
|
18
|
+
app.set("case sensitive routing", false);
|
|
19
|
+
const originalSet = app.set.bind(app);
|
|
20
|
+
app.set = function lockedCaseInsensitiveRouting(setting, value) {
|
|
21
|
+
// Express 用单参数 app.set(name) 读取配置;不能把它改写成写入。
|
|
22
|
+
if (arguments.length < 2)
|
|
23
|
+
return originalSet(setting);
|
|
24
|
+
if (String(setting).toLocaleLowerCase("en-US") === "case sensitive routing") {
|
|
25
|
+
if (value) {
|
|
26
|
+
throw new Error("Case-sensitive routing is disabled; API security matches paths case-insensitively");
|
|
27
|
+
}
|
|
28
|
+
return originalSet(setting, false);
|
|
29
|
+
}
|
|
30
|
+
return originalSet(setting, value);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
9
33
|
const digest = (value) => createHash("sha256").update(value).digest();
|
|
10
34
|
const constantTimeEqual = (left, right) => timingSafeEqual(digest(left), digest(right));
|
|
11
35
|
export function verifySetupToken(expected, provided) {
|
|
12
36
|
return Boolean(expected && provided) && constantTimeEqual(expected ?? "", provided ?? "");
|
|
13
37
|
}
|
|
14
38
|
function requestKey(request) {
|
|
15
|
-
|
|
39
|
+
const peer = request.socket.remoteAddress || "unknown";
|
|
40
|
+
const trustProxy = request.app?.get?.("trust proxy");
|
|
41
|
+
const trustsForwarded = trustProxy === true
|
|
42
|
+
|| (typeof trustProxy === "number" && trustProxy > 0)
|
|
43
|
+
|| (Array.isArray(trustProxy) && trustProxy.length > 0)
|
|
44
|
+
|| (typeof trustProxy === "string" && trustProxy !== "false" && trustProxy.length > 0);
|
|
45
|
+
// 未启用 trust proxy 时忽略 X-Forwarded-For,始终按直连对端计限速。
|
|
46
|
+
if (!trustsForwarded)
|
|
47
|
+
return peer;
|
|
48
|
+
return request.ip || peer;
|
|
49
|
+
}
|
|
50
|
+
/** 禁止 trust proxy=true(信任整条转发链);至少收敛为单跳。 */
|
|
51
|
+
export function resolveTrustProxySetting(trustProxy) {
|
|
52
|
+
if (trustProxy === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (trustProxy === true)
|
|
55
|
+
return 1;
|
|
56
|
+
return trustProxy;
|
|
16
57
|
}
|
|
17
58
|
function consumeRate(entries, key, limit, windowMs, entryLimit = maximumRateEntries) {
|
|
18
59
|
const currentTime = Date.now();
|
|
@@ -58,7 +99,7 @@ export function createBasicAuthMiddleware(options) {
|
|
|
58
99
|
const failureWindowMs = options.failureWindowMs ?? 15 * 60_000;
|
|
59
100
|
const failures = new Map();
|
|
60
101
|
return (request, response, next) => {
|
|
61
|
-
if (request.path === "/api/health")
|
|
102
|
+
if (normalizeApiPath(request.path) === "/api/health")
|
|
62
103
|
return next();
|
|
63
104
|
const key = requestKey(request);
|
|
64
105
|
const credentials = parseBasicCredentials(request.get("authorization"));
|
|
@@ -91,7 +132,7 @@ export function createSecurityHeadersMiddleware() {
|
|
|
91
132
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
92
133
|
response.setHeader("X-Frame-Options", "DENY");
|
|
93
134
|
response.setHeader("X-Permitted-Cross-Domain-Policies", "none");
|
|
94
|
-
response.setHeader("Cache-Control", request.path.startsWith("/api/") ? "no-store" : "private, no-cache");
|
|
135
|
+
response.setHeader("Cache-Control", normalizeApiPath(request.path).startsWith("/api/") ? "no-store" : "private, no-cache");
|
|
95
136
|
response.vary("Authorization");
|
|
96
137
|
if (request.secure)
|
|
97
138
|
response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
@@ -131,7 +172,8 @@ export function createSameOriginMiddleware() {
|
|
|
131
172
|
export function createApiRateLimitMiddleware(limit = 600, windowMs = 60_000, entryLimit = maximumRateEntries) {
|
|
132
173
|
const entries = new Map();
|
|
133
174
|
return (request, response, next) => {
|
|
134
|
-
|
|
175
|
+
const path = normalizeApiPath(request.path);
|
|
176
|
+
if (!path.startsWith("/api/") || path === "/api/health")
|
|
135
177
|
return next();
|
|
136
178
|
const rate = consumeRate(entries, requestKey(request), limit, windowMs, entryLimit);
|
|
137
179
|
if (rate.allowed)
|
|
@@ -144,10 +186,11 @@ export function createApiRateLimitMiddleware(limit = 600, windowMs = 60_000, ent
|
|
|
144
186
|
export function createAuthenticationRateLimitMiddleware(limit = 10, windowMs = 15 * 60_000) {
|
|
145
187
|
const entries = new Map();
|
|
146
188
|
return (request, response, next) => {
|
|
147
|
-
const
|
|
189
|
+
const path = normalizeApiPath(request.path);
|
|
190
|
+
const authenticationWrite = request.method === "POST" && ["/api/auth/login", "/api/auth/register"].includes(path);
|
|
148
191
|
if (!authenticationWrite)
|
|
149
192
|
return next();
|
|
150
|
-
const rate = consumeRate(entries, `${requestKey(request)}:${
|
|
193
|
+
const rate = consumeRate(entries, `${requestKey(request)}:${path}`, limit, windowMs);
|
|
151
194
|
if (rate.allowed)
|
|
152
195
|
return next();
|
|
153
196
|
logger.warn("security.request.blocked", { control: "authentication_rate_limit", retryAfterSeconds: rate.retryAfter });
|
|
@@ -158,9 +201,10 @@ export function createAuthenticationRateLimitMiddleware(limit = 10, windowMs = 1
|
|
|
158
201
|
export function createUploadRateLimitMiddleware(limit = 30, windowMs = 10 * 60_000, entryLimit = maximumRateEntries) {
|
|
159
202
|
const entries = new Map();
|
|
160
203
|
return (request, response, next) => {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
||
|
|
204
|
+
const path = normalizeApiPath(request.path);
|
|
205
|
+
const uploadWrite = (request.method === "POST" || request.method === "PUT") && (path === "/api/auth/avatar"
|
|
206
|
+
|| path === "/api/works/import"
|
|
207
|
+
|| /^\/api\/works\/[^/]+\/(?:import|cover|attachments)$/u.test(path));
|
|
164
208
|
if (!uploadWrite)
|
|
165
209
|
return next();
|
|
166
210
|
const actorKey = request.authUser?.userId ?? requestKey(request);
|
|
@@ -172,6 +216,54 @@ export function createUploadRateLimitMiddleware(limit = 30, windowMs = 10 * 60_0
|
|
|
172
216
|
response.status(429).json({ error: { code: "UPLOAD_RATE_LIMITED", message: "文件上传过于频繁,请稍后重试" } });
|
|
173
217
|
};
|
|
174
218
|
}
|
|
219
|
+
export function createCaptchaRateLimitMiddleware(limit = 20, windowMs = 60_000, entryLimit = maximumRateEntries) {
|
|
220
|
+
const entries = new Map();
|
|
221
|
+
return (request, response, next) => {
|
|
222
|
+
if (request.method !== "GET" || normalizeApiPath(request.path) !== "/api/auth/captcha")
|
|
223
|
+
return next();
|
|
224
|
+
const rate = consumeRate(entries, requestKey(request), limit, windowMs, entryLimit);
|
|
225
|
+
if (rate.allowed)
|
|
226
|
+
return next();
|
|
227
|
+
logger.warn("security.request.blocked", { control: "captcha_rate_limit", retryAfterSeconds: rate.retryAfter });
|
|
228
|
+
response.setHeader("Retry-After", String(rate.retryAfter));
|
|
229
|
+
response.status(429).json({ error: { code: "CAPTCHA_RATE_LIMITED", message: "验证码请求过于频繁,请稍后重试" } });
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function expensiveApiKind(method, path) {
|
|
233
|
+
if (method === "GET" && /^\/api\/works\/[^/]+\/export$/u.test(path))
|
|
234
|
+
return "export";
|
|
235
|
+
if (method === "GET" && /^\/api\/works\/[^/]+\/search$/u.test(path))
|
|
236
|
+
return "search";
|
|
237
|
+
if (method !== "POST")
|
|
238
|
+
return null;
|
|
239
|
+
if (/^\/api\/works\/[^/]+\/(?:suggestions|chat\/stream|tasks)(?:\/|$)/u.test(path)
|
|
240
|
+
|| /^\/api\/suggestions\/[^/]+\/guard$/u.test(path)
|
|
241
|
+
|| /^\/api\/ai-conversations\/[^/]+\/(?:compact|context\/prepare)$/u.test(path)
|
|
242
|
+
|| /^\/api\/tasks\/[^/]+\/(?:retry|cancel|relationship-changes\/apply)$/u.test(path)) {
|
|
243
|
+
return "ai";
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const expensiveApiLimits = {
|
|
248
|
+
ai: 30,
|
|
249
|
+
export: 10,
|
|
250
|
+
search: 60
|
|
251
|
+
};
|
|
252
|
+
export function createExpensiveApiRateLimitMiddleware(windowMs = 60_000, entryLimit = maximumRateEntries) {
|
|
253
|
+
const entries = new Map();
|
|
254
|
+
return (request, response, next) => {
|
|
255
|
+
const kind = expensiveApiKind(request.method, normalizeApiPath(request.path));
|
|
256
|
+
if (!kind)
|
|
257
|
+
return next();
|
|
258
|
+
const actorKey = request.authUser?.userId ?? requestKey(request);
|
|
259
|
+
const rate = consumeRate(entries, `${kind}:${actorKey}`, expensiveApiLimits[kind], windowMs, entryLimit);
|
|
260
|
+
if (rate.allowed)
|
|
261
|
+
return next();
|
|
262
|
+
logger.warn("security.request.blocked", { control: "expensive_api_rate_limit", kind, retryAfterSeconds: rate.retryAfter });
|
|
263
|
+
response.setHeader("Retry-After", String(rate.retryAfter));
|
|
264
|
+
response.status(429).json({ error: { code: "EXPENSIVE_API_RATE_LIMITED", message: "该操作请求过于频繁,请稍后重试" } });
|
|
265
|
+
};
|
|
266
|
+
}
|
|
175
267
|
function parseIpv4(address) {
|
|
176
268
|
if (isIP(address) !== 4)
|
|
177
269
|
return null;
|