@musnows/scriverse 0.6.9 → 0.6.11
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 +13 -1
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +4 -4
- package/dist/ai.js.map +1 -1
- package/dist/app.js +84 -4
- package/dist/app.js.map +1 -1
- package/dist/database.js +167 -7
- package/dist/database.js.map +1 -1
- package/dist/logger.js +2 -2
- package/dist/logger.js.map +1 -1
- package/dist/public/app.js +342 -5
- package/dist/public/global-search.js +12 -3
- package/dist/public/index.html +46 -2
- package/dist/public/s3-backup-ui.d.ts +17 -0
- package/dist/public/s3-backup-ui.js +28 -0
- package/dist/public/styles.css +84 -1
- package/dist/s3-backup.js +654 -0
- package/dist/s3-backup.js.map +1 -0
- package/dist/security.js +25 -0
- package/dist/security.js.map +1 -1
- package/dist/store.js +7 -3
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -1
package/dist/public/app.js
CHANGED
|
@@ -45,7 +45,7 @@ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20
|
|
|
45
45
|
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
|
|
46
46
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
47
47
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
48
|
-
import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-
|
|
48
|
+
import { prioritizeGlobalSearchResults, resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260804-agent-history-score-sort-v1";
|
|
49
49
|
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
|
|
50
50
|
import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
|
|
51
51
|
import {
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
|
|
58
58
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
59
59
|
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
60
|
+
import { collectS3BackupRunTransitions, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260804-s3-backup-v1";
|
|
60
61
|
import {
|
|
61
62
|
clampCropRect,
|
|
62
63
|
containImageRect,
|
|
@@ -195,6 +196,14 @@ let backgroundTaskCenterWorkId = null;
|
|
|
195
196
|
let backgroundTaskCenterTasksInitialized = false;
|
|
196
197
|
let backgroundTaskCenterTaskSnapshots = new Map();
|
|
197
198
|
let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
199
|
+
let s3BackupTargets = [];
|
|
200
|
+
let s3BackupRuns = [];
|
|
201
|
+
let editingS3BackupTargetId = null;
|
|
202
|
+
let s3BackupPollTimer = null;
|
|
203
|
+
let s3BackupPollRequest = 0;
|
|
204
|
+
let s3BackupRunsInitialized = false;
|
|
205
|
+
let s3BackupRunSnapshots = new Map();
|
|
206
|
+
const s3BackupPollInterval = 8_000;
|
|
198
207
|
let productUpdateStatus = null;
|
|
199
208
|
let productUpdateChecking = false;
|
|
200
209
|
let productUpdateTimer = null;
|
|
@@ -2582,6 +2591,28 @@ function formatAiFailureMessage(error) {
|
|
|
2582
2591
|
return lines.join("\n");
|
|
2583
2592
|
}
|
|
2584
2593
|
|
|
2594
|
+
function isAgentToolCallLimitFailure(text) {
|
|
2595
|
+
return /more than \d+ tool calls in one response cycle\./u.test(String(text ?? ""));
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function aiToolCallSettingsLinkMarkup(text) {
|
|
2599
|
+
if (!isAgentToolCallLimitFailure(text) || !state.work || !canReadModule("ai-settings")) return "";
|
|
2600
|
+
const href = serializePageRoute({ view: "module", workId: state.work.id, module: "ai-settings" });
|
|
2601
|
+
return `<p class="ai-error-settings-link-wrap"><a class="ai-error-settings-link" data-ai-tool-call-settings-link href="${esc(href)}">前往本书 AI 设置调整工具调用上限</a></p>`;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
async function openAiToolCallSettings() {
|
|
2605
|
+
if (!state.work || !canReadModule("ai-settings")) return;
|
|
2606
|
+
await showModule("ai-settings");
|
|
2607
|
+
const target = document.getElementById("agent-tool-call-limit-settings");
|
|
2608
|
+
if (!target) return;
|
|
2609
|
+
target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
|
2610
|
+
target.classList.add("is-targeted");
|
|
2611
|
+
window.setTimeout(() => target.classList.remove("is-targeted"), 1_800);
|
|
2612
|
+
const input = target.querySelector("#agent-tool-call-limit");
|
|
2613
|
+
if (input instanceof HTMLInputElement) input.focus({ preventScroll: true });
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2585
2616
|
async function apiPage(path, page = 1, limit = 30) {
|
|
2586
2617
|
const separator = path.includes("?") ? "&" : "?";
|
|
2587
2618
|
const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
|
|
@@ -2857,6 +2888,7 @@ function clearAuthenticationOverlays() {
|
|
|
2857
2888
|
}
|
|
2858
2889
|
|
|
2859
2890
|
function invalidateAuthentication() {
|
|
2891
|
+
stopS3BackupEventPolling();
|
|
2860
2892
|
state.user = null;
|
|
2861
2893
|
state.csrfToken = null;
|
|
2862
2894
|
moduleRequestCache.clear();
|
|
@@ -2975,6 +3007,7 @@ async function initializeAuthentication() {
|
|
|
2975
3007
|
applyAuthenticatedUser(session);
|
|
2976
3008
|
scheduleSystemBootCheck();
|
|
2977
3009
|
await loadPlatformUiSettings();
|
|
3010
|
+
startS3BackupEventPolling();
|
|
2978
3011
|
return true;
|
|
2979
3012
|
}
|
|
2980
3013
|
|
|
@@ -3473,6 +3506,7 @@ function renderSettingsHub() {
|
|
|
3473
3506
|
$("#platform-usage-button").classList.toggle("hidden", !isAdmin);
|
|
3474
3507
|
$("#user-management-button").classList.toggle("hidden", !isAdmin);
|
|
3475
3508
|
$("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
|
|
3509
|
+
$("#s3-backup-button").classList.toggle("hidden", !isAdmin);
|
|
3476
3510
|
$("#collaboration-button").disabled = !canManageWork;
|
|
3477
3511
|
$("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
|
|
3478
3512
|
$("#work-audit-button").disabled = !canManageWork;
|
|
@@ -3850,6 +3884,258 @@ async function openPlatformUiSettingsDialog() {
|
|
|
3850
3884
|
}
|
|
3851
3885
|
}
|
|
3852
3886
|
|
|
3887
|
+
function s3BackupTargetFailed(target) {
|
|
3888
|
+
const failedAt = Date.parse(target.lastFailureAt || "");
|
|
3889
|
+
const succeededAt = Date.parse(target.lastSuccessAt || "");
|
|
3890
|
+
return Number.isFinite(failedAt) && (!Number.isFinite(succeededAt) || failedAt > succeededAt);
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
function renderS3BackupTargets() {
|
|
3894
|
+
const enabledCount = s3BackupTargets.filter((target) => target.enabled).length;
|
|
3895
|
+
$("#s3-backup-summary").textContent = s3BackupTargets.length
|
|
3896
|
+
? `共 ${s3BackupTargets.length} 个目标,其中 ${enabledCount} 个已启用;定时任务使用服务器本地时间。`
|
|
3897
|
+
: "尚未配置备份目标。新增目标后可选择定时启用,也可随时手动执行。";
|
|
3898
|
+
$("#s3-backup-run-all").disabled = enabledCount === 0;
|
|
3899
|
+
const host = $("#s3-backup-targets");
|
|
3900
|
+
host.innerHTML = s3BackupTargets.length ? s3BackupTargets.map((target) => {
|
|
3901
|
+
const failed = s3BackupTargetFailed(target);
|
|
3902
|
+
const path = `${target.endpoint} / ${target.bucket} / ${target.rootPrefix}`;
|
|
3903
|
+
return `<article class="s3-backup-target-card${failed ? " is-failed" : ""}" data-s3-backup-target="${esc(target.id)}">
|
|
3904
|
+
<div class="s3-backup-target-heading"><span class="s3-backup-status ${target.enabled ? "is-enabled" : ""}">${target.enabled ? "已启用" : "已停用"}</span><div><strong>${esc(target.name)}</strong><code>${esc(path)}</code></div></div>
|
|
3905
|
+
<div class="s3-backup-target-actions"><button type="button" data-s3-backup-run="${esc(target.id)}" aria-label="立即运行 ${esc(target.name)}">立即备份</button><button type="button" data-s3-backup-edit="${esc(target.id)}" aria-label="编辑 ${esc(target.name)}">编辑</button><button class="danger-button" type="button" data-s3-backup-delete="${esc(target.id)}" aria-label="删除 ${esc(target.name)}">删除</button></div>
|
|
3906
|
+
<dl class="s3-backup-target-meta">
|
|
3907
|
+
<div><dt>定时计划</dt><dd>${target.enabled ? `每天 ${esc(target.scheduleTime)}` : "不自动执行"}</dd></div>
|
|
3908
|
+
<div><dt>同步内容</dt><dd>${target.backupImages ? "数据库与图片" : "仅数据库"}</dd></div>
|
|
3909
|
+
<div><dt>数据库留存</dt><dd>${Number(target.retentionCount)} 个快照</dd></div>
|
|
3910
|
+
<div><dt>最近成功</dt><dd>${target.lastSuccessAt ? esc(formatDateTime(target.lastSuccessAt)) : "尚未成功"}</dd></div>
|
|
3911
|
+
</dl>
|
|
3912
|
+
${failed ? `<p class="s3-backup-target-error">最近失败:${esc(target.lastError || "S3 服务请求失败")} · ${esc(formatDateTime(target.lastFailureAt))}</p>` : ""}
|
|
3913
|
+
</article>`;
|
|
3914
|
+
}).join("") : '<p class="s3-backup-empty">还没有 S3 备份目标。点击“新增目标”配置第一个全系统备份位置。</p>';
|
|
3915
|
+
|
|
3916
|
+
host.querySelectorAll("[data-s3-backup-run]").forEach((button) => button.addEventListener("click", () => {
|
|
3917
|
+
void queueS3BackupRuns([button.dataset.s3BackupRun], button);
|
|
3918
|
+
}));
|
|
3919
|
+
host.querySelectorAll("[data-s3-backup-edit]").forEach((button) => button.addEventListener("click", () => {
|
|
3920
|
+
const target = s3BackupTargets.find((item) => item.id === button.dataset.s3BackupEdit);
|
|
3921
|
+
if (target) openS3BackupTargetDialog(target);
|
|
3922
|
+
}));
|
|
3923
|
+
host.querySelectorAll("[data-s3-backup-delete]").forEach((button) => button.addEventListener("click", async () => {
|
|
3924
|
+
const target = s3BackupTargets.find((item) => item.id === button.dataset.s3BackupDelete);
|
|
3925
|
+
if (!target) return;
|
|
3926
|
+
const confirmed = await confirmToast(`删除 S3 备份目标“${target.name}”吗?远端已有对象不会被删除。`, {
|
|
3927
|
+
title: "删除备份目标",
|
|
3928
|
+
confirmLabel: "确认删除"
|
|
3929
|
+
});
|
|
3930
|
+
if (!confirmed) return;
|
|
3931
|
+
button.disabled = true;
|
|
3932
|
+
try {
|
|
3933
|
+
await api(`/api/platform/backups/targets/${encodeURIComponent(target.id)}`, { method: "DELETE" });
|
|
3934
|
+
await loadS3BackupData();
|
|
3935
|
+
toast(`备份目标“${target.name}”已删除`);
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
button.disabled = false;
|
|
3938
|
+
toast(error.message, "error");
|
|
3939
|
+
}
|
|
3940
|
+
}));
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
function renderS3BackupRuns() {
|
|
3944
|
+
const host = $("#s3-backup-runs");
|
|
3945
|
+
host.innerHTML = s3BackupRuns.length ? s3BackupRuns.map((run) => {
|
|
3946
|
+
const status = s3BackupStatusLabel(run.status);
|
|
3947
|
+
const details = run.serverResponse ? JSON.stringify(run.serverResponse, null, 2) : "";
|
|
3948
|
+
const metrics = run.status === "running"
|
|
3949
|
+
? "正在同步"
|
|
3950
|
+
: `图片上传 ${Number(run.imagesUploaded)} · 跳过 ${Number(run.imagesSkipped)} · 清理快照 ${Number(run.databasesDeleted)}`;
|
|
3951
|
+
return `<article class="s3-backup-run-card">
|
|
3952
|
+
<span class="s3-backup-status is-${esc(run.status)}">${esc(status)}</span>
|
|
3953
|
+
<div class="s3-backup-run-copy"><strong>${esc(run.targetName)}</strong><small>${run.trigger === "scheduled" ? "定时任务" : "手动触发"} · ${esc(formatDateTime(run.finishedAt || run.startedAt))}${run.errorMessage ? ` · ${esc(run.errorMessage)}` : ""}</small></div>
|
|
3954
|
+
<span class="s3-backup-run-metrics">${esc(metrics)}</span>
|
|
3955
|
+
${details ? `<details><summary>查看 S3 服务端返回结果</summary><pre>${esc(details)}</pre></details>` : ""}
|
|
3956
|
+
</article>`;
|
|
3957
|
+
}).join("") : '<p class="s3-backup-empty">还没有备份运行记录。</p>';
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
async function loadS3BackupData({ loading = false } = {}) {
|
|
3961
|
+
if (loading) {
|
|
3962
|
+
$("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">正在读取备份目标……</p>';
|
|
3963
|
+
$("#s3-backup-runs").innerHTML = '<p class="s3-backup-empty">正在读取运行记录……</p>';
|
|
3964
|
+
}
|
|
3965
|
+
const [targets, runs] = await Promise.all([
|
|
3966
|
+
api("/api/platform/backups/targets"),
|
|
3967
|
+
api("/api/platform/backups/runs?limit=30")
|
|
3968
|
+
]);
|
|
3969
|
+
s3BackupTargets = targets;
|
|
3970
|
+
s3BackupRuns = runs.items;
|
|
3971
|
+
renderS3BackupTargets();
|
|
3972
|
+
renderS3BackupRuns();
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
async function openS3BackupDialog() {
|
|
3976
|
+
if (state.user?.role !== "admin") return toast("需要系统管理员权限", "error");
|
|
3977
|
+
const dialog = $("#s3-backup-dialog");
|
|
3978
|
+
if (!dialog.open) dialog.showModal();
|
|
3979
|
+
try {
|
|
3980
|
+
await loadS3BackupData({ loading: true });
|
|
3981
|
+
} catch (error) {
|
|
3982
|
+
$("#s3-backup-targets").innerHTML = '<p class="s3-backup-empty">备份配置加载失败,请稍后刷新。</p>';
|
|
3983
|
+
toast(error.message, "error");
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
function updateS3BackupRootPreview() {
|
|
3988
|
+
$("#s3-backup-root-preview").textContent = s3BackupRootPrefix($("#s3-backup-base-path").value);
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
function openS3BackupTargetDialog(target = null) {
|
|
3992
|
+
editingS3BackupTargetId = target?.id ?? null;
|
|
3993
|
+
const form = $("#s3-backup-target-form");
|
|
3994
|
+
form.reset();
|
|
3995
|
+
$("#s3-backup-target-title").textContent = target ? "编辑 S3 目标" : "新增 S3 目标";
|
|
3996
|
+
$("#s3-backup-name").value = target?.name ?? "";
|
|
3997
|
+
$("#s3-backup-endpoint").value = target?.endpoint ?? "";
|
|
3998
|
+
$("#s3-backup-region").value = target?.region ?? "us-east-1";
|
|
3999
|
+
$("#s3-backup-bucket").value = target?.bucket ?? "";
|
|
4000
|
+
$("#s3-backup-base-path").value = target?.basePath ?? "";
|
|
4001
|
+
$("#s3-backup-schedule-time").value = target?.scheduleTime ?? "03:00";
|
|
4002
|
+
$("#s3-backup-retention-count").value = String(target?.retentionCount ?? 7);
|
|
4003
|
+
$("#s3-backup-enabled").checked = target?.enabled ?? false;
|
|
4004
|
+
$("#s3-backup-images").checked = target?.backupImages ?? true;
|
|
4005
|
+
$("#s3-backup-force-path-style").checked = target?.forcePathStyle ?? true;
|
|
4006
|
+
$("#s3-backup-access-key").required = !target;
|
|
4007
|
+
$("#s3-backup-secret-key").required = !target;
|
|
4008
|
+
$("#s3-backup-access-key").placeholder = target ? "留空则保持现有 Access Key" : "请输入 Access Key";
|
|
4009
|
+
$("#s3-backup-secret-key").placeholder = target ? "留空则保持现有 Secret Key" : "请输入 Secret Key";
|
|
4010
|
+
$("#s3-backup-credentials-note").textContent = target
|
|
4011
|
+
? "AK 与 SK 均留空时保持现有凭据;只填写其中一项时仅轮换对应凭据。"
|
|
4012
|
+
: "新增目标时必须填写 AK 和 SK。";
|
|
4013
|
+
$("#s3-backup-target-save").textContent = target ? "保存修改" : "保存目标";
|
|
4014
|
+
updateS3BackupRootPreview();
|
|
4015
|
+
const dialog = $("#s3-backup-target-dialog");
|
|
4016
|
+
if (!dialog.open) dialog.showModal();
|
|
4017
|
+
queueMicrotask(() => $("#s3-backup-name").focus());
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
async function saveS3BackupTarget(event) {
|
|
4021
|
+
event.preventDefault();
|
|
4022
|
+
const button = $("#s3-backup-target-save");
|
|
4023
|
+
const accessKeyId = $("#s3-backup-access-key").value;
|
|
4024
|
+
const secretAccessKey = $("#s3-backup-secret-key").value;
|
|
4025
|
+
const body = {
|
|
4026
|
+
name: $("#s3-backup-name").value.trim(),
|
|
4027
|
+
endpoint: $("#s3-backup-endpoint").value.trim(),
|
|
4028
|
+
region: $("#s3-backup-region").value.trim(),
|
|
4029
|
+
bucket: $("#s3-backup-bucket").value.trim(),
|
|
4030
|
+
basePath: $("#s3-backup-base-path").value.trim(),
|
|
4031
|
+
forcePathStyle: $("#s3-backup-force-path-style").checked,
|
|
4032
|
+
enabled: $("#s3-backup-enabled").checked,
|
|
4033
|
+
backupImages: $("#s3-backup-images").checked,
|
|
4034
|
+
scheduleTime: $("#s3-backup-schedule-time").value,
|
|
4035
|
+
retentionCount: Number($("#s3-backup-retention-count").value),
|
|
4036
|
+
...(!editingS3BackupTargetId || accessKeyId ? { accessKeyId } : {}),
|
|
4037
|
+
...(!editingS3BackupTargetId || secretAccessKey ? { secretAccessKey } : {})
|
|
4038
|
+
};
|
|
4039
|
+
button.disabled = true;
|
|
4040
|
+
try {
|
|
4041
|
+
await api(editingS3BackupTargetId
|
|
4042
|
+
? `/api/platform/backups/targets/${encodeURIComponent(editingS3BackupTargetId)}`
|
|
4043
|
+
: "/api/platform/backups/targets", {
|
|
4044
|
+
method: editingS3BackupTargetId ? "PATCH" : "POST",
|
|
4045
|
+
body
|
|
4046
|
+
});
|
|
4047
|
+
const message = editingS3BackupTargetId ? "S3 备份目标已更新" : "S3 备份目标已创建";
|
|
4048
|
+
$("#s3-backup-target-dialog").close();
|
|
4049
|
+
await loadS3BackupData();
|
|
4050
|
+
toast(message);
|
|
4051
|
+
} catch (error) {
|
|
4052
|
+
toast(error.message, "error");
|
|
4053
|
+
} finally {
|
|
4054
|
+
button.disabled = false;
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
async function queueS3BackupRuns(targetIds, button = null) {
|
|
4059
|
+
if (button) button.disabled = true;
|
|
4060
|
+
try {
|
|
4061
|
+
const result = await api("/api/platform/backups/run", {
|
|
4062
|
+
method: "POST",
|
|
4063
|
+
body: targetIds ? { targetIds } : {}
|
|
4064
|
+
});
|
|
4065
|
+
if (result.acceptedTargetIds.length) {
|
|
4066
|
+
toast(`已将 ${result.acceptedTargetIds.length} 个 S3 备份目标加入串行队列`);
|
|
4067
|
+
} else if (result.skippedTargetIds.length) {
|
|
4068
|
+
toast("所选 S3 备份目标已在队列中");
|
|
4069
|
+
} else {
|
|
4070
|
+
toast("当前没有已启用的 S3 备份目标", "error");
|
|
4071
|
+
}
|
|
4072
|
+
window.setTimeout(() => {
|
|
4073
|
+
void refreshS3BackupEvents();
|
|
4074
|
+
if ($("#s3-backup-dialog").open) void loadS3BackupData().catch((error) => toast(error.message, "error"));
|
|
4075
|
+
}, 500);
|
|
4076
|
+
} catch (error) {
|
|
4077
|
+
toast(error.message, "error");
|
|
4078
|
+
} finally {
|
|
4079
|
+
if (button) button.disabled = false;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
function scheduleS3BackupEventPoll(delay = s3BackupPollInterval) {
|
|
4084
|
+
if (s3BackupPollTimer !== null) window.clearTimeout(s3BackupPollTimer);
|
|
4085
|
+
s3BackupPollTimer = null;
|
|
4086
|
+
if (state.user?.role !== "admin" || systemRestartDetected) return;
|
|
4087
|
+
s3BackupPollTimer = window.setTimeout(() => {
|
|
4088
|
+
s3BackupPollTimer = null;
|
|
4089
|
+
void refreshS3BackupEvents();
|
|
4090
|
+
}, delay);
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
async function refreshS3BackupEvents({ announce = true } = {}) {
|
|
4094
|
+
if (state.user?.role !== "admin" || systemRestartDetected) return;
|
|
4095
|
+
if (s3BackupPollTimer !== null) window.clearTimeout(s3BackupPollTimer);
|
|
4096
|
+
s3BackupPollTimer = null;
|
|
4097
|
+
const requestId = ++s3BackupPollRequest;
|
|
4098
|
+
try {
|
|
4099
|
+
const result = await api("/api/platform/backups/runs?limit=100");
|
|
4100
|
+
if (requestId !== s3BackupPollRequest || state.user?.role !== "admin") return;
|
|
4101
|
+
const transitions = collectS3BackupRunTransitions(
|
|
4102
|
+
s3BackupRunSnapshots,
|
|
4103
|
+
result.items,
|
|
4104
|
+
s3BackupRunsInitialized
|
|
4105
|
+
);
|
|
4106
|
+
s3BackupRunSnapshots = transitions.snapshots;
|
|
4107
|
+
if (s3BackupRunsInitialized && announce) {
|
|
4108
|
+
for (const run of transitions.failures) toast(s3BackupFailureToast(run), "error");
|
|
4109
|
+
}
|
|
4110
|
+
s3BackupRunsInitialized = true;
|
|
4111
|
+
if ($("#s3-backup-dialog").open) {
|
|
4112
|
+
s3BackupRuns = result.items.slice(0, 30);
|
|
4113
|
+
renderS3BackupRuns();
|
|
4114
|
+
s3BackupTargets = await api("/api/platform/backups/targets");
|
|
4115
|
+
renderS3BackupTargets();
|
|
4116
|
+
}
|
|
4117
|
+
} catch (error) {
|
|
4118
|
+
console.error("Failed to refresh S3 backup events", error);
|
|
4119
|
+
} finally {
|
|
4120
|
+
scheduleS3BackupEventPoll();
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
function startS3BackupEventPolling() {
|
|
4125
|
+
if (state.user?.role !== "admin") return;
|
|
4126
|
+
s3BackupRunSnapshots = new Map();
|
|
4127
|
+
s3BackupRunsInitialized = false;
|
|
4128
|
+
void refreshS3BackupEvents({ announce: false });
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
function stopS3BackupEventPolling() {
|
|
4132
|
+
if (s3BackupPollTimer !== null) window.clearTimeout(s3BackupPollTimer);
|
|
4133
|
+
s3BackupPollTimer = null;
|
|
4134
|
+
s3BackupPollRequest += 1;
|
|
4135
|
+
s3BackupRunsInitialized = false;
|
|
4136
|
+
s3BackupRunSnapshots = new Map();
|
|
4137
|
+
}
|
|
4138
|
+
|
|
3853
4139
|
function renderMemberPermissionGrid(value) {
|
|
3854
4140
|
const permissions = normalizeModulePermissions(value, "custom");
|
|
3855
4141
|
$("#member-permission-grid").innerHTML = WORK_PERMISSION_MODULES.map((item) => `<label class="member-permission-row">
|
|
@@ -7416,6 +7702,7 @@ async function renderBookAiSettings() {
|
|
|
7416
7702
|
]);
|
|
7417
7703
|
const host = $("#module-content");
|
|
7418
7704
|
const workId = String(state.work.id);
|
|
7705
|
+
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
7419
7706
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
|
|
7420
7707
|
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
7421
7708
|
const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
|
|
@@ -7429,8 +7716,14 @@ async function renderBookAiSettings() {
|
|
|
7429
7716
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
7430
7717
|
title: "本书 Token 用量",
|
|
7431
7718
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
7432
|
-
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
7433
|
-
|
|
7719
|
+
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
7720
|
+
const agentToolCallLimitInput = host.querySelector("#agent-tool-call-limit");
|
|
7721
|
+
agentToolCallLimitInput?.setAttribute("max", String(maximumAgentToolCallLimit));
|
|
7722
|
+
const agentToolCallDescription = agentToolCallLimitInput?.closest(".config-section")?.querySelector(".config-section-header p");
|
|
7723
|
+
if (agentToolCallDescription?.firstChild) agentToolCallDescription.firstChild.nodeValue = agentToolCallDescription.firstChild.nodeValue.replace("5–48", `5–${maximumAgentToolCallLimit}`);
|
|
7724
|
+
host.querySelectorAll(".config-section").forEach((section) => {
|
|
7725
|
+
if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
|
|
7726
|
+
});
|
|
7434
7727
|
bindUsageCalendarInteractions(host);
|
|
7435
7728
|
scrollUsageCalendarsToLatest(host);
|
|
7436
7729
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
@@ -7597,12 +7890,25 @@ async function renderBookAiSettings() {
|
|
|
7597
7890
|
});
|
|
7598
7891
|
$("#save-agent-tool-call-limit").addEventListener("click", async () => {
|
|
7599
7892
|
const button = $("#save-agent-tool-call-limit");
|
|
7893
|
+
const input = $("#agent-tool-call-limit");
|
|
7894
|
+
const value = Number(input.value);
|
|
7895
|
+
const maximum = Number(input.max) || 80;
|
|
7896
|
+
if (!Number.isInteger(value) || value < 5) {
|
|
7897
|
+
toast(`Agent 工具调用上限必须是 5 到 ${maximum} 之间的整数`, "error");
|
|
7898
|
+
input.focus();
|
|
7899
|
+
return;
|
|
7900
|
+
}
|
|
7901
|
+
if (value > maximum) {
|
|
7902
|
+
toast(`Agent 工具调用上限不能超过 ${maximum} 次`, "error");
|
|
7903
|
+
input.focus();
|
|
7904
|
+
return;
|
|
7905
|
+
}
|
|
7600
7906
|
button.disabled = true;
|
|
7601
7907
|
try {
|
|
7602
7908
|
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
7603
7909
|
method: "PATCH",
|
|
7604
7910
|
body: {
|
|
7605
|
-
agentToolCallLimit:
|
|
7911
|
+
agentToolCallLimit: value,
|
|
7606
7912
|
agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
|
|
7607
7913
|
}
|
|
7608
7914
|
});
|
|
@@ -10576,9 +10882,13 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
10576
10882
|
const isFailure = role === "assistant" && text.startsWith("调用失败:");
|
|
10577
10883
|
message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
|
|
10578
10884
|
const messageBody = isFailure
|
|
10579
|
-
? `<p class="ai-error-text">${esc(text)}</p
|
|
10885
|
+
? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
|
|
10580
10886
|
: renderMarkdown(text);
|
|
10581
10887
|
message.innerHTML = `<div class="message-body">${messageBody}</div>`;
|
|
10888
|
+
message.querySelector("[data-ai-tool-call-settings-link]")?.addEventListener("click", (event) => {
|
|
10889
|
+
event.preventDefault();
|
|
10890
|
+
openAiToolCallSettings().catch((error) => toast(`打开 AI 设置失败:${error.message}`, "error"));
|
|
10891
|
+
});
|
|
10582
10892
|
const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
|
|
10583
10893
|
if (isFailure) {
|
|
10584
10894
|
message.dataset.status = "failed";
|
|
@@ -11425,6 +11735,7 @@ $("#api-key-copy-button").addEventListener("click", async () => {
|
|
|
11425
11735
|
});
|
|
11426
11736
|
$("#logout-button").addEventListener("click", async () => {
|
|
11427
11737
|
try {
|
|
11738
|
+
stopS3BackupEventPolling();
|
|
11428
11739
|
await api("/api/auth/session", { method: "DELETE" });
|
|
11429
11740
|
state.user = null;
|
|
11430
11741
|
state.csrfToken = null;
|
|
@@ -11555,6 +11866,7 @@ $("#work-audit-load-more").addEventListener("click", () => {
|
|
|
11555
11866
|
if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
|
|
11556
11867
|
});
|
|
11557
11868
|
$("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
|
|
11869
|
+
$("#s3-backup-button").addEventListener("click", () => openS3BackupDialog().catch((error) => toast(error.message, "error")));
|
|
11558
11870
|
$("#collaboration-button").addEventListener("click", () => openMembersDialog());
|
|
11559
11871
|
$("#presence-button").addEventListener("click", () => {
|
|
11560
11872
|
const panel = $("#presence-panel");
|
|
@@ -11566,6 +11878,30 @@ $("#users-settings-return").addEventListener("click", () => returnToSettingsHub(
|
|
|
11566
11878
|
$("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
11567
11879
|
$("#platform-ui-settings-return").addEventListener("click", () => returnToSettingsHub("#platform-ui-settings-button", "#platform-ui-settings-dialog").catch((error) => toast(error.message, "error")));
|
|
11568
11880
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
11881
|
+
$("#s3-backup-close").addEventListener("click", () => $("#s3-backup-dialog").close());
|
|
11882
|
+
$("#s3-backup-settings-return").addEventListener("click", () => returnToSettingsHub("#s3-backup-button", "#s3-backup-dialog").catch((error) => toast(error.message, "error")));
|
|
11883
|
+
$("#s3-backup-add").addEventListener("click", () => openS3BackupTargetDialog());
|
|
11884
|
+
$("#s3-backup-run-all").addEventListener("click", (event) => void queueS3BackupRuns(null, event.currentTarget));
|
|
11885
|
+
$("#s3-backup-refresh").addEventListener("click", async (event) => {
|
|
11886
|
+
const button = event.currentTarget;
|
|
11887
|
+
button.disabled = true;
|
|
11888
|
+
try {
|
|
11889
|
+
await loadS3BackupData();
|
|
11890
|
+
toast("S3 备份状态已刷新");
|
|
11891
|
+
} catch (error) {
|
|
11892
|
+
toast(error.message, "error");
|
|
11893
|
+
} finally {
|
|
11894
|
+
button.disabled = false;
|
|
11895
|
+
}
|
|
11896
|
+
});
|
|
11897
|
+
$("#s3-backup-target-close").addEventListener("click", () => $("#s3-backup-target-dialog").close());
|
|
11898
|
+
$("#s3-backup-target-cancel").addEventListener("click", () => $("#s3-backup-target-dialog").close());
|
|
11899
|
+
$("#s3-backup-target-form").addEventListener("submit", saveS3BackupTarget);
|
|
11900
|
+
$("#s3-backup-base-path").addEventListener("input", updateS3BackupRootPreview);
|
|
11901
|
+
$("#s3-backup-target-dialog").addEventListener("close", () => {
|
|
11902
|
+
editingS3BackupTargetId = null;
|
|
11903
|
+
$("#s3-backup-target-form").reset();
|
|
11904
|
+
});
|
|
11569
11905
|
$("#platform-ui-settings-form").addEventListener("submit", async (event) => {
|
|
11570
11906
|
event.preventDefault();
|
|
11571
11907
|
const button = $("#platform-ui-settings-save");
|
|
@@ -12225,6 +12561,7 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
|
12225
12561
|
document.addEventListener("visibilitychange", () => {
|
|
12226
12562
|
if (document.visibilityState !== "visible") return;
|
|
12227
12563
|
if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
|
|
12564
|
+
if (state.user?.role === "admin" && !systemRestartDetected) void refreshS3BackupEvents();
|
|
12228
12565
|
void refreshSystemHealth();
|
|
12229
12566
|
});
|
|
12230
12567
|
window.addEventListener("beforeunload", (event) => {
|
|
@@ -12,12 +12,21 @@ const entityTargets = Object.freeze({
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
export function prioritizeGlobalSearchResults(results = []) {
|
|
15
|
-
const
|
|
15
|
+
const nonProseResults = [];
|
|
16
16
|
const proseResults = [];
|
|
17
17
|
for (const result of Array.isArray(results) ? results : []) {
|
|
18
|
-
(String(result?.type ?? "") === "chapter" ? proseResults :
|
|
18
|
+
(String(result?.type ?? "") === "chapter" ? proseResults : nonProseResults).push(result);
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
const sortByRelevance = (group) => group
|
|
21
|
+
.map((result, index) => ({ result, index, score: Number(result?.score) }))
|
|
22
|
+
.sort((left, right) => {
|
|
23
|
+
if (Number.isFinite(left.score) && Number.isFinite(right.score) && left.score !== right.score) {
|
|
24
|
+
return right.score - left.score;
|
|
25
|
+
}
|
|
26
|
+
return left.index - right.index;
|
|
27
|
+
})
|
|
28
|
+
.map(({ result }) => result);
|
|
29
|
+
return [...sortByRelevance(nonProseResults), ...sortByRelevance(proseResults)];
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
function positiveLine(value) {
|
package/dist/public/index.html
CHANGED
|
@@ -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=
|
|
13
|
+
<link rel="stylesheet" href="/styles.css?v=20260805-ai-tool-call-settings-v7">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="auth-pending">
|
|
16
16
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -210,6 +210,7 @@
|
|
|
210
210
|
<button id="platform-usage-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">T</span><strong>Token 用量</strong><small>总消耗、缓存命中率、每日网格与作品明细</small></button>
|
|
211
211
|
<button id="user-management-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">人</span><strong>用户管理</strong><small>管理员、普通用户与账户状态</small></button>
|
|
212
212
|
<button id="platform-ui-settings-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">界</span><strong>界面与分页</strong><small>设置通知位置及各模块的单页数量</small></button>
|
|
213
|
+
<button id="s3-backup-button" class="settings-hub-card hidden" type="button"><span class="settings-card-mark">S3</span><strong>S3 备份</strong><small>将整个系统的数据库和图片同步到多个目标</small></button>
|
|
213
214
|
<button id="collaboration-button" class="settings-hub-card" type="button"><span class="settings-card-mark">协</span><strong>作品协作</strong><small>邀请注册用户共同编辑当前作品</small></button>
|
|
214
215
|
<button id="writing-progress-button" class="settings-hub-card" type="button"><span class="settings-card-mark">字</span><strong>写作目标</strong><small>每日目标、总字数目标与近 30 天趋势</small></button>
|
|
215
216
|
<button id="work-audit-button" class="settings-hub-card" type="button"><span class="settings-card-mark">录</span><strong>操作记录</strong><small>查看作品修改、操作者、对象与时间</small></button>
|
|
@@ -801,6 +802,49 @@
|
|
|
801
802
|
</form>
|
|
802
803
|
</dialog>
|
|
803
804
|
|
|
805
|
+
<dialog id="s3-backup-dialog" class="dialog wide-dialog s3-backup-dialog" aria-labelledby="s3-backup-title" aria-describedby="s3-backup-description">
|
|
806
|
+
<div class="dialog-header">
|
|
807
|
+
<div><span class="eyebrow">系统管理员</span><h2 id="s3-backup-title">S3 系统备份</h2><p id="s3-backup-description" class="dialog-header-meta">备份范围始终是整个 Scriverse 系统,不按作品拆分。所有启用目标会按配置顺序依次执行。</p></div>
|
|
808
|
+
<div class="settings-dialog-header-actions"><button id="s3-backup-settings-return" class="ghost-button settings-parent-button" type="button">返回设置</button><button id="s3-backup-close" class="dialog-close" aria-label="关闭 S3 系统备份" type="button">×</button></div>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="s3-backup-body">
|
|
811
|
+
<section class="s3-backup-overview" aria-labelledby="s3-backup-overview-title">
|
|
812
|
+
<div><h3 id="s3-backup-overview-title">备份目标</h3><p id="s3-backup-summary">正在读取备份配置……</p></div>
|
|
813
|
+
<div class="s3-backup-overview-actions"><button id="s3-backup-run-all" class="ghost-button" type="button">立即备份已启用目标</button><button id="s3-backup-add" class="primary-button" type="button">新增目标</button></div>
|
|
814
|
+
</section>
|
|
815
|
+
<div id="s3-backup-targets" class="s3-backup-target-list" aria-live="polite"></div>
|
|
816
|
+
<section class="s3-backup-history" aria-labelledby="s3-backup-history-title">
|
|
817
|
+
<header><div><h3 id="s3-backup-history-title">最近运行</h3><p>数据库快照保留在各目标的 <code>/scriverse/db</code>,图片只增量写入 <code>/scriverse/img</code>。</p></div><button id="s3-backup-refresh" class="ghost-button" type="button">刷新</button></header>
|
|
818
|
+
<div id="s3-backup-runs" class="s3-backup-run-list" aria-live="polite"></div>
|
|
819
|
+
</section>
|
|
820
|
+
</div>
|
|
821
|
+
</dialog>
|
|
822
|
+
|
|
823
|
+
<dialog id="s3-backup-target-dialog" class="dialog editor-dialog s3-backup-target-dialog" aria-labelledby="s3-backup-target-title">
|
|
824
|
+
<form id="s3-backup-target-form">
|
|
825
|
+
<div class="dialog-header"><div><span class="eyebrow">系统备份目标</span><h2 id="s3-backup-target-title">新增 S3 目标</h2><p class="dialog-header-meta">AK 与 SK 会使用系统主密钥加密保存,读取配置时不会回显。</p></div><button id="s3-backup-target-close" class="dialog-close" aria-label="关闭 S3 目标编辑" type="button">×</button></div>
|
|
826
|
+
<div class="dialog-fields s3-backup-target-fields">
|
|
827
|
+
<label>配置名称<input id="s3-backup-name" name="name" maxlength="100" required autocomplete="off" placeholder="例如:异地 MinIO"></label>
|
|
828
|
+
<label>S3 服务地址<input id="s3-backup-endpoint" name="endpoint" type="url" maxlength="2000" required spellcheck="false" autocomplete="url" placeholder="https://s3.example.com"></label>
|
|
829
|
+
<label>区域<input id="s3-backup-region" name="region" maxlength="64" required spellcheck="false" autocomplete="off" value="us-east-1"></label>
|
|
830
|
+
<label>桶名称<input id="s3-backup-bucket" name="bucket" maxlength="255" required spellcheck="false" autocomplete="off" placeholder="scriverse-backups"></label>
|
|
831
|
+
<label class="s3-backup-wide-field">可选子目录<input id="s3-backup-base-path" name="basePath" maxlength="512" spellcheck="false" autocomplete="off" placeholder="例如:authors/mothra"><small>实际根路径:<code id="s3-backup-root-preview">scriverse</code></small></label>
|
|
832
|
+
<label>Access Key<input id="s3-backup-access-key" name="accessKeyId" type="password" maxlength="512" autocomplete="new-password" spellcheck="false"></label>
|
|
833
|
+
<label>Secret Key<input id="s3-backup-secret-key" name="secretAccessKey" type="password" maxlength="2048" autocomplete="new-password" spellcheck="false"></label>
|
|
834
|
+
<p id="s3-backup-credentials-note" class="form-field-note s3-backup-wide-field">新增目标时必须填写 AK 和 SK。</p>
|
|
835
|
+
<label>每日触发时间<input id="s3-backup-schedule-time" name="scheduleTime" type="time" value="03:00" required></label>
|
|
836
|
+
<label>数据库留存个数<input id="s3-backup-retention-count" name="retentionCount" type="number" min="1" max="365" step="1" value="7" required></label>
|
|
837
|
+
<fieldset class="s3-backup-options s3-backup-wide-field">
|
|
838
|
+
<legend>同步选项</legend>
|
|
839
|
+
<label><input id="s3-backup-enabled" name="enabled" type="checkbox"><span><strong>启用定时备份</strong><small>到达每日触发时间后加入串行同步队列。</small></span></label>
|
|
840
|
+
<label><input id="s3-backup-images" name="backupImages" type="checkbox" checked><span><strong>备份图片</strong><small>上传附件、作品封面和用户头像;已存在的对象会跳过。</small></span></label>
|
|
841
|
+
<label><input id="s3-backup-force-path-style" name="forcePathStyle" type="checkbox" checked><span><strong>使用 Path-style</strong><small>大多数 MinIO 与 S3 兼容服务需要开启;AWS 虚拟主机模式可关闭。</small></span></label>
|
|
842
|
+
</fieldset>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="dialog-actions"><button id="s3-backup-target-cancel" class="ghost-button" type="button">取消</button><button id="s3-backup-target-save" class="primary-button" type="submit">保存目标</button></div>
|
|
845
|
+
</form>
|
|
846
|
+
</dialog>
|
|
847
|
+
|
|
804
848
|
<dialog id="account-dialog" class="dialog account-dialog" aria-labelledby="account-dialog-title">
|
|
805
849
|
<div class="dialog-header"><div><span class="eyebrow">个人账户</span><h2 id="account-dialog-title">账户设置</h2></div><button id="account-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
|
|
806
850
|
<div class="account-settings-body">
|
|
@@ -954,6 +998,6 @@
|
|
|
954
998
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
955
999
|
<script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
|
|
956
1000
|
<script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
|
|
957
|
-
<script type="module" src="/app.js?v=
|
|
1001
|
+
<script type="module" src="/app.js?v=20260805-ai-tool-call-settings-v5"></script>
|
|
958
1002
|
</body>
|
|
959
1003
|
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type S3BackupRunSnapshot = {
|
|
2
|
+
id: string;
|
|
3
|
+
status: string;
|
|
4
|
+
targetName?: string;
|
|
5
|
+
errorMessage?: string | null;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function s3BackupRootPrefix(basePath?: string): string;
|
|
10
|
+
export function s3BackupStatusLabel(status: string): string;
|
|
11
|
+
export function collectS3BackupRunTransitions<T extends S3BackupRunSnapshot>(
|
|
12
|
+
previousSnapshots: ReadonlyMap<string, string>,
|
|
13
|
+
runs: T[],
|
|
14
|
+
initialized: boolean,
|
|
15
|
+
maximumSnapshots?: number
|
|
16
|
+
): { snapshots: Map<string, string>; failures: T[] };
|
|
17
|
+
export function s3BackupFailureToast(run: Pick<S3BackupRunSnapshot, "targetName" | "errorMessage">): string;
|