@musnows/scriverse 0.6.2 → 0.6.4

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 (40) hide show
  1. package/dist/ai-protocol.js +22 -9
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +913 -195
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +245 -46
  8. package/dist/app.js.map +1 -1
  9. package/dist/cli-core.js +23 -13
  10. package/dist/cli-core.js.map +1 -1
  11. package/dist/database.js +251 -2
  12. package/dist/database.js.map +1 -1
  13. package/dist/docx-export.js +89 -0
  14. package/dist/docx-export.js.map +1 -0
  15. package/dist/google-vertex-auth.js +155 -0
  16. package/dist/google-vertex-auth.js.map +1 -0
  17. package/dist/image-captcha.js +8 -5
  18. package/dist/image-captcha.js.map +1 -1
  19. package/dist/public/ai-context-meter.js +4 -0
  20. package/dist/public/ai-mentions.js +10 -0
  21. package/dist/public/ai-message-time.js +3 -9
  22. package/dist/public/ai-tool-call.js +4 -0
  23. package/dist/public/app.js +1004 -131
  24. package/dist/public/display-labels.js +2 -1
  25. package/dist/public/index.html +75 -16
  26. package/dist/public/page-route.js +2 -2
  27. package/dist/public/styles.css +155 -19
  28. package/dist/public/system-status.d.ts +12 -0
  29. package/dist/public/system-status.js +16 -0
  30. package/dist/public/theme-init.js +2 -2
  31. package/dist/security.js +101 -9
  32. package/dist/security.js.map +1 -1
  33. package/dist/store.js +290 -21
  34. package/dist/store.js.map +1 -1
  35. package/dist/user-auth.js +62 -24
  36. package/dist/user-auth.js.map +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/writing-progress-time.js +23 -0
  39. package/dist/writing-progress-time.js.map +1 -1
  40. package/package.json +2 -1
@@ -1,7 +1,7 @@
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
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
- import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
4
+ import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260801-context-lock-v2";
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";
7
7
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
@@ -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";
@@ -35,7 +36,7 @@ import {
35
36
  taskScopeLabel,
36
37
  timelineStatusLabel,
37
38
  characterStateFieldLabel
38
- } from "/display-labels.js?v=20260728-hybrid-search-v1";
39
+ } from "/display-labels.js?v=20260801-google-vertex";
39
40
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
40
41
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
41
42
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -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,
@@ -108,8 +110,11 @@ const state = {
108
110
  aiCitations: [],
109
111
  aiReferences: [],
110
112
  aiPromptSent: false,
113
+ aiTaskType: "chat",
114
+ aiContextScope: { type: "none" },
111
115
  aiConversationId: null,
112
116
  aiConversations: [],
117
+ aiRoleplayCharacter: null,
113
118
  aiLastMessageAt: null,
114
119
  settings: [],
115
120
  dirty: false,
@@ -152,6 +157,7 @@ function createPresenceClientId() {
152
157
 
153
158
  const presenceClientId = createPresenceClientId();
154
159
  const presenceHeartbeatInterval = 12_000;
160
+ const systemBootCheckInterval = 8_000;
155
161
  let presenceParticipants = [];
156
162
  let presenceHeartbeatTimer = null;
157
163
  let presenceHeartbeatQueued = null;
@@ -160,6 +166,10 @@ const acknowledgedCollaborativeChangeIds = new Set();
160
166
  let collaborativeChangePromptOpen = false;
161
167
  let relationshipPresenceId = null;
162
168
  let collaborationAutoSaveDisabled = false;
169
+ let systemBootId = null;
170
+ let systemBootCheckTimer = null;
171
+ let systemBootCheckPromise = null;
172
+ let systemRestartDetected = false;
163
173
  let chapterAnnotations = [];
164
174
  let workAuditRecords = [];
165
175
  let workAuditNextPage = null;
@@ -177,6 +187,10 @@ let backgroundTaskCenterWorkId = null;
177
187
  let backgroundTaskCenterTasksInitialized = false;
178
188
  let backgroundTaskCenterTaskSnapshots = new Map();
179
189
  let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
190
+ const systemHealthPollInterval = 30_000;
191
+ let systemHealthTimer = null;
192
+ let systemHealthSnapshot = { status: "checking", version: "" };
193
+ let topbarStatusSource = "view";
180
194
  const taskProgressRefreshInterval = 2_500;
181
195
  const taskStatusSnapshots = new Map();
182
196
 
@@ -325,6 +339,7 @@ function applyWorkAccessMode() {
325
339
  $("#ai-prompt").readOnly = aiReadOnly;
326
340
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
327
341
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
342
+ renderAiRoleplayCharacterSelect();
328
343
  updateBackgroundTaskCenterVisibility();
329
344
  if (proseReadOnly) {
330
345
  chapterEditorReadOnly = true;
@@ -338,6 +353,15 @@ const $ = (selector) => document.querySelector(selector);
338
353
  const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
339
354
  const maximumAvatarFileSize = 5 * 1024 * 1024;
340
355
 
356
+ function setAiAssistantStatus(status) {
357
+ const failed = status === "error";
358
+ const label = failed ? "创作助手状态:执行失败" : "创作助手状态:正常";
359
+ const dot = $("#ai-status-dot");
360
+ dot.classList.toggle("is-error", failed);
361
+ dot.setAttribute("aria-label", label);
362
+ dot.title = label;
363
+ }
364
+
341
365
  function userAvatarInitial(user) {
342
366
  return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
343
367
  }
@@ -395,6 +419,7 @@ let aiReferencesLoadPromise = null;
395
419
  let aiReferencesLoadWorkId = null;
396
420
  let aiConversationsLoadPromise = null;
397
421
  let aiConversationsLoadWorkId = null;
422
+ let latestAiContextUsage = null;
398
423
  const aiConversationHistoryPageLimit = 20;
399
424
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
400
425
  let workScopedUiGeneration = 0;
@@ -614,7 +639,7 @@ function presencePageForRoute(route = currentPageRoute()) {
614
639
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
615
640
  if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
616
641
  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" };
642
+ if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage" || route.view === "work-audit") return { kind: "settings" };
618
643
  return { kind: "welcome" };
619
644
  }
620
645
 
@@ -780,6 +805,7 @@ function currentPageRoute() {
780
805
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
781
806
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
782
807
  if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
808
+ if (!$("#work-audit-view").classList.contains("hidden")) return { view: "work-audit", workId, ...settingsRouteContext() };
783
809
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
784
810
  if (!workId) return { view: "shelf" };
785
811
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
@@ -1300,7 +1326,7 @@ function renderAiReferences() {
1300
1326
 
1301
1327
  function renderAiQuickActions() {
1302
1328
  const quickActions = $(".quick-actions");
1303
- const visible = shouldShowAiQuickActions(state.aiPromptSent);
1329
+ const visible = !state.aiRoleplayCharacter && shouldShowAiQuickActions(state.aiPromptSent);
1304
1330
  quickActions.classList.toggle("hidden", !visible);
1305
1331
  quickActions.setAttribute("aria-hidden", String(!visible));
1306
1332
  }
@@ -1314,7 +1340,7 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
1314
1340
  role.textContent = label;
1315
1341
  const time = document.createElement("time");
1316
1342
  time.dateTime = timestamp;
1317
- time.textContent = formatAiMessageTime(timestamp, previousCreatedAt);
1343
+ time.textContent = formatAiMessageTime(timestamp);
1318
1344
  heading.append(role, time);
1319
1345
  message.prepend(heading);
1320
1346
  message.dataset.createdAt = timestamp;
@@ -1328,24 +1354,38 @@ function updateMessageCreatedAt(message, createdAt) {
1328
1354
  const time = message.querySelector(".message-heading time");
1329
1355
  if (!time) return;
1330
1356
  time.dateTime = createdAt;
1331
- time.textContent = formatAiMessageTime(createdAt, message.dataset.previousCreatedAt || null);
1357
+ time.textContent = formatAiMessageTime(createdAt);
1332
1358
  message.dataset.createdAt = createdAt;
1333
1359
  if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
1334
1360
  }
1335
1361
 
1336
1362
  function resetAiFeed() {
1337
1363
  state.aiLastMessageAt = null;
1338
- $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1364
+ const roleplayName = state.aiRoleplayCharacter?.name;
1365
+ $("#ai-feed").innerHTML = roleplayName
1366
+ ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
1367
+ : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1339
1368
  }
1340
1369
 
1341
- function appendAiContextCompactionDivider(kind, before = null) {
1370
+ function aiAssistantLabel(suffix = "") {
1371
+ const name = state.aiRoleplayCharacter?.name || "助手";
1372
+ return suffix ? `${name} · ${suffix}` : name;
1373
+ }
1374
+
1375
+ function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
1342
1376
  const divider = document.createElement("div");
1343
1377
  divider.className = "ai-context-compaction-divider";
1344
1378
  divider.dataset.contextCompaction = kind;
1345
1379
  divider.dataset.testid = "ai-context-compaction-divider";
1346
1380
  divider.setAttribute("role", "separator");
1347
- divider.setAttribute("aria-label", "已压缩上下文");
1381
+ divider.setAttribute("aria-label", ariaLabel);
1382
+ if (title) divider.title = title;
1348
1383
  divider.innerHTML = "<span>已压缩上下文</span>";
1384
+ return divider;
1385
+ }
1386
+
1387
+ function appendAiContextCompactionDivider(kind, before = null) {
1388
+ const divider = createAiContextCompactionDivider({ kind });
1349
1389
  const feed = $("#ai-feed");
1350
1390
  if (before?.parentElement === feed) feed.insertBefore(divider, before);
1351
1391
  else feed.append(divider);
@@ -1434,7 +1474,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1434
1474
  grep: "查询正文关键字",
1435
1475
  search_story_entities: "搜索作品实体",
1436
1476
  read_character_sections: "读取人物 Markdown 章节",
1437
- search_drafts: "搜索想法"
1477
+ search_drafts: "搜索想法",
1478
+ recall_self: "回忆自身"
1438
1479
  };
1439
1480
 
1440
1481
  const AI_TOOL_DESCRIPTIONS = {
@@ -1443,7 +1484,8 @@ const AI_TOOL_DESCRIPTIONS = {
1443
1484
  grep: "查询正文关键字所在的完整段落及章节信息。",
1444
1485
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1445
1486
  read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1446
- search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
1487
+ search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1488
+ recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。"
1447
1489
  };
1448
1490
 
1449
1491
  let aiFeedScrollFrame = null;
@@ -1524,8 +1566,10 @@ function openAiToolCallDetail(toolCall) {
1524
1566
  time.textContent = formatAiToolCallTime(calledAt);
1525
1567
  if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
1526
1568
  else time.removeAttribute("datetime");
1569
+ const resultDetails = formatAiToolCallResult(toolCall?.result);
1527
1570
  $("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
1528
- $("#ai-tool-call-result").textContent = JSON.stringify(toolCall?.result ?? {}, null, 2);
1571
+ $("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
1572
+ $("#ai-tool-call-result").textContent = resultDetails.text;
1529
1573
  $("#ai-tool-call-dialog").showModal();
1530
1574
  }
1531
1575
 
@@ -1595,14 +1639,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1595
1639
  list.className = "ai-process-list";
1596
1640
  for (const step of steps) {
1597
1641
  if (step?.type === "context_compaction") {
1598
- const compaction = document.createElement("div");
1599
- compaction.className = "ai-process-context-compaction";
1600
- compaction.dataset.testid = "ai-process-context-compaction";
1601
- compaction.setAttribute("role", "separator");
1602
- compaction.setAttribute("aria-label", `第 ${Number(step.round) || 1} 轮已压缩上下文`);
1603
- compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
1604
- compaction.innerHTML = "<span>已压缩上下文</span>";
1605
- list.append(compaction);
1642
+ list.append(createAiContextCompactionDivider({
1643
+ kind: "tool",
1644
+ ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
1645
+ title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
1646
+ }));
1606
1647
  continue;
1607
1648
  }
1608
1649
  if (step?.type === "tool" && step.toolCall) {
@@ -1663,7 +1704,7 @@ function renderAiConversationHistory() {
1663
1704
  const title = document.createElement("strong");
1664
1705
  title.textContent = conversation.title;
1665
1706
  const meta = document.createElement("small");
1666
- meta.textContent = `${conversation.messageCount} · ${formatDateTime(conversation.updatedAt)}`;
1707
+ meta.textContent = `${conversation.messageCount} 条${conversation.roleplayCharacter?.name ? ` · 扮演 ${conversation.roleplayCharacter.name}` : ""} · ${formatDateTime(conversation.updatedAt)}`;
1667
1708
  button.append(title, meta);
1668
1709
  button.addEventListener("click", () => openAiConversation(conversation.id));
1669
1710
  host.append(button);
@@ -1759,6 +1800,10 @@ async function openAiConversation(conversationId, hideHistory = true) {
1759
1800
  upsertAiConversationSummary(conversation);
1760
1801
  state.aiConversationId = conversation.id;
1761
1802
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1803
+ applyAiConversationTaskType(conversation.taskType);
1804
+ applyAiConversationContextScope(conversation.contextScope);
1805
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1806
+ resetAiContextMeter();
1762
1807
  $("#ai-conversation-title").textContent = conversation.title;
1763
1808
  resetAiFeed();
1764
1809
  for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
@@ -1777,26 +1822,152 @@ async function openAiConversation(conversationId, hideHistory = true) {
1777
1822
  if (hideHistory) setAiHistoryVisible(false);
1778
1823
  }
1779
1824
 
1780
- async function createNewAiConversation() {
1825
+ async function createNewAiConversation(taskType = "chat") {
1781
1826
  if (!state.work) return;
1782
- const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1827
+ const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
1783
1828
  upsertAiConversationSummary(conversation);
1784
1829
  state.aiConversationId = conversation.id;
1785
1830
  state.aiPromptSent = false;
1831
+ applyAiConversationTaskType(conversation.taskType);
1832
+ applyAiConversationContextScope(conversation.contextScope);
1833
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1786
1834
  $("#ai-conversation-title").textContent = conversation.title;
1787
1835
  resetAiFeed();
1788
1836
  hideAiContextWarning();
1789
- setAiContextMeter(null);
1837
+ resetAiContextMeter();
1790
1838
  renderAiQuickActions();
1791
1839
  setAiHistoryVisible(false);
1792
1840
  }
1793
1841
 
1842
+ function renderAiRoleplayCharacterSelect() {
1843
+ const select = $("#ai-roleplay-character");
1844
+ const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
1845
+ const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
1846
+ const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
1847
+ id: String(character.id),
1848
+ name: String(character.name)
1849
+ }))];
1850
+ if (selectedId && !options.some((option) => option.id === selectedId)) {
1851
+ options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
1852
+ }
1853
+ select.replaceChildren(...options.map((option) => {
1854
+ const element = document.createElement("option");
1855
+ element.value = option.id;
1856
+ element.textContent = option.name;
1857
+ return element;
1858
+ }));
1859
+ select.value = selectedId;
1860
+ const canSelectCharacter = Boolean(state.work)
1861
+ && canReadModule("characters")
1862
+ && canWritePermissionModule(state.work, "ai-chat");
1863
+ select.disabled = !canSelectCharacter || state.aiPromptSent;
1864
+ select.title = canSelectCharacter
1865
+ ? "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
1866
+ : "当前账户没有角色模块读取权限";
1867
+ }
1868
+
1869
+ function syncAiTaskOptions() {
1870
+ const roleplaySelected = $("#ai-task").value === "roleplay";
1871
+ $("#ai-scope").classList.toggle("hidden", roleplaySelected);
1872
+ $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
1873
+ $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
1874
+ $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
1875
+ $(".ai-include-setting-info").classList.toggle("hidden", roleplaySelected);
1876
+ $(".ai-include-setting-info").setAttribute("aria-hidden", String(roleplaySelected));
1877
+ $("#ai-task").disabled = state.aiPromptSent;
1878
+ $("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
1879
+ $("#ai-scope").disabled = roleplaySelected || state.aiPromptSent;
1880
+ $("#ai-scope").title = state.aiPromptSent
1881
+ ? "对话开始后不能切换上下文引用"
1882
+ : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
1883
+ syncAiIncludeSettingInfoControl();
1884
+ }
1885
+
1886
+ function applyAiConversationTaskType(taskType) {
1887
+ const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
1888
+ state.aiTaskType = normalizedTaskType;
1889
+ $("#ai-task").value = normalizedTaskType;
1890
+ $("#ai-task").dataset.previousValue = normalizedTaskType;
1891
+ syncAiTaskOptions();
1892
+ }
1893
+
1894
+ function applyAiConversationContextScope(scope) {
1895
+ const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
1896
+ state.aiContextScope = normalizedScope;
1897
+ $("#ai-scope").value = normalizedScope.type === "book" ? "book"
1898
+ : normalizedScope.type === "volume" ? "volume"
1899
+ : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
1900
+ : normalizedScope.type === "chapter" ? "chapter"
1901
+ : "none";
1902
+ $("#ai-include-setting-info").checked = normalizedScope.includeSettingInfo !== false;
1903
+ syncAiTaskOptions();
1904
+ }
1905
+
1906
+ function applyAiRoleplayCharacter(character) {
1907
+ state.aiRoleplayCharacter = character?.id ? character : null;
1908
+ const active = Boolean(state.aiRoleplayCharacter);
1909
+ if (active) $("#ai-scope").value = "none";
1910
+ $(".ai-panel").classList.toggle("is-roleplaying", active);
1911
+ $("#ai-prompt").dataset.placeholder = active
1912
+ ? `以 ${String(state.aiRoleplayCharacter.name)} 的身份开始对话……`
1913
+ : "告诉 AI 你想讨论或修改什么……";
1914
+ renderAiRoleplayCharacterSelect();
1915
+ syncAiTaskOptions();
1916
+ renderAiQuickActions();
1917
+ resetAiContextMeter();
1918
+ }
1919
+
1920
+ function refreshAiMessageRoleLabels() {
1921
+ $("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
1922
+ label.textContent = aiAssistantLabel();
1923
+ });
1924
+ }
1925
+
1926
+ async function updateAiRoleplayCharacter(characterId) {
1927
+ const conversationId = await ensureAiConversation();
1928
+ const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
1929
+ method: "PATCH",
1930
+ body: { characterId: characterId || null }
1931
+ });
1932
+ upsertAiConversationSummary(conversation);
1933
+ applyAiConversationTaskType(conversation.taskType);
1934
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1935
+ if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
1936
+ else resetAiFeed();
1937
+ toast(conversation.roleplayCharacter
1938
+ ? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
1939
+ : "已退出角色扮演模式");
1940
+ }
1941
+
1794
1942
  async function ensureAiConversation() {
1795
1943
  if (state.aiConversationId) return state.aiConversationId;
1796
- await createNewAiConversation();
1944
+ await createNewAiConversation($("#ai-task").value);
1797
1945
  return state.aiConversationId;
1798
1946
  }
1799
1947
 
1948
+ async function persistAiConversationTaskType(taskType) {
1949
+ const conversationId = await ensureAiConversation();
1950
+ const conversation = await api(`/api/ai-conversations/${conversationId}/task-type`, {
1951
+ method: "PATCH",
1952
+ body: { taskType }
1953
+ });
1954
+ upsertAiConversationSummary(conversation);
1955
+ applyAiConversationTaskType(conversation.taskType);
1956
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1957
+ return conversation;
1958
+ }
1959
+
1960
+ async function persistAiConversationContextScope(scope) {
1961
+ const conversationId = await ensureAiConversation();
1962
+ const conversation = await api(`/api/ai-conversations/${conversationId}/context-scope`, {
1963
+ method: "PATCH",
1964
+ body: { scope }
1965
+ });
1966
+ upsertAiConversationSummary(conversation);
1967
+ applyAiConversationContextScope(conversation.contextScope);
1968
+ return conversation;
1969
+ }
1970
+
1800
1971
  async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
1801
1972
  const conversationId = await ensureAiConversation();
1802
1973
  if (!conversationId) throw new Error("无法创建 AI 对话");
@@ -2100,7 +2271,7 @@ function clearChapterLineSelection() {
2100
2271
  }
2101
2272
 
2102
2273
  const typographyStorageKey = "ai-novel-typography-v1";
2103
- const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, density: "balanced" });
2274
+ const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 16, aiFontSize: 14, density: "balanced" });
2104
2275
  const cjkFontStacks = {
2105
2276
  system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
2106
2277
  pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
@@ -2116,15 +2287,21 @@ const latinFontStacks = {
2116
2287
  consolas: 'Consolas, "Liberation Mono", Menlo, Monaco, "SFMono-Regular"'
2117
2288
  };
2118
2289
  const typographyFontSizes = [15, 16, 17, 18, 20];
2290
+ const typographyUiFontSizes = [14, 15, 16, 17, 18];
2291
+ const typographyAiFontSizes = [12, 13, 14, 15, 16];
2119
2292
  const densityLineHeights = { compact: 1.4, balanced: 1.55, relaxed: 1.75 };
2120
2293
 
2121
2294
  function normalizeTypographySettings(input) {
2122
2295
  const value = input && typeof input === "object" ? input : {};
2123
2296
  const fontSize = Number(value.fontSize);
2297
+ const uiFontSize = Number(value.uiFontSize);
2298
+ const aiFontSize = Number(value.aiFontSize);
2124
2299
  return {
2125
2300
  cjkFont: Object.hasOwn(cjkFontStacks, value.cjkFont) ? value.cjkFont : typographyDefaults.cjkFont,
2126
2301
  latinFont: Object.hasOwn(latinFontStacks, value.latinFont) ? value.latinFont : typographyDefaults.latinFont,
2127
2302
  fontSize: typographyFontSizes.includes(fontSize) ? fontSize : typographyDefaults.fontSize,
2303
+ uiFontSize: typographyUiFontSizes.includes(uiFontSize) ? uiFontSize : typographyDefaults.uiFontSize,
2304
+ aiFontSize: typographyAiFontSizes.includes(aiFontSize) ? aiFontSize : typographyDefaults.aiFontSize,
2128
2305
  density: Object.hasOwn(densityLineHeights, value.density) ? value.density : typographyDefaults.density
2129
2306
  };
2130
2307
  }
@@ -2176,9 +2353,15 @@ function applyTypographySettings(settings) {
2176
2353
  root.style.setProperty("--font-latin", latinFontStacks[normalized.latinFont]);
2177
2354
  root.style.setProperty("--editor-font-size", `${normalized.fontSize}px`);
2178
2355
  root.style.setProperty("--editor-line-height", String(densityLineHeights[normalized.density]));
2356
+ root.style.setProperty("--ui-font-size", `${normalized.uiFontSize}px`);
2357
+ root.style.setProperty("--ui-font-scale", String(normalized.uiFontSize / typographyDefaults.uiFontSize));
2358
+ root.style.setProperty("--ai-font-size", `${normalized.aiFontSize}px`);
2359
+ root.style.setProperty("--ai-font-scale", String(normalized.aiFontSize / typographyDefaults.aiFontSize));
2179
2360
  root.dataset.cjkFont = normalized.cjkFont;
2180
2361
  root.dataset.latinFont = normalized.latinFont;
2181
2362
  root.dataset.fontSize = String(normalized.fontSize);
2363
+ root.dataset.uiFontSize = String(normalized.uiFontSize);
2364
+ root.dataset.aiFontSize = String(normalized.aiFontSize);
2182
2365
  root.dataset.density = normalized.density;
2183
2366
  scheduleChapterLineNumbers();
2184
2367
  }
@@ -2199,6 +2382,8 @@ function fillAppearanceForm(settings) {
2199
2382
  $("#appearance-cjk-font").value = normalized.cjkFont;
2200
2383
  $("#appearance-latin-font").value = normalized.latinFont;
2201
2384
  $("#appearance-font-size").value = String(normalized.fontSize);
2385
+ $("#appearance-ui-font-size").value = String(normalized.uiFontSize);
2386
+ $("#appearance-ai-font-size").value = String(normalized.aiFontSize);
2202
2387
  $("#appearance-density").value = normalized.density;
2203
2388
  }
2204
2389
 
@@ -2208,6 +2393,8 @@ function readAppearanceForm() {
2208
2393
  cjkFont: form.get("cjkFont"),
2209
2394
  latinFont: form.get("latinFont"),
2210
2395
  fontSize: form.get("fontSize"),
2396
+ uiFontSize: form.get("uiFontSize"),
2397
+ aiFontSize: form.get("aiFontSize"),
2211
2398
  density: form.get("density")
2212
2399
  });
2213
2400
  }
@@ -2218,6 +2405,7 @@ function renderTypographyPreview() {
2218
2405
  preview.style.fontFamily = `${latinFontStacks[settings.latinFont]}, ${cjkFontStacks[settings.cjkFont]}, monospace, sans-serif`;
2219
2406
  preview.style.fontSize = `${settings.fontSize}px`;
2220
2407
  preview.style.lineHeight = String(densityLineHeights[settings.density]);
2408
+ $("#font-size-preview").textContent = `界面 ${settings.uiFontSize} px · Agent 对话 ${settings.aiFontSize} px`;
2221
2409
  }
2222
2410
 
2223
2411
  function openAppearanceDialog() {
@@ -2296,19 +2484,29 @@ async function api(path, options = {}) {
2296
2484
  const headers = { ...(options.headers ?? {}) };
2297
2485
  if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
2298
2486
  if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
2299
- const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
2300
- ...options,
2301
- headers,
2302
- body: body && typeof body !== "string" ? JSON.stringify(body) : body
2303
- });
2487
+ let response;
2488
+ try {
2489
+ response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
2490
+ ...options,
2491
+ headers,
2492
+ body: body && typeof body !== "string" ? JSON.stringify(body) : body
2493
+ });
2494
+ } catch (error) {
2495
+ updateSystemHealth({ status: "offline" });
2496
+ throw error;
2497
+ }
2498
+ updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
2304
2499
  if (!response.ok) {
2305
2500
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
2306
2501
  // Presence is best-effort; a heartbeat 401 must not force the login wall.
2307
2502
  if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
2308
- state.user = null;
2309
- state.csrfToken = null;
2310
- moduleRequestCache.clear();
2311
- showAuth(false);
2503
+ const restarted = await checkSystemBoot(true);
2504
+ if (!restarted) {
2505
+ state.user = null;
2506
+ state.csrfToken = null;
2507
+ moduleRequestCache.clear();
2508
+ showAuth(false);
2509
+ }
2312
2510
  }
2313
2511
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2314
2512
  }
@@ -2317,6 +2515,12 @@ async function api(path, options = {}) {
2317
2515
  return null;
2318
2516
  }
2319
2517
  const payload = await response.json();
2518
+ if (path === "/api/health") {
2519
+ updateSystemHealth({
2520
+ status: payload.data?.status === "ok" ? "ready" : "degraded",
2521
+ version: payload.data?.version
2522
+ });
2523
+ }
2320
2524
  invalidateModuleRequestsAfterMutation(path, method);
2321
2525
  return payload.data;
2322
2526
  }
@@ -2351,9 +2555,9 @@ function formatAiFailureMessage(error) {
2351
2555
  if (status) lines.push(`服务端状态:HTTP ${status}`);
2352
2556
  if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2353
2557
  if (modelId) lines.push(`模型 ID:${modelId}`);
2354
- if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2355
2558
  if (callId) lines.push(`调用 ID:${callId}`);
2356
- return lines.join("\n\n");
2559
+ if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2560
+ return lines.join("\n");
2357
2561
  }
2358
2562
 
2359
2563
  async function apiPage(path, page = 1, limit = 30) {
@@ -2431,23 +2635,97 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2431
2635
  affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
2432
2636
  }
2433
2637
 
2638
+ function applyProductHealthMetadata(health) {
2639
+ const version = String(health?.version ?? "").trim();
2640
+ document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
2641
+ element.textContent = version ? `v${version}` : "v—";
2642
+ });
2643
+ document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
2644
+ element.classList.toggle("hidden", health?.development !== true);
2645
+ });
2646
+ }
2647
+
2648
+ function scheduleSystemHealthCheck() {
2649
+ if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
2650
+ systemHealthTimer = window.setTimeout(() => {
2651
+ systemHealthTimer = null;
2652
+ void refreshSystemHealth();
2653
+ }, systemHealthPollInterval);
2654
+ }
2655
+
2656
+ async function refreshSystemHealth() {
2657
+ try {
2658
+ const health = await api("/api/health");
2659
+ applyProductHealthMetadata(health);
2660
+ } catch {
2661
+ // 系统状态已由 api 记录;健康检查失败不弹出重复提示
2662
+ } finally {
2663
+ scheduleSystemHealthCheck();
2664
+ }
2665
+ }
2666
+
2434
2667
  async function initializeProductFooters() {
2435
2668
  const year = String(new Date().getFullYear());
2436
2669
  document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
2670
+ applyProductHealthMetadata(null);
2671
+ await refreshSystemHealth();
2672
+ }
2673
+
2674
+ function observeSystemBootId(value) {
2675
+ const nextBootId = typeof value === "string" ? value.trim() : "";
2676
+ if (!nextBootId) return false;
2677
+ if (!systemBootId) {
2678
+ systemBootId = nextBootId;
2679
+ return false;
2680
+ }
2681
+ if (nextBootId === systemBootId) return false;
2682
+ systemRestartDetected = true;
2683
+ if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
2684
+ systemBootCheckTimer = null;
2685
+ const dialog = $("#system-restart-dialog");
2686
+ if (!dialog.open) dialog.showModal();
2687
+ window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
2688
+ return true;
2689
+ }
2690
+
2691
+ async function checkSystemBoot(forceFresh = false) {
2692
+ if (!state.user || systemRestartDetected) return systemRestartDetected;
2693
+ if (systemBootCheckPromise) {
2694
+ if (!forceFresh) return systemBootCheckPromise;
2695
+ await systemBootCheckPromise;
2696
+ if (systemRestartDetected) return true;
2697
+ }
2698
+ systemBootCheckPromise = (async () => {
2699
+ try {
2700
+ const response = await fetch("/api/health", {
2701
+ cache: "no-store",
2702
+ headers: { Accept: "application/json" }
2703
+ });
2704
+ if (!response.ok) return false;
2705
+ const payload = await response.json();
2706
+ return observeSystemBootId(payload?.data?.bootId);
2707
+ } catch {
2708
+ return false;
2709
+ }
2710
+ })();
2437
2711
  try {
2438
- const health = await api("/api/health");
2439
- const version = String(health.version ?? "").trim();
2440
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
2441
- element.textContent = version ? `v${version}` : "v—";
2442
- });
2443
- document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
2444
- element.classList.toggle("hidden", health.development !== true);
2445
- });
2446
- } catch {
2447
- document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
2712
+ return await systemBootCheckPromise;
2713
+ } finally {
2714
+ systemBootCheckPromise = null;
2448
2715
  }
2449
2716
  }
2450
2717
 
2718
+ function scheduleSystemBootCheck(delay = systemBootCheckInterval) {
2719
+ if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
2720
+ systemBootCheckTimer = null;
2721
+ if (!state.user || systemRestartDetected) return;
2722
+ systemBootCheckTimer = setTimeout(async () => {
2723
+ systemBootCheckTimer = null;
2724
+ await checkSystemBoot();
2725
+ scheduleSystemBootCheck();
2726
+ }, delay);
2727
+ }
2728
+
2451
2729
  function selectAuthMode(mode) {
2452
2730
  const registerTab = $("#auth-register-tab");
2453
2731
  const login = mode === "login" || registerTab.disabled;
@@ -2589,7 +2867,9 @@ async function initializeAuthentication() {
2589
2867
  }
2590
2868
  // 已登录却停在登录页路由时,回到书架首页
2591
2869
  if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
2870
+ observeSystemBootId(session.bootId);
2592
2871
  applyAuthenticatedUser(session);
2872
+ scheduleSystemBootCheck();
2593
2873
  await loadPlatformUiSettings();
2594
2874
  return true;
2595
2875
  }
@@ -2745,10 +3025,53 @@ document.addEventListener("toggle", (event) => {
2745
3025
  }
2746
3026
  }, true);
2747
3027
 
3028
+ function paintTopbarStatus(text, { source, tone, title }) {
3029
+ const element = $("#save-state");
3030
+ const colors = { ok: "var(--green)", pending: "var(--muted)", error: "var(--accent)" };
3031
+ topbarStatusSource = source;
3032
+ element.textContent = text;
3033
+ element.style.color = colors[tone] ?? colors.ok;
3034
+ element.dataset.statusSource = source;
3035
+ element.setAttribute("aria-label", `${source === "system" ? "系统" : "工作台"}状态:${text}`);
3036
+ element.title = title || text;
3037
+ }
3038
+
3039
+ function setTopbarViewState(text) {
3040
+ state.dirty = false;
3041
+ paintTopbarStatus(text, { source: "view", tone: "ok", title: text });
3042
+ }
3043
+
3044
+ function updateSystemHealth(next) {
3045
+ systemHealthSnapshot = {
3046
+ ...systemHealthSnapshot,
3047
+ ...next,
3048
+ version: next.version === undefined ? systemHealthSnapshot.version : String(next.version ?? "")
3049
+ };
3050
+ if (topbarStatusSource === "system") renderSystemHealth();
3051
+ }
3052
+
3053
+ function renderSystemHealth() {
3054
+ const presentation = systemStatusPresentation(systemHealthSnapshot);
3055
+ paintTopbarStatus(presentation.label, {
3056
+ source: "system",
3057
+ tone: presentation.tone,
3058
+ title: presentation.title
3059
+ });
3060
+ }
3061
+
3062
+ function showSystemStatus() {
3063
+ state.dirty = false;
3064
+ topbarStatusSource = "system";
3065
+ renderSystemHealth();
3066
+ }
3067
+
2748
3068
  function setSaveState(text, dirty = false) {
2749
3069
  state.dirty = dirty;
2750
- $("#save-state").textContent = text;
2751
- $("#save-state").style.color = dirty ? "var(--accent)" : "var(--green)";
3070
+ paintTopbarStatus(text, {
3071
+ source: "save",
3072
+ tone: dirty ? "error" : "ok",
3073
+ title: text
3074
+ });
2752
3075
  }
2753
3076
 
2754
3077
  function chapterDraftSnapshot() {
@@ -2990,6 +3313,11 @@ async function initializePage() {
2990
3313
  if (route.view === "platform-usage") {
2991
3314
  await showPlatformUsage();
2992
3315
  settingsReturnContext = restoredSettingsReturnContext(route);
3316
+ return;
3317
+ }
3318
+ if (route.view === "work-audit") {
3319
+ await showWorkAudit();
3320
+ settingsReturnContext = restoredSettingsReturnContext(route);
2993
3321
  }
2994
3322
  } finally {
2995
3323
  document.body.classList.remove("auth-pending");
@@ -3009,6 +3337,7 @@ function showShelf() {
3009
3337
  $("#shelf-view").classList.remove("hidden");
3010
3338
  $("#platform-ai-view").classList.add("hidden");
3011
3339
  $("#platform-usage-view").classList.add("hidden");
3340
+ $("#work-audit-view").classList.add("hidden");
3012
3341
  $("#settings-hub-view").classList.add("hidden");
3013
3342
  $("#welcome-view").classList.add("hidden");
3014
3343
  $("#editor-view").classList.add("hidden");
@@ -3016,7 +3345,7 @@ function showShelf() {
3016
3345
  $("#work-meta").textContent = `${state.works.length} 部作品`;
3017
3346
  $("#settings-button").removeAttribute("aria-current");
3018
3347
  $("#top-search-button").disabled = true;
3019
- setSaveState("书架");
3348
+ setTopbarViewState("书架");
3020
3349
  renderShelf();
3021
3350
  replacePageRoute({ view: "shelf" });
3022
3351
  }
@@ -3033,7 +3362,7 @@ function renderSettingsHub() {
3033
3362
  const hasWork = Boolean(state.work);
3034
3363
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
3035
3364
  const canReadAggregate = hasWork && canReadAggregateContent();
3036
- const canReadFullExport = canReadAggregate && canReadModule("drafts");
3365
+ const canExportManuscript = hasWork && canReadModule("editor");
3037
3366
  const isAdmin = state.user?.role === "admin";
3038
3367
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
3039
3368
  $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
@@ -3043,10 +3372,11 @@ function renderSettingsHub() {
3043
3372
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
3044
3373
  $("#work-audit-button").disabled = !canManageWork;
3045
3374
  $("#top-search-button").disabled = !canReadAggregate;
3046
- $("#export-button").disabled = !canReadFullExport;
3375
+ $("#export-button").disabled = !canExportManuscript;
3376
+ $("#export-button").setAttribute("aria-expanded", "false");
3047
3377
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
3048
3378
  $("#settings-work-note").textContent = hasWork
3049
- ? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
3379
+ ? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP DOCX;DOCX 在有封面时会嵌入为首页。`
3050
3380
  : "当前未选择作品;打开作品后可使用导出。";
3051
3381
  }
3052
3382
 
@@ -3115,36 +3445,173 @@ async function openWritingProgressDialog() {
3115
3445
  const workAuditActionLabels = {
3116
3446
  "work.created": "创建作品",
3117
3447
  "work.updated": "更新作品",
3448
+ "work.cover.updated": "更新作品封面",
3449
+ "work.cover.deleted": "删除作品封面",
3450
+ "work.member-added": "添加作品成员",
3451
+ "work.member-role-updated": "更新成员权限",
3452
+ "work.member-removed": "移除作品成员",
3453
+ "work.writing_goal.updated": "更新写作目标",
3118
3454
  "volume.created": "创建分卷",
3119
3455
  "volume.updated": "更新分卷",
3120
3456
  "volume.deleted": "删除分卷",
3457
+ "volume.restored": "恢复分卷",
3121
3458
  "chapter.created": "创建章节",
3122
3459
  "chapter.saved": "保存章节",
3123
3460
  "chapter.moved": "移动章节",
3124
3461
  "chapter.deleted": "删除章节",
3125
3462
  "chapter.purged": "彻底删除章节",
3126
3463
  "chapter.restored": "恢复章节",
3464
+ "chapter.annotation.created": "添加正文评论",
3465
+ "chapter.annotation.updated": "更新正文评论",
3466
+ "chapter.annotation.deleted": "删除正文评论",
3127
3467
  "draft.created": "创建想法",
3128
3468
  "draft.updated": "更新想法",
3129
3469
  "draft.deleted": "删除想法",
3130
3470
  "draft.restored": "恢复想法",
3471
+ "setting.created": "创建设定",
3472
+ "setting.updated": "更新设定",
3473
+ "setting.deleted": "删除设定",
3474
+ "setting.restored": "恢复设定",
3475
+ "character.created": "创建角色",
3476
+ "character.updated": "更新角色",
3477
+ "character.deleted": "删除角色",
3478
+ "character.restored": "恢复角色",
3479
+ "race.created": "创建种族",
3480
+ "race.updated": "更新种族",
3481
+ "race.deleted": "删除种族",
3482
+ "race.restored": "恢复种族",
3483
+ "organization.created": "创建组织",
3484
+ "organization.updated": "更新组织",
3485
+ "organization.deleted": "删除组织",
3486
+ "organization.restored": "恢复组织",
3487
+ "timeline-track.created": "创建时间轴",
3488
+ "timeline-track.updated": "更新时间轴",
3489
+ "timeline-track.deleted": "删除时间轴",
3490
+ "timeline-track.restored": "恢复时间轴",
3491
+ "timeline.created": "创建时间事件",
3492
+ "timeline.updated": "更新时间事件",
3493
+ "timeline.deleted": "删除时间事件",
3494
+ "timeline.restored": "恢复时间事件",
3495
+ "relationship.created": "创建人物关系",
3496
+ "relationship.updated": "更新人物关系",
3497
+ "relationship.deleted": "删除人物关系",
3498
+ "relationship.restored": "恢复人物关系",
3499
+ "outline.created": "创建章节大纲",
3500
+ "outline.updated": "更新章节大纲",
3501
+ "outline.deleted": "删除章节大纲",
3502
+ "foreshadow.created": "创建伏笔",
3503
+ "foreshadow.updated": "更新伏笔",
3504
+ "foreshadow.deleted": "删除伏笔",
3505
+ "foreshadow.restored": "恢复伏笔",
3506
+ "task.created": "创建 AI 分析任务",
3507
+ "task.cancelled": "取消 AI 分析任务",
3508
+ "attachment.created": "创建附件",
3509
+ "attachment.deleted": "删除附件",
3510
+ "attachment.garbage-collected": "清理未引用附件",
3511
+ "file.restored": "恢复导入快照",
3131
3512
  "work.imported": "导入正文"
3132
3513
  };
3133
3514
 
3134
3515
  function workAuditEntityLabel(type) {
3135
- return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
3516
+ return ({
3517
+ work: "作品",
3518
+ volume: "分卷",
3519
+ chapter: "章节",
3520
+ draft: "想法",
3521
+ user: "用户",
3522
+ setting: "设定",
3523
+ character: "角色",
3524
+ race: "种族",
3525
+ organization: "组织",
3526
+ "timeline-track": "时间轴",
3527
+ "timeline-event": "时间事件",
3528
+ relationship: "人物关系",
3529
+ "chapter-outline": "章节大纲",
3530
+ foreshadow: "伏笔",
3531
+ "chapter-annotation": "正文评论",
3532
+ attachment: "附件",
3533
+ "file-version": "导入快照",
3534
+ "analysis-task": "AI 分析任务",
3535
+ review: "审核"
3536
+ })[type] ?? type;
3537
+ }
3538
+
3539
+ const workAuditDetailLabels = {
3540
+ fields: "变更字段",
3541
+ versionNo: "版本号",
3542
+ fromVersion: "来源版本",
3543
+ source: "操作来源",
3544
+ sourceRef: "来源引用",
3545
+ changeNote: "变更说明",
3546
+ name: "名称",
3547
+ description: "说明",
3548
+ chapterType: "章节类型",
3549
+ volumeId: "所属分卷",
3550
+ previousVolumeId: "原分卷",
3551
+ sortOrder: "排序位置",
3552
+ timeLabel: "时间标签",
3553
+ location: "地点",
3554
+ eventType: "事件类型",
3555
+ batch: "批量操作",
3556
+ recoverable: "可恢复",
3557
+ excludedFromAnalysis: "排除 AI 分析",
3558
+ startLine: "起始行",
3559
+ endLine: "结束行",
3560
+ characterId: "角色 ID",
3561
+ restorePointId: "恢复点 ID",
3562
+ storageKey: "存储位置",
3563
+ byteLength: "文件字节数",
3564
+ mimeType: "文件类型",
3565
+ role: "成员角色",
3566
+ status: "状态",
3567
+ previousStatus: "原状态",
3568
+ dailyGoal: "每日目标",
3569
+ targetTotal: "总字数目标",
3570
+ deadline: "计划完成日期",
3571
+ title: "标题",
3572
+ reason: "原因"
3573
+ };
3574
+
3575
+ function workAuditDetailValue(value) {
3576
+ if (typeof value === "boolean") return value ? "是" : "否";
3577
+ if (Array.isArray(value)) return value.map((item) => typeof item === "object" ? JSON.stringify(item) : String(item)).join("、");
3578
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
3579
+ return String(value);
3580
+ }
3581
+
3582
+ function workAuditDetailEntries(detail) {
3583
+ if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
3584
+ return Object.entries(detail)
3585
+ .filter(([, value]) => value !== null && value !== undefined && value !== "")
3586
+ .map(([key, value]) => ({ label: workAuditDetailLabels[key] ?? key, value: workAuditDetailValue(value) }));
3136
3587
  }
3137
3588
 
3138
- function workAuditDetailText(detail) {
3139
- const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
3140
- return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
3589
+ function workAuditTimestamp(createdAt) {
3590
+ const formatted = formatDateTime(createdAt);
3591
+ const parts = formatted.split(/\s+/u);
3592
+ return { date: parts[0] || "—", time: parts.slice(1).join(" ") || "—" };
3141
3593
  }
3142
3594
 
3143
3595
  function renderWorkAuditRecords() {
3144
- $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
3145
- <time>${esc(formatDateTime(record.createdAt))}</time>
3146
- <div><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><span>${esc(record.actor)} · ${esc(workAuditEntityLabel(record.entityType))}${record.entityId ? ` · ${esc(record.entityId)}` : ""}</span>${workAuditDetailText(record.detail) ? `<small>${esc(workAuditDetailText(record.detail))}</small>` : ""}</div>
3147
- </article>`).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
3596
+ $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => {
3597
+ const timestamp = workAuditTimestamp(record.createdAt);
3598
+ const details = workAuditDetailEntries(record.detail);
3599
+ return `<article class="work-audit-row">
3600
+ <header class="work-audit-time"><time datetime="${esc(record.createdAt)}"><span>${esc(timestamp.date)}</span><strong>${esc(timestamp.time)}</strong></time></header>
3601
+ <div class="work-audit-event">
3602
+ <div class="work-audit-event-heading"><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><code>${esc(record.action)}</code></div>
3603
+ <dl class="work-audit-meta">
3604
+ <div><dt>操作者</dt><dd>${esc(record.actor || "system")}</dd></div>
3605
+ <div><dt>对象类型</dt><dd>${esc(workAuditEntityLabel(record.entityType))}</dd></div>
3606
+ <div><dt>对象 ID</dt><dd><code>${record.entityId ? esc(record.entityId) : "—"}</code></dd></div>
3607
+ </dl>
3608
+ ${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>` : ""}
3609
+ </div>
3610
+ </article>`;
3611
+ }).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
3612
+ $("#work-audit-summary").textContent = workAuditRecords.length
3613
+ ? `已显示 ${workAuditRecords.length} 条记录${workAuditNextPage === null ? ",已加载全部记录" : ",还有更多记录可继续加载"}`
3614
+ : "当前作品还没有操作记录。";
3148
3615
  $("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
3149
3616
  }
3150
3617
 
@@ -3156,18 +3623,36 @@ async function loadWorkAuditPage(page = 1, append = false) {
3156
3623
  renderWorkAuditRecords();
3157
3624
  }
3158
3625
 
3159
- async function openWorkAuditDialog() {
3160
- if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
3626
+ async function showWorkAudit() {
3627
+ if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return false;
3161
3628
  workAuditRecords = [];
3162
3629
  workAuditNextPage = null;
3630
+ dismissChapterInsightToast();
3631
+ updateDocumentTitle(state.work);
3632
+ $("#app").classList.add("shelf-mode");
3633
+ $("#shelf-view").classList.add("hidden");
3634
+ $("#platform-ai-view").classList.add("hidden");
3635
+ $("#platform-usage-view").classList.add("hidden");
3636
+ $("#settings-hub-view").classList.add("hidden");
3637
+ $("#work-audit-view").classList.remove("hidden");
3638
+ $("#welcome-view").classList.add("hidden");
3639
+ $("#editor-view").classList.add("hidden");
3640
+ $("#module-view").classList.add("hidden");
3641
+ $("#work-audit-eyebrow").textContent = `作品安全 · 《${state.work.title}》`;
3642
+ $("#work-meta").textContent = "操作记录";
3643
+ $("#settings-button").setAttribute("aria-current", "page");
3644
+ setTopbarViewState("操作记录");
3645
+ $("#work-audit-summary").textContent = "正在读取操作记录……";
3163
3646
  $("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
3164
- $("#work-audit-dialog").showModal();
3647
+ replacePageRoute({ view: "work-audit", workId: state.work.id, ...settingsRouteContext() });
3165
3648
  try {
3166
3649
  await loadWorkAuditPage();
3167
3650
  } catch (error) {
3168
- $("#work-audit-dialog").close();
3651
+ $("#work-audit-summary").textContent = "操作记录加载失败。";
3652
+ $("#work-audit-list").innerHTML = '<p class="entity-history-empty">暂时无法读取操作记录,请稍后刷新。</p>';
3169
3653
  toast(error.message, "error");
3170
3654
  }
3655
+ return true;
3171
3656
  }
3172
3657
 
3173
3658
  async function saveWritingGoal(event) {
@@ -3468,7 +3953,8 @@ async function openSearchResult(result) {
3468
3953
  $("#search-dialog").close();
3469
3954
  const inSettings = !$("#settings-hub-view").classList.contains("hidden")
3470
3955
  || !$("#platform-ai-view").classList.contains("hidden")
3471
- || !$("#platform-usage-view").classList.contains("hidden");
3956
+ || !$("#platform-usage-view").classList.contains("hidden")
3957
+ || !$("#work-audit-view").classList.contains("hidden");
3472
3958
  if (inSettings) await returnFromSettings();
3473
3959
  if (target.kind === "chapter") {
3474
3960
  await selectChapter(target.id);
@@ -3494,7 +3980,8 @@ async function openSearchResult(result) {
3494
3980
  async function showSettingsHub() {
3495
3981
  const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
3496
3982
  || !$("#platform-ai-view").classList.contains("hidden")
3497
- || !$("#platform-usage-view").classList.contains("hidden");
3983
+ || !$("#platform-usage-view").classList.contains("hidden")
3984
+ || !$("#work-audit-view").classList.contains("hidden");
3498
3985
  if (!alreadyInSettings) {
3499
3986
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
3500
3987
  settingsReturnContext = captureSettingsReturnContext();
@@ -3506,13 +3993,14 @@ async function showSettingsHub() {
3506
3993
  $("#shelf-view").classList.add("hidden");
3507
3994
  $("#platform-ai-view").classList.add("hidden");
3508
3995
  $("#platform-usage-view").classList.add("hidden");
3996
+ $("#work-audit-view").classList.add("hidden");
3509
3997
  $("#settings-hub-view").classList.remove("hidden");
3510
3998
  $("#welcome-view").classList.add("hidden");
3511
3999
  $("#editor-view").classList.add("hidden");
3512
4000
  $("#module-view").classList.add("hidden");
3513
4001
  $("#work-meta").textContent = "设置中心";
3514
4002
  $("#settings-button").setAttribute("aria-current", "page");
3515
- setSaveState("设置");
4003
+ setTopbarViewState("设置");
3516
4004
  renderSettingsHub();
3517
4005
  replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
3518
4006
  return true;
@@ -3532,6 +4020,7 @@ async function returnFromSettings() {
3532
4020
  $("#settings-hub-view").classList.add("hidden");
3533
4021
  $("#platform-ai-view").classList.add("hidden");
3534
4022
  $("#platform-usage-view").classList.add("hidden");
4023
+ $("#work-audit-view").classList.add("hidden");
3535
4024
  if (context.view === "shelf" || !state.work) return showShelf();
3536
4025
  $("#app").classList.remove("shelf-mode");
3537
4026
  $("#shelf-view").classList.add("hidden");
@@ -3551,13 +4040,14 @@ async function showPlatformAi() {
3551
4040
  $("#shelf-view").classList.add("hidden");
3552
4041
  $("#platform-ai-view").classList.remove("hidden");
3553
4042
  $("#platform-usage-view").classList.add("hidden");
4043
+ $("#work-audit-view").classList.add("hidden");
3554
4044
  $("#settings-hub-view").classList.add("hidden");
3555
4045
  $("#welcome-view").classList.add("hidden");
3556
4046
  $("#editor-view").classList.add("hidden");
3557
4047
  $("#module-view").classList.add("hidden");
3558
4048
  $("#work-meta").textContent = "平台 AI 管理";
3559
4049
  $("#settings-button").setAttribute("aria-current", "page");
3560
- setSaveState("平台 AI");
4050
+ setTopbarViewState("平台 AI");
3561
4051
  await renderPlatformAiConfig();
3562
4052
  replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
3563
4053
  return true;
@@ -3571,13 +4061,14 @@ async function showPlatformUsage() {
3571
4061
  $("#shelf-view").classList.add("hidden");
3572
4062
  $("#platform-ai-view").classList.add("hidden");
3573
4063
  $("#platform-usage-view").classList.remove("hidden");
4064
+ $("#work-audit-view").classList.add("hidden");
3574
4065
  $("#settings-hub-view").classList.add("hidden");
3575
4066
  $("#welcome-view").classList.add("hidden");
3576
4067
  $("#editor-view").classList.add("hidden");
3577
4068
  $("#module-view").classList.add("hidden");
3578
4069
  $("#work-meta").textContent = "Token 用量";
3579
4070
  $("#settings-button").setAttribute("aria-current", "page");
3580
- setSaveState("Token 用量");
4071
+ setTopbarViewState("Token 用量");
3581
4072
  await renderPlatformTokenUsage();
3582
4073
  replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
3583
4074
  return true;
@@ -3645,13 +4136,17 @@ function resetWorkScopedUiCaches() {
3645
4136
  state.aiPromptSent = false;
3646
4137
  state.aiConversationId = null;
3647
4138
  state.aiConversations = [];
4139
+ state.aiRoleplayCharacter = null;
3648
4140
  renderAiCitations();
3649
4141
  renderAiReferences();
3650
4142
  renderAiQuickActions();
3651
4143
  resetAiFeed();
4144
+ applyAiConversationTaskType("chat");
4145
+ applyAiConversationContextScope({ type: "none" });
4146
+ applyAiRoleplayCharacter(null);
3652
4147
  $("#ai-conversation-title").textContent = "新对话";
3653
4148
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
3654
- setAiContextMeter(null);
4149
+ resetAiContextMeter();
3655
4150
  renderAiConversationHistory();
3656
4151
  }
3657
4152
 
@@ -3666,11 +4161,12 @@ async function selectWork(workId, preferredChapterId = null) {
3666
4161
  }
3667
4162
  const nextWork = await api(`/api/works/${workId}?directory=volumes`);
3668
4163
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
3669
- if (discarding) setSaveState("就绪");
4164
+ showSystemStatus();
3670
4165
  $("#app").classList.remove("shelf-mode");
3671
4166
  $("#shelf-view").classList.add("hidden");
3672
4167
  $("#platform-ai-view").classList.add("hidden");
3673
4168
  $("#platform-usage-view").classList.add("hidden");
4169
+ $("#work-audit-view").classList.add("hidden");
3674
4170
  $("#settings-hub-view").classList.add("hidden");
3675
4171
  $("#settings-button").removeAttribute("aria-current");
3676
4172
  settingsReturnContext = null;
@@ -4151,6 +4647,7 @@ function showWelcome(hasWork = false) {
4151
4647
  $("#editor-view").classList.add("hidden");
4152
4648
  $("#module-view").classList.add("hidden");
4153
4649
  $("#welcome-view").classList.remove("hidden");
4650
+ if (hasWork) showSystemStatus();
4154
4651
  $("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
4155
4652
  $("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
4156
4653
  replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
@@ -4204,6 +4701,7 @@ async function showModule(module) {
4204
4701
  }
4205
4702
  return;
4206
4703
  }
4704
+ showSystemStatus();
4207
4705
  dismissChapterInsightToast();
4208
4706
  $("#welcome-view").classList.add("hidden");
4209
4707
  $("#editor-view").classList.add("hidden");
@@ -4698,8 +5196,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4698
5196
  <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
4699
5197
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
4700
5198
  </section>` : "";
4701
- const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4702
- + field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
5199
+ const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
4703
5200
  + `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
4704
5201
  + `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
4705
5202
  + field("title", "标题", "text", item?.title ?? "")
@@ -4731,7 +5228,8 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4731
5228
  submitLabel: viewOnly ? "关闭" : "保存想法",
4732
5229
  hideCancel: viewOnly,
4733
5230
  editor: true,
4734
- errorPrefix: "想法保存失败:"
5231
+ errorPrefix: "想法保存失败:",
5232
+ meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
4735
5233
  });
4736
5234
  const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
4737
5235
  const syncDraftBindingFields = () => {
@@ -6133,6 +6631,7 @@ function openTaskDetailDialog(task, trace) {
6133
6631
  </li>`;
6134
6632
  }
6135
6633
  if (item.type === "book") return "<li>全书</li>";
6634
+ if (item.type === "settings-catalog") return "<li>设定库</li>";
6136
6635
  if (item.type === "selection") return item.restricted
6137
6636
  ? "<li>选定内容(正文读取权限受限)</li>"
6138
6637
  : `<li>选定内容:${esc(item.selection || "未提供")}</li>`;
@@ -6214,7 +6713,7 @@ function renderProviderCards(providers, models) {
6214
6713
  }).join("")}</div>
6215
6714
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
6216
6715
  }).join("")}</div>`
6217
- : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
6716
+ : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、AnthropicGoogle Vertex 接口地址和凭据,测试成功后再添加模型。");
6218
6717
  }
6219
6718
 
6220
6719
  function bindPlatformProviderActions(host, providers, models) {
@@ -6560,21 +7059,72 @@ function tokenUsageCalendarMarkup(daily) {
6560
7059
  const calendar = buildUsageCalendar(daily);
6561
7060
  const cells = calendar.cells.map((cell) => {
6562
7061
  const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
6563
- return `<span class="usage-calendar-cell${cell.future ? " is-future" : ""}" data-level="${cell.level}" role="gridcell" aria-label="${esc(label)}" title="${esc(label)}" ${cell.future ? 'aria-disabled="true"' : 'tabindex="0"'}></span>`;
7062
+ return cell.future
7063
+ ? `<span class="usage-calendar-cell is-future" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
7064
+ : `<button class="usage-calendar-cell" type="button" data-level="${cell.level}" data-usage-calendar-label="${esc(label)}" role="gridcell" aria-label="${esc(label)}"></button>`;
6564
7065
  }).join("");
6565
7066
  const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
6566
- return `<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
6567
- <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
6568
- <div class="usage-calendar-months" aria-hidden="true">${months}</div>
6569
- <div class="usage-calendar-body">
6570
- <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
6571
- <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
7067
+ return `<div class="usage-calendar-widget">
7068
+ <div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
7069
+ <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
7070
+ <div class="usage-calendar-months" aria-hidden="true">${months}</div>
7071
+ <div class="usage-calendar-body">
7072
+ <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
7073
+ <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
7074
+ </div>
6572
7075
  </div>
6573
7076
  </div>
7077
+ <output class="usage-calendar-tooltip" role="tooltip" hidden></output>
6574
7078
  </div>
6575
7079
  <div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
6576
7080
  }
6577
7081
 
7082
+ function bindUsageCalendarInteractions(root) {
7083
+ root.querySelectorAll(".usage-calendar-widget").forEach((widget) => {
7084
+ const tooltip = widget.querySelector(".usage-calendar-tooltip");
7085
+ const calendarScroll = widget.querySelector(".usage-calendar-scroll");
7086
+ let activeCell = null;
7087
+ const hideTooltip = () => {
7088
+ tooltip.hidden = true;
7089
+ activeCell = null;
7090
+ };
7091
+ const showTooltip = (cell) => {
7092
+ activeCell = cell;
7093
+ tooltip.textContent = cell.dataset.usageCalendarLabel;
7094
+ tooltip.hidden = false;
7095
+ const widgetRect = widget.getBoundingClientRect();
7096
+ const cellRect = cell.getBoundingClientRect();
7097
+ const edgeInset = tooltip.offsetWidth / 2 + 8;
7098
+ const centeredLeft = cellRect.left + cellRect.width / 2 - widgetRect.left;
7099
+ const fitsAbove = cellRect.top - widgetRect.top >= tooltip.offsetHeight + 8;
7100
+ tooltip.dataset.placement = fitsAbove ? "top" : "bottom";
7101
+ tooltip.style.left = `${Math.min(widget.clientWidth - edgeInset, Math.max(edgeInset, centeredLeft))}px`;
7102
+ tooltip.style.top = fitsAbove
7103
+ ? `${cellRect.top - widgetRect.top - 8}px`
7104
+ : `${cellRect.bottom - widgetRect.top + 8}px`;
7105
+ };
7106
+ widget.querySelectorAll("button.usage-calendar-cell").forEach((cell) => {
7107
+ cell.addEventListener("mouseenter", () => showTooltip(cell));
7108
+ cell.addEventListener("mouseleave", () => {
7109
+ if (document.activeElement !== cell) hideTooltip();
7110
+ });
7111
+ cell.addEventListener("focus", () => showTooltip(cell));
7112
+ cell.addEventListener("blur", () => {
7113
+ if (!cell.matches(":hover")) hideTooltip();
7114
+ });
7115
+ cell.addEventListener("click", () => showTooltip(cell));
7116
+ cell.addEventListener("keydown", (event) => {
7117
+ if (event.key !== "Escape") return;
7118
+ hideTooltip();
7119
+ cell.blur();
7120
+ });
7121
+ });
7122
+ calendarScroll.addEventListener("scroll", () => {
7123
+ if (activeCell && !tooltip.hidden) showTooltip(activeCell);
7124
+ });
7125
+ });
7126
+ }
7127
+
6578
7128
  function scrollUsageCalendarsToLatest(root) {
6579
7129
  window.requestAnimationFrame(() => {
6580
7130
  root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
@@ -6633,6 +7183,7 @@ async function renderPlatformTokenUsage() {
6633
7183
  description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
6634
7184
  showWorks: true
6635
7185
  });
7186
+ bindUsageCalendarInteractions(host);
6636
7187
  scrollUsageCalendarsToLatest(host);
6637
7188
  }
6638
7189
 
@@ -6652,10 +7203,20 @@ async function renderBookAiSettings() {
6652
7203
  const host = $("#module-content");
6653
7204
  const workId = String(state.work.id);
6654
7205
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
7206
+ const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
7207
+ const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
7208
+ const quotaRemainingTokens = usage?.quota?.remainingTokens === null
7209
+ ? null
7210
+ : Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
7211
+ const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
7212
+ const quotaStatusText = dailyTokenQuota === null
7213
+ ? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
7214
+ : `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
6655
7215
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6656
7216
  title: "本书 Token 用量",
6657
7217
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6658
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
7218
+ })}</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)}`;
7219
+ bindUsageCalendarInteractions(host);
6659
7220
  scrollUsageCalendarsToLatest(host);
6660
7221
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6661
7222
  "beforebegin",
@@ -6671,6 +7232,7 @@ async function renderBookAiSettings() {
6671
7232
  );
6672
7233
  if (!canEditModule("ai-settings")) {
6673
7234
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
7235
+ host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button").forEach((button) => { button.disabled = true; });
6674
7236
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
6675
7237
  }
6676
7238
  const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
@@ -6700,6 +7262,31 @@ async function renderBookAiSettings() {
6700
7262
  return status;
6701
7263
  };
6702
7264
  updateRelationshipIndexStatus(relationshipIndex);
7265
+ $("#daily-token-quota-enabled").addEventListener("change", (event) => {
7266
+ $("#daily-token-quota").disabled = !event.currentTarget.checked;
7267
+ });
7268
+ $("#save-daily-token-quota").addEventListener("click", async () => {
7269
+ const button = $("#save-daily-token-quota");
7270
+ const enabled = $("#daily-token-quota-enabled").checked;
7271
+ const quota = Number($("#daily-token-quota").value);
7272
+ if (enabled && (!Number.isInteger(quota) || quota < 10_000 || quota > 2_000_000_000)) {
7273
+ toast("每日 Token 额度必须是 10,000 到 2,000,000,000 之间的整数", "error");
7274
+ $("#daily-token-quota").focus();
7275
+ return;
7276
+ }
7277
+ button.disabled = true;
7278
+ try {
7279
+ await api(`/api/works/${state.work.id}/ai-settings`, {
7280
+ method: "PATCH",
7281
+ body: { dailyTokenQuota: enabled ? quota : null }
7282
+ });
7283
+ toast(enabled ? "本书每日 Token 额度已保存" : "本书每日 Token 额度限制已关闭");
7284
+ await renderBookAiSettings();
7285
+ } catch (error) {
7286
+ toast(error.message, "error");
7287
+ button.disabled = false;
7288
+ }
7289
+ });
6703
7290
  $("#save-work-system-prompt").addEventListener("click", async () => {
6704
7291
  const button = $("#save-work-system-prompt");
6705
7292
  button.disabled = true;
@@ -6779,6 +7366,34 @@ async function renderBookAiSettings() {
6779
7366
  button.disabled = false;
6780
7367
  }
6781
7368
  });
7369
+ $("#save-agent-tool-call-limit").addEventListener("click", async () => {
7370
+ const button = $("#save-agent-tool-call-limit");
7371
+ button.disabled = true;
7372
+ try {
7373
+ await api(`/api/works/${state.work.id}/ai-settings`, {
7374
+ method: "PATCH",
7375
+ body: {
7376
+ agentToolCallLimit: Number($("#agent-tool-call-limit").value),
7377
+ agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
7378
+ }
7379
+ });
7380
+ toast("Agent 工具调用上限已保存");
7381
+ } catch (error) {
7382
+ toast(error.message, "error");
7383
+ } finally {
7384
+ button.disabled = false;
7385
+ }
7386
+ });
7387
+ host.querySelector(".agent-tool-call-global-multiplier-toggle")?.addEventListener("click", (event) => {
7388
+ const option = event.target.closest("button[data-global-multiplier]");
7389
+ if (!option || option.disabled) return;
7390
+ const value = option.getAttribute("data-global-multiplier");
7391
+ const hidden = $("#agent-tool-call-global-multiplier");
7392
+ if (hidden) hidden.value = value;
7393
+ host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button[data-global-multiplier]").forEach((item) => {
7394
+ item.setAttribute("aria-pressed", String(item === option));
7395
+ });
7396
+ });
6782
7397
  $("#save-agent-tools").addEventListener("click", async () => {
6783
7398
  const button = $("#save-agent-tools");
6784
7399
  button.disabled = true;
@@ -6857,23 +7472,51 @@ async function ensureAiModelsLoaded() {
6857
7472
  }
6858
7473
  }
6859
7474
 
7475
+ function syncAiIncludeSettingInfoControl() {
7476
+ const scopeType = $("#ai-scope").value;
7477
+ const checkbox = $("#ai-include-setting-info");
7478
+ const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
7479
+ const enabled = proseScopes.has(scopeType);
7480
+ const roleplaySelected = $("#ai-task").value === "roleplay";
7481
+ checkbox.disabled = roleplaySelected || state.aiPromptSent || !enabled;
7482
+ checkbox.title = state.aiPromptSent
7483
+ ? "对话开始后不能切换上下文选项"
7484
+ : enabled
7485
+ ? "在正文上下文中注入锁定设定、组织/种族简表等"
7486
+ : scopeType === "settings-catalog"
7487
+ ? "设定库范围会直接注入设定目录,无需此选项"
7488
+ : "仅在选择正文类上下文范围时可用";
7489
+ }
7490
+
6860
7491
  function currentAiRequestScope() {
6861
7492
  if (!state.work) return null;
6862
- const taskType = $("#ai-task").value;
6863
- const scopeType = $("#ai-scope").value;
6864
- const requiresChapter = taskType === "polish" || taskType === "continue" || scopeType !== "none";
7493
+ const selectedTaskType = $("#ai-task").value;
7494
+ const roleplaySelected = selectedTaskType === "roleplay";
7495
+ const taskType = roleplaySelected ? "chat" : selectedTaskType;
7496
+ if (state.aiPromptSent) {
7497
+ const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
7498
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7499
+ return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
7500
+ }
7501
+ const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
7502
+ const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
6865
7503
  if (requiresChapter && !state.chapter) return null;
6866
7504
  const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
6867
7505
  const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
6868
7506
  const includeBookSummary = scopeType === "chapter-summary";
6869
- const scope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
7507
+ const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
6870
7508
  : scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
6871
7509
  : scopeType === "book" ? { type: "book" }
6872
7510
  : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
7511
+ : scopeType === "settings-catalog" ? { type: "settings-catalog" }
6873
7512
  : { type: "chapter", chapterId: state.chapter?.id };
6874
- Object.assign(scope, buildAiReferenceScope(state.aiReferences));
6875
- if (includeBookSummary) scope.includeBookSummary = true;
6876
- return { taskType, scope, selection };
7513
+ if (includeBookSummary) conversationScope.includeBookSummary = true;
7514
+ const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
7515
+ if (proseScopes.has(scopeType) || taskType === "polish") {
7516
+ conversationScope.includeSettingInfo = $("#ai-include-setting-info").checked;
7517
+ }
7518
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7519
+ return { taskType, scope, conversationScope, selection };
6877
7520
  }
6878
7521
 
6879
7522
  function renderAiContextDistribution(usage) {
@@ -6917,10 +7560,12 @@ function renderAiContextDistribution(usage) {
6917
7560
  }
6918
7561
 
6919
7562
  function setAiContextMeter(usage) {
7563
+ const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
7564
+ latestAiContextUsage = displayUsage;
6920
7565
  const meter = $("#ai-context-meter");
6921
7566
  const value = meter.querySelector("b");
6922
- renderAiContextDistribution(usage);
6923
- if (!usage) {
7567
+ renderAiContextDistribution(displayUsage);
7568
+ if (!displayUsage) {
6924
7569
  meter.classList.add("is-empty");
6925
7570
  meter.classList.remove("is-warning", "is-danger");
6926
7571
  meter.style.setProperty("--context-usage", "0");
@@ -6930,17 +7575,22 @@ function setAiContextMeter(usage) {
6930
7575
  meter.setAttribute("aria-label", tooltip);
6931
7576
  return;
6932
7577
  }
6933
- const percent = Math.max(0, Math.min(100, Number(usage.usagePercent) || 0));
7578
+ const percent = Math.max(0, Math.min(100, Number(displayUsage.usagePercent) || 0));
6934
7579
  meter.classList.remove("is-empty");
6935
7580
  meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
6936
7581
  meter.classList.toggle("is-danger", percent >= 90);
6937
7582
  meter.style.setProperty("--context-usage", String(percent));
6938
7583
  value.textContent = `${percent}%`;
6939
- const tooltip = formatAiContextUsageTooltip(usage);
7584
+ const tooltip = formatAiContextUsageTooltip(displayUsage);
6940
7585
  meter.dataset.tooltip = tooltip;
6941
7586
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6942
7587
  }
6943
7588
 
7589
+ function resetAiContextMeter() {
7590
+ latestAiContextUsage = null;
7591
+ setAiContextMeter(null);
7592
+ }
7593
+
6944
7594
  function setAiContextDistributionVisible(visible) {
6945
7595
  const meter = $("#ai-context-meter");
6946
7596
  const popover = $("#ai-context-popover");
@@ -6972,6 +7622,7 @@ async function loadAiReferences() {
6972
7622
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6973
7623
  state.characters = characters;
6974
7624
  state.settings = settings;
7625
+ renderAiRoleplayCharacterSelect();
6975
7626
  loadedAiReferencesWorkId = workId;
6976
7627
  }
6977
7628
 
@@ -7300,9 +7951,39 @@ function bindWorkCoverControls(work) {
7300
7951
  });
7301
7952
  }
7302
7953
 
7303
- function downloadWorkManuscript(work) {
7954
+ function downloadWorkManuscript(work, format = "markdown") {
7304
7955
  if (!work?.id) return;
7305
- window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=markdown`;
7956
+ const exportFormat = format === "docx" ? "docx" : "markdown";
7957
+ window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=${exportFormat}`;
7958
+ }
7959
+
7960
+ let manuscriptExportWork = null;
7961
+
7962
+ function closeManuscriptExportMenu() {
7963
+ const menu = $("#manuscript-export-menu");
7964
+ if (!menu) return;
7965
+ menu.classList.add("hidden");
7966
+ manuscriptExportWork = null;
7967
+ $("#export-button")?.setAttribute("aria-expanded", "false");
7968
+ $("#work-export-button")?.setAttribute("aria-expanded", "false");
7969
+ }
7970
+
7971
+ function showManuscriptExportMenu(anchor, work) {
7972
+ if (!work?.id || !anchor) return;
7973
+ const menu = $("#manuscript-export-menu");
7974
+ if (!menu) return;
7975
+ manuscriptExportWork = work;
7976
+ menu.classList.remove("hidden");
7977
+ const anchorRect = anchor.getBoundingClientRect();
7978
+ const menuRect = menu.getBoundingClientRect();
7979
+ const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - menuRect.width - 8));
7980
+ const top = Math.max(8, Math.min(anchorRect.bottom + 6, window.innerHeight - menuRect.height - 8));
7981
+ menu.style.left = `${left}px`;
7982
+ menu.style.top = `${top}px`;
7983
+ if (anchor.id === "export-button" || anchor.id === "work-export-button") {
7984
+ anchor.setAttribute("aria-expanded", "true");
7985
+ }
7986
+ menu.querySelector("button[data-export-format]")?.focus();
7306
7987
  }
7307
7988
 
7308
7989
  function openWorkSettingsDialog(work) {
@@ -7322,8 +8003,8 @@ function openWorkSettingsDialog(work) {
7322
8003
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
7323
8004
  </section>`;
7324
8005
  const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
7325
- <div><strong id="work-export-settings-title">导出正文</strong><small>服务器将分卷、章节标题与正文压缩为 ZIP,压缩包内含 Markdown 文件;不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
7326
- <button id="work-export-button" class="ghost-button" type="button">下载 ZIP</button>
8006
+ <div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择导出 Markdown ZIP DOCX(书名、分卷、章节为一级至三级标题;若已设置封面则嵌入为首页)。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
8007
+ <button id="work-export-button" class="ghost-button" type="button" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false">导出正文</button>
7327
8008
  </section>`;
7328
8009
  const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
7329
8010
  <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
@@ -7357,7 +8038,11 @@ function openWorkSettingsDialog(work) {
7357
8038
  $("#form-dialog").close();
7358
8039
  void openImportHistory();
7359
8040
  });
7360
- $("#work-export-button")?.addEventListener("click", () => downloadWorkManuscript(work));
8041
+ $("#work-export-button")?.addEventListener("click", (event) => {
8042
+ event.preventDefault();
8043
+ event.stopPropagation();
8044
+ showManuscriptExportMenu(event.currentTarget, work);
8045
+ });
7361
8046
  $("#chapter-recycle-bin-button")?.addEventListener("click", () => {
7362
8047
  $("#form-dialog").close();
7363
8048
  void openChapterRecycleBin();
@@ -9216,23 +9901,66 @@ async function openTaskDialog() {
9216
9901
 
9217
9902
  function openProviderDialog(item) {
9218
9903
  const protocol = item?.protocol ?? "openai-chat-completions";
9219
- const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
9220
- openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
9221
- const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
9222
- if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
9223
- await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
9224
- await renderPlatformAiConfig();
9225
- await loadModels();
9226
- }, item ? "协议、限流与凭据" : "OpenAI / Anthropic 兼容协议");
9227
- if (!item) {
9228
- const protocolSelect = $("#dialog-fields select[name='protocol']");
9229
- const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
9230
- protocolSelect.addEventListener("change", () => {
9231
- baseUrlInput.value = protocolSelect.value === "anthropic-messages"
9232
- ? "https://api.anthropic.com"
9233
- : "https://api.openai.com/v1";
9234
- });
9235
- }
9904
+ const providerProtocolOptions = [
9905
+ ["openai-chat-completions", "OpenAI Chat Completions"],
9906
+ ["anthropic-messages", "Anthropic Messages"],
9907
+ ["google-vertex", "Google Vertex"]
9908
+ ];
9909
+ const defaultBaseUrlForProtocol = (value) => {
9910
+ if (value === "anthropic-messages") return "https://api.anthropic.com";
9911
+ if (value === "google-vertex") {
9912
+ return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
9913
+ }
9914
+ return "https://api.openai.com/v1";
9915
+ };
9916
+ const credentialFieldForProtocol = (value) => {
9917
+ if (value === "google-vertex") {
9918
+ return field(
9919
+ "apiKey",
9920
+ item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
9921
+ "textarea",
9922
+ ""
9923
+ );
9924
+ }
9925
+ return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
9926
+ };
9927
+ const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
9928
+ openDialog(
9929
+ item ? "编辑 AI 供应商" : "新建 AI 供应商",
9930
+ field("name", "显示名称", "text", item?.name)
9931
+ + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
9932
+ + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
9933
+ + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
9934
+ + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
9935
+ + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
9936
+ + field("note", "用途备注", "textarea", item?.note)
9937
+ + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
9938
+ async (form) => {
9939
+ const body = {
9940
+ name: form.get("name"),
9941
+ protocol: form.get("protocol"),
9942
+ baseUrl: form.get("baseUrl"),
9943
+ concurrencyLimit: Number(form.get("concurrencyLimit")),
9944
+ rpmLimit: Number(form.get("rpmLimit")),
9945
+ note: form.get("note"),
9946
+ status: form.get("enabled") === "on" ? "enabled" : "disabled"
9947
+ };
9948
+ if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
9949
+ await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
9950
+ await renderPlatformAiConfig();
9951
+ await loadModels();
9952
+ },
9953
+ item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
9954
+ );
9955
+ const protocolSelect = $("#dialog-fields select[name='protocol']");
9956
+ const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
9957
+ const credentialHost = $("#dialog-fields [data-provider-credential-field]");
9958
+ const syncProviderCredentialField = () => {
9959
+ const nextProtocol = protocolSelect.value;
9960
+ credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
9961
+ if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
9962
+ };
9963
+ protocolSelect.addEventListener("change", syncProviderCredentialField);
9236
9964
  }
9237
9965
 
9238
9966
  function openModelDialog(providerId, item = null) {
@@ -9295,31 +10023,44 @@ async function sendAi() {
9295
10023
  try {
9296
10024
  await ensureAiModelsLoaded();
9297
10025
  } catch (error) {
10026
+ setAiAssistantStatus("error");
9298
10027
  return toast(`创作助手加载失败:${error.message}`, "error");
9299
10028
  }
9300
10029
  const modelId = $("#ai-model").value;
9301
10030
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
9302
10031
  const instruction = aiPromptText().trim();
9303
10032
  if (!instruction) return toast("请输入指令", "error");
10033
+ if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
9304
10034
  const requestScope = currentAiRequestScope();
9305
10035
  if (!requestScope) return toast("请先选择章节", "error");
9306
10036
  const { taskType, scope, selection } = requestScope;
9307
10037
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
10038
+ try {
10039
+ await persistAiConversationTaskType($("#ai-task").value);
10040
+ await persistAiConversationContextScope(requestScope.conversationScope);
10041
+ } catch (error) {
10042
+ return toast(`对话配置锁定失败:${error.message}`, "error");
10043
+ }
10044
+ setAiAssistantStatus("ready");
9308
10045
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9309
10046
  let persistedUserMessage = null;
9310
10047
  if (taskType !== "chat") {
9311
10048
  try {
9312
10049
  persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9313
10050
  } catch (error) {
10051
+ setAiAssistantStatus("error");
9314
10052
  return toast(`对话记录创建失败:${error.message}`, "error");
9315
10053
  }
9316
10054
  state.aiPromptSent = true;
10055
+ syncAiTaskOptions();
10056
+ renderAiRoleplayCharacterSelect();
9317
10057
  renderAiQuickActions();
9318
10058
  appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9319
10059
  clearAiPromptComposer();
9320
10060
  }
9321
10061
  $("#ai-send").disabled = true;
9322
10062
  $("#ai-send").textContent = "发送中";
10063
+ $("#ai-roleplay-character").disabled = true;
9323
10064
  try {
9324
10065
  let assistantContent = "";
9325
10066
  let assistantMessage;
@@ -9336,6 +10077,10 @@ async function sendAi() {
9336
10077
  applyAiConversationTitle(streamed.conversationTitle);
9337
10078
  } else {
9338
10079
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
10080
+ const suggestionFailed = suggestion.guard?.status === "failed"
10081
+ || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
10082
+ || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
10083
+ if (suggestionFailed) setAiAssistantStatus("error");
9339
10084
  setAiContextMeter(suggestion.contextUsage);
9340
10085
  assistantContent = suggestion.content;
9341
10086
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
@@ -9358,10 +10103,12 @@ async function sendAi() {
9358
10103
  } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
9359
10104
  }
9360
10105
  } catch (error) {
10106
+ setAiAssistantStatus("error");
9361
10107
  if (suggestion) appendSuggestion(suggestion);
9362
10108
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
9363
10109
  }
9364
10110
  } catch (error) {
10111
+ setAiAssistantStatus("error");
9365
10112
  const failureMessage = formatAiFailureMessage(error);
9366
10113
  let persistedFailureMessage = null;
9367
10114
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -9369,6 +10116,7 @@ async function sendAi() {
9369
10116
  } finally {
9370
10117
  $("#ai-send").disabled = false;
9371
10118
  $("#ai-send").textContent = "发送";
10119
+ renderAiRoleplayCharacterSelect();
9372
10120
  }
9373
10121
  }
9374
10122
 
@@ -9382,7 +10130,7 @@ async function streamChat(body) {
9382
10130
  let messageMounted = false;
9383
10131
  const mountAssistantMessage = () => {
9384
10132
  if (messageMounted) return;
9385
- attachMessageHeading(message, "助手 · 正在生成");
10133
+ attachMessageHeading(message, aiAssistantLabel("正在生成"));
9386
10134
  $("#ai-feed").append(message);
9387
10135
  messageMounted = true;
9388
10136
  scrollAiFeedToBottom();
@@ -9455,6 +10203,8 @@ async function streamChat(body) {
9455
10203
  state.aiConversationId = persistedUserMessage.conversationId;
9456
10204
  updateAiConversationSummaryFromMessage(persistedUserMessage);
9457
10205
  state.aiPromptSent = true;
10206
+ syncAiTaskOptions();
10207
+ renderAiRoleplayCharacterSelect();
9458
10208
  renderAiQuickActions();
9459
10209
  appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9460
10210
  clearAiPromptComposer();
@@ -9489,6 +10239,7 @@ async function streamChat(body) {
9489
10239
  const toolCall = { ...payload };
9490
10240
  const round = toolCall.round;
9491
10241
  delete toolCall.round;
10242
+ if (toolCall.status === "failed") setAiAssistantStatus("error");
9492
10243
  toolCalls.push(toolCall);
9493
10244
  processSteps.push(aiToolProcessStep(toolCall, round));
9494
10245
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
@@ -9516,6 +10267,7 @@ async function streamChat(body) {
9516
10267
  attachAssistantCopyAction(message, streamedText);
9517
10268
  scrollAiFeedToBottom();
9518
10269
  } else if (eventName === "error") {
10270
+ setAiAssistantStatus("error");
9519
10271
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
9520
10272
  }
9521
10273
  };
@@ -9536,7 +10288,7 @@ async function streamChat(body) {
9536
10288
  typewriter.reveal();
9537
10289
  message.classList.remove("is-streaming");
9538
10290
  content.setAttribute("aria-busy", "false");
9539
- message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
10291
+ message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
9540
10292
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
9541
10293
  meta.textContent = "生成中断";
9542
10294
  scrollAiFeedToBottom();
@@ -9552,7 +10304,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
9552
10304
  ? `<p class="ai-error-text">${esc(text)}</p>`
9553
10305
  : renderMarkdown(text);
9554
10306
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
9555
- const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
10307
+ const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
9556
10308
  if (isFailure) {
9557
10309
  message.dataset.status = "failed";
9558
10310
  const failureBadge = document.createElement("strong");
@@ -9960,6 +10712,28 @@ $("#onboarding-dialog").addEventListener("cancel", (event) => {
9960
10712
  event.preventDefault();
9961
10713
  completeOnboarding();
9962
10714
  });
10715
+ $("#system-restart-dialog").addEventListener("cancel", (event) => {
10716
+ event.preventDefault();
10717
+ });
10718
+ function hasUnsavedEditorChanges() {
10719
+ return state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty;
10720
+ }
10721
+
10722
+ function redirectToLoginAfterSystemRestart() {
10723
+ state.user = null;
10724
+ state.csrfToken = null;
10725
+ moduleRequestCache.clear();
10726
+ document.documentElement.classList.remove("dev-auth-bypass");
10727
+ document.documentElement.classList.add("login-route");
10728
+ window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
10729
+ const toastRegion = $("#toast-region");
10730
+ toastRegion.replaceChildren();
10731
+ if (typeof toastRegion.hidePopover === "function" && toastRegion.matches(":popover-open")) toastRegion.hidePopover();
10732
+ $("#system-restart-dialog").close();
10733
+ showAuth(false);
10734
+ }
10735
+
10736
+ $("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
9963
10737
  $("#onboarding-dialog").addEventListener("keydown", (event) => {
9964
10738
  if (event.key === "Escape") {
9965
10739
  event.preventDefault();
@@ -10498,10 +11272,20 @@ $("#writing-progress-button").addEventListener("click", () => openWritingProgres
10498
11272
  $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
10499
11273
  $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
10500
11274
  $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
10501
- $("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
10502
- $("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
10503
- $("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
10504
- $("#work-audit-refresh").addEventListener("click", () => loadWorkAuditPage().catch((error) => toast(error.message, "error")));
11275
+ $("#work-audit-button").addEventListener("click", () => showWorkAudit().catch((error) => toast(error.message, "error")));
11276
+ $("#work-audit-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button").catch((error) => toast(error.message, "error")));
11277
+ $("#work-audit-refresh").addEventListener("click", async () => {
11278
+ const button = $("#work-audit-refresh");
11279
+ button.disabled = true;
11280
+ try {
11281
+ await loadWorkAuditPage();
11282
+ toast("操作记录已刷新");
11283
+ } catch (error) {
11284
+ toast(error.message, "error");
11285
+ } finally {
11286
+ button.disabled = false;
11287
+ }
11288
+ });
10505
11289
  $("#work-audit-load-more").addEventListener("click", () => {
10506
11290
  if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
10507
11291
  });
@@ -10564,6 +11348,7 @@ $("#form-dialog").addEventListener("close", () => {
10564
11348
  formDialogVditors.forEach(destroyVditorEditor);
10565
11349
  formDialogVditors = [];
10566
11350
  void discardPendingMarkdownAttachments();
11351
+ closeManuscriptExportMenu();
10567
11352
  if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
10568
11353
  });
10569
11354
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
@@ -10783,8 +11568,61 @@ $("#ai-model").addEventListener("focus", () => {
10783
11568
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
10784
11569
  });
10785
11570
  $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
10786
- $("#ai-task").addEventListener("change", () => setAiContextMeter(null));
10787
- $("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
11571
+ $("#ai-roleplay-character").addEventListener("focus", async () => {
11572
+ try {
11573
+ await ensureAiReferencesLoaded();
11574
+ renderAiRoleplayCharacterSelect();
11575
+ } catch (error) {
11576
+ toast(`角色卡加载失败:${error.message}`, "error");
11577
+ }
11578
+ });
11579
+ $("#ai-roleplay-character").addEventListener("change", async (event) => {
11580
+ const select = event.currentTarget;
11581
+ const characterId = select.value;
11582
+ select.disabled = true;
11583
+ try {
11584
+ await updateAiRoleplayCharacter(characterId);
11585
+ } catch (error) {
11586
+ renderAiRoleplayCharacterSelect();
11587
+ toast(`角色扮演模式切换失败:${error.message}`, "error");
11588
+ } finally {
11589
+ renderAiRoleplayCharacterSelect();
11590
+ }
11591
+ });
11592
+ $("#ai-task").addEventListener("change", async (event) => {
11593
+ const select = event.currentTarget;
11594
+ const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
11595
+ const nextTaskType = select.value;
11596
+ if (state.aiPromptSent) {
11597
+ applyAiConversationTaskType(previousTaskType);
11598
+ return toast("当前对话已经开始,请新建对话后再切换任务类型", "error");
11599
+ }
11600
+ applyAiConversationTaskType(nextTaskType);
11601
+ if (state.aiConversationId) {
11602
+ select.disabled = true;
11603
+ try {
11604
+ await persistAiConversationTaskType(nextTaskType);
11605
+ } catch (error) {
11606
+ applyAiConversationTaskType(previousTaskType);
11607
+ toast(`任务类型切换失败:${error.message}`, "error");
11608
+ } finally {
11609
+ syncAiTaskOptions();
11610
+ renderAiRoleplayCharacterSelect();
11611
+ }
11612
+ }
11613
+ setAiContextMeter(null);
11614
+ });
11615
+ $("#ai-scope").addEventListener("change", (event) => {
11616
+ if (state.aiPromptSent) {
11617
+ applyAiConversationContextScope(state.aiContextScope);
11618
+ return toast("当前对话已经开始,请新建对话后再切换上下文引用", "error");
11619
+ }
11620
+ event.currentTarget.title = "";
11621
+ syncAiIncludeSettingInfoControl();
11622
+ setAiContextMeter(null);
11623
+ });
11624
+ $("#ai-include-setting-info").addEventListener("change", () => setAiContextMeter(null));
11625
+ syncAiIncludeSettingInfoControl();
10788
11626
  $("#ai-mention-menu").addEventListener("click", (event) => {
10789
11627
  const button = event.target.closest("[data-ai-reference-id]");
10790
11628
  if (button) selectAiMention(button);
@@ -10923,6 +11761,9 @@ document.addEventListener("pointerdown", (event) => {
10923
11761
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
10924
11762
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10925
11763
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
11764
+ if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {
11765
+ closeManuscriptExportMenu();
11766
+ }
10926
11767
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10927
11768
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10928
11769
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -10945,6 +11786,7 @@ document.addEventListener("keydown", (event) => {
10945
11786
  closeChapterTypeMenu();
10946
11787
  closeLineCitationMenu();
10947
11788
  closeMarkdownTableMenu(true);
11789
+ closeManuscriptExportMenu();
10948
11790
  hideAiMentionMenu();
10949
11791
  setAiContextDistributionVisible(false);
10950
11792
  }
@@ -11063,7 +11905,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
11063
11905
  $(".quick-actions").addEventListener("click", (event) => {
11064
11906
  const button = event.target.closest("[data-task]");
11065
11907
  if (!button) return;
11066
- $("#ai-task").value = button.dataset.task;
11908
+ applyAiConversationTaskType(button.dataset.task);
11067
11909
  setAiPromptText(button.dataset.prompt);
11068
11910
  $("#ai-prompt").focus();
11069
11911
  });
@@ -11097,8 +11939,39 @@ $("#search-form").addEventListener("submit", async (event) => {
11097
11939
  $("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
11098
11940
  });
11099
11941
  });
11100
- $("#export-button").addEventListener("click", () => downloadWorkManuscript(state.work));
11101
- window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
11942
+ $("#export-button").addEventListener("click", (event) => {
11943
+ event.preventDefault();
11944
+ event.stopPropagation();
11945
+ if (!state.work) return;
11946
+ const menu = $("#manuscript-export-menu");
11947
+ const expanded = menu && !menu.classList.contains("hidden") && manuscriptExportWork?.id === state.work.id;
11948
+ if (expanded) {
11949
+ closeManuscriptExportMenu();
11950
+ return;
11951
+ }
11952
+ showManuscriptExportMenu(event.currentTarget, state.work);
11953
+ });
11954
+ $("#manuscript-export-menu").addEventListener("click", (event) => {
11955
+ const option = event.target.closest("[data-export-format]");
11956
+ if (!option || !manuscriptExportWork) return;
11957
+ const format = option.getAttribute("data-export-format") === "docx" ? "docx" : "markdown";
11958
+ const work = manuscriptExportWork;
11959
+ closeManuscriptExportMenu();
11960
+ downloadWorkManuscript(work, format);
11961
+ });
11962
+ document.addEventListener("visibilitychange", () => {
11963
+ if (document.visibilityState !== "visible") return;
11964
+ if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
11965
+ void refreshSystemHealth();
11966
+ });
11967
+ window.addEventListener("beforeunload", (event) => {
11968
+ if (hasUnsavedEditorChanges()) event.preventDefault();
11969
+ });
11970
+ window.addEventListener("online", () => {
11971
+ updateSystemHealth({ status: "checking" });
11972
+ void refreshSystemHealth();
11973
+ });
11974
+ window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
11102
11975
 
11103
11976
  initializePage().catch((error) => {
11104
11977
  restoringPageRoute = false;