@musnows/scriverse 0.5.11 → 0.6.0
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 -1
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai.js +358 -21
- package/dist/ai.js.map +1 -1
- package/dist/app.js +33 -5
- package/dist/app.js.map +1 -1
- package/dist/database.js +35 -0
- package/dist/database.js.map +1 -1
- package/dist/public/ai-context-meter.js +32 -0
- package/dist/public/app.js +459 -100
- package/dist/public/index.html +7 -3
- package/dist/public/module-request-cache.d.ts +17 -0
- package/dist/public/module-request-cache.js +35 -0
- package/dist/public/stream-typewriter.d.ts +1 -1
- package/dist/public/stream-typewriter.js +16 -12
- package/dist/public/styles.css +55 -11
- package/dist/store.js +96 -23
- package/dist/store.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/writing-progress-time.js +71 -0
- package/dist/writing-progress-time.js.map +1 -0
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -8,10 +8,10 @@ import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260
|
|
|
8
8
|
import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
|
|
9
9
|
import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
|
|
10
10
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
11
|
-
import { createStreamTypewriter } from "/stream-typewriter.js?v=
|
|
11
|
+
import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
|
|
12
12
|
import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
|
|
13
13
|
import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
|
|
14
|
-
import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=
|
|
14
|
+
import { formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=20260730-token-distribution-v2";
|
|
15
15
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
16
16
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
17
17
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
@@ -48,6 +48,7 @@ import { resolveGlobalSearchTarget, splitGlobalSearchHighlight } from "/global-s
|
|
|
48
48
|
import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
|
|
49
49
|
import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
|
|
50
50
|
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
|
|
51
|
+
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
51
52
|
import {
|
|
52
53
|
clampCropRect,
|
|
53
54
|
containImageRect,
|
|
@@ -85,6 +86,12 @@ function normalizePageSizes(value) {
|
|
|
85
86
|
]));
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
function isSelectableModel(model) {
|
|
90
|
+
return Boolean(model?.enabled)
|
|
91
|
+
&& model?.providerStatus === "enabled"
|
|
92
|
+
&& model?.providerConnectionStatus === "success";
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
const state = {
|
|
89
96
|
user: null,
|
|
90
97
|
csrfToken: null,
|
|
@@ -117,6 +124,21 @@ const state = {
|
|
|
117
124
|
contextChapterId: null
|
|
118
125
|
};
|
|
119
126
|
|
|
127
|
+
const moduleRequestCache = createModuleRequestCache();
|
|
128
|
+
const cachedWorkModules = new Set([
|
|
129
|
+
"drafts",
|
|
130
|
+
"settings",
|
|
131
|
+
"characters",
|
|
132
|
+
"races",
|
|
133
|
+
"organizations",
|
|
134
|
+
"timeline",
|
|
135
|
+
"outlines",
|
|
136
|
+
"relationships",
|
|
137
|
+
"reviews",
|
|
138
|
+
"tasks",
|
|
139
|
+
"ai-settings"
|
|
140
|
+
]);
|
|
141
|
+
|
|
120
142
|
function createPresenceClientId() {
|
|
121
143
|
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
122
144
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
@@ -371,6 +393,9 @@ let aiReferencesLoadWorkId = null;
|
|
|
371
393
|
let aiConversationsLoadPromise = null;
|
|
372
394
|
let aiConversationsLoadWorkId = null;
|
|
373
395
|
let workScopedUiGeneration = 0;
|
|
396
|
+
const loadedVolumeChapterIds = new Set();
|
|
397
|
+
const volumeChapterLoadingIds = new Set();
|
|
398
|
+
const volumeChapterRequests = new Map();
|
|
374
399
|
let raceHierarchyLoadPromise = null;
|
|
375
400
|
let raceHierarchyLoadWorkId = null;
|
|
376
401
|
let loadedRaceHierarchyWorkId = null;
|
|
@@ -874,6 +899,7 @@ let chapterEditorReadOnly = true;
|
|
|
874
899
|
let characterListPage = 1;
|
|
875
900
|
let taskListPage = 1;
|
|
876
901
|
let draftTypeFilter = "all";
|
|
902
|
+
let draftFiltersPanelOpen = false;
|
|
877
903
|
const moduleListPages = {
|
|
878
904
|
drafts: 1,
|
|
879
905
|
settings: 1,
|
|
@@ -1592,6 +1618,14 @@ function renderAiConversationHistory() {
|
|
|
1592
1618
|
}
|
|
1593
1619
|
}
|
|
1594
1620
|
|
|
1621
|
+
function applyAiConversationTitle(title) {
|
|
1622
|
+
if (!title || !state.aiConversationId) return;
|
|
1623
|
+
const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
|
|
1624
|
+
if (current) current.title = title;
|
|
1625
|
+
$("#ai-conversation-title").textContent = title;
|
|
1626
|
+
renderAiConversationHistory();
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1595
1629
|
async function loadAiConversations(openLatest = true) {
|
|
1596
1630
|
const workId = state.work?.id;
|
|
1597
1631
|
if (!workId) return;
|
|
@@ -2126,17 +2160,46 @@ async function api(path, options = {}) {
|
|
|
2126
2160
|
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
2127
2161
|
state.user = null;
|
|
2128
2162
|
state.csrfToken = null;
|
|
2163
|
+
moduleRequestCache.clear();
|
|
2129
2164
|
showAuth(false);
|
|
2130
2165
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2166
|
+
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
2167
|
+
}
|
|
2168
|
+
if (response.status === 204) {
|
|
2169
|
+
invalidateModuleRequestsAfterMutation(path, method);
|
|
2170
|
+
return null;
|
|
2134
2171
|
}
|
|
2135
|
-
if (response.status === 204) return null;
|
|
2136
2172
|
const payload = await response.json();
|
|
2173
|
+
invalidateModuleRequestsAfterMutation(path, method);
|
|
2137
2174
|
return payload.data;
|
|
2138
2175
|
}
|
|
2139
2176
|
|
|
2177
|
+
function createClientError(payload, fallbackMessage, fallbackStatus = null) {
|
|
2178
|
+
const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
2179
|
+
const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
|
|
2180
|
+
error.code = typeof source.code === "string" ? source.code : undefined;
|
|
2181
|
+
error.status = Number.isInteger(source.status) ? source.status : fallbackStatus;
|
|
2182
|
+
error.details = source.details;
|
|
2183
|
+
error.failure = typeof source.failure === "string" ? source.failure : undefined;
|
|
2184
|
+
error.callId = typeof source.callId === "string" ? source.callId : undefined;
|
|
2185
|
+
return error;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function formatAiFailureMessage(error) {
|
|
2189
|
+
const message = error instanceof Error ? error.message : String(error ?? "未知错误");
|
|
2190
|
+
const lines = [`调用失败:${message}`];
|
|
2191
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
2192
|
+
const status = Number.isInteger(error?.status) ? error.status : null;
|
|
2193
|
+
const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
|
|
2194
|
+
const failure = typeof error?.failure === "string" ? error.failure : typeof details.failure === "string" ? details.failure : "";
|
|
2195
|
+
const callId = typeof error?.callId === "string" ? error.callId : typeof details.callId === "string" ? details.callId : "";
|
|
2196
|
+
if (code) lines.push(`错误码:${code}`);
|
|
2197
|
+
if (status) lines.push(`服务端状态:HTTP ${status}`);
|
|
2198
|
+
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2199
|
+
if (callId) lines.push(`调用 ID:${callId}`);
|
|
2200
|
+
return lines.join("\n\n");
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2140
2203
|
async function apiPage(path, page = 1, limit = 30) {
|
|
2141
2204
|
const separator = path.includes("?") ? "&" : "?";
|
|
2142
2205
|
const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
|
|
@@ -2155,6 +2218,63 @@ async function apiAllPages(path, limit = 100) {
|
|
|
2155
2218
|
}
|
|
2156
2219
|
}
|
|
2157
2220
|
|
|
2221
|
+
function cachedModuleRequest(module, requestKey, loader, options = {}) {
|
|
2222
|
+
const workId = state.work?.id;
|
|
2223
|
+
if (!workId) return Promise.resolve().then(loader);
|
|
2224
|
+
return moduleRequestCache.request(workId, module, requestKey, loader, options);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function moduleApi(module, path, options = {}) {
|
|
2228
|
+
return cachedModuleRequest(module, `api:${path}`, () => api(path), options);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function moduleApiPage(module, path, page = 1, limit = 30, options = {}) {
|
|
2232
|
+
return cachedModuleRequest(module, `page:${path}:${page}:${limit}`, () => apiPage(path, page, limit), options);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function moduleApiAllPages(module, path, limit = 100, options = {}) {
|
|
2236
|
+
return cachedModuleRequest(module, `all:${path}:${limit}`, () => apiAllPages(path, limit), options);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function invalidateModuleRequestsAfterMutation(path, method) {
|
|
2240
|
+
if (["GET", "HEAD", "OPTIONS"].includes(method) || !state.work) return;
|
|
2241
|
+
if (
|
|
2242
|
+
path.startsWith("/api/auth/")
|
|
2243
|
+
|| path.includes("/presence")
|
|
2244
|
+
|| path.includes("/context/prepare")
|
|
2245
|
+
|| path.includes("/ai-context-usage")
|
|
2246
|
+
|| path.includes("/chat/stream")
|
|
2247
|
+
) return;
|
|
2248
|
+
|
|
2249
|
+
const affected = new Set();
|
|
2250
|
+
if (path.includes("/ai-settings") || path.includes("/task-defaults") || path.includes("/providers") || path.includes("/models")) {
|
|
2251
|
+
affected.add("ai-settings");
|
|
2252
|
+
}
|
|
2253
|
+
if (path.includes("/tasks")) affected.add("tasks");
|
|
2254
|
+
if (path.includes("/drafts")) affected.add("drafts");
|
|
2255
|
+
if (path.includes("/settings") && !path.includes("/ai-settings")) affected.add("settings");
|
|
2256
|
+
if (path.includes("/characters") || path.includes("/character-sections")) affected.add("characters");
|
|
2257
|
+
if (path.includes("/races")) affected.add("races");
|
|
2258
|
+
if (path.includes("/organizations")) affected.add("organizations");
|
|
2259
|
+
if (path.includes("/timeline")) affected.add("timeline");
|
|
2260
|
+
if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
|
|
2261
|
+
if (path.includes("/relationships")) affected.add("relationships");
|
|
2262
|
+
if (path.includes("/reviews")) affected.add("reviews");
|
|
2263
|
+
if (path.includes("/entity-versions/")) {
|
|
2264
|
+
if (path.includes("/draft/")) affected.add("drafts");
|
|
2265
|
+
if (path.includes("/setting/")) affected.add("settings");
|
|
2266
|
+
if (path.includes("/character/")) affected.add("characters");
|
|
2267
|
+
if (path.includes("/race/")) affected.add("races");
|
|
2268
|
+
if (path.includes("/organization/")) affected.add("organizations");
|
|
2269
|
+
if (path.includes("/timeline-event/") || path.includes("/timeline-track/")) affected.add("timeline");
|
|
2270
|
+
if (path.includes("/chapter-outline/") || path.includes("/foreshadow/")) affected.add("outlines");
|
|
2271
|
+
if (path.includes("/relationship/")) affected.add("relationships");
|
|
2272
|
+
if (path.includes("/review/")) affected.add("reviews");
|
|
2273
|
+
}
|
|
2274
|
+
if (cachedWorkModules.has(state.module)) affected.add(state.module);
|
|
2275
|
+
affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2158
2278
|
async function initializeProductFooters() {
|
|
2159
2279
|
const year = String(new Date().getFullYear());
|
|
2160
2280
|
document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
|
|
@@ -3345,11 +3465,15 @@ function resetWorkScopedUiCaches() {
|
|
|
3345
3465
|
state.races = [];
|
|
3346
3466
|
characterListPage = 1;
|
|
3347
3467
|
draftTypeFilter = "all";
|
|
3468
|
+
draftFiltersPanelOpen = false;
|
|
3348
3469
|
Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
|
|
3349
3470
|
relationshipFilters.fromCharacterIds = [];
|
|
3350
3471
|
relationshipFilters.toCharacterIds = [];
|
|
3351
3472
|
taskListPage = 1;
|
|
3352
3473
|
state.collapsedVolumeIds.clear();
|
|
3474
|
+
loadedVolumeChapterIds.clear();
|
|
3475
|
+
volumeChapterLoadingIds.clear();
|
|
3476
|
+
volumeChapterRequests.clear();
|
|
3353
3477
|
state.collapsedRaceIds.clear();
|
|
3354
3478
|
lastSavedChapterSnapshot = null;
|
|
3355
3479
|
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
@@ -3373,7 +3497,13 @@ function resetWorkScopedUiCaches() {
|
|
|
3373
3497
|
async function selectWork(workId, preferredChapterId = null) {
|
|
3374
3498
|
const discarding = state.work?.id !== workId && state.dirty;
|
|
3375
3499
|
if (discarding && !(await confirmDiscardChanges())) return false;
|
|
3376
|
-
|
|
3500
|
+
if (state.work?.id === workId) {
|
|
3501
|
+
workScopedUiGeneration += 1;
|
|
3502
|
+
loadedVolumeChapterIds.clear();
|
|
3503
|
+
volumeChapterLoadingIds.clear();
|
|
3504
|
+
volumeChapterRequests.clear();
|
|
3505
|
+
}
|
|
3506
|
+
const nextWork = await api(`/api/works/${workId}?directory=volumes`);
|
|
3377
3507
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
3378
3508
|
if (discarding) setSaveState("就绪");
|
|
3379
3509
|
$("#app").classList.remove("shelf-mode");
|
|
@@ -3384,6 +3514,8 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
3384
3514
|
$("#settings-button").removeAttribute("aria-current");
|
|
3385
3515
|
settingsReturnContext = null;
|
|
3386
3516
|
state.work = nextWork;
|
|
3517
|
+
state.work.volumes = state.work.volumes.map((volume) => ({ ...volume, chapters: Array.isArray(volume.chapters) ? volume.chapters : [] }));
|
|
3518
|
+
state.collapsedVolumeIds = new Set(state.work.volumes.map((volume) => volume.id));
|
|
3387
3519
|
state.chapter = null;
|
|
3388
3520
|
chapterEditorReadOnly = true;
|
|
3389
3521
|
if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
|
|
@@ -3393,40 +3525,121 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
3393
3525
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
3394
3526
|
$("#top-search-button").disabled = !canReadAggregateContent();
|
|
3395
3527
|
renderTree();
|
|
3396
|
-
|
|
3397
|
-
const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
|
|
3528
|
+
void loadAllVolumeChapters(nextWork.id);
|
|
3398
3529
|
if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
|
|
3399
|
-
else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
|
|
3400
3530
|
else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
|
|
3401
3531
|
else if (!canReadModule(state.module)) showWelcome(true);
|
|
3402
3532
|
else await showModule(state.module);
|
|
3403
3533
|
return true;
|
|
3404
3534
|
}
|
|
3405
3535
|
|
|
3536
|
+
async function loadVolumeChapters(volumeId) {
|
|
3537
|
+
if (!state.work || loadedVolumeChapterIds.has(volumeId)) return;
|
|
3538
|
+
const existingRequest = volumeChapterRequests.get(volumeId);
|
|
3539
|
+
if (existingRequest) return existingRequest;
|
|
3540
|
+
const workId = state.work.id;
|
|
3541
|
+
const generation = workScopedUiGeneration;
|
|
3542
|
+
volumeChapterLoadingIds.add(volumeId);
|
|
3543
|
+
renderTree();
|
|
3544
|
+
const request = (async () => {
|
|
3545
|
+
try {
|
|
3546
|
+
const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
|
|
3547
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
3548
|
+
const volume = state.work.volumes.find((item) => item.id === volumeId);
|
|
3549
|
+
if (!volume) return;
|
|
3550
|
+
volume.chapters = chapters;
|
|
3551
|
+
volume.chapterCount = chapters.length;
|
|
3552
|
+
loadedVolumeChapterIds.add(volumeId);
|
|
3553
|
+
renderTree();
|
|
3554
|
+
} catch (error) {
|
|
3555
|
+
if (state.work?.id === workId && generation === workScopedUiGeneration) {
|
|
3556
|
+
toast(`加载分卷章节失败:${error.message}`, "error");
|
|
3557
|
+
renderTree();
|
|
3558
|
+
}
|
|
3559
|
+
} finally {
|
|
3560
|
+
volumeChapterLoadingIds.delete(volumeId);
|
|
3561
|
+
volumeChapterRequests.delete(volumeId);
|
|
3562
|
+
if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
|
|
3563
|
+
}
|
|
3564
|
+
})();
|
|
3565
|
+
volumeChapterRequests.set(volumeId, request);
|
|
3566
|
+
return request;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
async function loadAllVolumeChapters(workId) {
|
|
3570
|
+
const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
|
|
3571
|
+
for (const volumeId of volumeIds) {
|
|
3572
|
+
if (state.work?.id !== workId) return;
|
|
3573
|
+
await loadVolumeChapters(volumeId);
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
function mergeChapterDirectoryEntry(chapter) {
|
|
3578
|
+
if (!state.work || !chapter?.volumeId) return;
|
|
3579
|
+
const volume = state.work.volumes.find((item) => item.id === chapter.volumeId);
|
|
3580
|
+
if (!volume) return;
|
|
3581
|
+
const directoryEntry = {
|
|
3582
|
+
id: chapter.id,
|
|
3583
|
+
workId: chapter.workId,
|
|
3584
|
+
volumeId: chapter.volumeId,
|
|
3585
|
+
title: chapter.title,
|
|
3586
|
+
chapterType: chapter.chapterType,
|
|
3587
|
+
sortOrder: chapter.sortOrder,
|
|
3588
|
+
wordCount: chapter.wordCount,
|
|
3589
|
+
versionNo: chapter.versionNo,
|
|
3590
|
+
analysisStatus: chapter.analysisStatus,
|
|
3591
|
+
excludedFromAnalysis: chapter.excludedFromAnalysis,
|
|
3592
|
+
createdAt: chapter.createdAt,
|
|
3593
|
+
updatedAt: chapter.updatedAt
|
|
3594
|
+
};
|
|
3595
|
+
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
3596
|
+
const existingIndex = chapters.findIndex((item) => item.id === chapter.id);
|
|
3597
|
+
if (existingIndex >= 0) chapters[existingIndex] = directoryEntry;
|
|
3598
|
+
else chapters.push(directoryEntry);
|
|
3599
|
+
chapters.sort((left, right) => Number(left.sortOrder) - Number(right.sortOrder));
|
|
3600
|
+
volume.chapters = chapters;
|
|
3601
|
+
volume.chapterCount = Math.max(Number(volume.chapterCount ?? 0), chapters.length);
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3406
3604
|
function renderTree() {
|
|
3407
3605
|
if (!state.work) return;
|
|
3408
|
-
const count = state.work.volumes.reduce((total, volume) => total + volume.chapters
|
|
3606
|
+
const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
|
|
3409
3607
|
const proseEditable = canEditProse();
|
|
3410
3608
|
$("#chapter-count").textContent = `${count} 章`;
|
|
3411
3609
|
$("#novel-tree").classList.remove("empty-copy");
|
|
3412
|
-
$("#novel-tree").innerHTML = state.work.volumes.map((volume) =>
|
|
3413
|
-
|
|
3610
|
+
$("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
|
|
3611
|
+
const collapsed = state.collapsedVolumeIds.has(volume.id);
|
|
3612
|
+
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
3613
|
+
const chapterContent = collapsed
|
|
3614
|
+
? ""
|
|
3615
|
+
: volumeChapterLoadingIds.has(volume.id)
|
|
3616
|
+
? '<p class="entity-history-empty">正在加载章节……</p>'
|
|
3617
|
+
: !loadedVolumeChapterIds.has(volume.id)
|
|
3618
|
+
? '<p class="entity-history-empty">展开后加载章节。</p>'
|
|
3619
|
+
: chapters.length
|
|
3620
|
+
? chapters.map((chapter) => `
|
|
3621
|
+
<button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
|
|
3622
|
+
<span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
|
|
3623
|
+
</button>`).join("")
|
|
3624
|
+
: '<p class="entity-history-empty">本卷还没有章节。</p>';
|
|
3625
|
+
return `
|
|
3626
|
+
<div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
|
|
3414
3627
|
<div class="volume-title">
|
|
3415
|
-
<button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${
|
|
3628
|
+
<button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
|
|
3416
3629
|
${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
|
|
3417
3630
|
</div>
|
|
3418
3631
|
<div class="volume-chapters">
|
|
3419
|
-
${
|
|
3420
|
-
<button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
|
|
3421
|
-
<span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
|
|
3422
|
-
</button>`).join("")}
|
|
3632
|
+
${chapterContent}
|
|
3423
3633
|
</div>
|
|
3424
|
-
</div
|
|
3634
|
+
</div>`;
|
|
3635
|
+
}).join("");
|
|
3425
3636
|
$("#novel-tree").querySelectorAll("[data-volume-toggle]").forEach((button) => {
|
|
3426
3637
|
button.addEventListener("click", () => {
|
|
3427
3638
|
const volumeId = button.dataset.volumeToggle;
|
|
3428
|
-
if (state.collapsedVolumeIds.has(volumeId))
|
|
3429
|
-
|
|
3639
|
+
if (state.collapsedVolumeIds.has(volumeId)) {
|
|
3640
|
+
state.collapsedVolumeIds.delete(volumeId);
|
|
3641
|
+
void loadVolumeChapters(volumeId);
|
|
3642
|
+
} else state.collapsedVolumeIds.add(volumeId);
|
|
3430
3643
|
renderTree();
|
|
3431
3644
|
});
|
|
3432
3645
|
button.addEventListener("contextmenu", (event) => {
|
|
@@ -3446,7 +3659,7 @@ function renderTree() {
|
|
|
3446
3659
|
button.closest(".volume-node")?.classList.remove("is-drag-target");
|
|
3447
3660
|
const chapterId = event.dataTransfer?.getData("text/plain");
|
|
3448
3661
|
const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
|
|
3449
|
-
if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
|
|
3662
|
+
if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, Number(volume.chapterCount ?? volume.chapters.length));
|
|
3450
3663
|
});
|
|
3451
3664
|
}
|
|
3452
3665
|
});
|
|
@@ -3715,7 +3928,11 @@ async function deleteChapter(chapterId) {
|
|
|
3715
3928
|
async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
3716
3929
|
if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
|
|
3717
3930
|
cancelChapterAutoSave();
|
|
3718
|
-
state.chapter
|
|
3931
|
+
if (state.chapter?.id !== chapterId) {
|
|
3932
|
+
state.chapter = await api(`/api/chapters/${chapterId}`);
|
|
3933
|
+
mergeChapterDirectoryEntry(state.chapter);
|
|
3934
|
+
}
|
|
3935
|
+
state.collapsedVolumeIds.delete(state.chapter.volumeId);
|
|
3719
3936
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
3720
3937
|
chapterEditorReadOnly = !canEditProse() || !editMode;
|
|
3721
3938
|
state.module = "editor";
|
|
@@ -4152,6 +4369,15 @@ function mountRelationshipFilterToggle() {
|
|
|
4152
4369
|
});
|
|
4153
4370
|
}
|
|
4154
4371
|
|
|
4372
|
+
function mountDraftFilterToggle() {
|
|
4373
|
+
$("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.remove();
|
|
4374
|
+
$("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="draft-filter-toggle" aria-label="筛选草稿" aria-controls="draft-filter-panel" aria-expanded="${draftFiltersPanelOpen}" 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>`);
|
|
4375
|
+
$("#module-header-actions").querySelector('[data-module-header-action="draft-filter-toggle"]')?.addEventListener("click", async () => {
|
|
4376
|
+
draftFiltersPanelOpen = !draftFiltersPanelOpen;
|
|
4377
|
+
await renderDrafts(moduleListPages.drafts);
|
|
4378
|
+
});
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4155
4381
|
function bindRecordPreview(selector, open) {
|
|
4156
4382
|
$("#module-content").querySelectorAll(selector).forEach((card) => {
|
|
4157
4383
|
const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
|
|
@@ -4246,28 +4472,42 @@ function draftTypeLabel(draftType) {
|
|
|
4246
4472
|
|
|
4247
4473
|
async function deleteDraft(item) {
|
|
4248
4474
|
if (!item || !canEditModule("drafts")) return;
|
|
4475
|
+
const dialog = $("#form-dialog");
|
|
4476
|
+
dialog.close();
|
|
4249
4477
|
if (!await confirmToast(`确认删除草稿“${item.title}”吗?草稿将从当前列表移除。`, {
|
|
4250
4478
|
title: "删除草稿",
|
|
4251
4479
|
confirmLabel: "确认删除"
|
|
4252
|
-
}))
|
|
4480
|
+
})) {
|
|
4481
|
+
openDraftDialog(item);
|
|
4482
|
+
return;
|
|
4483
|
+
}
|
|
4253
4484
|
try {
|
|
4254
4485
|
await api(`/api/drafts/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
4255
4486
|
await renderDrafts(moduleListPages.drafts);
|
|
4256
4487
|
toast("草稿已删除");
|
|
4257
4488
|
} catch (error) {
|
|
4258
4489
|
toast(error.message, "error");
|
|
4490
|
+
try {
|
|
4491
|
+
openDraftDialog(await api(`/api/drafts/${encodeURIComponent(item.id)}`));
|
|
4492
|
+
} catch (reloadError) {
|
|
4493
|
+
toast(reloadError.message, "error");
|
|
4494
|
+
}
|
|
4259
4495
|
}
|
|
4260
4496
|
}
|
|
4261
4497
|
|
|
4262
4498
|
function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
4263
4499
|
const viewOnly = readOnly || !canEditModule("drafts");
|
|
4500
|
+
const management = item && !viewOnly ? `<section class="entity-dialog-management" aria-label="草稿操作">
|
|
4501
|
+
<div><strong>草稿操作</strong><small>删除后将从草稿列表移除,版本历史仍会保留。</small></div>
|
|
4502
|
+
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除草稿</button></div>
|
|
4503
|
+
</section>` : "";
|
|
4264
4504
|
const fields = `<p class="form-field-note">草稿只记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。</p>`
|
|
4265
4505
|
+ field("draftType", "草稿类型", "select", item?.draftType ?? "prose", [["prose", "正文草稿"], ["setting", "设定草稿"]])
|
|
4266
4506
|
+ field("title", "标题", "text", item?.title ?? "")
|
|
4267
4507
|
+ field("content", "内容", "markdown", item?.content ?? "", {
|
|
4268
4508
|
placeholder: "记录尚未定稿的片段、方向或设定想法……",
|
|
4269
4509
|
readOnly: viewOnly
|
|
4270
|
-
});
|
|
4510
|
+
}) + management;
|
|
4271
4511
|
openDialog(item ? viewOnly ? "查看草稿" : "编辑草稿" : "新建草稿", fields, async (form) => {
|
|
4272
4512
|
if (viewOnly) return;
|
|
4273
4513
|
const title = String(form.get("title") ?? "").trim();
|
|
@@ -4287,7 +4527,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4287
4527
|
}, item ? draftTypeLabel(item.draftType) : "未确认想法", {
|
|
4288
4528
|
submitLabel: viewOnly ? "关闭" : "保存草稿",
|
|
4289
4529
|
hideCancel: viewOnly,
|
|
4290
|
-
|
|
4530
|
+
editor: true,
|
|
4291
4531
|
errorPrefix: "草稿保存失败:"
|
|
4292
4532
|
});
|
|
4293
4533
|
if (viewOnly) {
|
|
@@ -4296,10 +4536,13 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4296
4536
|
else control.readOnly = true;
|
|
4297
4537
|
});
|
|
4298
4538
|
}
|
|
4539
|
+
$("#dialog-fields").querySelector("[data-dialog-draft-delete]")?.addEventListener("click", () => {
|
|
4540
|
+
void deleteDraft(item);
|
|
4541
|
+
});
|
|
4299
4542
|
}
|
|
4300
4543
|
|
|
4301
4544
|
async function renderDrafts(page = moduleListPages.drafts) {
|
|
4302
|
-
const allDrafts = await
|
|
4545
|
+
const allDrafts = await moduleApiAllPages("drafts", `/api/works/${state.work.id}/drafts`);
|
|
4303
4546
|
const drafts = draftTypeFilter === "all"
|
|
4304
4547
|
? allDrafts
|
|
4305
4548
|
: allDrafts.filter((draft) => draft.draftType === draftTypeFilter);
|
|
@@ -4309,7 +4552,8 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4309
4552
|
const layout = readModuleLayout();
|
|
4310
4553
|
if (drafts.length) mountModuleLayoutToggle(layout, "草稿列表样式");
|
|
4311
4554
|
else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
4312
|
-
|
|
4555
|
+
mountDraftFilterToggle();
|
|
4556
|
+
const filterToolbar = `<section id="draft-filter-panel" class="draft-filter-toolbar${draftFiltersPanelOpen ? "" : " hidden"}" aria-label="草稿筛选">
|
|
4313
4557
|
<label for="draft-type-filter">草稿类型</label>
|
|
4314
4558
|
<select id="draft-type-filter" aria-label="按草稿类型筛选">
|
|
4315
4559
|
<option value="all" ${draftTypeFilter === "all" ? "selected" : ""}>全部草稿</option>
|
|
@@ -4319,7 +4563,7 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4319
4563
|
${draftTypeFilter === "all" ? "" : `<span aria-live="polite">筛选后剩余 ${drafts.length} 篇草稿</span>`}
|
|
4320
4564
|
</section>`;
|
|
4321
4565
|
const actions = (item) => canEditModule("drafts")
|
|
4322
|
-
? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}
|
|
4566
|
+
? `${recordCardEditButton("edit-draft", item.id, `草稿“${item.title}”`)}${recordHistoryButton("draft", item.id, item.title)}`
|
|
4323
4567
|
: recordHistoryButton("draft", item.id, item.title);
|
|
4324
4568
|
const cards = `<div class="card-grid">${pageResult.items.map((item) => `
|
|
4325
4569
|
<article class="record-card preview-record-card" data-open-draft="${esc(item.id)}" role="button" tabindex="0" aria-label="查看草稿 ${esc(item.title)}">
|
|
@@ -4344,12 +4588,12 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4344
4588
|
: emptyDrafts);
|
|
4345
4589
|
$("#draft-type-filter").addEventListener("change", async (event) => {
|
|
4346
4590
|
draftTypeFilter = ["prose", "setting"].includes(event.currentTarget.value) ? event.currentTarget.value : "all";
|
|
4591
|
+
draftFiltersPanelOpen = true;
|
|
4347
4592
|
moduleListPages.drafts = 1;
|
|
4348
4593
|
await renderDrafts(1);
|
|
4349
4594
|
});
|
|
4350
4595
|
bindModuleLayoutToggle(() => renderDrafts(pageResult.page));
|
|
4351
4596
|
bindModulePagination("drafts", renderDrafts);
|
|
4352
|
-
const draftById = (draftId) => drafts.find((draft) => draft.id === draftId);
|
|
4353
4597
|
$("#module-content").querySelectorAll("[data-open-draft]").forEach((card) => {
|
|
4354
4598
|
const open = async () => openDraftDialog(await api(`/api/drafts/${encodeURIComponent(card.dataset.openDraft)}`), { readOnly: true });
|
|
4355
4599
|
card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) void open(); });
|
|
@@ -4363,9 +4607,6 @@ async function renderDrafts(page = moduleListPages.drafts) {
|
|
|
4363
4607
|
$("#module-content").querySelectorAll("[data-edit-draft]").forEach((button) => button.addEventListener("click", async () => {
|
|
4364
4608
|
openDraftDialog(await api(`/api/drafts/${encodeURIComponent(button.dataset.editDraft)}`));
|
|
4365
4609
|
}));
|
|
4366
|
-
$("#module-content").querySelectorAll("[data-delete-draft]").forEach((button) => button.addEventListener("click", () => {
|
|
4367
|
-
void deleteDraft(draftById(button.dataset.deleteDraft));
|
|
4368
|
-
}));
|
|
4369
4610
|
bindEntityHistoryButtons(() => renderDrafts(pageResult.page));
|
|
4370
4611
|
}
|
|
4371
4612
|
|
|
@@ -4387,7 +4628,7 @@ function renderSettingRows(records) {
|
|
|
4387
4628
|
}
|
|
4388
4629
|
|
|
4389
4630
|
async function renderSettings(page = moduleListPages.settings) {
|
|
4390
|
-
const records = await
|
|
4631
|
+
const records = await moduleApiAllPages("settings", `/api/works/${state.work.id}/settings`);
|
|
4391
4632
|
state.settings = records;
|
|
4392
4633
|
mountModuleCount(records.length);
|
|
4393
4634
|
const pageResult = paginateModuleItems(records, page, "settings");
|
|
@@ -4408,9 +4649,11 @@ async function renderCharacters(page = characterListPage) {
|
|
|
4408
4649
|
const hasCharacterFilters = characterFilters.raceIds.length > 0 || characterFilters.organizationIds.length > 0;
|
|
4409
4650
|
const pageSize = pageSizeFor("characters");
|
|
4410
4651
|
const [characterSource, races, organizations] = await Promise.all([
|
|
4411
|
-
hasCharacterFilters
|
|
4412
|
-
|
|
4413
|
-
|
|
4652
|
+
hasCharacterFilters
|
|
4653
|
+
? moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
|
|
4654
|
+
: moduleApiPage("characters", `/api/works/${state.work.id}/characters`, page, pageSize),
|
|
4655
|
+
canReadModule("races") ? moduleApi("characters", `/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
4656
|
+
canReadModule("organizations") ? moduleApiAllPages("characters", `/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
4414
4657
|
]);
|
|
4415
4658
|
const characterPage = hasCharacterFilters
|
|
4416
4659
|
? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
|
|
@@ -4586,7 +4829,7 @@ async function renderRaces() {
|
|
|
4586
4829
|
const workId = state.work.id;
|
|
4587
4830
|
const generation = workScopedUiGeneration;
|
|
4588
4831
|
const requestId = ++raceListRequestId;
|
|
4589
|
-
const roots = await
|
|
4832
|
+
const roots = await moduleApi("races", `/api/works/${workId}/races?scope=roots`);
|
|
4590
4833
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
|
|
4591
4834
|
state.races = roots.items;
|
|
4592
4835
|
loadedRaceHierarchyWorkId = roots.items.length === roots.total ? workId : null;
|
|
@@ -4594,7 +4837,7 @@ async function renderRaces() {
|
|
|
4594
4837
|
if (loadedRaceHierarchyWorkId === workId) return;
|
|
4595
4838
|
|
|
4596
4839
|
const dismissLoadingToast = persistentToast("正在加载子种族……");
|
|
4597
|
-
const loadPromise =
|
|
4840
|
+
const loadPromise = moduleApi("races", `/api/works/${workId}/races?scope=descendants`).then((descendants) => {
|
|
4598
4841
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== raceListRequestId) return;
|
|
4599
4842
|
state.races = [...roots.items, ...descendants];
|
|
4600
4843
|
loadedRaceHierarchyWorkId = workId;
|
|
@@ -4618,8 +4861,8 @@ async function renderRaces() {
|
|
|
4618
4861
|
|
|
4619
4862
|
async function renderOrganizations(page = moduleListPages.organizations) {
|
|
4620
4863
|
[state.organizations, state.characters] = await Promise.all([
|
|
4621
|
-
|
|
4622
|
-
canReadModule("characters") ?
|
|
4864
|
+
moduleApiAllPages("organizations", `/api/works/${state.work.id}/organizations`),
|
|
4865
|
+
canReadModule("characters") ? moduleApiAllPages("organizations", `/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
4623
4866
|
]);
|
|
4624
4867
|
mountModuleCount(state.organizations.length);
|
|
4625
4868
|
const pageResult = paginateModuleItems(state.organizations, page, "organizations");
|
|
@@ -4685,8 +4928,8 @@ function setTimelineMultiSelectMode(enabled) {
|
|
|
4685
4928
|
|
|
4686
4929
|
async function renderTimeline(page = moduleListPages.timeline) {
|
|
4687
4930
|
const [events, tracks] = await Promise.all([
|
|
4688
|
-
|
|
4689
|
-
|
|
4931
|
+
moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline`),
|
|
4932
|
+
moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline-tracks`)
|
|
4690
4933
|
]);
|
|
4691
4934
|
mountModuleCount(events.length);
|
|
4692
4935
|
const pageResult = paginateModuleItems(events, page, "timeline");
|
|
@@ -4729,8 +4972,8 @@ async function renderTimeline(page = moduleListPages.timeline) {
|
|
|
4729
4972
|
async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
|
|
4730
4973
|
const currentChapterId = state.chapter?.id;
|
|
4731
4974
|
const [outlines, foreshadows] = await Promise.all([
|
|
4732
|
-
|
|
4733
|
-
|
|
4975
|
+
moduleApiAllPages("outlines", `/api/works/${state.work.id}/outlines`),
|
|
4976
|
+
moduleApiAllPages("outlines", `/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `¤tChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
|
|
4734
4977
|
]);
|
|
4735
4978
|
mountModuleCount(outlines.length + foreshadows.length);
|
|
4736
4979
|
const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
|
|
@@ -4783,8 +5026,8 @@ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foresh
|
|
|
4783
5026
|
}
|
|
4784
5027
|
|
|
4785
5028
|
async function renderRelationships(page = moduleListPages.relationships) {
|
|
4786
|
-
state.characters = canReadModule("characters") ? await
|
|
4787
|
-
const relationships = await
|
|
5029
|
+
state.characters = canReadModule("characters") ? await moduleApiAllPages("relationships", `/api/works/${state.work.id}/characters`) : [];
|
|
5030
|
+
const relationships = await moduleApiAllPages("relationships", `/api/works/${state.work.id}/relationships`);
|
|
4788
5031
|
const filteredRelationships = filterRelationships(relationships, relationshipFilters);
|
|
4789
5032
|
const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
|
|
4790
5033
|
moduleListPages.relationships = pageResult.page;
|
|
@@ -4858,8 +5101,8 @@ async function renderReviews(page = moduleListPages.reviews) {
|
|
|
4858
5101
|
const canMergeCharacters = canResolveReview
|
|
4859
5102
|
&& ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
|
|
4860
5103
|
const [reviews, characters] = await Promise.all([
|
|
4861
|
-
|
|
4862
|
-
canReadCharacters ?
|
|
5104
|
+
moduleApiAllPages("reviews", `/api/works/${state.work.id}/reviews`),
|
|
5105
|
+
canReadCharacters ? moduleApiAllPages("reviews", `/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
|
|
4863
5106
|
]);
|
|
4864
5107
|
mountModuleCount(reviews.length);
|
|
4865
5108
|
const pageResult = paginateModuleItems(reviews, page, "reviews");
|
|
@@ -4947,16 +5190,16 @@ async function renderReviews(page = moduleListPages.reviews) {
|
|
|
4947
5190
|
}));
|
|
4948
5191
|
}
|
|
4949
5192
|
|
|
4950
|
-
async function renderTasks(page = taskListPage) {
|
|
5193
|
+
async function renderTasks(page = taskListPage, { refresh = false } = {}) {
|
|
4951
5194
|
stopTaskProgressRefresh();
|
|
4952
5195
|
const pageSize = pageSizeFor("analysisTasks");
|
|
4953
5196
|
const [taskPage, settings] = await Promise.all([
|
|
4954
|
-
|
|
5197
|
+
moduleApiPage("tasks", `/api/works/${state.work.id}/tasks`, page, pageSize, { refresh }),
|
|
4955
5198
|
canReadModule("ai-settings")
|
|
4956
|
-
?
|
|
5199
|
+
? moduleApi("tasks", `/api/works/${state.work.id}/ai-settings`, { refresh })
|
|
4957
5200
|
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunDailyTaskLimit: 0, autoRunFailureThreshold: 3, autoRunPaused: false })
|
|
4958
5201
|
]);
|
|
4959
|
-
if (!taskPage.items.length && page > 1) return renderTasks(page - 1);
|
|
5202
|
+
if (!taskPage.items.length && page > 1) return renderTasks(page - 1, { refresh });
|
|
4960
5203
|
taskListPage = taskPage.page;
|
|
4961
5204
|
const tasks = taskPage.items;
|
|
4962
5205
|
const taskTotal = Number(taskPage.total ?? taskPage.stats?.total ?? tasks.length);
|
|
@@ -5067,7 +5310,7 @@ async function renderTasks(page = taskListPage) {
|
|
|
5067
5310
|
: "自动执行已关闭");
|
|
5068
5311
|
taskAutoRunEditing = false;
|
|
5069
5312
|
taskAutoRunEditingWorkId = null;
|
|
5070
|
-
await renderTasks();
|
|
5313
|
+
await renderTasks(taskListPage, { refresh: true });
|
|
5071
5314
|
} catch (error) {
|
|
5072
5315
|
toast(error.message, "error");
|
|
5073
5316
|
button.disabled = false;
|
|
@@ -5171,12 +5414,7 @@ async function rerunAnalysisTaskWithModel(task, button) {
|
|
|
5171
5414
|
try {
|
|
5172
5415
|
const models = await api(`/api/works/${encodeURIComponent(task.workId)}/models`);
|
|
5173
5416
|
const currentModelId = String(task.model?.id ?? "");
|
|
5174
|
-
const availableModels = models.filter((model) =>
|
|
5175
|
-
model.id !== currentModelId
|
|
5176
|
-
&& model.enabled
|
|
5177
|
-
&& model.providerStatus === "enabled"
|
|
5178
|
-
&& model.providerConnectionStatus === "success"
|
|
5179
|
-
);
|
|
5417
|
+
const availableModels = models.filter((model) => model.id !== currentModelId && isSelectableModel(model));
|
|
5180
5418
|
if (!availableModels.length) throw new Error("当前没有其他可用模型,请先配置并测试模型");
|
|
5181
5419
|
$("#form-dialog").close();
|
|
5182
5420
|
const currentModelLabel = task.model?.displayName || "运行时默认模型";
|
|
@@ -5226,7 +5464,7 @@ function scheduleTaskProgressRefresh(workId, runningCount) {
|
|
|
5226
5464
|
return;
|
|
5227
5465
|
}
|
|
5228
5466
|
try {
|
|
5229
|
-
await renderTasks();
|
|
5467
|
+
await renderTasks(taskListPage, { refresh: true });
|
|
5230
5468
|
} catch (error) {
|
|
5231
5469
|
console.error("Failed to refresh task progress", error);
|
|
5232
5470
|
scheduleTaskProgressRefresh(workId, runningCount);
|
|
@@ -5687,11 +5925,28 @@ function openTaskDetailDialog(task, trace) {
|
|
|
5687
5925
|
}
|
|
5688
5926
|
|
|
5689
5927
|
function renderProviderCards(providers, models) {
|
|
5690
|
-
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) =>
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5928
|
+
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => {
|
|
5929
|
+
const providerModels = models.filter((model) => model.providerId === provider.id);
|
|
5930
|
+
const providerStatusClass = provider.status === "disabled" ? "is-disabled" : provider.status === "error" ? "is-error" : "is-enabled";
|
|
5931
|
+
const disabledNotice = provider.status === "disabled"
|
|
5932
|
+
? `<div class="provider-disabled-notice" role="status"><strong>已停用</strong><span>不会出现在新任务的模型列表中,历史任务仍可查看。</span></div>`
|
|
5933
|
+
: "";
|
|
5934
|
+
return `
|
|
5935
|
+
<article class="record-card provider-card ${provider.status === "disabled" ? "is-disabled" : ""}"><div class="provider-card-meta"><small>平台级 · ${esc(providerProtocolLabel(provider.protocol))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><span class="provider-status-badge ${providerStatusClass}">${esc(providerStatusLabel(provider.status))}</span></div><h3>${esc(provider.name)}</h3>
|
|
5936
|
+
${disabledNotice}<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
5937
|
+
<div class="provider-models">${providerModels.map((model) => {
|
|
5938
|
+
const modelUnavailable = !isSelectableModel({ ...model, providerStatus: provider.status, providerConnectionStatus: provider.connectionStatus });
|
|
5939
|
+
const modelStatus = !model.enabled
|
|
5940
|
+
? `<span class="model-status-badge is-disabled">模型已停用</span>`
|
|
5941
|
+
: provider.status !== "enabled"
|
|
5942
|
+
? `<span class="model-status-badge is-disabled">供应商已停用</span>`
|
|
5943
|
+
: provider.connectionStatus !== "success"
|
|
5944
|
+
? `<span class="model-status-badge is-unavailable">连接不可用</span>`
|
|
5945
|
+
: "";
|
|
5946
|
+
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 ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>${modelStatus}<button class="ghost-button model-test-button" type="button" data-test-model="${esc(model.id)}" aria-label="测试模型 ${esc(model.displayName)}">测试连接</button></div>`;
|
|
5947
|
+
}).join("")}</div>
|
|
5948
|
+
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
5949
|
+
}).join("")}</div>`
|
|
5695
5950
|
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或 Anthropic 兼容接口地址和密钥,测试成功后再添加模型。");
|
|
5696
5951
|
}
|
|
5697
5952
|
|
|
@@ -5704,28 +5959,50 @@ function bindPlatformProviderActions(host, providers, models) {
|
|
|
5704
5959
|
await renderPlatformAiConfig();
|
|
5705
5960
|
await loadModels();
|
|
5706
5961
|
}));
|
|
5962
|
+
host.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", async () => {
|
|
5963
|
+
button.disabled = true;
|
|
5964
|
+
button.textContent = "测试中";
|
|
5965
|
+
const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
|
|
5966
|
+
toast(result.ok ? "模型连接测试成功" : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
|
|
5967
|
+
await renderPlatformAiConfig();
|
|
5968
|
+
await loadModels();
|
|
5969
|
+
}));
|
|
5707
5970
|
host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel)));
|
|
5708
5971
|
host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(undefined, models.find((model) => model.id === button.dataset.editModel))));
|
|
5709
5972
|
host.querySelectorAll("[data-edit-provider]").forEach((button) => button.addEventListener("click", () => openProviderDialog(providers.find((provider) => provider.id === button.dataset.editProvider))));
|
|
5710
5973
|
}
|
|
5711
5974
|
|
|
5712
|
-
function renderTaskDefaults(models, providers, taskDefaults) {
|
|
5975
|
+
function renderTaskDefaults(models, providers, taskDefaults, settings) {
|
|
5713
5976
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
5714
5977
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
5715
|
-
|
|
5978
|
+
const availableModels = models.filter((model) => isSelectableModel(model));
|
|
5979
|
+
const availableModelIds = new Set(availableModels.map((model) => model.id));
|
|
5980
|
+
const currentDefaultModels = taskDefaults
|
|
5981
|
+
.map((item) => item.model)
|
|
5982
|
+
.filter((model) => model && !availableModelIds.has(model.id));
|
|
5983
|
+
const optionModels = [...availableModels, ...currentDefaultModels];
|
|
5984
|
+
return optionModels.length ? `<section class="config-section">
|
|
5716
5985
|
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
|
|
5717
|
-
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody
|
|
5986
|
+
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody><tr><td>创作助手对话标题生成</td><td><select class="default-model-select" data-title-generation-default aria-label="创作助手对话标题生成">
|
|
5987
|
+
<option value="" ${settings.titleGenerationModelId ? "" : "selected"}>使用提示词前 15 个字</option>
|
|
5988
|
+
${models.map((model) => {
|
|
5989
|
+
const provider = providerById.get(model.providerId);
|
|
5990
|
+
const available = model.enabled && provider?.status === "enabled" && provider?.connectionStatus === "success";
|
|
5991
|
+
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>`;
|
|
5992
|
+
}).join("")}
|
|
5993
|
+
</select></td></tr>${taskTypeLabels.map(([taskType, label]) => {
|
|
5718
5994
|
const currentModelId = defaultModelByTask.get(taskType) ?? "";
|
|
5719
5995
|
return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
|
|
5720
5996
|
<option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
|
|
5721
|
-
${
|
|
5997
|
+
${optionModels.map((model) => {
|
|
5722
5998
|
const provider = providerById.get(model.providerId);
|
|
5723
|
-
const available = model.
|
|
5724
|
-
|
|
5999
|
+
const available = isSelectableModel({ ...model, providerStatus: model.providerStatus ?? provider?.status, providerConnectionStatus: model.providerConnectionStatus ?? provider?.connectionStatus });
|
|
6000
|
+
const unavailableLabel = !model.enabled ? "模型已停用 · " : provider?.status !== "enabled" ? "供应商已停用 · " : "连接不可用 · ";
|
|
6001
|
+
return `<option value="${esc(model.id)}" ${model.id === currentModelId ? "selected" : ""} ${available ? "" : "disabled"}>${esc(`${available ? "" : unavailableLabel}${modelOptionLabel({ ...model, providerName: model.providerName || provider?.name })}`)}</option>`;
|
|
5725
6002
|
}).join("")}
|
|
5726
6003
|
</select></td></tr>`;
|
|
5727
6004
|
}).join("")}</tbody></table>
|
|
5728
|
-
</section>` : emptyModule("
|
|
6005
|
+
</section>` : emptyModule("尚未配置可用模型", "请先启用并测试供应商模型,已停用模型不会出现在新任务选择中。");
|
|
5729
6006
|
}
|
|
5730
6007
|
|
|
5731
6008
|
const relationshipIndexStatusLabels = Object.freeze({
|
|
@@ -6106,12 +6383,12 @@ async function renderBookAiSettings() {
|
|
|
6106
6383
|
relationshipSearchIndexRefreshTimer = null;
|
|
6107
6384
|
}
|
|
6108
6385
|
const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6386
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings`),
|
|
6387
|
+
moduleApi("ai-settings", "/api/platform/ai/providers"),
|
|
6388
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/models`),
|
|
6389
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/task-defaults`),
|
|
6390
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/relationship-search-index`),
|
|
6391
|
+
moduleApi("ai-settings", `/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
|
|
6115
6392
|
]);
|
|
6116
6393
|
const host = $("#module-content");
|
|
6117
6394
|
const workId = String(state.work.id);
|
|
@@ -6119,7 +6396,7 @@ async function renderBookAiSettings() {
|
|
|
6119
6396
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
6120
6397
|
title: "本书 Token 用量",
|
|
6121
6398
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
6122
|
-
})}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
|
|
6399
|
+
})}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</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)}`;
|
|
6123
6400
|
scrollUsageCalendarsToLatest(host);
|
|
6124
6401
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
6125
6402
|
"beforebegin",
|
|
@@ -6256,6 +6533,18 @@ async function renderBookAiSettings() {
|
|
|
6256
6533
|
button.disabled = false;
|
|
6257
6534
|
}
|
|
6258
6535
|
});
|
|
6536
|
+
host.querySelector("[data-title-generation-default]")?.addEventListener("change", async (event) => {
|
|
6537
|
+
const select = event.currentTarget;
|
|
6538
|
+
select.disabled = true;
|
|
6539
|
+
try {
|
|
6540
|
+
await api(`/api/works/${state.work.id}/ai-settings`, { method: "PATCH", body: { titleGenerationModelId: select.value } });
|
|
6541
|
+
toast("创作助手对话标题生成模型已更新");
|
|
6542
|
+
} catch (error) {
|
|
6543
|
+
toast(error.message, "error");
|
|
6544
|
+
}
|
|
6545
|
+
await renderBookAiSettings();
|
|
6546
|
+
await loadModels();
|
|
6547
|
+
});
|
|
6259
6548
|
host.querySelectorAll("[data-task-default]").forEach((select) => select.addEventListener("change", async () => {
|
|
6260
6549
|
select.disabled = true;
|
|
6261
6550
|
try {
|
|
@@ -6275,11 +6564,11 @@ async function loadModels() {
|
|
|
6275
6564
|
const generation = workScopedUiGeneration;
|
|
6276
6565
|
const models = await api(`/api/works/${workId}/models`);
|
|
6277
6566
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
6278
|
-
state.models = models;
|
|
6567
|
+
state.models = models.filter((model) => isSelectableModel(model));
|
|
6279
6568
|
loadedAiModelsWorkId = workId;
|
|
6280
6569
|
const select = $("#ai-model");
|
|
6281
6570
|
select.innerHTML = state.models.length
|
|
6282
|
-
? state.models.map((model) => `<option value="${esc(model.id)}"
|
|
6571
|
+
? state.models.map((model) => `<option value="${esc(model.id)}">${esc(modelOptionLabel(model))}</option>`).join("")
|
|
6283
6572
|
: '<option value="">请先配置模型</option>';
|
|
6284
6573
|
scheduleAiContextUsage();
|
|
6285
6574
|
}
|
|
@@ -6327,9 +6616,50 @@ function currentAiRequestScope() {
|
|
|
6327
6616
|
return { taskType, scope, selection };
|
|
6328
6617
|
}
|
|
6329
6618
|
|
|
6619
|
+
function renderAiContextDistribution(usage) {
|
|
6620
|
+
const popover = $("#ai-context-popover");
|
|
6621
|
+
const host = $("#ai-context-distribution");
|
|
6622
|
+
const distribution = normalizeAiContextTokenDistribution(usage);
|
|
6623
|
+
const contextWindow = distribution.contextWindow.toLocaleString("zh-CN");
|
|
6624
|
+
$("#ai-context-popover-description").textContent = usage
|
|
6625
|
+
? `已占用 ${distribution.occupiedTokens.toLocaleString("zh-CN")} / ${contextWindow} tok`
|
|
6626
|
+
: "选择可用模型后显示当前上下文用量";
|
|
6627
|
+
host.replaceChildren(...distribution.items.map((item) => {
|
|
6628
|
+
const row = document.createElement("div");
|
|
6629
|
+
row.className = "ai-context-distribution-row";
|
|
6630
|
+
row.dataset.key = item.key;
|
|
6631
|
+
row.setAttribute("role", "listitem");
|
|
6632
|
+
row.setAttribute("aria-label", `${item.label}:${item.tokens.toLocaleString("zh-CN")} tok,占 ${item.percent}%`);
|
|
6633
|
+
|
|
6634
|
+
const label = document.createElement("div");
|
|
6635
|
+
label.className = "ai-context-distribution-label";
|
|
6636
|
+
const title = document.createElement("span");
|
|
6637
|
+
title.textContent = item.label;
|
|
6638
|
+
if (item.key === "context") {
|
|
6639
|
+
const description = document.createElement("small");
|
|
6640
|
+
description.textContent = "用户和 agent 的交互";
|
|
6641
|
+
title.append(" ", description);
|
|
6642
|
+
}
|
|
6643
|
+
const value = document.createElement("strong");
|
|
6644
|
+
value.textContent = `${item.tokens.toLocaleString("zh-CN")} tok · ${item.percent}%`;
|
|
6645
|
+
label.append(title, value);
|
|
6646
|
+
|
|
6647
|
+
const track = document.createElement("div");
|
|
6648
|
+
track.className = "ai-context-distribution-track";
|
|
6649
|
+
track.setAttribute("aria-hidden", "true");
|
|
6650
|
+
const bar = document.createElement("span");
|
|
6651
|
+
bar.style.setProperty("--distribution-percent", String(item.percent));
|
|
6652
|
+
track.append(bar);
|
|
6653
|
+
row.append(label, track);
|
|
6654
|
+
return row;
|
|
6655
|
+
}));
|
|
6656
|
+
popover.dataset.hasUsage = String(Boolean(usage));
|
|
6657
|
+
}
|
|
6658
|
+
|
|
6330
6659
|
function setAiContextMeter(usage) {
|
|
6331
6660
|
const meter = $("#ai-context-meter");
|
|
6332
6661
|
const value = meter.querySelector("b");
|
|
6662
|
+
renderAiContextDistribution(usage);
|
|
6333
6663
|
if (!usage) {
|
|
6334
6664
|
meter.classList.add("is-empty");
|
|
6335
6665
|
meter.classList.remove("is-warning", "is-danger");
|
|
@@ -6351,6 +6681,14 @@ function setAiContextMeter(usage) {
|
|
|
6351
6681
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
6352
6682
|
}
|
|
6353
6683
|
|
|
6684
|
+
function setAiContextDistributionVisible(visible) {
|
|
6685
|
+
const meter = $("#ai-context-meter");
|
|
6686
|
+
const popover = $("#ai-context-popover");
|
|
6687
|
+
popover.classList.toggle("hidden", !visible);
|
|
6688
|
+
meter.setAttribute("aria-expanded", String(visible));
|
|
6689
|
+
if (!visible && document.activeElement === $("#ai-context-popover-close")) meter.focus();
|
|
6690
|
+
}
|
|
6691
|
+
|
|
6354
6692
|
function showAiContextWarning(usage = null) {
|
|
6355
6693
|
const percent = Math.max(0, Math.round(Number(usage?.conversationUsagePercent) || 0));
|
|
6356
6694
|
const threshold = Math.max(50, Math.min(90, Number(usage?.compactThreshold) || 85));
|
|
@@ -6641,6 +6979,7 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
6641
6979
|
dialog.classList.toggle("wide-dialog", Boolean(options.wide));
|
|
6642
6980
|
dialog.classList.toggle("trace-dialog", Boolean(options.trace));
|
|
6643
6981
|
dialog.classList.toggle("large-dialog", Boolean(options.large));
|
|
6982
|
+
dialog.classList.toggle("editor-dialog", Boolean(options.editor));
|
|
6644
6983
|
bindDynamicListControls($("#dialog-fields"));
|
|
6645
6984
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
6646
6985
|
formDialogVditors = bindVditorEditors($("#dialog-fields"));
|
|
@@ -8361,11 +8700,7 @@ async function openTaskDialog() {
|
|
|
8361
8700
|
return;
|
|
8362
8701
|
}
|
|
8363
8702
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
8364
|
-
const availableTaskModels = taskModels.filter((model) =>
|
|
8365
|
-
model.enabled
|
|
8366
|
-
&& model.providerStatus === "enabled"
|
|
8367
|
-
&& model.providerConnectionStatus === "success"
|
|
8368
|
-
);
|
|
8703
|
+
const availableTaskModels = taskModels.filter((model) => isSelectableModel(model));
|
|
8369
8704
|
const characterOptions = relationshipCharacters.map((character) => [character.id, character.name]);
|
|
8370
8705
|
const relationshipCharacterPicker = `<div class="form-field relationship-character-field">
|
|
8371
8706
|
<span id="relationship-character-label">被分析角色(可多选)</span>
|
|
@@ -8671,8 +9006,8 @@ async function openTaskDialog() {
|
|
|
8671
9006
|
function openProviderDialog(item) {
|
|
8672
9007
|
const protocol = item?.protocol ?? "openai-chat-completions";
|
|
8673
9008
|
const defaultBaseUrl = protocol === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1";
|
|
8674
|
-
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("
|
|
8675
|
-
const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")),
|
|
9009
|
+
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("protocol", "接口协议", "select", protocol, [["openai-chat-completions", "OpenAI Chat Completions"], ["anthropic-messages", "Anthropic Messages"]]) + field("baseUrl", "API 基础地址", "url", item?.baseUrl ?? defaultBaseUrl) + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
|
|
9010
|
+
const body = { name: form.get("name"), protocol: form.get("protocol"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
|
|
8676
9011
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
8677
9012
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
8678
9013
|
await renderPlatformAiConfig();
|
|
@@ -8758,29 +9093,37 @@ async function sendAi() {
|
|
|
8758
9093
|
let assistantContent = "";
|
|
8759
9094
|
let assistantMessage;
|
|
8760
9095
|
let assistantMetadata = {};
|
|
9096
|
+
let persistedStreamMessage = null;
|
|
8761
9097
|
let suggestion = null;
|
|
8762
9098
|
if (taskType === "chat") {
|
|
8763
9099
|
const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId, currentMessageId: persistedUserMessage.id });
|
|
8764
9100
|
assistantContent = streamed.content;
|
|
8765
9101
|
assistantMessage = streamed.message;
|
|
8766
9102
|
assistantMetadata = streamed.metadata;
|
|
9103
|
+
persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
|
|
9104
|
+
applyAiConversationTitle(streamed.conversationTitle);
|
|
8767
9105
|
} else {
|
|
8768
9106
|
suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
|
|
8769
9107
|
assistantContent = suggestion.content;
|
|
8770
9108
|
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
8771
9109
|
}
|
|
8772
9110
|
try {
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
9111
|
+
if (persistedStreamMessage) {
|
|
9112
|
+
updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
|
|
9113
|
+
attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
|
|
9114
|
+
} else {
|
|
9115
|
+
const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
|
|
9116
|
+
if (assistantMessage) {
|
|
9117
|
+
updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
|
|
9118
|
+
attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
|
|
9119
|
+
} else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
|
|
9120
|
+
}
|
|
8778
9121
|
} catch (error) {
|
|
8779
9122
|
if (suggestion) appendSuggestion(suggestion);
|
|
8780
9123
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
8781
9124
|
}
|
|
8782
9125
|
} catch (error) {
|
|
8783
|
-
const failureMessage =
|
|
9126
|
+
const failureMessage = formatAiFailureMessage(error);
|
|
8784
9127
|
let persistedFailureMessage = null;
|
|
8785
9128
|
try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
8786
9129
|
appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
|
|
@@ -8814,6 +9157,9 @@ async function streamChat(body) {
|
|
|
8814
9157
|
let generatedMetadata = {};
|
|
8815
9158
|
let toolCalls = [];
|
|
8816
9159
|
let processSteps = [];
|
|
9160
|
+
let persistedMessageId = null;
|
|
9161
|
+
let persistedMessageCreatedAt = null;
|
|
9162
|
+
let conversationTitle = null;
|
|
8817
9163
|
let finalAnswerStarted = false;
|
|
8818
9164
|
const processStartedAt = Date.now();
|
|
8819
9165
|
const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
|
|
@@ -8825,7 +9171,7 @@ async function streamChat(body) {
|
|
|
8825
9171
|
});
|
|
8826
9172
|
if (!response.ok || !response.body) {
|
|
8827
9173
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
8828
|
-
throw
|
|
9174
|
+
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
8829
9175
|
}
|
|
8830
9176
|
const reader = response.body.getReader();
|
|
8831
9177
|
const decoder = new TextDecoder();
|
|
@@ -8868,6 +9214,9 @@ async function streamChat(body) {
|
|
|
8868
9214
|
meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
|
|
8869
9215
|
scrollAiFeedToBottom();
|
|
8870
9216
|
} else if (eventName === "complete") {
|
|
9217
|
+
persistedMessageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
9218
|
+
persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
|
|
9219
|
+
conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
|
|
8871
9220
|
await typewriter.finish();
|
|
8872
9221
|
message.classList.remove("is-streaming");
|
|
8873
9222
|
content.setAttribute("aria-busy", "false");
|
|
@@ -8881,7 +9230,7 @@ async function streamChat(body) {
|
|
|
8881
9230
|
attachAssistantCopyAction(message, streamedText);
|
|
8882
9231
|
scrollAiFeedToBottom();
|
|
8883
9232
|
} else if (eventName === "error") {
|
|
8884
|
-
streamError =
|
|
9233
|
+
streamError = createClientError(payload, "AI 流式调用失败", response.status);
|
|
8885
9234
|
}
|
|
8886
9235
|
};
|
|
8887
9236
|
while (true) {
|
|
@@ -8895,7 +9244,7 @@ async function streamChat(body) {
|
|
|
8895
9244
|
if (buffer.trim()) await consume(buffer);
|
|
8896
9245
|
await typewriter.finish();
|
|
8897
9246
|
if (streamError) throw streamError;
|
|
8898
|
-
return { content: streamedText, message, metadata: generatedMetadata };
|
|
9247
|
+
return { content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle };
|
|
8899
9248
|
} catch (error) {
|
|
8900
9249
|
typewriter.reveal();
|
|
8901
9250
|
message.classList.remove("is-streaming");
|
|
@@ -8910,8 +9259,12 @@ async function streamChat(body) {
|
|
|
8910
9259
|
|
|
8911
9260
|
function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
|
|
8912
9261
|
const message = document.createElement("div");
|
|
8913
|
-
|
|
8914
|
-
message.
|
|
9262
|
+
const isFailure = role === "assistant" && text.startsWith("调用失败:");
|
|
9263
|
+
message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
|
|
9264
|
+
const messageBody = isFailure
|
|
9265
|
+
? `<p class="ai-error-text">${esc(text)}</p>`
|
|
9266
|
+
: renderMarkdown(text);
|
|
9267
|
+
message.innerHTML = `<div class="message-body">${messageBody}</div>`;
|
|
8915
9268
|
attachMessageHeading(message, role === "user" ? "作者" : "助手", createdAt ?? undefined);
|
|
8916
9269
|
if (citations.length) {
|
|
8917
9270
|
const references = document.createElement("div");
|
|
@@ -10270,6 +10623,7 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
10270
10623
|
if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
|
|
10271
10624
|
if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
|
|
10272
10625
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
10626
|
+
if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
|
|
10273
10627
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
10274
10628
|
$("#account-menu").classList.add("hidden");
|
|
10275
10629
|
$("#account-button").setAttribute("aria-expanded", "false");
|
|
@@ -10291,6 +10645,7 @@ document.addEventListener("keydown", (event) => {
|
|
|
10291
10645
|
closeLineCitationMenu();
|
|
10292
10646
|
closeMarkdownTableMenu(true);
|
|
10293
10647
|
hideAiMentionMenu();
|
|
10648
|
+
setAiContextDistributionVisible(false);
|
|
10294
10649
|
}
|
|
10295
10650
|
});
|
|
10296
10651
|
document.addEventListener("keydown", (event) => {
|
|
@@ -10300,6 +10655,10 @@ document.addEventListener("keydown", (event) => {
|
|
|
10300
10655
|
if (event.repeat) return;
|
|
10301
10656
|
openSearchDialog().catch((error) => toast(error.message, "error"));
|
|
10302
10657
|
}, { capture: true });
|
|
10658
|
+
$("#ai-context-meter").addEventListener("click", () => {
|
|
10659
|
+
setAiContextDistributionVisible($("#ai-context-popover").classList.contains("hidden"));
|
|
10660
|
+
});
|
|
10661
|
+
$("#ai-context-popover-close").addEventListener("click", () => setAiContextDistributionVisible(false));
|
|
10303
10662
|
$("#ai-send").addEventListener("click", sendAi);
|
|
10304
10663
|
$("#ai-new-conversation").addEventListener("click", async () => {
|
|
10305
10664
|
const button = $("#ai-new-conversation");
|