@musnows/scriverse 0.6.0 → 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, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260730-token-distribution-v2";
14
+ import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260731-skills-description-v1";
15
15
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
16
16
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
17
17
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
18
18
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260725-unified-permissions";
19
- import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260729-drafts-v1";
19
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260731-drafts-to-ideas-v1";
20
20
  import {
21
21
  chapterVersionSourceLabel,
22
22
  foreshadowStatusLabel,
@@ -36,12 +36,12 @@ import {
36
36
  timelineStatusLabel,
37
37
  characterStateFieldLabel
38
38
  } from "/display-labels.js?v=20260728-hybrid-search-v1";
39
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260729-drafts-v1";
39
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
40
40
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
41
41
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
42
42
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
43
43
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
44
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260729-drafts-v1";
44
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260731-drafts-to-ideas-v1";
45
45
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
46
46
  import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
47
47
  import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-search.js?v=20260728-hybrid-search-v1";
@@ -69,6 +69,7 @@ const defaultPageSizes = Object.freeze({
69
69
  timeline: 30,
70
70
  outlines: 30,
71
71
  relationships: 30,
72
+ comments: 30,
72
73
  reviews: 30,
73
74
  analysisTasks: 30,
74
75
  fileVersions: 30
@@ -134,6 +135,7 @@ const cachedWorkModules = new Set([
134
135
  "timeline",
135
136
  "outlines",
136
137
  "relationships",
138
+ "comments",
137
139
  "reviews",
138
140
  "tasks",
139
141
  "ai-settings"
@@ -311,6 +313,7 @@ function applyWorkAccessMode() {
311
313
  const button = $(`#module-nav [data-module="${item.uiModule}"]`);
312
314
  if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
313
315
  }
316
+ $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
314
317
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
315
318
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
316
319
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -392,6 +395,8 @@ let aiReferencesLoadPromise = null;
392
395
  let aiReferencesLoadWorkId = null;
393
396
  let aiConversationsLoadPromise = null;
394
397
  let aiConversationsLoadWorkId = null;
398
+ const aiConversationHistoryPageLimit = 20;
399
+ let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
395
400
  let workScopedUiGeneration = 0;
396
401
  const loadedVolumeChapterIds = new Set();
397
402
  const volumeChapterLoadingIds = new Set();
@@ -662,7 +667,7 @@ function renderPresence() {
662
667
  const localKey = presencePageKey(presencePageForRoute());
663
668
  syncChapterAutoSaveWithPresence();
664
669
  control.classList.remove("hidden");
665
- $("#presence-count").textContent = `${groups.length} 人在线`;
670
+ $("#presence-count").textContent = `${groups.length} 人`;
666
671
  $("#presence-list").innerHTML = groups.map((participant) => {
667
672
  const isCurrent = participant.userId === state.user?.userId;
668
673
  const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
@@ -908,6 +913,7 @@ const moduleListPages = {
908
913
  outlinePlans: 1,
909
914
  foreshadows: 1,
910
915
  relationships: 1,
916
+ comments: 1,
911
917
  reviews: 1
912
918
  };
913
919
  const characterFilters = { raceIds: [], organizationIds: [] };
@@ -1244,7 +1250,7 @@ function renderAiCitations() {
1244
1250
  card.append(main, remove, fullText);
1245
1251
  host.append(card);
1246
1252
  }
1247
- scheduleAiContextUsage();
1253
+ setAiContextMeter(null);
1248
1254
  }
1249
1255
 
1250
1256
  function aiReferenceKey(reference) {
@@ -1288,7 +1294,7 @@ function renderAiReferences() {
1288
1294
  if (rendered.has(aiReferenceKey(reference))) continue;
1289
1295
  prompt.append(createAiReferenceChip(reference), document.createTextNode(" "));
1290
1296
  }
1291
- scheduleAiContextUsage();
1297
+ setAiContextMeter(null);
1292
1298
  }
1293
1299
 
1294
1300
  function renderAiQuickActions() {
@@ -1331,6 +1337,34 @@ function resetAiFeed() {
1331
1337
  $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1332
1338
  }
1333
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
+
1334
1368
  function renderMessageCardActions(message) {
1335
1369
  let actions = message.querySelector(".message-card-actions");
1336
1370
  if (!actions) {
@@ -1367,7 +1401,7 @@ function renderMessageCardActions(message) {
1367
1401
  fork.disabled = true;
1368
1402
  try {
1369
1403
  const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
1370
- await loadAiConversations(false);
1404
+ upsertAiConversationSummary(conversation);
1371
1405
  await openAiConversation(conversation.id);
1372
1406
  toast("已从所选消息创建分支对话");
1373
1407
  } catch (error) {
@@ -1399,7 +1433,7 @@ const AI_TOOL_DISPLAY_NAMES = {
1399
1433
  grep: "查询正文关键字",
1400
1434
  search_story_entities: "搜索作品实体",
1401
1435
  read_character_sections: "读取人物 Markdown 章节",
1402
- search_drafts: "搜索草稿"
1436
+ search_drafts: "搜索想法"
1403
1437
  };
1404
1438
 
1405
1439
  const AI_TOOL_DESCRIPTIONS = {
@@ -1559,6 +1593,17 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1559
1593
  const list = document.createElement("div");
1560
1594
  list.className = "ai-process-list";
1561
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
+ }
1562
1607
  if (step?.type === "tool" && step.toolCall) {
1563
1608
  const tool = document.createElement("section");
1564
1609
  tool.className = "ai-process-step ai-process-tool-step";
@@ -1599,6 +1644,12 @@ function setAiHistoryVisible(visible) {
1599
1644
  function renderAiConversationHistory() {
1600
1645
  const host = $("#ai-history-list");
1601
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} 条`;
1602
1653
  if (!state.aiConversations.length) {
1603
1654
  host.innerHTML = '<p class="ai-history-empty">还没有历史对话</p>';
1604
1655
  return;
@@ -1618,6 +1669,43 @@ function renderAiConversationHistory() {
1618
1669
  }
1619
1670
  }
1620
1671
 
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
+
1621
1709
  function applyAiConversationTitle(title) {
1622
1710
  if (!title || !state.aiConversationId) return;
1623
1711
  const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
@@ -1626,20 +1714,27 @@ function applyAiConversationTitle(title) {
1626
1714
  renderAiConversationHistory();
1627
1715
  }
1628
1716
 
1629
- async function loadAiConversations(openLatest = true) {
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) {
1630
1732
  const workId = state.work?.id;
1631
1733
  if (!workId) return;
1632
1734
  const generation = workScopedUiGeneration;
1633
- const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1735
+ const conversations = await apiPage(`/api/works/${workId}/ai-conversations`, page, aiConversationHistoryPageLimit);
1634
1736
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
1635
- state.aiConversations = conversations;
1636
- loadedAiConversationsWorkId = workId;
1637
- renderAiConversationHistory();
1638
- if (openLatest && state.aiConversations.length) await openAiConversation(state.aiConversations[0].id, false);
1639
- else {
1640
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
1641
- if (current) $("#ai-conversation-title").textContent = current.title;
1642
- }
1737
+ applyAiConversations(conversations);
1643
1738
  }
1644
1739
 
1645
1740
  async function ensureAiConversationsLoaded() {
@@ -1647,7 +1742,7 @@ async function ensureAiConversationsLoaded() {
1647
1742
  if (!workId || loadedAiConversationsWorkId === workId) return;
1648
1743
  if (aiConversationsLoadPromise && aiConversationsLoadWorkId === workId) return aiConversationsLoadPromise;
1649
1744
  aiConversationsLoadWorkId = workId;
1650
- aiConversationsLoadPromise = loadAiConversations(false);
1745
+ aiConversationsLoadPromise = loadAiConversations();
1651
1746
  try {
1652
1747
  await aiConversationsLoadPromise;
1653
1748
  } finally {
@@ -1660,11 +1755,15 @@ async function ensureAiConversationsLoaded() {
1660
1755
 
1661
1756
  async function openAiConversation(conversationId, hideHistory = true) {
1662
1757
  const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1758
+ upsertAiConversationSummary(conversation);
1663
1759
  state.aiConversationId = conversation.id;
1664
1760
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1665
1761
  $("#ai-conversation-title").textContent = conversation.title;
1666
1762
  resetAiFeed();
1667
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
+ }
1668
1767
  state.aiCitations = [];
1669
1768
  state.aiReferences = [];
1670
1769
  setAiPromptText("");
@@ -1680,14 +1779,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1680
1779
  async function createNewAiConversation() {
1681
1780
  if (!state.work) return;
1682
1781
  const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1782
+ upsertAiConversationSummary(conversation);
1683
1783
  state.aiConversationId = conversation.id;
1684
1784
  state.aiPromptSent = false;
1685
1785
  $("#ai-conversation-title").textContent = conversation.title;
1686
1786
  resetAiFeed();
1687
1787
  hideAiContextWarning();
1788
+ setAiContextMeter(null);
1688
1789
  renderAiQuickActions();
1689
1790
  setAiHistoryVisible(false);
1690
- await loadAiConversations(false);
1691
1791
  }
1692
1792
 
1693
1793
  async function ensureAiConversation() {
@@ -1700,7 +1800,7 @@ async function persistAiConversationMessage(role, content, citations = [], metad
1700
1800
  const conversationId = await ensureAiConversation();
1701
1801
  if (!conversationId) throw new Error("无法创建 AI 对话");
1702
1802
  const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
1703
- await loadAiConversations(false);
1803
+ updateAiConversationSummaryFromMessage(message);
1704
1804
  return message;
1705
1805
  }
1706
1806
 
@@ -1837,9 +1937,9 @@ async function createSelectedLineAnnotation(kind) {
1837
1937
  if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
1838
1938
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1839
1939
  closeLineCitationMenu();
1840
- const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : "写下这段正文的批注", {
1841
- title: kind === "todo" ? "添加正文待办" : "添加正文批注",
1842
- inputLabel: kind === "todo" ? "待办内容" : "批注内容",
1940
+ const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : `评论第 ${selection.safeStart + 1} 行正文`, {
1941
+ title: kind === "todo" ? "添加正文待办" : "添加正文评论",
1942
+ inputLabel: kind === "todo" ? "待办内容" : "评论内容",
1843
1943
  confirmLabel: "添加",
1844
1944
  maxLength: 2000
1845
1945
  });
@@ -1849,61 +1949,107 @@ async function createSelectedLineAnnotation(kind) {
1849
1949
  method: "POST",
1850
1950
  body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
1851
1951
  });
1852
- toast(kind === "todo" ? "正文待办已添加" : "正文批注已添加");
1952
+ toast(kind === "todo" ? "正文待办已添加" : "正文评论已添加");
1853
1953
  await openChapterAnnotationsDialog();
1854
1954
  } catch (error) {
1855
1955
  toast(error.message, "error");
1856
1956
  }
1857
1957
  }
1858
1958
 
1859
- function renderChapterAnnotations() {
1860
- const host = $("#chapter-annotations-list");
1861
- host.innerHTML = chapterAnnotations.length ? chapterAnnotations.map((annotation) => `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1862
- <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "批注"}</span><strong>${annotation.startLine === annotation.endLine ? `第 ${annotation.startLine} 行` : `第 ${annotation.startLine}-${annotation.endLine} 行`}</strong><em>${annotation.status === "resolved" ? "已完成" : "处理中"}</em></header>
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>
1863
1978
  <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
1864
- <p>${esc(annotation.note)}</p>
1979
+ <p data-annotation-content>${esc(annotation.note)}</p>
1865
1980
  <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
1866
- <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>编辑说明</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : "完成待办"}</button><button class="danger-button" type="button" data-annotation-delete>删除</button>` : ""}</footer>
1867
- </article>`).join("") : '<p class="entity-history-empty">本章还没有批注或待办。选择正文行并打开右键菜单即可添加。</p>';
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
+ };
1868
1992
  host.querySelectorAll("[data-annotation-id]").forEach((card) => {
1869
- const annotation = chapterAnnotations.find((item) => item.id === card.dataset.annotationId);
1993
+ const annotation = annotations.find((item) => item.id === card.dataset.annotationId);
1870
1994
  if (!annotation) return;
1871
- card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
1872
- $("#chapter-annotations-dialog").close();
1873
- paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
1874
- selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
1995
+ card.querySelector("[data-annotation-locate]")?.addEventListener("click", async () => {
1996
+ try { await locate(annotation); }
1997
+ catch (error) { toast(error.message, "error"); }
1875
1998
  });
1876
1999
  card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
1877
2000
  try {
1878
2001
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
1879
- await loadChapterAnnotations();
2002
+ await refresh();
1880
2003
  } catch (error) { toast(error.message, "error"); }
1881
2004
  });
1882
2005
  card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
1883
- const dialog = $("#chapter-annotations-dialog");
1884
- dialog.close();
1885
- const note = await inputToast("修改批注或待办说明", { title: "编辑说明", inputLabel: "说明", placeholder: annotation.note, confirmLabel: "保存", maxLength: 2000 });
1886
- if (!note) return dialog.showModal();
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();
1887
2015
  try {
1888
2016
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
1889
- await loadChapterAnnotations();
2017
+ await refresh();
1890
2018
  } catch (error) { toast(error.message, "error"); }
1891
- dialog.showModal();
2019
+ resumeOverlay();
1892
2020
  });
1893
2021
  card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
1894
- const dialog = $("#chapter-annotations-dialog");
1895
- dialog.close();
1896
- const confirmed = await confirmToast("删除后不会在本章清单中显示,但历史版本仍会保留。", { title: "删除批注或待办", confirmLabel: "确认删除" });
1897
- if (!confirmed) return dialog.showModal();
2022
+ suspendOverlay();
2023
+ const confirmed = await confirmToast("删除后不会在正文评论中显示,但历史版本仍会保留。", {
2024
+ title: annotation.kind === "todo" ? "删除待办" : "删除评论",
2025
+ confirmLabel: "确认删除"
2026
+ });
2027
+ if (!confirmed) return resumeOverlay();
1898
2028
  try {
1899
2029
  await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
1900
- await loadChapterAnnotations();
1901
- dialog.showModal();
1902
- } catch (error) { dialog.showModal(); toast(error.message, "error"); }
2030
+ await refresh();
2031
+ } catch (error) { toast(error.message, "error"); }
2032
+ resumeOverlay();
1903
2033
  });
1904
2034
  });
1905
2035
  }
1906
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
+
1907
2053
  async function loadChapterAnnotations() {
1908
2054
  if (!state.chapter) return;
1909
2055
  chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
@@ -1912,8 +2058,8 @@ async function loadChapterAnnotations() {
1912
2058
 
1913
2059
  async function openChapterAnnotationsDialog() {
1914
2060
  if (!state.chapter) return;
1915
- $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文批注与待办`;
1916
- $("#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>';
1917
2063
  if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
1918
2064
  try {
1919
2065
  await loadChapterAnnotations();
@@ -2182,6 +2328,10 @@ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2182
2328
  error.details = source.details;
2183
2329
  error.failure = typeof source.failure === "string" ? source.failure : undefined;
2184
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;
2185
2335
  return error;
2186
2336
  }
2187
2337
 
@@ -2193,8 +2343,13 @@ function formatAiFailureMessage(error) {
2193
2343
  const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
2194
2344
  const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
2195
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 : "";
2196
2349
  if (code) lines.push(`错误码:${code}`);
2197
2350
  if (status) lines.push(`服务端状态:HTTP ${status}`);
2351
+ if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
2352
+ if (modelId) lines.push(`模型 ID:${modelId}`);
2198
2353
  if (failure && failure !== message) lines.push(`详细原因:${failure}`);
2199
2354
  if (callId) lines.push(`调用 ID:${callId}`);
2200
2355
  return lines.join("\n\n");
@@ -2242,7 +2397,6 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2242
2397
  path.startsWith("/api/auth/")
2243
2398
  || path.includes("/presence")
2244
2399
  || path.includes("/context/prepare")
2245
- || path.includes("/ai-context-usage")
2246
2400
  || path.includes("/chat/stream")
2247
2401
  ) return;
2248
2402
 
@@ -2259,6 +2413,7 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2259
2413
  if (path.includes("/timeline")) affected.add("timeline");
2260
2414
  if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
2261
2415
  if (path.includes("/relationships")) affected.add("relationships");
2416
+ if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
2262
2417
  if (path.includes("/reviews")) affected.add("reviews");
2263
2418
  if (path.includes("/entity-versions/")) {
2264
2419
  if (path.includes("/draft/")) affected.add("drafts");
@@ -2523,7 +2678,7 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
2523
2678
  });
2524
2679
  }
2525
2680
 
2526
- function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2681
+ function inputToast(message, { title = "请输入", inputLabel = title, value = "", placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
2527
2682
  const region = $("#toast-region");
2528
2683
  const element = document.createElement("section");
2529
2684
  element.className = "toast toast-confirmation toast-input-dialog";
@@ -2537,6 +2692,7 @@ function inputToast(message, { title = "请输入", inputLabel = title, placehol
2537
2692
  input.className = "toast-input";
2538
2693
  input.type = "text";
2539
2694
  input.maxLength = maxLength;
2695
+ input.value = value;
2540
2696
  input.placeholder = placeholder;
2541
2697
  input.setAttribute("aria-label", inputLabel);
2542
2698
  const actions = document.createElement("div");
@@ -2963,15 +3119,15 @@ const workAuditActionLabels = {
2963
3119
  "chapter.deleted": "删除章节",
2964
3120
  "chapter.purged": "彻底删除章节",
2965
3121
  "chapter.restored": "恢复章节",
2966
- "draft.created": "创建草稿",
2967
- "draft.updated": "更新草稿",
2968
- "draft.deleted": "删除草稿",
2969
- "draft.restored": "恢复草稿",
3122
+ "draft.created": "创建想法",
3123
+ "draft.updated": "更新想法",
3124
+ "draft.deleted": "删除想法",
3125
+ "draft.restored": "恢复想法",
2970
3126
  "work.imported": "导入正文"
2971
3127
  };
2972
3128
 
2973
3129
  function workAuditEntityLabel(type) {
2974
- return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "草稿", user: "用户" })[type] ?? type;
3130
+ return ({ work: "作品", volume: "分卷", chapter: "章节", draft: "想法", user: "用户" })[type] ?? type;
2975
3131
  }
2976
3132
 
2977
3133
  function workAuditDetailText(detail) {
@@ -3089,6 +3245,7 @@ async function openPlatformUiSettingsDialog() {
3089
3245
  $("#page-size-timeline").value = String(pageSizes.timeline);
3090
3246
  $("#page-size-outlines").value = String(pageSizes.outlines);
3091
3247
  $("#page-size-relationships").value = String(pageSizes.relationships);
3248
+ $("#page-size-comments").value = String(pageSizes.comments);
3092
3249
  $("#page-size-reviews").value = String(pageSizes.reviews);
3093
3250
  $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
3094
3251
  $("#page-size-file-versions").value = String(pageSizes.fileVersions);
@@ -3455,6 +3612,7 @@ function resetWorkScopedUiCaches() {
3455
3612
  aiReferencesLoadWorkId = null;
3456
3613
  aiConversationsLoadPromise = null;
3457
3614
  aiConversationsLoadWorkId = null;
3615
+ aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
3458
3616
  raceHierarchyLoadPromise = null;
3459
3617
  raceHierarchyLoadWorkId = null;
3460
3618
  loadedRaceHierarchyWorkId = null;
@@ -3476,9 +3634,6 @@ function resetWorkScopedUiCaches() {
3476
3634
  volumeChapterRequests.clear();
3477
3635
  state.collapsedRaceIds.clear();
3478
3636
  lastSavedChapterSnapshot = null;
3479
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
3480
- aiContextUsageTimer = null;
3481
- aiContextUsageRequest += 1;
3482
3637
  state.aiCitations = [];
3483
3638
  state.aiReferences = [];
3484
3639
  state.aiPromptSent = false;
@@ -4000,7 +4155,7 @@ function markActiveModule(module) {
4000
4155
  }
4001
4156
 
4002
4157
  const moduleMeta = {
4003
- drafts: ["临时想法", "创作草稿", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建草稿"],
4158
+ drafts: ["临时想法", "创作想法", "记录可能采用、也可能永远不会写入正文或正式设定的想法。", "新建想法"],
4004
4159
  settings: ["世界事实", "世界观与设定库", "锁定的设定会成为 AI 续写与校对的硬约束。", "新建设定"],
4005
4160
  characters: ["人物档案", "角色与人物属性", "维护别名、属性、当前状态及不可被 AI 覆盖的字段。", "新建角色"],
4006
4161
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
@@ -4008,6 +4163,7 @@ const moduleMeta = {
4008
4163
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
4009
4164
  outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
4010
4165
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
4166
+ comments: ["正文协作", "正文评论", "集中查看并处理当前作品所有章节的评论与待办。", ""],
4011
4167
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
4012
4168
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
4013
4169
  "ai-settings": ["书籍提示词", "本书 AI 设置", "本书系统提示词会追加在内置提示词和平台全局提示词之后;任务默认模型只作用于当前作品。", "保存设置"]
@@ -4052,7 +4208,7 @@ async function showModule(module) {
4052
4208
  $("#module-description").textContent = meta[2];
4053
4209
  $("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
4054
4210
  $("#module-create-button").textContent = meta[3];
4055
- $("#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));
4056
4212
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
4057
4213
  bindModuleContentInteractions();
4058
4214
  try {
@@ -4064,6 +4220,7 @@ async function showModule(module) {
4064
4220
  if (module === "timeline") await renderTimeline();
4065
4221
  if (module === "outlines") await renderOutlines();
4066
4222
  if (module === "relationships") await renderRelationships();
4223
+ if (module === "comments") await renderWorkChapterComments();
4067
4224
  if (module === "reviews") await renderReviews();
4068
4225
  if (module === "tasks") await renderTasks(taskListPage);
4069
4226
  if (module === "ai-settings") await renderBookAiSettings();
@@ -4371,7 +4528,7 @@ function mountRelationshipFilterToggle() {
4371
4528
 
4372
4529
  function mountDraftFilterToggle() {
4373
4530
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
4374
- $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选草稿" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" title="筛选草稿"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
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>`);
4375
4532
  $("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
4376
4533
  draftFiltersPanelOpen = !draftFiltersPanelOpen;
4377
4534
  await renderDrafts(moduleListPages.drafts);
@@ -4467,15 +4624,15 @@ function settingRecordActions(item) {
4467
4624
  }
4468
4625
 
4469
4626
  function draftTypeLabel(draftType) {
4470
- return draftType === "setting" ? "设定草稿" : "正文草稿";
4627
+ return draftType === "setting" ? "设定想法" : "正文想法";
4471
4628
  }
4472
4629
 
4473
4630
  async function deleteDraft(item) {
4474
4631
  if (!item || !canEditModule("drafts")) return;
4475
4632
  const dialog = $("#form-dialog");
4476
4633
  dialog.close();
4477
- if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
4478
- title: "删除草稿",
4634
+ if (!await confirmToast(`确认删除想法“${item.title}”吗?想法将从当前列表移除。`, {
4635
+ title: "删除想法",
4479
4636
  confirmLabel: "确认删除"
4480
4637
  })) {
4481
4638
  openDraftDialog(item);
@@ -4484,7 +4641,7 @@ async function deleteDraft(item) {
4484
4641
  try {
4485
4642
  await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
4486
4643
  await renderDrafts(moduleListPages.drafts);
4487
- toast("草稿已删除");
4644
+ toast("想法已删除");
4488
4645
  } catch (error) {
4489
4646
  toast(error.message, "error");
4490
4647
  try {
@@ -4497,21 +4654,21 @@ async function deleteDraft(item) {
4497
4654
 
4498
4655
  function openDraftDialog(item = null, { readOnly = false } = {}) {
4499
4656
  const viewOnly = readOnly || !canEditModule("drafts");
4500
- const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
4501
- <div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
4502
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
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>
4503
4660
  </section>` : "";
4504
- const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
4505
- + 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", "设定想法"]])
4506
4663
  + field("title", "标题", "text", item?.title ?? "")
4507
4664
  + field("content", "内容", "markdown", item?.content ?? "", {
4508
4665
  placeholder: "记录尚未定稿的片段、方向或设定想法……",
4509
4666
  readOnly: viewOnly
4510
4667
  }) + management;
4511
- openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
4668
+ openDialog(item ? viewOnly ? "查看想法" : "编辑想法" : "新建想法", fields, async (form) => {
4512
4669
  if (viewOnly) return;
4513
4670
  const title = String(form.get("title") ?? "").trim();
4514
- if (!title) throw new Error("请填写草稿标题");
4671
+ if (!title) throw new Error("请填写想法标题");
4515
4672
  const body = {
4516
4673
  draftType: form.get("draftType"),
4517
4674
  title,
@@ -4523,12 +4680,12 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
4523
4680
  body
4524
4681
  });
4525
4682
  await renderDrafts(moduleListPages.drafts);
4526
- toast(item ? "草稿已保存" : "草稿已创建");
4683
+ toast(item ? "想法已保存" : "想法已创建");
4527
4684
  }, item ? draftTypeLabel(item.draftType) : "未确认想法", {
4528
- submitLabel: viewOnly ? "关闭" : "保存草稿",
4685
+ submitLabel: viewOnly ? "关闭" : "保存想法",
4529
4686
  hideCancel: viewOnly,
4530
4687
  editor: true,
4531
- errorPrefix: "草稿保存失败:"
4688
+ errorPrefix: "想法保存失败:"
4532
4689
  });
4533
4690
  if (viewOnly) {
4534
4691
  $("#dialog-fields").querySelectorAll("input, select, textarea").forEach((control) => {
@@ -4550,23 +4707,23 @@ async function renderDrafts(page = moduleListPages.drafts) {
4550
4707
  const pageResult = paginateModuleItems(drafts, page, "drafts");
4551
4708
  moduleListPages.drafts = pageResult.page;
4552
4709
  const layout = readModuleLayout();
4553
- if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
4710
+ if (drafts.length) mountModuleLayoutToggle(layout, "想法列表样式");
4554
4711
  else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
4555
4712
  mountDraftFilterToggle();
4556
- const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
4557
- <label for="draft-type-filter">草稿类型</label>
4558
- <select id="draft-type-filter" aria-label="按草稿类型筛选">
4559
- <option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
4560
- <option value="prose" ${draftTypeFilter === "prose" ? "selected" : ""}>正文草稿</option>
4561
- <option value="setting" ${draftTypeFilter === "setting" ? "selected" : ""}>设定草稿</option>
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>
4562
4719
  </select>
4563
- ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
4720
+ ${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 条想法</span>`}
4564
4721
  </section>`;
4565
4722
  const actions = (item) => canEditModule("drafts")
4566
- ? `${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)}`
4567
4724
  : recordHistoryButton("draft", item.id, item.title);
4568
4725
  const cards = `<div class="card-grid">${pageResult.items.map((item) => `
4569
- <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
4726
+ <article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看想法 ${esc(item.title)}">
4570
4727
  <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4571
4728
  <h3>${esc(item.title)}</h3>
4572
4729
  <div class="record-markdown-preview">${esc(item.contentPreview || "暂无内容")}</div>
@@ -4574,17 +4731,17 @@ async function renderDrafts(page = moduleListPages.drafts) {
4574
4731
  </article>`).join("")}</div>`;
4575
4732
  const rows = `<div class="module-row-list">${pageResult.items.map((item) => {
4576
4733
  const preview = moduleRowPreview(item.contentPreview || "暂无内容");
4577
- return `<article class="record-card module-row preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
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)}">
4578
4735
  <small>${esc(draftTypeLabel(item.draftType))} · 更新于 ${esc(formatDateTime(item.updatedAt))}</small>
4579
4736
  <h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
4580
4737
  <div class="card-actions">${actions(item)}</div>
4581
4738
  </article>`;
4582
4739
  }).join("")}</div>`;
4583
4740
  const emptyDrafts = allDrafts.length
4584
- ? emptyModule("没有符合筛选条件的草稿", "可以切换草稿类型查看其他想法。")
4585
- : emptyModule("还没有草稿", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4741
+ ? emptyModule("没有符合筛选条件的想法", "可以切换想法类型查看其他内容。")
4742
+ : emptyModule("还没有想法", "把不一定会进入正文或设定的片段、方向和备选想法先记在这里。");
4586
4743
  $("#module-content").innerHTML = filterToolbar + (drafts.length
4587
- ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "草稿列表")}`
4744
+ ? `${layout === "rows" ? rows : cards}${renderModulePagination(pageResult, "drafts", "想法列表")}`
4588
4745
  : emptyDrafts);
4589
4746
  $("#draft-type-filter").addEventListener("change", async (event) => {
4590
4747
  draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
@@ -5095,6 +5252,35 @@ async function renderRelationships(page = moduleListPages.relationships) {
5095
5252
  bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
5096
5253
  }
5097
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
+
5098
5284
  async function renderReviews(page = moduleListPages.reviews) {
5099
5285
  const canReadCharacters = canReadModule("characters");
5100
5286
  const canResolveReview = canEditModule("reviews");
@@ -5943,9 +6129,9 @@ function renderProviderCards(providers, models) {
5943
6129
  : provider.connectionStatus !== "success"
5944
6130
  ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
5945
6131
  : "";
5946
- return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}<button class="ghost-button model-test-button" type="button" data-test-model="${esc(model.id)}" aria-label="测试模型 ${esc(model.displayName)}">测试连接</button></div>`;
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>`;
5947
6133
  }).join("")}</div>
5948
- <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
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>`;
5949
6135
  }).join("")}</div>`
5950
6136
  : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
5951
6137
  }
@@ -5959,14 +6145,6 @@ function bindPlatformProviderActions(host, providers, models) {
5959
6145
  await renderPlatformAiConfig();
5960
6146
  await loadModels();
5961
6147
  }));
5962
- host.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", async () => {
5963
- button.disabled = true;
5964
- button.textContent = "测试中";
5965
- const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
5966
- toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
5967
- await renderPlatformAiConfig();
5968
- await loadModels();
5969
- }));
5970
6148
  host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
5971
6149
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
5972
6150
  host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
@@ -6408,7 +6586,7 @@ async function renderBookAiSettings() {
6408
6586
  );
6409
6587
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
6410
6588
  "beforeend",
6411
- `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索草稿</strong><small>查询正文草稿和设定草稿。草稿只是可能采用、也可能永远不会进入正文或正式设定的临时想法,Agent 不会把它当作已确认事实。</small></span></label>`
6589
+ `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label>`
6412
6590
  );
6413
6591
  if (!canEditModule("ai-settings")) {
6414
6592
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
@@ -6447,7 +6625,7 @@ async function renderBookAiSettings() {
6447
6625
  try {
6448
6626
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { systemPrompt: $("#work-system-prompt").value } });
6449
6627
  toast("本书系统提示词已保存");
6450
- scheduleAiContextUsage();
6628
+ setAiContextMeter(null);
6451
6629
  } catch (error) {
6452
6630
  toast(error.message, "error");
6453
6631
  } finally {
@@ -6500,7 +6678,7 @@ async function renderBookAiSettings() {
6500
6678
  try {
6501
6679
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { bookSummaryContextPercent: Number($("#book-summary-context-percent").value) } });
6502
6680
  toast("全书概要引用配额已保存");
6503
- scheduleAiContextUsage();
6681
+ setAiContextMeter(null);
6504
6682
  } catch (error) {
6505
6683
  toast(error.message, "error");
6506
6684
  } finally {
@@ -6513,7 +6691,7 @@ async function renderBookAiSettings() {
6513
6691
  try {
6514
6692
  await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { contextCompactThreshold: Number($("#context-compact-threshold").value) } });
6515
6693
  toast("对话上下文 compact 阈值已保存");
6516
- scheduleAiContextUsage();
6694
+ setAiContextMeter(null);
6517
6695
  } catch (error) {
6518
6696
  toast(error.message, "error");
6519
6697
  } finally {
@@ -6564,13 +6742,17 @@ async function loadModels() {
6564
6742
  const generation = workScopedUiGeneration;
6565
6743
  const models = await api(`/api/works/${workId}/models`);
6566
6744
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
6567
- state.models = models.filter((model) => isSelectableModel(model));
6745
+ applyAiModels(models);
6568
6746
  loadedAiModelsWorkId = workId;
6747
+ }
6748
+
6749
+ function applyAiModels(models) {
6750
+ state.models = models.filter((model) => isSelectableModel(model));
6569
6751
  const select = $("#ai-model");
6570
6752
  select.innerHTML = state.models.length
6571
6753
  ? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
6572
6754
  : '<option value="">请先配置模型</option>';
6573
- scheduleAiContextUsage();
6755
+ setAiContextMeter(null);
6574
6756
  }
6575
6757
 
6576
6758
  async function ensureAiModelsLoaded() {
@@ -6594,9 +6776,6 @@ async function ensureAiModelsLoaded() {
6594
6776
  }
6595
6777
  }
6596
6778
 
6597
- let aiContextUsageTimer = null;
6598
- let aiContextUsageRequest = 0;
6599
-
6600
6779
  function currentAiRequestScope() {
6601
6780
  if (!state.work) return null;
6602
6781
  const taskType = $("#ai-task").value;
@@ -6622,7 +6801,7 @@ function renderAiContextDistribution(usage) {
6622
6801
  const distribution = normalizeAiContextTokenDistribution(usage);
6623
6802
  const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
6624
6803
  $("#ai-context-popover-description").textContent = usage
6625
- ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok`
6804
+ ? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok · ${formatAiContextUsagePercent(distribution.occupiedTokens, distribution.contextWindow)}`
6626
6805
  : "选择可用模型后显示当前上下文用量";
6627
6806
  host.replaceChildren(...distribution.items.map((item) => {
6628
6807
  const row = document.createElement("div");
@@ -6635,9 +6814,9 @@ function renderAiContextDistribution(usage) {
6635
6814
  label.className = "ai-context-distribution-label";
6636
6815
  const title = document.createElement("span");
6637
6816
  title.textContent = item.label;
6638
- if (item.key === "context") {
6817
+ if (item.key === "skills" || item.key === "context") {
6639
6818
  const description = document.createElement("small");
6640
- description.textContent = "用户和 agent 的交互";
6819
+ description.textContent = item.key === "skills" ? "待加入" : "用户和 agent 的交互";
6641
6820
  title.append(" ", description);
6642
6821
  }
6643
6822
  const value = document.createElement("strong");
@@ -6701,57 +6880,6 @@ function hideAiContextWarning() {
6701
6880
  $("#ai-context-warning").classList.add("hidden");
6702
6881
  }
6703
6882
 
6704
- async function prepareAiConversationContext({ instruction, scope, modelId, citations }) {
6705
- const conversationId = await ensureAiConversation();
6706
- const prepared = await api(`/api/ai-conversations/${conversationId}/context/prepare`, {
6707
- method: "POST",
6708
- body: { instruction, scope, modelId, citations }
6709
- });
6710
- setAiContextMeter(prepared.usage);
6711
- if (prepared.action === "warn") {
6712
- showAiContextWarning(prepared.usage);
6713
- return false;
6714
- }
6715
- hideAiContextWarning();
6716
- if (prepared.action === "compacted") toast("已自动整理较早对话为长期记忆并继续发送");
6717
- return true;
6718
- }
6719
-
6720
- function scheduleAiContextUsage() {
6721
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
6722
- aiContextUsageTimer = setTimeout(() => {
6723
- aiContextUsageTimer = null;
6724
- void refreshAiContextUsage();
6725
- }, 260);
6726
- }
6727
-
6728
- async function refreshAiContextUsage() {
6729
- const requestScope = currentAiRequestScope();
6730
- const modelId = $("#ai-model").value;
6731
- if (!requestScope || !modelId || (requestScope.taskType === "polish" && !requestScope.selection)) {
6732
- setAiContextMeter(null);
6733
- return;
6734
- }
6735
- const requestId = ++aiContextUsageRequest;
6736
- try {
6737
- const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
6738
- const usage = await api(`/api/works/${state.work.id}/ai-context-usage`, {
6739
- method: "POST",
6740
- body: {
6741
- modelId,
6742
- taskType: requestScope.taskType,
6743
- scope: requestScope.scope,
6744
- instruction: aiPromptText(),
6745
- citations,
6746
- conversationId: state.aiConversationId || undefined
6747
- }
6748
- });
6749
- if (requestId === aiContextUsageRequest) setAiContextMeter(usage);
6750
- } catch {
6751
- if (requestId === aiContextUsageRequest) setAiContextMeter(null);
6752
- }
6753
- }
6754
-
6755
6883
  async function loadAiReferences() {
6756
6884
  const workId = state.work?.id;
6757
6885
  if (!workId) return;
@@ -9026,16 +9154,30 @@ function openProviderDialog(item) {
9026
9154
 
9027
9155
  function openModelDialog(providerId, item = null) {
9028
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>`;
9029
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>`;
9030
- openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + field("contextWindow", "模型上下文令牌总量", "number", values.contextWindow) + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled), async (form) => {
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) => {
9031
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);
9032
9167
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
9033
9168
  await renderPlatformAiConfig();
9034
9169
  await loadModels();
9035
9170
  }, item ? "模型配置" : "供应商模型");
9036
9171
  const modelIdInput = $("#dialog-fields input[name='modelId']");
9172
+ const contextWindowInput = $("#model-context-window");
9173
+ const contextWindowHint = $("#model-context-window-hint");
9037
9174
  const temperatureInput = $("#dialog-fields input[name='temperature']");
9038
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
+ };
9039
9181
  const syncKimiTemperature = () => {
9040
9182
  const isKimi = isKimiModelId(modelIdInput.value);
9041
9183
  temperatureHint.hidden = !isKimi;
@@ -9044,13 +9186,31 @@ function openModelDialog(providerId, item = null) {
9044
9186
  if (isKimiModelId(modelIdInput.value)) temperatureInput.value = "1";
9045
9187
  syncKimiTemperature();
9046
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();
9047
9207
  syncKimiTemperature();
9048
9208
  }
9049
9209
 
9050
9210
  async function sendAi() {
9051
9211
  if (!state.work) return toast("请先选择作品", "error");
9052
9212
  try {
9053
- await Promise.all([ensureAiModelsLoaded(), ensureAiConversationsLoaded()]);
9213
+ await ensureAiModelsLoaded();
9054
9214
  } catch (error) {
9055
9215
  return toast(`创作助手加载失败:${error.message}`, "error");
9056
9216
  }
@@ -9063,30 +9223,18 @@ async function sendAi() {
9063
9223
  const { taskType, scope, selection } = requestScope;
9064
9224
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
9065
9225
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9066
- if (taskType === "chat") {
9067
- $("#ai-send").disabled = true;
9068
- $("#ai-send").textContent = "检查上下文";
9226
+ let persistedUserMessage = null;
9227
+ if (taskType !== "chat") {
9069
9228
  try {
9070
- const mayContinue = await prepareAiConversationContext({ instruction, scope, modelId, citations });
9071
- if (!mayContinue) return;
9229
+ persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9072
9230
  } catch (error) {
9073
- toast(`上下文检查失败:${error.message}`, "error");
9074
- return;
9075
- } finally {
9076
- $("#ai-send").disabled = false;
9077
- $("#ai-send").textContent = "发送";
9231
+ return toast(`对话记录创建失败:${error.message}`, "error");
9078
9232
  }
9233
+ state.aiPromptSent = true;
9234
+ renderAiQuickActions();
9235
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9236
+ clearAiPromptComposer();
9079
9237
  }
9080
- let persistedUserMessage;
9081
- try {
9082
- persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
9083
- } catch (error) {
9084
- return toast(`对话记录创建失败:${error.message}`, "error");
9085
- }
9086
- state.aiPromptSent = true;
9087
- renderAiQuickActions();
9088
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9089
- clearAiPromptComposer();
9090
9238
  $("#ai-send").disabled = true;
9091
9239
  $("#ai-send").textContent = "发送中";
9092
9240
  try {
@@ -9096,7 +9244,8 @@ async function sendAi() {
9096
9244
  let persistedStreamMessage = null;
9097
9245
  let suggestion = null;
9098
9246
  if (taskType === "chat") {
9099
- 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;
9100
9249
  assistantContent = streamed.content;
9101
9250
  assistantMessage = streamed.message;
9102
9251
  assistantMetadata = streamed.metadata;
@@ -9104,11 +9253,18 @@ async function sendAi() {
9104
9253
  applyAiConversationTitle(streamed.conversationTitle);
9105
9254
  } else {
9106
9255
  suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
9256
+ setAiContextMeter(suggestion.contextUsage);
9107
9257
  assistantContent = suggestion.content;
9108
9258
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
9109
9259
  }
9110
9260
  try {
9111
9261
  if (persistedStreamMessage) {
9262
+ updateAiConversationSummaryFromMessage({
9263
+ conversationId: state.aiConversationId,
9264
+ role: "assistant",
9265
+ content: assistantContent,
9266
+ createdAt: persistedStreamMessage.createdAt
9267
+ });
9112
9268
  updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
9113
9269
  attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
9114
9270
  } else {
@@ -9138,13 +9294,19 @@ async function streamChat(body) {
9138
9294
  message.className = "assistant-message is-streaming";
9139
9295
  message.dataset.testid = "ai-stream-message";
9140
9296
  message.innerHTML = '<div class="message-body" data-testid="ai-stream-content" aria-live="polite" aria-busy="true"></div><div class="message-meta">正在连接模型流……</div>';
9141
- attachMessageHeading(message, "助手 · 正在生成");
9142
- $("#ai-feed").append(message);
9143
- scrollAiFeedToBottom();
9144
9297
  const content = message.querySelector(".message-body");
9145
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
+ };
9146
9307
  const typewriter = createStreamTypewriter({
9147
9308
  onRender: (text, progress) => {
9309
+ mountAssistantMessage();
9148
9310
  content.innerHTML = renderMarkdown(text);
9149
9311
  const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
9150
9312
  meta.textContent = progress.pendingCharacters > 0
@@ -9160,6 +9322,8 @@ async function streamChat(body) {
9160
9322
  let persistedMessageId = null;
9161
9323
  let persistedMessageCreatedAt = null;
9162
9324
  let conversationTitle = null;
9325
+ let persistedUserMessage = null;
9326
+ let contextAction = "ready";
9163
9327
  let finalAnswerStarted = false;
9164
9328
  const processStartedAt = Date.now();
9165
9329
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
@@ -9186,7 +9350,35 @@ async function streamChat(body) {
9186
9350
  }
9187
9351
  if (!dataLines.length) return;
9188
9352
  const payload = JSON.parse(dataLines.join("\n"));
9189
- 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();
9190
9382
  const firstFinalDelta = streamedText.length === 0;
9191
9383
  const delta = typeof payload.delta === "string" ? payload.delta : "";
9192
9384
  streamedText += delta;
@@ -9195,6 +9387,7 @@ async function streamChat(body) {
9195
9387
  if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
9196
9388
  meta.textContent = "正在生成回复……";
9197
9389
  } else if (eventName === "process_step") {
9390
+ mountAssistantMessage();
9198
9391
  const step = { ...payload };
9199
9392
  const append = step.append === true;
9200
9393
  delete step.append;
@@ -9202,9 +9395,14 @@ async function streamChat(body) {
9202
9395
  if (existing && typeof step.content === "string") existing.content += step.content;
9203
9396
  else processSteps.push(step);
9204
9397
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9205
- 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} 轮中间结果`;
9206
9403
  scrollAiFeedToBottom();
9207
9404
  } else if (eventName === "tool_call") {
9405
+ mountAssistantMessage();
9208
9406
  const toolCall = { ...payload };
9209
9407
  const round = toolCall.round;
9210
9408
  delete toolCall.round;
@@ -9213,10 +9411,15 @@ async function streamChat(body) {
9213
9411
  renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
9214
9412
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
9215
9413
  scrollAiFeedToBottom();
9414
+ } else if (eventName === "context_compacted") {
9415
+ setAiContextMeter(payload.contextUsage);
9416
+ if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
9216
9417
  } else if (eventName === "complete") {
9418
+ mountAssistantMessage();
9217
9419
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
9218
9420
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
9219
9421
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
9422
+ setAiContextMeter(payload.contextUsage);
9220
9423
  await typewriter.finish();
9221
9424
  message.classList.remove("is-streaming");
9222
9425
  content.setAttribute("aria-busy", "false");
@@ -9244,8 +9447,9 @@ async function streamChat(body) {
9244
9447
  if (buffer.trim()) await consume(buffer);
9245
9448
  await typewriter.finish();
9246
9449
  if (streamError) throw streamError;
9247
- return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle };
9450
+ return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
9248
9451
  } catch (error) {
9452
+ mountAssistantMessage();
9249
9453
  typewriter.reveal();
9250
9454
  message.classList.remove("is-streaming");
9251
9455
  content.setAttribute("aria-busy", "false");
@@ -9265,7 +9469,15 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
9265
9469
  ? `<p class="ai-error-text">${esc(text)}</p>`
9266
9470
  : renderMarkdown(text);
9267
9471
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
9268
- attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
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
+ }
9269
9481
  if (citations.length) {
9270
9482
  const references = document.createElement("div");
9271
9483
  references.className = "message-citations";
@@ -10238,6 +10450,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
10238
10450
  timeline: Number($("#page-size-timeline").value),
10239
10451
  outlines: Number($("#page-size-outlines").value),
10240
10452
  relationships: Number($("#page-size-relationships").value),
10453
+ comments: Number($("#page-size-comments").value),
10241
10454
  reviews: Number($("#page-size-reviews").value),
10242
10455
  analysisTasks: Number($("#page-size-analysis-tasks").value),
10243
10456
  fileVersions: Number($("#page-size-file-versions").value)
@@ -10383,10 +10596,14 @@ $("#chapter-content").addEventListener("input", (event) => {
10383
10596
  scheduleChapterAutoSave();
10384
10597
  clearChapterLineSelection();
10385
10598
  scheduleChapterLineNumbers();
10386
- scheduleAiContextUsage();
10599
+ setAiContextMeter(null);
10387
10600
  });
10388
- $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
10601
+ $("#chapter-content").addEventListener("select", () => setAiContextMeter(null));
10389
10602
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
10603
+ $("#chapter-content").addEventListener("contextmenu", (event) => {
10604
+ if (!state.chapter) return;
10605
+ showLineCitationMenu(event, lineIndexAtPointer(event.clientY));
10606
+ });
10390
10607
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
10391
10608
  const row = event.target.closest(".chapter-line-number");
10392
10609
  if (!row || event.button !== 0) return;
@@ -10466,7 +10683,7 @@ $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!m
10466
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]?.());
10467
10684
  $("#ai-prompt").addEventListener("input", async () => {
10468
10685
  updateAiMentionMenu();
10469
- scheduleAiContextUsage();
10686
+ setAiContextMeter(null);
10470
10687
  if (!findAiMention(aiPromptTextBeforeCursor())) return;
10471
10688
  try {
10472
10689
  await ensureAiReferencesLoaded();
@@ -10481,9 +10698,9 @@ $("#ai-prompt").addEventListener("focus", () => {
10481
10698
  $("#ai-model").addEventListener("focus", () => {
10482
10699
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
10483
10700
  });
10484
- $("#ai-model").addEventListener("change", scheduleAiContextUsage);
10485
- $("#ai-task").addEventListener("change", scheduleAiContextUsage);
10486
- $("#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));
10487
10704
  $("#ai-mention-menu").addEventListener("click", (event) => {
10488
10705
  const button = event.target.closest("[data-ai-reference-id]");
10489
10706
  if (button) selectAiMention(button);
@@ -10686,7 +10903,15 @@ $("#ai-context-compact").addEventListener("click", async () => {
10686
10903
  });
10687
10904
  hideAiContextWarning();
10688
10905
  toast(result.changed ? `已整理 ${result.compactedMessageCount} 条较早消息为长期记忆` : "当前没有需要整理的较早消息");
10689
- 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
+ }
10690
10915
  } catch (error) {
10691
10916
  toast(`上下文压缩失败:${error.message}`, "error");
10692
10917
  } finally {
@@ -10712,6 +10937,30 @@ $("#ai-history-toggle").addEventListener("click", async () => {
10712
10937
  toast(`对话历史加载失败:${error.message}`, "error");
10713
10938
  }
10714
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
+ });
10715
10964
  $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
10716
10965
  $("#ai-history-dialog").addEventListener("close", () => {
10717
10966
  $("#ai-history-toggle").setAttribute("aria-expanded", "false");