@musnows/scriverse 0.9.5 → 0.9.7

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.
@@ -2,6 +2,7 @@ import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate,
2
2
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
+ import { applyAiSkillCommand, findAiSkillCommand, listAiSkillOptions } from "/ai-skill-menu.js?v=20260830-ai-skill-slash-menu-v1";
5
6
  import {
6
7
  emptyRoleplayScenePin,
7
8
  normalizeRoleplayScenePin,
@@ -25,13 +26,13 @@ import {
25
26
  visibleForeshadowReminders
26
27
  } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
27
28
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
28
- import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1";
29
+ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1&feature=semantic-search-v6";
29
30
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
30
31
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
31
32
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
32
33
  import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
33
34
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
34
- import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount } from "/ai-usage.js?v=20260821-ai-usage-pricing-v1";
35
+ import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount, usageCalendarYears } from "/ai-usage.js?v=20260830-ai-usage-year-v1";
35
36
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
36
37
  import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260828-context-output-usage-v1";
37
38
  import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
@@ -52,7 +53,7 @@ import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-p
52
53
  import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
53
54
  import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatImageFile, normalizeAiChatImageAttachments } from "/ai-image-attachments.js?v=20260820-ai-chat-image-attachments-v2";
54
55
  import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
55
- import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft } from "/chapter-line-id-tracker.js?v=20260828-stable-line-ids-v1";
56
+ import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-v1";
56
57
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
57
58
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
58
59
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
@@ -180,12 +181,16 @@ function normalizePageSizes(value) {
180
181
  ]));
181
182
  }
182
183
 
183
- function isSelectableModel(model) {
184
+ function isAvailableConfiguredModel(model) {
184
185
  return Boolean(model?.enabled)
185
186
  && model?.providerStatus === "enabled"
186
187
  && model?.providerConnectionStatus === "success";
187
188
  }
188
189
 
190
+ function isSelectableModel(model) {
191
+ return (model?.modelKind ?? "chat") === "chat" && isAvailableConfiguredModel(model);
192
+ }
193
+
189
194
  const state = {
190
195
  user: null,
191
196
  csrfToken: null,
@@ -201,6 +206,7 @@ const state = {
201
206
  aiCitations: [],
202
207
  aiReferences: [],
203
208
  aiImageAttachments: [],
209
+ aiSemanticSnapshot: null,
204
210
  aiPromptSent: false,
205
211
  aiTaskType: "chat",
206
212
  aiContextScope: { type: "none" },
@@ -296,6 +302,9 @@ let taskProgressRefreshTimer = null;
296
302
  let taskAutoRunEditing = false;
297
303
  let taskAutoRunEditingWorkId = null;
298
304
  let relationshipSearchIndexRefreshTimer = null;
305
+ let semanticSearchIndexRefreshTimer = null;
306
+ let aiSemanticSearchResults = [];
307
+ let aiSemanticSearchQuery = "";
299
308
  let backgroundTaskCenterTimer = null;
300
309
  let backgroundTaskCenterRequest = 0;
301
310
  let backgroundTaskCenterWorkId = null;
@@ -841,7 +850,7 @@ const workspaceOnboardingSteps = [
841
850
  { selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲/伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
842
851
  { selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
843
852
  { selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
844
- { selector: ".quick-actions button[data-task=\"continue\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
853
+ { selector: ".quick-actions button[data-prompt^=\"续写\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
845
854
  { selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
846
855
  { selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和正文 ZIP 导出都集中在这里。", placement: "bottom" },
847
856
  { selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
@@ -1350,6 +1359,7 @@ function replaceCurrentChapterSearchMatch() {
1350
1359
  const input = $("#chapter-content");
1351
1360
  const replacement = $("#chapter-replace-query").value;
1352
1361
  input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
1362
+ syncChapterDraftLineIds(input.value);
1353
1363
  chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
1354
1364
  updateChapterStats();
1355
1365
  clearChapterLineSelection();
@@ -1369,6 +1379,7 @@ function replaceAllChapterSearchMatches() {
1369
1379
  const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
1370
1380
  if (!result.matches) return renderChapterSearchStatus();
1371
1381
  input.value = result.content;
1382
+ syncChapterDraftLineIds(input.value);
1372
1383
  chapterSearchMatchIndex = -1;
1373
1384
  updateChapterStats();
1374
1385
  clearChapterLineSelection();
@@ -1516,6 +1527,7 @@ let moduleNavExpanded = false;
1516
1527
  const chapterAutoSaveDelay = 800;
1517
1528
  const chapterLineInputRenderDelay = 32;
1518
1529
  let aiMentionMatch = null;
1530
+ let aiSkillMatch = null;
1519
1531
  let aiMentionRange = null;
1520
1532
  let aiMentionActiveIndex = -1;
1521
1533
  let settingsReturnContext = null;
@@ -1528,6 +1540,11 @@ let taskListPage = 1;
1528
1540
  let draftTypeFilter = "all";
1529
1541
  let draftBindingFilters = [];
1530
1542
  let draftFiltersPanelOpen = false;
1543
+ const chapterCommentFilters = { chapterId: "", keyword: "" };
1544
+ let chapterCommentFiltersPanelOpen = false;
1545
+ let chapterCommentChapterOptions = [];
1546
+ let chapterCommentSearchTimer = null;
1547
+ let chapterCommentRenderRequestId = 0;
1531
1548
  const moduleListPages = {
1532
1549
  drafts: 1,
1533
1550
  settings: 1,
@@ -2089,6 +2106,148 @@ function renderAiCitations() {
2089
2106
  setAiContextMeter(null);
2090
2107
  }
2091
2108
 
2109
+ function setAiSemanticSearchVisible(visible) {
2110
+ const panel = $("#ai-semantic-search-panel");
2111
+ panel.classList.toggle("hidden", !visible);
2112
+ $("#ai-semantic-search-toggle").setAttribute("aria-expanded", String(visible));
2113
+ if (visible) $("#ai-semantic-query").focus();
2114
+ }
2115
+
2116
+ function semanticSearchScopeTypes(value) {
2117
+ return ({
2118
+ chapter: ["chapter"],
2119
+ setting: ["setting"],
2120
+ character: ["character"],
2121
+ race: ["race"],
2122
+ organization: ["organization"],
2123
+ timeline: ["timeline-track", "timeline-event"],
2124
+ relationship: ["relationship"],
2125
+ outlines: ["chapter-outline", "foreshadow"]
2126
+ })[value] ?? undefined;
2127
+ }
2128
+
2129
+ function syncAiSemanticSelectionSummary() {
2130
+ const selected = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')];
2131
+ const tokenCount = selected.reduce((total, input) => total + Number(input.dataset.estimatedTokens || 0), 0);
2132
+ $("#ai-semantic-selection-summary").textContent = selected.length
2133
+ ? `已选择 ${selected.length} 项,预计 ${tokenCount.toLocaleString("zh-CN")} Token`
2134
+ : "尚未选择结果";
2135
+ $("#ai-semantic-inject").disabled = selected.length === 0 || !state.work || !canWritePermissionModule(state.work, "ai-chat");
2136
+ }
2137
+
2138
+ function renderAiSemanticSearchResults(search) {
2139
+ aiSemanticSearchResults = Array.isArray(search?.results) ? search.results : [];
2140
+ const host = $("#ai-semantic-search-results");
2141
+ const matchLabels = { metadata: "资料", exact: "精确", phonetic: "拼音", semantic: "语义" };
2142
+ host.innerHTML = aiSemanticSearchResults.length
2143
+ ? aiSemanticSearchResults.map((item, index) => {
2144
+ const lineRange = Number.isInteger(item.startLine)
2145
+ ? `<span>${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
2146
+ : "";
2147
+ const matchKinds = (Array.isArray(item.matchKinds) ? item.matchKinds : [])
2148
+ .map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchLabels[kind] ?? kind)}</span>`).join("");
2149
+ const relevance = typeof item.semanticScore === "number" ? `<span>相似度 ${Math.round(item.semanticScore * 1000) / 10}%</span>` : "";
2150
+ const rerank = typeof item.rerankScore === "number" ? `<span>Rerank ${item.rerankScore > 0 ? "相关" : "不相关"}</span>` : "";
2151
+ return `<article class="ai-semantic-result" data-semantic-result-index="${index}"><label>${item.entryId ? `<input type="checkbox" data-semantic-entry-id="${esc(item.entryId)}" data-estimated-tokens="${esc(String(item.estimatedTokens ?? 0))}">` : '<span class="ai-semantic-view-only">仅查看</span>'}<span class="ai-semantic-result-copy"><strong>${esc(searchResultTypeLabel(item.type))} · ${esc(item.title)}</strong><small>${lineRange}${matchKinds}${relevance}${rerank}<span>预计 ${esc(String(item.estimatedTokens ?? 0))} Token</span></small><span>${esc(item.snippet || "无原文片段")}</span></span></label><button type="button" data-semantic-locate="${index}">定位来源</button></article>`;
2152
+ }).join("")
2153
+ : '<p class="ai-semantic-empty">没有找到可展示的结果。</p>';
2154
+ host.querySelectorAll('input[data-semantic-entry-id]').forEach((input) => input.addEventListener("change", syncAiSemanticSelectionSummary));
2155
+ host.querySelectorAll("[data-semantic-locate]").forEach((button) => button.addEventListener("click", async () => {
2156
+ const result = aiSemanticSearchResults[Number(button.dataset.semanticLocate)];
2157
+ if (!result) return;
2158
+ try {
2159
+ setAiSemanticSearchVisible(false);
2160
+ await openSearchResult(result);
2161
+ } catch (error) {
2162
+ toast(error.message, "error");
2163
+ }
2164
+ }));
2165
+ syncAiSemanticSelectionSummary();
2166
+ }
2167
+
2168
+ function renderAiSemanticInjection() {
2169
+ const host = $("#ai-semantic-injection");
2170
+ const snapshot = state.aiSemanticSnapshot;
2171
+ host.classList.toggle("hidden", !snapshot);
2172
+ if (!snapshot) {
2173
+ host.replaceChildren();
2174
+ setAiContextMeter(null);
2175
+ return;
2176
+ }
2177
+ host.innerHTML = `<div><strong>本轮语义快照</strong><span>${esc(String(snapshot.itemCount ?? snapshot.items?.length ?? 0))} 项 · 预计 ${Number(snapshot.estimatedTokens ?? 0).toLocaleString("zh-CN")} Token</span><small>${esc(snapshot.query ?? "")}</small></div><button type="button" aria-label="移除本轮语义快照">×</button>`;
2178
+ host.querySelector("button")?.addEventListener("click", () => {
2179
+ state.aiSemanticSnapshot = null;
2180
+ renderAiSemanticInjection();
2181
+ persistActiveAiChatTab();
2182
+ });
2183
+ setAiContextMeter(null);
2184
+ }
2185
+
2186
+ async function runAiSemanticSearch() {
2187
+ if (!state.work) return toast("请先选择作品", "error");
2188
+ const query = $("#ai-semantic-query").value.trim();
2189
+ if (!query) return toast("请输入自然语言检索问题", "error");
2190
+ const button = $("#ai-semantic-search-run");
2191
+ button.disabled = true;
2192
+ $("#ai-semantic-search-status").textContent = "正在执行主动语义检索……";
2193
+ try {
2194
+ const selection = state.chapter
2195
+ ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd).slice(0, 4_000)
2196
+ : "";
2197
+ aiSemanticSearchQuery = query;
2198
+ const search = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search`, {
2199
+ method: "POST",
2200
+ body: {
2201
+ query,
2202
+ types: semanticSearchScopeTypes($("#ai-semantic-scope").value),
2203
+ currentChapterId: state.chapter?.id,
2204
+ selection: selection || undefined,
2205
+ includeKeyword: true
2206
+ }
2207
+ });
2208
+ renderAiSemanticSearchResults(search);
2209
+ $("#ai-semantic-search-status").textContent = search.semanticUsed
2210
+ ? `${search.results.length} 项融合结果${search.degraded ? `;${search.reason}` : ";已标注 semantic 通道"}`
2211
+ : `${search.results.length} 项关键词降级结果;${search.reason}`;
2212
+ } catch (error) {
2213
+ aiSemanticSearchResults = [];
2214
+ renderAiSemanticSearchResults({ results: [] });
2215
+ $("#ai-semantic-search-status").textContent = error.message;
2216
+ toast(error.message, "error");
2217
+ } finally {
2218
+ button.disabled = false;
2219
+ }
2220
+ }
2221
+
2222
+ async function injectAiSemanticSelection() {
2223
+ if (!state.work) return;
2224
+ const entryIds = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')]
2225
+ .map((input) => input.dataset.semanticEntryId)
2226
+ .filter(Boolean);
2227
+ if (!entryIds.length) return;
2228
+ const button = $("#ai-semantic-inject");
2229
+ button.disabled = true;
2230
+ try {
2231
+ const snapshot = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search/snapshots`, {
2232
+ method: "POST",
2233
+ body: {
2234
+ query: aiSemanticSearchQuery,
2235
+ entryIds,
2236
+ scope: { types: semanticSearchScopeTypes($("#ai-semantic-scope").value) ?? [] },
2237
+ conversationId: state.aiConversationId || undefined
2238
+ }
2239
+ });
2240
+ state.aiSemanticSnapshot = snapshot;
2241
+ renderAiSemanticInjection();
2242
+ persistActiveAiChatTab();
2243
+ setAiSemanticSearchVisible(false);
2244
+ toast(`已将 ${snapshot.itemCount} 项语义原文加入本轮上下文`);
2245
+ } catch (error) {
2246
+ toast(error.message, "error");
2247
+ button.disabled = false;
2248
+ }
2249
+ }
2250
+
2092
2251
  function aiReferenceKey(reference) {
2093
2252
  return `${reference.kind}:${reference.id}`;
2094
2253
  }
@@ -2170,7 +2329,7 @@ function createAiChatTabState(input = {}) {
2170
2329
  roleplayUserCharacter: input.roleplayUserCharacter ?? null,
2171
2330
  citations: input.citations ?? [],
2172
2331
  references: input.references ?? [],
2173
- composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2332
+ composer: input.composer ?? { text: "", citations: [], references: [], images: [], semanticSnapshot: null, sceneDirection: "", scenePin: emptyRoleplayScenePin() },
2174
2333
  contextUsage: input.contextUsage ?? null,
2175
2334
  contextWarning: input.contextWarning === true,
2176
2335
  lastMessageAt: input.lastMessageAt ?? null,
@@ -2213,6 +2372,7 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
2213
2372
  citations: tab.citations.map((citation) => ({ ...citation })),
2214
2373
  references: tab.references.map((reference) => ({ ...reference })),
2215
2374
  images: normalizeAiChatImageAttachments(snapshot.images),
2375
+ semanticSnapshot: snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null,
2216
2376
  sceneDirection: String(snapshot.sceneDirection ?? ""),
2217
2377
  scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
2218
2378
  };
@@ -2224,6 +2384,7 @@ function clearAiChatTabComposer(tab) {
2224
2384
  citations: [],
2225
2385
  references: [],
2226
2386
  images: [],
2387
+ semanticSnapshot: null,
2227
2388
  sceneDirection: "",
2228
2389
  scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
2229
2390
  });
@@ -2237,6 +2398,7 @@ function applyAiChatTabState(tab) {
2237
2398
  state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
2238
2399
  state.aiReferences = tab.references.map((reference) => ({ ...reference }));
2239
2400
  state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
2401
+ state.aiSemanticSnapshot = tab.composer?.semanticSnapshot ? structuredClone(tab.composer.semanticSnapshot) : null;
2240
2402
  state.aiLastMessageAt = tab.lastMessageAt;
2241
2403
  $("#ai-conversation-title").textContent = tab.title || "新对话";
2242
2404
  applyAiConversationTaskType(tab.taskType);
@@ -2248,6 +2410,7 @@ function applyAiChatTabState(tab) {
2248
2410
  setAiPromptText(tab.composer.text);
2249
2411
  restoreAiSceneComposer(tab.composer);
2250
2412
  renderAiCitations();
2413
+ renderAiSemanticInjection();
2251
2414
  renderAiReferences();
2252
2415
  renderAiImageAttachments();
2253
2416
  latestAiContextUsage = null;
@@ -2544,7 +2707,7 @@ function resetAiFeed(
2544
2707
  const roleplayUserName = roleplayUserCharacter?.name;
2545
2708
  feed.innerHTML = roleplayName
2546
2709
  ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系、知情设定和故事正文回答。"}</p></div></div>`
2547
- : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
2710
+ : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后即可开始问答;所有引用都基于已保存正文。</p></div></div>';
2548
2711
  }
2549
2712
 
2550
2713
  function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
@@ -3594,9 +3757,7 @@ async function deleteAiConversation(conversation) {
3594
3757
 
3595
3758
  const aiConversationTaskTypeLabels = {
3596
3759
  chat: "问答",
3597
- roleplay: "角色扮演",
3598
- continue: "续写",
3599
- polish: "润色选中文本"
3760
+ roleplay: "角色扮演"
3600
3761
  };
3601
3762
 
3602
3763
  function aiConversationTaskTypeLabel(taskType) {
@@ -4392,7 +4553,7 @@ function syncAiTaskOptions() {
4392
4553
  }
4393
4554
 
4394
4555
  function applyAiConversationTaskType(taskType) {
4395
- const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
4556
+ const normalizedTaskType = taskType === "roleplay" ? "roleplay" : "chat";
4396
4557
  state.aiTaskType = normalizedTaskType;
4397
4558
  const tab = activeAiChatTab();
4398
4559
  if (tab) tab.taskType = normalizedTaskType;
@@ -4559,7 +4720,7 @@ async function persistAiRequestInterruption(request, interruption = null) {
4559
4720
  const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
4560
4721
  const metadata = cancelledByClient
4561
4722
  ? {
4562
- ...(partialContent ? interruption.metadata : {}),
4723
+ ...(interruption?.metadata ?? {}),
4563
4724
  interrupted: true,
4564
4725
  interruptionCode: "AI_REQUEST_CANCELLED",
4565
4726
  interruptionMessage: cancellationMessage.slice(0, 500)
@@ -4732,10 +4893,12 @@ function clearAiPromptComposer() {
4732
4893
  state.aiCitations = [];
4733
4894
  state.aiReferences = [];
4734
4895
  state.aiImageAttachments = [];
4896
+ state.aiSemanticSnapshot = null;
4735
4897
  setAiPromptText("");
4736
4898
  setAiSceneDirection("");
4737
4899
  renderAiCitations();
4738
4900
  renderAiImageAttachments();
4901
+ renderAiSemanticInjection();
4739
4902
  hideAiMentionMenu();
4740
4903
  syncAiSceneComposer();
4741
4904
  }
@@ -4746,6 +4909,7 @@ function captureAiPromptComposer() {
4746
4909
  citations: state.aiCitations.map((citation) => ({ ...citation })),
4747
4910
  references: state.aiReferences.map((reference) => ({ ...reference })),
4748
4911
  images: normalizeAiChatImageAttachments(state.aiImageAttachments),
4912
+ semanticSnapshot: state.aiSemanticSnapshot ? structuredClone(state.aiSemanticSnapshot) : null,
4749
4913
  sceneDirection: aiSceneDirectionText(),
4750
4914
  scenePin: captureAiScenePin()
4751
4915
  };
@@ -4755,10 +4919,12 @@ function restoreAiPromptComposer(snapshot) {
4755
4919
  state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
4756
4920
  state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
4757
4921
  state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
4922
+ state.aiSemanticSnapshot = snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null;
4758
4923
  setAiPromptText(snapshot.text);
4759
4924
  restoreAiSceneComposer(snapshot);
4760
4925
  renderAiCitations();
4761
4926
  renderAiImageAttachments();
4927
+ renderAiSemanticInjection();
4762
4928
  hideAiMentionMenu();
4763
4929
  }
4764
4930
 
@@ -4866,6 +5032,7 @@ function aiPromptTextBoundary(prompt, offset) {
4866
5032
 
4867
5033
  function hideAiMentionMenu() {
4868
5034
  aiMentionMatch = null;
5035
+ aiSkillMatch = null;
4869
5036
  aiMentionRange = null;
4870
5037
  aiMentionActiveIndex = -1;
4871
5038
  const prompt = $("#ai-prompt");
@@ -4922,21 +5089,37 @@ function syncAiReferencesWithPrompt() {
4922
5089
  function updateAiMentionMenu() {
4923
5090
  syncAiReferencesWithPrompt();
4924
5091
  const prompt = $("#ai-prompt");
4925
- const match = findAiMention(aiPromptTextBeforeCursor());
4926
- if (!match) return hideAiMentionMenu();
5092
+ const textBeforeCursor = aiPromptTextBeforeCursor();
5093
+ const skillMatch = $("#ai-task").value === "roleplay" ? null : findAiSkillCommand(textBeforeCursor);
5094
+ const mentionMatch = skillMatch ? null : findAiMention(textBeforeCursor);
5095
+ if (!skillMatch && !mentionMatch) return hideAiMentionMenu();
4927
5096
  const selection = window.getSelection();
4928
5097
  if (!selection?.rangeCount || !prompt.contains(selection.anchorNode)) return hideAiMentionMenu();
4929
- aiMentionMatch = match;
5098
+ aiSkillMatch = skillMatch;
5099
+ aiMentionMatch = mentionMatch;
4930
5100
  aiMentionRange = selection.getRangeAt(0).cloneRange();
4931
5101
  const menu = $("#ai-mention-menu");
5102
+ if (skillMatch) {
5103
+ const options = listAiSkillOptions(skillMatch.query);
5104
+ aiMentionActiveIndex = -1;
5105
+ prompt.removeAttribute("aria-activedescendant");
5106
+ menu.setAttribute("aria-label", "选择写作 Skill");
5107
+ menu.innerHTML = options.length
5108
+ ? options.map((item, index) => `<button id="ai-skill-option-${index}" class="ai-mention-option ai-skill-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-skill-name="${esc(item.name)}"><small>Skill</small><span><strong>/${esc(item.name)}</strong><em>${esc(item.label)} · ${esc(item.description)}</em></span></button>`).join("")
5109
+ : '<p class="ai-mention-empty">没有匹配的写作 Skill</p>';
5110
+ menu.classList.remove("hidden");
5111
+ prompt.setAttribute("aria-expanded", "true");
5112
+ return;
5113
+ }
4932
5114
  const chapters = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
4933
5115
  ...chapter,
4934
5116
  volumeTitle: volume.title
4935
5117
  }))) ?? [];
4936
- const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query)
5118
+ const options = listAiMentionOptions(state.characters, state.settings, chapters, mentionMatch.query)
4937
5119
  .filter((item) => item.kind !== "context-settings" || $("#ai-task").value !== "roleplay");
4938
5120
  aiMentionActiveIndex = -1;
4939
5121
  prompt.removeAttribute("aria-activedescendant");
5122
+ menu.setAttribute("aria-label", "引用角色、设定、章节或上下文能力");
4940
5123
  menu.innerHTML = options.length
4941
5124
  ? options.map((item, index) => `<button id="ai-mention-option-${index}" class="ai-mention-option" type="button" role="option" aria-selected="false" tabindex="-1" data-ai-reference-kind="${esc(item.kind)}" data-ai-reference-id="${esc(item.id)}" data-ai-reference-name="${esc(item.name)}"><small>${esc(item.kindLabel)}</small><strong>${esc(item.name)}</strong></button>`).join("")
4942
5125
  : '<p class="ai-mention-empty">没有匹配的角色、设定、章节或上下文能力</p>';
@@ -4977,6 +5160,30 @@ function selectAiMention(button) {
4977
5160
  hideAiMentionMenu();
4978
5161
  }
4979
5162
 
5163
+ function selectAiSkill(button) {
5164
+ if (!aiSkillMatch || !aiMentionRange) return;
5165
+ const prompt = $("#ai-prompt");
5166
+ const cursorText = aiPromptTextFromRange(aiMentionRange, prompt);
5167
+ const localSkill = findAiSkillCommand(cursorText);
5168
+ if (!localSkill) return hideAiMentionMenu();
5169
+ const applied = applyAiSkillCommand(cursorText, localSkill, button.dataset.aiSkillName);
5170
+ const range = document.createRange();
5171
+ const startBoundary = aiPromptTextBoundary(prompt, localSkill.start);
5172
+ const endBoundary = aiPromptTextBoundary(prompt, cursorText.length);
5173
+ range.setStart(startBoundary.node, startBoundary.offset);
5174
+ range.setEnd(endBoundary.node, endBoundary.offset);
5175
+ range.deleteContents();
5176
+ const command = document.createTextNode(`${applied.command} `);
5177
+ range.insertNode(command);
5178
+ const selection = window.getSelection();
5179
+ selection?.removeAllRanges();
5180
+ range.setStartAfter(command);
5181
+ range.collapse(true);
5182
+ selection?.addRange(range);
5183
+ prompt.focus();
5184
+ hideAiMentionMenu();
5185
+ }
5186
+
4980
5187
  function addSelectedLinesAsCitation() {
4981
5188
  if (!state.chapter || !chapterLineSelection) return;
4982
5189
  const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
@@ -5127,11 +5334,14 @@ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
5127
5334
  }
5128
5335
  const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
5129
5336
  if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
5130
- chapterAnnotationCounts = new Map(
5337
+ const savedLineIds = normalizeChapterLineIdDraft(state.chapter.content, state.chapter.lineIds);
5338
+ const draftLineIds = syncChapterDraftLineIds($("#chapter-content").value);
5339
+ const savedCounts = new Map(
5131
5340
  (Array.isArray(counts) ? counts : [])
5132
5341
  .map((item) => [Number(item.line), Number(item.count)])
5133
5342
  .filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
5134
5343
  );
5344
+ chapterAnnotationCounts = remapChapterLineCounts(savedLineIds, draftLineIds, savedCounts);
5135
5345
  scheduleChapterLineNumbers();
5136
5346
  }
5137
5347
 
@@ -6367,15 +6577,18 @@ function syncChapterDraftLineIds(content, hint = null) {
6367
6577
  resetChapterDraftLineIds(state.chapter);
6368
6578
  }
6369
6579
  if (chapterDraftLineIdState.content !== content) {
6580
+ const beforeLineIds = chapterDraftLineIdState.lineIds;
6581
+ const lineIds = reconcileChapterLineIdDraft(
6582
+ chapterDraftLineIdState.content,
6583
+ content,
6584
+ beforeLineIds,
6585
+ hint
6586
+ );
6587
+ chapterAnnotationCounts = remapChapterLineCounts(beforeLineIds, lineIds, chapterAnnotationCounts);
6370
6588
  chapterDraftLineIdState = {
6371
6589
  chapterId: state.chapter.id,
6372
6590
  content,
6373
- lineIds: reconcileChapterLineIdDraft(
6374
- chapterDraftLineIdState.content,
6375
- content,
6376
- chapterDraftLineIdState.lineIds,
6377
- hint
6378
- )
6591
+ lineIds
6379
6592
  };
6380
6593
  }
6381
6594
  return chapterDraftLineIdState.lineIds;
@@ -8152,6 +8365,13 @@ function resetWorkScopedUiCaches() {
8152
8365
  draftTypeFilter = "all";
8153
8366
  draftBindingFilters = [];
8154
8367
  draftFiltersPanelOpen = false;
8368
+ chapterCommentFilters.chapterId = "";
8369
+ chapterCommentFilters.keyword = "";
8370
+ chapterCommentFiltersPanelOpen = false;
8371
+ chapterCommentChapterOptions = [];
8372
+ clearTimeout(chapterCommentSearchTimer);
8373
+ chapterCommentSearchTimer = null;
8374
+ chapterCommentRenderRequestId += 1;
8155
8375
  settingFilters.keyword = "";
8156
8376
  settingFilters.category = "";
8157
8377
  settingFilters.lockState = "all";
@@ -8459,11 +8679,21 @@ function renderChapterBatchDialog() {
8459
8679
  function updateChapterBatchControls() {
8460
8680
  const count = chapterBatchSelectedIds.size;
8461
8681
  const action = $("#chapter-batch-action").value;
8682
+ const renumbering = action === "renumberTitles";
8683
+ const template = $("#chapter-batch-template").value.trim();
8684
+ const templateValid = template.split("{n}").length === 2;
8685
+ const startAt = Number($("#chapter-batch-start").value);
8686
+ const sequenceEnd = startAt + count - 1;
8462
8687
  $("#chapter-batch-count").textContent = `已选择 ${count} 章`;
8463
- $("#chapter-batch-apply").disabled = count === 0;
8688
+ $("#chapter-batch-apply").disabled = count === 0 || (renumbering && (!templateValid || !Number.isInteger(startAt) || startAt < 1 || sequenceEnd > 999999));
8464
8689
  $("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
8465
8690
  $("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
8466
- $("#chapter-batch-apply").textContent = action === "delete" ? "软删除所选章节" : "应用到所选章节";
8691
+ for (const id of ["chapter-batch-template-field", "chapter-batch-number-style-field", "chapter-batch-start-field", "chapter-batch-renumber-note"]) {
8692
+ $(`#${id}`).classList.toggle("hidden", !renumbering);
8693
+ }
8694
+ $("#chapter-batch-apply").textContent = action === "delete"
8695
+ ? "软删除所选章节"
8696
+ : renumbering ? "重排所选章节" : "应用到所选章节";
8467
8697
  }
8468
8698
 
8469
8699
  function openChapterBatchDialog() {
@@ -8483,16 +8713,41 @@ async function submitChapterBatch(event) {
8483
8713
  ? { type: "move", volumeId: $("#chapter-batch-volume").value }
8484
8714
  : actionValue === "setType"
8485
8715
  ? { type: "setType", chapterType: $("#chapter-batch-type").value }
8486
- : actionValue === "exclude" || actionValue === "include"
8487
- ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
8488
- : { type: "delete" };
8716
+ : actionValue === "renumberTitles"
8717
+ ? {
8718
+ type: "renumberTitles",
8719
+ template: $("#chapter-batch-template").value.trim(),
8720
+ numberStyle: $("#chapter-batch-number-style").value,
8721
+ startAt: Number($("#chapter-batch-start").value)
8722
+ }
8723
+ : actionValue === "exclude" || actionValue === "include"
8724
+ ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
8725
+ : { type: "delete" };
8489
8726
  const dialog = $("#chapter-batch-dialog");
8490
- if (action.type === "delete") {
8727
+ if (state.module === "editor" && state.chapter && state.dirty) {
8491
8728
  dialog.close();
8492
- const confirmed = await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
8493
- title: "批量删除需要再次确认",
8494
- confirmLabel: "确认软删除"
8495
- });
8729
+ const confirmedDiscard = await confirmDiscardChanges("当前章节有未保存修改,批量处理将丢弃这些修改。是否继续?");
8730
+ if (!confirmedDiscard) {
8731
+ dialog.showModal();
8732
+ return;
8733
+ }
8734
+ }
8735
+ if (action.type === "renumberTitles" && (action.template.split("{n}").length !== 2 || !Number.isInteger(action.startAt) || action.startAt < 1 || action.startAt + chapters.length - 1 > 999999)) {
8736
+ toast("标题格式必须且只能包含一个 {n},且所选章节的序号不能超过 999999", "error");
8737
+ $("#chapter-batch-template").focus();
8738
+ return;
8739
+ }
8740
+ if (action.type === "delete" || action.type === "renumberTitles") {
8741
+ dialog.close();
8742
+ const confirmed = action.type === "delete"
8743
+ ? await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
8744
+ title: "批量删除需要再次确认",
8745
+ confirmLabel: "确认软删除"
8746
+ })
8747
+ : await confirmToast(`将按目录顺序,把所选 ${chapters.length} 个章节从第 ${action.startAt} 个序号开始重排为“${action.template}”格式。每个改名章节都会保留版本,确认继续吗?`, {
8748
+ title: "重排标题需要再次确认",
8749
+ confirmLabel: "确认重排"
8750
+ });
8496
8751
  if (!confirmed) {
8497
8752
  dialog.showModal();
8498
8753
  return;
@@ -8500,21 +8755,26 @@ async function submitChapterBatch(event) {
8500
8755
  }
8501
8756
  $("#chapter-batch-apply").disabled = true;
8502
8757
  try {
8503
- await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
8758
+ const result = await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
8504
8759
  method: "POST",
8505
8760
  body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
8506
8761
  });
8507
8762
  const workId = state.work.id;
8508
8763
  state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
8764
+ const currentEditorVisible = state.module === "editor";
8509
8765
  const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
8510
8766
  if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
8511
8767
  if (state.chapter && !currentStillExists) {
8512
8768
  state.chapter = null;
8513
8769
  showWelcome(true);
8770
+ } else if (state.chapter && currentEditorVisible) {
8771
+ await selectChapter(state.chapter.id, { editMode: !chapterEditorReadOnly });
8514
8772
  } else renderTree();
8515
8773
  if (dialog.open) dialog.close();
8516
8774
  chapterBatchSelectedIds.clear();
8517
- toast(`已批量处理 ${chapters.length} 个章节`);
8775
+ toast(action.type === "renumberTitles"
8776
+ ? `已按目录顺序重排 ${Number(result.updated ?? chapters.length)} 个章节标题`
8777
+ : `已批量处理 ${chapters.length} 个章节`);
8518
8778
  } catch (error) {
8519
8779
  if (!dialog.open) dialog.showModal();
8520
8780
  $("#chapter-batch-apply").disabled = false;
@@ -9389,6 +9649,7 @@ function tidyChapterBlankLines() {
9389
9649
  const normalized = normalizeParagraphSpacing(input.value);
9390
9650
  if (normalized === input.value) return toast("正文空行已经符合要求");
9391
9651
  input.value = normalized;
9652
+ syncChapterDraftLineIds(input.value);
9392
9653
  scheduleChapterLineNumbers();
9393
9654
  updateChapterStats();
9394
9655
  scheduleChapterAutoSave(120);
@@ -10043,6 +10304,17 @@ function mountOutlineBoardFilterToggle() {
10043
10304
  });
10044
10305
  }
10045
10306
 
10307
+ function mountChapterCommentFilterToggle() {
10308
+ $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]')?.remove();
10309
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="chapter-comment-filter-toggle" aria-label="筛选正文评论与待办" aria-controls="chapter-comment-filter-panel" aria-expanded="${chapterCommentFiltersPanelOpen}" 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>`);
10310
+ const toggle = $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]');
10311
+ toggle?.addEventListener("click", () => {
10312
+ chapterCommentFiltersPanelOpen = !chapterCommentFiltersPanelOpen;
10313
+ $("#chapter-comment-filter-panel")?.classList.toggle("hidden", !chapterCommentFiltersPanelOpen);
10314
+ toggle.setAttribute("aria-expanded", String(chapterCommentFiltersPanelOpen));
10315
+ });
10316
+ }
10317
+
10046
10318
  function bindRecordPreview(selector, open) {
10047
10319
  $("#module-content").querySelectorAll(selector).forEach((card) => {
10048
10320
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -11293,32 +11565,102 @@ async function renderRelationships(page = moduleListPages.relationships) {
11293
11565
  }
11294
11566
 
11295
11567
  async function renderWorkChapterComments(page = moduleListPages.comments) {
11296
- const pageSize = pageSizeFor("comments");
11297
- const result = await moduleApiPage(
11298
- "comments",
11299
- `/api/works/${encodeURIComponent(state.work.id)}/chapter-annotations`,
11300
- page,
11301
- pageSize
11302
- );
11303
- if (!result.items.length && page > 1) return renderWorkChapterComments(page - 1);
11304
- const total = Number(result.total ?? result.items.length);
11305
- const pageCount = Math.max(1, Math.ceil(total / result.limit));
11306
- const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
11307
- moduleListPages.comments = pageResult.page;
11308
- mountModuleCount(total);
11309
- $("#module-content").innerHTML = result.items.length
11310
- ? `<div class="chapter-comment-module-list">${result.items.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
11311
- : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
11312
- bindModulePagination("comments", renderWorkChapterComments);
11313
- bindChapterAnnotationCards($("#module-content"), result.items, {
11314
- refresh: () => renderWorkChapterComments(pageResult.page),
11315
- locate: async (annotation) => {
11316
- const selected = await selectChapter(annotation.chapterId);
11317
- if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
11318
- await new Promise((resolve) => window.requestAnimationFrame(resolve));
11319
- revealChapterLines(annotation.startLine, annotation.endLine);
11568
+ const workId = state.work.id;
11569
+ const generation = workScopedUiGeneration;
11570
+ const hasFilters = () => Boolean(chapterCommentFilters.chapterId || chapterCommentFilters.keyword.trim());
11571
+ const chapterOptionsMarkup = () => `<option value="">全部章节</option>${chapterCommentChapterOptions.map((chapter) => `<option value="${esc(chapter.id)}" ${chapterCommentFilters.chapterId === chapter.id ? "selected" : ""}>${esc(chapter.volumeTitle)} / ${esc(chapter.title)}</option>`).join("")}`;
11572
+ const filterToolbar = `<section id="chapter-comment-filter-panel" class="character-filter-toolbar chapter-comment-filter-toolbar${chapterCommentFiltersPanelOpen ? "" : " hidden"}" aria-label="正文评论与待办筛选">
11573
+ <label class="setting-filter-field" for="chapter-comment-chapter-filter"><span>按章节筛选</span><select id="chapter-comment-chapter-filter" aria-label="按章节筛选正文评论与待办">${chapterOptionsMarkup()}</select></label>
11574
+ <label class="setting-filter-field" for="chapter-comment-keyword-filter"><span>按关键词搜索</span><input id="chapter-comment-keyword-filter" type="search" value="${esc(chapterCommentFilters.keyword)}" placeholder="搜索评论、待办或引用正文" aria-label="按关键词搜索正文评论与待办" autocomplete="off" maxlength="100"></label>
11575
+ <div class="character-filter-toolbar-actions"><span id="chapter-comment-filter-result-count" class="character-filter-result-count${hasFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-chapter-comment-filters" class="ghost-button" type="button" ${hasFilters() ? "" : "disabled"}>重置筛选</button></div>
11576
+ </section><div id="chapter-comment-filter-results"></div>`;
11577
+ $("#module-content").innerHTML = filterToolbar;
11578
+ mountChapterCommentFilterToggle();
11579
+
11580
+ const refreshResults = async (requestedPage = moduleListPages.comments) => {
11581
+ const requestId = ++chapterCommentRenderRequestId;
11582
+ const pageSize = pageSizeFor("comments");
11583
+ const parameters = new URLSearchParams();
11584
+ if (chapterCommentFilters.chapterId) parameters.set("chapterId", chapterCommentFilters.chapterId);
11585
+ if (chapterCommentFilters.keyword.trim()) parameters.set("q", chapterCommentFilters.keyword.trim());
11586
+ const path = `/api/works/${encodeURIComponent(workId)}/chapter-annotations${parameters.size ? `?${parameters}` : ""}`;
11587
+ $("#chapter-comment-filter-results")?.setAttribute("aria-busy", "true");
11588
+ let result;
11589
+ try {
11590
+ result = await moduleApiPage("comments", path, requestedPage, pageSize);
11591
+ } finally {
11592
+ if (requestId === chapterCommentRenderRequestId) $("#chapter-comment-filter-results")?.removeAttribute("aria-busy");
11320
11593
  }
11594
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== chapterCommentRenderRequestId || state.module !== "comments") return;
11595
+ chapterCommentChapterOptions = Array.isArray(result.chapterOptions) ? result.chapterOptions : [];
11596
+ if (chapterCommentFilters.chapterId && !chapterCommentChapterOptions.some((chapter) => chapter.id === chapterCommentFilters.chapterId)) {
11597
+ chapterCommentFilters.chapterId = "";
11598
+ $("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
11599
+ return refreshResults(1);
11600
+ }
11601
+ $("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
11602
+ if (!result.items.length && requestedPage > 1) return refreshResults(requestedPage - 1);
11603
+ const total = Number(result.total ?? result.items.length);
11604
+ const pageCount = Math.max(1, Math.ceil(total / result.limit));
11605
+ const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
11606
+ moduleListPages.comments = pageResult.page;
11607
+ mountModuleCount(total);
11608
+ const completedTodos = result.items.filter((annotation) => annotation.kind === "todo" && annotation.status === "resolved");
11609
+ const visibleAnnotations = result.items.filter((annotation) => annotation.kind !== "todo" || annotation.status !== "resolved");
11610
+ const visibleMarkup = visibleAnnotations.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("");
11611
+ const completedMarkup = completedTodos.length ? `<details class="chapter-comment-completed-group"><summary><span>已完成待办</span><strong>${completedTodos.length}</strong><small>默认折叠,点击展开</small></summary><div class="chapter-comment-completed-list">${completedTodos.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div></details>` : "";
11612
+ const filtersActive = hasFilters();
11613
+ $("#chapter-comment-filter-results").innerHTML = result.items.length
11614
+ ? `<div class="chapter-comment-module-list">${visibleMarkup}${completedMarkup}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
11615
+ : chapterCommentChapterOptions.length
11616
+ ? emptyModule("没有符合筛选条件的评论或待办", "可以切换章节、修改关键词或重置筛选。")
11617
+ : emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
11618
+ const resultCount = $("#chapter-comment-filter-result-count");
11619
+ resultCount.textContent = filtersActive ? `筛选后共 ${total} 条评论与待办` : "";
11620
+ resultCount.classList.toggle("hidden", !filtersActive);
11621
+ $("#clear-chapter-comment-filters").disabled = !filtersActive;
11622
+ bindModulePagination("comments", refreshResults);
11623
+ bindChapterAnnotationCards($("#chapter-comment-filter-results"), result.items, {
11624
+ refresh: () => refreshResults(pageResult.page),
11625
+ locate: async (annotation) => {
11626
+ const selected = await selectChapter(annotation.chapterId);
11627
+ if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
11628
+ await new Promise((resolve) => window.requestAnimationFrame(resolve));
11629
+ revealChapterLines(annotation.startLine, annotation.endLine);
11630
+ }
11631
+ });
11632
+ };
11633
+
11634
+ $("#chapter-comment-chapter-filter").addEventListener("change", (event) => {
11635
+ chapterCommentFilters.chapterId = event.currentTarget.value;
11636
+ chapterCommentFiltersPanelOpen = true;
11637
+ moduleListPages.comments = 1;
11638
+ clearTimeout(chapterCommentSearchTimer);
11639
+ chapterCommentSearchTimer = null;
11640
+ void refreshResults(1).catch((error) => toast(error.message, "error"));
11321
11641
  });
11642
+ $("#chapter-comment-keyword-filter").addEventListener("input", (event) => {
11643
+ chapterCommentFilters.keyword = event.currentTarget.value;
11644
+ chapterCommentFiltersPanelOpen = true;
11645
+ moduleListPages.comments = 1;
11646
+ clearTimeout(chapterCommentSearchTimer);
11647
+ chapterCommentSearchTimer = window.setTimeout(() => {
11648
+ chapterCommentSearchTimer = null;
11649
+ void refreshResults(1).catch((error) => toast(error.message, "error"));
11650
+ }, 250);
11651
+ });
11652
+ $("#clear-chapter-comment-filters").addEventListener("click", () => {
11653
+ chapterCommentFilters.chapterId = "";
11654
+ chapterCommentFilters.keyword = "";
11655
+ chapterCommentFiltersPanelOpen = true;
11656
+ moduleListPages.comments = 1;
11657
+ clearTimeout(chapterCommentSearchTimer);
11658
+ chapterCommentSearchTimer = null;
11659
+ $("#chapter-comment-chapter-filter").value = "";
11660
+ $("#chapter-comment-keyword-filter").value = "";
11661
+ void refreshResults(1).then(() => $("#chapter-comment-keyword-filter")?.focus()).catch((error) => toast(error.message, "error"));
11662
+ });
11663
+ await refreshResults(page);
11322
11664
  }
11323
11665
 
11324
11666
  async function renderReviews(page = moduleListPages.reviews) {
@@ -12465,16 +12807,17 @@ function renderProviderCards(providers, models, protocolOptions) {
12465
12807
  <article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
12466
12808
  ${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n分析请求超时:${Number(provider.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS).toLocaleString("zh-CN")} 秒\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
12467
12809
  <div class="provider-models">${providerModels.map((model) => {
12468
- const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
12810
+ const modelUnavailable = !isAvailableConfiguredModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
12469
12811
  const modelStatus = !model.enabled
12470
12812
  ? `<span class="model-status-badge is-disabled">模型已停用</span>`
12471
12813
  : provider.connectionStatus !== "success"
12472
12814
  ? `<span class="model-status-badge is-unavailable">连接不可用</span>`
12473
12815
  : "";
12816
+ const kindLabel = model.modelKind === "embedding" ? "Embedding" : model.modelKind === "rerank" ? "Rerank" : "Chat";
12474
12817
  const capability = model.multimodalEnabled ? " · 多模态" : "";
12475
12818
  const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
12476
12819
  const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
12477
- 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 ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
12820
+ 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)} · ${esc(kindLabel)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge}${model.modelKind === "chat" ? ` · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}` : ""}</button>${modelStatus}</div>`;
12478
12821
  }).join("")}</div>
12479
12822
  <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-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
12480
12823
  }).join("")}</div>`
@@ -12598,7 +12941,7 @@ function renderTaskDefaults(models, providers, taskDefaults, settings, protocolO
12598
12941
  <option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
12599
12942
  ${models.map((model) => {
12600
12943
  const provider = providerById.get(model.providerId);
12601
- const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
12944
+ const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
12602
12945
  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>`;
12603
12946
  }).join("")}
12604
12947
  </select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
@@ -12656,6 +12999,34 @@ function relationshipIndexStatusMarkup(status) {
12656
12999
  </div>`;
12657
13000
  }
12658
13001
 
13002
+ const semanticIndexStatusLabels = Object.freeze({
13003
+ disabled: "未开启",
13004
+ unconfigured: "配置不完整",
13005
+ idle: "等待重建",
13006
+ building: "正在构建",
13007
+ ready: "可以检索",
13008
+ failed: "部分失败",
13009
+ paused: "已暂停"
13010
+ });
13011
+
13012
+ function semanticIndexStatusMarkup(status = {}) {
13013
+ const progress = Math.min(100, Math.max(0, Number(status.progress) || 0));
13014
+ const statusName = semanticIndexStatusLabels[status.status] ?? "状态未知";
13015
+ const model = status.embeddingModel
13016
+ ? `${status.embeddingModel.providerName} · ${status.embeddingModel.displayName}`
13017
+ : "尚未选择 embedding 模型";
13018
+ const rerank = status.rerankModel
13019
+ ? `${status.rerankModel.providerName} · ${status.rerankModel.displayName}`
13020
+ : "未启用 rerank";
13021
+ return `<div class="semantic-index-summary">
13022
+ <div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>Embedding <strong>${esc(model)}</strong></span><span>Rerank <strong>${esc(rerank)}</strong></span></div>
13023
+ <progress class="semantic-index-progress" max="100" value="${esc(String(progress))}" aria-label="RAG 构建进度 ${esc(String(progress))}%"></progress>
13024
+ <dl class="relationship-index-metrics"><div><dt>构建进度</dt><dd>${esc(String(progress))}%</dd></div><div><dt>已处理来源</dt><dd>${esc(String(status.processedSources ?? 0))} / ${esc(String(status.totalSources ?? 0))}</dd></div><div><dt>有效分片</dt><dd>${esc(String(status.indexedChunkCount ?? 0))}</dd></div><div><dt>失败来源</dt><dd>${esc(String(status.failedSources ?? 0))}</dd></div></dl>
13025
+ ${status.error ? `<p class="relationship-index-error">${esc(status.error)}</p>` : ""}
13026
+ ${status.status === "paused" ? `<p class="usage-measurement-note">已连续失败 ${esc(String(status.consecutiveFailures ?? 0))} 次。检查端点和模型后点击“完整重建 RAG”恢复。</p>` : ""}
13027
+ </div>`;
13028
+ }
13029
+
12659
13030
  function updateBackgroundTaskCenterVisibility() {
12660
13031
  const button = $("#background-task-button");
12661
13032
  if (!button) return;
@@ -12973,20 +13344,22 @@ async function renderPlatformAiConfig() {
12973
13344
  }
12974
13345
 
12975
13346
  function tokenUsageDateLabel(date) {
13347
+ const [year, month, day] = String(date).split("-").map(Number);
12976
13348
  return new Intl.DateTimeFormat("zh-CN", {
12977
13349
  year: "numeric",
12978
13350
  month: "short",
12979
13351
  day: "numeric",
12980
- weekday: "short"
12981
- }).format(new Date(`${date}T00:00:00`));
13352
+ weekday: "short",
13353
+ timeZone: "UTC"
13354
+ }).format(new Date(Date.UTC(year, month - 1, day)));
12982
13355
  }
12983
13356
 
12984
- function tokenUsageCalendarMarkup(daily) {
12985
- const calendar = buildUsageCalendar(daily);
13357
+ function tokenUsageCalendarMarkup(daily, year, serverDate) {
13358
+ const calendar = buildUsageCalendar(daily, year, serverDate);
12986
13359
  const cells = calendar.cells.map((cell) => {
12987
13360
  const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
12988
- return cell.future
12989
- ? `<span class="usage-calendar-cell is-future" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
13361
+ return cell.outsideYear || cell.future
13362
+ ? `<span class="usage-calendar-cell ${cell.outsideYear ? "is-outside-year" : "is-future"}" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
12990
13363
  : `<button class="usage-calendar-cell" type="button" data-level="${cell.level}" data-usage-calendar-label="${esc(label)}" role="gridcell" aria-label="${esc(label)}"></button>`;
12991
13364
  }).join("");
12992
13365
  const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
@@ -12996,7 +13369,7 @@ function tokenUsageCalendarMarkup(daily) {
12996
13369
  <div class="usage-calendar-months" aria-hidden="true">${months}</div>
12997
13370
  <div class="usage-calendar-body">
12998
13371
  <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
12999
- <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
13372
+ <div class="usage-calendar-grid" role="grid" aria-label="${calendar.year} 年每日 Token 用量">${cells}</div>
13000
13373
  </div>
13001
13374
  </div>
13002
13375
  </div>
@@ -13005,6 +13378,21 @@ function tokenUsageCalendarMarkup(daily) {
13005
13378
  <div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
13006
13379
  }
13007
13380
 
13381
+ function bindUsageCalendar(root, usage) {
13382
+ root.querySelectorAll("[data-usage-calendar-year]").forEach((select) => {
13383
+ select.addEventListener("change", () => {
13384
+ const section = select.closest(".usage-calendar-section");
13385
+ const calendarHost = section?.querySelector("[data-usage-calendar-host]");
13386
+ if (!calendarHost) return;
13387
+ calendarHost.innerHTML = tokenUsageCalendarMarkup(usage?.daily, Number(select.value), usage?.serverDate);
13388
+ bindUsageCalendarInteractions(calendarHost);
13389
+ scrollUsageCalendarsToLatest(calendarHost);
13390
+ });
13391
+ });
13392
+ bindUsageCalendarInteractions(root);
13393
+ scrollUsageCalendarsToLatest(root);
13394
+ }
13395
+
13008
13396
  function bindUsageCalendarInteractions(root) {
13009
13397
  root.querySelectorAll(".usage-calendar-widget").forEach((widget) => {
13010
13398
  const tooltip = widget.querySelector(".usage-calendar-tooltip");
@@ -13224,6 +13612,19 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
13224
13612
  <td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
13225
13613
  <td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
13226
13614
  </tr>`).join("");
13615
+ const callTypeLabels = { chat: "Chat / 分析", embedding: "Embedding", rerank: "Rerank" };
13616
+ const callTypeUsage = (Array.isArray(usage?.callTypes) ? usage.callTypes : [])
13617
+ .map((item) => `<span class="usage-call-type-chip"><strong>${esc(callTypeLabels[item.callType] ?? item.callType)}</strong><span>${esc(formatTokenCount(item.totalTokens))} Token · ${Number(item.requestCount || 0).toLocaleString("zh-CN")} 次</span></span>`)
13618
+ .join("");
13619
+ const calendarYears = usageCalendarYears(usage?.daily);
13620
+ const selectedCalendarYear = calendarYears[0] ?? null;
13621
+ const usageTimezone = String(usage?.timezone || "服务器本地时区");
13622
+ const calendarYearSelect = selectedCalendarYear === null
13623
+ ? ""
13624
+ : `<select class="usage-calendar-year-select" data-usage-calendar-year aria-label="每日用量年份">${calendarYears.map((year) => `<option value="${year}">${year} 年</option>`).join("")}</select>`;
13625
+ const calendarMarkup = selectedCalendarYear === null
13626
+ ? '<p class="usage-calendar-empty">尚无每日 Token 用量记录。</p>'
13627
+ : tokenUsageCalendarMarkup(usage?.daily, selectedCalendarYear, usage?.serverDate);
13227
13628
  return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
13228
13629
  <div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
13229
13630
  <div class="usage-stat-grid">
@@ -13233,9 +13634,10 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
13233
13634
  <article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
13234
13635
  </div>
13235
13636
  <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
13637
+ ${callTypeUsage ? `<div class="usage-call-types" aria-label="按调用类型区分的 Token 用量">${callTypeUsage}</div>` : ""}
13236
13638
  <section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
13237
- <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
13238
- ${tokenUsageCalendarMarkup(usage?.daily)}
13639
+ <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格按服务器时区(${esc(usageTimezone)})分年展示;颜色越深,当天消耗越高。</p></div>${calendarYearSelect}</header>
13640
+ <div data-usage-calendar-host>${calendarMarkup}</div>
13239
13641
  </section>
13240
13642
  ${showWorks ? `<section class="usage-work-section" aria-labelledby="usage-work-title"><header><div><h3 id="usage-work-title">各作品用量</h3><p>按 Token 总消耗从高到低排列,包含尚未使用 AI 的作品。</p></div></header><div class="usage-work-table-scroll"><table class="usage-work-table"><thead><tr><th>作品</th><th>总消耗</th><th>输入</th><th>输出</th><th>缓存命中率</th><th>调用</th></tr></thead><tbody>${workRows || '<tr><td colspan="6">还没有作品用量记录。</td></tr>'}</tbody></table></div></section>` : ""}
13241
13643
  </section>`;
@@ -13244,16 +13646,14 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
13244
13646
  async function renderPlatformTokenUsage() {
13245
13647
  const host = $("#platform-usage-content");
13246
13648
  host.innerHTML = '<div class="empty-state">正在汇总 Token 用量……</div>';
13247
- const timezoneOffset = -new Date().getTimezoneOffset();
13248
- const usage = await api(`/api/platform/ai/usage?timezoneOffset=${timezoneOffset}`);
13649
+ const usage = await api("/api/platform/ai/usage");
13249
13650
  host.innerHTML = tokenUsageOverviewMarkup(usage, {
13250
13651
  title: "项目累计用量",
13251
13652
  description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
13252
13653
  showWorks: true
13253
13654
  });
13254
13655
  bindTokenUsageDetails(host, usage, "项目累计用量");
13255
- bindUsageCalendarInteractions(host);
13256
- scrollUsageCalendarsToLatest(host);
13656
+ bindUsageCalendar(host, usage);
13257
13657
  }
13258
13658
 
13259
13659
  async function renderBookAiSettings() {
@@ -13261,22 +13661,35 @@ async function renderBookAiSettings() {
13261
13661
  clearTimeout(relationshipSearchIndexRefreshTimer);
13262
13662
  relationshipSearchIndexRefreshTimer = null;
13263
13663
  }
13264
- const [settings, providers, models, taskDefaults, relationshipIndex, usage, protocolOptions, writeTools] = await Promise.all([
13664
+ if (semanticSearchIndexRefreshTimer) {
13665
+ clearTimeout(semanticSearchIndexRefreshTimer);
13666
+ semanticSearchIndexRefreshTimer = null;
13667
+ }
13668
+ const [settings, providers, models, semanticModels, taskDefaults, relationshipIndex, semanticIndex, usage, protocolOptions, writeTools, remoteMcpSettings] = await Promise.all([
13265
13669
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
13266
13670
  moduleApi("ai-settings", "/api/platform/ai/providers"),
13267
13671
  moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
13672
+ moduleApi("ai-settings", `/api/works/${state.work.id}/semantic-models`),
13268
13673
  moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
13269
13674
  moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
13270
- moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
13675
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/semantic-search-index`),
13676
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage`),
13271
13677
  moduleApi("ai-settings", "/api/platform/ai/protocols"),
13272
13678
  // 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
13273
- api(`/api/works/${state.work.id}/ai/tools`).catch(() => null)
13679
+ api(`/api/works/${state.work.id}/ai/tools`).catch(() => null),
13680
+ moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/mcp-servers`)
13274
13681
  ]);
13275
13682
  const writeToolsState = writeTools?.tools ?? null;
13276
13683
  const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
13277
13684
  const host = $("#module-content");
13278
13685
  platformAiProtocolOptions = protocolOptions;
13279
13686
  const workId = String(state.work.id);
13687
+ const remoteMcpConfigText = JSON.stringify(remoteMcpSettings?.config ?? { mcpServers: {} }, null, 2);
13688
+ const remoteMcpServers = Array.isArray(remoteMcpSettings?.servers) ? remoteMcpSettings.servers : [];
13689
+ const remoteMcpToolCount = Math.max(0, Number(remoteMcpSettings?.totalToolCount) || 0);
13690
+ const remoteMcpStatusText = remoteMcpServers.length > 0
13691
+ ? `已验证 ${remoteMcpServers.length} 个远程 MCP Server,共发现 ${remoteMcpToolCount} 个工具。`
13692
+ : "尚未配置远程 MCP Server。";
13280
13693
  const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
13281
13694
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
13282
13695
  const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
@@ -13300,6 +13713,17 @@ async function renderBookAiSettings() {
13300
13713
  title: "本书 Token 用量",
13301
13714
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
13302
13715
  })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</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>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
13716
+ const workSystemPromptSection = host.querySelector("#work-system-prompt")?.closest(".config-section");
13717
+ workSystemPromptSection?.insertAdjacentHTML("afterend", `<section class="config-section remote-mcp-settings"><div class="config-section-header"><div><h2>远程 MCP 工具</h2><p>填写标准的 <code>mcpServers</code> JSON 配置。保存前会逐个检查 JSON、远程传输、安全地址、MCP 握手与工具列表;任一 Server 失败时都不会覆盖当前配置。</p></div></div><label class="field-label remote-mcp-config-field"><span>mcpServers JSON</span><textarea id="remote-mcp-config" rows="12" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="remote-mcp-config-help remote-mcp-status" placeholder='{"mcpServers":{"example":{"url":"https://example.com/mcp"}}}'>${esc(remoteMcpConfigText)}</textarea></label><small id="remote-mcp-config-help" class="remote-mcp-config-help">仅支持远程 MCP 工具(SSE / Streamable HTTP),不支持会执行本地命令的 stdio 配置。敏感 Header 会加密保存,页面中的 ${esc("********")} 掩码再次保存时会保留原值。</small><p id="remote-mcp-status" class="remote-mcp-status" role="status" aria-live="polite">${esc(remoteMcpStatusText)}</p><div class="card-actions"><button id="save-remote-mcp-config" class="ghost-button config-save-button" type="button">测试并保存 MCP 配置</button></div></section>`);
13718
+ const semanticModelOptions = (kind, selectedId) => semanticModels
13719
+ .filter((model) => model.modelKind === kind)
13720
+ .map((model) => {
13721
+ const available = isAvailableConfiguredModel(model);
13722
+ return `<option value="${esc(model.id)}" ${model.id === selectedId ? "selected" : ""} ${available || model.id === selectedId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel(model)}`)}</option>`;
13723
+ }).join("");
13724
+ const semanticSection = `<section id="semantic-search-settings" class="config-section semantic-search-settings"><div class="config-section-header"><div><h2>主动语义检索(RAG)</h2><p>与拼音索引并列维护。Embedding 和 rerank 复用平台供应商的端点与凭证保险库;普通聊天、续写、润色和分析不会自动调用此通道。</p></div></div><div class="semantic-settings-grid"><label class="checkbox-field config-checkbox-field semantic-enabled-field"><input id="semantic-search-enabled" type="checkbox" ${settings.semanticSearchEnabled ? "checked" : ""}>启用主动语义检索</label><label>Embedding 模型<select id="semantic-embedding-model" aria-label="RAG Embedding 模型"><option value="">请选择 embedding 模型</option>${semanticModelOptions("embedding", settings.semanticEmbeddingModelId)}</select></label><label>Rerank 模型(可选)<select id="semantic-rerank-model" aria-label="RAG Rerank 模型"><option value="">不使用 rerank</option>${semanticModelOptions("rerank", settings.semanticRerankModelId)}</select></label><label>向量维度<input id="semantic-vector-dimension" type="number" min="1" max="65536" value="${esc(String(settings.semanticVectorDimension ?? 1024))}"></label><label>语义召回数量<input id="semantic-recall-limit" type="number" min="1" max="200" value="${esc(String(settings.semanticRecallLimit ?? 20))}"></label><label>展示结果数量<input id="semantic-result-limit" type="number" min="1" max="100" value="${esc(String(settings.semanticResultLimit ?? 12))}"></label><label>上下文预算(Token)<input id="semantic-budget-tokens" type="number" min="256" max="100000" value="${esc(String(settings.semanticBudgetTokens ?? 4000))}"></label><label>RRF 语义通道权重<input id="semantic-channel-weight" type="number" min="0.1" max="5" step="0.1" value="${esc(String(settings.semanticChannelWeight ?? 1))}"></label></div><p class="usage-measurement-note">API Key 由所选模型所属供应商的凭证保险库加密保存;此页面不会读取或返回明文密钥。更换端点、模型、维度或分片规则后必须完整重建,旧向量不会继续参与检索。</p><div id="semantic-search-index-status" role="status" aria-live="polite">${semanticIndexStatusMarkup(semanticIndex)}</div><div class="relationship-index-actions"><button id="save-semantic-search-settings" class="primary-button config-save-button" type="button">保存 RAG 配置</button><button id="sync-semantic-search-index" class="ghost-button config-save-button" type="button">同步增量</button><button id="refresh-semantic-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-semantic-search-index" class="ghost-button config-save-button" type="button">完整重建 RAG</button></div></section>`;
13725
+ const relationshipSection = [...host.querySelectorAll(".config-section")].find((section) => section.querySelector("h2")?.textContent === "人物关系拼音索引");
13726
+ relationshipSection?.insertAdjacentHTML("afterend", semanticSection);
13303
13727
  bindTokenUsageDetails(host, usage, "本书 Token 用量");
13304
13728
  const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
13305
13729
  dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
@@ -13324,8 +13748,7 @@ async function renderBookAiSettings() {
13324
13748
  if (section.querySelector("h2")?.textContent === "Agent 工具调用上限") section.id = "agent-tool-call-limit-settings";
13325
13749
  });
13326
13750
  host.insertAdjacentHTML("beforeend", `<section class="config-section"><div class="config-section-header"><div><h2>AI 可写工具</h2><p>默认全部关闭:逐项开启后,侧边栏 AI 才能在对应模块提交修改计划。计划只包含操作描述与 AI 简述;确认前系统会按当前数据库生成字段级明细(含修改前后值),执行时整体原子完成并再次校验权限、开关与目标版本,全程可在「AI 操作审批中心」追溯。AI 不能删除任何条目,也不能改写正文。</p></div><div class="card-actions"><button id="open-ai-approval-center-from-settings" class="ghost-button" type="button">打开 AI 操作审批中心</button></div></div><div class="ai-agent-tools ai-write-tools">${AI_WRITE_TOOLS_META.map((tool) => `<label><input name="ai-write-tool" type="checkbox" value="${esc(tool.id)}" ${writeToolsState?.[tool.id] === true ? "checked" : ""}><span><strong>${esc(tool.label)}</strong><small>${esc(tool.description)}</small></span></label>`).join("")}</div><p class="usage-measurement-note">${writeTools ? `当前单次审批最多 ${writeToolsMaxOperations} 个操作,可通过环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 调整。` : "工具开关状态暂时无法加载,显示的勾选可能不是最新值。"}</p><div class="card-actions"><button id="save-ai-write-tools" class="ghost-button config-save-button" type="button">保存开关设置</button></div></section>`);
13327
- bindUsageCalendarInteractions(host);
13328
- scrollUsageCalendarsToLatest(host);
13751
+ bindUsageCalendar(host, usage);
13329
13752
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
13330
13753
  "beforebegin",
13331
13754
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
@@ -13334,6 +13757,10 @@ async function renderBookAiSettings() {
13334
13757
  "afterend",
13335
13758
  `<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
13336
13759
  );
13760
+ host.querySelector('input[name="agent-tool"][value="read_character_sections"]').closest("label").insertAdjacentHTML(
13761
+ "afterend",
13762
+ `<label><input name="agent-tool" type="checkbox" value="semantic_search_story" ${agentTools.has("semantic_search_story") ? "checked" : ""} ${settings.semanticSearchEnabled ? "" : "disabled"}><span><strong>语义检索作品原文(RAG)</strong><small>允许 Agent 显式调用 semantic_search_story;只影响保存后新建的对话,普通消息不会自动检索。</small></span></label>`
13763
+ );
13337
13764
  host.querySelector(".ai-agent-tools").insertAdjacentHTML(
13338
13765
  "beforeend",
13339
13766
  `<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算两个 YYYY-MM-DD 日期之间的天数差,不读取作品内容。</small></span></label>`
@@ -13370,6 +13797,109 @@ async function renderBookAiSettings() {
13370
13797
  return status;
13371
13798
  };
13372
13799
  updateRelationshipIndexStatus(relationshipIndex);
13800
+ const isCurrentSemanticIndexPanel = () => state.module === "ai-settings"
13801
+ && String(state.work?.id ?? "") === workId
13802
+ && Boolean($("#semantic-search-index-status"));
13803
+ const updateSemanticIndexStatus = (status) => {
13804
+ const statusHost = $("#semantic-search-index-status");
13805
+ if (!statusHost) return;
13806
+ statusHost.innerHTML = semanticIndexStatusMarkup(status);
13807
+ if (semanticSearchIndexRefreshTimer) clearTimeout(semanticSearchIndexRefreshTimer);
13808
+ semanticSearchIndexRefreshTimer = null;
13809
+ if (!["idle", "building"].includes(String(status.status))) return;
13810
+ semanticSearchIndexRefreshTimer = setTimeout(async () => {
13811
+ semanticSearchIndexRefreshTimer = null;
13812
+ if (!isCurrentSemanticIndexPanel()) return;
13813
+ try {
13814
+ const nextStatus = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
13815
+ if (isCurrentSemanticIndexPanel()) updateSemanticIndexStatus(nextStatus);
13816
+ } catch {
13817
+ // 后台轮询失败时保留当前进度,用户仍可手动刷新。
13818
+ }
13819
+ }, 1_200);
13820
+ };
13821
+ const refreshSemanticIndexStatus = async () => {
13822
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
13823
+ updateSemanticIndexStatus(status);
13824
+ return status;
13825
+ };
13826
+ updateSemanticIndexStatus(semanticIndex);
13827
+ $("#save-semantic-search-settings").addEventListener("click", async () => {
13828
+ const button = $("#save-semantic-search-settings");
13829
+ const enabled = $("#semantic-search-enabled").checked;
13830
+ const embeddingModelId = $("#semantic-embedding-model").value || null;
13831
+ const vectorDimension = Number($("#semantic-vector-dimension").value);
13832
+ const recallLimit = Number($("#semantic-recall-limit").value);
13833
+ const resultLimit = Number($("#semantic-result-limit").value);
13834
+ const budgetTokens = Number($("#semantic-budget-tokens").value);
13835
+ const channelWeight = Number($("#semantic-channel-weight").value);
13836
+ if (enabled && !embeddingModelId) return toast("开启 RAG 前必须选择 embedding 模型", "error");
13837
+ if (!Number.isInteger(vectorDimension) || vectorDimension < 1 || vectorDimension > 65_536) return toast("向量维度必须是 1 到 65536 的整数", "error");
13838
+ if (!Number.isInteger(recallLimit) || recallLimit < 1 || recallLimit > 200) return toast("语义召回数量必须是 1 到 200 的整数", "error");
13839
+ if (!Number.isInteger(resultLimit) || resultLimit < 1 || resultLimit > 100) return toast("展示结果数量必须是 1 到 100 的整数", "error");
13840
+ if (!Number.isInteger(budgetTokens) || budgetTokens < 256 || budgetTokens > 100_000) return toast("语义上下文预算必须是 256 到 100000 Token", "error");
13841
+ if (!Number.isFinite(channelWeight) || channelWeight < 0.1 || channelWeight > 5) return toast("语义通道权重必须在 0.1 到 5 之间", "error");
13842
+ button.disabled = true;
13843
+ try {
13844
+ const updated = await api(`/api/works/${workId}/ai-settings/semantic-search`, {
13845
+ method: "PATCH",
13846
+ body: {
13847
+ enabled,
13848
+ embeddingModelId,
13849
+ rerankModelId: $("#semantic-rerank-model").value || null,
13850
+ vectorDimension,
13851
+ recallLimit,
13852
+ resultLimit,
13853
+ budgetTokens,
13854
+ channelWeight
13855
+ }
13856
+ });
13857
+ updateSemanticIndexStatus(updated.semanticIndex);
13858
+ toast(enabled ? "RAG 配置已保存;请执行完整重建" : "主动语义检索已关闭");
13859
+ await renderBookAiSettings();
13860
+ } catch (error) {
13861
+ toast(error.message, "error");
13862
+ button.disabled = false;
13863
+ }
13864
+ });
13865
+ $("#sync-semantic-search-index").addEventListener("click", async () => {
13866
+ const button = $("#sync-semantic-search-index");
13867
+ button.disabled = true;
13868
+ try {
13869
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/sync`, { method: "POST" });
13870
+ updateSemanticIndexStatus({ ...status, status: "building" });
13871
+ toast("已开始同步 RAG 增量来源");
13872
+ } catch (error) {
13873
+ toast(error.message, "error");
13874
+ } finally {
13875
+ button.disabled = false;
13876
+ }
13877
+ });
13878
+ $("#refresh-semantic-search-index").addEventListener("click", async () => {
13879
+ const button = $("#refresh-semantic-search-index");
13880
+ button.disabled = true;
13881
+ try {
13882
+ await refreshSemanticIndexStatus();
13883
+ toast("RAG 状态已刷新", "info");
13884
+ } catch (error) {
13885
+ toast(error.message, "error");
13886
+ } finally {
13887
+ button.disabled = false;
13888
+ }
13889
+ });
13890
+ $("#rebuild-semantic-search-index").addEventListener("click", async () => {
13891
+ const button = $("#rebuild-semantic-search-index");
13892
+ button.disabled = true;
13893
+ try {
13894
+ const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/rebuild`, { method: "POST" });
13895
+ updateSemanticIndexStatus({ ...status, status: "building", progress: 0 });
13896
+ toast("已开始完整重建 RAG");
13897
+ } catch (error) {
13898
+ toast(error.message, "error");
13899
+ } finally {
13900
+ button.disabled = false;
13901
+ }
13902
+ });
13373
13903
  const syncTokenQuotaWarnings = () => {
13374
13904
  for (const period of ["daily", "monthly"]) {
13375
13905
  const enabled = $(`#${period}-token-quota-enabled`).checked;
@@ -13462,6 +13992,39 @@ async function renderBookAiSettings() {
13462
13992
  button.disabled = false;
13463
13993
  }
13464
13994
  });
13995
+ $("#save-remote-mcp-config").addEventListener("click", async () => {
13996
+ const button = $("#save-remote-mcp-config");
13997
+ const editor = $("#remote-mcp-config");
13998
+ const status = $("#remote-mcp-status");
13999
+ let configuration;
14000
+ try {
14001
+ configuration = JSON.parse(editor.value);
14002
+ } catch {
14003
+ toast("MCP 配置不是合法 JSON,请检查逗号、引号和括号", "error");
14004
+ editor.focus();
14005
+ return;
14006
+ }
14007
+ button.disabled = true;
14008
+ button.textContent = "正在测试连接…";
14009
+ status.textContent = "正在逐个验证远程 MCP Server 的地址、协议握手与工具列表,请稍候。";
14010
+ try {
14011
+ const saved = await api(`/api/works/${state.work.id}/ai-settings/mcp-servers`, {
14012
+ method: "PUT",
14013
+ body: configuration
14014
+ });
14015
+ const serverCount = Array.isArray(saved.servers) ? saved.servers.length : 0;
14016
+ const toolCount = Math.max(0, Number(saved.totalToolCount) || 0);
14017
+ toast(serverCount > 0
14018
+ ? `已验证并保存 ${serverCount} 个 MCP Server,共 ${toolCount} 个工具`
14019
+ : "远程 MCP 配置已清空");
14020
+ await renderBookAiSettings();
14021
+ } catch (error) {
14022
+ status.textContent = "验证失败,当前已保存配置保持不变。";
14023
+ toast(error.message, "error");
14024
+ button.disabled = false;
14025
+ button.textContent = "测试并保存 MCP 配置";
14026
+ }
14027
+ });
13465
14028
  $("#sync-relationship-search-index").addEventListener("click", async () => {
13466
14029
  const button = $("#sync-relationship-search-index");
13467
14030
  button.disabled = true;
@@ -13689,28 +14252,33 @@ function currentAiRequestScope() {
13689
14252
  if (!state.work) return null;
13690
14253
  const selectedTaskType = $("#ai-task").value;
13691
14254
  const roleplaySelected = selectedTaskType === "roleplay";
13692
- const taskType = roleplaySelected ? "chat" : selectedTaskType;
13693
- if (state.aiPromptSent) {
13694
- const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
13695
- conversationScope.includeSettingInfo = false;
13696
- const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
13697
- return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
13698
- }
13699
- const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
13700
- const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
13701
- if (requiresChapter && !state.chapter) return null;
13702
- const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
14255
+ const taskType = "chat";
14256
+ const scopeType = state.aiPromptSent
14257
+ ? null
14258
+ : roleplaySelected ? "none" : $("#ai-scope").value;
14259
+ if (!state.aiPromptSent && scopeType !== "none" && scopeType !== "settings-catalog" && !state.chapter) return null;
13703
14260
  const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
13704
- const includeBookSummary = scopeType === "chapter-summary";
13705
- const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
13706
- : scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
14261
+ const conversationScope = state.aiPromptSent
14262
+ ? JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }))
14263
+ : scopeType === "none" ? { type: "none" }
13707
14264
  : scopeType === "book" ? { type: "book" }
13708
14265
  : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
13709
14266
  : scopeType === "settings-catalog" ? { type: "settings-catalog" }
13710
14267
  : { type: "chapter", chapterId: state.chapter?.id };
13711
- if (includeBookSummary) conversationScope.includeBookSummary = true;
14268
+ if (!state.aiPromptSent && scopeType === "chapter-summary") conversationScope.includeBookSummary = true;
13712
14269
  conversationScope.includeSettingInfo = false;
13713
- const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
14270
+ const referencedScope = mergeAiReferenceScope(conversationScope, state.aiReferences);
14271
+ const chapterInput = state.chapter && !roleplaySelected ? $("#chapter-content") : null;
14272
+ const selectionStart = chapterInput?.selectionStart ?? 0;
14273
+ const selectionEnd = chapterInput?.selectionEnd ?? 0;
14274
+ const selection = chapterInput?.value.slice(selectionStart, selectionEnd) ?? "";
14275
+ const writingTarget = state.chapter && !roleplaySelected ? {
14276
+ chapterId: state.chapter.id,
14277
+ writingChapterVersion: state.chapter.versionNo,
14278
+ ...(selection ? { selection, selectionStart, selectionEnd } : {})
14279
+ } : {};
14280
+ const scope = { ...referencedScope, ...writingTarget };
14281
+ if (state.aiSemanticSnapshot?.id) scope.semanticSnapshotId = state.aiSemanticSnapshot.id;
13714
14282
  return { taskType, scope, conversationScope, selection };
13715
14283
  }
13716
14284
 
@@ -13736,7 +14304,7 @@ function renderAiContextDistribution(usage) {
13736
14304
  if (item.key === "skills" || item.key === "input" || item.key === "output") {
13737
14305
  const description = document.createElement("small");
13738
14306
  description.textContent = item.key === "skills"
13739
- ? "待加入"
14307
+ ? item.tokens > 0 ? "按需加载" : "未加载"
13740
14308
  : item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
13741
14309
  title.append(" ", description);
13742
14310
  }
@@ -13887,9 +14455,10 @@ function field(name, label, type = "text", value = "", options = []) {
13887
14455
  return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
13888
14456
  }
13889
14457
  if (type === "keyword-chips") {
14458
+ const chipLabel = String(label).includes("关键词") ? "关键词" : label;
13890
14459
  const values = uniqueRelationshipKeywords(Array.isArray(value) ? value : []);
13891
- const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`).join("");
13892
- return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入关键词后按回车添加;也可用逗号一次添加多个。</small></div>`;
14460
+ const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(chipLabel)}:${esc(keyword)}">×</button></span>`).join("");
14461
+ return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}" data-remove-label="${esc(chipLabel)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入${esc(chipLabel)}后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入${esc(chipLabel)}后按回车添加;也可用逗号一次添加多个。</small></div>`;
13893
14462
  }
13894
14463
  if (type === "key-value-list") {
13895
14464
  const config = Array.isArray(options) ? {} : options;
@@ -13901,9 +14470,10 @@ function field(name, label, type = "text", value = "", options = []) {
13901
14470
  const valueAriaLabel = config.valueAriaLabel ?? "扩展属性内容";
13902
14471
  const removeLabel = config.removeLabel ?? "删除此扩展属性";
13903
14472
  const addLabel = config.addLabel ?? "添加属性";
14473
+ const multilineValue = config.multilineValue ?? false;
13904
14474
  const values = normalizeCharacterDetails(value);
13905
14475
  const rows = values.length ? values : [{ label: "", value: "" }];
13906
- return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}"><input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}"><button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
14476
+ return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}">${multilineValue ? `<textarea class="key-value-list-value" name="${esc(valueName)}" rows="1" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}" data-auto-grow>${esc(item.value)}</textarea>` : `<input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}">`}<button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
13907
14477
  }
13908
14478
  if (type === "section-list") {
13909
14479
  const values = normalizeCharacterSections(value);
@@ -13976,6 +14546,10 @@ function renderKnowledgeMarkdownSections() {
13976
14546
  }
13977
14547
 
13978
14548
  function bindDynamicListControls(container) {
14549
+ resizeAutoGrowingTextareas(container);
14550
+ container.addEventListener("input", (event) => {
14551
+ if (event.target.matches?.("textarea[data-auto-grow]")) resizeAutoGrowingTextarea(event.target);
14552
+ });
13979
14553
  container.querySelectorAll("[data-item-list-add]").forEach((button) => button.addEventListener("click", () => {
13980
14554
  const rows = button.previousElementSibling;
13981
14555
  const row = document.createElement("div");
@@ -13997,6 +14571,7 @@ function bindDynamicListControls(container) {
13997
14571
  const row = rows.lastElementChild.cloneNode(true);
13998
14572
  row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
13999
14573
  rows.append(row);
14574
+ resizeAutoGrowingTextareas(row);
14000
14575
  row.querySelector("input").focus();
14001
14576
  }));
14002
14577
  container.onclick = (event) => {
@@ -14004,21 +14579,65 @@ function bindDynamicListControls(container) {
14004
14579
  if (!remove) return;
14005
14580
  const row = remove.closest(".item-list-row, .structured-list-row");
14006
14581
  const rows = row.parentElement;
14007
- if (rows.children.length === 1) row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
14582
+ if (rows.children.length === 1) {
14583
+ row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
14584
+ resizeAutoGrowingTextareas(row);
14585
+ }
14008
14586
  else row.remove();
14009
14587
  };
14010
14588
  }
14011
14589
 
14590
+ const supportsNativeTextareaContentSizing = typeof CSS !== "undefined" && CSS.supports("field-sizing", "content");
14591
+
14592
+ function resizeAutoGrowingTextarea(textarea) {
14593
+ if (supportsNativeTextareaContentSizing) {
14594
+ textarea.style.removeProperty("height");
14595
+ return;
14596
+ }
14597
+ textarea.style.height = "0px";
14598
+ const minimumHeight = Number.parseFloat(getComputedStyle(textarea).minHeight) || 0;
14599
+ textarea.style.height = `${Math.max(minimumHeight, textarea.scrollHeight)}px`;
14600
+ }
14601
+
14602
+ const autoGrowingTextareaWidths = new WeakMap();
14603
+ const autoGrowingTextareaObserver = typeof ResizeObserver === "function"
14604
+ ? new ResizeObserver((entries) => {
14605
+ entries.forEach(({ target }) => {
14606
+ const width = target.getBoundingClientRect().width;
14607
+ if (autoGrowingTextareaWidths.get(target) === width) return;
14608
+ autoGrowingTextareaWidths.set(target, width);
14609
+ resizeAutoGrowingTextarea(target);
14610
+ });
14611
+ })
14612
+ : null;
14613
+
14614
+ function resizeAutoGrowingTextareas(container) {
14615
+ container?.querySelectorAll("textarea[data-auto-grow]").forEach((textarea) => {
14616
+ resizeAutoGrowingTextarea(textarea);
14617
+ autoGrowingTextareaObserver?.observe(textarea);
14618
+ });
14619
+ }
14620
+
14621
+ let autoGrowingTextareaResizeFrame = null;
14622
+ window.addEventListener("resize", () => {
14623
+ if (autoGrowingTextareaResizeFrame !== null) cancelAnimationFrame(autoGrowingTextareaResizeFrame);
14624
+ autoGrowingTextareaResizeFrame = requestAnimationFrame(() => {
14625
+ autoGrowingTextareaResizeFrame = null;
14626
+ resizeAutoGrowingTextareas(document);
14627
+ });
14628
+ });
14629
+
14012
14630
  function appendRelationshipKeywordChips(editor, values) {
14013
14631
  const input = editor.querySelector("[data-keyword-input]");
14014
14632
  if (!input) return;
14015
14633
  const existing = new Set([...editor.querySelectorAll("[data-keyword-value]")].map((control) => String(control.value).toLocaleLowerCase("zh-CN")));
14016
14634
  const name = editor.dataset.name || "keywords";
14635
+ const removeLabel = editor.dataset.removeLabel || "关键词";
14017
14636
  for (const keyword of uniqueRelationshipKeywords(values)) {
14018
14637
  const key = keyword.toLocaleLowerCase("zh-CN");
14019
14638
  if (existing.has(key)) continue;
14020
14639
  existing.add(key);
14021
- input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除关键词:${esc(keyword)}">×</button></span>`);
14640
+ input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(removeLabel)}:${esc(keyword)}">×</button></span>`);
14022
14641
  }
14023
14642
  }
14024
14643
 
@@ -14690,6 +15309,7 @@ function activateCharacterEditorTab(key) {
14690
15309
  button.tabIndex = active ? 0 : -1;
14691
15310
  });
14692
15311
  document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
15312
+ resizeAutoGrowingTextareas(document.querySelector(`[data-character-editor-panel="${key}"]`));
14693
15313
  if (
14694
15314
  key === "relationships"
14695
15315
  && characterEditorItem?.id
@@ -15662,17 +16282,17 @@ function renderCharacterEditorFields(item) {
15662
16282
  const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
15663
16283
  const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
15664
16284
  const stateEntries = characterStateEntries(item?.currentState ?? {});
16285
+ const raceField = !canReadModule("races")
16286
+ ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
16287
+ : state.races.length
16288
+ ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
16289
+ : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>';
15665
16290
  $("#character-editor-fields").innerHTML = [
15666
16291
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
15667
16292
  `<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
15668
- field("name", "标准名", "text", item?.name) +
16293
+ raceField +
15669
16294
  field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
15670
- field("aliases", "别名", "item-list", item?.aliases ?? []) +
15671
- (!canReadModule("races")
15672
- ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
15673
- : state.races.length
15674
- ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
15675
- : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
16295
+ field("aliases", "别名", "keyword-chips", item?.aliases ?? []) +
15676
16296
  (!canReadModule("organizations")
15677
16297
  ? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
15678
16298
  : organizationOptions.length
@@ -15688,7 +16308,7 @@ function renderCharacterEditorFields(item) {
15688
16308
  field("summary", "人物简介", "textarea", item?.profile?.summary) +
15689
16309
  '<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
15690
16310
  characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
15691
- field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
16311
+ field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
15692
16312
  '<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
15693
16313
  characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
15694
16314
  field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
@@ -15712,9 +16332,8 @@ function renderCharacterEditorFields(item) {
15712
16332
  : '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
15713
16333
  item?.id ? roleplayMemoryToolbarMarkup() : "")
15714
16334
  ].join("");
15715
- const name = $("#character-editor-fields [name='name']");
15716
- if (name) name.required = true;
15717
16335
  bindDynamicListControls($("#character-editor-fields"));
16336
+ bindRelationshipKeywordControls($("#character-editor-fields"));
15718
16337
  renderCharacterAvatar(item);
15719
16338
  renderCharacterEditorRelationships();
15720
16339
  renderCharacterMarkdownSections();
@@ -15794,7 +16413,7 @@ function renderCharacterHistory() {
15794
16413
  const restored = await api(`/api/characters/${characterEditorItem.id}/restore`, { method: "POST", body: { versionNo } });
15795
16414
  characterEditorItem = restored;
15796
16415
  renderCharacterEditorFields(restored);
15797
- $("#character-editor-title").textContent = restored.name;
16416
+ $("#character-editor-name").value = restored.name;
15798
16417
  $("#character-editor-version").textContent = `v${restored.versionNo}`;
15799
16418
  $("#character-change-note").value = "";
15800
16419
  await Promise.all([renderCharacters(), loadAiReferences()]);
@@ -15833,7 +16452,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15833
16452
  characterEditorRelationshipsLoaded = false;
15834
16453
  characterEditorSections = [];
15835
16454
  $("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
15836
- $("#character-editor-title").textContent = item?.name || "新建角色";
16455
+ $("#character-editor-name").value = item?.name ?? "";
15837
16456
  $("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
15838
16457
  $("#character-change-note").value = "";
15839
16458
  $("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
@@ -15882,6 +16501,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15882
16501
  setCharacterHistoryVisible(false);
15883
16502
  renderCharacterEditorFields(item);
15884
16503
  const viewOnly = readOnly || !canEditModule("characters");
16504
+ $("#character-editor-form").classList.toggle("is-read-only", viewOnly);
16505
+ $("#character-editor-name").readOnly = viewOnly;
16506
+ $("#character-editor-name").setAttribute("aria-readonly", String(viewOnly));
15885
16507
  if (viewOnly) {
15886
16508
  $("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
15887
16509
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
@@ -15929,10 +16551,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15929
16551
  busyTarget: form,
15930
16552
  button: submit,
15931
16553
  prepare: async () => {
16554
+ commitRelationshipKeywordInputs(form);
15932
16555
  const body = collectCharacterBody(new FormData(form));
15933
16556
  if (!body.name) {
15934
16557
  toast("请填写角色标准名", "error");
15935
- form.querySelector("[name='name']")?.focus();
16558
+ $("#character-editor-name").focus();
15936
16559
  return null;
15937
16560
  }
15938
16561
  const currentItem = characterEditorItem;
@@ -15946,7 +16569,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15946
16569
  state.characters = upsertEntityCollection(state.characters, saved);
15947
16570
  entityEditorDirty = false;
15948
16571
  renderCharacterAvatar(saved);
15949
- $("#character-editor-title").textContent = saved.name;
16572
+ $("#character-editor-name").value = saved.name;
15950
16573
  $("#character-editor-version").textContent = `v${saved.versionNo}`;
15951
16574
  $("#character-change-note").value = "";
15952
16575
  $("#character-history-button").disabled = false;
@@ -15972,6 +16595,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
15972
16595
  if (item) {
15973
16596
  void loadCharacterMarkdownSections(item.id);
15974
16597
  }
16598
+ (viewOnly ? $("#character-editor-close") : $("#character-editor-name")).focus();
15975
16599
  }
15976
16600
 
15977
16601
  function knowledgeEditorSection(key, title, description, content) {
@@ -17125,11 +17749,16 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
17125
17749
 
17126
17750
  function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
17127
17751
  const values = modelFormValues(item);
17752
+ const modelKindFields = `<div class="form-field model-kind-fields" role="group" aria-labelledby="model-kind-heading"><span id="model-kind-heading">专用模型类型</span><label class="checkbox-field model-capability-option"><input id="model-kind-embedding" name="embeddingModel" type="checkbox" ${values.modelKind === "embedding" ? "checked" : ""}><span><strong>这是一个 embedding 模型</strong><small>只用于语义向量,不会出现在 chat 框或 AI 分析任务中。</small></span></label><label class="checkbox-field model-capability-option"><input id="model-kind-rerank" name="rerankModel" type="checkbox" ${values.modelKind === "rerank" ? "checked" : ""}><span><strong>这是一个 rerank 模型</strong><small>只用于语义候选重排,不会出现在 chat 框或 AI 分析任务中。</small></span></label><small>两项都不勾选时,该模型按普通 chat 模型使用。</small></div>`;
17128
17753
  const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
17129
17754
  const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
17130
17755
  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>`;
17131
17756
  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>`;
17132
- const connectionTestDescription = values.multimodalEnabled && imageDefaultSupported
17757
+ const connectionTestDescription = values.modelKind === "embedding"
17758
+ ? "使用当前供应商凭据调用 OpenAI-compatible embeddings 接口并校验向量。"
17759
+ : values.modelKind === "rerank"
17760
+ ? "使用 Qwen reranker 的 yes/no 模板发起最小相关性判定。"
17761
+ : values.multimodalEnabled && imageDefaultSupported
17133
17762
  ? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
17134
17763
  : "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
17135
17764
  const connectionTest = item && item.providerStatus === "enabled"
@@ -17138,8 +17767,9 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
17138
17767
  <button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
17139
17768
  </section>`
17140
17769
  : "";
17141
- 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("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
17142
- 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", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
17770
+ openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + modelKindFields + `<div data-chat-model-fields>` + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + `</div>` + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
17771
+ const modelKind = form.get("embeddingModel") === "on" ? "embedding" : form.get("rerankModel") === "on" ? "rerank" : "chat";
17772
+ const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), modelKind, purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
17143
17773
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
17144
17774
  await renderPlatformAiConfig();
17145
17775
  await loadModels();
@@ -17157,6 +17787,17 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
17157
17787
  const multimodalInput = $("#model-multimodal-enabled");
17158
17788
  const imageDefaultField = $("#model-image-tool-default-field");
17159
17789
  const imageDefaultInput = $("#model-image-tool-default");
17790
+ const embeddingModelInput = $("#model-kind-embedding");
17791
+ const rerankModelInput = $("#model-kind-rerank");
17792
+ const chatModelFields = $("#dialog-fields [data-chat-model-fields]");
17793
+ const syncModelKindFields = (changedInput = null) => {
17794
+ if (changedInput?.checked) {
17795
+ const other = changedInput === embeddingModelInput ? rerankModelInput : embeddingModelInput;
17796
+ if (other) other.checked = false;
17797
+ }
17798
+ const specialized = Boolean(embeddingModelInput?.checked || rerankModelInput?.checked);
17799
+ chatModelFields?.classList.toggle("hidden", specialized);
17800
+ };
17160
17801
  const syncMultimodalFields = () => {
17161
17802
  if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
17162
17803
  const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
@@ -17183,6 +17824,8 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
17183
17824
  contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
17184
17825
  thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
17185
17826
  multimodalInput?.addEventListener("change", syncMultimodalFields);
17827
+ embeddingModelInput?.addEventListener("change", () => syncModelKindFields(embeddingModelInput));
17828
+ rerankModelInput?.addEventListener("change", () => syncModelKindFields(rerankModelInput));
17186
17829
  $("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
17187
17830
  const button = event.currentTarget;
17188
17831
  button.disabled = true;
@@ -17206,6 +17849,7 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
17206
17849
  syncKimiTemperature();
17207
17850
  syncThinkingEffort();
17208
17851
  syncMultimodalFields();
17852
+ syncModelKindFields();
17209
17853
  }
17210
17854
 
17211
17855
  async function sendAi() {
@@ -17242,8 +17886,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17242
17886
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
17243
17887
  const requestScope = currentAiRequestScope();
17244
17888
  if (!requestScope) return toast("请先选择章节", "error");
17245
- const { taskType, scope, selection } = requestScope;
17246
- if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
17889
+ const { scope } = requestScope;
17247
17890
  const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
17248
17891
  const selectedTaskType = $("#ai-task").value;
17249
17892
  persistActiveAiChatTab();
@@ -17277,9 +17920,6 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17277
17920
  if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
17278
17921
  return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
17279
17922
  }
17280
- if (imageAttachmentIds.length > 0 && taskType !== "chat") {
17281
- return toast("图片附件目前仅支持问答对话", "error");
17282
- }
17283
17923
  try {
17284
17924
  await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
17285
17925
  } catch (error) {
@@ -17288,86 +17928,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17288
17928
  }
17289
17929
  setAiChatTabStatus(tab, "streaming");
17290
17930
  if (retry?.message?.isConnected) retry.message.remove();
17291
- if (taskType !== "chat") {
17292
- if (!retry) {
17293
- try {
17294
- const request = assertAiRequestCurrent(requestHolder.snapshot);
17295
- const persistedUserMessage = await persistAiConversationMessage(
17296
- request.conversationId,
17297
- "user",
17298
- instruction,
17299
- citations,
17300
- { modelId },
17301
- { signal: request.signal }
17302
- );
17303
- assertAiRequestCurrent(request);
17304
- updateAiConversationSummaryFromMessage(persistedUserMessage);
17305
- requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
17306
- tab.modelId = modelId;
17307
- tab.selectedModelId = modelId;
17308
- tab.promptSent = true;
17309
- clearAiChatTabComposer(tab);
17310
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
17311
- if (isActiveAiChatTab(tab)) {
17312
- state.aiConversationModelId = modelId;
17313
- state.aiPromptSent = true;
17314
- syncAiTaskOptions();
17315
- renderAiRoleplayCharacterSelect();
17316
- renderAiQuickActions();
17317
- clearAiPromptComposer();
17318
- }
17319
- } catch (error) {
17320
- if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
17321
- setAiChatTabStatus(tab, "error");
17322
- return toast(`对话记录创建失败:${error.message}`, "error");
17323
- }
17324
- } else {
17325
- prepareAiRetryState(tab, modelId);
17326
- }
17327
- } else if (retry) {
17328
- prepareAiRetryState(tab, modelId);
17329
- }
17931
+ if (retry) prepareAiRetryState(tab, modelId);
17330
17932
  let assistantContent = "";
17331
17933
  let assistantMessage;
17332
17934
  let assistantMetadata = {};
17333
17935
  let persistedStreamMessage = null;
17334
- let suggestion = null;
17335
- if (taskType === "chat") {
17336
- const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
17337
- instruction,
17338
- ...(sceneDirection ? { sceneDirection } : {}),
17339
- ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
17340
- scope,
17341
- modelId,
17342
- citations,
17343
- ...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
17344
- conversationId: requestHolder.snapshot.conversationId,
17345
- ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
17346
- }, retry), createAiIdempotencyKey());
17347
- const request = assertAiRequestCurrent(requestHolder.snapshot);
17348
- if (streamed.action === "warn") return;
17349
- assistantContent = streamed.content;
17350
- assistantMessage = streamed.message;
17351
- assistantMetadata = streamed.metadata;
17352
- persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
17353
- applyAiConversationTitle(streamed.conversationTitle, request.conversationId);
17354
- } else {
17355
- const request = assertAiRequestCurrent(requestHolder.snapshot);
17356
- suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
17357
- method: "POST",
17358
- body: { taskType, instruction, scope, modelId, citations, conversationId: requestHolder.snapshot.conversationId },
17359
- signal: request.signal
17360
- });
17361
- assertAiRequestCurrent(request);
17362
- const suggestionFailed = suggestion.guard?.status === "failed"
17363
- || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
17364
- || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
17365
- if (suggestionFailed) setAiChatTabStatus(tab, "error");
17366
- tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
17367
- if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
17368
- assistantContent = suggestion.content;
17369
- assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
17370
- }
17936
+ let writingSuggestion = null;
17937
+ const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
17938
+ instruction,
17939
+ ...(sceneDirection ? { sceneDirection } : {}),
17940
+ ...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
17941
+ scope,
17942
+ modelId,
17943
+ citations,
17944
+ ...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
17945
+ conversationId: requestHolder.snapshot.conversationId,
17946
+ ...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
17947
+ }, retry), createAiIdempotencyKey());
17948
+ const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
17949
+ if (streamed.action === "warn") return;
17950
+ assistantContent = streamed.content;
17951
+ assistantMessage = streamed.message;
17952
+ assistantMetadata = streamed.metadata;
17953
+ writingSuggestion = streamed.writingSuggestion;
17954
+ persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
17955
+ applyAiConversationTitle(streamed.conversationTitle, streamedRequest.conversationId);
17371
17956
  try {
17372
17957
  const request = assertAiRequestCurrent(requestHolder.snapshot);
17373
17958
  if (persistedStreamMessage) {
@@ -17386,19 +17971,19 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17386
17971
  assistantContent,
17387
17972
  [],
17388
17973
  assistantMetadata,
17389
- { signal: request.signal, requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
17974
+ { signal: request.signal, requestId: aiAssistantRequestId(request) }
17390
17975
  );
17391
17976
  assertAiRequestCurrent(request);
17392
17977
  updateAiConversationSummaryFromMessage(persistedAssistantMessage);
17393
17978
  if (assistantMessage) {
17394
17979
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
17395
17980
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
17396
- } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id, { tab });
17981
+ }
17397
17982
  }
17983
+ if (writingSuggestion && assistantMessage) attachWritingSuggestion(assistantMessage, writingSuggestion, { tab });
17398
17984
  } catch (error) {
17399
17985
  if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
17400
17986
  setAiChatTabStatus(tab, "error");
17401
- if (suggestion) appendSuggestion(suggestion, null, null, { tab });
17402
17987
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
17403
17988
  }
17404
17989
  } catch (error) {
@@ -17481,7 +18066,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
17481
18066
  failureMessage,
17482
18067
  [],
17483
18068
  {},
17484
- { requestId: retry && taskType !== "chat" ? null : aiAssistantRequestId(request) }
18069
+ { requestId: aiAssistantRequestId(request) }
17485
18070
  );
17486
18071
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
17487
18072
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
@@ -17561,6 +18146,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
17561
18146
  let persistedMessageId = null;
17562
18147
  let persistedMessageCreatedAt = null;
17563
18148
  let conversationTitle = null;
18149
+ let writingSuggestion = null;
17564
18150
  let persistedUserMessage = null;
17565
18151
  let contextAction = "ready";
17566
18152
  let warningOnly = false;
@@ -17716,6 +18302,13 @@ async function streamChat(requestHolder, body, idempotencyKey) {
17716
18302
  persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
17717
18303
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
17718
18304
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
18305
+ writingSuggestion = payload.writingSuggestion && typeof payload.writingSuggestion === "object"
18306
+ ? payload.writingSuggestion
18307
+ : null;
18308
+ const writingSuggestionFailed = writingSuggestion?.guard?.status === "failed"
18309
+ || writingSuggestion?.toolCalls?.some((toolCall) => toolCall.status === "failed")
18310
+ || writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
18311
+ if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
17719
18312
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
17720
18313
  setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
17721
18314
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
@@ -17728,7 +18321,18 @@ async function streamChat(requestHolder, body, idempotencyKey) {
17728
18321
  const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
17729
18322
  ? payload.processDurationMs
17730
18323
  : elapsedProcessTime();
17731
- generatedMetadata = { modelDisplayName: payload.model?.displayName, outputTokens: payload.outputTokens, cacheHitPercent: payload.cacheHitPercent, toolCalls, processSteps, processDurationMs };
18324
+ generatedMetadata = {
18325
+ modelDisplayName: payload.model?.displayName,
18326
+ outputTokens: payload.outputTokens,
18327
+ cacheHitPercent: payload.cacheHitPercent,
18328
+ toolCalls,
18329
+ processSteps,
18330
+ processDurationMs,
18331
+ ...(writingSuggestion ? {
18332
+ activeSkills: [writingSuggestion.taskType === "continue" ? "continue-writing" : "polish-writing"],
18333
+ writingSuggestionId: writingSuggestion.id
18334
+ } : {})
18335
+ };
17732
18336
  renderAiProcessSteps(message, processSteps, true, processDurationMs);
17733
18337
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
17734
18338
  attachAssistantCopyAction(message, streamedText);
@@ -17745,18 +18349,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
17745
18349
  assertAiRequestCurrent(requestHolder.snapshot);
17746
18350
  if (streamError) throw streamError;
17747
18351
  assertAiStreamCompleted(streamCompleted);
17748
- return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
18352
+ return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage };
17749
18353
  } catch (error) {
17750
18354
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
17751
18355
  const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
17752
- const interruption = streamedText ? {
18356
+ const hasRenderableProcessSteps = processSteps.some(shouldRenderAiProcessStep);
18357
+ const interruption = streamedText || hasRenderableProcessSteps ? {
17753
18358
  content: streamedText,
17754
18359
  message,
17755
18360
  metadata: {
17756
18361
  interrupted: true,
17757
18362
  interruptionCode,
17758
18363
  interruptionMessage: streamFailure.message.slice(0, 500),
17759
- processDurationMs: Math.min(86_400_000, elapsedProcessTime())
18364
+ processDurationMs: Math.min(86_400_000, elapsedProcessTime()),
18365
+ ...(toolCalls.length ? { toolCalls } : {}),
18366
+ ...(processSteps.length ? { processSteps } : {})
17760
18367
  }
17761
18368
  } : null;
17762
18369
  if (interruption) streamFailure.streamInterruption = interruption;
@@ -17939,47 +18546,91 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
17939
18546
  if (isFailure) renderMessageCardActions(message);
17940
18547
  attachMessageIdentity(message, messageId);
17941
18548
  feed.append(message);
18549
+ const writingSuggestionId = role === "assistant" && typeof metadata?.writingSuggestionId === "string"
18550
+ ? metadata.writingSuggestionId
18551
+ : "";
18552
+ if (writingSuggestionId && !isFailure && !isInterrupted) {
18553
+ api(`/api/suggestions/${encodeURIComponent(writingSuggestionId)}`)
18554
+ .then((suggestion) => attachWritingSuggestion(message, suggestion, { tab }))
18555
+ .catch(() => undefined);
18556
+ }
17942
18557
  scrollAiFeedToBottom(feed);
18558
+ return message;
17943
18559
  }
17944
18560
 
17945
- function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
17946
- const tab = options.tab ?? activeAiChatTab();
17947
- const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
17948
- const message = document.createElement("div");
17949
- message.className = "assistant-message";
17950
- const applicable = suggestion.action !== "note";
17951
- const guard = suggestion.guard;
17952
- const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
17953
- message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, suggestion.cacheHitPercent, `基于 v${suggestion.chapterVersion ?? "-"}`, suggestion.processDurationMs))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
17954
- attachMessageHeading(message, "助手建议", createdAt ?? undefined, tab);
17955
- attachAssistantCopyAction(message, suggestion.content);
17956
- attachMessageIdentity(message, messageId);
17957
- if (applicable) {
17958
- message.querySelector('[data-action="accept"]').addEventListener("click", async () => {
18561
+ function continuationGuardMarkup(guard) {
18562
+ if (!guard) return "";
18563
+ const issues = Array.isArray(guard.issues) ? guard.issues : [];
18564
+ return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
18565
+ }
18566
+
18567
+ async function applyAcceptedWritingSuggestion(message, suggestion) {
18568
+ const result = await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/accept`, { method: "POST", body: {} });
18569
+ state.chapter = result.chapter;
18570
+ resetChapterDraftLineIds(state.chapter);
18571
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
18572
+ $("#chapter-content").value = state.chapter.content;
18573
+ scheduleChapterLineNumbers();
18574
+ updateChapterStats();
18575
+ state.work = await api(`/api/works/${state.work.id}`);
18576
+ renderTree();
18577
+ message.querySelector("[data-writing-suggestion-actions]").innerHTML = "<span>已采纳并生成新版本</span>";
18578
+ toast("AI 建议已采纳,正文已生成新版本");
18579
+ }
18580
+
18581
+ function attachWritingSuggestion(message, suggestion, options = {}) {
18582
+ if (!suggestion || suggestion.action === "note" || !suggestion.id) return message;
18583
+ const suggestionId = String(suggestion.id);
18584
+ if (message.dataset.writingSuggestionId === suggestionId) return message;
18585
+ message.dataset.writingSuggestionId = suggestionId;
18586
+ message.querySelector("[data-writing-suggestion-ui]")?.remove();
18587
+ const heading = message.querySelector(".message-heading > span");
18588
+ if (heading) heading.textContent = "助手建议";
18589
+ const host = document.createElement("div");
18590
+ host.dataset.writingSuggestionUi = "";
18591
+ host.className = "writing-suggestion-ui";
18592
+ host.innerHTML = `${continuationGuardMarkup(suggestion.guard)}<div class="message-actions" data-writing-suggestion-actions></div>`;
18593
+ const actions = host.querySelector("[data-writing-suggestion-actions]");
18594
+ if (suggestion.status === "accepted") {
18595
+ actions.innerHTML = "<span>已采纳并生成新版本</span>";
18596
+ } else if (suggestion.status === "rejected") {
18597
+ actions.innerHTML = "<span>已拒绝</span>";
18598
+ } else {
18599
+ actions.innerHTML = '<button type="button" data-action="accept">采纳到正文</button><button type="button" data-action="reject">拒绝</button>';
18600
+ actions.querySelector('[data-action="accept"]').addEventListener("click", async () => {
17959
18601
  try {
17960
- const result = await api(`/api/suggestions/${suggestion.id}/accept`, { method: "POST", body: {} });
17961
- state.chapter = result.chapter;
17962
- resetChapterDraftLineIds(state.chapter);
17963
- lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
17964
- $("#chapter-content").value = state.chapter.content;
17965
- scheduleChapterLineNumbers();
17966
- updateChapterStats();
17967
- state.work = await api(`/api/works/${state.work.id}`);
17968
- renderTree();
17969
- message.querySelector(".message-actions").innerHTML = "<span>已采纳并生成新版本</span>";
17970
- toast("AI 建议已采纳,正文已生成新版本");
17971
- } catch (error) { toast(error.message, "error"); }
18602
+ await applyAcceptedWritingSuggestion(message, suggestion);
18603
+ } catch (error) {
18604
+ toast(error.message, "error");
18605
+ }
17972
18606
  });
17973
- message.querySelector('[data-action="reject"]').addEventListener("click", async () => {
17974
- await api(`/api/suggestions/${suggestion.id}/reject`, { method: "POST", body: {} });
17975
- message.querySelector(".message-actions").innerHTML = "<span>已拒绝</span>";
18607
+ actions.querySelector('[data-action="reject"]').addEventListener("click", async () => {
18608
+ try {
18609
+ await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/reject`, { method: "POST", body: {} });
18610
+ actions.innerHTML = "<span>已拒绝</span>";
18611
+ } catch (error) {
18612
+ toast(error.message, "error");
18613
+ }
17976
18614
  });
17977
18615
  }
17978
- feed.append(message);
17979
- scrollAiFeedToBottom(feed);
18616
+ message.append(host);
18617
+ const tab = options.tab ?? activeAiChatTab();
18618
+ scrollAiFeedToBottom(options.feed ?? tab?.feed ?? $("#ai-feed"));
17980
18619
  return message;
17981
18620
  }
17982
18621
 
18622
+ function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
18623
+ const tab = options.tab ?? activeAiChatTab();
18624
+ const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
18625
+ const message = appendMessage("assistant", suggestion.content, [], createdAt, {
18626
+ modelDisplayName: suggestion.model?.displayName,
18627
+ outputTokens: suggestion.outputTokens,
18628
+ cacheHitPercent: suggestion.cacheHitPercent,
18629
+ processDurationMs: suggestion.processDurationMs
18630
+ }, messageId, { tab, feed });
18631
+ return attachWritingSuggestion(message, suggestion, { tab, feed });
18632
+ }
18633
+
17983
18634
  function chapterVersionCompareOption(version) {
17984
18635
  return `<option value="version:${Number(version.versionNo)}">v${Number(version.versionNo)} · ${esc(chapterVersionSourceLabel(version.source))}</option>`;
17985
18636
  }
@@ -19418,6 +20069,8 @@ $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
19418
20069
  $("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
19419
20070
  $("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
19420
20071
  $("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
20072
+ $("#chapter-batch-template").addEventListener("input", updateChapterBatchControls);
20073
+ $("#chapter-batch-start").addEventListener("input", updateChapterBatchControls);
19421
20074
  $("#chapter-batch-search").addEventListener("input", renderChapterBatchDialog);
19422
20075
  $("#chapter-batch-select-all").addEventListener("click", () => {
19423
20076
  const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
@@ -19456,7 +20109,7 @@ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
19456
20109
  $("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
19457
20110
  $("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
19458
20111
  $("#character-editor-fields").addEventListener("click", (event) => {
19459
- if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
20112
+ if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove], [data-keyword-chip-remove]")) markEntityEditorDirty();
19460
20113
  const uploadButton = event.target.closest("#character-avatar-upload-button");
19461
20114
  if (uploadButton) {
19462
20115
  if (!characterEditorItem?.id) {
@@ -19659,6 +20312,17 @@ $("#ai-panel-toggle").addEventListener("click", () => {
19659
20312
  panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
19660
20313
  applyPanelLayout(true);
19661
20314
  });
20315
+ $("#ai-semantic-search-toggle").addEventListener("click", () => {
20316
+ setAiSemanticSearchVisible($("#ai-semantic-search-panel").classList.contains("hidden"));
20317
+ });
20318
+ $("#ai-semantic-search-close").addEventListener("click", () => setAiSemanticSearchVisible(false));
20319
+ $("#ai-semantic-search-run").addEventListener("click", () => { void runAiSemanticSearch(); });
20320
+ $("#ai-semantic-query").addEventListener("keydown", (event) => {
20321
+ if (event.key !== "Enter" || event.shiftKey) return;
20322
+ event.preventDefault();
20323
+ void runAiSemanticSearch();
20324
+ });
20325
+ $("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
19662
20326
  setupPanelResize($("#left-panel-resize"), "left");
19663
20327
  setupPanelResize($("#ai-panel-resize"), "ai");
19664
20328
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
@@ -19735,7 +20399,9 @@ $("#module-create-button").addEventListener("click", () => ({ drafts: openDraftD
19735
20399
  $("#ai-prompt").addEventListener("input", async () => {
19736
20400
  updateAiMentionMenu();
19737
20401
  setAiContextMeter(null);
19738
- if (!findAiMention(aiPromptTextBeforeCursor())) return;
20402
+ const textBeforeCursor = aiPromptTextBeforeCursor();
20403
+ if ($("#ai-task").value !== "roleplay" && findAiSkillCommand(textBeforeCursor)) return;
20404
+ if (!findAiMention(textBeforeCursor)) return;
19739
20405
  try {
19740
20406
  await ensureAiReferencesLoaded();
19741
20407
  updateAiMentionMenu();
@@ -19865,6 +20531,7 @@ $("#ai-task").addEventListener("change", async (event) => {
19865
20531
  }
19866
20532
  }
19867
20533
  setAiContextMeter(null);
20534
+ updateAiMentionMenu();
19868
20535
  });
19869
20536
  $("#ai-scope").addEventListener("change", (event) => {
19870
20537
  if (state.aiPromptSent) {
@@ -19875,6 +20542,8 @@ $("#ai-scope").addEventListener("change", (event) => {
19875
20542
  setAiContextMeter(null);
19876
20543
  });
19877
20544
  $("#ai-mention-menu").addEventListener("click", (event) => {
20545
+ const skillButton = event.target.closest("[data-ai-skill-name]");
20546
+ if (skillButton) return selectAiSkill(skillButton);
19878
20547
  const button = event.target.closest("[data-ai-reference-id]");
19879
20548
  if (button) selectAiMention(button);
19880
20549
  });
@@ -20069,6 +20738,11 @@ document.addEventListener("keydown", (event) => {
20069
20738
  return;
20070
20739
  }
20071
20740
  if (event.key === "Escape") {
20741
+ if (!$("#ai-semantic-search-panel").classList.contains("hidden")) {
20742
+ setAiSemanticSearchVisible(false);
20743
+ $("#ai-semantic-search-toggle").focus();
20744
+ return;
20745
+ }
20072
20746
  if (!$("#ai-model-popover").classList.contains("hidden")) {
20073
20747
  setAiModelPickerVisible(false);
20074
20748
  return;
@@ -20413,7 +21087,8 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
20413
21087
  const activeOption = $("#ai-mention-menu").querySelector('[role="option"][aria-selected="true"]');
20414
21088
  if (activeOption) {
20415
21089
  event.preventDefault();
20416
- selectAiMention(activeOption);
21090
+ if (activeOption.dataset.aiSkillName) selectAiSkill(activeOption);
21091
+ else selectAiMention(activeOption);
20417
21092
  return;
20418
21093
  }
20419
21094
  }