@musnows/scriverse 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
- import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
4
+ import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260801-context-lock-v2";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
7
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
@@ -36,7 +36,7 @@ import {
36
36
  taskScopeLabel,
37
37
  timelineStatusLabel,
38
38
  characterStateFieldLabel
39
- } from "/display-labels.js?v=20260728-hybrid-search-v1";
39
+ } from "/display-labels.js?v=20260801-google-vertex";
40
40
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
41
41
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
42
42
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -110,8 +110,11 @@ const state = {
110
110
  aiCitations: [],
111
111
  aiReferences: [],
112
112
  aiPromptSent: false,
113
+ aiTaskType: "chat",
114
+ aiContextScope: { type: "none" },
113
115
  aiConversationId: null,
114
116
  aiConversations: [],
117
+ aiRoleplayCharacter: null,
115
118
  aiLastMessageAt: null,
116
119
  settings: [],
117
120
  dirty: false,
@@ -336,6 +339,7 @@ function applyWorkAccessMode() {
336
339
  $("#ai-prompt").readOnly = aiReadOnly;
337
340
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
338
341
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
342
+ renderAiRoleplayCharacterSelect();
339
343
  updateBackgroundTaskCenterVisibility();
340
344
  if (proseReadOnly) {
341
345
  chapterEditorReadOnly = true;
@@ -1322,7 +1326,7 @@ function renderAiReferences() {
1322
1326
 
1323
1327
  function renderAiQuickActions() {
1324
1328
  const quickActions = $(".quick-actions");
1325
- const visible = shouldShowAiQuickActions(state.aiPromptSent);
1329
+ const visible = !state.aiRoleplayCharacter && shouldShowAiQuickActions(state.aiPromptSent);
1326
1330
  quickActions.classList.toggle("hidden", !visible);
1327
1331
  quickActions.setAttribute("aria-hidden", String(!visible));
1328
1332
  }
@@ -1357,7 +1361,15 @@ function updateMessageCreatedAt(message, createdAt) {
1357
1361
 
1358
1362
  function resetAiFeed() {
1359
1363
  state.aiLastMessageAt = null;
1360
- $("#ai-feed").innerHTML = '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1364
+ const roleplayName = state.aiRoleplayCharacter?.name;
1365
+ $("#ai-feed").innerHTML = roleplayName
1366
+ ? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
1367
+ : '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
1368
+ }
1369
+
1370
+ function aiAssistantLabel(suffix = "") {
1371
+ const name = state.aiRoleplayCharacter?.name || "助手";
1372
+ return suffix ? `${name} · ${suffix}` : name;
1361
1373
  }
1362
1374
 
1363
1375
  function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
@@ -1462,7 +1474,8 @@ const AI_TOOL_DISPLAY_NAMES = {
1462
1474
  grep: "查询正文关键字",
1463
1475
  search_story_entities: "搜索作品实体",
1464
1476
  read_character_sections: "读取人物 Markdown 章节",
1465
- search_drafts: "搜索想法"
1477
+ search_drafts: "搜索想法",
1478
+ recall_self: "回忆自身"
1466
1479
  };
1467
1480
 
1468
1481
  const AI_TOOL_DESCRIPTIONS = {
@@ -1471,7 +1484,8 @@ const AI_TOOL_DESCRIPTIONS = {
1471
1484
  grep: "查询正文关键字所在的完整段落及章节信息。",
1472
1485
  search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
1473
1486
  read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
1474
- search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
1487
+ search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
1488
+ recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。"
1475
1489
  };
1476
1490
 
1477
1491
  let aiFeedScrollFrame = null;
@@ -1690,7 +1704,7 @@ function renderAiConversationHistory() {
1690
1704
  const title = document.createElement("strong");
1691
1705
  title.textContent = conversation.title;
1692
1706
  const meta = document.createElement("small");
1693
- meta.textContent = `${conversation.messageCount} · ${formatDateTime(conversation.updatedAt)}`;
1707
+ meta.textContent = `${conversation.messageCount} 条${conversation.roleplayCharacter?.name ? ` · 扮演 ${conversation.roleplayCharacter.name}` : ""} · ${formatDateTime(conversation.updatedAt)}`;
1694
1708
  button.append(title, meta);
1695
1709
  button.addEventListener("click", () => openAiConversation(conversation.id));
1696
1710
  host.append(button);
@@ -1786,6 +1800,9 @@ async function openAiConversation(conversationId, hideHistory = true) {
1786
1800
  upsertAiConversationSummary(conversation);
1787
1801
  state.aiConversationId = conversation.id;
1788
1802
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1803
+ applyAiConversationTaskType(conversation.taskType);
1804
+ applyAiConversationContextScope(conversation.contextScope);
1805
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1789
1806
  resetAiContextMeter();
1790
1807
  $("#ai-conversation-title").textContent = conversation.title;
1791
1808
  resetAiFeed();
@@ -1805,12 +1822,15 @@ async function openAiConversation(conversationId, hideHistory = true) {
1805
1822
  if (hideHistory) setAiHistoryVisible(false);
1806
1823
  }
1807
1824
 
1808
- async function createNewAiConversation() {
1825
+ async function createNewAiConversation(taskType = "chat") {
1809
1826
  if (!state.work) return;
1810
- const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
1827
+ const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
1811
1828
  upsertAiConversationSummary(conversation);
1812
1829
  state.aiConversationId = conversation.id;
1813
1830
  state.aiPromptSent = false;
1831
+ applyAiConversationTaskType(conversation.taskType);
1832
+ applyAiConversationContextScope(conversation.contextScope);
1833
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1814
1834
  $("#ai-conversation-title").textContent = conversation.title;
1815
1835
  resetAiFeed();
1816
1836
  hideAiContextWarning();
@@ -1819,12 +1839,135 @@ async function createNewAiConversation() {
1819
1839
  setAiHistoryVisible(false);
1820
1840
  }
1821
1841
 
1842
+ function renderAiRoleplayCharacterSelect() {
1843
+ const select = $("#ai-roleplay-character");
1844
+ const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
1845
+ const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
1846
+ const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
1847
+ id: String(character.id),
1848
+ name: String(character.name)
1849
+ }))];
1850
+ if (selectedId && !options.some((option) => option.id === selectedId)) {
1851
+ options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
1852
+ }
1853
+ select.replaceChildren(...options.map((option) => {
1854
+ const element = document.createElement("option");
1855
+ element.value = option.id;
1856
+ element.textContent = option.name;
1857
+ return element;
1858
+ }));
1859
+ select.value = selectedId;
1860
+ const canSelectCharacter = Boolean(state.work)
1861
+ && canReadModule("characters")
1862
+ && canWritePermissionModule(state.work, "ai-chat");
1863
+ select.disabled = !canSelectCharacter || state.aiPromptSent;
1864
+ select.title = canSelectCharacter
1865
+ ? "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
1866
+ : "当前账户没有角色模块读取权限";
1867
+ }
1868
+
1869
+ function syncAiTaskOptions() {
1870
+ const roleplaySelected = $("#ai-task").value === "roleplay";
1871
+ $("#ai-scope").classList.toggle("hidden", roleplaySelected);
1872
+ $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
1873
+ $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
1874
+ $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
1875
+ $(".ai-include-setting-info").classList.toggle("hidden", roleplaySelected);
1876
+ $(".ai-include-setting-info").setAttribute("aria-hidden", String(roleplaySelected));
1877
+ $("#ai-task").disabled = state.aiPromptSent;
1878
+ $("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
1879
+ $("#ai-scope").disabled = roleplaySelected || state.aiPromptSent;
1880
+ $("#ai-scope").title = state.aiPromptSent
1881
+ ? "对话开始后不能切换上下文引用"
1882
+ : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
1883
+ syncAiIncludeSettingInfoControl();
1884
+ }
1885
+
1886
+ function applyAiConversationTaskType(taskType) {
1887
+ const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
1888
+ state.aiTaskType = normalizedTaskType;
1889
+ $("#ai-task").value = normalizedTaskType;
1890
+ $("#ai-task").dataset.previousValue = normalizedTaskType;
1891
+ syncAiTaskOptions();
1892
+ }
1893
+
1894
+ function applyAiConversationContextScope(scope) {
1895
+ const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
1896
+ state.aiContextScope = normalizedScope;
1897
+ $("#ai-scope").value = normalizedScope.type === "book" ? "book"
1898
+ : normalizedScope.type === "volume" ? "volume"
1899
+ : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
1900
+ : normalizedScope.type === "chapter" ? "chapter"
1901
+ : "none";
1902
+ $("#ai-include-setting-info").checked = normalizedScope.includeSettingInfo !== false;
1903
+ syncAiTaskOptions();
1904
+ }
1905
+
1906
+ function applyAiRoleplayCharacter(character) {
1907
+ state.aiRoleplayCharacter = character?.id ? character : null;
1908
+ const active = Boolean(state.aiRoleplayCharacter);
1909
+ if (active) $("#ai-scope").value = "none";
1910
+ $(".ai-panel").classList.toggle("is-roleplaying", active);
1911
+ $("#ai-prompt").dataset.placeholder = active
1912
+ ? `以 ${String(state.aiRoleplayCharacter.name)} 的身份开始对话……`
1913
+ : "告诉 AI 你想讨论或修改什么……";
1914
+ renderAiRoleplayCharacterSelect();
1915
+ syncAiTaskOptions();
1916
+ renderAiQuickActions();
1917
+ resetAiContextMeter();
1918
+ }
1919
+
1920
+ function refreshAiMessageRoleLabels() {
1921
+ $("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
1922
+ label.textContent = aiAssistantLabel();
1923
+ });
1924
+ }
1925
+
1926
+ async function updateAiRoleplayCharacter(characterId) {
1927
+ const conversationId = await ensureAiConversation();
1928
+ const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
1929
+ method: "PATCH",
1930
+ body: { characterId: characterId || null }
1931
+ });
1932
+ upsertAiConversationSummary(conversation);
1933
+ applyAiConversationTaskType(conversation.taskType);
1934
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1935
+ if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
1936
+ else resetAiFeed();
1937
+ toast(conversation.roleplayCharacter
1938
+ ? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
1939
+ : "已退出角色扮演模式");
1940
+ }
1941
+
1822
1942
  async function ensureAiConversation() {
1823
1943
  if (state.aiConversationId) return state.aiConversationId;
1824
- await createNewAiConversation();
1944
+ await createNewAiConversation($("#ai-task").value);
1825
1945
  return state.aiConversationId;
1826
1946
  }
1827
1947
 
1948
+ async function persistAiConversationTaskType(taskType) {
1949
+ const conversationId = await ensureAiConversation();
1950
+ const conversation = await api(`/api/ai-conversations/${conversationId}/task-type`, {
1951
+ method: "PATCH",
1952
+ body: { taskType }
1953
+ });
1954
+ upsertAiConversationSummary(conversation);
1955
+ applyAiConversationTaskType(conversation.taskType);
1956
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1957
+ return conversation;
1958
+ }
1959
+
1960
+ async function persistAiConversationContextScope(scope) {
1961
+ const conversationId = await ensureAiConversation();
1962
+ const conversation = await api(`/api/ai-conversations/${conversationId}/context-scope`, {
1963
+ method: "PATCH",
1964
+ body: { scope }
1965
+ });
1966
+ upsertAiConversationSummary(conversation);
1967
+ applyAiConversationContextScope(conversation.contextScope);
1968
+ return conversation;
1969
+ }
1970
+
1828
1971
  async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
1829
1972
  const conversationId = await ensureAiConversation();
1830
1973
  if (!conversationId) throw new Error("无法创建 AI 对话");
@@ -2128,7 +2271,7 @@ function clearChapterLineSelection() {
2128
2271
  }
2129
2272
 
2130
2273
  const typographyStorageKey = "ai-novel-typography-v1";
2131
- const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, density: "balanced" });
2274
+ const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 16, aiFontSize: 14, density: "balanced" });
2132
2275
  const cjkFontStacks = {
2133
2276
  system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
2134
2277
  pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
@@ -2144,15 +2287,21 @@ const latinFontStacks = {
2144
2287
  consolas: 'Consolas, "Liberation Mono", Menlo, Monaco, "SFMono-Regular"'
2145
2288
  };
2146
2289
  const typographyFontSizes = [15, 16, 17, 18, 20];
2290
+ const typographyUiFontSizes = [14, 15, 16, 17, 18];
2291
+ const typographyAiFontSizes = [12, 13, 14, 15, 16];
2147
2292
  const densityLineHeights = { compact: 1.4, balanced: 1.55, relaxed: 1.75 };
2148
2293
 
2149
2294
  function normalizeTypographySettings(input) {
2150
2295
  const value = input && typeof input === "object" ? input : {};
2151
2296
  const fontSize = Number(value.fontSize);
2297
+ const uiFontSize = Number(value.uiFontSize);
2298
+ const aiFontSize = Number(value.aiFontSize);
2152
2299
  return {
2153
2300
  cjkFont: Object.hasOwn(cjkFontStacks, value.cjkFont) ? value.cjkFont : typographyDefaults.cjkFont,
2154
2301
  latinFont: Object.hasOwn(latinFontStacks, value.latinFont) ? value.latinFont : typographyDefaults.latinFont,
2155
2302
  fontSize: typographyFontSizes.includes(fontSize) ? fontSize : typographyDefaults.fontSize,
2303
+ uiFontSize: typographyUiFontSizes.includes(uiFontSize) ? uiFontSize : typographyDefaults.uiFontSize,
2304
+ aiFontSize: typographyAiFontSizes.includes(aiFontSize) ? aiFontSize : typographyDefaults.aiFontSize,
2156
2305
  density: Object.hasOwn(densityLineHeights, value.density) ? value.density : typographyDefaults.density
2157
2306
  };
2158
2307
  }
@@ -2204,9 +2353,15 @@ function applyTypographySettings(settings) {
2204
2353
  root.style.setProperty("--font-latin", latinFontStacks[normalized.latinFont]);
2205
2354
  root.style.setProperty("--editor-font-size", `${normalized.fontSize}px`);
2206
2355
  root.style.setProperty("--editor-line-height", String(densityLineHeights[normalized.density]));
2356
+ root.style.setProperty("--ui-font-size", `${normalized.uiFontSize}px`);
2357
+ root.style.setProperty("--ui-font-scale", String(normalized.uiFontSize / typographyDefaults.uiFontSize));
2358
+ root.style.setProperty("--ai-font-size", `${normalized.aiFontSize}px`);
2359
+ root.style.setProperty("--ai-font-scale", String(normalized.aiFontSize / typographyDefaults.aiFontSize));
2207
2360
  root.dataset.cjkFont = normalized.cjkFont;
2208
2361
  root.dataset.latinFont = normalized.latinFont;
2209
2362
  root.dataset.fontSize = String(normalized.fontSize);
2363
+ root.dataset.uiFontSize = String(normalized.uiFontSize);
2364
+ root.dataset.aiFontSize = String(normalized.aiFontSize);
2210
2365
  root.dataset.density = normalized.density;
2211
2366
  scheduleChapterLineNumbers();
2212
2367
  }
@@ -2227,6 +2382,8 @@ function fillAppearanceForm(settings) {
2227
2382
  $("#appearance-cjk-font").value = normalized.cjkFont;
2228
2383
  $("#appearance-latin-font").value = normalized.latinFont;
2229
2384
  $("#appearance-font-size").value = String(normalized.fontSize);
2385
+ $("#appearance-ui-font-size").value = String(normalized.uiFontSize);
2386
+ $("#appearance-ai-font-size").value = String(normalized.aiFontSize);
2230
2387
  $("#appearance-density").value = normalized.density;
2231
2388
  }
2232
2389
 
@@ -2236,6 +2393,8 @@ function readAppearanceForm() {
2236
2393
  cjkFont: form.get("cjkFont"),
2237
2394
  latinFont: form.get("latinFont"),
2238
2395
  fontSize: form.get("fontSize"),
2396
+ uiFontSize: form.get("uiFontSize"),
2397
+ aiFontSize: form.get("aiFontSize"),
2239
2398
  density: form.get("density")
2240
2399
  });
2241
2400
  }
@@ -2246,6 +2405,7 @@ function renderTypographyPreview() {
2246
2405
  preview.style.fontFamily = `${latinFontStacks[settings.latinFont]}, ${cjkFontStacks[settings.cjkFont]}, monospace, sans-serif`;
2247
2406
  preview.style.fontSize = `${settings.fontSize}px`;
2248
2407
  preview.style.lineHeight = String(densityLineHeights[settings.density]);
2408
+ $("#font-size-preview").textContent = `界面 ${settings.uiFontSize} px · Agent 对话 ${settings.aiFontSize} px`;
2249
2409
  }
2250
2410
 
2251
2411
  function openAppearanceDialog() {
@@ -3976,10 +4136,14 @@ function resetWorkScopedUiCaches() {
3976
4136
  state.aiPromptSent = false;
3977
4137
  state.aiConversationId = null;
3978
4138
  state.aiConversations = [];
4139
+ state.aiRoleplayCharacter = null;
3979
4140
  renderAiCitations();
3980
4141
  renderAiReferences();
3981
4142
  renderAiQuickActions();
3982
4143
  resetAiFeed();
4144
+ applyAiConversationTaskType("chat");
4145
+ applyAiConversationContextScope({ type: "none" });
4146
+ applyAiRoleplayCharacter(null);
3983
4147
  $("#ai-conversation-title").textContent = "新对话";
3984
4148
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
3985
4149
  resetAiContextMeter();
@@ -6467,6 +6631,7 @@ function openTaskDetailDialog(task, trace) {
6467
6631
  </li>`;
6468
6632
  }
6469
6633
  if (item.type === "book") return "<li>全书</li>";
6634
+ if (item.type === "settings-catalog") return "<li>设定库</li>";
6470
6635
  if (item.type === "selection") return item.restricted
6471
6636
  ? "<li>选定内容(正文读取权限受限)</li>"
6472
6637
  : `<li>选定内容:${esc(item.selection || "未提供")}</li>`;
@@ -6548,7 +6713,7 @@ function renderProviderCards(providers, models) {
6548
6713
  }).join("")}</div>
6549
6714
  <div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
6550
6715
  }).join("")}</div>`
6551
- : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
6716
+ : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、AnthropicGoogle Vertex 接口地址和凭据,测试成功后再添加模型。");
6552
6717
  }
6553
6718
 
6554
6719
  function bindPlatformProviderActions(host, providers, models) {
@@ -7307,23 +7472,51 @@ async function ensureAiModelsLoaded() {
7307
7472
  }
7308
7473
  }
7309
7474
 
7475
+ function syncAiIncludeSettingInfoControl() {
7476
+ const scopeType = $("#ai-scope").value;
7477
+ const checkbox = $("#ai-include-setting-info");
7478
+ const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
7479
+ const enabled = proseScopes.has(scopeType);
7480
+ const roleplaySelected = $("#ai-task").value === "roleplay";
7481
+ checkbox.disabled = roleplaySelected || state.aiPromptSent || !enabled;
7482
+ checkbox.title = state.aiPromptSent
7483
+ ? "对话开始后不能切换上下文选项"
7484
+ : enabled
7485
+ ? "在正文上下文中注入锁定设定、组织/种族简表等"
7486
+ : scopeType === "settings-catalog"
7487
+ ? "设定库范围会直接注入设定目录,无需此选项"
7488
+ : "仅在选择正文类上下文范围时可用";
7489
+ }
7490
+
7310
7491
  function currentAiRequestScope() {
7311
7492
  if (!state.work) return null;
7312
- const taskType = $("#ai-task").value;
7313
- const scopeType = $("#ai-scope").value;
7314
- const requiresChapter = taskType === "polish" || taskType === "continue" || scopeType !== "none";
7493
+ const selectedTaskType = $("#ai-task").value;
7494
+ const roleplaySelected = selectedTaskType === "roleplay";
7495
+ const taskType = roleplaySelected ? "chat" : selectedTaskType;
7496
+ if (state.aiPromptSent) {
7497
+ const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
7498
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7499
+ return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
7500
+ }
7501
+ const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
7502
+ const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
7315
7503
  if (requiresChapter && !state.chapter) return null;
7316
7504
  const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
7317
7505
  const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
7318
7506
  const includeBookSummary = scopeType === "chapter-summary";
7319
- const scope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
7507
+ const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
7320
7508
  : scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
7321
7509
  : scopeType === "book" ? { type: "book" }
7322
7510
  : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
7511
+ : scopeType === "settings-catalog" ? { type: "settings-catalog" }
7323
7512
  : { type: "chapter", chapterId: state.chapter?.id };
7324
- Object.assign(scope, buildAiReferenceScope(state.aiReferences));
7325
- if (includeBookSummary) scope.includeBookSummary = true;
7326
- return { taskType, scope, selection };
7513
+ if (includeBookSummary) conversationScope.includeBookSummary = true;
7514
+ const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
7515
+ if (proseScopes.has(scopeType) || taskType === "polish") {
7516
+ conversationScope.includeSettingInfo = $("#ai-include-setting-info").checked;
7517
+ }
7518
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7519
+ return { taskType, scope, conversationScope, selection };
7327
7520
  }
7328
7521
 
7329
7522
  function renderAiContextDistribution(usage) {
@@ -7429,6 +7622,7 @@ async function loadAiReferences() {
7429
7622
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
7430
7623
  state.characters = characters;
7431
7624
  state.settings = settings;
7625
+ renderAiRoleplayCharacterSelect();
7432
7626
  loadedAiReferencesWorkId = workId;
7433
7627
  }
7434
7628
 
@@ -9707,23 +9901,66 @@ async function openTaskDialog() {
9707
9901
 
9708
9902
  function openProviderDialog(item) {
9709
9903
  const protocol = item?.protocol ?? "openai-chat-completions";
9710
- const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
9711
- openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
9712
- const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
9713
- if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
9714
- await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
9715
- await renderPlatformAiConfig();
9716
- await loadModels();
9717
- }, item ? "协议、限流与凭据" : "OpenAI / Anthropic 兼容协议");
9718
- if (!item) {
9719
- const protocolSelect = $("#dialog-fields select[name='protocol']");
9720
- const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
9721
- protocolSelect.addEventListener("change", () => {
9722
- baseUrlInput.value = protocolSelect.value === "anthropic-messages"
9723
- ? "https://api.anthropic.com"
9724
- : "https://api.openai.com/v1";
9725
- });
9726
- }
9904
+ const providerProtocolOptions = [
9905
+ ["openai-chat-completions", "OpenAI Chat Completions"],
9906
+ ["anthropic-messages", "Anthropic Messages"],
9907
+ ["google-vertex", "Google Vertex"]
9908
+ ];
9909
+ const defaultBaseUrlForProtocol = (value) => {
9910
+ if (value === "anthropic-messages") return "https://api.anthropic.com";
9911
+ if (value === "google-vertex") {
9912
+ return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
9913
+ }
9914
+ return "https://api.openai.com/v1";
9915
+ };
9916
+ const credentialFieldForProtocol = (value) => {
9917
+ if (value === "google-vertex") {
9918
+ return field(
9919
+ "apiKey",
9920
+ item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
9921
+ "textarea",
9922
+ ""
9923
+ );
9924
+ }
9925
+ return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
9926
+ };
9927
+ const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
9928
+ openDialog(
9929
+ item ? "编辑 AI 供应商" : "新建 AI 供应商",
9930
+ field("name", "显示名称", "text", item?.name)
9931
+ + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
9932
+ + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
9933
+ + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
9934
+ + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
9935
+ + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
9936
+ + field("note", "用途备注", "textarea", item?.note)
9937
+ + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
9938
+ async (form) => {
9939
+ const body = {
9940
+ name: form.get("name"),
9941
+ protocol: form.get("protocol"),
9942
+ baseUrl: form.get("baseUrl"),
9943
+ concurrencyLimit: Number(form.get("concurrencyLimit")),
9944
+ rpmLimit: Number(form.get("rpmLimit")),
9945
+ note: form.get("note"),
9946
+ status: form.get("enabled") === "on" ? "enabled" : "disabled"
9947
+ };
9948
+ if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
9949
+ await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
9950
+ await renderPlatformAiConfig();
9951
+ await loadModels();
9952
+ },
9953
+ item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
9954
+ );
9955
+ const protocolSelect = $("#dialog-fields select[name='protocol']");
9956
+ const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
9957
+ const credentialHost = $("#dialog-fields [data-provider-credential-field]");
9958
+ const syncProviderCredentialField = () => {
9959
+ const nextProtocol = protocolSelect.value;
9960
+ credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
9961
+ if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
9962
+ };
9963
+ protocolSelect.addEventListener("change", syncProviderCredentialField);
9727
9964
  }
9728
9965
 
9729
9966
  function openModelDialog(providerId, item = null) {
@@ -9793,10 +10030,17 @@ async function sendAi() {
9793
10030
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
9794
10031
  const instruction = aiPromptText().trim();
9795
10032
  if (!instruction) return toast("请输入指令", "error");
10033
+ if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
9796
10034
  const requestScope = currentAiRequestScope();
9797
10035
  if (!requestScope) return toast("请先选择章节", "error");
9798
10036
  const { taskType, scope, selection } = requestScope;
9799
10037
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
10038
+ try {
10039
+ await persistAiConversationTaskType($("#ai-task").value);
10040
+ await persistAiConversationContextScope(requestScope.conversationScope);
10041
+ } catch (error) {
10042
+ return toast(`对话配置锁定失败:${error.message}`, "error");
10043
+ }
9800
10044
  setAiAssistantStatus("ready");
9801
10045
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9802
10046
  let persistedUserMessage = null;
@@ -9808,12 +10052,15 @@ async function sendAi() {
9808
10052
  return toast(`对话记录创建失败:${error.message}`, "error");
9809
10053
  }
9810
10054
  state.aiPromptSent = true;
10055
+ syncAiTaskOptions();
10056
+ renderAiRoleplayCharacterSelect();
9811
10057
  renderAiQuickActions();
9812
10058
  appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9813
10059
  clearAiPromptComposer();
9814
10060
  }
9815
10061
  $("#ai-send").disabled = true;
9816
10062
  $("#ai-send").textContent = "发送中";
10063
+ $("#ai-roleplay-character").disabled = true;
9817
10064
  try {
9818
10065
  let assistantContent = "";
9819
10066
  let assistantMessage;
@@ -9869,6 +10116,7 @@ async function sendAi() {
9869
10116
  } finally {
9870
10117
  $("#ai-send").disabled = false;
9871
10118
  $("#ai-send").textContent = "发送";
10119
+ renderAiRoleplayCharacterSelect();
9872
10120
  }
9873
10121
  }
9874
10122
 
@@ -9882,7 +10130,7 @@ async function streamChat(body) {
9882
10130
  let messageMounted = false;
9883
10131
  const mountAssistantMessage = () => {
9884
10132
  if (messageMounted) return;
9885
- attachMessageHeading(message, "助手 · 正在生成");
10133
+ attachMessageHeading(message, aiAssistantLabel("正在生成"));
9886
10134
  $("#ai-feed").append(message);
9887
10135
  messageMounted = true;
9888
10136
  scrollAiFeedToBottom();
@@ -9955,6 +10203,8 @@ async function streamChat(body) {
9955
10203
  state.aiConversationId = persistedUserMessage.conversationId;
9956
10204
  updateAiConversationSummaryFromMessage(persistedUserMessage);
9957
10205
  state.aiPromptSent = true;
10206
+ syncAiTaskOptions();
10207
+ renderAiRoleplayCharacterSelect();
9958
10208
  renderAiQuickActions();
9959
10209
  appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9960
10210
  clearAiPromptComposer();
@@ -10038,7 +10288,7 @@ async function streamChat(body) {
10038
10288
  typewriter.reveal();
10039
10289
  message.classList.remove("is-streaming");
10040
10290
  content.setAttribute("aria-busy", "false");
10041
- message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
10291
+ message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
10042
10292
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
10043
10293
  meta.textContent = "生成中断";
10044
10294
  scrollAiFeedToBottom();
@@ -10054,7 +10304,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
10054
10304
  ? `<p class="ai-error-text">${esc(text)}</p>`
10055
10305
  : renderMarkdown(text);
10056
10306
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
10057
- const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
10307
+ const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
10058
10308
  if (isFailure) {
10059
10309
  message.dataset.status = "failed";
10060
10310
  const failureBadge = document.createElement("strong");
@@ -11318,8 +11568,61 @@ $("#ai-model").addEventListener("focus", () => {
11318
11568
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
11319
11569
  });
11320
11570
  $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
11321
- $("#ai-task").addEventListener("change", () => setAiContextMeter(null));
11322
- $("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
11571
+ $("#ai-roleplay-character").addEventListener("focus", async () => {
11572
+ try {
11573
+ await ensureAiReferencesLoaded();
11574
+ renderAiRoleplayCharacterSelect();
11575
+ } catch (error) {
11576
+ toast(`角色卡加载失败:${error.message}`, "error");
11577
+ }
11578
+ });
11579
+ $("#ai-roleplay-character").addEventListener("change", async (event) => {
11580
+ const select = event.currentTarget;
11581
+ const characterId = select.value;
11582
+ select.disabled = true;
11583
+ try {
11584
+ await updateAiRoleplayCharacter(characterId);
11585
+ } catch (error) {
11586
+ renderAiRoleplayCharacterSelect();
11587
+ toast(`角色扮演模式切换失败:${error.message}`, "error");
11588
+ } finally {
11589
+ renderAiRoleplayCharacterSelect();
11590
+ }
11591
+ });
11592
+ $("#ai-task").addEventListener("change", async (event) => {
11593
+ const select = event.currentTarget;
11594
+ const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
11595
+ const nextTaskType = select.value;
11596
+ if (state.aiPromptSent) {
11597
+ applyAiConversationTaskType(previousTaskType);
11598
+ return toast("当前对话已经开始,请新建对话后再切换任务类型", "error");
11599
+ }
11600
+ applyAiConversationTaskType(nextTaskType);
11601
+ if (state.aiConversationId) {
11602
+ select.disabled = true;
11603
+ try {
11604
+ await persistAiConversationTaskType(nextTaskType);
11605
+ } catch (error) {
11606
+ applyAiConversationTaskType(previousTaskType);
11607
+ toast(`任务类型切换失败:${error.message}`, "error");
11608
+ } finally {
11609
+ syncAiTaskOptions();
11610
+ renderAiRoleplayCharacterSelect();
11611
+ }
11612
+ }
11613
+ setAiContextMeter(null);
11614
+ });
11615
+ $("#ai-scope").addEventListener("change", (event) => {
11616
+ if (state.aiPromptSent) {
11617
+ applyAiConversationContextScope(state.aiContextScope);
11618
+ return toast("当前对话已经开始,请新建对话后再切换上下文引用", "error");
11619
+ }
11620
+ event.currentTarget.title = "";
11621
+ syncAiIncludeSettingInfoControl();
11622
+ setAiContextMeter(null);
11623
+ });
11624
+ $("#ai-include-setting-info").addEventListener("change", () => setAiContextMeter(null));
11625
+ syncAiIncludeSettingInfoControl();
11323
11626
  $("#ai-mention-menu").addEventListener("click", (event) => {
11324
11627
  const button = event.target.closest("[data-ai-reference-id]");
11325
11628
  if (button) selectAiMention(button);
@@ -11602,7 +11905,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
11602
11905
  $(".quick-actions").addEventListener("click", (event) => {
11603
11906
  const button = event.target.closest("[data-task]");
11604
11907
  if (!button) return;
11605
- $("#ai-task").value = button.dataset.task;
11908
+ applyAiConversationTaskType(button.dataset.task);
11606
11909
  setAiPromptText(button.dataset.prompt);
11607
11910
  $("#ai-prompt").focus();
11608
11911
  });