@musnows/scriverse 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,9 @@ import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-s
10
10
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
11
11
  import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
12
12
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
13
- import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
14
- import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260731-skills-description-v1";
13
+ import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
14
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260801-retain-usage-v1";
15
+ import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
15
16
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
16
17
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
17
18
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
@@ -49,6 +50,7 @@ import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20
49
50
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
50
51
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
51
52
  import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
53
+ import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
52
54
  import {
53
55
  clampCropRect,
54
56
  containImageRect,
@@ -152,6 +154,7 @@ function createPresenceClientId() {
152
154
 
153
155
  const presenceClientId = createPresenceClientId();
154
156
  const presenceHeartbeatInterval = 12_000;
157
+ const systemBootCheckInterval = 8_000;
155
158
  let presenceParticipants = [];
156
159
  let presenceHeartbeatTimer = null;
157
160
  let presenceHeartbeatQueued = null;
@@ -160,6 +163,10 @@ const acknowledgedCollaborativeChangeIds = new Set();
160
163
  let collaborativeChangePromptOpen = false;
161
164
  let relationshipPresenceId = null;
162
165
  let collaborationAutoSaveDisabled = false;
166
+ let systemBootId = null;
167
+ let systemBootCheckTimer = null;
168
+ let systemBootCheckPromise = null;
169
+ let systemRestartDetected = false;
163
170
  let chapterAnnotations = [];
164
171
  let workAuditRecords = [];
165
172
  let workAuditNextPage = null;
@@ -177,6 +184,10 @@ let backgroundTaskCenterWorkId = null;
177
184
  let backgroundTaskCenterTasksInitialized = false;
178
185
  let backgroundTaskCenterTaskSnapshots = new Map();
179
186
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
187
+ const systemHealthPollInterval = 30_000;
188
+ let systemHealthTimer = null;
189
+ let systemHealthSnapshot = { status: "checking", version: "" };
190
+ let topbarStatusSource = "view";
180
191
  const taskProgressRefreshInterval = 2_500;
181
192
  const taskStatusSnapshots = new Map();
182
193
 
@@ -338,6 +349,15 @@ const $ = (selector) => document.querySelector(selector);
338
349
  const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
339
350
  const maximumAvatarFileSize = 5 * 1024 * 1024;
340
351
 
352
+ function setAiAssistantStatus(status) {
353
+ const failed = status === "error";
354
+ const label = failed ? "创作助手状态:执行失败" : "创作助手状态:正常";
355
+ const dot = $("#ai-status-dot");
356
+ dot.classList.toggle("is-error", failed);
357
+ dot.setAttribute("aria-label", label);
358
+ dot.title = label;
359
+ }
360
+
341
361
  function userAvatarInitial(user) {
342
362
  return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
343
363
  }
@@ -395,6 +415,7 @@ let aiReferencesLoadPromise = null;
395
415
  let aiReferencesLoadWorkId = null;
396
416
  let aiConversationsLoadPromise = null;
397
417
  let aiConversationsLoadWorkId = null;
418
+ let latestAiContextUsage = null;
398
419
  const aiConversationHistoryPageLimit = 20;
399
420
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
400
421
  let workScopedUiGeneration = 0;
@@ -614,7 +635,7 @@ function presencePageForRoute(route = currentPageRoute()) {
614
635
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
615
636
  if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
616
637
  if (route.view === "module") return { kind: "module", module: route.module };
617
- if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage") return { kind: "settings" };
638
+ if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage" || route.view === "work-audit") return { kind: "settings" };
618
639
  return { kind: "welcome" };
619
640
  }
620
641
 
@@ -780,6 +801,7 @@ function currentPageRoute() {
780
801
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
781
802
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
782
803
  if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
804
+ if (!$("#work-audit-view").classList.contains("hidden")) return { view: "work-audit", workId, ...settingsRouteContext() };
783
805
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
784
806
  if (!workId) return { view: "shelf" };
785
807
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
@@ -1314,7 +1336,7 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1314
1336
  role.textContent = label;
1315
1337
  const time = document.createElement("time");
1316
1338
  time.dateTime = timestamp;
1317
- time.textContent = formatAiMessageTime(timestamp, previousCreatedAt);
1339
+ time.textContent = formatAiMessageTime(timestamp);
1318
1340
  heading.append(role, time);
1319
1341
  message.prepend(heading);
1320
1342
  message.dataset.createdAt = timestamp;
@@ -1328,7 +1350,7 @@ function updateMessageCreatedAt(message, createdAt) {
1328
1350
  const time = message.querySelector(".message-heading time");
1329
1351
  if (!time) return;
1330
1352
  time.dateTime = createdAt;
1331
- time.textContent = formatAiMessageTime(createdAt, message.dataset.previousCreatedAt || null);
1353
+ time.textContent = formatAiMessageTime(createdAt);
1332
1354
  message.dataset.createdAt = createdAt;
1333
1355
  if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
1334
1356
  }
@@ -1338,14 +1360,20 @@ function resetAiFeed() {
1338
1360
  $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1339
1361
  }
1340
1362
 
1341
- function appendAiContextCompactionDivider(kind, before = null) {
1363
+ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
1342
1364
  const divider = document.createElement("div");
1343
1365
  divider.className = "ai-context-compaction-divider";
1344
1366
  divider.dataset.contextCompaction = kind;
1345
1367
  divider.dataset.testid = "ai-context-compaction-divider";
1346
1368
  divider.setAttribute("role", "separator");
1347
- divider.setAttribute("aria-label", "已压缩上下文");
1369
+ divider.setAttribute("aria-label", ariaLabel);
1370
+ if (title) divider.title = title;
1348
1371
  divider.innerHTML = "<span>已压缩上下文</span>";
1372
+ return divider;
1373
+ }
1374
+
1375
+ function appendAiContextCompactionDivider(kind, before = null) {
1376
+ const divider = createAiContextCompactionDivider({ kind });
1349
1377
  const feed = $("#ai-feed");
1350
1378
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1351
1379
  else feed.append(divider);
@@ -1524,8 +1552,10 @@ function openAiToolCallDetail(toolCall) {
1524
1552
  time.textContent = formatAiToolCallTime(calledAt);
1525
1553
  if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
1526
1554
  else time.removeAttribute("datetime");
1555
+ const resultDetails = formatAiToolCallResult(toolCall?.result);
1527
1556
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
1528
- $("#ai-tool-call-result").textContent = JSON.stringify(toolCall?.result ?? {}, null, 2);
1557
+ $("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
1558
+ $("#ai-tool-call-result").textContent = resultDetails.text;
1529
1559
  $("#ai-tool-call-dialog").showModal();
1530
1560
  }
1531
1561
 
@@ -1595,14 +1625,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1595
1625
  list.className = "ai-process-list";
1596
1626
  for (const step of steps) {
1597
1627
  if (step?.type === "context_compaction") {
1598
- const compaction = document.createElement("div");
1599
- compaction.className = "ai-process-context-compaction";
1600
- compaction.dataset.testid = "ai-process-context-compaction";
1601
- compaction.setAttribute("role", "separator");
1602
- compaction.setAttribute("aria-label", `第 ${Number(step.round) || 1} 轮已压缩上下文`);
1603
- compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
1604
- compaction.innerHTML = "<span>已压缩上下文</span>";
1605
- list.append(compaction);
1628
+ list.append(createAiContextCompactionDivider({
1629
+ kind: "tool",
1630
+ ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
1631
+ title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
1632
+ }));
1606
1633
  continue;
1607
1634
  }
1608
1635
  if (step?.type === "tool" && step.toolCall) {
@@ -1759,6 +1786,7 @@ async function openAiConversation(conversationId, hideHistory = true) {
1759
1786
  upsertAiConversationSummary(conversation);
1760
1787
  state.aiConversationId = conversation.id;
1761
1788
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1789
+ resetAiContextMeter();
1762
1790
  $("#ai-conversation-title").textContent = conversation.title;
1763
1791
  resetAiFeed();
1764
1792
  for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
@@ -1786,7 +1814,7 @@ async function createNewAiConversation() {
1786
1814
  $("#ai-conversation-title").textContent = conversation.title;
1787
1815
  resetAiFeed();
1788
1816
  hideAiContextWarning();
1789
- setAiContextMeter(null);
1817
+ resetAiContextMeter();
1790
1818
  renderAiQuickActions();
1791
1819
  setAiHistoryVisible(false);
1792
1820
  }
@@ -2296,19 +2324,29 @@ async function api(path, options = {}) {
2296
2324
  const headers = { ...(options.headers ?? {}) };
2297
2325
  if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
2298
2326
  if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
2299
- const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
2300
- ...options,
2301
- headers,
2302
- body: body && typeof body !== "string" ? JSON.stringify(body) : body
2303
- });
2327
+ let response;
2328
+ try {
2329
+ response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
2330
+ ...options,
2331
+ headers,
2332
+ body: body && typeof body !== "string" ? JSON.stringify(body) : body
2333
+ });
2334
+ } catch (error) {
2335
+ updateSystemHealth({ status: "offline" });
2336
+ throw error;
2337
+ }
2338
+ updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
2304
2339
  if (!response.ok) {
2305
2340
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
2306
2341
  // Presence is best-effort; a heartbeat 401 must not force the login wall.
2307
2342
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2308
- state.user = null;
2309
- state.csrfToken = null;
2310
- moduleRequestCache.clear();
2311
- showAuth(false);
2343
+ const restarted = await checkSystemBoot(true);
2344
+ if (!restarted) {
2345
+ state.user = null;
2346
+ state.csrfToken = null;
2347
+ moduleRequestCache.clear();
2348
+ showAuth(false);
2349
+ }
2312
2350
  }
2313
2351
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2314
2352
  }
@@ -2317,6 +2355,12 @@ async function api(path, options = {}) {
2317
2355
  return null;
2318
2356
  }
2319
2357
  const payload = await response.json();
2358
+ if (path === "/api/health") {
2359
+ updateSystemHealth({
2360
+ status: payload.data?.status === "ok" ? "ready" : "degraded",
2361
+ version: payload.data?.version
2362
+ });
2363
+ }
2320
2364
  invalidateModuleRequestsAfterMutation(path, method);
2321
2365
  return payload.data;
2322
2366
  }
@@ -2351,9 +2395,9 @@ function formatAiFailureMessage(error) {
2351
2395
  if (status) lines.push(`服务端状态:HTTP ${status}`);
2352
2396
  if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2353
2397
  if (modelId) lines.push(`模型 ID:${modelId}`);
2354
- if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2355
2398
  if (callId) lines.push(`调用 ID:${callId}`);
2356
- return lines.join("\n\n");
2399
+ if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2400
+ return lines.join("\n");
2357
2401
  }
2358
2402
 
2359
2403
  async function apiPage(path, page = 1, limit = 30) {
@@ -2431,23 +2475,97 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2431
2475
  affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
2432
2476
  }
2433
2477
 
2478
+ function applyProductHealthMetadata(health) {
2479
+ const version = String(health?.version ?? "").trim();
2480
+ document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
2481
+ element.textContent = version ? `v${version}` : "v—";
2482
+ });
2483
+ document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
2484
+ element.classList.toggle("hidden", health?.development !== true);
2485
+ });
2486
+ }
2487
+
2488
+ function scheduleSystemHealthCheck() {
2489
+ if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
2490
+ systemHealthTimer = window.setTimeout(() => {
2491
+ systemHealthTimer = null;
2492
+ void refreshSystemHealth();
2493
+ }, systemHealthPollInterval);
2494
+ }
2495
+
2496
+ async function refreshSystemHealth() {
2497
+ try {
2498
+ const health = await api("/api/health");
2499
+ applyProductHealthMetadata(health);
2500
+ } catch {
2501
+ // 系统状态已由 api 记录;健康检查失败不弹出重复提示
2502
+ } finally {
2503
+ scheduleSystemHealthCheck();
2504
+ }
2505
+ }
2506
+
2434
2507
  async function initializeProductFooters() {
2435
2508
  const year = String(new Date().getFullYear());
2436
2509
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
2510
+ applyProductHealthMetadata(null);
2511
+ await refreshSystemHealth();
2512
+ }
2513
+
2514
+ function observeSystemBootId(value) {
2515
+ const nextBootId = typeof value === "string" ? value.trim() : "";
2516
+ if (!nextBootId) return false;
2517
+ if (!systemBootId) {
2518
+ systemBootId = nextBootId;
2519
+ return false;
2520
+ }
2521
+ if (nextBootId === systemBootId) return false;
2522
+ systemRestartDetected = true;
2523
+ if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
2524
+ systemBootCheckTimer = null;
2525
+ const dialog = $("#system-restart-dialog");
2526
+ if (!dialog.open) dialog.showModal();
2527
+ window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
2528
+ return true;
2529
+ }
2530
+
2531
+ async function checkSystemBoot(forceFresh = false) {
2532
+ if (!state.user || systemRestartDetected) return systemRestartDetected;
2533
+ if (systemBootCheckPromise) {
2534
+ if (!forceFresh) return systemBootCheckPromise;
2535
+ await systemBootCheckPromise;
2536
+ if (systemRestartDetected) return true;
2537
+ }
2538
+ systemBootCheckPromise = (async () => {
2539
+ try {
2540
+ const response = await fetch("/api/health", {
2541
+ cache: "no-store",
2542
+ headers: { Accept: "application/json" }
2543
+ });
2544
+ if (!response.ok) return false;
2545
+ const payload = await response.json();
2546
+ return observeSystemBootId(payload?.data?.bootId);
2547
+ } catch {
2548
+ return false;
2549
+ }
2550
+ })();
2437
2551
  try {
2438
- const health = await api("/api/health");
2439
- const version = String(health.version ?? "").trim();
2440
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
2441
- element.textContent = version ? `v${version}` : "v—";
2442
- });
2443
- document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
2444
- element.classList.toggle("hidden", health.development !== true);
2445
- });
2446
- } catch {
2447
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
2552
+ return await systemBootCheckPromise;
2553
+ } finally {
2554
+ systemBootCheckPromise = null;
2448
2555
  }
2449
2556
  }
2450
2557
 
2558
+ function scheduleSystemBootCheck(delay = systemBootCheckInterval) {
2559
+ if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
2560
+ systemBootCheckTimer = null;
2561
+ if (!state.user || systemRestartDetected) return;
2562
+ systemBootCheckTimer = setTimeout(async () => {
2563
+ systemBootCheckTimer = null;
2564
+ await checkSystemBoot();
2565
+ scheduleSystemBootCheck();
2566
+ }, delay);
2567
+ }
2568
+
2451
2569
  function selectAuthMode(mode) {
2452
2570
  const registerTab = $("#auth-register-tab");
2453
2571
  const login = mode === "login" || registerTab.disabled;
@@ -2589,7 +2707,9 @@ async function initializeAuthentication() {
2589
2707
  }
2590
2708
  // 已登录却停在登录页路由时,回到书架首页
2591
2709
  if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
2710
+ observeSystemBootId(session.bootId);
2592
2711
  applyAuthenticatedUser(session);
2712
+ scheduleSystemBootCheck();
2593
2713
  await loadPlatformUiSettings();
2594
2714
  return true;
2595
2715
  }
@@ -2745,10 +2865,53 @@ document.addEventListener("toggle", (event) => {
2745
2865
  }
2746
2866
  }, true);
2747
2867
 
2868
+ function paintTopbarStatus(text, { source, tone, title }) {
2869
+ const element = $("#save-state");
2870
+ const colors = { ok: "var(--green)", pending: "var(--muted)", error: "var(--accent)" };
2871
+ topbarStatusSource = source;
2872
+ element.textContent = text;
2873
+ element.style.color = colors[tone] ?? colors.ok;
2874
+ element.dataset.statusSource = source;
2875
+ element.setAttribute("aria-label", `${source === "system" ? "系统" : "工作台"}状态:${text}`);
2876
+ element.title = title || text;
2877
+ }
2878
+
2879
+ function setTopbarViewState(text) {
2880
+ state.dirty = false;
2881
+ paintTopbarStatus(text, { source: "view", tone: "ok", title: text });
2882
+ }
2883
+
2884
+ function updateSystemHealth(next) {
2885
+ systemHealthSnapshot = {
2886
+ ...systemHealthSnapshot,
2887
+ ...next,
2888
+ version: next.version === undefined ? systemHealthSnapshot.version : String(next.version ?? "")
2889
+ };
2890
+ if (topbarStatusSource === "system") renderSystemHealth();
2891
+ }
2892
+
2893
+ function renderSystemHealth() {
2894
+ const presentation = systemStatusPresentation(systemHealthSnapshot);
2895
+ paintTopbarStatus(presentation.label, {
2896
+ source: "system",
2897
+ tone: presentation.tone,
2898
+ title: presentation.title
2899
+ });
2900
+ }
2901
+
2902
+ function showSystemStatus() {
2903
+ state.dirty = false;
2904
+ topbarStatusSource = "system";
2905
+ renderSystemHealth();
2906
+ }
2907
+
2748
2908
  function setSaveState(text, dirty = false) {
2749
2909
  state.dirty = dirty;
2750
- $("#save-state").textContent = text;
2751
- $("#save-state").style.color = dirty ? "var(--accent)" : "var(--green)";
2910
+ paintTopbarStatus(text, {
2911
+ source: "save",
2912
+ tone: dirty ? "error" : "ok",
2913
+ title: text
2914
+ });
2752
2915
  }
2753
2916
 
2754
2917
  function chapterDraftSnapshot() {
@@ -2990,6 +3153,11 @@ async function initializePage() {
2990
3153
  if (route.view === "platform-usage") {
2991
3154
  await showPlatformUsage();
2992
3155
  settingsReturnContext = restoredSettingsReturnContext(route);
3156
+ return;
3157
+ }
3158
+ if (route.view === "work-audit") {
3159
+ await showWorkAudit();
3160
+ settingsReturnContext = restoredSettingsReturnContext(route);
2993
3161
  }
2994
3162
  } finally {
2995
3163
  document.body.classList.remove("auth-pending");
@@ -3009,6 +3177,7 @@ function showShelf() {
3009
3177
  $("#shelf-view").classList.remove("hidden");
3010
3178
  $("#platform-ai-view").classList.add("hidden");
3011
3179
  $("#platform-usage-view").classList.add("hidden");
3180
+ $("#work-audit-view").classList.add("hidden");
3012
3181
  $("#settings-hub-view").classList.add("hidden");
3013
3182
  $("#welcome-view").classList.add("hidden");
3014
3183
  $("#editor-view").classList.add("hidden");
@@ -3016,7 +3185,7 @@ function showShelf() {
3016
3185
  $("#work-meta").textContent = `${state.works.length} 部作品`;
3017
3186
  $("#settings-button").removeAttribute("aria-current");
3018
3187
  $("#top-search-button").disabled = true;
3019
- setSaveState("书架");
3188
+ setTopbarViewState("书架");
3020
3189
  renderShelf();
3021
3190
  replacePageRoute({ view: "shelf" });
3022
3191
  }
@@ -3033,7 +3202,7 @@ function renderSettingsHub() {
3033
3202
  const hasWork = Boolean(state.work);
3034
3203
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
3035
3204
  const canReadAggregate = hasWork && canReadAggregateContent();
3036
- const canReadFullExport = canReadAggregate && canReadModule("drafts");
3205
+ const canExportManuscript = hasWork && canReadModule("editor");
3037
3206
  const isAdmin = state.user?.role === "admin";
3038
3207
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
3039
3208
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -3043,10 +3212,11 @@ function renderSettingsHub() {
3043
3212
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
3044
3213
  $("#work-audit-button").disabled = !canManageWork;
3045
3214
  $("#top-search-button").disabled = !canReadAggregate;
3046
- $("#export-button").disabled = !canReadFullExport;
3215
+ $("#export-button").disabled = !canExportManuscript;
3216
+ $("#export-button").setAttribute("aria-expanded", "false");
3047
3217
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
3048
3218
  $("#settings-work-note").textContent = hasWork
3049
- ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
3219
+ ? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP DOCX;DOCX 在有封面时会嵌入为首页。`
3050
3220
  : "当前未选择作品;打开作品后可使用导出。";
3051
3221
  }
3052
3222
 
@@ -3115,36 +3285,173 @@ async function openWritingProgressDialog() {
3115
3285
  const workAuditActionLabels = {
3116
3286
  "work.created": "创建作品",
3117
3287
  "work.updated": "更新作品",
3288
+ "work.cover.updated": "更新作品封面",
3289
+ "work.cover.deleted": "删除作品封面",
3290
+ "work.member-added": "添加作品成员",
3291
+ "work.member-role-updated": "更新成员权限",
3292
+ "work.member-removed": "移除作品成员",
3293
+ "work.writing_goal.updated": "更新写作目标",
3118
3294
  "volume.created": "创建分卷",
3119
3295
  "volume.updated": "更新分卷",
3120
3296
  "volume.deleted": "删除分卷",
3297
+ "volume.restored": "恢复分卷",
3121
3298
  "chapter.created": "创建章节",
3122
3299
  "chapter.saved": "保存章节",
3123
3300
  "chapter.moved": "移动章节",
3124
3301
  "chapter.deleted": "删除章节",
3125
3302
  "chapter.purged": "彻底删除章节",
3126
3303
  "chapter.restored": "恢复章节",
3304
+ "chapter.annotation.created": "添加正文评论",
3305
+ "chapter.annotation.updated": "更新正文评论",
3306
+ "chapter.annotation.deleted": "删除正文评论",
3127
3307
  "draft.created": "创建想法",
3128
3308
  "draft.updated": "更新想法",
3129
3309
  "draft.deleted": "删除想法",
3130
3310
  "draft.restored": "恢复想法",
3311
+ "setting.created": "创建设定",
3312
+ "setting.updated": "更新设定",
3313
+ "setting.deleted": "删除设定",
3314
+ "setting.restored": "恢复设定",
3315
+ "character.created": "创建角色",
3316
+ "character.updated": "更新角色",
3317
+ "character.deleted": "删除角色",
3318
+ "character.restored": "恢复角色",
3319
+ "race.created": "创建种族",
3320
+ "race.updated": "更新种族",
3321
+ "race.deleted": "删除种族",
3322
+ "race.restored": "恢复种族",
3323
+ "organization.created": "创建组织",
3324
+ "organization.updated": "更新组织",
3325
+ "organization.deleted": "删除组织",
3326
+ "organization.restored": "恢复组织",
3327
+ "timeline-track.created": "创建时间轴",
3328
+ "timeline-track.updated": "更新时间轴",
3329
+ "timeline-track.deleted": "删除时间轴",
3330
+ "timeline-track.restored": "恢复时间轴",
3331
+ "timeline.created": "创建时间事件",
3332
+ "timeline.updated": "更新时间事件",
3333
+ "timeline.deleted": "删除时间事件",
3334
+ "timeline.restored": "恢复时间事件",
3335
+ "relationship.created": "创建人物关系",
3336
+ "relationship.updated": "更新人物关系",
3337
+ "relationship.deleted": "删除人物关系",
3338
+ "relationship.restored": "恢复人物关系",
3339
+ "outline.created": "创建章节大纲",
3340
+ "outline.updated": "更新章节大纲",
3341
+ "outline.deleted": "删除章节大纲",
3342
+ "foreshadow.created": "创建伏笔",
3343
+ "foreshadow.updated": "更新伏笔",
3344
+ "foreshadow.deleted": "删除伏笔",
3345
+ "foreshadow.restored": "恢复伏笔",
3346
+ "task.created": "创建 AI 分析任务",
3347
+ "task.cancelled": "取消 AI 分析任务",
3348
+ "attachment.created": "创建附件",
3349
+ "attachment.deleted": "删除附件",
3350
+ "attachment.garbage-collected": "清理未引用附件",
3351
+ "file.restored": "恢复导入快照",
3131
3352
  "work.imported": "导入正文"
3132
3353
  };
3133
3354
 
3134
3355
  function workAuditEntityLabel(type) {
3135
- return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
3356
+ return ({
3357
+ work: "作品",
3358
+ volume: "分卷",
3359
+ chapter: "章节",
3360
+ draft: "想法",
3361
+ user: "用户",
3362
+ setting: "设定",
3363
+ character: "角色",
3364
+ race: "种族",
3365
+ organization: "组织",
3366
+ "timeline-track": "时间轴",
3367
+ "timeline-event": "时间事件",
3368
+ relationship: "人物关系",
3369
+ "chapter-outline": "章节大纲",
3370
+ foreshadow: "伏笔",
3371
+ "chapter-annotation": "正文评论",
3372
+ attachment: "附件",
3373
+ "file-version": "导入快照",
3374
+ "analysis-task": "AI 分析任务",
3375
+ review: "审核"
3376
+ })[type] ?? type;
3377
+ }
3378
+
3379
+ const workAuditDetailLabels = {
3380
+ fields: "变更字段",
3381
+ versionNo: "版本号",
3382
+ fromVersion: "来源版本",
3383
+ source: "操作来源",
3384
+ sourceRef: "来源引用",
3385
+ changeNote: "变更说明",
3386
+ name: "名称",
3387
+ description: "说明",
3388
+ chapterType: "章节类型",
3389
+ volumeId: "所属分卷",
3390
+ previousVolumeId: "原分卷",
3391
+ sortOrder: "排序位置",
3392
+ timeLabel: "时间标签",
3393
+ location: "地点",
3394
+ eventType: "事件类型",
3395
+ batch: "批量操作",
3396
+ recoverable: "可恢复",
3397
+ excludedFromAnalysis: "排除 AI 分析",
3398
+ startLine: "起始行",
3399
+ endLine: "结束行",
3400
+ characterId: "角色 ID",
3401
+ restorePointId: "恢复点 ID",
3402
+ storageKey: "存储位置",
3403
+ byteLength: "文件字节数",
3404
+ mimeType: "文件类型",
3405
+ role: "成员角色",
3406
+ status: "状态",
3407
+ previousStatus: "原状态",
3408
+ dailyGoal: "每日目标",
3409
+ targetTotal: "总字数目标",
3410
+ deadline: "计划完成日期",
3411
+ title: "标题",
3412
+ reason: "原因"
3413
+ };
3414
+
3415
+ function workAuditDetailValue(value) {
3416
+ if (typeof value === "boolean") return value ? "是" : "否";
3417
+ if (Array.isArray(value)) return value.map((item) => typeof item === "object" ? JSON.stringify(item) : String(item)).join("、");
3418
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
3419
+ return String(value);
3136
3420
  }
3137
3421
 
3138
- function workAuditDetailText(detail) {
3139
- const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
3140
- return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
3422
+ function workAuditDetailEntries(detail) {
3423
+ if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
3424
+ return Object.entries(detail)
3425
+ .filter(([, value]) => value !== null && value !== undefined && value !== "")
3426
+ .map(([key, value]) => ({ label: workAuditDetailLabels[key] ?? key, value: workAuditDetailValue(value) }));
3427
+ }
3428
+
3429
+ function workAuditTimestamp(createdAt) {
3430
+ const formatted = formatDateTime(createdAt);
3431
+ const parts = formatted.split(/\s+/u);
3432
+ return { date: parts[0] || "—", time: parts.slice(1).join(" ") || "—" };
3141
3433
  }
3142
3434
 
3143
3435
  function renderWorkAuditRecords() {
3144
- $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
3145
- <time>${esc(formatDateTime(record.createdAt))}</time>
3146
- <div><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><span>${esc(record.actor)} · ${esc(workAuditEntityLabel(record.entityType))}${record.entityId ? ` · ${esc(record.entityId)}` : ""}</span>${workAuditDetailText(record.detail) ? `<small>${esc(workAuditDetailText(record.detail))}</small>` : ""}</div>
3147
- </article>`).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
3436
+ $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => {
3437
+ const timestamp = workAuditTimestamp(record.createdAt);
3438
+ const details = workAuditDetailEntries(record.detail);
3439
+ return `<article class="work-audit-row">
3440
+ <header class="work-audit-time"><time datetime="${esc(record.createdAt)}"><span>${esc(timestamp.date)}</span><strong>${esc(timestamp.time)}</strong></time></header>
3441
+ <div class="work-audit-event">
3442
+ <div class="work-audit-event-heading"><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><code>${esc(record.action)}</code></div>
3443
+ <dl class="work-audit-meta">
3444
+ <div><dt>操作者</dt><dd>${esc(record.actor || "system")}</dd></div>
3445
+ <div><dt>对象类型</dt><dd>${esc(workAuditEntityLabel(record.entityType))}</dd></div>
3446
+ <div><dt>对象 ID</dt><dd><code>${record.entityId ? esc(record.entityId) : "—"}</code></dd></div>
3447
+ </dl>
3448
+ ${details.length ? `<dl class="work-audit-details">${details.map((detail) => `<div><dt>${esc(detail.label)}</dt><dd><code>${esc(detail.value)}</code></dd></div>`).join("")}</dl>` : ""}
3449
+ </div>
3450
+ </article>`;
3451
+ }).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
3452
+ $("#work-audit-summary").textContent = workAuditRecords.length
3453
+ ? `已显示 ${workAuditRecords.length} 条记录${workAuditNextPage === null ? ",已加载全部记录" : ",还有更多记录可继续加载"}`
3454
+ : "当前作品还没有操作记录。";
3148
3455
  $("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
3149
3456
  }
3150
3457
 
@@ -3156,18 +3463,36 @@ async function loadWorkAuditPage(page = 1, append = false) {
3156
3463
  renderWorkAuditRecords();
3157
3464
  }
3158
3465
 
3159
- async function openWorkAuditDialog() {
3160
- if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
3466
+ async function showWorkAudit() {
3467
+ if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return false;
3161
3468
  workAuditRecords = [];
3162
3469
  workAuditNextPage = null;
3470
+ dismissChapterInsightToast();
3471
+ updateDocumentTitle(state.work);
3472
+ $("#app").classList.add("shelf-mode");
3473
+ $("#shelf-view").classList.add("hidden");
3474
+ $("#platform-ai-view").classList.add("hidden");
3475
+ $("#platform-usage-view").classList.add("hidden");
3476
+ $("#settings-hub-view").classList.add("hidden");
3477
+ $("#work-audit-view").classList.remove("hidden");
3478
+ $("#welcome-view").classList.add("hidden");
3479
+ $("#editor-view").classList.add("hidden");
3480
+ $("#module-view").classList.add("hidden");
3481
+ $("#work-audit-eyebrow").textContent = `作品安全 · 《${state.work.title}》`;
3482
+ $("#work-meta").textContent = "操作记录";
3483
+ $("#settings-button").setAttribute("aria-current", "page");
3484
+ setTopbarViewState("操作记录");
3485
+ $("#work-audit-summary").textContent = "正在读取操作记录……";
3163
3486
  $("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
3164
- $("#work-audit-dialog").showModal();
3487
+ replacePageRoute({ view: "work-audit", workId: state.work.id, ...settingsRouteContext() });
3165
3488
  try {
3166
3489
  await loadWorkAuditPage();
3167
3490
  } catch (error) {
3168
- $("#work-audit-dialog").close();
3491
+ $("#work-audit-summary").textContent = "操作记录加载失败。";
3492
+ $("#work-audit-list").innerHTML = '<p class="entity-history-empty">暂时无法读取操作记录,请稍后刷新。</p>';
3169
3493
  toast(error.message, "error");
3170
3494
  }
3495
+ return true;
3171
3496
  }
3172
3497
 
3173
3498
  async function saveWritingGoal(event) {
@@ -3468,7 +3793,8 @@ async function openSearchResult(result) {
3468
3793
  $("#search-dialog").close();
3469
3794
  const inSettings = !$("#settings-hub-view").classList.contains("hidden")
3470
3795
  || !$("#platform-ai-view").classList.contains("hidden")
3471
- || !$("#platform-usage-view").classList.contains("hidden");
3796
+ || !$("#platform-usage-view").classList.contains("hidden")
3797
+ || !$("#work-audit-view").classList.contains("hidden");
3472
3798
  if (inSettings) await returnFromSettings();
3473
3799
  if (target.kind === "chapter") {
3474
3800
  await selectChapter(target.id);
@@ -3494,7 +3820,8 @@ async function openSearchResult(result) {
3494
3820
  async function showSettingsHub() {
3495
3821
  const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
3496
3822
  || !$("#platform-ai-view").classList.contains("hidden")
3497
- || !$("#platform-usage-view").classList.contains("hidden");
3823
+ || !$("#platform-usage-view").classList.contains("hidden")
3824
+ || !$("#work-audit-view").classList.contains("hidden");
3498
3825
  if (!alreadyInSettings) {
3499
3826
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
3500
3827
  settingsReturnContext = captureSettingsReturnContext();
@@ -3506,13 +3833,14 @@ async function showSettingsHub() {
3506
3833
  $("#shelf-view").classList.add("hidden");
3507
3834
  $("#platform-ai-view").classList.add("hidden");
3508
3835
  $("#platform-usage-view").classList.add("hidden");
3836
+ $("#work-audit-view").classList.add("hidden");
3509
3837
  $("#settings-hub-view").classList.remove("hidden");
3510
3838
  $("#welcome-view").classList.add("hidden");
3511
3839
  $("#editor-view").classList.add("hidden");
3512
3840
  $("#module-view").classList.add("hidden");
3513
3841
  $("#work-meta").textContent = "设置中心";
3514
3842
  $("#settings-button").setAttribute("aria-current", "page");
3515
- setSaveState("设置");
3843
+ setTopbarViewState("设置");
3516
3844
  renderSettingsHub();
3517
3845
  replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
3518
3846
  return true;
@@ -3532,6 +3860,7 @@ async function returnFromSettings() {
3532
3860
  $("#settings-hub-view").classList.add("hidden");
3533
3861
  $("#platform-ai-view").classList.add("hidden");
3534
3862
  $("#platform-usage-view").classList.add("hidden");
3863
+ $("#work-audit-view").classList.add("hidden");
3535
3864
  if (context.view === "shelf" || !state.work) return showShelf();
3536
3865
  $("#app").classList.remove("shelf-mode");
3537
3866
  $("#shelf-view").classList.add("hidden");
@@ -3551,13 +3880,14 @@ async function showPlatformAi() {
3551
3880
  $("#shelf-view").classList.add("hidden");
3552
3881
  $("#platform-ai-view").classList.remove("hidden");
3553
3882
  $("#platform-usage-view").classList.add("hidden");
3883
+ $("#work-audit-view").classList.add("hidden");
3554
3884
  $("#settings-hub-view").classList.add("hidden");
3555
3885
  $("#welcome-view").classList.add("hidden");
3556
3886
  $("#editor-view").classList.add("hidden");
3557
3887
  $("#module-view").classList.add("hidden");
3558
3888
  $("#work-meta").textContent = "平台 AI 管理";
3559
3889
  $("#settings-button").setAttribute("aria-current", "page");
3560
- setSaveState("平台 AI");
3890
+ setTopbarViewState("平台 AI");
3561
3891
  await renderPlatformAiConfig();
3562
3892
  replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
3563
3893
  return true;
@@ -3571,13 +3901,14 @@ async function showPlatformUsage() {
3571
3901
  $("#shelf-view").classList.add("hidden");
3572
3902
  $("#platform-ai-view").classList.add("hidden");
3573
3903
  $("#platform-usage-view").classList.remove("hidden");
3904
+ $("#work-audit-view").classList.add("hidden");
3574
3905
  $("#settings-hub-view").classList.add("hidden");
3575
3906
  $("#welcome-view").classList.add("hidden");
3576
3907
  $("#editor-view").classList.add("hidden");
3577
3908
  $("#module-view").classList.add("hidden");
3578
3909
  $("#work-meta").textContent = "Token 用量";
3579
3910
  $("#settings-button").setAttribute("aria-current", "page");
3580
- setSaveState("Token 用量");
3911
+ setTopbarViewState("Token 用量");
3581
3912
  await renderPlatformTokenUsage();
3582
3913
  replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
3583
3914
  return true;
@@ -3651,7 +3982,7 @@ function resetWorkScopedUiCaches() {
3651
3982
  resetAiFeed();
3652
3983
  $("#ai-conversation-title").textContent = "新对话";
3653
3984
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
3654
- setAiContextMeter(null);
3985
+ resetAiContextMeter();
3655
3986
  renderAiConversationHistory();
3656
3987
  }
3657
3988
 
@@ -3666,11 +3997,12 @@ async function selectWork(workId, preferredChapterId = null) {
3666
3997
  }
3667
3998
  const nextWork = await api(`/api/works/${workId}?directory=volumes`);
3668
3999
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3669
- if (discarding) setSaveState("就绪");
4000
+ showSystemStatus();
3670
4001
  $("#app").classList.remove("shelf-mode");
3671
4002
  $("#shelf-view").classList.add("hidden");
3672
4003
  $("#platform-ai-view").classList.add("hidden");
3673
4004
  $("#platform-usage-view").classList.add("hidden");
4005
+ $("#work-audit-view").classList.add("hidden");
3674
4006
  $("#settings-hub-view").classList.add("hidden");
3675
4007
  $("#settings-button").removeAttribute("aria-current");
3676
4008
  settingsReturnContext = null;
@@ -4151,6 +4483,7 @@ function showWelcome(hasWork = false) {
4151
4483
  $("#editor-view").classList.add("hidden");
4152
4484
  $("#module-view").classList.add("hidden");
4153
4485
  $("#welcome-view").classList.remove("hidden");
4486
+ if (hasWork) showSystemStatus();
4154
4487
  $("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
4155
4488
  $("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
4156
4489
  replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
@@ -4204,6 +4537,7 @@ async function showModule(module) {
4204
4537
  }
4205
4538
  return;
4206
4539
  }
4540
+ showSystemStatus();
4207
4541
  dismissChapterInsightToast();
4208
4542
  $("#welcome-view").classList.add("hidden");
4209
4543
  $("#editor-view").classList.add("hidden");
@@ -4698,8 +5032,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4698
5032
  <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
4699
5033
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
4700
5034
  </section>` : "";
4701
- const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4702
- + field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
5035
+ const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
4703
5036
  + `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
4704
5037
  + `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
4705
5038
  + field("title", "标题", "text", item?.title ?? "")
@@ -4731,7 +5064,8 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4731
5064
  submitLabel: viewOnly ? "关闭" : "保存想法",
4732
5065
  hideCancel: viewOnly,
4733
5066
  editor: true,
4734
- errorPrefix: "想法保存失败:"
5067
+ errorPrefix: "想法保存失败:",
5068
+ meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
4735
5069
  });
4736
5070
  const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
4737
5071
  const syncDraftBindingFields = () => {
@@ -6560,21 +6894,72 @@ function tokenUsageCalendarMarkup(daily) {
6560
6894
  const calendar = buildUsageCalendar(daily);
6561
6895
  const cells = calendar.cells.map((cell) => {
6562
6896
  const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
6563
- return `<span class="usage-calendar-cell${cell.future ? " is-future" : ""}" data-level="${cell.level}" role="gridcell" aria-label="${esc(label)}" title="${esc(label)}" ${cell.future ? 'aria-disabled="true"' : 'tabindex="0"'}></span>`;
6897
+ return cell.future
6898
+ ? `<span class="usage-calendar-cell is-future" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
6899
+ : `<button class="usage-calendar-cell" type="button" data-level="${cell.level}" data-usage-calendar-label="${esc(label)}" role="gridcell" aria-label="${esc(label)}"></button>`;
6564
6900
  }).join("");
6565
6901
  const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
6566
- return `<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
6567
- <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
6568
- <div class="usage-calendar-months" aria-hidden="true">${months}</div>
6569
- <div class="usage-calendar-body">
6570
- <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
6571
- <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
6902
+ return `<div class="usage-calendar-widget">
6903
+ <div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
6904
+ <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
6905
+ <div class="usage-calendar-months" aria-hidden="true">${months}</div>
6906
+ <div class="usage-calendar-body">
6907
+ <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
6908
+ <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
6909
+ </div>
6572
6910
  </div>
6573
6911
  </div>
6912
+ <output class="usage-calendar-tooltip" role="tooltip" hidden></output>
6574
6913
  </div>
6575
6914
  <div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
6576
6915
  }
6577
6916
 
6917
+ function bindUsageCalendarInteractions(root) {
6918
+ root.querySelectorAll(".usage-calendar-widget").forEach((widget) => {
6919
+ const tooltip = widget.querySelector(".usage-calendar-tooltip");
6920
+ const calendarScroll = widget.querySelector(".usage-calendar-scroll");
6921
+ let activeCell = null;
6922
+ const hideTooltip = () => {
6923
+ tooltip.hidden = true;
6924
+ activeCell = null;
6925
+ };
6926
+ const showTooltip = (cell) => {
6927
+ activeCell = cell;
6928
+ tooltip.textContent = cell.dataset.usageCalendarLabel;
6929
+ tooltip.hidden = false;
6930
+ const widgetRect = widget.getBoundingClientRect();
6931
+ const cellRect = cell.getBoundingClientRect();
6932
+ const edgeInset = tooltip.offsetWidth / 2 + 8;
6933
+ const centeredLeft = cellRect.left + cellRect.width / 2 - widgetRect.left;
6934
+ const fitsAbove = cellRect.top - widgetRect.top >= tooltip.offsetHeight + 8;
6935
+ tooltip.dataset.placement = fitsAbove ? "top" : "bottom";
6936
+ tooltip.style.left = `${Math.min(widget.clientWidth - edgeInset, Math.max(edgeInset, centeredLeft))}px`;
6937
+ tooltip.style.top = fitsAbove
6938
+ ? `${cellRect.top - widgetRect.top - 8}px`
6939
+ : `${cellRect.bottom - widgetRect.top + 8}px`;
6940
+ };
6941
+ widget.querySelectorAll("button.usage-calendar-cell").forEach((cell) => {
6942
+ cell.addEventListener("mouseenter", () => showTooltip(cell));
6943
+ cell.addEventListener("mouseleave", () => {
6944
+ if (document.activeElement !== cell) hideTooltip();
6945
+ });
6946
+ cell.addEventListener("focus", () => showTooltip(cell));
6947
+ cell.addEventListener("blur", () => {
6948
+ if (!cell.matches(":hover")) hideTooltip();
6949
+ });
6950
+ cell.addEventListener("click", () => showTooltip(cell));
6951
+ cell.addEventListener("keydown", (event) => {
6952
+ if (event.key !== "Escape") return;
6953
+ hideTooltip();
6954
+ cell.blur();
6955
+ });
6956
+ });
6957
+ calendarScroll.addEventListener("scroll", () => {
6958
+ if (activeCell && !tooltip.hidden) showTooltip(activeCell);
6959
+ });
6960
+ });
6961
+ }
6962
+
6578
6963
  function scrollUsageCalendarsToLatest(root) {
6579
6964
  window.requestAnimationFrame(() => {
6580
6965
  root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
@@ -6633,6 +7018,7 @@ async function renderPlatformTokenUsage() {
6633
7018
  description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
6634
7019
  showWorks: true
6635
7020
  });
7021
+ bindUsageCalendarInteractions(host);
6636
7022
  scrollUsageCalendarsToLatest(host);
6637
7023
  }
6638
7024
 
@@ -6652,10 +7038,20 @@ async function renderBookAiSettings() {
6652
7038
  const host = $("#module-content");
6653
7039
  const workId = String(state.work.id);
6654
7040
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
7041
+ const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
7042
+ const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
7043
+ const quotaRemainingTokens = usage?.quota?.remainingTokens === null
7044
+ ? null
7045
+ : Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
7046
+ const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
7047
+ const quotaStatusText = dailyTokenQuota === null
7048
+ ? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
7049
+ : `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
6655
7050
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6656
7051
  title: "本书 Token 用量",
6657
7052
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6658
- })}</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>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
7053
+ })}</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)}`;
7054
+ bindUsageCalendarInteractions(host);
6659
7055
  scrollUsageCalendarsToLatest(host);
6660
7056
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6661
7057
  "beforebegin",
@@ -6671,6 +7067,7 @@ async function renderBookAiSettings() {
6671
7067
  );
6672
7068
  if (!canEditModule("ai-settings")) {
6673
7069
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
7070
+ host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button").forEach((button) => { button.disabled = true; });
6674
7071
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
6675
7072
  }
6676
7073
  const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
@@ -6700,6 +7097,31 @@ async function renderBookAiSettings() {
6700
7097
  return status;
6701
7098
  };
6702
7099
  updateRelationshipIndexStatus(relationshipIndex);
7100
+ $("#daily-token-quota-enabled").addEventListener("change", (event) => {
7101
+ $("#daily-token-quota").disabled = !event.currentTarget.checked;
7102
+ });
7103
+ $("#save-daily-token-quota").addEventListener("click", async () => {
7104
+ const button = $("#save-daily-token-quota");
7105
+ const enabled = $("#daily-token-quota-enabled").checked;
7106
+ const quota = Number($("#daily-token-quota").value);
7107
+ if (enabled && (!Number.isInteger(quota) || quota < 10_000 || quota > 2_000_000_000)) {
7108
+ toast("每日 Token 额度必须是 10,000 到 2,000,000,000 之间的整数", "error");
7109
+ $("#daily-token-quota").focus();
7110
+ return;
7111
+ }
7112
+ button.disabled = true;
7113
+ try {
7114
+ await api(`/api/works/${state.work.id}/ai-settings`, {
7115
+ method: "PATCH",
7116
+ body: { dailyTokenQuota: enabled ? quota : null }
7117
+ });
7118
+ toast(enabled ? "本书每日 Token 额度已保存" : "本书每日 Token 额度限制已关闭");
7119
+ await renderBookAiSettings();
7120
+ } catch (error) {
7121
+ toast(error.message, "error");
7122
+ button.disabled = false;
7123
+ }
7124
+ });
6703
7125
  $("#save-work-system-prompt").addEventListener("click", async () => {
6704
7126
  const button = $("#save-work-system-prompt");
6705
7127
  button.disabled = true;
@@ -6779,6 +7201,34 @@ async function renderBookAiSettings() {
6779
7201
  button.disabled = false;
6780
7202
  }
6781
7203
  });
7204
+ $("#save-agent-tool-call-limit").addEventListener("click", async () => {
7205
+ const button = $("#save-agent-tool-call-limit");
7206
+ button.disabled = true;
7207
+ try {
7208
+ await api(`/api/works/${state.work.id}/ai-settings`, {
7209
+ method: "PATCH",
7210
+ body: {
7211
+ agentToolCallLimit: Number($("#agent-tool-call-limit").value),
7212
+ agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
7213
+ }
7214
+ });
7215
+ toast("Agent 工具调用上限已保存");
7216
+ } catch (error) {
7217
+ toast(error.message, "error");
7218
+ } finally {
7219
+ button.disabled = false;
7220
+ }
7221
+ });
7222
+ host.querySelector(".agent-tool-call-global-multiplier-toggle")?.addEventListener("click", (event) => {
7223
+ const option = event.target.closest("button[data-global-multiplier]");
7224
+ if (!option || option.disabled) return;
7225
+ const value = option.getAttribute("data-global-multiplier");
7226
+ const hidden = $("#agent-tool-call-global-multiplier");
7227
+ if (hidden) hidden.value = value;
7228
+ host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button[data-global-multiplier]").forEach((item) => {
7229
+ item.setAttribute("aria-pressed", String(item === option));
7230
+ });
7231
+ });
6782
7232
  $("#save-agent-tools").addEventListener("click", async () => {
6783
7233
  const button = $("#save-agent-tools");
6784
7234
  button.disabled = true;
@@ -6917,10 +7367,12 @@ function renderAiContextDistribution(usage) {
6917
7367
  }
6918
7368
 
6919
7369
  function setAiContextMeter(usage) {
7370
+ const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
7371
+ latestAiContextUsage = displayUsage;
6920
7372
  const meter = $("#ai-context-meter");
6921
7373
  const value = meter.querySelector("b");
6922
- renderAiContextDistribution(usage);
6923
- if (!usage) {
7374
+ renderAiContextDistribution(displayUsage);
7375
+ if (!displayUsage) {
6924
7376
  meter.classList.add("is-empty");
6925
7377
  meter.classList.remove("is-warning", "is-danger");
6926
7378
  meter.style.setProperty("--context-usage", "0");
@@ -6930,17 +7382,22 @@ function setAiContextMeter(usage) {
6930
7382
  meter.setAttribute("aria-label", tooltip);
6931
7383
  return;
6932
7384
  }
6933
- const percent = Math.max(0, Math.min(100, Number(usage.usagePercent) || 0));
7385
+ const percent = Math.max(0, Math.min(100, Number(displayUsage.usagePercent) || 0));
6934
7386
  meter.classList.remove("is-empty");
6935
7387
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
6936
7388
  meter.classList.toggle("is-danger", percent >= 90);
6937
7389
  meter.style.setProperty("--context-usage", String(percent));
6938
7390
  value.textContent = `${percent}%`;
6939
- const tooltip = formatAiContextUsageTooltip(usage);
7391
+ const tooltip = formatAiContextUsageTooltip(displayUsage);
6940
7392
  meter.dataset.tooltip = tooltip;
6941
7393
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6942
7394
  }
6943
7395
 
7396
+ function resetAiContextMeter() {
7397
+ latestAiContextUsage = null;
7398
+ setAiContextMeter(null);
7399
+ }
7400
+
6944
7401
  function setAiContextDistributionVisible(visible) {
6945
7402
  const meter = $("#ai-context-meter");
6946
7403
  const popover = $("#ai-context-popover");
@@ -7300,9 +7757,39 @@ function bindWorkCoverControls(work) {
7300
7757
  });
7301
7758
  }
7302
7759
 
7303
- function downloadWorkManuscript(work) {
7760
+ function downloadWorkManuscript(work, format = "markdown") {
7304
7761
  if (!work?.id) return;
7305
- window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=markdown`;
7762
+ const exportFormat = format === "docx" ? "docx" : "markdown";
7763
+ window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=${exportFormat}`;
7764
+ }
7765
+
7766
+ let manuscriptExportWork = null;
7767
+
7768
+ function closeManuscriptExportMenu() {
7769
+ const menu = $("#manuscript-export-menu");
7770
+ if (!menu) return;
7771
+ menu.classList.add("hidden");
7772
+ manuscriptExportWork = null;
7773
+ $("#export-button")?.setAttribute("aria-expanded", "false");
7774
+ $("#work-export-button")?.setAttribute("aria-expanded", "false");
7775
+ }
7776
+
7777
+ function showManuscriptExportMenu(anchor, work) {
7778
+ if (!work?.id || !anchor) return;
7779
+ const menu = $("#manuscript-export-menu");
7780
+ if (!menu) return;
7781
+ manuscriptExportWork = work;
7782
+ menu.classList.remove("hidden");
7783
+ const anchorRect = anchor.getBoundingClientRect();
7784
+ const menuRect = menu.getBoundingClientRect();
7785
+ const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - menuRect.width - 8));
7786
+ const top = Math.max(8, Math.min(anchorRect.bottom + 6, window.innerHeight - menuRect.height - 8));
7787
+ menu.style.left = `${left}px`;
7788
+ menu.style.top = `${top}px`;
7789
+ if (anchor.id === "export-button" || anchor.id === "work-export-button") {
7790
+ anchor.setAttribute("aria-expanded", "true");
7791
+ }
7792
+ menu.querySelector("button[data-export-format]")?.focus();
7306
7793
  }
7307
7794
 
7308
7795
  function openWorkSettingsDialog(work) {
@@ -7322,8 +7809,8 @@ function openWorkSettingsDialog(work) {
7322
7809
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
7323
7810
  </section>`;
7324
7811
  const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
7325
- <div><strong id="work-export-settings-title">导出正文</strong><small>服务器将分卷、章节标题与正文压缩为 ZIP,压缩包内含 Markdown 文件;不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
7326
- <button id="work-export-button" class="ghost-button" type="button">下载 ZIP</button>
7812
+ <div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择导出 Markdown ZIP DOCX(书名、分卷、章节为一级至三级标题;若已设置封面则嵌入为首页)。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
7813
+ <button id="work-export-button" class="ghost-button" type="button" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false">导出正文</button>
7327
7814
  </section>`;
7328
7815
  const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
7329
7816
  <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
@@ -7357,7 +7844,11 @@ function openWorkSettingsDialog(work) {
7357
7844
  $("#form-dialog").close();
7358
7845
  void openImportHistory();
7359
7846
  });
7360
- $("#work-export-button")?.addEventListener("click", () => downloadWorkManuscript(work));
7847
+ $("#work-export-button")?.addEventListener("click", (event) => {
7848
+ event.preventDefault();
7849
+ event.stopPropagation();
7850
+ showManuscriptExportMenu(event.currentTarget, work);
7851
+ });
7361
7852
  $("#chapter-recycle-bin-button")?.addEventListener("click", () => {
7362
7853
  $("#form-dialog").close();
7363
7854
  void openChapterRecycleBin();
@@ -9295,6 +9786,7 @@ async function sendAi() {
9295
9786
  try {
9296
9787
  await ensureAiModelsLoaded();
9297
9788
  } catch (error) {
9789
+ setAiAssistantStatus("error");
9298
9790
  return toast(`创作助手加载失败:${error.message}`, "error");
9299
9791
  }
9300
9792
  const modelId = $("#ai-model").value;
@@ -9305,12 +9797,14 @@ async function sendAi() {
9305
9797
  if (!requestScope) return toast("请先选择章节", "error");
9306
9798
  const { taskType, scope, selection } = requestScope;
9307
9799
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
9800
+ setAiAssistantStatus("ready");
9308
9801
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9309
9802
  let persistedUserMessage = null;
9310
9803
  if (taskType !== "chat") {
9311
9804
  try {
9312
9805
  persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9313
9806
  } catch (error) {
9807
+ setAiAssistantStatus("error");
9314
9808
  return toast(`对话记录创建失败:${error.message}`, "error");
9315
9809
  }
9316
9810
  state.aiPromptSent = true;
@@ -9336,6 +9830,10 @@ async function sendAi() {
9336
9830
  applyAiConversationTitle(streamed.conversationTitle);
9337
9831
  } else {
9338
9832
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
9833
+ const suggestionFailed = suggestion.guard?.status === "failed"
9834
+ || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
9835
+ || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
9836
+ if (suggestionFailed) setAiAssistantStatus("error");
9339
9837
  setAiContextMeter(suggestion.contextUsage);
9340
9838
  assistantContent = suggestion.content;
9341
9839
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
@@ -9358,10 +9856,12 @@ async function sendAi() {
9358
9856
  } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
9359
9857
  }
9360
9858
  } catch (error) {
9859
+ setAiAssistantStatus("error");
9361
9860
  if (suggestion) appendSuggestion(suggestion);
9362
9861
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
9363
9862
  }
9364
9863
  } catch (error) {
9864
+ setAiAssistantStatus("error");
9365
9865
  const failureMessage = formatAiFailureMessage(error);
9366
9866
  let persistedFailureMessage = null;
9367
9867
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -9489,6 +9989,7 @@ async function streamChat(body) {
9489
9989
  const toolCall = { ...payload };
9490
9990
  const round = toolCall.round;
9491
9991
  delete toolCall.round;
9992
+ if (toolCall.status === "failed") setAiAssistantStatus("error");
9492
9993
  toolCalls.push(toolCall);
9493
9994
  processSteps.push(aiToolProcessStep(toolCall, round));
9494
9995
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
@@ -9516,6 +10017,7 @@ async function streamChat(body) {
9516
10017
  attachAssistantCopyAction(message, streamedText);
9517
10018
  scrollAiFeedToBottom();
9518
10019
  } else if (eventName === "error") {
10020
+ setAiAssistantStatus("error");
9519
10021
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
9520
10022
  }
9521
10023
  };
@@ -9960,6 +10462,28 @@ $("#onboarding-dialog").addEventListener("cancel", (event) => {
9960
10462
  event.preventDefault();
9961
10463
  completeOnboarding();
9962
10464
  });
10465
+ $("#system-restart-dialog").addEventListener("cancel", (event) => {
10466
+ event.preventDefault();
10467
+ });
10468
+ function hasUnsavedEditorChanges() {
10469
+ return state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty;
10470
+ }
10471
+
10472
+ function redirectToLoginAfterSystemRestart() {
10473
+ state.user = null;
10474
+ state.csrfToken = null;
10475
+ moduleRequestCache.clear();
10476
+ document.documentElement.classList.remove("dev-auth-bypass");
10477
+ document.documentElement.classList.add("login-route");
10478
+ window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
10479
+ const toastRegion = $("#toast-region");
10480
+ toastRegion.replaceChildren();
10481
+ if (typeof toastRegion.hidePopover === "function" && toastRegion.matches(":popover-open")) toastRegion.hidePopover();
10482
+ $("#system-restart-dialog").close();
10483
+ showAuth(false);
10484
+ }
10485
+
10486
+ $("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
9963
10487
  $("#onboarding-dialog").addEventListener("keydown", (event) => {
9964
10488
  if (event.key === "Escape") {
9965
10489
  event.preventDefault();
@@ -10498,10 +11022,20 @@ $("#writing-progress-button").addEventListener("click", () => openWritingProgres
10498
11022
  $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
10499
11023
  $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
10500
11024
  $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
10501
- $("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
10502
- $("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
10503
- $("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
10504
- $("#work-audit-refresh").addEventListener("click", () => loadWorkAuditPage().catch((error) => toast(error.message, "error")));
11025
+ $("#work-audit-button").addEventListener("click", () => showWorkAudit().catch((error) => toast(error.message, "error")));
11026
+ $("#work-audit-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button").catch((error) => toast(error.message, "error")));
11027
+ $("#work-audit-refresh").addEventListener("click", async () => {
11028
+ const button = $("#work-audit-refresh");
11029
+ button.disabled = true;
11030
+ try {
11031
+ await loadWorkAuditPage();
11032
+ toast("操作记录已刷新");
11033
+ } catch (error) {
11034
+ toast(error.message, "error");
11035
+ } finally {
11036
+ button.disabled = false;
11037
+ }
11038
+ });
10505
11039
  $("#work-audit-load-more").addEventListener("click", () => {
10506
11040
  if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
10507
11041
  });
@@ -10564,6 +11098,7 @@ $("#form-dialog").addEventListener("close", () => {
10564
11098
  formDialogVditors.forEach(destroyVditorEditor);
10565
11099
  formDialogVditors = [];
10566
11100
  void discardPendingMarkdownAttachments();
11101
+ closeManuscriptExportMenu();
10567
11102
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
10568
11103
  });
10569
11104
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -10923,6 +11458,9 @@ document.addEventListener("pointerdown", (event) => {
10923
11458
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
10924
11459
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10925
11460
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
11461
+ if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {
11462
+ closeManuscriptExportMenu();
11463
+ }
10926
11464
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10927
11465
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10928
11466
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -10945,6 +11483,7 @@ document.addEventListener("keydown", (event) => {
10945
11483
  closeChapterTypeMenu();
10946
11484
  closeLineCitationMenu();
10947
11485
  closeMarkdownTableMenu(true);
11486
+ closeManuscriptExportMenu();
10948
11487
  hideAiMentionMenu();
10949
11488
  setAiContextDistributionVisible(false);
10950
11489
  }
@@ -11097,8 +11636,39 @@ $("#search-form").addEventListener("submit", async (event) => {
11097
11636
  $("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
11098
11637
  });
11099
11638
  });
11100
- $("#export-button").addEventListener("click", () => downloadWorkManuscript(state.work));
11101
- window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
11639
+ $("#export-button").addEventListener("click", (event) => {
11640
+ event.preventDefault();
11641
+ event.stopPropagation();
11642
+ if (!state.work) return;
11643
+ const menu = $("#manuscript-export-menu");
11644
+ const expanded = menu && !menu.classList.contains("hidden") && manuscriptExportWork?.id === state.work.id;
11645
+ if (expanded) {
11646
+ closeManuscriptExportMenu();
11647
+ return;
11648
+ }
11649
+ showManuscriptExportMenu(event.currentTarget, state.work);
11650
+ });
11651
+ $("#manuscript-export-menu").addEventListener("click", (event) => {
11652
+ const option = event.target.closest("[data-export-format]");
11653
+ if (!option || !manuscriptExportWork) return;
11654
+ const format = option.getAttribute("data-export-format") === "docx" ? "docx" : "markdown";
11655
+ const work = manuscriptExportWork;
11656
+ closeManuscriptExportMenu();
11657
+ downloadWorkManuscript(work, format);
11658
+ });
11659
+ document.addEventListener("visibilitychange", () => {
11660
+ if (document.visibilityState !== "visible") return;
11661
+ if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
11662
+ void refreshSystemHealth();
11663
+ });
11664
+ window.addEventListener("beforeunload", (event) => {
11665
+ if (hasUnsavedEditorChanges()) event.preventDefault();
11666
+ });
11667
+ window.addEventListener("online", () => {
11668
+ updateSystemHealth({ status: "checking" });
11669
+ void refreshSystemHealth();
11670
+ });
11671
+ window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
11102
11672
 
11103
11673
  initializePage().catch((error) => {
11104
11674
  restoringPageRoute = false;