@musnows/scriverse 0.6.1 → 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.
Files changed (47) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +267 -54
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +256 -57
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +4 -0
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +23 -7
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/credential-vault.js +7 -5
  16. package/dist/credential-vault.js.map +1 -1
  17. package/dist/database.js +243 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/docx-export.js +89 -0
  20. package/dist/docx-export.js.map +1 -0
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/image-captcha.js +8 -5
  24. package/dist/image-captcha.js.map +1 -1
  25. package/dist/public/ai-context-meter.js +4 -0
  26. package/dist/public/ai-message-time.js +3 -9
  27. package/dist/public/ai-tool-call.js +4 -0
  28. package/dist/public/app.js +765 -111
  29. package/dist/public/index.html +49 -17
  30. package/dist/public/markdown.js +1 -2
  31. package/dist/public/page-route.js +2 -2
  32. package/dist/public/styles.css +87 -29
  33. package/dist/public/system-status.d.ts +12 -0
  34. package/dist/public/system-status.js +16 -0
  35. package/dist/public/theme-init.js +2 -2
  36. package/dist/security.js +143 -16
  37. package/dist/security.js.map +1 -1
  38. package/dist/server-runtime.js +56 -1
  39. package/dist/server-runtime.js.map +1 -1
  40. package/dist/store.js +289 -39
  41. package/dist/store.js.map +1 -1
  42. package/dist/user-auth.js +56 -88
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/dist/writing-progress-time.js +15 -0
  46. package/dist/writing-progress-time.js.map +1 -1
  47. package/package.json +2 -1
@@ -1,6 +1,6 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260730-table-wrap-menu-v1";
3
+ import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
@@ -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 };
@@ -904,6 +926,7 @@ let chapterEditorReadOnly = true;
904
926
  let characterListPage = 1;
905
927
  let taskListPage = 1;
906
928
  let draftTypeFilter = "all";
929
+ let draftBindingFilters = [];
907
930
  let draftFiltersPanelOpen = false;
908
931
  const moduleListPages = {
909
932
  drafts: 1,
@@ -1313,7 +1336,7 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1313
1336
  role.textContent = label;
1314
1337
  const time = document.createElement("time");
1315
1338
  time.dateTime = timestamp;
1316
- time.textContent = formatAiMessageTime(timestamp, previousCreatedAt);
1339
+ time.textContent = formatAiMessageTime(timestamp);
1317
1340
  heading.append(role, time);
1318
1341
  message.prepend(heading);
1319
1342
  message.dataset.createdAt = timestamp;
@@ -1327,7 +1350,7 @@ function updateMessageCreatedAt(message, createdAt) {
1327
1350
  const time = message.querySelector(".message-heading time");
1328
1351
  if (!time) return;
1329
1352
  time.dateTime = createdAt;
1330
- time.textContent = formatAiMessageTime(createdAt, message.dataset.previousCreatedAt || null);
1353
+ time.textContent = formatAiMessageTime(createdAt);
1331
1354
  message.dataset.createdAt = createdAt;
1332
1355
  if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
1333
1356
  }
@@ -1337,14 +1360,20 @@ function resetAiFeed() {
1337
1360
  $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1338
1361
  }
1339
1362
 
1340
- function appendAiContextCompactionDivider(kind, before = null) {
1363
+ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
1341
1364
  const divider = document.createElement("div");
1342
1365
  divider.className = "ai-context-compaction-divider";
1343
1366
  divider.dataset.contextCompaction = kind;
1344
1367
  divider.dataset.testid = "ai-context-compaction-divider";
1345
1368
  divider.setAttribute("role", "separator");
1346
- divider.setAttribute("aria-label", "已压缩上下文");
1369
+ divider.setAttribute("aria-label", ariaLabel);
1370
+ if (title) divider.title = title;
1347
1371
  divider.innerHTML = "<span>已压缩上下文</span>";
1372
+ return divider;
1373
+ }
1374
+
1375
+ function appendAiContextCompactionDivider(kind, before = null) {
1376
+ const divider = createAiContextCompactionDivider({ kind });
1348
1377
  const feed = $("#ai-feed");
1349
1378
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1350
1379
  else feed.append(divider);
@@ -1523,8 +1552,10 @@ function openAiToolCallDetail(toolCall) {
1523
1552
  time.textContent = formatAiToolCallTime(calledAt);
1524
1553
  if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
1525
1554
  else time.removeAttribute("datetime");
1555
+ const resultDetails = formatAiToolCallResult(toolCall?.result);
1526
1556
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
1527
- $("#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;
1528
1559
  $("#ai-tool-call-dialog").showModal();
1529
1560
  }
1530
1561
 
@@ -1594,14 +1625,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1594
1625
  list.className = "ai-process-list";
1595
1626
  for (const step of steps) {
1596
1627
  if (step?.type === "context_compaction") {
1597
- const compaction = document.createElement("div");
1598
- compaction.className = "ai-process-context-compaction";
1599
- compaction.dataset.testid = "ai-process-context-compaction";
1600
- compaction.setAttribute("role", "separator");
1601
- compaction.setAttribute("aria-label", `第 ${Number(step.round) || 1} 轮已压缩上下文`);
1602
- compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
1603
- compaction.innerHTML = "<span>已压缩上下文</span>";
1604
- list.append(compaction);
1628
+ list.append(createAiContextCompactionDivider({
1629
+ kind: "tool",
1630
+ ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
1631
+ title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
1632
+ }));
1605
1633
  continue;
1606
1634
  }
1607
1635
  if (step?.type === "tool" && step.toolCall) {
@@ -1758,6 +1786,7 @@ async function openAiConversation(conversationId, hideHistory = true) {
1758
1786
  upsertAiConversationSummary(conversation);
1759
1787
  state.aiConversationId = conversation.id;
1760
1788
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1789
+ resetAiContextMeter();
1761
1790
  $("#ai-conversation-title").textContent = conversation.title;
1762
1791
  resetAiFeed();
1763
1792
  for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
@@ -1785,7 +1814,7 @@ async function createNewAiConversation() {
1785
1814
  $("#ai-conversation-title").textContent = conversation.title;
1786
1815
  resetAiFeed();
1787
1816
  hideAiContextWarning();
1788
- setAiContextMeter(null);
1817
+ resetAiContextMeter();
1789
1818
  renderAiQuickActions();
1790
1819
  setAiHistoryVisible(false);
1791
1820
  }
@@ -2295,19 +2324,29 @@ async function api(path, options = {}) {
2295
2324
  const headers = { ...(options.headers ?? {}) };
2296
2325
  if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
2297
2326
  if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
2298
- const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
2299
- ...options,
2300
- headers,
2301
- body: body && typeof body !== "string" ? JSON.stringify(body) : body
2302
- });
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" });
2303
2339
  if (!response.ok) {
2304
2340
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
2305
2341
  // Presence is best-effort; a heartbeat 401 must not force the login wall.
2306
2342
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2307
- state.user = null;
2308
- state.csrfToken = null;
2309
- moduleRequestCache.clear();
2310
- 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
+ }
2311
2350
  }
2312
2351
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2313
2352
  }
@@ -2316,6 +2355,12 @@ async function api(path, options = {}) {
2316
2355
  return null;
2317
2356
  }
2318
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
+ }
2319
2364
  invalidateModuleRequestsAfterMutation(path, method);
2320
2365
  return payload.data;
2321
2366
  }
@@ -2350,9 +2395,9 @@ function formatAiFailureMessage(error) {
2350
2395
  if (status) lines.push(`服务端状态:HTTP ${status}`);
2351
2396
  if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2352
2397
  if (modelId) lines.push(`模型 ID:${modelId}`);
2353
- if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2354
2398
  if (callId) lines.push(`调用 ID:${callId}`);
2355
- return lines.join("\n\n");
2399
+ if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2400
+ return lines.join("\n");
2356
2401
  }
2357
2402
 
2358
2403
  async function apiPage(path, page = 1, limit = 30) {
@@ -2430,23 +2475,97 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2430
2475
  affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
2431
2476
  }
2432
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
+
2433
2507
  async function initializeProductFooters() {
2434
2508
  const year = String(new Date().getFullYear());
2435
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
+ })();
2436
2551
  try {
2437
- const health = await api("/api/health");
2438
- const version = String(health.version ?? "").trim();
2439
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
2440
- element.textContent = version ? `v${version}` : "v—";
2441
- });
2442
- document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
2443
- element.classList.toggle("hidden", health.development !== true);
2444
- });
2445
- } catch {
2446
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
2552
+ return await systemBootCheckPromise;
2553
+ } finally {
2554
+ systemBootCheckPromise = null;
2447
2555
  }
2448
2556
  }
2449
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
+
2450
2569
  function selectAuthMode(mode) {
2451
2570
  const registerTab = $("#auth-register-tab");
2452
2571
  const login = mode === "login" || registerTab.disabled;
@@ -2484,7 +2603,7 @@ async function refreshAuthCaptcha(target = "login") {
2484
2603
  if (answerInput) answerInput.value = "";
2485
2604
  }
2486
2605
 
2487
- function showAuth(setupRequired, registrationOpen = false) {
2606
+ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
2488
2607
  if (state.user) return;
2489
2608
  document.body.classList.add("auth-pending");
2490
2609
  $("#auth-view").classList.remove("hidden");
@@ -2501,6 +2620,10 @@ function showAuth(setupRequired, registrationOpen = false) {
2501
2620
  registerTab.disabled = !canRegister;
2502
2621
  registerTab.setAttribute("aria-disabled", String(!canRegister));
2503
2622
  registerTab.textContent = canRegister ? "注册" : "注册已禁用";
2623
+ const setupTokenField = $("#register-setup-token-field");
2624
+ const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
2625
+ setupTokenField.classList.toggle("hidden", !setupTokenRequired);
2626
+ setupTokenInput.required = setupTokenRequired;
2504
2627
  selectAuthMode(setupRequired && canRegister ? "register" : "login");
2505
2628
  }
2506
2629
 
@@ -2579,12 +2702,14 @@ async function initializeAuthentication() {
2579
2702
  if (!session.authenticated) {
2580
2703
  // 未登录时一律转到登录页路由;登录页本身则保持原样
2581
2704
  if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
2582
- showAuth(session.setupRequired, session.registrationOpen === true);
2705
+ showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
2583
2706
  return false;
2584
2707
  }
2585
2708
  // 已登录却停在登录页路由时,回到书架首页
2586
2709
  if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
2710
+ observeSystemBootId(session.bootId);
2587
2711
  applyAuthenticatedUser(session);
2712
+ scheduleSystemBootCheck();
2588
2713
  await loadPlatformUiSettings();
2589
2714
  return true;
2590
2715
  }
@@ -2740,10 +2865,53 @@ document.addEventListener("toggle", (event) => {
2740
2865
  }
2741
2866
  }, true);
2742
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
+
2743
2908
  function setSaveState(text, dirty = false) {
2744
2909
  state.dirty = dirty;
2745
- $("#save-state").textContent = text;
2746
- $("#save-state").style.color = dirty ? "var(--accent)" : "var(--green)";
2910
+ paintTopbarStatus(text, {
2911
+ source: "save",
2912
+ tone: dirty ? "error" : "ok",
2913
+ title: text
2914
+ });
2747
2915
  }
2748
2916
 
2749
2917
  function chapterDraftSnapshot() {
@@ -2985,6 +3153,11 @@ async function initializePage() {
2985
3153
  if (route.view === "platform-usage") {
2986
3154
  await showPlatformUsage();
2987
3155
  settingsReturnContext = restoredSettingsReturnContext(route);
3156
+ return;
3157
+ }
3158
+ if (route.view === "work-audit") {
3159
+ await showWorkAudit();
3160
+ settingsReturnContext = restoredSettingsReturnContext(route);
2988
3161
  }
2989
3162
  } finally {
2990
3163
  document.body.classList.remove("auth-pending");
@@ -3004,6 +3177,7 @@ function showShelf() {
3004
3177
  $("#shelf-view").classList.remove("hidden");
3005
3178
  $("#platform-ai-view").classList.add("hidden");
3006
3179
  $("#platform-usage-view").classList.add("hidden");
3180
+ $("#work-audit-view").classList.add("hidden");
3007
3181
  $("#settings-hub-view").classList.add("hidden");
3008
3182
  $("#welcome-view").classList.add("hidden");
3009
3183
  $("#editor-view").classList.add("hidden");
@@ -3011,7 +3185,7 @@ function showShelf() {
3011
3185
  $("#work-meta").textContent = `${state.works.length} 部作品`;
3012
3186
  $("#settings-button").removeAttribute("aria-current");
3013
3187
  $("#top-search-button").disabled = true;
3014
- setSaveState("书架");
3188
+ setTopbarViewState("书架");
3015
3189
  renderShelf();
3016
3190
  replacePageRoute({ view: "shelf" });
3017
3191
  }
@@ -3028,7 +3202,7 @@ function renderSettingsHub() {
3028
3202
  const hasWork = Boolean(state.work);
3029
3203
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
3030
3204
  const canReadAggregate = hasWork && canReadAggregateContent();
3031
- const canReadFullExport = canReadAggregate && canReadModule("drafts");
3205
+ const canExportManuscript = hasWork && canReadModule("editor");
3032
3206
  const isAdmin = state.user?.role === "admin";
3033
3207
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
3034
3208
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -3038,10 +3212,11 @@ function renderSettingsHub() {
3038
3212
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
3039
3213
  $("#work-audit-button").disabled = !canManageWork;
3040
3214
  $("#top-search-button").disabled = !canReadAggregate;
3041
- $("#export-button").disabled = !canReadFullExport;
3215
+ $("#export-button").disabled = !canExportManuscript;
3216
+ $("#export-button").setAttribute("aria-expanded", "false");
3042
3217
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
3043
3218
  $("#settings-work-note").textContent = hasWork
3044
- ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
3219
+ ? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP DOCX;DOCX 在有封面时会嵌入为首页。`
3045
3220
  : "当前未选择作品;打开作品后可使用导出。";
3046
3221
  }
3047
3222
 
@@ -3110,36 +3285,173 @@ async function openWritingProgressDialog() {
3110
3285
  const workAuditActionLabels = {
3111
3286
  "work.created": "创建作品",
3112
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": "更新写作目标",
3113
3294
  "volume.created": "创建分卷",
3114
3295
  "volume.updated": "更新分卷",
3115
3296
  "volume.deleted": "删除分卷",
3297
+ "volume.restored": "恢复分卷",
3116
3298
  "chapter.created": "创建章节",
3117
3299
  "chapter.saved": "保存章节",
3118
3300
  "chapter.moved": "移动章节",
3119
3301
  "chapter.deleted": "删除章节",
3120
3302
  "chapter.purged": "彻底删除章节",
3121
3303
  "chapter.restored": "恢复章节",
3304
+ "chapter.annotation.created": "添加正文评论",
3305
+ "chapter.annotation.updated": "更新正文评论",
3306
+ "chapter.annotation.deleted": "删除正文评论",
3122
3307
  "draft.created": "创建想法",
3123
3308
  "draft.updated": "更新想法",
3124
3309
  "draft.deleted": "删除想法",
3125
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": "恢复导入快照",
3126
3352
  "work.imported": "导入正文"
3127
3353
  };
3128
3354
 
3129
3355
  function workAuditEntityLabel(type) {
3130
- 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);
3420
+ }
3421
+
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) }));
3131
3427
  }
3132
3428
 
3133
- function workAuditDetailText(detail) {
3134
- const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
3135
- return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
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(" ") || "—" };
3136
3433
  }
3137
3434
 
3138
3435
  function renderWorkAuditRecords() {
3139
- $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
3140
- <time>${esc(formatDateTime(record.createdAt))}</time>
3141
- <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>
3142
- </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
+ : "当前作品还没有操作记录。";
3143
3455
  $("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
3144
3456
  }
3145
3457
 
@@ -3151,18 +3463,36 @@ async function loadWorkAuditPage(page = 1, append = false) {
3151
3463
  renderWorkAuditRecords();
3152
3464
  }
3153
3465
 
3154
- async function openWorkAuditDialog() {
3155
- 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;
3156
3468
  workAuditRecords = [];
3157
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 = "正在读取操作记录……";
3158
3486
  $("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
3159
- $("#work-audit-dialog").showModal();
3487
+ replacePageRoute({ view: "work-audit", workId: state.work.id, ...settingsRouteContext() });
3160
3488
  try {
3161
3489
  await loadWorkAuditPage();
3162
3490
  } catch (error) {
3163
- $("#work-audit-dialog").close();
3491
+ $("#work-audit-summary").textContent = "操作记录加载失败。";
3492
+ $("#work-audit-list").innerHTML = '<p class="entity-history-empty">暂时无法读取操作记录,请稍后刷新。</p>';
3164
3493
  toast(error.message, "error");
3165
3494
  }
3495
+ return true;
3166
3496
  }
3167
3497
 
3168
3498
  async function saveWritingGoal(event) {
@@ -3463,7 +3793,8 @@ async function openSearchResult(result) {
3463
3793
  $("#search-dialog").close();
3464
3794
  const inSettings = !$("#settings-hub-view").classList.contains("hidden")
3465
3795
  || !$("#platform-ai-view").classList.contains("hidden")
3466
- || !$("#platform-usage-view").classList.contains("hidden");
3796
+ || !$("#platform-usage-view").classList.contains("hidden")
3797
+ || !$("#work-audit-view").classList.contains("hidden");
3467
3798
  if (inSettings) await returnFromSettings();
3468
3799
  if (target.kind === "chapter") {
3469
3800
  await selectChapter(target.id);
@@ -3489,7 +3820,8 @@ async function openSearchResult(result) {
3489
3820
  async function showSettingsHub() {
3490
3821
  const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
3491
3822
  || !$("#platform-ai-view").classList.contains("hidden")
3492
- || !$("#platform-usage-view").classList.contains("hidden");
3823
+ || !$("#platform-usage-view").classList.contains("hidden")
3824
+ || !$("#work-audit-view").classList.contains("hidden");
3493
3825
  if (!alreadyInSettings) {
3494
3826
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
3495
3827
  settingsReturnContext = captureSettingsReturnContext();
@@ -3501,13 +3833,14 @@ async function showSettingsHub() {
3501
3833
  $("#shelf-view").classList.add("hidden");
3502
3834
  $("#platform-ai-view").classList.add("hidden");
3503
3835
  $("#platform-usage-view").classList.add("hidden");
3836
+ $("#work-audit-view").classList.add("hidden");
3504
3837
  $("#settings-hub-view").classList.remove("hidden");
3505
3838
  $("#welcome-view").classList.add("hidden");
3506
3839
  $("#editor-view").classList.add("hidden");
3507
3840
  $("#module-view").classList.add("hidden");
3508
3841
  $("#work-meta").textContent = "设置中心";
3509
3842
  $("#settings-button").setAttribute("aria-current", "page");
3510
- setSaveState("设置");
3843
+ setTopbarViewState("设置");
3511
3844
  renderSettingsHub();
3512
3845
  replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
3513
3846
  return true;
@@ -3527,6 +3860,7 @@ async function returnFromSettings() {
3527
3860
  $("#settings-hub-view").classList.add("hidden");
3528
3861
  $("#platform-ai-view").classList.add("hidden");
3529
3862
  $("#platform-usage-view").classList.add("hidden");
3863
+ $("#work-audit-view").classList.add("hidden");
3530
3864
  if (context.view === "shelf" || !state.work) return showShelf();
3531
3865
  $("#app").classList.remove("shelf-mode");
3532
3866
  $("#shelf-view").classList.add("hidden");
@@ -3546,13 +3880,14 @@ async function showPlatformAi() {
3546
3880
  $("#shelf-view").classList.add("hidden");
3547
3881
  $("#platform-ai-view").classList.remove("hidden");
3548
3882
  $("#platform-usage-view").classList.add("hidden");
3883
+ $("#work-audit-view").classList.add("hidden");
3549
3884
  $("#settings-hub-view").classList.add("hidden");
3550
3885
  $("#welcome-view").classList.add("hidden");
3551
3886
  $("#editor-view").classList.add("hidden");
3552
3887
  $("#module-view").classList.add("hidden");
3553
3888
  $("#work-meta").textContent = "平台 AI 管理";
3554
3889
  $("#settings-button").setAttribute("aria-current", "page");
3555
- setSaveState("平台 AI");
3890
+ setTopbarViewState("平台 AI");
3556
3891
  await renderPlatformAiConfig();
3557
3892
  replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
3558
3893
  return true;
@@ -3566,13 +3901,14 @@ async function showPlatformUsage() {
3566
3901
  $("#shelf-view").classList.add("hidden");
3567
3902
  $("#platform-ai-view").classList.add("hidden");
3568
3903
  $("#platform-usage-view").classList.remove("hidden");
3904
+ $("#work-audit-view").classList.add("hidden");
3569
3905
  $("#settings-hub-view").classList.add("hidden");
3570
3906
  $("#welcome-view").classList.add("hidden");
3571
3907
  $("#editor-view").classList.add("hidden");
3572
3908
  $("#module-view").classList.add("hidden");
3573
3909
  $("#work-meta").textContent = "Token 用量";
3574
3910
  $("#settings-button").setAttribute("aria-current", "page");
3575
- setSaveState("Token 用量");
3911
+ setTopbarViewState("Token 用量");
3576
3912
  await renderPlatformTokenUsage();
3577
3913
  replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
3578
3914
  return true;
@@ -3623,6 +3959,7 @@ function resetWorkScopedUiCaches() {
3623
3959
  state.races = [];
3624
3960
  characterListPage = 1;
3625
3961
  draftTypeFilter = "all";
3962
+ draftBindingFilters = [];
3626
3963
  draftFiltersPanelOpen = false;
3627
3964
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3628
3965
  relationshipFilters.fromCharacterIds = [];
@@ -3645,7 +3982,7 @@ function resetWorkScopedUiCaches() {
3645
3982
  resetAiFeed();
3646
3983
  $("#ai-conversation-title").textContent = "新对话";
3647
3984
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
3648
- setAiContextMeter(null);
3985
+ resetAiContextMeter();
3649
3986
  renderAiConversationHistory();
3650
3987
  }
3651
3988
 
@@ -3660,11 +3997,12 @@ async function selectWork(workId, preferredChapterId = null) {
3660
3997
  }
3661
3998
  const nextWork = await api(`/api/works/${workId}?directory=volumes`);
3662
3999
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3663
- if (discarding) setSaveState("就绪");
4000
+ showSystemStatus();
3664
4001
  $("#app").classList.remove("shelf-mode");
3665
4002
  $("#shelf-view").classList.add("hidden");
3666
4003
  $("#platform-ai-view").classList.add("hidden");
3667
4004
  $("#platform-usage-view").classList.add("hidden");
4005
+ $("#work-audit-view").classList.add("hidden");
3668
4006
  $("#settings-hub-view").classList.add("hidden");
3669
4007
  $("#settings-button").removeAttribute("aria-current");
3670
4008
  settingsReturnContext = null;
@@ -4145,6 +4483,7 @@ function showWelcome(hasWork = false) {
4145
4483
  $("#editor-view").classList.add("hidden");
4146
4484
  $("#module-view").classList.add("hidden");
4147
4485
  $("#welcome-view").classList.remove("hidden");
4486
+ if (hasWork) showSystemStatus();
4148
4487
  $("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
4149
4488
  $("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
4150
4489
  replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
@@ -4198,6 +4537,7 @@ async function showModule(module) {
4198
4537
  }
4199
4538
  return;
4200
4539
  }
4540
+ showSystemStatus();
4201
4541
  dismissChapterInsightToast();
4202
4542
  $("#welcome-view").classList.add("hidden");
4203
4543
  $("#editor-view").classList.add("hidden");
@@ -4627,12 +4967,44 @@ function draftTypeLabel(draftType) {
4627
4967
  return draftType === "setting" ? "设定想法" : "正文想法";
4628
4968
  }
4629
4969
 
4970
+ const draftSettingModules = Object.freeze([
4971
+ ["settings", "设定库"],
4972
+ ["characters", "角色"],
4973
+ ["races", "种族"],
4974
+ ["organizations", "组织"],
4975
+ ["timeline", "时间轴"],
4976
+ ["relationships", "关系"],
4977
+ ["outlines", "大纲/伏笔"]
4978
+ ]);
4979
+
4980
+ function draftSettingModuleLabel(module) {
4981
+ return draftSettingModules.find(([value]) => value === module)?.[1] ?? "未知模块";
4982
+ }
4983
+
4984
+ function draftBindingKey(item) {
4985
+ if (item.draftType === "prose" && item.volumeId) return `volume:${item.volumeId}`;
4986
+ if (item.draftType === "setting" && item.settingModule) return `module:${item.settingModule}`;
4987
+ return "global";
4988
+ }
4989
+
4990
+ function draftBindingLabel(item) {
4991
+ if (item.draftType === "prose" && item.volumeId) return `分卷 · ${item.volumeTitle || "已删除分卷"}`;
4992
+ if (item.draftType === "setting" && item.settingModule) return `设定模块 · ${draftSettingModuleLabel(item.settingModule)}`;
4993
+ return "全局";
4994
+ }
4995
+
4630
4996
  async function deleteDraft(item) {
4631
4997
  if (!item || !canEditModule("drafts")) return;
4632
- const dialog = $("#form-dialog");
4633
- dialog.close();
4998
+ $("#form-dialog").close();
4634
4999
  if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
4635
5000
  title: "删除想法",
5001
+ confirmLabel: "继续删除"
5002
+ })) {
5003
+ openDraftDialog(item);
5004
+ return;
5005
+ }
5006
+ if (!await confirmToast(`删除想法“${item.title}”后将从想法列表移除,版本历史仍会保留。仍要删除吗?`, {
5007
+ title: "删除操作需要再次确认",
4636
5008
  confirmLabel: "确认删除"
4637
5009
  })) {
4638
5010
  openDraftDialog(item);
@@ -4654,23 +5026,30 @@ async function deleteDraft(item) {
4654
5026
 
4655
5027
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4656
5028
  const viewOnly = readOnly || !canEditModule("drafts");
5029
+ const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
5030
+ const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
4657
5031
  const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
4658
5032
  <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
4659
5033
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
4660
5034
  </section>` : "";
4661
- const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4662
- + field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
5035
+ const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
5036
+ + `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
5037
+ + `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
4663
5038
  + field("title", "标题", "text", item?.title ?? "")
4664
5039
  + field("content", "内容", "markdown", item?.content ?? "", {
4665
5040
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
5041
+ attachmentModule: "drafts",
4666
5042
  readOnly: viewOnly
4667
5043
  }) + management;
4668
5044
  openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
4669
5045
  if (viewOnly) return;
4670
5046
  const title = String(form.get("title") ?? "").trim();
4671
5047
  if (!title) throw new Error("请填写想法标题");
5048
+ const draftType = form.get("draftType") === "setting" ? "setting" : "prose";
4672
5049
  const body = {
4673
- draftType: form.get("draftType"),
5050
+ draftType,
5051
+ volumeId: draftType === "prose" ? String(form.get("volumeId") ?? "") || null : null,
5052
+ settingModule: draftType === "setting" ? String(form.get("settingModule") ?? "") || null : null,
4674
5053
  title,
4675
5054
  content: String(form.get("content") ?? ""),
4676
5055
  ...(item ? { expectedVersionNo: item.versionNo } : {})
@@ -4685,8 +5064,21 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4685
5064
  submitLabel: viewOnly ? "关闭" : "保存想法",
4686
5065
  hideCancel: viewOnly,
4687
5066
  editor: true,
4688
- errorPrefix: "想法保存失败:"
4689
- });
5067
+ errorPrefix: "想法保存失败:",
5068
+ meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
5069
+ });
5070
+ const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
5071
+ const syncDraftBindingFields = () => {
5072
+ const draftType = draftTypeSelect?.value === "setting" ? "setting" : "prose";
5073
+ $("#dialog-fields").querySelectorAll("[data-draft-binding-field]").forEach((bindingField) => {
5074
+ const active = bindingField.dataset.draftBindingField === draftType;
5075
+ bindingField.classList.toggle("hidden", !active);
5076
+ const select = bindingField.querySelector("select");
5077
+ if (select) select.disabled = !active || viewOnly;
5078
+ });
5079
+ };
5080
+ draftTypeSelect?.addEventListener("change", syncDraftBindingFields);
5081
+ syncDraftBindingFields();
4690
5082
  if (viewOnly) {
4691
5083
  $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
4692
5084
  if (control instanceof HTMLSelectElement) control.disabled = true;
@@ -4700,9 +5092,14 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4700
5092
 
4701
5093
  async function renderDrafts(page = moduleListPages.drafts) {
4702
5094
  const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
4703
- const drafts = draftTypeFilter === "all"
5095
+ const typeDrafts = draftTypeFilter === "all"
4704
5096
  ? allDrafts
4705
5097
  : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
5098
+ const selectedBindingKeys = new Set(draftBindingFilters);
5099
+ const drafts = selectedBindingKeys.size
5100
+ ? typeDrafts.filter((draft) => selectedBindingKeys.has(draftBindingKey(draft)))
5101
+ : typeDrafts;
5102
+ const hasDraftFilters = draftTypeFilter !== "all" || selectedBindingKeys.size > 0;
4706
5103
  mountModuleCount(drafts.length);
4707
5104
  const pageResult = paginateModuleItems(drafts, page, "drafts");
4708
5105
  moduleListPages.drafts = pageResult.page;
@@ -4710,21 +5107,26 @@ async function renderDrafts(page = moduleListPages.drafts) {
4710
5107
  if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
4711
5108
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4712
5109
  mountDraftFilterToggle();
4713
- const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
4714
- <label for="draft-type-filter">想法类型</label>
4715
- <select id="draft-type-filter" aria-label="按想法类型筛选">
5110
+ const bindingOptions = [
5111
+ ["global", "全局(未绑定)"],
5112
+ ...(state.work?.volumes ?? []).map((volume) => [`volume:${volume.id}`, `分卷 · ${volume.title}`]),
5113
+ ...draftSettingModules.map(([value, label]) => [`module:${value}`, `设定模块 · ${label}`])
5114
+ ];
5115
+ const filterToolbar = `<section id="draft-filter-panel" class="character-filter-toolbar draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
5116
+ <label class="draft-type-filter-field" for="draft-type-filter"><span>按想法类型筛选</span><select id="draft-type-filter" aria-label="按想法类型筛选">
4716
5117
  <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部想法</option>
4717
5118
  <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文想法</option>
4718
5119
  <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定想法</option>
4719
- </select>
4720
- ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>`}
5120
+ </select></label>
5121
+ <details class="character-filter-dropdown"><summary><span>按绑定位置筛选</span><strong>${selectedBindingKeys.size ? `已选 ${selectedBindingKeys.size} 项` : "全部位置"}</strong></summary><div id="draft-binding-filter" class="character-filter-options">${bindingOptions.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedBindingKeys.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("")}</div></details>
5122
+ <div class="character-filter-toolbar-actions">${hasDraftFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>` : ""}<button id="clear-draft-filters" class="ghost-button" type="button" ${hasDraftFilters ? "" : "disabled"}>重置筛选</button></div>
4721
5123
  </section>`;
4722
5124
  const actions = (item) => canEditModule("drafts")
4723
5125
  ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4724
5126
  : recordHistoryButton("draft", item.id, item.title);
4725
5127
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4726
5128
  <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4727
- <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
5129
+ <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4728
5130
  <h3>${esc(item.title)}</h3>
4729
5131
  <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
4730
5132
  <div class="card-actions">${actions(item)}</div>
@@ -4732,13 +5134,13 @@ async function renderDrafts(page = moduleListPages.drafts) {
4732
5134
  const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4733
5135
  const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4734
5136
  return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4735
- <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
5137
+ <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4736
5138
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4737
5139
  <div class="card-actions">${actions(item)}</div>
4738
5140
  </article>`;
4739
5141
  }).join("")}</div>`;
4740
5142
  const emptyDrafts = allDrafts.length
4741
- ? emptyModule("没有符合筛选条件的想法", "可以切换想法类型查看其他内容。")
5143
+ ? emptyModule("没有符合筛选条件的想法", "可以切换想法类型、绑定位置或重置筛选。")
4742
5144
  : emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4743
5145
  $("#module-content").innerHTML = filterToolbar + (drafts.length
4744
5146
  ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
@@ -4749,6 +5151,19 @@ async function renderDrafts(page = moduleListPages.drafts) {
4749
5151
  moduleListPages.drafts = 1;
4750
5152
  await renderDrafts(1);
4751
5153
  });
5154
+ $("#draft-binding-filter").addEventListener("change", async () => {
5155
+ draftBindingFilters = [...$("#draft-binding-filter").querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
5156
+ draftFiltersPanelOpen = true;
5157
+ moduleListPages.drafts = 1;
5158
+ await renderDrafts(1);
5159
+ });
5160
+ $("#clear-draft-filters")?.addEventListener("click", async () => {
5161
+ draftTypeFilter = "all";
5162
+ draftBindingFilters = [];
5163
+ draftFiltersPanelOpen = true;
5164
+ moduleListPages.drafts = 1;
5165
+ await renderDrafts(1);
5166
+ });
4752
5167
  bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4753
5168
  bindModulePagination("drafts", renderDrafts);
4754
5169
  $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
@@ -6479,21 +6894,72 @@ function tokenUsageCalendarMarkup(daily) {
6479
6894
  const calendar = buildUsageCalendar(daily);
6480
6895
  const cells = calendar.cells.map((cell) => {
6481
6896
  const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
6482
- 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>`;
6483
6900
  }).join("");
6484
6901
  const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
6485
- return `<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
6486
- <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
6487
- <div class="usage-calendar-months" aria-hidden="true">${months}</div>
6488
- <div class="usage-calendar-body">
6489
- <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
6490
- <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>
6491
6910
  </div>
6492
6911
  </div>
6912
+ <output class="usage-calendar-tooltip" role="tooltip" hidden></output>
6493
6913
  </div>
6494
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>`;
6495
6915
  }
6496
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
+
6497
6963
  function scrollUsageCalendarsToLatest(root) {
6498
6964
  window.requestAnimationFrame(() => {
6499
6965
  root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
@@ -6552,6 +7018,7 @@ async function renderPlatformTokenUsage() {
6552
7018
  description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
6553
7019
  showWorks: true
6554
7020
  });
7021
+ bindUsageCalendarInteractions(host);
6555
7022
  scrollUsageCalendarsToLatest(host);
6556
7023
  }
6557
7024
 
@@ -6571,10 +7038,20 @@ async function renderBookAiSettings() {
6571
7038
  const host = $("#module-content");
6572
7039
  const workId = String(state.work.id);
6573
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。`;
6574
7050
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6575
7051
  title: "本书 Token 用量",
6576
7052
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6577
- })}</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);
6578
7055
  scrollUsageCalendarsToLatest(host);
6579
7056
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6580
7057
  "beforebegin",
@@ -6590,6 +7067,7 @@ async function renderBookAiSettings() {
6590
7067
  );
6591
7068
  if (!canEditModule("ai-settings")) {
6592
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; });
6593
7071
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
6594
7072
  }
6595
7073
  const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
@@ -6619,6 +7097,31 @@ async function renderBookAiSettings() {
6619
7097
  return status;
6620
7098
  };
6621
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
+ });
6622
7125
  $("#save-work-system-prompt").addEventListener("click", async () => {
6623
7126
  const button = $("#save-work-system-prompt");
6624
7127
  button.disabled = true;
@@ -6698,6 +7201,34 @@ async function renderBookAiSettings() {
6698
7201
  button.disabled = false;
6699
7202
  }
6700
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
+ });
6701
7232
  $("#save-agent-tools").addEventListener("click", async () => {
6702
7233
  const button = $("#save-agent-tools");
6703
7234
  button.disabled = true;
@@ -6836,10 +7367,12 @@ function renderAiContextDistribution(usage) {
6836
7367
  }
6837
7368
 
6838
7369
  function setAiContextMeter(usage) {
7370
+ const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
7371
+ latestAiContextUsage = displayUsage;
6839
7372
  const meter = $("#ai-context-meter");
6840
7373
  const value = meter.querySelector("b");
6841
- renderAiContextDistribution(usage);
6842
- if (!usage) {
7374
+ renderAiContextDistribution(displayUsage);
7375
+ if (!displayUsage) {
6843
7376
  meter.classList.add("is-empty");
6844
7377
  meter.classList.remove("is-warning", "is-danger");
6845
7378
  meter.style.setProperty("--context-usage", "0");
@@ -6849,17 +7382,22 @@ function setAiContextMeter(usage) {
6849
7382
  meter.setAttribute("aria-label", tooltip);
6850
7383
  return;
6851
7384
  }
6852
- 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));
6853
7386
  meter.classList.remove("is-empty");
6854
7387
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
6855
7388
  meter.classList.toggle("is-danger", percent >= 90);
6856
7389
  meter.style.setProperty("--context-usage", String(percent));
6857
7390
  value.textContent = `${percent}%`;
6858
- const tooltip = formatAiContextUsageTooltip(usage);
7391
+ const tooltip = formatAiContextUsageTooltip(displayUsage);
6859
7392
  meter.dataset.tooltip = tooltip;
6860
7393
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6861
7394
  }
6862
7395
 
7396
+ function resetAiContextMeter() {
7397
+ latestAiContextUsage = null;
7398
+ setAiContextMeter(null);
7399
+ }
7400
+
6863
7401
  function setAiContextDistributionVisible(visible) {
6864
7402
  const meter = $("#ai-context-meter");
6865
7403
  const popover = $("#ai-context-popover");
@@ -6912,7 +7450,7 @@ async function ensureAiReferencesLoaded() {
6912
7450
 
6913
7451
  function field(name, label, type = "text", value = "", options = []) {
6914
7452
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
6915
- if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
7453
+ if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-attachment-module="${esc(options.attachmentModule ?? "settings")}" data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
6916
7454
  if (type === "item-list") {
6917
7455
  const values = Array.isArray(value) && value.length ? value : [""];
6918
7456
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
@@ -7219,9 +7757,39 @@ function bindWorkCoverControls(work) {
7219
7757
  });
7220
7758
  }
7221
7759
 
7222
- function downloadWorkManuscript(work) {
7760
+ function downloadWorkManuscript(work, format = "markdown") {
7223
7761
  if (!work?.id) return;
7224
- 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();
7225
7793
  }
7226
7794
 
7227
7795
  function openWorkSettingsDialog(work) {
@@ -7241,8 +7809,8 @@ function openWorkSettingsDialog(work) {
7241
7809
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
7242
7810
  </section>`;
7243
7811
  const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
7244
- <div><strong id="work-export-settings-title">导出正文</strong><small>服务器将分卷、章节标题与正文压缩为 ZIP,压缩包内含 Markdown 文件;不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
7245
- <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>
7246
7814
  </section>`;
7247
7815
  const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
7248
7816
  <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
@@ -7276,7 +7844,11 @@ function openWorkSettingsDialog(work) {
7276
7844
  $("#form-dialog").close();
7277
7845
  void openImportHistory();
7278
7846
  });
7279
- $("#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
+ });
7280
7852
  $("#chapter-recycle-bin-button")?.addEventListener("click", () => {
7281
7853
  $("#form-dialog").close();
7282
7854
  void openChapterRecycleBin();
@@ -7598,10 +8170,10 @@ function markdownImageLabel(file, fallback = "图片附件") {
7598
8170
  return String(file?.name ?? "").replace(/[\[\]\r\n]/gu, "").trim() || fallback;
7599
8171
  }
7600
8172
 
7601
- async function uploadMarkdownAttachment(file) {
8173
+ async function uploadMarkdownAttachment(file, module = "settings") {
7602
8174
  const body = new FormData();
7603
8175
  body.append("file", file);
7604
- const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
8176
+ const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
7605
8177
  if (!attachment.deduplicated) markdownEditorPendingAttachments.push(String(attachment.id));
7606
8178
  return { attachment, imageLabel: markdownImageLabel(file) };
7607
8179
  }
@@ -7632,7 +8204,7 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
7632
8204
  };
7633
8205
  }
7634
8206
 
7635
- function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = uploadMarkdownAttachment, placeholder = "", readOnly = false, width = "auto" } = {}) {
8207
+ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
7636
8208
  if (!window.Vditor) {
7637
8209
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
7638
8210
  return null;
@@ -7663,7 +8235,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
7663
8235
  accept: "image/*",
7664
8236
  max: 10 * 1024 * 1024,
7665
8237
  multiple: true,
7666
- handler: createVditorUploadHandler(uploadAttachment, () => editor)
8238
+ handler: createVditorUploadHandler(uploadAttachment ?? ((file) => uploadMarkdownAttachment(file, attachmentModule)), () => editor)
7667
8239
  },
7668
8240
  input: (markdown) => {
7669
8241
  normalizeVditorAttachmentImages(editor);
@@ -7892,6 +8464,7 @@ function bindVditorEditors(container) {
7892
8464
  if (valueField) valueField.value = markdown;
7893
8465
  markEntityEditorDirty();
7894
8466
  },
8467
+ attachmentModule: host.dataset.attachmentModule ?? "settings",
7895
8468
  placeholder: host.dataset.placeholder ?? "",
7896
8469
  readOnly: Boolean(valueField?.readOnly)
7897
8470
  });
@@ -7907,7 +8480,7 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
7907
8480
  async function uploadCharacterSectionAttachment(file) {
7908
8481
  const body = new FormData();
7909
8482
  body.append("file", file);
7910
- const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
8483
+ const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
7911
8484
  if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
7912
8485
  return {
7913
8486
  attachment,
@@ -7980,6 +8553,7 @@ async function openKnowledgeSectionEditor(index = null) {
7980
8553
  host.querySelectorAll("input, textarea").forEach((control) => control.addEventListener("input", () => { knowledgeSectionEditorDirty = true; }));
7981
8554
  knowledgeSectionVditor = createVditorEditor($("#knowledge-section-markdown"), section?.contentMarkdown ?? "", {
7982
8555
  onInput: () => { knowledgeSectionEditorDirty = true; },
8556
+ attachmentModule: knowledgeEditorKind === "race" ? "races" : "organizations",
7983
8557
  placeholder: "从这里开始写 Markdown 设定…",
7984
8558
  width: "100%"
7985
8559
  });
@@ -9212,6 +9786,7 @@ async function sendAi() {
9212
9786
  try {
9213
9787
  await ensureAiModelsLoaded();
9214
9788
  } catch (error) {
9789
+ setAiAssistantStatus("error");
9215
9790
  return toast(`创作助手加载失败:${error.message}`, "error");
9216
9791
  }
9217
9792
  const modelId = $("#ai-model").value;
@@ -9222,12 +9797,14 @@ async function sendAi() {
9222
9797
  if (!requestScope) return toast("请先选择章节", "error");
9223
9798
  const { taskType, scope, selection } = requestScope;
9224
9799
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
9800
+ setAiAssistantStatus("ready");
9225
9801
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9226
9802
  let persistedUserMessage = null;
9227
9803
  if (taskType !== "chat") {
9228
9804
  try {
9229
9805
  persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9230
9806
  } catch (error) {
9807
+ setAiAssistantStatus("error");
9231
9808
  return toast(`对话记录创建失败:${error.message}`, "error");
9232
9809
  }
9233
9810
  state.aiPromptSent = true;
@@ -9253,6 +9830,10 @@ async function sendAi() {
9253
9830
  applyAiConversationTitle(streamed.conversationTitle);
9254
9831
  } else {
9255
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");
9256
9837
  setAiContextMeter(suggestion.contextUsage);
9257
9838
  assistantContent = suggestion.content;
9258
9839
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
@@ -9275,10 +9856,12 @@ async function sendAi() {
9275
9856
  } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
9276
9857
  }
9277
9858
  } catch (error) {
9859
+ setAiAssistantStatus("error");
9278
9860
  if (suggestion) appendSuggestion(suggestion);
9279
9861
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
9280
9862
  }
9281
9863
  } catch (error) {
9864
+ setAiAssistantStatus("error");
9282
9865
  const failureMessage = formatAiFailureMessage(error);
9283
9866
  let persistedFailureMessage = null;
9284
9867
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -9406,6 +9989,7 @@ async function streamChat(body) {
9406
9989
  const toolCall = { ...payload };
9407
9990
  const round = toolCall.round;
9408
9991
  delete toolCall.round;
9992
+ if (toolCall.status === "failed") setAiAssistantStatus("error");
9409
9993
  toolCalls.push(toolCall);
9410
9994
  processSteps.push(aiToolProcessStep(toolCall, round));
9411
9995
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
@@ -9433,6 +10017,7 @@ async function streamChat(body) {
9433
10017
  attachAssistantCopyAction(message, streamedText);
9434
10018
  scrollAiFeedToBottom();
9435
10019
  } else if (eventName === "error") {
10020
+ setAiAssistantStatus("error");
9436
10021
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
9437
10022
  }
9438
10023
  };
@@ -9877,6 +10462,28 @@ $("#onboarding-dialog").addEventListener("cancel", (event) => {
9877
10462
  event.preventDefault();
9878
10463
  completeOnboarding();
9879
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);
9880
10487
  $("#onboarding-dialog").addEventListener("keydown", (event) => {
9881
10488
  if (event.key === "Escape") {
9882
10489
  event.preventDefault();
@@ -10381,6 +10988,7 @@ $("#register-form").addEventListener("submit", async (event) => {
10381
10988
  username: form.get("username"),
10382
10989
  password: form.get("password"),
10383
10990
  passwordConfirmation: form.get("passwordConfirmation"),
10991
+ setupToken: form.get("setupToken") || undefined,
10384
10992
  captchaId: form.get("captchaId"),
10385
10993
  captchaAnswer: form.get("captchaAnswer")
10386
10994
  }
@@ -10414,10 +11022,20 @@ $("#writing-progress-button").addEventListener("click", () => openWritingProgres
10414
11022
  $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
10415
11023
  $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
10416
11024
  $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
10417
- $("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
10418
- $("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
10419
- $("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
10420
- $("#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
+ });
10421
11039
  $("#work-audit-load-more").addEventListener("click", () => {
10422
11040
  if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
10423
11041
  });
@@ -10480,6 +11098,7 @@ $("#form-dialog").addEventListener("close", () => {
10480
11098
  formDialogVditors.forEach(destroyVditorEditor);
10481
11099
  formDialogVditors = [];
10482
11100
  void discardPendingMarkdownAttachments();
11101
+ closeManuscriptExportMenu();
10483
11102
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
10484
11103
  });
10485
11104
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -10839,6 +11458,9 @@ document.addEventListener("pointerdown", (event) => {
10839
11458
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
10840
11459
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10841
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
+ }
10842
11464
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10843
11465
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10844
11466
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -10861,6 +11483,7 @@ document.addEventListener("keydown", (event) => {
10861
11483
  closeChapterTypeMenu();
10862
11484
  closeLineCitationMenu();
10863
11485
  closeMarkdownTableMenu(true);
11486
+ closeManuscriptExportMenu();
10864
11487
  hideAiMentionMenu();
10865
11488
  setAiContextDistributionVisible(false);
10866
11489
  }
@@ -11013,8 +11636,39 @@ $("#search-form").addEventListener("submit", async (event) => {
11013
11636
  $("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
11014
11637
  });
11015
11638
  });
11016
- $("#export-button").addEventListener("click", () => downloadWorkManuscript(state.work));
11017
- 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" }));
11018
11672
 
11019
11673
  initializePage().catch((error) => {
11020
11674
  restoringPageRoute = false;