@musnows/scriverse 0.5.12 → 0.6.1

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.
@@ -5,18 +5,18 @@ import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-
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 } from "/ai-context-meter.js?v=20260718-layered-context";
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
@@ -86,6 +87,12 @@ function normalizePageSizes(value) {
86
87
  ]));
87
88
  }
88
89
 
90
+ function isSelectableModel(model) {
91
+ return Boolean(model?.enabled)
92
+ && model?.providerStatus === "enabled"
93
+ && model?.providerConnectionStatus === "success";
94
+ }
95
+
89
96
  const state = {
90
97
  user: null,
91
98
  csrfToken: null,
@@ -128,6 +135,7 @@ const cachedWorkModules = new Set([
128
135
  "timeline",
129
136
  "outlines",
130
137
  "relationships",
138
+ "comments",
131
139
  "reviews",
132
140
  "tasks",
133
141
  "ai-settings"
@@ -305,6 +313,7 @@ function applyWorkAccessMode() {
305
313
  const button = $(`#module-nav [data-module="${item.uiModule}"]`);
306
314
  if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
307
315
  }
316
+ $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
308
317
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
309
318
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
310
319
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -386,6 +395,8 @@ let aiReferencesLoadPromise = null;
386
395
  let aiReferencesLoadWorkId = null;
387
396
  let aiConversationsLoadPromise = null;
388
397
  let aiConversationsLoadWorkId = null;
398
+ const aiConversationHistoryPageLimit = 20;
399
+ let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
389
400
  let workScopedUiGeneration = 0;
390
401
  const loadedVolumeChapterIds = new Set();
391
402
  const volumeChapterLoadingIds = new Set();
@@ -656,7 +667,7 @@ function renderPresence() {
656
667
  const localKey = presencePageKey(presencePageForRoute());
657
668
  syncChapterAutoSaveWithPresence();
658
669
  control.classList.remove("hidden");
659
- $("#presence-count").textContent = `${groups.length} 人在线`;
670
+ $("#presence-count").textContent = `${groups.length} 人`;
660
671
  $("#presence-list").innerHTML = groups.map((participant) => {
661
672
  const isCurrent = participant.userId === state.user?.userId;
662
673
  const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
@@ -902,6 +913,7 @@ const moduleListPages = {
902
913
  outlinePlans: 1,
903
914
  foreshadows: 1,
904
915
  relationships: 1,
916
+ comments: 1,
905
917
  reviews: 1
906
918
  };
907
919
  const characterFilters = { raceIds: [], organizationIds: [] };
@@ -1238,7 +1250,7 @@ function renderAiCitations() {
1238
1250
  card.append(main, remove, fullText);
1239
1251
  host.append(card);
1240
1252
  }
1241
- scheduleAiContextUsage();
1253
+ setAiContextMeter(null);
1242
1254
  }
1243
1255
 
1244
1256
  function aiReferenceKey(reference) {
@@ -1282,7 +1294,7 @@ function renderAiReferences() {
1282
1294
  if (rendered.has(aiReferenceKey(reference))) continue;
1283
1295
  prompt.append(createAiReferenceChip(reference), document.createTextNode(" "));
1284
1296
  }
1285
- scheduleAiContextUsage();
1297
+ setAiContextMeter(null);
1286
1298
  }
1287
1299
 
1288
1300
  function renderAiQuickActions() {
@@ -1325,6 +1337,34 @@ function resetAiFeed() {
1325
1337
  $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1326
1338
  }
1327
1339
 
1340
+ function appendAiContextCompactionDivider(kind, before = null) {
1341
+ const divider = document.createElement("div");
1342
+ divider.className = "ai-context-compaction-divider";
1343
+ divider.dataset.contextCompaction = kind;
1344
+ divider.dataset.testid = "ai-context-compaction-divider";
1345
+ divider.setAttribute("role", "separator");
1346
+ divider.setAttribute("aria-label", "已压缩上下文");
1347
+ divider.innerHTML = "<span>已压缩上下文</span>";
1348
+ const feed = $("#ai-feed");
1349
+ if (before?.parentElement === feed) feed.insertBefore(divider, before);
1350
+ else feed.append(divider);
1351
+ scrollAiFeedToBottom();
1352
+ return divider;
1353
+ }
1354
+
1355
+ function renderConversationCompactionDivider(compactedMessageCount, totalMessageCount = null) {
1356
+ const feed = $("#ai-feed");
1357
+ feed.querySelector('[data-context-compaction="conversation"]')?.remove();
1358
+ const compactedCount = Math.max(0, Number(compactedMessageCount) || 0);
1359
+ if (compactedCount === 0) return null;
1360
+ const messages = [...feed.querySelectorAll("[data-message-id]")];
1361
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1362
+ const totalCount = Math.max(messages.length, Number(totalMessageCount ?? current?.messageCount) || messages.length);
1363
+ const loadedOffset = Math.max(0, totalCount - messages.length);
1364
+ const boundaryIndex = Math.max(0, Math.min(messages.length, compactedCount - loadedOffset));
1365
+ return appendAiContextCompactionDivider("conversation", messages[boundaryIndex] ?? null);
1366
+ }
1367
+
1328
1368
  function renderMessageCardActions(message) {
1329
1369
  let actions = message.querySelector(".message-card-actions");
1330
1370
  if (!actions) {
@@ -1361,7 +1401,7 @@ function renderMessageCardActions(message) {
1361
1401
  fork.disabled = true;
1362
1402
  try {
1363
1403
  const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
1364
- await loadAiConversations(false);
1404
+ upsertAiConversationSummary(conversation);
1365
1405
  await openAiConversation(conversation.id);
1366
1406
  toast("已从所选消息创建分支对话");
1367
1407
  } catch (error) {
@@ -1393,7 +1433,7 @@ const AI_TOOL_DISPLAY_NAMES = {
1393
1433
  grep: "查询正文关键字",
1394
1434
  search_story_entities: "搜索作品实体",
1395
1435
  read_character_sections: "读取人物 Markdown 章节",
1396
- search_drafts: "搜索草稿"
1436
+ search_drafts: "搜索想法"
1397
1437
  };
1398
1438
 
1399
1439
  const AI_TOOL_DESCRIPTIONS = {
@@ -1553,6 +1593,17 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1553
1593
  const list = document.createElement("div");
1554
1594
  list.className = "ai-process-list";
1555
1595
  for (const step of steps) {
1596
+ if (step?.type === "context_compaction") {
1597
+ const compaction = document.createElement("div");
1598
+ compaction.className = "ai-process-context-compaction";
1599
+ compaction.dataset.testid = "ai-process-context-compaction";
1600
+ compaction.setAttribute("role", "separator");
1601
+ compaction.setAttribute("aria-label", `第 ${Number(step.round) || 1} 轮已压缩上下文`);
1602
+ compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
1603
+ compaction.innerHTML = "<span>已压缩上下文</span>";
1604
+ list.append(compaction);
1605
+ continue;
1606
+ }
1556
1607
  if (step?.type === "tool" && step.toolCall) {
1557
1608
  const tool = document.createElement("section");
1558
1609
  tool.className = "ai-process-step ai-process-tool-step";
@@ -1593,6 +1644,12 @@ function setAiHistoryVisible(visible) {
1593
1644
  function renderAiConversationHistory() {
1594
1645
  const host = $("#ai-history-list");
1595
1646
  host.replaceChildren();
1647
+ const pagination = $("#ai-history-pagination");
1648
+ const showPagination = aiConversationHistoryPage.page > 1 || aiConversationHistoryPage.hasMore;
1649
+ pagination.classList.toggle("hidden", !showPagination);
1650
+ $("#ai-history-previous").disabled = aiConversationHistoryPage.page <= 1;
1651
+ $("#ai-history-next").disabled = !aiConversationHistoryPage.hasMore;
1652
+ $("#ai-history-page-label").textContent = `第 ${aiConversationHistoryPage.page} 页 · 本页 ${state.aiConversations.length} 条`;
1596
1653
  if (!state.aiConversations.length) {
1597
1654
  host.innerHTML = '<p class="ai-history-empty">还没有历史对话</p>';
1598
1655
  return;
@@ -1612,20 +1669,72 @@ function renderAiConversationHistory() {
1612
1669
  }
1613
1670
  }
1614
1671
 
1615
- async function loadAiConversations(openLatest = true) {
1672
+ function defaultAiConversationTitle(prompt) {
1673
+ const normalized = String(prompt ?? "").replace(/\s+/gu, " ").trim();
1674
+ return Array.from(normalized).slice(0, 15).join("") || "新对话";
1675
+ }
1676
+
1677
+ function upsertAiConversationSummary(conversation) {
1678
+ if (!conversation?.id) return;
1679
+ const current = state.aiConversations.find((item) => item.id === conversation.id);
1680
+ const lastMessage = Array.isArray(conversation.messages) ? conversation.messages.at(-1) : null;
1681
+ const summary = { ...current, ...conversation, ...(lastMessage ? { preview: lastMessage.content } : {}) };
1682
+ delete summary.messages;
1683
+ delete summary.messagesPage;
1684
+ if (!current && loadedAiConversationsWorkId === state.work?.id) {
1685
+ loadedAiConversationsWorkId = null;
1686
+ aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
1687
+ state.aiConversations = [summary];
1688
+ renderAiConversationHistory();
1689
+ return;
1690
+ }
1691
+ state.aiConversations = [summary, ...state.aiConversations.filter((item) => item.id !== summary.id)]
1692
+ .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
1693
+ renderAiConversationHistory();
1694
+ }
1695
+
1696
+ function updateAiConversationSummaryFromMessage(message) {
1697
+ if (!message?.conversationId) return;
1698
+ const current = state.aiConversations.find((conversation) => conversation.id === message.conversationId);
1699
+ if (!current) return;
1700
+ upsertAiConversationSummary({
1701
+ ...current,
1702
+ title: current.title === "新对话" && message.role === "user" ? defaultAiConversationTitle(message.content) : current.title,
1703
+ messageCount: Number(current.messageCount ?? 0) + 1,
1704
+ preview: message.content,
1705
+ updatedAt: message.createdAt ?? current.updatedAt
1706
+ });
1707
+ }
1708
+
1709
+ function applyAiConversationTitle(title) {
1710
+ if (!title || !state.aiConversationId) return;
1711
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1712
+ if (current) current.title = title;
1713
+ $("#ai-conversation-title").textContent = title;
1714
+ renderAiConversationHistory();
1715
+ }
1716
+
1717
+ function applyAiConversations(pageResult) {
1718
+ state.aiConversations = pageResult.items;
1719
+ aiConversationHistoryPage = {
1720
+ page: pageResult.page,
1721
+ limit: pageResult.limit,
1722
+ hasMore: pageResult.hasMore,
1723
+ nextPage: pageResult.nextPage
1724
+ };
1725
+ loadedAiConversationsWorkId = state.work?.id ?? null;
1726
+ renderAiConversationHistory();
1727
+ const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1728
+ if (current) $("#ai-conversation-title").textContent = current.title;
1729
+ }
1730
+
1731
+ async function loadAiConversations(page = 1) {
1616
1732
  const workId = state.work?.id;
1617
1733
  if (!workId) return;
1618
1734
  const generation = workScopedUiGeneration;
1619
- const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1735
+ const conversations = await apiPage(`/api/works/${workId}/ai-conversations`, page, aiConversationHistoryPageLimit);
1620
1736
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
1621
- state.aiConversations = conversations;
1622
- loadedAiConversationsWorkId = workId;
1623
- renderAiConversationHistory();
1624
- if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
1625
- else {
1626
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1627
- if (current) $("#ai-conversation-title").textContent = current.title;
1628
- }
1737
+ applyAiConversations(conversations);
1629
1738
  }
1630
1739
 
1631
1740
  async function ensureAiConversationsLoaded() {
@@ -1633,7 +1742,7 @@ async function ensureAiConversationsLoaded() {
1633
1742
  if (!workId || loadedAiConversationsWorkId === workId) return;
1634
1743
  if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
1635
1744
  aiConversationsLoadWorkId = workId;
1636
- aiConversationsLoadPromise = loadAiConversations(false);
1745
+ aiConversationsLoadPromise = loadAiConversations();
1637
1746
  try {
1638
1747
  await aiConversationsLoadPromise;
1639
1748
  } finally {
@@ -1646,11 +1755,15 @@ async function ensureAiConversationsLoaded() {
1646
1755
 
1647
1756
  async function openAiConversation(conversationId, hideHistory = true) {
1648
1757
  const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1758
+ upsertAiConversationSummary(conversation);
1649
1759
  state.aiConversationId = conversation.id;
1650
1760
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1651
1761
  $("#ai-conversation-title").textContent = conversation.title;
1652
1762
  resetAiFeed();
1653
1763
  for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
1764
+ if (conversation.hasCompactedSummary) {
1765
+ renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
1766
+ }
1654
1767
  state.aiCitations = [];
1655
1768
  state.aiReferences = [];
1656
1769
  setAiPromptText("");
@@ -1666,14 +1779,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1666
1779
  async function createNewAiConversation() {
1667
1780
  if (!state.work) return;
1668
1781
  const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1782
+ upsertAiConversationSummary(conversation);
1669
1783
  state.aiConversationId = conversation.id;
1670
1784
  state.aiPromptSent = false;
1671
1785
  $("#ai-conversation-title").textContent = conversation.title;
1672
1786
  resetAiFeed();
1673
1787
  hideAiContextWarning();
1788
+ setAiContextMeter(null);
1674
1789
  renderAiQuickActions();
1675
1790
  setAiHistoryVisible(false);
1676
- await loadAiConversations(false);
1677
1791
  }
1678
1792
 
1679
1793
  async function ensureAiConversation() {
@@ -1686,7 +1800,7 @@ async function persistAiConversationMessage(role, content, citations = [], metad
1686
1800
  const conversationId = await ensureAiConversation();
1687
1801
  if (!conversationId) throw new Error("无法创建 AI 对话");
1688
1802
  const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
1689
- await loadAiConversations(false);
1803
+ updateAiConversationSummaryFromMessage(message);
1690
1804
  return message;
1691
1805
  }
1692
1806
 
@@ -1823,9 +1937,9 @@ async function createSelectedLineAnnotation(kind) {
1823
1937
  if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
1824
1938
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1825
1939
  closeLineCitationMenu();
1826
- const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : "写下这段正文的批注", {
1827
- title: kind === "todo" ? "添加正文待办" : "添加正文批注",
1828
- inputLabel: kind === "todo" ? "待办内容" : "批注内容",
1940
+ const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
1941
+ title: kind === "todo" ? "添加正文待办" : "添加正文评论",
1942
+ inputLabel: kind === "todo" ? "待办内容" : "评论内容",
1829
1943
  confirmLabel: "添加",
1830
1944
  maxLength: 2000
1831
1945
  });
@@ -1835,61 +1949,107 @@ async function createSelectedLineAnnotation(kind) {
1835
1949
  method: "POST",
1836
1950
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
1837
1951
  });
1838
- toast(kind === "todo" ? "正文待办已添加" : "正文批注已添加");
1952
+ toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
1839
1953
  await openChapterAnnotationsDialog();
1840
1954
  } catch (error) {
1841
1955
  toast(error.message, "error");
1842
1956
  }
1843
1957
  }
1844
1958
 
1845
- function renderChapterAnnotations() {
1846
- const host = $("#chapter-annotations-list");
1847
- host.innerHTML = chapterAnnotations.length ? chapterAnnotations.map((annotation) => `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1848
- <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>
1959
+ function chapterAnnotationLineLabel(annotation) {
1960
+ return annotation.startLine === annotation.endLine
1961
+ ? `第 ${annotation.startLine} 行`
1962
+ : `第 ${annotation.startLine}-${annotation.endLine} 行`;
1963
+ }
1964
+
1965
+ function chapterAnnotationStatusLabel(annotation) {
1966
+ if (annotation.status === "resolved") return annotation.kind === "todo" ? "已完成" : "已解决";
1967
+ return annotation.kind === "todo" ? "待处理" : "未解决";
1968
+ }
1969
+
1970
+ function chapterAnnotationCard(annotation, { showSource = false } = {}) {
1971
+ const lineLabel = chapterAnnotationLineLabel(annotation);
1972
+ const sourceLabel = `${annotation.volumeTitle ?? "正文"} / ${annotation.chapterTitle ?? "未命名章节"}`;
1973
+ const heading = showSource
1974
+ ? `<strong class="chapter-annotation-source" title="${esc(sourceLabel)}">${esc(sourceLabel)}</strong><span class="chapter-annotation-line">${esc(lineLabel)}</span>`
1975
+ : `<strong>${esc(lineLabel)}</strong>`;
1976
+ return `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1977
+ <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "评论"}</span>${heading}<em>${esc(chapterAnnotationStatusLabel(annotation))}</em></header>
1849
1978
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
1850
- <p>${esc(annotation.note)}</p>
1979
+ <p data-annotation-content>${esc(annotation.note)}</p>
1851
1980
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
1852
- <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>
1853
- </article>`).join("") : '<p class="entity-history-empty">本章还没有批注或待办。选择正文行并打开右键菜单即可添加。</p>';
1981
+ <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>
1982
+ </article>`;
1983
+ }
1984
+
1985
+ function bindChapterAnnotationCards(host, annotations, { refresh, locate, overlayDialog = null }) {
1986
+ const suspendOverlay = () => {
1987
+ if (overlayDialog?.open) overlayDialog.close();
1988
+ };
1989
+ const resumeOverlay = () => {
1990
+ if (overlayDialog && !overlayDialog.open) overlayDialog.showModal();
1991
+ };
1854
1992
  host.querySelectorAll("[data-annotation-id]").forEach((card) => {
1855
- const annotation = chapterAnnotations.find((item) => item.id === card.dataset.annotationId);
1993
+ const annotation = annotations.find((item) => item.id === card.dataset.annotationId);
1856
1994
  if (!annotation) return;
1857
- card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
1858
- $("#chapter-annotations-dialog").close();
1859
- paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
1860
- selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
1995
+ card.querySelector("[data-annotation-locate]")?.addEventListener("click", async () => {
1996
+ try { await locate(annotation); }
1997
+ catch (error) { toast(error.message, "error"); }
1861
1998
  });
1862
1999
  card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
1863
2000
  try {
1864
2001
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
1865
- await loadChapterAnnotations();
2002
+ await refresh();
1866
2003
  } catch (error) { toast(error.message, "error"); }
1867
2004
  });
1868
2005
  card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
1869
- const dialog = $("#chapter-annotations-dialog");
1870
- dialog.close();
1871
- const note = await inputToast("修改批注或待办说明", { title: "编辑说明", inputLabel: "说明", placeholder: annotation.note, confirmLabel: "保存", maxLength: 2000 });
1872
- if (!note) return dialog.showModal();
2006
+ suspendOverlay();
2007
+ const note = await inputToast(annotation.kind === "todo" ? "修改待办内容" : "修改评论内容", {
2008
+ title: annotation.kind === "todo" ? "编辑待办" : "编辑评论",
2009
+ inputLabel: annotation.kind === "todo" ? "待办内容" : "评论内容",
2010
+ value: annotation.note,
2011
+ confirmLabel: "保存",
2012
+ maxLength: 2000
2013
+ });
2014
+ if (!note) return resumeOverlay();
1873
2015
  try {
1874
2016
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
1875
- await loadChapterAnnotations();
2017
+ await refresh();
1876
2018
  } catch (error) { toast(error.message, "error"); }
1877
- dialog.showModal();
2019
+ resumeOverlay();
1878
2020
  });
1879
2021
  card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
1880
- const dialog = $("#chapter-annotations-dialog");
1881
- dialog.close();
1882
- const confirmed = await confirmToast("删除后不会在本章清单中显示,但历史版本仍会保留。", { title: "删除批注或待办", confirmLabel: "确认删除" });
1883
- if (!confirmed) return dialog.showModal();
2022
+ suspendOverlay();
2023
+ const confirmed = await confirmToast("删除后不会在正文评论中显示,但历史版本仍会保留。", {
2024
+ title: annotation.kind === "todo" ? "删除待办" : "删除评论",
2025
+ confirmLabel: "确认删除"
2026
+ });
2027
+ if (!confirmed) return resumeOverlay();
1884
2028
  try {
1885
2029
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
1886
- await loadChapterAnnotations();
1887
- dialog.showModal();
1888
- } catch (error) { dialog.showModal(); toast(error.message, "error"); }
2030
+ await refresh();
2031
+ } catch (error) { toast(error.message, "error"); }
2032
+ resumeOverlay();
1889
2033
  });
1890
2034
  });
1891
2035
  }
1892
2036
 
2037
+ function renderChapterAnnotations() {
2038
+ const host = $("#chapter-annotations-list");
2039
+ host.innerHTML = chapterAnnotations.length
2040
+ ? chapterAnnotations.map((annotation) => chapterAnnotationCard(annotation)).join("")
2041
+ : '<p class="entity-history-empty">本章还没有评论。在正文行上点击右键即可添加。</p>';
2042
+ bindChapterAnnotationCards(host, chapterAnnotations, {
2043
+ refresh: loadChapterAnnotations,
2044
+ locate: (annotation) => {
2045
+ $("#chapter-annotations-dialog").close();
2046
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
2047
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
2048
+ },
2049
+ overlayDialog: $("#chapter-annotations-dialog")
2050
+ });
2051
+ }
2052
+
1893
2053
  async function loadChapterAnnotations() {
1894
2054
  if (!state.chapter) return;
1895
2055
  chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
@@ -1898,8 +2058,8 @@ async function loadChapterAnnotations() {
1898
2058
 
1899
2059
  async function openChapterAnnotationsDialog() {
1900
2060
  if (!state.chapter) return;
1901
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文批注与待办`;
1902
- $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载批注与待办…</p>';
2061
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文评论`;
2062
+ $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载评论…</p>';
1903
2063
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
1904
2064
  try {
1905
2065
  await loadChapterAnnotations();
@@ -2149,9 +2309,7 @@ async function api(path, options = {}) {
2149
2309
  moduleRequestCache.clear();
2150
2310
  showAuth(false);
2151
2311
  }
2152
- const error = new Error(payload.error?.message ?? `请求失败:${response.status}`);
2153
- error.code = payload.error?.code;
2154
- throw error;
2312
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2155
2313
  }
2156
2314
  if (response.status === 204) {
2157
2315
  invalidateModuleRequestsAfterMutation(path, method);
@@ -2162,6 +2320,41 @@ async function api(path, options = {}) {
2162
2320
  return payload.data;
2163
2321
  }
2164
2322
 
2323
+ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2324
+ const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
2325
+ const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
2326
+ error.code = typeof source.code === "string" ? source.code : undefined;
2327
+ error.status = Number.isInteger(source.status) ? source.status : fallbackStatus;
2328
+ error.details = source.details;
2329
+ error.failure = typeof source.failure === "string" ? source.failure : undefined;
2330
+ error.callId = typeof source.callId === "string" ? source.callId : undefined;
2331
+ error.providerName = typeof source.providerName === "string" ? source.providerName : undefined;
2332
+ error.providerId = typeof source.providerId === "string" ? source.providerId : undefined;
2333
+ error.modelId = typeof source.modelId === "string" ? source.modelId : undefined;
2334
+ error.modelRecordId = typeof source.modelRecordId === "string" ? source.modelRecordId : undefined;
2335
+ return error;
2336
+ }
2337
+
2338
+ function formatAiFailureMessage(error) {
2339
+ const message = error instanceof Error ? error.message : String(error ?? "未知错误");
2340
+ const lines = [`调用失败:${message}`];
2341
+ const code = typeof error?.code === "string" ? error.code : "";
2342
+ const status = Number.isInteger(error?.status) ? error.status : null;
2343
+ const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
2344
+ const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
2345
+ const callId = typeof error?.callId === "string" ? error.callId : typeof details.callId === "string" ? details.callId : "";
2346
+ const providerName = typeof error?.providerName === "string" ? error.providerName : typeof details.providerName === "string" ? details.providerName : "";
2347
+ const providerId = typeof error?.providerId === "string" ? error.providerId : typeof details.providerId === "string" ? details.providerId : "";
2348
+ const modelId = typeof error?.modelId === "string" ? error.modelId : typeof details.modelId === "string" ? details.modelId : "";
2349
+ if (code) lines.push(`错误码:${code}`);
2350
+ if (status) lines.push(`服务端状态:HTTP ${status}`);
2351
+ if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2352
+ if (modelId) lines.push(`模型 ID:${modelId}`);
2353
+ if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2354
+ if (callId) lines.push(`调用 ID:${callId}`);
2355
+ return lines.join("\n\n");
2356
+ }
2357
+
2165
2358
  async function apiPage(path, page = 1, limit = 30) {
2166
2359
  const separator = path.includes("?") ? "&" : "?";
2167
2360
  const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
@@ -2204,7 +2397,6 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2204
2397
  path.startsWith("/api/auth/")
2205
2398
  || path.includes("/presence")
2206
2399
  || path.includes("/context/prepare")
2207
- || path.includes("/ai-context-usage")
2208
2400
  || path.includes("/chat/stream")
2209
2401
  ) return;
2210
2402
 
@@ -2221,6 +2413,7 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2221
2413
  if (path.includes("/timeline")) affected.add("timeline");
2222
2414
  if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
2223
2415
  if (path.includes("/relationships")) affected.add("relationships");
2416
+ if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
2224
2417
  if (path.includes("/reviews")) affected.add("reviews");
2225
2418
  if (path.includes("/entity-versions/")) {
2226
2419
  if (path.includes("/draft/")) affected.add("drafts");
@@ -2485,7 +2678,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
2485
2678
  });
2486
2679
  }
2487
2680
 
2488
- function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2681
+ function inputToast(message, { title = "请输入", inputLabel = title, value = "", placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2489
2682
  const region = $("#toast-region");
2490
2683
  const element = document.createElement("section");
2491
2684
  element.className = "toast toast-confirmation toast-input-dialog";
@@ -2499,6 +2692,7 @@ function inputToast(message, { title = "请输入", inputLabel = title, placehol
2499
2692
  input.className = "toast-input";
2500
2693
  input.type = "text";
2501
2694
  input.maxLength = maxLength;
2695
+ input.value = value;
2502
2696
  input.placeholder = placeholder;
2503
2697
  input.setAttribute("aria-label", inputLabel);
2504
2698
  const actions = document.createElement("div");
@@ -2925,15 +3119,15 @@ const workAuditActionLabels = {
2925
3119
  "chapter.deleted": "删除章节",
2926
3120
  "chapter.purged": "彻底删除章节",
2927
3121
  "chapter.restored": "恢复章节",
2928
- "draft.created": "创建草稿",
2929
- "draft.updated": "更新草稿",
2930
- "draft.deleted": "删除草稿",
2931
- "draft.restored": "恢复草稿",
3122
+ "draft.created": "创建想法",
3123
+ "draft.updated": "更新想法",
3124
+ "draft.deleted": "删除想法",
3125
+ "draft.restored": "恢复想法",
2932
3126
  "work.imported": "导入正文"
2933
3127
  };
2934
3128
 
2935
3129
  function workAuditEntityLabel(type) {
2936
- return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
3130
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
2937
3131
  }
2938
3132
 
2939
3133
  function workAuditDetailText(detail) {
@@ -3051,6 +3245,7 @@ async function openPlatformUiSettingsDialog() {
3051
3245
  $("#page-size-timeline").value = String(pageSizes.timeline);
3052
3246
  $("#page-size-outlines").value = String(pageSizes.outlines);
3053
3247
  $("#page-size-relationships").value = String(pageSizes.relationships);
3248
+ $("#page-size-comments").value = String(pageSizes.comments);
3054
3249
  $("#page-size-reviews").value = String(pageSizes.reviews);
3055
3250
  $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
3056
3251
  $("#page-size-file-versions").value = String(pageSizes.fileVersions);
@@ -3417,6 +3612,7 @@ function resetWorkScopedUiCaches() {
3417
3612
  aiReferencesLoadWorkId = null;
3418
3613
  aiConversationsLoadPromise = null;
3419
3614
  aiConversationsLoadWorkId = null;
3615
+ aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
3420
3616
  raceHierarchyLoadPromise = null;
3421
3617
  raceHierarchyLoadWorkId = null;
3422
3618
  loadedRaceHierarchyWorkId = null;
@@ -3438,9 +3634,6 @@ function resetWorkScopedUiCaches() {
3438
3634
  volumeChapterRequests.clear();
3439
3635
  state.collapsedRaceIds.clear();
3440
3636
  lastSavedChapterSnapshot = null;
3441
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
3442
- aiContextUsageTimer = null;
3443
- aiContextUsageRequest += 1;
3444
3637
  state.aiCitations = [];
3445
3638
  state.aiReferences = [];
3446
3639
  state.aiPromptSent = false;
@@ -3962,7 +4155,7 @@ function markActiveModule(module) {
3962
4155
  }
3963
4156
 
3964
4157
  const moduleMeta = {
3965
- drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
4158
+ drafts: ["临时想法", "创作想法", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建想法"],
3966
4159
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
3967
4160
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
3968
4161
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -3970,6 +4163,7 @@ const moduleMeta = {
3970
4163
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
3971
4164
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
3972
4165
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
4166
+ comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
3973
4167
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
3974
4168
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
3975
4169
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -4014,7 +4208,7 @@ async function showModule(module) {
4014
4208
  $("#module-description").textContent = meta[2];
4015
4209
  $("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
4016
4210
  $("#module-create-button").textContent = meta[3];
4017
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
4211
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || module === "comments" || !canEditModule(module));
4018
4212
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
4019
4213
  bindModuleContentInteractions();
4020
4214
  try {
@@ -4026,6 +4220,7 @@ async function showModule(module) {
4026
4220
  if (module === "timeline") await renderTimeline();
4027
4221
  if (module === "outlines") await renderOutlines();
4028
4222
  if (module === "relationships") await renderRelationships();
4223
+ if (module === "comments") await renderWorkChapterComments();
4029
4224
  if (module === "reviews") await renderReviews();
4030
4225
  if (module === "tasks") await renderTasks(taskListPage);
4031
4226
  if (module === "ai-settings") await renderBookAiSettings();
@@ -4333,7 +4528,7 @@ function mountRelationshipFilterToggle() {
4333
4528
 
4334
4529
  function mountDraftFilterToggle() {
4335
4530
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
4336
- $("#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>`);
4531
+ $("#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>`);
4337
4532
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
4338
4533
  draftFiltersPanelOpen = !draftFiltersPanelOpen;
4339
4534
  await renderDrafts(moduleListPages.drafts);
@@ -4429,15 +4624,15 @@ function settingRecordActions(item) {
4429
4624
  }
4430
4625
 
4431
4626
  function draftTypeLabel(draftType) {
4432
- return draftType === "setting" ? "设定草稿" : "正文草稿";
4627
+ return draftType === "setting" ? "设定想法" : "正文想法";
4433
4628
  }
4434
4629
 
4435
4630
  async function deleteDraft(item) {
4436
4631
  if (!item || !canEditModule("drafts")) return;
4437
4632
  const dialog = $("#form-dialog");
4438
4633
  dialog.close();
4439
- if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4440
- title: "删除草稿",
4634
+ if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
4635
+ title: "删除想法",
4441
4636
  confirmLabel: "确认删除"
4442
4637
  })) {
4443
4638
  openDraftDialog(item);
@@ -4446,7 +4641,7 @@ async function deleteDraft(item) {
4446
4641
  try {
4447
4642
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4448
4643
  await renderDrafts(moduleListPages.drafts);
4449
- toast("草稿已删除");
4644
+ toast("想法已删除");
4450
4645
  } catch (error) {
4451
4646
  toast(error.message, "error");
4452
4647
  try {
@@ -4459,21 +4654,21 @@ async function deleteDraft(item) {
4459
4654
 
4460
4655
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4461
4656
  const viewOnly = readOnly || !canEditModule("drafts");
4462
- const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
4463
- <div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
4464
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
4657
+ const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="想法操作">
4658
+ <div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
4659
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
4465
4660
  </section>` : "";
4466
- const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4467
- + field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
4661
+ const fields = `<p class="form-field-note">这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4662
+ + field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
4468
4663
  + field("title", "标题", "text", item?.title ?? "")
4469
4664
  + field("content", "内容", "markdown", item?.content ?? "", {
4470
4665
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
4471
4666
  readOnly: viewOnly
4472
4667
  }) + management;
4473
- openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4668
+ openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
4474
4669
  if (viewOnly) return;
4475
4670
  const title = String(form.get("title") ?? "").trim();
4476
- if (!title) throw new Error("请填写草稿标题");
4671
+ if (!title) throw new Error("请填写想法标题");
4477
4672
  const body = {
4478
4673
  draftType: form.get("draftType"),
4479
4674
  title,
@@ -4485,12 +4680,12 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4485
4680
  body
4486
4681
  });
4487
4682
  await renderDrafts(moduleListPages.drafts);
4488
- toast(item ? "草稿已保存" : "草稿已创建");
4683
+ toast(item ? "想法已保存" : "想法已创建");
4489
4684
  }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4490
- submitLabel: viewOnly ? "关闭" : "保存草稿",
4685
+ submitLabel: viewOnly ? "关闭" : "保存想法",
4491
4686
  hideCancel: viewOnly,
4492
4687
  editor: true,
4493
- errorPrefix: "草稿保存失败:"
4688
+ errorPrefix: "想法保存失败:"
4494
4689
  });
4495
4690
  if (viewOnly) {
4496
4691
  $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
@@ -4512,23 +4707,23 @@ async function renderDrafts(page = moduleListPages.drafts) {
4512
4707
  const pageResult = paginateModuleItems(drafts, page, "drafts");
4513
4708
  moduleListPages.drafts = pageResult.page;
4514
4709
  const layout = readModuleLayout();
4515
- if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4710
+ if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
4516
4711
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4517
4712
  mountDraftFilterToggle();
4518
- const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4519
- <label for="draft-type-filter">草稿类型</label>
4520
- <select id="draft-type-filter" aria-label="按草稿类型筛选">
4521
- <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
4522
- <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
4523
- <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
4713
+ const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="想法筛选">
4714
+ <label for="draft-type-filter">想法类型</label>
4715
+ <select id="draft-type-filter" aria-label="按想法类型筛选">
4716
+ <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部想法</option>
4717
+ <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文想法</option>
4718
+ <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定想法</option>
4524
4719
  </select>
4525
- ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4720
+ ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>`}
4526
4721
  </section>`;
4527
4722
  const actions = (item) => canEditModule("drafts")
4528
- ? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4723
+ ? `${recordCardEditButton("edit-draft", item.id, `想法“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
4529
4724
  : recordHistoryButton("draft", item.id, item.title);
4530
4725
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4531
- <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4726
+ <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4532
4727
  <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4533
4728
  <h3>${esc(item.title)}</h3>
4534
4729
  <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
@@ -4536,17 +4731,17 @@ async function renderDrafts(page = moduleListPages.drafts) {
4536
4731
  </article>`).join("")}</div>`;
4537
4732
  const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4538
4733
  const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4539
- 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)}">
4734
+ 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)}">
4540
4735
  <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4541
4736
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4542
4737
  <div class="card-actions">${actions(item)}</div>
4543
4738
  </article>`;
4544
4739
  }).join("")}</div>`;
4545
4740
  const emptyDrafts = allDrafts.length
4546
- ? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
4547
- : emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4741
+ ? emptyModule("没有符合筛选条件的想法", "可以切换想法类型查看其他内容。")
4742
+ : emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4548
4743
  $("#module-content").innerHTML = filterToolbar + (drafts.length
4549
- ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
4744
+ ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
4550
4745
  : emptyDrafts);
4551
4746
  $("#draft-type-filter").addEventListener("change", async (event) => {
4552
4747
  draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
@@ -5057,6 +5252,35 @@ async function renderRelationships(page = moduleListPages.relationships) {
5057
5252
  bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
5058
5253
  }
5059
5254
 
5255
+ async function renderWorkChapterComments(page = moduleListPages.comments) {
5256
+ const pageSize = pageSizeFor("comments");
5257
+ const result = await moduleApiPage(
5258
+ "comments",
5259
+ `/api/works/${encodeURIComponent(state.work.id)}/chapter-annotations`,
5260
+ page,
5261
+ pageSize
5262
+ );
5263
+ if (!result.items.length && page > 1) return renderWorkChapterComments(page - 1);
5264
+ const total = Number(result.total ?? result.items.length);
5265
+ const pageCount = Math.max(1, Math.ceil(total / result.limit));
5266
+ const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
5267
+ moduleListPages.comments = pageResult.page;
5268
+ mountModuleCount(total);
5269
+ $("#module-content").innerHTML = result.items.length
5270
+ ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论列表")}`
5271
+ : emptyModule("还没有正文评论", "在任一正文行上点击右键,即可添加评论或待办。");
5272
+ bindModulePagination("comments", renderWorkChapterComments);
5273
+ bindChapterAnnotationCards($("#module-content"), result.items, {
5274
+ refresh: () => renderWorkChapterComments(pageResult.page),
5275
+ locate: async (annotation) => {
5276
+ await selectChapter(annotation.chapterId);
5277
+ await new Promise((resolve) => window.requestAnimationFrame(resolve));
5278
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
5279
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
5280
+ }
5281
+ });
5282
+ }
5283
+
5060
5284
  async function renderReviews(page = moduleListPages.reviews) {
5061
5285
  const canReadCharacters = canReadModule("characters");
5062
5286
  const canResolveReview = canEditModule("reviews");
@@ -5376,12 +5600,7 @@ async function rerunAnalysisTaskWithModel(task, button) {
5376
5600
  try {
5377
5601
  const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
5378
5602
  const currentModelId = String(task.model?.id ?? "");
5379
- const availableModels = models.filter((model) =>
5380
- model.id !== currentModelId
5381
- && model.enabled
5382
- && model.providerStatus === "enabled"
5383
- && model.providerConnectionStatus === "success"
5384
- );
5603
+ const availableModels = models.filter((model) => model.id !== currentModelId && isSelectableModel(model));
5385
5604
  if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
5386
5605
  $("#form-dialog").close();
5387
5606
  const currentModelLabel = task.model?.displayName || "运行时默认模型";
@@ -5892,11 +6111,28 @@ function openTaskDetailDialog(task, trace) {
5892
6111
  }
5893
6112
 
5894
6113
  function renderProviderCards(providers, models) {
5895
- return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
5896
- <article class="record-card provider-card"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
5897
- <p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
5898
- <div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<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>`).join("")}</div>
5899
- <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}">测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`).join("")}</div>`
6114
+ return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
6115
+ const providerModels = models.filter((model) => model.providerId === provider.id);
6116
+ const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
6117
+ const disabledNotice = provider.status === "disabled"
6118
+ ? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
6119
+ : "";
6120
+ return `
6121
+ <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
6122
+ ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
6123
+ <div class="provider-models">${providerModels.map((model) => {
6124
+ const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
6125
+ const modelStatus = !model.enabled
6126
+ ? `<span class="model-status-badge is-disabled">模型已停用</span>`
6127
+ : provider.status !== "enabled"
6128
+ ? `<span class="model-status-badge is-disabled">供应商已停用</span>`
6129
+ : provider.connectionStatus !== "success"
6130
+ ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
6131
+ : "";
6132
+ 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>`;
6133
+ }).join("")}</div>
6134
+ <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>`;
6135
+ }).join("")}</div>`
5900
6136
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
5901
6137
  }
5902
6138
 
@@ -5914,23 +6150,37 @@ function bindPlatformProviderActions(host, providers, models) {
5914
6150
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
5915
6151
  }
5916
6152
 
5917
- function renderTaskDefaults(models, providers, taskDefaults) {
6153
+ function renderTaskDefaults(models, providers, taskDefaults, settings) {
5918
6154
  const providerById = new Map(providers.map((provider) => [provider.id, provider]));
5919
6155
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
5920
- return models.length ? `<section class="config-section">
6156
+ const availableModels = models.filter((model) => isSelectableModel(model));
6157
+ const availableModelIds = new Set(availableModels.map((model) => model.id));
6158
+ const currentDefaultModels = taskDefaults
6159
+ .map((item) => item.model)
6160
+ .filter((model) => model && !availableModelIds.has(model.id));
6161
+ const optionModels = [...availableModels, ...currentDefaultModels];
6162
+ return optionModels.length ? `<section class="config-section">
5921
6163
  <div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
5922
- <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody>${taskTypeLabels.map(([taskType, label]) => {
6164
+ <table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
6165
+ <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
6166
+ ${models.map((model) => {
6167
+ const provider = providerById.get(model.providerId);
6168
+ const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
6169
+ return `<option value="${esc(model.id)}" ${model.id === settings.titleGenerationModelId ? "selected" : ""} ${available || model.id === settings.titleGenerationModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
6170
+ }).join("")}
6171
+ </select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
5923
6172
  const currentModelId = defaultModelByTask.get(taskType) ?? "";
5924
6173
  return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
5925
6174
  <option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
5926
- ${models.map((model) => {
6175
+ ${optionModels.map((model) => {
5927
6176
  const provider = providerById.get(model.providerId);
5928
- const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
5929
- return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available || model.id === currentModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
6177
+ const available = isSelectableModel({ ...model, providerStatus: model.providerStatus ?? provider?.status, providerConnectionStatus: model.providerConnectionStatus ?? provider?.connectionStatus });
6178
+ const unavailableLabel = !model.enabled ? "模型已停用 · " : provider?.status !== "enabled" ? "供应商已停用 · " : "连接不可用 · ";
6179
+ return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available ? "" : "disabled"}>${esc(`${available ? "" : unavailableLabel}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
5930
6180
  }).join("")}
5931
6181
  </select></td></tr>`;
5932
6182
  }).join("")}</tbody></table>
5933
- </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
6183
+ </section>` : emptyModule("尚未配置可用模型", "请先启用并测试供应商模型,已停用模型不会出现在新任务选择中。");
5934
6184
  }
5935
6185
 
5936
6186
  const relationshipIndexStatusLabels = Object.freeze({
@@ -6324,7 +6574,7 @@ async function renderBookAiSettings() {
6324
6574
  host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
6325
6575
  title: "本书 Token 用量",
6326
6576
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
6327
- })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
6577
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
6328
6578
  scrollUsageCalendarsToLatest(host);
6329
6579
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
6330
6580
  "beforebegin",
@@ -6336,7 +6586,7 @@ async function renderBookAiSettings() {
6336
6586
  );
6337
6587
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
6338
6588
  "beforeend",
6339
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
6589
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label>`
6340
6590
  );
6341
6591
  if (!canEditModule("ai-settings")) {
6342
6592
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -6375,7 +6625,7 @@ async function renderBookAiSettings() {
6375
6625
  try {
6376
6626
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { systemPrompt: $("#work-system-prompt").value } });
6377
6627
  toast("本书系统提示词已保存");
6378
- scheduleAiContextUsage();
6628
+ setAiContextMeter(null);
6379
6629
  } catch (error) {
6380
6630
  toast(error.message, "error");
6381
6631
  } finally {
@@ -6428,7 +6678,7 @@ async function renderBookAiSettings() {
6428
6678
  try {
6429
6679
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { bookSummaryContextPercent: Number($("#book-summary-context-percent").value) } });
6430
6680
  toast("全书概要引用配额已保存");
6431
- scheduleAiContextUsage();
6681
+ setAiContextMeter(null);
6432
6682
  } catch (error) {
6433
6683
  toast(error.message, "error");
6434
6684
  } finally {
@@ -6441,7 +6691,7 @@ async function renderBookAiSettings() {
6441
6691
  try {
6442
6692
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
6443
6693
  toast("对话上下文 compact 阈值已保存");
6444
- scheduleAiContextUsage();
6694
+ setAiContextMeter(null);
6445
6695
  } catch (error) {
6446
6696
  toast(error.message, "error");
6447
6697
  } finally {
@@ -6461,6 +6711,18 @@ async function renderBookAiSettings() {
6461
6711
  button.disabled = false;
6462
6712
  }
6463
6713
  });
6714
+ host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
6715
+ const select = event.currentTarget;
6716
+ select.disabled = true;
6717
+ try {
6718
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { titleGenerationModelId: select.value } });
6719
+ toast("创作助手对话标题生成模型已更新");
6720
+ } catch (error) {
6721
+ toast(error.message, "error");
6722
+ }
6723
+ await renderBookAiSettings();
6724
+ await loadModels();
6725
+ });
6464
6726
  host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
6465
6727
  select.disabled = true;
6466
6728
  try {
@@ -6480,13 +6742,17 @@ async function loadModels() {
6480
6742
  const generation = workScopedUiGeneration;
6481
6743
  const models = await api(`/api/works/${workId}/models`);
6482
6744
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6483
- state.models = models;
6745
+ applyAiModels(models);
6484
6746
  loadedAiModelsWorkId = workId;
6747
+ }
6748
+
6749
+ function applyAiModels(models) {
6750
+ state.models = models.filter((model) => isSelectableModel(model));
6485
6751
  const select = $("#ai-model");
6486
6752
  select.innerHTML = state.models.length
6487
- ? state.models.map((model) => `<option value="${esc(model.id)}" ${model.enabled ? "" : "disabled"}>${esc(modelOptionLabel(model))}</option>`).join("")
6753
+ ? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
6488
6754
  : '<option value="">请先配置模型</option>';
6489
- scheduleAiContextUsage();
6755
+ setAiContextMeter(null);
6490
6756
  }
6491
6757
 
6492
6758
  async function ensureAiModelsLoaded() {
@@ -6510,9 +6776,6 @@ async function ensureAiModelsLoaded() {
6510
6776
  }
6511
6777
  }
6512
6778
 
6513
- let aiContextUsageTimer = null;
6514
- let aiContextUsageRequest = 0;
6515
-
6516
6779
  function currentAiRequestScope() {
6517
6780
  if (!state.work) return null;
6518
6781
  const taskType = $("#ai-task").value;
@@ -6532,9 +6795,50 @@ function currentAiRequestScope() {
6532
6795
  return { taskType, scope, selection };
6533
6796
  }
6534
6797
 
6798
+ function renderAiContextDistribution(usage) {
6799
+ const popover = $("#ai-context-popover");
6800
+ const host = $("#ai-context-distribution");
6801
+ const distribution = normalizeAiContextTokenDistribution(usage);
6802
+ const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
6803
+ $("#ai-context-popover-description").textContent = usage
6804
+ ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
6805
+ : "选择可用模型后显示当前上下文用量";
6806
+ host.replaceChildren(...distribution.items.map((item) => {
6807
+ const row = document.createElement("div");
6808
+ row.className = "ai-context-distribution-row";
6809
+ row.dataset.key = item.key;
6810
+ row.setAttribute("role", "listitem");
6811
+ row.setAttribute("aria-label", `${item.label}:${item.tokens.toLocaleString("zh-CN")} tok,占 ${item.percent}%`);
6812
+
6813
+ const label = document.createElement("div");
6814
+ label.className = "ai-context-distribution-label";
6815
+ const title = document.createElement("span");
6816
+ title.textContent = item.label;
6817
+ if (item.key === "skills" || item.key === "context") {
6818
+ const description = document.createElement("small");
6819
+ description.textContent = item.key === "skills" ? "待加入" : "用户和 agent 的交互";
6820
+ title.append(" ", description);
6821
+ }
6822
+ const value = document.createElement("strong");
6823
+ value.textContent = `${item.tokens.toLocaleString("zh-CN")} tok · ${item.percent}%`;
6824
+ label.append(title, value);
6825
+
6826
+ const track = document.createElement("div");
6827
+ track.className = "ai-context-distribution-track";
6828
+ track.setAttribute("aria-hidden", "true");
6829
+ const bar = document.createElement("span");
6830
+ bar.style.setProperty("--distribution-percent", String(item.percent));
6831
+ track.append(bar);
6832
+ row.append(label, track);
6833
+ return row;
6834
+ }));
6835
+ popover.dataset.hasUsage = String(Boolean(usage));
6836
+ }
6837
+
6535
6838
  function setAiContextMeter(usage) {
6536
6839
  const meter = $("#ai-context-meter");
6537
6840
  const value = meter.querySelector("b");
6841
+ renderAiContextDistribution(usage);
6538
6842
  if (!usage) {
6539
6843
  meter.classList.add("is-empty");
6540
6844
  meter.classList.remove("is-warning", "is-danger");
@@ -6556,6 +6860,14 @@ function setAiContextMeter(usage) {
6556
6860
  meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
6557
6861
  }
6558
6862
 
6863
+ function setAiContextDistributionVisible(visible) {
6864
+ const meter = $("#ai-context-meter");
6865
+ const popover = $("#ai-context-popover");
6866
+ popover.classList.toggle("hidden", !visible);
6867
+ meter.setAttribute("aria-expanded", String(visible));
6868
+ if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
6869
+ }
6870
+
6559
6871
  function showAiContextWarning(usage = null) {
6560
6872
  const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
6561
6873
  const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
@@ -6568,57 +6880,6 @@ function hideAiContextWarning() {
6568
6880
  $("#ai-context-warning").classList.add("hidden");
6569
6881
  }
6570
6882
 
6571
- async function prepareAiConversationContext({ instruction, scope, modelId, citations }) {
6572
- const conversationId = await ensureAiConversation();
6573
- const prepared = await api(`/api/ai-conversations/${conversationId}/context/prepare`, {
6574
- method: "POST",
6575
- body: { instruction, scope, modelId, citations }
6576
- });
6577
- setAiContextMeter(prepared.usage);
6578
- if (prepared.action === "warn") {
6579
- showAiContextWarning(prepared.usage);
6580
- return false;
6581
- }
6582
- hideAiContextWarning();
6583
- if (prepared.action === "compacted") toast("已自动整理较早对话为长期记忆并继续发送");
6584
- return true;
6585
- }
6586
-
6587
- function scheduleAiContextUsage() {
6588
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
6589
- aiContextUsageTimer = setTimeout(() => {
6590
- aiContextUsageTimer = null;
6591
- void refreshAiContextUsage();
6592
- }, 260);
6593
- }
6594
-
6595
- async function refreshAiContextUsage() {
6596
- const requestScope = currentAiRequestScope();
6597
- const modelId = $("#ai-model").value;
6598
- if (!requestScope || !modelId || (requestScope.taskType === "polish" && !requestScope.selection)) {
6599
- setAiContextMeter(null);
6600
- return;
6601
- }
6602
- const requestId = ++aiContextUsageRequest;
6603
- try {
6604
- const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
6605
- const usage = await api(`/api/works/${state.work.id}/ai-context-usage`, {
6606
- method: "POST",
6607
- body: {
6608
- modelId,
6609
- taskType: requestScope.taskType,
6610
- scope: requestScope.scope,
6611
- instruction: aiPromptText(),
6612
- citations,
6613
- conversationId: state.aiConversationId || undefined
6614
- }
6615
- });
6616
- if (requestId === aiContextUsageRequest) setAiContextMeter(usage);
6617
- } catch {
6618
- if (requestId === aiContextUsageRequest) setAiContextMeter(null);
6619
- }
6620
- }
6621
-
6622
6883
  async function loadAiReferences() {
6623
6884
  const workId = state.work?.id;
6624
6885
  if (!workId) return;
@@ -8567,11 +8828,7 @@ async function openTaskDialog() {
8567
8828
  return;
8568
8829
  }
8569
8830
  const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
8570
- const availableTaskModels = taskModels.filter((model) =>
8571
- model.enabled
8572
- && model.providerStatus === "enabled"
8573
- && model.providerConnectionStatus === "success"
8574
- );
8831
+ const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
8575
8832
  const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
8576
8833
  const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
8577
8834
  <span id="relationship-character-label">被分析角色(可多选)</span>
@@ -8877,8 +9134,8 @@ async function openTaskDialog() {
8877
9134
  function openProviderDialog(item) {
8878
9135
  const protocol = item?.protocol ?? "openai-chat-completions";
8879
9136
  const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
8880
- openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出令牌数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
8881
- const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
9137
+ openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
9138
+ const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
8882
9139
  if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
8883
9140
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
8884
9141
  await renderPlatformAiConfig();
@@ -8897,16 +9154,30 @@ function openProviderDialog(item) {
8897
9154
 
8898
9155
  function openModelDialog(providerId, item = null) {
8899
9156
  const values = modelFormValues(item);
9157
+ 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>`;
8900
9158
  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>`;
8901
- 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) => {
9159
+ const connectionTest = item && item.providerStatus === "enabled"
9160
+ ? `<section class="model-connection-test">
9161
+ <div><strong>模型连接测试</strong><p>使用当前已保存的模型标识符和供应商凭据发起最小请求。</p></div>
9162
+ <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
9163
+ </section>`
9164
+ : "";
9165
+ 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) => {
8902
9166
  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);
8903
9167
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
8904
9168
  await renderPlatformAiConfig();
8905
9169
  await loadModels();
8906
9170
  }, item ? "模型配置" : "供应商模型");
8907
9171
  const modelIdInput = $("#dialog-fields input[name='modelId']");
9172
+ const contextWindowInput = $("#model-context-window");
9173
+ const contextWindowHint = $("#model-context-window-hint");
8908
9174
  const temperatureInput = $("#dialog-fields input[name='temperature']");
8909
9175
  const temperatureHint = $("#model-temperature-hint");
9176
+ const syncModelContextWindowGuidance = () => {
9177
+ const guidance = modelContextWindowGuidance(contextWindowInput.value);
9178
+ contextWindowInput.setCustomValidity(guidance.belowMinimum ? "模型上下文不能低于 32K(32768 Token)。" : "");
9179
+ contextWindowHint.hidden = !guidance.showRecommendation;
9180
+ };
8910
9181
  const syncKimiTemperature = () => {
8911
9182
  const isKimi = isKimiModelId(modelIdInput.value);
8912
9183
  temperatureHint.hidden = !isKimi;
@@ -8915,13 +9186,31 @@ function openModelDialog(providerId, item = null) {
8915
9186
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
8916
9187
  syncKimiTemperature();
8917
9188
  });
9189
+ contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
9190
+ $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
9191
+ const button = event.currentTarget;
9192
+ button.disabled = true;
9193
+ button.textContent = "测试中";
9194
+ try {
9195
+ const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
9196
+ toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
9197
+ await renderPlatformAiConfig();
9198
+ await loadModels();
9199
+ } catch (error) {
9200
+ toast(`模型连接测试失败:${error.message}`, "error");
9201
+ } finally {
9202
+ button.disabled = false;
9203
+ button.textContent = "测试连接";
9204
+ }
9205
+ });
9206
+ syncModelContextWindowGuidance();
8918
9207
  syncKimiTemperature();
8919
9208
  }
8920
9209
 
8921
9210
  async function sendAi() {
8922
9211
  if (!state.work) return toast("请先选择作品", "error");
8923
9212
  try {
8924
- await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
9213
+ await ensureAiModelsLoaded();
8925
9214
  } catch (error) {
8926
9215
  return toast(`创作助手加载失败:${error.message}`, "error");
8927
9216
  }
@@ -8934,30 +9223,18 @@ async function sendAi() {
8934
9223
  const { taskType, scope, selection } = requestScope;
8935
9224
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
8936
9225
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
8937
- if (taskType === "chat") {
8938
- $("#ai-send").disabled = true;
8939
- $("#ai-send").textContent = "检查上下文";
9226
+ let persistedUserMessage = null;
9227
+ if (taskType !== "chat") {
8940
9228
  try {
8941
- const mayContinue = await prepareAiConversationContext({ instruction, scope, modelId, citations });
8942
- if (!mayContinue) return;
9229
+ persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
8943
9230
  } catch (error) {
8944
- toast(`上下文检查失败:${error.message}`, "error");
8945
- return;
8946
- } finally {
8947
- $("#ai-send").disabled = false;
8948
- $("#ai-send").textContent = "发送";
9231
+ return toast(`对话记录创建失败:${error.message}`, "error");
8949
9232
  }
9233
+ state.aiPromptSent = true;
9234
+ renderAiQuickActions();
9235
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9236
+ clearAiPromptComposer();
8950
9237
  }
8951
- let persistedUserMessage;
8952
- try {
8953
- persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
8954
- } catch (error) {
8955
- return toast(`对话记录创建失败:${error.message}`, "error");
8956
- }
8957
- state.aiPromptSent = true;
8958
- renderAiQuickActions();
8959
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
8960
- clearAiPromptComposer();
8961
9238
  $("#ai-send").disabled = true;
8962
9239
  $("#ai-send").textContent = "发送中";
8963
9240
  try {
@@ -8967,18 +9244,27 @@ async function sendAi() {
8967
9244
  let persistedStreamMessage = null;
8968
9245
  let suggestion = null;
8969
9246
  if (taskType === "chat") {
8970
- const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
9247
+ const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId || undefined });
9248
+ if (streamed.action === "warn") return;
8971
9249
  assistantContent = streamed.content;
8972
9250
  assistantMessage = streamed.message;
8973
9251
  assistantMetadata = streamed.metadata;
8974
9252
  persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
9253
+ applyAiConversationTitle(streamed.conversationTitle);
8975
9254
  } else {
8976
9255
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
9256
+ setAiContextMeter(suggestion.contextUsage);
8977
9257
  assistantContent = suggestion.content;
8978
9258
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
8979
9259
  }
8980
9260
  try {
8981
9261
  if (persistedStreamMessage) {
9262
+ updateAiConversationSummaryFromMessage({
9263
+ conversationId: state.aiConversationId,
9264
+ role: "assistant",
9265
+ content: assistantContent,
9266
+ createdAt: persistedStreamMessage.createdAt
9267
+ });
8982
9268
  updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
8983
9269
  attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
8984
9270
  } else {
@@ -8993,7 +9279,7 @@ async function sendAi() {
8993
9279
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
8994
9280
  }
8995
9281
  } catch (error) {
8996
- const failureMessage = `调用失败:${error.message}`;
9282
+ const failureMessage = formatAiFailureMessage(error);
8997
9283
  let persistedFailureMessage = null;
8998
9284
  try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
8999
9285
  appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
@@ -9008,13 +9294,19 @@ async function streamChat(body) {
9008
9294
  message.className = "assistant-message is-streaming";
9009
9295
  message.dataset.testid = "ai-stream-message";
9010
9296
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
9011
- attachMessageHeading(message, "助手 · 正在生成");
9012
- $("#ai-feed").append(message);
9013
- scrollAiFeedToBottom();
9014
9297
  const content = message.querySelector(".message-body");
9015
9298
  const meta = message.querySelector(".message-meta");
9299
+ let messageMounted = false;
9300
+ const mountAssistantMessage = () => {
9301
+ if (messageMounted) return;
9302
+ attachMessageHeading(message, "助手 · 正在生成");
9303
+ $("#ai-feed").append(message);
9304
+ messageMounted = true;
9305
+ scrollAiFeedToBottom();
9306
+ };
9016
9307
  const typewriter = createStreamTypewriter({
9017
9308
  onRender: (text, progress) => {
9309
+ mountAssistantMessage();
9018
9310
  content.innerHTML = renderMarkdown(text);
9019
9311
  const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
9020
9312
  meta.textContent = progress.pendingCharacters > 0
@@ -9029,6 +9321,9 @@ async function streamChat(body) {
9029
9321
  let processSteps = [];
9030
9322
  let persistedMessageId = null;
9031
9323
  let persistedMessageCreatedAt = null;
9324
+ let conversationTitle = null;
9325
+ let persistedUserMessage = null;
9326
+ let contextAction = "ready";
9032
9327
  let finalAnswerStarted = false;
9033
9328
  const processStartedAt = Date.now();
9034
9329
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -9040,7 +9335,7 @@ async function streamChat(body) {
9040
9335
  });
9041
9336
  if (!response.ok || !response.body) {
9042
9337
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
9043
- throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
9338
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
9044
9339
  }
9045
9340
  const reader = response.body.getReader();
9046
9341
  const decoder = new TextDecoder();
@@ -9055,7 +9350,35 @@ async function streamChat(body) {
9055
9350
  }
9056
9351
  if (!dataLines.length) return;
9057
9352
  const payload = JSON.parse(dataLines.join("\n"));
9058
- if (eventName === "delta") {
9353
+ if (eventName === "context") {
9354
+ contextAction = typeof payload.action === "string" ? payload.action : "ready";
9355
+ setAiContextMeter(payload.usage);
9356
+ if (payload.conversation?.id) {
9357
+ state.aiConversationId = payload.conversation.id;
9358
+ upsertAiConversationSummary(payload.conversation);
9359
+ $("#ai-conversation-title").textContent = payload.conversation.title;
9360
+ }
9361
+ if (contextAction === "warn") showAiContextWarning(payload.usage);
9362
+ else {
9363
+ hideAiContextWarning();
9364
+ if (contextAction === "compacted") {
9365
+ renderConversationCompactionDivider(payload.compaction?.compactedMessageCount, payload.conversation?.messageCount);
9366
+ toast("已自动整理较早对话为长期记忆并继续发送");
9367
+ }
9368
+ }
9369
+ } else if (eventName === "user_message") {
9370
+ persistedUserMessage = payload.message;
9371
+ if (persistedUserMessage?.id) {
9372
+ state.aiConversationId = persistedUserMessage.conversationId;
9373
+ updateAiConversationSummaryFromMessage(persistedUserMessage);
9374
+ state.aiPromptSent = true;
9375
+ renderAiQuickActions();
9376
+ appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9377
+ clearAiPromptComposer();
9378
+ mountAssistantMessage();
9379
+ }
9380
+ } else if (eventName === "delta") {
9381
+ mountAssistantMessage();
9059
9382
  const firstFinalDelta = streamedText.length === 0;
9060
9383
  const delta = typeof payload.delta === "string" ? payload.delta : "";
9061
9384
  streamedText += delta;
@@ -9064,6 +9387,7 @@ async function streamChat(body) {
9064
9387
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
9065
9388
  meta.textContent = "正在生成回复……";
9066
9389
  } else if (eventName === "process_step") {
9390
+ mountAssistantMessage();
9067
9391
  const step = { ...payload };
9068
9392
  const append = step.append === true;
9069
9393
  delete step.append;
@@ -9071,9 +9395,14 @@ async function streamChat(body) {
9071
9395
  if (existing && typeof step.content === "string") existing.content += step.content;
9072
9396
  else processSteps.push(step);
9073
9397
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9074
- meta.textContent = step.type === "thinking" ? `正在思考 · 第 ${Number(step.round) || 1} 轮` : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
9398
+ meta.textContent = step.type === "thinking"
9399
+ ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
9400
+ : step.type === "context_compaction"
9401
+ ? `已压缩第 ${Number(step.round) || 1} 轮工具上下文`
9402
+ : `正在处理第 ${Number(step.round) || 1} 轮中间结果`;
9075
9403
  scrollAiFeedToBottom();
9076
9404
  } else if (eventName === "tool_call") {
9405
+ mountAssistantMessage();
9077
9406
  const toolCall = { ...payload };
9078
9407
  const round = toolCall.round;
9079
9408
  delete toolCall.round;
@@ -9082,9 +9411,15 @@ async function streamChat(body) {
9082
9411
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9083
9412
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
9084
9413
  scrollAiFeedToBottom();
9414
+ } else if (eventName === "context_compacted") {
9415
+ setAiContextMeter(payload.contextUsage);
9416
+ if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
9085
9417
  } else if (eventName === "complete") {
9418
+ mountAssistantMessage();
9086
9419
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9087
9420
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9421
+ conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
9422
+ setAiContextMeter(payload.contextUsage);
9088
9423
  await typewriter.finish();
9089
9424
  message.classList.remove("is-streaming");
9090
9425
  content.setAttribute("aria-busy", "false");
@@ -9098,7 +9433,7 @@ async function streamChat(body) {
9098
9433
  attachAssistantCopyAction(message, streamedText);
9099
9434
  scrollAiFeedToBottom();
9100
9435
  } else if (eventName === "error") {
9101
- streamError = new Error(payload.message ?? "AI 流式调用失败");
9436
+ streamError = createClientError(payload, "AI 流式调用失败", response.status);
9102
9437
  }
9103
9438
  };
9104
9439
  while (true) {
@@ -9112,8 +9447,9 @@ async function streamChat(body) {
9112
9447
  if (buffer.trim()) await consume(buffer);
9113
9448
  await typewriter.finish();
9114
9449
  if (streamError) throw streamError;
9115
- return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt };
9450
+ return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
9116
9451
  } catch (error) {
9452
+ mountAssistantMessage();
9117
9453
  typewriter.reveal();
9118
9454
  message.classList.remove("is-streaming");
9119
9455
  content.setAttribute("aria-busy", "false");
@@ -9127,9 +9463,21 @@ async function streamChat(body) {
9127
9463
 
9128
9464
  function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
9129
9465
  const message = document.createElement("div");
9130
- message.className = role === "user" ? "user-message" : "assistant-message";
9131
- message.innerHTML = `<div class="message-body">${renderMarkdown(text)}</div>`;
9132
- attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
9466
+ const isFailure = role === "assistant" && text.startsWith("调用失败:");
9467
+ message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
9468
+ const messageBody = isFailure
9469
+ ? `<p class="ai-error-text">${esc(text)}</p>`
9470
+ : renderMarkdown(text);
9471
+ message.innerHTML = `<div class="message-body">${messageBody}</div>`;
9472
+ const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
9473
+ if (isFailure) {
9474
+ message.dataset.status = "failed";
9475
+ const failureBadge = document.createElement("strong");
9476
+ failureBadge.className = "ai-message-status is-error";
9477
+ failureBadge.textContent = "失败";
9478
+ failureBadge.setAttribute("aria-label", "消息状态:失败");
9479
+ heading.firstElementChild?.append(failureBadge);
9480
+ }
9133
9481
  if (citations.length) {
9134
9482
  const references = document.createElement("div");
9135
9483
  references.className = "message-citations";
@@ -10102,6 +10450,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
10102
10450
  timeline: Number($("#page-size-timeline").value),
10103
10451
  outlines: Number($("#page-size-outlines").value),
10104
10452
  relationships: Number($("#page-size-relationships").value),
10453
+ comments: Number($("#page-size-comments").value),
10105
10454
  reviews: Number($("#page-size-reviews").value),
10106
10455
  analysisTasks: Number($("#page-size-analysis-tasks").value),
10107
10456
  fileVersions: Number($("#page-size-file-versions").value)
@@ -10247,10 +10596,14 @@ $("#chapter-content").addEventListener("input", (event) => {
10247
10596
  scheduleChapterAutoSave();
10248
10597
  clearChapterLineSelection();
10249
10598
  scheduleChapterLineNumbers();
10250
- scheduleAiContextUsage();
10599
+ setAiContextMeter(null);
10251
10600
  });
10252
- $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
10601
+ $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
10253
10602
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
10603
+ $("#chapter-content").addEventListener("contextmenu", (event) => {
10604
+ if (!state.chapter) return;
10605
+ showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
10606
+ });
10254
10607
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
10255
10608
  const row = event.target.closest(".chapter-line-number");
10256
10609
  if (!row || event.button !== 0) return;
@@ -10330,7 +10683,7 @@ $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!m
10330
10683
  $("#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]?.());
10331
10684
  $("#ai-prompt").addEventListener("input", async () => {
10332
10685
  updateAiMentionMenu();
10333
- scheduleAiContextUsage();
10686
+ setAiContextMeter(null);
10334
10687
  if (!findAiMention(aiPromptTextBeforeCursor())) return;
10335
10688
  try {
10336
10689
  await ensureAiReferencesLoaded();
@@ -10345,9 +10698,9 @@ $("#ai-prompt").addEventListener("focus", () => {
10345
10698
  $("#ai-model").addEventListener("focus", () => {
10346
10699
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
10347
10700
  });
10348
- $("#ai-model").addEventListener("change", scheduleAiContextUsage);
10349
- $("#ai-task").addEventListener("change", scheduleAiContextUsage);
10350
- $("#ai-scope").addEventListener("change", scheduleAiContextUsage);
10701
+ $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
10702
+ $("#ai-task").addEventListener("change", () => setAiContextMeter(null));
10703
+ $("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
10351
10704
  $("#ai-mention-menu").addEventListener("click", (event) => {
10352
10705
  const button = event.target.closest("[data-ai-reference-id]");
10353
10706
  if (button) selectAiMention(button);
@@ -10487,6 +10840,7 @@ document.addEventListener("pointerdown", (event) => {
10487
10840
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
10488
10841
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
10489
10842
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
10843
+ if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
10490
10844
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
10491
10845
  $("#account-menu").classList.add("hidden");
10492
10846
  $("#account-button").setAttribute("aria-expanded", "false");
@@ -10508,6 +10862,7 @@ document.addEventListener("keydown", (event) => {
10508
10862
  closeLineCitationMenu();
10509
10863
  closeMarkdownTableMenu(true);
10510
10864
  hideAiMentionMenu();
10865
+ setAiContextDistributionVisible(false);
10511
10866
  }
10512
10867
  });
10513
10868
  document.addEventListener("keydown", (event) => {
@@ -10517,6 +10872,10 @@ document.addEventListener("keydown", (event) => {
10517
10872
  if (event.repeat) return;
10518
10873
  openSearchDialog().catch((error) => toast(error.message, "error"));
10519
10874
  }, { capture: true });
10875
+ $("#ai-context-meter").addEventListener("click", () => {
10876
+ setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
10877
+ });
10878
+ $("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
10520
10879
  $("#ai-send").addEventListener("click", sendAi);
10521
10880
  $("#ai-new-conversation").addEventListener("click", async () => {
10522
10881
  const button = $("#ai-new-conversation");
@@ -10544,7 +10903,15 @@ $("#ai-context-compact").addEventListener("click", async () => {
10544
10903
  });
10545
10904
  hideAiContextWarning();
10546
10905
  toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
10547
- await refreshAiContextUsage();
10906
+ setAiContextMeter(result.usage);
10907
+ if (result.changed) {
10908
+ const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
10909
+ if (current) {
10910
+ current.compactedMessageCount = result.compactedMessageCount;
10911
+ current.hasCompactedSummary = true;
10912
+ }
10913
+ renderConversationCompactionDivider(result.compactedMessageCount, current?.messageCount);
10914
+ }
10548
10915
  } catch (error) {
10549
10916
  toast(`上下文压缩失败:${error.message}`, "error");
10550
10917
  } finally {
@@ -10570,6 +10937,30 @@ $("#ai-history-toggle").addEventListener("click", async () => {
10570
10937
  toast(`对话历史加载失败:${error.message}`, "error");
10571
10938
  }
10572
10939
  });
10940
+ $("#ai-history-previous").addEventListener("click", async () => {
10941
+ const targetPage = aiConversationHistoryPage.page - 1;
10942
+ if (targetPage < 1) return;
10943
+ $("#ai-history-previous").disabled = true;
10944
+ $("#ai-history-next").disabled = true;
10945
+ try {
10946
+ await loadAiConversations(targetPage);
10947
+ } catch (error) {
10948
+ renderAiConversationHistory();
10949
+ toast(`对话历史加载失败:${error.message}`, "error");
10950
+ }
10951
+ });
10952
+ $("#ai-history-next").addEventListener("click", async () => {
10953
+ const targetPage = aiConversationHistoryPage.nextPage;
10954
+ if (!targetPage) return;
10955
+ $("#ai-history-previous").disabled = true;
10956
+ $("#ai-history-next").disabled = true;
10957
+ try {
10958
+ await loadAiConversations(targetPage);
10959
+ } catch (error) {
10960
+ renderAiConversationHistory();
10961
+ toast(`对话历史加载失败:${error.message}`, "error");
10962
+ }
10963
+ });
10573
10964
  $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
10574
10965
  $("#ai-history-dialog").addEventListener("close", () => {
10575
10966
  $("#ai-history-toggle").setAttribute("aria-expanded", "false");