@musnows/scriverse 0.6.0 → 0.6.2

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 (44) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +177 -0
  4. package/dist/ai-tool-results.js.map +1 -0
  5. package/dist/ai.js +516 -138
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +186 -55
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +9 -5
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +14 -2
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/collaboration-presence.js +1 -0
  16. package/dist/collaboration-presence.js.map +1 -1
  17. package/dist/credential-vault.js +7 -5
  18. package/dist/credential-vault.js.map +1 -1
  19. package/dist/database.js +121 -2
  20. package/dist/database.js.map +1 -1
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/public/ai-context-meter.js +7 -0
  24. package/dist/public/app.js +556 -223
  25. package/dist/public/entity-version.js +2 -2
  26. package/dist/public/index.html +19 -11
  27. package/dist/public/markdown.js +1 -2
  28. package/dist/public/model-config.d.ts +3 -0
  29. package/dist/public/model-config.js +14 -0
  30. package/dist/public/page-route.js +1 -0
  31. package/dist/public/styles.css +40 -20
  32. package/dist/public/work-permissions.d.ts +1 -1
  33. package/dist/public/work-permissions.js +1 -1
  34. package/dist/security.js +45 -10
  35. package/dist/security.js.map +1 -1
  36. package/dist/server-runtime.js +56 -1
  37. package/dist/server-runtime.js.map +1 -1
  38. package/dist/store.js +236 -25
  39. package/dist/store.js.map +1 -1
  40. package/dist/user-auth.js +19 -71
  41. package/dist/user-auth.js.map +1 -1
  42. package/dist/version.js +1 -1
  43. package/dist/work-permissions.js +1 -1
  44. package/package.json +1 -1
@@ -1,22 +1,22 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260730-table-wrap-menu-v1";
3
+ import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
7
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
8
- import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260731-model-context-guidance-v1";
9
9
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
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
13
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
14
- import { formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260730-token-distribution-v2";
14
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260731-skills-description-v1";
15
15
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
16
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
17
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
18
18
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
19
- import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260729-drafts-v1";
19
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260731-drafts-to-ideas-v1";
20
20
  import {
21
21
  chapterVersionSourceLabel,
22
22
  foreshadowStatusLabel,
@@ -36,12 +36,12 @@ import {
36
36
  timelineStatusLabel,
37
37
  characterStateFieldLabel
38
38
  } from "/display-labels.js?v=20260728-hybrid-search-v1";
39
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
39
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
40
40
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
41
41
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
42
42
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
43
43
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
44
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260729-drafts-v1";
44
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
45
45
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
46
46
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
47
47
  import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
@@ -69,6 +69,7 @@ const defaultPageSizes = Object.freeze({
69
69
  timeline: 30,
70
70
  outlines: 30,
71
71
  relationships: 30,
72
+ comments: 30,
72
73
  reviews: 30,
73
74
  analysisTasks: 30,
74
75
  fileVersions: 30
@@ -134,6 +135,7 @@ const cachedWorkModules = new Set([
134
135
  "timeline",
135
136
  "outlines",
136
137
  "relationships",
138
+ "comments",
137
139
  "reviews",
138
140
  "tasks",
139
141
  "ai-settings"
@@ -311,6 +313,7 @@ function applyWorkAccessMode() {
311
313
  const button = $(`#module-nav [data-module="${item.uiModule}"]`);
312
314
  if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
313
315
  }
316
+ $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
314
317
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
315
318
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
316
319
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -392,6 +395,8 @@ let aiReferencesLoadPromise = null;
392
395
  let aiReferencesLoadWorkId = null;
393
396
  let aiConversationsLoadPromise = null;
394
397
  let aiConversationsLoadWorkId = null;
398
+ const aiConversationHistoryPageLimit = 20;
399
+ let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
395
400
  let workScopedUiGeneration = 0;
396
401
  const loadedVolumeChapterIds = new Set();
397
402
  const volumeChapterLoadingIds = new Set();
@@ -662,7 +667,7 @@ function renderPresence() {
662
667
  const localKey = presencePageKey(presencePageForRoute());
663
668
  syncChapterAutoSaveWithPresence();
664
669
  control.classList.remove("hidden");
665
- $("#presence-count").textContent = `${groups.length} 人在线`;
670
+ $("#presence-count").textContent = `${groups.length} 人`;
666
671
  $("#presence-list").innerHTML = groups.map((participant) => {
667
672
  const isCurrent = participant.userId === state.user?.userId;
668
673
  const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
@@ -899,6 +904,7 @@ let chapterEditorReadOnly = true;
899
904
  let characterListPage = 1;
900
905
  let taskListPage = 1;
901
906
  let draftTypeFilter = "all";
907
+ let draftBindingFilters = [];
902
908
  let draftFiltersPanelOpen = false;
903
909
  const moduleListPages = {
904
910
  drafts: 1,
@@ -908,6 +914,7 @@ const moduleListPages = {
908
914
  outlinePlans: 1,
909
915
  foreshadows: 1,
910
916
  relationships: 1,
917
+ comments: 1,
911
918
  reviews: 1
912
919
  };
913
920
  const characterFilters = { raceIds: [], organizationIds: [] };
@@ -1244,7 +1251,7 @@ function renderAiCitations() {
1244
1251
  card.append(main, remove, fullText);
1245
1252
  host.append(card);
1246
1253
  }
1247
- scheduleAiContextUsage();
1254
+ setAiContextMeter(null);
1248
1255
  }
1249
1256
 
1250
1257
  function aiReferenceKey(reference) {
@@ -1288,7 +1295,7 @@ function renderAiReferences() {
1288
1295
  if (rendered.has(aiReferenceKey(reference))) continue;
1289
1296
  prompt.append(createAiReferenceChip(reference), document.createTextNode(" "));
1290
1297
  }
1291
- scheduleAiContextUsage();
1298
+ setAiContextMeter(null);
1292
1299
  }
1293
1300
 
1294
1301
  function renderAiQuickActions() {
@@ -1331,6 +1338,34 @@ function resetAiFeed() {
1331
1338
  $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1332
1339
  }
1333
1340
 
1341
+ function appendAiContextCompactionDivider(kind, before = null) {
1342
+ const divider = document.createElement("div");
1343
+ divider.className = "ai-context-compaction-divider";
1344
+ divider.dataset.contextCompaction = kind;
1345
+ divider.dataset.testid = "ai-context-compaction-divider";
1346
+ divider.setAttribute("role", "separator");
1347
+ divider.setAttribute("aria-label", "已压缩上下文");
1348
+ divider.innerHTML = "<span>已压缩上下文</span>";
1349
+ const feed = $("#ai-feed");
1350
+ if (before?.parentElement === feed) feed.insertBefore(divider, before);
1351
+ else feed.append(divider);
1352
+ scrollAiFeedToBottom();
1353
+ return divider;
1354
+ }
1355
+
1356
+ function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
1357
+ const feed = $("#ai-feed");
1358
+ feed.querySelector('[data-context-compaction="conversation"]')?.remove();
1359
+ const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
1360
+ if (compactedCount === 0) return null;
1361
+ const messages = [...feed.querySelectorAll("[data-message-id]")];
1362
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1363
+ const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
1364
+ const loadedOffset = Math.max(0, totalCount - messages.length);
1365
+ const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
1366
+ return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
1367
+ }
1368
+
1334
1369
  function renderMessageCardActions(message) {
1335
1370
  let actions = message.querySelector(".message-card-actions");
1336
1371
  if (!actions) {
@@ -1367,7 +1402,7 @@ function renderMessageCardActions(message) {
1367
1402
  fork.disabled = true;
1368
1403
  try {
1369
1404
  const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
1370
- await loadAiConversations(false);
1405
+ upsertAiConversationSummary(conversation);
1371
1406
  await openAiConversation(conversation.id);
1372
1407
  toast("已从所选消息创建分支对话");
1373
1408
  } catch (error) {
@@ -1399,7 +1434,7 @@ const AI_TOOL_DISPLAY_NAMES = {
1399
1434
  grep: "查询正文关键字",
1400
1435
  search_story_entities: "搜索作品实体",
1401
1436
  read_character_sections: "读取人物 Markdown 章节",
1402
- search_drafts: "搜索草稿"
1437
+ search_drafts: "搜索想法"
1403
1438
  };
1404
1439
 
1405
1440
  const AI_TOOL_DESCRIPTIONS = {
@@ -1559,6 +1594,17 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1559
1594
  const list = document.createElement("div");
1560
1595
  list.className = "ai-process-list";
1561
1596
  for (const step of steps) {
1597
+ 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);
1606
+ continue;
1607
+ }
1562
1608
  if (step?.type === "tool" && step.toolCall) {
1563
1609
  const tool = document.createElement("section");
1564
1610
  tool.className = "ai-process-step ai-process-tool-step";
@@ -1599,6 +1645,12 @@ function setAiHistoryVisible(visible) {
1599
1645
  function renderAiConversationHistory() {
1600
1646
  const host = $("#ai-history-list");
1601
1647
  host.replaceChildren();
1648
+ const pagination = $("#ai-history-pagination");
1649
+ const showPagination = aiConversationHistoryPage.page > 1 || aiConversationHistoryPage.hasMore;
1650
+ pagination.classList.toggle("hidden", !showPagination);
1651
+ $("#ai-history-previous").disabled = aiConversationHistoryPage.page <= 1;
1652
+ $("#ai-history-next").disabled = !aiConversationHistoryPage.hasMore;
1653
+ $("#ai-history-page-label").textContent = `第 ${aiConversationHistoryPage.page} 页 · 本页 ${state.aiConversations.length} 条`;
1602
1654
  if (!state.aiConversations.length) {
1603
1655
  host.innerHTML = '<p class="ai-history-empty">还没有历史对话</p>';
1604
1656
  return;
@@ -1618,6 +1670,43 @@ function renderAiConversationHistory() {
1618
1670
  }
1619
1671
  }
1620
1672
 
1673
+ function defaultAiConversationTitle(prompt) {
1674
+ const normalized = String(prompt ?? "").replace(/\s+/gu, " ").trim();
1675
+ return Array.from(normalized).slice(0, 15).join("") || "新对话";
1676
+ }
1677
+
1678
+ function upsertAiConversationSummary(conversation) {
1679
+ if (!conversation?.id) return;
1680
+ const current = state.aiConversations.find((item) => item.id === conversation.id);
1681
+ const lastMessage = Array.isArray(conversation.messages) ? conversation.messages.at(-1) : null;
1682
+ const summary = { ...current, ...conversation, ...(lastMessage ? { preview: lastMessage.content } : {}) };
1683
+ delete summary.messages;
1684
+ delete summary.messagesPage;
1685
+ if (!current && loadedAiConversationsWorkId === state.work?.id) {
1686
+ loadedAiConversationsWorkId = null;
1687
+ aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
1688
+ state.aiConversations = [summary];
1689
+ renderAiConversationHistory();
1690
+ return;
1691
+ }
1692
+ state.aiConversations = [summary, ...state.aiConversations.filter((item) => item.id !== summary.id)]
1693
+ .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
1694
+ renderAiConversationHistory();
1695
+ }
1696
+
1697
+ function updateAiConversationSummaryFromMessage(message) {
1698
+ if (!message?.conversationId) return;
1699
+ const current = state.aiConversations.find((conversation) => conversation.id === message.conversationId);
1700
+ if (!current) return;
1701
+ upsertAiConversationSummary({
1702
+ ...current,
1703
+ title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
1704
+ messageCount: Number(current.messageCount ?? 0) + 1,
1705
+ preview: message.content,
1706
+ updatedAt: message.createdAt ?? current.updatedAt
1707
+ });
1708
+ }
1709
+
1621
1710
  function applyAiConversationTitle(title) {
1622
1711
  if (!title || !state.aiConversationId) return;
1623
1712
  const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
@@ -1626,20 +1715,27 @@ function applyAiConversationTitle(title) {
1626
1715
  renderAiConversationHistory();
1627
1716
  }
1628
1717
 
1629
- async function loadAiConversations(openLatest = true) {
1718
+ function applyAiConversations(pageResult) {
1719
+ state.aiConversations = pageResult.items;
1720
+ aiConversationHistoryPage = {
1721
+ page: pageResult.page,
1722
+ limit: pageResult.limit,
1723
+ hasMore: pageResult.hasMore,
1724
+ nextPage: pageResult.nextPage
1725
+ };
1726
+ loadedAiConversationsWorkId = state.work?.id ?? null;
1727
+ renderAiConversationHistory();
1728
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1729
+ if (current) $("#ai-conversation-title").textContent = current.title;
1730
+ }
1731
+
1732
+ async function loadAiConversations(page = 1) {
1630
1733
  const workId = state.work?.id;
1631
1734
  if (!workId) return;
1632
1735
  const generation = workScopedUiGeneration;
1633
- const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1736
+ const conversations = await apiPage(`/api/works/${workId}/ai-conversations`, page, aiConversationHistoryPageLimit);
1634
1737
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
1635
- state.aiConversations = conversations;
1636
- loadedAiConversationsWorkId = workId;
1637
- renderAiConversationHistory();
1638
- if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
1639
- else {
1640
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1641
- if (current) $("#ai-conversation-title").textContent = current.title;
1642
- }
1738
+ applyAiConversations(conversations);
1643
1739
  }
1644
1740
 
1645
1741
  async function ensureAiConversationsLoaded() {
@@ -1647,7 +1743,7 @@ async function ensureAiConversationsLoaded() {
1647
1743
  if (!workId || loadedAiConversationsWorkId === workId) return;
1648
1744
  if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
1649
1745
  aiConversationsLoadWorkId = workId;
1650
- aiConversationsLoadPromise = loadAiConversations(false);
1746
+ aiConversationsLoadPromise = loadAiConversations();
1651
1747
  try {
1652
1748
  await aiConversationsLoadPromise;
1653
1749
  } finally {
@@ -1660,11 +1756,15 @@ async function ensureAiConversationsLoaded() {
1660
1756
 
1661
1757
  async function openAiConversation(conversationId, hideHistory = true) {
1662
1758
  const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1759
+ upsertAiConversationSummary(conversation);
1663
1760
  state.aiConversationId = conversation.id;
1664
1761
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1665
1762
  $("#ai-conversation-title").textContent = conversation.title;
1666
1763
  resetAiFeed();
1667
1764
  for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
1765
+ if (conversation.hasCompactedSummary) {
1766
+ renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
1767
+ }
1668
1768
  state.aiCitations = [];
1669
1769
  state.aiReferences = [];
1670
1770
  setAiPromptText("");
@@ -1680,14 +1780,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1680
1780
  async function createNewAiConversation() {
1681
1781
  if (!state.work) return;
1682
1782
  const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1783
+ upsertAiConversationSummary(conversation);
1683
1784
  state.aiConversationId = conversation.id;
1684
1785
  state.aiPromptSent = false;
1685
1786
  $("#ai-conversation-title").textContent = conversation.title;
1686
1787
  resetAiFeed();
1687
1788
  hideAiContextWarning();
1789
+ setAiContextMeter(null);
1688
1790
  renderAiQuickActions();
1689
1791
  setAiHistoryVisible(false);
1690
- await loadAiConversations(false);
1691
1792
  }
1692
1793
 
1693
1794
  async function ensureAiConversation() {
@@ -1700,7 +1801,7 @@ async function persistAiConversationMessage(role, content, citations = [], metad
1700
1801
  const conversationId = await ensureAiConversation();
1701
1802
  if (!conversationId) throw new Error("无法创建 AI 对话");
1702
1803
  const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
1703
- await loadAiConversations(false);
1804
+ updateAiConversationSummaryFromMessage(message);
1704
1805
  return message;
1705
1806
  }
1706
1807
 
@@ -1837,9 +1938,9 @@ async function createSelectedLineAnnotation(kind) {
1837
1938
  if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
1838
1939
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1839
1940
  closeLineCitationMenu();
1840
- const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : "写下这段正文的批注", {
1841
- title: kind === "todo" ? "添加正文待办" : "添加正文批注",
1842
- inputLabel: kind === "todo" ? "待办内容" : "批注内容",
1941
+ const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
1942
+ title: kind === "todo" ? "添加正文待办" : "添加正文评论",
1943
+ inputLabel: kind === "todo" ? "待办内容" : "评论内容",
1843
1944
  confirmLabel: "添加",
1844
1945
  maxLength: 2000
1845
1946
  });
@@ -1849,61 +1950,107 @@ async function createSelectedLineAnnotation(kind) {
1849
1950
  method: "POST",
1850
1951
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
1851
1952
  });
1852
- toast(kind === "todo" ? "正文待办已添加" : "正文批注已添加");
1953
+ toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
1853
1954
  await openChapterAnnotationsDialog();
1854
1955
  } catch (error) {
1855
1956
  toast(error.message, "error");
1856
1957
  }
1857
1958
  }
1858
1959
 
1859
- function renderChapterAnnotations() {
1860
- const host = $("#chapter-annotations-list");
1861
- host.innerHTML = chapterAnnotations.length ? chapterAnnotations.map((annotation) => `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1862
- <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "批注"}</span><strong>${annotation.startLine === annotation.endLine ? `第 ${annotation.startLine} 行` : `第 ${annotation.startLine}-${annotation.endLine} 行`}</strong><em>${annotation.status === "resolved" ? "已完成" : "处理中"}</em></header>
1960
+ function chapterAnnotationLineLabel(annotation) {
1961
+ return annotation.startLine === annotation.endLine
1962
+ ? `第 ${annotation.startLine} 行`
1963
+ : `第 ${annotation.startLine}-${annotation.endLine} 行`;
1964
+ }
1965
+
1966
+ function chapterAnnotationStatusLabel(annotation) {
1967
+ if (annotation.status === "resolved") return annotation.kind === "todo" ? "已完成" : "已解决";
1968
+ return annotation.kind === "todo" ? "待处理" : "未解决";
1969
+ }
1970
+
1971
+ function chapterAnnotationCard(annotation, { showSource = false } = {}) {
1972
+ const lineLabel = chapterAnnotationLineLabel(annotation);
1973
+ const sourceLabel = `${annotation.volumeTitle ?? "正文"} / ${annotation.chapterTitle ?? "未命名章节"}`;
1974
+ const heading = showSource
1975
+ ? `<strong class="chapter-annotation-source" title="${esc(sourceLabel)}">${esc(sourceLabel)}</strong><span class="chapter-annotation-line">${esc(lineLabel)}</span>`
1976
+ : `<strong>${esc(lineLabel)}</strong>`;
1977
+ return `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1978
+ <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "评论"}</span>${heading}<em>${esc(chapterAnnotationStatusLabel(annotation))}</em></header>
1863
1979
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
1864
- <p>${esc(annotation.note)}</p>
1980
+ <p data-annotation-content>${esc(annotation.note)}</p>
1865
1981
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
1866
- <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>编辑说明</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : "完成待办"}</button><button class="danger-button" type="button" data-annotation-delete>删除</button>` : ""}</footer>
1867
- </article>`).join("") : '<p class="entity-history-empty">本章还没有批注或待办。选择正文行并打开右键菜单即可添加。</p>';
1982
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>${annotation.kind === "todo" ? "编辑待办" : "编辑评论"}</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : (annotation.kind === "todo" ? "完成待办" : "解决评论")}</button><button class="danger-button" type="button" data-annotation-delete>${annotation.kind === "todo" ? "删除待办" : "删除评论"}</button>` : ""}</footer>
1983
+ </article>`;
1984
+ }
1985
+
1986
+ function bindChapterAnnotationCards(host, annotations, { refresh, locate, overlayDialog = null }) {
1987
+ const suspendOverlay = () => {
1988
+ if (overlayDialog?.open) overlayDialog.close();
1989
+ };
1990
+ const resumeOverlay = () => {
1991
+ if (overlayDialog && !overlayDialog.open) overlayDialog.showModal();
1992
+ };
1868
1993
  host.querySelectorAll("[data-annotation-id]").forEach((card) => {
1869
- const annotation = chapterAnnotations.find((item) => item.id === card.dataset.annotationId);
1994
+ const annotation = annotations.find((item) => item.id === card.dataset.annotationId);
1870
1995
  if (!annotation) return;
1871
- card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
1872
- $("#chapter-annotations-dialog").close();
1873
- paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
1874
- selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
1996
+ card.querySelector("[data-annotation-locate]")?.addEventListener("click", async () => {
1997
+ try { await locate(annotation); }
1998
+ catch (error) { toast(error.message, "error"); }
1875
1999
  });
1876
2000
  card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
1877
2001
  try {
1878
2002
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
1879
- await loadChapterAnnotations();
2003
+ await refresh();
1880
2004
  } catch (error) { toast(error.message, "error"); }
1881
2005
  });
1882
2006
  card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
1883
- const dialog = $("#chapter-annotations-dialog");
1884
- dialog.close();
1885
- const note = await inputToast("修改批注或待办说明", { title: "编辑说明", inputLabel: "说明", placeholder: annotation.note, confirmLabel: "保存", maxLength: 2000 });
1886
- if (!note) return dialog.showModal();
2007
+ suspendOverlay();
2008
+ const note = await inputToast(annotation.kind === "todo" ? "修改待办内容" : "修改评论内容", {
2009
+ title: annotation.kind === "todo" ? "编辑待办" : "编辑评论",
2010
+ inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
2011
+ value: annotation.note,
2012
+ confirmLabel: "保存",
2013
+ maxLength: 2000
2014
+ });
2015
+ if (!note) return resumeOverlay();
1887
2016
  try {
1888
2017
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
1889
- await loadChapterAnnotations();
2018
+ await refresh();
1890
2019
  } catch (error) { toast(error.message, "error"); }
1891
- dialog.showModal();
2020
+ resumeOverlay();
1892
2021
  });
1893
2022
  card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
1894
- const dialog = $("#chapter-annotations-dialog");
1895
- dialog.close();
1896
- const confirmed = await confirmToast("删除后不会在本章清单中显示,但历史版本仍会保留。", { title: "删除批注或待办", confirmLabel: "确认删除" });
1897
- if (!confirmed) return dialog.showModal();
2023
+ suspendOverlay();
2024
+ const confirmed = await confirmToast("删除后不会在正文评论中显示,但历史版本仍会保留。", {
2025
+ title: annotation.kind === "todo" ? "删除待办" : "删除评论",
2026
+ confirmLabel: "确认删除"
2027
+ });
2028
+ if (!confirmed) return resumeOverlay();
1898
2029
  try {
1899
2030
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
1900
- await loadChapterAnnotations();
1901
- dialog.showModal();
1902
- } catch (error) { dialog.showModal(); toast(error.message, "error"); }
2031
+ await refresh();
2032
+ } catch (error) { toast(error.message, "error"); }
2033
+ resumeOverlay();
1903
2034
  });
1904
2035
  });
1905
2036
  }
1906
2037
 
2038
+ function renderChapterAnnotations() {
2039
+ const host = $("#chapter-annotations-list");
2040
+ host.innerHTML = chapterAnnotations.length
2041
+ ? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
2042
+ : '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
2043
+ bindChapterAnnotationCards(host, chapterAnnotations, {
2044
+ refresh: loadChapterAnnotations,
2045
+ locate: (annotation) => {
2046
+ $("#chapter-annotations-dialog").close();
2047
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
2048
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
2049
+ },
2050
+ overlayDialog: $("#chapter-annotations-dialog")
2051
+ });
2052
+ }
2053
+
1907
2054
  async function loadChapterAnnotations() {
1908
2055
  if (!state.chapter) return;
1909
2056
  chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
@@ -1912,8 +2059,8 @@ async function loadChapterAnnotations() {
1912
2059
 
1913
2060
  async function openChapterAnnotationsDialog() {
1914
2061
  if (!state.chapter) return;
1915
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文批注与待办`;
1916
- $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载批注与待办…</p>';
2062
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
2063
+ $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
1917
2064
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
1918
2065
  try {
1919
2066
  await loadChapterAnnotations();
@@ -2182,6 +2329,10 @@ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2182
2329
  error.details = source.details;
2183
2330
  error.failure = typeof source.failure === "string" ? source.failure : undefined;
2184
2331
  error.callId = typeof source.callId === "string" ? source.callId : undefined;
2332
+ error.providerName = typeof source.providerName === "string" ? source.providerName : undefined;
2333
+ error.providerId = typeof source.providerId === "string" ? source.providerId : undefined;
2334
+ error.modelId = typeof source.modelId === "string" ? source.modelId : undefined;
2335
+ error.modelRecordId = typeof source.modelRecordId === "string" ? source.modelRecordId : undefined;
2185
2336
  return error;
2186
2337
  }
2187
2338
 
@@ -2193,8 +2344,13 @@ function formatAiFailureMessage(error) {
2193
2344
  const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
2194
2345
  const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
2195
2346
  const callId = typeof error?.callId === "string" ? error.callId : typeof details.callId === "string" ? details.callId : "";
2347
+ const providerName = typeof error?.providerName === "string" ? error.providerName : typeof details.providerName === "string" ? details.providerName : "";
2348
+ const providerId = typeof error?.providerId === "string" ? error.providerId : typeof details.providerId === "string" ? details.providerId : "";
2349
+ const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
2196
2350
  if (code) lines.push(`错误码:${code}`);
2197
2351
  if (status) lines.push(`服务端状态:HTTP ${status}`);
2352
+ if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2353
+ if (modelId) lines.push(`模型 ID:${modelId}`);
2198
2354
  if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2199
2355
  if (callId) lines.push(`调用 ID:${callId}`);
2200
2356
  return lines.join("\n\n");
@@ -2242,7 +2398,6 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2242
2398
  path.startsWith("/api/auth/")
2243
2399
  || path.includes("/presence")
2244
2400
  || path.includes("/context/prepare")
2245
- || path.includes("/ai-context-usage")
2246
2401
  || path.includes("/chat/stream")
2247
2402
  ) return;
2248
2403
 
@@ -2259,6 +2414,7 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2259
2414
  if (path.includes("/timeline")) affected.add("timeline");
2260
2415
  if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
2261
2416
  if (path.includes("/relationships")) affected.add("relationships");
2417
+ if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
2262
2418
  if (path.includes("/reviews")) affected.add("reviews");
2263
2419
  if (path.includes("/entity-versions/")) {
2264
2420
  if (path.includes("/draft/")) affected.add("drafts");
@@ -2329,7 +2485,7 @@ async function refreshAuthCaptcha(target = "login") {
2329
2485
  if (answerInput) answerInput.value = "";
2330
2486
  }
2331
2487
 
2332
- function showAuth(setupRequired, registrationOpen = false) {
2488
+ function showAuth(setupRequired, registrationOpen = false, setupTokenRequired = false) {
2333
2489
  if (state.user) return;
2334
2490
  document.body.classList.add("auth-pending");
2335
2491
  $("#auth-view").classList.remove("hidden");
@@ -2346,6 +2502,10 @@ function showAuth(setupRequired, registrationOpen = false) {
2346
2502
  registerTab.disabled = !canRegister;
2347
2503
  registerTab.setAttribute("aria-disabled", String(!canRegister));
2348
2504
  registerTab.textContent = canRegister ? "注册" : "注册已禁用";
2505
+ const setupTokenField = $("#register-setup-token-field");
2506
+ const setupTokenInput = setupTokenField.querySelector('input[name="setupToken"]');
2507
+ setupTokenField.classList.toggle("hidden", !setupTokenRequired);
2508
+ setupTokenInput.required = setupTokenRequired;
2349
2509
  selectAuthMode(setupRequired && canRegister ? "register" : "login");
2350
2510
  }
2351
2511
 
@@ -2424,7 +2584,7 @@ async function initializeAuthentication() {
2424
2584
  if (!session.authenticated) {
2425
2585
  // 未登录时一律转到登录页路由;登录页本身则保持原样
2426
2586
  if (route.view !== "login") window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
2427
- showAuth(session.setupRequired, session.registrationOpen === true);
2587
+ showAuth(session.setupRequired, session.registrationOpen === true, session.setupTokenRequired === true);
2428
2588
  return false;
2429
2589
  }
2430
2590
  // 已登录却停在登录页路由时,回到书架首页
@@ -2523,7 +2683,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
2523
2683
  });
2524
2684
  }
2525
2685
 
2526
- function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2686
+ function inputToast(message, { title = "请输入", inputLabel = title, value = "", placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2527
2687
  const region = $("#toast-region");
2528
2688
  const element = document.createElement("section");
2529
2689
  element.className = "toast toast-confirmation toast-input-dialog";
@@ -2537,6 +2697,7 @@ function inputToast(message, { title = "请输入", inputLabel = title, placehol
2537
2697
  input.className = "toast-input";
2538
2698
  input.type = "text";
2539
2699
  input.maxLength = maxLength;
2700
+ input.value = value;
2540
2701
  input.placeholder = placeholder;
2541
2702
  input.setAttribute("aria-label", inputLabel);
2542
2703
  const actions = document.createElement("div");
@@ -2963,15 +3124,15 @@ const workAuditActionLabels = {
2963
3124
  "chapter.deleted": "删除章节",
2964
3125
  "chapter.purged": "彻底删除章节",
2965
3126
  "chapter.restored": "恢复章节",
2966
- "draft.created": "创建草稿",
2967
- "draft.updated": "更新草稿",
2968
- "draft.deleted": "删除草稿",
2969
- "draft.restored": "恢复草稿",
3127
+ "draft.created": "创建想法",
3128
+ "draft.updated": "更新想法",
3129
+ "draft.deleted": "删除想法",
3130
+ "draft.restored": "恢复想法",
2970
3131
  "work.imported": "导入正文"
2971
3132
  };
2972
3133
 
2973
3134
  function workAuditEntityLabel(type) {
2974
- return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
3135
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
2975
3136
  }
2976
3137
 
2977
3138
  function workAuditDetailText(detail) {
@@ -3089,6 +3250,7 @@ async function openPlatformUiSettingsDialog() {
3089
3250
  $("#page-size-timeline").value = String(pageSizes.timeline);
3090
3251
  $("#page-size-outlines").value = String(pageSizes.outlines);
3091
3252
  $("#page-size-relationships").value = String(pageSizes.relationships);
3253
+ $("#page-size-comments").value = String(pageSizes.comments);
3092
3254
  $("#page-size-reviews").value = String(pageSizes.reviews);
3093
3255
  $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
3094
3256
  $("#page-size-file-versions").value = String(pageSizes.fileVersions);
@@ -3455,6 +3617,7 @@ function resetWorkScopedUiCaches() {
3455
3617
  aiReferencesLoadWorkId = null;
3456
3618
  aiConversationsLoadPromise = null;
3457
3619
  aiConversationsLoadWorkId = null;
3620
+ aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
3458
3621
  raceHierarchyLoadPromise = null;
3459
3622
  raceHierarchyLoadWorkId = null;
3460
3623
  loadedRaceHierarchyWorkId = null;
@@ -3465,6 +3628,7 @@ function resetWorkScopedUiCaches() {
3465
3628
  state.races = [];
3466
3629
  characterListPage = 1;
3467
3630
  draftTypeFilter = "all";
3631
+ draftBindingFilters = [];
3468
3632
  draftFiltersPanelOpen = false;
3469
3633
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
3470
3634
  relationshipFilters.fromCharacterIds = [];
@@ -3476,9 +3640,6 @@ function resetWorkScopedUiCaches() {
3476
3640
  volumeChapterRequests.clear();
3477
3641
  state.collapsedRaceIds.clear();
3478
3642
  lastSavedChapterSnapshot = null;
3479
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
3480
- aiContextUsageTimer = null;
3481
- aiContextUsageRequest += 1;
3482
3643
  state.aiCitations = [];
3483
3644
  state.aiReferences = [];
3484
3645
  state.aiPromptSent = false;
@@ -4000,7 +4161,7 @@ function markActiveModule(module) {
4000
4161
  }
4001
4162
 
4002
4163
  const moduleMeta = {
4003
- drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
4164
+ drafts: ["临时想法", "创作想法", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建想法"],
4004
4165
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
4005
4166
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
4006
4167
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -4008,6 +4169,7 @@ const moduleMeta = {
4008
4169
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
4009
4170
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
4010
4171
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
4172
+ comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
4011
4173
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
4012
4174
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
4013
4175
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -4052,7 +4214,7 @@ async function showModule(module) {
4052
4214
  $("#module-description").textContent = meta[2];
4053
4215
  $("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
4054
4216
  $("#module-create-button").textContent = meta[3];
4055
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
4217
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || module === "comments" || !canEditModule(module));
4056
4218
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
4057
4219
  bindModuleContentInteractions();
4058
4220
  try {
@@ -4064,6 +4226,7 @@ async function showModule(module) {
4064
4226
  if (module === "timeline") await renderTimeline();
4065
4227
  if (module === "outlines") await renderOutlines();
4066
4228
  if (module === "relationships") await renderRelationships();
4229
+ if (module === "comments") await renderWorkChapterComments();
4067
4230
  if (module === "reviews") await renderReviews();
4068
4231
  if (module === "tasks") await renderTasks(taskListPage);
4069
4232
  if (module === "ai-settings") await renderBookAiSettings();
@@ -4371,7 +4534,7 @@ function mountRelationshipFilterToggle() {
4371
4534
 
4372
4535
  function mountDraftFilterToggle() {
4373
4536
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
4374
- $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选草稿" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" title="筛选草稿"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
4537
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选想法" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" title="筛选想法"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
4375
4538
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
4376
4539
  draftFiltersPanelOpen = !draftFiltersPanelOpen;
4377
4540
  await renderDrafts(moduleListPages.drafts);
@@ -4467,15 +4630,47 @@ function settingRecordActions(item) {
4467
4630
  }
4468
4631
 
4469
4632
  function draftTypeLabel(draftType) {
4470
- return draftType === "setting" ? "设定草稿" : "正文草稿";
4633
+ return draftType === "setting" ? "设定想法" : "正文想法";
4634
+ }
4635
+
4636
+ const draftSettingModules = Object.freeze([
4637
+ ["settings", "设定库"],
4638
+ ["characters", "角色"],
4639
+ ["races", "种族"],
4640
+ ["organizations", "组织"],
4641
+ ["timeline", "时间轴"],
4642
+ ["relationships", "关系"],
4643
+ ["outlines", "大纲/伏笔"]
4644
+ ]);
4645
+
4646
+ function draftSettingModuleLabel(module) {
4647
+ return draftSettingModules.find(([value]) => value === module)?.[1] ?? "未知模块";
4648
+ }
4649
+
4650
+ function draftBindingKey(item) {
4651
+ if (item.draftType === "prose" && item.volumeId) return `volume:${item.volumeId}`;
4652
+ if (item.draftType === "setting" && item.settingModule) return `module:${item.settingModule}`;
4653
+ return "global";
4654
+ }
4655
+
4656
+ function draftBindingLabel(item) {
4657
+ if (item.draftType === "prose" && item.volumeId) return `分卷 · ${item.volumeTitle || "已删除分卷"}`;
4658
+ if (item.draftType === "setting" && item.settingModule) return `设定模块 · ${draftSettingModuleLabel(item.settingModule)}`;
4659
+ return "全局";
4471
4660
  }
4472
4661
 
4473
4662
  async function deleteDraft(item) {
4474
4663
  if (!item || !canEditModule("drafts")) return;
4475
- const dialog = $("#form-dialog");
4476
- dialog.close();
4477
- if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4478
- title: "删除草稿",
4664
+ $("#form-dialog").close();
4665
+ if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
4666
+ title: "删除想法",
4667
+ confirmLabel: "继续删除"
4668
+ })) {
4669
+ openDraftDialog(item);
4670
+ return;
4671
+ }
4672
+ if (!await confirmToast(`删除想法“${item.title}”后将从想法列表移除,版本历史仍会保留。仍要删除吗?`, {
4673
+ title: "删除操作需要再次确认",
4479
4674
  confirmLabel: "确认删除"
4480
4675
  })) {
4481
4676
  openDraftDialog(item);
@@ -4484,7 +4679,7 @@ async function deleteDraft(item) {
4484
4679
  try {
4485
4680
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4486
4681
  await renderDrafts(moduleListPages.drafts);
4487
- toast("草稿已删除");
4682
+ toast("想法已删除");
4488
4683
  } catch (error) {
4489
4684
  toast(error.message, "error");
4490
4685
  try {
@@ -4497,23 +4692,31 @@ async function deleteDraft(item) {
4497
4692
 
4498
4693
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4499
4694
  const viewOnly = readOnly || !canEditModule("drafts");
4500
- const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
4501
- <div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
4502
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
4695
+ const volumeOptions = [["", "全局(不绑定分卷)"], ...(state.work?.volumes ?? []).map((volume) => [volume.id, volume.title])];
4696
+ const settingModuleOptions = [["", "全局(不绑定设定模块)"], ...draftSettingModules];
4697
+ const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
4698
+ <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
4699
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
4503
4700
  </section>` : "";
4504
- const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4505
- + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4701
+ const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4702
+ + field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
4703
+ + `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
4704
+ + `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
4506
4705
  + field("title", "标题", "text", item?.title ?? "")
4507
4706
  + field("content", "内容", "markdown", item?.content ?? "", {
4508
4707
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
4708
+ attachmentModule: "drafts",
4509
4709
  readOnly: viewOnly
4510
4710
  }) + management;
4511
- openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4711
+ openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
4512
4712
  if (viewOnly) return;
4513
4713
  const title = String(form.get("title") ?? "").trim();
4514
- if (!title) throw new Error("请填写草稿标题");
4714
+ if (!title) throw new Error("请填写想法标题");
4715
+ const draftType = form.get("draftType") === "setting" ? "setting" : "prose";
4515
4716
  const body = {
4516
- draftType: form.get("draftType"),
4717
+ draftType,
4718
+ volumeId: draftType === "prose" ? String(form.get("volumeId") ?? "") || null : null,
4719
+ settingModule: draftType === "setting" ? String(form.get("settingModule") ?? "") || null : null,
4517
4720
  title,
4518
4721
  content: String(form.get("content") ?? ""),
4519
4722
  ...(item ? { expectedVersionNo: item.versionNo } : {})
@@ -4523,13 +4726,25 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4523
4726
  body
4524
4727
  });
4525
4728
  await renderDrafts(moduleListPages.drafts);
4526
- toast(item ? "草稿已保存" : "草稿已创建");
4729
+ toast(item ? "想法已保存" : "想法已创建");
4527
4730
  }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4528
- submitLabel: viewOnly ? "关闭" : "保存草稿",
4731
+ submitLabel: viewOnly ? "关闭" : "保存想法",
4529
4732
  hideCancel: viewOnly,
4530
4733
  editor: true,
4531
- errorPrefix: "草稿保存失败:"
4532
- });
4734
+ errorPrefix: "想法保存失败:"
4735
+ });
4736
+ const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
4737
+ const syncDraftBindingFields = () => {
4738
+ const draftType = draftTypeSelect?.value === "setting" ? "setting" : "prose";
4739
+ $("#dialog-fields").querySelectorAll("[data-draft-binding-field]").forEach((bindingField) => {
4740
+ const active = bindingField.dataset.draftBindingField === draftType;
4741
+ bindingField.classList.toggle("hidden", !active);
4742
+ const select = bindingField.querySelector("select");
4743
+ if (select) select.disabled = !active || viewOnly;
4744
+ });
4745
+ };
4746
+ draftTypeSelect?.addEventListener("change", syncDraftBindingFields);
4747
+ syncDraftBindingFields();
4533
4748
  if (viewOnly) {
4534
4749
  $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
4535
4750
  if (control instanceof HTMLSelectElement) control.disabled = true;
@@ -4543,48 +4758,58 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4543
4758
 
4544
4759
  async function renderDrafts(page = moduleListPages.drafts) {
4545
4760
  const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
4546
- const drafts = draftTypeFilter === "all"
4761
+ const typeDrafts = draftTypeFilter === "all"
4547
4762
  ? allDrafts
4548
4763
  : allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
4764
+ const selectedBindingKeys = new Set(draftBindingFilters);
4765
+ const drafts = selectedBindingKeys.size
4766
+ ? typeDrafts.filter((draft) => selectedBindingKeys.has(draftBindingKey(draft)))
4767
+ : typeDrafts;
4768
+ const hasDraftFilters = draftTypeFilter !== "all" || selectedBindingKeys.size > 0;
4549
4769
  mountModuleCount(drafts.length);
4550
4770
  const pageResult = paginateModuleItems(drafts, page, "drafts");
4551
4771
  moduleListPages.drafts = pageResult.page;
4552
4772
  const layout = readModuleLayout();
4553
- if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4773
+ if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
4554
4774
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4555
4775
  mountDraftFilterToggle();
4556
- const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4557
- <label for="draft-type-filter">草稿类型</label>
4558
- <select id="draft-type-filter" aria-label="按草稿类型筛选">
4559
- <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
4560
- <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
4561
- <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
4562
- </select>
4563
- ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4776
+ const bindingOptions = [
4777
+ ["global", "全局(未绑定)"],
4778
+ ...(state.work?.volumes ?? []).map((volume) => [`volume:${volume.id}`, `分卷 · ${volume.title}`]),
4779
+ ...draftSettingModules.map(([value, label]) => [`module:${value}`, `设定模块 · ${label}`])
4780
+ ];
4781
+ const filterToolbar = `<section id="draft-filter-panel" class="character-filter-toolbar draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
4782
+ <label class="draft-type-filter-field" for="draft-type-filter"><span>按想法类型筛选</span><select id="draft-type-filter" aria-label="按想法类型筛选">
4783
+ <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部想法</option>
4784
+ <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文想法</option>
4785
+ <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定想法</option>
4786
+ </select></label>
4787
+ <details class="character-filter-dropdown"><summary><span>按绑定位置筛选</span><strong>${selectedBindingKeys.size ? `已选 ${selectedBindingKeys.size} 项` : "全部位置"}</strong></summary><div id="draft-binding-filter" class="character-filter-options">${bindingOptions.map(([value, label]) => `<label class="character-filter-option"><input type="checkbox" value="${esc(value)}" ${selectedBindingKeys.has(value) ? "checked" : ""}><span>${esc(label)}</span></label>`).join("")}</div></details>
4788
+ <div class="character-filter-toolbar-actions">${hasDraftFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>` : ""}<button id="clear-draft-filters" class="ghost-button" type="button" ${hasDraftFilters ? "" : "disabled"}>重置筛选</button></div>
4564
4789
  </section>`;
4565
4790
  const actions = (item) => canEditModule("drafts")
4566
- ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4791
+ ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4567
4792
  : recordHistoryButton("draft", item.id, item.title);
4568
4793
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4569
- <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4570
- <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4794
+ <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4795
+ <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4571
4796
  <h3>${esc(item.title)}</h3>
4572
4797
  <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
4573
4798
  <div class="card-actions">${actions(item)}</div>
4574
4799
  </article>`).join("")}</div>`;
4575
4800
  const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4576
4801
  const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4577
- return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4578
- <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4802
+ return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4803
+ <small>${esc(draftTypeLabel(item.draftType))} · ${esc(draftBindingLabel(item))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4579
4804
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4580
4805
  <div class="card-actions">${actions(item)}</div>
4581
4806
  </article>`;
4582
4807
  }).join("")}</div>`;
4583
4808
  const emptyDrafts = allDrafts.length
4584
- ? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
4585
- : emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4809
+ ? emptyModule("没有符合筛选条件的想法", "可以切换想法类型、绑定位置或重置筛选。")
4810
+ : emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4586
4811
  $("#module-content").innerHTML = filterToolbar + (drafts.length
4587
- ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
4812
+ ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
4588
4813
  : emptyDrafts);
4589
4814
  $("#draft-type-filter").addEventListener("change", async (event) => {
4590
4815
  draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
@@ -4592,6 +4817,19 @@ async function renderDrafts(page = moduleListPages.drafts) {
4592
4817
  moduleListPages.drafts = 1;
4593
4818
  await renderDrafts(1);
4594
4819
  });
4820
+ $("#draft-binding-filter").addEventListener("change", async () => {
4821
+ draftBindingFilters = [...$("#draft-binding-filter").querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
4822
+ draftFiltersPanelOpen = true;
4823
+ moduleListPages.drafts = 1;
4824
+ await renderDrafts(1);
4825
+ });
4826
+ $("#clear-draft-filters")?.addEventListener("click", async () => {
4827
+ draftTypeFilter = "all";
4828
+ draftBindingFilters = [];
4829
+ draftFiltersPanelOpen = true;
4830
+ moduleListPages.drafts = 1;
4831
+ await renderDrafts(1);
4832
+ });
4595
4833
  bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
4596
4834
  bindModulePagination("drafts", renderDrafts);
4597
4835
  $("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
@@ -5095,6 +5333,35 @@ async function renderRelationships(page = moduleListPages.relationships) {
5095
5333
  bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
5096
5334
  }
5097
5335
 
5336
+ async function renderWorkChapterComments(page = moduleListPages.comments) {
5337
+ const pageSize = pageSizeFor("comments");
5338
+ const result = await moduleApiPage(
5339
+ "comments",
5340
+ `/api/works/${encodeURIComponent(state.work.id)}/chapter-annotations`,
5341
+ page,
5342
+ pageSize
5343
+ );
5344
+ if (!result.items.length && page > 1) return renderWorkChapterComments(page - 1);
5345
+ const total = Number(result.total ?? result.items.length);
5346
+ const pageCount = Math.max(1, Math.ceil(total / result.limit));
5347
+ const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
5348
+ moduleListPages.comments = pageResult.page;
5349
+ mountModuleCount(total);
5350
+ $("#module-content").innerHTML = result.items.length
5351
+ ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
5352
+ : emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
5353
+ bindModulePagination("comments", renderWorkChapterComments);
5354
+ bindChapterAnnotationCards($("#module-content"), result.items, {
5355
+ refresh: () => renderWorkChapterComments(pageResult.page),
5356
+ locate: async (annotation) => {
5357
+ await selectChapter(annotation.chapterId);
5358
+ await new Promise((resolve) => window.requestAnimationFrame(resolve));
5359
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
5360
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
5361
+ }
5362
+ });
5363
+ }
5364
+
5098
5365
  async function renderReviews(page = moduleListPages.reviews) {
5099
5366
  const canReadCharacters = canReadModule("characters");
5100
5367
  const canResolveReview = canEditModule("reviews");
@@ -5943,9 +6210,9 @@ function renderProviderCards(providers, models) {
5943
6210
  : provider.connectionStatus !== "success"
5944
6211
  ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
5945
6212
  : "";
5946
- return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}<button class="ghost-button model-test-button" type="button" data-test-model="${esc(model.id)}" aria-label="测试模型 ${esc(model.displayName)}">测试连接</button></div>`;
6213
+ return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
5947
6214
  }).join("")}</div>
5948
- <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><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>`;
6215
+ <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>`;
5949
6216
  }).join("")}</div>`
5950
6217
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
5951
6218
  }
@@ -5959,14 +6226,6 @@ function bindPlatformProviderActions(host, providers, models) {
5959
6226
  await renderPlatformAiConfig();
5960
6227
  await loadModels();
5961
6228
  }));
5962
- host.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", async () => {
5963
- button.disabled = true;
5964
- button.textContent = "测试中";
5965
- const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
5966
- toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
5967
- await renderPlatformAiConfig();
5968
- await loadModels();
5969
- }));
5970
6229
  host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
5971
6230
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
5972
6231
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
@@ -6408,7 +6667,7 @@ async function renderBookAiSettings() {
6408
6667
  );
6409
6668
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
6410
6669
  "beforeend",
6411
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
6670
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label>`
6412
6671
  );
6413
6672
  if (!canEditModule("ai-settings")) {
6414
6673
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -6447,7 +6706,7 @@ async function renderBookAiSettings() {
6447
6706
  try {
6448
6707
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { systemPrompt: $("#work-system-prompt").value } });
6449
6708
  toast("本书系统提示词已保存");
6450
- scheduleAiContextUsage();
6709
+ setAiContextMeter(null);
6451
6710
  } catch (error) {
6452
6711
  toast(error.message, "error");
6453
6712
  } finally {
@@ -6500,7 +6759,7 @@ async function renderBookAiSettings() {
6500
6759
  try {
6501
6760
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { bookSummaryContextPercent: Number($("#book-summary-context-percent").value) } });
6502
6761
  toast("全书概要引用配额已保存");
6503
- scheduleAiContextUsage();
6762
+ setAiContextMeter(null);
6504
6763
  } catch (error) {
6505
6764
  toast(error.message, "error");
6506
6765
  } finally {
@@ -6513,7 +6772,7 @@ async function renderBookAiSettings() {
6513
6772
  try {
6514
6773
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
6515
6774
  toast("对话上下文 compact 阈值已保存");
6516
- scheduleAiContextUsage();
6775
+ setAiContextMeter(null);
6517
6776
  } catch (error) {
6518
6777
  toast(error.message, "error");
6519
6778
  } finally {
@@ -6564,13 +6823,17 @@ async function loadModels() {
6564
6823
  const generation = workScopedUiGeneration;
6565
6824
  const models = await api(`/api/works/${workId}/models`);
6566
6825
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6567
- state.models = models.filter((model) => isSelectableModel(model));
6826
+ applyAiModels(models);
6568
6827
  loadedAiModelsWorkId = workId;
6828
+ }
6829
+
6830
+ function applyAiModels(models) {
6831
+ state.models = models.filter((model) => isSelectableModel(model));
6569
6832
  const select = $("#ai-model");
6570
6833
  select.innerHTML = state.models.length
6571
6834
  ? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
6572
6835
  : '<option value="">请先配置模型</option>';
6573
- scheduleAiContextUsage();
6836
+ setAiContextMeter(null);
6574
6837
  }
6575
6838
 
6576
6839
  async function ensureAiModelsLoaded() {
@@ -6594,9 +6857,6 @@ async function ensureAiModelsLoaded() {
6594
6857
  }
6595
6858
  }
6596
6859
 
6597
- let aiContextUsageTimer = null;
6598
- let aiContextUsageRequest = 0;
6599
-
6600
6860
  function currentAiRequestScope() {
6601
6861
  if (!state.work) return null;
6602
6862
  const taskType = $("#ai-task").value;
@@ -6622,7 +6882,7 @@ function renderAiContextDistribution(usage) {
6622
6882
  const distribution = normalizeAiContextTokenDistribution(usage);
6623
6883
  const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
6624
6884
  $("#ai-context-popover-description").textContent = usage
6625
- ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok`
6885
+ ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
6626
6886
  : "选择可用模型后显示当前上下文用量";
6627
6887
  host.replaceChildren(...distribution.items.map((item) => {
6628
6888
  const row = document.createElement("div");
@@ -6635,9 +6895,9 @@ function renderAiContextDistribution(usage) {
6635
6895
  label.className = "ai-context-distribution-label";
6636
6896
  const title = document.createElement("span");
6637
6897
  title.textContent = item.label;
6638
- if (item.key === "context") {
6898
+ if (item.key === "skills" || item.key === "context") {
6639
6899
  const description = document.createElement("small");
6640
- description.textContent = "用户和 agent 的交互";
6900
+ description.textContent = item.key === "skills" ? "待加入" : "用户和 agent 的交互";
6641
6901
  title.append(" ", description);
6642
6902
  }
6643
6903
  const value = document.createElement("strong");
@@ -6701,57 +6961,6 @@ function hideAiContextWarning() {
6701
6961
  $("#ai-context-warning").classList.add("hidden");
6702
6962
  }
6703
6963
 
6704
- async function prepareAiConversationContext({ instruction, scope, modelId, citations }) {
6705
- const conversationId = await ensureAiConversation();
6706
- const prepared = await api(`/api/ai-conversations/${conversationId}/context/prepare`, {
6707
- method: "POST",
6708
- body: { instruction, scope, modelId, citations }
6709
- });
6710
- setAiContextMeter(prepared.usage);
6711
- if (prepared.action === "warn") {
6712
- showAiContextWarning(prepared.usage);
6713
- return false;
6714
- }
6715
- hideAiContextWarning();
6716
- if (prepared.action === "compacted") toast("已自动整理较早对话为长期记忆并继续发送");
6717
- return true;
6718
- }
6719
-
6720
- function scheduleAiContextUsage() {
6721
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
6722
- aiContextUsageTimer = setTimeout(() => {
6723
- aiContextUsageTimer = null;
6724
- void refreshAiContextUsage();
6725
- }, 260);
6726
- }
6727
-
6728
- async function refreshAiContextUsage() {
6729
- const requestScope = currentAiRequestScope();
6730
- const modelId = $("#ai-model").value;
6731
- if (!requestScope || !modelId || (requestScope.taskType === "polish" && !requestScope.selection)) {
6732
- setAiContextMeter(null);
6733
- return;
6734
- }
6735
- const requestId = ++aiContextUsageRequest;
6736
- try {
6737
- const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
6738
- const usage = await api(`/api/works/${state.work.id}/ai-context-usage`, {
6739
- method: "POST",
6740
- body: {
6741
- modelId,
6742
- taskType: requestScope.taskType,
6743
- scope: requestScope.scope,
6744
- instruction: aiPromptText(),
6745
- citations,
6746
- conversationId: state.aiConversationId || undefined
6747
- }
6748
- });
6749
- if (requestId === aiContextUsageRequest) setAiContextMeter(usage);
6750
- } catch {
6751
- if (requestId === aiContextUsageRequest) setAiContextMeter(null);
6752
- }
6753
- }
6754
-
6755
6964
  async function loadAiReferences() {
6756
6965
  const workId = state.work?.id;
6757
6966
  if (!workId) return;
@@ -6784,7 +6993,7 @@ async function ensureAiReferencesLoaded() {
6784
6993
 
6785
6994
  function field(name, label, type = "text", value = "", options = []) {
6786
6995
  if (type === "textarea") return `<label>${esc(label)}<textarea name="${esc(name)}">${esc(value)}</textarea></label>`;
6787
- if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
6996
+ if (type === "markdown") return `<div class="form-field markdown-editor-field" data-vditor-editor-field><span>${esc(label)}</span><div class="vditor-editor-host" data-vditor-editor data-attachment-module="${esc(options.attachmentModule ?? "settings")}" data-placeholder="${esc(options.placeholder ?? `在这里编辑${label}`)}" aria-label="${esc(label)} Markdown 编辑器"></div><textarea class="hidden" name="${esc(name)}" data-vditor-value maxlength="200000" aria-label="${esc(label)} Markdown 原文" ${options.readOnly ? "readonly" : ""}>${esc(value)}</textarea></div>`;
6788
6997
  if (type === "item-list") {
6789
6998
  const values = Array.isArray(value) && value.length ? value : [""];
6790
6999
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
@@ -7470,10 +7679,10 @@ function markdownImageLabel(file, fallback = "图片附件") {
7470
7679
  return String(file?.name ?? "").replace(/[\[\]\r\n]/gu, "").trim() || fallback;
7471
7680
  }
7472
7681
 
7473
- async function uploadMarkdownAttachment(file) {
7682
+ async function uploadMarkdownAttachment(file, module = "settings") {
7474
7683
  const body = new FormData();
7475
7684
  body.append("file", file);
7476
- const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
7685
+ const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
7477
7686
  if (!attachment.deduplicated) markdownEditorPendingAttachments.push(String(attachment.id));
7478
7687
  return { attachment, imageLabel: markdownImageLabel(file) };
7479
7688
  }
@@ -7504,7 +7713,7 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
7504
7713
  };
7505
7714
  }
7506
7715
 
7507
- function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = uploadMarkdownAttachment, placeholder = "", readOnly = false, width = "auto" } = {}) {
7716
+ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = null, attachmentModule = "settings", placeholder = "", readOnly = false, width = "auto" } = {}) {
7508
7717
  if (!window.Vditor) {
7509
7718
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
7510
7719
  return null;
@@ -7535,7 +7744,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
7535
7744
  accept: "image/*",
7536
7745
  max: 10 * 1024 * 1024,
7537
7746
  multiple: true,
7538
- handler: createVditorUploadHandler(uploadAttachment, () => editor)
7747
+ handler: createVditorUploadHandler(uploadAttachment ?? ((file) => uploadMarkdownAttachment(file, attachmentModule)), () => editor)
7539
7748
  },
7540
7749
  input: (markdown) => {
7541
7750
  normalizeVditorAttachmentImages(editor);
@@ -7764,6 +7973,7 @@ function bindVditorEditors(container) {
7764
7973
  if (valueField) valueField.value = markdown;
7765
7974
  markEntityEditorDirty();
7766
7975
  },
7976
+ attachmentModule: host.dataset.attachmentModule ?? "settings",
7767
7977
  placeholder: host.dataset.placeholder ?? "",
7768
7978
  readOnly: Boolean(valueField?.readOnly)
7769
7979
  });
@@ -7779,7 +7989,7 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
7779
7989
  async function uploadCharacterSectionAttachment(file) {
7780
7990
  const body = new FormData();
7781
7991
  body.append("file", file);
7782
- const attachment = await api(`/api/works/${state.work.id}/attachments`, { method: "POST", body });
7992
+ const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
7783
7993
  if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
7784
7994
  return {
7785
7995
  attachment,
@@ -7852,6 +8062,7 @@ async function openKnowledgeSectionEditor(index = null) {
7852
8062
  host.querySelectorAll("input, textarea").forEach((control) => control.addEventListener("input", () => { knowledgeSectionEditorDirty = true; }));
7853
8063
  knowledgeSectionVditor = createVditorEditor($("#knowledge-section-markdown"), section?.contentMarkdown ?? "", {
7854
8064
  onInput: () => { knowledgeSectionEditorDirty = true; },
8065
+ attachmentModule: knowledgeEditorKind === "race" ? "races" : "organizations",
7855
8066
  placeholder: "从这里开始写 Markdown 设定…",
7856
8067
  width: "100%"
7857
8068
  });
@@ -9026,16 +9237,30 @@ function openProviderDialog(item) {
9026
9237
 
9027
9238
  function openModelDialog(providerId, item = null) {
9028
9239
  const values = modelFormValues(item);
9240
+ const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
9029
9241
  const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
9030
- openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + field("contextWindow", "模型上下文令牌总量", "number", values.contextWindow) + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled), async (form) => {
9242
+ const connectionTest = item && item.providerStatus === "enabled"
9243
+ ? `<section class="model-connection-test">
9244
+ <div><strong>模型连接测试</strong><p>使用当前已保存的模型标识符和供应商凭据发起最小请求。</p></div>
9245
+ <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
9246
+ </section>`
9247
+ : "";
9248
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
9031
9249
  const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
9032
9250
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
9033
9251
  await renderPlatformAiConfig();
9034
9252
  await loadModels();
9035
9253
  }, item ? "模型配置" : "供应商模型");
9036
9254
  const modelIdInput = $("#dialog-fields input[name='modelId']");
9255
+ const contextWindowInput = $("#model-context-window");
9256
+ const contextWindowHint = $("#model-context-window-hint");
9037
9257
  const temperatureInput = $("#dialog-fields input[name='temperature']");
9038
9258
  const temperatureHint = $("#model-temperature-hint");
9259
+ const syncModelContextWindowGuidance = () => {
9260
+ const guidance = modelContextWindowGuidance(contextWindowInput.value);
9261
+ contextWindowInput.setCustomValidity(guidance.belowMinimum ? "模型上下文不能低于 32K(32768 Token)。" : "");
9262
+ contextWindowHint.hidden = !guidance.showRecommendation;
9263
+ };
9039
9264
  const syncKimiTemperature = () => {
9040
9265
  const isKimi = isKimiModelId(modelIdInput.value);
9041
9266
  temperatureHint.hidden = !isKimi;
@@ -9044,13 +9269,31 @@ function openModelDialog(providerId, item = null) {
9044
9269
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
9045
9270
  syncKimiTemperature();
9046
9271
  });
9272
+ contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
9273
+ $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
9274
+ const button = event.currentTarget;
9275
+ button.disabled = true;
9276
+ button.textContent = "测试中";
9277
+ try {
9278
+ const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
9279
+ toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
9280
+ await renderPlatformAiConfig();
9281
+ await loadModels();
9282
+ } catch (error) {
9283
+ toast(`模型连接测试失败:${error.message}`, "error");
9284
+ } finally {
9285
+ button.disabled = false;
9286
+ button.textContent = "测试连接";
9287
+ }
9288
+ });
9289
+ syncModelContextWindowGuidance();
9047
9290
  syncKimiTemperature();
9048
9291
  }
9049
9292
 
9050
9293
  async function sendAi() {
9051
9294
  if (!state.work) return toast("请先选择作品", "error");
9052
9295
  try {
9053
- await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
9296
+ await ensureAiModelsLoaded();
9054
9297
  } catch (error) {
9055
9298
  return toast(`创作助手加载失败:${error.message}`, "error");
9056
9299
  }
@@ -9063,30 +9306,18 @@ async function sendAi() {
9063
9306
  const { taskType, scope, selection } = requestScope;
9064
9307
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
9065
9308
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9066
- if (taskType === "chat") {
9067
- $("#ai-send").disabled = true;
9068
- $("#ai-send").textContent = "检查上下文";
9309
+ let persistedUserMessage = null;
9310
+ if (taskType !== "chat") {
9069
9311
  try {
9070
- const mayContinue = await prepareAiConversationContext({ instruction, scope, modelId, citations });
9071
- if (!mayContinue) return;
9312
+ persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9072
9313
  } catch (error) {
9073
- toast(`上下文检查失败:${error.message}`, "error");
9074
- return;
9075
- } finally {
9076
- $("#ai-send").disabled = false;
9077
- $("#ai-send").textContent = "发送";
9314
+ return toast(`对话记录创建失败:${error.message}`, "error");
9078
9315
  }
9316
+ state.aiPromptSent = true;
9317
+ renderAiQuickActions();
9318
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9319
+ clearAiPromptComposer();
9079
9320
  }
9080
- let persistedUserMessage;
9081
- try {
9082
- persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9083
- } catch (error) {
9084
- return toast(`对话记录创建失败:${error.message}`, "error");
9085
- }
9086
- state.aiPromptSent = true;
9087
- renderAiQuickActions();
9088
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9089
- clearAiPromptComposer();
9090
9321
  $("#ai-send").disabled = true;
9091
9322
  $("#ai-send").textContent = "发送中";
9092
9323
  try {
@@ -9096,7 +9327,8 @@ async function sendAi() {
9096
9327
  let persistedStreamMessage = null;
9097
9328
  let suggestion = null;
9098
9329
  if (taskType === "chat") {
9099
- const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
9330
+ const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId || undefined });
9331
+ if (streamed.action === "warn") return;
9100
9332
  assistantContent = streamed.content;
9101
9333
  assistantMessage = streamed.message;
9102
9334
  assistantMetadata = streamed.metadata;
@@ -9104,11 +9336,18 @@ async function sendAi() {
9104
9336
  applyAiConversationTitle(streamed.conversationTitle);
9105
9337
  } else {
9106
9338
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
9339
+ setAiContextMeter(suggestion.contextUsage);
9107
9340
  assistantContent = suggestion.content;
9108
9341
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
9109
9342
  }
9110
9343
  try {
9111
9344
  if (persistedStreamMessage) {
9345
+ updateAiConversationSummaryFromMessage({
9346
+ conversationId: state.aiConversationId,
9347
+ role: "assistant",
9348
+ content: assistantContent,
9349
+ createdAt: persistedStreamMessage.createdAt
9350
+ });
9112
9351
  updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
9113
9352
  attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
9114
9353
  } else {
@@ -9138,13 +9377,19 @@ async function streamChat(body) {
9138
9377
  message.className = "assistant-message is-streaming";
9139
9378
  message.dataset.testid = "ai-stream-message";
9140
9379
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
9141
- attachMessageHeading(message, "助手 · 正在生成");
9142
- $("#ai-feed").append(message);
9143
- scrollAiFeedToBottom();
9144
9380
  const content = message.querySelector(".message-body");
9145
9381
  const meta = message.querySelector(".message-meta");
9382
+ let messageMounted = false;
9383
+ const mountAssistantMessage = () => {
9384
+ if (messageMounted) return;
9385
+ attachMessageHeading(message, "助手 · 正在生成");
9386
+ $("#ai-feed").append(message);
9387
+ messageMounted = true;
9388
+ scrollAiFeedToBottom();
9389
+ };
9146
9390
  const typewriter = createStreamTypewriter({
9147
9391
  onRender: (text, progress) => {
9392
+ mountAssistantMessage();
9148
9393
  content.innerHTML = renderMarkdown(text);
9149
9394
  const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
9150
9395
  meta.textContent = progress.pendingCharacters > 0
@@ -9160,6 +9405,8 @@ async function streamChat(body) {
9160
9405
  let persistedMessageId = null;
9161
9406
  let persistedMessageCreatedAt = null;
9162
9407
  let conversationTitle = null;
9408
+ let persistedUserMessage = null;
9409
+ let contextAction = "ready";
9163
9410
  let finalAnswerStarted = false;
9164
9411
  const processStartedAt = Date.now();
9165
9412
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -9186,7 +9433,35 @@ async function streamChat(body) {
9186
9433
  }
9187
9434
  if (!dataLines.length) return;
9188
9435
  const payload = JSON.parse(dataLines.join("\n"));
9189
- if (eventName === "delta") {
9436
+ if (eventName === "context") {
9437
+ contextAction = typeof payload.action === "string" ? payload.action : "ready";
9438
+ setAiContextMeter(payload.usage);
9439
+ if (payload.conversation?.id) {
9440
+ state.aiConversationId = payload.conversation.id;
9441
+ upsertAiConversationSummary(payload.conversation);
9442
+ $("#ai-conversation-title").textContent = payload.conversation.title;
9443
+ }
9444
+ if (contextAction === "warn") showAiContextWarning(payload.usage);
9445
+ else {
9446
+ hideAiContextWarning();
9447
+ if (contextAction === "compacted") {
9448
+ renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
9449
+ toast("已自动整理较早对话为长期记忆并继续发送");
9450
+ }
9451
+ }
9452
+ } else if (eventName === "user_message") {
9453
+ persistedUserMessage = payload.message;
9454
+ if (persistedUserMessage?.id) {
9455
+ state.aiConversationId = persistedUserMessage.conversationId;
9456
+ updateAiConversationSummaryFromMessage(persistedUserMessage);
9457
+ state.aiPromptSent = true;
9458
+ renderAiQuickActions();
9459
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9460
+ clearAiPromptComposer();
9461
+ mountAssistantMessage();
9462
+ }
9463
+ } else if (eventName === "delta") {
9464
+ mountAssistantMessage();
9190
9465
  const firstFinalDelta = streamedText.length === 0;
9191
9466
  const delta = typeof payload.delta === "string" ? payload.delta : "";
9192
9467
  streamedText += delta;
@@ -9195,6 +9470,7 @@ async function streamChat(body) {
9195
9470
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
9196
9471
  meta.textContent = "正在生成回复……";
9197
9472
  } else if (eventName === "process_step") {
9473
+ mountAssistantMessage();
9198
9474
  const step = { ...payload };
9199
9475
  const append = step.append === true;
9200
9476
  delete step.append;
@@ -9202,9 +9478,14 @@ async function streamChat(body) {
9202
9478
  if (existing && typeof step.content === "string") existing.content += step.content;
9203
9479
  else processSteps.push(step);
9204
9480
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9205
- meta.textContent = step.type === "thinking" ? `正在思考 · 第 ${Number(step.round) || 1} 轮` : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
9481
+ meta.textContent = step.type === "thinking"
9482
+ ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
9483
+ : step.type === "context_compaction"
9484
+ ? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
9485
+ : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
9206
9486
  scrollAiFeedToBottom();
9207
9487
  } else if (eventName === "tool_call") {
9488
+ mountAssistantMessage();
9208
9489
  const toolCall = { ...payload };
9209
9490
  const round = toolCall.round;
9210
9491
  delete toolCall.round;
@@ -9213,10 +9494,15 @@ async function streamChat(body) {
9213
9494
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9214
9495
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
9215
9496
  scrollAiFeedToBottom();
9497
+ } else if (eventName === "context_compacted") {
9498
+ setAiContextMeter(payload.contextUsage);
9499
+ if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
9216
9500
  } else if (eventName === "complete") {
9501
+ mountAssistantMessage();
9217
9502
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9218
9503
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9219
9504
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
9505
+ setAiContextMeter(payload.contextUsage);
9220
9506
  await typewriter.finish();
9221
9507
  message.classList.remove("is-streaming");
9222
9508
  content.setAttribute("aria-busy", "false");
@@ -9244,8 +9530,9 @@ async function streamChat(body) {
9244
9530
  if (buffer.trim()) await consume(buffer);
9245
9531
  await typewriter.finish();
9246
9532
  if (streamError) throw streamError;
9247
- return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle };
9533
+ return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
9248
9534
  } catch (error) {
9535
+ mountAssistantMessage();
9249
9536
  typewriter.reveal();
9250
9537
  message.classList.remove("is-streaming");
9251
9538
  content.setAttribute("aria-busy", "false");
@@ -9265,7 +9552,15 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
9265
9552
  ? `<p class="ai-error-text">${esc(text)}</p>`
9266
9553
  : renderMarkdown(text);
9267
9554
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
9268
- attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
9555
+ const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
9556
+ if (isFailure) {
9557
+ message.dataset.status = "failed";
9558
+ const failureBadge = document.createElement("strong");
9559
+ failureBadge.className = "ai-message-status is-error";
9560
+ failureBadge.textContent = "失败";
9561
+ failureBadge.setAttribute("aria-label", "消息状态:失败");
9562
+ heading.firstElementChild?.append(failureBadge);
9563
+ }
9269
9564
  if (citations.length) {
9270
9565
  const references = document.createElement("div");
9271
9566
  references.className = "message-citations";
@@ -10169,6 +10464,7 @@ $("#register-form").addEventListener("submit", async (event) => {
10169
10464
  username: form.get("username"),
10170
10465
  password: form.get("password"),
10171
10466
  passwordConfirmation: form.get("passwordConfirmation"),
10467
+ setupToken: form.get("setupToken") || undefined,
10172
10468
  captchaId: form.get("captchaId"),
10173
10469
  captchaAnswer: form.get("captchaAnswer")
10174
10470
  }
@@ -10238,6 +10534,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
10238
10534
  timeline: Number($("#page-size-timeline").value),
10239
10535
  outlines: Number($("#page-size-outlines").value),
10240
10536
  relationships: Number($("#page-size-relationships").value),
10537
+ comments: Number($("#page-size-comments").value),
10241
10538
  reviews: Number($("#page-size-reviews").value),
10242
10539
  analysisTasks: Number($("#page-size-analysis-tasks").value),
10243
10540
  fileVersions: Number($("#page-size-file-versions").value)
@@ -10383,10 +10680,14 @@ $("#chapter-content").addEventListener("input", (event) => {
10383
10680
  scheduleChapterAutoSave();
10384
10681
  clearChapterLineSelection();
10385
10682
  scheduleChapterLineNumbers();
10386
- scheduleAiContextUsage();
10683
+ setAiContextMeter(null);
10387
10684
  });
10388
- $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
10685
+ $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
10389
10686
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
10687
+ $("#chapter-content").addEventListener("contextmenu", (event) => {
10688
+ if (!state.chapter) return;
10689
+ showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
10690
+ });
10390
10691
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
10391
10692
  const row = event.target.closest(".chapter-line-number");
10392
10693
  if (!row || event.button !== 0) return;
@@ -10466,7 +10767,7 @@ $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!m
10466
10767
  $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftDialog, settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
10467
10768
  $("#ai-prompt").addEventListener("input", async () => {
10468
10769
  updateAiMentionMenu();
10469
- scheduleAiContextUsage();
10770
+ setAiContextMeter(null);
10470
10771
  if (!findAiMention(aiPromptTextBeforeCursor())) return;
10471
10772
  try {
10472
10773
  await ensureAiReferencesLoaded();
@@ -10481,9 +10782,9 @@ $("#ai-prompt").addEventListener("focus", () => {
10481
10782
  $("#ai-model").addEventListener("focus", () => {
10482
10783
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
10483
10784
  });
10484
- $("#ai-model").addEventListener("change", scheduleAiContextUsage);
10485
- $("#ai-task").addEventListener("change", scheduleAiContextUsage);
10486
- $("#ai-scope").addEventListener("change", scheduleAiContextUsage);
10785
+ $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
10786
+ $("#ai-task").addEventListener("change", () => setAiContextMeter(null));
10787
+ $("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
10487
10788
  $("#ai-mention-menu").addEventListener("click", (event) => {
10488
10789
  const button = event.target.closest("[data-ai-reference-id]");
10489
10790
  if (button) selectAiMention(button);
@@ -10686,7 +10987,15 @@ $("#ai-context-compact").addEventListener("click", async () => {
10686
10987
  });
10687
10988
  hideAiContextWarning();
10688
10989
  toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
10689
- await refreshAiContextUsage();
10990
+ setAiContextMeter(result.usage);
10991
+ if (result.changed) {
10992
+ const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
10993
+ if (current) {
10994
+ current.compactedMessageCount = result.compactedMessageCount;
10995
+ current.hasCompactedSummary = true;
10996
+ }
10997
+ renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
10998
+ }
10690
10999
  } catch (error) {
10691
11000
  toast(`上下文压缩失败:${error.message}`, "error");
10692
11001
  } finally {
@@ -10712,6 +11021,30 @@ $("#ai-history-toggle").addEventListener("click", async () => {
10712
11021
  toast(`对话历史加载失败:${error.message}`, "error");
10713
11022
  }
10714
11023
  });
11024
+ $("#ai-history-previous").addEventListener("click", async () => {
11025
+ const targetPage = aiConversationHistoryPage.page - 1;
11026
+ if (targetPage < 1) return;
11027
+ $("#ai-history-previous").disabled = true;
11028
+ $("#ai-history-next").disabled = true;
11029
+ try {
11030
+ await loadAiConversations(targetPage);
11031
+ } catch (error) {
11032
+ renderAiConversationHistory();
11033
+ toast(`对话历史加载失败:${error.message}`, "error");
11034
+ }
11035
+ });
11036
+ $("#ai-history-next").addEventListener("click", async () => {
11037
+ const targetPage = aiConversationHistoryPage.nextPage;
11038
+ if (!targetPage) return;
11039
+ $("#ai-history-previous").disabled = true;
11040
+ $("#ai-history-next").disabled = true;
11041
+ try {
11042
+ await loadAiConversations(targetPage);
11043
+ } catch (error) {
11044
+ renderAiConversationHistory();
11045
+ toast(`对话历史加载失败:${error.message}`, "error");
11046
+ }
11047
+ });
10715
11048
  $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
10716
11049
  $("#ai-history-dialog").addEventListener("close", () => {
10717
11050
  $("#ai-history-toggle").setAttribute("aria-expanded", "false");