@musnows/scriverse 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-protocol.js +22 -9
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +913 -195
- package/dist/ai.js.map +1 -1
- package/dist/app.js +245 -46
- package/dist/app.js.map +1 -1
- package/dist/cli-core.js +23 -13
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +251 -2
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/google-vertex-auth.js +155 -0
- package/dist/google-vertex-auth.js.map +1 -0
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-mentions.js +10 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +1004 -131
- package/dist/public/display-labels.js +2 -1
- package/dist/public/index.html +75 -16
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +155 -19
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +101 -9
- package/dist/security.js.map +1 -1
- package/dist/store.js +290 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +62 -24
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +23 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/public/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
|
|
4
|
-
import {
|
|
4
|
+
import { findAiMention, listAiMentionOptions, mergeAiReferenceScope } from "/ai-mentions.js?v=20260801-context-lock-v2";
|
|
5
5
|
import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
|
|
6
6
|
import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
|
|
7
7
|
import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
|
|
@@ -10,8 +10,9 @@ import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-s
|
|
|
10
10
|
import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
|
|
11
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
|
-
import { formatAiMessageTime } from "/ai-message-time.js?v=
|
|
14
|
-
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution } from "/ai-context-meter.js?v=
|
|
13
|
+
import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
|
|
14
|
+
import { formatAiContextUsagePercent, formatAiContextUsageTooltip, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260801-retain-usage-v1";
|
|
15
|
+
import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
|
|
15
16
|
import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
|
|
16
17
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
17
18
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
taskScopeLabel,
|
|
36
37
|
timelineStatusLabel,
|
|
37
38
|
characterStateFieldLabel
|
|
38
|
-
} from "/display-labels.js?v=
|
|
39
|
+
} from "/display-labels.js?v=20260801-google-vertex";
|
|
39
40
|
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
|
|
40
41
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
41
42
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
@@ -49,6 +50,7 @@ import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20
|
|
|
49
50
|
import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
|
|
50
51
|
import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
|
|
51
52
|
import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
|
|
53
|
+
import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
|
|
52
54
|
import {
|
|
53
55
|
clampCropRect,
|
|
54
56
|
containImageRect,
|
|
@@ -108,8 +110,11 @@ const state = {
|
|
|
108
110
|
aiCitations: [],
|
|
109
111
|
aiReferences: [],
|
|
110
112
|
aiPromptSent: false,
|
|
113
|
+
aiTaskType: "chat",
|
|
114
|
+
aiContextScope: { type: "none" },
|
|
111
115
|
aiConversationId: null,
|
|
112
116
|
aiConversations: [],
|
|
117
|
+
aiRoleplayCharacter: null,
|
|
113
118
|
aiLastMessageAt: null,
|
|
114
119
|
settings: [],
|
|
115
120
|
dirty: false,
|
|
@@ -152,6 +157,7 @@ function createPresenceClientId() {
|
|
|
152
157
|
|
|
153
158
|
const presenceClientId = createPresenceClientId();
|
|
154
159
|
const presenceHeartbeatInterval = 12_000;
|
|
160
|
+
const systemBootCheckInterval = 8_000;
|
|
155
161
|
let presenceParticipants = [];
|
|
156
162
|
let presenceHeartbeatTimer = null;
|
|
157
163
|
let presenceHeartbeatQueued = null;
|
|
@@ -160,6 +166,10 @@ const acknowledgedCollaborativeChangeIds = new Set();
|
|
|
160
166
|
let collaborativeChangePromptOpen = false;
|
|
161
167
|
let relationshipPresenceId = null;
|
|
162
168
|
let collaborationAutoSaveDisabled = false;
|
|
169
|
+
let systemBootId = null;
|
|
170
|
+
let systemBootCheckTimer = null;
|
|
171
|
+
let systemBootCheckPromise = null;
|
|
172
|
+
let systemRestartDetected = false;
|
|
163
173
|
let chapterAnnotations = [];
|
|
164
174
|
let workAuditRecords = [];
|
|
165
175
|
let workAuditNextPage = null;
|
|
@@ -177,6 +187,10 @@ let backgroundTaskCenterWorkId = null;
|
|
|
177
187
|
let backgroundTaskCenterTasksInitialized = false;
|
|
178
188
|
let backgroundTaskCenterTaskSnapshots = new Map();
|
|
179
189
|
let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
|
|
190
|
+
const systemHealthPollInterval = 30_000;
|
|
191
|
+
let systemHealthTimer = null;
|
|
192
|
+
let systemHealthSnapshot = { status: "checking", version: "" };
|
|
193
|
+
let topbarStatusSource = "view";
|
|
180
194
|
const taskProgressRefreshInterval = 2_500;
|
|
181
195
|
const taskStatusSnapshots = new Map();
|
|
182
196
|
|
|
@@ -325,6 +339,7 @@ function applyWorkAccessMode() {
|
|
|
325
339
|
$("#ai-prompt").readOnly = aiReadOnly;
|
|
326
340
|
$("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
|
|
327
341
|
$("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
|
|
342
|
+
renderAiRoleplayCharacterSelect();
|
|
328
343
|
updateBackgroundTaskCenterVisibility();
|
|
329
344
|
if (proseReadOnly) {
|
|
330
345
|
chapterEditorReadOnly = true;
|
|
@@ -338,6 +353,15 @@ const $ = (selector) => document.querySelector(selector);
|
|
|
338
353
|
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
|
|
339
354
|
const maximumAvatarFileSize = 5 * 1024 * 1024;
|
|
340
355
|
|
|
356
|
+
function setAiAssistantStatus(status) {
|
|
357
|
+
const failed = status === "error";
|
|
358
|
+
const label = failed ? "创作助手状态:执行失败" : "创作助手状态:正常";
|
|
359
|
+
const dot = $("#ai-status-dot");
|
|
360
|
+
dot.classList.toggle("is-error", failed);
|
|
361
|
+
dot.setAttribute("aria-label", label);
|
|
362
|
+
dot.title = label;
|
|
363
|
+
}
|
|
364
|
+
|
|
341
365
|
function userAvatarInitial(user) {
|
|
342
366
|
return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
|
|
343
367
|
}
|
|
@@ -395,6 +419,7 @@ let aiReferencesLoadPromise = null;
|
|
|
395
419
|
let aiReferencesLoadWorkId = null;
|
|
396
420
|
let aiConversationsLoadPromise = null;
|
|
397
421
|
let aiConversationsLoadWorkId = null;
|
|
422
|
+
let latestAiContextUsage = null;
|
|
398
423
|
const aiConversationHistoryPageLimit = 20;
|
|
399
424
|
let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
|
|
400
425
|
let workScopedUiGeneration = 0;
|
|
@@ -614,7 +639,7 @@ function presencePageForRoute(route = currentPageRoute()) {
|
|
|
614
639
|
if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
|
|
615
640
|
if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
|
|
616
641
|
if (route.view === "module") return { kind: "module", module: route.module };
|
|
617
|
-
if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage") return { kind: "settings" };
|
|
642
|
+
if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage" || route.view === "work-audit") return { kind: "settings" };
|
|
618
643
|
return { kind: "welcome" };
|
|
619
644
|
}
|
|
620
645
|
|
|
@@ -780,6 +805,7 @@ function currentPageRoute() {
|
|
|
780
805
|
if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
|
|
781
806
|
if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
|
|
782
807
|
if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
|
|
808
|
+
if (!$("#work-audit-view").classList.contains("hidden")) return { view: "work-audit", workId, ...settingsRouteContext() };
|
|
783
809
|
if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
|
|
784
810
|
if (!workId) return { view: "shelf" };
|
|
785
811
|
if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
|
|
@@ -1300,7 +1326,7 @@ function renderAiReferences() {
|
|
|
1300
1326
|
|
|
1301
1327
|
function renderAiQuickActions() {
|
|
1302
1328
|
const quickActions = $(".quick-actions");
|
|
1303
|
-
const visible = shouldShowAiQuickActions(state.aiPromptSent);
|
|
1329
|
+
const visible = !state.aiRoleplayCharacter && shouldShowAiQuickActions(state.aiPromptSent);
|
|
1304
1330
|
quickActions.classList.toggle("hidden", !visible);
|
|
1305
1331
|
quickActions.setAttribute("aria-hidden", String(!visible));
|
|
1306
1332
|
}
|
|
@@ -1314,7 +1340,7 @@ function attachMessageHeading(message, label, createdAt = new Date().toISOString
|
|
|
1314
1340
|
role.textContent = label;
|
|
1315
1341
|
const time = document.createElement("time");
|
|
1316
1342
|
time.dateTime = timestamp;
|
|
1317
|
-
time.textContent = formatAiMessageTime(timestamp
|
|
1343
|
+
time.textContent = formatAiMessageTime(timestamp);
|
|
1318
1344
|
heading.append(role, time);
|
|
1319
1345
|
message.prepend(heading);
|
|
1320
1346
|
message.dataset.createdAt = timestamp;
|
|
@@ -1328,24 +1354,38 @@ function updateMessageCreatedAt(message, createdAt) {
|
|
|
1328
1354
|
const time = message.querySelector(".message-heading time");
|
|
1329
1355
|
if (!time) return;
|
|
1330
1356
|
time.dateTime = createdAt;
|
|
1331
|
-
time.textContent = formatAiMessageTime(createdAt
|
|
1357
|
+
time.textContent = formatAiMessageTime(createdAt);
|
|
1332
1358
|
message.dataset.createdAt = createdAt;
|
|
1333
1359
|
if (message === $("#ai-feed").lastElementChild) state.aiLastMessageAt = createdAt;
|
|
1334
1360
|
}
|
|
1335
1361
|
|
|
1336
1362
|
function resetAiFeed() {
|
|
1337
1363
|
state.aiLastMessageAt = null;
|
|
1338
|
-
|
|
1364
|
+
const roleplayName = state.aiRoleplayCharacter?.name;
|
|
1365
|
+
$("#ai-feed").innerHTML = roleplayName
|
|
1366
|
+
? `<div class="assistant-message"><span class="message-heading"><span>${esc(roleplayName)}</span></span><div class="message-body"><p>正在扮演 ${esc(roleplayName)}。我只能通过角色卡和与自己有关的记忆回答。</p></div></div>`
|
|
1367
|
+
: '<div class="assistant-message"><span class="message-heading"><span>助手</span></span><div class="message-body"><p>选择章节和模型后,可以问答、续写或校对。所有引用都基于已保存正文。</p></div></div>';
|
|
1339
1368
|
}
|
|
1340
1369
|
|
|
1341
|
-
function
|
|
1370
|
+
function aiAssistantLabel(suffix = "") {
|
|
1371
|
+
const name = state.aiRoleplayCharacter?.name || "助手";
|
|
1372
|
+
return suffix ? `${name} · ${suffix}` : name;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function createAiContextCompactionDivider({ kind = "conversation", ariaLabel = "已压缩上下文", title = "" } = {}) {
|
|
1342
1376
|
const divider = document.createElement("div");
|
|
1343
1377
|
divider.className = "ai-context-compaction-divider";
|
|
1344
1378
|
divider.dataset.contextCompaction = kind;
|
|
1345
1379
|
divider.dataset.testid = "ai-context-compaction-divider";
|
|
1346
1380
|
divider.setAttribute("role", "separator");
|
|
1347
|
-
divider.setAttribute("aria-label",
|
|
1381
|
+
divider.setAttribute("aria-label", ariaLabel);
|
|
1382
|
+
if (title) divider.title = title;
|
|
1348
1383
|
divider.innerHTML = "<span>已压缩上下文</span>";
|
|
1384
|
+
return divider;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function appendAiContextCompactionDivider(kind, before = null) {
|
|
1388
|
+
const divider = createAiContextCompactionDivider({ kind });
|
|
1349
1389
|
const feed = $("#ai-feed");
|
|
1350
1390
|
if (before?.parentElement === feed) feed.insertBefore(divider, before);
|
|
1351
1391
|
else feed.append(divider);
|
|
@@ -1434,7 +1474,8 @@ const AI_TOOL_DISPLAY_NAMES = {
|
|
|
1434
1474
|
grep: "查询正文关键字",
|
|
1435
1475
|
search_story_entities: "搜索作品实体",
|
|
1436
1476
|
read_character_sections: "读取人物 Markdown 章节",
|
|
1437
|
-
search_drafts: "搜索想法"
|
|
1477
|
+
search_drafts: "搜索想法",
|
|
1478
|
+
recall_self: "回忆自身"
|
|
1438
1479
|
};
|
|
1439
1480
|
|
|
1440
1481
|
const AI_TOOL_DESCRIPTIONS = {
|
|
@@ -1443,7 +1484,8 @@ const AI_TOOL_DESCRIPTIONS = {
|
|
|
1443
1484
|
grep: "查询正文关键字所在的完整段落及章节信息。",
|
|
1444
1485
|
search_story_entities: "按实体名、拼音或短关键词混合检索设定、人物、组织等结构化记录;非语义问答。",
|
|
1445
1486
|
read_character_sections: "读取指定人物 Markdown 档案章节的摘要或原文。",
|
|
1446
|
-
search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。"
|
|
1487
|
+
search_drafts: "搜索可能采用、也可能永远不会进入正文或正式设定的未确认临时想法。",
|
|
1488
|
+
recall_self: "读取当前扮演角色自己的角色卡、档案,以及自己参与的关系、时间线和正文记忆。"
|
|
1447
1489
|
};
|
|
1448
1490
|
|
|
1449
1491
|
let aiFeedScrollFrame = null;
|
|
@@ -1524,8 +1566,10 @@ function openAiToolCallDetail(toolCall) {
|
|
|
1524
1566
|
time.textContent = formatAiToolCallTime(calledAt);
|
|
1525
1567
|
if (calledAt && !Number.isNaN(new Date(calledAt).getTime())) time.dateTime = new Date(calledAt).toISOString();
|
|
1526
1568
|
else time.removeAttribute("datetime");
|
|
1569
|
+
const resultDetails = formatAiToolCallResult(toolCall?.result);
|
|
1527
1570
|
$("#ai-tool-call-arguments").textContent = JSON.stringify(toolCall?.arguments ?? {}, null, 2);
|
|
1528
|
-
$("#ai-tool-call-result").textContent =
|
|
1571
|
+
$("#ai-tool-call-result-length").textContent = `${resultDetails.characterCount.toLocaleString("zh-CN")} 字符`;
|
|
1572
|
+
$("#ai-tool-call-result").textContent = resultDetails.text;
|
|
1529
1573
|
$("#ai-tool-call-dialog").showModal();
|
|
1530
1574
|
}
|
|
1531
1575
|
|
|
@@ -1595,14 +1639,11 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
|
|
|
1595
1639
|
list.className = "ai-process-list";
|
|
1596
1640
|
for (const step of steps) {
|
|
1597
1641
|
if (step?.type === "context_compaction") {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
compaction.title = `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`;
|
|
1604
|
-
compaction.innerHTML = "<span>已压缩上下文</span>";
|
|
1605
|
-
list.append(compaction);
|
|
1642
|
+
list.append(createAiContextCompactionDivider({
|
|
1643
|
+
kind: "tool",
|
|
1644
|
+
ariaLabel: `第 ${Number(step.round) || 1} 轮已压缩上下文`,
|
|
1645
|
+
title: `已将 ${Number(step.sourceMessageCount) || 0} 条工具上下文压缩为摘要`
|
|
1646
|
+
}));
|
|
1606
1647
|
continue;
|
|
1607
1648
|
}
|
|
1608
1649
|
if (step?.type === "tool" && step.toolCall) {
|
|
@@ -1663,7 +1704,7 @@ function renderAiConversationHistory() {
|
|
|
1663
1704
|
const title = document.createElement("strong");
|
|
1664
1705
|
title.textContent = conversation.title;
|
|
1665
1706
|
const meta = document.createElement("small");
|
|
1666
|
-
meta.textContent = `${conversation.messageCount}
|
|
1707
|
+
meta.textContent = `${conversation.messageCount} 条${conversation.roleplayCharacter?.name ? ` · 扮演 ${conversation.roleplayCharacter.name}` : ""} · ${formatDateTime(conversation.updatedAt)}`;
|
|
1667
1708
|
button.append(title, meta);
|
|
1668
1709
|
button.addEventListener("click", () => openAiConversation(conversation.id));
|
|
1669
1710
|
host.append(button);
|
|
@@ -1759,6 +1800,10 @@ async function openAiConversation(conversationId, hideHistory = true) {
|
|
|
1759
1800
|
upsertAiConversationSummary(conversation);
|
|
1760
1801
|
state.aiConversationId = conversation.id;
|
|
1761
1802
|
state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
|
|
1803
|
+
applyAiConversationTaskType(conversation.taskType);
|
|
1804
|
+
applyAiConversationContextScope(conversation.contextScope);
|
|
1805
|
+
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
1806
|
+
resetAiContextMeter();
|
|
1762
1807
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1763
1808
|
resetAiFeed();
|
|
1764
1809
|
for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
|
|
@@ -1777,26 +1822,152 @@ async function openAiConversation(conversationId, hideHistory = true) {
|
|
|
1777
1822
|
if (hideHistory) setAiHistoryVisible(false);
|
|
1778
1823
|
}
|
|
1779
1824
|
|
|
1780
|
-
async function createNewAiConversation() {
|
|
1825
|
+
async function createNewAiConversation(taskType = "chat") {
|
|
1781
1826
|
if (!state.work) return;
|
|
1782
|
-
const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: {} });
|
|
1827
|
+
const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
|
|
1783
1828
|
upsertAiConversationSummary(conversation);
|
|
1784
1829
|
state.aiConversationId = conversation.id;
|
|
1785
1830
|
state.aiPromptSent = false;
|
|
1831
|
+
applyAiConversationTaskType(conversation.taskType);
|
|
1832
|
+
applyAiConversationContextScope(conversation.contextScope);
|
|
1833
|
+
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
1786
1834
|
$("#ai-conversation-title").textContent = conversation.title;
|
|
1787
1835
|
resetAiFeed();
|
|
1788
1836
|
hideAiContextWarning();
|
|
1789
|
-
|
|
1837
|
+
resetAiContextMeter();
|
|
1790
1838
|
renderAiQuickActions();
|
|
1791
1839
|
setAiHistoryVisible(false);
|
|
1792
1840
|
}
|
|
1793
1841
|
|
|
1842
|
+
function renderAiRoleplayCharacterSelect() {
|
|
1843
|
+
const select = $("#ai-roleplay-character");
|
|
1844
|
+
const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
|
|
1845
|
+
const availableCharacters = state.characters.filter((character) => !character.mergedIntoCharacterId);
|
|
1846
|
+
const options = [{ id: "", name: "选择角色卡" }, ...availableCharacters.map((character) => ({
|
|
1847
|
+
id: String(character.id),
|
|
1848
|
+
name: String(character.name)
|
|
1849
|
+
}))];
|
|
1850
|
+
if (selectedId && !options.some((option) => option.id === selectedId)) {
|
|
1851
|
+
options.push({ id: selectedId, name: String(state.aiRoleplayCharacter.name) });
|
|
1852
|
+
}
|
|
1853
|
+
select.replaceChildren(...options.map((option) => {
|
|
1854
|
+
const element = document.createElement("option");
|
|
1855
|
+
element.value = option.id;
|
|
1856
|
+
element.textContent = option.name;
|
|
1857
|
+
return element;
|
|
1858
|
+
}));
|
|
1859
|
+
select.value = selectedId;
|
|
1860
|
+
const canSelectCharacter = Boolean(state.work)
|
|
1861
|
+
&& canReadModule("characters")
|
|
1862
|
+
&& canWritePermissionModule(state.work, "ai-chat");
|
|
1863
|
+
select.disabled = !canSelectCharacter || state.aiPromptSent;
|
|
1864
|
+
select.title = canSelectCharacter
|
|
1865
|
+
? "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
|
|
1866
|
+
: "当前账户没有角色模块读取权限";
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function syncAiTaskOptions() {
|
|
1870
|
+
const roleplaySelected = $("#ai-task").value === "roleplay";
|
|
1871
|
+
$("#ai-scope").classList.toggle("hidden", roleplaySelected);
|
|
1872
|
+
$("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
|
|
1873
|
+
$("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
|
|
1874
|
+
$("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
|
|
1875
|
+
$(".ai-include-setting-info").classList.toggle("hidden", roleplaySelected);
|
|
1876
|
+
$(".ai-include-setting-info").setAttribute("aria-hidden", String(roleplaySelected));
|
|
1877
|
+
$("#ai-task").disabled = state.aiPromptSent;
|
|
1878
|
+
$("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
|
|
1879
|
+
$("#ai-scope").disabled = roleplaySelected || state.aiPromptSent;
|
|
1880
|
+
$("#ai-scope").title = state.aiPromptSent
|
|
1881
|
+
? "对话开始后不能切换上下文引用"
|
|
1882
|
+
: roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
|
|
1883
|
+
syncAiIncludeSettingInfoControl();
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function applyAiConversationTaskType(taskType) {
|
|
1887
|
+
const normalizedTaskType = ["chat", "roleplay", "continue", "polish"].includes(taskType) ? taskType : "chat";
|
|
1888
|
+
state.aiTaskType = normalizedTaskType;
|
|
1889
|
+
$("#ai-task").value = normalizedTaskType;
|
|
1890
|
+
$("#ai-task").dataset.previousValue = normalizedTaskType;
|
|
1891
|
+
syncAiTaskOptions();
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function applyAiConversationContextScope(scope) {
|
|
1895
|
+
const normalizedScope = scope && typeof scope === "object" ? JSON.parse(JSON.stringify(scope)) : { type: "none" };
|
|
1896
|
+
state.aiContextScope = normalizedScope;
|
|
1897
|
+
$("#ai-scope").value = normalizedScope.type === "book" ? "book"
|
|
1898
|
+
: normalizedScope.type === "volume" ? "volume"
|
|
1899
|
+
: normalizedScope.type === "chapter" && normalizedScope.includeBookSummary ? "chapter-summary"
|
|
1900
|
+
: normalizedScope.type === "chapter" ? "chapter"
|
|
1901
|
+
: "none";
|
|
1902
|
+
$("#ai-include-setting-info").checked = normalizedScope.includeSettingInfo !== false;
|
|
1903
|
+
syncAiTaskOptions();
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function applyAiRoleplayCharacter(character) {
|
|
1907
|
+
state.aiRoleplayCharacter = character?.id ? character : null;
|
|
1908
|
+
const active = Boolean(state.aiRoleplayCharacter);
|
|
1909
|
+
if (active) $("#ai-scope").value = "none";
|
|
1910
|
+
$(".ai-panel").classList.toggle("is-roleplaying", active);
|
|
1911
|
+
$("#ai-prompt").dataset.placeholder = active
|
|
1912
|
+
? `以 ${String(state.aiRoleplayCharacter.name)} 的身份开始对话……`
|
|
1913
|
+
: "告诉 AI 你想讨论或修改什么……";
|
|
1914
|
+
renderAiRoleplayCharacterSelect();
|
|
1915
|
+
syncAiTaskOptions();
|
|
1916
|
+
renderAiQuickActions();
|
|
1917
|
+
resetAiContextMeter();
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function refreshAiMessageRoleLabels() {
|
|
1921
|
+
$("#ai-feed").querySelectorAll(".assistant-message .message-heading > span").forEach((label) => {
|
|
1922
|
+
label.textContent = aiAssistantLabel();
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function updateAiRoleplayCharacter(characterId) {
|
|
1927
|
+
const conversationId = await ensureAiConversation();
|
|
1928
|
+
const conversation = await api(`/api/ai-conversations/${conversationId}/roleplay`, {
|
|
1929
|
+
method: "PATCH",
|
|
1930
|
+
body: { characterId: characterId || null }
|
|
1931
|
+
});
|
|
1932
|
+
upsertAiConversationSummary(conversation);
|
|
1933
|
+
applyAiConversationTaskType(conversation.taskType);
|
|
1934
|
+
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
1935
|
+
if ($("#ai-feed").querySelector("[data-message-id]")) refreshAiMessageRoleLabels();
|
|
1936
|
+
else resetAiFeed();
|
|
1937
|
+
toast(conversation.roleplayCharacter
|
|
1938
|
+
? `已进入 ${conversation.roleplayCharacter.name} 的角色扮演模式`
|
|
1939
|
+
: "已退出角色扮演模式");
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1794
1942
|
async function ensureAiConversation() {
|
|
1795
1943
|
if (state.aiConversationId) return state.aiConversationId;
|
|
1796
|
-
await createNewAiConversation();
|
|
1944
|
+
await createNewAiConversation($("#ai-task").value);
|
|
1797
1945
|
return state.aiConversationId;
|
|
1798
1946
|
}
|
|
1799
1947
|
|
|
1948
|
+
async function persistAiConversationTaskType(taskType) {
|
|
1949
|
+
const conversationId = await ensureAiConversation();
|
|
1950
|
+
const conversation = await api(`/api/ai-conversations/${conversationId}/task-type`, {
|
|
1951
|
+
method: "PATCH",
|
|
1952
|
+
body: { taskType }
|
|
1953
|
+
});
|
|
1954
|
+
upsertAiConversationSummary(conversation);
|
|
1955
|
+
applyAiConversationTaskType(conversation.taskType);
|
|
1956
|
+
applyAiRoleplayCharacter(conversation.roleplayCharacter);
|
|
1957
|
+
return conversation;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function persistAiConversationContextScope(scope) {
|
|
1961
|
+
const conversationId = await ensureAiConversation();
|
|
1962
|
+
const conversation = await api(`/api/ai-conversations/${conversationId}/context-scope`, {
|
|
1963
|
+
method: "PATCH",
|
|
1964
|
+
body: { scope }
|
|
1965
|
+
});
|
|
1966
|
+
upsertAiConversationSummary(conversation);
|
|
1967
|
+
applyAiConversationContextScope(conversation.contextScope);
|
|
1968
|
+
return conversation;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1800
1971
|
async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
|
|
1801
1972
|
const conversationId = await ensureAiConversation();
|
|
1802
1973
|
if (!conversationId) throw new Error("无法创建 AI 对话");
|
|
@@ -2100,7 +2271,7 @@ function clearChapterLineSelection() {
|
|
|
2100
2271
|
}
|
|
2101
2272
|
|
|
2102
2273
|
const typographyStorageKey = "ai-novel-typography-v1";
|
|
2103
|
-
const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, density: "balanced" });
|
|
2274
|
+
const typographyDefaults = Object.freeze({ cjkFont: "system", latinFont: "system", fontSize: 17, uiFontSize: 16, aiFontSize: 14, density: "balanced" });
|
|
2104
2275
|
const cjkFontStacks = {
|
|
2105
2276
|
system: '"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC"',
|
|
2106
2277
|
pingfang: '"PingFang SC", "Heiti SC", "Microsoft YaHei", "Noto Sans CJK SC"',
|
|
@@ -2116,15 +2287,21 @@ const latinFontStacks = {
|
|
|
2116
2287
|
consolas: 'Consolas, "Liberation Mono", Menlo, Monaco, "SFMono-Regular"'
|
|
2117
2288
|
};
|
|
2118
2289
|
const typographyFontSizes = [15, 16, 17, 18, 20];
|
|
2290
|
+
const typographyUiFontSizes = [14, 15, 16, 17, 18];
|
|
2291
|
+
const typographyAiFontSizes = [12, 13, 14, 15, 16];
|
|
2119
2292
|
const densityLineHeights = { compact: 1.4, balanced: 1.55, relaxed: 1.75 };
|
|
2120
2293
|
|
|
2121
2294
|
function normalizeTypographySettings(input) {
|
|
2122
2295
|
const value = input && typeof input === "object" ? input : {};
|
|
2123
2296
|
const fontSize = Number(value.fontSize);
|
|
2297
|
+
const uiFontSize = Number(value.uiFontSize);
|
|
2298
|
+
const aiFontSize = Number(value.aiFontSize);
|
|
2124
2299
|
return {
|
|
2125
2300
|
cjkFont: Object.hasOwn(cjkFontStacks, value.cjkFont) ? value.cjkFont : typographyDefaults.cjkFont,
|
|
2126
2301
|
latinFont: Object.hasOwn(latinFontStacks, value.latinFont) ? value.latinFont : typographyDefaults.latinFont,
|
|
2127
2302
|
fontSize: typographyFontSizes.includes(fontSize) ? fontSize : typographyDefaults.fontSize,
|
|
2303
|
+
uiFontSize: typographyUiFontSizes.includes(uiFontSize) ? uiFontSize : typographyDefaults.uiFontSize,
|
|
2304
|
+
aiFontSize: typographyAiFontSizes.includes(aiFontSize) ? aiFontSize : typographyDefaults.aiFontSize,
|
|
2128
2305
|
density: Object.hasOwn(densityLineHeights, value.density) ? value.density : typographyDefaults.density
|
|
2129
2306
|
};
|
|
2130
2307
|
}
|
|
@@ -2176,9 +2353,15 @@ function applyTypographySettings(settings) {
|
|
|
2176
2353
|
root.style.setProperty("--font-latin", latinFontStacks[normalized.latinFont]);
|
|
2177
2354
|
root.style.setProperty("--editor-font-size", `${normalized.fontSize}px`);
|
|
2178
2355
|
root.style.setProperty("--editor-line-height", String(densityLineHeights[normalized.density]));
|
|
2356
|
+
root.style.setProperty("--ui-font-size", `${normalized.uiFontSize}px`);
|
|
2357
|
+
root.style.setProperty("--ui-font-scale", String(normalized.uiFontSize / typographyDefaults.uiFontSize));
|
|
2358
|
+
root.style.setProperty("--ai-font-size", `${normalized.aiFontSize}px`);
|
|
2359
|
+
root.style.setProperty("--ai-font-scale", String(normalized.aiFontSize / typographyDefaults.aiFontSize));
|
|
2179
2360
|
root.dataset.cjkFont = normalized.cjkFont;
|
|
2180
2361
|
root.dataset.latinFont = normalized.latinFont;
|
|
2181
2362
|
root.dataset.fontSize = String(normalized.fontSize);
|
|
2363
|
+
root.dataset.uiFontSize = String(normalized.uiFontSize);
|
|
2364
|
+
root.dataset.aiFontSize = String(normalized.aiFontSize);
|
|
2182
2365
|
root.dataset.density = normalized.density;
|
|
2183
2366
|
scheduleChapterLineNumbers();
|
|
2184
2367
|
}
|
|
@@ -2199,6 +2382,8 @@ function fillAppearanceForm(settings) {
|
|
|
2199
2382
|
$("#appearance-cjk-font").value = normalized.cjkFont;
|
|
2200
2383
|
$("#appearance-latin-font").value = normalized.latinFont;
|
|
2201
2384
|
$("#appearance-font-size").value = String(normalized.fontSize);
|
|
2385
|
+
$("#appearance-ui-font-size").value = String(normalized.uiFontSize);
|
|
2386
|
+
$("#appearance-ai-font-size").value = String(normalized.aiFontSize);
|
|
2202
2387
|
$("#appearance-density").value = normalized.density;
|
|
2203
2388
|
}
|
|
2204
2389
|
|
|
@@ -2208,6 +2393,8 @@ function readAppearanceForm() {
|
|
|
2208
2393
|
cjkFont: form.get("cjkFont"),
|
|
2209
2394
|
latinFont: form.get("latinFont"),
|
|
2210
2395
|
fontSize: form.get("fontSize"),
|
|
2396
|
+
uiFontSize: form.get("uiFontSize"),
|
|
2397
|
+
aiFontSize: form.get("aiFontSize"),
|
|
2211
2398
|
density: form.get("density")
|
|
2212
2399
|
});
|
|
2213
2400
|
}
|
|
@@ -2218,6 +2405,7 @@ function renderTypographyPreview() {
|
|
|
2218
2405
|
preview.style.fontFamily = `${latinFontStacks[settings.latinFont]}, ${cjkFontStacks[settings.cjkFont]}, monospace, sans-serif`;
|
|
2219
2406
|
preview.style.fontSize = `${settings.fontSize}px`;
|
|
2220
2407
|
preview.style.lineHeight = String(densityLineHeights[settings.density]);
|
|
2408
|
+
$("#font-size-preview").textContent = `界面 ${settings.uiFontSize} px · Agent 对话 ${settings.aiFontSize} px`;
|
|
2221
2409
|
}
|
|
2222
2410
|
|
|
2223
2411
|
function openAppearanceDialog() {
|
|
@@ -2296,19 +2484,29 @@ async function api(path, options = {}) {
|
|
|
2296
2484
|
const headers = { ...(options.headers ?? {}) };
|
|
2297
2485
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
2298
2486
|
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
headers
|
|
2302
|
-
|
|
2303
|
-
|
|
2487
|
+
let response;
|
|
2488
|
+
try {
|
|
2489
|
+
response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
|
|
2490
|
+
...options,
|
|
2491
|
+
headers,
|
|
2492
|
+
body: body && typeof body !== "string" ? JSON.stringify(body) : body
|
|
2493
|
+
});
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
updateSystemHealth({ status: "offline" });
|
|
2496
|
+
throw error;
|
|
2497
|
+
}
|
|
2498
|
+
updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
|
|
2304
2499
|
if (!response.ok) {
|
|
2305
2500
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
2306
2501
|
// Presence is best-effort; a heartbeat 401 must not force the login wall.
|
|
2307
2502
|
if (response.status === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2503
|
+
const restarted = await checkSystemBoot(true);
|
|
2504
|
+
if (!restarted) {
|
|
2505
|
+
state.user = null;
|
|
2506
|
+
state.csrfToken = null;
|
|
2507
|
+
moduleRequestCache.clear();
|
|
2508
|
+
showAuth(false);
|
|
2509
|
+
}
|
|
2312
2510
|
}
|
|
2313
2511
|
throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
|
|
2314
2512
|
}
|
|
@@ -2317,6 +2515,12 @@ async function api(path, options = {}) {
|
|
|
2317
2515
|
return null;
|
|
2318
2516
|
}
|
|
2319
2517
|
const payload = await response.json();
|
|
2518
|
+
if (path === "/api/health") {
|
|
2519
|
+
updateSystemHealth({
|
|
2520
|
+
status: payload.data?.status === "ok" ? "ready" : "degraded",
|
|
2521
|
+
version: payload.data?.version
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2320
2524
|
invalidateModuleRequestsAfterMutation(path, method);
|
|
2321
2525
|
return payload.data;
|
|
2322
2526
|
}
|
|
@@ -2351,9 +2555,9 @@ function formatAiFailureMessage(error) {
|
|
|
2351
2555
|
if (status) lines.push(`服务端状态:HTTP ${status}`);
|
|
2352
2556
|
if (providerName || providerId) lines.push(`模型供应商:${providerName || providerId}`);
|
|
2353
2557
|
if (modelId) lines.push(`模型 ID:${modelId}`);
|
|
2354
|
-
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2355
2558
|
if (callId) lines.push(`调用 ID:${callId}`);
|
|
2356
|
-
|
|
2559
|
+
if (failure && failure !== message) lines.push(`详细原因:${failure}`);
|
|
2560
|
+
return lines.join("\n");
|
|
2357
2561
|
}
|
|
2358
2562
|
|
|
2359
2563
|
async function apiPage(path, page = 1, limit = 30) {
|
|
@@ -2431,23 +2635,97 @@ function invalidateModuleRequestsAfterMutation(path, method) {
|
|
|
2431
2635
|
affected.forEach((module) => moduleRequestCache.invalidate(state.work.id, module));
|
|
2432
2636
|
}
|
|
2433
2637
|
|
|
2638
|
+
function applyProductHealthMetadata(health) {
|
|
2639
|
+
const version = String(health?.version ?? "").trim();
|
|
2640
|
+
document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
|
|
2641
|
+
element.textContent = version ? `v${version}` : "v—";
|
|
2642
|
+
});
|
|
2643
|
+
document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
|
|
2644
|
+
element.classList.toggle("hidden", health?.development !== true);
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
function scheduleSystemHealthCheck() {
|
|
2649
|
+
if (systemHealthTimer !== null) window.clearTimeout(systemHealthTimer);
|
|
2650
|
+
systemHealthTimer = window.setTimeout(() => {
|
|
2651
|
+
systemHealthTimer = null;
|
|
2652
|
+
void refreshSystemHealth();
|
|
2653
|
+
}, systemHealthPollInterval);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
async function refreshSystemHealth() {
|
|
2657
|
+
try {
|
|
2658
|
+
const health = await api("/api/health");
|
|
2659
|
+
applyProductHealthMetadata(health);
|
|
2660
|
+
} catch {
|
|
2661
|
+
// 系统状态已由 api 记录;健康检查失败不弹出重复提示
|
|
2662
|
+
} finally {
|
|
2663
|
+
scheduleSystemHealthCheck();
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2434
2667
|
async function initializeProductFooters() {
|
|
2435
2668
|
const year = String(new Date().getFullYear());
|
|
2436
2669
|
document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
|
|
2670
|
+
applyProductHealthMetadata(null);
|
|
2671
|
+
await refreshSystemHealth();
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function observeSystemBootId(value) {
|
|
2675
|
+
const nextBootId = typeof value === "string" ? value.trim() : "";
|
|
2676
|
+
if (!nextBootId) return false;
|
|
2677
|
+
if (!systemBootId) {
|
|
2678
|
+
systemBootId = nextBootId;
|
|
2679
|
+
return false;
|
|
2680
|
+
}
|
|
2681
|
+
if (nextBootId === systemBootId) return false;
|
|
2682
|
+
systemRestartDetected = true;
|
|
2683
|
+
if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
|
|
2684
|
+
systemBootCheckTimer = null;
|
|
2685
|
+
const dialog = $("#system-restart-dialog");
|
|
2686
|
+
if (!dialog.open) dialog.showModal();
|
|
2687
|
+
window.requestAnimationFrame(() => $("#system-restart-dialog-title").focus());
|
|
2688
|
+
return true;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
async function checkSystemBoot(forceFresh = false) {
|
|
2692
|
+
if (!state.user || systemRestartDetected) return systemRestartDetected;
|
|
2693
|
+
if (systemBootCheckPromise) {
|
|
2694
|
+
if (!forceFresh) return systemBootCheckPromise;
|
|
2695
|
+
await systemBootCheckPromise;
|
|
2696
|
+
if (systemRestartDetected) return true;
|
|
2697
|
+
}
|
|
2698
|
+
systemBootCheckPromise = (async () => {
|
|
2699
|
+
try {
|
|
2700
|
+
const response = await fetch("/api/health", {
|
|
2701
|
+
cache: "no-store",
|
|
2702
|
+
headers: { Accept: "application/json" }
|
|
2703
|
+
});
|
|
2704
|
+
if (!response.ok) return false;
|
|
2705
|
+
const payload = await response.json();
|
|
2706
|
+
return observeSystemBootId(payload?.data?.bootId);
|
|
2707
|
+
} catch {
|
|
2708
|
+
return false;
|
|
2709
|
+
}
|
|
2710
|
+
})();
|
|
2437
2711
|
try {
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
element.textContent = version ? `v${version}` : "v—";
|
|
2442
|
-
});
|
|
2443
|
-
document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
|
|
2444
|
-
element.classList.toggle("hidden", health.development !== true);
|
|
2445
|
-
});
|
|
2446
|
-
} catch {
|
|
2447
|
-
document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
|
|
2712
|
+
return await systemBootCheckPromise;
|
|
2713
|
+
} finally {
|
|
2714
|
+
systemBootCheckPromise = null;
|
|
2448
2715
|
}
|
|
2449
2716
|
}
|
|
2450
2717
|
|
|
2718
|
+
function scheduleSystemBootCheck(delay = systemBootCheckInterval) {
|
|
2719
|
+
if (systemBootCheckTimer !== null) clearTimeout(systemBootCheckTimer);
|
|
2720
|
+
systemBootCheckTimer = null;
|
|
2721
|
+
if (!state.user || systemRestartDetected) return;
|
|
2722
|
+
systemBootCheckTimer = setTimeout(async () => {
|
|
2723
|
+
systemBootCheckTimer = null;
|
|
2724
|
+
await checkSystemBoot();
|
|
2725
|
+
scheduleSystemBootCheck();
|
|
2726
|
+
}, delay);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2451
2729
|
function selectAuthMode(mode) {
|
|
2452
2730
|
const registerTab = $("#auth-register-tab");
|
|
2453
2731
|
const login = mode === "login" || registerTab.disabled;
|
|
@@ -2589,7 +2867,9 @@ async function initializeAuthentication() {
|
|
|
2589
2867
|
}
|
|
2590
2868
|
// 已登录却停在登录页路由时,回到书架首页
|
|
2591
2869
|
if (route.view === "login") window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
|
|
2870
|
+
observeSystemBootId(session.bootId);
|
|
2592
2871
|
applyAuthenticatedUser(session);
|
|
2872
|
+
scheduleSystemBootCheck();
|
|
2593
2873
|
await loadPlatformUiSettings();
|
|
2594
2874
|
return true;
|
|
2595
2875
|
}
|
|
@@ -2745,10 +3025,53 @@ document.addEventListener("toggle", (event) => {
|
|
|
2745
3025
|
}
|
|
2746
3026
|
}, true);
|
|
2747
3027
|
|
|
3028
|
+
function paintTopbarStatus(text, { source, tone, title }) {
|
|
3029
|
+
const element = $("#save-state");
|
|
3030
|
+
const colors = { ok: "var(--green)", pending: "var(--muted)", error: "var(--accent)" };
|
|
3031
|
+
topbarStatusSource = source;
|
|
3032
|
+
element.textContent = text;
|
|
3033
|
+
element.style.color = colors[tone] ?? colors.ok;
|
|
3034
|
+
element.dataset.statusSource = source;
|
|
3035
|
+
element.setAttribute("aria-label", `${source === "system" ? "系统" : "工作台"}状态:${text}`);
|
|
3036
|
+
element.title = title || text;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function setTopbarViewState(text) {
|
|
3040
|
+
state.dirty = false;
|
|
3041
|
+
paintTopbarStatus(text, { source: "view", tone: "ok", title: text });
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function updateSystemHealth(next) {
|
|
3045
|
+
systemHealthSnapshot = {
|
|
3046
|
+
...systemHealthSnapshot,
|
|
3047
|
+
...next,
|
|
3048
|
+
version: next.version === undefined ? systemHealthSnapshot.version : String(next.version ?? "")
|
|
3049
|
+
};
|
|
3050
|
+
if (topbarStatusSource === "system") renderSystemHealth();
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
function renderSystemHealth() {
|
|
3054
|
+
const presentation = systemStatusPresentation(systemHealthSnapshot);
|
|
3055
|
+
paintTopbarStatus(presentation.label, {
|
|
3056
|
+
source: "system",
|
|
3057
|
+
tone: presentation.tone,
|
|
3058
|
+
title: presentation.title
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
function showSystemStatus() {
|
|
3063
|
+
state.dirty = false;
|
|
3064
|
+
topbarStatusSource = "system";
|
|
3065
|
+
renderSystemHealth();
|
|
3066
|
+
}
|
|
3067
|
+
|
|
2748
3068
|
function setSaveState(text, dirty = false) {
|
|
2749
3069
|
state.dirty = dirty;
|
|
2750
|
-
|
|
2751
|
-
|
|
3070
|
+
paintTopbarStatus(text, {
|
|
3071
|
+
source: "save",
|
|
3072
|
+
tone: dirty ? "error" : "ok",
|
|
3073
|
+
title: text
|
|
3074
|
+
});
|
|
2752
3075
|
}
|
|
2753
3076
|
|
|
2754
3077
|
function chapterDraftSnapshot() {
|
|
@@ -2990,6 +3313,11 @@ async function initializePage() {
|
|
|
2990
3313
|
if (route.view === "platform-usage") {
|
|
2991
3314
|
await showPlatformUsage();
|
|
2992
3315
|
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
if (route.view === "work-audit") {
|
|
3319
|
+
await showWorkAudit();
|
|
3320
|
+
settingsReturnContext = restoredSettingsReturnContext(route);
|
|
2993
3321
|
}
|
|
2994
3322
|
} finally {
|
|
2995
3323
|
document.body.classList.remove("auth-pending");
|
|
@@ -3009,6 +3337,7 @@ function showShelf() {
|
|
|
3009
3337
|
$("#shelf-view").classList.remove("hidden");
|
|
3010
3338
|
$("#platform-ai-view").classList.add("hidden");
|
|
3011
3339
|
$("#platform-usage-view").classList.add("hidden");
|
|
3340
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3012
3341
|
$("#settings-hub-view").classList.add("hidden");
|
|
3013
3342
|
$("#welcome-view").classList.add("hidden");
|
|
3014
3343
|
$("#editor-view").classList.add("hidden");
|
|
@@ -3016,7 +3345,7 @@ function showShelf() {
|
|
|
3016
3345
|
$("#work-meta").textContent = `${state.works.length} 部作品`;
|
|
3017
3346
|
$("#settings-button").removeAttribute("aria-current");
|
|
3018
3347
|
$("#top-search-button").disabled = true;
|
|
3019
|
-
|
|
3348
|
+
setTopbarViewState("书架");
|
|
3020
3349
|
renderShelf();
|
|
3021
3350
|
replacePageRoute({ view: "shelf" });
|
|
3022
3351
|
}
|
|
@@ -3033,7 +3362,7 @@ function renderSettingsHub() {
|
|
|
3033
3362
|
const hasWork = Boolean(state.work);
|
|
3034
3363
|
const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
|
|
3035
3364
|
const canReadAggregate = hasWork && canReadAggregateContent();
|
|
3036
|
-
const
|
|
3365
|
+
const canExportManuscript = hasWork && canReadModule("editor");
|
|
3037
3366
|
const isAdmin = state.user?.role === "admin";
|
|
3038
3367
|
$("#platform-ai-button").classList.toggle("hidden", !isAdmin);
|
|
3039
3368
|
$("#platform-usage-button").classList.toggle("hidden", !isAdmin);
|
|
@@ -3043,10 +3372,11 @@ function renderSettingsHub() {
|
|
|
3043
3372
|
$("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
|
|
3044
3373
|
$("#work-audit-button").disabled = !canManageWork;
|
|
3045
3374
|
$("#top-search-button").disabled = !canReadAggregate;
|
|
3046
|
-
$("#export-button").disabled = !
|
|
3375
|
+
$("#export-button").disabled = !canExportManuscript;
|
|
3376
|
+
$("#export-button").setAttribute("aria-expanded", "false");
|
|
3047
3377
|
$("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
|
|
3048
3378
|
$("#settings-work-note").textContent = hasWork
|
|
3049
|
-
? `当前作品:《${state.work.title}
|
|
3379
|
+
? `当前作品:《${state.work.title}》。导出正文时可选择 Markdown ZIP 或 DOCX;DOCX 在有封面时会嵌入为首页。`
|
|
3050
3380
|
: "当前未选择作品;打开作品后可使用导出。";
|
|
3051
3381
|
}
|
|
3052
3382
|
|
|
@@ -3115,36 +3445,173 @@ async function openWritingProgressDialog() {
|
|
|
3115
3445
|
const workAuditActionLabels = {
|
|
3116
3446
|
"work.created": "创建作品",
|
|
3117
3447
|
"work.updated": "更新作品",
|
|
3448
|
+
"work.cover.updated": "更新作品封面",
|
|
3449
|
+
"work.cover.deleted": "删除作品封面",
|
|
3450
|
+
"work.member-added": "添加作品成员",
|
|
3451
|
+
"work.member-role-updated": "更新成员权限",
|
|
3452
|
+
"work.member-removed": "移除作品成员",
|
|
3453
|
+
"work.writing_goal.updated": "更新写作目标",
|
|
3118
3454
|
"volume.created": "创建分卷",
|
|
3119
3455
|
"volume.updated": "更新分卷",
|
|
3120
3456
|
"volume.deleted": "删除分卷",
|
|
3457
|
+
"volume.restored": "恢复分卷",
|
|
3121
3458
|
"chapter.created": "创建章节",
|
|
3122
3459
|
"chapter.saved": "保存章节",
|
|
3123
3460
|
"chapter.moved": "移动章节",
|
|
3124
3461
|
"chapter.deleted": "删除章节",
|
|
3125
3462
|
"chapter.purged": "彻底删除章节",
|
|
3126
3463
|
"chapter.restored": "恢复章节",
|
|
3464
|
+
"chapter.annotation.created": "添加正文评论",
|
|
3465
|
+
"chapter.annotation.updated": "更新正文评论",
|
|
3466
|
+
"chapter.annotation.deleted": "删除正文评论",
|
|
3127
3467
|
"draft.created": "创建想法",
|
|
3128
3468
|
"draft.updated": "更新想法",
|
|
3129
3469
|
"draft.deleted": "删除想法",
|
|
3130
3470
|
"draft.restored": "恢复想法",
|
|
3471
|
+
"setting.created": "创建设定",
|
|
3472
|
+
"setting.updated": "更新设定",
|
|
3473
|
+
"setting.deleted": "删除设定",
|
|
3474
|
+
"setting.restored": "恢复设定",
|
|
3475
|
+
"character.created": "创建角色",
|
|
3476
|
+
"character.updated": "更新角色",
|
|
3477
|
+
"character.deleted": "删除角色",
|
|
3478
|
+
"character.restored": "恢复角色",
|
|
3479
|
+
"race.created": "创建种族",
|
|
3480
|
+
"race.updated": "更新种族",
|
|
3481
|
+
"race.deleted": "删除种族",
|
|
3482
|
+
"race.restored": "恢复种族",
|
|
3483
|
+
"organization.created": "创建组织",
|
|
3484
|
+
"organization.updated": "更新组织",
|
|
3485
|
+
"organization.deleted": "删除组织",
|
|
3486
|
+
"organization.restored": "恢复组织",
|
|
3487
|
+
"timeline-track.created": "创建时间轴",
|
|
3488
|
+
"timeline-track.updated": "更新时间轴",
|
|
3489
|
+
"timeline-track.deleted": "删除时间轴",
|
|
3490
|
+
"timeline-track.restored": "恢复时间轴",
|
|
3491
|
+
"timeline.created": "创建时间事件",
|
|
3492
|
+
"timeline.updated": "更新时间事件",
|
|
3493
|
+
"timeline.deleted": "删除时间事件",
|
|
3494
|
+
"timeline.restored": "恢复时间事件",
|
|
3495
|
+
"relationship.created": "创建人物关系",
|
|
3496
|
+
"relationship.updated": "更新人物关系",
|
|
3497
|
+
"relationship.deleted": "删除人物关系",
|
|
3498
|
+
"relationship.restored": "恢复人物关系",
|
|
3499
|
+
"outline.created": "创建章节大纲",
|
|
3500
|
+
"outline.updated": "更新章节大纲",
|
|
3501
|
+
"outline.deleted": "删除章节大纲",
|
|
3502
|
+
"foreshadow.created": "创建伏笔",
|
|
3503
|
+
"foreshadow.updated": "更新伏笔",
|
|
3504
|
+
"foreshadow.deleted": "删除伏笔",
|
|
3505
|
+
"foreshadow.restored": "恢复伏笔",
|
|
3506
|
+
"task.created": "创建 AI 分析任务",
|
|
3507
|
+
"task.cancelled": "取消 AI 分析任务",
|
|
3508
|
+
"attachment.created": "创建附件",
|
|
3509
|
+
"attachment.deleted": "删除附件",
|
|
3510
|
+
"attachment.garbage-collected": "清理未引用附件",
|
|
3511
|
+
"file.restored": "恢复导入快照",
|
|
3131
3512
|
"work.imported": "导入正文"
|
|
3132
3513
|
};
|
|
3133
3514
|
|
|
3134
3515
|
function workAuditEntityLabel(type) {
|
|
3135
|
-
return ({
|
|
3516
|
+
return ({
|
|
3517
|
+
work: "作品",
|
|
3518
|
+
volume: "分卷",
|
|
3519
|
+
chapter: "章节",
|
|
3520
|
+
draft: "想法",
|
|
3521
|
+
user: "用户",
|
|
3522
|
+
setting: "设定",
|
|
3523
|
+
character: "角色",
|
|
3524
|
+
race: "种族",
|
|
3525
|
+
organization: "组织",
|
|
3526
|
+
"timeline-track": "时间轴",
|
|
3527
|
+
"timeline-event": "时间事件",
|
|
3528
|
+
relationship: "人物关系",
|
|
3529
|
+
"chapter-outline": "章节大纲",
|
|
3530
|
+
foreshadow: "伏笔",
|
|
3531
|
+
"chapter-annotation": "正文评论",
|
|
3532
|
+
attachment: "附件",
|
|
3533
|
+
"file-version": "导入快照",
|
|
3534
|
+
"analysis-task": "AI 分析任务",
|
|
3535
|
+
review: "审核"
|
|
3536
|
+
})[type] ?? type;
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
const workAuditDetailLabels = {
|
|
3540
|
+
fields: "变更字段",
|
|
3541
|
+
versionNo: "版本号",
|
|
3542
|
+
fromVersion: "来源版本",
|
|
3543
|
+
source: "操作来源",
|
|
3544
|
+
sourceRef: "来源引用",
|
|
3545
|
+
changeNote: "变更说明",
|
|
3546
|
+
name: "名称",
|
|
3547
|
+
description: "说明",
|
|
3548
|
+
chapterType: "章节类型",
|
|
3549
|
+
volumeId: "所属分卷",
|
|
3550
|
+
previousVolumeId: "原分卷",
|
|
3551
|
+
sortOrder: "排序位置",
|
|
3552
|
+
timeLabel: "时间标签",
|
|
3553
|
+
location: "地点",
|
|
3554
|
+
eventType: "事件类型",
|
|
3555
|
+
batch: "批量操作",
|
|
3556
|
+
recoverable: "可恢复",
|
|
3557
|
+
excludedFromAnalysis: "排除 AI 分析",
|
|
3558
|
+
startLine: "起始行",
|
|
3559
|
+
endLine: "结束行",
|
|
3560
|
+
characterId: "角色 ID",
|
|
3561
|
+
restorePointId: "恢复点 ID",
|
|
3562
|
+
storageKey: "存储位置",
|
|
3563
|
+
byteLength: "文件字节数",
|
|
3564
|
+
mimeType: "文件类型",
|
|
3565
|
+
role: "成员角色",
|
|
3566
|
+
status: "状态",
|
|
3567
|
+
previousStatus: "原状态",
|
|
3568
|
+
dailyGoal: "每日目标",
|
|
3569
|
+
targetTotal: "总字数目标",
|
|
3570
|
+
deadline: "计划完成日期",
|
|
3571
|
+
title: "标题",
|
|
3572
|
+
reason: "原因"
|
|
3573
|
+
};
|
|
3574
|
+
|
|
3575
|
+
function workAuditDetailValue(value) {
|
|
3576
|
+
if (typeof value === "boolean") return value ? "是" : "否";
|
|
3577
|
+
if (Array.isArray(value)) return value.map((item) => typeof item === "object" ? JSON.stringify(item) : String(item)).join("、");
|
|
3578
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
3579
|
+
return String(value);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
function workAuditDetailEntries(detail) {
|
|
3583
|
+
if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
|
|
3584
|
+
return Object.entries(detail)
|
|
3585
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
|
3586
|
+
.map(([key, value]) => ({ label: workAuditDetailLabels[key] ?? key, value: workAuditDetailValue(value) }));
|
|
3136
3587
|
}
|
|
3137
3588
|
|
|
3138
|
-
function
|
|
3139
|
-
const
|
|
3140
|
-
|
|
3589
|
+
function workAuditTimestamp(createdAt) {
|
|
3590
|
+
const formatted = formatDateTime(createdAt);
|
|
3591
|
+
const parts = formatted.split(/\s+/u);
|
|
3592
|
+
return { date: parts[0] || "—", time: parts.slice(1).join(" ") || "—" };
|
|
3141
3593
|
}
|
|
3142
3594
|
|
|
3143
3595
|
function renderWorkAuditRecords() {
|
|
3144
|
-
$("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) =>
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3596
|
+
$("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => {
|
|
3597
|
+
const timestamp = workAuditTimestamp(record.createdAt);
|
|
3598
|
+
const details = workAuditDetailEntries(record.detail);
|
|
3599
|
+
return `<article class="work-audit-row">
|
|
3600
|
+
<header class="work-audit-time"><time datetime="${esc(record.createdAt)}"><span>${esc(timestamp.date)}</span><strong>${esc(timestamp.time)}</strong></time></header>
|
|
3601
|
+
<div class="work-audit-event">
|
|
3602
|
+
<div class="work-audit-event-heading"><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><code>${esc(record.action)}</code></div>
|
|
3603
|
+
<dl class="work-audit-meta">
|
|
3604
|
+
<div><dt>操作者</dt><dd>${esc(record.actor || "system")}</dd></div>
|
|
3605
|
+
<div><dt>对象类型</dt><dd>${esc(workAuditEntityLabel(record.entityType))}</dd></div>
|
|
3606
|
+
<div><dt>对象 ID</dt><dd><code>${record.entityId ? esc(record.entityId) : "—"}</code></dd></div>
|
|
3607
|
+
</dl>
|
|
3608
|
+
${details.length ? `<dl class="work-audit-details">${details.map((detail) => `<div><dt>${esc(detail.label)}</dt><dd><code>${esc(detail.value)}</code></dd></div>`).join("")}</dl>` : ""}
|
|
3609
|
+
</div>
|
|
3610
|
+
</article>`;
|
|
3611
|
+
}).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
|
|
3612
|
+
$("#work-audit-summary").textContent = workAuditRecords.length
|
|
3613
|
+
? `已显示 ${workAuditRecords.length} 条记录${workAuditNextPage === null ? ",已加载全部记录" : ",还有更多记录可继续加载"}`
|
|
3614
|
+
: "当前作品还没有操作记录。";
|
|
3148
3615
|
$("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
|
|
3149
3616
|
}
|
|
3150
3617
|
|
|
@@ -3156,18 +3623,36 @@ async function loadWorkAuditPage(page = 1, append = false) {
|
|
|
3156
3623
|
renderWorkAuditRecords();
|
|
3157
3624
|
}
|
|
3158
3625
|
|
|
3159
|
-
async function
|
|
3160
|
-
if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
|
|
3626
|
+
async function showWorkAudit() {
|
|
3627
|
+
if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return false;
|
|
3161
3628
|
workAuditRecords = [];
|
|
3162
3629
|
workAuditNextPage = null;
|
|
3630
|
+
dismissChapterInsightToast();
|
|
3631
|
+
updateDocumentTitle(state.work);
|
|
3632
|
+
$("#app").classList.add("shelf-mode");
|
|
3633
|
+
$("#shelf-view").classList.add("hidden");
|
|
3634
|
+
$("#platform-ai-view").classList.add("hidden");
|
|
3635
|
+
$("#platform-usage-view").classList.add("hidden");
|
|
3636
|
+
$("#settings-hub-view").classList.add("hidden");
|
|
3637
|
+
$("#work-audit-view").classList.remove("hidden");
|
|
3638
|
+
$("#welcome-view").classList.add("hidden");
|
|
3639
|
+
$("#editor-view").classList.add("hidden");
|
|
3640
|
+
$("#module-view").classList.add("hidden");
|
|
3641
|
+
$("#work-audit-eyebrow").textContent = `作品安全 · 《${state.work.title}》`;
|
|
3642
|
+
$("#work-meta").textContent = "操作记录";
|
|
3643
|
+
$("#settings-button").setAttribute("aria-current", "page");
|
|
3644
|
+
setTopbarViewState("操作记录");
|
|
3645
|
+
$("#work-audit-summary").textContent = "正在读取操作记录……";
|
|
3163
3646
|
$("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
|
|
3164
|
-
|
|
3647
|
+
replacePageRoute({ view: "work-audit", workId: state.work.id, ...settingsRouteContext() });
|
|
3165
3648
|
try {
|
|
3166
3649
|
await loadWorkAuditPage();
|
|
3167
3650
|
} catch (error) {
|
|
3168
|
-
$("#work-audit-
|
|
3651
|
+
$("#work-audit-summary").textContent = "操作记录加载失败。";
|
|
3652
|
+
$("#work-audit-list").innerHTML = '<p class="entity-history-empty">暂时无法读取操作记录,请稍后刷新。</p>';
|
|
3169
3653
|
toast(error.message, "error");
|
|
3170
3654
|
}
|
|
3655
|
+
return true;
|
|
3171
3656
|
}
|
|
3172
3657
|
|
|
3173
3658
|
async function saveWritingGoal(event) {
|
|
@@ -3468,7 +3953,8 @@ async function openSearchResult(result) {
|
|
|
3468
3953
|
$("#search-dialog").close();
|
|
3469
3954
|
const inSettings = !$("#settings-hub-view").classList.contains("hidden")
|
|
3470
3955
|
|| !$("#platform-ai-view").classList.contains("hidden")
|
|
3471
|
-
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3956
|
+
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3957
|
+
|| !$("#work-audit-view").classList.contains("hidden");
|
|
3472
3958
|
if (inSettings) await returnFromSettings();
|
|
3473
3959
|
if (target.kind === "chapter") {
|
|
3474
3960
|
await selectChapter(target.id);
|
|
@@ -3494,7 +3980,8 @@ async function openSearchResult(result) {
|
|
|
3494
3980
|
async function showSettingsHub() {
|
|
3495
3981
|
const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
|
|
3496
3982
|
|| !$("#platform-ai-view").classList.contains("hidden")
|
|
3497
|
-
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3983
|
+
|| !$("#platform-usage-view").classList.contains("hidden")
|
|
3984
|
+
|| !$("#work-audit-view").classList.contains("hidden");
|
|
3498
3985
|
if (!alreadyInSettings) {
|
|
3499
3986
|
if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
|
|
3500
3987
|
settingsReturnContext = captureSettingsReturnContext();
|
|
@@ -3506,13 +3993,14 @@ async function showSettingsHub() {
|
|
|
3506
3993
|
$("#shelf-view").classList.add("hidden");
|
|
3507
3994
|
$("#platform-ai-view").classList.add("hidden");
|
|
3508
3995
|
$("#platform-usage-view").classList.add("hidden");
|
|
3996
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3509
3997
|
$("#settings-hub-view").classList.remove("hidden");
|
|
3510
3998
|
$("#welcome-view").classList.add("hidden");
|
|
3511
3999
|
$("#editor-view").classList.add("hidden");
|
|
3512
4000
|
$("#module-view").classList.add("hidden");
|
|
3513
4001
|
$("#work-meta").textContent = "设置中心";
|
|
3514
4002
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3515
|
-
|
|
4003
|
+
setTopbarViewState("设置");
|
|
3516
4004
|
renderSettingsHub();
|
|
3517
4005
|
replacePageRoute({ view: "settings", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3518
4006
|
return true;
|
|
@@ -3532,6 +4020,7 @@ async function returnFromSettings() {
|
|
|
3532
4020
|
$("#settings-hub-view").classList.add("hidden");
|
|
3533
4021
|
$("#platform-ai-view").classList.add("hidden");
|
|
3534
4022
|
$("#platform-usage-view").classList.add("hidden");
|
|
4023
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3535
4024
|
if (context.view === "shelf" || !state.work) return showShelf();
|
|
3536
4025
|
$("#app").classList.remove("shelf-mode");
|
|
3537
4026
|
$("#shelf-view").classList.add("hidden");
|
|
@@ -3551,13 +4040,14 @@ async function showPlatformAi() {
|
|
|
3551
4040
|
$("#shelf-view").classList.add("hidden");
|
|
3552
4041
|
$("#platform-ai-view").classList.remove("hidden");
|
|
3553
4042
|
$("#platform-usage-view").classList.add("hidden");
|
|
4043
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3554
4044
|
$("#settings-hub-view").classList.add("hidden");
|
|
3555
4045
|
$("#welcome-view").classList.add("hidden");
|
|
3556
4046
|
$("#editor-view").classList.add("hidden");
|
|
3557
4047
|
$("#module-view").classList.add("hidden");
|
|
3558
4048
|
$("#work-meta").textContent = "平台 AI 管理";
|
|
3559
4049
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3560
|
-
|
|
4050
|
+
setTopbarViewState("平台 AI");
|
|
3561
4051
|
await renderPlatformAiConfig();
|
|
3562
4052
|
replacePageRoute({ view: "platform-ai", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3563
4053
|
return true;
|
|
@@ -3571,13 +4061,14 @@ async function showPlatformUsage() {
|
|
|
3571
4061
|
$("#shelf-view").classList.add("hidden");
|
|
3572
4062
|
$("#platform-ai-view").classList.add("hidden");
|
|
3573
4063
|
$("#platform-usage-view").classList.remove("hidden");
|
|
4064
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3574
4065
|
$("#settings-hub-view").classList.add("hidden");
|
|
3575
4066
|
$("#welcome-view").classList.add("hidden");
|
|
3576
4067
|
$("#editor-view").classList.add("hidden");
|
|
3577
4068
|
$("#module-view").classList.add("hidden");
|
|
3578
4069
|
$("#work-meta").textContent = "Token 用量";
|
|
3579
4070
|
$("#settings-button").setAttribute("aria-current", "page");
|
|
3580
|
-
|
|
4071
|
+
setTopbarViewState("Token 用量");
|
|
3581
4072
|
await renderPlatformTokenUsage();
|
|
3582
4073
|
replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
|
|
3583
4074
|
return true;
|
|
@@ -3645,13 +4136,17 @@ function resetWorkScopedUiCaches() {
|
|
|
3645
4136
|
state.aiPromptSent = false;
|
|
3646
4137
|
state.aiConversationId = null;
|
|
3647
4138
|
state.aiConversations = [];
|
|
4139
|
+
state.aiRoleplayCharacter = null;
|
|
3648
4140
|
renderAiCitations();
|
|
3649
4141
|
renderAiReferences();
|
|
3650
4142
|
renderAiQuickActions();
|
|
3651
4143
|
resetAiFeed();
|
|
4144
|
+
applyAiConversationTaskType("chat");
|
|
4145
|
+
applyAiConversationContextScope({ type: "none" });
|
|
4146
|
+
applyAiRoleplayCharacter(null);
|
|
3652
4147
|
$("#ai-conversation-title").textContent = "新对话";
|
|
3653
4148
|
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
3654
|
-
|
|
4149
|
+
resetAiContextMeter();
|
|
3655
4150
|
renderAiConversationHistory();
|
|
3656
4151
|
}
|
|
3657
4152
|
|
|
@@ -3666,11 +4161,12 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
3666
4161
|
}
|
|
3667
4162
|
const nextWork = await api(`/api/works/${workId}?directory=volumes`);
|
|
3668
4163
|
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
3669
|
-
|
|
4164
|
+
showSystemStatus();
|
|
3670
4165
|
$("#app").classList.remove("shelf-mode");
|
|
3671
4166
|
$("#shelf-view").classList.add("hidden");
|
|
3672
4167
|
$("#platform-ai-view").classList.add("hidden");
|
|
3673
4168
|
$("#platform-usage-view").classList.add("hidden");
|
|
4169
|
+
$("#work-audit-view").classList.add("hidden");
|
|
3674
4170
|
$("#settings-hub-view").classList.add("hidden");
|
|
3675
4171
|
$("#settings-button").removeAttribute("aria-current");
|
|
3676
4172
|
settingsReturnContext = null;
|
|
@@ -4151,6 +4647,7 @@ function showWelcome(hasWork = false) {
|
|
|
4151
4647
|
$("#editor-view").classList.add("hidden");
|
|
4152
4648
|
$("#module-view").classList.add("hidden");
|
|
4153
4649
|
$("#welcome-view").classList.remove("hidden");
|
|
4650
|
+
if (hasWork) showSystemStatus();
|
|
4154
4651
|
$("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
|
|
4155
4652
|
$("#welcome-new-work").textContent = hasWork ? "新建章节" : "创建第一部作品";
|
|
4156
4653
|
replacePageRoute(hasWork && state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
|
|
@@ -4204,6 +4701,7 @@ async function showModule(module) {
|
|
|
4204
4701
|
}
|
|
4205
4702
|
return;
|
|
4206
4703
|
}
|
|
4704
|
+
showSystemStatus();
|
|
4207
4705
|
dismissChapterInsightToast();
|
|
4208
4706
|
$("#welcome-view").classList.add("hidden");
|
|
4209
4707
|
$("#editor-view").classList.add("hidden");
|
|
@@ -4698,8 +5196,7 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4698
5196
|
<div><strong>想法操作</strong><small>删除后将从想法列表移除,版本历史仍会保留。</small></div>
|
|
4699
5197
|
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-draft-delete>删除想法</button></div>
|
|
4700
5198
|
</section>` : "";
|
|
4701
|
-
const fields =
|
|
4702
|
-
+ field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
|
|
5199
|
+
const fields = field("draftType", "想法类型", "select", item?.draftType ?? "prose", [["prose", "正文想法"], ["setting", "设定想法"]])
|
|
4703
5200
|
+ `<div class="draft-binding-field" data-draft-binding-field="prose">${field("volumeId", "绑定分卷", "select", item?.volumeId ?? "", volumeOptions)}</div>`
|
|
4704
5201
|
+ `<div class="draft-binding-field" data-draft-binding-field="setting">${field("settingModule", "绑定设定模块", "select", item?.settingModule ?? "", settingModuleOptions)}</div>`
|
|
4705
5202
|
+ field("title", "标题", "text", item?.title ?? "")
|
|
@@ -4731,7 +5228,8 @@ function openDraftDialog(item = null, { readOnly = false } = {}) {
|
|
|
4731
5228
|
submitLabel: viewOnly ? "关闭" : "保存想法",
|
|
4732
5229
|
hideCancel: viewOnly,
|
|
4733
5230
|
editor: true,
|
|
4734
|
-
errorPrefix: "想法保存失败:"
|
|
5231
|
+
errorPrefix: "想法保存失败:",
|
|
5232
|
+
meta: "这里记录未确认的临时想法,可能采用,也可能永远不会写入正文或正式设定。"
|
|
4735
5233
|
});
|
|
4736
5234
|
const draftTypeSelect = $("#dialog-fields").querySelector('select[name="draftType"]');
|
|
4737
5235
|
const syncDraftBindingFields = () => {
|
|
@@ -6133,6 +6631,7 @@ function openTaskDetailDialog(task, trace) {
|
|
|
6133
6631
|
</li>`;
|
|
6134
6632
|
}
|
|
6135
6633
|
if (item.type === "book") return "<li>全书</li>";
|
|
6634
|
+
if (item.type === "settings-catalog") return "<li>设定库</li>";
|
|
6136
6635
|
if (item.type === "selection") return item.restricted
|
|
6137
6636
|
? "<li>选定内容(正文读取权限受限)</li>"
|
|
6138
6637
|
: `<li>选定内容:${esc(item.selection || "未提供")}</li>`;
|
|
@@ -6214,7 +6713,7 @@ function renderProviderCards(providers, models) {
|
|
|
6214
6713
|
}).join("")}</div>
|
|
6215
6714
|
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button>${provider.status === "enabled" ? `<button data-test-provider="${esc(provider.id)}" ${providerModels.length ? "" : "disabled aria-disabled=\"true\" title=\"请先添加模型\""}>测试连接</button>` : ""}<button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`;
|
|
6216
6715
|
}).join("")}</div>`
|
|
6217
|
-
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI 或
|
|
6716
|
+
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI、Anthropic 或 Google Vertex 接口地址和凭据,测试成功后再添加模型。");
|
|
6218
6717
|
}
|
|
6219
6718
|
|
|
6220
6719
|
function bindPlatformProviderActions(host, providers, models) {
|
|
@@ -6560,21 +7059,72 @@ function tokenUsageCalendarMarkup(daily) {
|
|
|
6560
7059
|
const calendar = buildUsageCalendar(daily);
|
|
6561
7060
|
const cells = calendar.cells.map((cell) => {
|
|
6562
7061
|
const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
|
|
6563
|
-
return
|
|
7062
|
+
return cell.future
|
|
7063
|
+
? `<span class="usage-calendar-cell is-future" data-level="${cell.level}" role="gridcell" aria-disabled="true"></span>`
|
|
7064
|
+
: `<button class="usage-calendar-cell" type="button" data-level="${cell.level}" data-usage-calendar-label="${esc(label)}" role="gridcell" aria-label="${esc(label)}"></button>`;
|
|
6564
7065
|
}).join("");
|
|
6565
7066
|
const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
|
|
6566
|
-
return `<div class="usage-calendar-
|
|
6567
|
-
<div class="usage-calendar-
|
|
6568
|
-
<div class="usage-calendar-
|
|
6569
|
-
|
|
6570
|
-
<div class="usage-calendar-
|
|
6571
|
-
|
|
7067
|
+
return `<div class="usage-calendar-widget">
|
|
7068
|
+
<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
|
|
7069
|
+
<div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
|
|
7070
|
+
<div class="usage-calendar-months" aria-hidden="true">${months}</div>
|
|
7071
|
+
<div class="usage-calendar-body">
|
|
7072
|
+
<div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
|
|
7073
|
+
<div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
|
|
7074
|
+
</div>
|
|
6572
7075
|
</div>
|
|
6573
7076
|
</div>
|
|
7077
|
+
<output class="usage-calendar-tooltip" role="tooltip" hidden></output>
|
|
6574
7078
|
</div>
|
|
6575
7079
|
<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>`;
|
|
6576
7080
|
}
|
|
6577
7081
|
|
|
7082
|
+
function bindUsageCalendarInteractions(root) {
|
|
7083
|
+
root.querySelectorAll(".usage-calendar-widget").forEach((widget) => {
|
|
7084
|
+
const tooltip = widget.querySelector(".usage-calendar-tooltip");
|
|
7085
|
+
const calendarScroll = widget.querySelector(".usage-calendar-scroll");
|
|
7086
|
+
let activeCell = null;
|
|
7087
|
+
const hideTooltip = () => {
|
|
7088
|
+
tooltip.hidden = true;
|
|
7089
|
+
activeCell = null;
|
|
7090
|
+
};
|
|
7091
|
+
const showTooltip = (cell) => {
|
|
7092
|
+
activeCell = cell;
|
|
7093
|
+
tooltip.textContent = cell.dataset.usageCalendarLabel;
|
|
7094
|
+
tooltip.hidden = false;
|
|
7095
|
+
const widgetRect = widget.getBoundingClientRect();
|
|
7096
|
+
const cellRect = cell.getBoundingClientRect();
|
|
7097
|
+
const edgeInset = tooltip.offsetWidth / 2 + 8;
|
|
7098
|
+
const centeredLeft = cellRect.left + cellRect.width / 2 - widgetRect.left;
|
|
7099
|
+
const fitsAbove = cellRect.top - widgetRect.top >= tooltip.offsetHeight + 8;
|
|
7100
|
+
tooltip.dataset.placement = fitsAbove ? "top" : "bottom";
|
|
7101
|
+
tooltip.style.left = `${Math.min(widget.clientWidth - edgeInset, Math.max(edgeInset, centeredLeft))}px`;
|
|
7102
|
+
tooltip.style.top = fitsAbove
|
|
7103
|
+
? `${cellRect.top - widgetRect.top - 8}px`
|
|
7104
|
+
: `${cellRect.bottom - widgetRect.top + 8}px`;
|
|
7105
|
+
};
|
|
7106
|
+
widget.querySelectorAll("button.usage-calendar-cell").forEach((cell) => {
|
|
7107
|
+
cell.addEventListener("mouseenter", () => showTooltip(cell));
|
|
7108
|
+
cell.addEventListener("mouseleave", () => {
|
|
7109
|
+
if (document.activeElement !== cell) hideTooltip();
|
|
7110
|
+
});
|
|
7111
|
+
cell.addEventListener("focus", () => showTooltip(cell));
|
|
7112
|
+
cell.addEventListener("blur", () => {
|
|
7113
|
+
if (!cell.matches(":hover")) hideTooltip();
|
|
7114
|
+
});
|
|
7115
|
+
cell.addEventListener("click", () => showTooltip(cell));
|
|
7116
|
+
cell.addEventListener("keydown", (event) => {
|
|
7117
|
+
if (event.key !== "Escape") return;
|
|
7118
|
+
hideTooltip();
|
|
7119
|
+
cell.blur();
|
|
7120
|
+
});
|
|
7121
|
+
});
|
|
7122
|
+
calendarScroll.addEventListener("scroll", () => {
|
|
7123
|
+
if (activeCell && !tooltip.hidden) showTooltip(activeCell);
|
|
7124
|
+
});
|
|
7125
|
+
});
|
|
7126
|
+
}
|
|
7127
|
+
|
|
6578
7128
|
function scrollUsageCalendarsToLatest(root) {
|
|
6579
7129
|
window.requestAnimationFrame(() => {
|
|
6580
7130
|
root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
|
|
@@ -6633,6 +7183,7 @@ async function renderPlatformTokenUsage() {
|
|
|
6633
7183
|
description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
|
|
6634
7184
|
showWorks: true
|
|
6635
7185
|
});
|
|
7186
|
+
bindUsageCalendarInteractions(host);
|
|
6636
7187
|
scrollUsageCalendarsToLatest(host);
|
|
6637
7188
|
}
|
|
6638
7189
|
|
|
@@ -6652,10 +7203,20 @@ async function renderBookAiSettings() {
|
|
|
6652
7203
|
const host = $("#module-content");
|
|
6653
7204
|
const workId = String(state.work.id);
|
|
6654
7205
|
const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"]);
|
|
7206
|
+
const dailyTokenQuota = settings.dailyTokenQuota === null ? null : Number(settings.dailyTokenQuota);
|
|
7207
|
+
const quotaUsedTokens = Number(usage?.quota?.usedTokens) || 0;
|
|
7208
|
+
const quotaRemainingTokens = usage?.quota?.remainingTokens === null
|
|
7209
|
+
? null
|
|
7210
|
+
: Math.max(0, Number(usage?.quota?.remainingTokens) || 0);
|
|
7211
|
+
const quotaTimezone = String(usage?.quota?.timezone || "后端部署时区");
|
|
7212
|
+
const quotaStatusText = dailyTokenQuota === null
|
|
7213
|
+
? `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} Token,当前未启用额度限制。`
|
|
7214
|
+
: `今日已使用 ${quotaUsedTokens.toLocaleString("zh-CN")} / ${dailyTokenQuota.toLocaleString("zh-CN")} Token,剩余 ${Number(quotaRemainingTokens).toLocaleString("zh-CN")} Token。`;
|
|
6655
7215
|
host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
|
|
6656
7216
|
title: "本书 Token 用量",
|
|
6657
7217
|
description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
|
|
6658
|
-
})}</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
|
|
7218
|
+
})}</section><section class="config-section"><div class="config-section-header"><div><h2>每日 Token 额度</h2><p>限制本书在后端部署时区(${esc(quotaTimezone)})每个自然日可使用的输入与输出 Token 总量。额度最低为 10,000;达到额度后,新的 AI 请求会等到后端时区的次日零点重置后再执行。</p></div></div><div class="config-inline-save"><label><input id="daily-token-quota-enabled" type="checkbox" ${dailyTokenQuota === null ? "" : "checked"}>启用每日额度</label><label class="daily-token-quota-field">每日额度<input id="daily-token-quota" type="number" min="10000" max="2000000000" step="1000" value="${esc(String(dailyTokenQuota ?? 10000))}" aria-label="本书每日 Token 额度" ${dailyTokenQuota === null ? "disabled" : ""}></label><button id="save-daily-token-quota" class="ghost-button config-save-button" type="button">保存</button></div><p id="daily-token-quota-status" class="usage-measurement-note" role="status">${esc(quotaStatusText)}</p></section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>Agent 工具调用上限</h2><p>限制单次回答里 Agent 可调用工具的次数,并用「全局倍数」给整次回答加一道不会因 Compact 重置的熔断阀,防止工具死循环空耗 Token。调用上限 5–48(默认 12);全局倍数 1–6(默认 3,全局上限 = 调用上限 × 倍数)。<a class="config-doc-link" href="https://scriverse.top/docs/global-tool-call-limit.html" target="_blank" rel="noopener noreferrer">了解原理与推荐设置</a></p></div></div><div class="config-inline-save"><label class="agent-tool-call-limit-field">调用上限<input id="agent-tool-call-limit" type="number" min="5" max="48" value="${esc(String(settings.agentToolCallLimit ?? 12))}" aria-label="Agent 工具调用上限"></label><div class="agent-tool-call-global-multiplier-field"><span id="agent-tool-call-global-multiplier-label">全局倍数</span><div class="settings-layout-toggle agent-tool-call-global-multiplier-toggle" role="group" aria-labelledby="agent-tool-call-global-multiplier-label">${[1, 2, 3, 4, 5, 6].map((value) => `<button type="button" data-global-multiplier="${value}" aria-pressed="${Number(settings.agentToolCallGlobalMultiplier ?? 3) === value}">${value}</button>`).join("")}</div><input id="agent-tool-call-global-multiplier" type="hidden" value="${esc(String(Math.min(6, Math.max(1, Number(settings.agentToolCallGlobalMultiplier ?? 3) || 3))))}" aria-label="Agent 工具调用全局倍数"></div><button id="save-agent-tool-call-limit" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。已开始的对话会锁定创建时的工具集,修改后仅对新对话生效,避免打断 prompt cache。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名、拼音或短关键词混合检索设定、人物、组织、时间线、关系、大纲和伏笔;非语义问答。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults, settings)}`;
|
|
7219
|
+
bindUsageCalendarInteractions(host);
|
|
6659
7220
|
scrollUsageCalendarsToLatest(host);
|
|
6660
7221
|
host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
|
|
6661
7222
|
"beforebegin",
|
|
@@ -6671,6 +7232,7 @@ async function renderBookAiSettings() {
|
|
|
6671
7232
|
);
|
|
6672
7233
|
if (!canEditModule("ai-settings")) {
|
|
6673
7234
|
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
7235
|
+
host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button").forEach((button) => { button.disabled = true; });
|
|
6674
7236
|
host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
6675
7237
|
}
|
|
6676
7238
|
const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
|
|
@@ -6700,6 +7262,31 @@ async function renderBookAiSettings() {
|
|
|
6700
7262
|
return status;
|
|
6701
7263
|
};
|
|
6702
7264
|
updateRelationshipIndexStatus(relationshipIndex);
|
|
7265
|
+
$("#daily-token-quota-enabled").addEventListener("change", (event) => {
|
|
7266
|
+
$("#daily-token-quota").disabled = !event.currentTarget.checked;
|
|
7267
|
+
});
|
|
7268
|
+
$("#save-daily-token-quota").addEventListener("click", async () => {
|
|
7269
|
+
const button = $("#save-daily-token-quota");
|
|
7270
|
+
const enabled = $("#daily-token-quota-enabled").checked;
|
|
7271
|
+
const quota = Number($("#daily-token-quota").value);
|
|
7272
|
+
if (enabled && (!Number.isInteger(quota) || quota < 10_000 || quota > 2_000_000_000)) {
|
|
7273
|
+
toast("每日 Token 额度必须是 10,000 到 2,000,000,000 之间的整数", "error");
|
|
7274
|
+
$("#daily-token-quota").focus();
|
|
7275
|
+
return;
|
|
7276
|
+
}
|
|
7277
|
+
button.disabled = true;
|
|
7278
|
+
try {
|
|
7279
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
7280
|
+
method: "PATCH",
|
|
7281
|
+
body: { dailyTokenQuota: enabled ? quota : null }
|
|
7282
|
+
});
|
|
7283
|
+
toast(enabled ? "本书每日 Token 额度已保存" : "本书每日 Token 额度限制已关闭");
|
|
7284
|
+
await renderBookAiSettings();
|
|
7285
|
+
} catch (error) {
|
|
7286
|
+
toast(error.message, "error");
|
|
7287
|
+
button.disabled = false;
|
|
7288
|
+
}
|
|
7289
|
+
});
|
|
6703
7290
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
6704
7291
|
const button = $("#save-work-system-prompt");
|
|
6705
7292
|
button.disabled = true;
|
|
@@ -6779,6 +7366,34 @@ async function renderBookAiSettings() {
|
|
|
6779
7366
|
button.disabled = false;
|
|
6780
7367
|
}
|
|
6781
7368
|
});
|
|
7369
|
+
$("#save-agent-tool-call-limit").addEventListener("click", async () => {
|
|
7370
|
+
const button = $("#save-agent-tool-call-limit");
|
|
7371
|
+
button.disabled = true;
|
|
7372
|
+
try {
|
|
7373
|
+
await api(`/api/works/${state.work.id}/ai-settings`, {
|
|
7374
|
+
method: "PATCH",
|
|
7375
|
+
body: {
|
|
7376
|
+
agentToolCallLimit: Number($("#agent-tool-call-limit").value),
|
|
7377
|
+
agentToolCallGlobalMultiplier: Number($("#agent-tool-call-global-multiplier").value)
|
|
7378
|
+
}
|
|
7379
|
+
});
|
|
7380
|
+
toast("Agent 工具调用上限已保存");
|
|
7381
|
+
} catch (error) {
|
|
7382
|
+
toast(error.message, "error");
|
|
7383
|
+
} finally {
|
|
7384
|
+
button.disabled = false;
|
|
7385
|
+
}
|
|
7386
|
+
});
|
|
7387
|
+
host.querySelector(".agent-tool-call-global-multiplier-toggle")?.addEventListener("click", (event) => {
|
|
7388
|
+
const option = event.target.closest("button[data-global-multiplier]");
|
|
7389
|
+
if (!option || option.disabled) return;
|
|
7390
|
+
const value = option.getAttribute("data-global-multiplier");
|
|
7391
|
+
const hidden = $("#agent-tool-call-global-multiplier");
|
|
7392
|
+
if (hidden) hidden.value = value;
|
|
7393
|
+
host.querySelectorAll(".agent-tool-call-global-multiplier-toggle button[data-global-multiplier]").forEach((item) => {
|
|
7394
|
+
item.setAttribute("aria-pressed", String(item === option));
|
|
7395
|
+
});
|
|
7396
|
+
});
|
|
6782
7397
|
$("#save-agent-tools").addEventListener("click", async () => {
|
|
6783
7398
|
const button = $("#save-agent-tools");
|
|
6784
7399
|
button.disabled = true;
|
|
@@ -6857,23 +7472,51 @@ async function ensureAiModelsLoaded() {
|
|
|
6857
7472
|
}
|
|
6858
7473
|
}
|
|
6859
7474
|
|
|
7475
|
+
function syncAiIncludeSettingInfoControl() {
|
|
7476
|
+
const scopeType = $("#ai-scope").value;
|
|
7477
|
+
const checkbox = $("#ai-include-setting-info");
|
|
7478
|
+
const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
|
|
7479
|
+
const enabled = proseScopes.has(scopeType);
|
|
7480
|
+
const roleplaySelected = $("#ai-task").value === "roleplay";
|
|
7481
|
+
checkbox.disabled = roleplaySelected || state.aiPromptSent || !enabled;
|
|
7482
|
+
checkbox.title = state.aiPromptSent
|
|
7483
|
+
? "对话开始后不能切换上下文选项"
|
|
7484
|
+
: enabled
|
|
7485
|
+
? "在正文上下文中注入锁定设定、组织/种族简表等"
|
|
7486
|
+
: scopeType === "settings-catalog"
|
|
7487
|
+
? "设定库范围会直接注入设定目录,无需此选项"
|
|
7488
|
+
: "仅在选择正文类上下文范围时可用";
|
|
7489
|
+
}
|
|
7490
|
+
|
|
6860
7491
|
function currentAiRequestScope() {
|
|
6861
7492
|
if (!state.work) return null;
|
|
6862
|
-
const
|
|
6863
|
-
const
|
|
6864
|
-
const
|
|
7493
|
+
const selectedTaskType = $("#ai-task").value;
|
|
7494
|
+
const roleplaySelected = selectedTaskType === "roleplay";
|
|
7495
|
+
const taskType = roleplaySelected ? "chat" : selectedTaskType;
|
|
7496
|
+
if (state.aiPromptSent) {
|
|
7497
|
+
const conversationScope = JSON.parse(JSON.stringify(state.aiContextScope ?? { type: "none" }));
|
|
7498
|
+
const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
|
|
7499
|
+
return { taskType, scope, conversationScope, selection: typeof conversationScope.selection === "string" ? conversationScope.selection : "" };
|
|
7500
|
+
}
|
|
7501
|
+
const scopeType = roleplaySelected ? "none" : $("#ai-scope").value;
|
|
7502
|
+
const requiresChapter = taskType === "polish" || taskType === "continue" || (scopeType !== "none" && scopeType !== "settings-catalog");
|
|
6865
7503
|
if (requiresChapter && !state.chapter) return null;
|
|
6866
7504
|
const selection = state.chapter ? $("#chapter-content").value.slice($("#chapter-content").selectionStart, $("#chapter-content").selectionEnd) : "";
|
|
6867
7505
|
const volume = state.chapter ? state.work.volumes.find((item) => item.id === state.chapter.volumeId) : null;
|
|
6868
7506
|
const includeBookSummary = scopeType === "chapter-summary";
|
|
6869
|
-
const
|
|
7507
|
+
const conversationScope = taskType === "polish" ? { type: "chapter", chapterId: state.chapter?.id, selection }
|
|
6870
7508
|
: scopeType === "none" ? { type: "none", ...(taskType === "continue" && state.chapter ? { chapterId: state.chapter.id } : {}) }
|
|
6871
7509
|
: scopeType === "book" ? { type: "book" }
|
|
6872
7510
|
: scopeType === "volume" ? { type: "volume", volumeId: volume?.id }
|
|
7511
|
+
: scopeType === "settings-catalog" ? { type: "settings-catalog" }
|
|
6873
7512
|
: { type: "chapter", chapterId: state.chapter?.id };
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
7513
|
+
if (includeBookSummary) conversationScope.includeBookSummary = true;
|
|
7514
|
+
const proseScopes = new Set(["chapter", "chapter-summary", "volume", "book"]);
|
|
7515
|
+
if (proseScopes.has(scopeType) || taskType === "polish") {
|
|
7516
|
+
conversationScope.includeSettingInfo = $("#ai-include-setting-info").checked;
|
|
7517
|
+
}
|
|
7518
|
+
const scope = mergeAiReferenceScope(conversationScope, state.aiReferences);
|
|
7519
|
+
return { taskType, scope, conversationScope, selection };
|
|
6877
7520
|
}
|
|
6878
7521
|
|
|
6879
7522
|
function renderAiContextDistribution(usage) {
|
|
@@ -6917,10 +7560,12 @@ function renderAiContextDistribution(usage) {
|
|
|
6917
7560
|
}
|
|
6918
7561
|
|
|
6919
7562
|
function setAiContextMeter(usage) {
|
|
7563
|
+
const displayUsage = resolveAiContextUsage(latestAiContextUsage, usage);
|
|
7564
|
+
latestAiContextUsage = displayUsage;
|
|
6920
7565
|
const meter = $("#ai-context-meter");
|
|
6921
7566
|
const value = meter.querySelector("b");
|
|
6922
|
-
renderAiContextDistribution(
|
|
6923
|
-
if (!
|
|
7567
|
+
renderAiContextDistribution(displayUsage);
|
|
7568
|
+
if (!displayUsage) {
|
|
6924
7569
|
meter.classList.add("is-empty");
|
|
6925
7570
|
meter.classList.remove("is-warning", "is-danger");
|
|
6926
7571
|
meter.style.setProperty("--context-usage", "0");
|
|
@@ -6930,17 +7575,22 @@ function setAiContextMeter(usage) {
|
|
|
6930
7575
|
meter.setAttribute("aria-label", tooltip);
|
|
6931
7576
|
return;
|
|
6932
7577
|
}
|
|
6933
|
-
const percent = Math.max(0, Math.min(100, Number(
|
|
7578
|
+
const percent = Math.max(0, Math.min(100, Number(displayUsage.usagePercent) || 0));
|
|
6934
7579
|
meter.classList.remove("is-empty");
|
|
6935
7580
|
meter.classList.toggle("is-warning", percent >= 70 && percent < 90);
|
|
6936
7581
|
meter.classList.toggle("is-danger", percent >= 90);
|
|
6937
7582
|
meter.style.setProperty("--context-usage", String(percent));
|
|
6938
7583
|
value.textContent = `${percent}%`;
|
|
6939
|
-
const tooltip = formatAiContextUsageTooltip(
|
|
7584
|
+
const tooltip = formatAiContextUsageTooltip(displayUsage);
|
|
6940
7585
|
meter.dataset.tooltip = tooltip;
|
|
6941
7586
|
meter.setAttribute("aria-label", `当前上下文用量:${tooltip}`);
|
|
6942
7587
|
}
|
|
6943
7588
|
|
|
7589
|
+
function resetAiContextMeter() {
|
|
7590
|
+
latestAiContextUsage = null;
|
|
7591
|
+
setAiContextMeter(null);
|
|
7592
|
+
}
|
|
7593
|
+
|
|
6944
7594
|
function setAiContextDistributionVisible(visible) {
|
|
6945
7595
|
const meter = $("#ai-context-meter");
|
|
6946
7596
|
const popover = $("#ai-context-popover");
|
|
@@ -6972,6 +7622,7 @@ async function loadAiReferences() {
|
|
|
6972
7622
|
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
6973
7623
|
state.characters = characters;
|
|
6974
7624
|
state.settings = settings;
|
|
7625
|
+
renderAiRoleplayCharacterSelect();
|
|
6975
7626
|
loadedAiReferencesWorkId = workId;
|
|
6976
7627
|
}
|
|
6977
7628
|
|
|
@@ -7300,9 +7951,39 @@ function bindWorkCoverControls(work) {
|
|
|
7300
7951
|
});
|
|
7301
7952
|
}
|
|
7302
7953
|
|
|
7303
|
-
function downloadWorkManuscript(work) {
|
|
7954
|
+
function downloadWorkManuscript(work, format = "markdown") {
|
|
7304
7955
|
if (!work?.id) return;
|
|
7305
|
-
|
|
7956
|
+
const exportFormat = format === "docx" ? "docx" : "markdown";
|
|
7957
|
+
window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=${exportFormat}`;
|
|
7958
|
+
}
|
|
7959
|
+
|
|
7960
|
+
let manuscriptExportWork = null;
|
|
7961
|
+
|
|
7962
|
+
function closeManuscriptExportMenu() {
|
|
7963
|
+
const menu = $("#manuscript-export-menu");
|
|
7964
|
+
if (!menu) return;
|
|
7965
|
+
menu.classList.add("hidden");
|
|
7966
|
+
manuscriptExportWork = null;
|
|
7967
|
+
$("#export-button")?.setAttribute("aria-expanded", "false");
|
|
7968
|
+
$("#work-export-button")?.setAttribute("aria-expanded", "false");
|
|
7969
|
+
}
|
|
7970
|
+
|
|
7971
|
+
function showManuscriptExportMenu(anchor, work) {
|
|
7972
|
+
if (!work?.id || !anchor) return;
|
|
7973
|
+
const menu = $("#manuscript-export-menu");
|
|
7974
|
+
if (!menu) return;
|
|
7975
|
+
manuscriptExportWork = work;
|
|
7976
|
+
menu.classList.remove("hidden");
|
|
7977
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
7978
|
+
const menuRect = menu.getBoundingClientRect();
|
|
7979
|
+
const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - menuRect.width - 8));
|
|
7980
|
+
const top = Math.max(8, Math.min(anchorRect.bottom + 6, window.innerHeight - menuRect.height - 8));
|
|
7981
|
+
menu.style.left = `${left}px`;
|
|
7982
|
+
menu.style.top = `${top}px`;
|
|
7983
|
+
if (anchor.id === "export-button" || anchor.id === "work-export-button") {
|
|
7984
|
+
anchor.setAttribute("aria-expanded", "true");
|
|
7985
|
+
}
|
|
7986
|
+
menu.querySelector("button[data-export-format]")?.focus();
|
|
7306
7987
|
}
|
|
7307
7988
|
|
|
7308
7989
|
function openWorkSettingsDialog(work) {
|
|
@@ -7322,8 +8003,8 @@ function openWorkSettingsDialog(work) {
|
|
|
7322
8003
|
<button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
|
|
7323
8004
|
</section>`;
|
|
7324
8005
|
const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
|
|
7325
|
-
<div><strong id="work-export-settings-title">导出正文</strong><small
|
|
7326
|
-
<button id="work-export-button" class="ghost-button" type="button"
|
|
8006
|
+
<div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择导出 Markdown ZIP 或 DOCX(书名、分卷、章节为一级至三级标题;若已设置封面则嵌入为首页)。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
|
|
8007
|
+
<button id="work-export-button" class="ghost-button" type="button" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false">导出正文</button>
|
|
7327
8008
|
</section>`;
|
|
7328
8009
|
const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
|
|
7329
8010
|
<div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
|
|
@@ -7357,7 +8038,11 @@ function openWorkSettingsDialog(work) {
|
|
|
7357
8038
|
$("#form-dialog").close();
|
|
7358
8039
|
void openImportHistory();
|
|
7359
8040
|
});
|
|
7360
|
-
$("#work-export-button")?.addEventListener("click", () =>
|
|
8041
|
+
$("#work-export-button")?.addEventListener("click", (event) => {
|
|
8042
|
+
event.preventDefault();
|
|
8043
|
+
event.stopPropagation();
|
|
8044
|
+
showManuscriptExportMenu(event.currentTarget, work);
|
|
8045
|
+
});
|
|
7361
8046
|
$("#chapter-recycle-bin-button")?.addEventListener("click", () => {
|
|
7362
8047
|
$("#form-dialog").close();
|
|
7363
8048
|
void openChapterRecycleBin();
|
|
@@ -9216,23 +9901,66 @@ async function openTaskDialog() {
|
|
|
9216
9901
|
|
|
9217
9902
|
function openProviderDialog(item) {
|
|
9218
9903
|
const protocol = item?.protocol ?? "openai-chat-completions";
|
|
9219
|
-
const
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9904
|
+
const providerProtocolOptions = [
|
|
9905
|
+
["openai-chat-completions", "OpenAI Chat Completions"],
|
|
9906
|
+
["anthropic-messages", "Anthropic Messages"],
|
|
9907
|
+
["google-vertex", "Google Vertex"]
|
|
9908
|
+
];
|
|
9909
|
+
const defaultBaseUrlForProtocol = (value) => {
|
|
9910
|
+
if (value === "anthropic-messages") return "https://api.anthropic.com";
|
|
9911
|
+
if (value === "google-vertex") {
|
|
9912
|
+
return "https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/endpoints/openapi";
|
|
9913
|
+
}
|
|
9914
|
+
return "https://api.openai.com/v1";
|
|
9915
|
+
};
|
|
9916
|
+
const credentialFieldForProtocol = (value) => {
|
|
9917
|
+
if (value === "google-vertex") {
|
|
9918
|
+
return field(
|
|
9919
|
+
"apiKey",
|
|
9920
|
+
item ? "替换服务账号 JSON(留空则不变)" : "服务账号 JSON",
|
|
9921
|
+
"textarea",
|
|
9922
|
+
""
|
|
9923
|
+
);
|
|
9924
|
+
}
|
|
9925
|
+
return field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password");
|
|
9926
|
+
};
|
|
9927
|
+
const defaultBaseUrl = item?.baseUrl ?? defaultBaseUrlForProtocol(protocol);
|
|
9928
|
+
openDialog(
|
|
9929
|
+
item ? "编辑 AI 供应商" : "新建 AI 供应商",
|
|
9930
|
+
field("name", "显示名称", "text", item?.name)
|
|
9931
|
+
+ field("protocol", "接口协议", "select", protocol, providerProtocolOptions)
|
|
9932
|
+
+ field("baseUrl", "API 基础地址", "url", defaultBaseUrl)
|
|
9933
|
+
+ `<div data-provider-credential-field>${credentialFieldForProtocol(protocol)}</div>`
|
|
9934
|
+
+ field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10)
|
|
9935
|
+
+ field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10)
|
|
9936
|
+
+ field("note", "用途备注", "textarea", item?.note)
|
|
9937
|
+
+ field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true),
|
|
9938
|
+
async (form) => {
|
|
9939
|
+
const body = {
|
|
9940
|
+
name: form.get("name"),
|
|
9941
|
+
protocol: form.get("protocol"),
|
|
9942
|
+
baseUrl: form.get("baseUrl"),
|
|
9943
|
+
concurrencyLimit: Number(form.get("concurrencyLimit")),
|
|
9944
|
+
rpmLimit: Number(form.get("rpmLimit")),
|
|
9945
|
+
note: form.get("note"),
|
|
9946
|
+
status: form.get("enabled") === "on" ? "enabled" : "disabled"
|
|
9947
|
+
};
|
|
9948
|
+
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
9949
|
+
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
9950
|
+
await renderPlatformAiConfig();
|
|
9951
|
+
await loadModels();
|
|
9952
|
+
},
|
|
9953
|
+
item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
|
|
9954
|
+
);
|
|
9955
|
+
const protocolSelect = $("#dialog-fields select[name='protocol']");
|
|
9956
|
+
const baseUrlInput = $("#dialog-fields input[name='baseUrl']");
|
|
9957
|
+
const credentialHost = $("#dialog-fields [data-provider-credential-field]");
|
|
9958
|
+
const syncProviderCredentialField = () => {
|
|
9959
|
+
const nextProtocol = protocolSelect.value;
|
|
9960
|
+
credentialHost.innerHTML = credentialFieldForProtocol(nextProtocol);
|
|
9961
|
+
if (!item) baseUrlInput.value = defaultBaseUrlForProtocol(nextProtocol);
|
|
9962
|
+
};
|
|
9963
|
+
protocolSelect.addEventListener("change", syncProviderCredentialField);
|
|
9236
9964
|
}
|
|
9237
9965
|
|
|
9238
9966
|
function openModelDialog(providerId, item = null) {
|
|
@@ -9295,31 +10023,44 @@ async function sendAi() {
|
|
|
9295
10023
|
try {
|
|
9296
10024
|
await ensureAiModelsLoaded();
|
|
9297
10025
|
} catch (error) {
|
|
10026
|
+
setAiAssistantStatus("error");
|
|
9298
10027
|
return toast(`创作助手加载失败:${error.message}`, "error");
|
|
9299
10028
|
}
|
|
9300
10029
|
const modelId = $("#ai-model").value;
|
|
9301
10030
|
if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
|
|
9302
10031
|
const instruction = aiPromptText().trim();
|
|
9303
10032
|
if (!instruction) return toast("请输入指令", "error");
|
|
10033
|
+
if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
|
|
9304
10034
|
const requestScope = currentAiRequestScope();
|
|
9305
10035
|
if (!requestScope) return toast("请先选择章节", "error");
|
|
9306
10036
|
const { taskType, scope, selection } = requestScope;
|
|
9307
10037
|
if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
|
|
10038
|
+
try {
|
|
10039
|
+
await persistAiConversationTaskType($("#ai-task").value);
|
|
10040
|
+
await persistAiConversationContextScope(requestScope.conversationScope);
|
|
10041
|
+
} catch (error) {
|
|
10042
|
+
return toast(`对话配置锁定失败:${error.message}`, "error");
|
|
10043
|
+
}
|
|
10044
|
+
setAiAssistantStatus("ready");
|
|
9308
10045
|
const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
|
|
9309
10046
|
let persistedUserMessage = null;
|
|
9310
10047
|
if (taskType !== "chat") {
|
|
9311
10048
|
try {
|
|
9312
10049
|
persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
|
|
9313
10050
|
} catch (error) {
|
|
10051
|
+
setAiAssistantStatus("error");
|
|
9314
10052
|
return toast(`对话记录创建失败:${error.message}`, "error");
|
|
9315
10053
|
}
|
|
9316
10054
|
state.aiPromptSent = true;
|
|
10055
|
+
syncAiTaskOptions();
|
|
10056
|
+
renderAiRoleplayCharacterSelect();
|
|
9317
10057
|
renderAiQuickActions();
|
|
9318
10058
|
appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
|
|
9319
10059
|
clearAiPromptComposer();
|
|
9320
10060
|
}
|
|
9321
10061
|
$("#ai-send").disabled = true;
|
|
9322
10062
|
$("#ai-send").textContent = "发送中";
|
|
10063
|
+
$("#ai-roleplay-character").disabled = true;
|
|
9323
10064
|
try {
|
|
9324
10065
|
let assistantContent = "";
|
|
9325
10066
|
let assistantMessage;
|
|
@@ -9336,6 +10077,10 @@ async function sendAi() {
|
|
|
9336
10077
|
applyAiConversationTitle(streamed.conversationTitle);
|
|
9337
10078
|
} else {
|
|
9338
10079
|
suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
|
|
10080
|
+
const suggestionFailed = suggestion.guard?.status === "failed"
|
|
10081
|
+
|| suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
|
|
10082
|
+
|| suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
|
|
10083
|
+
if (suggestionFailed) setAiAssistantStatus("error");
|
|
9339
10084
|
setAiContextMeter(suggestion.contextUsage);
|
|
9340
10085
|
assistantContent = suggestion.content;
|
|
9341
10086
|
assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
|
|
@@ -9358,10 +10103,12 @@ async function sendAi() {
|
|
|
9358
10103
|
} else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
|
|
9359
10104
|
}
|
|
9360
10105
|
} catch (error) {
|
|
10106
|
+
setAiAssistantStatus("error");
|
|
9361
10107
|
if (suggestion) appendSuggestion(suggestion);
|
|
9362
10108
|
toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
|
|
9363
10109
|
}
|
|
9364
10110
|
} catch (error) {
|
|
10111
|
+
setAiAssistantStatus("error");
|
|
9365
10112
|
const failureMessage = formatAiFailureMessage(error);
|
|
9366
10113
|
let persistedFailureMessage = null;
|
|
9367
10114
|
try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
|
|
@@ -9369,6 +10116,7 @@ async function sendAi() {
|
|
|
9369
10116
|
} finally {
|
|
9370
10117
|
$("#ai-send").disabled = false;
|
|
9371
10118
|
$("#ai-send").textContent = "发送";
|
|
10119
|
+
renderAiRoleplayCharacterSelect();
|
|
9372
10120
|
}
|
|
9373
10121
|
}
|
|
9374
10122
|
|
|
@@ -9382,7 +10130,7 @@ async function streamChat(body) {
|
|
|
9382
10130
|
let messageMounted = false;
|
|
9383
10131
|
const mountAssistantMessage = () => {
|
|
9384
10132
|
if (messageMounted) return;
|
|
9385
|
-
attachMessageHeading(message, "
|
|
10133
|
+
attachMessageHeading(message, aiAssistantLabel("正在生成"));
|
|
9386
10134
|
$("#ai-feed").append(message);
|
|
9387
10135
|
messageMounted = true;
|
|
9388
10136
|
scrollAiFeedToBottom();
|
|
@@ -9455,6 +10203,8 @@ async function streamChat(body) {
|
|
|
9455
10203
|
state.aiConversationId = persistedUserMessage.conversationId;
|
|
9456
10204
|
updateAiConversationSummaryFromMessage(persistedUserMessage);
|
|
9457
10205
|
state.aiPromptSent = true;
|
|
10206
|
+
syncAiTaskOptions();
|
|
10207
|
+
renderAiRoleplayCharacterSelect();
|
|
9458
10208
|
renderAiQuickActions();
|
|
9459
10209
|
appendMessage("user", persistedUserMessage.content, persistedUserMessage.citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
|
|
9460
10210
|
clearAiPromptComposer();
|
|
@@ -9489,6 +10239,7 @@ async function streamChat(body) {
|
|
|
9489
10239
|
const toolCall = { ...payload };
|
|
9490
10240
|
const round = toolCall.round;
|
|
9491
10241
|
delete toolCall.round;
|
|
10242
|
+
if (toolCall.status === "failed") setAiAssistantStatus("error");
|
|
9492
10243
|
toolCalls.push(toolCall);
|
|
9493
10244
|
processSteps.push(aiToolProcessStep(toolCall, round));
|
|
9494
10245
|
renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
|
|
@@ -9516,6 +10267,7 @@ async function streamChat(body) {
|
|
|
9516
10267
|
attachAssistantCopyAction(message, streamedText);
|
|
9517
10268
|
scrollAiFeedToBottom();
|
|
9518
10269
|
} else if (eventName === "error") {
|
|
10270
|
+
setAiAssistantStatus("error");
|
|
9519
10271
|
streamError = createClientError(payload, "AI 流式调用失败", response.status);
|
|
9520
10272
|
}
|
|
9521
10273
|
};
|
|
@@ -9536,7 +10288,7 @@ async function streamChat(body) {
|
|
|
9536
10288
|
typewriter.reveal();
|
|
9537
10289
|
message.classList.remove("is-streaming");
|
|
9538
10290
|
content.setAttribute("aria-busy", "false");
|
|
9539
|
-
message.querySelector(".message-heading > span").textContent = "
|
|
10291
|
+
message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
|
|
9540
10292
|
renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
|
|
9541
10293
|
meta.textContent = "生成中断";
|
|
9542
10294
|
scrollAiFeedToBottom();
|
|
@@ -9552,7 +10304,7 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
|
|
|
9552
10304
|
? `<p class="ai-error-text">${esc(text)}</p>`
|
|
9553
10305
|
: renderMarkdown(text);
|
|
9554
10306
|
message.innerHTML = `<div class="message-body">${messageBody}</div>`;
|
|
9555
|
-
const heading = attachMessageHeading(message, role === "user" ? "作者" :
|
|
10307
|
+
const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
|
|
9556
10308
|
if (isFailure) {
|
|
9557
10309
|
message.dataset.status = "failed";
|
|
9558
10310
|
const failureBadge = document.createElement("strong");
|
|
@@ -9960,6 +10712,28 @@ $("#onboarding-dialog").addEventListener("cancel", (event) => {
|
|
|
9960
10712
|
event.preventDefault();
|
|
9961
10713
|
completeOnboarding();
|
|
9962
10714
|
});
|
|
10715
|
+
$("#system-restart-dialog").addEventListener("cancel", (event) => {
|
|
10716
|
+
event.preventDefault();
|
|
10717
|
+
});
|
|
10718
|
+
function hasUnsavedEditorChanges() {
|
|
10719
|
+
return state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty;
|
|
10720
|
+
}
|
|
10721
|
+
|
|
10722
|
+
function redirectToLoginAfterSystemRestart() {
|
|
10723
|
+
state.user = null;
|
|
10724
|
+
state.csrfToken = null;
|
|
10725
|
+
moduleRequestCache.clear();
|
|
10726
|
+
document.documentElement.classList.remove("dev-auth-bypass");
|
|
10727
|
+
document.documentElement.classList.add("login-route");
|
|
10728
|
+
window.history.replaceState(null, "", serializePageRoute({ view: "login" }));
|
|
10729
|
+
const toastRegion = $("#toast-region");
|
|
10730
|
+
toastRegion.replaceChildren();
|
|
10731
|
+
if (typeof toastRegion.hidePopover === "function" && toastRegion.matches(":popover-open")) toastRegion.hidePopover();
|
|
10732
|
+
$("#system-restart-dialog").close();
|
|
10733
|
+
showAuth(false);
|
|
10734
|
+
}
|
|
10735
|
+
|
|
10736
|
+
$("#system-restart-confirm").addEventListener("click", redirectToLoginAfterSystemRestart);
|
|
9963
10737
|
$("#onboarding-dialog").addEventListener("keydown", (event) => {
|
|
9964
10738
|
if (event.key === "Escape") {
|
|
9965
10739
|
event.preventDefault();
|
|
@@ -10498,10 +11272,20 @@ $("#writing-progress-button").addEventListener("click", () => openWritingProgres
|
|
|
10498
11272
|
$("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
|
|
10499
11273
|
$("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
|
|
10500
11274
|
$("#writing-goal-form").addEventListener("submit", saveWritingGoal);
|
|
10501
|
-
$("#work-audit-button").addEventListener("click", () =>
|
|
10502
|
-
$("#work-audit-
|
|
10503
|
-
$("#work-audit-
|
|
10504
|
-
$("#work-audit-refresh")
|
|
11275
|
+
$("#work-audit-button").addEventListener("click", () => showWorkAudit().catch((error) => toast(error.message, "error")));
|
|
11276
|
+
$("#work-audit-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button").catch((error) => toast(error.message, "error")));
|
|
11277
|
+
$("#work-audit-refresh").addEventListener("click", async () => {
|
|
11278
|
+
const button = $("#work-audit-refresh");
|
|
11279
|
+
button.disabled = true;
|
|
11280
|
+
try {
|
|
11281
|
+
await loadWorkAuditPage();
|
|
11282
|
+
toast("操作记录已刷新");
|
|
11283
|
+
} catch (error) {
|
|
11284
|
+
toast(error.message, "error");
|
|
11285
|
+
} finally {
|
|
11286
|
+
button.disabled = false;
|
|
11287
|
+
}
|
|
11288
|
+
});
|
|
10505
11289
|
$("#work-audit-load-more").addEventListener("click", () => {
|
|
10506
11290
|
if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
|
|
10507
11291
|
});
|
|
@@ -10564,6 +11348,7 @@ $("#form-dialog").addEventListener("close", () => {
|
|
|
10564
11348
|
formDialogVditors.forEach(destroyVditorEditor);
|
|
10565
11349
|
formDialogVditors = [];
|
|
10566
11350
|
void discardPendingMarkdownAttachments();
|
|
11351
|
+
closeManuscriptExportMenu();
|
|
10567
11352
|
if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
|
|
10568
11353
|
});
|
|
10569
11354
|
$("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
|
|
@@ -10783,8 +11568,61 @@ $("#ai-model").addEventListener("focus", () => {
|
|
|
10783
11568
|
ensureAiModelsLoaded().catch((error) => toast(`模型加载失败:${error.message}`, "error"));
|
|
10784
11569
|
});
|
|
10785
11570
|
$("#ai-model").addEventListener("change", () => setAiContextMeter(null));
|
|
10786
|
-
$("#ai-
|
|
10787
|
-
|
|
11571
|
+
$("#ai-roleplay-character").addEventListener("focus", async () => {
|
|
11572
|
+
try {
|
|
11573
|
+
await ensureAiReferencesLoaded();
|
|
11574
|
+
renderAiRoleplayCharacterSelect();
|
|
11575
|
+
} catch (error) {
|
|
11576
|
+
toast(`角色卡加载失败:${error.message}`, "error");
|
|
11577
|
+
}
|
|
11578
|
+
});
|
|
11579
|
+
$("#ai-roleplay-character").addEventListener("change", async (event) => {
|
|
11580
|
+
const select = event.currentTarget;
|
|
11581
|
+
const characterId = select.value;
|
|
11582
|
+
select.disabled = true;
|
|
11583
|
+
try {
|
|
11584
|
+
await updateAiRoleplayCharacter(characterId);
|
|
11585
|
+
} catch (error) {
|
|
11586
|
+
renderAiRoleplayCharacterSelect();
|
|
11587
|
+
toast(`角色扮演模式切换失败:${error.message}`, "error");
|
|
11588
|
+
} finally {
|
|
11589
|
+
renderAiRoleplayCharacterSelect();
|
|
11590
|
+
}
|
|
11591
|
+
});
|
|
11592
|
+
$("#ai-task").addEventListener("change", async (event) => {
|
|
11593
|
+
const select = event.currentTarget;
|
|
11594
|
+
const previousTaskType = select.dataset.previousValue || state.aiTaskType || "chat";
|
|
11595
|
+
const nextTaskType = select.value;
|
|
11596
|
+
if (state.aiPromptSent) {
|
|
11597
|
+
applyAiConversationTaskType(previousTaskType);
|
|
11598
|
+
return toast("当前对话已经开始,请新建对话后再切换任务类型", "error");
|
|
11599
|
+
}
|
|
11600
|
+
applyAiConversationTaskType(nextTaskType);
|
|
11601
|
+
if (state.aiConversationId) {
|
|
11602
|
+
select.disabled = true;
|
|
11603
|
+
try {
|
|
11604
|
+
await persistAiConversationTaskType(nextTaskType);
|
|
11605
|
+
} catch (error) {
|
|
11606
|
+
applyAiConversationTaskType(previousTaskType);
|
|
11607
|
+
toast(`任务类型切换失败:${error.message}`, "error");
|
|
11608
|
+
} finally {
|
|
11609
|
+
syncAiTaskOptions();
|
|
11610
|
+
renderAiRoleplayCharacterSelect();
|
|
11611
|
+
}
|
|
11612
|
+
}
|
|
11613
|
+
setAiContextMeter(null);
|
|
11614
|
+
});
|
|
11615
|
+
$("#ai-scope").addEventListener("change", (event) => {
|
|
11616
|
+
if (state.aiPromptSent) {
|
|
11617
|
+
applyAiConversationContextScope(state.aiContextScope);
|
|
11618
|
+
return toast("当前对话已经开始,请新建对话后再切换上下文引用", "error");
|
|
11619
|
+
}
|
|
11620
|
+
event.currentTarget.title = "";
|
|
11621
|
+
syncAiIncludeSettingInfoControl();
|
|
11622
|
+
setAiContextMeter(null);
|
|
11623
|
+
});
|
|
11624
|
+
$("#ai-include-setting-info").addEventListener("change", () => setAiContextMeter(null));
|
|
11625
|
+
syncAiIncludeSettingInfoControl();
|
|
10788
11626
|
$("#ai-mention-menu").addEventListener("click", (event) => {
|
|
10789
11627
|
const button = event.target.closest("[data-ai-reference-id]");
|
|
10790
11628
|
if (button) selectAiMention(button);
|
|
@@ -10923,6 +11761,9 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
10923
11761
|
if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
|
|
10924
11762
|
if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
|
|
10925
11763
|
if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
|
|
11764
|
+
if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {
|
|
11765
|
+
closeManuscriptExportMenu();
|
|
11766
|
+
}
|
|
10926
11767
|
if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
|
|
10927
11768
|
if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
|
|
10928
11769
|
if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
|
|
@@ -10945,6 +11786,7 @@ document.addEventListener("keydown", (event) => {
|
|
|
10945
11786
|
closeChapterTypeMenu();
|
|
10946
11787
|
closeLineCitationMenu();
|
|
10947
11788
|
closeMarkdownTableMenu(true);
|
|
11789
|
+
closeManuscriptExportMenu();
|
|
10948
11790
|
hideAiMentionMenu();
|
|
10949
11791
|
setAiContextDistributionVisible(false);
|
|
10950
11792
|
}
|
|
@@ -11063,7 +11905,7 @@ $("#ai-prompt").addEventListener("keydown", (event) => {
|
|
|
11063
11905
|
$(".quick-actions").addEventListener("click", (event) => {
|
|
11064
11906
|
const button = event.target.closest("[data-task]");
|
|
11065
11907
|
if (!button) return;
|
|
11066
|
-
|
|
11908
|
+
applyAiConversationTaskType(button.dataset.task);
|
|
11067
11909
|
setAiPromptText(button.dataset.prompt);
|
|
11068
11910
|
$("#ai-prompt").focus();
|
|
11069
11911
|
});
|
|
@@ -11097,8 +11939,39 @@ $("#search-form").addEventListener("submit", async (event) => {
|
|
|
11097
11939
|
$("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
|
|
11098
11940
|
});
|
|
11099
11941
|
});
|
|
11100
|
-
$("#export-button").addEventListener("click", () =>
|
|
11101
|
-
|
|
11942
|
+
$("#export-button").addEventListener("click", (event) => {
|
|
11943
|
+
event.preventDefault();
|
|
11944
|
+
event.stopPropagation();
|
|
11945
|
+
if (!state.work) return;
|
|
11946
|
+
const menu = $("#manuscript-export-menu");
|
|
11947
|
+
const expanded = menu && !menu.classList.contains("hidden") && manuscriptExportWork?.id === state.work.id;
|
|
11948
|
+
if (expanded) {
|
|
11949
|
+
closeManuscriptExportMenu();
|
|
11950
|
+
return;
|
|
11951
|
+
}
|
|
11952
|
+
showManuscriptExportMenu(event.currentTarget, state.work);
|
|
11953
|
+
});
|
|
11954
|
+
$("#manuscript-export-menu").addEventListener("click", (event) => {
|
|
11955
|
+
const option = event.target.closest("[data-export-format]");
|
|
11956
|
+
if (!option || !manuscriptExportWork) return;
|
|
11957
|
+
const format = option.getAttribute("data-export-format") === "docx" ? "docx" : "markdown";
|
|
11958
|
+
const work = manuscriptExportWork;
|
|
11959
|
+
closeManuscriptExportMenu();
|
|
11960
|
+
downloadWorkManuscript(work, format);
|
|
11961
|
+
});
|
|
11962
|
+
document.addEventListener("visibilitychange", () => {
|
|
11963
|
+
if (document.visibilityState !== "visible") return;
|
|
11964
|
+
if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
|
|
11965
|
+
void refreshSystemHealth();
|
|
11966
|
+
});
|
|
11967
|
+
window.addEventListener("beforeunload", (event) => {
|
|
11968
|
+
if (hasUnsavedEditorChanges()) event.preventDefault();
|
|
11969
|
+
});
|
|
11970
|
+
window.addEventListener("online", () => {
|
|
11971
|
+
updateSystemHealth({ status: "checking" });
|
|
11972
|
+
void refreshSystemHealth();
|
|
11973
|
+
});
|
|
11974
|
+
window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
|
|
11102
11975
|
|
|
11103
11976
|
initializePage().catch((error) => {
|
|
11104
11977
|
restoringPageRoute = false;
|