@musnows/scriverse 0.6.3 → 0.6.5

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-setting-mention-v1";
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;
@@ -1281,7 +1285,7 @@ function aiReferenceKey(reference) {
1281
1285
  }
1282
1286
 
1283
1287
  function aiReferenceKindLabel(reference) {
1284
- return ({ character: "角色", setting: "设定", chapter: "章节" })[reference.kind] ?? "引用";
1288
+ return ({ character: "角色", setting: "设定", chapter: "章节", "context-settings": "能力" })[reference.kind] ?? "引用";
1285
1289
  }
1286
1290
 
1287
1291
  function createAiReferenceChip(reference) {
@@ -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,131 @@ 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-task").disabled = state.aiPromptSent;
1876
+ $("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
1877
+ $("#ai-scope").disabled = roleplaySelected || state.aiPromptSent;
1878
+ $("#ai-scope").title = state.aiPromptSent
1879
+ ? "对话开始后不能切换上下文引用"
1880
+ : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
1881
+ }
1882
+
1883
+ function applyAiConversationTaskType(taskType) {
1884
+ const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
1885
+ state.aiTaskType = normalizedTaskType;
1886
+ $("#ai-task").value = normalizedTaskType;
1887
+ $("#ai-task").dataset.previousValue = normalizedTaskType;
1888
+ syncAiTaskOptions();
1889
+ }
1890
+
1891
+ function applyAiConversationContextScope(scope) {
1892
+ const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
1893
+ state.aiContextScope = normalizedScope;
1894
+ $("#ai-scope").value = normalizedScope.type === "book" ? "book"
1895
+ : normalizedScope.type === "volume" ? "volume"
1896
+ : normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
1897
+ : normalizedScope.type === "chapter" ? "chapter"
1898
+ : "none";
1899
+ syncAiTaskOptions();
1900
+ }
1901
+
1902
+ function applyAiRoleplayCharacter(character) {
1903
+ state.aiRoleplayCharacter = character?.id ? character : null;
1904
+ const active = Boolean(state.aiRoleplayCharacter);
1905
+ if (active) $("#ai-scope").value = "none";
1906
+ $(".ai-panel").classList.toggle("is-roleplaying", active);
1907
+ $("#ai-prompt").dataset.placeholder = active
1908
+ ? `以 ${String(state.aiRoleplayCharacter.name)} 的身份开始对话……`
1909
+ : "告诉 AI 你想讨论或修改什么……";
1910
+ renderAiRoleplayCharacterSelect();
1911
+ syncAiTaskOptions();
1912
+ renderAiQuickActions();
1913
+ resetAiContextMeter();
1914
+ }
1915
+
1916
+ function refreshAiMessageRoleLabels() {
1917
+ $("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
1918
+ label.textContent = aiAssistantLabel();
1919
+ });
1920
+ }
1921
+
1922
+ async function updateAiRoleplayCharacter(characterId) {
1923
+ const conversationId = await ensureAiConversation();
1924
+ const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
1925
+ method: "PATCH",
1926
+ body: { characterId: characterId || null }
1927
+ });
1928
+ upsertAiConversationSummary(conversation);
1929
+ applyAiConversationTaskType(conversation.taskType);
1930
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1931
+ if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
1932
+ else resetAiFeed();
1933
+ toast(conversation.roleplayCharacter
1934
+ ? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
1935
+ : "已退出角色扮演模式");
1936
+ }
1937
+
1822
1938
  async function ensureAiConversation() {
1823
1939
  if (state.aiConversationId) return state.aiConversationId;
1824
- await createNewAiConversation();
1940
+ await createNewAiConversation($("#ai-task").value);
1825
1941
  return state.aiConversationId;
1826
1942
  }
1827
1943
 
1944
+ async function persistAiConversationTaskType(taskType) {
1945
+ const conversationId = await ensureAiConversation();
1946
+ const conversation = await api(`/api/ai-conversations/${conversationId}/task-type`, {
1947
+ method: "PATCH",
1948
+ body: { taskType }
1949
+ });
1950
+ upsertAiConversationSummary(conversation);
1951
+ applyAiConversationTaskType(conversation.taskType);
1952
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
1953
+ return conversation;
1954
+ }
1955
+
1956
+ async function persistAiConversationContextScope(scope) {
1957
+ const conversationId = await ensureAiConversation();
1958
+ const conversation = await api(`/api/ai-conversations/${conversationId}/context-scope`, {
1959
+ method: "PATCH",
1960
+ body: { scope }
1961
+ });
1962
+ upsertAiConversationSummary(conversation);
1963
+ applyAiConversationContextScope(conversation.contextScope);
1964
+ return conversation;
1965
+ }
1966
+
1828
1967
  async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
1829
1968
  const conversationId = await ensureAiConversation();
1830
1969
  if (!conversationId) throw new Error("无法创建 AI 对话");
@@ -1903,10 +2042,11 @@ function updateAiMentionMenu() {
1903
2042
  ...chapter,
1904
2043
  volumeTitle: volume.title
1905
2044
  }))) ?? [];
1906
- const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query);
2045
+ const options = listAiMentionOptions(state.characters, state.settings, chapters, match.query)
2046
+ .filter((item) => item.kind !== "context-settings" || $("#ai-task").value !== "roleplay");
1907
2047
  menu.innerHTML = options.length
1908
2048
  ? options.map((item) => `<button class="ai-mention-option" type="button" role="option" 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("")
1909
- : '<p class="ai-mention-empty">没有匹配的角色、设定或章节</p>';
2049
+ : '<p class="ai-mention-empty">没有匹配的角色、设定、章节或上下文能力</p>';
1910
2050
  menu.classList.remove("hidden");
1911
2051
  }
1912
2052
 
@@ -2128,7 +2268,7 @@ function clearChapterLineSelection() {
2128
2268
  }
2129
2269
 
2130
2270
  const typographyStorageKey = "ai-novel-typography-v1";
2131
- const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, density: "balanced" });
2271
+ const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 16, aiFontSize: 14, density: "balanced" });
2132
2272
  const cjkFontStacks = {
2133
2273
  system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
2134
2274
  pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
@@ -2144,15 +2284,21 @@ const latinFontStacks = {
2144
2284
  consolas: 'Consolas, "Liberation Mono", Menlo, Monaco, "SFMono-Regular"'
2145
2285
  };
2146
2286
  const typographyFontSizes = [15, 16, 17, 18, 20];
2287
+ const typographyUiFontSizes = [14, 15, 16, 17, 18];
2288
+ const typographyAiFontSizes = [12, 13, 14, 15, 16];
2147
2289
  const densityLineHeights = { compact: 1.4, balanced: 1.55, relaxed: 1.75 };
2148
2290
 
2149
2291
  function normalizeTypographySettings(input) {
2150
2292
  const value = input && typeof input === "object" ? input : {};
2151
2293
  const fontSize = Number(value.fontSize);
2294
+ const uiFontSize = Number(value.uiFontSize);
2295
+ const aiFontSize = Number(value.aiFontSize);
2152
2296
  return {
2153
2297
  cjkFont: Object.hasOwn(cjkFontStacks, value.cjkFont) ? value.cjkFont : typographyDefaults.cjkFont,
2154
2298
  latinFont: Object.hasOwn(latinFontStacks, value.latinFont) ? value.latinFont : typographyDefaults.latinFont,
2155
2299
  fontSize: typographyFontSizes.includes(fontSize) ? fontSize : typographyDefaults.fontSize,
2300
+ uiFontSize: typographyUiFontSizes.includes(uiFontSize) ? uiFontSize : typographyDefaults.uiFontSize,
2301
+ aiFontSize: typographyAiFontSizes.includes(aiFontSize) ? aiFontSize : typographyDefaults.aiFontSize,
2156
2302
  density: Object.hasOwn(densityLineHeights, value.density) ? value.density : typographyDefaults.density
2157
2303
  };
2158
2304
  }
@@ -2204,9 +2350,15 @@ function applyTypographySettings(settings) {
2204
2350
  root.style.setProperty("--font-latin", latinFontStacks[normalized.latinFont]);
2205
2351
  root.style.setProperty("--editor-font-size", `${normalized.fontSize}px`);
2206
2352
  root.style.setProperty("--editor-line-height", String(densityLineHeights[normalized.density]));
2353
+ root.style.setProperty("--ui-font-size", `${normalized.uiFontSize}px`);
2354
+ root.style.setProperty("--ui-font-scale", String(normalized.uiFontSize / typographyDefaults.uiFontSize));
2355
+ root.style.setProperty("--ai-font-size", `${normalized.aiFontSize}px`);
2356
+ root.style.setProperty("--ai-font-scale", String(normalized.aiFontSize / typographyDefaults.aiFontSize));
2207
2357
  root.dataset.cjkFont = normalized.cjkFont;
2208
2358
  root.dataset.latinFont = normalized.latinFont;
2209
2359
  root.dataset.fontSize = String(normalized.fontSize);
2360
+ root.dataset.uiFontSize = String(normalized.uiFontSize);
2361
+ root.dataset.aiFontSize = String(normalized.aiFontSize);
2210
2362
  root.dataset.density = normalized.density;
2211
2363
  scheduleChapterLineNumbers();
2212
2364
  }
@@ -2227,6 +2379,8 @@ function fillAppearanceForm(settings) {
2227
2379
  $("#appearance-cjk-font").value = normalized.cjkFont;
2228
2380
  $("#appearance-latin-font").value = normalized.latinFont;
2229
2381
  $("#appearance-font-size").value = String(normalized.fontSize);
2382
+ $("#appearance-ui-font-size").value = String(normalized.uiFontSize);
2383
+ $("#appearance-ai-font-size").value = String(normalized.aiFontSize);
2230
2384
  $("#appearance-density").value = normalized.density;
2231
2385
  }
2232
2386
 
@@ -2236,6 +2390,8 @@ function readAppearanceForm() {
2236
2390
  cjkFont: form.get("cjkFont"),
2237
2391
  latinFont: form.get("latinFont"),
2238
2392
  fontSize: form.get("fontSize"),
2393
+ uiFontSize: form.get("uiFontSize"),
2394
+ aiFontSize: form.get("aiFontSize"),
2239
2395
  density: form.get("density")
2240
2396
  });
2241
2397
  }
@@ -2246,6 +2402,7 @@ function renderTypographyPreview() {
2246
2402
  preview.style.fontFamily = `${latinFontStacks[settings.latinFont]}, ${cjkFontStacks[settings.cjkFont]}, monospace, sans-serif`;
2247
2403
  preview.style.fontSize = `${settings.fontSize}px`;
2248
2404
  preview.style.lineHeight = String(densityLineHeights[settings.density]);
2405
+ $("#font-size-preview").textContent = `界面 ${settings.uiFontSize} px · Agent 对话 ${settings.aiFontSize} px`;
2249
2406
  }
2250
2407
 
2251
2408
  function openAppearanceDialog() {
@@ -3976,10 +4133,14 @@ function resetWorkScopedUiCaches() {
3976
4133
  state.aiPromptSent = false;
3977
4134
  state.aiConversationId = null;
3978
4135
  state.aiConversations = [];
4136
+ state.aiRoleplayCharacter = null;
3979
4137
  renderAiCitations();
3980
4138
  renderAiReferences();
3981
4139
  renderAiQuickActions();
3982
4140
  resetAiFeed();
4141
+ applyAiConversationTaskType("chat");
4142
+ applyAiConversationContextScope({ type: "none" });
4143
+ applyAiRoleplayCharacter(null);
3983
4144
  $("#ai-conversation-title").textContent = "新对话";
3984
4145
  $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
3985
4146
  resetAiContextMeter();
@@ -6467,6 +6628,7 @@ function openTaskDetailDialog(task, trace) {
6467
6628
  </li>`;
6468
6629
  }
6469
6630
  if (item.type === "book") return "<li>全书</li>";
6631
+ if (item.type === "settings-catalog") return "<li>设定库</li>";
6470
6632
  if (item.type === "selection") return item.restricted
6471
6633
  ? "<li>选定内容(正文读取权限受限)</li>"
6472
6634
  : `<li>选定内容:${esc(item.selection || "未提供")}</li>`;
@@ -6548,7 +6710,7 @@ function renderProviderCards(providers, models) {
6548
6710
  }).join("")}</div>
6549
6711
  <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
6712
  }).join("")}</div>`
6551
- : emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
6713
+ : emptyModule("尚未配置 AI 供应商", "添加 OpenAI、AnthropicGoogle Vertex 接口地址和凭据,测试成功后再添加模型。");
6552
6714
  }
6553
6715
 
6554
6716
  function bindPlatformProviderActions(host, providers, models) {
@@ -7051,6 +7213,7 @@ async function renderBookAiSettings() {
7051
7213
  title: "本书 Token 用量",
7052
7214
  description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
7053
7215
  })}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label><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="10000" max="2000000000" step="1000" 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>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>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"><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)}`;
7216
+ $("#daily-token-quota-status").closest(".config-section").insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label><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>`);
7054
7217
  bindUsageCalendarInteractions(host);
7055
7218
  scrollUsageCalendarsToLatest(host);
7056
7219
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
@@ -7122,6 +7285,20 @@ async function renderBookAiSettings() {
7122
7285
  button.disabled = false;
7123
7286
  }
7124
7287
  });
7288
+ $("#save-always-include-setting-info").addEventListener("click", async () => {
7289
+ const button = $("#save-always-include-setting-info");
7290
+ button.disabled = true;
7291
+ try {
7292
+ const alwaysIncludeSettingInfo = $("#always-include-setting-info").checked;
7293
+ await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { alwaysIncludeSettingInfo } });
7294
+ toast(alwaysIncludeSettingInfo ? "已开启全局设定上下文注入" : "已关闭全局设定上下文注入");
7295
+ setAiContextMeter(null);
7296
+ } catch (error) {
7297
+ toast(error.message, "error");
7298
+ } finally {
7299
+ button.disabled = false;
7300
+ }
7301
+ });
7125
7302
  $("#save-work-system-prompt").addEventListener("click", async () => {
7126
7303
  const button = $("#save-work-system-prompt");
7127
7304
  button.disabled = true;
@@ -7309,21 +7486,31 @@ async function ensureAiModelsLoaded() {
7309
7486
 
7310
7487
  function currentAiRequestScope() {
7311
7488
  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";
7489
+ const selectedTaskType = $("#ai-task").value;
7490
+ const roleplaySelected = selectedTaskType === "roleplay";
7491
+ const taskType = roleplaySelected ? "chat" : selectedTaskType;
7492
+ if (state.aiPromptSent) {
7493
+ const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
7494
+ conversationScope.includeSettingInfo = false;
7495
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7496
+ return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
7497
+ }
7498
+ const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
7499
+ const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
7315
7500
  if (requiresChapter && !state.chapter) return null;
7316
7501
  const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
7317
7502
  const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
7318
7503
  const includeBookSummary = scopeType === "chapter-summary";
7319
- const scope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
7504
+ const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
7320
7505
  : scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
7321
7506
  : scopeType === "book" ? { type: "book" }
7322
7507
  : scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
7508
+ : scopeType === "settings-catalog" ? { type: "settings-catalog" }
7323
7509
  : { type: "chapter", chapterId: state.chapter?.id };
7324
- Object.assign(scope, buildAiReferenceScope(state.aiReferences));
7325
- if (includeBookSummary) scope.includeBookSummary = true;
7326
- return { taskType, scope, selection };
7510
+ if (includeBookSummary) conversationScope.includeBookSummary = true;
7511
+ conversationScope.includeSettingInfo = false;
7512
+ const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
7513
+ return { taskType, scope, conversationScope, selection };
7327
7514
  }
7328
7515
 
7329
7516
  function renderAiContextDistribution(usage) {
@@ -7429,6 +7616,7 @@ async function loadAiReferences() {
7429
7616
  if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
7430
7617
  state.characters = characters;
7431
7618
  state.settings = settings;
7619
+ renderAiRoleplayCharacterSelect();
7432
7620
  loadedAiReferencesWorkId = workId;
7433
7621
  }
7434
7622
 
@@ -9707,23 +9895,66 @@ async function openTaskDialog() {
9707
9895
 
9708
9896
  function openProviderDialog(item) {
9709
9897
  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
- }
9898
+ const providerProtocolOptions = [
9899
+ ["openai-chat-completions", "OpenAI Chat Completions"],
9900
+ ["anthropic-messages", "Anthropic Messages"],
9901
+ ["google-vertex", "Google Vertex"]
9902
+ ];
9903
+ const defaultBaseUrlForProtocol = (value) => {
9904
+ if (value === "anthropic-messages") return "https://api.anthropic.com";
9905
+ if (value === "google-vertex") {
9906
+ return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
9907
+ }
9908
+ return "https://api.openai.com/v1";
9909
+ };
9910
+ const credentialFieldForProtocol = (value) => {
9911
+ if (value === "google-vertex") {
9912
+ return field(
9913
+ "apiKey",
9914
+ item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
9915
+ "textarea",
9916
+ ""
9917
+ );
9918
+ }
9919
+ return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
9920
+ };
9921
+ const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
9922
+ openDialog(
9923
+ item ? "编辑 AI 供应商" : "新建 AI 供应商",
9924
+ field("name", "显示名称", "text", item?.name)
9925
+ + field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
9926
+ + field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
9927
+ + `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
9928
+ + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
9929
+ + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
9930
+ + field("note", "用途备注", "textarea", item?.note)
9931
+ + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
9932
+ async (form) => {
9933
+ const body = {
9934
+ name: form.get("name"),
9935
+ protocol: form.get("protocol"),
9936
+ baseUrl: form.get("baseUrl"),
9937
+ concurrencyLimit: Number(form.get("concurrencyLimit")),
9938
+ rpmLimit: Number(form.get("rpmLimit")),
9939
+ note: form.get("note"),
9940
+ status: form.get("enabled") === "on" ? "enabled" : "disabled"
9941
+ };
9942
+ if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
9943
+ await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
9944
+ await renderPlatformAiConfig();
9945
+ await loadModels();
9946
+ },
9947
+ item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
9948
+ );
9949
+ const protocolSelect = $("#dialog-fields select[name='protocol']");
9950
+ const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
9951
+ const credentialHost = $("#dialog-fields [data-provider-credential-field]");
9952
+ const syncProviderCredentialField = () => {
9953
+ const nextProtocol = protocolSelect.value;
9954
+ credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
9955
+ if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
9956
+ };
9957
+ protocolSelect.addEventListener("change", syncProviderCredentialField);
9727
9958
  }
9728
9959
 
9729
9960
  function openModelDialog(providerId, item = null) {
@@ -9793,10 +10024,17 @@ async function sendAi() {
9793
10024
  if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
9794
10025
  const instruction = aiPromptText().trim();
9795
10026
  if (!instruction) return toast("请输入指令", "error");
10027
+ if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
9796
10028
  const requestScope = currentAiRequestScope();
9797
10029
  if (!requestScope) return toast("请先选择章节", "error");
9798
10030
  const { taskType, scope, selection } = requestScope;
9799
10031
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
10032
+ try {
10033
+ await persistAiConversationTaskType($("#ai-task").value);
10034
+ await persistAiConversationContextScope(requestScope.conversationScope);
10035
+ } catch (error) {
10036
+ return toast(`对话配置锁定失败:${error.message}`, "error");
10037
+ }
9800
10038
  setAiAssistantStatus("ready");
9801
10039
  const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
9802
10040
  let persistedUserMessage = null;
@@ -9808,12 +10046,15 @@ async function sendAi() {
9808
10046
  return toast(`对话记录创建失败:${error.message}`, "error");
9809
10047
  }
9810
10048
  state.aiPromptSent = true;
10049
+ syncAiTaskOptions();
10050
+ renderAiRoleplayCharacterSelect();
9811
10051
  renderAiQuickActions();
9812
10052
  appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9813
10053
  clearAiPromptComposer();
9814
10054
  }
9815
10055
  $("#ai-send").disabled = true;
9816
10056
  $("#ai-send").textContent = "发送中";
10057
+ $("#ai-roleplay-character").disabled = true;
9817
10058
  try {
9818
10059
  let assistantContent = "";
9819
10060
  let assistantMessage;
@@ -9869,6 +10110,7 @@ async function sendAi() {
9869
10110
  } finally {
9870
10111
  $("#ai-send").disabled = false;
9871
10112
  $("#ai-send").textContent = "发送";
10113
+ renderAiRoleplayCharacterSelect();
9872
10114
  }
9873
10115
  }
9874
10116
 
@@ -9882,7 +10124,7 @@ async function streamChat(body) {
9882
10124
  let messageMounted = false;
9883
10125
  const mountAssistantMessage = () => {
9884
10126
  if (messageMounted) return;
9885
- attachMessageHeading(message, "助手 · 正在生成");
10127
+ attachMessageHeading(message, aiAssistantLabel("正在生成"));
9886
10128
  $("#ai-feed").append(message);
9887
10129
  messageMounted = true;
9888
10130
  scrollAiFeedToBottom();
@@ -9955,6 +10197,8 @@ async function streamChat(body) {
9955
10197
  state.aiConversationId = persistedUserMessage.conversationId;
9956
10198
  updateAiConversationSummaryFromMessage(persistedUserMessage);
9957
10199
  state.aiPromptSent = true;
10200
+ syncAiTaskOptions();
10201
+ renderAiRoleplayCharacterSelect();
9958
10202
  renderAiQuickActions();
9959
10203
  appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
9960
10204
  clearAiPromptComposer();
@@ -10038,7 +10282,7 @@ async function streamChat(body) {
10038
10282
  typewriter.reveal();
10039
10283
  message.classList.remove("is-streaming");
10040
10284
  content.setAttribute("aria-busy", "false");
10041
- message.querySelector(".message-heading > span").textContent = "助手 · 生成中断";
10285
+ message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
10042
10286
  renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
10043
10287
  meta.textContent = "生成中断";
10044
10288
  scrollAiFeedToBottom();
@@ -10054,7 +10298,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
10054
10298
  ? `<p class="ai-error-text">${esc(text)}</p>`
10055
10299
  : renderMarkdown(text);
10056
10300
  message.innerHTML = `<div class="message-body">${messageBody}</div>`;
10057
- const heading = attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
10301
+ const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
10058
10302
  if (isFailure) {
10059
10303
  message.dataset.status = "failed";
10060
10304
  const failureBadge = document.createElement("strong");
@@ -11318,8 +11562,58 @@ $("#ai-model").addEventListener("focus", () => {
11318
11562
  ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
11319
11563
  });
11320
11564
  $("#ai-model").addEventListener("change", () => setAiContextMeter(null));
11321
- $("#ai-task").addEventListener("change", () => setAiContextMeter(null));
11322
- $("#ai-scope").addEventListener("change", () => setAiContextMeter(null));
11565
+ $("#ai-roleplay-character").addEventListener("focus", async () => {
11566
+ try {
11567
+ await ensureAiReferencesLoaded();
11568
+ renderAiRoleplayCharacterSelect();
11569
+ } catch (error) {
11570
+ toast(`角色卡加载失败:${error.message}`, "error");
11571
+ }
11572
+ });
11573
+ $("#ai-roleplay-character").addEventListener("change", async (event) => {
11574
+ const select = event.currentTarget;
11575
+ const characterId = select.value;
11576
+ select.disabled = true;
11577
+ try {
11578
+ await updateAiRoleplayCharacter(characterId);
11579
+ } catch (error) {
11580
+ renderAiRoleplayCharacterSelect();
11581
+ toast(`角色扮演模式切换失败:${error.message}`, "error");
11582
+ } finally {
11583
+ renderAiRoleplayCharacterSelect();
11584
+ }
11585
+ });
11586
+ $("#ai-task").addEventListener("change", async (event) => {
11587
+ const select = event.currentTarget;
11588
+ const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
11589
+ const nextTaskType = select.value;
11590
+ if (state.aiPromptSent) {
11591
+ applyAiConversationTaskType(previousTaskType);
11592
+ return toast("当前对话已经开始,请新建对话后再切换任务类型", "error");
11593
+ }
11594
+ applyAiConversationTaskType(nextTaskType);
11595
+ if (state.aiConversationId) {
11596
+ select.disabled = true;
11597
+ try {
11598
+ await persistAiConversationTaskType(nextTaskType);
11599
+ } catch (error) {
11600
+ applyAiConversationTaskType(previousTaskType);
11601
+ toast(`任务类型切换失败:${error.message}`, "error");
11602
+ } finally {
11603
+ syncAiTaskOptions();
11604
+ renderAiRoleplayCharacterSelect();
11605
+ }
11606
+ }
11607
+ setAiContextMeter(null);
11608
+ });
11609
+ $("#ai-scope").addEventListener("change", (event) => {
11610
+ if (state.aiPromptSent) {
11611
+ applyAiConversationContextScope(state.aiContextScope);
11612
+ return toast("当前对话已经开始,请新建对话后再切换上下文引用", "error");
11613
+ }
11614
+ event.currentTarget.title = "";
11615
+ setAiContextMeter(null);
11616
+ });
11323
11617
  $("#ai-mention-menu").addEventListener("click", (event) => {
11324
11618
  const button = event.target.closest("[data-ai-reference-id]");
11325
11619
  if (button) selectAiMention(button);
@@ -11602,7 +11896,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
11602
11896
  $(".quick-actions").addEventListener("click", (event) => {
11603
11897
  const button = event.target.closest("[data-task]");
11604
11898
  if (!button) return;
11605
- $("#ai-task").value = button.dataset.task;
11899
+ applyAiConversationTaskType(button.dataset.task);
11606
11900
  setAiPromptText(button.dataset.prompt);
11607
11901
  $("#ai-prompt").focus();
11608
11902
  });