@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.
- package/dist/ai-protocol.js +22 -9
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai.js +758 -164
- package/dist/ai.js.map +1 -1
- package/dist/app.js +95 -14
- package/dist/app.js.map +1 -1
- package/dist/database.js +146 -2
- package/dist/database.js.map +1 -1
- package/dist/google-vertex-auth.js +155 -0
- package/dist/google-vertex-auth.js.map +1 -0
- package/dist/public/ai-mentions.js +15 -1
- package/dist/public/app.js +339 -45
- package/dist/public/display-labels.js +2 -1
- package/dist/public/index.html +27 -3
- package/dist/public/styles.css +59 -2
- package/dist/store.js +189 -7
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +21 -2
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +8 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -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 {
|
|
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=
|
|
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
|
-
|
|
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}
|
|
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"
|
|
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 或
|
|
6713
|
+
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI、Anthropic 或 Google 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
|
|
7313
|
-
const
|
|
7314
|
-
const
|
|
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
|
|
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
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
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
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9723
|
-
|
|
9724
|
-
|
|
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" ? "作者" :
|
|
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-
|
|
11322
|
-
|
|
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
|
-
|
|
11899
|
+
applyAiConversationTaskType(button.dataset.task);
|
|
11606
11900
|
setAiPromptText(button.dataset.prompt);
|
|
11607
11901
|
$("#ai-prompt").focus();
|
|
11608
11902
|
});
|