@musnows/scriverse 0.9.5 → 0.9.6
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 +6 -0
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-skills.js +134 -0
- package/dist/ai-skills.js.map +1 -0
- package/dist/ai.js +1535 -53
- package/dist/ai.js.map +1 -1
- package/dist/app.js +138 -14
- package/dist/app.js.map +1 -1
- package/dist/chapter-title-numbering.js +56 -0
- package/dist/chapter-title-numbering.js.map +1 -0
- package/dist/database.js +142 -2
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +822 -219
- package/dist/public/chapter-line-id-tracker.d.ts +6 -0
- package/dist/public/chapter-line-id-tracker.js +24 -0
- package/dist/public/index.html +22 -11
- package/dist/public/model-config.d.ts +1 -0
- package/dist/public/model-config.js +9 -4
- package/dist/public/styles.css +107 -8
- package/dist/remote-mcp.js +496 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/security.js +4 -0
- package/dist/security.js.map +1 -1
- package/dist/semantic-search.js +225 -0
- package/dist/semantic-search.js.map +1 -0
- package/dist/skills/continue-writing/SKILL.md +23 -0
- package/dist/skills/polish-writing/SKILL.md +23 -0
- package/dist/store.js +246 -33
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +11 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
package/dist/public/app.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
visibleForeshadowReminders
|
|
26
26
|
} from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
|
|
27
27
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
28
|
-
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1";
|
|
28
|
+
import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, modelThinkingEffortLabel, supportsMultimodalModelProtocol } from "/model-config.js?v=20260822-ai-model-thinking-label-v3&feature=ai-provider-responses-v1&feature=semantic-search-v6";
|
|
29
29
|
import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
|
|
30
30
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
31
31
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
|
|
@@ -52,7 +52,7 @@ import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-p
|
|
|
52
52
|
import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
|
|
53
53
|
import { AI_CHAT_IMAGE_ATTACHMENT_MAX_COUNT, aiChatImageAttachmentIds, isAiChatImageFile, normalizeAiChatImageAttachments } from "/ai-image-attachments.js?v=20260820-ai-chat-image-attachments-v2";
|
|
54
54
|
import { findTextMatches, replaceTextMatches } from "/chapter-search.js?v=20260818-chapter-search-replace-v1";
|
|
55
|
-
import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft } from "/chapter-line-id-tracker.js?v=
|
|
55
|
+
import { MAX_CHAPTER_LINE_IDS, normalizeChapterLineIdDraft, reconcileChapterLineIdDraft, remapChapterLineCounts } from "/chapter-line-id-tracker.js?v=20260829-live-annotation-anchors-v1";
|
|
56
56
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
57
57
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
58
58
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260816-character-gender-v1";
|
|
@@ -180,12 +180,16 @@ function normalizePageSizes(value) {
|
|
|
180
180
|
]));
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
function
|
|
183
|
+
function isAvailableConfiguredModel(model) {
|
|
184
184
|
return Boolean(model?.enabled)
|
|
185
185
|
&& model?.providerStatus === "enabled"
|
|
186
186
|
&& model?.providerConnectionStatus === "success";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
function isSelectableModel(model) {
|
|
190
|
+
return (model?.modelKind ?? "chat") === "chat" && isAvailableConfiguredModel(model);
|
|
191
|
+
}
|
|
192
|
+
|
|
189
193
|
const state = {
|
|
190
194
|
user: null,
|
|
191
195
|
csrfToken: null,
|
|
@@ -201,6 +205,7 @@ const state = {
|
|
|
201
205
|
aiCitations: [],
|
|
202
206
|
aiReferences: [],
|
|
203
207
|
aiImageAttachments: [],
|
|
208
|
+
aiSemanticSnapshot: null,
|
|
204
209
|
aiPromptSent: false,
|
|
205
210
|
aiTaskType: "chat",
|
|
206
211
|
aiContextScope: { type: "none" },
|
|
@@ -296,6 +301,9 @@ let taskProgressRefreshTimer = null;
|
|
|
296
301
|
let taskAutoRunEditing = false;
|
|
297
302
|
let taskAutoRunEditingWorkId = null;
|
|
298
303
|
let relationshipSearchIndexRefreshTimer = null;
|
|
304
|
+
let semanticSearchIndexRefreshTimer = null;
|
|
305
|
+
let aiSemanticSearchResults = [];
|
|
306
|
+
let aiSemanticSearchQuery = "";
|
|
299
307
|
let backgroundTaskCenterTimer = null;
|
|
300
308
|
let backgroundTaskCenterRequest = 0;
|
|
301
309
|
let backgroundTaskCenterWorkId = null;
|
|
@@ -841,7 +849,7 @@ const workspaceOnboardingSteps = [
|
|
|
841
849
|
{ selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲/伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
|
|
842
850
|
{ selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
|
|
843
851
|
{ selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
|
|
844
|
-
{ selector: ".quick-actions button[data-
|
|
852
|
+
{ selector: ".quick-actions button[data-prompt^=\"续写\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
|
|
845
853
|
{ selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
|
|
846
854
|
{ selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和正文 ZIP 导出都集中在这里。", placement: "bottom" },
|
|
847
855
|
{ selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
|
|
@@ -1350,6 +1358,7 @@ function replaceCurrentChapterSearchMatch() {
|
|
|
1350
1358
|
const input = $("#chapter-content");
|
|
1351
1359
|
const replacement = $("#chapter-replace-query").value;
|
|
1352
1360
|
input.value = `${input.value.slice(0, start)}${replacement}${input.value.slice(start + query.length)}`;
|
|
1361
|
+
syncChapterDraftLineIds(input.value);
|
|
1353
1362
|
chapterSearchMatchIndex = Math.min(index, Math.max(0, findTextMatches(input.value, query).length - 1));
|
|
1354
1363
|
updateChapterStats();
|
|
1355
1364
|
clearChapterLineSelection();
|
|
@@ -1369,6 +1378,7 @@ function replaceAllChapterSearchMatches() {
|
|
|
1369
1378
|
const result = replaceTextMatches(input.value, query, $("#chapter-replace-query").value);
|
|
1370
1379
|
if (!result.matches) return renderChapterSearchStatus();
|
|
1371
1380
|
input.value = result.content;
|
|
1381
|
+
syncChapterDraftLineIds(input.value);
|
|
1372
1382
|
chapterSearchMatchIndex = -1;
|
|
1373
1383
|
updateChapterStats();
|
|
1374
1384
|
clearChapterLineSelection();
|
|
@@ -1528,6 +1538,11 @@ let taskListPage = 1;
|
|
|
1528
1538
|
let draftTypeFilter = "all";
|
|
1529
1539
|
let draftBindingFilters = [];
|
|
1530
1540
|
let draftFiltersPanelOpen = false;
|
|
1541
|
+
const chapterCommentFilters = { chapterId: "", keyword: "" };
|
|
1542
|
+
let chapterCommentFiltersPanelOpen = false;
|
|
1543
|
+
let chapterCommentChapterOptions = [];
|
|
1544
|
+
let chapterCommentSearchTimer = null;
|
|
1545
|
+
let chapterCommentRenderRequestId = 0;
|
|
1531
1546
|
const moduleListPages = {
|
|
1532
1547
|
drafts: 1,
|
|
1533
1548
|
settings: 1,
|
|
@@ -2089,6 +2104,148 @@ function renderAiCitations() {
|
|
|
2089
2104
|
setAiContextMeter(null);
|
|
2090
2105
|
}
|
|
2091
2106
|
|
|
2107
|
+
function setAiSemanticSearchVisible(visible) {
|
|
2108
|
+
const panel = $("#ai-semantic-search-panel");
|
|
2109
|
+
panel.classList.toggle("hidden", !visible);
|
|
2110
|
+
$("#ai-semantic-search-toggle").setAttribute("aria-expanded", String(visible));
|
|
2111
|
+
if (visible) $("#ai-semantic-query").focus();
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function semanticSearchScopeTypes(value) {
|
|
2115
|
+
return ({
|
|
2116
|
+
chapter: ["chapter"],
|
|
2117
|
+
setting: ["setting"],
|
|
2118
|
+
character: ["character"],
|
|
2119
|
+
race: ["race"],
|
|
2120
|
+
organization: ["organization"],
|
|
2121
|
+
timeline: ["timeline-track", "timeline-event"],
|
|
2122
|
+
relationship: ["relationship"],
|
|
2123
|
+
outlines: ["chapter-outline", "foreshadow"]
|
|
2124
|
+
})[value] ?? undefined;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function syncAiSemanticSelectionSummary() {
|
|
2128
|
+
const selected = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')];
|
|
2129
|
+
const tokenCount = selected.reduce((total, input) => total + Number(input.dataset.estimatedTokens || 0), 0);
|
|
2130
|
+
$("#ai-semantic-selection-summary").textContent = selected.length
|
|
2131
|
+
? `已选择 ${selected.length} 项,预计 ${tokenCount.toLocaleString("zh-CN")} Token`
|
|
2132
|
+
: "尚未选择结果";
|
|
2133
|
+
$("#ai-semantic-inject").disabled = selected.length === 0 || !state.work || !canWritePermissionModule(state.work, "ai-chat");
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function renderAiSemanticSearchResults(search) {
|
|
2137
|
+
aiSemanticSearchResults = Array.isArray(search?.results) ? search.results : [];
|
|
2138
|
+
const host = $("#ai-semantic-search-results");
|
|
2139
|
+
const matchLabels = { metadata: "资料", exact: "精确", phonetic: "拼音", semantic: "语义" };
|
|
2140
|
+
host.innerHTML = aiSemanticSearchResults.length
|
|
2141
|
+
? aiSemanticSearchResults.map((item, index) => {
|
|
2142
|
+
const lineRange = Number.isInteger(item.startLine)
|
|
2143
|
+
? `<span>${item.startLine === item.endLine ? `第 ${item.startLine} 行` : `第 ${item.startLine}-${item.endLine} 行`}</span>`
|
|
2144
|
+
: "";
|
|
2145
|
+
const matchKinds = (Array.isArray(item.matchKinds) ? item.matchKinds : [])
|
|
2146
|
+
.map((kind) => `<span class="search-result-chip search-result-chip-${esc(kind)}">${esc(matchLabels[kind] ?? kind)}</span>`).join("");
|
|
2147
|
+
const relevance = typeof item.semanticScore === "number" ? `<span>相似度 ${Math.round(item.semanticScore * 1000) / 10}%</span>` : "";
|
|
2148
|
+
const rerank = typeof item.rerankScore === "number" ? `<span>Rerank ${item.rerankScore > 0 ? "相关" : "不相关"}</span>` : "";
|
|
2149
|
+
return `<article class="ai-semantic-result" data-semantic-result-index="${index}"><label>${item.entryId ? `<input type="checkbox" data-semantic-entry-id="${esc(item.entryId)}" data-estimated-tokens="${esc(String(item.estimatedTokens ?? 0))}">` : '<span class="ai-semantic-view-only">仅查看</span>'}<span class="ai-semantic-result-copy"><strong>${esc(searchResultTypeLabel(item.type))} · ${esc(item.title)}</strong><small>${lineRange}${matchKinds}${relevance}${rerank}<span>预计 ${esc(String(item.estimatedTokens ?? 0))} Token</span></small><span>${esc(item.snippet || "无原文片段")}</span></span></label><button type="button" data-semantic-locate="${index}">定位来源</button></article>`;
|
|
2150
|
+
}).join("")
|
|
2151
|
+
: '<p class="ai-semantic-empty">没有找到可展示的结果。</p>';
|
|
2152
|
+
host.querySelectorAll('input[data-semantic-entry-id]').forEach((input) => input.addEventListener("change", syncAiSemanticSelectionSummary));
|
|
2153
|
+
host.querySelectorAll("[data-semantic-locate]").forEach((button) => button.addEventListener("click", async () => {
|
|
2154
|
+
const result = aiSemanticSearchResults[Number(button.dataset.semanticLocate)];
|
|
2155
|
+
if (!result) return;
|
|
2156
|
+
try {
|
|
2157
|
+
setAiSemanticSearchVisible(false);
|
|
2158
|
+
await openSearchResult(result);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
toast(error.message, "error");
|
|
2161
|
+
}
|
|
2162
|
+
}));
|
|
2163
|
+
syncAiSemanticSelectionSummary();
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function renderAiSemanticInjection() {
|
|
2167
|
+
const host = $("#ai-semantic-injection");
|
|
2168
|
+
const snapshot = state.aiSemanticSnapshot;
|
|
2169
|
+
host.classList.toggle("hidden", !snapshot);
|
|
2170
|
+
if (!snapshot) {
|
|
2171
|
+
host.replaceChildren();
|
|
2172
|
+
setAiContextMeter(null);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
host.innerHTML = `<div><strong>本轮语义快照</strong><span>${esc(String(snapshot.itemCount ?? snapshot.items?.length ?? 0))} 项 · 预计 ${Number(snapshot.estimatedTokens ?? 0).toLocaleString("zh-CN")} Token</span><small>${esc(snapshot.query ?? "")}</small></div><button type="button" aria-label="移除本轮语义快照">×</button>`;
|
|
2176
|
+
host.querySelector("button")?.addEventListener("click", () => {
|
|
2177
|
+
state.aiSemanticSnapshot = null;
|
|
2178
|
+
renderAiSemanticInjection();
|
|
2179
|
+
persistActiveAiChatTab();
|
|
2180
|
+
});
|
|
2181
|
+
setAiContextMeter(null);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
async function runAiSemanticSearch() {
|
|
2185
|
+
if (!state.work) return toast("请先选择作品", "error");
|
|
2186
|
+
const query = $("#ai-semantic-query").value.trim();
|
|
2187
|
+
if (!query) return toast("请输入自然语言检索问题", "error");
|
|
2188
|
+
const button = $("#ai-semantic-search-run");
|
|
2189
|
+
button.disabled = true;
|
|
2190
|
+
$("#ai-semantic-search-status").textContent = "正在执行主动语义检索……";
|
|
2191
|
+
try {
|
|
2192
|
+
const selection = state.chapter
|
|
2193
|
+
? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd).slice(0, 4_000)
|
|
2194
|
+
: "";
|
|
2195
|
+
aiSemanticSearchQuery = query;
|
|
2196
|
+
const search = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search`, {
|
|
2197
|
+
method: "POST",
|
|
2198
|
+
body: {
|
|
2199
|
+
query,
|
|
2200
|
+
types: semanticSearchScopeTypes($("#ai-semantic-scope").value),
|
|
2201
|
+
currentChapterId: state.chapter?.id,
|
|
2202
|
+
selection: selection || undefined,
|
|
2203
|
+
includeKeyword: true
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
renderAiSemanticSearchResults(search);
|
|
2207
|
+
$("#ai-semantic-search-status").textContent = search.semanticUsed
|
|
2208
|
+
? `${search.results.length} 项融合结果${search.degraded ? `;${search.reason}` : ";已标注 semantic 通道"}`
|
|
2209
|
+
: `${search.results.length} 项关键词降级结果;${search.reason}`;
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
aiSemanticSearchResults = [];
|
|
2212
|
+
renderAiSemanticSearchResults({ results: [] });
|
|
2213
|
+
$("#ai-semantic-search-status").textContent = error.message;
|
|
2214
|
+
toast(error.message, "error");
|
|
2215
|
+
} finally {
|
|
2216
|
+
button.disabled = false;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function injectAiSemanticSelection() {
|
|
2221
|
+
if (!state.work) return;
|
|
2222
|
+
const entryIds = [...$("#ai-semantic-search-results").querySelectorAll('input[data-semantic-entry-id]:checked')]
|
|
2223
|
+
.map((input) => input.dataset.semanticEntryId)
|
|
2224
|
+
.filter(Boolean);
|
|
2225
|
+
if (!entryIds.length) return;
|
|
2226
|
+
const button = $("#ai-semantic-inject");
|
|
2227
|
+
button.disabled = true;
|
|
2228
|
+
try {
|
|
2229
|
+
const snapshot = await api(`/api/works/${encodeURIComponent(state.work.id)}/semantic-search/snapshots`, {
|
|
2230
|
+
method: "POST",
|
|
2231
|
+
body: {
|
|
2232
|
+
query: aiSemanticSearchQuery,
|
|
2233
|
+
entryIds,
|
|
2234
|
+
scope: { types: semanticSearchScopeTypes($("#ai-semantic-scope").value) ?? [] },
|
|
2235
|
+
conversationId: state.aiConversationId || undefined
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
state.aiSemanticSnapshot = snapshot;
|
|
2239
|
+
renderAiSemanticInjection();
|
|
2240
|
+
persistActiveAiChatTab();
|
|
2241
|
+
setAiSemanticSearchVisible(false);
|
|
2242
|
+
toast(`已将 ${snapshot.itemCount} 项语义原文加入本轮上下文`);
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
toast(error.message, "error");
|
|
2245
|
+
button.disabled = false;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2092
2249
|
function aiReferenceKey(reference) {
|
|
2093
2250
|
return `${reference.kind}:${reference.id}`;
|
|
2094
2251
|
}
|
|
@@ -2170,7 +2327,7 @@ function createAiChatTabState(input = {}) {
|
|
|
2170
2327
|
roleplayUserCharacter: input.roleplayUserCharacter ?? null,
|
|
2171
2328
|
citations: input.citations ?? [],
|
|
2172
2329
|
references: input.references ?? [],
|
|
2173
|
-
composer: input.composer ?? { text: "", citations: [], references: [], images: [], sceneDirection: "", scenePin: emptyRoleplayScenePin() },
|
|
2330
|
+
composer: input.composer ?? { text: "", citations: [], references: [], images: [], semanticSnapshot: null, sceneDirection: "", scenePin: emptyRoleplayScenePin() },
|
|
2174
2331
|
contextUsage: input.contextUsage ?? null,
|
|
2175
2332
|
contextWarning: input.contextWarning === true,
|
|
2176
2333
|
lastMessageAt: input.lastMessageAt ?? null,
|
|
@@ -2213,6 +2370,7 @@ function setAiChatTabComposerSnapshot(tab, snapshot) {
|
|
|
2213
2370
|
citations: tab.citations.map((citation) => ({ ...citation })),
|
|
2214
2371
|
references: tab.references.map((reference) => ({ ...reference })),
|
|
2215
2372
|
images: normalizeAiChatImageAttachments(snapshot.images),
|
|
2373
|
+
semanticSnapshot: snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null,
|
|
2216
2374
|
sceneDirection: String(snapshot.sceneDirection ?? ""),
|
|
2217
2375
|
scenePin: normalizeRoleplayScenePin(snapshot.scenePin)
|
|
2218
2376
|
};
|
|
@@ -2224,6 +2382,7 @@ function clearAiChatTabComposer(tab) {
|
|
|
2224
2382
|
citations: [],
|
|
2225
2383
|
references: [],
|
|
2226
2384
|
images: [],
|
|
2385
|
+
semanticSnapshot: null,
|
|
2227
2386
|
sceneDirection: "",
|
|
2228
2387
|
scenePin: normalizeRoleplayScenePin(tab.composer?.scenePin)
|
|
2229
2388
|
});
|
|
@@ -2237,6 +2396,7 @@ function applyAiChatTabState(tab) {
|
|
|
2237
2396
|
state.aiCitations = tab.citations.map((citation) => ({ ...citation }));
|
|
2238
2397
|
state.aiReferences = tab.references.map((reference) => ({ ...reference }));
|
|
2239
2398
|
state.aiImageAttachments = normalizeAiChatImageAttachments(tab.composer?.images);
|
|
2399
|
+
state.aiSemanticSnapshot = tab.composer?.semanticSnapshot ? structuredClone(tab.composer.semanticSnapshot) : null;
|
|
2240
2400
|
state.aiLastMessageAt = tab.lastMessageAt;
|
|
2241
2401
|
$("#ai-conversation-title").textContent = tab.title || "新对话";
|
|
2242
2402
|
applyAiConversationTaskType(tab.taskType);
|
|
@@ -2248,6 +2408,7 @@ function applyAiChatTabState(tab) {
|
|
|
2248
2408
|
setAiPromptText(tab.composer.text);
|
|
2249
2409
|
restoreAiSceneComposer(tab.composer);
|
|
2250
2410
|
renderAiCitations();
|
|
2411
|
+
renderAiSemanticInjection();
|
|
2251
2412
|
renderAiReferences();
|
|
2252
2413
|
renderAiImageAttachments();
|
|
2253
2414
|
latestAiContextUsage = null;
|
|
@@ -2544,7 +2705,7 @@ function resetAiFeed(
|
|
|
2544
2705
|
const roleplayUserName = roleplayUserCharacter?.name;
|
|
2545
2706
|
feed.innerHTML = roleplayName
|
|
2546
2707
|
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。${roleplayUserName ? `你将以 ${esc(roleplayUserName)} 的身份与我互动。` : "我可以通过角色卡、人物关系、知情设定和故事正文回答。"}</p></div></div>`
|
|
2547
|
-
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p
|
|
2708
|
+
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后即可开始问答;提到续写或润色时会自动加载对应 Skill,也可用 /continue-writing 或 /polish-writing 强制加载。所有引用都基于已保存正文。</p></div></div>';
|
|
2548
2709
|
}
|
|
2549
2710
|
|
|
2550
2711
|
function aiAssistantLabel(suffix = "", roleplayCharacter = state.aiRoleplayCharacter) {
|
|
@@ -3594,9 +3755,7 @@ async function deleteAiConversation(conversation) {
|
|
|
3594
3755
|
|
|
3595
3756
|
const aiConversationTaskTypeLabels = {
|
|
3596
3757
|
chat: "问答",
|
|
3597
|
-
roleplay: "角色扮演"
|
|
3598
|
-
continue: "续写",
|
|
3599
|
-
polish: "润色选中文本"
|
|
3758
|
+
roleplay: "角色扮演"
|
|
3600
3759
|
};
|
|
3601
3760
|
|
|
3602
3761
|
function aiConversationTaskTypeLabel(taskType) {
|
|
@@ -4392,7 +4551,7 @@ function syncAiTaskOptions() {
|
|
|
4392
4551
|
}
|
|
4393
4552
|
|
|
4394
4553
|
function applyAiConversationTaskType(taskType) {
|
|
4395
|
-
const normalizedTaskType =
|
|
4554
|
+
const normalizedTaskType = taskType === "roleplay" ? "roleplay" : "chat";
|
|
4396
4555
|
state.aiTaskType = normalizedTaskType;
|
|
4397
4556
|
const tab = activeAiChatTab();
|
|
4398
4557
|
if (tab) tab.taskType = normalizedTaskType;
|
|
@@ -4559,7 +4718,7 @@ async function persistAiRequestInterruption(request, interruption = null) {
|
|
|
4559
4718
|
const cancelledByClient = request.signal.reason?.code === "AI_REQUEST_CANCELLED";
|
|
4560
4719
|
const metadata = cancelledByClient
|
|
4561
4720
|
? {
|
|
4562
|
-
...(
|
|
4721
|
+
...(interruption?.metadata ?? {}),
|
|
4563
4722
|
interrupted: true,
|
|
4564
4723
|
interruptionCode: "AI_REQUEST_CANCELLED",
|
|
4565
4724
|
interruptionMessage: cancellationMessage.slice(0, 500)
|
|
@@ -4732,10 +4891,12 @@ function clearAiPromptComposer() {
|
|
|
4732
4891
|
state.aiCitations = [];
|
|
4733
4892
|
state.aiReferences = [];
|
|
4734
4893
|
state.aiImageAttachments = [];
|
|
4894
|
+
state.aiSemanticSnapshot = null;
|
|
4735
4895
|
setAiPromptText("");
|
|
4736
4896
|
setAiSceneDirection("");
|
|
4737
4897
|
renderAiCitations();
|
|
4738
4898
|
renderAiImageAttachments();
|
|
4899
|
+
renderAiSemanticInjection();
|
|
4739
4900
|
hideAiMentionMenu();
|
|
4740
4901
|
syncAiSceneComposer();
|
|
4741
4902
|
}
|
|
@@ -4746,6 +4907,7 @@ function captureAiPromptComposer() {
|
|
|
4746
4907
|
citations: state.aiCitations.map((citation) => ({ ...citation })),
|
|
4747
4908
|
references: state.aiReferences.map((reference) => ({ ...reference })),
|
|
4748
4909
|
images: normalizeAiChatImageAttachments(state.aiImageAttachments),
|
|
4910
|
+
semanticSnapshot: state.aiSemanticSnapshot ? structuredClone(state.aiSemanticSnapshot) : null,
|
|
4749
4911
|
sceneDirection: aiSceneDirectionText(),
|
|
4750
4912
|
scenePin: captureAiScenePin()
|
|
4751
4913
|
};
|
|
@@ -4755,10 +4917,12 @@ function restoreAiPromptComposer(snapshot) {
|
|
|
4755
4917
|
state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
|
|
4756
4918
|
state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
|
|
4757
4919
|
state.aiImageAttachments = normalizeAiChatImageAttachments(snapshot.images);
|
|
4920
|
+
state.aiSemanticSnapshot = snapshot.semanticSnapshot ? structuredClone(snapshot.semanticSnapshot) : null;
|
|
4758
4921
|
setAiPromptText(snapshot.text);
|
|
4759
4922
|
restoreAiSceneComposer(snapshot);
|
|
4760
4923
|
renderAiCitations();
|
|
4761
4924
|
renderAiImageAttachments();
|
|
4925
|
+
renderAiSemanticInjection();
|
|
4762
4926
|
hideAiMentionMenu();
|
|
4763
4927
|
}
|
|
4764
4928
|
|
|
@@ -5127,11 +5291,14 @@ async function loadChapterAnnotationCounts(chapterId = state.chapter?.id) {
|
|
|
5127
5291
|
}
|
|
5128
5292
|
const counts = await api(`/api/chapters/${encodeURIComponent(chapterId)}/annotation-counts`);
|
|
5129
5293
|
if (String(state.chapter?.id ?? "") !== String(chapterId)) return;
|
|
5130
|
-
|
|
5294
|
+
const savedLineIds = normalizeChapterLineIdDraft(state.chapter.content, state.chapter.lineIds);
|
|
5295
|
+
const draftLineIds = syncChapterDraftLineIds($("#chapter-content").value);
|
|
5296
|
+
const savedCounts = new Map(
|
|
5131
5297
|
(Array.isArray(counts) ? counts : [])
|
|
5132
5298
|
.map((item) => [Number(item.line), Number(item.count)])
|
|
5133
5299
|
.filter(([line, count]) => Number.isInteger(line) && line > 0 && Number.isInteger(count) && count > 0)
|
|
5134
5300
|
);
|
|
5301
|
+
chapterAnnotationCounts = remapChapterLineCounts(savedLineIds, draftLineIds, savedCounts);
|
|
5135
5302
|
scheduleChapterLineNumbers();
|
|
5136
5303
|
}
|
|
5137
5304
|
|
|
@@ -6367,15 +6534,18 @@ function syncChapterDraftLineIds(content, hint = null) {
|
|
|
6367
6534
|
resetChapterDraftLineIds(state.chapter);
|
|
6368
6535
|
}
|
|
6369
6536
|
if (chapterDraftLineIdState.content !== content) {
|
|
6537
|
+
const beforeLineIds = chapterDraftLineIdState.lineIds;
|
|
6538
|
+
const lineIds = reconcileChapterLineIdDraft(
|
|
6539
|
+
chapterDraftLineIdState.content,
|
|
6540
|
+
content,
|
|
6541
|
+
beforeLineIds,
|
|
6542
|
+
hint
|
|
6543
|
+
);
|
|
6544
|
+
chapterAnnotationCounts = remapChapterLineCounts(beforeLineIds, lineIds, chapterAnnotationCounts);
|
|
6370
6545
|
chapterDraftLineIdState = {
|
|
6371
6546
|
chapterId: state.chapter.id,
|
|
6372
6547
|
content,
|
|
6373
|
-
lineIds
|
|
6374
|
-
chapterDraftLineIdState.content,
|
|
6375
|
-
content,
|
|
6376
|
-
chapterDraftLineIdState.lineIds,
|
|
6377
|
-
hint
|
|
6378
|
-
)
|
|
6548
|
+
lineIds
|
|
6379
6549
|
};
|
|
6380
6550
|
}
|
|
6381
6551
|
return chapterDraftLineIdState.lineIds;
|
|
@@ -8152,6 +8322,13 @@ function resetWorkScopedUiCaches() {
|
|
|
8152
8322
|
draftTypeFilter = "all";
|
|
8153
8323
|
draftBindingFilters = [];
|
|
8154
8324
|
draftFiltersPanelOpen = false;
|
|
8325
|
+
chapterCommentFilters.chapterId = "";
|
|
8326
|
+
chapterCommentFilters.keyword = "";
|
|
8327
|
+
chapterCommentFiltersPanelOpen = false;
|
|
8328
|
+
chapterCommentChapterOptions = [];
|
|
8329
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
8330
|
+
chapterCommentSearchTimer = null;
|
|
8331
|
+
chapterCommentRenderRequestId += 1;
|
|
8155
8332
|
settingFilters.keyword = "";
|
|
8156
8333
|
settingFilters.category = "";
|
|
8157
8334
|
settingFilters.lockState = "all";
|
|
@@ -8459,11 +8636,21 @@ function renderChapterBatchDialog() {
|
|
|
8459
8636
|
function updateChapterBatchControls() {
|
|
8460
8637
|
const count = chapterBatchSelectedIds.size;
|
|
8461
8638
|
const action = $("#chapter-batch-action").value;
|
|
8639
|
+
const renumbering = action === "renumberTitles";
|
|
8640
|
+
const template = $("#chapter-batch-template").value.trim();
|
|
8641
|
+
const templateValid = template.split("{n}").length === 2;
|
|
8642
|
+
const startAt = Number($("#chapter-batch-start").value);
|
|
8643
|
+
const sequenceEnd = startAt + count - 1;
|
|
8462
8644
|
$("#chapter-batch-count").textContent = `已选择 ${count} 章`;
|
|
8463
|
-
$("#chapter-batch-apply").disabled = count === 0;
|
|
8645
|
+
$("#chapter-batch-apply").disabled = count === 0 || (renumbering && (!templateValid || !Number.isInteger(startAt) || startAt < 1 || sequenceEnd > 999999));
|
|
8464
8646
|
$("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
|
|
8465
8647
|
$("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
|
|
8466
|
-
|
|
8648
|
+
for (const id of ["chapter-batch-template-field", "chapter-batch-number-style-field", "chapter-batch-start-field", "chapter-batch-renumber-note"]) {
|
|
8649
|
+
$(`#${id}`).classList.toggle("hidden", !renumbering);
|
|
8650
|
+
}
|
|
8651
|
+
$("#chapter-batch-apply").textContent = action === "delete"
|
|
8652
|
+
? "软删除所选章节"
|
|
8653
|
+
: renumbering ? "重排所选章节" : "应用到所选章节";
|
|
8467
8654
|
}
|
|
8468
8655
|
|
|
8469
8656
|
function openChapterBatchDialog() {
|
|
@@ -8483,16 +8670,41 @@ async function submitChapterBatch(event) {
|
|
|
8483
8670
|
? { type: "move", volumeId: $("#chapter-batch-volume").value }
|
|
8484
8671
|
: actionValue === "setType"
|
|
8485
8672
|
? { type: "setType", chapterType: $("#chapter-batch-type").value }
|
|
8486
|
-
: actionValue === "
|
|
8487
|
-
? {
|
|
8488
|
-
|
|
8673
|
+
: actionValue === "renumberTitles"
|
|
8674
|
+
? {
|
|
8675
|
+
type: "renumberTitles",
|
|
8676
|
+
template: $("#chapter-batch-template").value.trim(),
|
|
8677
|
+
numberStyle: $("#chapter-batch-number-style").value,
|
|
8678
|
+
startAt: Number($("#chapter-batch-start").value)
|
|
8679
|
+
}
|
|
8680
|
+
: actionValue === "exclude" || actionValue === "include"
|
|
8681
|
+
? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
|
|
8682
|
+
: { type: "delete" };
|
|
8489
8683
|
const dialog = $("#chapter-batch-dialog");
|
|
8490
|
-
if (
|
|
8684
|
+
if (state.module === "editor" && state.chapter && state.dirty) {
|
|
8491
8685
|
dialog.close();
|
|
8492
|
-
const
|
|
8493
|
-
|
|
8494
|
-
|
|
8495
|
-
|
|
8686
|
+
const confirmedDiscard = await confirmDiscardChanges("当前章节有未保存修改,批量处理将丢弃这些修改。是否继续?");
|
|
8687
|
+
if (!confirmedDiscard) {
|
|
8688
|
+
dialog.showModal();
|
|
8689
|
+
return;
|
|
8690
|
+
}
|
|
8691
|
+
}
|
|
8692
|
+
if (action.type === "renumberTitles" && (action.template.split("{n}").length !== 2 || !Number.isInteger(action.startAt) || action.startAt < 1 || action.startAt + chapters.length - 1 > 999999)) {
|
|
8693
|
+
toast("标题格式必须且只能包含一个 {n},且所选章节的序号不能超过 999999", "error");
|
|
8694
|
+
$("#chapter-batch-template").focus();
|
|
8695
|
+
return;
|
|
8696
|
+
}
|
|
8697
|
+
if (action.type === "delete" || action.type === "renumberTitles") {
|
|
8698
|
+
dialog.close();
|
|
8699
|
+
const confirmed = action.type === "delete"
|
|
8700
|
+
? await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
|
|
8701
|
+
title: "批量删除需要再次确认",
|
|
8702
|
+
confirmLabel: "确认软删除"
|
|
8703
|
+
})
|
|
8704
|
+
: await confirmToast(`将按目录顺序,把所选 ${chapters.length} 个章节从第 ${action.startAt} 个序号开始重排为“${action.template}”格式。每个改名章节都会保留版本,确认继续吗?`, {
|
|
8705
|
+
title: "重排标题需要再次确认",
|
|
8706
|
+
confirmLabel: "确认重排"
|
|
8707
|
+
});
|
|
8496
8708
|
if (!confirmed) {
|
|
8497
8709
|
dialog.showModal();
|
|
8498
8710
|
return;
|
|
@@ -8500,21 +8712,26 @@ async function submitChapterBatch(event) {
|
|
|
8500
8712
|
}
|
|
8501
8713
|
$("#chapter-batch-apply").disabled = true;
|
|
8502
8714
|
try {
|
|
8503
|
-
await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
|
|
8715
|
+
const result = await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
|
|
8504
8716
|
method: "POST",
|
|
8505
8717
|
body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
|
|
8506
8718
|
});
|
|
8507
8719
|
const workId = state.work.id;
|
|
8508
8720
|
state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
|
|
8721
|
+
const currentEditorVisible = state.module === "editor";
|
|
8509
8722
|
const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
|
|
8510
8723
|
if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
|
|
8511
8724
|
if (state.chapter && !currentStillExists) {
|
|
8512
8725
|
state.chapter = null;
|
|
8513
8726
|
showWelcome(true);
|
|
8727
|
+
} else if (state.chapter && currentEditorVisible) {
|
|
8728
|
+
await selectChapter(state.chapter.id, { editMode: !chapterEditorReadOnly });
|
|
8514
8729
|
} else renderTree();
|
|
8515
8730
|
if (dialog.open) dialog.close();
|
|
8516
8731
|
chapterBatchSelectedIds.clear();
|
|
8517
|
-
toast(
|
|
8732
|
+
toast(action.type === "renumberTitles"
|
|
8733
|
+
? `已按目录顺序重排 ${Number(result.updated ?? chapters.length)} 个章节标题`
|
|
8734
|
+
: `已批量处理 ${chapters.length} 个章节`);
|
|
8518
8735
|
} catch (error) {
|
|
8519
8736
|
if (!dialog.open) dialog.showModal();
|
|
8520
8737
|
$("#chapter-batch-apply").disabled = false;
|
|
@@ -9389,6 +9606,7 @@ function tidyChapterBlankLines() {
|
|
|
9389
9606
|
const normalized = normalizeParagraphSpacing(input.value);
|
|
9390
9607
|
if (normalized === input.value) return toast("正文空行已经符合要求");
|
|
9391
9608
|
input.value = normalized;
|
|
9609
|
+
syncChapterDraftLineIds(input.value);
|
|
9392
9610
|
scheduleChapterLineNumbers();
|
|
9393
9611
|
updateChapterStats();
|
|
9394
9612
|
scheduleChapterAutoSave(120);
|
|
@@ -10043,6 +10261,17 @@ function mountOutlineBoardFilterToggle() {
|
|
|
10043
10261
|
});
|
|
10044
10262
|
}
|
|
10045
10263
|
|
|
10264
|
+
function mountChapterCommentFilterToggle() {
|
|
10265
|
+
$("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]')?.remove();
|
|
10266
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="chapter-comment-filter-toggle" aria-label="筛选正文评论与待办" aria-controls="chapter-comment-filter-panel" aria-expanded="${chapterCommentFiltersPanelOpen}" title="筛选正文评论与待办"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
|
|
10267
|
+
const toggle = $("#module-header-actions").querySelector('[data-module-header-action="chapter-comment-filter-toggle"]');
|
|
10268
|
+
toggle?.addEventListener("click", () => {
|
|
10269
|
+
chapterCommentFiltersPanelOpen = !chapterCommentFiltersPanelOpen;
|
|
10270
|
+
$("#chapter-comment-filter-panel")?.classList.toggle("hidden", !chapterCommentFiltersPanelOpen);
|
|
10271
|
+
toggle.setAttribute("aria-expanded", String(chapterCommentFiltersPanelOpen));
|
|
10272
|
+
});
|
|
10273
|
+
}
|
|
10274
|
+
|
|
10046
10275
|
function bindRecordPreview(selector, open) {
|
|
10047
10276
|
$("#module-content").querySelectorAll(selector).forEach((card) => {
|
|
10048
10277
|
const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
|
|
@@ -11293,32 +11522,102 @@ async function renderRelationships(page = moduleListPages.relationships) {
|
|
|
11293
11522
|
}
|
|
11294
11523
|
|
|
11295
11524
|
async function renderWorkChapterComments(page = moduleListPages.comments) {
|
|
11296
|
-
const
|
|
11297
|
-
const
|
|
11298
|
-
|
|
11299
|
-
|
|
11300
|
-
|
|
11301
|
-
|
|
11302
|
-
|
|
11303
|
-
|
|
11304
|
-
|
|
11305
|
-
|
|
11306
|
-
|
|
11307
|
-
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11311
|
-
|
|
11312
|
-
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
await
|
|
11319
|
-
|
|
11525
|
+
const workId = state.work.id;
|
|
11526
|
+
const generation = workScopedUiGeneration;
|
|
11527
|
+
const hasFilters = () => Boolean(chapterCommentFilters.chapterId || chapterCommentFilters.keyword.trim());
|
|
11528
|
+
const chapterOptionsMarkup = () => `<option value="">全部章节</option>${chapterCommentChapterOptions.map((chapter) => `<option value="${esc(chapter.id)}" ${chapterCommentFilters.chapterId === chapter.id ? "selected" : ""}>${esc(chapter.volumeTitle)} / ${esc(chapter.title)}</option>`).join("")}`;
|
|
11529
|
+
const filterToolbar = `<section id="chapter-comment-filter-panel" class="character-filter-toolbar chapter-comment-filter-toolbar${chapterCommentFiltersPanelOpen ? "" : " hidden"}" aria-label="正文评论与待办筛选">
|
|
11530
|
+
<label class="setting-filter-field" for="chapter-comment-chapter-filter"><span>按章节筛选</span><select id="chapter-comment-chapter-filter" aria-label="按章节筛选正文评论与待办">${chapterOptionsMarkup()}</select></label>
|
|
11531
|
+
<label class="setting-filter-field" for="chapter-comment-keyword-filter"><span>按关键词搜索</span><input id="chapter-comment-keyword-filter" type="search" value="${esc(chapterCommentFilters.keyword)}" placeholder="搜索评论、待办或引用正文" aria-label="按关键词搜索正文评论与待办" autocomplete="off" maxlength="100"></label>
|
|
11532
|
+
<div class="character-filter-toolbar-actions"><span id="chapter-comment-filter-result-count" class="character-filter-result-count${hasFilters() ? "" : " hidden"}" role="status" aria-live="polite"></span><button id="clear-chapter-comment-filters" class="ghost-button" type="button" ${hasFilters() ? "" : "disabled"}>重置筛选</button></div>
|
|
11533
|
+
</section><div id="chapter-comment-filter-results"></div>`;
|
|
11534
|
+
$("#module-content").innerHTML = filterToolbar;
|
|
11535
|
+
mountChapterCommentFilterToggle();
|
|
11536
|
+
|
|
11537
|
+
const refreshResults = async (requestedPage = moduleListPages.comments) => {
|
|
11538
|
+
const requestId = ++chapterCommentRenderRequestId;
|
|
11539
|
+
const pageSize = pageSizeFor("comments");
|
|
11540
|
+
const parameters = new URLSearchParams();
|
|
11541
|
+
if (chapterCommentFilters.chapterId) parameters.set("chapterId", chapterCommentFilters.chapterId);
|
|
11542
|
+
if (chapterCommentFilters.keyword.trim()) parameters.set("q", chapterCommentFilters.keyword.trim());
|
|
11543
|
+
const path = `/api/works/${encodeURIComponent(workId)}/chapter-annotations${parameters.size ? `?${parameters}` : ""}`;
|
|
11544
|
+
$("#chapter-comment-filter-results")?.setAttribute("aria-busy", "true");
|
|
11545
|
+
let result;
|
|
11546
|
+
try {
|
|
11547
|
+
result = await moduleApiPage("comments", path, requestedPage, pageSize);
|
|
11548
|
+
} finally {
|
|
11549
|
+
if (requestId === chapterCommentRenderRequestId) $("#chapter-comment-filter-results")?.removeAttribute("aria-busy");
|
|
11550
|
+
}
|
|
11551
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== chapterCommentRenderRequestId || state.module !== "comments") return;
|
|
11552
|
+
chapterCommentChapterOptions = Array.isArray(result.chapterOptions) ? result.chapterOptions : [];
|
|
11553
|
+
if (chapterCommentFilters.chapterId && !chapterCommentChapterOptions.some((chapter) => chapter.id === chapterCommentFilters.chapterId)) {
|
|
11554
|
+
chapterCommentFilters.chapterId = "";
|
|
11555
|
+
$("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
|
|
11556
|
+
return refreshResults(1);
|
|
11320
11557
|
}
|
|
11558
|
+
$("#chapter-comment-chapter-filter").innerHTML = chapterOptionsMarkup();
|
|
11559
|
+
if (!result.items.length && requestedPage > 1) return refreshResults(requestedPage - 1);
|
|
11560
|
+
const total = Number(result.total ?? result.items.length);
|
|
11561
|
+
const pageCount = Math.max(1, Math.ceil(total / result.limit));
|
|
11562
|
+
const pageResult = { ...result, total, pageCount, itemCount: result.items.length };
|
|
11563
|
+
moduleListPages.comments = pageResult.page;
|
|
11564
|
+
mountModuleCount(total);
|
|
11565
|
+
const completedTodos = result.items.filter((annotation) => annotation.kind === "todo" && annotation.status === "resolved");
|
|
11566
|
+
const visibleAnnotations = result.items.filter((annotation) => annotation.kind !== "todo" || annotation.status !== "resolved");
|
|
11567
|
+
const visibleMarkup = visibleAnnotations.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("");
|
|
11568
|
+
const completedMarkup = completedTodos.length ? `<details class="chapter-comment-completed-group"><summary><span>已完成待办</span><strong>${completedTodos.length}</strong><small>默认折叠,点击展开</small></summary><div class="chapter-comment-completed-list">${completedTodos.map((annotation) => chapterAnnotationCard(annotation, { showSource: true })).join("")}</div></details>` : "";
|
|
11569
|
+
const filtersActive = hasFilters();
|
|
11570
|
+
$("#chapter-comment-filter-results").innerHTML = result.items.length
|
|
11571
|
+
? `<div class="chapter-comment-module-list">${visibleMarkup}${completedMarkup}</div>${renderModulePagination(pageResult, "comments", "正文评论与待办列表")}`
|
|
11572
|
+
: chapterCommentChapterOptions.length
|
|
11573
|
+
? emptyModule("没有符合筛选条件的评论或待办", "可以切换章节、修改关键词或重置筛选。")
|
|
11574
|
+
: emptyModule("还没有正文评论或待办", "在任一正文行上点击右键,即可添加评论或待办。");
|
|
11575
|
+
const resultCount = $("#chapter-comment-filter-result-count");
|
|
11576
|
+
resultCount.textContent = filtersActive ? `筛选后共 ${total} 条评论与待办` : "";
|
|
11577
|
+
resultCount.classList.toggle("hidden", !filtersActive);
|
|
11578
|
+
$("#clear-chapter-comment-filters").disabled = !filtersActive;
|
|
11579
|
+
bindModulePagination("comments", refreshResults);
|
|
11580
|
+
bindChapterAnnotationCards($("#chapter-comment-filter-results"), result.items, {
|
|
11581
|
+
refresh: () => refreshResults(pageResult.page),
|
|
11582
|
+
locate: async (annotation) => {
|
|
11583
|
+
const selected = await selectChapter(annotation.chapterId);
|
|
11584
|
+
if (!selected || String(state.chapter?.id ?? "") !== String(annotation.chapterId)) return;
|
|
11585
|
+
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
|
11586
|
+
revealChapterLines(annotation.startLine, annotation.endLine);
|
|
11587
|
+
}
|
|
11588
|
+
});
|
|
11589
|
+
};
|
|
11590
|
+
|
|
11591
|
+
$("#chapter-comment-chapter-filter").addEventListener("change", (event) => {
|
|
11592
|
+
chapterCommentFilters.chapterId = event.currentTarget.value;
|
|
11593
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11594
|
+
moduleListPages.comments = 1;
|
|
11595
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11596
|
+
chapterCommentSearchTimer = null;
|
|
11597
|
+
void refreshResults(1).catch((error) => toast(error.message, "error"));
|
|
11598
|
+
});
|
|
11599
|
+
$("#chapter-comment-keyword-filter").addEventListener("input", (event) => {
|
|
11600
|
+
chapterCommentFilters.keyword = event.currentTarget.value;
|
|
11601
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11602
|
+
moduleListPages.comments = 1;
|
|
11603
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11604
|
+
chapterCommentSearchTimer = window.setTimeout(() => {
|
|
11605
|
+
chapterCommentSearchTimer = null;
|
|
11606
|
+
void refreshResults(1).catch((error) => toast(error.message, "error"));
|
|
11607
|
+
}, 250);
|
|
11608
|
+
});
|
|
11609
|
+
$("#clear-chapter-comment-filters").addEventListener("click", () => {
|
|
11610
|
+
chapterCommentFilters.chapterId = "";
|
|
11611
|
+
chapterCommentFilters.keyword = "";
|
|
11612
|
+
chapterCommentFiltersPanelOpen = true;
|
|
11613
|
+
moduleListPages.comments = 1;
|
|
11614
|
+
clearTimeout(chapterCommentSearchTimer);
|
|
11615
|
+
chapterCommentSearchTimer = null;
|
|
11616
|
+
$("#chapter-comment-chapter-filter").value = "";
|
|
11617
|
+
$("#chapter-comment-keyword-filter").value = "";
|
|
11618
|
+
void refreshResults(1).then(() => $("#chapter-comment-keyword-filter")?.focus()).catch((error) => toast(error.message, "error"));
|
|
11321
11619
|
});
|
|
11620
|
+
await refreshResults(page);
|
|
11322
11621
|
}
|
|
11323
11622
|
|
|
11324
11623
|
async function renderReviews(page = moduleListPages.reviews) {
|
|
@@ -12465,16 +12764,17 @@ function renderProviderCards(providers, models, protocolOptions) {
|
|
|
12465
12764
|
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol, protocolOptions))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
12466
12765
|
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n最大输出参数:${esc(provider.maxTokensParameter ?? "max_tokens")}\n思考类型:${esc(provider.thinkingType ?? "enabled")}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}\n分析请求超时:${Number(provider.analysisTimeoutSeconds ?? DEFAULT_AI_ANALYSIS_TIMEOUT_SECONDS).toLocaleString("zh-CN")} 秒\n每日 Token 额度:${provider.dailyTokenQuota === null || provider.dailyTokenQuota === undefined ? "未限制" : Number(provider.dailyTokenQuota).toLocaleString("zh-CN")} · 每月 Token 额度:${provider.monthlyTokenQuota === null || provider.monthlyTokenQuota === undefined ? "未限制" : Number(provider.monthlyTokenQuota).toLocaleString("zh-CN")}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
12467
12766
|
<div class="provider-models">${providerModels.map((model) => {
|
|
12468
|
-
const modelUnavailable = !
|
|
12767
|
+
const modelUnavailable = !isAvailableConfiguredModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
12469
12768
|
const modelStatus = !model.enabled
|
|
12470
12769
|
? `<span class="model-status-badge is-disabled">模型已停用</span>`
|
|
12471
12770
|
: provider.connectionStatus !== "success"
|
|
12472
12771
|
? `<span class="model-status-badge is-unavailable">连接不可用</span>`
|
|
12473
12772
|
: "";
|
|
12773
|
+
const kindLabel = model.modelKind === "embedding" ? "Embedding" : model.modelKind === "rerank" ? "Rerank" : "Chat";
|
|
12474
12774
|
const capability = model.multimodalEnabled ? " · 多模态" : "";
|
|
12475
12775
|
const defaultBadge = model.imageToolDefault ? " · 默认读图模型" : "";
|
|
12476
12776
|
const thinkingEffortLabel = MODEL_THINKING_EFFORT_OPTIONS.find(([value]) => value === model.thinkingEffort)?.[1] ?? "模型默认";
|
|
12477
|
-
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}</div>`;
|
|
12777
|
+
return `<div class="provider-model-row${modelUnavailable ? " is-unavailable" : ""}"><button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${esc(kindLabel)} · ${model.enabled ? "启用" : "停用"}${capability}${defaultBadge}${model.modelKind === "chat" ? ` · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 思考强度 ${esc(thinkingEffortLabel)} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}` : ""}</button>${modelStatus}</div>`;
|
|
12478
12778
|
}).join("")}</div>
|
|
12479
12779
|
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-import-provider-models="${esc(provider.id)}">获取模型</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
12480
12780
|
}).join("")}</div>`
|
|
@@ -12598,7 +12898,7 @@ function renderTaskDefaults(models, providers, taskDefaults, settings, protocolO
|
|
|
12598
12898
|
<option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
|
|
12599
12899
|
${models.map((model) => {
|
|
12600
12900
|
const provider = providerById.get(model.providerId);
|
|
12601
|
-
const available = model
|
|
12901
|
+
const available = isSelectableModel({ ...model, providerStatus: provider?.status, providerConnectionStatus: provider?.connectionStatus });
|
|
12602
12902
|
return `<option value="${esc(model.id)}" ${model.id === settings.titleGenerationModelId ? "selected" : ""} ${available || model.id === settings.titleGenerationModelId ? "" : "disabled"}>${esc(modelOptionLabel({ ...model, providerName: model.providerName || provider?.name }))}</option>`;
|
|
12603
12903
|
}).join("")}
|
|
12604
12904
|
</select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
|
|
@@ -12656,6 +12956,34 @@ function relationshipIndexStatusMarkup(status) {
|
|
|
12656
12956
|
</div>`;
|
|
12657
12957
|
}
|
|
12658
12958
|
|
|
12959
|
+
const semanticIndexStatusLabels = Object.freeze({
|
|
12960
|
+
disabled: "未开启",
|
|
12961
|
+
unconfigured: "配置不完整",
|
|
12962
|
+
idle: "等待重建",
|
|
12963
|
+
building: "正在构建",
|
|
12964
|
+
ready: "可以检索",
|
|
12965
|
+
failed: "部分失败",
|
|
12966
|
+
paused: "已暂停"
|
|
12967
|
+
});
|
|
12968
|
+
|
|
12969
|
+
function semanticIndexStatusMarkup(status = {}) {
|
|
12970
|
+
const progress = Math.min(100, Math.max(0, Number(status.progress) || 0));
|
|
12971
|
+
const statusName = semanticIndexStatusLabels[status.status] ?? "状态未知";
|
|
12972
|
+
const model = status.embeddingModel
|
|
12973
|
+
? `${status.embeddingModel.providerName} · ${status.embeddingModel.displayName}`
|
|
12974
|
+
: "尚未选择 embedding 模型";
|
|
12975
|
+
const rerank = status.rerankModel
|
|
12976
|
+
? `${status.rerankModel.providerName} · ${status.rerankModel.displayName}`
|
|
12977
|
+
: "未启用 rerank";
|
|
12978
|
+
return `<div class="semantic-index-summary">
|
|
12979
|
+
<div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>Embedding <strong>${esc(model)}</strong></span><span>Rerank <strong>${esc(rerank)}</strong></span></div>
|
|
12980
|
+
<progress class="semantic-index-progress" max="100" value="${esc(String(progress))}" aria-label="RAG 构建进度 ${esc(String(progress))}%"></progress>
|
|
12981
|
+
<dl class="relationship-index-metrics"><div><dt>构建进度</dt><dd>${esc(String(progress))}%</dd></div><div><dt>已处理来源</dt><dd>${esc(String(status.processedSources ?? 0))} / ${esc(String(status.totalSources ?? 0))}</dd></div><div><dt>有效分片</dt><dd>${esc(String(status.indexedChunkCount ?? 0))}</dd></div><div><dt>失败来源</dt><dd>${esc(String(status.failedSources ?? 0))}</dd></div></dl>
|
|
12982
|
+
${status.error ? `<p class="relationship-index-error">${esc(status.error)}</p>` : ""}
|
|
12983
|
+
${status.status === "paused" ? `<p class="usage-measurement-note">已连续失败 ${esc(String(status.consecutiveFailures ?? 0))} 次。检查端点和模型后点击“完整重建 RAG”恢复。</p>` : ""}
|
|
12984
|
+
</div>`;
|
|
12985
|
+
}
|
|
12986
|
+
|
|
12659
12987
|
function updateBackgroundTaskCenterVisibility() {
|
|
12660
12988
|
const button = $("#background-task-button");
|
|
12661
12989
|
if (!button) return;
|
|
@@ -13224,6 +13552,10 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
13224
13552
|
<td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
|
|
13225
13553
|
<td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
|
|
13226
13554
|
</tr>`).join("");
|
|
13555
|
+
const callTypeLabels = { chat: "Chat / 分析", embedding: "Embedding", rerank: "Rerank" };
|
|
13556
|
+
const callTypeUsage = (Array.isArray(usage?.callTypes) ? usage.callTypes : [])
|
|
13557
|
+
.map((item) => `<span class="usage-call-type-chip"><strong>${esc(callTypeLabels[item.callType] ?? item.callType)}</strong><span>${esc(formatTokenCount(item.totalTokens))} Token · ${Number(item.requestCount || 0).toLocaleString("zh-CN")} 次</span></span>`)
|
|
13558
|
+
.join("");
|
|
13227
13559
|
return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
|
|
13228
13560
|
<div class="config-section-header usage-overview-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div><button class="ghost-button usage-details-button" type="button" data-token-usage-details aria-haspopup="dialog" aria-expanded="false" aria-controls="token-usage-details-toast">详细数据</button></div>
|
|
13229
13561
|
<div class="usage-stat-grid">
|
|
@@ -13233,6 +13565,7 @@ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false
|
|
|
13233
13565
|
<article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
|
|
13234
13566
|
</div>
|
|
13235
13567
|
<p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)} 有 ${unpricedModelCount.toLocaleString("zh-CN")} 个模型在价格表中未找到对应价格</p>
|
|
13568
|
+
${callTypeUsage ? `<div class="usage-call-types" aria-label="按调用类型区分的 Token 用量">${callTypeUsage}</div>` : ""}
|
|
13236
13569
|
<section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
|
|
13237
13570
|
<header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
|
|
13238
13571
|
${tokenUsageCalendarMarkup(usage?.daily)}
|
|
@@ -13261,22 +13594,35 @@ async function renderBookAiSettings() {
|
|
|
13261
13594
|
clearTimeout(relationshipSearchIndexRefreshTimer);
|
|
13262
13595
|
relationshipSearchIndexRefreshTimer = null;
|
|
13263
13596
|
}
|
|
13264
|
-
|
|
13597
|
+
if (semanticSearchIndexRefreshTimer) {
|
|
13598
|
+
clearTimeout(semanticSearchIndexRefreshTimer);
|
|
13599
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13600
|
+
}
|
|
13601
|
+
const [settings, providers, models, semanticModels, taskDefaults, relationshipIndex, semanticIndex, usage, protocolOptions, writeTools, remoteMcpSettings] = await Promise.all([
|
|
13265
13602
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
13266
13603
|
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
13267
13604
|
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
13605
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/semantic-models`),
|
|
13268
13606
|
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
13269
13607
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
13608
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/semantic-search-index`),
|
|
13270
13609
|
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`),
|
|
13271
13610
|
moduleApi("ai-settings", "/api/platform/ai/protocols"),
|
|
13272
13611
|
// 可写工具开关独立于 ai-settings 存储;加载失败时仍可展示其余配置。
|
|
13273
|
-
api(`/api/works/${state.work.id}/ai/tools`).catch(() => null)
|
|
13612
|
+
api(`/api/works/${state.work.id}/ai/tools`).catch(() => null),
|
|
13613
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/mcp-servers`)
|
|
13274
13614
|
]);
|
|
13275
13615
|
const writeToolsState = writeTools?.tools ?? null;
|
|
13276
13616
|
const writeToolsMaxOperations = Number(writeTools?.maxOperations) > 0 ? Number(writeTools.maxOperations) : null;
|
|
13277
13617
|
const host = $("#module-content");
|
|
13278
13618
|
platformAiProtocolOptions = protocolOptions;
|
|
13279
13619
|
const workId = String(state.work.id);
|
|
13620
|
+
const remoteMcpConfigText = JSON.stringify(remoteMcpSettings?.config ?? { mcpServers: {} }, null, 2);
|
|
13621
|
+
const remoteMcpServers = Array.isArray(remoteMcpSettings?.servers) ? remoteMcpSettings.servers : [];
|
|
13622
|
+
const remoteMcpToolCount = Math.max(0, Number(remoteMcpSettings?.totalToolCount) || 0);
|
|
13623
|
+
const remoteMcpStatusText = remoteMcpServers.length > 0
|
|
13624
|
+
? `已验证 ${remoteMcpServers.length} 个远程 MCP Server,共发现 ${remoteMcpToolCount} 个工具。`
|
|
13625
|
+
: "尚未配置远程 MCP Server。";
|
|
13280
13626
|
const maximumAgentToolCallLimit = Math.max(5, Number(settings.agentToolCallLimitMaximum) || 80);
|
|
13281
13627
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image", "calculate_time"]);
|
|
13282
13628
|
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
@@ -13300,6 +13646,17 @@ async function renderBookAiSettings() {
|
|
|
13300
13646
|
title: "本书 Token 用量",
|
|
13301
13647
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
13302
13648
|
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 10,000 时仅提示风险;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>该阈值按对话历史的独立预算计算,用于显示可选择压缩或忽略的提醒;整次请求达到模型上下文窗口 95% 时仍会强制压缩较早消息,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>设定上下文注入</h2><p>开启后,本书的普通 AI 请求会自动注入锁定设定、组织、种族与相关约束;即使本轮同时使用“@注入上下文设定”,也只会注入一次。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="always-include-setting-info" type="checkbox" ${settings.alwaysIncludeSettingInfo ? "checked" : ""}>是否注入设定</label><button id="save-always-include-setting-info" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section ai-agent-tools-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
13649
|
+
const workSystemPromptSection = host.querySelector("#work-system-prompt")?.closest(".config-section");
|
|
13650
|
+
workSystemPromptSection?.insertAdjacentHTML("afterend", `<section class="config-section remote-mcp-settings"><div class="config-section-header"><div><h2>远程 MCP 工具</h2><p>填写标准的 <code>mcpServers</code> JSON 配置。保存前会逐个检查 JSON、远程传输、安全地址、MCP 握手与工具列表;任一 Server 失败时都不会覆盖当前配置。</p></div></div><label class="field-label remote-mcp-config-field"><span>mcpServers JSON</span><textarea id="remote-mcp-config" rows="12" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="remote-mcp-config-help remote-mcp-status" placeholder='{"mcpServers":{"example":{"url":"https://example.com/mcp"}}}'>${esc(remoteMcpConfigText)}</textarea></label><small id="remote-mcp-config-help" class="remote-mcp-config-help">仅支持远程 MCP 工具(SSE / Streamable HTTP),不支持会执行本地命令的 stdio 配置。敏感 Header 会加密保存,页面中的 ${esc("********")} 掩码再次保存时会保留原值。</small><p id="remote-mcp-status" class="remote-mcp-status" role="status" aria-live="polite">${esc(remoteMcpStatusText)}</p><div class="card-actions"><button id="save-remote-mcp-config" class="ghost-button config-save-button" type="button">测试并保存 MCP 配置</button></div></section>`);
|
|
13651
|
+
const semanticModelOptions = (kind, selectedId) => semanticModels
|
|
13652
|
+
.filter((model) => model.modelKind === kind)
|
|
13653
|
+
.map((model) => {
|
|
13654
|
+
const available = isAvailableConfiguredModel(model);
|
|
13655
|
+
return `<option value="${esc(model.id)}" ${model.id === selectedId ? "selected" : ""} ${available || model.id === selectedId ? "" : "disabled"}>${esc(`${available ? "" : "不可用 · "}${modelOptionLabel(model)}`)}</option>`;
|
|
13656
|
+
}).join("");
|
|
13657
|
+
const semanticSection = `<section id="semantic-search-settings" class="config-section semantic-search-settings"><div class="config-section-header"><div><h2>主动语义检索(RAG)</h2><p>与拼音索引并列维护。Embedding 和 rerank 复用平台供应商的端点与凭证保险库;普通聊天、续写、润色和分析不会自动调用此通道。</p></div></div><div class="semantic-settings-grid"><label class="checkbox-field config-checkbox-field semantic-enabled-field"><input id="semantic-search-enabled" type="checkbox" ${settings.semanticSearchEnabled ? "checked" : ""}>启用主动语义检索</label><label>Embedding 模型<select id="semantic-embedding-model" aria-label="RAG Embedding 模型"><option value="">请选择 embedding 模型</option>${semanticModelOptions("embedding", settings.semanticEmbeddingModelId)}</select></label><label>Rerank 模型(可选)<select id="semantic-rerank-model" aria-label="RAG Rerank 模型"><option value="">不使用 rerank</option>${semanticModelOptions("rerank", settings.semanticRerankModelId)}</select></label><label>向量维度<input id="semantic-vector-dimension" type="number" min="1" max="65536" value="${esc(String(settings.semanticVectorDimension ?? 1024))}"></label><label>语义召回数量<input id="semantic-recall-limit" type="number" min="1" max="200" value="${esc(String(settings.semanticRecallLimit ?? 20))}"></label><label>展示结果数量<input id="semantic-result-limit" type="number" min="1" max="100" value="${esc(String(settings.semanticResultLimit ?? 12))}"></label><label>上下文预算(Token)<input id="semantic-budget-tokens" type="number" min="256" max="100000" value="${esc(String(settings.semanticBudgetTokens ?? 4000))}"></label><label>RRF 语义通道权重<input id="semantic-channel-weight" type="number" min="0.1" max="5" step="0.1" value="${esc(String(settings.semanticChannelWeight ?? 1))}"></label></div><p class="usage-measurement-note">API Key 由所选模型所属供应商的凭证保险库加密保存;此页面不会读取或返回明文密钥。更换端点、模型、维度或分片规则后必须完整重建,旧向量不会继续参与检索。</p><div id="semantic-search-index-status" role="status" aria-live="polite">${semanticIndexStatusMarkup(semanticIndex)}</div><div class="relationship-index-actions"><button id="save-semantic-search-settings" class="primary-button config-save-button" type="button">保存 RAG 配置</button><button id="sync-semantic-search-index" class="ghost-button config-save-button" type="button">同步增量</button><button id="refresh-semantic-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-semantic-search-index" class="ghost-button config-save-button" type="button">完整重建 RAG</button></div></section>`;
|
|
13658
|
+
const relationshipSection = [...host.querySelectorAll(".config-section")].find((section) => section.querySelector("h2")?.textContent === "人物关系拼音索引");
|
|
13659
|
+
relationshipSection?.insertAdjacentHTML("afterend", semanticSection);
|
|
13303
13660
|
bindTokenUsageDetails(host, usage, "本书 Token 用量");
|
|
13304
13661
|
const dailyQuotaSection = host.querySelector("#daily-token-quota-enabled")?.closest(".config-section");
|
|
13305
13662
|
dailyQuotaSection?.insertAdjacentHTML("afterend", `<section class="config-section"><div class="config-section-header"><div><h2>每月 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然月(当月 1 日至月末)可使用的输入与输出 Token 总量。额度必须设置为大于 0 的整数;低于 1,000,000 时仅提示风险;达到额度后,新的 AI 请求会等到下月 1 日零点重置后再执行。</p></div></div><div class="config-inline-save"><label class="checkbox-field config-checkbox-field"><input id="monthly-token-quota-enabled" type="checkbox" ${monthlyTokenQuota === null ? "" : "checked"}>启用每月额度</label><label class="monthly-token-quota-field">每月额度<input id="monthly-token-quota" type="number" min="1" max="2000000000" step="1" value="${esc(String(monthlyTokenQuota ?? 10000))}" aria-label="本书每月 Token 额度" ${monthlyTokenQuota === null ? "disabled" : ""}></label><button id="save-monthly-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="monthly-token-quota-status" class="usage-measurement-note" role="status">${esc(monthlyQuotaStatusText)}</p></section>`);
|
|
@@ -13334,6 +13691,10 @@ async function renderBookAiSettings() {
|
|
|
13334
13691
|
"afterend",
|
|
13335
13692
|
`<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
|
|
13336
13693
|
);
|
|
13694
|
+
host.querySelector('input[name="agent-tool"][value="read_character_sections"]').closest("label").insertAdjacentHTML(
|
|
13695
|
+
"afterend",
|
|
13696
|
+
`<label><input name="agent-tool" type="checkbox" value="semantic_search_story" ${agentTools.has("semantic_search_story") ? "checked" : ""} ${settings.semanticSearchEnabled ? "" : "disabled"}><span><strong>语义检索作品原文</strong><small>允许 Agent 显式调用 semantic_search_story;只影响保存后新建的对话,普通消息不会自动检索。</small></span></label>`
|
|
13697
|
+
);
|
|
13337
13698
|
host.querySelector(".ai-agent-tools").insertAdjacentHTML(
|
|
13338
13699
|
"beforeend",
|
|
13339
13700
|
`<label><input name="agent-tool" type="checkbox" value="search_drafts" ${agentTools.has("search_drafts") ? "checked" : ""}><span><strong>搜索想法</strong><small>查询正文想法和设定想法。这些内容只是可能采用、也可能永远不会进入正文或正式设定的临时方向,Agent 不会把它当作已确认事实。</small></span></label><label><input name="agent-tool" type="checkbox" value="image" ${agentTools.has("image") ? "checked" : ""}><span><strong>读取设定图片</strong><small>读取设定正文引用的单张图片附件,并由多模态模型返回图片理解内容。</small></span></label><label><input name="agent-tool" type="checkbox" value="calculate_time" ${agentTools.has("calculate_time") ? "checked" : ""}><span><strong>计算日期</strong><small>计算两个 YYYY-MM-DD 日期之间的天数差,不读取作品内容。</small></span></label>`
|
|
@@ -13370,6 +13731,109 @@ async function renderBookAiSettings() {
|
|
|
13370
13731
|
return status;
|
|
13371
13732
|
};
|
|
13372
13733
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
13734
|
+
const isCurrentSemanticIndexPanel = () => state.module === "ai-settings"
|
|
13735
|
+
&& String(state.work?.id ?? "") === workId
|
|
13736
|
+
&& Boolean($("#semantic-search-index-status"));
|
|
13737
|
+
const updateSemanticIndexStatus = (status) => {
|
|
13738
|
+
const statusHost = $("#semantic-search-index-status");
|
|
13739
|
+
if (!statusHost) return;
|
|
13740
|
+
statusHost.innerHTML = semanticIndexStatusMarkup(status);
|
|
13741
|
+
if (semanticSearchIndexRefreshTimer) clearTimeout(semanticSearchIndexRefreshTimer);
|
|
13742
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13743
|
+
if (!["idle", "building"].includes(String(status.status))) return;
|
|
13744
|
+
semanticSearchIndexRefreshTimer = setTimeout(async () => {
|
|
13745
|
+
semanticSearchIndexRefreshTimer = null;
|
|
13746
|
+
if (!isCurrentSemanticIndexPanel()) return;
|
|
13747
|
+
try {
|
|
13748
|
+
const nextStatus = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
|
|
13749
|
+
if (isCurrentSemanticIndexPanel()) updateSemanticIndexStatus(nextStatus);
|
|
13750
|
+
} catch {
|
|
13751
|
+
// 后台轮询失败时保留当前进度,用户仍可手动刷新。
|
|
13752
|
+
}
|
|
13753
|
+
}, 1_200);
|
|
13754
|
+
};
|
|
13755
|
+
const refreshSemanticIndexStatus = async () => {
|
|
13756
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index`);
|
|
13757
|
+
updateSemanticIndexStatus(status);
|
|
13758
|
+
return status;
|
|
13759
|
+
};
|
|
13760
|
+
updateSemanticIndexStatus(semanticIndex);
|
|
13761
|
+
$("#save-semantic-search-settings").addEventListener("click", async () => {
|
|
13762
|
+
const button = $("#save-semantic-search-settings");
|
|
13763
|
+
const enabled = $("#semantic-search-enabled").checked;
|
|
13764
|
+
const embeddingModelId = $("#semantic-embedding-model").value || null;
|
|
13765
|
+
const vectorDimension = Number($("#semantic-vector-dimension").value);
|
|
13766
|
+
const recallLimit = Number($("#semantic-recall-limit").value);
|
|
13767
|
+
const resultLimit = Number($("#semantic-result-limit").value);
|
|
13768
|
+
const budgetTokens = Number($("#semantic-budget-tokens").value);
|
|
13769
|
+
const channelWeight = Number($("#semantic-channel-weight").value);
|
|
13770
|
+
if (enabled && !embeddingModelId) return toast("开启 RAG 前必须选择 embedding 模型", "error");
|
|
13771
|
+
if (!Number.isInteger(vectorDimension) || vectorDimension < 1 || vectorDimension > 65_536) return toast("向量维度必须是 1 到 65536 的整数", "error");
|
|
13772
|
+
if (!Number.isInteger(recallLimit) || recallLimit < 1 || recallLimit > 200) return toast("语义召回数量必须是 1 到 200 的整数", "error");
|
|
13773
|
+
if (!Number.isInteger(resultLimit) || resultLimit < 1 || resultLimit > 100) return toast("展示结果数量必须是 1 到 100 的整数", "error");
|
|
13774
|
+
if (!Number.isInteger(budgetTokens) || budgetTokens < 256 || budgetTokens > 100_000) return toast("语义上下文预算必须是 256 到 100000 Token", "error");
|
|
13775
|
+
if (!Number.isFinite(channelWeight) || channelWeight < 0.1 || channelWeight > 5) return toast("语义通道权重必须在 0.1 到 5 之间", "error");
|
|
13776
|
+
button.disabled = true;
|
|
13777
|
+
try {
|
|
13778
|
+
const updated = await api(`/api/works/${workId}/ai-settings/semantic-search`, {
|
|
13779
|
+
method: "PATCH",
|
|
13780
|
+
body: {
|
|
13781
|
+
enabled,
|
|
13782
|
+
embeddingModelId,
|
|
13783
|
+
rerankModelId: $("#semantic-rerank-model").value || null,
|
|
13784
|
+
vectorDimension,
|
|
13785
|
+
recallLimit,
|
|
13786
|
+
resultLimit,
|
|
13787
|
+
budgetTokens,
|
|
13788
|
+
channelWeight
|
|
13789
|
+
}
|
|
13790
|
+
});
|
|
13791
|
+
updateSemanticIndexStatus(updated.semanticIndex);
|
|
13792
|
+
toast(enabled ? "RAG 配置已保存;请执行完整重建" : "主动语义检索已关闭");
|
|
13793
|
+
await renderBookAiSettings();
|
|
13794
|
+
} catch (error) {
|
|
13795
|
+
toast(error.message, "error");
|
|
13796
|
+
button.disabled = false;
|
|
13797
|
+
}
|
|
13798
|
+
});
|
|
13799
|
+
$("#sync-semantic-search-index").addEventListener("click", async () => {
|
|
13800
|
+
const button = $("#sync-semantic-search-index");
|
|
13801
|
+
button.disabled = true;
|
|
13802
|
+
try {
|
|
13803
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/sync`, { method: "POST" });
|
|
13804
|
+
updateSemanticIndexStatus({ ...status, status: "building" });
|
|
13805
|
+
toast("已开始同步 RAG 增量来源");
|
|
13806
|
+
} catch (error) {
|
|
13807
|
+
toast(error.message, "error");
|
|
13808
|
+
} finally {
|
|
13809
|
+
button.disabled = false;
|
|
13810
|
+
}
|
|
13811
|
+
});
|
|
13812
|
+
$("#refresh-semantic-search-index").addEventListener("click", async () => {
|
|
13813
|
+
const button = $("#refresh-semantic-search-index");
|
|
13814
|
+
button.disabled = true;
|
|
13815
|
+
try {
|
|
13816
|
+
await refreshSemanticIndexStatus();
|
|
13817
|
+
toast("RAG 状态已刷新", "info");
|
|
13818
|
+
} catch (error) {
|
|
13819
|
+
toast(error.message, "error");
|
|
13820
|
+
} finally {
|
|
13821
|
+
button.disabled = false;
|
|
13822
|
+
}
|
|
13823
|
+
});
|
|
13824
|
+
$("#rebuild-semantic-search-index").addEventListener("click", async () => {
|
|
13825
|
+
const button = $("#rebuild-semantic-search-index");
|
|
13826
|
+
button.disabled = true;
|
|
13827
|
+
try {
|
|
13828
|
+
const status = await api(`/api/works/${workId}/ai-settings/semantic-search-index/rebuild`, { method: "POST" });
|
|
13829
|
+
updateSemanticIndexStatus({ ...status, status: "building", progress: 0 });
|
|
13830
|
+
toast("已开始完整重建 RAG");
|
|
13831
|
+
} catch (error) {
|
|
13832
|
+
toast(error.message, "error");
|
|
13833
|
+
} finally {
|
|
13834
|
+
button.disabled = false;
|
|
13835
|
+
}
|
|
13836
|
+
});
|
|
13373
13837
|
const syncTokenQuotaWarnings = () => {
|
|
13374
13838
|
for (const period of ["daily", "monthly"]) {
|
|
13375
13839
|
const enabled = $(`#${period}-token-quota-enabled`).checked;
|
|
@@ -13462,6 +13926,39 @@ async function renderBookAiSettings() {
|
|
|
13462
13926
|
button.disabled = false;
|
|
13463
13927
|
}
|
|
13464
13928
|
});
|
|
13929
|
+
$("#save-remote-mcp-config").addEventListener("click", async () => {
|
|
13930
|
+
const button = $("#save-remote-mcp-config");
|
|
13931
|
+
const editor = $("#remote-mcp-config");
|
|
13932
|
+
const status = $("#remote-mcp-status");
|
|
13933
|
+
let configuration;
|
|
13934
|
+
try {
|
|
13935
|
+
configuration = JSON.parse(editor.value);
|
|
13936
|
+
} catch {
|
|
13937
|
+
toast("MCP 配置不是合法 JSON,请检查逗号、引号和括号", "error");
|
|
13938
|
+
editor.focus();
|
|
13939
|
+
return;
|
|
13940
|
+
}
|
|
13941
|
+
button.disabled = true;
|
|
13942
|
+
button.textContent = "正在测试连接…";
|
|
13943
|
+
status.textContent = "正在逐个验证远程 MCP Server 的地址、协议握手与工具列表,请稍候。";
|
|
13944
|
+
try {
|
|
13945
|
+
const saved = await api(`/api/works/${state.work.id}/ai-settings/mcp-servers`, {
|
|
13946
|
+
method: "PUT",
|
|
13947
|
+
body: configuration
|
|
13948
|
+
});
|
|
13949
|
+
const serverCount = Array.isArray(saved.servers) ? saved.servers.length : 0;
|
|
13950
|
+
const toolCount = Math.max(0, Number(saved.totalToolCount) || 0);
|
|
13951
|
+
toast(serverCount > 0
|
|
13952
|
+
? `已验证并保存 ${serverCount} 个 MCP Server,共 ${toolCount} 个工具`
|
|
13953
|
+
: "远程 MCP 配置已清空");
|
|
13954
|
+
await renderBookAiSettings();
|
|
13955
|
+
} catch (error) {
|
|
13956
|
+
status.textContent = "验证失败,当前已保存配置保持不变。";
|
|
13957
|
+
toast(error.message, "error");
|
|
13958
|
+
button.disabled = false;
|
|
13959
|
+
button.textContent = "测试并保存 MCP 配置";
|
|
13960
|
+
}
|
|
13961
|
+
});
|
|
13465
13962
|
$("#sync-relationship-search-index").addEventListener("click", async () => {
|
|
13466
13963
|
const button = $("#sync-relationship-search-index");
|
|
13467
13964
|
button.disabled = true;
|
|
@@ -13689,28 +14186,33 @@ function currentAiRequestScope() {
|
|
|
13689
14186
|
if (!state.work) return null;
|
|
13690
14187
|
const selectedTaskType = $("#ai-task").value;
|
|
13691
14188
|
const roleplaySelected = selectedTaskType === "roleplay";
|
|
13692
|
-
const taskType =
|
|
13693
|
-
|
|
13694
|
-
|
|
13695
|
-
|
|
13696
|
-
|
|
13697
|
-
return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
|
|
13698
|
-
}
|
|
13699
|
-
const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
|
|
13700
|
-
const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
|
|
13701
|
-
if (requiresChapter && !state.chapter) return null;
|
|
13702
|
-
const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
|
|
14189
|
+
const taskType = "chat";
|
|
14190
|
+
const scopeType = state.aiPromptSent
|
|
14191
|
+
? null
|
|
14192
|
+
: roleplaySelected ? "none" : $("#ai-scope").value;
|
|
14193
|
+
if (!state.aiPromptSent && scopeType !== "none" && scopeType !== "settings-catalog" && !state.chapter) return null;
|
|
13703
14194
|
const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
|
|
13704
|
-
const
|
|
13705
|
-
|
|
13706
|
-
: scopeType === "none" ? { type: "none"
|
|
14195
|
+
const conversationScope = state.aiPromptSent
|
|
14196
|
+
? JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }))
|
|
14197
|
+
: scopeType === "none" ? { type: "none" }
|
|
13707
14198
|
: scopeType === "book" ? { type: "book" }
|
|
13708
14199
|
: scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
|
|
13709
14200
|
: scopeType === "settings-catalog" ? { type: "settings-catalog" }
|
|
13710
14201
|
: { type: "chapter", chapterId: state.chapter?.id };
|
|
13711
|
-
if (
|
|
14202
|
+
if (!state.aiPromptSent && scopeType === "chapter-summary") conversationScope.includeBookSummary = true;
|
|
13712
14203
|
conversationScope.includeSettingInfo = false;
|
|
13713
|
-
const
|
|
14204
|
+
const referencedScope = mergeAiReferenceScope(conversationScope, state.aiReferences);
|
|
14205
|
+
const chapterInput = state.chapter && !roleplaySelected ? $("#chapter-content") : null;
|
|
14206
|
+
const selectionStart = chapterInput?.selectionStart ?? 0;
|
|
14207
|
+
const selectionEnd = chapterInput?.selectionEnd ?? 0;
|
|
14208
|
+
const selection = chapterInput?.value.slice(selectionStart, selectionEnd) ?? "";
|
|
14209
|
+
const writingTarget = state.chapter && !roleplaySelected ? {
|
|
14210
|
+
chapterId: state.chapter.id,
|
|
14211
|
+
writingChapterVersion: state.chapter.versionNo,
|
|
14212
|
+
...(selection ? { selection, selectionStart, selectionEnd } : {})
|
|
14213
|
+
} : {};
|
|
14214
|
+
const scope = { ...referencedScope, ...writingTarget };
|
|
14215
|
+
if (state.aiSemanticSnapshot?.id) scope.semanticSnapshotId = state.aiSemanticSnapshot.id;
|
|
13714
14216
|
return { taskType, scope, conversationScope, selection };
|
|
13715
14217
|
}
|
|
13716
14218
|
|
|
@@ -13736,7 +14238,7 @@ function renderAiContextDistribution(usage) {
|
|
|
13736
14238
|
if (item.key === "skills" || item.key === "input" || item.key === "output") {
|
|
13737
14239
|
const description = document.createElement("small");
|
|
13738
14240
|
description.textContent = item.key === "skills"
|
|
13739
|
-
? "
|
|
14241
|
+
? item.tokens > 0 ? "按需加载" : "未加载"
|
|
13740
14242
|
: item.key === "input" ? "用户和 agent 的交互" : "当前调用实际输出";
|
|
13741
14243
|
title.append(" ", description);
|
|
13742
14244
|
}
|
|
@@ -13887,9 +14389,10 @@ function field(name, label, type = "text", value = "", options = []) {
|
|
|
13887
14389
|
return `<div class="form-field item-list-field"><span>${esc(label)}</span><div class="item-list-rows" data-item-list-rows data-name="${esc(name)}" data-label="${esc(label)}">${values.map((item) => `<div class="item-list-row"><input name="${esc(name)}" value="${esc(item)}" aria-label="${esc(label)}"><button type="button" data-item-list-remove aria-label="删除此条">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-item-list-add>添加一条</button></div>`;
|
|
13888
14390
|
}
|
|
13889
14391
|
if (type === "keyword-chips") {
|
|
14392
|
+
const chipLabel = String(label).includes("关键词") ? "关键词" : label;
|
|
13890
14393
|
const values = uniqueRelationshipKeywords(Array.isArray(value) ? value : []);
|
|
13891
|
-
const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="
|
|
13892
|
-
return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="
|
|
14394
|
+
const chips = values.map((keyword) => `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(chipLabel)}:${esc(keyword)}">×</button></span>`).join("");
|
|
14395
|
+
return `<div class="form-field keyword-chip-field" data-keyword-chips data-name="${esc(name)}" data-remove-label="${esc(chipLabel)}"><span>${esc(label)}</span><div class="keyword-chip-editor" role="group" aria-label="${esc(label)}">${chips}<input type="text" data-keyword-input aria-label="${esc(label)}" placeholder="输入${esc(chipLabel)}后按回车添加,逗号可批量添加" autocomplete="off"></div><small>输入${esc(chipLabel)}后按回车添加;也可用逗号一次添加多个。</small></div>`;
|
|
13893
14396
|
}
|
|
13894
14397
|
if (type === "key-value-list") {
|
|
13895
14398
|
const config = Array.isArray(options) ? {} : options;
|
|
@@ -13901,9 +14404,10 @@ function field(name, label, type = "text", value = "", options = []) {
|
|
|
13901
14404
|
const valueAriaLabel = config.valueAriaLabel ?? "扩展属性内容";
|
|
13902
14405
|
const removeLabel = config.removeLabel ?? "删除此扩展属性";
|
|
13903
14406
|
const addLabel = config.addLabel ?? "添加属性";
|
|
14407
|
+
const multilineValue = config.multilineValue ?? false;
|
|
13904
14408
|
const values = normalizeCharacterDetails(value);
|
|
13905
14409
|
const rows = values.length ? values : [{ label: "", value: "" }];
|
|
13906
|
-
return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}"
|
|
14410
|
+
return `<div class="form-field structured-list-field character-profile-detail-list"><span>${esc(label)}</span><div class="structured-list-rows" data-structured-list-rows data-kind="key-value">${rows.map((item) => `<div class="structured-list-row key-value-list-row"><input name="${esc(keyName)}" value="${esc(item.label)}" placeholder="${esc(keyPlaceholder)}" aria-label="${esc(keyAriaLabel)}">${multilineValue ? `<textarea class="key-value-list-value" name="${esc(valueName)}" rows="1" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}" data-auto-grow>${esc(item.value)}</textarea>` : `<input name="${esc(valueName)}" value="${esc(item.value)}" placeholder="${esc(valuePlaceholder)}" aria-label="${esc(valueAriaLabel)}">`}<button type="button" data-structured-list-remove aria-label="${esc(removeLabel)}">删除</button></div>`).join("")}</div><button class="item-list-add" type="button" data-structured-list-add>${esc(addLabel)}</button></div>`;
|
|
13907
14411
|
}
|
|
13908
14412
|
if (type === "section-list") {
|
|
13909
14413
|
const values = normalizeCharacterSections(value);
|
|
@@ -13976,6 +14480,10 @@ function renderKnowledgeMarkdownSections() {
|
|
|
13976
14480
|
}
|
|
13977
14481
|
|
|
13978
14482
|
function bindDynamicListControls(container) {
|
|
14483
|
+
resizeAutoGrowingTextareas(container);
|
|
14484
|
+
container.addEventListener("input", (event) => {
|
|
14485
|
+
if (event.target.matches?.("textarea[data-auto-grow]")) resizeAutoGrowingTextarea(event.target);
|
|
14486
|
+
});
|
|
13979
14487
|
container.querySelectorAll("[data-item-list-add]").forEach((button) => button.addEventListener("click", () => {
|
|
13980
14488
|
const rows = button.previousElementSibling;
|
|
13981
14489
|
const row = document.createElement("div");
|
|
@@ -13997,6 +14505,7 @@ function bindDynamicListControls(container) {
|
|
|
13997
14505
|
const row = rows.lastElementChild.cloneNode(true);
|
|
13998
14506
|
row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
|
|
13999
14507
|
rows.append(row);
|
|
14508
|
+
resizeAutoGrowingTextareas(row);
|
|
14000
14509
|
row.querySelector("input").focus();
|
|
14001
14510
|
}));
|
|
14002
14511
|
container.onclick = (event) => {
|
|
@@ -14004,21 +14513,65 @@ function bindDynamicListControls(container) {
|
|
|
14004
14513
|
if (!remove) return;
|
|
14005
14514
|
const row = remove.closest(".item-list-row, .structured-list-row");
|
|
14006
14515
|
const rows = row.parentElement;
|
|
14007
|
-
if (rows.children.length === 1)
|
|
14516
|
+
if (rows.children.length === 1) {
|
|
14517
|
+
row.querySelectorAll("input, textarea").forEach((control) => { control.value = ""; });
|
|
14518
|
+
resizeAutoGrowingTextareas(row);
|
|
14519
|
+
}
|
|
14008
14520
|
else row.remove();
|
|
14009
14521
|
};
|
|
14010
14522
|
}
|
|
14011
14523
|
|
|
14524
|
+
const supportsNativeTextareaContentSizing = typeof CSS !== "undefined" && CSS.supports("field-sizing", "content");
|
|
14525
|
+
|
|
14526
|
+
function resizeAutoGrowingTextarea(textarea) {
|
|
14527
|
+
if (supportsNativeTextareaContentSizing) {
|
|
14528
|
+
textarea.style.removeProperty("height");
|
|
14529
|
+
return;
|
|
14530
|
+
}
|
|
14531
|
+
textarea.style.height = "0px";
|
|
14532
|
+
const minimumHeight = Number.parseFloat(getComputedStyle(textarea).minHeight) || 0;
|
|
14533
|
+
textarea.style.height = `${Math.max(minimumHeight, textarea.scrollHeight)}px`;
|
|
14534
|
+
}
|
|
14535
|
+
|
|
14536
|
+
const autoGrowingTextareaWidths = new WeakMap();
|
|
14537
|
+
const autoGrowingTextareaObserver = typeof ResizeObserver === "function"
|
|
14538
|
+
? new ResizeObserver((entries) => {
|
|
14539
|
+
entries.forEach(({ target }) => {
|
|
14540
|
+
const width = target.getBoundingClientRect().width;
|
|
14541
|
+
if (autoGrowingTextareaWidths.get(target) === width) return;
|
|
14542
|
+
autoGrowingTextareaWidths.set(target, width);
|
|
14543
|
+
resizeAutoGrowingTextarea(target);
|
|
14544
|
+
});
|
|
14545
|
+
})
|
|
14546
|
+
: null;
|
|
14547
|
+
|
|
14548
|
+
function resizeAutoGrowingTextareas(container) {
|
|
14549
|
+
container?.querySelectorAll("textarea[data-auto-grow]").forEach((textarea) => {
|
|
14550
|
+
resizeAutoGrowingTextarea(textarea);
|
|
14551
|
+
autoGrowingTextareaObserver?.observe(textarea);
|
|
14552
|
+
});
|
|
14553
|
+
}
|
|
14554
|
+
|
|
14555
|
+
let autoGrowingTextareaResizeFrame = null;
|
|
14556
|
+
window.addEventListener("resize", () => {
|
|
14557
|
+
if (autoGrowingTextareaResizeFrame !== null) cancelAnimationFrame(autoGrowingTextareaResizeFrame);
|
|
14558
|
+
autoGrowingTextareaResizeFrame = requestAnimationFrame(() => {
|
|
14559
|
+
autoGrowingTextareaResizeFrame = null;
|
|
14560
|
+
resizeAutoGrowingTextareas(document);
|
|
14561
|
+
});
|
|
14562
|
+
});
|
|
14563
|
+
|
|
14012
14564
|
function appendRelationshipKeywordChips(editor, values) {
|
|
14013
14565
|
const input = editor.querySelector("[data-keyword-input]");
|
|
14014
14566
|
if (!input) return;
|
|
14015
14567
|
const existing = new Set([...editor.querySelectorAll("[data-keyword-value]")].map((control) => String(control.value).toLocaleLowerCase("zh-CN")));
|
|
14016
14568
|
const name = editor.dataset.name || "keywords";
|
|
14569
|
+
const removeLabel = editor.dataset.removeLabel || "关键词";
|
|
14017
14570
|
for (const keyword of uniqueRelationshipKeywords(values)) {
|
|
14018
14571
|
const key = keyword.toLocaleLowerCase("zh-CN");
|
|
14019
14572
|
if (existing.has(key)) continue;
|
|
14020
14573
|
existing.add(key);
|
|
14021
|
-
input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="
|
|
14574
|
+
input.insertAdjacentHTML("beforebegin", `<span class="keyword-chip" data-keyword-chip><span>${esc(keyword)}</span><input type="hidden" name="${esc(name)}" value="${esc(keyword)}" data-keyword-value><button type="button" data-keyword-chip-remove aria-label="删除${esc(removeLabel)}:${esc(keyword)}">×</button></span>`);
|
|
14022
14575
|
}
|
|
14023
14576
|
}
|
|
14024
14577
|
|
|
@@ -14690,6 +15243,7 @@ function activateCharacterEditorTab(key) {
|
|
|
14690
15243
|
button.tabIndex = active ? 0 : -1;
|
|
14691
15244
|
});
|
|
14692
15245
|
document.querySelectorAll("[data-character-editor-panel]").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.characterEditorPanel !== key));
|
|
15246
|
+
resizeAutoGrowingTextareas(document.querySelector(`[data-character-editor-panel="${key}"]`));
|
|
14693
15247
|
if (
|
|
14694
15248
|
key === "relationships"
|
|
14695
15249
|
&& characterEditorItem?.id
|
|
@@ -15662,17 +16216,17 @@ function renderCharacterEditorFields(item) {
|
|
|
15662
16216
|
const organizationOptions = state.organizations.map((organization) => [organization.id, organization.name]);
|
|
15663
16217
|
const chapterOptions = [["", "未指定"], ...(state.work?.volumes ?? []).flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]))];
|
|
15664
16218
|
const stateEntries = characterStateEntries(item?.currentState ?? {});
|
|
16219
|
+
const raceField = !canReadModule("races")
|
|
16220
|
+
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
16221
|
+
: state.races.length
|
|
16222
|
+
? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
|
|
16223
|
+
: '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>';
|
|
15665
16224
|
$("#character-editor-fields").innerHTML = [
|
|
15666
16225
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
15667
16226
|
`<div class="avatar-settings character-avatar-settings"><div id="character-avatar-preview" class="character-avatar character-avatar-editor-preview" role="img" aria-label="角色头像"></div><div class="avatar-settings-copy"><strong>角色头像</strong><small>支持 PNG、JPEG、WebP,文件不超过 2 MB。选择后可框选正方形选区再裁剪上传。</small></div><div class="avatar-settings-actions"><button id="character-avatar-upload-button" class="ghost-button" type="button">${item?.avatarUrl ? "更换头像" : "上传头像"}</button><button id="character-avatar-remove-button" class="ghost-button${item?.avatarUrl ? "" : " hidden"}" type="button">移除头像</button></div></div>` +
|
|
15668
|
-
|
|
16227
|
+
raceField +
|
|
15669
16228
|
field("gender", "性别", "select", item?.gender ?? "unknown", CHARACTER_GENDER_OPTIONS) +
|
|
15670
|
-
field("aliases", "别名", "
|
|
15671
|
-
(!canReadModule("races")
|
|
15672
|
-
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
15673
|
-
: state.races.length
|
|
15674
|
-
? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
|
|
15675
|
-
: '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
|
|
16229
|
+
field("aliases", "别名", "keyword-chips", item?.aliases ?? []) +
|
|
15676
16230
|
(!canReadModule("organizations")
|
|
15677
16231
|
? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
|
|
15678
16232
|
: organizationOptions.length
|
|
@@ -15688,7 +16242,7 @@ function renderCharacterEditorFields(item) {
|
|
|
15688
16242
|
field("summary", "人物简介", "textarea", item?.profile?.summary) +
|
|
15689
16243
|
'<div class="form-field"><span>人设摘要</span><small>关系扮演时作为公开人设注入对方可见的角色卡,不会包含私密档案或 Markdown 章节。</small><textarea name="personaSummary" maxlength="20000" aria-label="人设摘要">' + esc(item?.profile?.personaSummary ?? "") + "</textarea></div>"),
|
|
15690
16244
|
characterEditorSection("settings", "扩展设定", "可用短属性和 Markdown 长章节承载形态、能力、生态、经历与研究记录。",
|
|
15691
|
-
field("details", "扩展属性", "key-value-list", item?.attributes?.details) +
|
|
16245
|
+
field("details", "扩展属性", "key-value-list", item?.attributes?.details, { multilineValue: true }) +
|
|
15692
16246
|
'<div id="character-markdown-sections" class="character-markdown-sections"></div>'),
|
|
15693
16247
|
characterEditorSection("state", "状态与约束", "维护任意当前状态,并明确禁止 AI 自行覆盖的字段。",
|
|
15694
16248
|
field("isDead", "标记为已死亡", "checkbox", item?.isDead ?? false) +
|
|
@@ -15712,9 +16266,8 @@ function renderCharacterEditorFields(item) {
|
|
|
15712
16266
|
: '<div class="character-editor-empty-field"><b>角色扮演记忆</b><span>保存角色卡后即可管理该角色的共享记忆库。</span></div>',
|
|
15713
16267
|
item?.id ? roleplayMemoryToolbarMarkup() : "")
|
|
15714
16268
|
].join("");
|
|
15715
|
-
const name = $("#character-editor-fields [name='name']");
|
|
15716
|
-
if (name) name.required = true;
|
|
15717
16269
|
bindDynamicListControls($("#character-editor-fields"));
|
|
16270
|
+
bindRelationshipKeywordControls($("#character-editor-fields"));
|
|
15718
16271
|
renderCharacterAvatar(item);
|
|
15719
16272
|
renderCharacterEditorRelationships();
|
|
15720
16273
|
renderCharacterMarkdownSections();
|
|
@@ -15794,7 +16347,7 @@ function renderCharacterHistory() {
|
|
|
15794
16347
|
const restored = await api(`/api/characters/${characterEditorItem.id}/restore`, { method: "POST", body: { versionNo } });
|
|
15795
16348
|
characterEditorItem = restored;
|
|
15796
16349
|
renderCharacterEditorFields(restored);
|
|
15797
|
-
$("#character-editor-
|
|
16350
|
+
$("#character-editor-name").value = restored.name;
|
|
15798
16351
|
$("#character-editor-version").textContent = `v${restored.versionNo}`;
|
|
15799
16352
|
$("#character-change-note").value = "";
|
|
15800
16353
|
await Promise.all([renderCharacters(), loadAiReferences()]);
|
|
@@ -15833,7 +16386,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15833
16386
|
characterEditorRelationshipsLoaded = false;
|
|
15834
16387
|
characterEditorSections = [];
|
|
15835
16388
|
$("#character-editor-eyebrow").textContent = item ? "人物主档案" : "建立人物档案";
|
|
15836
|
-
$("#character-editor-
|
|
16389
|
+
$("#character-editor-name").value = item?.name ?? "";
|
|
15837
16390
|
$("#character-editor-version").textContent = item ? `v${item.versionNo}` : "新档案";
|
|
15838
16391
|
$("#character-change-note").value = "";
|
|
15839
16392
|
$("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
|
|
@@ -15882,6 +16435,9 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15882
16435
|
setCharacterHistoryVisible(false);
|
|
15883
16436
|
renderCharacterEditorFields(item);
|
|
15884
16437
|
const viewOnly = readOnly || !canEditModule("characters");
|
|
16438
|
+
$("#character-editor-form").classList.toggle("is-read-only", viewOnly);
|
|
16439
|
+
$("#character-editor-name").readOnly = viewOnly;
|
|
16440
|
+
$("#character-editor-name").setAttribute("aria-readonly", String(viewOnly));
|
|
15885
16441
|
if (viewOnly) {
|
|
15886
16442
|
$("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
|
|
15887
16443
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
@@ -15929,10 +16485,11 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15929
16485
|
busyTarget: form,
|
|
15930
16486
|
button: submit,
|
|
15931
16487
|
prepare: async () => {
|
|
16488
|
+
commitRelationshipKeywordInputs(form);
|
|
15932
16489
|
const body = collectCharacterBody(new FormData(form));
|
|
15933
16490
|
if (!body.name) {
|
|
15934
16491
|
toast("请填写角色标准名", "error");
|
|
15935
|
-
|
|
16492
|
+
$("#character-editor-name").focus();
|
|
15936
16493
|
return null;
|
|
15937
16494
|
}
|
|
15938
16495
|
const currentItem = characterEditorItem;
|
|
@@ -15946,7 +16503,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15946
16503
|
state.characters = upsertEntityCollection(state.characters, saved);
|
|
15947
16504
|
entityEditorDirty = false;
|
|
15948
16505
|
renderCharacterAvatar(saved);
|
|
15949
|
-
$("#character-editor-
|
|
16506
|
+
$("#character-editor-name").value = saved.name;
|
|
15950
16507
|
$("#character-editor-version").textContent = `v${saved.versionNo}`;
|
|
15951
16508
|
$("#character-change-note").value = "";
|
|
15952
16509
|
$("#character-history-button").disabled = false;
|
|
@@ -15972,6 +16529,7 @@ async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
|
15972
16529
|
if (item) {
|
|
15973
16530
|
void loadCharacterMarkdownSections(item.id);
|
|
15974
16531
|
}
|
|
16532
|
+
(viewOnly ? $("#character-editor-close") : $("#character-editor-name")).focus();
|
|
15975
16533
|
}
|
|
15976
16534
|
|
|
15977
16535
|
function knowledgeEditorSection(key, title, description, content) {
|
|
@@ -17125,11 +17683,16 @@ function openProviderDialog(item, protocolOptions = platformAiProtocolOptions) {
|
|
|
17125
17683
|
|
|
17126
17684
|
function openModelDialog(providerId, item = null, provider = null, protocolOptions = platformAiProtocolOptions) {
|
|
17127
17685
|
const values = modelFormValues(item);
|
|
17686
|
+
const modelKindFields = `<div class="form-field model-kind-fields" role="group" aria-labelledby="model-kind-heading"><span id="model-kind-heading">专用模型类型</span><label class="checkbox-field model-capability-option"><input id="model-kind-embedding" name="embeddingModel" type="checkbox" ${values.modelKind === "embedding" ? "checked" : ""}><span><strong>这是一个 embedding 模型</strong><small>只用于语义向量,不会出现在 chat 框或 AI 分析任务中。</small></span></label><label class="checkbox-field model-capability-option"><input id="model-kind-rerank" name="rerankModel" type="checkbox" ${values.modelKind === "rerank" ? "checked" : ""}><span><strong>这是一个 rerank 模型</strong><small>只用于语义候选重排,不会出现在 chat 框或 AI 分析任务中。</small></span></label><small>两项都不勾选时,该模型按普通 chat 模型使用。</small></div>`;
|
|
17128
17687
|
const imageDefaultSupported = supportsMultimodalModelProtocol(provider?.protocol, protocolOptions);
|
|
17129
17688
|
const multimodalFields = imageDefaultSupported ? `<div class="form-field model-multimodal-fields" role="group" aria-labelledby="model-multimodal-heading"><span id="model-multimodal-heading" class="model-multimodal-heading">模型能力</span><label class="checkbox-field model-capability-option"><input id="model-multimodal-enabled" name="multimodalEnabled" type="checkbox" ${values.multimodalEnabled ? "checked" : ""}><span><strong>支持多模态图片理解</strong><small>启用后可用于读取设定库中的图片附件。</small></span></label><label id="model-image-tool-default-field" class="checkbox-field model-capability-option ${values.multimodalEnabled ? "" : "hidden"}"><input id="model-image-tool-default" name="imageToolDefault" type="checkbox" ${values.imageToolDefault ? "checked" : ""}><span><strong>设为多模态读图工具默认模型</strong><small>支持多模态的接口协议都可以作为默认读图模型。</small></span></label><small class="model-multimodal-note">当前供应商支持多模态读图工具默认模型。</small></div>` : "";
|
|
17130
17689
|
const contextWindowField = `<div class="form-field model-context-window-field"><label for="model-context-window">模型上下文令牌总量<input id="model-context-window" name="contextWindow" type="number" value="${esc(values.contextWindow)}" min="${MIN_MODEL_CONTEXT_WINDOW}" max="2000000" step="1" required aria-describedby="model-context-window-hint"></label><small id="model-context-window-hint" class="model-context-window-hint" hidden>低于 128K 的模型在小说创作场景不太适用,建议使用支持更长上下文的模型。</small></div>`;
|
|
17131
17690
|
const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
|
|
17132
|
-
const connectionTestDescription = values.
|
|
17691
|
+
const connectionTestDescription = values.modelKind === "embedding"
|
|
17692
|
+
? "使用当前供应商凭据调用 OpenAI-compatible embeddings 接口并校验向量。"
|
|
17693
|
+
: values.modelKind === "rerank"
|
|
17694
|
+
? "使用 Qwen reranker 的 yes/no 模板发起最小相关性判定。"
|
|
17695
|
+
: values.multimodalEnabled && imageDefaultSupported
|
|
17133
17696
|
? "使用当前已保存的模型标识符、思考设置和供应商凭据,并发送一张测试图片验证图片请求。"
|
|
17134
17697
|
: "使用当前已保存的模型标识符、思考设置和供应商凭据发起最小请求。";
|
|
17135
17698
|
const connectionTest = item && item.providerStatus === "enabled"
|
|
@@ -17138,8 +17701,9 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
17138
17701
|
<button class="ghost-button" type="button" data-test-model="${esc(item.id)}">测试连接</button>
|
|
17139
17702
|
</section>`
|
|
17140
17703
|
: "";
|
|
17141
|
-
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
17142
|
-
const
|
|
17704
|
+
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + modelKindFields + `<div data-chat-model-fields>` + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + contextWindowField + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("thinkingEffort", "思考强度(模型默认时不发送强度参数)", "select", values.thinkingEffort, MODEL_THINKING_EFFORT_OPTIONS) + multimodalFields + `</div>` + field("enabled", "启用模型", "checkbox", values.enabled) + connectionTest, async (form) => {
|
|
17705
|
+
const modelKind = form.get("embeddingModel") === "on" ? "embedding" : form.get("rerankModel") === "on" ? "rerank" : "chat";
|
|
17706
|
+
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), modelKind, purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", thinkingEffort: form.get("thinkingEffort") ?? thinkingEffortSelect.value, multimodalEnabled: form.get("multimodalEnabled") === "on", imageToolDefault: form.get("imageToolDefault") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
17143
17707
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
17144
17708
|
await renderPlatformAiConfig();
|
|
17145
17709
|
await loadModels();
|
|
@@ -17157,6 +17721,17 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
17157
17721
|
const multimodalInput = $("#model-multimodal-enabled");
|
|
17158
17722
|
const imageDefaultField = $("#model-image-tool-default-field");
|
|
17159
17723
|
const imageDefaultInput = $("#model-image-tool-default");
|
|
17724
|
+
const embeddingModelInput = $("#model-kind-embedding");
|
|
17725
|
+
const rerankModelInput = $("#model-kind-rerank");
|
|
17726
|
+
const chatModelFields = $("#dialog-fields [data-chat-model-fields]");
|
|
17727
|
+
const syncModelKindFields = (changedInput = null) => {
|
|
17728
|
+
if (changedInput?.checked) {
|
|
17729
|
+
const other = changedInput === embeddingModelInput ? rerankModelInput : embeddingModelInput;
|
|
17730
|
+
if (other) other.checked = false;
|
|
17731
|
+
}
|
|
17732
|
+
const specialized = Boolean(embeddingModelInput?.checked || rerankModelInput?.checked);
|
|
17733
|
+
chatModelFields?.classList.toggle("hidden", specialized);
|
|
17734
|
+
};
|
|
17160
17735
|
const syncMultimodalFields = () => {
|
|
17161
17736
|
if (!multimodalInput || !imageDefaultField || !imageDefaultInput) return;
|
|
17162
17737
|
const hideImageDefault = !multimodalInput.checked || !imageDefaultSupported;
|
|
@@ -17183,6 +17758,8 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
17183
17758
|
contextWindowInput.addEventListener("input", syncModelContextWindowGuidance);
|
|
17184
17759
|
thinkingEnabledInput.addEventListener("change", syncThinkingEffort);
|
|
17185
17760
|
multimodalInput?.addEventListener("change", syncMultimodalFields);
|
|
17761
|
+
embeddingModelInput?.addEventListener("change", () => syncModelKindFields(embeddingModelInput));
|
|
17762
|
+
rerankModelInput?.addEventListener("change", () => syncModelKindFields(rerankModelInput));
|
|
17186
17763
|
$("#dialog-fields [data-test-model]")?.addEventListener("click", async (event) => {
|
|
17187
17764
|
const button = event.currentTarget;
|
|
17188
17765
|
button.disabled = true;
|
|
@@ -17206,6 +17783,7 @@ function openModelDialog(providerId, item = null, provider = null, protocolOptio
|
|
|
17206
17783
|
syncKimiTemperature();
|
|
17207
17784
|
syncThinkingEffort();
|
|
17208
17785
|
syncMultimodalFields();
|
|
17786
|
+
syncModelKindFields();
|
|
17209
17787
|
}
|
|
17210
17788
|
|
|
17211
17789
|
async function sendAi() {
|
|
@@ -17242,8 +17820,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
17242
17820
|
if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
|
|
17243
17821
|
const requestScope = currentAiRequestScope();
|
|
17244
17822
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
17245
|
-
const {
|
|
17246
|
-
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
17823
|
+
const { scope } = requestScope;
|
|
17247
17824
|
const citations = requestComposerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
17248
17825
|
const selectedTaskType = $("#ai-task").value;
|
|
17249
17826
|
persistActiveAiChatTab();
|
|
@@ -17277,9 +17854,6 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
17277
17854
|
if (imageAttachmentIds.length > 0 && !state.models.find((model) => model.id === modelId)?.multimodalEnabled) {
|
|
17278
17855
|
return toast("当前选择的模型不是多模态模型,无法发送图片附件", "error");
|
|
17279
17856
|
}
|
|
17280
|
-
if (imageAttachmentIds.length > 0 && taskType !== "chat") {
|
|
17281
|
-
return toast("图片附件目前仅支持问答对话", "error");
|
|
17282
|
-
}
|
|
17283
17857
|
try {
|
|
17284
17858
|
await prepareAiRequestConversation(requestHolder, selectedTaskType, requestScope.conversationScope);
|
|
17285
17859
|
} catch (error) {
|
|
@@ -17288,86 +17862,31 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
17288
17862
|
}
|
|
17289
17863
|
setAiChatTabStatus(tab, "streaming");
|
|
17290
17864
|
if (retry?.message?.isConnected) retry.message.remove();
|
|
17291
|
-
if (
|
|
17292
|
-
if (!retry) {
|
|
17293
|
-
try {
|
|
17294
|
-
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
17295
|
-
const persistedUserMessage = await persistAiConversationMessage(
|
|
17296
|
-
request.conversationId,
|
|
17297
|
-
"user",
|
|
17298
|
-
instruction,
|
|
17299
|
-
citations,
|
|
17300
|
-
{ modelId },
|
|
17301
|
-
{ signal: request.signal }
|
|
17302
|
-
);
|
|
17303
|
-
assertAiRequestCurrent(request);
|
|
17304
|
-
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
17305
|
-
requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
|
|
17306
|
-
tab.modelId = modelId;
|
|
17307
|
-
tab.selectedModelId = modelId;
|
|
17308
|
-
tab.promptSent = true;
|
|
17309
|
-
clearAiChatTabComposer(tab);
|
|
17310
|
-
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id, { tab });
|
|
17311
|
-
if (isActiveAiChatTab(tab)) {
|
|
17312
|
-
state.aiConversationModelId = modelId;
|
|
17313
|
-
state.aiPromptSent = true;
|
|
17314
|
-
syncAiTaskOptions();
|
|
17315
|
-
renderAiRoleplayCharacterSelect();
|
|
17316
|
-
renderAiQuickActions();
|
|
17317
|
-
clearAiPromptComposer();
|
|
17318
|
-
}
|
|
17319
|
-
} catch (error) {
|
|
17320
|
-
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
17321
|
-
setAiChatTabStatus(tab, "error");
|
|
17322
|
-
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
17323
|
-
}
|
|
17324
|
-
} else {
|
|
17325
|
-
prepareAiRetryState(tab, modelId);
|
|
17326
|
-
}
|
|
17327
|
-
} else if (retry) {
|
|
17328
|
-
prepareAiRetryState(tab, modelId);
|
|
17329
|
-
}
|
|
17865
|
+
if (retry) prepareAiRetryState(tab, modelId);
|
|
17330
17866
|
let assistantContent = "";
|
|
17331
17867
|
let assistantMessage;
|
|
17332
17868
|
let assistantMetadata = {};
|
|
17333
17869
|
let persistedStreamMessage = null;
|
|
17334
|
-
let
|
|
17335
|
-
|
|
17336
|
-
|
|
17337
|
-
|
|
17338
|
-
|
|
17339
|
-
|
|
17340
|
-
|
|
17341
|
-
|
|
17342
|
-
|
|
17343
|
-
|
|
17344
|
-
|
|
17345
|
-
|
|
17346
|
-
|
|
17347
|
-
|
|
17348
|
-
|
|
17349
|
-
|
|
17350
|
-
|
|
17351
|
-
|
|
17352
|
-
|
|
17353
|
-
|
|
17354
|
-
} else {
|
|
17355
|
-
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
17356
|
-
suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
|
|
17357
|
-
method: "POST",
|
|
17358
|
-
body: { taskType, instruction, scope, modelId, citations, conversationId: requestHolder.snapshot.conversationId },
|
|
17359
|
-
signal: request.signal
|
|
17360
|
-
});
|
|
17361
|
-
assertAiRequestCurrent(request);
|
|
17362
|
-
const suggestionFailed = suggestion.guard?.status === "failed"
|
|
17363
|
-
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
17364
|
-
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
17365
|
-
if (suggestionFailed) setAiChatTabStatus(tab, "error");
|
|
17366
|
-
tab.contextUsage = mergeAiContextUsage(tab.contextUsage, suggestion.contextUsage, false);
|
|
17367
|
-
if (isActiveAiChatTab(tab)) setAiContextMeter(suggestion.contextUsage, false);
|
|
17368
|
-
assistantContent = suggestion.content;
|
|
17369
|
-
assistantMetadata = { modelId, modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent, processDurationMs: suggestion.processDurationMs };
|
|
17370
|
-
}
|
|
17870
|
+
let writingSuggestion = null;
|
|
17871
|
+
const streamed = await streamChat(requestHolder, aiRetryStreamRequestBody({
|
|
17872
|
+
instruction,
|
|
17873
|
+
...(sceneDirection ? { sceneDirection } : {}),
|
|
17874
|
+
...($("#ai-task").value === "roleplay" ? { scenePin } : {}),
|
|
17875
|
+
scope,
|
|
17876
|
+
modelId,
|
|
17877
|
+
citations,
|
|
17878
|
+
...(imageAttachmentIds.length ? { imageAttachmentIds } : {}),
|
|
17879
|
+
conversationId: requestHolder.snapshot.conversationId,
|
|
17880
|
+
...(ignoreContextWarning ? { ignoreContextWarning: true } : {})
|
|
17881
|
+
}, retry), createAiIdempotencyKey());
|
|
17882
|
+
const streamedRequest = assertAiRequestCurrent(requestHolder.snapshot);
|
|
17883
|
+
if (streamed.action === "warn") return;
|
|
17884
|
+
assistantContent = streamed.content;
|
|
17885
|
+
assistantMessage = streamed.message;
|
|
17886
|
+
assistantMetadata = streamed.metadata;
|
|
17887
|
+
writingSuggestion = streamed.writingSuggestion;
|
|
17888
|
+
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
17889
|
+
applyAiConversationTitle(streamed.conversationTitle, streamedRequest.conversationId);
|
|
17371
17890
|
try {
|
|
17372
17891
|
const request = assertAiRequestCurrent(requestHolder.snapshot);
|
|
17373
17892
|
if (persistedStreamMessage) {
|
|
@@ -17386,19 +17905,19 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
17386
17905
|
assistantContent,
|
|
17387
17906
|
[],
|
|
17388
17907
|
assistantMetadata,
|
|
17389
|
-
{ signal: request.signal, requestId:
|
|
17908
|
+
{ signal: request.signal, requestId: aiAssistantRequestId(request) }
|
|
17390
17909
|
);
|
|
17391
17910
|
assertAiRequestCurrent(request);
|
|
17392
17911
|
updateAiConversationSummaryFromMessage(persistedAssistantMessage);
|
|
17393
17912
|
if (assistantMessage) {
|
|
17394
17913
|
updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
|
|
17395
17914
|
attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
|
|
17396
|
-
}
|
|
17915
|
+
}
|
|
17397
17916
|
}
|
|
17917
|
+
if (writingSuggestion && assistantMessage) attachWritingSuggestion(assistantMessage, writingSuggestion, { tab });
|
|
17398
17918
|
} catch (error) {
|
|
17399
17919
|
if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
|
|
17400
17920
|
setAiChatTabStatus(tab, "error");
|
|
17401
|
-
if (suggestion) appendSuggestion(suggestion, null, null, { tab });
|
|
17402
17921
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
17403
17922
|
}
|
|
17404
17923
|
} catch (error) {
|
|
@@ -17481,7 +18000,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
|
|
|
17481
18000
|
failureMessage,
|
|
17482
18001
|
[],
|
|
17483
18002
|
{},
|
|
17484
|
-
{ requestId:
|
|
18003
|
+
{ requestId: aiAssistantRequestId(request) }
|
|
17485
18004
|
);
|
|
17486
18005
|
updateAiConversationSummaryFromMessage(persistedFailureMessage);
|
|
17487
18006
|
} catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
@@ -17561,6 +18080,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
17561
18080
|
let persistedMessageId = null;
|
|
17562
18081
|
let persistedMessageCreatedAt = null;
|
|
17563
18082
|
let conversationTitle = null;
|
|
18083
|
+
let writingSuggestion = null;
|
|
17564
18084
|
let persistedUserMessage = null;
|
|
17565
18085
|
let contextAction = "ready";
|
|
17566
18086
|
let warningOnly = false;
|
|
@@ -17716,6 +18236,13 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
17716
18236
|
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
17717
18237
|
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
17718
18238
|
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
18239
|
+
writingSuggestion = payload.writingSuggestion && typeof payload.writingSuggestion === "object"
|
|
18240
|
+
? payload.writingSuggestion
|
|
18241
|
+
: null;
|
|
18242
|
+
const writingSuggestionFailed = writingSuggestion?.guard?.status === "failed"
|
|
18243
|
+
|| writingSuggestion?.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
18244
|
+
|| writingSuggestion?.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
18245
|
+
if (writingSuggestionFailed) setAiChatTabStatus(tab, "error");
|
|
17719
18246
|
const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
|
|
17720
18247
|
setAiChatTabContextUsage(tab, payload.contextUsage, announcedCompaction);
|
|
17721
18248
|
await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
|
|
@@ -17728,7 +18255,18 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
17728
18255
|
const processDurationMs = Number.isFinite(payload.processDurationMs) && payload.processDurationMs >= 0
|
|
17729
18256
|
? payload.processDurationMs
|
|
17730
18257
|
: elapsedProcessTime();
|
|
17731
|
-
generatedMetadata = {
|
|
18258
|
+
generatedMetadata = {
|
|
18259
|
+
modelDisplayName: payload.model?.displayName,
|
|
18260
|
+
outputTokens: payload.outputTokens,
|
|
18261
|
+
cacheHitPercent: payload.cacheHitPercent,
|
|
18262
|
+
toolCalls,
|
|
18263
|
+
processSteps,
|
|
18264
|
+
processDurationMs,
|
|
18265
|
+
...(writingSuggestion ? {
|
|
18266
|
+
activeSkills: [writingSuggestion.taskType === "continue" ? "continue-writing" : "polish-writing"],
|
|
18267
|
+
writingSuggestionId: writingSuggestion.id
|
|
18268
|
+
} : {})
|
|
18269
|
+
};
|
|
17732
18270
|
renderAiProcessSteps(message, processSteps, true, processDurationMs);
|
|
17733
18271
|
meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent, "", processDurationMs);
|
|
17734
18272
|
attachAssistantCopyAction(message, streamedText);
|
|
@@ -17745,18 +18283,21 @@ async function streamChat(requestHolder, body, idempotencyKey) {
|
|
|
17745
18283
|
assertAiRequestCurrent(requestHolder.snapshot);
|
|
17746
18284
|
if (streamError) throw streamError;
|
|
17747
18285
|
assertAiStreamCompleted(streamCompleted);
|
|
17748
|
-
return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
|
|
18286
|
+
return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage };
|
|
17749
18287
|
} catch (error) {
|
|
17750
18288
|
const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
|
|
17751
18289
|
const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
|
|
17752
|
-
const
|
|
18290
|
+
const hasRenderableProcessSteps = processSteps.some(shouldRenderAiProcessStep);
|
|
18291
|
+
const interruption = streamedText || hasRenderableProcessSteps ? {
|
|
17753
18292
|
content: streamedText,
|
|
17754
18293
|
message,
|
|
17755
18294
|
metadata: {
|
|
17756
18295
|
interrupted: true,
|
|
17757
18296
|
interruptionCode,
|
|
17758
18297
|
interruptionMessage: streamFailure.message.slice(0, 500),
|
|
17759
|
-
processDurationMs: Math.min(86_400_000, elapsedProcessTime())
|
|
18298
|
+
processDurationMs: Math.min(86_400_000, elapsedProcessTime()),
|
|
18299
|
+
...(toolCalls.length ? { toolCalls } : {}),
|
|
18300
|
+
...(processSteps.length ? { processSteps } : {})
|
|
17760
18301
|
}
|
|
17761
18302
|
} : null;
|
|
17762
18303
|
if (interruption) streamFailure.streamInterruption = interruption;
|
|
@@ -17939,47 +18480,91 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
17939
18480
|
if (isFailure) renderMessageCardActions(message);
|
|
17940
18481
|
attachMessageIdentity(message, messageId);
|
|
17941
18482
|
feed.append(message);
|
|
18483
|
+
const writingSuggestionId = role === "assistant" && typeof metadata?.writingSuggestionId === "string"
|
|
18484
|
+
? metadata.writingSuggestionId
|
|
18485
|
+
: "";
|
|
18486
|
+
if (writingSuggestionId && !isFailure && !isInterrupted) {
|
|
18487
|
+
api(`/api/suggestions/${encodeURIComponent(writingSuggestionId)}`)
|
|
18488
|
+
.then((suggestion) => attachWritingSuggestion(message, suggestion, { tab }))
|
|
18489
|
+
.catch(() => undefined);
|
|
18490
|
+
}
|
|
17942
18491
|
scrollAiFeedToBottom(feed);
|
|
18492
|
+
return message;
|
|
17943
18493
|
}
|
|
17944
18494
|
|
|
17945
|
-
function
|
|
17946
|
-
|
|
17947
|
-
const
|
|
17948
|
-
|
|
17949
|
-
|
|
17950
|
-
|
|
17951
|
-
|
|
17952
|
-
const
|
|
17953
|
-
|
|
17954
|
-
|
|
17955
|
-
|
|
17956
|
-
|
|
17957
|
-
|
|
17958
|
-
|
|
18495
|
+
function continuationGuardMarkup(guard) {
|
|
18496
|
+
if (!guard) return "";
|
|
18497
|
+
const issues = Array.isArray(guard.issues) ? guard.issues : [];
|
|
18498
|
+
return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
|
|
18499
|
+
}
|
|
18500
|
+
|
|
18501
|
+
async function applyAcceptedWritingSuggestion(message, suggestion) {
|
|
18502
|
+
const result = await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/accept`, { method: "POST", body: {} });
|
|
18503
|
+
state.chapter = result.chapter;
|
|
18504
|
+
resetChapterDraftLineIds(state.chapter);
|
|
18505
|
+
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
18506
|
+
$("#chapter-content").value = state.chapter.content;
|
|
18507
|
+
scheduleChapterLineNumbers();
|
|
18508
|
+
updateChapterStats();
|
|
18509
|
+
state.work = await api(`/api/works/${state.work.id}`);
|
|
18510
|
+
renderTree();
|
|
18511
|
+
message.querySelector("[data-writing-suggestion-actions]").innerHTML = "<span>已采纳并生成新版本</span>";
|
|
18512
|
+
toast("AI 建议已采纳,正文已生成新版本");
|
|
18513
|
+
}
|
|
18514
|
+
|
|
18515
|
+
function attachWritingSuggestion(message, suggestion, options = {}) {
|
|
18516
|
+
if (!suggestion || suggestion.action === "note" || !suggestion.id) return message;
|
|
18517
|
+
const suggestionId = String(suggestion.id);
|
|
18518
|
+
if (message.dataset.writingSuggestionId === suggestionId) return message;
|
|
18519
|
+
message.dataset.writingSuggestionId = suggestionId;
|
|
18520
|
+
message.querySelector("[data-writing-suggestion-ui]")?.remove();
|
|
18521
|
+
const heading = message.querySelector(".message-heading > span");
|
|
18522
|
+
if (heading) heading.textContent = "助手建议";
|
|
18523
|
+
const host = document.createElement("div");
|
|
18524
|
+
host.dataset.writingSuggestionUi = "";
|
|
18525
|
+
host.className = "writing-suggestion-ui";
|
|
18526
|
+
host.innerHTML = `${continuationGuardMarkup(suggestion.guard)}<div class="message-actions" data-writing-suggestion-actions></div>`;
|
|
18527
|
+
const actions = host.querySelector("[data-writing-suggestion-actions]");
|
|
18528
|
+
if (suggestion.status === "accepted") {
|
|
18529
|
+
actions.innerHTML = "<span>已采纳并生成新版本</span>";
|
|
18530
|
+
} else if (suggestion.status === "rejected") {
|
|
18531
|
+
actions.innerHTML = "<span>已拒绝</span>";
|
|
18532
|
+
} else {
|
|
18533
|
+
actions.innerHTML = '<button type="button" data-action="accept">采纳到正文</button><button type="button" data-action="reject">拒绝</button>';
|
|
18534
|
+
actions.querySelector('[data-action="accept"]').addEventListener("click", async () => {
|
|
17959
18535
|
try {
|
|
17960
|
-
|
|
17961
|
-
|
|
17962
|
-
|
|
17963
|
-
|
|
17964
|
-
$("#chapter-content").value = state.chapter.content;
|
|
17965
|
-
scheduleChapterLineNumbers();
|
|
17966
|
-
updateChapterStats();
|
|
17967
|
-
state.work = await api(`/api/works/${state.work.id}`);
|
|
17968
|
-
renderTree();
|
|
17969
|
-
message.querySelector(".message-actions").innerHTML = "<span>已采纳并生成新版本</span>";
|
|
17970
|
-
toast("AI 建议已采纳,正文已生成新版本");
|
|
17971
|
-
} catch (error) { toast(error.message, "error"); }
|
|
18536
|
+
await applyAcceptedWritingSuggestion(message, suggestion);
|
|
18537
|
+
} catch (error) {
|
|
18538
|
+
toast(error.message, "error");
|
|
18539
|
+
}
|
|
17972
18540
|
});
|
|
17973
|
-
|
|
17974
|
-
|
|
17975
|
-
|
|
18541
|
+
actions.querySelector('[data-action="reject"]').addEventListener("click", async () => {
|
|
18542
|
+
try {
|
|
18543
|
+
await api(`/api/suggestions/${encodeURIComponent(suggestion.id)}/reject`, { method: "POST", body: {} });
|
|
18544
|
+
actions.innerHTML = "<span>已拒绝</span>";
|
|
18545
|
+
} catch (error) {
|
|
18546
|
+
toast(error.message, "error");
|
|
18547
|
+
}
|
|
17976
18548
|
});
|
|
17977
18549
|
}
|
|
17978
|
-
|
|
17979
|
-
|
|
18550
|
+
message.append(host);
|
|
18551
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
18552
|
+
scrollAiFeedToBottom(options.feed ?? tab?.feed ?? $("#ai-feed"));
|
|
17980
18553
|
return message;
|
|
17981
18554
|
}
|
|
17982
18555
|
|
|
18556
|
+
function appendSuggestion(suggestion, createdAt = null, messageId = null, options = {}) {
|
|
18557
|
+
const tab = options.tab ?? activeAiChatTab();
|
|
18558
|
+
const feed = options.feed ?? tab?.feed ?? $("#ai-feed");
|
|
18559
|
+
const message = appendMessage("assistant", suggestion.content, [], createdAt, {
|
|
18560
|
+
modelDisplayName: suggestion.model?.displayName,
|
|
18561
|
+
outputTokens: suggestion.outputTokens,
|
|
18562
|
+
cacheHitPercent: suggestion.cacheHitPercent,
|
|
18563
|
+
processDurationMs: suggestion.processDurationMs
|
|
18564
|
+
}, messageId, { tab, feed });
|
|
18565
|
+
return attachWritingSuggestion(message, suggestion, { tab, feed });
|
|
18566
|
+
}
|
|
18567
|
+
|
|
17983
18568
|
function chapterVersionCompareOption(version) {
|
|
17984
18569
|
return `<option value="version:${Number(version.versionNo)}">v${Number(version.versionNo)} · ${esc(chapterVersionSourceLabel(version.source))}</option>`;
|
|
17985
18570
|
}
|
|
@@ -19418,6 +20003,8 @@ $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
|
|
|
19418
20003
|
$("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
19419
20004
|
$("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
19420
20005
|
$("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
|
|
20006
|
+
$("#chapter-batch-template").addEventListener("input", updateChapterBatchControls);
|
|
20007
|
+
$("#chapter-batch-start").addEventListener("input", updateChapterBatchControls);
|
|
19421
20008
|
$("#chapter-batch-search").addEventListener("input", renderChapterBatchDialog);
|
|
19422
20009
|
$("#chapter-batch-select-all").addEventListener("click", () => {
|
|
19423
20010
|
const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
|
|
@@ -19456,7 +20043,7 @@ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
|
19456
20043
|
$("#knowledge-editor-form").addEventListener("input", markEntityEditorDirty);
|
|
19457
20044
|
$("#knowledge-editor-form").addEventListener("change", markEntityEditorDirty);
|
|
19458
20045
|
$("#character-editor-fields").addEventListener("click", (event) => {
|
|
19459
|
-
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
|
|
20046
|
+
if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove], [data-keyword-chip-remove]")) markEntityEditorDirty();
|
|
19460
20047
|
const uploadButton = event.target.closest("#character-avatar-upload-button");
|
|
19461
20048
|
if (uploadButton) {
|
|
19462
20049
|
if (!characterEditorItem?.id) {
|
|
@@ -19659,6 +20246,17 @@ $("#ai-panel-toggle").addEventListener("click", () => {
|
|
|
19659
20246
|
panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
|
|
19660
20247
|
applyPanelLayout(true);
|
|
19661
20248
|
});
|
|
20249
|
+
$("#ai-semantic-search-toggle").addEventListener("click", () => {
|
|
20250
|
+
setAiSemanticSearchVisible($("#ai-semantic-search-panel").classList.contains("hidden"));
|
|
20251
|
+
});
|
|
20252
|
+
$("#ai-semantic-search-close").addEventListener("click", () => setAiSemanticSearchVisible(false));
|
|
20253
|
+
$("#ai-semantic-search-run").addEventListener("click", () => { void runAiSemanticSearch(); });
|
|
20254
|
+
$("#ai-semantic-query").addEventListener("keydown", (event) => {
|
|
20255
|
+
if (event.key !== "Enter" || event.shiftKey) return;
|
|
20256
|
+
event.preventDefault();
|
|
20257
|
+
void runAiSemanticSearch();
|
|
20258
|
+
});
|
|
20259
|
+
$("#ai-semantic-inject").addEventListener("click", () => { void injectAiSemanticSelection(); });
|
|
19662
20260
|
setupPanelResize($("#left-panel-resize"), "left");
|
|
19663
20261
|
setupPanelResize($("#ai-panel-resize"), "ai");
|
|
19664
20262
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
|
|
@@ -20069,6 +20667,11 @@ document.addEventListener("keydown", (event) => {
|
|
|
20069
20667
|
return;
|
|
20070
20668
|
}
|
|
20071
20669
|
if (event.key === "Escape") {
|
|
20670
|
+
if (!$("#ai-semantic-search-panel").classList.contains("hidden")) {
|
|
20671
|
+
setAiSemanticSearchVisible(false);
|
|
20672
|
+
$("#ai-semantic-search-toggle").focus();
|
|
20673
|
+
return;
|
|
20674
|
+
}
|
|
20072
20675
|
if (!$("#ai-model-popover").classList.contains("hidden")) {
|
|
20073
20676
|
setAiModelPickerVisible(false);
|
|
20074
20677
|
return;
|