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