@musnows/scriverse 0.6.8 → 0.6.10

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.
@@ -36,7 +36,7 @@ import {
36
36
  taskScopeLabel,
37
37
  timelineStatusLabel,
38
38
  characterStateFieldLabel
39
- } from "/display-labels.js?v=20260801-google-vertex";
39
+ } from "/display-labels.js?v=20260804-agent-history-search-v1";
40
40
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
41
41
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
42
42
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -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 { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
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;
@@ -1812,8 +1821,10 @@ async function ensureAiConversationsLoaded() {
1812
1821
  }
1813
1822
  }
1814
1823
 
1815
- async function openAiConversation(conversationId, hideHistory = true) {
1816
- const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1824
+ async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
1825
+ const parameters = new URLSearchParams({ page: "1", limit: "100" });
1826
+ if (focusMessageId) parameters.set("messageId", String(focusMessageId));
1827
+ const conversation = await api(`/api/ai-conversations/${conversationId}?${parameters}`);
1817
1828
  upsertAiConversationSummary(conversation);
1818
1829
  state.aiConversationId = conversation.id;
1819
1830
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
@@ -1839,6 +1850,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1839
1850
  if (hideHistory) setAiHistoryVisible(false);
1840
1851
  }
1841
1852
 
1853
+ function focusAiConversationMessage(messageId) {
1854
+ const message = [...$("#ai-feed").querySelectorAll("[data-message-id]")]
1855
+ .find((candidate) => candidate.dataset.messageId === String(messageId));
1856
+ if (!message) return;
1857
+ message.scrollIntoView({ behavior: "smooth", block: "center" });
1858
+ message.classList.add("is-search-target");
1859
+ window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
1860
+ }
1861
+
1842
1862
  async function createNewAiConversation(taskType = "chat") {
1843
1863
  if (!state.work) return;
1844
1864
  const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
@@ -2571,6 +2591,28 @@ function formatAiFailureMessage(error) {
2571
2591
  return lines.join("\n");
2572
2592
  }
2573
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
+
2574
2616
  async function apiPage(path, page = 1, limit = 30) {
2575
2617
  const separator = path.includes("?") ? "&" : "?";
2576
2618
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
@@ -2846,6 +2888,7 @@ function clearAuthenticationOverlays() {
2846
2888
  }
2847
2889
 
2848
2890
  function invalidateAuthentication() {
2891
+ stopS3BackupEventPolling();
2849
2892
  state.user = null;
2850
2893
  state.csrfToken = null;
2851
2894
  moduleRequestCache.clear();
@@ -2964,6 +3007,7 @@ async function initializeAuthentication() {
2964
3007
  applyAuthenticatedUser(session);
2965
3008
  scheduleSystemBootCheck();
2966
3009
  await loadPlatformUiSettings();
3010
+ startS3BackupEventPolling();
2967
3011
  return true;
2968
3012
  }
2969
3013
 
@@ -3462,6 +3506,7 @@ function renderSettingsHub() {
3462
3506
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
3463
3507
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
3464
3508
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
3509
+ $("#s3-backup-button").classList.toggle("hidden", !isAdmin);
3465
3510
  $("#collaboration-button").disabled = !canManageWork;
3466
3511
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
3467
3512
  $("#work-audit-button").disabled = !canManageWork;
@@ -3839,6 +3884,258 @@ async function openPlatformUiSettingsDialog() {
3839
3884
  }
3840
3885
  }
3841
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
+
3842
4139
  function renderMemberPermissionGrid(value) {
3843
4140
  const permissions = normalizeModulePermissions(value, "custom");
3844
4141
  $("#member-permission-grid").innerHTML = WORK_PERMISSION_MODULES.map((item) => `<label class="member-permission-row">
@@ -3965,8 +4262,9 @@ function renderSearchResults(results, query) {
3965
4262
  $("#search-results").innerHTML = '<p class="search-results-status">未找到相关内容。</p>';
3966
4263
  return;
3967
4264
  }
4265
+ const orderedResults = prioritizeGlobalSearchResults(results);
3968
4266
  const matchKindLabel = { metadata: "资料命中", exact: "精确命中", phonetic: "拼音命中" };
3969
- $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${results.length} 条结果,按综合相关度排序。</p>${results.map((item) => {
4267
+ $("#search-results").innerHTML = `<p class="search-results-summary">找到 ${orderedResults.length} 条结果,非正文结果优先,正文条目随后展示。</p>${orderedResults.map((item) => {
3970
4268
  const matchKinds = Array.isArray(item.matchKinds) ? item.matchKinds : [];
3971
4269
  const lineRange = Number.isInteger(item.startLine)
3972
4270
  ? `<span class="search-result-chip">${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
@@ -3981,7 +4279,7 @@ function renderSearchResults(results, query) {
3981
4279
  }).join("")}`;
3982
4280
  $("#search-results").querySelectorAll(".search-result").forEach((button, index) => {
3983
4281
  button.addEventListener("click", () => {
3984
- openSearchResult(results[index])
4282
+ openSearchResult(orderedResults[index])
3985
4283
  .catch((error) => toast(error.message, "error"));
3986
4284
  });
3987
4285
  });
@@ -4050,6 +4348,12 @@ async function openSearchResult(result) {
4050
4348
  || !$("#platform-usage-view").classList.contains("hidden")
4051
4349
  || !$("#work-audit-view").classList.contains("hidden");
4052
4350
  if (inSettings) await returnFromSettings();
4351
+ if (target.kind === "agent-history") {
4352
+ ensureAiPanelExpanded();
4353
+ await openAiConversation(target.conversationId, true, target.messageId);
4354
+ if (target.messageId) focusAiConversationMessage(target.messageId);
4355
+ return;
4356
+ }
4053
4357
  if (target.kind === "chapter") {
4054
4358
  await selectChapter(target.id);
4055
4359
  if (state.chapter?.id === target.id && target.startLine) {
@@ -6841,11 +7145,9 @@ function renderProviderCards(providers, models) {
6841
7145
  const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
6842
7146
  const modelStatus = !model.enabled
6843
7147
  ? `<span class="model-status-badge is-disabled">模型已停用</span>`
6844
- : provider.status !== "enabled"
6845
- ? `<span class="model-status-badge is-disabled">供应商已停用</span>`
6846
- : provider.connectionStatus !== "success"
6847
- ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
6848
- : "";
7148
+ : provider.connectionStatus !== "success"
7149
+ ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
7150
+ : "";
6849
7151
  const capability = model.multimodalEnabled ? " · 多模态" : "";
6850
7152
  const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
6851
7153
  return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
@@ -7400,6 +7702,7 @@ async function renderBookAiSettings() {
7400
7702
  ]);
7401
7703
  const host = $("#module-content");
7402
7704
  const workId = String(state.work.id);
7705
+ const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
7403
7706
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"]);
7404
7707
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
7405
7708
  const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
@@ -7413,8 +7716,14 @@ async function renderBookAiSettings() {
7413
7716
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
7414
7717
  title: "本书 Token 用量",
7415
7718
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
7416
- })}</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)}`;
7417
- $("#daily-token-quota-status").closest(".config-section").insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label><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>`);
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
+ });
7418
7727
  bindUsageCalendarInteractions(host);
7419
7728
  scrollUsageCalendarsToLatest(host);
7420
7729
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
@@ -7581,12 +7890,25 @@ async function renderBookAiSettings() {
7581
7890
  });
7582
7891
  $("#save-agent-tool-call-limit").addEventListener("click", async () => {
7583
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
+ }
7584
7906
  button.disabled = true;
7585
7907
  try {
7586
7908
  await api(`/api/works/${state.work.id}/ai-settings`, {
7587
7909
  method: "PATCH",
7588
7910
  body: {
7589
- agentToolCallLimit: Number($("#agent-tool-call-limit").value),
7911
+ agentToolCallLimit: value,
7590
7912
  agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
7591
7913
  }
7592
7914
  });
@@ -10560,9 +10882,13 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
10560
10882
  const isFailure = role === "assistant" && text.startsWith("调用失败:");
10561
10883
  message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
10562
10884
  const messageBody = isFailure
10563
- ? `<p class="ai-error-text">${esc(text)}</p>`
10885
+ ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
10564
10886
  : renderMarkdown(text);
10565
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
+ });
10566
10892
  const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
10567
10893
  if (isFailure) {
10568
10894
  message.dataset.status = "failed";
@@ -11409,6 +11735,7 @@ $("#api-key-copy-button").addEventListener("click", async () => {
11409
11735
  });
11410
11736
  $("#logout-button").addEventListener("click", async () => {
11411
11737
  try {
11738
+ stopS3BackupEventPolling();
11412
11739
  await api("/api/auth/session", { method: "DELETE" });
11413
11740
  state.user = null;
11414
11741
  state.csrfToken = null;
@@ -11539,6 +11866,7 @@ $("#work-audit-load-more").addEventListener("click", () => {
11539
11866
  if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
11540
11867
  });
11541
11868
  $("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
11869
+ $("#s3-backup-button").addEventListener("click", () => openS3BackupDialog().catch((error) => toast(error.message, "error")));
11542
11870
  $("#collaboration-button").addEventListener("click", () => openMembersDialog());
11543
11871
  $("#presence-button").addEventListener("click", () => {
11544
11872
  const panel = $("#presence-panel");
@@ -11550,6 +11878,30 @@ $("#users-settings-return").addEventListener("click", () => returnToSettingsHub(
11550
11878
  $("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
11551
11879
  $("#platform-ui-settings-return").addEventListener("click", () => returnToSettingsHub("#platform-ui-settings-button", "#platform-ui-settings-dialog").catch((error) => toast(error.message, "error")));
11552
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
+ });
11553
11905
  $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
11554
11906
  event.preventDefault();
11555
11907
  const button = $("#platform-ui-settings-save");
@@ -12209,6 +12561,7 @@ $("#manuscript-export-menu").addEventListener("click", (event) => {
12209
12561
  document.addEventListener("visibilitychange", () => {
12210
12562
  if (document.visibilityState !== "visible") return;
12211
12563
  if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
12564
+ if (state.user?.role === "admin" && !systemRestartDetected) void refreshS3BackupEvents();
12212
12565
  void refreshSystemHealth();
12213
12566
  });
12214
12567
  window.addEventListener("beforeunload", (event) => {
@@ -93,7 +93,8 @@ export function searchResultTypeLabel(value) {
93
93
  relationship: "人物关系",
94
94
  "chapter-outline": "章节大纲",
95
95
  foreshadow: "伏笔",
96
- review: "审核项"
96
+ review: "审核项",
97
+ "agent-history": "Agent 历史"
97
98
  }, value, "其他资料");
98
99
  }
99
100
 
@@ -1,5 +1,6 @@
1
1
  export type GlobalSearchTarget =
2
2
  | { kind: "chapter"; type: "chapter"; id: string; module: "editor"; startLine?: number; endLine?: number }
3
+ | { kind: "agent-history"; type: "agent-history"; id: string; module: "editor"; conversationId: string; messageId?: string }
3
4
  | {
4
5
  kind: "entity";
5
6
  type: "setting" | "character" | "race" | "organization" | "timeline-track" | "timeline-event" | "relationship" | "chapter-outline" | "foreshadow" | "review";
@@ -9,5 +10,6 @@ export type GlobalSearchTarget =
9
10
  apiPath: string;
10
11
  };
11
12
 
13
+ export function prioritizeGlobalSearchResults<T extends { type?: unknown }>(results: readonly T[]): T[];
12
14
  export function splitGlobalSearchHighlight(value: unknown, query: unknown): Array<{ text: string; match: boolean }>;
13
- export function resolveGlobalSearchTarget(result?: { type?: unknown; id?: unknown; startLine?: unknown; endLine?: unknown }): GlobalSearchTarget | null;
15
+ export function resolveGlobalSearchTarget(result?: { type?: unknown; id?: unknown; startLine?: unknown; endLine?: unknown; conversationId?: unknown; messageId?: unknown }): GlobalSearchTarget | null;